ReviewOS

also looking at this

craft-native/craft

fix(ios): hand the page the answers native was already sending

#222
Merged glennmichael123 wants to merge fix/ios-unanswered-posts into main
18 files +597 -164
.github/workflows/README.mdmodified+2-2
Changes to .github/workflows/README.md
@@ -30,8 +30,8 @@ bun run test:mobile-e2e:ios
3030```
3131
3232```
33ok ios-shim — 6/6 cases, no zig
34ok ios-runtime — 6/6 cases, zig served checkPermission, clipboardRead, clipboardWrite, getCurrentPosition, getDeviceInfo, log, requestPermission, share
33ok ios-shim — 12/12 cases, no zig
34ok ios-runtime — 12/12 cases, zig served checkPermission, clearWatch, clipboardRead, clipboardWrite, getCurrentPosition, getDeviceInfo, haptic, log, requestPermission, share, startListening, stopListening, vibrate, watchPosition
3535```
3636
3737After the suite, each leg cold-starts the app through a link twice, from an
docs/api/mobile/haptics.mdmodified+7-0
Changes to docs/api/mobile/haptics.md
@@ -2,6 +2,13 @@
22
33Provide tactile feedback through device vibrations.
44
5Haptics are feedback, so these methods never stop a flow for a capability the
6app left off. On iOS, with `enableHaptics` off in `craft.config.json`, they
7resolve without playing anything, the way the web fallback does on a device
8with no vibration motor. They reject only when the native call itself fails.
9The raw `window.craft.haptic()` is different: it rejects with
10`CAPABILITY_DISABLED`, for code that needs to know.
11
512## Import
613
714```typescript
packages/ios/README.mdmodified+11-8
Changes to packages/ios/README.md
@@ -159,14 +159,17 @@ window.addEventListener('craftReady', (e) => {
159159 console.log('Capabilities:', e.detail.capabilities);
160160});
161161
162// Haptic feedback
163window.craft.haptic('light'); // light, medium, heavy
164window.craft.haptic('success'); // success, warning, error
165window.craft.haptic('selection');
166
167// Speech recognition
168window.craft.startListening();
169window.craft.stopListening();
162// Haptic feedback. Resolves true, and rejects with CAPABILITY_DISABLED when
163// enableHaptics is off.
164await window.craft.haptic('light'); // light, medium, heavy
165await window.craft.haptic('success'); // success, warning, error
166await window.craft.haptic('selection');
167
168// Speech recognition. startListening resolves true once the request is
169// taken; whether listening actually began arrives as the events below
170// (craftSpeechError for a declined prompt or no recognizer).
171await window.craft.startListening();
172await window.craft.stopListening();
170173
171174// Listen for speech events
172175window.addEventListener('craftSpeechStart', () => { /* recording started */ });
packages/ios/fixtures/zig-slice/page.mmodified+4-2
Changes to packages/ios/fixtures/zig-slice/page.m
@@ -33,8 +33,10 @@ 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"
36 "// Speech outcomes arrive on the event channel and nowhere else. Both\n"
37 "// actions reply true once the request is taken (#207), which says\n"
38 "// nothing about what happened next, so these three listeners are what\n"
39 "// the assertions read.\n"
3840 "// A start settles as EITHER craftSpeechStart or craftSpeechError, and both\n"
3941 "// count: the first means the engine is running, the second means the\n"
4042 "// authorization block fired, hopped to the main queue and named a reason.\n"
packages/ios/src/page-script.test.tsadded+147-0
Changes to packages/ios/src/page-script.test.ts
@@ -0,0 +1,147 @@
1import { readFileSync } from 'node:fs'
2import { join } from 'node:path'
3import { describe, expect, it } from 'bun:test'
4
5// The script CraftApp.swift injects into every page, run here against a fake
6// native side. The E2E suite runs it on a simulator too, but with one config
7// per run, so a path that needs a capability both on and off is checked here.
8
9const template = readFileSync(join(import.meta.dir, '../templates/CraftApp.swift'), 'utf8')
10
11/** The page script, as the string Swift evaluates, with every flag off. */
12function pageScript(): string {
13 const opening = 'let script = """\n window.craft = {'
14 const start = template.indexOf(opening)
15 if (start === -1) throw new Error('CraftApp.swift no longer injects `window.craft = {` from `let script`')
16 const end = template.indexOf('\n """', start)
17 return template
18 .slice(template.indexOf('\n', start) + 1, end)
19 // `\(config.enableHaptics)` and the like. Each is a Bool in the template.
20 .replace(/\\\((?:[^()]|\([^()]*\))*\)/g, 'false')
21 .replace(/\\\\/g, '\\')
22}
23
24interface Post { action: string, callbackId?: string, [key: string]: unknown }
25
26function loadPage() {
27 const posts: Post[] = []
28 const listeners: Record<string, ((event: { type: string, detail: unknown }) => void)[]> = {}
29 const page: Record<string, any> = {
30 webkit: { messageHandlers: { craft: { postMessage: (message: Post) => posts.push(message) } } },
31 addEventListener: (type: string, listener: (event: { type: string, detail: unknown }) => void) => {
32 (listeners[type] ||= []).push(listener)
33 },
34 removeEventListener: () => {},
35 dispatchEvent: (event: { type: string, detail: unknown }) => {
36 for (const listener of listeners[event.type] || []) listener(event)
37 return true
38 },
39 location: { href: 'craft://app/' },
40 }
41 class CustomEvent {
42 type: string
43 detail: unknown
44 constructor(type: string, options?: { detail?: unknown }) {
45 this.type = type
46 this.detail = options?.detail
47 }
48 }
49 const quiet = { log() {}, warn() {}, error() {} }
50 // eslint-disable-next-line no-new-func
51 new Function('window', 'document', 'navigator', 'CustomEvent', 'console', pageScript())(
52 page,
53 { addEventListener() {}, readyState: 'complete' },
54 {},
55 CustomEvent,
56 quiet,
57 )
58
59 const craft = page.craft
60 /** The last message the page posted for `action`. */
61 const last = (action: string): Post => {
62 const post = posts.findLast(message => message.action === action)
63 if (!post) throw new Error(`the page never posted ${action}`)
64 return post
65 }
66 return {
67 craft,
68 last,
69 // What Swift's resolveCallback and rejectCallback evaluate.
70 answer: (action: string, value: unknown) => craft._resolveCallback(last(action).callbackId, value),
71 refuse: (action: string, message: string, code: string) => craft._rejectCallback(last(action).callbackId, message, code),
72 }
73}
74
75describe('the injected iOS page script', () => {
76 // #207: each of these posted no callbackId, so Swift's reply helpers, which
77 // return early on a nil id, dropped the answer and the page got undefined.
78 const calls: [string, (craft: any) => unknown][] = [
79 ['haptic', craft => craft.haptic('light')],
80 ['vibrate', craft => craft.vibrate([100, 50, 100])],
81 ['startListening', craft => craft.startListening()],
82 ['stopListening', craft => craft.stopListening()],
83 ['watchPosition', craft => craft.geolocation.watchPosition(() => {})],
84 ['clearWatch', craft => craft.geolocation.clearWatch()],
85 ]
86
87 for (const [action, call] of calls) {
88 it(`hands ${action} the answer native sends`, async () => {
89 const page = loadPage()
90 const returned = call(page.craft)
91
92 expect(page.last(action).callbackId).toMatch(/^cb_\d+$/)
93 expect(returned).toBeInstanceOf(Promise)
94 page.answer(action, true)
95 expect(await returned).toBe(true)
96 })
97 }
98
99 it('hands a refusal to the caller of the raw call', async () => {
100 const page = loadPage()
101 const haptic = page.craft.haptic('light')
102 page.refuse('haptic', 'Haptics is disabled', 'CAPABILITY_DISABLED')
103
104 await expect(haptic).rejects.toMatchObject({ code: 'CAPABILITY_DISABLED' })
105 })
106
107 it('sends the v1 location clearWatch with an id once the last watch leaves', async () => {
108 const page = loadPage()
109 const first = page.craft.location.watchPosition(() => {})
110 const second = page.craft.location.watchPosition(() => {})
111 page.answer('watchPosition', true)
112
113 page.craft.location.clearWatch(first)
114 expect(() => page.last('clearWatch')).toThrow()
115 page.craft.location.clearWatch(second)
116 expect(page.last('clearWatch').callbackId).toMatch(/^cb_\d+$/)
117 page.answer('clearWatch', true)
118 })
119
120 // Haptics are feedback. An app that left enableHaptics off must not have
121 // `await haptics.selection()` stop the flow it sits in, which is how the
122 // SDK documents it and how Android and the web fallback behave.
123 const feedback: [string, string, (craft: any) => Promise<unknown>][] = [
124 ['impact', 'haptic', craft => craft.haptics.impact('heavy')],
125 ['notification', 'haptic', craft => craft.haptics.notification('error')],
126 ['selection', 'haptic', craft => craft.haptics.selection()],
127 ['vibrate', 'vibrate', craft => craft.haptics.vibrate([100])],
128 ]
129
130 for (const [name, action, call] of feedback) {
131 it(`settles haptics.${name} with nothing played when haptics are off`, async () => {
132 const page = loadPage()
133 const settled = call(page.craft)
134 page.refuse(action, 'Haptics is disabled', 'CAPABILITY_DISABLED')
135
136 expect(await settled).toBeUndefined()
137 })
138
139 it(`still rejects haptics.${name} when the native call fails`, async () => {
140 const page = loadPage()
141 const settled = call(page.craft)
142 page.refuse(action, 'Native API call failed', 'NATIVE_CALL_FAILED')
143
144 await expect(settled).rejects.toMatchObject({ code: 'NATIVE_CALL_FAILED' })
145 })
146 }
147})
packages/ios/templates/CraftApp.swiftmodified+69-14
Changes to packages/ios/templates/CraftApp.swift
@@ -667,16 +667,23 @@ struct CraftWebView: UIViewRepresentable {
667667 switch action {
668668 case "startListening":
669669 if config.enableSpeechRecognition {
670 // Answers that the request was taken. Authorization and the
671 // recognizer answer later, as craftSpeech* events; waiting
672 // for them here would leave the promise open on every path
673 // that ends without one.
670674 startSpeechRecognition()
675 resolveCallback(callbackId, result: true)
671676 } else {
672677 rejectCallback(callbackId, error: "Speech recognition is disabled", code: "CAPABILITY_DISABLED")
673678 }
674679 case "stopListening":
675680 stopSpeechRecognition()
681 resolveCallback(callbackId, result: true)
676682 case "haptic":
677683 if config.enableHaptics {
678684 let style = body["style"] as? String ?? "medium"
679685 triggerHaptic(style: style)
686 resolveCallback(callbackId, result: true)
680687 } else {
681688 rejectCallback(callbackId, error: "Haptics is disabled", code: "CAPABILITY_DISABLED")
682689 }
@@ -2013,16 +2020,37 @@ struct CraftWebView: UIViewRepresentable {
20132020 return Promise.reject(new Error('Android fitness APIs are unavailable on iOS'));
20142021 },
20152022
2023 // Resolves true once UIKit has taken the haptic, and rejects
2024 // CAPABILITY_DISABLED when enableHaptics is off.
20162025 haptic: function(style) {
2017 window.webkit.messageHandlers.craft.postMessage({action: 'haptic', style: style || 'medium'});
2026 var self = window.craft;
2027 var id = 'cb_' + (++self._callbackId);
2028 window.webkit.messageHandlers.craft.postMessage({action: 'haptic', style: style || 'medium', callbackId: id});
2029 return new Promise(function(resolve, reject) {
2030 self._callbacks[id] = {resolve: resolve, reject: reject};
2031 });
20182032 },
20192033
2034 // Resolves true once native has taken the request, not once
2035 // audio is flowing. What happens next (a prompt declined, no
2036 // recognizer, a transcript) still arrives as craftSpeech*
2037 // events, because none of it is known when this answers.
20202038 startListening: function() {
2021 window.webkit.messageHandlers.craft.postMessage({action: 'startListening'});
2039 var self = window.craft;
2040 var id = 'cb_' + (++self._callbackId);
2041 window.webkit.messageHandlers.craft.postMessage({action: 'startListening', callbackId: id});
2042 return new Promise(function(resolve, reject) {
2043 self._callbacks[id] = {resolve: resolve, reject: reject};
2044 });
20222045 },
20232046
20242047 stopListening: function() {
2025 window.webkit.messageHandlers.craft.postMessage({action: 'stopListening'});
2048 var self = window.craft;
2049 var id = 'cb_' + (++self._callbackId);
2050 window.webkit.messageHandlers.craft.postMessage({action: 'stopListening', callbackId: id});
2051 return new Promise(function(resolve, reject) {
2052 self._callbacks[id] = {resolve: resolve, reject: reject};
2053 });
20262054 },
20272055
20282056 share: function(text) {
@@ -2169,12 +2197,25 @@ struct CraftWebView: UIViewRepresentable {
21692197 }
21702198 });
21712199 },
2200 // Resolves true once updates have started, before any
2201 // authorization answer. A later refusal arrives as a
2202 // craftLocationError event.
21722203 watchPosition: function(callback) {
2204 var self = window.craft;
2205 var id = 'cb_' + (++self._callbackId);
21732206 window.addEventListener('craftLocationUpdate', function(e) { callback(e.detail); });
2174 window.webkit.messageHandlers.craft.postMessage({action: 'watchPosition'});
2207 window.webkit.messageHandlers.craft.postMessage({action: 'watchPosition', callbackId: id});
2208 return new Promise(function(resolve, reject) {
2209 self._callbacks[id] = {resolve: resolve, reject: reject};
2210 });
21752211 },
21762212 clearWatch: function() {
2177 window.webkit.messageHandlers.craft.postMessage({action: 'clearWatch'});
2213 var self = window.craft;
2214 var id = 'cb_' + (++self._callbackId);
2215 window.webkit.messageHandlers.craft.postMessage({action: 'clearWatch', callbackId: id});
2216 return new Promise(function(resolve, reject) {
2217 self._callbacks[id] = {resolve: resolve, reject: reject};
2218 });
21782219 }
21792220 },
21802221
@@ -2274,7 +2315,12 @@ struct CraftWebView: UIViewRepresentable {
22742315
22752316 // Vibrate
22762317 vibrate: function(pattern) {
2277 window.webkit.messageHandlers.craft.postMessage({action: 'vibrate', pattern: pattern});
2318 var self = window.craft;
2319 var id = 'cb_' + (++self._callbackId);
2320 window.webkit.messageHandlers.craft.postMessage({action: 'vibrate', pattern: pattern, callbackId: id});
2321 return new Promise(function(resolve, reject) {
2322 self._callbacks[id] = {resolve: resolve, reject: reject};
2323 });
22782324 },
22792325
22802326 // Open URL
@@ -2924,14 +2970,25 @@ struct CraftWebView: UIViewRepresentable {
29242970 getInfo: function() { return craft.getDeviceInfo(); },
29252971 getCapabilities: function() { return Promise.resolve(Object.assign({}, craft.capabilities)); }
29262972 };
2973 // Feedback, as on Android and the web, where there is no
2974 // motor to fire: an app that left enableHaptics off gets
2975 // nothing played and a settled promise, so `await
2976 // haptics.selection()` in the middle of a flow does not stop
2977 // the flow. A native failure still rejects, and
2978 // craft.haptic() itself reports the refusal.
2979 function hapticFeedback(answer) {
2980 return answer.then(function() {}, function(error) {
2981 if (error && error.code === 'CAPABILITY_DISABLED') return;
2982 throw error;
2983 });
2984 }
29272985 craft.haptics = {
2928 impact: function(style) { craft.haptic(style || 'medium'); return Promise.resolve(); },
2986 impact: function(style) { return hapticFeedback(craft.haptic(style || 'medium')); },
29292987 notification: function(type) {
2930 craft.haptic(type === 'error' ? 'heavy' : type === 'warning' ? 'medium' : 'light');
2931 return Promise.resolve();
2988 return hapticFeedback(craft.haptic(type === 'error' ? 'heavy' : type === 'warning' ? 'medium' : 'light'));
29322989 },
2933 selection: function() { craft.haptic('soft'); return Promise.resolve(); },
2934 vibrate: function(pattern) { craft.vibrate(pattern || []); return Promise.resolve(); }
2990 selection: function() { return hapticFeedback(craft.haptic('soft')); },
2991 vibrate: function(pattern) { return hapticFeedback(craft.vibrate(pattern || [])); }
29352992 };
29362993 craft.permissions = {
29372994 check: function(permission) { return craft._invoke('checkPermission', {permission: permission}); },
@@ -2971,9 +3028,7 @@ struct CraftWebView: UIViewRepresentable {
29713028 },
29723029 clearWatch: function(id) {
29733030 locationWatchCallbacks.delete(id);
2974 if (locationWatchCallbacks.size === 0) {
2975 window.webkit.messageHandlers.craft.postMessage({action: 'clearWatch'});
2976 }
3031 if (locationWatchCallbacks.size === 0) void craft._invoke('clearWatch');
29773032 },
29783033 startRecording: function() { return craft._invoke('startLocationRecording'); },
29793034 pauseRecording: function() { return craft._invoke('pauseLocationRecording'); },
packages/react/src/index.tsmodified+8-1
Changes to packages/react/src/index.ts
@@ -127,7 +127,14 @@ export function useHaptic() {
127127 console.warn('Craft not ready');
128128 return;
129129 }
130 await craft.haptic(type);
130 try {
131 await craft.haptic(type);
132 }
133 catch (error) {
134 // Feedback: an app built with haptics off plays nothing, the same as
135 // Craft not being ready. Any other failure still reaches the caller.
136 if ((error as { code?: string } | null)?.code !== 'CAPABILITY_DISABLED') throw error;
137 }
131138 },
132139 [craft, isReady]
133140 );
packages/svelte/src/actions/haptic.tsmodified+8-1
Changes to packages/svelte/src/actions/haptic.ts
@@ -5,7 +5,14 @@ export function haptic(node: HTMLElement, type: string = 'selection') {
55 const handleClick = async () => {
66 const _$craft = get(craft);
77 if (!_$craft) return;
8 await _$craft.haptic(type);
8 try {
9 await _$craft.haptic(type);
10 }
11 catch (error) {
12 // Feedback: an app built with haptics off plays nothing on click, rather
13 // than an unhandled rejection. Any other failure still surfaces.
14 if ((error as { code?: string } | null)?.code !== 'CAPABILITY_DISABLED') throw error;
15 }
916 };
1017
1118 node.addEventListener('click', handleClick);
packages/typescript/types/craft.d.tsmodified+16-4
Changes to packages/typescript/types/craft.d.ts
@@ -114,8 +114,11 @@ export interface CraftBridge {
114114 /**
115115 * Trigger haptic feedback
116116 * @param style - Feedback style: 'light', 'medium', 'heavy', 'success', 'warning', 'error', 'selection'
117 * @returns On iOS, a promise that resolves `true` once UIKit has taken the
118 * haptic, and rejects with `CAPABILITY_DISABLED` when `enableHaptics` is off.
119 * Android returns nothing yet (#219).
117120 */
118 haptic(style: HapticStyle): void;
121 haptic(style: HapticStyle): Promise<boolean> | void;
119122
120123 /**
121124 * Open the native share sheet.
@@ -189,13 +192,20 @@ export interface CraftBridge {
189192
190193 /**
191194 * Start speech recognition
195 * @returns On iOS, a promise that resolves `true` once native has taken the
196 * request, and rejects with `CAPABILITY_DISABLED` when
197 * `enableSpeechRecognition` is off. `true` does not mean audio is flowing:
198 * a declined prompt or a missing recognizer still arrives as
199 * `craftSpeechError`, and transcripts as `craftSpeechResult`. Android returns
200 * nothing yet (#219).
192201 */
193 startListening(): void;
202 startListening(): Promise<boolean> | void;
194203
195204 /**
196205 * Stop speech recognition
206 * @returns On iOS, a promise that resolves `true`. Android returns nothing yet (#219).
197207 */
198 stopListening(): void;
208 stopListening(): Promise<boolean> | void;
199209
200210 // ==================== Files ====================
201211
@@ -478,8 +488,10 @@ export interface CraftBridge {
478488 /**
479489 * Vibrate with pattern
480490 * @param pattern - Array of durations in ms [on, off, on, off, ...]
491 * @returns On iOS, a promise that resolves `true` once the pattern is
492 * scheduled. Android returns nothing yet (#219).
481493 */
482 vibrate(pattern: number[]): void;
494 vibrate(pattern: number[]): Promise<boolean> | void;
483495
484496 /**
485497 * Open URL in external browser
packages/vue/src/index.tsmodified+8-1
Changes to packages/vue/src/index.ts
@@ -128,7 +128,14 @@ export function useHaptic() {
128128 console.warn('Craft not ready');
129129 return;
130130 }
131 await craft.value.haptic(type);
131 try {
132 await craft.value.haptic(type);
133 }
134 catch (error) {
135 // Feedback: an app built with haptics off plays nothing, the same as
136 // Craft not being ready. Any other failure still reaches the caller.
137 if ((error as { code?: string } | null)?.code !== 'CAPABILITY_DISABLED') throw error;
138 }
132139 };
133140
134141 return { haptic };
packages/zig/src/bridge_mobile.zigmodified+3-3
Changes to packages/zig/src/bridge_mobile.zig
@@ -20,9 +20,9 @@ const objc = objc_runtime.objc;
2020/// `{success:true, browser:true}` and made "the bridge worked" and "there is no
2121/// bridge" identical to the caller.
2222///
23/// `haptic` would have been the obvious first pick and is the wrong one: it is
24/// fire-and-forget, so it exercises no reply path, and Core Haptics is a silent
25/// no-op on the simulator — it would have passed while doing nothing.
23/// `haptic` would have been the obvious first pick and is the wrong one: Core
24/// Haptics is a silent no-op on the simulator, so its `true` says only that
25/// UIKit took the call — it would have passed while doing nothing.
2626pub const A = struct {
2727 pub const get_device_info = "getDeviceInfo";
2828};
packages/zig/src/bridge_mobile_haptics.zigmodified+26-30
Changes to packages/zig/src/bridge_mobile_haptics.zig
@@ -1,8 +1,8 @@
11//! The haptics half of the `mobile` namespace: `haptic` and `vibrate`.
22//!
3//! Split out of `bridge_mobile.zig` because the two actions have opposite
4//! contracts: `haptic` is fire-and-forget and gated on `config.enableHaptics`,
5//! `vibrate` replies and is gated on nothing.
3//! Split out of `bridge_mobile.zig` because the two actions are gated
4//! differently: `haptic` on `config.enableHaptics`, `vibrate` on nothing. Both
5//! answer `true` once UIKit has taken the call.
66//!
77//! ## The refusal that used to live here
88//!
@@ -83,23 +83,21 @@ fn hapticTypeIn(allocator: std.mem.Allocator, data: []const u8) !HapticType {
8383/// `craft.vibrate([100])` fires impacts on an app with haptics "disabled".
8484/// Adding a gate here would be a divergence dressed as a tidy-up.
8585pub const capability_actions = [_]capabilities.ActionDecl{
86 // `.none` is the contract: the page's `craft.haptic()` returns undefined
87 // and the spec's `case "haptic"` never touches `resolveCallback`. An error
88 // still reaches the page — with no pending entry to settle,
89 // `craft-bridge.js` reports it to the console — so a refusal is logged
90 // rather than swallowed.
91 .{ .name = A.haptic, .reply = .none },
86 // `.result`, the same `true` as `vibrate`. The page's `craft.haptic()`
87 // awaits it, so a refusal from the gate reaches the caller, and `.none`
88 // would leave that promise open on every call that succeeds (#207).
89 .{ .name = A.haptic, .reply = .result },
9290 // `.result` carrying the JSON literal `true`. Not a shape worth improving:
9391 // the hand-off path already delivers exactly this today, and `.none` would
9492 // park any `_req`-based caller for the full 30s timeout.
9593 .{ .name = A.vibrate, .reply = .result },
9694};
9795
98/// The reply `vibrate` sends, byte-identical to what the hand-off path delivers
99/// today. A bare `true`, not an object — the spec serialises with
96/// The reply `haptic` and `vibrate` send, byte-identical to what the hand-off
97/// path delivers. A bare `true`, not an object — the spec serialises with
10098/// `.fragmentsAllowed`. It stays truthy on purpose: `craft-bridge.js` resolves
10199/// with `payload || {}`, so `false` or `0` would arrive as `{}`.
102const vibrate_reply = "true";
100const success_reply = "true";
103101
104102pub const HapticsBridge = struct {
105103 allocator: std.mem.Allocator,
@@ -136,12 +134,13 @@ pub const HapticsBridge = struct {
136134 /// unrecognised style to `.impact_medium` as well, matching the spec's
137135 /// `default:` arm, and this path exercises the same branch.
138136 ///
139 /// No reply on success, matching `.none` and the spec. The error from
140 /// `triggerHapticChecked` is the one thing that can come back, and it means
141 /// UIKit was not there to accept the call — never that the device stayed
142 /// still, which no iOS API reports.
137 /// `true` once UIKit has accepted the call, as the spec's `case "haptic"`
138 /// answers. The error from `triggerHapticChecked` means UIKit was not there
139 /// to accept it — never that the device stayed still, which no iOS API
140 /// reports.
143141 fn haptic(self: *Self, data: []const u8) !void {
144142 try triggerHapticChecked(try hapticTypeIn(self.allocator, data));
143 bridge_error.sendResultToJS(self.allocator, A.haptic, success_reply);
145144 }
146145
147146 /// `case "vibrate"` (`CraftApp.swift:685-691`).
@@ -176,7 +175,7 @@ pub const HapticsBridge = struct {
176175 try triggerHapticChecked(.impact_medium);
177176 }
178177
179 bridge_error.sendResultToJS(self.allocator, A.vibrate, vibrate_reply);
178 bridge_error.sendResultToJS(self.allocator, A.vibrate, success_reply);
180179 }
181180};
182181
@@ -282,8 +281,7 @@ fn schedulePattern(items: []const std.json.Value) !usize {
282281/// Engine is absent on the simulator and on iPad, and System Haptics can be off
283282/// in Settings — all three are silent no-ops with no API that reports them. So
284283/// "returned without error" means "UIKit accepted the call", never "the device
285/// buzzed", which is the other half of why `haptic` has no success reply to
286/// fabricate.
284/// buzzed", and that is all the `true` `haptic` answers with claims.
287285///
288286/// `pub` for one caller outside this file. `bridge_mobile_speech.zig` fires a
289287/// light impact on start and on stop because `CraftApp.swift:2544` and `:2558`
@@ -384,8 +382,8 @@ pub fn triggerHapticChecked(haptic_type: HapticType) !void {
384382/// tag, so honouring `'soft'` as `UIImpactFeedbackStyleSoft` would mean
385383/// widening the enum *and* changing what the shipped API does.
386384/// - **An unknown style is absorbed, not rejected.** A typo is indistinguishable
387/// from `"medium"`. With no reply channel there is nowhere to report it, and
388/// the spec's `default:` swallows it too.
385/// from `"medium"`, because the spec's `default:` swallows it and rejecting
386/// it here would be stricter than the surface pages are written against.
389387///
390388/// The field is `style`. Not `type` — `examples/web_to_native/app.html` posts
391389/// `{type: ...}` but through a local `invoke` shim that never reaches this
@@ -482,9 +480,8 @@ test "the table declares exactly the two actions the dispatcher serves" {
482480 try testing.expectEqualStrings(A.vibrate, capability_actions[1].name);
483481
484482 // The replies are the halves that go wrong invisibly: a `.result` that
485 // never replies parks the caller for 30s, and a `.none` that is awaited
486 // resolves immediately and means nothing.
487 try testing.expectEqual(capabilities.Reply.none, capability_actions[0].reply);
483 // never replies leaves the page's promise open. Both are awaited (#207).
484 try testing.expectEqual(capabilities.Reply.result, capability_actions[0].reply);
488485 try testing.expectEqual(capabilities.Reply.result, capability_actions[1].reply);
489486}
490487
@@ -548,7 +545,7 @@ test "haptic reads the style the way the spec's cast reads it" {
548545
549546 // `as? String` fails for each of these, and `?? "medium"` catches them all.
550547 // A page posting `{style: 2}` gets a medium impact, not an error — matching
551 // the spec, which cannot report one either: `case "haptic"` has no reply.
548 // the spec, whose cast takes the same default.
552549 try testing.expectEqual(HapticType.impact_medium, try hapticTypeIn(alloc, "{}"));
553550 try testing.expectEqual(HapticType.impact_medium, try hapticTypeIn(alloc, "{\"style\":null}"));
554551 try testing.expectEqual(HapticType.impact_medium, try hapticTypeIn(alloc, "{\"style\":2}"));
@@ -584,8 +581,7 @@ test "the spec's default arm absorbs everything else, including 'soft'" {
584581 // because fixing it is a behaviour change, not a port.
585582 try testing.expectEqual(HapticType.impact_medium, hapticTypeForStyle("soft"));
586583 try testing.expectEqual(HapticType.impact_medium, hapticTypeForStyle("rigid"));
587 // A typo is indistinguishable from "medium". There is no reply channel to
588 // say otherwise on, which is exactly what the spec does too.
584 // A typo is indistinguishable from "medium", exactly as in the spec.
589585 try testing.expectEqual(HapticType.impact_medium, hapticTypeForStyle("mediuim"));
590586 try testing.expectEqual(HapticType.impact_medium, hapticTypeForStyle(""));
591587}
@@ -688,14 +684,14 @@ test "an absurd duration saturates instead of panicking" {
688684 );
689685}
690686
691test "the vibrate reply is the bare JSON literal the hand-off path already sends" {
687test "the haptic and vibrate reply is the bare JSON literal the hand-off path sends" {
692688 // Not `{"success":true}`. The spec serialises with `.fragmentsAllowed`, so
693689 // a page on the hand-off path receives `true` today, and it must keep
694690 // receiving `true`. Truthy also matters: `craft-bridge.js` resolves with
695691 // `payload || {}`, which would turn `false` into an empty object.
696 try testing.expectEqualStrings("true", vibrate_reply);
692 try testing.expectEqualStrings("true", success_reply);
697693
698 const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, vibrate_reply, .{});
694 const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, success_reply, .{});
699695 defer parsed.deinit();
700696 try testing.expectEqual(true, parsed.value.bool);
701697}
packages/zig/src/bridge_mobile_speech.zigmodified+30-26
Changes to packages/zig/src/bridge_mobile_speech.zig
@@ -1,13 +1,12 @@
11//! The `mobile` namespace's speech pair: `startListening` and `stopListening`.
22//!
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.
3//! Both actions answer `true` as soon as the request is taken, and nothing
4//! more. The page awaits that answer (#207), but everything that happens after
5//! it — a declined prompt, no recognizer, a transcript, the end — travels as a
6//! `craftSpeech*` event, because none of it is known while the dispatch frame
7//! is still open. No path in this file returns an error to `ios_dispatch`
8//! either: a failure is reported as the event a page is listening for, and
9//! the promise has already been answered.
1110//!
1211//! ## What this round changed
1312//!
@@ -188,25 +187,28 @@ pub const A = struct {
188187 pub const stop_listening = "stopListening";
189188};
190189
191/// `.none` for both, and the distinction matters.
190/// `.result` for both: `true`, sent from `handleMessage` while the dispatch
191/// frame still holds the page's request id.
192192///
193/// `.result` would tell an app to await a reply that never comes: neither
194/// action calls `sendResultToJS` on any path, because neither has a page-side
195/// promise to settle. Everything observable leaves through `ios_events`.
193/// Settling on the outcome instead would leave the promise open. The
194/// authorization answer arrives after `ios_dispatch` has popped the request
195/// context, so a reply sent from there carries no id and the page drops it,
196/// and a start while authorization is already in flight has no outcome of its
197/// own. Everything after the `true` leaves through `ios_events`.
196198///
197199/// `.live` for both. A refusal here is a specific condition — speech not
198200/// configured, authorization declined, no input route — and never the normal
199201/// answer, which is what `.unavailable` would claim.
200202pub const capability_actions = [_]capabilities.ActionDecl{
201 .{ .name = A.start_listening, .reply = .none },
202 .{ .name = A.stop_listening, .reply = .none },
203 .{ .name = A.start_listening, .reply = .result },
204 .{ .name = A.stop_listening, .reply = .result },
203205};
204206
205207pub const SpeechBridge = struct {
206 /// Held for the interface `ios_dispatch` builds every mobile bridge with.
207 /// Unused: nothing in a dispatch allocates, and the two paths that do —
208 /// shaping a transcript and shaping an error — run from callbacks where
209 /// this bridge is long gone, so they take the C allocator instead.
208 /// Shapes the `true` each action answers with. The two other paths that
209 /// allocate — shaping a transcript and shaping an error — run from
210 /// callbacks where this bridge is long gone, so they take the C allocator
211 /// instead.
210212 allocator: std.mem.Allocator,
211213
212214 const Self = @This();
@@ -218,15 +220,17 @@ pub const SpeechBridge = struct {
218220 pub fn deinit(_: *Self) void {}
219221
220222 /// The payload is ignored, deliberately: Swift reads no field from either
221 /// action, and the injected JS posts neither a body nor a `callbackId`.
222 /// Parsing it would only create a way to fail that the spec has not got.
223 pub fn handleMessage(_: *Self, action: []const u8, _: []const u8) !void {
223 /// action, and the injected JS posts only a `callbackId`. Parsing it would
224 /// only create a way to fail that the spec has not got.
225 pub fn handleMessage(self: *Self, action: []const u8, _: []const u8) !void {
224226 if (std.mem.eql(u8, action, A.start_listening)) {
225227 startListening();
228 bridge_error.sendResultToJS(self.allocator, A.start_listening, "true");
226229 return;
227230 }
228231 if (std.mem.eql(u8, action, A.stop_listening)) {
229232 stopListening();
233 bridge_error.sendResultToJS(self.allocator, A.stop_listening, "true");
230234 return;
231235 }
232236 return BridgeError.UnknownAction;
@@ -1184,11 +1188,12 @@ test "the action names match the Swift case labels exactly" {
11841188 try testing.expectEqualStrings("stopListening", A.stop_listening);
11851189}
11861190
1187test "both actions are declared, and neither promises a reply" {
1191test "both actions are declared, and both answer the promise the page awaits" {
11881192 try testing.expectEqual(@as(usize, 2), capability_actions.len);
11891193 for (capability_actions) |decl| {
1190 // `.result` would tell an app to await something no path here sends.
1191 try testing.expectEqual(capabilities.Reply.none, decl.reply);
1194 // The page posts a callbackId for both (#207), so `.none` would be a
1195 // promise that never settles.
1196 try testing.expectEqual(capabilities.Reply.result, decl.reply);
11921197 try testing.expectEqual(capabilities.ActionStatus.live, decl.status);
11931198 }
11941199}
@@ -1207,8 +1212,7 @@ test "an unrelated action falls through rather than being refused" {
12071212
12081213test "a malformed payload is not a reason to refuse either action" {
12091214 // Neither action reads a field, so neither parses one. A parser here would
1210 // invent a failure the spec has not got — and it would surface as a
1211 // dispatcher error under a request id no page holds.
1215 // invent a failure the spec has not got.
12121216 var bridge = SpeechBridge.init(testing.allocator);
12131217 defer bridge.deinit();
12141218
packages/zig/test/ios_conformance_test.zigmodified+153-69
Changes to packages/zig/test/ios_conformance_test.zig
@@ -644,6 +644,41 @@ test "the gate table has no entry for an action Zig does not serve" {
644644 }
645645}
646646
647/// The `capability_actions` array of one mobile module, or null when it has
648/// none.
649///
650/// Bounded to the array, not the whole file: prose quotes declarations —
651/// `bridge_mobile_haptics.zig`'s header describes one it removed — and a scan
652/// that read comments would attribute that sentence to whichever entry
653/// preceded it.
654fn manifestTable(source: []const u8) ?[]const u8 {
655 const table_start = std.mem.indexOf(u8, source, "pub const capability_actions = [_]capabilities.ActionDecl{") orelse
656 return null;
657 const table_end = std.mem.indexOfPos(u8, source, table_start, "\n};") orelse return null;
658 return source[table_start..table_end];
659}
660
661/// The action named by the manifest entry that contains offset `at`.
662///
663/// The entry names its action as `A.some_const`; this resolves it back through
664/// the `A` block to the string the spec spells.
665fn declaredActionBefore(source: []const u8, table: []const u8, at: usize) ?[]const u8 {
666 const name_at = std.mem.lastIndexOf(u8, table[0..at], ".name = A.") orelse return null;
667 const const_start = name_at + ".name = A.".len;
668 var const_end = const_start;
669 while (const_end < table.len and (std.ascii.isAlphanumeric(table[const_end]) or
670 table[const_end] == '_')) : (const_end += 1)
671 {}
672
673 var needle_buf: [96]u8 = undefined;
674 const member = table[const_start..const_end];
675 const decl = std.fmt.bufPrint(&needle_buf, "const {s} = \"", .{member}) catch return null;
676 const decl_at = std.mem.indexOf(u8, source, decl) orelse return null;
677 const value_start = decl_at + decl.len;
678 const value_end = std.mem.indexOfScalarPos(u8, source, value_start, '"') orelse return null;
679 return source[value_start..value_end];
680}
681
647682/// The `.reason` text of every `.status = .unavailable` entry, by action.
648683///
649684/// The reason is what a reader trusts, so it is what has to be checked. A
@@ -656,36 +691,12 @@ fn collectUnavailableReasons(allocator: std.mem.Allocator) !std.StringHashMap([]
656691 errdefer map.deinit();
657692
658693 for (zig_sources) |source| {
659 // Bounded to the manifest array, not the whole file: prose quotes
660 // `.status = .unavailable` — `bridge_mobile_haptics.zig`'s header does,
661 // describing the declaration it removed — and a scan that read comments
662 // would attribute that sentence to whichever entry preceded it.
663 const table_start = std.mem.indexOf(u8, source, "pub const capability_actions = [_]capabilities.ActionDecl{") orelse
664 continue;
665 const table_end = std.mem.indexOfPos(u8, source, table_start, "\n};") orelse continue;
666 const table = source[table_start..table_end];
694 const table = manifestTable(source) orelse continue;
667695
668696 var search: usize = 0;
669697 while (std.mem.indexOfPos(u8, table, search, ".status = .unavailable")) |at| {
670698 search = at + 1;
671
672 // The entry names its action as `A.some_const`; resolve it back
673 // through the `A` block to the string the spec spells.
674 const name_at = std.mem.lastIndexOf(u8, table[0..at], ".name = A.") orelse continue;
675 const const_start = name_at + ".name = A.".len;
676 var const_end = const_start;
677 while (const_end < table.len and (std.ascii.isAlphanumeric(table[const_end]) or
678 table[const_end] == '_')) : (const_end += 1)
679 {}
680
681 var needle_buf: [96]u8 = undefined;
682 const member = table[const_start..const_end];
683 if (member.len + 16 > needle_buf.len) continue;
684 const decl = try std.fmt.bufPrint(&needle_buf, "const {s} = \"", .{member});
685 const decl_at = std.mem.indexOf(u8, source, decl) orelse continue;
686 const value_start = decl_at + decl.len;
687 const value_end = std.mem.indexOfScalarPos(u8, source, value_start, '"') orelse continue;
688 const action = source[value_start..value_end];
699 const action = declaredActionBefore(source, table, at) orelse continue;
689700
690701 // The reason, inline or by name.
691702 const reason_at = std.mem.indexOfPos(u8, table, at, ".reason = ") orelse continue;
@@ -1656,18 +1667,16 @@ test "the SDK's event map declares everything that does fire" {
16561667// `share` was one (#203). Its case resolves whether the person shared and
16571668// rejects `CAPABILITY_DISABLED` and `INVALID_ARGUMENT`, and `craft.share(text)`
16581669// posted `{action: 'share', text}`, so an app with sharing switched off called
1659// it, heard nothing and carried on as if it had worked.
1660
1661/// Actions the page still posts without a callbackId even though the spec
1662/// answers them. Tracked in #207. This list may only shrink: the second test
1663/// below fails when one of them is fixed and left here.
1664const unanswered_posts = [_][]const u8{
1665 "haptic",
1666 "startListening",
1667 "watchPosition",
1668 "clearWatch",
1669 "vibrate",
1670};
1670// it, heard nothing and carried on as if it had worked. `haptic`,
1671// `startListening`, `watchPosition`, `clearWatch` and `vibrate` were five more
1672// (#207).
1673//
1674// Fixing those by adding a callbackId turns the bug around rather than
1675// removing it: `haptic` and `startListening` only ever *rejected*, so a page
1676// that waited on them would have waited for ever on every call that worked.
1677// So the checks run both ways. A call the spec answers must carry an id, a
1678// call that carries an id must have a case that can resolve it, and no Zig
1679// module may declare a reply the page is waiting for as `.none`.
16711680
16721681/// One `postMessage(...)` call in the page script.
16731682const Post = struct {
@@ -1730,13 +1739,45 @@ const PostIterator = struct {
17301739 }
17311740};
17321741
1733/// Whether the dispatcher's `case` for `action` answers through the callback.
1742/// Every action the page waits on an answer for: literal posts that carry a
1743/// callbackId, and every `_invoke('name'`, which always adds one.
1744fn collectAwaitedActions(allocator: std.mem.Allocator) !std.StringHashMap(void) {
1745 var set = std.StringHashMap(void).init(allocator);
1746 errdefer set.deinit();
1747
1748 var it = PostIterator{ .source = swift_spec };
1749 while (it.next()) |post| {
1750 const action = post.action orelse continue;
1751 if (post.carries_callback) try set.put(action, {});
1752 }
1753
1754 const needle = "_invoke('";
1755 var search: usize = 0;
1756 while (std.mem.indexOfPos(u8, swift_spec, search, needle)) |at| {
1757 const name_start = at + needle.len;
1758 const name_end = std.mem.indexOfScalarPos(u8, swift_spec, name_start, '\'') orelse break;
1759 try set.put(swift_spec[name_start..name_end], {});
1760 search = name_end;
1761 }
1762 return set;
1763}
1764
1765/// What a `case` has to do with its callbackId to count.
1766const CaseUse = enum {
1767 /// Anything: resolve, reject, or hand it to a helper that will.
1768 answers,
1769 /// Something other than `rejectCallback`, so a call that succeeds settles.
1770 resolves,
1771};
1772
1773/// Whether the dispatcher's `case` for `action` uses the callback the way
1774/// `use` asks.
17341775///
17351776/// The case runs from its label to the next line at the same indentation that
17361777/// starts another case, a `default:` or the closing brace. Nested switches sit
17371778/// deeper and stay inside it. Comment lines are skipped so a remark that
17381779/// mentions callbackId cannot pass for code that uses it.
1739fn specAnswers(region: []const u8, action: []const u8) bool {
1780fn caseUsesCallback(region: []const u8, action: []const u8, use: CaseUse) bool {
17401781 var lines = std.mem.splitScalar(u8, region, '\n');
17411782 var case_indent: ?usize = null;
17421783 while (lines.next()) |line| {
@@ -1749,8 +1790,9 @@ fn specAnswers(region: []const u8, action: []const u8) bool {
17491790 std.mem.startsWith(u8, body, "default") or body[0] == '}');
17501791 if (closes) return false;
17511792 if (std.mem.startsWith(u8, body, "//")) continue;
1752 if (std.mem.indexOf(u8, body, "callbackId") != null) return true;
1753 continue;
1793 if (std.mem.indexOf(u8, body, "callbackId") == null) continue;
1794 if (use == .resolves and std.mem.indexOf(u8, body, "rejectCallback(") != null) continue;
1795 return true;
17541796 }
17551797
17561798 if (std.mem.startsWith(u8, body, "case ") and labels(body, action)) case_indent = indent;
@@ -1758,6 +1800,14 @@ fn specAnswers(region: []const u8, action: []const u8) bool {
17581800 return false;
17591801}
17601802
1803fn specAnswers(region: []const u8, action: []const u8) bool {
1804 return caseUsesCallback(region, action, .answers);
1805}
1806
1807fn specResolves(region: []const u8, action: []const u8) bool {
1808 return caseUsesCallback(region, action, .resolves);
1809}
1810
17611811/// Whether a `case` line lists `action` among its labels.
17621812fn labels(case_line: []const u8, action: []const u8) bool {
17631813 var search: usize = 0;
@@ -1769,13 +1819,6 @@ fn labels(case_line: []const u8, action: []const u8) bool {
17691819 return false;
17701820}
17711821
1772fn isRecordedUnanswered(action: []const u8) bool {
1773 for (unanswered_posts) |recorded| {
1774 if (std.mem.eql(u8, recorded, action)) return true;
1775 }
1776 return false;
1777}
1778
17791822test "the post scan tells a call that waits from one that does not" {
17801823 // Shown both answers before it is trusted with the real file. A scanner
17811824 // that finds nothing proves nothing.
@@ -1818,6 +1861,23 @@ test "the post scan tells a call that waits from one that does not" {
18181861 try testing.expect(!specAnswers(dispatcher, "stopListening"));
18191862 try testing.expect(!specAnswers(dispatcher, "log"));
18201863 try testing.expect(!specAnswers(dispatcher, "hap"));
1864
1865 // The shape #207 would have left behind: a case that answers, but only
1866 // ever with a rejection.
1867 try testing.expect(!specResolves(dispatcher, "haptic"));
1868 const resolving =
1869 \\ case "vibrate":
1870 \\ pendingCallbackId = callbackId
1871 \\ case "haptic":
1872 \\ if enabled {
1873 \\ resolveCallback(callbackId, result: true)
1874 \\ } else {
1875 \\ rejectCallback(callbackId, error: "Haptics is disabled")
1876 \\ }
1877 \\ }
1878 ;
1879 try testing.expect(specResolves(resolving, "vibrate"));
1880 try testing.expect(specResolves(resolving, "haptic"));
18211881}
18221882
18231883test "every call the spec answers carries a callbackId to answer it on" {
@@ -1829,7 +1889,6 @@ test "every call the spec answers carries a callbackId to answer it on" {
18291889 literal_posts += 1;
18301890 if (post.carries_callback) continue;
18311891 if (!specAnswers(region, action)) continue;
1832 if (isRecordedUnanswered(action)) continue;
18331892
18341893 std.debug.print(
18351894 "the page posts `{s}` with no callbackId, and the spec answers it.\n" ++
@@ -1844,31 +1903,56 @@ test "every call the spec answers carries a callbackId to answer it on" {
18441903 try testing.expect(literal_posts >= 40);
18451904}
18461905
1847test "every recorded unanswered post is real, and still unanswered" {
1906test "every call the page waits on has a case that can resolve it" {
18481907 const region = dispatcherRegion();
1849 for (unanswered_posts) |action| {
1850 if (!specAnswers(region, action)) {
1851 std.debug.print(
1852 "`{s}` is recorded as unanswered, but the spec no longer answers it.\n" ++
1853 " Take it off unanswered_posts.\n",
1854 .{action},
1855 );
1856 return error.RecordedPostIsNotAnswered;
1857 }
1908 var awaited = try collectAwaitedActions(testing.allocator);
1909 defer awaited.deinit();
1910 // Non-vacuity: every `_invoke` and callback post counts, and there are
1911 // far more than this.
1912 try testing.expect(awaited.count() >= 80);
1913
1914 var it = awaited.keyIterator();
1915 while (it.next()) |action| {
1916 if (specResolves(region, action.*)) continue;
1917 std.debug.print(
1918 "the page waits on `{s}`, and the spec's case never resolves it.\n" ++
1919 " If the case only rejects (or has no case at all), every call that\n" ++
1920 " works leaves the page's promise open. Resolve it once the work is\n" ++
1921 " done, or once the request is taken if the outcome comes later.\n",
1922 .{action.*},
1923 );
1924 return error.PageWaitsOnAnAnswerThatNeverComes;
1925 }
1926}
18581927
1859 var it = PostIterator{ .source = swift_spec };
1860 const still_dropped = while (it.next()) |post| {
1861 const name = post.action orelse continue;
1862 if (std.mem.eql(u8, name, action) and !post.carries_callback) break true;
1863 } else false;
1928test "no Zig module declares a reply the page waits on as none" {
1929 // `.reply = .none` is a promise from the module that nothing is sent on
1930 // success. For an action the page awaits, that is the same hang as a Swift
1931 // case that only rejects, in the half that serves the call first.
1932 var awaited = try collectAwaitedActions(testing.allocator);
1933 defer awaited.deinit();
1934
1935 var declarations: usize = 0;
1936 for (zig_sources) |source| {
1937 const table = manifestTable(source) orelse continue;
1938 var search: usize = 0;
1939 while (std.mem.indexOfPos(u8, table, search, ".name = A.")) |at| {
1940 search = at + 1;
1941 declarations += 1;
1942 }
18641943
1865 if (!still_dropped) {
1944 search = 0;
1945 while (std.mem.indexOfPos(u8, table, search, ".reply = .none")) |at| {
1946 search = at + 1;
1947 const action = declaredActionBefore(source, table, at) orelse return error.ManifestEntryUnreadable;
1948 if (!awaited.contains(action)) continue;
18661949 std.debug.print(
1867 "every post of `{s}` carries a callbackId now.\n" ++
1868 " Take it off unanswered_posts so it cannot regress unnoticed.\n",
1950 "`{s}` is declared `.reply = .none`, and the page waits on its answer.\n" ++
1951 " Send the result from the handler and declare `.result`.\n",
18691952 .{action},
18701953 );
1871 return error.RecordedPostIsFixed;
1954 return error.ZigLeavesAnAwaitedCallUnanswered;
18721955 }
18731956 }
1957 try testing.expect(declarations >= 80);
18741958}
scripts/mobile-e2e/driver.htmlmodified+81-0
Changes to scripts/mobile-e2e/driver.html
@@ -219,6 +219,87 @@
219219 })
220220 }
221221 },
222 {
223 // #207. `craft.geolocation.watchPosition` posted no callbackId, so a
224 // page could not tell a watch that started from one refused. It
225 // answers once updates have started; fixes still arrive as events.
226 // After currentPosition, so authorization is already settled.
227 name: 'geolocation.watchPosition.resolvesTrue',
228 platforms: ['ios'],
229 run: function () {
230 return withTimeout(function () { return window.craft.geolocation.watchPosition(function () {}) }).then(function (outcome) {
231 if (outcome.state === 'timeout') return 'watchPosition never settled'
232 if (outcome.state === 'rejected') return 'watchPosition rejected: ' + outcome.error.code + ' ' + outcome.error.message
233 if (outcome.value !== true) return 'watchPosition resolved with ' + JSON.stringify(outcome.value)
234 })
235 }
236 },
237 {
238 name: 'geolocation.clearWatch.resolvesTrue',
239 platforms: ['ios'],
240 run: function () {
241 return withTimeout(function () { return window.craft.geolocation.clearWatch() }).then(function (outcome) {
242 if (outcome.state === 'timeout') return 'clearWatch never settled'
243 if (outcome.state === 'rejected') return 'clearWatch rejected: ' + outcome.error.code + ' ' + outcome.error.message
244 if (outcome.value !== true) return 'clearWatch resolved with ' + JSON.stringify(outcome.value)
245 })
246 }
247 },
248 {
249 // #207 again, and the half a callbackId alone would have broken.
250 // Haptics is on, and native used to answer an enabled haptic with
251 // nothing at all; it only ever rejected. So a page that awaited it
252 // would wait for ever on every call that worked.
253 name: 'haptic.resolvesTrue',
254 platforms: ['ios'],
255 run: function () {
256 return withTimeout(function () { return window.craft.haptic('light') }).then(function (outcome) {
257 if (outcome.state === 'timeout') return 'haptic never settled with haptics enabled'
258 if (outcome.state === 'rejected') return 'haptic rejected: ' + outcome.error.code + ' ' + outcome.error.message
259 if (outcome.value !== true) return 'haptic resolved with ' + JSON.stringify(outcome.value)
260 })
261 }
262 },
263 {
264 name: 'vibrate.resolvesTrue',
265 platforms: ['ios'],
266 run: function () {
267 return withTimeout(function () { return window.craft.vibrate([]) }).then(function (outcome) {
268 if (outcome.state === 'timeout') return 'vibrate never settled'
269 if (outcome.state === 'rejected') return 'vibrate rejected: ' + outcome.error.code + ' ' + outcome.error.message
270 if (outcome.value !== true) return 'vibrate resolved with ' + JSON.stringify(outcome.value)
271 })
272 }
273 },
274 {
275 // Speech recognition is off, and `startListening` used to drop the
276 // refusal: the page got undefined and no craftSpeechError either.
277 // Refused before any authorization prompt, so nothing waits on a
278 // microphone the simulator does not have.
279 name: 'speech.disabled.rejects',
280 platforms: ['ios'],
281 run: function () {
282 return withTimeout(function () { return window.craft.startListening() }).then(function (outcome) {
283 if (outcome.state === 'timeout') return 'startListening never settled'
284 if (outcome.state === 'resolved') return 'startListening resolved with ' + JSON.stringify(outcome.value) + ' while speech recognition is disabled'
285 if (outcome.error.code !== 'CAPABILITY_DISABLED') return 'code is ' + JSON.stringify(outcome.error.code)
286 })
287 }
288 },
289 {
290 // stopListening is not gated, so it runs with speech off, and it is
291 // the one speech call whose success path a simulator can reach
292 // without a microphone. It used to answer nothing on either side.
293 name: 'speech.stopListening.resolvesTrue',
294 platforms: ['ios'],
295 run: function () {
296 return withTimeout(function () { return window.craft.stopListening() }).then(function (outcome) {
297 if (outcome.state === 'timeout') return 'stopListening never settled'
298 if (outcome.state === 'rejected') return 'stopListening rejected: ' + outcome.error.code + ' ' + outcome.error.message
299 if (outcome.value !== true) return 'stopListening resolved with ' + JSON.stringify(outcome.value)
300 })
301 }
302 },
222303 {
223304 // Sharing is off too, and this is the call that used to answer
224305 // nothing: `craft.share(text)` posted no callbackId, so Swift's
scripts/mobile-e2e/ios.tsmodified+7-2
Changes to scripts/mobile-e2e/ios.ts
@@ -23,13 +23,18 @@ const DEEP_LINK_SCHEME = 'crafte2eprobe'
2323 * was a location promise that never settled with location *enabled*, and a
2424 * disabled capability cannot reach that code. Sharing took over the rejection.
2525 *
26 * `enableShare` is spelled out although false is the default, because the
27 * share case depends on it and a default is not something this file controls.
26 * `enableShare` and `enableSpeechRecognition` are spelled out although false
27 * is the default, because the refusal cases depend on them and a default is
28 * not something this file controls.
2829 */
2930const CONFIG = {
3031 enableClipboard: true,
3132 enableGeolocation: true,
33 // On so the success path is exercised: an enabled haptic used to answer
34 // nothing at all (#207).
35 enableHaptics: true,
3236 enableShare: false,
37 enableSpeechRecognition: false,
3338 // For the cold-start link (#198), which the harness opens from an XCUITest.
3439 enableDeepLinks: true,
3540 urlSchemes: [DEEP_LINK_SCHEME],
scripts/mobile-e2e/protocol.test.tsmodified+4-1
Changes to scripts/mobile-e2e/protocol.test.ts
@@ -270,7 +270,10 @@ describe('zig attribution', () => {
270270 })
271271
272272 it('reads the refusal Zig writes through its own capability gate', () => {
273 const text = 'info: ios: refusing share; enableShare is not enabled in craft.config.json'
273 const text = [
274 'info: ios: refusing startListening; enableSpeechRecognition is not enabled in craft.config.json',
275 'info: ios: refusing share; enableShare is not enabled in craft.config.json',
276 ].join('\n')
274277 expect(zigRefusals(text)).toEqual(ZIG_REFUSED_ACTIONS)
275278 })
276279
scripts/mobile-e2e/protocol.tsmodified+13-0
Changes to scripts/mobile-e2e/protocol.ts
@@ -80,7 +80,13 @@ export const REQUIRED_CASES: Record<MobilePlatform, string[]> = {
8080 'clipboard.roundTrip',
8181 'permissions.location.granted',
8282 'geolocation.currentPosition',
83 'geolocation.watchPosition.resolvesTrue',
84 'geolocation.clearWatch.resolvesTrue',
85 'haptic.resolvesTrue',
86 'vibrate.resolvesTrue',
8387 'share.disabled.rejects',
88 'speech.disabled.rejects',
89 'speech.stopListening.resolvesTrue',
8490 ],
8591 android: [
8692 'bridge.ready',
@@ -548,12 +554,18 @@ export function zigRefusals(text: string): string[] {
548554 */
549555export const ZIG_TESTED_ACTIONS = [
550556 'checkPermission',
557 'clearWatch',
551558 'clipboardRead',
552559 'clipboardWrite',
553560 'getCurrentPosition',
554561 'getDeviceInfo',
562 'haptic',
555563 'requestPermission',
556564 'share',
565 'startListening',
566 'stopListening',
567 'vibrate',
568 'watchPosition',
557569]
558570
559571/**
@@ -569,6 +581,7 @@ export const ZIG_SERVED_ACTIONS = ZIG_TESTED_ACTIONS.filter(action => action !==
569581/** The actions the suite configures off, so Zig must refuse each by name. */
570582export const ZIG_REFUSED_ACTIONS = [
571583 'share',
584 'startListening',
572585]
573586
574587/**