The mobile E2E workflow has been reporting green while running nothing. Every
iOS step that would have tested something was guarded on
hashFiles('packages/ios/TestApp/TestApp.xcodeproj') != '', against a project
that has never existed in this tree, and the Android job's only test call was
gated on packages/android/gradlew, which the generator does not write. Both
skipped on every run.
Running a generated app for the first time found why that mattered.
The iOS bridge was dead in the shipping configuration
A generated iOS app with no dev server loads its page from craft://app,
served out of the bundle. The message-handler guard added in 68c6d3a called
isTrustedOrigin, which trusted file://, https:// and http-on-localhost —
and not the scheme the app actually uses. Every call from the app's own page
was refused:
Blocked Craft bridge message from untrusted origin: craft://appNot one window.craft promise ever settled. isTrustedURL knew about
craft://app; the bridge guard did not, because the clause lived in the caller
rather than in the decision. Both go through one function now.
The code compiles perfectly and simply says no, so no compile gate could have seen it.
And Android could not tell it was an emulator
isEmulator was FINGERPRINT.contains("generic") || contains("emulator"). A
current Google APIs image fingerprints as
google/sdk_gphone64_x86_64/emu64xa:14/..., which contains neither word, so
the first real Android run reported isEmulator is false from inside an API 34
emulator. Build.HARDWARE leads now — an AVD's virtual board is goldfish on
the old QEMU pipeline and ranchu on the current one.
bridge_android_device.zig carried a copy of the same broken check, under a
test named "the emulator test asks exactly what the Kotlin asks". Both sides
ask the same six questions again. That divergence matters more than either
implementation: the point of the hand-off is that a page cannot tell which side
answered, and two emulator tests that disagree would make isEmulator depend
on whether libcraft.so happened to load.
What runs now
Generate an app, drop a self-driving page into its web assets, build against
the simulator SDK — which no job did before; the templates gate compiles
-sdk iphoneos only — install on a booted device, and read the page's own
report back off the console.
Per platform, one success path and one rejection path:
| success | rejection | |
|---|---|---|
| iOS | clipboard round trip, getDeviceInfo().isSimulator | getCurrentPosition() with geolocation off → CAPABILITY_DISABLED |
| Android | clipboard round trip, getDeviceInfo().isEmulator + sdkVersion vs getprop | registerPush() with push off → "Push notifications are disabled" |
Two things make those hard to fake. The clipboard nonce is generated by the
harness and read back out of the device's own pasteboard afterwards with
simctl pbpaste, so a value invented in the page would not be there. And
isSimulator comes from Swift's #if targetEnvironment(simulator) — decided
by the compiler, not guessable by JavaScript.
Nothing skips
The page announces which cases it will run before running any, so a suite that
dies halfway cannot be read as a shorter suite that passed. protocol.ts holds
a required-case list per platform that must all appear and all pass, plus a
guard that each platform keeps at least one rejection case — a suite of success
paths only proves half the bridge. The plan carries the run's nonce, because
Android reads its transcript out of a logcat ring buffer that outlives an
uninstall. A missing toolchain, an absent device, or an app that printed
nothing is a failure with a named cause, and evidence uploads with
if-no-files-found: error.
iOS runs twice
Once with the Zig archive linked, once without. Both legs assert the same
cases, because the hand-off is meant to be invisible to the page — and that is
exactly why the linked leg also checks Zig's own logs. Not a count: log is
itself a dispatched action, so six of the ten lines in a passing run are the
page's own reporting traffic, and a count is satisfied while Swift serves
everything under test. It names the four actions the suite exercises, and
requires the refusal line Zig's capability gate writes for
getCurrentPosition — the part that proves Zig answered rather than merely
being offered the call.
ok ios-shim — 4/4 cases, no zig
ok ios-runtime — 4/4 cases, zig served clipboardRead, clipboardWrite, getCurrentPosition, getDeviceInfo, logMutation-tested: reverting the origin fix turns the suite red with four independent reasons, including "the test page never announced a plan; it did not reach the native bridge".
Also
- The header's claim that the iOS Zig sources have not compiled since
1b6f285is stale —zig build build-ios-allsucceeds, and it is now the iOS job's first step.build-ios-allrather thanbuild-ios-simulatorbecauseinstallRuntimeresolves every SDK before writing anything and refuses a runtime directory with no device slice. -Dmacos-sdkis dropped from the iOS build: the iOS steps resolve their SDK throughxcrunat configure time, and only desktop and host-test artifacts read that option.- Two path filters pointed at
packages/zig/src/ios/**and.../android/**, neither of which exists; the mobile sources are flat files undersrc/. packages/ios/fixtures/zig-slice— 435 lines of assertions against the Zig runtime on a simulator, passing, wired into no workflow — now runs too.- Every job gets a
timeout-minutes; none had one. orderSimulatorssorted booted devices ahead of everything without looking at the runtime, so an open Apple Watch or Apple TV simulator would have been handed back as the place to install an iOS build.craft ios runpicks its device the same way.- The summary job treated
cancelledas a failure, so pushing twice in quick succession would have put a red X on the cancelled run. scripts/mobile-e2e/protocol.test.tsis run by a job now. It was not: everybun testin this repo is scoped to a package directory or one explicit file, so the ratchet guarding the case list was itself unenforced.
Then the suite proved its own worth: Android was running no Zig at all
Nothing copied libcraft.so into a generated app. CraftNative calls
System.loadLibrary("craft") inside a try/catch and sets isAvailable = false
on the UnsatisfiedLinkError, so every @JavascriptInterface method fell
through to Kotlin — by design, so an app with no runtime still works, which is
exactly why nobody noticed that every generated app was in that state. All 38
bridge_android_*.zig modules were built by CI and loaded by nothing.
init({ runtimeDir }) installs it now, into
app/src/main/jniLibs/<abi>/libcraft.so — AGP's default jniLibs.srcDirs, so
no Gradle template has to learn anything, and zig build build-android-all
already emits that exact shape. CRAFT_ANDROID_RUNTIME mirrors the iOS
builder's knob, null included.
The other half is being able to see it. Zig's std.log went to stderr, which
ART discards, so three states were indistinguishable: never shipped, loaded
with a bad descriptor, or loaded and bound and the action declined. All three
end with the shim answering. android_log.zig routes std.log to logcat under
the CraftNative tag — through android.util.Log, not liblog, because this
library deliberately links no libc and so can neither call liblog nor dlopen
it, while JNI calls are the one thing it can already do.
JNI_OnLoad now reports success too, not just failure —
"registered 103 natives on com/craft/runtime/CraftNative" — because otherwise
"bound" and "never shipped" are both silent, and it is the only line that
proves the log channel works rather than merely being quiet.
So the Android suite runs twice as well, and the runtime leg asserts two
independent things, since either alone would pass with the runtime absent: the
APK carries lib/x86_64/libcraft.so (every emulator is x86_64), and
JNI_OnLoad reported binding. The shim leg asserts the negatives of both.
The thread-attach dance moved into jni_runtime.zig on the way, because the
reply channel and the log channel both need it — a second copy of something
that subtle is how the two drift apart, which is the same failure that had a
broken emulator test living in two places.
What this found and did not fix
Filed rather than fixed: #202
(11 iOS dispatch cases drop the callback on a bad argument),
#203 (share() is
fire-and-forget, so four of its native outcomes cannot reach the page), and
#204 (the shipped
libcraft.so carries debug info — ~5.5 MB per ABI, unstripped because the
generated project configures no NDK for AGP's strip task to use).
Deliberately not included: a true cancellation path. The realistic one —
grant CAMERA, openCamera(), then KEYCODE_BACK for RESULT_CANCELED —
depends on a camera app being present and on keyevent timing, and a flaky gate
is worth less than a narrower dependable one. Both platforms exercise a
rejection instead.
Verification
bun run test (Zig suite), bun run typecheck, bun run verify:packages,
bun run verify:docs and Pickier all pass, as do 31 unit tests over the report
protocol, 28 over the iOS builder and 61 over the Android builder.
On devices, from this PR's CI:
ok ios-shim — 4/4 cases, no zig
ok ios-runtime — 4/4 cases, zig served clipboardRead, clipboardWrite, getCurrentPosition, getDeviceInfo, log
ok android-shim — 4/4 cases, no zig
ok android-runtime — 4/4 cases, zig served registered:103and in the runtime leg's logcat, the first time the Zig library has loaded on Android at all:
I CraftNative: craft: libcraft.so loaded
I CraftNative: craft: registered 103 natives on com/craft/runtime/CraftNative
W CraftNative: craft: getDeviceInfo fell through to the shim (JavaException)That last line is worth being plain about. The Android runtime leg proves the
library loads and binds — that is what it asserts — not that Zig serves
each action the way the iOS leg proves. getDeviceInfo throws a Java exception
on its first-ever run on a device and falls back to Kotlin, which the page
cannot see and the suite correctly still passes. Fixing it, and tightening the
Android leg to assert per-action like iOS, is the follow-up.