Two commits. The first closes a gap the migration kept working around; the second spends it.
1. Read the app's capability config instead of ignoring it
Every action in CraftApp.swift's dispatcher is guarded by a config.enable* flag. Zig had none of them, so 32 actions it already served were served unconditionally — an app that set enableClipboard: false got clipboard anyway.
src/ios_config.zig reads the same bundled craft.config.json Swift decodes; ios_dispatch.route consults it before the module chain. Only actions Zig serves are gated — the rest keep whatever answer the shim gives them, because Zig deciding on the shim's behalf would answer from Zig's parse a question the owning arm is about to answer from its own.
60 of the spec's 65 gated cases hang
Only share, secureClear, getCurrentPosition, watchPosition and startLocationRecording reply CAPABILITY_DISABLED. The rest have no else: the guard fails, the case falls out of the switch, the callback is never settled, and the page's promise stays pending until its timeout. That is the ota* defect the conformance test already catches, at twelve times the scale. Those 60 are not a contract to port, so a disabled capability answers rather than hangs.
The decode is all-or-nothing, because Swift's is
CraftConfig's properties carry defaults, but a synthesized Decodable does not use them — it throws on a missing key, try? swallows it, and line 178 falls back to an all-false CraftConfig(). One absent key disables all 35 capabilities. Both runtimes read one file in one process through the hand-off table, so a per-key decode would be more useful and would disagree with Swift about the same bytes.
Seven behaviours verified against a real swiftc run rather than assumed:
| input | Swift | why it matters |
|---|---|---|
| missing required key | throws → all false | the rule above |
1 for a Bool | throws | coercing would be the obvious thing to write |
null / "true" for a Bool | throws | |
non-string in trustedOrigins | throws | |
| duplicated key | keeps the first, no throw | Zig's default is error.DuplicateField, which would disable an app over a pasted line |
| unknown extra key | ignored | |
devServerURL absent or null | fine | the one optional |
Keeping the table honest
Five assertions compare the gate table against the spec: it must cover every gated action Zig serves, agree on which flag, carry no stale entry, spell only keys CraftConfig actually has, and require exactly the keys Swift's decoder requires. A misspelling like enableMlKit is not a one-flag bug — the key reads as missing and disables all 35 — so that has its own check.
Six mutations verified to fail the build, on exit codes (a first pass keyed on a grep pattern that also matched benign error.NoWebView log lines would have reported false passes):
PASS (caught, rc=1): gateFor missing an entry (clipboardRead)
PASS (caught, rc=1): gateFor pointing at the wrong feature
PASS (caught, rc=1): a typo in a json key (enableMLKit -> enableMlKit)
PASS (caught, rc=1): a stale gate entry for an action Zig does not serve
PASS (caught, rc=1): dropping a required non-capability key (darkMode)
PASS (caught, rc=1): parse dropping the duplicate-key option2. Screenshot and deep-link handshake — ratchet 48 → 46
Both were unserved for exactly the gap above; bridge_mobile_misc's header said so in as many words — "It stays with the shim until a config channel exists." The gate caught both new actions as ungated on the first build after I added them, which is what it is for.
takeScreenshotrenders the key window's layer and answers a bare JSON string carrying adata:URL.UIGraphicsBeginImageContextWithOptionsand friends stand in forUIGraphicsImageRenderer, whoseimageWithActions:takes a block — and a global block cannot capture the layer it must render, so reaching it would mean module-level mutable state to avoid two C calls that produce the same pixels. The five entry points resolve viadlsym, the routebridge_mobile_imagepickeralready takes, because the host test binaries link Cocoa and not UIKit. They resolve together, so a UIKit missing one refuses before beginning the image context — the only call here with a cleanup obligation.registerDeepLinkHandleracknowledges and does nothing else, because there is nothing native to register: Swift's whole arm isresolveCallback(true)and the real registration is the page's ownaddEventListener. Porting it faithfully means porting a handshake.getInitialURLstays with the shim — its value lives inDeepLinkManager.shared, a pure-Swift class with no@objcsurface, fed by SwiftUI's.onOpenURL. The config channel does not change that.
Verification
zig build test green (153/153), zig fmt --check, build-ios and build-ios-simulator clean, pickier . 0 errors.
20 fixture assertions pass on a simulator, including three new ones:
ok: disabled capability refused with CAPABILITY_DISABLED (i=20 seen 1x)
ok: enabled capabilities still served (i=10, i=12, i=14 above)
ok: screenshot rendered the live window to a real PNG (i=22 seen 1x)
ok: deep-link handshake replied true behind its own gate (i=24 seen 1x)Both arms of the gate matter: the Keychain, SQLite and notification round trips run through actions the same config enables, so it is not simply refusing everything. The screenshot assertion decodes the base64 and checks the PNG signature byte by byte plus a size floor — a fabricated data URL with the right prefix fails it, verified by mutation.
What this unblocks
33 of the remaining 46 are behind a config flag rather than behind any Objective-C difficulty. That was the actual wall, and it is gone.