Follow-on to #85. Twelve more actions cross into Zig, including the first asynchronous reply, and the fixture now round-trips a secret through the real Keychain.
The async foundation (ios_async.zig)
The plan flagged async replies as the highest-risk item after Phase 1: a completion handler fires after the dispatch frame is gone (the request id is no longer anywhere) and on whatever queue the framework chose (evaluateJavaScript is main-thread-only). Losing the id silently drops correlation to action-name matching — caller B's answer handed to caller A whenever two calls of one action are in flight.
Three pieces, each checkable:
- a slot pool capturing
{request id, action}at dispatch time, with generation counters so a stale or double-firing completion cannot resolve a reused slot; - completion blocks that are
BLOCK_IS_GLOBAL—Block_copyon a global block returns the same pointer, so no heap copy, no descriptor lifetime, no copy/dispose. Each slot has its own comptime-generated invoke with the index baked in: how a non-capturing block knows who it is; - delivery hopped to the main queue with
dispatch_async_f— the function-pointer variant, so the hop itself needs no block.
The pool exhausts loudly (acquire → null obliges a Busy reply), and an action longer than slot storage is refused rather than truncated — a truncated name would make the reply name a different action than the page called.
Five modules, 12 actions
| module | actions |
|---|---|
| misc | setFlashlight |
| shortcuts | setShortcuts, clearShortcuts |
| securestore | secureSet, secureGet, secureRemove, secureClear |
| biometric | setBiometricPersistence, checkBiometricPersistence, clearBiometricPersistence |
| permissions | checkPermission, requestPermission (async, on ios_async) |
Deliberately not served: getInitialURL, registerDeepLinkHandler, takeScreenshot, openSettings stay with the Swift shim. Their Swift arms are config-gated (enableDeepLinks, enableScreenCapture) and no config channel into Zig exists yet — declaring them .unavailable would make Zig dispatch-and-refuse, stealing them from the shim that serves them correctly today. Falling through is the honest routing.
The adversarial review pass caught another lawful-payload refusal before it shipped: floats rendered with {d} into a [64]u8 buffer, when {d} on f64 needs up to 347 bytes — {"x":1e300} failed as NativeCallFailed. Sized by std.fmt.float.bufferSize now, pinned by a test.
The Keychain on a simulator, reverse-engineered
The fixture's tenth assertion round-trips a secret containing a quote and a backslash through real SecItemAdd/SecItemCopyMatching, byte-identical. Getting it to run surfaced a mechanism worth recording:
- unsigned app →
SecItemAddfails witherrSecMissingEntitlement(-34018) - signing
application-identifierentitlements in → the launch itself is denied (POSIX 163 from RunningBoard; bisected — the same signature without them launches) - the simulator's actual mechanism, which Xcode uses: identity entitlements live in a
__TEXT,__entitlementsMach-O section, not the signature
The harness now embeds the identity via -sectcreate and signs ad-hoc — matching what a real simulator build carries.
Verification
All ten fixture assertions on an iPhone 17 Pro simulator (iOS 26.5):
ok: page -> native … ok: error route closed with escaping intact
ok: Tier-0 action served by Zig with a live UIKit value
ok: secureSet/secureGet round-tripped a secret through the Keychain
PASSPlus zig build test / test:ios / desktop / build-ios / build-ios-simulator / build-android, zig fmt --check, verify:cimports, pickier. The conformance ratchet is at 74 and only goes down.