@swiftbrowser/native 0.18.1 → 0.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +73 -8
  2. package/dist/bridge.d.ts +64 -2
  3. package/dist/bridge.d.ts.map +1 -1
  4. package/dist/bridge.js.map +1 -1
  5. package/dist/geolocation.d.ts +73 -0
  6. package/dist/geolocation.d.ts.map +1 -0
  7. package/dist/geolocation.js +109 -0
  8. package/dist/geolocation.js.map +1 -0
  9. package/dist/hostRequests.d.ts +3 -1
  10. package/dist/hostRequests.d.ts.map +1 -1
  11. package/dist/hostRequests.js +46 -5
  12. package/dist/hostRequests.js.map +1 -1
  13. package/dist/index.d.ts +1 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/nativeFunctions.d.ts +35 -0
  18. package/dist/nativeFunctions.d.ts.map +1 -0
  19. package/dist/nativeFunctions.js +90 -0
  20. package/dist/nativeFunctions.js.map +1 -0
  21. package/dist/reachability.d.ts +24 -0
  22. package/dist/reachability.d.ts.map +1 -0
  23. package/dist/reachability.js +41 -0
  24. package/dist/reachability.js.map +1 -0
  25. package/dist/run.d.ts.map +1 -1
  26. package/dist/run.js +3 -1
  27. package/dist/run.js.map +1 -1
  28. package/dist/runtime.d.ts +9 -2
  29. package/dist/runtime.d.ts.map +1 -1
  30. package/dist/runtime.js +62 -5
  31. package/dist/runtime.js.map +1 -1
  32. package/dist/speech.d.ts +148 -0
  33. package/dist/speech.d.ts.map +1 -0
  34. package/dist/speech.js +243 -0
  35. package/dist/speech.js.map +1 -0
  36. package/dist/wakeLock.d.ts +32 -0
  37. package/dist/wakeLock.d.ts.map +1 -0
  38. package/dist/wakeLock.js +60 -0
  39. package/dist/wakeLock.js.map +1 -0
  40. package/package.json +3 -3
  41. package/src/bridge.ts +70 -2
  42. package/src/geolocation.ts +160 -0
  43. package/src/hostRequests.ts +47 -7
  44. package/src/index.ts +1 -0
  45. package/src/nativeFunctions.ts +94 -0
  46. package/src/reachability.ts +49 -0
  47. package/src/run.ts +5 -3
  48. package/src/runtime.ts +58 -5
  49. package/src/speech.ts +312 -0
  50. package/src/wakeLock.ts +63 -0
  51. package/swift/Sources/SwiftBrowserNative/Host/AppleSignInBridge.swift +139 -0
  52. package/swift/Sources/SwiftBrowserNative/Host/AudioRecorderBridge.swift +5 -0
  53. package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +83 -12
  54. package/swift/Sources/SwiftBrowserNative/Host/GeolocationBridge.swift +150 -0
  55. package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +131 -2
  56. package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +81 -11
  57. package/swift/Sources/SwiftBrowserNative/Host/ICloudBridge.swift +112 -0
  58. package/swift/Sources/SwiftBrowserNative/Host/ReachabilityBridge.swift +47 -0
  59. package/swift/Sources/SwiftBrowserNative/Host/SpeechBridge.swift +106 -0
  60. package/swift/Sources/SwiftBrowserNative/Host/SpeechRecognitionBridge.swift +198 -0
  61. package/swift/Sources/SwiftBrowserNative/Host/WorkerHost.swift +14 -5
  62. package/swift/Sources/SwiftBrowserNative/Runtime/NativeFunctions.swift +134 -0
  63. package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +1 -0
  64. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +2 -0
  65. package/swift/Sources/SwiftBrowserNative/Views/Kinds/TextInputHints.swift +105 -0
  66. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Touches.swift +102 -0
  67. package/swift/Sources/SwiftBrowserProtocol/Props.swift +13 -0
  68. package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +9 -0
  69. package/wasm/SwiftBrowserNative.wasm +2 -2
package/src/speech.ts ADDED
@@ -0,0 +1,312 @@
1
+ /**
2
+ * The Web Speech API over the host: `speechSynthesis` and
3
+ * `SpeechSynthesisUtterance` on `AVSpeechSynthesizer` (the bridge's
4
+ * `speak(id, json)` and `speechControl(action)`, events at
5
+ * `__sbSpeechEvent(id, json)`), and `SpeechRecognition` on
6
+ * `SFSpeechRecognizer` (`recognize(id, action, json)`, events at
7
+ * `__sbRecognition(id, json)`; the app's Info.plist names
8
+ * `NSSpeechRecognitionUsageDescription` and `NSMicrophoneUsageDescription`).
9
+ * The shapes apps use: an utterance's `text`, `lang`, `rate`, `pitch`,
10
+ * `volume` and its `start`, `end`, `error`, `pause`, `resume` and `boundary`
11
+ * events; a recognition's `lang`, `continuous`, `interimResults`, `start()`,
12
+ * `stop()`, `abort()` and its `start`, `result` (with `results[i][0].transcript`
13
+ * and `isFinal`), `error` (`error`: `not-allowed`, `audio-capture`,
14
+ * `no-speech`, `network`, `aborted`) and `end` events.
15
+ */
16
+
17
+ import type { NativeBridge } from './bridge';
18
+
19
+ type Listener<E> = (event: E) => void;
20
+
21
+ /** Handlers and listeners by event type, as the DOM's targets have them. */
22
+ class Target<E extends { type: string }> {
23
+ private readonly listeners = new Map<string, Set<Listener<E>>>();
24
+ constructor(private readonly handlers: Record<string, unknown>) {}
25
+ add(type: string, listener: Listener<E>): void {
26
+ if (typeof listener !== 'function') return;
27
+ this.listeners.set(type, (this.listeners.get(type) ?? new Set()).add(listener));
28
+ }
29
+ remove(type: string, listener: Listener<E>): void {
30
+ this.listeners.get(type)?.delete(listener);
31
+ }
32
+ fire(event: E): void {
33
+ const handler = this.handlers[`on${event.type}`];
34
+ if (typeof handler === 'function') (handler as Listener<E>)(event);
35
+ for (const listener of [...(this.listeners.get(event.type) ?? [])]) listener(event);
36
+ }
37
+ }
38
+
39
+ // MARK: Synthesis
40
+
41
+ export interface SpeechSynthesisEvent {
42
+ type: 'start' | 'end' | 'error' | 'pause' | 'resume' | 'boundary';
43
+ utterance: SpeechSynthesisUtterance;
44
+ charIndex: number;
45
+ charLength: number;
46
+ elapsedTime: number;
47
+ name: string;
48
+ error?: string;
49
+ }
50
+
51
+ export class SpeechSynthesisUtterance {
52
+ text: string;
53
+ lang = '';
54
+ voice: { name: string; lang: string } | null = null;
55
+ volume = 1;
56
+ rate = 1;
57
+ pitch = 1;
58
+ onstart: Listener<SpeechSynthesisEvent> | null = null;
59
+ onend: Listener<SpeechSynthesisEvent> | null = null;
60
+ onerror: Listener<SpeechSynthesisEvent> | null = null;
61
+ onpause: Listener<SpeechSynthesisEvent> | null = null;
62
+ onresume: Listener<SpeechSynthesisEvent> | null = null;
63
+ onboundary: Listener<SpeechSynthesisEvent> | null = null;
64
+ onmark: Listener<SpeechSynthesisEvent> | null = null;
65
+ /** @internal */
66
+ readonly target = new Target<SpeechSynthesisEvent>(this as unknown as Record<string, unknown>);
67
+ constructor(text = '') {
68
+ this.text = String(text);
69
+ }
70
+ addEventListener(type: string, listener: Listener<SpeechSynthesisEvent>): void {
71
+ this.target.add(type, listener);
72
+ }
73
+ removeEventListener(type: string, listener: Listener<SpeechSynthesisEvent>): void {
74
+ this.target.remove(type, listener);
75
+ }
76
+ }
77
+
78
+ export interface SpeechSynthesis {
79
+ readonly speaking: boolean;
80
+ readonly pending: boolean;
81
+ readonly paused: boolean;
82
+ onvoiceschanged: (() => void) | null;
83
+ speak(utterance: SpeechSynthesisUtterance): void;
84
+ cancel(): void;
85
+ pause(): void;
86
+ resume(): void;
87
+ getVoices(): { name: string; lang: string; default: boolean; localService: boolean; voiceURI: string }[];
88
+ addEventListener(type: string, listener: () => void): void;
89
+ removeEventListener(type: string, listener: () => void): void;
90
+ }
91
+
92
+ /** The host's event for utterance `id`. */
93
+ export interface NativeSpeechEvent {
94
+ type: 'start' | 'end' | 'cancel' | 'pause' | 'resume' | 'boundary' | 'error';
95
+ charIndex?: number;
96
+ charLength?: number;
97
+ message?: string;
98
+ }
99
+
100
+ export function createSpeechSynthesis(native: NativeBridge): { speechSynthesis: SpeechSynthesis; receive(id: number, json: string): void } {
101
+ /** The utterances handed to the host, by id, in order: the first is speaking once it started. */
102
+ const queue = new Map<number, SpeechSynthesisUtterance>();
103
+ let nextId = 1;
104
+ let speakingId: number | null = null;
105
+ let paused = false;
106
+ const started = new Map<number, number>();
107
+ const event = (type: SpeechSynthesisEvent['type'], utterance: SpeechSynthesisUtterance, id: number, extra: Partial<SpeechSynthesisEvent> = {}): SpeechSynthesisEvent => ({
108
+ type,
109
+ utterance,
110
+ charIndex: 0,
111
+ charLength: 0,
112
+ elapsedTime: (native.now() - (started.get(id) ?? native.now())) / 1000,
113
+ name: '',
114
+ ...extra,
115
+ });
116
+ const synthesis: SpeechSynthesis = {
117
+ get speaking() {
118
+ return speakingId !== null;
119
+ },
120
+ get pending() {
121
+ return queue.size > (speakingId === null ? 0 : 1);
122
+ },
123
+ get paused() {
124
+ return paused;
125
+ },
126
+ onvoiceschanged: null,
127
+ speak(utterance) {
128
+ if (!(utterance instanceof SpeechSynthesisUtterance)) throw new TypeError('speechSynthesis.speak: not a SpeechSynthesisUtterance');
129
+ const id = nextId++;
130
+ queue.set(id, utterance);
131
+ native.speak!(id, JSON.stringify({ text: utterance.text, lang: utterance.lang || null, voice: utterance.voice?.name ?? null, rate: utterance.rate, pitch: utterance.pitch, volume: utterance.volume }));
132
+ },
133
+ cancel() {
134
+ native.speechControl!('cancel');
135
+ // The host answers each with `cancel`; the queue empties as they come.
136
+ },
137
+ pause() {
138
+ native.speechControl!('pause');
139
+ },
140
+ resume() {
141
+ native.speechControl!('resume');
142
+ },
143
+ getVoices() {
144
+ return [];
145
+ },
146
+ addEventListener() {},
147
+ removeEventListener() {},
148
+ };
149
+ return {
150
+ speechSynthesis: synthesis,
151
+ receive(id, json) {
152
+ const utterance = queue.get(id);
153
+ if (!utterance) return;
154
+ const e = JSON.parse(json) as NativeSpeechEvent;
155
+ switch (e.type) {
156
+ case 'start':
157
+ speakingId = id;
158
+ paused = false;
159
+ started.set(id, native.now());
160
+ utterance.target.fire(event('start', utterance, id));
161
+ break;
162
+ case 'boundary':
163
+ utterance.target.fire(event('boundary', utterance, id, { name: 'word', charIndex: e.charIndex ?? 0, charLength: e.charLength ?? 0 }));
164
+ break;
165
+ case 'pause':
166
+ paused = true;
167
+ utterance.target.fire(event('pause', utterance, id, { charIndex: e.charIndex ?? 0 }));
168
+ break;
169
+ case 'resume':
170
+ paused = false;
171
+ utterance.target.fire(event('resume', utterance, id, { charIndex: e.charIndex ?? 0 }));
172
+ break;
173
+ case 'end':
174
+ case 'cancel':
175
+ case 'error': {
176
+ queue.delete(id);
177
+ if (speakingId === id) speakingId = null;
178
+ if (queue.size === 0) paused = false;
179
+ const done = e.type === 'end' ? event('end', utterance, id) : event('error', utterance, id, { error: e.type === 'cancel' ? 'canceled' : (e.message ?? 'synthesis-failed') });
180
+ started.delete(id);
181
+ utterance.target.fire(done);
182
+ break;
183
+ }
184
+ }
185
+ },
186
+ };
187
+ }
188
+
189
+ // MARK: Recognition
190
+
191
+ export interface SpeechRecognitionAlternative {
192
+ transcript: string;
193
+ confidence: number;
194
+ }
195
+
196
+ export interface SpeechRecognitionResult {
197
+ readonly isFinal: boolean;
198
+ readonly length: number;
199
+ [index: number]: SpeechRecognitionAlternative;
200
+ item(index: number): SpeechRecognitionAlternative;
201
+ }
202
+
203
+ export interface SpeechRecognitionEvent {
204
+ type: 'start' | 'audiostart' | 'result' | 'error' | 'end' | 'audioend' | 'nomatch';
205
+ resultIndex: number;
206
+ results: SpeechRecognitionResult[];
207
+ error?: string;
208
+ message?: string;
209
+ }
210
+
211
+ /** The host's event for recognition `id`. */
212
+ export interface NativeRecognitionEvent {
213
+ type: 'start' | 'result' | 'error' | 'end';
214
+ transcript?: string;
215
+ isFinal?: boolean;
216
+ confidence?: number;
217
+ error?: string;
218
+ message?: string;
219
+ }
220
+
221
+ export type RecognitionBridge = Required<Pick<NativeBridge, 'recognize'>>;
222
+
223
+ const recognitions = new Map<number, SpeechRecognition>();
224
+ let recognitionBridge: RecognitionBridge | null = null;
225
+ let nextRecognition = 1;
226
+
227
+ export class SpeechRecognition {
228
+ lang = '';
229
+ continuous = false;
230
+ interimResults = false;
231
+ maxAlternatives = 1;
232
+ onstart: Listener<SpeechRecognitionEvent> | null = null;
233
+ onaudiostart: Listener<SpeechRecognitionEvent> | null = null;
234
+ onresult: Listener<SpeechRecognitionEvent> | null = null;
235
+ onerror: Listener<SpeechRecognitionEvent> | null = null;
236
+ onend: Listener<SpeechRecognitionEvent> | null = null;
237
+ onaudioend: Listener<SpeechRecognitionEvent> | null = null;
238
+ onnomatch: Listener<SpeechRecognitionEvent> | null = null;
239
+ private readonly target = new Target<SpeechRecognitionEvent>(this as unknown as Record<string, unknown>);
240
+ private id: number | null = null;
241
+ /** The final results so far in this session, then the interim one (if any) last. */
242
+ private results: SpeechRecognitionResult[] = [];
243
+
244
+ static install(bridge: RecognitionBridge): void {
245
+ recognitionBridge = bridge;
246
+ }
247
+
248
+ start(): void {
249
+ if (!recognitionBridge) throw new Error('SpeechRecognition: no host');
250
+ if (this.id !== null) throw Object.assign(new Error('SpeechRecognition: already started'), { name: 'InvalidStateError' });
251
+ this.id = nextRecognition++;
252
+ this.results = [];
253
+ recognitions.set(this.id, this);
254
+ recognitionBridge.recognize(this.id, 'start', JSON.stringify({ lang: this.lang || null, continuous: this.continuous, interimResults: this.interimResults }));
255
+ }
256
+
257
+ /** Stops listening; what was heard is still delivered, then `end`. */
258
+ stop(): void {
259
+ if (this.id === null) return;
260
+ recognitionBridge?.recognize(this.id, 'stop', '{}');
261
+ }
262
+
263
+ /** Stops listening and keeps nothing more. */
264
+ abort(): void {
265
+ if (this.id === null) return;
266
+ recognitionBridge?.recognize(this.id, 'abort', '{}');
267
+ }
268
+
269
+ addEventListener(type: string, listener: Listener<SpeechRecognitionEvent>): void {
270
+ this.target.add(type, listener);
271
+ }
272
+ removeEventListener(type: string, listener: Listener<SpeechRecognitionEvent>): void {
273
+ this.target.remove(type, listener);
274
+ }
275
+
276
+ /** `__sbRecognition(id, json)`. */
277
+ static receive(id: number, json: string): void {
278
+ recognitions.get(id)?.handle(JSON.parse(json) as NativeRecognitionEvent);
279
+ }
280
+
281
+ private event(type: SpeechRecognitionEvent['type'], extra: Partial<SpeechRecognitionEvent> = {}): SpeechRecognitionEvent {
282
+ return { type, resultIndex: 0, results: this.results, ...extra };
283
+ }
284
+
285
+ private handle(e: NativeRecognitionEvent): void {
286
+ switch (e.type) {
287
+ case 'start':
288
+ this.target.fire(this.event('start'));
289
+ this.target.fire(this.event('audiostart'));
290
+ break;
291
+ case 'result': {
292
+ const alternative: SpeechRecognitionAlternative = { transcript: e.transcript ?? '', confidence: e.confidence ?? 0 };
293
+ const result: SpeechRecognitionResult = { isFinal: e.isFinal === true, length: 1, 0: alternative, item: () => alternative };
294
+ // An interim result replaces the last interim one; a final one takes its place for good.
295
+ const last = this.results[this.results.length - 1];
296
+ const index = last && !last.isFinal ? this.results.length - 1 : this.results.length;
297
+ this.results = [...this.results.slice(0, index), result];
298
+ this.target.fire(this.event('result', { resultIndex: index }));
299
+ break;
300
+ }
301
+ case 'error':
302
+ this.target.fire(this.event('error', { error: e.error ?? 'aborted', message: e.message ?? '' }));
303
+ break;
304
+ case 'end':
305
+ if (this.id !== null) recognitions.delete(this.id);
306
+ this.id = null;
307
+ this.target.fire(this.event('audioend'));
308
+ this.target.fire(this.event('end'));
309
+ break;
310
+ }
311
+ }
312
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * `navigator.wakeLock` (the Screen Wake Lock API) over the host's idle timer
3
+ * (`wakeLock(active)` on the bridge: `UIApplication.isIdleTimerDisabled`, a
4
+ * power assertion on macOS). `request('screen')` resolves with a
5
+ * `WakeLockSentinel`; the screen stays awake while any sentinel is held and
6
+ * `release()` lets it go. Unlike a page's, which the browser releases when
7
+ * the document hides, a native sentinel stays held in the background: the
8
+ * system owns the screen there, and the lock holds again when the app returns.
9
+ */
10
+
11
+ import type { NativeBridge } from './bridge';
12
+
13
+ type ReleaseListener = (event: { type: 'release'; target: WakeLockSentinel }) => void;
14
+
15
+ export class WakeLockSentinel {
16
+ readonly type = 'screen' as const;
17
+ released = false;
18
+ onrelease: ReleaseListener | null = null;
19
+ private readonly listeners = new Set<ReleaseListener>();
20
+ /** @internal */
21
+ constructor(private readonly onRelease: (sentinel: WakeLockSentinel) => void) {}
22
+
23
+ release(): Promise<void> {
24
+ if (this.released) return Promise.resolve();
25
+ this.released = true;
26
+ this.onRelease(this);
27
+ const event = { type: 'release' as const, target: this };
28
+ this.onrelease?.(event);
29
+ for (const listener of [...this.listeners]) listener(event);
30
+ return Promise.resolve();
31
+ }
32
+
33
+ addEventListener(type: string, listener: ReleaseListener): void {
34
+ if (type === 'release' && typeof listener === 'function') this.listeners.add(listener);
35
+ }
36
+
37
+ removeEventListener(type: string, listener: ReleaseListener): void {
38
+ if (type === 'release') this.listeners.delete(listener);
39
+ }
40
+ }
41
+
42
+ export interface WakeLock {
43
+ request(type?: string): Promise<WakeLockSentinel>;
44
+ }
45
+
46
+ export function createWakeLock(native: NativeBridge): WakeLock {
47
+ const held = new Set<WakeLockSentinel>();
48
+ const release = (sentinel: WakeLockSentinel): void => {
49
+ held.delete(sentinel);
50
+ if (held.size === 0) native.wakeLock!(false);
51
+ };
52
+ return {
53
+ request(type = 'screen') {
54
+ if (type !== 'screen') {
55
+ return Promise.reject(Object.assign(new TypeError(`wakeLock.request: '${String(type)}' is not a wake lock type ('screen')`), { name: 'TypeError' }));
56
+ }
57
+ const sentinel = new WakeLockSentinel(release);
58
+ if (held.size === 0) native.wakeLock!(true);
59
+ held.add(sentinel);
60
+ return Promise.resolve(sentinel);
61
+ },
62
+ };
63
+ }
@@ -0,0 +1,139 @@
1
+ #if canImport(SwiftUI) && canImport(AuthenticationServices) && !canImport(FoundationBridge)
2
+ import AuthenticationServices
3
+ import Foundation
4
+ import SwiftBrowserProtocol
5
+ #if canImport(UIKit)
6
+ import UIKit
7
+ #elseif canImport(AppKit)
8
+ import AppKit
9
+ #endif
10
+
11
+ /// Sign in with Apple for the app (the `appleSignIn` host request,
12
+ /// packages/native/src/hostRequests.ts): `signIn` shows the system sheet
13
+ /// through `ASAuthorizationController` with the scopes, nonce and state asked
14
+ /// for (the app's entitlement: `com.apple.developer.applesignin`,
15
+ /// `swiftbrowser native init --sign-in-with-apple`) and answers the
16
+ /// credential as the page's `AppleIDCredential`; `credentialState` asks the
17
+ /// provider what a user identifier's credential is now. One sign-in at a time.
18
+ @MainActor
19
+ final class AppleSignInBridge: NSObject, ASAuthorizationControllerDelegate, ASAuthorizationControllerPresentationContextProviding {
20
+ private var continuation: CheckedContinuation<JSONValue, any Error>?
21
+ private var controller: ASAuthorizationController?
22
+
23
+ func handle(_ params: JSONObject) async throws -> JSONValue {
24
+ switch params["action"]?.string {
25
+ case "signIn":
26
+ return try await signIn(params)
27
+ case "credentialState":
28
+ guard let user = params["user"]?.string else { throw HostRequestFailure("appleSignIn: credentialState needs a user") }
29
+ return .object(["state": .string(try await credentialState(user))])
30
+ default:
31
+ throw HostRequestFailure("unsupported: appleSignIn: unknown action \(params["action"]?.string ?? "")")
32
+ }
33
+ }
34
+
35
+ private func signIn(_ params: JSONObject) async throws -> JSONValue {
36
+ guard continuation == nil else { throw HostRequestFailure("busy: a sign-in is already showing") }
37
+ let request = ASAuthorizationAppleIDProvider().createRequest()
38
+ let scopes = params["scopes"]?.array?.compactMap(\.string) ?? []
39
+ request.requestedScopes = scopes.compactMap { scope in
40
+ switch scope {
41
+ case "fullName": return .fullName
42
+ case "email": return .email
43
+ default: return nil
44
+ }
45
+ }
46
+ request.nonce = params["nonce"]?.string
47
+ request.state = params["state"]?.string
48
+ let controller = ASAuthorizationController(authorizationRequests: [request])
49
+ controller.delegate = self
50
+ controller.presentationContextProvider = self
51
+ self.controller = controller
52
+ return try await withCheckedThrowingContinuation { continuation in
53
+ self.continuation = continuation
54
+ controller.performRequests()
55
+ }
56
+ }
57
+
58
+ private func credentialState(_ user: String) async throws -> String {
59
+ let state = try await ASAuthorizationAppleIDProvider().credentialState(forUserID: user)
60
+ switch state {
61
+ case .authorized: return "authorized"
62
+ case .revoked: return "revoked"
63
+ case .notFound: return "notFound"
64
+ case .transferred: return "transferred"
65
+ @unknown default: return "unknown"
66
+ }
67
+ }
68
+
69
+ private func finish(_ result: Result<JSONValue, any Error>) {
70
+ controller = nil
71
+ guard let continuation else { return }
72
+ self.continuation = nil
73
+ continuation.resume(with: result)
74
+ }
75
+
76
+ /// The credential as JSON (read off the main actor's data, where the delegate is called).
77
+ private nonisolated static func json(_ credential: ASAuthorizationAppleIDCredential) -> JSONValue {
78
+ var fields: JSONObject = [
79
+ "user": .string(credential.user),
80
+ "email": credential.email.map(JSONValue.string) ?? .null,
81
+ "identityToken": credential.identityToken.flatMap { String(data: $0, encoding: .utf8) }.map(JSONValue.string) ?? .null,
82
+ "authorizationCode": credential.authorizationCode.flatMap { String(data: $0, encoding: .utf8) }.map(JSONValue.string) ?? .null,
83
+ "state": credential.state.map(JSONValue.string) ?? .null,
84
+ ]
85
+ switch credential.realUserStatus {
86
+ case .likelyReal: fields["realUserStatus"] = .string("likelyReal")
87
+ case .unknown: fields["realUserStatus"] = .string("unknown")
88
+ case .unsupported: fields["realUserStatus"] = .string("unsupported")
89
+ @unknown default: fields["realUserStatus"] = .string("unknown")
90
+ }
91
+ if let name = credential.fullName {
92
+ fields["fullName"] = .object([
93
+ "givenName": name.givenName.map(JSONValue.string) ?? .null,
94
+ "familyName": name.familyName.map(JSONValue.string) ?? .null,
95
+ "middleName": name.middleName.map(JSONValue.string) ?? .null,
96
+ "namePrefix": name.namePrefix.map(JSONValue.string) ?? .null,
97
+ "nameSuffix": name.nameSuffix.map(JSONValue.string) ?? .null,
98
+ "nickname": name.nickname.map(JSONValue.string) ?? .null,
99
+ ])
100
+ } else {
101
+ fields["fullName"] = .null
102
+ }
103
+ return .object(fields)
104
+ }
105
+
106
+ // MARK: ASAuthorizationControllerDelegate (on the main thread, where the controller was made)
107
+
108
+ nonisolated func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
109
+ let result: Result<JSONValue, any Error>
110
+ if let credential = authorization.credential as? ASAuthorizationAppleIDCredential {
111
+ result = .success(Self.json(credential))
112
+ } else {
113
+ result = .failure(HostRequestFailure("appleSignIn: the credential is not an Apple ID"))
114
+ }
115
+ MainActor.assumeIsolated { finish(result) }
116
+ }
117
+
118
+ nonisolated func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: any Error) {
119
+ let cancelled = (error as? ASAuthorizationError)?.code == .canceled
120
+ let message = error.localizedDescription
121
+ MainActor.assumeIsolated {
122
+ finish(.failure(HostRequestFailure(cancelled ? "cancelled: the viewer closed Sign in with Apple" : "appleSignIn: \(message)")))
123
+ }
124
+ }
125
+
126
+ // MARK: ASAuthorizationControllerPresentationContextProviding
127
+
128
+ nonisolated func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
129
+ MainActor.assumeIsolated {
130
+ #if canImport(UIKit)
131
+ let windows = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.flatMap(\.windows)
132
+ return windows.first { $0.isKeyWindow } ?? windows.first ?? ASPresentationAnchor()
133
+ #else
134
+ return NSApplication.shared.keyWindow ?? ASPresentationAnchor()
135
+ #endif
136
+ }
137
+ }
138
+ }
139
+ #endif
@@ -39,6 +39,11 @@ final class AudioRecorderBridge {
39
39
 
40
40
  private func start() async throws {
41
41
  guard recorder == nil else { throw HostRequestFailure("busy: already recording") }
42
+ // Asking for the microphone without the usage string in the app's
43
+ // Info.plist ends the app (iOS's privacy check): an error instead.
44
+ guard Bundle.main.object(forInfoDictionaryKey: "NSMicrophoneUsageDescription") != nil else {
45
+ throw HostRequestFailure("permission: the app's Info.plist has no NSMicrophoneUsageDescription")
46
+ }
42
47
  guard await AVAudioApplication.requestRecordPermission() else {
43
48
  throw HostRequestFailure("permission: the microphone was not allowed")
44
49
  }