feat(ios): the Zig runtime, offered to a Swift-hosted app - #103
Merged
Conversation
✅ Binary load timeWhat this measures
Both binaries are measured interleaved on this runner and compared by |
✅ Binary Size Report
Size limits
|
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 ships. This adds the seam in the missing direction. `craft_ios_handle_action` takes an already-parsed message from a Swift host and answers whether Zig has taken responsibility for it; `craft_ios_set_webview` hands over the webview replies are evaluated against, which in a Swift-hosted app Zig never sees. The return value is the whole contract. True means the page will hear back from Zig and the caller must not answer as well — a refusal is true, because every refusal path has already settled the promise. False means no module recognised the action, which is the host's signal to serve it from its own switch. `handOffToHost` is deliberately not consulted here: 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 method the caller is about to call. `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. Fixes a real defect 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, …)` — a silent no-op, so the action ran, worked, and the page heard nothing. On the Swift side, `CraftZigRuntime` finds the entry points with `dlsym`, 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 as before: the absence of a symbol is the off switch, so there is no build flag and 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.
Review of the previous commit found the contract right in the abstract and wrong in two concrete places. `craft_ios_handle_action` claimed every action a Zig module recognised — including the ones Zig recognises in order to refuse, and one it answers with less than the host does. In a Swift-hosted app both replace a working answer with a worse one, which is the opposite of what linking the runtime is supposed to do. **Actions declared `.unavailable` now fall through.** Four of them: `haptic`, `getNetworkStatus`, `lockOrientation`, `unlockOrientation`. Each has a Zig handler that refuses on purpose — `haptic`'s is `return error.HapticsGateNotVisibleToZig`, because `config.enableHaptics` is not visible from Zig — and a Swift arm that works. In the Zig-hosted app that refusal is the honest end of the line: `route` tries `handOffToHost` next and the shim serves it. The host-offered path has no such fallback, because `craft_ios_handle_action` deliberately skips `handOffToHost` — there the host *is* the caller. So claiming one of these was not "a refusal instead of an answer", it was a refusal instead of the host's working answer: an app with haptics today would stop buzzing the moment the runtime was linked. `ios_conformance_test.zig` already records the rule — "falling through beats `.unavailable`". This applies it to the one path that had no way to fall through. The set is read from the modules' own manifests rather than listed beside them, so a module that declares a new `.unavailable` action gets the behaviour without anyone remembering to update a list. **`getDeviceInfo` answers all fourteen fields.** It is `.live`, not `.unavailable`, so nothing flagged it — and it returned four of the spec's fourteen. A page reading `screenWidth`, `batteryLevel`, `locale` or `timezone` got `undefined` from an action that reported success. Added `platform`, `name`, `identifierForVendor`, `screenWidth`, `screenHeight`, `screenScale`, `batteryLevel`, `batteryState`, `locale` and `timezone`, matching the spec value for value: `identifierForVendor` is `""` when nil, `batteryLevel` stays -1 because neither side enables battery monitoring, and `batteryState` uses the spec's own four names. `name` is the field a user can set, so it is the field that can carry a quote — every string now goes through `escapeJsonString` rather than being interpolated raw. The `CGRect` read is a plain `objc_msgSend` cast, not `objc_runtime.msgSendStret`: that helper picks `objc_msgSend_stret` for anything over 16 bytes, `CGRect` is 32, and `objc_msgSend_stret` does not exist on arm64. `bridge_mobile_motion.zig:124` records the same trap. The new conformance test reads the field names out of the spec's own dictionary, so a field added to `CraftApp.swift` fails the build until Zig answers it too. That is the check that would have caught this.
Three independent reviewers upheld this one and I could not refute it either: `attach` hands Zig `Unmanaged.passUnretained(webView).toOpaque()` and nothing ever hands it back. `craft_ios_set_webview` had exactly one Swift call site and no opposite number — no `dismantleUIView`, no clear in `Coordinator` `deinit`. While an app has one webview for its whole life that is fine, and that is why it survived review the first time. But a `UIViewRepresentable` is rebuilt whenever SwiftUI decides its identity changed, and from the second rebuild on Zig is holding a deallocated view. Every reply after that is an `objc_msgSend` into freed memory: a use-after-free that reads as working right up until the allocator reuses the page. `craft_ios_clear_webview` is the missing half, called from `dismantleUIView`. It compares before it clears. SwiftUI is free to build the replacement view *before* dismantling the old one, and during a state-driven rebuild it usually does — so an unconditional clear on dismantle would blank the pointer the new view had just installed and leave a live app whose every reply reached `error.NoWebView`. Clearing only when the pointer still matches makes both orders correct, and the test pins the awkward one. No new `-u` flag: the symbol shares an object file with `craft_ios_handle_action`, so the existing flag already pulls it in. Confirmed in the built app rather than assumed — `dyld_info -exports` lists `_craft_ios_clear_webview`, which is what `dlsym` actually reads.
glennmichael123
force-pushed
the
feat/ios-swift-host-seam
branch
from
September 3, 2026 11:56
5098fe7 to
bb6189f
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Until now the Zig mobile bridge could only serve a page whose webview Zig itself built. A generated app is SwiftUI-hosted —
WKScriptMessageHandlerbelongs 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_actionreturns 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 calledsendErrorToJS, so returning false would invite Swift to answer the same call a second time — and Swift's arm for a disabled capability has noelse, so the page would get one rejection and one silence.handOffToHostis 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 samedispatchmethod the caller is about to call anyway.routeand the new entry point now shareofferToModules, 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_webviewwas?objc.id— andobjc.idis already?*anyopaque. The double optional meantsetWebView(null)stored a present outer value wrapping a nil webview, andorelseunwrapped only the outer one. Every reply after such a call went toobjc_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 flagCraftZigRuntimefinds the entry points the same wayCraftSwiftShimis found from Zig. An app built without the archive finds nothing,offeranswers 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.aand writes the link settings. The four-uflags are load-bearing: nothing in the Swift references these symbols, so without them the linker drops the entire archive as unreachable andofferanswers false for every action.Verification
Built and inspected end to end, without a simulator:
build-ios-all -Doptimize=ReleaseSafelipofat simulator archive (x86_64 + arm64), arm64 device archiveproject.ymllibcraft-ios.a_craft_ios_*in the binarynm -U_craft_ios_*in the export triedyld_info -exports— whatdlsymactually readsCFBundleExecutableresolveszig build testbun run buildThe export-trie check is the one that matters most:
nmshowing a symbol does not guaranteedlsymfinds it, and if it didn't,offerwould 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.