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.
Follow-ups found during the run
Second round — filed by the follow-up PRs
Android first: the four items below are one subsystem, and #233 has just been through it.
fix(android): haptic, vibrate, startListening and stopListening answer nothing, where iOS now does #219 Android haptic, vibrate, startListening, stopListening answer nothing, where iOS now does — fix(android): answer the page from haptic, vibrate and the speech calls #234
test(android): compare each JNI descriptor with its Kotlin external fun, not only the name #230 compare each JNI descriptor with its Kotlin external fun, not only the name — test(android): compare each JNI descriptor with its Kotlin external fun #235
fix(android): CraftNative.deliver drops a script silently when no deliverer is installed #231 CraftNative.deliver drops a script silently when no deliverer is installed — fix(android): make a dropped script visible, instead of reporting it delivered #238
fix(android): injecting the bridge is not idempotent, and a stale injection callback can mark the wrong page ready #228 injecting the bridge is not idempotent, and a stale injection callback can mark the wrong page ready — fix(android): tie a ready mark to its own navigation, and inject a document once #239
fix(android): a recreated activity restores the wrong initial URL, and Recents re-fires the launch shortcut #229 a recreated activity restores the wrong initial URL, and Recents re-fires the launch shortcut — fix(android): skip the whole launch intent on a relaunch from Recents #240
Then the iOS bridge core. #225 goes before #223 , which says it cannot safely take a deadline until the lease check is right.
fix(ios): ios_async's main-queue hop checks only that a slot is in use, not which lease #225 ios_async's main-queue hop checks only that a slot is in use, not which lease — fix(ios): answer the lease a hop was queued for, not whichever call holds the slot #241
fix(ios): an answer to a call made before a page reload can settle a different call after it #226 an answer to a call made before a page reload can settle a different call after it — fix(ios): give each page load callback ids no earlier load is still owed #243
fix(ios): Zig actions that wait on a framework completion have no deadline, and cannot safely get one yet #223 Zig actions that wait on a framework completion have no deadline — 4 of 6 have one (fix(ios): give getPendingNotifications a deadline, on the shared block table #247 , fix(ios): give scheduleNotification's add a deadline, starting on the grant #248 , fix(ios): give getHealthData and saveHealthWorkout deadlines, on the shared block table #250 ); the other 2 deliberately have none (fix(ios): resolve openSettings' selector before the lease, and say why two actions have no deadline #251 ) — awaiting a decision on closing
fix(ios): Swift-only actions that wait on a framework callback have no deadline #224 Swift-only actions that wait on a framework callback have no deadline — fix(ios): give the Swift-only calls that wait on a framework callback a deadline #244
fix(ios): openURL, setBadge and setFlashlight never settle on a malformed argument in Swift #221 openURL, setBadge and setFlashlight never settle on a malformed argument in Swift — fix(ios): answer a malformed argument instead of leaving the page waiting #246
Last, because it changes what existing pages get back:
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
fix(mobile): share() is fire-and-forget, so four of its native outcomes can never reach the page #203 : split in two. fix(ios): let share() settle the promise its native side answers #208 makes iOS share() return its promise and adds a static check for the whole bug class, which found five more (fix(ios): five page calls drop the answer the native side sends them #207 ). fix(android): resolve share() with whether the person picked an app #210 resolves Android share() with whether an app was picked and adds the suite's first real cancellation path: the harness dismisses the real share menu. Android's ungated enable* flags are filed as fix(android): enable* flags shape the manifest and craft.capabilities, but the bridge never checks them #209 .
fix(ios): let share() settle the promise its native side answers #208 and fix(android): resolve share() with whether the person picked an app #210 merged. Both carried two red iOS jobs that also fail intermittently on main: the shim leg's first simctl launch --console-pty after 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 fix(mobile-e2e): launch iOS apps without a pty, so a cold simulator cannot eat the budget #212 : both iOS harnesses launch without --console-pty (≈200s on a cold simulator, reproduced) and the slice's count agrees with its wait loop. The other slice flake is a real never-settling promise, filed as fix(ios): removeSiriShortcut can wait for ever on a completion the framework never fires #211 .
fix(ios): settle location promises when Core Location has no callback #197 : 4fe86f0 had already fixed it in Swift, but no test ran it on a device. test(mobile-e2e): prove iOS location settles, and that Zig served it #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 of 4fe86f0 fails permissions.location.granted on both legs. Zig now logs hand-backs so the runtime leg can prove Zig served the calls.
fix(mobile-e2e): launch iOS apps without a pty, so a cold simulator cannot eat the budget #212 , test(mobile-e2e): prove iOS location settles, and that Zig served it #213 and test(mobile-e2e): prove Android reports approximate location as granted #214 merged, all fully green. fix(android): report granted location permission through the mobile bridge #192 turned out to be fixed by bb5c1b5 and 780e952 but never run on a device. test(mobile-e2e): prove Android reports approximate location as granted #214 's emulator run grants approximate location only, and a CI mutation that requires precise location makes it fail with denied on both legs (run 35111184463).
fix(ios): deliver custom-scheme deep links on cold start #198 : reproduced with an XCUITest cold start. The launch link was dispatched 1ms before onLink could exist. fix(ios): hand a cold-start link to the page that subscribes for it #216 replays early links to the first subscriber, with getInitialURL claiming the launch link so it is not delivered twice, and adds a real cold-start test to both iOS legs. Android parity is fix(android): hand a link that arrives before onLink to the first subscriber, as iOS now does #215 .
perf(android): the shipped libcraft.so carries debug info, ~5.5 MB per ABI #204 : zig objcopy cannot copy ELF to ELF, so perf(android): ship libcraft.so without its DWARF, and keep the DWARF for symbols #217 strips with the NDK's llvm-objcopy, keeps the DWARF in android-symbols/, and the Android runner refuses a library that still carries any.
Nothing in CI compiles the Kotlin templates, and #156 shipped three type errors because of it #163 : already done by 6f3a4d7 (android-builder → test:templates). It caught fix(android): resolve share() with whether the person picked an app #210 's duplicate PendingIntent import today. Closed with that evidence.
fix(ios): add required WCSessionDelegate methods to generated Watch app #193 –fix(ios): configure APNs production entitlement for release archives #196 : all four were fixed in the generator on 14 September (5d03387 , 6bd84f4 , 611be12 ), and none of those fixes was checked where it failed. test(ios): install the Watch-enabled app and read the APNs environment it signs #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 the aps-environment Xcode actually signs for Debug and Release. Reverting each fix makes the gate fail with that issue's own message. fix(ios): configure APNs production entitlement for release archives #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.
perf(android): ship libcraft.so without its DWARF, and keep the DWARF for symbols #217 and test(ios): install the Watch-enabled app and read the APNs environment it signs #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, fix(android): hand a link that arrives before onLink to the first subscriber, as iOS now does #215 ports an iOS contract, and fix(android): enable* flags shape the manifest and craft.capabilities, but the bridge never checks them #209 changes Android defaults so it goes last.
fix(ios): five page calls drop the answer the native side sends them #207 : fix(ios): hand the page the answers native was already sending #222 , fully green. Adding the callbackId alone would have hung haptic and 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. The craft.haptics.* feedback helpers treat "haptics left off" as nothing to play, as Android and the web do. Filed fix(android): haptic, vibrate, startListening and stopListening answer nothing, where iOS now does #219 (Android parity), fix(location): craft.watchPosition and clearWatch mean different things on each platform, and v1 can leave a dead watch #220 (the flat watch API) and fix(ios): openURL, setBadge and setFlashlight never settle on a malformed argument in Swift #221 (Swift cases that never settle on a bad argument).
fix(ios): removeSiriShortcut can wait for ever on a completion the framework never fires #211 : fix(ios): settle a Siri shortcut removal whose completion never comes #227 , fully green. Both runtimes now answer TIMEOUT after 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 fix(ios): Zig actions that wait on a framework completion have no deadline, and cannot safely get one yet #223 , fix(ios): Swift-only actions that wait on a framework callback have no deadline #224 , fix(ios): ios_async's main-queue hop checks only that a slot is in use, not which lease #225 and fix(ios): an answer to a call made before a page reload can settle a different call after it #226 .
fix(android): hand a link that arrives before onLink to the first subscriber, as iOS now does #215 : fix(android): hand the launch link to the page that subscribes for it #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 craftReady handlers on both platforms. Filed fix(android): injecting the bridge is not idempotent, and a stale injection callback can mark the wrong page ready #228 , fix(android): a recreated activity restores the wrong initial URL, and Recents re-fires the launch shortcut #229 , test(android): compare each JNI descriptor with its Kotlin external fun, not only the name #230 and fix(android): CraftNative.deliver drops a script silently when no deliverer is installed #231 .
fix(android): enable* flags shape the manifest and craft.capabilities, but the bridge never checks them #209 : fix(android): enforce the capability flags the bridge already reports #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.capabilities object the page reads, with Kotlin second checks for the four capabilities Android does not gate with a permission. The issue asked for an android_config.zig mirror of iOS; Android calls page → Kotlin → Zig, so the gate belongs above Kotlin, not beside it. Refusals carry iOS’s CAPABILITY_DISABLED; the calls that still answer nothing (fix(android): haptic, vibrate, startListening and stopListening answer nothing, where iOS now does #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.
fix(android): haptic, vibrate, startListening and stopListening answer nothing, where iOS now does #219 : fix(android): answer the page from haptic, vibrate and the speech calls #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 fix(android): enable* flags shape the manifest and craft.capabilities, but the bridge never checks them #209 ’s refusal now; stopListening leaves the gate, as on iOS. craft.haptics.* gets fix(ios): five page calls drop the answer the native side sends them #207 ’s split, which Android could not draw until craft.haptic() had an answer to give. A microphone grant used to go nowhere: startListening now starts the recogniser from onRequestPermissionsResult, the way iOS starts from inside its authorization callback.
test(android): compare each JNI descriptor with its Kotlin external fun, not only the name #230 : test(android): compare each JNI descriptor with its Kotlin external fun #235 merged, fully green. Descriptors are derived from each external fun and compared with the table. Primitives come from a small map; every other type resolves from CraftNative.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, including nativeDispatchDeepLink’s shape from before fix(android): hand a link that arrives before onLink to the first subscriber, as iOS now does #215 .
Out of band: fix(sdk): CraftApp.show() re-enters the CLI shim unmarked, so #26's guidance never reaches SDK callers #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. fix(sdk): mark the SDK spawn, keep a quiet child's stderr, read the CLI version form #237 merged, fully green: the SDK marks its spawn the way the CLI has since craft <url> fails with "Unknown option --url": resolveCraftBinary() resolves to the CLI shim via PATH #26 , a quiet child keeps its stderr so the resulting message survives, and parseCraftVersionOutput reads the CLI’s own craft/<version> <platform> form — which had been reporting two halves of one checkout as drift, and was the only signal a user got.
fix(android): injecting the bridge is not idempotent, and a stale injection callback can mark the wrong page ready #228 : fix(android): tie a ready mark to its own navigation, and inject a document once #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 var and 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 same window.craft, promise runtime and ready count to survive it.
fix(android): CraftNative.deliver drops a script silently when no deliverer is installed #231 : fix(android): make a dropped script visible, instead of reporting it delivered #238 merged, fully green. deliver answers 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 to droppedCount. 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 test(android): compare each JNI descriptor with its Kotlin external fun #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.
fix(android): a recreated activity restores the wrong initial URL, and Recents re-fires the launch shortcut #229 : fix(android): skip the whole launch intent on a relaunch from Recents #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 fix(android): hand the launch link to the page that subscribes for it #232 did, so that half is now pinned rather than changed. The other half was real — onCreate dispatched the shortcut whenever savedInstanceState was 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.
fix(ios): ios_async's main-queue hop checks only that a slot is in use, not which lease #225 : fix(ios): answer the lease a hop was queued for, not whichever call holds the slot #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 encoding scheduleDeadline already 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. abandon also leaked a stashed payload, which both release paths now clear through one function. The Busy half became fix(ios): a full async pool reports NativeCallFailed, which is not what happened #242 rather than being invented here.
fix(ios): an answer to a call made before a page reload can settle a different call after it #226 : fix(ios): give each page load callback ids no earlier load is still owed #243 merged, fully green. _callbackId restarted 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.
fix(ios): ios_async's main-queue hop checks only that a slot is in use, not which lease #225 and fix(ios): an answer to a call made before a page reload can settle a different call after it #226 are done, so fix(ios): Zig actions that wait on a framework completion have no deadline, and cannot safely get one yet #223 's stated blocker is cleared: the deadline sweep (fix(ios): Zig actions that wait on a framework completion have no deadline, and cannot safely get one yet #223 with fix(ios): Swift-only actions that wait on a framework callback have no deadline #224 ) is the next piece of work.
fix(ios): Swift-only actions that wait on a framework callback have no deadline #224 : fix(ios): give the Swift-only calls that wait on a framework callback a deadline #244 merged, fully green. Five actions answered only by Swift, each waiting on a framework callback nobody is in front of. One armDeadline/claimDeadline pair 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. registerPush also 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.
fix(ios): Zig actions that wait on a framework completion have no deadline, and cannot safely get one yet #223 is the Zig half and needs groundwork first: those modules keep side tables read by reply-slot index alone, which fix(ios): ios_async's main-queue hop checks only that a slot is in use, not which lease #225 did not touch. refactor(ios): lift #211's owed-block design out of Siri, so other actions can use it #245 lifts fix(ios): removeSiriShortcut can wait for ever on a completion the framework never fires #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.
fix(ios): openURL, setBadge and setFlashlight never settle on a malformed argument in Swift #221 : fix(ios): answer a malformed argument instead of leaving the page waiting #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.
fix(ios): Zig actions that wait on a framework completion have no deadline, and cannot safely get one yet #223 is landing one action at a time. refactor(ios): lift #211's owed-block design out of Siri, so other actions can use it #245 extracted the table; fix(ios): give getPendingNotifications a deadline, on the shared block table #247 is getPendingNotifications, with a ratchet that a module parking on ios_pending must also call scheduleDeadline — 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).
fix(ios): Zig actions that wait on a framework completion have no deadline, and cannot safely get one yet #223 , first action: fix(ios): give getPendingNotifications a deadline, on the shared block table #247 merged, fully green. getPendingNotifications parks on ios_pending.Table with 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 call scheduleDeadline — added because removing the deadline passed every other test. It conflicted with fix(ios): openURL, setBadge and setFlashlight never settle on a malformed argument in Swift #221 in ios_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 fix(ios): Zig actions that wait on a framework completion have no deadline, and cannot safely get one yet #223 : scheduleNotification (two stages, deadline after the grant, fresh block for stage two), getHealthData with saveHealthWorkout (one shared table, three-stage chain — the biggest), openURL (a confirmation for tel:/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.
fix(ios): Zig actions that wait on a framework completion have no deadline, and cannot safely get one yet #223 , second action: fix(ios): give scheduleNotification's add a deadline, starting on the grant #248 merged, fully green. scheduleNotification finishes 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 indexOf matched 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: fix(ios): give getPendingNotifications a deadline, on the shared block table #247 's ratchet did not catch fix(ios): give scheduleNotification's add a deadline, starting on the grant #248 's action losing its deadline, because it is per-module and getPendingNotifications still had one. Close that when the health module lands — the next file where two actions share a table.
fix(ios): Zig actions that wait on a framework completion have no deadline, and cannot safely get one yet #223 , the rest: test(ios): require a deadline per parked call, or a written reason for none #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. fix(ios): give getHealthData and saveHealthWorkout deadlines, on the shared block table #250 put the health module on the table: getHealthData and saveHealthWorkout get deadlines, requestHealthAuthorization shares 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. fix(ios): resolve openSettings' selector before the lease, and say why two actions have no deadline #251 fixed openSettings resolving its selector after the lease.
openSettings and openURL get no deadline, with the reason at each call. openSettings suspends the app while the uptime clock runs on, so a deadline shorter than the visit answers TIMEOUT for an open that worked. openURL leases no reply slot — its request id travels in the block — so fix(ios): Zig actions that wait on a framework completion have no deadline, and cannot safely get one yet #223 's concern does not apply, and a deadline would time out someone deciding on a tel: call. fix(ios): Zig actions that wait on a framework completion have no deadline, and cannot safely get one yet #223 stays open until that is agreed.
Out of band: fix(ios): a cancelled page load switches the app to the bundled copy for the rest of the session #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. fix(ios): fall back to the bundle only when the remote is out of reach, and come back #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 by scripts/load-failure.ts — confirmed from the job log, all eleven cases. Android's identical one-way latch is fix(android): any failed main-frame load switches to the bundled copy for the rest of the session #254 .
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.
share()never reports back to the page — iOS in fix(ios): let share() settle the promise its native side answers #208, Android in fix(android): resolve share() with whether the person picked an app #210libcraft.so— perf(android): ship libcraft.so without its DWARF, and keep the DWARF for symbols #217Follow-ups found during the run
removeSiriShortcutcan wait for ever on a completion the framework never fires — fix(ios): settle a Siri shortcut removal whose completion never comes #227onLinkto the first subscriber — fix(android): hand the launch link to the page that subscribes for it #232enable*flags are never checked by the bridge — fix(android): enforce the capability flags the bridge already reports #233Second round — filed by the follow-up PRs
Android first: the four items below are one subsystem, and #233 has just been through it.
haptic,vibrate,startListening,stopListeninganswer nothing, where iOS now does — fix(android): answer the page from haptic, vibrate and the speech calls #234external fun, not only the name — test(android): compare each JNI descriptor with its Kotlin external fun #235CraftNative.deliverdrops a script silently when no deliverer is installed — fix(android): make a dropped script visible, instead of reporting it delivered #238Then the iOS bridge core. #225 goes before #223, which says it cannot safely take a deadline until the lease check is right.
ios_async's main-queue hop checks only that a slot is in use, not which lease — fix(ios): answer the lease a hop was queued for, not whichever call holds the slot #241openURL,setBadgeandsetFlashlightnever settle on a malformed argument in Swift — fix(ios): answer a malformed argument instead of leaving the page waiting #246Last, because it changes what existing pages get back:
craft.watchPositionandclearWatchmean different things on each platform, and v1 can leave a dead watchEach item: a test that fails without the fix,
bun run test,bun run typecheck,bun run verify:packagesand Pickier locally, then CI.Log
share()return its promise and adds a static check for the whole bug class, which found five more (fix(ios): five page calls drop the answer the native side sends them #207). fix(android): resolve share() with whether the person picked an app #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 fix(android): enable* flags shape the manifest and craft.capabilities, but the bridge never checks them #209.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.--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 fix(ios): removeSiriShortcut can wait for ever on a completion the framework never fires #211.permissions.location.grantedon both legs. Zig now logs hand-backs so the runtime leg can prove Zig served the calls.deniedon both legs (run 35111184463).onLinkcould exist. fix(ios): hand a cold-start link to the page that subscribes for it #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 fix(android): hand a link that arrives before onLink to the first subscriber, as iOS now does #215.zig objcopycannot copy ELF to ELF, so perf(android): ship libcraft.so without its DWARF, and keep the DWARF for symbols #217 strips with the NDK'sllvm-objcopy, keeps the DWARF inandroid-symbols/, and the Android runner refuses a library that still carries any.android-builder→test:templates). It caught fix(android): resolve share() with whether the person picked an app #210's duplicatePendingIntentimport today. Closed with that evidence.aps-environmentXcode actually signs for Debug and Release. Reverting each fix makes the gate fail with that issue's own message. fix(ios): configure APNs production entitlement for release archives #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.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 fix(android): haptic, vibrate, startListening and stopListening answer nothing, where iOS now does #219 (Android parity), fix(location): craft.watchPosition and clearWatch mean different things on each platform, and v1 can leave a dead watch #220 (the flat watch API) and fix(ios): openURL, setBadge and setFlashlight never settle on a malformed argument in Swift #221 (Swift cases that never settle on a bad argument).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 fix(ios): Zig actions that wait on a framework completion have no deadline, and cannot safely get one yet #223, fix(ios): Swift-only actions that wait on a framework callback have no deadline #224, fix(ios): ios_async's main-queue hop checks only that a slot is in use, not which lease #225 and fix(ios): an answer to a call made before a page reload can settle a different call after it #226.craftReadyhandlers on both platforms. Filed fix(android): injecting the bridge is not idempotent, and a stale injection callback can mark the wrong page ready #228, fix(android): a recreated activity restores the wrong initial URL, and Recents re-fires the launch shortcut #229, test(android): compare each JNI descriptor with its Kotlin external fun, not only the name #230 and fix(android): CraftNative.deliver drops a script silently when no deliverer is installed #231.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 (fix(android): haptic, vibrate, startListening and stopListening answer nothing, where iOS now does #219) no-op with a warning.share. Three of them can carry fix(android): enable* flags shape the manifest and craft.capabilities, but the bridge never checks them #209’s refusal now;stopListeningleaves the gate, as on iOS.craft.haptics.*gets fix(ios): five page calls drop the answer the native side sends them #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.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 fix(android): hand a link that arrives before onLink to the first subscriber, as iOS now does #215.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.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.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 test(android): compare each JNI descriptor with its Kotlin external fun #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.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.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 fix(ios): a full async pool reports NativeCallFailed, which is not what happened #242 rather than being invented here._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.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.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.setSharedItem, and collecting them all is what turned five into thirteen. Its exception list is empty.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).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 fix(ios): openURL, setBadge and setFlashlight never settle on a malformed argument in Swift #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.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.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.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.getPendingNotificationsstill had one. Close that when the health module lands — the next file where two actions share a table.// no-deadline:line saying why, so an omission becomes a statement someone wrote. fix(ios): give getHealthData and saveHealthWorkout deadlines, on the shared block table #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. fix(ios): resolve openSettings' selector before the lease, and say why two actions have no deadline #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 fix(ios): Zig actions that wait on a framework completion have no deadline, and cannot safely get one yet #223's concern does not apply, and a deadline would time out someone deciding on atel:call. fix(ios): Zig actions that wait on a framework completion have no deadline, and cannot safely get one yet #223 stays open until that is agreed.-999, quick tab taps) sent the iOS app to its bundled copy for the rest of the session. fix(ios): fall back to the bundle only when the remote is out of reach, and come back #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 fix(android): any failed main-frame load switches to the bundled copy for the rest of the session #254.