Skip to content

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

Merged
glennmichael123 merged 3 commits into
mainfrom
feat/ios-swift-host-seam
Sep 3, 2026
Merged

feat(ios): the Zig runtime, offered to a Swift-hosted app#103
glennmichael123 merged 3 commits into
mainfrom
feat/ios-swift-host-seam

Conversation

@glennmichael123

Copy link
Copy Markdown
Contributor

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.

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

✅ Binary load time

rounds:    25 interleaved
base:      p50 24.0ms   p95 25.3ms   (22.8–25.6ms)
head:      p50 24.2ms   p95 25.1ms   (23.0–26.1ms)
delta:     +0.7%  (fails above +20.0%)

No binary load time regression.
What this measures

craft --help: process spawn, dynamic linking and argument parsing.
It never opens a window, so it cannot see a change in window or
webview startup — real startup is benchmarks/startup.bench.ts, which
needs a display.

Both binaries are measured interleaved on this runner and compared by
p50, rather than against a number recorded on another machine. On
byte-identical binaries that method reads within ~3.5%; the old one
swung 45%.

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

✅ Binary Size Report

Metric Value
Current Size 14241KB (13.90MB)
Change 0KB (0%) unchanged
Size limits
  • Warning: 14.50MB
  • Maximum: 16.00MB

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant