@swiftbrowser/native 0.18.0 → 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.
- package/README.md +73 -8
- package/dist/bridge.d.ts +64 -2
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js.map +1 -1
- package/dist/geolocation.d.ts +73 -0
- package/dist/geolocation.d.ts.map +1 -0
- package/dist/geolocation.js +109 -0
- package/dist/geolocation.js.map +1 -0
- package/dist/hostRequests.d.ts +3 -1
- package/dist/hostRequests.d.ts.map +1 -1
- package/dist/hostRequests.js +46 -5
- package/dist/hostRequests.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/nativeFunctions.d.ts +35 -0
- package/dist/nativeFunctions.d.ts.map +1 -0
- package/dist/nativeFunctions.js +90 -0
- package/dist/nativeFunctions.js.map +1 -0
- package/dist/reachability.d.ts +24 -0
- package/dist/reachability.d.ts.map +1 -0
- package/dist/reachability.js +41 -0
- package/dist/reachability.js.map +1 -0
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +3 -1
- package/dist/run.js.map +1 -1
- package/dist/runtime.d.ts +9 -2
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +62 -5
- package/dist/runtime.js.map +1 -1
- package/dist/speech.d.ts +148 -0
- package/dist/speech.d.ts.map +1 -0
- package/dist/speech.js +243 -0
- package/dist/speech.js.map +1 -0
- package/dist/wakeLock.d.ts +32 -0
- package/dist/wakeLock.d.ts.map +1 -0
- package/dist/wakeLock.js +60 -0
- package/dist/wakeLock.js.map +1 -0
- package/package.json +3 -3
- package/src/bridge.ts +70 -2
- package/src/geolocation.ts +160 -0
- package/src/hostRequests.ts +47 -7
- package/src/index.ts +1 -0
- package/src/nativeFunctions.ts +94 -0
- package/src/reachability.ts +49 -0
- package/src/run.ts +5 -3
- package/src/runtime.ts +58 -5
- package/src/speech.ts +312 -0
- package/src/wakeLock.ts +63 -0
- package/swift/Sources/SwiftBrowserNative/Host/AppleSignInBridge.swift +139 -0
- package/swift/Sources/SwiftBrowserNative/Host/AudioRecorderBridge.swift +5 -0
- package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +83 -12
- package/swift/Sources/SwiftBrowserNative/Host/GeolocationBridge.swift +150 -0
- package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +131 -2
- package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +81 -11
- package/swift/Sources/SwiftBrowserNative/Host/ICloudBridge.swift +112 -0
- package/swift/Sources/SwiftBrowserNative/Host/ReachabilityBridge.swift +47 -0
- package/swift/Sources/SwiftBrowserNative/Host/SpeechBridge.swift +106 -0
- package/swift/Sources/SwiftBrowserNative/Host/SpeechRecognitionBridge.swift +198 -0
- package/swift/Sources/SwiftBrowserNative/Host/WorkerHost.swift +14 -5
- package/swift/Sources/SwiftBrowserNative/Runtime/NativeFunctions.swift +134 -0
- package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +1 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +2 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/TextInputHints.swift +105 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Touches.swift +102 -0
- package/swift/Sources/SwiftBrowserProtocol/Props.swift +13 -0
- package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +9 -0
- 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
|
+
}
|
package/src/wakeLock.ts
ADDED
|
@@ -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
|
}
|