A running checklist for working through the open mobile backlog in order, one PR per item, each merged only after CI is green. Ticked when the PR merges; marked blocked with a link to the write-up when an item needs something outside the repository.
- #203
share()never reports back to the page — iOS in #208, Android in #210 - #197 iOS location promises that never settle — #213
- #192 Android doesn't report granted location permission — #214
- #198 iOS cold-start deep links — #216
- #204 strip debug info from
libcraft.so— #217 - #163 check whether CI now compiles the Kotlin templates — closed with evidence
- #193, #194, #195 the Watch companion (one PR) — #218
- #196 APNs production entitlement — #218, with the Watch companion
Follow-ups found during the run
- #207 five iOS page calls drop the answer the native side sends them — #222
- #211
removeSiriShortcutcan wait for ever on a completion the framework never fires — #227 - #215 Android: hand a link that arrives before
onLinkto the first subscriber — #232 - #209 Android
enable*flags are never checked by the bridge — #233
Second round — filed by the follow-up PRs
Android first: the four items below are one subsystem, and #233 has just been through it.
- #219 Android
haptic,vibrate,startListening,stopListeninganswer nothing, where iOS now does — #234 - #230 compare each JNI descriptor with its Kotlin
external fun, not only the name — #235 - #231
CraftNative.deliverdrops a script silently when no deliverer is installed — #238 - #228 injecting the bridge is not idempotent, and a stale injection callback can mark the wrong page ready — #239
- #229 a recreated activity restores the wrong initial URL, and Recents re-fires the launch shortcut — #240
Then the iOS bridge core. #225 goes before #223, which says it cannot safely take a deadline until the lease check is right.
- #225
ios_async's main-queue hop checks only that a slot is in use, not which lease — #241 - #226 an answer to a call made before a page reload can settle a different call after it — #243
- #223 Zig actions that wait on a framework completion have no deadline — 4 of 6 have one (#247, #248, #250); the other 2 deliberately have none (#251), which was agreed, so it closed
- #224 Swift-only actions that wait on a framework callback have no deadline — #244
- #221
openURL,setBadgeandsetFlashlightnever settle on a malformed argument in Swift — #246
Last, because it changes what existing pages get back:
- #220
craft.watchPositionandclearWatchmean different things on each platform, and v1 can leave a dead watch
Each item: a test that fails without the fix, bun run test, bun run typecheck, bun run verify:packages and Pickier locally, then CI.
Log
- #203: split in two. #208 makes iOS
share()return its promise and adds a static check for the whole bug class, which found five more (#207). #210 resolves Androidshare()with whether an app was picked and adds the suite's first real cancellation path: the harness dismisses the real share menu. Android's ungatedenable*flags are filed as #209. - #208 and #210 merged. Both carried two red iOS jobs that also fail intermittently on
main: the shim leg's firstsimctl launch --console-ptyafter a cold boot takes ~200s (reproduced locally on a fresh simulator; a plain launch takes 3s), and the Zig slice fixture sometimes loses the Siri deletion reply (i=38) or the simulated coordinate. Fixing the gate before the next merge. - Gate fix #212: both iOS harnesses launch without
--console-pty(≈200s on a cold simulator, reproduced) and the slice'scountagrees with its wait loop. The other slice flake is a real never-settling promise, filed as #211. - #197:
4fe86f0had already fixed it in Swift, but no test ran it on a device. #213 turns geolocation on in the iOS probe, grants location and sets a coordinate from the host, and requires the reported coordinate back. A targeted mutation of4fe86f0failspermissions.location.grantedon both legs. Zig now logs hand-backs so the runtime leg can prove Zig served the calls. - #212, #213 and #214 merged, all fully green. #192 turned out to be fixed by
bb5c1b5and780e952but never run on a device. #214's emulator run grants approximate location only, and a CI mutation that requires precise location makes it fail withdeniedon both legs (run 35111184463). - #198: reproduced with an XCUITest cold start. The launch link was dispatched 1ms before
onLinkcould exist. #216 replays early links to the first subscriber, withgetInitialURLclaiming the launch link so it is not delivered twice, and adds a real cold-start test to both iOS legs. Android parity is #215. - #204:
zig objcopycannot copy ELF to ELF, so #217 strips with the NDK'sllvm-objcopy, keeps the DWARF inandroid-symbols/, and the Android runner refuses a library that still carries any. - #163: already done by
6f3a4d7(android-builder→test:templates). It caught #210's duplicatePendingIntentimport today. Closed with that evidence. - #193–#196: all four were fixed in the generator on 14 September (
5d03387,6bd84f4,611be12), and none of those fixes was checked where it failed. #218 builds the Watch-enabled app with the Watch app embedded and installs it on an iOS simulator; the macOS runner already has a watchOS runtime. It also reads theaps-environmentXcode actually signs for Debug and Release. Reverting each fix makes the gate fail with that issue's own message. #196 is a one-file change, so it rides in the same PR as the Watch work to respect the 5–8 files per commit rule. - #217 and #218 merged, fully green. The original backlog is done. Continuing with the four issues filed along the way, in the order above: the two iOS ones are self-contained, #215 ports an iOS contract, and #209 changes Android defaults so it goes last.
- #207: #222, fully green. Adding the callbackId alone would have hung
hapticand speech on every call that worked: native only ever rejected them, and Zig declared them.reply = .none. Both runtimes now resolve. The conformance test checks both directions, and a Bun test runs the real page script. Thecraft.haptics.*feedback helpers treat "haptics left off" as nothing to play, as Android and the web do. Filed #219 (Android parity), #220 (the flat watch API) and #221 (Swift cases that never settle on a bad argument). - #211: #227, fully green. Both runtimes now answer
TIMEOUTafter 15 s. Zig's completion blocks moved out of the reply slots, so a late completion cannot answer the next call. Across three CI runs the simulator never called the deletion completion at all, while locally it arrives in about 40 ms. So the slice fixture asserts that the call settles, and a timeout shows as a CI warning rather than a failure. Filed #223, #224, #225 and #226. - #215: #232 merged, fully green (including the Kotlin compile and the Android emulator cold starts). The Kotlin compile and emulator are verified only in CI. It also fixes a double delivery to
craftReadyhandlers on both platforms. Filed #228, #229, #230 and #231. - #209: #233 merged, fully green. Android now refuses a call whose capability the app was not built with, from a gate over the page API that reads the same
craft.capabilitiesobject the page reads, with Kotlin second checks for the four capabilities Android does not gate with a permission. The issue asked for anandroid_config.zigmirror of iOS; Android calls page → Kotlin → Zig, so the gate belongs above Kotlin, not beside it. Refusals carry iOS’sCAPABILITY_DISABLED; the calls that still answer nothing (#219) no-op with a warning. - The original backlog and all four of its follow-ups are done. Starting a second round on what those PRs filed, below.
- #219: #234 merged, fully green. A @JavascriptInterface method answers its caller synchronously, so each Kotlin method returns whether the device took the call and the page hands that back — no channel to park, unlike
share. Three of them can carry #209’s refusal now;stopListeningleaves the gate, as on iOS.craft.haptics.*gets #207’s split, which Android could not draw untilcraft.haptic()had an answer to give. A microphone grant used to go nowhere:startListeningnow starts the recogniser fromonRequestPermissionsResult, the way iOS starts from inside its authorization callback. - #230: #235 merged, fully green. Descriptors are derived from each
external funand compared with the table. Primitives come from a small map; every other type resolves fromCraftNative.kt’s own imports, so the next class needs no change there, and one that resolves as neither fails the test by name. Four mutations fail it, includingnativeDispatchDeepLink’s shape from before #215. - Out of band: #236, an SDK spawn that re-enters the CLI shim unmarked, filed from a real failure rather than from this run. Taken ahead of the list because it is a crash someone hit. #237 merged, fully green: the SDK marks its spawn the way the CLI has since #26, a quiet child keeps its stderr so the resulting message survives, and
parseCraftVersionOutputreads the CLI’s owncraft/<version> <platform>form — which had been reporting two halves of one checkout as drift, and was the only signal a user got. - #228: #239 merged, fully green. A navigation generation ties each ready mark to the page its injection ran in, so a posted completion can no longer flush the deep-link and shortcut queues into a document with no bridge. The script is wrapped so a second run in the same document does nothing — a block, not a function, so
varand function declarations scope as before. The issue’s first step was “verify on an emulator”, which is now an E2E case that pushes state and requires the samewindow.craft, promise runtime and ready count to survive it. - #231: #238 merged, fully green.
deliveranswers whether a deliverer took the script, and Zig counts a false the way it counts its other failures — it returned void, so a script handed over before the WebView existed was dropped and reported as delivered, invisible even todroppedCount. The first drop of a process also logs, in the words the E2E scan matches, because nothing on a device reads the counter; the rest are counted, so a startup that drops in bulk cannot drown the line. The one Kotlin method Zig calls back is now pinned the way #235 pins the natives. Its first push produced no workflow runs at all: close-and-reopen did nothing, an empty commit did nothing, and only a force-push with a new SHA triggered CI. - #229: #240 merged, fully green, and the Android group is done. Only half of it was a bug: the launch URL is already saved and restored from the bundle, which #232 did, so that half is now pinned rather than changed. The other half was real —
onCreatedispatched the shortcut wheneversavedInstanceStatewas null, and a Recents relaunch carries the intent the app was first opened with, so the shortcut fired again every time the task was reopened. The history flag guards both now, as it already guarded the link. - #225: #241 merged, fully green. The main-queue hop carried only the slot index, so two
deliver*calls for one ticket could both pass their own generation check and the second would answer whichever call leased the slot next. The hop now carries the whole ticket, in the encodingscheduleDeadlinealready used. A pool block belongs to a slot rather than a lease, so arming is what tells a second fire from a first; the narrower window — a stale fire after re-lease where the next lease also armed a block — stays open by design and is stated in the PR.abandonalso leaked a stashed payload, which both release paths now clear through one function. TheBusyhalf became #242 rather than being invented here. - #226: #243 merged, fully green.
_callbackIdrestarted at 0 on every injection, so a reloaded page's nth call inherited whatever answer the previous page's nth call was still owed. The Coordinator now carries the highest id it has seen and seeds each injection above it, which makes an id unique for the life of the process; raised from the message handler before the call is offered to Zig, so it covers the ids Zig serves too. A random offset would have made a collision unlikely rather than impossible, and a page-load generation native compares is more machinery for the same guarantee. - #225 and #226 are done, so #223's stated blocker is cleared: the deadline sweep (#223 with #224) is the next piece of work.
- #224: #244 merged, fully green. Five actions answered only by Swift, each waiting on a framework callback nobody is in front of. One
armDeadline/claimDeadlinepair rather than five copies: both sides race for the same token on the main queue, so exactly one answers, and a deadline reports TIMEOUT rather than claiming the work happened.registerPushalso had a second call overwrite the first's callbackId, leaving that promise unsettled for ever; a displaced call is now rejected. Its deadline is armed after the grant, because the authorization prompt has a person in front of it, and a late token still reaches a page listening for craftPushToken. - #223 is the Zig half and needs groundwork first: those modules keep side tables read by reply-slot index alone, which #225 did not touch. #245 lifts #211's owed-block design into
ios_pending.Table(Call)and migrates Siri onto it, with no behaviour change; the six actions then go on it one or two per PR. - #221: #246 merged, fully green. The issue named three cases and its comment two more; the conformance scan it asked for found eight others, so thirteen in all now reject with INVALID_ARGUMENT instead of settling nothing. The scan reports every gap rather than the first — its first run named only
setSharedItem, and collecting them all is what turned five into thirteen. Its exception list is empty. - #223 is landing one action at a time. #245 extracted the table; #247 is
getPendingNotifications, with a ratchet that a module parking onios_pendingmust also callscheduleDeadline— added because removing the deadline passed every test otherwise. Left:scheduleNotification(two stages, deadline after the prompt),getHealthData,saveHealthWorkout,openURL,openSettings(suspended while Settings is open). - #223, first action: #247 merged, fully green.
getPendingNotificationsparks onios_pending.Tablewith a 10s deadline, and its blocks are indexed per block rather than per reply slot. A conformance ratchet requires any module parking on the table to callscheduleDeadline— added because removing the deadline passed every other test. It conflicted with #221 inios_conformance_test.zig: both added a test at the same anchor and neither closed its own brace, so the shared one belonged to whichever came last. Resolved by keeping both, and re-checked by confirming each ratchet still fails its own mutation rather than trusting the build. - Left on #223:
scheduleNotification(two stages, deadline after the grant, fresh block for stage two),getHealthDatawithsaveHealthWorkout(one shared table, three-stage chain — the biggest),openURL(a confirmation fortel:/facetime:),openSettings(the app is suspended while Settings is open, so a main-queue deadline would report TIMEOUT for a success). The ratchet is per-module, not per-action, so it will not notice a second action in a file that already has one. - #223, second action: #248 merged, fully green.
scheduleNotificationfinishes the notifications module — both stages on the table, and the last side table keyed by reply-slot index is gone with the mutex that served it. The clock starts on the authorization grant, not at dispatch: stage one puts a prompt on someone's screen, and only the write afterwards is unattended. Stage two claims a fresh block, since stage one's was settled and may already belong to another call. - Worth carrying: the ordering test's first version searched the source for a literal that also appears in the test itself, so
indexOfmatched its own text and the assertion passed with the real call deleted. The needle is joined at comptime now and the occurrence count asserted. Any source-scanning test living in the file it scans has this hazard, and there are several here. - Also confirmed rather than predicted: #247's ratchet did not catch #248's action losing its deadline, because it is per-module and
getPendingNotificationsstill had one. Close that when the health module lands — the next file where two actions share a table. - #223, the rest: #249 made the deadline ratchet per-claim — each function that parks a call must arm a deadline or carry a
// no-deadline:line saying why, so an omission becomes a statement someone wrote. #250 put the health module on the table:getHealthDataandsaveHealthWorkoutget deadlines,requestHealthAuthorizationshares the table but a person answers its sheet. The workout chain re-arms at every stage, because one armed at dispatch can land between two stages, find nothing, and leave the next with no clock. #251 fixedopenSettingsresolving its selector after the lease. openSettingsandopenURLget no deadline, with the reason at each call.openSettingssuspends the app while the uptime clock runs on, so a deadline shorter than the visit answers TIMEOUT for an open that worked.openURLleases no reply slot — its request id travels in the block — so #223's concern does not apply, and a deadline would time out someone deciding on atel:call. #223 stays open until that is agreed.- Out of band: #252, filed from WildLoop, where a cancelled page load (
-999, quick tab taps) sent the iOS app to its bundled copy for the rest of the session. #253 merged, fully green: fall back only on connectivity errors, and come back on reconnect and on foreground. The network trigger fires on a down-to-up transition only, because retrying while merely connected loops against a server that is down on a network that is up. The classifier is compiled and run in CI byscripts/load-failure.ts— confirmed from the job log, all eleven cases. Android's identical one-way latch is #254.
Third round — the release-readiness audit
Filed while auditing the published v0.0.92 release once the second round closed. These are release-path items rather than bridge behaviour, so they sit apart from the list above.
- #272 reject tag/version mismatches and branch dispatches
- #281 make the release manifest cover every published binary asset
- #286 ship declarations from the standalone iOS and Android packages
- #287 include the declared Svelte source export in npm packages
- #307 a scheduled notification can fire before the simulator app terminates — #308
- #282 standalone macOS CLI crashes during App Sandbox initialisation — cause found and already fixed in
entitlements-distribution.plist; held open until a release provesverify-macos-downloadsgreen on both legs - #288 build and validate every npm workspace before publishing — the pipeline half has landed; the corrected republish needs a release, since 0.0.92 is immutable on npm
- #283 logsmith
--no-outputmutates CHANGELOG.md — avoided locally by #306; the bug itself is upstream and not ours to fix - #279 make the SBOM vulnerability scan actionable and failure-safe — three of five asks landed in #316; the baseline/allowlist with expiry is what remains
- #276 remediate the locked dependency advisories — blocked on one product decision, below
Log, continued
- #316 and #317 merged together, both fully green, and main's post-merge CI green after them — worth noting because the combined
ci.ymlwas a state neither pull request had tested. #316 closed three of #279's five asks: no scanner invocation swallows its exit code any more, scanning is scoped to the exact shipped archives, and the installer downloads are pinned. #317 added a native-startup check that now runs on every pull request. - #319 rewrote
AGENTS.md, which had been byte-identical toCLAUDE.mdbelow its title. Four separately diagnosed build failures in one session came out of that, none of them code defects: an unpinnedzigon PATH, the three sibling first-party checkouts absent, that relative path resolved at the wrong depth, and local clones drifted frompins.envwith those pins unreachable by ordinaryfetchbecause they are not branch tips. It also records that ascripts/*.test.tsfile does not run untilci.ymlnames it — found by hitting it while adding that change's own test. A ratchet fails if the file ever collapses back into a copy or drops a shared house rule. - #282 is not what its report says. The published v0.0.92 archives were re-tested byte-for-byte (both SHA-256 match the manifest).
codesign --verify --deep --strictpasses, with the chain resolving throughDeveloper ID Certification AuthoritytoApple Root CA, a secure timestamp and hardened runtime. Nothing transforms the Mach-O aftercodesign; the reported "code or signature have been modified" was specific to the verifying machine, whose Authority could not be resolved. The real cause iscom.apple.security.app-sandboxon a raw executable with noInfo.plist, which traps inlibsecinitbeforemain— proven by re-signing the same bytes ad-hoc without entitlements, after which it runs at exit 0. Details on the issue. The lesson to keep:codesign --verifypasses on the broken binary, so the only thing protecting a release is the step that executes it. - A non-publishing release rehearsal on main covered the four legs asked of it. Packed npm consumers and native startup pass locally: 8 npm archives with 13 installed import and type entry points, 36 package-surface checks, and the built CLI reporting its own version. Manifest integrity and SBOM validation are release-only and were not rehearsed: the manifest needs four archives built on four separate runners and its verify step downloads the published manifest, while the SBOM check needs scanners this machine does not have. 134 of 134 script tests, typecheck clean, Pickier 0 errors.
- #276 is down to 6 advisories from 13, and neither remaining path is a dependency refresh. The one low-severity esbuild finding is dev-only, and the only automated fix is a downgrade —
bun audit fixmoves esbuild to 0.27.2 to fall below the advisory window rather than up past it, and reports that as fixed. Sobun audit fixmust not become a release step, which contradicts one of #276's own acceptance criteria. The six remaining moderates are all Svelte, dev and peer only and bundled in no published archive; bun states the blocker itself —svelte 4.2.20 -> 5.55.7 / blocked by a dependent's range. There is no patched 3.x or 4.x, so closing them means widening the peer range past the declared^3.0.0 || ^4.0.0, which is a breaking API decision rather than an update. That decision is the only thing blocking #276 and the remainder of #279, which want a single policy change between them: a reviewed severity gate with a documented allowlist, recording why each retained finding is unreachable in shipped artifacts.