@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.
- package/README.md +79 -2
- package/dist/blob.d.ts +60 -0
- package/dist/blob.d.ts.map +1 -0
- package/dist/blob.js +153 -0
- package/dist/blob.js.map +1 -0
- package/dist/bridge.d.ts +39 -1
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js.map +1 -1
- package/dist/fetch.d.ts +11 -3
- package/dist/fetch.d.ts.map +1 -1
- package/dist/fetch.js +26 -2
- package/dist/fetch.js.map +1 -1
- package/dist/hostRequests.d.ts +49 -0
- package/dist/hostRequests.d.ts.map +1 -0
- package/dist/hostRequests.js +134 -0
- package/dist/hostRequests.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/matchMedia.d.ts +43 -0
- package/dist/matchMedia.d.ts.map +1 -0
- package/dist/matchMedia.js +63 -0
- package/dist/matchMedia.js.map +1 -0
- package/dist/notifications.d.ts +108 -0
- package/dist/notifications.d.ts.map +1 -0
- package/dist/notifications.js +251 -0
- package/dist/notifications.js.map +1 -0
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +9 -1
- package/dist/run.js.map +1 -1
- package/dist/runtime.d.ts +1 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +58 -0
- package/dist/runtime.js.map +1 -1
- package/dist/secureStorage.d.ts +20 -0
- package/dist/secureStorage.d.ts.map +1 -0
- package/dist/secureStorage.js +28 -0
- package/dist/secureStorage.js.map +1 -0
- package/dist/subtle.d.ts +38 -0
- package/dist/subtle.d.ts.map +1 -0
- package/dist/subtle.js +72 -0
- package/dist/subtle.js.map +1 -0
- package/dist/webSocket.d.ts +73 -0
- package/dist/webSocket.d.ts.map +1 -0
- package/dist/webSocket.js +145 -0
- package/dist/webSocket.js.map +1 -0
- package/package.json +3 -3
- package/src/blob.ts +186 -0
- package/src/bridge.ts +49 -1
- package/src/fetch.ts +29 -3
- package/src/hostRequests.ts +148 -0
- package/src/index.ts +7 -0
- package/src/matchMedia.ts +79 -0
- package/src/notifications.ts +317 -0
- package/src/run.ts +10 -1
- package/src/runtime.ts +60 -0
- package/src/secureStorage.ts +39 -0
- package/src/subtle.ts +87 -0
- package/src/webSocket.ts +175 -0
- package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +166 -0
- package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +437 -0
- package/swift/Sources/SwiftBrowserNative/Host/NotificationBridge.swift +304 -0
- package/swift/Sources/SwiftBrowserNative/Host/SecureStorage.swift +60 -0
- package/swift/Sources/SwiftBrowserNative/Host/ShimAliases.swift +1 -0
- package/swift/Sources/SwiftBrowserNative/Host/WebSocketBridge.swift +118 -0
- package/swift/Sources/SwiftBrowserNative/Runtime/NativeApp.swift +26 -0
- package/swift/Sources/SwiftBrowserNative/Runtime/NativeAppDelegate.swift +36 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +16 -5
- 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
|
+
}
|
package/src/webSocket.ts
ADDED
|
@@ -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 {
|