also looking at this
fix(ios): hand the page the answers native was already sending
#222
18 files
+597
-164
| @@ -30,8 +30,8 @@ bun run test:mobile-e2e:ios | ||
| 30 | 30 | ``` |
| 31 | 31 | |
| 32 | 32 | ``` |
| 33 | ok ios-shim — 6/6 cases, no zig | |
| 34 | ok ios-runtime — 6/6 cases, zig served checkPermission, clipboardRead, clipboardWrite, getCurrentPosition, getDeviceInfo, log, requestPermission, share | |
| 33 | ok ios-shim — 12/12 cases, no zig | |
| 34 | ok ios-runtime — 12/12 cases, zig served checkPermission, clearWatch, clipboardRead, clipboardWrite, getCurrentPosition, getDeviceInfo, haptic, log, requestPermission, share, startListening, stopListening, vibrate, watchPosition | |
| 35 | 35 | ``` |
| 36 | 36 | |
| 37 | 37 | After the suite, each leg cold-starts the app through a link twice, from an |
| @@ -2,6 +2,13 @@ | ||
| 2 | 2 | |
| 3 | 3 | Provide tactile feedback through device vibrations. |
| 4 | 4 | |
| 5 | Haptics are feedback, so these methods never stop a flow for a capability the | |
| 6 | app left off. On iOS, with `enableHaptics` off in `craft.config.json`, they | |
| 7 | resolve without playing anything, the way the web fallback does on a device | |
| 8 | with no vibration motor. They reject only when the native call itself fails. | |
| 9 | The raw `window.craft.haptic()` is different: it rejects with | |
| 10 | `CAPABILITY_DISABLED`, for code that needs to know. | |
| 11 | ||
| 5 | 12 | ## Import |
| 6 | 13 | |
| 7 | 14 | ```typescript |
| @@ -159,14 +159,17 @@ window.addEventListener('craftReady', (e) => { | ||
| 159 | 159 | console.log('Capabilities:', e.detail.capabilities); |
| 160 | 160 | }); |
| 161 | 161 | |
| 162 | // Haptic feedback | |
| 163 | window.craft.haptic('light'); // light, medium, heavy | |
| 164 | window.craft.haptic('success'); // success, warning, error | |
| 165 | window.craft.haptic('selection'); | |
| 166 | ||
| 167 | // Speech recognition | |
| 168 | window.craft.startListening(); | |
| 169 | window.craft.stopListening(); | |
| 162 | // Haptic feedback. Resolves true, and rejects with CAPABILITY_DISABLED when | |
| 163 | // enableHaptics is off. | |
| 164 | await window.craft.haptic('light'); // light, medium, heavy | |
| 165 | await window.craft.haptic('success'); // success, warning, error | |
| 166 | await 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). | |
| 171 | await window.craft.startListening(); | |
| 172 | await window.craft.stopListening(); | |
| 170 | 173 | |
| 171 | 174 | // Listen for speech events |
| 172 | 175 | window.addEventListener('craftSpeechStart', () => { /* recording started */ }); |
| @@ -33,8 +33,10 @@ const char craft_slice_page[] = | ||
| 33 | 33 | "window.addEventListener('craftMotionUpdate', function () {\n" |
| 34 | 34 | " if (!sentEvent) { sentEvent = true; bridge.postMessage({t:'mobile', a:'getDeviceInfo', i:16}); }\n" |
| 35 | 35 | "});\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" | |
| 38 | 40 | "// A start settles as EITHER craftSpeechStart or craftSpeechError, and both\n" |
| 39 | 41 | "// count: the first means the engine is running, the second means the\n" |
| 40 | 42 | "// authorization block fired, hopped to the main queue and named a reason.\n" |
| @@ -0,0 +1,147 @@ | ||
| 1 | import { readFileSync } from 'node:fs' | |
| 2 | import { join } from 'node:path' | |
| 3 | import { 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 | ||
| 9 | const template = readFileSync(join(import.meta.dir, '../templates/CraftApp.swift'), 'utf8') | |
| 10 | ||
| 11 | /** The page script, as the string Swift evaluates, with every flag off. */ | |
| 12 | function 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 | ||
| 24 | interface Post { action: string, callbackId?: string, [key: string]: unknown } | |
| 25 | ||
| 26 | function 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 | ||
| 75 | describe('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 | }) | |
| @@ -667,16 +667,23 @@ struct CraftWebView: UIViewRepresentable { | ||
| 667 | 667 | switch action { |
| 668 | 668 | case "startListening": |
| 669 | 669 | 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. | |
| 670 | 674 | startSpeechRecognition() |
| 675 | resolveCallback(callbackId, result: true) | |
| 671 | 676 | } else { |
| 672 | 677 | rejectCallback(callbackId, error: "Speech recognition is disabled", code: "CAPABILITY_DISABLED") |
| 673 | 678 | } |
| 674 | 679 | case "stopListening": |
| 675 | 680 | stopSpeechRecognition() |
| 681 | resolveCallback(callbackId, result: true) | |
| 676 | 682 | case "haptic": |
| 677 | 683 | if config.enableHaptics { |
| 678 | 684 | let style = body["style"] as? String ?? "medium" |
| 679 | 685 | triggerHaptic(style: style) |
| 686 | resolveCallback(callbackId, result: true) | |
| 680 | 687 | } else { |
| 681 | 688 | rejectCallback(callbackId, error: "Haptics is disabled", code: "CAPABILITY_DISABLED") |
| 682 | 689 | } |
| @@ -2013,16 +2020,37 @@ struct CraftWebView: UIViewRepresentable { | ||
| 2013 | 2020 | return Promise.reject(new Error('Android fitness APIs are unavailable on iOS')); |
| 2014 | 2021 | }, |
| 2015 | 2022 | |
| 2023 | // Resolves true once UIKit has taken the haptic, and rejects | |
| 2024 | // CAPABILITY_DISABLED when enableHaptics is off. | |
| 2016 | 2025 | 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 | }); | |
| 2018 | 2032 | }, |
| 2019 | 2033 | |
| 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. | |
| 2020 | 2038 | 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 | }); | |
| 2022 | 2045 | }, |
| 2023 | 2046 | |
| 2024 | 2047 | 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 | }); | |
| 2026 | 2054 | }, |
| 2027 | 2055 | |
| 2028 | 2056 | share: function(text) { |
| @@ -2169,12 +2197,25 @@ struct CraftWebView: UIViewRepresentable { | ||
| 2169 | 2197 | } |
| 2170 | 2198 | }); |
| 2171 | 2199 | }, |
| 2200 | // Resolves true once updates have started, before any | |
| 2201 | // authorization answer. A later refusal arrives as a | |
| 2202 | // craftLocationError event. | |
| 2172 | 2203 | watchPosition: function(callback) { |
| 2204 | var self = window.craft; | |
| 2205 | var id = 'cb_' + (++self._callbackId); | |
| 2173 | 2206 | 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 | }); | |
| 2175 | 2211 | }, |
| 2176 | 2212 | 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 | }); | |
| 2178 | 2219 | } |
| 2179 | 2220 | }, |
| 2180 | 2221 | |
| @@ -2274,7 +2315,12 @@ struct CraftWebView: UIViewRepresentable { | ||
| 2274 | 2315 | |
| 2275 | 2316 | // Vibrate |
| 2276 | 2317 | 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 | }); | |
| 2278 | 2324 | }, |
| 2279 | 2325 | |
| 2280 | 2326 | // Open URL |
| @@ -2924,14 +2970,25 @@ struct CraftWebView: UIViewRepresentable { | ||
| 2924 | 2970 | getInfo: function() { return craft.getDeviceInfo(); }, |
| 2925 | 2971 | getCapabilities: function() { return Promise.resolve(Object.assign({}, craft.capabilities)); } |
| 2926 | 2972 | }; |
| 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 | } | |
| 2927 | 2985 | craft.haptics = { |
| 2928 | impact: function(style) { craft.haptic(style || 'medium'); return Promise.resolve(); }, | |
| 2986 | impact: function(style) { return hapticFeedback(craft.haptic(style || 'medium')); }, | |
| 2929 | 2987 | 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')); | |
| 2932 | 2989 | }, |
| 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 || [])); } | |
| 2935 | 2992 | }; |
| 2936 | 2993 | craft.permissions = { |
| 2937 | 2994 | check: function(permission) { return craft._invoke('checkPermission', {permission: permission}); }, |
| @@ -2971,9 +3028,7 @@ struct CraftWebView: UIViewRepresentable { | ||
| 2971 | 3028 | }, |
| 2972 | 3029 | clearWatch: function(id) { |
| 2973 | 3030 | 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'); | |
| 2977 | 3032 | }, |
| 2978 | 3033 | startRecording: function() { return craft._invoke('startLocationRecording'); }, |
| 2979 | 3034 | pauseRecording: function() { return craft._invoke('pauseLocationRecording'); }, |
| @@ -127,7 +127,14 @@ export function useHaptic() { | ||
| 127 | 127 | console.warn('Craft not ready'); |
| 128 | 128 | return; |
| 129 | 129 | } |
| 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 | } | |
| 131 | 138 | }, |
| 132 | 139 | [craft, isReady] |
| 133 | 140 | ); |
| @@ -5,7 +5,14 @@ export function haptic(node: HTMLElement, type: string = 'selection') { | ||
| 5 | 5 | const handleClick = async () => { |
| 6 | 6 | const _$craft = get(craft); |
| 7 | 7 | 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 | } | |
| 9 | 16 | }; |
| 10 | 17 | |
| 11 | 18 | node.addEventListener('click', handleClick); |
| @@ -114,8 +114,11 @@ export interface CraftBridge { | ||
| 114 | 114 | /** |
| 115 | 115 | * Trigger haptic feedback |
| 116 | 116 | * @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). | |
| 117 | 120 | */ |
| 118 | haptic(style: HapticStyle): void; | |
| 121 | haptic(style: HapticStyle): Promise<boolean> | void; | |
| 119 | 122 | |
| 120 | 123 | /** |
| 121 | 124 | * Open the native share sheet. |
| @@ -189,13 +192,20 @@ export interface CraftBridge { | ||
| 189 | 192 | |
| 190 | 193 | /** |
| 191 | 194 | * 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). | |
| 192 | 201 | */ |
| 193 | startListening(): void; | |
| 202 | startListening(): Promise<boolean> | void; | |
| 194 | 203 | |
| 195 | 204 | /** |
| 196 | 205 | * Stop speech recognition |
| 206 | * @returns On iOS, a promise that resolves `true`. Android returns nothing yet (#219). | |
| 197 | 207 | */ |
| 198 | stopListening(): void; | |
| 208 | stopListening(): Promise<boolean> | void; | |
| 199 | 209 | |
| 200 | 210 | // ==================== Files ==================== |
| 201 | 211 | |
| @@ -478,8 +488,10 @@ export interface CraftBridge { | ||
| 478 | 488 | /** |
| 479 | 489 | * Vibrate with pattern |
| 480 | 490 | * @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). | |
| 481 | 493 | */ |
| 482 | vibrate(pattern: number[]): void; | |
| 494 | vibrate(pattern: number[]): Promise<boolean> | void; | |
| 483 | 495 | |
| 484 | 496 | /** |
| 485 | 497 | * Open URL in external browser |
| @@ -128,7 +128,14 @@ export function useHaptic() { | ||
| 128 | 128 | console.warn('Craft not ready'); |
| 129 | 129 | return; |
| 130 | 130 | } |
| 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 | } | |
| 132 | 139 | }; |
| 133 | 140 | |
| 134 | 141 | return { haptic }; |
| @@ -20,9 +20,9 @@ const objc = objc_runtime.objc; | ||
| 20 | 20 | /// `{success:true, browser:true}` and made "the bridge worked" and "there is no |
| 21 | 21 | /// bridge" identical to the caller. |
| 22 | 22 | /// |
| 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. | |
| 26 | 26 | pub const A = struct { |
| 27 | 27 | pub const get_device_info = "getDeviceInfo"; |
| 28 | 28 | }; |
| @@ -1,8 +1,8 @@ | ||
| 1 | 1 | //! The haptics half of the `mobile` namespace: `haptic` and `vibrate`. |
| 2 | 2 | //! |
| 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. | |
| 6 | 6 | //! |
| 7 | 7 | //! ## The refusal that used to live here |
| 8 | 8 | //! |
| @@ -83,23 +83,21 @@ fn hapticTypeIn(allocator: std.mem.Allocator, data: []const u8) !HapticType { | ||
| 83 | 83 | /// `craft.vibrate([100])` fires impacts on an app with haptics "disabled". |
| 84 | 84 | /// Adding a gate here would be a divergence dressed as a tidy-up. |
| 85 | 85 | pub 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 }, | |
| 92 | 90 | // `.result` carrying the JSON literal `true`. Not a shape worth improving: |
| 93 | 91 | // the hand-off path already delivers exactly this today, and `.none` would |
| 94 | 92 | // park any `_req`-based caller for the full 30s timeout. |
| 95 | 93 | .{ .name = A.vibrate, .reply = .result }, |
| 96 | 94 | }; |
| 97 | 95 | |
| 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 | |
| 100 | 98 | /// `.fragmentsAllowed`. It stays truthy on purpose: `craft-bridge.js` resolves |
| 101 | 99 | /// with `payload || {}`, so `false` or `0` would arrive as `{}`. |
| 102 | const vibrate_reply = "true"; | |
| 100 | const success_reply = "true"; | |
| 103 | 101 | |
| 104 | 102 | pub const HapticsBridge = struct { |
| 105 | 103 | allocator: std.mem.Allocator, |
| @@ -136,12 +134,13 @@ pub const HapticsBridge = struct { | ||
| 136 | 134 | /// unrecognised style to `.impact_medium` as well, matching the spec's |
| 137 | 135 | /// `default:` arm, and this path exercises the same branch. |
| 138 | 136 | /// |
| 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. | |
| 143 | 141 | fn haptic(self: *Self, data: []const u8) !void { |
| 144 | 142 | try triggerHapticChecked(try hapticTypeIn(self.allocator, data)); |
| 143 | bridge_error.sendResultToJS(self.allocator, A.haptic, success_reply); | |
| 145 | 144 | } |
| 146 | 145 | |
| 147 | 146 | /// `case "vibrate"` (`CraftApp.swift:685-691`). |
| @@ -176,7 +175,7 @@ pub const HapticsBridge = struct { | ||
| 176 | 175 | try triggerHapticChecked(.impact_medium); |
| 177 | 176 | } |
| 178 | 177 | |
| 179 | bridge_error.sendResultToJS(self.allocator, A.vibrate, vibrate_reply); | |
| 178 | bridge_error.sendResultToJS(self.allocator, A.vibrate, success_reply); | |
| 180 | 179 | } |
| 181 | 180 | }; |
| 182 | 181 | |
| @@ -282,8 +281,7 @@ fn schedulePattern(items: []const std.json.Value) !usize { | ||
| 282 | 281 | /// Engine is absent on the simulator and on iPad, and System Haptics can be off |
| 283 | 282 | /// in Settings — all three are silent no-ops with no API that reports them. So |
| 284 | 283 | /// "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. | |
| 287 | 285 | /// |
| 288 | 286 | /// `pub` for one caller outside this file. `bridge_mobile_speech.zig` fires a |
| 289 | 287 | /// light impact on start and on stop because `CraftApp.swift:2544` and `:2558` |
| @@ -384,8 +382,8 @@ pub fn triggerHapticChecked(haptic_type: HapticType) !void { | ||
| 384 | 382 | /// tag, so honouring `'soft'` as `UIImpactFeedbackStyleSoft` would mean |
| 385 | 383 | /// widening the enum *and* changing what the shipped API does. |
| 386 | 384 | /// - **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. | |
| 389 | 387 | /// |
| 390 | 388 | /// The field is `style`. Not `type` — `examples/web_to_native/app.html` posts |
| 391 | 389 | /// `{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" { | ||
| 482 | 480 | try testing.expectEqualStrings(A.vibrate, capability_actions[1].name); |
| 483 | 481 | |
| 484 | 482 | // 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); | |
| 488 | 485 | try testing.expectEqual(capabilities.Reply.result, capability_actions[1].reply); |
| 489 | 486 | } |
| 490 | 487 | |
| @@ -548,7 +545,7 @@ test "haptic reads the style the way the spec's cast reads it" { | ||
| 548 | 545 | |
| 549 | 546 | // `as? String` fails for each of these, and `?? "medium"` catches them all. |
| 550 | 547 | // 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. | |
| 552 | 549 | try testing.expectEqual(HapticType.impact_medium, try hapticTypeIn(alloc, "{}")); |
| 553 | 550 | try testing.expectEqual(HapticType.impact_medium, try hapticTypeIn(alloc, "{\"style\":null}")); |
| 554 | 551 | 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'" { | ||
| 584 | 581 | // because fixing it is a behaviour change, not a port. |
| 585 | 582 | try testing.expectEqual(HapticType.impact_medium, hapticTypeForStyle("soft")); |
| 586 | 583 | 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. | |
| 589 | 585 | try testing.expectEqual(HapticType.impact_medium, hapticTypeForStyle("mediuim")); |
| 590 | 586 | try testing.expectEqual(HapticType.impact_medium, hapticTypeForStyle("")); |
| 591 | 587 | } |
| @@ -688,14 +684,14 @@ test "an absurd duration saturates instead of panicking" { | ||
| 688 | 684 | ); |
| 689 | 685 | } |
| 690 | 686 | |
| 691 | test "the vibrate reply is the bare JSON literal the hand-off path already sends" { | |
| 687 | test "the haptic and vibrate reply is the bare JSON literal the hand-off path sends" { | |
| 692 | 688 | // Not `{"success":true}`. The spec serialises with `.fragmentsAllowed`, so |
| 693 | 689 | // a page on the hand-off path receives `true` today, and it must keep |
| 694 | 690 | // receiving `true`. Truthy also matters: `craft-bridge.js` resolves with |
| 695 | 691 | // `payload || {}`, which would turn `false` into an empty object. |
| 696 | try testing.expectEqualStrings("true", vibrate_reply); | |
| 692 | try testing.expectEqualStrings("true", success_reply); | |
| 697 | 693 | |
| 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, .{}); | |
| 699 | 695 | defer parsed.deinit(); |
| 700 | 696 | try testing.expectEqual(true, parsed.value.bool); |
| 701 | 697 | } |
| @@ -1,13 +1,12 @@ | ||
| 1 | 1 | //! The `mobile` namespace's speech pair: `startListening` and `stopListening`. |
| 2 | 2 | //! |
| 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. | |
| 11 | 10 | //! |
| 12 | 11 | //! ## What this round changed |
| 13 | 12 | //! |
| @@ -188,25 +187,28 @@ pub const A = struct { | ||
| 188 | 187 | pub const stop_listening = "stopListening"; |
| 189 | 188 | }; |
| 190 | 189 | |
| 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. | |
| 192 | 192 | /// |
| 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`. | |
| 196 | 198 | /// |
| 197 | 199 | /// `.live` for both. A refusal here is a specific condition — speech not |
| 198 | 200 | /// configured, authorization declined, no input route — and never the normal |
| 199 | 201 | /// answer, which is what `.unavailable` would claim. |
| 200 | 202 | pub 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 }, | |
| 203 | 205 | }; |
| 204 | 206 | |
| 205 | 207 | pub 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. | |
| 210 | 212 | allocator: std.mem.Allocator, |
| 211 | 213 | |
| 212 | 214 | const Self = @This(); |
| @@ -218,15 +220,17 @@ pub const SpeechBridge = struct { | ||
| 218 | 220 | pub fn deinit(_: *Self) void {} |
| 219 | 221 | |
| 220 | 222 | /// 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 { | |
| 224 | 226 | if (std.mem.eql(u8, action, A.start_listening)) { |
| 225 | 227 | startListening(); |
| 228 | bridge_error.sendResultToJS(self.allocator, A.start_listening, "true"); | |
| 226 | 229 | return; |
| 227 | 230 | } |
| 228 | 231 | if (std.mem.eql(u8, action, A.stop_listening)) { |
| 229 | 232 | stopListening(); |
| 233 | bridge_error.sendResultToJS(self.allocator, A.stop_listening, "true"); | |
| 230 | 234 | return; |
| 231 | 235 | } |
| 232 | 236 | return BridgeError.UnknownAction; |
| @@ -1184,11 +1188,12 @@ test "the action names match the Swift case labels exactly" { | ||
| 1184 | 1188 | try testing.expectEqualStrings("stopListening", A.stop_listening); |
| 1185 | 1189 | } |
| 1186 | 1190 | |
| 1187 | test "both actions are declared, and neither promises a reply" { | |
| 1191 | test "both actions are declared, and both answer the promise the page awaits" { | |
| 1188 | 1192 | try testing.expectEqual(@as(usize, 2), capability_actions.len); |
| 1189 | 1193 | 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); | |
| 1192 | 1197 | try testing.expectEqual(capabilities.ActionStatus.live, decl.status); |
| 1193 | 1198 | } |
| 1194 | 1199 | } |
| @@ -1207,8 +1212,7 @@ test "an unrelated action falls through rather than being refused" { | ||
| 1207 | 1212 | |
| 1208 | 1213 | test "a malformed payload is not a reason to refuse either action" { |
| 1209 | 1214 | // 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. | |
| 1212 | 1216 | var bridge = SpeechBridge.init(testing.allocator); |
| 1213 | 1217 | defer bridge.deinit(); |
| 1214 | 1218 | |
| @@ -644,6 +644,41 @@ test "the gate table has no entry for an action Zig does not serve" { | ||
| 644 | 644 | } |
| 645 | 645 | } |
| 646 | 646 | |
| 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. | |
| 654 | fn 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. | |
| 665 | fn 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 | ||
| 647 | 682 | /// The `.reason` text of every `.status = .unavailable` entry, by action. |
| 648 | 683 | /// |
| 649 | 684 | /// 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([] | ||
| 656 | 691 | errdefer map.deinit(); |
| 657 | 692 | |
| 658 | 693 | 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; | |
| 667 | 695 | |
| 668 | 696 | var search: usize = 0; |
| 669 | 697 | while (std.mem.indexOfPos(u8, table, search, ".status = .unavailable")) |at| { |
| 670 | 698 | 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; | |
| 689 | 700 | |
| 690 | 701 | // The reason, inline or by name. |
| 691 | 702 | 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" { | ||
| 1656 | 1667 | // `share` was one (#203). Its case resolves whether the person shared and |
| 1657 | 1668 | // rejects `CAPABILITY_DISABLED` and `INVALID_ARGUMENT`, and `craft.share(text)` |
| 1658 | 1669 | // 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. | |
| 1664 | const 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`. | |
| 1671 | 1680 | |
| 1672 | 1681 | /// One `postMessage(...)` call in the page script. |
| 1673 | 1682 | const Post = struct { |
| @@ -1730,13 +1739,45 @@ const PostIterator = struct { | ||
| 1730 | 1739 | } |
| 1731 | 1740 | }; |
| 1732 | 1741 | |
| 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. | |
| 1744 | fn 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. | |
| 1766 | const 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. | |
| 1734 | 1775 | /// |
| 1735 | 1776 | /// The case runs from its label to the next line at the same indentation that |
| 1736 | 1777 | /// starts another case, a `default:` or the closing brace. Nested switches sit |
| 1737 | 1778 | /// deeper and stay inside it. Comment lines are skipped so a remark that |
| 1738 | 1779 | /// mentions callbackId cannot pass for code that uses it. |
| 1739 | fn specAnswers(region: []const u8, action: []const u8) bool { | |
| 1780 | fn caseUsesCallback(region: []const u8, action: []const u8, use: CaseUse) bool { | |
| 1740 | 1781 | var lines = std.mem.splitScalar(u8, region, '\n'); |
| 1741 | 1782 | var case_indent: ?usize = null; |
| 1742 | 1783 | while (lines.next()) |line| { |
| @@ -1749,8 +1790,9 @@ fn specAnswers(region: []const u8, action: []const u8) bool { | ||
| 1749 | 1790 | std.mem.startsWith(u8, body, "default") or body[0] == '}'); |
| 1750 | 1791 | if (closes) return false; |
| 1751 | 1792 | 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; | |
| 1754 | 1796 | } |
| 1755 | 1797 | |
| 1756 | 1798 | 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 { | ||
| 1758 | 1800 | return false; |
| 1759 | 1801 | } |
| 1760 | 1802 | |
| 1803 | fn specAnswers(region: []const u8, action: []const u8) bool { | |
| 1804 | return caseUsesCallback(region, action, .answers); | |
| 1805 | } | |
| 1806 | ||
| 1807 | fn specResolves(region: []const u8, action: []const u8) bool { | |
| 1808 | return caseUsesCallback(region, action, .resolves); | |
| 1809 | } | |
| 1810 | ||
| 1761 | 1811 | /// Whether a `case` line lists `action` among its labels. |
| 1762 | 1812 | fn labels(case_line: []const u8, action: []const u8) bool { |
| 1763 | 1813 | var search: usize = 0; |
| @@ -1769,13 +1819,6 @@ fn labels(case_line: []const u8, action: []const u8) bool { | ||
| 1769 | 1819 | return false; |
| 1770 | 1820 | } |
| 1771 | 1821 | |
| 1772 | fn 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 | ||
| 1779 | 1822 | test "the post scan tells a call that waits from one that does not" { |
| 1780 | 1823 | // Shown both answers before it is trusted with the real file. A scanner |
| 1781 | 1824 | // that finds nothing proves nothing. |
| @@ -1818,6 +1861,23 @@ test "the post scan tells a call that waits from one that does not" { | ||
| 1818 | 1861 | try testing.expect(!specAnswers(dispatcher, "stopListening")); |
| 1819 | 1862 | try testing.expect(!specAnswers(dispatcher, "log")); |
| 1820 | 1863 | 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")); | |
| 1821 | 1881 | } |
| 1822 | 1882 | |
| 1823 | 1883 | test "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" { | ||
| 1829 | 1889 | literal_posts += 1; |
| 1830 | 1890 | if (post.carries_callback) continue; |
| 1831 | 1891 | if (!specAnswers(region, action)) continue; |
| 1832 | if (isRecordedUnanswered(action)) continue; | |
| 1833 | 1892 | |
| 1834 | 1893 | std.debug.print( |
| 1835 | 1894 | "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" { | ||
| 1844 | 1903 | try testing.expect(literal_posts >= 40); |
| 1845 | 1904 | } |
| 1846 | 1905 | |
| 1847 | test "every recorded unanswered post is real, and still unanswered" { | |
| 1906 | test "every call the page waits on has a case that can resolve it" { | |
| 1848 | 1907 | 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 | } | |
| 1858 | 1927 | |
| 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; | |
| 1928 | test "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 | } | |
| 1864 | 1943 | |
| 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; | |
| 1866 | 1949 | 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", | |
| 1869 | 1952 | .{action}, |
| 1870 | 1953 | ); |
| 1871 | return error.RecordedPostIsFixed; | |
| 1954 | return error.ZigLeavesAnAwaitedCallUnanswered; | |
| 1872 | 1955 | } |
| 1873 | 1956 | } |
| 1957 | try testing.expect(declarations >= 80); | |
| 1874 | 1958 | } |
| @@ -219,6 +219,87 @@ | ||
| 219 | 219 | }) |
| 220 | 220 | } |
| 221 | 221 | }, |
| 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 | }, | |
| 222 | 303 | { |
| 223 | 304 | // Sharing is off too, and this is the call that used to answer |
| 224 | 305 | // nothing: `craft.share(text)` posted no callbackId, so Swift's |
| @@ -23,13 +23,18 @@ const DEEP_LINK_SCHEME = 'crafte2eprobe' | ||
| 23 | 23 | * was a location promise that never settled with location *enabled*, and a |
| 24 | 24 | * disabled capability cannot reach that code. Sharing took over the rejection. |
| 25 | 25 | * |
| 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. | |
| 28 | 29 | */ |
| 29 | 30 | const CONFIG = { |
| 30 | 31 | enableClipboard: true, |
| 31 | 32 | enableGeolocation: true, |
| 33 | // On so the success path is exercised: an enabled haptic used to answer | |
| 34 | // nothing at all (#207). | |
| 35 | enableHaptics: true, | |
| 32 | 36 | enableShare: false, |
| 37 | enableSpeechRecognition: false, | |
| 33 | 38 | // For the cold-start link (#198), which the harness opens from an XCUITest. |
| 34 | 39 | enableDeepLinks: true, |
| 35 | 40 | urlSchemes: [DEEP_LINK_SCHEME], |
| @@ -270,7 +270,10 @@ describe('zig attribution', () => { | ||
| 270 | 270 | }) |
| 271 | 271 | |
| 272 | 272 | 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') | |
| 274 | 277 | expect(zigRefusals(text)).toEqual(ZIG_REFUSED_ACTIONS) |
| 275 | 278 | }) |
| 276 | 279 | |
| @@ -80,7 +80,13 @@ export const REQUIRED_CASES: Record<MobilePlatform, string[]> = { | ||
| 80 | 80 | 'clipboard.roundTrip', |
| 81 | 81 | 'permissions.location.granted', |
| 82 | 82 | 'geolocation.currentPosition', |
| 83 | 'geolocation.watchPosition.resolvesTrue', | |
| 84 | 'geolocation.clearWatch.resolvesTrue', | |
| 85 | 'haptic.resolvesTrue', | |
| 86 | 'vibrate.resolvesTrue', | |
| 83 | 87 | 'share.disabled.rejects', |
| 88 | 'speech.disabled.rejects', | |
| 89 | 'speech.stopListening.resolvesTrue', | |
| 84 | 90 | ], |
| 85 | 91 | android: [ |
| 86 | 92 | 'bridge.ready', |
| @@ -548,12 +554,18 @@ export function zigRefusals(text: string): string[] { | ||
| 548 | 554 | */ |
| 549 | 555 | export const ZIG_TESTED_ACTIONS = [ |
| 550 | 556 | 'checkPermission', |
| 557 | 'clearWatch', | |
| 551 | 558 | 'clipboardRead', |
| 552 | 559 | 'clipboardWrite', |
| 553 | 560 | 'getCurrentPosition', |
| 554 | 561 | 'getDeviceInfo', |
| 562 | 'haptic', | |
| 555 | 563 | 'requestPermission', |
| 556 | 564 | 'share', |
| 565 | 'startListening', | |
| 566 | 'stopListening', | |
| 567 | 'vibrate', | |
| 568 | 'watchPosition', | |
| 557 | 569 | ] |
| 558 | 570 | |
| 559 | 571 | /** |
| @@ -569,6 +581,7 @@ export const ZIG_SERVED_ACTIONS = ZIG_TESTED_ACTIONS.filter(action => action !== | ||
| 569 | 581 | /** The actions the suite configures off, so Zig must refuse each by name. */ |
| 570 | 582 | export const ZIG_REFUSED_ACTIONS = [ |
| 571 | 583 | 'share', |
| 584 | 'startListening', | |
| 572 | 585 | ] |
| 573 | 586 | |
| 574 | 587 | /** |