@swiftbrowser/native 0.17.7 → 0.17.8

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 (70) hide show
  1. package/README.md +79 -2
  2. package/dist/blob.d.ts +60 -0
  3. package/dist/blob.d.ts.map +1 -0
  4. package/dist/blob.js +153 -0
  5. package/dist/blob.js.map +1 -0
  6. package/dist/bridge.d.ts +39 -1
  7. package/dist/bridge.d.ts.map +1 -1
  8. package/dist/bridge.js.map +1 -1
  9. package/dist/fetch.d.ts +11 -3
  10. package/dist/fetch.d.ts.map +1 -1
  11. package/dist/fetch.js +26 -2
  12. package/dist/fetch.js.map +1 -1
  13. package/dist/hostRequests.d.ts +49 -0
  14. package/dist/hostRequests.d.ts.map +1 -0
  15. package/dist/hostRequests.js +134 -0
  16. package/dist/hostRequests.js.map +1 -0
  17. package/dist/index.d.ts +7 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +7 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/matchMedia.d.ts +43 -0
  22. package/dist/matchMedia.d.ts.map +1 -0
  23. package/dist/matchMedia.js +63 -0
  24. package/dist/matchMedia.js.map +1 -0
  25. package/dist/notifications.d.ts +108 -0
  26. package/dist/notifications.d.ts.map +1 -0
  27. package/dist/notifications.js +251 -0
  28. package/dist/notifications.js.map +1 -0
  29. package/dist/run.d.ts.map +1 -1
  30. package/dist/run.js +9 -1
  31. package/dist/run.js.map +1 -1
  32. package/dist/runtime.d.ts +1 -0
  33. package/dist/runtime.d.ts.map +1 -1
  34. package/dist/runtime.js +58 -0
  35. package/dist/runtime.js.map +1 -1
  36. package/dist/secureStorage.d.ts +20 -0
  37. package/dist/secureStorage.d.ts.map +1 -0
  38. package/dist/secureStorage.js +28 -0
  39. package/dist/secureStorage.js.map +1 -0
  40. package/dist/subtle.d.ts +38 -0
  41. package/dist/subtle.d.ts.map +1 -0
  42. package/dist/subtle.js +72 -0
  43. package/dist/subtle.js.map +1 -0
  44. package/dist/webSocket.d.ts +73 -0
  45. package/dist/webSocket.d.ts.map +1 -0
  46. package/dist/webSocket.js +145 -0
  47. package/dist/webSocket.js.map +1 -0
  48. package/package.json +3 -3
  49. package/src/blob.ts +186 -0
  50. package/src/bridge.ts +49 -1
  51. package/src/fetch.ts +29 -3
  52. package/src/hostRequests.ts +148 -0
  53. package/src/index.ts +7 -0
  54. package/src/matchMedia.ts +79 -0
  55. package/src/notifications.ts +317 -0
  56. package/src/run.ts +10 -1
  57. package/src/runtime.ts +60 -0
  58. package/src/secureStorage.ts +39 -0
  59. package/src/subtle.ts +87 -0
  60. package/src/webSocket.ts +175 -0
  61. package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +166 -0
  62. package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +437 -0
  63. package/swift/Sources/SwiftBrowserNative/Host/NotificationBridge.swift +304 -0
  64. package/swift/Sources/SwiftBrowserNative/Host/SecureStorage.swift +60 -0
  65. package/swift/Sources/SwiftBrowserNative/Host/ShimAliases.swift +1 -0
  66. package/swift/Sources/SwiftBrowserNative/Host/WebSocketBridge.swift +118 -0
  67. package/swift/Sources/SwiftBrowserNative/Runtime/NativeApp.swift +26 -0
  68. package/swift/Sources/SwiftBrowserNative/Runtime/NativeAppDelegate.swift +36 -0
  69. package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +16 -5
  70. package/wasm/SwiftBrowserNative.wasm +2 -2
package/src/runtime.ts CHANGED
@@ -15,6 +15,7 @@
15
15
  * `mediaSession`, mediaSession.ts), `Image` (image.ts, on a bridge with `probeImage`) with the
16
16
  * bridge's `__sbImageProbed`, `Worker` (worker.ts) with the bridge's
17
17
  * `__sbWorkerMessage` and `__sbWorkerError`, `__sbOpenURL` (openURL.ts),
18
+ * `__sbNotificationPermission`, `__sbPushRegistered` and `__sbNotification` (notifications.ts),
18
19
  * and the bridge's callbacks `__sbTimerFired` and `__sbFetchResult`. In a
19
20
  * worker's own context (its bridge has
20
21
  * `workerPostMessage`), `self`, `postMessage`, `addEventListener` and
@@ -30,7 +31,14 @@ import { createImageGlobals } from './image';
30
31
  import { SimpleURL, SimpleURLSearchParams } from './url';
31
32
  import { installWorkerScope, NativeWorker } from './worker';
32
33
  import { createMediaSession, NativeMediaMetadata } from './mediaSession';
34
+ import { createNotificationGlobals } from './notifications';
33
35
  import { receiveOpenURL } from './openURL';
36
+ import { objectURL, SimpleBlob, SimpleFile, SimpleFormData } from './blob';
37
+ import { setBodyRandomness } from './fetch';
38
+ import { NativeWebSocket } from './webSocket';
39
+ import { createSubtleCrypto } from './subtle';
40
+ import { matchMedia } from './matchMedia';
41
+ import { installHostRequests, receiveHostResponse } from './hostRequests';
34
42
 
35
43
  type AnyGlobal = Record<string, unknown>;
36
44
 
@@ -172,6 +180,35 @@ export function installRuntime(native: NativeBridge, target: AnyGlobal = globalT
172
180
  define('URL', SimpleURL);
173
181
  define('URLSearchParams', SimpleURLSearchParams);
174
182
 
183
+ // Bytes with a type, files and forms (blob.ts): what `fetch` uploads, and
184
+ // what a picked file or a built image is. `URL.createObjectURL` is a `data:` URL.
185
+ define('Blob', SimpleBlob);
186
+ define('File', SimpleFile);
187
+ define('FormData', SimpleFormData);
188
+ const urlStatics = target['URL'] as { createObjectURL?: unknown; revokeObjectURL?: unknown };
189
+ if (urlStatics && typeof urlStatics.createObjectURL !== 'function') {
190
+ urlStatics.createObjectURL = (blob: SimpleBlob) => objectURL(blob);
191
+ urlStatics.revokeObjectURL = () => {};
192
+ }
193
+ const random = (count: number): Uint8Array => base64Decode(native.randomBytes(count));
194
+ setBodyRandomness(random);
195
+
196
+ // WebSockets (webSocket.ts) over the host's `URLSessionWebSocketTask`.
197
+ if (native.wsOpen && native.wsSend && native.wsClose) {
198
+ NativeWebSocket.install({ wsOpen: native.wsOpen.bind(native), wsSend: native.wsSend.bind(native), wsClose: native.wsClose.bind(native) });
199
+ define('WebSocket', NativeWebSocket);
200
+ define('__sbWebSocketEvent', NativeWebSocket.receive);
201
+ }
202
+
203
+ // `matchMedia`: the color scheme the host sends (run.ts), nothing else.
204
+ define('matchMedia', matchMedia);
205
+
206
+ // Host requests (hostRequests.ts): the share sheet, the pickers, the pasteboard, a sign-in.
207
+ if (native.hostRequest && !native.workerPostMessage) {
208
+ installHostRequests({ hostRequest: native.hostRequest.bind(native) });
209
+ define('__sbHostResponse', receiveHostResponse);
210
+ }
211
+
175
212
  // `Image`, for code that loads a picture to learn its size (React's
176
213
  // `AsyncImage`): the host reads it through its image loader.
177
214
  if (native.probeImage && !native.workerPostMessage) {
@@ -186,6 +223,8 @@ export function installRuntime(native: NativeBridge, target: AnyGlobal = globalT
186
223
  new Uint8Array(array.buffer, array.byteOffset, array.byteLength).set(bytes.subarray(0, array.byteLength));
187
224
  return array;
188
225
  },
226
+ // Digests and HMAC on the host's CryptoKit (subtle.ts).
227
+ subtle: createSubtleCrypto(native),
189
228
  randomUUID: (): string => {
190
229
  const bytes = base64Decode(native.randomBytes(16));
191
230
  bytes[6] = (bytes[6]! & 0x0f) | 0x40;
@@ -216,15 +255,36 @@ export function installRuntime(native: NativeBridge, target: AnyGlobal = globalT
216
255
  onLine: true,
217
256
  platform: 'iOS',
218
257
  hardwareConcurrency: 1,
258
+ // A haptic for a vibration; the pattern's length does not matter.
259
+ vibrate: (pattern: number | number[]): boolean => {
260
+ if (!native.haptic) return false;
261
+ const length = Array.isArray(pattern) ? pattern.reduce((sum, ms) => sum + Number(ms), 0) : Number(pattern);
262
+ if (!(length > 0)) return true;
263
+ native.haptic(length >= 200 ? 'impact' : 'selection');
264
+ return true;
265
+ },
219
266
  };
220
267
  // What plays, for the lock screen and the media keys (mediaSession.ts); not in a worker.
221
268
  const mediaSession = native.mediaSession && !native.workerPostMessage ? createMediaSession(native) : null;
222
269
  if (mediaSession) navigator['mediaSession'] = mediaSession.session;
270
+ // Notifications (notifications.ts): the browser's `Notification` and the
271
+ // badge over UserNotifications, and the push registration; not in a worker.
272
+ const notifications = native.notificationShow && !native.workerPostMessage ? createNotificationGlobals(native) : null;
273
+ if (notifications) {
274
+ navigator['setAppBadge'] = notifications.setAppBadge;
275
+ navigator['clearAppBadge'] = notifications.clearAppBadge;
276
+ }
223
277
  define('navigator', navigator);
224
278
  if (mediaSession) {
225
279
  define('MediaMetadata', NativeMediaMetadata);
226
280
  define('__sbMediaSessionCommand', mediaSession.receive);
227
281
  }
282
+ if (notifications) {
283
+ define('Notification', notifications.Notification);
284
+ define('__sbNotificationPermission', notifications.__sbNotificationPermission);
285
+ define('__sbPushRegistered', notifications.__sbPushRegistered);
286
+ define('__sbNotification', notifications.__sbNotification);
287
+ }
228
288
 
229
289
  if (native.workerPostMessage) {
230
290
  installed.push(...installWorkerScope(native, target));
@@ -0,0 +1,39 @@
1
+ /**
2
+ * `secureStorage`: `localStorage`'s shape over the Keychain, for what must
3
+ * not sit in plain text (a session token). The host's `secureGet`,
4
+ * `secureSet` and `secureRemove` are generic-password items of the app's
5
+ * (`kSecAttrAccessibleAfterFirstUnlock`, so a background fetch can read
6
+ * them). In the page (the round trip, or a browser) there is no Keychain:
7
+ * the values go to `localStorage` under a prefix, as a web app keeps them.
8
+ */
9
+
10
+ import { nativeBridge, type NativeBridge } from './bridge';
11
+
12
+ export type SecureStorageBridge = Pick<NativeBridge, 'secureGet' | 'secureSet' | 'secureRemove'>;
13
+
14
+ export interface SecureStorage {
15
+ getItem(key: string): string | null;
16
+ setItem(key: string, value: string): void;
17
+ removeItem(key: string): void;
18
+ }
19
+
20
+ const PREFIX = 'swiftbrowser.secure.';
21
+
22
+ /** Secure storage over `native`, or over `localStorage` when the host has no Keychain. */
23
+ export function createSecureStorage(native: SecureStorageBridge | undefined, fallback: () => Storage | undefined): SecureStorage {
24
+ if (native?.secureGet && native.secureSet && native.secureRemove) {
25
+ return {
26
+ getItem: (key) => native.secureGet!(String(key)),
27
+ setItem: (key, value) => native.secureSet!(String(key), String(value)),
28
+ removeItem: (key) => native.secureRemove!(String(key)),
29
+ };
30
+ }
31
+ return {
32
+ getItem: (key) => fallback()?.getItem(PREFIX + String(key)) ?? null,
33
+ setItem: (key, value) => fallback()?.setItem(PREFIX + String(key), String(value)),
34
+ removeItem: (key) => fallback()?.removeItem(PREFIX + String(key)),
35
+ };
36
+ }
37
+
38
+ /** The app's secure storage: the Keychain in the native app, `localStorage` in the page. */
39
+ export const secureStorage: SecureStorage = createSecureStorage(nativeBridge(), () => (typeof localStorage === 'undefined' ? undefined : localStorage));
package/src/subtle.ts ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * `crypto.subtle` for the native app, as far as apps use it for a PKCE
3
+ * challenge or a signature: `digest` (SHA-1, SHA-256, SHA-384, SHA-512) and
4
+ * HMAC keys with `importKey`, `sign` and `verify`, over the bridge's
5
+ * `digest` and `hmac` (CryptoKit on the Swift side). Both are synchronous
6
+ * there; the promises resolve at once.
7
+ */
8
+
9
+ import type { NativeBridge } from './bridge';
10
+ import { base64Decode, base64Encode } from './encoding';
11
+
12
+ export type SubtleBridge = Pick<NativeBridge, 'digest' | 'hmac'>;
13
+
14
+ type Algorithm = string | { name: string; hash?: string | { name: string } };
15
+
16
+ interface HmacKey {
17
+ type: 'secret';
18
+ extractable: boolean;
19
+ algorithm: { name: 'HMAC'; hash: { name: string }; length: number };
20
+ usages: string[];
21
+ /** The key's bytes, kept here: the host signs with them each time. */
22
+ readonly bytes: Uint8Array;
23
+ }
24
+
25
+ const DIGESTS = new Set(['SHA-1', 'SHA-256', 'SHA-384', 'SHA-512']);
26
+
27
+ function algorithmName(algorithm: Algorithm): string {
28
+ return (typeof algorithm === 'string' ? algorithm : algorithm.name).toUpperCase();
29
+ }
30
+
31
+ function hashName(algorithm: Algorithm): string {
32
+ if (typeof algorithm === 'string') return algorithm.toUpperCase();
33
+ const hash = algorithm.hash;
34
+ return (typeof hash === 'string' ? hash : hash?.name ?? '').toUpperCase();
35
+ }
36
+
37
+ function bytesOf(data: ArrayBuffer | ArrayBufferView): Uint8Array {
38
+ return data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
39
+ }
40
+
41
+ function toBuffer(base64: string): ArrayBuffer {
42
+ const bytes = base64Decode(base64);
43
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
44
+ }
45
+
46
+ function notSupported(what: string): Error {
47
+ const error = new Error(`crypto.subtle: ${what} is not supported in the native app`);
48
+ error.name = 'NotSupportedError';
49
+ return error;
50
+ }
51
+
52
+ /** `crypto.subtle` over the bridge. */
53
+ export function createSubtleCrypto(native: SubtleBridge) {
54
+ return {
55
+ async digest(algorithm: Algorithm, data: ArrayBuffer | ArrayBufferView): Promise<ArrayBuffer> {
56
+ const name = algorithmName(algorithm);
57
+ if (!DIGESTS.has(name)) throw notSupported(`the ${name} digest`);
58
+ const result = native.digest?.(name, base64Encode(bytesOf(data)));
59
+ if (!result) throw notSupported(`the ${name} digest`);
60
+ return toBuffer(result);
61
+ },
62
+
63
+ async importKey(format: string, keyData: ArrayBuffer | ArrayBufferView, algorithm: Algorithm, extractable: boolean, usages: string[]): Promise<HmacKey> {
64
+ if (format !== 'raw' || algorithmName(algorithm) !== 'HMAC') throw notSupported(`importing a ${format} ${algorithmName(algorithm)} key`);
65
+ const hash = hashName(algorithm);
66
+ if (!DIGESTS.has(hash)) throw notSupported(`HMAC with ${hash}`);
67
+ const bytes = bytesOf(keyData);
68
+ return { type: 'secret', extractable, algorithm: { name: 'HMAC', hash: { name: hash }, length: bytes.byteLength * 8 }, usages: [...usages], bytes };
69
+ },
70
+
71
+ async sign(algorithm: Algorithm, key: HmacKey, data: ArrayBuffer | ArrayBufferView): Promise<ArrayBuffer> {
72
+ if (algorithmName(algorithm) !== 'HMAC' || key.algorithm?.name !== 'HMAC') throw notSupported(`signing with ${algorithmName(algorithm)}`);
73
+ const result = native.hmac?.(key.algorithm.hash.name, base64Encode(key.bytes), base64Encode(bytesOf(data)));
74
+ if (!result) throw notSupported('HMAC');
75
+ return toBuffer(result);
76
+ },
77
+
78
+ async verify(algorithm: Algorithm, key: HmacKey, signature: ArrayBuffer | ArrayBufferView, data: ArrayBuffer | ArrayBufferView): Promise<boolean> {
79
+ const expected = new Uint8Array(await this.sign(algorithm, key, data));
80
+ const given = bytesOf(signature);
81
+ if (expected.byteLength !== given.byteLength) return false;
82
+ let difference = 0;
83
+ for (let i = 0; i < expected.byteLength; i += 1) difference |= expected[i]! ^ given[i]!;
84
+ return difference === 0;
85
+ },
86
+ };
87
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * `WebSocket` for the native app over the bridge's `wsOpen`, `wsSend` and
3
+ * `wsClose` (`URLSessionWebSocketTask` on the Swift side). What the socket
4
+ * receives arrives at `__sbWebSocketEvent(id, json)`: `{ type: "open",
5
+ * protocol }`, `{ type: "message", text }` or `{ type: "message", binary }`
6
+ * (base64), `{ type: "error", message }` and `{ type: "close", code, reason,
7
+ * wasClean }`. The browser's event model as apps use it: `onopen`,
8
+ * `onmessage`, `onerror`, `onclose`, `addEventListener`, `readyState`,
9
+ * `binaryType` (`blob` by default, or `arraybuffer`).
10
+ */
11
+
12
+ import { SimpleBlob } from './blob';
13
+ import type { NativeBridge } from './bridge';
14
+ import { base64Decode, base64Encode } from './encoding';
15
+
16
+ export interface WebSocketEventLike {
17
+ type: string;
18
+ target: NativeWebSocket;
19
+ data?: unknown;
20
+ code?: number;
21
+ reason?: string;
22
+ wasClean?: boolean;
23
+ message?: string;
24
+ }
25
+
26
+ type Handler = (event: WebSocketEventLike) => void;
27
+
28
+ /** The bridge's event for socket `id`. */
29
+ export interface NativeWebSocketEvent {
30
+ type: 'open' | 'message' | 'error' | 'close';
31
+ protocol?: string;
32
+ text?: string;
33
+ /** Base64. */
34
+ binary?: string;
35
+ message?: string;
36
+ code?: number;
37
+ reason?: string;
38
+ wasClean?: boolean;
39
+ }
40
+
41
+ export type WebSocketBridge = Required<Pick<NativeBridge, 'wsOpen' | 'wsSend' | 'wsClose'>>;
42
+
43
+ const sockets = new Map<number, NativeWebSocket>();
44
+ let nextId = 1;
45
+ let bridge: WebSocketBridge | null = null;
46
+
47
+ export class NativeWebSocket {
48
+ static readonly CONNECTING = 0;
49
+ static readonly OPEN = 1;
50
+ static readonly CLOSING = 2;
51
+ static readonly CLOSED = 3;
52
+ readonly CONNECTING = 0;
53
+ readonly OPEN = 1;
54
+ readonly CLOSING = 2;
55
+ readonly CLOSED = 3;
56
+
57
+ readonly url: string;
58
+ readyState = 0;
59
+ protocol = '';
60
+ extensions = '';
61
+ bufferedAmount = 0;
62
+ binaryType: 'blob' | 'arraybuffer' = 'blob';
63
+ onopen: Handler | null = null;
64
+ onmessage: Handler | null = null;
65
+ onerror: Handler | null = null;
66
+ onclose: Handler | null = null;
67
+
68
+ private readonly id: number;
69
+ private readonly listeners = new Map<string, Set<Handler>>();
70
+
71
+ constructor(url: string | { href: string }, protocols: string | string[] = []) {
72
+ if (!bridge) throw new TypeError('WebSocket: no native bridge');
73
+ const href = typeof url === 'string' ? url : url.href;
74
+ if (!/^wss?:\/\//i.test(href)) throw new SyntaxError(`WebSocket: the URL's scheme must be ws or wss: ${href}`);
75
+ this.url = href;
76
+ this.id = nextId++;
77
+ sockets.set(this.id, this);
78
+ bridge.wsOpen(this.id, href, typeof protocols === 'string' ? [protocols] : [...protocols]);
79
+ }
80
+
81
+ send(data: string | ArrayBuffer | ArrayBufferView | SimpleBlob): void {
82
+ if (this.readyState === NativeWebSocket.CONNECTING) throw new DOMExceptionLike('InvalidStateError', 'WebSocket: still connecting');
83
+ if (this.readyState !== NativeWebSocket.OPEN) return;
84
+ if (typeof data === 'string') {
85
+ bridge?.wsSend(this.id, JSON.stringify({ text: data }));
86
+ return;
87
+ }
88
+ const bytes = data instanceof SimpleBlob ? data.bytes() : data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
89
+ bridge?.wsSend(this.id, JSON.stringify({ binary: base64Encode(bytes) }));
90
+ }
91
+
92
+ close(code?: number, reason?: string): void {
93
+ if (code !== undefined && code !== 1000 && !(code >= 3000 && code <= 4999)) throw new DOMExceptionLike('InvalidAccessError', 'WebSocket: the close code must be 1000 or in 3000–4999');
94
+ if (this.readyState === NativeWebSocket.CLOSING || this.readyState === NativeWebSocket.CLOSED) return;
95
+ this.readyState = NativeWebSocket.CLOSING;
96
+ bridge?.wsClose(this.id, code ?? 1000, reason ?? '');
97
+ }
98
+
99
+ addEventListener(type: string, handler: Handler): void {
100
+ let set = this.listeners.get(type);
101
+ if (!set) {
102
+ set = new Set();
103
+ this.listeners.set(type, set);
104
+ }
105
+ set.add(handler);
106
+ }
107
+
108
+ removeEventListener(type: string, handler: Handler): void {
109
+ this.listeners.get(type)?.delete(handler);
110
+ }
111
+
112
+ dispatchEvent(event: WebSocketEventLike): boolean {
113
+ const own = { open: this.onopen, message: this.onmessage, error: this.onerror, close: this.onclose }[event.type];
114
+ own?.(event);
115
+ for (const handler of this.listeners.get(event.type) ?? []) handler(event);
116
+ return true;
117
+ }
118
+
119
+ /** The bridge's event for this socket. */
120
+ receive(event: NativeWebSocketEvent): void {
121
+ switch (event.type) {
122
+ case 'open':
123
+ if (this.readyState !== NativeWebSocket.CONNECTING) return;
124
+ this.readyState = NativeWebSocket.OPEN;
125
+ this.protocol = event.protocol ?? '';
126
+ this.dispatchEvent({ type: 'open', target: this });
127
+ return;
128
+ case 'message': {
129
+ if (this.readyState !== NativeWebSocket.OPEN) return;
130
+ let data: unknown = event.text ?? '';
131
+ if (event.binary !== undefined) {
132
+ const bytes = base64Decode(event.binary);
133
+ data = this.binaryType === 'arraybuffer' ? bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) : new SimpleBlob([bytes]);
134
+ }
135
+ this.dispatchEvent({ type: 'message', target: this, data });
136
+ return;
137
+ }
138
+ case 'error':
139
+ this.dispatchEvent({ type: 'error', target: this, message: event.message ?? '' });
140
+ return;
141
+ case 'close':
142
+ if (this.readyState === NativeWebSocket.CLOSED) return;
143
+ this.readyState = NativeWebSocket.CLOSED;
144
+ sockets.delete(this.id);
145
+ this.dispatchEvent({ type: 'close', target: this, code: event.code ?? 1006, reason: event.reason ?? '', wasClean: event.wasClean ?? false });
146
+ return;
147
+ }
148
+ }
149
+
150
+ /** The bridge's `__sbWebSocketEvent(id, json)`. */
151
+ static receive(id: number, json: string | NativeWebSocketEvent): void {
152
+ const socket = sockets.get(id);
153
+ if (!socket) return;
154
+ let event: NativeWebSocketEvent;
155
+ try {
156
+ event = typeof json === 'string' ? (JSON.parse(json) as NativeWebSocketEvent) : json;
157
+ } catch {
158
+ return;
159
+ }
160
+ socket.receive(event);
161
+ }
162
+
163
+ /** Points the sockets at the bridge (the runtime, once per install). */
164
+ static install(native: WebSocketBridge): void {
165
+ bridge = native;
166
+ }
167
+ }
168
+
169
+ /** A `DOMException` stand-in: an error with the browser's name. */
170
+ class DOMExceptionLike extends Error {
171
+ constructor(name: string, message: string) {
172
+ super(message);
173
+ this.name = name;
174
+ }
175
+ }
@@ -11,6 +11,9 @@ import AppKit
11
11
  #if canImport(FoundationBridge)
12
12
  import FoundationBridge // the shim's Foundation stand-ins (ShimAliases.swift)
13
13
  #endif
14
+ #if canImport(CryptoKit)
15
+ import CryptoKit
16
+ #endif
14
17
 
15
18
  /// The native primitives `@swiftbrowser/native` builds the web globals from
16
19
  /// (packages/native/src/runtime.ts), installed on `__sbNative`:
@@ -29,6 +32,12 @@ import FoundationBridge // the shim's Foundation stand-ins (ShimAliases.swift)
29
32
  /// | `locale()` | the viewer's locale identifier |
30
33
  /// | `launchArguments()` | the process's arguments (`-screen list` from a scheme or `simctl launch`) |
31
34
  /// | `mediaSession(update)` | `navigator.mediaSession`: the lock screen's now-playing info and the remote commands the app handles, on MediaPlayer (NowPlayingBridge.swift); a command the viewer gives comes to `__sbMediaSessionCommand(json)` |
35
+ /// | `notificationsReady()`, `notificationPermission(id, request)`, `notificationShow(json)`, `notificationClose(json)`, `setBadge(count)`, `pushRegister(id, key?)` | notifications on UserNotifications (NotificationBridge.swift): the permission answers `__sbNotificationPermission(id, state)`, a registration `__sbPushRegistered(id, result)`, and what the viewer opens or what arrives comes to `__sbNotification(json)` |
36
+ /// | `wsOpen(id, url, protocols)`, `wsSend(id, json)`, `wsClose(id, code, reason)` | `WebSocket` on `URLSessionWebSocketTask` (WebSocketBridge.swift); what a socket receives comes to `__sbWebSocketEvent(id, json)` |
37
+ /// | `digest(algorithm, base64)`, `hmac(algorithm, key, base64)` | `crypto.subtle` on CryptoKit, synchronously: the base64 digest or HMAC, or nothing when the algorithm is not one of SHA-1, SHA-256, SHA-384 and SHA-512 |
38
+ /// | `haptic(kind)` | `navigator.vibrate`: a feedback generator's `impact`, `selection`, `success`, `warning` or `error` |
39
+ /// | `hostRequest(id, kind, json)` | the web renderer's `share`, `pickFiles`, `clipboard`, `pickColor` and `webAuth` requests, answered with the system's share sheet, pickers, pasteboard and sign-in session (HostRequestBridge.swift); the answer comes to `__sbHostResponse(id, result)` |
40
+ /// | `secureGet(key)`, `secureSet(key, value)`, `secureRemove(key)` | `secureStorage` over the Keychain (SecureStorage.swift) |
32
41
  /// | `workerStart(id, name, engine)`, `workerPost(id, text)`, `workerTerminate(id)` | workers (JavaScriptCore only): a `JSContext` on a thread (`"context"`, WorkerHost.swift) or a hidden web view with WebKit's JIT (`"webview"`, WebViewWorkerHost.swift); their messages come to `__sbWorkerMessage(id, text)`, their errors to `__sbWorkerError(id, message)` |
33
42
  ///
34
43
  /// Everything JavaScript-facing runs on the main thread; work that is not
@@ -46,6 +55,11 @@ final class HostBridge {
46
55
  #if canImport(MediaPlayer)
47
56
  private var nowPlaying: NowPlayingBridge?
48
57
  #endif
58
+ #if canImport(UserNotifications)
59
+ private var notifications: NotificationBridge?
60
+ #endif
61
+ private var webSockets: WebSocketBridge?
62
+ private var hostRequests: HostRequestBridge?
49
63
  /// Keys of `localStorage` live under this prefix in `UserDefaults`.
50
64
  static let storagePrefix = "swiftbrowser.storage."
51
65
 
@@ -59,6 +73,7 @@ final class HostBridge {
59
73
  func invalidate() {
60
74
  for timer in timers.values { timer.invalidate() }
61
75
  timers.removeAll()
76
+ webSockets?.invalidate()
62
77
  #if canImport(JavaScriptCore)
63
78
  for worker in workers.values { worker.terminate() }
64
79
  workers.removeAll()
@@ -206,6 +221,93 @@ final class HostBridge {
206
221
  }
207
222
  #endif
208
223
 
224
+ #if canImport(UserNotifications)
225
+ host.install("notificationsReady") { [weak self] _ in
226
+ self?.notificationBridge.listen()
227
+ return nil
228
+ }
229
+ host.install("notificationPermission") { [weak self] arguments in
230
+ guard let self, let id = arguments[safe: 0]?.int else { return nil }
231
+ notificationBridge.permission(id: id, request: arguments[safe: 1]?.bool ?? false)
232
+ return nil
233
+ }
234
+ host.install("notificationShow") { [weak self] arguments in
235
+ guard let self, let json = arguments.first?.string else { return .string("notification: expected a JSON string") }
236
+ return notificationBridge.show(json).map(JSONValue.string)
237
+ }
238
+ host.install("notificationClose") { [weak self] arguments in
239
+ guard let self, let json = arguments.first?.string else { return nil }
240
+ notificationBridge.close(json)
241
+ return nil
242
+ }
243
+ host.install("setBadge") { [weak self] arguments in
244
+ guard let self, let count = arguments.first?.int else { return nil }
245
+ notificationBridge.setBadge(count)
246
+ return nil
247
+ }
248
+ host.install("pushRegister") { [weak self] arguments in
249
+ guard let self, let id = arguments[safe: 0]?.int else { return nil }
250
+ notificationBridge.registerForPush(id: id, applicationServerKey: arguments[safe: 1]?.string)
251
+ return nil
252
+ }
253
+ #endif
254
+
255
+ host.install("wsOpen") { [weak self] arguments in
256
+ guard let self, let id = arguments[safe: 0]?.int, let url = arguments[safe: 1]?.string else { return nil }
257
+ let protocols = arguments[safe: 2]?.array?.compactMap(\.string) ?? []
258
+ webSocketBridge.open(id: id, url: url, protocols: protocols)
259
+ return nil
260
+ }
261
+ host.install("wsSend") { [weak self] arguments in
262
+ guard let self, let id = arguments[safe: 0]?.int, let json = arguments[safe: 1]?.string else { return nil }
263
+ webSocketBridge.send(id: id, json: json)
264
+ return nil
265
+ }
266
+ host.install("wsClose") { [weak self] arguments in
267
+ guard let self, let id = arguments[safe: 0]?.int else { return nil }
268
+ webSocketBridge.close(id: id, code: arguments[safe: 1]?.int ?? 1000, reason: arguments[safe: 2]?.string ?? "")
269
+ return nil
270
+ }
271
+
272
+ host.install("digest") { arguments in
273
+ guard let algorithm = arguments[safe: 0]?.string, let encoded = arguments[safe: 1]?.string, let data = Data(base64Encoded: encoded) else { return nil }
274
+ return Self.digest(algorithm, data).map { .string($0.base64EncodedString()) }
275
+ }
276
+ host.install("hmac") { arguments in
277
+ guard let algorithm = arguments[safe: 0]?.string, let key = arguments[safe: 1]?.string, let encoded = arguments[safe: 2]?.string,
278
+ let keyData = Data(base64Encoded: key), let data = Data(base64Encoded: encoded) else { return nil }
279
+ return Self.hmac(algorithm, key: keyData, data).map { .string($0.base64EncodedString()) }
280
+ }
281
+
282
+ host.install("haptic") { arguments in
283
+ Self.haptic(arguments.first?.string ?? "impact")
284
+ return nil
285
+ }
286
+
287
+ host.install("hostRequest") { [weak self] arguments in
288
+ guard let self, let id = arguments[safe: 0]?.int, let kind = arguments[safe: 1]?.string else { return nil }
289
+ if hostRequests == nil {
290
+ hostRequests = HostRequestBridge { [weak self] id, result in self?.callIn("__sbHostResponse", [.number(Double(id)), .string(result.json)]) }
291
+ }
292
+ hostRequests?.perform(id: id, kind: kind, json: arguments[safe: 2]?.string ?? "{}")
293
+ return nil
294
+ }
295
+
296
+ host.install("secureGet") { arguments in
297
+ guard let key = arguments.first?.string else { return nil }
298
+ return SecureStorage.get(key).map(JSONValue.string) ?? .null
299
+ }
300
+ host.install("secureSet") { arguments in
301
+ guard let key = arguments[safe: 0]?.string, let value = arguments[safe: 1]?.string else { return nil }
302
+ SecureStorage.set(key, value)
303
+ return nil
304
+ }
305
+ host.install("secureRemove") { arguments in
306
+ guard let key = arguments.first?.string else { return nil }
307
+ SecureStorage.remove(key)
308
+ return nil
309
+ }
310
+
209
311
  #if canImport(JavaScriptCore)
210
312
  host.install("workerStart") { [weak self] arguments in
211
313
  guard let self, let id = arguments[safe: 0]?.int, let name = arguments[safe: 1]?.string else {
@@ -255,6 +357,70 @@ final class HostBridge {
255
357
  #endif
256
358
  }
257
359
 
360
+ #if canImport(UserNotifications)
361
+ /// The notification bridge, made when the app first uses notifications.
362
+ private var notificationBridge: NotificationBridge {
363
+ if let notifications { return notifications }
364
+ let bridge = NotificationBridge { [weak self] function, arguments in self?.callIn(function, arguments) }
365
+ notifications = bridge
366
+ return bridge
367
+ }
368
+ #endif
369
+
370
+ /// The WebSocket bridge, made when the app opens its first socket.
371
+ private var webSocketBridge: WebSocketBridge {
372
+ if let webSockets { return webSockets }
373
+ let bridge = WebSocketBridge(session: session) { [weak self] id, event in self?.callIn("__sbWebSocketEvent", [.number(Double(id)), .string(event.json)]) }
374
+ webSockets = bridge
375
+ return bridge
376
+ }
377
+
378
+ // MARK: crypto.subtle and haptics
379
+
380
+ static func digest(_ algorithm: String, _ data: Data) -> Data? {
381
+ #if canImport(CryptoKit)
382
+ switch algorithm {
383
+ case "SHA-256": Data(SHA256.hash(data: data))
384
+ case "SHA-384": Data(SHA384.hash(data: data))
385
+ case "SHA-512": Data(SHA512.hash(data: data))
386
+ case "SHA-1": Data(Insecure.SHA1.hash(data: data))
387
+ default: nil
388
+ }
389
+ #else
390
+ nil
391
+ #endif
392
+ }
393
+
394
+ static func hmac(_ algorithm: String, key: Data, _ data: Data) -> Data? {
395
+ #if canImport(CryptoKit) && !canImport(FoundationBridge)
396
+ let symmetricKey = SymmetricKey(data: key)
397
+ return switch algorithm {
398
+ case "SHA-256": Data(HMAC<SHA256>.authenticationCode(for: data, using: symmetricKey))
399
+ case "SHA-384": Data(HMAC<SHA384>.authenticationCode(for: data, using: symmetricKey))
400
+ case "SHA-512": Data(HMAC<SHA512>.authenticationCode(for: data, using: symmetricKey))
401
+ case "SHA-1": Data(HMAC<Insecure.SHA1>.authenticationCode(for: data, using: symmetricKey))
402
+ default: nil
403
+ }
404
+ #else
405
+ // The shim's CryptoKit has no HMAC.
406
+ nil
407
+ #endif
408
+ }
409
+
410
+ static func haptic(_ kind: String) {
411
+ #if canImport(UIKit) && !os(tvOS)
412
+ switch kind {
413
+ case "selection": UISelectionFeedbackGenerator().selectionChanged()
414
+ case "success": UINotificationFeedbackGenerator().notificationOccurred(.success)
415
+ case "warning": UINotificationFeedbackGenerator().notificationOccurred(.warning)
416
+ case "error": UINotificationFeedbackGenerator().notificationOccurred(.error)
417
+ default: UIImpactFeedbackGenerator(style: .medium).impactOccurred()
418
+ }
419
+ #elseif canImport(AppKit)
420
+ NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .now)
421
+ #endif
422
+ }
423
+
258
424
  /// Calls into JavaScript from a timer or a completion, where a throw has nowhere to go but the log.
259
425
  private func callIn(_ function: String, _ arguments: [JSONValue]) {
260
426
  do {