Closes #207.
What was wrong
The iOS page script posted these calls with no callbackId:
hapticvibratestartListeningandstopListeninggeolocation.watchPositiongeolocation.clearWatch- the v1
location.clearWatch
Swift's resolveCallback and rejectCallback both return early on a nil id, so native's answer was thrown away and the page got undefined. That included the answer that mattered most: CAPABILITY_DISABLED, when the app was built with the capability off.
Why a callbackId alone would have made it worse
Two of them only ever answered with a rejection. An enabled haptic or speech start did its work and sent nothing, on both runtimes:
- Swift.
case "haptic"andcase "startListening"calledrejectCallbackin theirelseand nothing in theirif.case "stopListening"never touched the callback at all. - Zig. A Zig-linked app serves these first.
bridge_mobile_haptics.zigandbridge_mobile_speech.zigdeclared.reply = .noneand never calledsendResultToJS.
Giving the page an id without changing native would have turned "the page hears nothing" into "the page waits for ever on every call that works". So both native halves change too:
Haptics.
hapticresolvestrueonce UIKit has taken the call, on both runtimes.vibratealready did.Speech.
startListeningandstopListeningresolvetrueonce the request is taken. Zig sends that fromhandleMessage, while the dispatch frame still holds the page's request id. Settling on the outcome instead would hang in more ways than it fixes:- an authorization answer arrives after the request context is popped, so its reply carries no id and the page drops it;
- a second start while authorization is in flight has no outcome of its own;
- Swift re-entry can stop before answering.
What happens after
truestill arrives ascraftSpeech*events, as before.craft.d.tssays so, so nobody "fixes" it later by waiting for them.Location.
watchPositionandclearWatchalready answered on both sides; only the page lacked the id.
One choice, and why: craft.haptics.* does not reject when haptics are off
enableHaptics is off by default. With the raw call now reporting the refusal, craft.haptics.impact() and its siblings would have started rejecting in every app that never turned haptics on. The review found real flows that would stop, including every example in docs/api/mobile/haptics.md that awaits a haptic before doing the work it decorates (await haptics.impact('light'); switchToTab(tabId)), and examples/notes-app.
I split the contract along the line that already exists:
- The raw calls tell the truth.
craft.haptic()andcraft.vibrate()resolvetrueor reject withCAPABILITY_DISABLED. This is what #207 asked for: code that needs to know can find out. - The feedback helpers treat a capability the app left off as nothing to play. That covers
craft.haptics.*, and with it the SDK'shaptics, plususeHapticin React and Vue anduse:hapticin Svelte. They resolve, the same as on Android, whose wrappers always resolve, and on the web fallback, which is a no-op without a vibration motor. Any other failure, such asNATIVE_CALL_FAILED, still rejects. The refusal also stays visible incraft.capabilities.hapticsand in the bridge's error history.
The issue described the old wrappers as reporting success for a refused call. That is still true for this one code, deliberately, and only for the helpers whose documented use is decoration.
Tests that fail without the fix
ios_conformance_test.zig now checks both directions, and the unanswered_posts exception list is gone:
| check | fails when |
|---|---|
| every call the spec answers carries a callbackId (existing, no exceptions now) | the page posts one of these without an id |
| every call the page waits on has a case that can resolve it (new) | a Swift case only rejects, as haptic and startListening did |
no Zig module declares a reply the page waits on as .none (new) | the Zig manifest says nothing is sent |
Each check was tried against the matching mutation: the Swift haptic resolve removed, the Zig startListening manifest set back to .none, and the page's vibrate id removed. Each fails with its own message.
packages/ios/src/page-script.test.ts (new) runs the real injected script from CraftApp.swift under Bun, with a fake native side. It checks that each call posts an id and settles with what native sends, and that the raw call rejects on a refusal. It also covers the haptics feedback split, which the E2E suite cannot reach because each run has one config. Against main's template, 12 of its 16 tests fail. The other four, the "settles with nothing played" cases, fail against a version whose wrappers pass the refusal on.
The iOS E2E suite gains six cases. The probe app now has haptics on and speech recognition off:
| case | asserts |
|---|---|
geolocation.watchPosition.resolvesTrue | true, after currentPosition has settled authorization |
geolocation.clearWatch.resolvesTrue | true |
haptic.resolvesTrue | true with haptics on, the path that answered nothing |
vibrate.resolvesTrue | true |
speech.disabled.rejects | CAPABILITY_DISABLED. On the runtime leg it must also come from Zig's own gate, so startListening is added to ZIG_REFUSED_ACTIONS |
speech.stopListening.resolvesTrue | true. Ungated, so it runs with speech off and needs no microphone |
The runtime leg also requires Zig to dispatch every one of these actions without handing any back to Swift.
Run locally on an iOS 26.5 simulator:
# this branch
ok ios-shim — 12/12 cases, no zig
ok ios-runtime — 12/12 cases, zig served checkPermission, clearWatch, clipboardRead, clipboardWrite, getCurrentPosition, getDeviceInfo, haptic, log, requestPermission, share, startListening, stopListening, vibrate, watchPosition
# main's page script (no ids), run before the stopListening case was added
FAIL ios-shim — 6/11 cases, no zig
case geolocation.watchPosition.resolvesTrue failed: watchPosition resolved with undefined
case geolocation.clearWatch.resolvesTrue failed: clearWatch resolved with undefined
case haptic.resolvesTrue failed: haptic resolved with undefined
case vibrate.resolvesTrue failed: vibrate resolved with undefined
case speech.disabled.rejects failed: startListening resolved with undefined while speech recognition is disabled
FAIL ios-runtime — 6/11 cases, (same five)
# page fixed, the success answers removed from Swift and Zig (haptic, stopListening)
FAIL ios-shim — 10/12 cases, no zig
case haptic.resolvesTrue failed: haptic never settled with haptics enabled
case speech.stopListening.resolvesTrue failed: stopListening never settled
FAIL ios-runtime — 10/12 cases, (same two)Not done here, filed
- #219: Android's
haptic,vibrate,startListeningandstopListeningstill return nothing.craft.d.tstypes themPromise<boolean> | voiduntil then. - #220:
craft.watchPosition/craft.clearWatchmean different things on each platform and disagree with their type, and the v1location.watchPositioncan leave a dead watch after a refusal. It is a behaviour change for iOS pages that callcraft.clearWatch()with no id, so it is kept out of this PR. - #221:
openURL,setBadgeandsetFlashlightnever settle on a malformed argument on the Swift path.
Verification
bun run test, bun run typecheck, bun run verify:packages, Pickier, the ios/typescript/android package tests, 50 protocol tests, and both iOS E2E legs locally, with and without each half of the fix.