ReviewOS

also looking at this

craft-native/craft

feat(ios): the speech pair closes the last deferral — ratchet 28 -> 26

#101
Merged glennmichael123 wants to merge feat/ios-speech-pair into main
10 files +1287 -573
packages/ios/fixtures/zig-slice/Info.plistmodified+2-0
Changes to packages/ios/fixtures/zig-slice/Info.plist
@@ -14,6 +14,8 @@
1414 <array><string>UIInterfaceOrientationPortrait</string></array>
1515 <key>NSBluetoothAlwaysUsageDescription</key>
1616 <string>The slice fixture asks for Bluetooth so startBluetoothScan reaches CBCentralManager rather than stopping at the plist guard.</string>
17 <key>NSSpeechRecognitionUsageDescription</key>
18 <string>The slice fixture asks for speech recognition so startListening reaches SFSpeechRecognizer rather than stopping at the plist guard, which is the exact proxy this module reads for enableSpeechRecognition.</string>
1719 <key>NSMicrophoneUsageDescription</key>
1820 <string>The slice fixture asks for the microphone so startAudioRecording reaches AVAudioRecorder rather than stopping at the plist guard.</string>
1921</dict>
packages/ios/fixtures/zig-slice/build-and-run.shmodified+60-2
Changes to packages/ios/fixtures/zig-slice/build-and-run.sh
@@ -65,6 +65,7 @@ swiftc -target "$TRIPLE" -sdk "$SDK" \
6565 -framework UIKit -framework WebKit -framework Foundation -framework Security \
6666 -framework Vision -framework LocalAuthentication -framework PDFKit \
6767 -framework WatchConnectivity -framework CoreBluetooth -framework AVFoundation -framework HealthKit \
68 -framework Speech \
6869 -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __entitlements -Xlinker "$OUT/sim.entitlements" \
6970 -o "$APP/CraftSlice"
7071
@@ -99,6 +100,21 @@ xcrun simctl install "$UDID" "$APP"
99100# permission change.
100101xcrun simctl privacy "$UDID" grant microphone "$BUNDLE_ID" 2>/dev/null || true
101102
103# And speech recognition, which needs the raw TCC identifier.
104#
105# `simctl privacy` documents nine services and none of them is speech, but the
106# service argument is not validated against that list — it is passed through,
107# so the kTCCService* name Apple uses internally is accepted where the friendly
108# name is not. Verified rather than assumed: with only `microphone` granted,
109# startListening reached SFSpeechRecognizer and stopped dead at a
110# "would like to access Speech Recognition" alert that no script can answer,
111# and the fixture timed out with no event at all. With this line, the same
112# build emits craftSpeechStart.
113#
114# If a future Xcode starts validating the argument this fails silently and the
115# i=68 assertion below fails loudly, which is the right way round.
116xcrun simctl privacy "$UDID" grant kTCCServiceSpeechRecognition "$BUNDLE_ID" 2>/dev/null || true
117
102118LOG="$OUT/console.log"
103119xcrun simctl launch --console-pty "$UDID" "$BUNDLE_ID" > "$LOG" 2>&1 &
104120LAUNCH_PID=$!
@@ -110,7 +126,8 @@ for _ in $(seq 1 60); do
110126 && grep -q 'i=32' "$LOG" 2>/dev/null && grep -q 'i=22' "$LOG" 2>/dev/null \
111127 && grep -q 'i=34' "$LOG" 2>/dev/null && grep -q 'i=38' "$LOG" 2>/dev/null \
112128 && grep -q 'i=46' "$LOG" 2>/dev/null && grep -q 'i=44' "$LOG" 2>/dev/null \
113 && grep -q 'i=24' "$LOG" 2>/dev/null && grep -q 'i=20' "$LOG" 2>/dev/null; then sleep 1; break; fi
129 && grep -q 'i=24' "$LOG" 2>/dev/null && grep -q 'i=20' "$LOG" 2>/dev/null \
130 && grep -q 'i=72' "$LOG" 2>/dev/null; then sleep 1; break; fi
114131 sleep 1
115132done
116133kill "$LAUNCH_PID" 2>/dev/null || true
@@ -318,12 +335,53 @@ C52="$(count 52)"
318335[ "$C52" -ge 1 ] || { echo "FAIL: sendToWatch did not refuse through the error path"; exit 1; }
319336echo "ok: watch refusal delivered through the async error path (i=52 seen ${C52}x)"
320337
338# The speech pair. Neither action replies, so every assertion here is on the
339# event channel: the ids below are marker dispatches the page posts from inside
340# a `craftSpeech*` listener, and reaching one means the event arrived with a
341# detail the page could index into.
342C68="$(count 68)"; C74="$(count 74)"
343if [ "$C74" -ge 1 ]; then
344 # Not a pass, but the most useful thing to print before failing: the page
345 # only posts i=74 when the detail carried one of Swift's four messages, so
346 # the refusal names its own cause and the module clearly ran.
347 echo "note: startListening refused rather than started (i=74 seen ${C74}x)"
348fi
349# The strongest assertion this pair has. craftSpeechStart is emitted on the far
350# side of the whole chain: the plist guard, requestAuthorization:'s block on an
351# arbitrary queue, the hop to the main queue, the audio session, a live
352# SFSpeechRecognizer, a non-zero input format, the recognition task, the tap
353# installed on the audio I/O thread, and AVAudioEngine actually starting. Any
354# link short of the last one emits craftSpeechError instead.
355[ "$C68" -ge 1 ] || {
356 echo "FAIL: startListening never started the engine"
357 echo " If i=74 is absent too, the authorization block never fired —"
358 echo " check that the kTCCServiceSpeechRecognition grant above still"
359 echo " takes, because an unanswerable system alert looks exactly"
360 echo " like this."
361 exit 1
362}
363echo "ok: the audio engine started and craftSpeechStart reached the page (i=68 seen ${C68}x)"
364
365C72="$(count 72)"
366# The load-bearing one, and it holds whichever way the start went. Swift's
367# stopSpeechRecognition() has no early return, so a stop that stops nothing
368# still emits craftSpeechEnd — which is what a page's listening indicator
369# binds to. This is also the only assertion that proves the teardown ran
370# without taking the process down: the tap handoff, the bounded drain and the
371# release all happen on the way to this event.
372[ "$C72" -ge 1 ] || { echo "FAIL: stopListening did not emit craftSpeechEnd"; exit 1; }
373echo "ok: stopListening emitted craftSpeechEnd through the teardown (i=72 seen ${C72}x)"
374
321375C54="$(count 54)"
322376# The exact trap this action was held back for: ios_async's pooled block would
323377# have replied the STRING "denied", and "denied" is truthy — so a page's
324378# `if (await openSettings())` would take the success branch on failure. The
325379# page asserts `typeof payload === 'boolean'`, which only the module's own
326# block can satisfy. Posted last, because it navigates to Settings.
380# block can satisfy. Posted last, because it navigates to Settings — and
381# "last" means from inside the craftSpeechEnd listener, so this assertion
382# depends on the speech pair above. That is why the speech block sits ahead of
383# it: a broken teardown would otherwise surface here, blaming openSettings for
384# an event that never arrived.
327385[ "$C54" -ge 1 ] || { echo "FAIL: openSettings did not reply with a boolean"; exit 1; }
328386echo "ok: openSettings replied a real boolean, not \"granted\"/\"denied\" (i=54 seen ${C54}x)"
329387
packages/ios/fixtures/zig-slice/craft.config.jsonmodified+1-1
Changes to packages/ios/fixtures/zig-slice/craft.config.json
@@ -3,7 +3,7 @@
33 "bundleId": "app.craft.slice",
44 "darkMode": false,
55 "backgroundColor": "#0b1712",
6 "enableSpeechRecognition": false,
6 "enableSpeechRecognition": true,
77 "enableHaptics": false,
88 "enableShare": false,
99 "enableCamera": true,
packages/ios/fixtures/zig-slice/page.mmodified+46-3
Changes to packages/ios/fixtures/zig-slice/page.m
@@ -33,6 +33,39 @@ const char craft_slice_page[] =
3333 "window.addEventListener('craftMotionUpdate', function () {\n"
3434 " if (!sentEvent) { sentEvent = true; bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:16}); }\n"
3535 "});\n"
36 "// Speech answers on the event channel and nowhere else: neither action\n"
37 "// replies, so these three listeners are the whole page-visible surface.\n"
38 "// A start settles as EITHER craftSpeechStart or craftSpeechError, and both\n"
39 "// count: the first means the engine is running, the second means the\n"
40 "// authorization block fired, hopped to the main queue and named a reason.\n"
41 "// What neither can be produced by is a module that was never reached.\n"
42 "window.addEventListener('craftSpeechStart', function (e) {\n"
43 " if (sentSpeechStart || sentSpeechErr) { return; }\n"
44 " // The detail is an object, not a string and not null — a page indexes\n"
45 " // into it, so `{}` and `\"\"` are not interchangeable.\n"
46 " if (!e.detail || typeof e.detail !== 'object') { return; }\n"
47 " sentSpeechStart = true;\n"
48 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:68});\n"
49 " bridge.postMessage({t:'mobile', a:'stopListening', i:109});\n"
50 "});\n"
51 "window.addEventListener('craftSpeechError', function (e) {\n"
52 " if (sentSpeechStart || sentSpeechErr) { return; }\n"
53 " // One of the four messages Swift sends, carried under Swift's key.\n"
54 " var known = ['Not authorized', 'Audio session failed',\n"
55 " 'Speech recognizer unavailable', 'Audio engine failed'];\n"
56 " if (!e.detail || known.indexOf(e.detail.error) === -1) { return; }\n"
57 " sentSpeechErr = true;\n"
58 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:74});\n"
59 " bridge.postMessage({t:'mobile', a:'stopListening', i:109});\n"
60 "});\n"
61 "window.addEventListener('craftSpeechEnd', function (e) {\n"
62 " if (sentSpeechEnd || !e.detail || typeof e.detail !== 'object') { return; }\n"
63 " sentSpeechEnd = true;\n"
64 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:72});\n"
65 " // Genuinely last: this backgrounds the app, and every chain that needs\n"
66 " // a foreground process has now closed.\n"
67 " bridge.postMessage({t:'mobile', a:'openSettings', i:102});\n"
68 "});\n"
3669 "var sentEvent = false;\n"
3770 "var sentRoundTrip = false, sentHandOff = false, sentTierZero = false, sentSecure = false, sentDb = false, sentNotif = false;\n"
3871 "var bridge = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.craft;\n"
@@ -53,6 +86,7 @@ const char craft_slice_page[] =
5386 "var sentDownload = false, sentWatch = false, sentSettings = false;\n"
5487 "var sentBtStart = false, sentBtStop = false;\n"
5588 "var sentRecStart = false, sentRecStop = false, sentHealth = false;\n"
89 "var sentSpeechStart = false, sentSpeechErr = false, sentSpeechEnd = false;\n"
5690 "window.__craftBridgeError = function (ctx) {\n"
5791 " // The message crossed Zig's escaping with a backslash, quote, and\n"
5892 " // newline intact; anything mangled and this equality fails, i=7 never\n"
@@ -215,6 +249,12 @@ const char craft_slice_page[] =
215249 " if (raw.slice(4, 8) === 'ftyp' && raw.length > 500) {\n"
216250 " sentRecStop = true;\n"
217251 " bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:64});\n"
252 " // Only now. Both features drive the one shared AVAudioSession, and\n"
253 " // speech sets category .record while the recorder wants\n"
254 " // .playAndRecord — starting speech mid-recording would reconfigure\n"
255 " // the session under a live encoder and make i=64 flaky for a reason\n"
256 " // that has nothing to do with either module.\n"
257 " bridge.postMessage({t:'mobile', a:'startListening', i:108});\n"
218258 " }\n"
219259 " }\n"
220260 " if (!sentBtStop && action === 'stopBluetoothScan' && payload === true) {\n"
@@ -381,9 +421,12 @@ const char craft_slice_page[] =
381421 " bridge.postMessage({t:'mobile', a:'getAppState', i:88});\n"
382422 " // And one the shim answers by rejecting, to prove the error route.\n"
383423 " bridge.postMessage({t:'mobile', a:'hostOnlyFail', i:6});\n"
384 " // Last, deliberately: this navigates to the Settings app, so anything\n"
385 " // posted after it would race the webview being backgrounded.\n"
386 " bridge.postMessage({t:'mobile', a:'openSettings', i:102});\n"
424 " // openSettings is NOT posted here. It navigates to the Settings app,\n"
425 " // and everything posted afterwards then runs in a backgrounded\n"
426 " // process — which is fine for a call that answers immediately and\n"
427 " // fatal for one that needs the system to present a prompt. It goes\n"
428 " // out from the craftSpeechEnd listener instead, once the last chain\n"
429 " // that needs a foreground app has finished.\n"
387430 "} else {\n"
388431 " document.getElementById('s').textContent = 'NO BRIDGE';\n"
389432 "}\n"
packages/zig/src/bridge_mobile_haptics.zigmodified+8-1
Changes to packages/zig/src/bridge_mobile_haptics.zig
@@ -259,7 +259,14 @@ fn schedulePattern(items: []const std.json.Value) !usize {
259259/// "returned without error" means "UIKit accepted the call", never "the device
260260/// buzzed", which is the other half of why `haptic` has no success reply to
261261/// fabricate.
262fn triggerHapticChecked(haptic_type: HapticType) !void {
262///
263/// `pub` for one caller outside this file. `bridge_mobile_speech.zig` fires a
264/// light impact on start and on stop because `CraftApp.swift:2544` and `:2558`
265/// do — directly, bypassing the `config.enableHaptics` check the dispatcher
266/// applies to `case "haptic"` at `:537`. That bypass is the spec's, not a
267/// choice this module makes, and routing the speech path through the gated
268/// entry point would be the divergence rather than the fix.
269pub fn triggerHapticChecked(haptic_type: HapticType) !void {
263270 if (!builtin.target.os.tag.isDarwin()) return error.UnsupportedPlatform;
264271
265272 switch (haptic_type) {
packages/zig/src/bridge_mobile_speech.zigmodified+1160-558
Changes to packages/zig/src/bridge_mobile_speech.zig
@@ -1,269 +1,201 @@
1//! The Speech + AVFoundation actions of the `mobile` namespace —
2//! `startListening` and `stopListening` — **researched in full and
3//! deliberately not served**. Both fall through to the working Swift shim.
1//! The `mobile` namespace's speech pair: `startListening` and `stopListening`.
42//!
5//! This is the outcome rule 6 exists for, and it is the cheapest one in the
6//! migration: the shim answers both actions exactly as the app answers them
7//! today, and — unlike `scheduleNotification`, `registerBackgroundTask` and
8//! `sendToWatch` — **there is no promise to strand**, because neither action
9//! has a `callbackId` and neither ever replies. Falling through costs the page
10//! nothing at all. What is written below is the part a host can honestly
11//! finish and pin: the event vocabulary, the four error messages, and the two
12//! detail shapers. What is missing is named, not glossed.
3//! Neither action replies. Both are raw `postMessage` calls with no
4//! `callbackId` (`CraftApp.swift:1562-1568`), so nothing on the page is
5//! awaiting an answer and every outcome — success, refusal, transcript, end —
6//! travels as a `craftSpeech*` event. That is why both are declared
7//! `.reply = .none` below, and why no path in this file returns an error to
8//! `ios_dispatch`: an error there would call `sendErrorToJS` under a request
9//! id no page is holding, which is console noise standing in for the event a
10//! page is actually listening for.
1311//!
14//! ## The contract, read rather than assumed
12//! ## What this round changed
1513//!
16//! **Payload: none, on both actions.** The injected JS is the whole surface
17//! (`CraftApp.swift:1562-1568`):
14//! An earlier round researched both actions in full and deliberately left them
15//! with the Swift shim, listing four blockers and five preconditions. All five
16//! preconditions are met here, and the four blockers are answered:
1817//!
19//! ```js
20//! startListening: function() {
21//! window.webkit.messageHandlers.craft.postMessage({action: 'startListening'});
22//! },
23//! stopListening: function() {
24//! window.webkit.messageHandlers.craft.postMessage({action: 'stopListening'});
25//! },
26//! ```
18//! 1. `ios_events.Event` can spell `craftSpeechStart` now — the arm was added
19//! alongside this module, and the vocabulary test covers it.
20//! 2. **The realtime tap.** `-[AVAudioNode installTapOnBus:…]` runs its block
21//! on the audio I/O thread, which no other module in this tree touches.
22//! Swift gets a free ARC retain for the duration of
23//! `recognitionRequest?.append(buffer)`; Zig gets nothing, so releasing
24//! the request while a tap call is in flight is a use-after-free on a
25//! realtime thread. The answer is below under "The audio thread", and it
26//! is an atomic handoff with a bounded drain — never a mutex, which on an
27//! audio thread is a priority inversion.
28//! 3. **The zero-format abort.** With no input route
29//! `-outputFormatForBus:0` yields a format whose `sampleRate` and
30//! `channelCount` are 0, and `installTapOnBus:` then raises an
31//! `NSException` the process cannot catch. Swift has the identical crash
32//! at `:2535-2538`. `formatCarriesInput` pre-checks and refuses.
33//! 4. **Testability.** The fixture drives both actions on a simulator and
34//! asserts the events, so the audio path is exercised rather than only the
35//! shapers.
2736//!
28//! No fields, no `callbackId`, no return value. `ios_dispatch.payloadOf` would
29//! hand a handler `"{}"`. There is no second page surface: the nested mobile
30//! contract block (`:2297-2427`) has no speech methods, and `craft-bridge.js`
31//! has no `mobile` namespace at all — its `window.craft.speech` is desktop TTS
32//! over `AVSpeechSynthesizer`, namespace `t = "speech"`, unrelated. So is
33//! `bridge_speech_recognition.zig`, which is the desktop `speechRecognition`
34//! namespace with actions `isAvailable`/`start`/`stop`; it must not be
35//! confused with this one.
37//! ## The event contract, which is the whole page-visible surface
3638//!
37//! **Reply: none, on any path.** The dispatcher (`CraftApp.swift:533-536`) is
38//!
39//! ```swift
40//! case "startListening":
41//! if config.enableSpeechRecognition { startSpeechRecognition() }
42//! case "stopListening":
43//! stopSpeechRecognition()
44//! ```
45//!
46//! and neither `startSpeechRecognition` (`:2486-2494`), `beginRecording`
47//! (`:2496-2548`) nor `stopSpeechRecognition` (`:2550-2559`) touches
48//! `resolveCallback`, `rejectCallback` or `resolveCallbackJSON` on any line.
49//! `craft.d.ts:188` and `:193` both declare `(): void`, and
50//! `test-bridges.html:785-795` awaits nothing. If this module ever serves
51//! these, the declaration is `.reply = .none` — the same reading
52//! `bridge_mobile_haptics.zig` made for `haptic`, and for the same reason.
53//!
54//! A denial does not reply either; it **emits**. Nor does the flag-off case
55//! emit anything at all: `if config.enableSpeechRecognition { … }` has no
56//! `else`, and `stopListening` is ungated and always runs.
57//!
58//! **Everything is events.** Four names, and the detail of each:
59//!
60//! | event | detail | when |
39//! | event | when Swift sends it | when this module does |
6140//! |---|---|---|
62//! | `craftSpeechError` | `{"error":"Not authorized"}` | auth status is not `.authorized` (`:2489`) |
63//! | `craftSpeechError` | `{"error":"Audio session failed"}` | `setCategory`/`setActive` threw (`:2507`) |
64//! | `craftSpeechError` | `{"error":"Speech recognizer unavailable"}` | request nil, recognizer nil, or not `isAvailable` (`:2515`) |
65//! | `craftSpeechError` | `{"error":"Audio engine failed"}` | `audioEngine.start()` threw (`:2546`) |
66//! | `craftSpeechStart` | `{}` | after `audioEngine.start()` succeeded (`:2543`) |
67//! | `craftSpeechResult` | `{"transcript": String, "isFinal": Bool}` | every partial and every final result (`:2525-2528`) |
68//! | `craftSpeechEnd` | `{}` | every `stopSpeechRecognition()` (`:2557`) |
69//!
70//! The result detail is **exactly two keys**. No `confidence`, no
71//! `alternatives`, no `segments`. Three independent consumers agree —
72//! `test-bridges.html:384-387` reads `e.detail.transcript` and
73//! `e.detail.isFinal`, `packages/ios/README.md:167-176` documents the same
74//! pair, and Android emits the same four names with the same keys
75//! (`CraftBridge.kt.template:1344-1383`). Unlike motion, there is no
76//! doc/implementation mismatch here to report.
77//!
78//! `sendToWeb` (`:4835-4846`) builds
79//! `window.dispatchEvent(new CustomEvent('<name>', {detail: <json>}));` — the
80//! same form `ios_events.formatEvent` builds, so the detail bytes below are
81//! the bytes that reach `e.detail` on either path.
82//!
83//! ## Why nothing is served
84//!
85//! Four blockers. Only the last three are load-bearing; the first is one line
86//! of data that would unblock nothing on its own.
41//! | `craftSpeechStart` | the engine started | the engine started |
42//! | `craftSpeechResult` | every partial and final transcript | the same |
43//! | `craftSpeechError` | four named failures | the same four |
44//! | `craftSpeechEnd` | every `stopSpeechRecognition()` | every `stopListening`, and every internal stop |
8745//!
88//! **1. `craftSpeechStart` cannot be spelled.** `ios_events.Event` carries
89//! `.speech_result`, `.speech_error` and `.speech_end`, and no
90//! `.speech_start`. Serving `startListening` without it would drop the one
91//! event that drives the page's state — `test-bridges.html:380-383` paints
92//! "Listening..." from it and from nothing else — so a working session would
93//! look to the page exactly like a session that never started. Adding the
94//! enum member and its `switch` arm is data rather than mechanism, and the
95//! existing test `"every event name is one an iOS page actually subscribes
96//! to"` already accepts the shape (`startsWith "craft"`, no colon). It is not
97//! left undone because a second file is off limits — landing this module
98//! already edits `ios_dispatch.zig`, `build.zig` and
99//! `test/ios_conformance_test.zig`. It is left undone because adding it alone
100//! serves nothing: blocker 2 stops `startListening` by itself, and an `Event`
101//! member no code emits is vocabulary for an event that never fires. So it is
102//! precondition 1 below rather than a reason of its own — and a test trips
103//! when it lands.
46//! `craftSpeechEnd` fires on a `stopListening` that stops nothing. Swift's
47//! `stopSpeechRecognition()` has no early return — it is called from
48//! `case "stopListening"` unconditionally, with no gate and no guard — so a
49//! page that stops twice gets two ends, and one that never started still gets
50//! one. That is bug-compatibility on purpose: the event is what a page's UI
51//! binds its "listening" indicator to, and an end that does not arrive leaves
52//! the indicator stuck on.
10453//!
105//! **2. The realtime tap has no precedent in this tree.**
106//! `-[AVAudioNode installTapOnBus:bufferSize:format:block:]` runs its block on
107//! the audio I/O thread, which is not a queue any existing module touches.
108//! `bridge_mobile_motion.zig`'s handler is an `NSOperationQueue.mainQueue`
109//! callback and is **not** a precedent for it. Swift's
110//! `self?.recognitionRequest?.append(buffer)` gets a free ARC retain for the
111//! duration of the call; Zig has none, so `stopListening` releasing the
112//! request while a tap call is in flight is a use-after-free on a realtime
113//! thread. It is solvable — an atomic pointer, never a mutex (priority
114//! inversion), plus stop-engine, remove-tap, clear-pointer, never-release
115//! ordering — but solving it is building a new foundation, and this round was
116//! asked to apply proven ones and to say so instead.
54//! ## Deliberate divergences, each with its reason
11755//!
118//! **3. The guard the tap needs is asserted, not read.** With no active input
119//! route, `-outputFormatForBus:0` yields a format whose `sampleRate` and
120//! `channelCount` are 0, and `installTapOnBus:` then raises an
121//! `NSException` — which Zig cannot catch, so the process aborts. Swift has
122//! the identical crash at `:2535-2538`. The honest port pre-checks the format
123//! and refuses with `craftSpeechError`; but that behaviour comes from API
124//! knowledge, nothing in this repo documents it, and there is no simulator
125//! here to verify it. Shipping an unverified guard against an unreproducible
126//! abort is the one place rule 5 and rule 1 meet.
127//!
128//! **4. Nothing here can test the audio path.** Every host test that could be
129//! written for a served version would exercise selector interning and block
130//! flags, and nothing of the recognizer, the session, the engine or the tap.
131//! That is a served action whose entire risk surface is untested.
132//!
133//! The gate is *not* among the blockers, because it is solved:
134//! `config.enableSpeechRecognition` defaults to `false`
135//! (`CraftApp.swift:189`, `packages/ios/src/index.ts:117`) and reaches Zig
136//! through nothing, but `packages/ios/src/index.ts:183` writes
56//! **The plist guard replaces the nil-recognizer guard.** Swift builds its
57//! `SFSpeechRecognizer` only when `config.enableSpeechRecognition` is on
58//! (`:439-440`), and `beginRecording`'s `guard let speechRecognizer` then
59//! emits `"Speech recognizer unavailable"` when it is off. Zig cannot see the
60//! ivar, but `packages/ios/src/index.ts:183` writes
13761//! `NSSpeechRecognitionUsageDescription` from that flag **and nothing else,
13862//! with no `||`** — the exact-proxy shape `bridge_mobile_motion.zig` already
139//! reads. (`NSMicrophoneUsageDescription` at `:184` is
140//! `enableSpeechRecognition || enableAudioRecording` and is **not** an exact
141//! proxy; it must not be used as the gate.) Here the proxy is mandatory
142//! rather than an improvement: without those keys in Info.plist iOS
143//! *terminates the process* on the authorization request or on mic access.
63//! reads. So a missing key means the flag was off, and the message emitted is
64//! the one Swift would have emitted for the same configuration.
65//!
66//! The check has to run *before* `requestAuthorization:`, where Swift's runs
67//! after, because asking for speech authorization without the key does not
68//! fail — iOS terminates the process. Order diverges; the page-visible
69//! message does not.
14470//!
145//! ## Why falling through, and not `.unavailable`
71//! (`NSMicrophoneUsageDescription` at `index.ts:184` is
72//! `enableSpeechRecognition || enableAudioRecording` and is therefore **not**
73//! an exact proxy, so it is not the gate. It is implied by the speech key
74//! rather than checked: speech on always writes both.)
14675//!
147//! `.unavailable` makes `ios_dispatch` route the action into this module and
148//! refuse it. For an action with a pending promise that is a nameable failure
149//! and an improvement on silence. Here there is no pending promise on any
150//! page surface, so the refusal would surface only as a console log — while
151//! taking away a shim path that works completely. That is strictly worse, and
152//! it is why `A` below is empty rather than carrying two `.unavailable` rows.
76//! **A zero input format is refused as `"Audio engine failed"`.** There is no
77//! Swift string for it because Swift does not survive the case, and inventing
78//! a fifth message would put bytes on a page's screen that no other arm of the
79//! bridge can produce. `startAndReturnError:` failing for the same underlying
80//! reason — no usable input — is what Swift reports as `"Audio engine
81//! failed"`, so the existing message is the honest one.
15382//!
154//! Serving only `stopListening` is worse still, and is ruled out for the
155//! reason `bridge_mobile_motion.zig` gives: a session started through the shim
156//! would meet a Zig stop, which would stop Zig's own null engine and emit
157//! `craftSpeechEnd` while Swift's recognizer kept pushing `craftSpeechResult`.
158//! Fabricated success at the event layer. Either both or neither; neither.
83//! **A second `startListening` while running stops first.** Swift's
84//! `beginRecording` cancels the task and nothing else (`:2497-2500`), then
85//! installs a second tap on bus 0, which raises. Reproducing that is not
86//! required of a port; `bridge_mobile_motion.zig` made the same call. The
87//! internal stop emits **no** `craftSpeechEnd`, because Swift's restart emits
88//! none either — only a real stop does.
15989//!
160//! ## What is finished here
90//! **`stopListening` does not touch the engine when nothing is running.**
91//! Swift's stop reads `audioEngine.inputNode` unconditionally, and that
92//! property *creates* the input node on demand; on a device with no input
93//! route available it can raise rather than return. Since a stop that stopped
94//! nothing has nothing to remove, the engine is touched only when this module
95//! knows a tap is installed. The `craftSpeechEnd` still fires, so the page
96//! sees no difference.
16197//!
162//! The two shapers and the four messages — the half of the port that decides
163//! page-visible bytes and that a host can pin. This is the precedent
164//! `bridge_mobile_haptics.zig` set with `triggerHapticChecked`: the finished
165//! half of a blocked action lives next to the admission, tested, so that
166//! unblocking it is a small deliberate edit rather than a rewrite.
98//! **Trailing buffers are not dropped.** The handoff clears the published
99//! request only after `removeTapOnBus:` has returned, so every buffer the tap
100//! delivered before removal reaches the recognizer — the same audio Swift
101//! would have appended.
167102//!
168//! `shapeResultDetail` is worth more than it looks. A transcript is arbitrary
169//! spoken text, the detail is inlined into JavaScript source as a literal, and
170//! `ios_events.zig`'s own module comment records what happens when a value
171//! like that is concatenated with hand-rolled escaping: `bridge_iap.zig:362`
172//! "works until a value contains a quote". Swift is safe here only because
173//! `JSONSerialization` escapes for it. Any Zig port must, and this one does.
103//! **On a recognition error, no `craftSpeechError` is emitted.** Swift
104//! discards the `NSError` entirely (`:2530-2532` calls only
105//! `stopSpeechRecognition()`), so the page sees `craftSpeechEnd` and nothing
106//! else. Android *does* emit one (`CraftBridge.kt.template:1367`). The
107//! divergence is left unfixed here on purpose: there is no Swift string to
108//! copy, and inventing one prejudges a contract question that belongs to the
109//! JS surface, not to a port. `error_messages` below is still exactly four.
174110//!
175//! ## What a future round still owes
111//! **Both haptics fire ungated.** `:2544` and `:2558` call
112//! `triggerHaptic("light")` directly, bypassing the `config.enableHaptics`
113//! check the dispatcher applies to `case "haptic"` at `:537`. Swift ignores
114//! the flag on this path, so firing it here is faithful rather than a
115//! divergence — and it is why `bridge_mobile_haptics.triggerHapticChecked`
116//! became `pub` in the same change.
176117//!
177//! Five preconditions, all five, before either action moves into `A`:
118//! ## The audio thread
178119//!
179//! 1. `.speech_start => "craftSpeechStart"` in `ios_events.Event`.
180//! 2. Refuse unless Info.plist carries `NSSpeechRecognitionUsageDescription`
181//! (`bridge_mobile_motion.zig:infoPlistHas` is the shape).
182//! 3. Validate `-sampleRate` and `-channelCount` before `installTapOnBus:`,
183//! and emit `craftSpeechError` rather than installing on a zero format.
184//! 4. Never release the recognition request while a tap call may be in
185//! flight — atomically clear the pointer after `removeTapOnBus:`, and
186//! prefer leaking one request per session over racing the audio thread.
187//! 5. Hop to the main queue with `dispatch_async_f(&_dispatch_main_q, …)`
188//! before touching `AVAudioEngine` from the recognition task handler; it
189//! fires on an internal Speech queue. (`ios_events.emit` needs no hop —
190//! it hops itself.)
120//! One rule: **the tap block touches exactly two atomics and one selector,
121//! all of them resolved before the tap was installed.** No allocation, no
122//! `sel_registerName`, no logging, no lock.
191123//!
192//! And two decisions that are decisions, not facts, to be taken out loud:
124//! The handoff is a published pointer plus an in-flight counter:
193125//!
194//! - **The error-emission divergence.** On a recognition error Swift emits
195//! **no** `craftSpeechError` — `:2530-2532` only calls
196//! `stopSpeechRecognition()`, so the page gets `craftSpeechEnd` and the
197//! `NSError` is discarded entirely. Android *does* emit one
198//! (`CraftBridge.kt.template:1367`). Matching Swift makes a simulator
199//! failure look exactly like a stream that never fires; matching Android
200//! tells the truth but deviates from the emitter contract this migration
201//! preserves. `err_recognition_failed` below is deliberately absent: there
202//! is no Swift string to copy, so inventing one here would prejudge it.
203//! - **`beginRecording` never stops the engine or removes the old tap**
204//! (`:2497-2500` cancels only the task). A second `startListening` while
205//! running installs a second tap on bus 0, which raises on iOS.
206//! Reproducing that bug is not required; stopping first, as
207//! `bridge_mobile_motion.zig` chose to, is the defensible port.
126//! - `active_request` holds the `SFSpeechAudioBufferRecognitionRequest` as a
127//! `usize`. Zero means "nobody may append".
128//! - `tap_in_flight` is incremented by the tap before it loads the pointer
129//! and decremented after the append returns.
130//! - Teardown stops the engine, removes the tap, clears `active_request`,
131//! then spins until `tap_in_flight` reads zero before releasing.
208132//!
209//! Also worth carrying: both `:2544` and `:2558` fire `triggerHaptic("light")`
210//! directly, bypassing the `config.enableHaptics` gate the dispatcher applies
211//! to `case "haptic"` at `:537`. Swift itself ignores the flag on this path,
212//! so firing it from a served port would be faithful rather than a
213//! divergence — but `triggerHapticChecked` in `bridge_mobile_haptics.zig` is
214//! not `pub`, so reuse needs a visibility change there.
133//! Every one of those four operations is `.seq_cst`, and that is not
134//! decoration. The dangerous interleaving is Dekker's: the tap increments then
135//! loads the pointer while teardown clears the pointer then loads the counter.
136//! Acquire/release ordering permits both sides to see the other's *old* value,
137//! which is exactly "teardown believes no tap is running while a tap holds a
138//! pointer it is about to release". Sequential consistency is what rules that
139//! out, and one `xchg` per audio buffer — roughly 86 a second at 1024 frames
140//! and 44.1kHz — is not a cost worth reasoning around.
215141//!
216//! ## Nothing in this file touches Objective-C
142//! The spin is bounded (`drain_spins` × 100µs = 20ms, against a tap call
143//! measured in microseconds) and runs on the main thread only while a stop is
144//! in progress. If it ever fails to drain, the request is **leaked rather
145//! than released** and the fact is logged. That is the trade the earlier round
146//! asked for in as many words: prefer leaking one request per session over
147//! racing the audio thread.
217148//!
218//! No `objc_getClass`, no `sel_registerName`, no block, no delegate, no
219//! `ios_async` ticket — there is no reply to correlate and no framework call
220//! to make. So there is nothing to Darwin-gate: the module compiles and its
221//! tests run identically on Linux and macOS, which is the one upside of
222//! serving nothing.
149//! ## Queues, and the hop each one needs
150//!
151//! - `handleMessage` runs on the main thread — it is reached from
152//! `-userContentController:didReceiveScriptMessage:`.
153//! - `+[SFSpeechRecognizer requestAuthorization:]` answers on an arbitrary
154//! queue, so its block hops to the main queue before anything touches
155//! `AVAudioSession` or `AVAudioEngine`. `bridge_mobile_audiorec.zig` makes
156//! the identical hop for the identical reason.
157//! - The recognition result handler fires on an internal Speech queue. It
158//! may emit (`ios_events.emit` hops itself) but must hop before tearing
159//! the engine down, which is precondition 5.
160//! - The tap block runs on the audio I/O thread. See above.
223161
224162const std = ;
163const builtin = ;
225164const capabilities = ;
226165const bridge_error = ;
227166const ios_events = ;
167const objc_runtime = ;
168const haptics = ;
169
170const objc = objc_runtime.objc;
171const BridgeError = bridge_error.BridgeError;
172const Id = ?*anyopaque;
173const is_darwin = builtin.target.os.tag.isDarwin();
228174
229/// **Empty on purpose.** No action name here means `ios_dispatch` never routes
230/// `startListening` or `stopListening` into this module, so both reach the
231/// Swift shim that serves them correctly today. See the module comment for the
232/// four blockers and the five preconditions.
233///
234/// `declared_count` is not decoration. `test/ios_conformance_test.zig` scans
235/// this block line by line from `pub const A = struct {` to the first bare
236/// `};`, and `zig fmt` collapses an empty struct onto one line — after which
237/// the scan would never find its terminator and would collect every quoted
238/// string in this file as an action name. A non-string member keeps the block
239/// two-line-safe under `zig fmt --check`, which CI runs.
240175pub const A = struct {
241 /// How many actions this module claims. Zero, deliberately.
242 pub const declared_count: usize = 0;
176 pub const start_listening = "startListening";
177 pub const stop_listening = "stopListening";
243178};
244179
245/// Empty, and specifically not two `.unavailable` rows.
180/// `.none` for both, and the distinction matters.
246181///
247/// `.unavailable` is for an action that dispatches and refuses — honest when a
248/// promise would otherwise hang, wrong here. Neither of these actions has a
249/// promise on any page surface, so a refusal would show up only in the
250/// console while removing a shim path that works end to end. Declaring
251/// nothing is what lets the shim keep answering.
252pub const capability_actions = [_]capabilities.ActionDecl{};
253
254/// The two spec actions this module researched and left with the shim.
182/// `.result` would tell an app to await a reply that never comes: neither
183/// action calls `sendResultToJS` on any path, because neither has a page-side
184/// promise to settle. Everything observable leaves through `ios_events`.
255185///
256/// Kept as data so the tests can prove both fall through rather than asserting
257/// it in prose, and so a future round has the exact spellings in one place.
258/// These names deliberately live outside the `A` block: inside it, the
259/// conformance scan would read them as served and the ratchet would drop for
260/// actions nothing here answers.
261pub const unserved_actions = [_][]const u8{ "startListening", "stopListening" };
186/// `.live` for both. A refusal here is a specific condition — speech not
187/// configured, authorization declined, no input route — and never the normal
188/// answer, which is what `.unavailable` would claim.
189pub const capability_actions = [_]capabilities.ActionDecl{
190 .{ .name = A.start_listening, .reply = .none },
191 .{ .name = A.stop_listening, .reply = .none },
192};
262193
263194pub const SpeechBridge = struct {
264 /// Held but never used: `ios_dispatch` constructs every mobile bridge with
265 /// `init(allocator)` and the shape is the interface. Nothing here
266 /// allocates, because nothing here answers.
195 /// Held for the interface `ios_dispatch` builds every mobile bridge with.
196 /// Unused: nothing in a dispatch allocates, and the two paths that do —
197 /// shaping a transcript and shaping an error — run from callbacks where
198 /// this bridge is long gone, so they take the C allocator instead.
267199 allocator: std.mem.Allocator,
268200
269201 const Self = @This();
@@ -274,42 +206,39 @@ pub const SpeechBridge = struct {
274206
275207 pub fn deinit(_: *Self) void {}
276208
277 /// Always `UnknownAction` — "not mine, ask the next" — which is what makes
278 /// the fall-through work.
279 ///
280 /// The payload is deliberately never parsed. `ios_dispatch.route` treats
281 /// any error other than `UnknownAction` as final and stops the chain, so a
282 /// module that parsed first and answered `InvalidJSON` on a malformed
283 /// payload would strand an action it does not even serve. That is the
284 /// failure this signature is shaped to make impossible, and a test below
285 /// pins it with payloads that no parser would accept.
209 /// The payload is ignored, deliberately: Swift reads no field from either
210 /// action, and the injected JS posts neither a body nor a `callbackId`.
211 /// Parsing it would only create a way to fail that the spec has not got.
286212 pub fn handleMessage(_: *Self, action: []const u8, _: []const u8) !void {
287 _ = action;
288 return bridge_error.BridgeError.UnknownAction;
213 if (std.mem.eql(u8, action, A.start_listening)) {
214 startListening();
215 return;
216 }
217 if (std.mem.eql(u8, action, A.stop_listening)) {
218 stopListening();
219 return;
220 }
221 return BridgeError.UnknownAction;
289222 }
290223};
291224
292225// =============================================================================
293// The event vocabulary, as far as it can be spelled today.
226// The event vocabulary.
294227// =============================================================================
295228
229/// `craftSpeechStart` — the engine is running and audio is reaching the
230/// recognizer.
231pub const event_start: ios_events.Event = .speech_start;
232
296233/// `craftSpeechResult` — the transcript stream.
297234pub const event_result: ios_events.Event = .speech_result;
298235
299236/// `craftSpeechError` — every refusal, on every path.
300237pub const event_error: ios_events.Event = .speech_error;
301238
302/// `craftSpeechEnd` — every `stopSpeechRecognition()`, including one that
303/// stops nothing.
239/// `craftSpeechEnd` — every stop, including one that stops nothing.
304240pub const event_end: ios_events.Event = .speech_end;
305241
306/// `craftSpeechStart`, which `ios_events.Event` cannot currently spell.
307///
308/// A plain string, not an `Event`, because that is the whole point: there is
309/// no member to name. Pinned here so the eventual enum arm is copied from the
310/// contract rather than retyped from memory.
311pub const event_start_name = "craftSpeechStart";
312
313242/// The `data: [:]` of `craftSpeechStart` and `craftSpeechEnd` — an empty
314243/// object, not an empty string and not `null`. `ios_events.formatEvent`
315244/// inlines the detail as a literal, so `e.detail` must still be an object the
@@ -339,12 +268,13 @@ pub const err_audio_session = "Audio session failed";
339268/// NO. A simulator commonly answers NO here.
340269pub const err_recognizer_unavailable = "Speech recognizer unavailable";
341270
342/// `:2546` — `-startAndReturnError:` returned NO.
271/// `:2546` — `-startAndReturnError:` returned NO. Also the zero-format
272/// refusal, which Swift does not survive to report; see the module comment.
343273pub const err_audio_engine = "Audio engine failed";
344274
345/// Every message the port may emit, in Swift's source order. A future round
346/// adding a fifth must add it to Swift's contract first, or document the
347/// divergence — see the module comment on the recognition-error case.
275/// Every message this module may emit, in Swift's source order. Still four:
276/// the recognition-error divergence is documented rather than papered over
277/// with a fifth string Swift has never sent.
348278pub const error_messages = [_][]const u8{
349279 err_not_authorized,
350280 err_audio_session,
@@ -352,6 +282,25 @@ pub const error_messages = [_][]const u8{
352282 err_audio_engine,
353283};
354284
285/// Why a start failed, in the vocabulary the page reads.
286///
287/// An error set rather than a returned string, so a path that forgets to
288/// report is a compile error rather than an empty message, and so
289/// `messageFor` is the single place the mapping lives.
290const StartFailure = error{
291 AudioSession,
292 RecognizerUnavailable,
293 AudioEngine,
294};
295
296fn messageFor(err: StartFailure) []const u8 {
297 return switch (err) {
298 error.AudioSession => err_audio_session,
299 error.RecognizerUnavailable => err_recognizer_unavailable,
300 error.AudioEngine => err_audio_engine,
301 };
302}
303
355304// =============================================================================
356305// The detail shapers. Pure, and the bytes are the contract.
357306// =============================================================================
@@ -418,393 +367,1046 @@ pub fn shapeResultDetail(
418367 return out.toOwnedSlice(allocator);
419368}
420369
370/// Emit one `craftSpeechError`, or drop it and say so.
371///
372/// Every failure path in this file ends here, so this is the one place that
373/// decides what a page sees when even the reporting fails. Dropping is the
374/// only option — there is no promise to reject and no second channel — and
375/// the log line is what a developer has left.
376fn emitError(message: []const u8) void {
377 const allocator = std.heap.c_allocator;
378 const detail = shapeErrorDetail(allocator, message) catch {
379 std.log.warn("speech: could not shape '{s}' for craftSpeechError", .{message});
380 return;
381 };
382 defer allocator.free(detail);
383 ios_events.emit(event_error, detail);
384}
385
421386// =============================================================================
422// Tests.
387// Native constants, each read from the SDK header rather than recalled.
388// =============================================================================
389
390/// `SFSpeechRecognizerAuthorizationStatusAuthorized`.
391///
392/// `SFSpeechRecognizer.h:25-41` numbers the enum implicitly from zero:
393/// NotDetermined 0, Denied 1, Restricted 2, Authorized 3. Only 3 proceeds —
394/// Swift's `guard status == .authorized` treats the other three identically,
395/// including NotDetermined, which is what a user dismissing the prompt leaves
396/// behind.
397const status_authorized: isize = 3;
398
399/// `AVAudioSessionCategoryOptionDuckOthers` — `AVAudioSessionTypes.h:466`,
400/// `0x2`. Swift passes `options: .duckOthers`.
401const category_option_duck_others: c_ulong = 0x2;
402
403/// `AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation` —
404/// `AVAudioSessionTypes.h:660`, `1`. Swift passes it on activation, where the
405/// header says it is only meaningful on deactivation; carried across anyway,
406/// because it is inert rather than wrong and matching the shim costs nothing.
407const set_active_notify_others: c_ulong = 1;
408
409/// The bus every call here uses. Swift hardcodes 0 in all four places.
410const bus_zero: c_ulong = 0;
411
412/// Swift's `bufferSize: 1024` (`:2536`). `AVAudioFrameCount` is `uint32_t`
413/// (`AVAudioTypes.h:22`).
414const tap_buffer_frames: u32 = 1024;
415
416/// The locale Swift builds its recognizer with (`:440`).
417const recognizer_locale = "en-US";
418
419/// The Info.plist key that stands in for `config.enableSpeechRecognition`.
420const key_speech_usage = "NSSpeechRecognitionUsageDescription";
421
422/// How many spins teardown will wait for the audio thread before giving up and
423/// leaking the request. 200 × 100µs is 20ms, against a tap call measured in
424/// microseconds — long enough that reaching the end means something is wrong,
425/// short enough that a page's stop still feels instant.
426const drain_spins: usize = 200;
427const drain_spin_micros: c_uint = 100;
428
429// =============================================================================
430// State.
423431//
424// Two things are under test. First, that this module really does serve
425// nothing — declaring nothing is only half of it, since a module that
426// answered anything other than `UnknownAction` would break the fall-through
427// even with an empty table. Second, the finished half: the event names and the
428// two detail shapers, whose bytes are what a page reads.
432// Module-level, because `ios_dispatch` builds a fresh `SpeechBridge` per
433// dispatch and drops it again: anything held on the bridge would be released
434// the instant `startListening` returned, and the session with it. Swift's are
435// ivars on a coordinator that outlives every call, for the same reason.
429436//
430// Nothing here is Darwin-gated, because nothing in this file touches
431// Objective-C.
437// Everything here is main-thread state except the two atomics, which are the
438// only things the audio thread may touch.
432439// =============================================================================
433440
434const testing = std.testing;
441/// The `SFSpeechRecognizer`, built once and kept. Swift's is built at
442/// coordinator init when the flag is on.
443var recognizer: Id = null;
435444
436test "this module declares no actions, so both fall through to the shim" {
437 // Table -> dispatch, with the table empty. The shim serves both actions
438 // correctly today and neither has a promise to strand, so an empty table
439 // is the whole feature: `ios_dispatch.route` never enters this module and
440 // `handOffToHost` answers.
441 try testing.expectEqual(@as(usize, 0), capability_actions.len);
442 try testing.expectEqual(@as(usize, 0), A.declared_count);
443}
444
445test "the two unserved actions are the spec's, spelled exactly" {
446 // The spelling a future round will move into `A`. Pinned here so the move
447 // is a copy rather than a retype — `startlistening` would route nowhere and
448 // look exactly like this module still not serving it.
449 try testing.expectEqual(@as(usize, 2), unserved_actions.len);
450 try testing.expectEqualStrings("startListening", unserved_actions[0]);
451 try testing.expectEqualStrings("stopListening", unserved_actions[1]);
452}
453
454test "neither unserved action is in the action table" {
455 // The invariant that keeps the ratchet honest. An action listed in `A`
456 // counts as migrated to `test/ios_conformance_test.zig` whether or not
457 // anything answers it, so a name leaking from `unserved_actions` into the
458 // table would lower the ratchet for work nobody did.
459 for (unserved_actions) |name| {
460 for (capability_actions) |decl| {
461 try testing.expect(!std.mem.eql(u8, decl.name, name));
462 }
445/// The `AVAudioEngine`. Swift's is `private var audioEngine = AVAudioEngine()`
446/// (`:381`) — one instance for the process.
447var engine: Id = null;
448
449/// The current `SFSpeechRecognitionTask`, retained, cancelled by teardown.
450var task: Id = null;
451
452/// Whether a tap is installed on bus 0 of the engine's input node. The only
453/// thing that makes teardown touch the engine at all.
454var tap_installed: bool = false;
455
456/// Whether a start is between `requestAuthorization:` and the main-queue hop.
457/// A second `startListening` in that window is dropped rather than queued: it
458/// would produce a second authorization callback racing the first one's
459/// `beginRecording`, and the page cannot tell two starts apart anyway.
460var starting: bool = false;
461
462/// Whether a session is running, in the sense that `craftSpeechStart` was
463/// emitted and no end has been.
464var listening: bool = false;
465
466/// `@selector(appendAudioPCMBuffer:)`, interned on the main thread before the
467/// tap is installed. The audio thread must never call `sel_registerName`,
468/// which takes the runtime's lock.
469var sel_append: objc.SEL = null;
470
471/// The `SFSpeechAudioBufferRecognitionRequest` the tap may append to, as a
472/// `usize`. Zero means nobody may append.
473///
474/// `.seq_cst` on every access; see the module comment on Dekker.
475var active_request: std.atomic.Value(usize) = .init(0);
476
477/// How many tap invocations are between their fence and their append. Teardown
478/// waits for this to reach zero before releasing what `active_request` held.
479var tap_in_flight: std.atomic.Value(u32) = .init(0);
480
481/// The authorization status, written on whatever queue Speech chose and read
482/// on the main queue after the hop.
483var auth_status: std.atomic.Value(isize) = .init(0);
484
485// =============================================================================
486// startListening.
487// =============================================================================
488
489/// `case "startListening"` (`CraftApp.swift:533-534`, `:2486-2548`).
490///
491/// Returns nothing and reports nothing to the dispatcher: every outcome is an
492/// event. The plist guard runs before `requestAuthorization:` because asking
493/// without the key terminates the process rather than failing.
494fn startListening() void {
495 if (!is_darwin) {
496 emitError(err_recognizer_unavailable);
497 return;
463498 }
499
500 if (starting) {
501 std.log.info("startListening: an authorization request is already in flight", .{});
502 return;
503 }
504
505 if (!hasSpeechUsageDescription()) {
506 std.log.warn(
507 "startListening refused: Info.plist has no {s}, so this app was not built " ++
508 "with speech recognition enabled",
509 .{key_speech_usage},
510 );
511 emitError(err_recognizer_unavailable);
512 return;
513 }
514
515 const SFSpeechRecognizer = objc.objc_getClass("SFSpeechRecognizer") orelse {
516 std.log.warn(
517 "startListening: SFSpeechRecognizer is not in this process; " ++
518 "the app does not link Speech",
519 .{},
520 );
521 emitError(err_recognizer_unavailable);
522 return;
523 };
524 const sel_request = objc.sel_registerName("requestAuthorization:") orelse {
525 emitError(err_recognizer_unavailable);
526 return;
527 };
528
529 starting = true;
530
531 const RequestFn = *const fn (Id, objc.SEL, *const AuthBlock) callconv(.c) void;
532 const requestAuthorization: RequestFn = @ptrCast(&objc.objc_msgSend);
533 requestAuthorization(SFSpeechRecognizer, sel_request, &auth_block);
464534}
465535
466test "handleMessage refuses everything, including the actions it researched" {
467 // Dispatch -> table, with both empty. `UnknownAction` is the only error
468 // `ios_dispatch.route` reads as "not mine, ask the next"; any other error
469 // is treated as final and would stop the chain before the shim.
470 var bridge = SpeechBridge.init(testing.allocator);
471 defer bridge.deinit();
536/// Whether the main bundle's Info.plist carries the speech usage description.
537///
538/// Answers `false` on any runtime failure rather than propagating one. Unlike
539/// `bridge_mobile_motion.zig`'s version, which separates "no NSBundle class"
540/// from "not configured" because it has an error to return, every caller here
541/// has only an event to emit — and the event would be the same either way, so
542/// the distinction would cost a code path and buy a page nothing. The log line
543/// above the call is where a broken process is named.
544fn hasSpeechUsageDescription() bool {
545 if (!is_darwin) return false;
546
547 const NSBundle = objc.objc_getClass("NSBundle") orelse return false;
548 const sel_main = objc.sel_registerName("mainBundle") orelse return false;
549 const bundle = objc.msgSendId(NSBundle, sel_main) orelse return false;
550
551 const NSString = objc.objc_getClass("NSString") orelse return false;
552 const sel_string = objc.sel_registerName("stringWithUTF8String:") orelse return false;
553 const ns_key = objc.msgSendId1(
554 NSString,
555 sel_string,
556 @as([*:0]const u8, key_speech_usage),
557 ) orelse return false;
558
559 const sel_lookup = objc.sel_registerName("objectForInfoDictionaryKey:") orelse return false;
560 return objc.msgSendId1(bundle, sel_lookup, ns_key) != null;
561}
562
563/// The main-queue half of the authorization answer.
564///
565/// Swift hops here too (`:2493`) before `beginRecording`, because activating an
566/// audio session and starting an engine off the main thread is not supported.
567fn authorizedOnMain(_: ?*anyopaque) callconv(.c) void {
568 starting = false;
569
570 if (auth_status.load(.seq_cst) != status_authorized) {
571 std.log.warn(
572 "startListening: speech authorization was not granted (status {d})",
573 .{auth_status.load(.seq_cst)},
574 );
575 emitError(err_not_authorized);
576 return;
577 }
472578
473 const E = bridge_error.BridgeError;
579 beginRecording() catch |err| {
580 std.log.warn("startListening: {s}", .{messageFor(err)});
581 emitError(messageFor(err));
582 return;
583 };
584}
474585
475 for (unserved_actions) |name| {
476 try testing.expectError(E.UnknownAction, bridge.handleMessage(name, "{}"));
586/// Everything `beginRecording` does, in Swift's order (`:2496-2548`).
587///
588/// Once `active_request` is published, ownership of the request belongs to
589/// `teardown`, so every failure after that point goes through `failStart`
590/// rather than releasing anything itself. That is why there is no `errdefer`
591/// here: an errdefer plus a teardown is a double release, and the object it
592/// would double-release is one an audio thread may be holding.
593fn beginRecording() StartFailure!void {
594 // Swift cancels only the task and then installs a second tap, which
595 // raises. Stop properly instead — and emit no end, because a restart is
596 // not a stop.
597 if (listening or tap_installed) teardown(false);
598
599 const session = sharedAudioSession() orelse return error.AudioSession;
600 configureSession(session) catch return error.AudioSession;
601
602 const rec = ensureRecognizer() orelse return error.RecognizerUnavailable;
603 if (!recognizerIsAvailable(rec)) {
604 std.log.warn("startListening: the recognizer reports itself unavailable", .{});
605 return error.RecognizerUnavailable;
477606 }
478607
479 // Casing, near misses, and the neighbouring namespaces' spellings — the
480 // desktop `speechRecognition` actions in particular, which are a different
481 // namespace and must never be answered from here.
482 for ([_][]const u8{
483 "startlistening",
484 "StartListening",
485 "startListen",
486 "listen",
487 "start",
488 "stop",
489 "isAvailable",
490 "speak",
491 "startMotionUpdates",
492 "getDeviceInfo",
493 "",
494 }) |name| {
495 try testing.expectError(E.UnknownAction, bridge.handleMessage(name, "{}"));
608 const eng = ensureEngine() orelse return error.AudioEngine;
609 const input = inputNode(eng) orelse return error.AudioEngine;
610 const format = outputFormat(input) orelse return error.AudioEngine;
611
612 // Precondition 3. `installTapOnBus:` on a zero format raises an
613 // NSException, and an uncaught ObjC exception is an uncatchable SIGABRT —
614 // Swift aborts here, this returns.
615 if (!formatCarriesInput(format)) {
616 std.log.warn(
617 "startListening: the input node reports no usable format, " ++
618 "so this process has no audio input route",
619 .{},
620 );
621 return error.AudioEngine;
496622 }
623
624 // Interned before the tap exists, so the audio thread never needs the
625 // runtime's selector lock.
626 sel_append = objc.sel_registerName("appendAudioPCMBuffer:") orelse
627 return error.RecognizerUnavailable;
628
629 const request = newRecognitionRequest() orelse return error.RecognizerUnavailable;
630 setShouldReportPartialResults(request, true);
631
632 // From here on the request is teardown's to release.
633 active_request.store(@intFromPtr(request), .seq_cst);
634
635 const started_task = startRecognitionTask(rec, request) orelse
636 return failStart(error.RecognizerUnavailable);
637 // `recognitionTaskWithRequest:` returns an autoreleased task; without a
638 // retain it dies at the end of this turn of the run loop and the stream
639 // stops with no event to say so.
640 task = objc.retain(started_task);
641
642 installTap(input, format) catch return failStart(error.AudioEngine);
643 tap_installed = true;
644
645 prepareEngine(eng);
646 if (!startEngine(eng)) return failStart(error.AudioEngine);
647
648 listening = true;
649 ios_events.emit(event_start, empty_detail);
650 fireHaptic();
497651}
498652
499test "no payload can turn the fall-through into a final error" {
500 // The subtle way a serve-nothing module breaks the thing it is protecting.
501 // `ios_dispatch.route` stops the chain on any error but `UnknownAction`, so
502 // a module that parsed its payload before routing would answer
503 // `InvalidJSON` for a malformed one and strand an action it does not
504 // serve — the page would get a rejection where the shim would have worked.
505 var bridge = SpeechBridge.init(testing.allocator);
506 defer bridge.deinit();
653/// Tear down whatever was built, then report why the start failed.
654///
655/// No `craftSpeechEnd`: a start that never emitted `craftSpeechStart` has no
656/// end to announce, and Swift's failure paths emit only the error.
657fn failStart(err: StartFailure) StartFailure {
658 teardown(false);
659 return err;
660}
661
662// =============================================================================
663// stopListening, and the teardown both stops share.
664// =============================================================================
665
666/// `case "stopListening"` (`CraftApp.swift:535-536`, `:2550-2559`).
667///
668/// Ungated in the spec — there is no `if config.` on this case — so
669/// `ios_config.gateFor` has no entry for it either, and an app with speech
670/// disabled can still stop. Which matters: the page's own stop is how a stuck
671/// listening indicator gets cleared.
672fn stopListening() void {
673 if (!is_darwin) {
674 // Nothing to stop, and the page still gets its end. `emit` is a no-op
675 // off Darwin, so this is documentation more than behaviour.
676 ios_events.emit(event_end, empty_detail);
677 return;
678 }
679 teardown(true);
680}
681
682/// The main-queue landing for a stop the recognition handler asked for.
683///
684/// Precondition 5: the handler fires on an internal Speech queue, and
685/// `AVAudioEngine` must not be touched from there.
686fn stopOnMain(_: ?*anyopaque) callconv(.c) void {
687 teardown(true);
688}
689
690/// Release everything, in the one order that is safe against the audio thread.
691///
692/// 1. Stop the engine and remove the tap, so `AVAudioEngine` guarantees no
693/// new tap call begins.
694/// 2. Clear the published request, so a call that began before step 1
695/// returned finds nothing to append to.
696/// 3. Drain: wait for any in-flight tap call to leave.
697/// 4. `endAudio`, then release — or leak, if step 3 timed out.
698/// 5. Cancel and release the task.
699///
700/// Steps 1 and 2 in that order rather than the reverse: clearing first would
701/// silently drop the last buffers the tap had already delivered, which is
702/// audio Swift would have transcribed.
703///
704/// Idempotent. Every field is checked, so a stop that stops nothing does
705/// nothing but emit — which is exactly what `stopListening` needs.
706fn teardown(emit_end: bool) void {
707 if (!is_darwin) return;
708
709 if (tap_installed) {
710 if (engine) |eng| {
711 stopEngine(eng);
712 if (inputNode(eng)) |input| removeTap(input);
713 }
714 tap_installed = false;
715 }
507716
508 const E = bridge_error.BridgeError;
509
510 for ([_][]const u8{
511 "{not json",
512 "",
513 "null",
514 "[1,2,3]",
515 "{\"transcript\":\"hello\"}",
516 "{\"callbackId\":\"cb_7\"}",
517 }) |payload| {
518 for (unserved_actions) |name| {
519 try testing.expectError(E.UnknownAction, bridge.handleMessage(name, payload));
717 const held = active_request.swap(0, .seq_cst);
718 const drained = drainTap();
719
720 if (held != 0) {
721 const request: Id = @ptrFromInt(held);
722 endAudio(request);
723 if (drained) {
724 objc.release(request);
725 } else {
726 std.log.warn(
727 "stopListening: the audio thread did not leave within {d}ms, " ++
728 "so one recognition request is leaked rather than released",
729 .{(drain_spins * drain_spin_micros) / 1000},
730 );
520731 }
521732 }
733
734 if (task) |t| {
735 cancelTask(t);
736 objc.release(t);
737 task = null;
738 }
739
740 listening = false;
741
742 if (emit_end) {
743 ios_events.emit(event_end, empty_detail);
744 fireHaptic();
745 }
522746}
523747
524test "the three spellable events are the ones the page subscribes to" {
525 // `test-bridges.html:384`, `:388`, `:391`, and Swift's `sendToWeb` call
526 // sites. camelCase, no colon — the iOS vocabulary, which is why
527 // `ios_events.Event` exists separately from `capabilities.Channel`.
528 try testing.expectEqualStrings("craftSpeechResult", event_result.eventName());
529 try testing.expectEqualStrings("craftSpeechError", event_error.eventName());
530 try testing.expectEqualStrings("craftSpeechEnd", event_end.eventName());
748/// Wait for `tap_in_flight` to reach zero, bounded.
749///
750/// Sleeping rather than spinning hot: the audio thread runs at a higher
751/// priority than the main thread, so burning the main thread's slice is the
752/// one thing that could actually delay the call being waited on. This Zig has
753/// no `std.Thread.sleep` (`startup_timing.zig:150` records the same gap), and
754/// a busy spin is the wrong shape here, so the sleep is libc's — which this
755/// file already depends on for `dlsym` and `dispatch_async_f`.
756fn drainTap() bool {
757 var spins: usize = 0;
758 while (spins < drain_spins) : (spins += 1) {
759 if (tap_in_flight.load(.seq_cst) == 0) return true;
760 _ = usleep(drain_spin_micros);
761 }
762 return tap_in_flight.load(.seq_cst) == 0;
763}
531764
532 // And no desktop channel can spell any of them, so emitting through
533 // `capabilities.Channel` would fire events with no subscriber.
534 for (std.enums.values(capabilities.Channel)) |channel| {
535 try testing.expect(!std.mem.eql(u8, channel.eventName(), event_result.eventName()));
536 try testing.expect(!std.mem.eql(u8, channel.eventName(), event_error.eventName()));
537 try testing.expect(!std.mem.eql(u8, channel.eventName(), event_end.eventName()));
538 try testing.expect(!std.mem.eql(u8, channel.eventName(), event_start_name));
765/// `triggerHaptic(style: "light")`, as `:2544` and `:2558` fire it.
766///
767/// Ungated on purpose — see the module comment. Errors are swallowed because
768/// the Taptic Engine is absent on the simulator and on iPad, and a haptic that
769/// could not fire is not a reason to withhold a speech event.
770fn fireHaptic() void {
771 haptics.triggerHapticChecked(.impact_light) catch |err| {
772 std.log.debug("speech: the light haptic did not fire: {}", .{err});
773 };
774}
775
776// =============================================================================
777// Blocks.
778//
779// All three are global blocks — `_NSConcreteGlobalBlock` with
780// `BLOCK_IS_GLOBAL`, so `Block_copy` is identity and none of them can outlive
781// its storage. None captures anything: the authorization block writes an
782// atomic and hops, the result block reads its arguments, and the tap block
783// reads two atomics. Capture would mean a heap block, a copy helper, and a
784// dispose helper, and the tap block would then be allocating on the audio
785// thread.
786// =============================================================================
787
788const BlockDescriptor = extern struct {
789 reserved: c_ulong = 0,
790 size: c_ulong,
791};
792
793/// 1 << 28 — a global block is never copied.
794const BLOCK_IS_GLOBAL: c_int = 1 << 28;
795
796extern var _NSConcreteGlobalBlock: anyopaque;
797
798/// `void (^)(SFSpeechRecognizerAuthorizationStatus status)`.
799const AuthBlock = extern struct {
800 isa: ?*anyopaque,
801 flags: c_int,
802 reserved: c_int = 0,
803 invoke: *const anyopaque,
804 descriptor: *const BlockDescriptor,
805};
806
807const auth_block_descriptor = BlockDescriptor{ .size = @sizeOf(AuthBlock) };
808
809fn authInvoke(_: *const AuthBlock, status: isize) callconv(.c) void {
810 // An arbitrary queue. Record and hop; touch nothing else.
811 auth_status.store(status, .seq_cst);
812 dispatch_async_f(&_dispatch_main_q, null, authorizedOnMain);
813}
814
815var auth_block: AuthBlock = if (is_darwin) .{
816 .isa = &_NSConcreteGlobalBlock,
817 .flags = BLOCK_IS_GLOBAL,
818 .invoke = @ptrCast(&authInvoke),
819 .descriptor = &auth_block_descriptor,
820} else undefined;
821
822/// `void (^)(SFSpeechRecognitionResult *result, NSError *error)`.
823const ResultBlock = extern struct {
824 isa: ?*anyopaque,
825 flags: c_int,
826 reserved: c_int = 0,
827 invoke: *const anyopaque,
828 descriptor: *const BlockDescriptor,
829};
830
831const result_block_descriptor = BlockDescriptor{ .size = @sizeOf(ResultBlock) };
832
833/// Swift's result handler (`:2522-2533`), including its discarded error.
834fn resultInvoke(_: *const ResultBlock, result: Id, err: Id) callconv(.c) void {
835 var is_final = false;
836
837 if (result) |r| {
838 is_final = resultIsFinal(r);
839 emitTranscript(r, is_final);
840 }
841
842 if (err != null or is_final) {
843 // Precondition 5: this is an internal Speech queue, and the teardown
844 // touches AVAudioEngine.
845 dispatch_async_f(&_dispatch_main_q, null, stopOnMain);
539846 }
540 // Non-vacuity: the loop above proves nothing over an empty enum.
541 try testing.expect(std.enums.values(capabilities.Channel).len >= 40);
542}
543
544test "craftSpeechStart is still unspellable, which is blocker one" {
545 // A tripwire, not a preference. This module's first blocker is that
546 // `ios_events.Event` has no `.speech_start`, and the moment somebody adds
547 // it precondition 1 is met — which should be noticed here, in the file
548 // that documents it, rather than in a later round rediscovering the
549 // research. Blockers 2-4 still stand on their own, so this trip is a
550 // prompt to re-read them, never a clearance to serve.
551 if (@hasField(ios_events.Event, "speech_start")) {
552 std.debug.print(
553 "ios_events.Event now has .speech_start, so blocker 1 in " ++
554 "bridge_mobile_speech.zig is lifted.\n" ++
555 " Emit it through the enum, delete event_start_name, and work " ++
556 "through preconditions 2-5 before moving either action into `A`.\n",
847}
848
849var result_block: ResultBlock = if (is_darwin) .{
850 .isa = &_NSConcreteGlobalBlock,
851 .flags = BLOCK_IS_GLOBAL,
852 .invoke = @ptrCast(&resultInvoke),
853 .descriptor = &result_block_descriptor,
854} else undefined;
855
856/// `AVAudioNodeTapBlock` — `void (^)(AVAudioPCMBuffer *buffer, AVAudioTime
857/// *when)` (`AVAudioNode.h:32`).
858const TapBlock = extern struct {
859 isa: ?*anyopaque,
860 flags: c_int,
861 reserved: c_int = 0,
862 invoke: *const anyopaque,
863 descriptor: *const BlockDescriptor,
864};
865
866const tap_block_descriptor = BlockDescriptor{ .size = @sizeOf(TapBlock) };
867
868/// The audio I/O thread. Two atomics, one `objc_msgSend`, nothing else.
869///
870/// No allocation, no `sel_registerName`, no logging, no lock: each of those
871/// takes a lock somewhere, and taking a lock here inverts priority against
872/// whatever holds it. `when` is unread, as it is in Swift.
873fn tapInvoke(_: *const TapBlock, buffer: Id, when: Id) callconv(.c) void {
874 _ = when;
875
876 _ = tap_in_flight.fetchAdd(1, .seq_cst);
877 defer _ = tap_in_flight.fetchSub(1, .seq_cst);
878
879 const held = active_request.load(.seq_cst);
880 if (held == 0) return;
881
882 const request: Id = @ptrFromInt(held);
883 const AppendFn = *const fn (Id, objc.SEL, Id) callconv(.c) void;
884 const append: AppendFn = @ptrCast(&objc.objc_msgSend);
885 append(request, sel_append, buffer);
886}
887
888var tap_block: TapBlock = if (is_darwin) .{
889 .isa = &_NSConcreteGlobalBlock,
890 .flags = BLOCK_IS_GLOBAL,
891 .invoke = @ptrCast(&tapInvoke),
892 .descriptor = &tap_block_descriptor,
893} else undefined;
894
895// =============================================================================
896// The Objective-C calls, one thin wrapper each.
897//
898// Wrapped rather than inlined so `beginRecording` reads as the sequence Swift
899// reads as, and so every signature is written exactly once — the place this
900// migration has repeatedly found its mistakes.
901// =============================================================================
902
903fn sharedAudioSession() Id {
904 const AVAudioSession = objc.objc_getClass("AVAudioSession") orelse {
905 std.log.warn(
906 "startListening: AVAudioSession is not in this process; " ++
907 "the app does not link AVFoundation",
557908 .{},
558909 );
559 return error.SpeechStartBlockerLifted;
910 return null;
911 };
912 const sel = objc.sel_registerName("sharedInstance") orelse return null;
913 return objc.msgSendId(AVAudioSession, sel);
914}
915
916/// `setCategory(.record, mode: .measurement, options: .duckOthers)` then
917/// `setActive(true, options: .notifyOthersOnDeactivation)` — Swift `:2502-2505`.
918fn configureSession(session: Id) !void {
919 const category = audioSessionConstant("AVAudioSessionCategoryRecord") orelse
920 return error.NotFound;
921 const mode = audioSessionConstant("AVAudioSessionModeMeasurement") orelse
922 return error.NotFound;
923
924 const sel_category = objc.sel_registerName("setCategory:mode:options:error:") orelse
925 return error.SelectorNotFound;
926 const CategoryFn = *const fn (Id, objc.SEL, Id, Id, c_ulong, ?*Id) callconv(.c) bool;
927 const setCategory: CategoryFn = @ptrCast(&objc.objc_msgSend);
928 var category_error: Id = null;
929 if (!setCategory(
930 session,
931 sel_category,
932 category,
933 mode,
934 category_option_duck_others,
935 &category_error,
936 )) {
937 logNSError("startListening", category_error);
938 return error.NativeCallFailed;
560939 }
561940
562 // Until then the name exists only as a string, and it is the right one.
563 try testing.expectEqualStrings("craftSpeechStart", event_start_name);
941 const sel_active = objc.sel_registerName("setActive:withOptions:error:") orelse
942 return error.SelectorNotFound;
943 const ActiveFn = *const fn (Id, objc.SEL, bool, c_ulong, ?*Id) callconv(.c) bool;
944 const setActive: ActiveFn = @ptrCast(&objc.objc_msgSend);
945 var active_error: Id = null;
946 if (!setActive(session, sel_active, true, set_active_notify_others, &active_error)) {
947 logNSError("startListening", active_error);
948 return error.NativeCallFailed;
949 }
564950}
565951
566test "start and end carry an empty object, not an empty string" {
567 // `data: [:]` through `JSONSerialization` is `{}`. `ios_events.formatEvent`
568 // inlines the detail as a literal, so `""` would produce
569 // `{detail:}` — a syntax error that takes the whole event with it — and
570 // `"null"` would make `e.detail` null where the page expects an object.
571 try testing.expectEqualStrings("{}", empty_detail);
952/// `SFSpeechRecognizer(locale: Locale(identifier: "en-US"))`, built once.
953fn ensureRecognizer() Id {
954 if (recognizer) |existing| return existing;
955
956 const NSLocale = objc.objc_getClass("NSLocale") orelse return null;
957 const sel_locale = objc.sel_registerName("localeWithLocaleIdentifier:") orelse return null;
958 const NSString = objc.objc_getClass("NSString") orelse return null;
959 const sel_string = objc.sel_registerName("stringWithUTF8String:") orelse return null;
960 const identifier = objc.msgSendId1(
961 NSString,
962 sel_string,
963 @as([*:0]const u8, recognizer_locale),
964 ) orelse return null;
965 const locale = objc.msgSendId1(NSLocale, sel_locale, identifier) orelse return null;
966
967 const SFSpeechRecognizer = objc.objc_getClass("SFSpeechRecognizer") orelse return null;
968 const sel_alloc = objc.sel_registerName("alloc") orelse return null;
969 const sel_init = objc.sel_registerName("initWithLocale:") orelse return null;
970 const allocated = objc.msgSendId(SFSpeechRecognizer, sel_alloc) orelse return null;
971
972 // `-initWithLocale:` is nullable: an unsupported locale answers nil, and
973 // then so does this, and the caller emits "Speech recognizer unavailable"
974 // exactly as Swift's `guard let` does.
975 recognizer = objc.msgSendId1(allocated, sel_init, locale);
976 return recognizer;
977}
572978
573 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, empty_detail, .{});
574 defer parsed.deinit();
575 try testing.expectEqual(@as(usize, 0), parsed.value.object.count());
979fn recognizerIsAvailable(rec: Id) bool {
980 const sel = objc.sel_registerName("isAvailable") orelse return false;
981 return objc.msgSendBool(rec, sel);
576982}
577983
578// -----------------------------------------------------------------------------
579// craftSpeechError
580// -----------------------------------------------------------------------------
984fn ensureEngine() Id {
985 if (engine) |existing| return existing;
986 const AVAudioEngine = objc.objc_getClass("AVAudioEngine") orelse return null;
987 engine = objc.allocInit(AVAudioEngine) catch null;
988 return engine;
989}
581990
582test "the four error messages are Swift's, character for character" {
583 // These strings are shown to a human (`test-bridges.html:392-393`) and
584 // documented in README.md. A paraphrase changes what a user reads without
585 // changing anything else a test can see.
586 try testing.expectEqualStrings("Not authorized", err_not_authorized);
587 try testing.expectEqualStrings("Audio session failed", err_audio_session);
588 try testing.expectEqualStrings("Speech recognizer unavailable", err_recognizer_unavailable);
589 try testing.expectEqualStrings("Audio engine failed", err_audio_engine);
590 try testing.expectEqual(@as(usize, 4), error_messages.len);
991fn inputNode(eng: Id) Id {
992 const sel = objc.sel_registerName("inputNode") orelse return null;
993 return objc.msgSendId(eng, sel);
591994}
592995
593test "an error detail is the single key the page reads" {
594 // `e.detail.error` (`test-bridges.html:392`) and nothing else. A `code` or
595 // `message` key alongside it would be a surface no consumer knows about.
596 const json = try shapeErrorDetail(testing.allocator, err_not_authorized);
597 defer testing.allocator.free(json);
996fn outputFormat(node: Id) Id {
997 const sel = objc.sel_registerName("outputFormatForBus:") orelse return null;
998 const Fn = *const fn (Id, objc.SEL, c_ulong) callconv(.c) Id;
999 const call: Fn = @ptrCast(&objc.objc_msgSend);
1000 return call(node, sel, bus_zero);
1001}
5981002
599 try testing.expectEqualStrings("{\"error\":\"Not authorized\"}", json);
1003/// Whether this format describes a real input route.
1004///
1005/// The header says it in as many words: "Check for the input node's input
1006/// format for non-zero sample rate and channel count to see if input is
1007/// enabled" (`AVAudioEngine.h:459-461`). Both must be non-zero — a format with
1008/// a rate and no channels is as unusable as one with neither.
1009fn formatCarriesInput(format: Id) bool {
1010 const sel_rate = objc.sel_registerName("sampleRate") orelse return false;
1011 const RateFn = *const fn (Id, objc.SEL) callconv(.c) f64;
1012 const sampleRate: RateFn = @ptrCast(&objc.objc_msgSend);
1013
1014 const sel_channels = objc.sel_registerName("channelCount") orelse return false;
1015 const ChannelFn = *const fn (Id, objc.SEL) callconv(.c) u32;
1016 const channelCount: ChannelFn = @ptrCast(&objc.objc_msgSend);
1017
1018 return sampleRate(format, sel_rate) > 0 and channelCount(format, sel_channels) > 0;
1019}
6001020
601 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
602 defer parsed.deinit();
603 try testing.expectEqual(@as(usize, 1), parsed.value.object.count());
604 try testing.expectEqualStrings("Not authorized", parsed.value.object.get("error").?.string);
1021fn newRecognitionRequest() Id {
1022 const class = objc.objc_getClass("SFSpeechAudioBufferRecognitionRequest") orelse return null;
1023 return objc.allocInit(class) catch null;
6051024}
6061025
607test "every one of the four messages shapes and parses back" {
608 for (error_messages) |message| {
609 const json = try shapeErrorDetail(testing.allocator, message);
610 defer testing.allocator.free(json);
1026fn setShouldReportPartialResults(request: Id, value: bool) void {
1027 const sel = objc.sel_registerName("setShouldReportPartialResults:") orelse return;
1028 const Fn = *const fn (Id, objc.SEL, bool) callconv(.c) void;
1029 const call: Fn = @ptrCast(&objc.objc_msgSend);
1030 call(request, sel, value);
1031}
6111032
612 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
613 defer parsed.deinit();
614 try testing.expectEqual(@as(usize, 1), parsed.value.object.count());
615 try testing.expectEqualStrings(message, parsed.value.object.get("error").?.string);
616 }
1033fn startRecognitionTask(rec: Id, request: Id) Id {
1034 const sel = objc.sel_registerName("recognitionTaskWithRequest:resultHandler:") orelse
1035 return null;
1036 const Fn = *const fn (Id, objc.SEL, Id, *const ResultBlock) callconv(.c) Id;
1037 const call: Fn = @ptrCast(&objc.objc_msgSend);
1038 return call(rec, sel, request, &result_block);
6171039}
6181040
619test "an error message carrying a quote does not end the object early" {
620 // No current message does. The escaping is a property of the position, not
621 // of today's inputs, and a future round quoting a framework error is
622 // exactly how the unescaped case arrives.
623 const json = try shapeErrorDetail(testing.allocator, "recognizer said \"no\"\\ever");
624 defer testing.allocator.free(json);
1041fn installTap(node: Id, format: Id) !void {
1042 const sel = objc.sel_registerName("installTapOnBus:bufferSize:format:block:") orelse
1043 return error.SelectorNotFound;
1044 const Fn = *const fn (Id, objc.SEL, c_ulong, u32, Id, *const TapBlock) callconv(.c) void;
1045 const call: Fn = @ptrCast(&objc.objc_msgSend);
1046 call(node, sel, bus_zero, tap_buffer_frames, format, &tap_block);
1047}
6251048
626 try testing.expectEqualStrings(
627 "{\"error\":\"recognizer said \\\"no\\\"\\\\ever\"}",
628 json,
629 );
1049fn removeTap(node: Id) void {
1050 const sel = objc.sel_registerName("removeTapOnBus:") orelse return;
1051 const Fn = *const fn (Id, objc.SEL, c_ulong) callconv(.c) void;
1052 const call: Fn = @ptrCast(&objc.objc_msgSend);
1053 call(node, sel, bus_zero);
1054}
6301055
631 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
632 defer parsed.deinit();
633 try testing.expectEqualStrings(
634 "recognizer said \"no\"\\ever",
635 parsed.value.object.get("error").?.string,
636 );
1056fn prepareEngine(eng: Id) void {
1057 const sel = objc.sel_registerName("prepare") orelse return;
1058 objc.msgSend(eng, sel);
6371059}
6381060
639// -----------------------------------------------------------------------------
640// craftSpeechResult
641// -----------------------------------------------------------------------------
1061fn startEngine(eng: Id) bool {
1062 const sel = objc.sel_registerName("startAndReturnError:") orelse return false;
1063 const Fn = *const fn (Id, objc.SEL, ?*Id) callconv(.c) bool;
1064 const call: Fn = @ptrCast(&objc.objc_msgSend);
1065 var start_error: Id = null;
1066 if (call(eng, sel, &start_error)) return true;
1067 logNSError("startListening", start_error);
1068 return false;
1069}
6421070
643test "a result detail is exactly transcript and isFinal" {
644 // `CraftApp.swift:2525-2528`, and the two keys `test-bridges.html:386`
645 // reads. These are the bytes `e.detail` receives either way, because
646 // `sendToWeb` and `ios_events.formatEvent` inline the detail identically.
647 const partial = try shapeResultDetail(testing.allocator, "hello world", false);
648 defer testing.allocator.free(partial);
649 try testing.expectEqualStrings(
650 "{\"transcript\":\"hello world\",\"isFinal\":false}",
651 partial,
652 );
1071fn stopEngine(eng: Id) void {
1072 const sel = objc.sel_registerName("stop") orelse return;
1073 objc.msgSend(eng, sel);
1074}
6531075
654 const final = try shapeResultDetail(testing.allocator, "hello world", true);
655 defer testing.allocator.free(final);
656 try testing.expectEqualStrings(
657 "{\"transcript\":\"hello world\",\"isFinal\":true}",
658 final,
1076fn endAudio(request: Id) void {
1077 const sel = objc.sel_registerName("endAudio") orelse return;
1078 objc.msgSend(request, sel);
1079}
1080
1081fn cancelTask(t: Id) void {
1082 const sel = objc.sel_registerName("cancel") orelse return;
1083 objc.msgSend(t, sel);
1084}
1085
1086fn resultIsFinal(result: Id) bool {
1087 const sel = objc.sel_registerName("isFinal") orelse return false;
1088 return objc.msgSendBool(result, sel);
1089}
1090
1091/// `result.bestTranscription.formattedString` -> one `craftSpeechResult`.
1092///
1093/// Runs on Speech's internal queue. `ios_events.emit` hops to the main queue
1094/// itself, so nothing here needs to.
1095fn emitTranscript(result: Id, is_final: bool) void {
1096 const sel_best = objc.sel_registerName("bestTranscription") orelse return;
1097 const transcription = objc.msgSendId(result, sel_best) orelse return;
1098
1099 const sel_formatted = objc.sel_registerName("formattedString") orelse return;
1100 const string = objc.msgSendId(transcription, sel_formatted) orelse return;
1101
1102 const utf8 = objc.getNSStringUTF8(string) orelse return;
1103 const text = std.mem.span(utf8);
1104
1105 const allocator = std.heap.c_allocator;
1106 const detail = shapeResultDetail(allocator, text, is_final) catch {
1107 std.log.warn("speech: could not shape a transcript for craftSpeechResult", .{});
1108 return;
1109 };
1110 defer allocator.free(detail);
1111
1112 ios_events.emit(event_result, detail);
1113}
1114
1115/// Read an `AVAudioSession` string constant out of the process.
1116///
1117/// `dlsym` rather than an `extern` declaration, for the reason
1118/// `bridge_mobile_imagepicker.zig` documents: a host test binary links Cocoa
1119/// and not AVFoundation, and an `extern` would make the whole test executable
1120/// fail to link rather than this one call fail to find its symbol.
1121fn audioSessionConstant(comptime symbol: [*:0]const u8) Id {
1122 const found = dlsym(RTLD_DEFAULT, symbol) orelse {
1123 std.log.warn("startListening: {s} is not in this process", .{symbol});
1124 return null;
1125 };
1126 const cell: *const Id = @ptrCast(@alignCast(found));
1127 return cell.*;
1128}
1129
1130fn logNSError(context: []const u8, err: Id) void {
1131 const object = err orelse {
1132 std.log.warn("{s}: the native call failed and reported no error", .{context});
1133 return;
1134 };
1135 const sel = objc.sel_registerName("localizedDescription") orelse return;
1136 const description = objc.msgSendId(object, sel) orelse return;
1137 const utf8 = objc.getNSStringUTF8(description) orelse return;
1138 std.log.warn("{s}: {s}", .{ context, std.mem.span(utf8) });
1139}
1140
1141const dispatch_function_t = *const fn (?*anyopaque) callconv(.c) void;
1142extern "c" fn dispatch_async_f(queue: *anyopaque, context: ?*anyopaque, work: dispatch_function_t) void;
1143extern var _dispatch_main_q: anyopaque;
1144
1145extern "c" fn dlsym(handle: ?*anyopaque, symbol: [*:0]const u8) ?*anyopaque;
1146
1147extern "c" fn usleep(microseconds: c_uint) c_int;
1148
1149/// `RTLD_DEFAULT` — search every image already loaded into the process.
1150const RTLD_DEFAULT: ?*anyopaque = @ptrFromInt(@as(usize, @bitCast(@as(isize, -2))));
1151
1152// =============================================================================
1153// Tests.
1154//
1155// Three groups. The pure shapers and the vocabulary, whose bytes are what a
1156// page reads. The routing, which is what makes `ios_dispatch` reach this file
1157// at all. And the state machine — the parts of teardown and the tap handoff
1158// that can be exercised without an audio device, which is more of them than it
1159// looks, because the handoff is deliberately made of atomics rather than of
1160// framework state.
1161//
1162// What no test here can reach: the recognizer, the session, the engine, the
1163// tap itself. That risk surface belongs to `packages/ios/fixtures/zig-slice`,
1164// which drives both actions on a simulator and asserts the events.
1165// =============================================================================
1166
1167const testing = std.testing;
1168
1169test "the action names match the Swift case labels exactly" {
1170 // A typo here routes nowhere and looks exactly like the shim still serving
1171 // both actions, which is the state this module just left.
1172 try testing.expectEqualStrings("startListening", A.start_listening);
1173 try testing.expectEqualStrings("stopListening", A.stop_listening);
1174}
1175
1176test "both actions are declared, and neither promises a reply" {
1177 try testing.expectEqual(@as(usize, 2), capability_actions.len);
1178 for (capability_actions) |decl| {
1179 // `.result` would tell an app to await something no path here sends.
1180 try testing.expectEqual(capabilities.Reply.none, decl.reply);
1181 try testing.expectEqual(capabilities.ActionStatus.live, decl.status);
1182 }
1183}
1184
1185test "an unrelated action falls through rather than being refused" {
1186 // `ios_dispatch.route` treats any error other than UnknownAction as final,
1187 // so a module that answered something else here would stop the chain and
1188 // strand an action another module serves.
1189 var bridge = SpeechBridge.init(testing.allocator);
1190 defer bridge.deinit();
1191 try testing.expectError(
1192 BridgeError.UnknownAction,
1193 bridge.handleMessage("getDeviceInfo", "{}"),
6591194 );
1195}
6601196
661 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, final, .{});
662 defer parsed.deinit();
663 try testing.expectEqual(@as(usize, 2), parsed.value.object.count());
664 try testing.expectEqualStrings("hello world", parsed.value.object.get("transcript").?.string);
665 try testing.expectEqual(true, parsed.value.object.get("isFinal").?.bool);
1197test "a malformed payload is not a reason to refuse either action" {
1198 // Neither action reads a field, so neither parses one. A parser here would
1199 // invent a failure the spec has not got — and it would surface as a
1200 // dispatcher error under a request id no page holds.
1201 var bridge = SpeechBridge.init(testing.allocator);
1202 defer bridge.deinit();
1203
1204 for ([_][]const u8{ "", "{", "not json at all", "[1,2,3]" }) |payload| {
1205 try bridge.handleMessage(A.stop_listening, payload);
1206 }
1207
1208 // And the stop left no state behind, which is what makes it idempotent.
1209 try testing.expect(!listening);
1210 try testing.expect(!tap_installed);
1211 try testing.expectEqual(@as(usize, 0), active_request.load(.seq_cst));
6661212}
6671213
668test "the detail does not invent keys the recognizer could have supplied" {
669 // `SFSpeechRecognitionResult` also carries `transcriptions`,
670 // `bestTranscription.segments` and per-segment `confidence`, and Swift
671 // reads none of them. Adding one would be a surface no consumer reads and
672 // a divergence from the emitter contract this migration preserves.
673 const json = try shapeResultDetail(testing.allocator, "x", true);
674 defer testing.allocator.free(json);
1214test "the four error messages are Swift's, in Swift's order" {
1215 try testing.expectEqual(@as(usize, 4), error_messages.len);
1216 try testing.expectEqualStrings("Not authorized", error_messages[0]);
1217 try testing.expectEqualStrings("Audio session failed", error_messages[1]);
1218 try testing.expectEqualStrings("Speech recognizer unavailable", error_messages[2]);
1219 try testing.expectEqualStrings("Audio engine failed", error_messages[3]);
1220
1221 // And every StartFailure maps into that set, so no path can emit a fifth
1222 // string by accident.
1223 inline for (@typeInfo(StartFailure).error_set.error_names.?) |name| {
1224 const message = messageFor(@field(StartFailure, name));
1225 var found = false;
1226 for (error_messages) |known| {
1227 if (std.mem.eql(u8, known, message)) found = true;
1228 }
1229 try testing.expect(found);
1230 }
1231}
6751232
676 try testing.expect(std.mem.indexOf(u8, json, "confidence") == null);
677 try testing.expect(std.mem.indexOf(u8, json, "alternatives") == null);
678 try testing.expect(std.mem.indexOf(u8, json, "segments") == null);
679 try testing.expect(std.mem.indexOf(u8, json, "\"transcript\":") != null);
680 try testing.expect(std.mem.indexOf(u8, json, "\"isFinal\":") != null);
1233test "a zero-format refusal reports the engine, not the session" {
1234 // The one message this module emits that Swift never reaches. Picking
1235 // `err_audio_session` would blame a session that succeeded; picking a
1236 // fifth string would put bytes on a page no other arm can produce.
1237 try testing.expectEqualStrings(err_audio_engine, messageFor(error.AudioEngine));
1238 try testing.expectEqualStrings(err_audio_session, messageFor(error.AudioSession));
6811239}
6821240
683test "isFinal is a JSON boolean, never a quoted string" {
684 // `${e.detail.isFinal}` renders `"false"` and `false` identically, so a
685 // quoted value would read as correct on the test page while being truthy
686 // to any code that branches on it.
687 const json = try shapeResultDetail(testing.allocator, "", false);
688 defer testing.allocator.free(json);
1241test "the four event names are the ones Swift's sendToWeb spells" {
1242 try testing.expectEqualStrings("craftSpeechStart", event_start.eventName());
1243 try testing.expectEqualStrings("craftSpeechResult", event_result.eventName());
1244 try testing.expectEqualStrings("craftSpeechError", event_error.eventName());
1245 try testing.expectEqualStrings("craftSpeechEnd", event_end.eventName());
1246}
6891247
690 try testing.expect(std.mem.indexOf(u8, json, "\"isFinal\":false") != null);
691 try testing.expect(std.mem.indexOf(u8, json, "\"false\"") == null);
1248test "the empty detail is an object a page can index into" {
1249 // `ios_events.formatEvent` inlines this as a literal. An empty string
1250 // would make `e.detail.anything` a TypeError rather than undefined.
1251 try testing.expectEqualStrings("{}", empty_detail);
1252}
6921253
693 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
694 defer parsed.deinit();
695 try testing.expectEqual(false, parsed.value.object.get("isFinal").?.bool);
1254test "an error detail carries the message under the key Swift uses" {
1255 const detail = try shapeErrorDetail(testing.allocator, err_not_authorized);
1256 defer testing.allocator.free(detail);
1257 try testing.expectEqualStrings("{\"error\":\"Not authorized\"}", detail);
6961258}
6971259
698test "an empty transcript still emits the key" {
699 // A first partial result can legitimately be empty. Omitting the key would
700 // leave `e.detail.transcript` undefined, which a page cannot tell from a
701 // bridge that stopped working.
702 const json = try shapeResultDetail(testing.allocator, "", false);
703 defer testing.allocator.free(json);
1260test "an error message with JavaScript metacharacters survives escaping" {
1261 // No current message needs this. The escaping is a property of the
1262 // position — a JSON string inlined into JavaScript source — so it is
1263 // pinned against the message that has not been written yet.
1264 const detail = try shapeErrorDetail(testing.allocator, "quote \" and \\ and\nnewline");
1265 defer testing.allocator.free(detail);
1266 try testing.expectEqualStrings(
1267 "{\"error\":\"quote \\\" and \\\\ and\\nnewline\"}",
1268 detail,
1269 );
1270}
7041271
705 try testing.expectEqualStrings("{\"transcript\":\"\",\"isFinal\":false}", json);
1272test "a transcript detail carries both keys, with isFinal as a real boolean" {
1273 const partial = try shapeResultDetail(testing.allocator, "hello", false);
1274 defer testing.allocator.free(partial);
1275 try testing.expectEqualStrings(
1276 "{\"transcript\":\"hello\",\"isFinal\":false}",
1277 partial,
1278 );
7061279
707 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
708 defer parsed.deinit();
709 try testing.expectEqual(@as(usize, 2), parsed.value.object.count());
710 try testing.expectEqualStrings("", parsed.value.object.get("transcript").?.string);
1280 const final = try shapeResultDetail(testing.allocator, "hello there", true);
1281 defer testing.allocator.free(final);
1282 try testing.expectEqualStrings(
1283 "{\"transcript\":\"hello there\",\"isFinal\":true}",
1284 final,
1285 );
7111286}
7121287
713test "a transcript with a quote does not truncate the event" {
714 // The failure this shaper exists to prevent, and the one
715 // `ios_events.zig` records against `bridge_iap.zig:362`: the detail is
716 // inlined into JavaScript source, so an unescaped `"` does not corrupt one
717 // field, it ends the object early and makes the whole `dispatchEvent` call
718 // a syntax error. The page sees the stream stop, not a bad transcript.
719 //
720 // Dictation really does produce these: it transcribes spoken quotation
721 // marks, and `formattedString` is not sanitised.
722 const json = try shapeResultDetail(testing.allocator, "she said \"hello\"", true);
723 defer testing.allocator.free(json);
1288test "an empty transcript still emits the key" {
1289 // A first partial result can legitimately be empty, and an omitted key
1290 // reads to a page exactly like a broken bridge.
1291 const detail = try shapeResultDetail(testing.allocator, "", false);
1292 defer testing.allocator.free(detail);
1293 try testing.expectEqualStrings("{\"transcript\":\"\",\"isFinal\":false}", detail);
1294}
7241295
1296test "a spoken quote cannot end the object early" {
1297 // Arbitrary speech lands in a JavaScript source position. An unescaped
1298 // quote would not corrupt one field: it would make the whole dispatchEvent
1299 // call a syntax error, and the page would see the stream stop with no
1300 // event to say why.
1301 const detail = try shapeResultDetail(testing.allocator, "say \"stop\" now", true);
1302 defer testing.allocator.free(detail);
7251303 try testing.expectEqualStrings(
726 "{\"transcript\":\"she said \\\"hello\\\"\",\"isFinal\":true}",
727 json,
1304 "{\"transcript\":\"say \\\"stop\\\" now\",\"isFinal\":true}",
1305 detail,
7281306 );
7291307
730 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
1308 // And the result is parseable, which is the property the escaping exists
1309 // for rather than the exact bytes.
1310 const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, detail, .{});
7311311 defer parsed.deinit();
7321312 try testing.expectEqualStrings(
733 "she said \"hello\"",
1313 "say \"stop\" now",
7341314 parsed.value.object.get("transcript").?.string,
7351315 );
1316 try testing.expectEqual(true, parsed.value.object.get("isFinal").?.bool);
7361317}
7371318
738test "backslashes, newlines and control bytes survive as escapes" {
739 // A literal newline inside a JavaScript string literal is a syntax error,
740 // and a lone backslash would escape whatever followed it. Both reach the
741 // page as a dead event rather than as bad text.
742 const json = try shapeResultDetail(testing.allocator, "a\\b\nc\td\r\x01", false);
743 defer testing.allocator.free(json);
1319test "the tap reads nothing while the request is unpublished" {
1320 // The handoff, exercised without an audio device. Zero means "nobody may
1321 // append", and it is the state both before a start and after a stop —
1322 // which is what makes a tap call that outlives its session harmless
1323 // rather than a use-after-free.
1324 const saved = active_request.swap(0, .seq_cst);
1325 defer active_request.store(saved, .seq_cst);
7441326
745 try testing.expectEqualStrings(
746 "{\"transcript\":\"a\\\\b\\nc\\td\\r\\u0001\",\"isFinal\":false}",
747 json,
748 );
1327 try testing.expectEqual(@as(usize, 0), active_request.load(.seq_cst));
7491328
750 // No raw control byte survived into the bytes that become JS source.
751 for (json) |c| try testing.expect(c >= 0x20);
1329 // The tap's own guard, spelled the way the tap spells it.
1330 try testing.expect(active_request.load(.seq_cst) == 0);
1331}
7521332
753 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
754 defer parsed.deinit();
755 try testing.expectEqualStrings("a\\b\nc\td\r\x01", parsed.value.object.get("transcript").?.string);
1333test "the drain reports success only when the audio thread has left" {
1334 // `drainTap` is what stands between a stop and a use-after-free, so its
1335 // two answers are pinned rather than assumed. The busy case deliberately
1336 // runs the full bounded wait: 20ms is the cost of proving it gives up
1337 // instead of spinning forever.
1338 tap_in_flight.store(0, .seq_cst);
1339 try testing.expect(drainTap());
1340
1341 tap_in_flight.store(1, .seq_cst);
1342 try testing.expect(!drainTap());
1343
1344 tap_in_flight.store(0, .seq_cst);
7561345}
7571346
758test "an apostrophe passes through unescaped, and must" {
759 // The opposite error, and an easy one to make while fixing the quote case.
760 // `formatEvent` single-quotes the event *name* only; the detail is an
761 // object literal, where `'` is an ordinary character inside a
762 // double-quoted JSON string. Escaping it would put a literal backslash
763 // into the transcript the page displays — and "don't" and "it's" are the
764 // most common words dictation produces.
765 const json = try shapeResultDetail(testing.allocator, "don't stop", true);
766 defer testing.allocator.free(json);
1347test "the native constants are the SDK's, not remembered" {
1348 // Every one of these was read out of a header rather than recalled, after
1349 // a HealthKit option guessed at 1 << 2 turned out to be 1 << 4 and took
1350 // the process down with an unrecoverable exception inside the framework.
1351 //
1352 // SFSpeechRecognizer.h:25-41 implicit 0..3, Authorized last
1353 // AVAudioSessionTypes.h:466 DuckOthers = 0x2
1354 // AVAudioSessionTypes.h:660 NotifyOthersOnDeactivation = 1
1355 // AVAudioNode.h:110 bufferSize 1024 in the header's own example
1356 try testing.expectEqual(@as(isize, 3), status_authorized);
1357 try testing.expectEqual(@as(c_ulong, 2), category_option_duck_others);
1358 try testing.expectEqual(@as(c_ulong, 1), set_active_notify_others);
1359 try testing.expectEqual(@as(u32, 1024), tap_buffer_frames);
1360 try testing.expectEqual(@as(c_ulong, 0), bus_zero);
1361
1362 // DuckOthers is 0x2 and MixWithOthers is 0x1; picking the neighbour would
1363 // silently stop ducking rather than fail.
1364 try testing.expect(category_option_duck_others != 1);
1365}
7671366
768 try testing.expectEqualStrings("{\"transcript\":\"don't stop\",\"isFinal\":true}", json);
769 try testing.expect(std.mem.indexOfScalar(u8, json, '\\') == null);
1367test "the plist key is the exact proxy, and the microphone key is not" {
1368 // `index.ts:183` writes this key from `enableSpeechRecognition` alone.
1369 // `:184` writes NSMicrophoneUsageDescription from
1370 // `enableSpeechRecognition || enableAudioRecording`, so it is implied by
1371 // speech being on but cannot stand in for it — an audio-recording app
1372 // would carry it with speech off.
1373 try testing.expectEqualStrings("NSSpeechRecognitionUsageDescription", key_speech_usage);
1374}
7701375
771 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
772 defer parsed.deinit();
773 try testing.expectEqualStrings("don't stop", parsed.value.object.get("transcript").?.string);
1376test "a test runner has no speech usage description, so a start refuses" {
1377 // The shape of an app built with enableSpeechRecognition off, which is
1378 // what a test binary's bundle looks like. The refusal has to come from the
1379 // plist guard, before requestAuthorization: is reached — asking without
1380 // the key terminates the process rather than failing.
1381 if (!is_darwin) return error.SkipZigTest;
1382
1383 try testing.expect(!hasSpeechUsageDescription());
1384
1385 var bridge = SpeechBridge.init(testing.allocator);
1386 defer bridge.deinit();
1387 try bridge.handleMessage(A.start_listening, "{}");
1388
1389 // Nothing was built and no authorization request is outstanding.
1390 try testing.expect(!starting);
1391 try testing.expect(!listening);
1392 try testing.expect(recognizer == null);
1393 try testing.expect(engine == null);
1394 try testing.expectEqual(@as(usize, 0), active_request.load(.seq_cst));
7741395}
7751396
776test "multi-byte UTF-8 is carried through unchanged" {
777 // `formattedString` is whatever locale the recognizer was built with, and
778 // even `en-US` produces curly apostrophes and em dashes. Escaping per byte
779 // rather than per code point would split a sequence and hand the page
780 // invalid JSON.
781 const spoken = "café — naïve 日本語";
782 const json = try shapeResultDetail(testing.allocator, spoken, true);
783 defer testing.allocator.free(json);
1397test "stopping something that never started is safe, and still ends" {
1398 // Swift's stopSpeechRecognition() has no early return and no guard: a page
1399 // that stops twice gets two ends, and one that never started still gets
1400 // one. Bug-compatible on purpose — the event is what a listening indicator
1401 // binds to, and an end that does not arrive leaves it stuck on.
1402 var bridge = SpeechBridge.init(testing.allocator);
1403 defer bridge.deinit();
1404
1405 try bridge.handleMessage(A.stop_listening, "{}");
1406 try bridge.handleMessage(A.stop_listening, "{}");
7841407
785 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
786 defer parsed.deinit();
787 try testing.expectEqualStrings(spoken, parsed.value.object.get("transcript").?.string);
788 try testing.expect(std.unicode.utf8ValidateSlice(json));
789}
790
791test "a shaped detail is a complete JSON value, which is what emit requires" {
792 // `ios_events.emit` documents its argument as a complete JSON value and
793 // inlines it verbatim. A shaper returning a fragment, or leaving a
794 // trailing comma, would produce a `dispatchEvent` call the page cannot
795 // parse — an event that silently never arrives.
796 const details = [_][]u8{
797 try shapeResultDetail(testing.allocator, "one", false),
798 try shapeResultDetail(testing.allocator, "", true),
799 try shapeErrorDetail(testing.allocator, err_audio_engine),
800 };
801 defer for (details) |d| testing.allocator.free(d);
802
803 for (details) |detail| {
804 try testing.expect(std.mem.startsWith(u8, detail, "{"));
805 try testing.expect(std.mem.endsWith(u8, detail, "}"));
806 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, detail, .{});
807 defer parsed.deinit();
808 try testing.expect(parsed.value == .object);
809 }
1408 try testing.expect(!listening);
1409 try testing.expect(!tap_installed);
1410 try testing.expect(task == null);
1411 try testing.expectEqual(@as(usize, 0), active_request.load(.seq_cst));
8101412}
packages/zig/src/ios_config.zigmodified+1-0
Changes to packages/zig/src/ios_config.zig
@@ -434,6 +434,7 @@ pub fn gateFor(action: []const u8) ?Feature {
434434 .{ "secureClear", .secure_storage },
435435 .{ "startAudioRecording", .audio_recording },
436436 .{ "startBluetoothScan", .bluetooth },
437 .{ "startListening", .speech_recognition },
437438 .{ "secureGet", .secure_storage },
438439 .{ "secureRemove", .secure_storage },
439440 .{ "secureSet", .secure_storage },
packages/zig/src/ios_dispatch.zigmodified+2-7
Changes to packages/zig/src/ios_dispatch.zig
@@ -39,13 +39,7 @@ const bridge_mobile_pdf = ;
3939const bridge_mobile_bluetooth = ;
4040const bridge_mobile_audiorec = ;
4141const bridge_mobile_health = ;
42// `bridge_mobile_speech.zig` is deliberately NOT in the chain. Its
43// capability_actions table is empty — the two speech actions were researched
44// in full and left with the Swift shim — so a module here would iterate,
45// return UnknownAction, and cost a comparison to serve nothing. Keeping it in
46// the chain would also read as coverage it does not provide, which is the
47// habit this migration exists to break. The file stays as the record of that
48// decision and of the Swift behaviour a future port has to match.
42const bridge_mobile_speech = ;
4943
5044const objc = objc_runtime.objc;
5145
@@ -287,6 +281,7 @@ const mobile_bridges = .{
287281 bridge_mobile_bluetooth.BluetoothBridge,
288282 bridge_mobile_audiorec.AudioRecordingBridge,
289283 bridge_mobile_health.HealthBridge,
284 bridge_mobile_speech.SpeechBridge,
290285};
291286
292287/// Narrow an arbitrary handler error to one the page's error codes can express.
packages/zig/src/ios_events.zigmodified+2-0
Changes to packages/zig/src/ios_events.zig
@@ -49,6 +49,7 @@ pub const Event = enum {
4949 location_update,
5050 location_error,
5151 motion_update,
52 speech_start,
5253 speech_result,
5354 speech_error,
5455 speech_end,
@@ -64,6 +65,7 @@ pub const Event = enum {
6465 .location_update => "craftLocationUpdate",
6566 .location_error => "craftLocationError",
6667 .motion_update => "craftMotionUpdate",
68 .speech_start => "craftSpeechStart",
6769 .speech_result => "craftSpeechResult",
6870 .speech_error => "craftSpeechError",
6971 .speech_end => "craftSpeechEnd",
packages/zig/test/ios_conformance_test.zigmodified+5-1
Changes to packages/zig/test/ios_conformance_test.zig
@@ -100,7 +100,11 @@ const dispatch_end = "func webView(";
100100/// 48 after calendar and contacts. Speech was researched in full and left
101101/// with the shim: neither action replies, so unlike the earlier deferrals
102102/// there is no promise to strand and falling through costs the page nothing.
103const max_not_yet_migrated: usize = 28;
103/// 26 once speech came back and took both. The deferral had listed five
104/// preconditions and four blockers; the load-bearing one was the realtime
105/// audio tap, which is the first callback in this migration that runs on a
106/// thread where a lock is a bug rather than a slowdown.
107const max_not_yet_migrated: usize = 26;
104108
105109fn dispatcherRegion() []const u8 {
106110 const begin = std.mem.indexOf(u8, swift_spec, dispatch_begin) orelse return "";