ReviewOS

also looking at this

craft-native/craft

Zig-native mobile: iOS compiles, serves a bridge call on a simulator, and is gated against drift

#83
Merged glennmichael123 wants to merge feat/mobile-zig-native into main
28 files +2460 -6557
10 days ago

Craft claims five platforms. Three worked. This is the first four steps of making iOS the fourth for real, plus the demolition that Android needs before it can follow.

Where mobile actually stood

Three facts, each verified, that together explain why "mobile is broken" was true for months without anyone being able to point at one cause.

iOS had not compiled since 2026-05-07. The Zig 0.17 migration (1b6f285) broke it; on 2026-08-18 the E2E workflow was made workflow_dispatch-only (e1bc449) rather than repaired.

Android compiled and shipped nothing. zig build build-android exits 0 and produces a 2,990-byte archive with zero defined symbols, because src/android.zig has no export fn and Zig only emits code reachable from an export. An empty static library is a legal static library, so the build was green.

Neither of those was why mobile didn't work. The shipped apps contained no Zig at all — no reference to craft_ios_*, @_silgen_name, libcraft, or jniLibs anywhere in packages/ios or packages/android. What shipped was 9,049 lines of Swift and Kotlin that the TypeScript couldn't reach.

What's here

1. iOS compiles again, and a gate keeps it that way

Six of the nine errors were in code that shouldn't exist, so deletion came before repair — ~3,200 lines, including:

  • ios.zig's JSBridge, which parsed JSON by substring search and whose handleMessage had no caller anywhere in the tree
  • injectBridgeScript, which ran before the navigation that wiped it, and whose fallback resolved {success:true, browser:true} — making "the bridge worked" and "there is no bridge" the same value
  • mobile.iOS.requestPermission, which took a callback and immediately did _ = callback;

test/ios_surface_test.zig is the gate. Zig analyses function bodies lazily, so mobile_test.zig stayed green on enums and struct literals while the cross-compile failed on nine errors — neither result contradicted the other. Forcing analysis of every reachable declaration turns "does iOS compile" into a host build that takes seconds. It immediately found a block-ABI defect the cross-compile itself missed, because evaluateJavaScript wasn't reachable from the exports.

2. Android's JNI layer deleted

3,087 lines removed, 61 added. The centre was a hand-written JNI vtable declaring four reserved slots, then GetVersion, then jumping straight to FindClass under a comment reading // ... many more function pointers .... The real table has ~230 entries in fixed ABI order — FindClass is index 6, GetObjectClass 31, NewStringUTF 167 — so every call read the wrong slot.

That none of it ever compiled is provable: jni was gated on os.tag == .linux, so on a macOS host it was an empty struct and no body was analysed. requestPermission contained if (callback) |cb| on a non-optional parameter — an unconditional compile error that never fired.

Also removed: nine handlers that extracted their arguments, discarded them, and replied {"success": true}; AndroidFeatures, ten bodies of _ = param;; and a callback table whose % 16 indexing aliased the 17th concurrent callback onto the 1st.

3. A bridge call served from Zig, on a simulator

Three things had to exist that never did — a registered WKScriptMessageHandler, a WKUserScript at atDocumentStart, and UIApplicationMain (the old code spun a bare NSRunLoop on a thread UIKit doesn't own).

Verified on an iPhone 17 Pro simulator, iOS 26.5:

ok: page -> native (i=1 seen 1x)
ok: native -> page, round trip closed (i=2 seen 1x)
ok: no feedback loop
ok: user script ran at document start
PASS

The i=2 signal is load-bearing: the page sends it only after receiving a reply whose systemName is "iOS", so it cannot be produced by a stub, a browser fallback, or native talking to itself. Registering the handler under any other name fails the first assertion.

iOS speaks the desktop envelope {t,a,d,i} and replies through bridge_error.sendResultToJS. The .ios arm on bridge.evalJS is six lines that light up every existing bridge_*.zig reply path unchanged.

4. A conformance gate, and five promises that hung forever

CraftApp.swift disagreed with itself: its injected JS offered five ota* methods posting actions its own switch never handled, and they bypassed _createCallback, which owns the 30-second timeout. So craft.ota.checkForUpdate() returned a promise that never settled — no result, no rejection, no timeout. They now reject immediately.

test/ios_conformance_test.zig makes that class of bug fail the build:

  • every posted action is one the dispatcher handles (no allow-list — that's how the exception becomes permanent)
  • every spec action is served by Zig or explicitly owed, with a ratchet at 105 that only goes down
  • every Zig action exists in the spec, catching a misspelling no page could reach
  • the scan finds the dispatcher and finds actions in it — first, because every other check is a subset check and an empty set satisfies all of them

The scan is bounded to the switch action block, not the file: there are 139 case "..." labels but only 106 actions, the rest being sub-switches over haptic styles and AR primitives. A whole-file scan would fail forever on case "box".

Verification

Every gate was checked for non-vacuity, not just for passing:

checkproof
iOS surface gatereintroducing the double optional makes test:ios fail
simulator sliceregistering the handler as "notcraft" fails the first assertion
migration ratchetforcing it to 0 reports exactly 105 unmigrated
orphan-action checkinjecting a phantom postMessage fails by name

zig build, zig build test, build-ios, build-ios-simulator, build-android, build-android-x86 all pass. libcraft-ios.a is 2.25 MB with 7 exported symbols, against Android's 2,990 bytes and zero.

Scope notes

  • The Android .so is still empty — expected, and stays that way until A1 gives it a JNI_OnLoad.
  • The Android/GTK guard landed here rather than in A1: Android's os.tag is .linux, so it falls into linux.zig's 44 GTK symbols — free in a static archive, fatal the moment Android emits a shared library. It's the same switch the .ios arm went into.
  • Structured d payloads are refused with an error rather than silently emptied. Substituting {} is how craft.fs.writeFile came to write empty files.
  • release.yml is untouched.

Plans for the rest: docs/ios-development.md, docs/android-development.md.

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

Sign in to comment on this pull request.