ReviewOS

also looking at this

craft-native/craft

iOS Phase 9: calendar and contacts — ratchet 53 → 48

#91
Merged glennmichael123 wants to merge feat/ios-phase9-data into main
6 files +5708 -1
packages/zig/src/bridge_mobile_speech.zigadded+810-0
Changes to packages/zig/src/bridge_mobile_speech.zig
@@ -0,0 +1,810 @@
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.
4//!
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.
13//!
14//! ## The contract, read rather than assumed
15//!
16//! **Payload: none, on both actions.** The injected JS is the whole surface
17//! (`CraftApp.swift:1562-1568`):
18//!
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//! ```
27//!
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.
36//!
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 |
61//! |---|---|---|
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.
87//!
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.
104//!
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.
117//!
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
137//! `NSSpeechRecognitionUsageDescription` from that flag **and nothing else,
138//! 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.
144//!
145//! ## Why falling through, and not `.unavailable`
146//!
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.
153//!
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.
159//!
160//! ## What is finished here
161//!
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.
167//!
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.
174//!
175//! ## What a future round still owes
176//!
177//! Five preconditions, all five, before either action moves into `A`:
178//!
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.)
191//!
192//! And two decisions that are decisions, not facts, to be taken out loud:
193//!
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.
208//!
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.
215//!
216//! ## Nothing in this file touches Objective-C
217//!
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.
223
224const std = ;
225const capabilities = ;
226const bridge_error = ;
227const ios_events = ;
228
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.
240pub const A = struct {
241 /// How many actions this module claims. Zero, deliberately.
242 pub const declared_count: usize = 0;
243};
244
245/// Empty, and specifically not two `.unavailable` rows.
246///
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.
255///
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" };
262
263pub 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.
267 allocator: std.mem.Allocator,
268
269 const Self = @This();
270
271 pub fn init(allocator: std.mem.Allocator) Self {
272 return .{ .allocator = allocator };
273 }
274
275 pub fn deinit(_: *Self) void {}
276
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.
286 pub fn handleMessage(_: *Self, action: []const u8, _: []const u8) !void {
287 _ = action;
288 return bridge_error.BridgeError.UnknownAction;
289 }
290};
291
292// =============================================================================
293// The event vocabulary, as far as it can be spelled today.
294// =============================================================================
295
296/// `craftSpeechResult` — the transcript stream.
297pub const event_result: ios_events.Event = .speech_result;
298
299/// `craftSpeechError` — every refusal, on every path.
300pub const event_error: ios_events.Event = .speech_error;
301
302/// `craftSpeechEnd` — every `stopSpeechRecognition()`, including one that
303/// stops nothing.
304pub const event_end: ios_events.Event = .speech_end;
305
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
313/// The `data: [:]` of `craftSpeechStart` and `craftSpeechEnd` — an empty
314/// object, not an empty string and not `null`. `ios_events.formatEvent`
315/// inlines the detail as a literal, so `e.detail` must still be an object the
316/// page can index into.
317pub const empty_detail = "{}";
318
319// -----------------------------------------------------------------------------
320// The four `craftSpeechError` messages, verbatim from the Swift.
321//
322// Verbatim matters: `test-bridges.html:391-394` paints `e.detail.error` into
323// the page for a human to read, and `packages/ios/README.md:173-175` documents
324// the key as the thing to log. Nothing downstream matches on the text, which
325// is exactly why a paraphrase would change what a user reads without changing
326// anything a test could see.
327// -----------------------------------------------------------------------------
328
329/// `CraftApp.swift:2489` — the authorization status was not `.authorized`.
330/// A user declining is this, not a native call failing.
331pub const err_not_authorized = "Not authorized";
332
333/// `:2507` — `setCategory:mode:options:error:` or
334/// `setActive:withOptions:error:` failed.
335pub const err_audio_session = "Audio session failed";
336
337/// `:2515` — the request is nil, the recognizer is nil (the
338/// `enableSpeechRecognition` flag was off, `:439-440`), or `-isAvailable` is
339/// NO. A simulator commonly answers NO here.
340pub const err_recognizer_unavailable = "Speech recognizer unavailable";
341
342/// `:2546` — `-startAndReturnError:` returned NO.
343pub const err_audio_engine = "Audio engine failed";
344
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.
348pub const error_messages = [_][]const u8{
349 err_not_authorized,
350 err_audio_session,
351 err_recognizer_unavailable,
352 err_audio_engine,
353};
354
355// =============================================================================
356// The detail shapers. Pure, and the bytes are the contract.
357// =============================================================================
358
359/// The `detail` of one `craftSpeechError`: `{"error":"<message>"}`.
360///
361/// Escaped even though all four current messages are plain ASCII, because the
362/// escaping is a property of the position — a JSON string inlined into
363/// JavaScript source — and not of today's inputs. A fifth message quoting a
364/// framework error would otherwise be the bug.
365///
366/// Caller owns the returned slice.
367pub fn shapeErrorDetail(allocator: std.mem.Allocator, message: []const u8) ![]u8 {
368 var out: std.ArrayListUnmanaged(u8) = .empty;
369 errdefer out.deinit(allocator);
370
371 try out.appendSlice(allocator, "{\"error\":\"");
372 try bridge_error.appendJsonEscaped(allocator, &out, message);
373 try out.appendSlice(allocator, "\"}");
374
375 return out.toOwnedSlice(allocator);
376}
377
378/// The `detail` of one `craftSpeechResult`:
379/// `{"transcript":"<text>","isFinal":<bool>}`.
380///
381/// Two keys, Swift's two (`CraftApp.swift:2525-2528`), and no third. Swift
382/// builds a `Dictionary`, so its own key order is arbitrary and no page can
383/// depend on it; one order is fixed here so the bytes are testable.
384///
385/// `transcript` is escaped because it is arbitrary spoken text landing in a
386/// JavaScript source position. An unescaped `"` would not corrupt one field —
387/// it would end the object early and make the whole `dispatchEvent` call a
388/// syntax error, so the page would see the stream stop rather than see a bad
389/// transcript. That is the failure `ios_events.zig` records against
390/// `bridge_iap.zig:362`, and the reason Swift is safe here is only that
391/// `JSONSerialization` escapes on its behalf.
392///
393/// `isFinal` is a real JSON boolean, never the string `"true"`: the page
394/// interpolates it into a template literal (`test-bridges.html:386`), where a
395/// quoted `"false"` would render as `false` and read as correct while being a
396/// truthy value to any code that branches on it.
397///
398/// An empty transcript still emits the key. Swift always writes it — a first
399/// partial result can legitimately be empty — and omitting it would make
400/// `e.detail.transcript` `undefined`, which a page cannot tell from a broken
401/// bridge.
402///
403/// Caller owns the returned slice.
404pub fn shapeResultDetail(
405 allocator: std.mem.Allocator,
406 transcript: []const u8,
407 is_final: bool,
408) ![]u8 {
409 var out: std.ArrayListUnmanaged(u8) = .empty;
410 errdefer out.deinit(allocator);
411
412 try out.appendSlice(allocator, "{\"transcript\":\"");
413 try bridge_error.appendJsonEscaped(allocator, &out, transcript);
414 try out.appendSlice(allocator, "\",\"isFinal\":");
415 try out.appendSlice(allocator, if (is_final) "true" else "false");
416 try out.append(allocator, '}');
417
418 return out.toOwnedSlice(allocator);
419}
420
421// =============================================================================
422// Tests.
423//
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.
429//
430// Nothing here is Darwin-gated, because nothing in this file touches
431// Objective-C.
432// =============================================================================
433
434const testing = std.testing;
435
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 }
463 }
464}
465
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();
472
473 const E = bridge_error.BridgeError;
474
475 for (unserved_actions) |name| {
476 try testing.expectError(E.UnknownAction, bridge.handleMessage(name, "{}"));
477 }
478
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, "{}"));
496 }
497}
498
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();
507
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));
520 }
521 }
522}
523
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());
531
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));
539 }
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",
557 .{},
558 );
559 return error.SpeechStartBlockerLifted;
560 }
561
562 // Until then the name exists only as a string, and it is the right one.
563 try testing.expectEqualStrings("craftSpeechStart", event_start_name);
564}
565
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);
572
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());
576}
577
578// -----------------------------------------------------------------------------
579// craftSpeechError
580// -----------------------------------------------------------------------------
581
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);
591}
592
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);
598
599 try testing.expectEqualStrings("{\"error\":\"Not authorized\"}", json);
600
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);
605}
606
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);
611
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 }
617}
618
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);
625
626 try testing.expectEqualStrings(
627 "{\"error\":\"recognizer said \\\"no\\\"\\\\ever\"}",
628 json,
629 );
630
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 );
637}
638
639// -----------------------------------------------------------------------------
640// craftSpeechResult
641// -----------------------------------------------------------------------------
642
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 );
653
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,
659 );
660
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);
666}
667
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);
675
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);
681}
682
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);
689
690 try testing.expect(std.mem.indexOf(u8, json, "\"isFinal\":false") != null);
691 try testing.expect(std.mem.indexOf(u8, json, "\"false\"") == null);
692
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);
696}
697
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);
704
705 try testing.expectEqualStrings("{\"transcript\":\"\",\"isFinal\":false}", json);
706
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);
711}
712
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);
724
725 try testing.expectEqualStrings(
726 "{\"transcript\":\"she said \\\"hello\\\"\",\"isFinal\":true}",
727 json,
728 );
729
730 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
731 defer parsed.deinit();
732 try testing.expectEqualStrings(
733 "she said \"hello\"",
734 parsed.value.object.get("transcript").?.string,
735 );
736}
737
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);
744
745 try testing.expectEqualStrings(
746 "{\"transcript\":\"a\\\\b\\nc\\td\\r\\u0001\",\"isFinal\":false}",
747 json,
748 );
749
750 // No raw control byte survived into the bytes that become JS source.
751 for (json) |c| try testing.expect(c >= 0x20);
752
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);
756}
757
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);
767
768 try testing.expectEqualStrings("{\"transcript\":\"don't stop\",\"isFinal\":true}", json);
769 try testing.expect(std.mem.indexOfScalar(u8, json, '\\') == null);
770
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);
774}
775
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);
784
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 }
810}