Until now the Zig mobile bridge could only serve a page whose webview Zig itself built. A generated app is SwiftUI-hosted — WKScriptMessageHandler belongs to the Swift template — so every migrated action was unreachable in the one configuration that actually ships.
This adds the seam in the missing direction.
The contract
craft_ios_handle_action returns true when Zig has taken responsibility — served the action, or refused it and settled the page's promise on the way. The caller must not answer as well. It returns false only when no Zig module recognised the action, which is the host's signal to serve it from its own switch.
A refusal is true. Every refusal path has already called sendErrorToJS, so returning false would invite Swift to answer the same call a second time — and Swift's arm for a disabled capability has no else, so the page would get one rejection and one silence.
handOffToHost is deliberately not consulted from this entry point: in this direction the host is the caller, so handing back would take a round trip through the Objective-C runtime to reach the same dispatch method the caller is about to call anyway.
route and the new entry point now share offerToModules, so the capability gate and the module chain cannot drift between a message Zig received itself and one a host passed over.
A silent defect fixed on the way
global_webview was ?objc.id — and objc.id is already ?*anyopaque. The double optional meant setWebView(null) stored a present outer value wrapping a nil webview, and orelse unwrapped only the outer one. Every reply after such a call went to objc_msgSend(nil, …), which is a silent no-op: the action ran, it worked, and the page heard nothing, with no error anywhere to say so.
Discovery is dlsym, so there is no build flag
CraftZigRuntime finds the entry points the same way CraftSwiftShim is found from Zig. An app built without the archive finds nothing, offer answers false for everything, and the existing switch serves the whole surface exactly as before. The absence of a symbol is the off switch — no build flag, no second code path.
The generator installs the archives as Runtime/<sdk>/libcraft-ios.a and writes the link settings. The four -u flags are load-bearing: nothing in the Swift references these symbols, so without them the linker drops the entire archive as unreachable and offer answers false for every action.
Verification
Built and inspected end to end, without a simulator:
| Check | Result |
|---|---|
build-ios-all -Doptimize=ReleaseSafe | 3 archives (a Debug build fails to link — UBSan symbols) |
| Generated app + runtime install | lipo fat simulator archive (x86_64 + arm64), arm64 device archive |
project.yml | parses; all three runtime settings present |
| swiftc build of the generated app | links against libcraft-ios.a |
4 × _craft_ios_* in the binary | nm -U |
4 × _craft_ios_* in the export trie | dyld_info -exports — what dlsym actually reads |
| Bundle structurally installable | CFBundleExecutable resolves |
zig build test | 150/150 steps, 2472/2484 passed, 12 skipped, 0 failed |
pickier / tsc / bun run build | clean |
The export-trie check is the one that matters most: nm showing a symbol does not guarantee dlsym finds it, and if it didn't, offer would answer false for every action and the app would look exactly like a normal Swift-only build.
Not yet exercised on a simulator — the seam is verified by linkage and symbol export, not by a running app.