ReviewOS

also looking at this

craft-native/craft

feat(ios): the Zig runtime, offered to a Swift-hosted app

#103
Merged glennmichael123 wants to merge feat/ios-swift-host-seam into main
7 files +868 -59
10 days ago

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:

CheckResult
build-ios-all -Doptimize=ReleaseSafe3 archives (a Debug build fails to link — UBSan symbols)
Generated app + runtime installlipo fat simulator archive (x86_64 + arm64), arm64 device archive
project.ymlparses; all three runtime settings present
swiftc build of the generated applinks against libcraft-ios.a
4 × _craft_ios_* in the binarynm -U
4 × _craft_ios_* in the export triedyld_info -exports — what dlsym actually reads
Bundle structurally installableCFBundleExecutable resolves
zig build test150/150 steps, 2472/2484 passed, 12 skipped, 0 failed
pickier / tsc / bun run buildclean

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.

7 changed files on the files tab, with 0 review threads.

Sign in to comment on this pull request.