* Switch the desktop Ktor engine to OkHttp
`java.net.http.HttpClient`'s constructor opens an NIO `Selector`, whose
Windows wakeup pipe is an AF_UNIX socket. The MSIX sandbox rejects the
connect with EINVAL, so every Microsoft Store build hard-crashes on the
first network call, before any request is sent (JDK-8312215, open since
Java 17 with no fix in sight). OkHttp uses blocking socket IO and is
unaffected.
Ships unconditionally rather than gated on the distribution channel:
OkHttp works everywhere, and a second engine would be a second code path
to test forever. Android already uses it.
Includes the unchecked-IO mapping from `fix/unchecked-io-network-errors`,
so a client that cannot be built at all surfaces as a network error
rather than taking down the app.
* Add an Export Logs button to the desktop About screen
Opening the log directory hands the shell a path that does not exist under
an MSIX container: the app's writes to %LOCALAPPDATA% are redirected into
the package's LocalCache, but reads fall through, so the app sees the
directory and Explorer does not. Snap confinement will do the same on
Linux. Exporting a zip to a location the user picks works on every
distribution vehicle, which makes it the one support instruction that
never needs a per-channel caveat.
Also walk up to the nearest existing ancestor before handing a directory
to the shell, so the open button degrades instead of erroring.
* Bake the distribution channel into the build
Per-vehicle rules (self-update, store payment policy, "get the app" links,
sandbox-aware paths) were tracked by hand. `-Pchannel=<token>` now resolves
to a `DistributionChannel` constant, defaulting to DEV, and an unknown
token fails the build rather than quietly shipping a store binary as DEV.
Every release pipeline passes its own channel; the existing F-Droid build
flags map to FDROID without their call sites changing.
Flat enum rather than capability flags: every channel-conditional branch is
a greppable `when` on the type. Flags can emerge later from actual
duplication.
The startup banner and all three crash dumps now carry the channel. That
line pays for itself immediately: this investigation started from a crash
log that did not say which build produced it.
* Un-redirect container paths before handing them to the shell
MSIX filesystem redirection is asymmetric: the app's writes under
%LOCALAPPDATA% land inside the package container, but reads fall through,
so File.exists() is true from inside the container and the app is
satisfied. Explorer runs outside it and correctly reports nothing at the
literal path, which is the "location is not available" dialog the
open-logs button produced on the Store build.
Rewrite the path into the container for the Microsoft Store channel, both
where it is shown and where it is handed to the shell. The package family
name is hardcoded: Windows derives its hash suffix from the publisher ID,
so it is not in AppxManifest.xml and cannot be computed from it, and it
only changes if the Store identity does.
`Windows.Storage.ApplicationData.Current.LocalCacheFolder` is the native
answer but means a WinRT dependency on a KMP desktop target for one path
lookup. Worth revisiting only if more packaged-app APIs are needed.
* Cover the About log section with a render test, document the channel
* Suppress TooGenericExceptionCaught on the unchecked IO catch
iOS and macOS were shipping as two separate App Store listings under two bundle
IDs. The supported model is Universal Purchase: one app record, one bundle ID,
one platform entry per OS. The binaries stay separate — each platform is still
built and uploaded independently with its own version stream and screenshots.
Points the iOS target and all four fastlane iOS identifiers at
com.darkrockstudios.apps.hammer, adds the for_platform :ios block the Appfile
was missing, and drops the hyphen from the iOS listing name, which only existed
because the unhyphenated name was taken by our own macOS record.
Stops skipping screenshot upload on the iOS lane. That was safe while iOS had
its own record and screenshots carried forward between versions; the iOS
platform newly added to the macOS record starts empty, and App Store Connect
refuses to submit a version with no iPhone screenshots.
Adds a shared concurrency group to the two App Store publish workflows. Both now
target the same App Store Connect record, and a full release tag starts them in
parallel. This is mutual exclusion rather than a needs: edge — neither waits on
the other's result, and a targeted single-platform tag skips the other job
before it reaches the gate.
Windows reports AltGr as Ctrl+Alt, so typing @ on a Turkish-Q or German
QWERTZ layout matched the loose Ctrl+Q quit test and closed the app.
Window shortcut matching moves into WindowShortcuts.kt, built on the
exact-modifier matchesShortcut helper, which also tightens Ctrl+W and
Ctrl+Shift+F.
* Move save-all off Ctrl+Alt+S to Ctrl+Shift+S
Ctrl+Alt+S is itself an AltGr chord on Windows, so it fired and ate the
character on layouts that map AltGr+S, such as Polish (s with acute).
Rebound on desktop, Android, and iOS, and Android's global search now
matches its modifiers exactly like the rest.
No shortcut uses Alt now, which is what keeps AltGr keystrokes reaching
the editor.
* Keep Koin hint classes out of test detection
* Handle F3 and Ctrl+Alt+S on the project window
Both shortcuts hung off a Modifier.onPreviewKeyEvent in ProjectRootUi, so
they only fired when focus sat inside the project subtree and did nothing
from the home screen. Move them to the window onKeyEvent that already owns
Esc, Ctrl+W, Ctrl+Q and Ctrl+Shift+F.
F3 now goes through ProjectRoot.startProjectSync(), which opens the sync
modal only for server-linked projects, restoring the gate the old sync menu
item had.
* Match shortcut modifiers exactly and run them pre-focus
F3 and Ctrl+Alt+S were hand-rolled in the window's when-chain, which
dropped the exact-modifier matching onKeyShortcut enforced: Ctrl+F3 and
Ctrl+Alt+Shift+S both fired. Extract that predicate as
KeyEvent.matchesShortcut and use it for both.
Move the two to onPreviewKeyEvent as well. onKeyEvent only runs when
nothing on the focus path consumed the key, so a focused editor could
swallow them; the docs claimed otherwise. The other window shortcuts stay
on onKeyEvent so a focused component can still handle Esc first.
* Give Android and iOS the project shortcuts back
Moving F3 and Ctrl+Alt+S to the desktop window left the other platforms
with nothing. Add ProjectShortcutHost, which ProjectRootScaffold binds
while the project UI is composed, and drive it from each platform's own
key hook: Activity.dispatchKeyEvent on Android and UIKit key commands on
iOS. Both are focus independent, which the Compose modifiers were not.
The iOS container lives in Swift because keyCommands is an Objective-C
category member and Kotlin cannot override those.
* Bind the project shortcuts in one place
Desktop kept its own copy of the save-all action while Android and iOS went
through ProjectShortcutHost. Move the binding down to ProjectRootUi, the one
composable all three platforms render, so each host only detects keys and
calls the host object.
That also lets the window drop the snackbar state and coroutine scope it had
hoisted purely to run the action.
tapUntilGone gated the save tap solely on isHittable. Under CI load the
top-bar save button's accessibility frame can never settle to hittable
within the timeout, so the loop waited out the whole budget without ever
tapping and failed with 'Element still present after 20.0s'.
Keep the proven isHittable hit-tested fast path and add a geometry
fallback: once the button's frame has settled (fully below the status
bar, ruling out the transient dead-pixel position, and stable across two
samples) tap it via tapCenter. This guarantees a tap eventually fires
even when isHittable never flips true, while still avoiding the
dead-pixel/scroll-away hazard the isHittable gate originally fixed.
A dropped coordinate tap left the field unfocused, so the software
keyboard never rose and the test failed on the single 10s wait.
Re-focus until the keyboard appears, matching typeIntoEditor.
* Harden flaky iOS scene-editor UI test with tap-retry helpers
SceneEditorWorkflowUITests.testEditSceneTextThenSave intermittently timed
out (most visibly waiting 20s for the save affordance to disappear). The
UI is Compose Multiplatform, which renders to a single surface, so
XCUITest taps are best-effort coordinate taps onto that surface. An
individual tap can be silently dropped (mid-relayout, under simulator
load, or while the software keyboard geometry is shifting). A lone tap
followed by one long wait is the classic XCUITest flake: a single dropped
tap costs the whole timeout and fails the test.
Add a small retry primitive and route the flake-prone taps through it:
- tapUntil(element, until:) re-taps on a short sub-timeout until the app
demonstrably reacts, converging instead of eating the full timeout on
one dropped tap.
- tap(_:expecting:) drives each scene-creation step by the element its
tap surfaces (add menu, then the create-item dialog).
- tapUntilGone(_:) replaces the lone save tap + long wait; it also covers
a late IME keystroke re-dirtying the buffer right after a save.
- openProjectCard re-taps the card until the project root's nav rail
renders (same dropped-tap resilience), keeping its existing
name/firstMatch card selection.
Validated by running the full iosUITests scheme on freshly-erased
simulators across 6 consecutive runs (all four UI test classes green
every run).
* Fix save-tap flake: only tap the save button when it's settled/hittable
CI surfaced the actual flake this test was meant to harden: the save tap
timed out because the button's accessibility frame is briefly stale right
after the edit that surfaces it — reported up under the status bar (a dead
pixel) until the top-bar layout settles. Blindly re-tapping that stale
coordinate never saved and could even scroll the app away from the editor,
so the re-tap loop ran the full timeout and failed.
Make tapUntilGone tap only when the button reports itself hittable
(settled), using a hit-tested tap() that re-resolves its real position;
otherwise wait for it to settle. Left untouched, the button stays hittable
even under load, so this converges cleanly instead of fighting a dead spot.
Scoped to the save helper; the navigation/card taps are unchanged.
Validated on a CI-sized simulator (iPhone 16e, 390x844) across 6 runs
under CPU load, all green.
The iOS SceneEditorWorkflowUITests was scoped to "scene opens" rather than
the Android-parity edit->save flow because the scene-editor-save affordance
never surfaced to XCUITest. Root cause: the iOS EditorTopBar save IconButton
was missing the SCENE_EDITOR_SAVE_TAG testTag that the Android/desktop
variants carry, so even once the edit dirtied the buffer and the button
rendered, XCUITest had no accessibility identifier to find.
- Tag the iOS EditorTopBar save button with SCENE_EDITOR_SAVE_TAG.
- Add a typeIntoEditor(_:into:until:) helper to HammerUITest that re-focuses
and re-injects until the edit propagates, mirroring the Android
typeIntoEditor retry loop (guards the enabled=hasReceivedInitialBuffer
gate on the initial buffer load).
- Promote testCreateSceneOpensEditor to testEditSceneTextThenSave: type into
the editor, assert save appears, tap it, assert it disappears — matching
SceneEditorWorkflowTest.editSceneTextThenSave.
Verified on iPhone 16 (iOS 18.6) simulator: TEST SUCCEEDED.
* Add iOS UI smoke tests (XCUITest)
Adds an iOS UI smoke-test suite that drives the real app on a simulator via
XCUITest, the iOS analogue of the android/src/androidTest Compose UI tests.
The whole iOS UI is Compose Multiplatform, and CMP (1.8+) maps Compose testTags
to iOS accessibilityIdentifiers automatically, so the tests target the same
testTags the Android suite uses.
Workflows covered (all green on simulator):
- LaunchSmoke: app boots through the SwiftUI entry point, Koin + data migration
run, project selection renders.
- ProjectWorkflow: create + open a project (exercises the create dialog text
entry and navigation into the editor).
- SceneEditorWorkflow: create a scene and confirm it opens in the editor.
- NotesWorkflow: navigate to Notes and open the create-note screen.
Setup:
- ios/scripts/add_ui_test_target.rb idempotently creates the iosUITests
UI-testing target + shared scheme via the xcodeproj gem (the folder previously
had source files but no actual target).
- ios/scripts/disable_sim_hardware_keyboard.sh forces the software keyboard so
XCUITest text entry lands (Compose fields need it).
- ios-ui-tests CI job runs the suite on a simulator and uploads the xcresult on
failure.
- composeUi ProjectCreateDialog: tag the name field so the create flow is
targetable (also benefits Android).
Known limitation: the app's custom rich-text editors (scene body, note body via
MarkdownEditField) do not report keyboard focus to XCUITest, so their text entry
can't be driven (only standard Compose text fields can). The scene and notes
tests therefore stop at "editor opens" / "creation screen opens"; the full
edit/create-with-body paths remain covered by the Android suite.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* iOS UI tests: type into note body via composetexteditor 2.3.0
composetexteditor 2.3.0 publishes text-editing accessibility semantics on its
editor, so XCUITest can now drive text entry into it. Bump the dependency and
promote the Notes UI test to the full Android-parity flow: create a note by
typing into the body, then assert its card appears.
The scene edit+save flow stays scoped to "scene opens" for now — text entry
works, but the scene editor's initial-buffer gating + dirty-driven save make
the save affordance unreliable to assert from an IME-driven edit; promoting it
is a follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace ad-hoc icon scripts with a single source-of-truth manifest
(scripts/assets.yaml) and a Python generator that renders every app icon,
store-listing graphic, MSIX tile, favicon, and the Play feature graphic from
two SVG sources. Compositions (icon + "Hammer" wordmark in Kingthings
Trypewriter) are defined once as percentages of canvas and reused across the
Play feature graphic, MSIX wide/splash tiles, and the Snap featured banner.
Adds the missing snap store icon, fixes the snap desktop icon to the correct
256x256 hicolor size, and routes uploads-only assets to build/store-assets/
(gitignored).
See docs/ASSET-GENERATION.md for the manifest schema.
Declare ITSAppUsesNonExemptEncryption=false so App Store Connect stops
asking on every upload, drop the legacy armv7 UIRequiredDeviceCapabilities
entry, and align CFBundleShortVersionString with the shared codebase
version (3.0.3) in gradle/libs.versions.toml. pbxproj now references the
freshly-issued "Hammer AppStore iOS" provisioning profile.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>