@swiftbrowser/native 0.18.1 → 0.18.3

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
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The host requests `@swiftbrowser/react` sends to the browser environment
3
- * (`ShareLink`, `fileImporter`, the clipboard, a color chooser: the web
3
+ * (`ShareLink`, `fileImporter`, `fileExporter`, the clipboard, a color chooser: the web
4
4
  * renderer's `performHostRequest` with its `browserEnvironment`), answered by
5
5
  * the native app instead: the bridge's `hostRequest(id, kind, json)` performs
6
6
  * one with UIKit (a share sheet, the photo or document picker, the
@@ -47,6 +47,7 @@ const pending = new Map<number, { resolve: (value: unknown) => void; reject: (er
47
47
  /** Points the requests at the bridge (the runtime, once per install). */
48
48
  export function installHostRequests(native: HostRequestBridge): void {
49
49
  bridge = native;
50
+ fileWatchers.clear();
50
51
  }
51
52
 
52
53
  /** The bridge's `__sbHostResponse(id, result)`. */
@@ -135,25 +136,54 @@ function appDirectories(): FileDirectories | null {
135
136
  return null;
136
137
  }
137
138
 
138
- /** The app's files (`files` requests): `fileManager`'s host over the sandbox. */
139
+ /** Who hears about the files iCloud Drive changed (`watch`). */
140
+ const fileWatchers = new Set<(urls: string[]) => void>();
141
+
142
+ /** The bridge's `__sbFilesChanged(json)`: the URLs another device changed in iCloud Drive (ICloudBridge.swift). */
143
+ export function receiveFilesChanged(json: string | string[]): void {
144
+ let urls: string[];
145
+ try {
146
+ const parsed: unknown = typeof json === 'string' ? JSON.parse(json) : json;
147
+ urls = Array.isArray(parsed) ? parsed.filter((url): url is string => typeof url === 'string') : [];
148
+ } catch {
149
+ return;
150
+ }
151
+ if (urls.length === 0) return;
152
+ for (const listener of [...fileWatchers]) listener(urls);
153
+ }
154
+
155
+ /** The app's files (`files` requests): `fileManager`'s host over the sandbox, and iCloud Drive's container once asked for. */
139
156
  function nativeFiles(directories: FileDirectories): Record<string, unknown> {
140
157
  const files = <T>(params: Record<string, unknown>): Promise<T> => hostRequest<T>('files', params);
158
+ let iCloud: Promise<string | null> | null = null;
141
159
  return {
142
160
  directories,
161
+ // The container's Documents (ICloudBridge.swift), resolved once; null without the entitlement or iCloud Drive.
162
+ iCloud: (): Promise<string | null> => (iCloud ??= files<{ url?: string } | null>({ action: 'iCloud' }).then((found) => (typeof found?.url === 'string' ? found.url : null))),
163
+ watch: (listener: (urls: string[]) => void): (() => void) => {
164
+ fileWatchers.add(listener);
165
+ return () => void fileWatchers.delete(listener);
166
+ },
143
167
  read: async (url: string): Promise<Uint8Array> => base64Decode((await files<{ data?: string }>({ action: 'read', url }))?.data ?? ''),
144
168
  write: (url: string, data: Uint8Array): Promise<void> => files({ action: 'write', url, data: base64Encode(data) }).then(() => undefined),
145
169
  remove: (url: string): Promise<void> => files({ action: 'remove', url }).then(() => undefined),
146
170
  createDirectory: (url: string): Promise<void> => files({ action: 'createDirectory', url }).then(() => undefined),
147
171
  list: async (url: string): Promise<string[]> => (await files<{ urls?: string[] }>({ action: 'list', url }))?.urls ?? [],
148
- attributes: async (url: string): Promise<{ size: number; modified: number | null; directory: boolean } | null> => {
149
- const found = await files<{ size?: number; modified?: number | null; directory?: boolean } | null>({ action: 'attributes', url });
172
+ attributes: async (url: string): Promise<{ size: number; modified: number | null; created: number | null; directory: boolean; type: string } | null> => {
173
+ const found = await files<{ size?: number; modified?: number | null; created?: number | null; directory?: boolean; type?: string } | null>({ action: 'attributes', url });
150
174
  if (!found || typeof found.size !== 'number') return null;
151
- return { size: found.size, modified: typeof found.modified === 'number' ? found.modified : null, directory: found.directory === true };
175
+ return {
176
+ size: found.size,
177
+ modified: typeof found.modified === 'number' ? found.modified : null,
178
+ created: typeof found.created === 'number' ? found.created : null,
179
+ directory: found.directory === true,
180
+ type: typeof found.type === 'string' ? found.type : '',
181
+ };
152
182
  },
153
183
  move: (from: string, to: string): Promise<void> => files({ action: 'move', from, to }).then(() => undefined),
154
184
  copy: (from: string, to: string): Promise<void> => files({ action: 'copy', from, to }).then(() => undefined),
155
- // AVPlayer and UIImage read the file themselves.
156
- loadURL: async (url: string): Promise<string> => url,
185
+ // AVPlayer and UIImage read the file themselves, once it is on the device (a file another device put in iCloud Drive is downloaded first).
186
+ loadURL: async (url: string): Promise<string> => (await files<{ url?: string } | null>({ action: 'download', url }))?.url ?? url,
157
187
  };
158
188
  }
159
189
 
@@ -180,6 +210,11 @@ export function nativeHostEnvironment(): Record<string, unknown> {
180
210
  void hostRequest('share', { files: [{ name: filename, type: blob.type, data: base64Encode(blob.bytes()) }] }).catch(() => undefined);
181
211
  },
182
212
  pickFiles: async (options: { accept: string; multiple: boolean }): Promise<SimpleFile[]> => filesOf(await hostRequest('pickFiles', { accept: options.accept, multiple: options.multiple })),
213
+ // `fileExporter`: the document picker, saving the files where the viewer chooses.
214
+ exportFiles: async (files: SimpleFile[]): Promise<{ outcome: string; urls?: string[] }> => {
215
+ const result = await hostRequest<{ outcome?: string; urls?: string[] } | null>('exportFiles', { files: await Promise.all(files.map(bridgedFile)) });
216
+ return { outcome: result?.outcome ?? 'cancelled', ...(result?.urls ? { urls: result.urls } : {}) };
217
+ },
183
218
  objectURL: (file: SimpleBlob): string => objectURL(file),
184
219
  clipboard: {
185
220
  writeText: (text: string): Promise<void> => hostRequest('clipboard', { action: 'write', text }).then(() => undefined),
@@ -190,6 +225,11 @@ export function nativeHostEnvironment(): Record<string, unknown> {
190
225
  readText: async (): Promise<string> => (await hostRequest<{ text?: string }>('clipboard', { action: 'readText' }))?.text ?? '',
191
226
  },
192
227
  pickColor: async (color: string): Promise<string | null> => (await hostRequest<{ color?: string }>('pickColor', { color }))?.color ?? null,
228
+ // Sign in with Apple (AppleSignInBridge.swift): the system sheet, and the credential's state.
229
+ appleSignIn: {
230
+ signIn: (options: { scopes: string[]; nonce?: string; state?: string }): Promise<unknown> => hostRequest('appleSignIn', { action: 'signIn', ...options }),
231
+ credentialState: async (user: string): Promise<string> => (await hostRequest<{ state?: string } | null>('appleSignIn', { action: 'credentialState', user }))?.state ?? 'unknown',
232
+ },
193
233
  // `AVAudioRecorder` (AudioRecorderBridge.swift): a recording ends as a file URL in Documents; `trim` writes another.
194
234
  audioRecorder: {
195
235
  start: (): Promise<void> => hostRequest('audioRecord', { action: 'start' }).then(() => undefined),
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@
13
13
 
14
14
  export { run, type RunOptions, type Running } from './run';
15
15
  export { Native, type NativeProps } from './native';
16
+ export { callNative, callNativeSync, nativeFunctions, hasNativeFunction, onNativeEvent } from './nativeFunctions';
16
17
  export { installRuntime, type InstallOptions } from './runtime';
17
18
  export { nativeBridge, launchArguments, type NativeBridge, type NativeFetchRequest, type NativeFetchResult, type WorkerEngine } from './bridge';
18
19
  export { SimpleHeaders, SimpleRequest, SimpleResponse, SimpleAbortController, SimpleAbortSignal, createFetch } from './fetch';
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Swift the app calls: the function half of the escape hatch (docs/native.md;
3
+ * `<Native>` in native.ts is the view half). The Swift app registers
4
+ * functions with `NativeFunctions.register(name) { call in ... }`
5
+ * (Native/Sources/SwiftBrowserNative/Runtime/NativeFunctions.swift);
6
+ * `callNative(name, params)` runs one and resolves with what it returned,
7
+ * through the host request `app:<name>` (hostRequests.ts), `callNativeSync`
8
+ * runs a sync one inline over the bridge's `callNativeSync(name, json)`, and
9
+ * `nativeFunctions()` lists them over `nativeFunctions()`. What Swift sends
10
+ * with `NativeFunctions.send(name, value)` arrives at `__sbNativeEvent(name,
11
+ * json)` and reaches `onNativeEvent(name)`'s listeners.
12
+ *
13
+ * On a page, or in a host without the functions, `callNative` rejects and
14
+ * `callNativeSync` throws `unsupported:`, and `nativeFunctions()` is empty:
15
+ * an app checks `hasNativeFunction(name)` and falls back to the web's way.
16
+ * A function that throws `NativeFunctionCancelled` rejects with an
17
+ * `AbortError`, as the system pickers' cancellations do.
18
+ */
19
+
20
+ import type { NativeBridge } from './bridge';
21
+ import { hostRequest } from './hostRequests';
22
+
23
+ export type NativeFunctionsBridge = Pick<NativeBridge, 'nativeFunctions' | 'callNativeSync'>;
24
+
25
+ let bridge: NativeFunctionsBridge | null = null;
26
+ const listeners = new Map<string, Set<(value: unknown) => void>>();
27
+
28
+ /** Points the functions at the bridge (the runtime, once per install); null forgets it. */
29
+ export function installNativeFunctions(native: NativeFunctionsBridge | null): void {
30
+ bridge = native;
31
+ listeners.clear();
32
+ }
33
+
34
+ /** The names the Swift app registered; empty on a page. */
35
+ export function nativeFunctions(): string[] {
36
+ return bridge?.nativeFunctions?.() ?? [];
37
+ }
38
+
39
+ /** Whether the Swift app registered `name`. */
40
+ export function hasNativeFunction(name: string): boolean {
41
+ return nativeFunctions().includes(name);
42
+ }
43
+
44
+ /** Runs the function registered under `name` with `params`; resolves with what it returned. */
45
+ export function callNative<T = unknown>(name: string, params: Record<string, unknown> = {}): Promise<T> {
46
+ return hostRequest<T>(`app:${name}`, params);
47
+ }
48
+
49
+ /** Runs the sync function registered under `name` inline; returns what it returned. */
50
+ export function callNativeSync<T = unknown>(name: string, params: Record<string, unknown> = {}): T {
51
+ if (!bridge?.callNativeSync) throw new Error(`unsupported: no native host for ${name}`);
52
+ let result: { value?: unknown; error?: string };
53
+ try {
54
+ result = JSON.parse(bridge.callNativeSync(name, JSON.stringify(params))) as { value?: unknown; error?: string };
55
+ } catch {
56
+ throw new Error(`${name}: malformed response`);
57
+ }
58
+ if (typeof result.error === 'string') {
59
+ const error = new Error(result.error);
60
+ if (result.error.startsWith('cancelled')) error.name = 'AbortError';
61
+ throw error;
62
+ }
63
+ return result.value as T;
64
+ }
65
+
66
+ /** Listens for what Swift sends under `name` (`NativeFunctions.send`); returns the function that stops listening. */
67
+ export function onNativeEvent<T = unknown>(name: string, listener: (value: T) => void): () => void {
68
+ let set = listeners.get(name);
69
+ if (!set) {
70
+ set = new Set();
71
+ listeners.set(name, set);
72
+ }
73
+ const entry = listener as (value: unknown) => void;
74
+ set.add(entry);
75
+ return () => {
76
+ set.delete(entry);
77
+ if (set.size === 0) listeners.delete(name);
78
+ };
79
+ }
80
+
81
+ /** The bridge's `__sbNativeEvent(name, json)`. */
82
+ export function receiveNativeEvent(name: string, value: unknown): void {
83
+ const set = listeners.get(name);
84
+ if (!set) return;
85
+ let parsed = value;
86
+ if (typeof value === 'string') {
87
+ try {
88
+ parsed = JSON.parse(value);
89
+ } catch {
90
+ return;
91
+ }
92
+ }
93
+ for (const listener of [...set]) listener(parsed);
94
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * `navigator.onLine` and the `online` and `offline` events over the host's
3
+ * path monitor (`online()` on the bridge, `__sbOnline(bool)` back). The
4
+ * value is what the host last said (`NWPathMonitor`'s path status, true
5
+ * where the host has no monitor); the events reach listeners on the global
6
+ * (`addEventListener('online', …)`, which the runtime defines in the app's
7
+ * context, where a page's `window` would take them) and the global's
8
+ * `ononline` and `onoffline` handlers.
9
+ */
10
+
11
+ import type { NativeBridge } from './bridge';
12
+
13
+ type Listener = (event: { type: 'online' | 'offline' }) => void;
14
+
15
+ export interface Reachability {
16
+ /** `navigator.onLine`. */
17
+ readonly onLine: boolean;
18
+ addEventListener(type: string, listener: Listener): void;
19
+ removeEventListener(type: string, listener: Listener): void;
20
+ /** `__sbOnline(online)`: the host's path changed. */
21
+ receive(online: boolean): void;
22
+ }
23
+
24
+ export function createReachability(native: NativeBridge, target: Record<string, unknown>): Reachability {
25
+ let online = native.online?.() ?? true;
26
+ const listeners = { online: new Set<Listener>(), offline: new Set<Listener>() };
27
+ const isKind = (type: string): type is 'online' | 'offline' => type === 'online' || type === 'offline';
28
+ return {
29
+ get onLine() {
30
+ return online;
31
+ },
32
+ addEventListener(type, listener) {
33
+ if (isKind(type) && typeof listener === 'function') listeners[type].add(listener);
34
+ },
35
+ removeEventListener(type, listener) {
36
+ if (isKind(type)) listeners[type].delete(listener);
37
+ },
38
+ receive(next) {
39
+ const value = Boolean(next);
40
+ if (value === online) return;
41
+ online = value;
42
+ const type = value ? 'online' : 'offline';
43
+ const event = { type } as const;
44
+ const handler = target[value ? 'ononline' : 'onoffline'];
45
+ if (typeof handler === 'function') (handler as Listener)(event);
46
+ for (const listener of [...listeners[type]]) listener(event);
47
+ },
48
+ };
49
+ }
package/src/run.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  * ran and its pass was applied (docs/native.md, "Synchronous calls").
9
9
  */
10
10
 
11
- import { createSwiftUIRoot, installTextMeasurer, type SwiftUIRoot, type TextMeasurerFunction } from '@swiftbrowser/react';
11
+ import { createSwiftUIRoot, installTextMeasurer, type Environment, type SwiftUIRoot, type TextMeasurerFunction } from '@swiftbrowser/react';
12
12
  import type { Op, RendererEvent } from '@swiftbrowser/web/src/protocol';
13
13
  import type { ReactNode } from 'react';
14
14
  import { browserEnvironment } from '@swiftbrowser/web/src/hostRequests';
@@ -35,6 +35,7 @@ interface EnvironmentFields {
35
35
  colorScheme?: 'light' | 'dark';
36
36
  dynamicTypeSize?: string;
37
37
  horizontalSizeClass?: 'compact' | 'regular';
38
+ scenePhase?: 'active' | 'inactive' | 'background';
38
39
  }
39
40
 
40
41
  /** The running app, when `run` was called (the dev loop calls it again on every save). */
@@ -82,13 +83,14 @@ export function run(element: ReactNode, options: RunOptions = {}): Running {
82
83
  try {
83
84
  if (parsed.type === 'environment') {
84
85
  const fields = parsed as EnvironmentFields;
85
- const next: Partial<{ colorScheme: 'light' | 'dark'; dynamicTypeSize: never; horizontalSizeClass: 'compact' | 'regular' }> = {};
86
+ const next: Partial<Environment> = {};
86
87
  if (fields.colorScheme) {
87
88
  next.colorScheme = fields.colorScheme;
88
89
  updateMediaEnvironment({ colorScheme: fields.colorScheme });
89
90
  }
90
- if (fields.dynamicTypeSize) (next as Record<string, unknown>)['dynamicTypeSize'] = fields.dynamicTypeSize;
91
+ if (fields.dynamicTypeSize) next.dynamicTypeSize = fields.dynamicTypeSize as Environment['dynamicTypeSize'];
91
92
  if (fields.horizontalSizeClass) next.horizontalSizeClass = fields.horizontalSizeClass;
93
+ if (fields.scenePhase) next.scenePhase = fields.scenePhase;
92
94
  if (Object.keys(next).length > 0) root.setEnvironment(next);
93
95
  return;
94
96
  }
package/src/runtime.ts CHANGED
@@ -11,12 +11,19 @@
11
11
  * `atob`, `btoa`, `fetch` with `Headers`, `Request`, `Response`,
12
12
  * `AbortController` and `AbortSignal`, `URL`, `URLSearchParams`, `crypto`
13
13
  * (`getRandomValues`, `randomUUID`), `localStorage`, `structuredClone`,
14
- * `navigator` (with `mediaSession` and `MediaMetadata` on a bridge with
14
+ * `navigator` (with `onLine` from the host's path monitor, reachability.ts,
15
+ * whose `online` and `offline` events `addEventListener` on the global takes
16
+ * with the bridge's `__sbOnline`; with `wakeLock` and `WakeLockSentinel` on
17
+ * a bridge with `wakeLock`, wakeLock.ts; with `geolocation` and
18
+ * `GeolocationPositionError` on a bridge with `geolocation`, geolocation.ts,
19
+ * with the bridge's `__sbGeolocation`; with `mediaSession` and `MediaMetadata` on a bridge with
15
20
  * `mediaSession`, mediaSession.ts), `Image` (image.ts, on a bridge with `probeImage`) with the
16
21
  * bridge's `__sbImageProbed`, `Worker` (worker.ts) with the bridge's
17
22
  * `__sbWorkerMessage` and `__sbWorkerError`, `__sbOpenURL` (openURL.ts),
18
23
  * `__sbNotificationPermission`, `__sbPushRegistered` and `__sbNotification` (notifications.ts),
19
- * `__sbUndoCommand` (undo.ts, on a bridge with `undoState`),
24
+ * `__sbUndoCommand` (undo.ts, on a bridge with `undoState`), `speechSynthesis`,
25
+ * `SpeechSynthesisUtterance` and `__sbSpeechEvent` on a bridge with `speak`,
26
+ * `SpeechRecognition` and `__sbRecognition` on one with `recognize` (speech.ts),
20
27
  * and the bridge's callbacks `__sbTimerFired` and `__sbFetchResult`. In a
21
28
  * worker's own context (its bridge has
22
29
  * `workerPostMessage`), `self`, `postMessage`, `addEventListener` and
@@ -40,7 +47,12 @@ import { NativeWebSocket } from './webSocket';
40
47
  import { createSubtleCrypto } from './subtle';
41
48
  import { matchMedia } from './matchMedia';
42
49
  import { createUndoHost, installUndoHost } from './undo';
43
- import { installHostRequests, receiveHostResponse } from './hostRequests';
50
+ import { installHostRequests, receiveFilesChanged, receiveHostResponse } from './hostRequests';
51
+ import { installNativeFunctions, receiveNativeEvent } from './nativeFunctions';
52
+ import { createReachability } from './reachability';
53
+ import { createWakeLock, WakeLockSentinel } from './wakeLock';
54
+ import { createGeolocation, GeolocationPositionError } from './geolocation';
55
+ import { createSpeechSynthesis, SpeechRecognition, SpeechSynthesisUtterance } from './speech';
44
56
 
45
57
  type AnyGlobal = Record<string, unknown>;
46
58
 
@@ -209,6 +221,15 @@ export function installRuntime(native: NativeBridge, target: AnyGlobal = globalT
209
221
  if (native.hostRequest && !native.workerPostMessage) {
210
222
  installHostRequests({ hostRequest: native.hostRequest.bind(native), ...(native.appDirectories ? { appDirectories: native.appDirectories.bind(native) } : {}) });
211
223
  define('__sbHostResponse', receiveHostResponse);
224
+ define('__sbFilesChanged', receiveFilesChanged);
225
+ // The escape hatch's functions (nativeFunctions.ts): `callNative` rides `hostRequest`.
226
+ installNativeFunctions({
227
+ ...(native.nativeFunctions ? { nativeFunctions: native.nativeFunctions.bind(native) } : {}),
228
+ ...(native.callNativeSync ? { callNativeSync: native.callNativeSync.bind(native) } : {}),
229
+ });
230
+ define('__sbNativeEvent', receiveNativeEvent);
231
+ } else {
232
+ installNativeFunctions(null);
212
233
  }
213
234
 
214
235
  // The window's undo manager (undo.ts), mirroring the app's: the system's undo comes to `__sbUndoCommand`.
@@ -259,11 +280,15 @@ export function installRuntime(native: NativeBridge, target: AnyGlobal = globalT
259
280
 
260
281
  define('structuredClone', <T>(value: T): T => (value === undefined ? value : (JSON.parse(JSON.stringify(value)) as T)));
261
282
 
283
+ // Reachability (reachability.ts): `navigator.onLine` from the host's path monitor.
284
+ const reachability = createReachability(native, target);
262
285
  const navigator: Record<string, unknown> = {
263
286
  userAgent: 'SwiftBrowserNative',
264
287
  language: native.locale().replace('_', '-'),
265
288
  languages: [native.locale().replace('_', '-')],
266
- onLine: true,
289
+ get onLine() {
290
+ return reachability.onLine;
291
+ },
267
292
  platform: 'iOS',
268
293
  hardwareConcurrency: 1,
269
294
  // A haptic for a vibration; the pattern's length does not matter.
@@ -275,6 +300,12 @@ export function installRuntime(native: NativeBridge, target: AnyGlobal = globalT
275
300
  return true;
276
301
  },
277
302
  };
303
+ // The screen wake lock (wakeLock.ts) over the host's idle timer; not in a worker.
304
+ const wakeLock = native.wakeLock && !native.workerPostMessage ? createWakeLock(native) : null;
305
+ if (wakeLock) navigator['wakeLock'] = wakeLock;
306
+ // The viewer's position (geolocation.ts) over the host's location manager; not in a worker.
307
+ const geolocation = native.geolocation && !native.workerPostMessage ? createGeolocation(native, target) : null;
308
+ if (geolocation) navigator['geolocation'] = geolocation.geolocation;
278
309
  // What plays, for the lock screen and the media keys (mediaSession.ts); not in a worker.
279
310
  const mediaSession = native.mediaSession && !native.workerPostMessage ? createMediaSession(native) : null;
280
311
  if (mediaSession) navigator['mediaSession'] = mediaSession.session;
@@ -286,6 +317,24 @@ export function installRuntime(native: NativeBridge, target: AnyGlobal = globalT
286
317
  navigator['clearAppBadge'] = notifications.clearAppBadge;
287
318
  }
288
319
  define('navigator', navigator);
320
+ if (wakeLock) define('WakeLockSentinel', WakeLockSentinel);
321
+ if (geolocation) {
322
+ define('GeolocationPositionError', GeolocationPositionError);
323
+ define('__sbGeolocation', geolocation.receive);
324
+ }
325
+ // Speech (speech.ts): synthesis on AVSpeechSynthesizer, recognition on SFSpeechRecognizer; not in a worker.
326
+ if (native.speak && native.speechControl && !native.workerPostMessage) {
327
+ const synthesis = createSpeechSynthesis(native);
328
+ define('speechSynthesis', synthesis.speechSynthesis);
329
+ define('SpeechSynthesisUtterance', SpeechSynthesisUtterance);
330
+ define('__sbSpeechEvent', synthesis.receive);
331
+ }
332
+ if (native.recognize && !native.workerPostMessage) {
333
+ SpeechRecognition.install({ recognize: native.recognize.bind(native) });
334
+ define('SpeechRecognition', SpeechRecognition);
335
+ define('webkitSpeechRecognition', SpeechRecognition);
336
+ define('__sbRecognition', SpeechRecognition.receive);
337
+ }
289
338
  if (mediaSession) {
290
339
  define('MediaMetadata', NativeMediaMetadata);
291
340
  define('__sbMediaSessionCommand', mediaSession.receive);
@@ -300,8 +349,12 @@ export function installRuntime(native: NativeBridge, target: AnyGlobal = globalT
300
349
  if (native.workerPostMessage) {
301
350
  installed.push(...installWorkerScope(native, target));
302
351
  } else {
303
- // The app's context: the URLs it is opened with (openURL.ts), and workers.
352
+ // The app's context: the URLs it is opened with (openURL.ts), the
353
+ // `online` and `offline` events (a page's `window` takes them), and workers.
304
354
  define('__sbOpenURL', receiveOpenURL);
355
+ define('addEventListener', reachability.addEventListener);
356
+ define('removeEventListener', reachability.removeEventListener);
357
+ define('__sbOnline', reachability.receive);
305
358
  if (native.workerStart) {
306
359
  define('Worker', NativeWorker);
307
360
  define('__sbWorkerMessage', NativeWorker.receive);