Three actions, two new modules. Both modules had to build their own completion block, for different reasons.
authenticate — and why it cannot use the pooled block
ios_async.boolErrorBlock is void (^)(BOOL, NSError *), which is exactly the shape evaluatePolicy:localizedReason:reply: wants. It is still the wrong block: its delivery path answers the strings "granted" and "denied", the vocabulary checkPermission speaks. Swift resolves the bare JSON true and rejects otherwise, so a page doing
if (await craft.authenticate()) { /* let them in */ }would read "denied" as truthy. The module builds its own block and answers through deliverJson / deliverErrorCode.
Deliberate divergence: Swift rejects every failure with the same localizedDescription, so a page cannot tell "the user pressed Cancel" from "there are no biometrics on this device". LAErrorUserCancel becomes CANCELLED; the rest stay PERMISSION_DENIED. That is the distinction a caller actually branches on — a cancel is a reason to re-offer the prompt, everything else is a reason to fall back to a passcode — and it is the same divergence bridge_mobile_filepicker already makes for a dismissed picker.
Placement: it does not join bridge_mobile_biometric.zig, which states at the top that it contains no LAContext. Swift implements the three *BiometricPersistence actions against one in-memory Date and never consults biometrics for them, which makes that file the rare mobile module that is pure Zig with no Darwin gate anywhere. Folding a LocalAuthentication call in would make it Objective-C-bound on every platform the dispatch chain compiles for, to nobody's benefit.
Lifetime: the LAContext is created +1 and released in the reply block — where Swift's local goes out of scope. Releasing earlier frees it while the prompt is still on screen.
The Siri pair — no Intents, no permission, no UI
Worth stating, because "Siri shortcut" suggests all three. registerSiriShortcut builds a plain NSUserActivity, sets six properties and calls becomeCurrent(). removeSiriShortcut calls one class method. Both are Foundation, both are ungated, neither prompts.
Kept apart from bridge_mobile_shortcuts.zig, which serves setShortcuts/clearShortcuts — those are UIApplication.shortcutItems, the long-press menu on the app icon. Two features, one English word, and neither module should have to explain the other.
The activity is released right after becomeCurrent() because Swift's is a local that ARC releases at the same point. Holding it in a module-level var would keep the donation alive longer than the spec does — a divergence in the direction of "works better", which is still a behaviour change observable through Spotlight.
The deletion completion is a void (^)(void) carrying nothing at all, not even a success flag, so the reply is shaped before the call and parked on the slot for the block to deliver.
Both modules park their per-slot state behind a mutex and clear it on take, following bridge_mobile_notifications — which is what makes a second fire of the same completion a no-op rather than a second reply and, here, a double free.
Verification
zig build test green (153/153), zig fmt --check, build-ios and build-ios-simulator clean, pickier . 0 errors.
26 fixture assertions pass on a simulator, three new:
ok: biometrics refused by the framework, not by a missing class (i=34 seen 1x)
ok: NSUserActivity donated and echoed back (i=36 seen 1x)
ok: argument-free completion block delivered its parked reply (i=38 seen 1x)The fixture now links LocalAuthentication, which is what makes the first one meaningful: PERMISSION_DENIED can only come from canEvaluatePolicy saying no, where an unlinked framework would have produced PLATFORM_NOT_SUPPORTED. The third is the liveness proof for the argument-free block — the reply carries data the block itself has no way to reconstruct, so its arrival means Foundation fired it and the parked payload went out under the request id captured back at dispatch.
Host tests cover the payload parsing for both modules, the block layout (global flag, descriptor size, one distinct invoke per slot), that a refusal leases no reply slot, and that a stray completion for a slot with no recorded call is ignored rather than double-freeing.