@swiftbrowser/native 0.17.6 → 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 +101 -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 +47 -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 +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -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/mediaSession.d.ts +59 -0
- package/dist/mediaSession.d.ts.map +1 -0
- package/dist/mediaSession.js +119 -0
- package/dist/mediaSession.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 +3 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +72 -3
- 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 +57 -1
- package/src/fetch.ts +29 -3
- package/src/hostRequests.ts +148 -0
- package/src/index.ts +8 -0
- package/src/matchMedia.ts +79 -0
- package/src/mediaSession.ts +163 -0
- package/src/notifications.ts +317 -0
- package/src/run.ts +10 -1
- package/src/runtime.ts +73 -3
- 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 +180 -0
- package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +437 -0
- package/swift/Sources/SwiftBrowserNative/Host/NotificationBridge.swift +304 -0
- package/swift/Sources/SwiftBrowserNative/Host/NowPlayingBridge.swift +190 -0
- package/swift/Sources/SwiftBrowserNative/Host/SecureStorage.swift +60 -0
- package/swift/Sources/SwiftBrowserNative/Host/ShimAliases.swift +3 -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/ElementView.swift +2 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +22 -9
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +8 -5
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Video.swift +304 -0
- package/swift/Sources/SwiftBrowserProtocol/Props.swift +43 -0
- package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +12 -2
- package/wasm/SwiftBrowserNative.wasm +2 -2
package/src/bridge.ts
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
* (Native/Sources/SwiftBrowserNative/Host/HostBridge.swift). Every call is
|
|
4
4
|
* synchronous; the ones that answer later do so by calling a global this
|
|
5
5
|
* package defines (`__sbTimerFired`, `__sbFetchResult`, `__sbImageProbed`,
|
|
6
|
-
* `__sbWorkerMessage`, `__sbOpenURL`
|
|
6
|
+
* `__sbWorkerMessage`, `__sbOpenURL`, `__sbMediaSessionCommand`,
|
|
7
|
+
* `__sbNotificationPermission`, `__sbPushRegistered`, `__sbNotification`,
|
|
8
|
+
* `__sbWebSocketEvent`, `__sbHostResponse`).
|
|
7
9
|
*/
|
|
8
10
|
export interface NativeBridge {
|
|
9
11
|
/** Applies a render pass's ops (a JSON array). Returns an error message, or nothing. */
|
|
@@ -35,6 +37,60 @@ export interface NativeBridge {
|
|
|
35
37
|
locale(): string;
|
|
36
38
|
/** The process's launch arguments (`-screen list` from an Xcode scheme or `simctl launch`). */
|
|
37
39
|
launchArguments?(): string[];
|
|
40
|
+
/**
|
|
41
|
+
* `navigator.mediaSession` (mediaSession.ts): the whole session as JSON
|
|
42
|
+
* (`metadata`, `playbackState`, `position`, `commands`, as the web
|
|
43
|
+
* renderer's `mediaSession` request carries them), for the lock screen
|
|
44
|
+
* and the media keys; a command the viewer gives arrives at
|
|
45
|
+
* `__sbMediaSessionCommand(json)`. Hosts before 0.19 lack it.
|
|
46
|
+
*/
|
|
47
|
+
mediaSession?(update: string): void;
|
|
48
|
+
|
|
49
|
+
// Notifications (notifications.ts), on UserNotifications. Hosts before 0.20 lack them.
|
|
50
|
+
/** The app listens: what came before it did (a tap that launched it) arrives at `__sbNotification(json)`. */
|
|
51
|
+
notificationsReady?(): void;
|
|
52
|
+
/** Answers `__sbNotificationPermission(id, 'default' | 'granted' | 'denied')`, after asking the viewer when `request` is set. */
|
|
53
|
+
notificationPermission?(id: number, request: boolean): void;
|
|
54
|
+
/** Shows `{ id, title, subtitle?, body?, data?, badge?, silent? }` now; returns what was wrong with it, or nothing. */
|
|
55
|
+
notificationShow?(json: string): string | null | undefined;
|
|
56
|
+
/** Closes the notifications with these identifiers (a JSON array). */
|
|
57
|
+
notificationClose?(json: string): void;
|
|
58
|
+
/** The app badge (0 clears it). */
|
|
59
|
+
setBadge?(count: number): void;
|
|
60
|
+
/**
|
|
61
|
+
* Registers for remote notifications; the device token (hex) or the
|
|
62
|
+
* failure answers `__sbPushRegistered(id, { token } | { error })`.
|
|
63
|
+
* `applicationServerKey` is for the page (the round trip's Web Push).
|
|
64
|
+
*/
|
|
65
|
+
pushRegister?(id: number, applicationServerKey?: string): void;
|
|
66
|
+
|
|
67
|
+
// WebSockets (webSocket.ts) on `URLSessionWebSocketTask`; events come to `__sbWebSocketEvent(id, json)`. Hosts before 0.20 lack them.
|
|
68
|
+
wsOpen?(id: number, url: string, protocols: string[]): void;
|
|
69
|
+
/** `{ text }` or `{ binary }` (base64), as JSON. */
|
|
70
|
+
wsSend?(id: number, json: string): void;
|
|
71
|
+
wsClose?(id: number, code: number, reason: string): void;
|
|
72
|
+
|
|
73
|
+
// `crypto.subtle` (subtle.ts) on CryptoKit, synchronously. Hosts before 0.20 lack them.
|
|
74
|
+
/** The base64 digest of base64 `data` under `algorithm` (`SHA-256`, …), or nothing when the host cannot. */
|
|
75
|
+
digest?(algorithm: string, data: string): string | null | undefined;
|
|
76
|
+
/** The base64 HMAC of base64 `data` with base64 `key` under the hash `algorithm`, or nothing. */
|
|
77
|
+
hmac?(algorithm: string, key: string, data: string): string | null | undefined;
|
|
78
|
+
|
|
79
|
+
/** `navigator.vibrate`: a haptic (`impact`, `selection`, `success`, `warning`, `error`). Hosts before 0.20 lack it. */
|
|
80
|
+
haptic?(kind: string): void;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A host request of the web renderer's kinds (hostRequests.ts): `share`,
|
|
84
|
+
* `pickFiles`, `clipboard`, `pickColor` and `webAuth`, with the request's
|
|
85
|
+
* params as JSON; the answer arrives at `__sbHostResponse(id, { value } | { error })`.
|
|
86
|
+
* Hosts before 0.20 lack it.
|
|
87
|
+
*/
|
|
88
|
+
hostRequest?(id: number, kind: string, json: string): void;
|
|
89
|
+
|
|
90
|
+
// The Keychain (secureStorage.ts). Hosts before 0.20 lack them.
|
|
91
|
+
secureGet?(key: string): string | null;
|
|
92
|
+
secureSet?(key: string, value: string): void;
|
|
93
|
+
secureRemove?(key: string): void;
|
|
38
94
|
|
|
39
95
|
// Workers (worker.ts): a second context on its own thread, from a bundle
|
|
40
96
|
// `swiftbrowser-native build --worker <name>=<entry>` wrote next to app.js
|
package/src/fetch.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `fetch`, `Headers`, `Request`, `Response` and `AbortController` over the
|
|
3
3
|
* bridge's `fetch` primitive (`URLSession` on the Swift side). Bodies cross
|
|
4
|
-
* as base64; a `Response` reads them back as text, JSON or
|
|
5
|
-
*
|
|
4
|
+
* as base64; a `Response` reads them back as text, JSON, bytes or a `Blob`.
|
|
5
|
+
* A `Blob` or `FormData` body (blob.ts) is sent whole, the form as
|
|
6
|
+
* multipart; there are no streams.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import type { NativeBridge, NativeFetchResult } from './bridge';
|
|
10
|
+
import { encodeMultipart, SimpleBlob, SimpleFormData } from './blob';
|
|
9
11
|
import { base64Decode, base64Encode, utf8Decode, utf8Encode } from './encoding';
|
|
10
12
|
|
|
11
13
|
type HeadersInitLike = SimpleHeaders | Record<string, string> | [string, string][];
|
|
@@ -114,16 +116,25 @@ function timeoutError(): Error {
|
|
|
114
116
|
return error;
|
|
115
117
|
}
|
|
116
118
|
|
|
117
|
-
type BodyInitLike = string | ArrayBuffer | ArrayBufferView | SimpleURLSearchParamsLike | null | undefined;
|
|
119
|
+
type BodyInitLike = string | ArrayBuffer | ArrayBufferView | SimpleURLSearchParamsLike | SimpleBlob | SimpleFormData | null | undefined;
|
|
118
120
|
interface SimpleURLSearchParamsLike {
|
|
119
121
|
toString(): string;
|
|
120
122
|
[Symbol.iterator](): IterableIterator<[string, string]>;
|
|
121
123
|
}
|
|
122
124
|
|
|
125
|
+
/** The multipart boundary's randomness: the bridge's bytes once the runtime is installed, a counter before. */
|
|
126
|
+
let randomBytes: (count: number) => Uint8Array = (count) => Uint8Array.from({ length: count }, (_, i) => (i * 37 + 11) & 255);
|
|
127
|
+
|
|
128
|
+
export function setBodyRandomness(random: (count: number) => Uint8Array): void {
|
|
129
|
+
randomBytes = random;
|
|
130
|
+
}
|
|
131
|
+
|
|
123
132
|
/** Bytes of a body, and the content type it implies when the caller set none. */
|
|
124
133
|
function encodeBody(body: BodyInitLike): { bytes: Uint8Array | null; contentType: string | null } {
|
|
125
134
|
if (body === null || body === undefined) return { bytes: null, contentType: null };
|
|
126
135
|
if (typeof body === 'string') return { bytes: utf8Encode(body), contentType: 'text/plain;charset=UTF-8' };
|
|
136
|
+
if (body instanceof SimpleBlob) return { bytes: body.bytes(), contentType: body.type || null };
|
|
137
|
+
if (body instanceof SimpleFormData) return encodeMultipart(body, randomBytes);
|
|
127
138
|
if (body instanceof ArrayBuffer) return { bytes: new Uint8Array(body), contentType: null };
|
|
128
139
|
if (ArrayBuffer.isView(body)) return { bytes: new Uint8Array(body.buffer, body.byteOffset, body.byteLength), contentType: null };
|
|
129
140
|
if (typeof body === 'object' && Symbol.iterator in body && typeof body.toString === 'function') {
|
|
@@ -160,6 +171,13 @@ class Body {
|
|
|
160
171
|
async json(): Promise<unknown> {
|
|
161
172
|
return JSON.parse(utf8Decode(this.take()));
|
|
162
173
|
}
|
|
174
|
+
async blob(): Promise<SimpleBlob> {
|
|
175
|
+
return new SimpleBlob([this.take()], { type: this.contentType() });
|
|
176
|
+
}
|
|
177
|
+
/** The body's content type, for `blob()`; a Request or Response reads its headers. */
|
|
178
|
+
protected contentType(): string {
|
|
179
|
+
return '';
|
|
180
|
+
}
|
|
163
181
|
}
|
|
164
182
|
|
|
165
183
|
export interface RequestInitLike {
|
|
@@ -190,6 +208,10 @@ export class SimpleRequest extends Body {
|
|
|
190
208
|
rawBytes(): Uint8Array | null {
|
|
191
209
|
return this.bytes;
|
|
192
210
|
}
|
|
211
|
+
protected override contentType(): string {
|
|
212
|
+
return this.headers.get('content-type') ?? '';
|
|
213
|
+
}
|
|
214
|
+
|
|
193
215
|
clone(): SimpleRequest {
|
|
194
216
|
return new SimpleRequest(this, { body: this.bytes });
|
|
195
217
|
}
|
|
@@ -222,6 +244,10 @@ export class SimpleResponse extends Body {
|
|
|
222
244
|
get ok(): boolean {
|
|
223
245
|
return this.status >= 200 && this.status < 300;
|
|
224
246
|
}
|
|
247
|
+
protected override contentType(): string {
|
|
248
|
+
return this.headers.get('content-type') ?? '';
|
|
249
|
+
}
|
|
250
|
+
|
|
225
251
|
clone(): SimpleResponse {
|
|
226
252
|
return new SimpleResponse(this.bytes, { status: this.status, statusText: this.statusText, headers: this.headers, url: this.url });
|
|
227
253
|
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host requests `@swiftbrowser/react` sends to the browser environment
|
|
3
|
+
* (`ShareLink`, `fileImporter`, the clipboard, a color chooser: the web
|
|
4
|
+
* renderer's `performHostRequest` with its `browserEnvironment`), answered by
|
|
5
|
+
* the native app instead: the bridge's `hostRequest(id, kind, json)` performs
|
|
6
|
+
* one with UIKit (a share sheet, the photo or document picker, the
|
|
7
|
+
* pasteboard, the color picker, `ASWebAuthenticationSession`), answering
|
|
8
|
+
* `__sbHostResponse(id, { value } | { error })`. `installHostEnvironment`
|
|
9
|
+
* fills the environment's gaps with these, leaving what the page has (the
|
|
10
|
+
* round trip runs in a browser with a share sheet of its own).
|
|
11
|
+
*
|
|
12
|
+
* `webAuthenticate` is the sign-in window apps open for OAuth: the system's
|
|
13
|
+
* sheet, resolving with the callback URL.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { objectURL, SimpleBlob, SimpleFile } from './blob';
|
|
17
|
+
import type { NativeBridge } from './bridge';
|
|
18
|
+
import { base64Decode, base64Encode } from './encoding';
|
|
19
|
+
|
|
20
|
+
export type HostRequestBridge = Required<Pick<NativeBridge, 'hostRequest'>>;
|
|
21
|
+
|
|
22
|
+
/** A file as it crosses the bridge: its name, MIME type and bytes (base64). */
|
|
23
|
+
export interface BridgedFile {
|
|
24
|
+
name: string;
|
|
25
|
+
type: string;
|
|
26
|
+
data: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface WebAuthenticateOptions {
|
|
30
|
+
/** The scheme of the callback URL the sign-in ends with (`myapp` for `myapp://auth`); universal links need none. */
|
|
31
|
+
callbackScheme?: string;
|
|
32
|
+
/** Whether the session shares no cookies with the system browser. */
|
|
33
|
+
ephemeral?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let bridge: HostRequestBridge | null = null;
|
|
37
|
+
let nextId = 1;
|
|
38
|
+
const pending = new Map<number, { resolve: (value: unknown) => void; reject: (error: Error) => void }>();
|
|
39
|
+
|
|
40
|
+
/** Points the requests at the bridge (the runtime, once per install). */
|
|
41
|
+
export function installHostRequests(native: HostRequestBridge): void {
|
|
42
|
+
bridge = native;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The bridge's `__sbHostResponse(id, result)`. */
|
|
46
|
+
export function receiveHostResponse(id: number, result: string | { value?: unknown; error?: string }): void {
|
|
47
|
+
const waiting = pending.get(id);
|
|
48
|
+
if (!waiting) return;
|
|
49
|
+
pending.delete(id);
|
|
50
|
+
let parsed: { value?: unknown; error?: string };
|
|
51
|
+
try {
|
|
52
|
+
parsed = typeof result === 'string' ? (JSON.parse(result) as { value?: unknown; error?: string }) : result;
|
|
53
|
+
} catch {
|
|
54
|
+
waiting.reject(new Error('host: malformed response'));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (typeof parsed.error === 'string') {
|
|
58
|
+
const error = new Error(parsed.error);
|
|
59
|
+
if (parsed.error.startsWith('cancelled')) error.name = 'AbortError';
|
|
60
|
+
waiting.reject(error);
|
|
61
|
+
} else {
|
|
62
|
+
waiting.resolve(parsed.value);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Performs one request of `kind` with `params`; resolves with the host's value. */
|
|
67
|
+
export function hostRequest<T = unknown>(kind: string, params: Record<string, unknown>): Promise<T> {
|
|
68
|
+
return new Promise<T>((resolve, reject) => {
|
|
69
|
+
if (!bridge) {
|
|
70
|
+
reject(new Error(`unsupported: no native host for ${kind}`));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const id = nextId++;
|
|
74
|
+
pending.set(id, { resolve: resolve as (value: unknown) => void, reject });
|
|
75
|
+
bridge.hostRequest(id, kind, JSON.stringify(params));
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Forgets the bridge and the waiting requests, for tests. */
|
|
80
|
+
export function resetHostRequests(): void {
|
|
81
|
+
bridge = null;
|
|
82
|
+
pending.clear();
|
|
83
|
+
nextId = 1;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function bridgedFile(file: SimpleBlob & { name?: string }): Promise<BridgedFile> {
|
|
87
|
+
return { name: file.name ?? 'file', type: file.type, data: base64Encode(file.bytes()) };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function filesOf(value: unknown): SimpleFile[] {
|
|
91
|
+
const files = (value as { files?: BridgedFile[] } | undefined)?.files ?? [];
|
|
92
|
+
return files.map((file) => new SimpleFile([base64Decode(file.data)], file.name, { type: file.type }));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Opens the system's sign-in sheet at `url` and resolves with the callback
|
|
97
|
+
* URL it ends with; rejects with an `AbortError` when the viewer closes it.
|
|
98
|
+
*/
|
|
99
|
+
export async function webAuthenticate(url: string, options: WebAuthenticateOptions = {}): Promise<string> {
|
|
100
|
+
const params: Record<string, unknown> = { action: 'start', url };
|
|
101
|
+
if (options.callbackScheme) params['callbackScheme'] = options.callbackScheme;
|
|
102
|
+
if (options.ephemeral !== undefined) params['ephemeral'] = options.ephemeral;
|
|
103
|
+
const result = await hostRequest<{ url?: string }>('webAuth', params);
|
|
104
|
+
if (typeof result?.url !== 'string') throw new Error('webAuth: the sign-in ended without a URL');
|
|
105
|
+
return result.url;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The environment fields the host answers, shaped as the web renderer's `HostRequestEnvironment`. */
|
|
109
|
+
export function nativeHostEnvironment(): Record<string, unknown> {
|
|
110
|
+
return {
|
|
111
|
+
share: async (data: { title?: string; text?: string; url?: string; files?: SimpleFile[] }): Promise<void> => {
|
|
112
|
+
const params: Record<string, unknown> = {};
|
|
113
|
+
if (data.title) params['title'] = data.title;
|
|
114
|
+
if (data.text) params['text'] = data.text;
|
|
115
|
+
if (data.url) params['url'] = data.url;
|
|
116
|
+
if (data.files?.length) params['files'] = await Promise.all(data.files.map(bridgedFile));
|
|
117
|
+
const result = await hostRequest<{ outcome?: string }>('share', params);
|
|
118
|
+
if (result?.outcome === 'cancelled') {
|
|
119
|
+
const error = new Error('cancelled: the viewer closed the share sheet');
|
|
120
|
+
error.name = 'AbortError';
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
canShare: () => true,
|
|
125
|
+
download: (blob: SimpleBlob, filename: string): void => {
|
|
126
|
+
void hostRequest('share', { files: [{ name: filename, type: blob.type, data: base64Encode(blob.bytes()) }] }).catch(() => undefined);
|
|
127
|
+
},
|
|
128
|
+
pickFiles: async (options: { accept: string; multiple: boolean }): Promise<SimpleFile[]> => filesOf(await hostRequest('pickFiles', { accept: options.accept, multiple: options.multiple })),
|
|
129
|
+
objectURL: (file: SimpleBlob): string => objectURL(file),
|
|
130
|
+
clipboard: {
|
|
131
|
+
writeText: (text: string): Promise<void> => hostRequest('clipboard', { action: 'write', text }).then(() => undefined),
|
|
132
|
+
writeImage: async (png: Promise<SimpleBlob>): Promise<void> => {
|
|
133
|
+
const file = await png;
|
|
134
|
+
await hostRequest('clipboard', { action: 'write', files: [{ name: 'image.png', type: file.type || 'image/png', data: base64Encode(file.bytes()) }] });
|
|
135
|
+
},
|
|
136
|
+
readText: async (): Promise<string> => (await hostRequest<{ text?: string }>('clipboard', { action: 'readText' }))?.text ?? '',
|
|
137
|
+
},
|
|
138
|
+
pickColor: async (color: string): Promise<string | null> => (await hostRequest<{ color?: string }>('pickColor', { color }))?.color ?? null,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Fills what `environment` lacks with the host's answers; what the page has stays. */
|
|
143
|
+
export function installHostEnvironment(environment: Record<string, unknown>): void {
|
|
144
|
+
if (!bridge) return;
|
|
145
|
+
for (const [key, value] of Object.entries(nativeHostEnvironment())) {
|
|
146
|
+
if (environment[key] === undefined) environment[key] = value;
|
|
147
|
+
}
|
|
148
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -17,6 +17,14 @@ export { installRuntime, type InstallOptions } from './runtime';
|
|
|
17
17
|
export { nativeBridge, launchArguments, type NativeBridge, type NativeFetchRequest, type NativeFetchResult, type WorkerEngine } from './bridge';
|
|
18
18
|
export { SimpleHeaders, SimpleRequest, SimpleResponse, SimpleAbortController, SimpleAbortSignal, createFetch } from './fetch';
|
|
19
19
|
export { SimpleURL, SimpleURLSearchParams } from './url';
|
|
20
|
+
export { SimpleBlob, SimpleFile, SimpleFormData, encodeMultipart, objectURL } from './blob';
|
|
21
|
+
export { NativeWebSocket, type NativeWebSocketEvent } from './webSocket';
|
|
22
|
+
export { createSubtleCrypto } from './subtle';
|
|
23
|
+
export { matchMedia, updateMediaEnvironment, type MediaEnvironment } from './matchMedia';
|
|
24
|
+
export { hostRequest, webAuthenticate, nativeHostEnvironment, installHostEnvironment, type WebAuthenticateOptions, type BridgedFile } from './hostRequests';
|
|
25
|
+
export { secureStorage, createSecureStorage, type SecureStorage } from './secureStorage';
|
|
20
26
|
export { SimpleTextEncoder, SimpleTextDecoder, base64Encode, base64Decode, utf8Encode, utf8Decode } from './encoding';
|
|
21
27
|
export { createWorker, NativeWorker, encodeMessage, decodeMessage, workerName, type NativeWorkerOptions, type WorkerLike, type WorkerMessageEvent, type WorkerErrorEvent } from './worker';
|
|
22
28
|
export { onOpenURL } from './openURL';
|
|
29
|
+
export { onNotification, registerForPush, type NotificationEvent, type NativeNotificationOptions, type RegisterForPushOptions } from './notifications';
|
|
30
|
+
export { createMediaSession, NativeMediaMetadata, type NativeMediaSession, type MediaMetadataInit, type MediaActionDetails, type MediaActionHandler, type MediaPositionState } from './mediaSession';
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `matchMedia` for the native app: `(prefers-color-scheme: dark)` and
|
|
3
|
+
* `light` follow the color scheme the host sends with the environment
|
|
4
|
+
* (run.ts hands it over), `(prefers-reduced-motion: reduce)` the host's
|
|
5
|
+
* accessibility setting when it sends one; every other query matches
|
|
6
|
+
* nothing. Listeners hear a change when the environment does.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface MediaQueryListLike {
|
|
10
|
+
readonly media: string;
|
|
11
|
+
readonly matches: boolean;
|
|
12
|
+
onchange: ((event: { matches: boolean; media: string }) => void) | null;
|
|
13
|
+
addEventListener(type: 'change', listener: (event: { matches: boolean; media: string }) => void): void;
|
|
14
|
+
removeEventListener(type: 'change', listener: (event: { matches: boolean; media: string }) => void): void;
|
|
15
|
+
/** The older names. */
|
|
16
|
+
addListener(listener: (event: { matches: boolean; media: string }) => void): void;
|
|
17
|
+
removeListener(listener: (event: { matches: boolean; media: string }) => void): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface MediaEnvironment {
|
|
21
|
+
colorScheme?: 'light' | 'dark';
|
|
22
|
+
reducedMotion?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type Listener = (event: { matches: boolean; media: string }) => void;
|
|
26
|
+
|
|
27
|
+
let environment: MediaEnvironment = {};
|
|
28
|
+
const lists = new Set<{ media: string; matches: boolean; listeners: Set<Listener>; onchange: Listener | null }>();
|
|
29
|
+
|
|
30
|
+
function evaluate(query: string): boolean {
|
|
31
|
+
const normalized = query.trim().toLowerCase().replace(/\s+/g, '');
|
|
32
|
+
if (normalized === '(prefers-color-scheme:dark)') return environment.colorScheme === 'dark';
|
|
33
|
+
if (normalized === '(prefers-color-scheme:light)') return environment.colorScheme !== 'dark';
|
|
34
|
+
if (normalized === '(prefers-reduced-motion:reduce)') return environment.reducedMotion === true;
|
|
35
|
+
if (normalized === '(prefers-reduced-motion:no-preference)') return environment.reducedMotion !== true;
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The runtime's `matchMedia(query)`. */
|
|
40
|
+
export function matchMedia(query: string): MediaQueryListLike {
|
|
41
|
+
const media = String(query);
|
|
42
|
+
const state = { media, matches: evaluate(media), listeners: new Set<Listener>(), onchange: null as Listener | null };
|
|
43
|
+
lists.add(state);
|
|
44
|
+
return {
|
|
45
|
+
media,
|
|
46
|
+
get matches() {
|
|
47
|
+
return state.matches;
|
|
48
|
+
},
|
|
49
|
+
get onchange() {
|
|
50
|
+
return state.onchange;
|
|
51
|
+
},
|
|
52
|
+
set onchange(listener) {
|
|
53
|
+
state.onchange = listener;
|
|
54
|
+
},
|
|
55
|
+
addEventListener: (_type, listener) => void state.listeners.add(listener),
|
|
56
|
+
removeEventListener: (_type, listener) => void state.listeners.delete(listener),
|
|
57
|
+
addListener: (listener) => void state.listeners.add(listener),
|
|
58
|
+
removeListener: (listener) => void state.listeners.delete(listener),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** What the host sent with the environment; lists whose answer changed tell their listeners. */
|
|
63
|
+
export function updateMediaEnvironment(next: MediaEnvironment): void {
|
|
64
|
+
environment = { ...environment, ...next };
|
|
65
|
+
for (const state of lists) {
|
|
66
|
+
const matches = evaluate(state.media);
|
|
67
|
+
if (matches === state.matches) continue;
|
|
68
|
+
state.matches = matches;
|
|
69
|
+
const event = { matches, media: state.media };
|
|
70
|
+
state.onchange?.(event);
|
|
71
|
+
for (const listener of state.listeners) listener(event);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Forgets the environment and the lists, for tests. */
|
|
76
|
+
export function resetMediaEnvironment(): void {
|
|
77
|
+
environment = {};
|
|
78
|
+
lists.clear();
|
|
79
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `navigator.mediaSession` and `MediaMetadata` for the native app: what an
|
|
3
|
+
* audio app tells the browser about what plays (the lock screen, the media
|
|
4
|
+
* keys) reaches the host's `mediaSession(update)` instead, which sets
|
|
5
|
+
* `MPNowPlayingInfoCenter` and `MPRemoteCommandCenter` (HostBridge.swift,
|
|
6
|
+
* NowPlayingBridge.swift); the commands the viewer gives come back to
|
|
7
|
+
* `__sbMediaSessionCommand(json)` and run the handlers `setActionHandler`
|
|
8
|
+
* registered, with the details the browser would give (`seekTime`,
|
|
9
|
+
* `seekOffset`). The update is the one the web renderer's `mediaSession`
|
|
10
|
+
* request carries (docs/ops-protocol.md, "Now playing"), so an app writes
|
|
11
|
+
* the same code for the page and the native app. In the page (the round
|
|
12
|
+
* trip) the page's own `navigator.mediaSession` is there already and
|
|
13
|
+
* nothing is installed over it.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { NativeBridge } from './bridge';
|
|
17
|
+
|
|
18
|
+
type Action = 'play' | 'pause' | 'stop' | 'nexttrack' | 'previoustrack' | 'seekto' | 'seekforward' | 'seekbackward' | 'skipad' | 'togglemicrophone' | 'togglecamera' | 'hangup';
|
|
19
|
+
|
|
20
|
+
/** The protocol's command each Media Session action is; the rest get no button. */
|
|
21
|
+
const COMMANDS: Partial<Record<Action, string>> = {
|
|
22
|
+
play: 'play',
|
|
23
|
+
pause: 'pause',
|
|
24
|
+
stop: 'stop',
|
|
25
|
+
nexttrack: 'nextTrack',
|
|
26
|
+
previoustrack: 'previousTrack',
|
|
27
|
+
seekto: 'seekTo',
|
|
28
|
+
seekforward: 'skipForward',
|
|
29
|
+
seekbackward: 'skipBackward',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export interface MediaImage {
|
|
33
|
+
src: string;
|
|
34
|
+
sizes?: string;
|
|
35
|
+
type?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface MediaMetadataInit {
|
|
39
|
+
title?: string;
|
|
40
|
+
artist?: string;
|
|
41
|
+
album?: string;
|
|
42
|
+
artwork?: MediaImage[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The browser's `MediaMetadata`. */
|
|
46
|
+
export class NativeMediaMetadata {
|
|
47
|
+
title: string;
|
|
48
|
+
artist: string;
|
|
49
|
+
album: string;
|
|
50
|
+
artwork: MediaImage[];
|
|
51
|
+
|
|
52
|
+
constructor(init: MediaMetadataInit = {}) {
|
|
53
|
+
this.title = init.title ?? '';
|
|
54
|
+
this.artist = init.artist ?? '';
|
|
55
|
+
this.album = init.album ?? '';
|
|
56
|
+
this.artwork = (init.artwork ?? []).map((image) => ({ ...image }));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface MediaPositionState {
|
|
61
|
+
duration?: number;
|
|
62
|
+
playbackRate?: number;
|
|
63
|
+
position?: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface MediaActionDetails {
|
|
67
|
+
action: Action;
|
|
68
|
+
seekTime?: number;
|
|
69
|
+
seekOffset?: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type MediaActionHandler = (details: MediaActionDetails) => void;
|
|
73
|
+
|
|
74
|
+
/** The browser's `navigator.mediaSession`, as far as audio apps use it. */
|
|
75
|
+
export interface NativeMediaSession {
|
|
76
|
+
metadata: NativeMediaMetadata | null;
|
|
77
|
+
playbackState: 'none' | 'playing' | 'paused';
|
|
78
|
+
setPositionState(state?: MediaPositionState): void;
|
|
79
|
+
setActionHandler(action: Action, handler: MediaActionHandler | null): void;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A command the host received: the protocol's name, with the position of a seek or the interval of a skip. */
|
|
83
|
+
interface ReceivedCommand {
|
|
84
|
+
command: string;
|
|
85
|
+
position?: number;
|
|
86
|
+
interval?: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function createMediaSession(native: Pick<NativeBridge, 'mediaSession'>): { session: NativeMediaSession; receive: (json: string) => void } {
|
|
90
|
+
let metadata: NativeMediaMetadata | null = null;
|
|
91
|
+
let playbackState: NativeMediaSession['playbackState'] = 'none';
|
|
92
|
+
let position: MediaPositionState | null = null;
|
|
93
|
+
const handlers = new Map<Action, MediaActionHandler>();
|
|
94
|
+
|
|
95
|
+
// The whole state each time: the host keeps what it has and changes what differs.
|
|
96
|
+
const sync = (): void => {
|
|
97
|
+
const commands = [...handlers.keys()].map((action) => COMMANDS[action]).filter((command): command is string => command !== undefined);
|
|
98
|
+
native.mediaSession?.(
|
|
99
|
+
JSON.stringify({
|
|
100
|
+
metadata: metadata === null ? null : { title: metadata.title, artist: metadata.artist, album: metadata.album, artwork: metadata.artwork[0]?.src },
|
|
101
|
+
playbackState,
|
|
102
|
+
position:
|
|
103
|
+
position === null
|
|
104
|
+
? null
|
|
105
|
+
: { duration: Number.isFinite(position.duration) ? position.duration : 0, position: position.position ?? 0, rate: position.playbackRate ?? 1 },
|
|
106
|
+
commands,
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const session: NativeMediaSession = {
|
|
112
|
+
get metadata() {
|
|
113
|
+
return metadata;
|
|
114
|
+
},
|
|
115
|
+
set metadata(value) {
|
|
116
|
+
metadata = value === null || value === undefined ? null : value instanceof NativeMediaMetadata ? value : new NativeMediaMetadata(value as MediaMetadataInit);
|
|
117
|
+
sync();
|
|
118
|
+
},
|
|
119
|
+
get playbackState() {
|
|
120
|
+
return playbackState;
|
|
121
|
+
},
|
|
122
|
+
set playbackState(value) {
|
|
123
|
+
if (value !== 'none' && value !== 'playing' && value !== 'paused') throw new TypeError(`${String(value)} is not a valid MediaSessionPlaybackState`);
|
|
124
|
+
playbackState = value;
|
|
125
|
+
sync();
|
|
126
|
+
},
|
|
127
|
+
setPositionState(state) {
|
|
128
|
+
if (state === undefined || state.duration === undefined) {
|
|
129
|
+
position = null;
|
|
130
|
+
} else {
|
|
131
|
+
if (!(state.duration >= 0)) throw new TypeError('the duration must be a non-negative number');
|
|
132
|
+
if (state.position !== undefined && (state.position < 0 || state.position > state.duration)) throw new TypeError('the position must be within the duration');
|
|
133
|
+
position = { ...state };
|
|
134
|
+
}
|
|
135
|
+
sync();
|
|
136
|
+
},
|
|
137
|
+
setActionHandler(action, handler) {
|
|
138
|
+
if (!(action in COMMANDS) && !['skipad', 'togglemicrophone', 'togglecamera', 'hangup'].includes(action)) throw new TypeError(`${String(action)} is not a valid MediaSessionAction`);
|
|
139
|
+
if (handler) handlers.set(action, handler);
|
|
140
|
+
else handlers.delete(action);
|
|
141
|
+
sync();
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const receive = (json: string): void => {
|
|
146
|
+
let received: ReceivedCommand;
|
|
147
|
+
try {
|
|
148
|
+
received = JSON.parse(json) as ReceivedCommand;
|
|
149
|
+
} catch {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const action = (Object.keys(COMMANDS) as Action[]).find((name) => COMMANDS[name] === received.command);
|
|
153
|
+
if (!action) return;
|
|
154
|
+
const handler = handlers.get(action);
|
|
155
|
+
if (!handler) return;
|
|
156
|
+
const details: MediaActionDetails = { action };
|
|
157
|
+
if (action === 'seekto' && typeof received.position === 'number') details.seekTime = received.position;
|
|
158
|
+
if ((action === 'seekforward' || action === 'seekbackward') && typeof received.interval === 'number') details.seekOffset = received.interval;
|
|
159
|
+
handler(details);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
return { session, receive };
|
|
163
|
+
}
|