@swiftbrowser/native 0.18.3 → 0.21.0
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 +53 -11
- package/bin/swiftbrowser-native.mjs +47 -0
- package/dist/blob.d.ts +85 -11
- package/dist/blob.d.ts.map +1 -1
- package/dist/blob.js +184 -34
- package/dist/blob.js.map +1 -1
- package/dist/bridge.d.ts +66 -8
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js.map +1 -1
- package/dist/encoding.d.ts +13 -0
- package/dist/encoding.d.ts.map +1 -1
- package/dist/encoding.js +24 -0
- package/dist/encoding.js.map +1 -1
- package/dist/fetch.d.ts +27 -13
- package/dist/fetch.d.ts.map +1 -1
- package/dist/fetch.js +117 -45
- package/dist/fetch.js.map +1 -1
- package/dist/hostRequests.d.ts +24 -6
- package/dist/hostRequests.d.ts.map +1 -1
- package/dist/hostRequests.js +108 -15
- package/dist/hostRequests.js.map +1 -1
- package/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -2
- package/dist/index.js.map +1 -1
- package/dist/ios.d.ts +79 -0
- package/dist/ios.d.ts.map +1 -0
- package/dist/ios.js +181 -0
- package/dist/ios.js.map +1 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +16 -7
- package/dist/runtime.js.map +1 -1
- package/dist/subtle.d.ts +1 -1
- package/dist/subtle.d.ts.map +1 -1
- package/dist/subtle.js +9 -5
- package/dist/subtle.js.map +1 -1
- package/dist/webSocket.d.ts +12 -5
- package/dist/webSocket.d.ts.map +1 -1
- package/dist/webSocket.js +37 -11
- package/dist/webSocket.js.map +1 -1
- package/package.json +7 -3
- package/src/blob.ts +215 -34
- package/src/bridge.ts +56 -8
- package/src/encoding.ts +22 -0
- package/src/fetch.ts +111 -48
- package/src/hostRequests.ts +129 -22
- package/src/index.ts +1 -2
- package/src/ios.ts +253 -0
- package/src/runtime.ts +23 -8
- package/src/subtle.ts +10 -6
- package/src/webSocket.ts +37 -13
- package/swift/Packages/SwiftBrowserDSP/Package.swift +31 -0
- package/swift/Packages/SwiftBrowserDSP/README.md +42 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ProgramNode.swift +306 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSP/ScopeView.swift +79 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Machine.swift +1561 -0
- package/swift/Packages/SwiftBrowserDSP/Sources/SwiftBrowserDSPCore/Program.swift +483 -0
- package/swift/Packages/SwiftBrowserLocalModel/Package.swift +35 -0
- package/swift/Packages/SwiftBrowserLocalModel/README.md +66 -0
- package/swift/Packages/SwiftBrowserLocalModel/Sources/SwiftBrowserLocalModel/LlamaEngine.swift +223 -0
- package/swift/Packages/SwiftBrowserLocalModel/Sources/SwiftBrowserLocalModel/LocalLanguageModel.swift +409 -0
- package/swift/Packages/SwiftBrowserShader/Package.swift +31 -0
- package/swift/Packages/SwiftBrowserShader/README.md +45 -0
- package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShader/ShaderRenderer.swift +103 -0
- package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShader/ShaderView.swift +168 -0
- package/swift/Packages/SwiftBrowserShader/Sources/SwiftBrowserShaderCore/ShaderProps.swift +120 -0
- package/swift/Sources/SwiftBrowserNative/Host/AudioEngineBridge.swift +921 -0
- package/swift/Sources/SwiftBrowserNative/Host/AudioEngineNodes.swift +70 -0
- package/swift/Sources/SwiftBrowserNative/Host/BodyStream.swift +166 -0
- package/swift/Sources/SwiftBrowserNative/Host/FilesBridge.swift +144 -10
- package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +151 -27
- package/swift/Sources/SwiftBrowserNative/Host/HostRequestBridge.swift +139 -40
- package/swift/Sources/SwiftBrowserNative/Host/JSHost.swift +116 -0
- package/swift/Sources/SwiftBrowserNative/Host/JavaScriptCoreHost.swift +141 -2
- package/swift/Sources/SwiftBrowserNative/Host/LanguageModelBridge.swift +511 -0
- package/swift/Sources/SwiftBrowserNative/Host/LanguageModelProvider.swift +195 -0
- package/swift/Sources/SwiftBrowserNative/Host/WebSocketBridge.swift +24 -11
- package/swift/Sources/SwiftBrowserNative/Host/WorkerHost.swift +3 -2
- package/swift/Sources/SwiftBrowserNative/Runtime/NativeFunctions.swift +1 -1
- package/swift/Sources/SwiftBrowserNative/Runtime/ViewRegistry.swift +57 -0
- package/swift/Sources/SwiftBrowserNative/Views/ElementView.swift +12 -7
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Controls.swift +18 -1
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Drawing.swift +378 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Lists.swift +41 -1
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/More.swift +88 -17
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Navigation.swift +87 -21
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/PinchTracker.swift +200 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/PopTap.swift +103 -0
- package/swift/Sources/SwiftBrowserNative/Views/Kinds/Stacks.swift +3 -2
- package/swift/Sources/SwiftBrowserNative/Views/StyleModifier.swift +146 -0
- package/swift/Sources/SwiftBrowserProtocol/Drawing.swift +306 -0
- package/swift/Sources/SwiftBrowserProtocol/Props.swift +31 -2
- package/swift/Sources/SwiftBrowserProtocol/RendererEvent.swift +24 -3
- package/swift/Sources/SwiftBrowserProtocol/Style.swift +87 -0
- package/swift/Sources/SwiftBrowserTree/OpTree.swift +25 -0
- package/wasm/SwiftBrowserNative.wasm +2 -2
- package/dist/native.d.ts +0 -19
- package/dist/native.d.ts.map +0 -1
- package/dist/native.js +0 -17
- package/dist/native.js.map +0 -1
- package/src/native.ts +0 -35
- package/swift/Sources/SwiftBrowserNative/Runtime/NativeRegistry.swift +0 -53
package/src/hostRequests.ts
CHANGED
|
@@ -14,23 +14,31 @@
|
|
|
14
14
|
* `webAuthenticate` is the sign-in window apps open for OAuth: the system's
|
|
15
15
|
* sheet, resolving with the callback URL.
|
|
16
16
|
*
|
|
17
|
+
* `audioEngine` is `AVAudioEngine` (@swiftbrowser/react): each command an
|
|
18
|
+
* `audioEngine` request, which Host/AudioEngineBridge.swift performs as it
|
|
19
|
+
* arrives, in order. `languageModel` is Foundation Models' on-device model
|
|
20
|
+
* (`LanguageModelSession`): `languageModel` requests, which
|
|
21
|
+
* Host/LanguageModelBridge.swift answers.
|
|
22
|
+
*
|
|
17
23
|
* `files` is the app's sandbox for `fileManager` (@swiftbrowser/react): the
|
|
18
|
-
* `files` requests of Host/FilesBridge.swift, bytes crossing as base64, with
|
|
24
|
+
* `files` requests of Host/FilesBridge.swift, bytes crossing as views beside the JSON (base64 to an older host), with
|
|
19
25
|
* the standard directories from the bridge's `appDirectories()`.
|
|
20
26
|
*/
|
|
21
27
|
|
|
22
|
-
import { objectURL, SimpleBlob, SimpleFile } from './blob';
|
|
28
|
+
import { blobBytes, LazyFile, objectURL, SimpleBlob, SimpleFile } from './blob';
|
|
23
29
|
import type { NativeBridge } from './bridge';
|
|
24
|
-
import {
|
|
30
|
+
import { base64Encode, bridgedBytes } from './encoding';
|
|
25
31
|
import { nativeUndoHost } from './undo';
|
|
26
32
|
|
|
27
|
-
export type HostRequestBridge = Required<Pick<NativeBridge, 'hostRequest'>> & Pick<NativeBridge, 'appDirectories'>;
|
|
33
|
+
export type HostRequestBridge = Required<Pick<NativeBridge, 'hostRequest'>> & Pick<NativeBridge, 'appDirectories' | 'hostRequestBytes'>;
|
|
28
34
|
|
|
29
|
-
/** A file as it crosses the bridge: its name, MIME type and bytes (base64). */
|
|
35
|
+
/** A file as it crosses the bridge: its name, MIME type and bytes (a view beside the JSON, or base64 to a host without them). */
|
|
30
36
|
export interface BridgedFile {
|
|
31
37
|
name: string;
|
|
32
38
|
type: string;
|
|
33
|
-
|
|
39
|
+
/** The bytes; absent when the file is still on disk, named by `url`. */
|
|
40
|
+
data?: string | Uint8Array;
|
|
41
|
+
url?: string;
|
|
34
42
|
}
|
|
35
43
|
|
|
36
44
|
export interface WebAuthenticateOptions {
|
|
@@ -50,14 +58,15 @@ export function installHostRequests(native: HostRequestBridge): void {
|
|
|
50
58
|
fileWatchers.clear();
|
|
51
59
|
}
|
|
52
60
|
|
|
53
|
-
/** The bridge's `__sbHostResponse(id, result)`. */
|
|
54
|
-
export function receiveHostResponse(id: number, result: string | { value?: unknown; error?: string }): void {
|
|
61
|
+
/** The bridge's `__sbHostResponse(id, result, ...buffers)`: the buffers go where the JSON says `{ "$bytes": n }`. */
|
|
62
|
+
export function receiveHostResponse(id: number, result: string | { value?: unknown; error?: string }, ...buffers: ArrayBufferView[]): void {
|
|
55
63
|
const waiting = pending.get(id);
|
|
56
64
|
if (!waiting) return;
|
|
57
65
|
pending.delete(id);
|
|
58
66
|
let parsed: { value?: unknown; error?: string };
|
|
59
67
|
try {
|
|
60
|
-
parsed =
|
|
68
|
+
parsed =
|
|
69
|
+
typeof result === 'string' ? (JSON.parse(result, buffers.length ? placeBytes(buffers) : undefined) as { value?: unknown; error?: string }) : result;
|
|
61
70
|
} catch {
|
|
62
71
|
waiting.reject(new Error('host: malformed response'));
|
|
63
72
|
return;
|
|
@@ -80,8 +89,40 @@ export function hostRequest<T = unknown>(kind: string, params: Record<string, un
|
|
|
80
89
|
}
|
|
81
90
|
const id = nextId++;
|
|
82
91
|
pending.set(id, { resolve: resolve as (value: unknown) => void, reject });
|
|
83
|
-
|
|
92
|
+
// Through `hostRequestBytes` whenever the host has it, views or not: the
|
|
93
|
+
// host then answers with bytes beside the JSON too.
|
|
94
|
+
const { json, views } = liftBytes(params, bridge.hostRequestBytes !== undefined);
|
|
95
|
+
if (bridge.hostRequestBytes) bridge.hostRequestBytes(id, kind, json, ...views);
|
|
96
|
+
else bridge.hostRequest(id, kind, json);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The params as JSON with their bytes lifted out: each typed array or
|
|
102
|
+
* `ArrayBuffer` becomes `{ "$bytes": n }` and the nth of `views`, for a host
|
|
103
|
+
* with `hostRequestBytes`; without one, base64 of its bytes in place, the
|
|
104
|
+
* form every host reads (docs/notes/bytes.md).
|
|
105
|
+
*/
|
|
106
|
+
export function liftBytes(params: unknown, lift: boolean): { json: string; views: ArrayBufferView[] } {
|
|
107
|
+
const views: ArrayBufferView[] = [];
|
|
108
|
+
const json = JSON.stringify(params, (_key, value: unknown) => {
|
|
109
|
+
const view = value instanceof ArrayBuffer ? new Uint8Array(value) : ArrayBuffer.isView(value) ? value : null;
|
|
110
|
+
if (!view) return value;
|
|
111
|
+
if (!lift) return base64Encode(new Uint8Array(view.buffer, view.byteOffset, view.byteLength));
|
|
112
|
+
views.push(view);
|
|
113
|
+
return { $bytes: views.length - 1 };
|
|
84
114
|
});
|
|
115
|
+
return { json, views };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A JSON reviver putting `buffers` where the text says `{ "$bytes": n }`. */
|
|
119
|
+
function placeBytes(buffers: ArrayBufferView[]): (key: string, value: unknown) => unknown {
|
|
120
|
+
return (_key, value) => {
|
|
121
|
+
if (value && typeof value === 'object' && typeof (value as { $bytes?: unknown }).$bytes === 'number') {
|
|
122
|
+
return buffers[(value as { $bytes: number }).$bytes] ?? value;
|
|
123
|
+
}
|
|
124
|
+
return value;
|
|
125
|
+
};
|
|
85
126
|
}
|
|
86
127
|
|
|
87
128
|
/** Forgets the bridge and the waiting requests, for tests. */
|
|
@@ -92,12 +133,33 @@ export function resetHostRequests(): void {
|
|
|
92
133
|
}
|
|
93
134
|
|
|
94
135
|
async function bridgedFile(file: SimpleBlob & { name?: string }): Promise<BridgedFile> {
|
|
95
|
-
|
|
136
|
+
// A picked file still on disk goes as its URL: the host reads it itself.
|
|
137
|
+
if (file instanceof LazyFile && file.isWhole) return { name: file.name ?? 'file', type: file.type, url: file.url };
|
|
138
|
+
return { name: file.name ?? 'file', type: file.type, data: await blobBytes(file) };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A file as a host answers it: bytes in place (`data`), or still on disk (`url`, `size`), read on demand through the files bridge. */
|
|
142
|
+
interface AnsweredFile {
|
|
143
|
+
name: string;
|
|
144
|
+
type: string;
|
|
145
|
+
data?: string | Uint8Array;
|
|
146
|
+
url?: string;
|
|
147
|
+
size?: number;
|
|
148
|
+
lastModified?: number;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Reads a range of a file on disk through the files bridge (FilesBridge.swift `readRange`). */
|
|
152
|
+
async function readFileRange(url: string, start: number, end: number): Promise<Uint8Array> {
|
|
153
|
+
return bridgedBytes((await hostRequest<{ data?: string | Uint8Array }>('files', { action: 'readRange', url, start, end }))?.data ?? '');
|
|
96
154
|
}
|
|
97
155
|
|
|
98
156
|
function filesOf(value: unknown): SimpleFile[] {
|
|
99
|
-
const files = (value as { files?:
|
|
100
|
-
return files.map((file) =>
|
|
157
|
+
const files = (value as { files?: AnsweredFile[] } | undefined)?.files ?? [];
|
|
158
|
+
return files.map((file) => {
|
|
159
|
+
const options = { type: file.type, ...(typeof file.lastModified === 'number' ? { lastModified: file.lastModified } : {}) };
|
|
160
|
+
if (file.data === undefined && typeof file.url === 'string') return LazyFile.make(file.url, file.name, file.size ?? 0, readFileRange, options);
|
|
161
|
+
return new SimpleFile([bridgedBytes(file.data ?? '')], file.name, options);
|
|
162
|
+
});
|
|
101
163
|
}
|
|
102
164
|
|
|
103
165
|
/**
|
|
@@ -159,18 +221,36 @@ function nativeFiles(directories: FileDirectories): Record<string, unknown> {
|
|
|
159
221
|
return {
|
|
160
222
|
directories,
|
|
161
223
|
// The container's Documents (ICloudBridge.swift), resolved once; null without the entitlement or iCloud Drive.
|
|
162
|
-
iCloud: (): Promise<string | null> =>
|
|
224
|
+
iCloud: (): Promise<string | null> =>
|
|
225
|
+
(iCloud ??= files<{ url?: string } | null>({ action: 'iCloud' }).then((found) => (typeof found?.url === 'string' ? found.url : null))),
|
|
163
226
|
watch: (listener: (urls: string[]) => void): (() => void) => {
|
|
164
227
|
fileWatchers.add(listener);
|
|
165
228
|
return () => void fileWatchers.delete(listener);
|
|
166
229
|
},
|
|
167
|
-
read: async (url: string): Promise<Uint8Array> =>
|
|
168
|
-
|
|
230
|
+
read: async (url: string): Promise<Uint8Array> => bridgedBytes((await files<{ data?: string | Uint8Array }>({ action: 'read', url }))?.data ?? ''),
|
|
231
|
+
// A range read through `FileHandle`, so a large file is read a piece at a time (FilesBridge.swift `readRange`).
|
|
232
|
+
readRange: async (url: string, start: number, end: number): Promise<Uint8Array> =>
|
|
233
|
+
bridgedBytes((await files<{ data?: string | Uint8Array }>({ action: 'readRange', url, start, end }))?.data ?? ''),
|
|
234
|
+
// A file the viewer picked or dropped (a `LazyFile`, still on disk) is copied there by the host (`importItem`); other bytes cross the bridge.
|
|
235
|
+
write: async (url: string, data: Uint8Array | SimpleBlob): Promise<void> => {
|
|
236
|
+
if (data instanceof LazyFile) {
|
|
237
|
+
if (data.isWhole) {
|
|
238
|
+
await files({ action: 'importItem', from: data.url, to: url });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
await files({ action: 'write', url, data: await data.bytesAsync() });
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
await files({ action: 'write', url, data: data instanceof SimpleBlob ? data.bytes() : data });
|
|
245
|
+
},
|
|
169
246
|
remove: (url: string): Promise<void> => files({ action: 'remove', url }).then(() => undefined),
|
|
170
247
|
createDirectory: (url: string): Promise<void> => files({ action: 'createDirectory', url }).then(() => undefined),
|
|
171
248
|
list: async (url: string): Promise<string[]> => (await files<{ urls?: string[] }>({ action: 'list', url }))?.urls ?? [],
|
|
172
249
|
attributes: async (url: string): Promise<{ size: number; modified: number | null; created: number | null; directory: boolean; type: string } | null> => {
|
|
173
|
-
const found = await files<{ size?: number; modified?: number | null; created?: number | null; directory?: boolean; type?: string } | null>({
|
|
250
|
+
const found = await files<{ size?: number; modified?: number | null; created?: number | null; directory?: boolean; type?: string } | null>({
|
|
251
|
+
action: 'attributes',
|
|
252
|
+
url,
|
|
253
|
+
});
|
|
174
254
|
if (!found || typeof found.size !== 'number') return null;
|
|
175
255
|
return {
|
|
176
256
|
size: found.size,
|
|
@@ -192,6 +272,9 @@ export function nativeHostEnvironment(): Record<string, unknown> {
|
|
|
192
272
|
const directories = appDirectories();
|
|
193
273
|
return {
|
|
194
274
|
...(directories ? { files: nativeFiles(directories) } : {}),
|
|
275
|
+
// A file dropped on the app, left on disk by the host (Drawing.swift's drop destination): a `LazyFile` read by range.
|
|
276
|
+
fileFromReference: (item: { type: string; name?: string; path?: string; url: string; size?: number }): SimpleFile =>
|
|
277
|
+
LazyFile.make(item.url, item.path ?? item.name ?? 'file', item.size ?? 0, readFileRange, { type: item.type }),
|
|
195
278
|
share: async (data: { title?: string; text?: string; url?: string; files?: SimpleFile[] }): Promise<void> => {
|
|
196
279
|
const params: Record<string, unknown> = {};
|
|
197
280
|
if (data.title) params['title'] = data.title;
|
|
@@ -207,20 +290,35 @@ export function nativeHostEnvironment(): Record<string, unknown> {
|
|
|
207
290
|
},
|
|
208
291
|
canShare: () => true,
|
|
209
292
|
download: (blob: SimpleBlob, filename: string): void => {
|
|
210
|
-
|
|
293
|
+
// A picked file still on disk goes by URL (the host copies it); bytes go in the same turn.
|
|
294
|
+
const file = blob instanceof LazyFile && blob.isWhole ? { name: filename, type: blob.type, url: blob.url } : null;
|
|
295
|
+
if (file) {
|
|
296
|
+
void hostRequest('share', { files: [file] }).catch(() => undefined);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (blob instanceof LazyFile) {
|
|
300
|
+
void blob
|
|
301
|
+
.bytesAsync()
|
|
302
|
+
.then((data) => hostRequest('share', { files: [{ name: filename, type: blob.type, data }] }))
|
|
303
|
+
.catch(() => undefined);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
void hostRequest('share', { files: [{ name: filename, type: blob.type, data: blob.bytes() }] }).catch(() => undefined);
|
|
211
307
|
},
|
|
212
|
-
pickFiles: async (options: { accept: string; multiple: boolean }): Promise<SimpleFile[]> =>
|
|
308
|
+
pickFiles: async (options: { accept: string; multiple: boolean }): Promise<SimpleFile[]> =>
|
|
309
|
+
filesOf(await hostRequest('pickFiles', { accept: options.accept, multiple: options.multiple })),
|
|
213
310
|
// `fileExporter`: the document picker, saving the files where the viewer chooses.
|
|
214
311
|
exportFiles: async (files: SimpleFile[]): Promise<{ outcome: string; urls?: string[] }> => {
|
|
215
312
|
const result = await hostRequest<{ outcome?: string; urls?: string[] } | null>('exportFiles', { files: await Promise.all(files.map(bridgedFile)) });
|
|
216
313
|
return { outcome: result?.outcome ?? 'cancelled', ...(result?.urls ? { urls: result.urls } : {}) };
|
|
217
314
|
},
|
|
218
|
-
|
|
315
|
+
// A picked file still on disk is its own URL (the image loader and AVPlayer read files); bytes become a `data:` URL.
|
|
316
|
+
objectURL: (file: SimpleBlob): string => (file instanceof LazyFile && file.isWhole ? file.url : objectURL(file)),
|
|
219
317
|
clipboard: {
|
|
220
318
|
writeText: (text: string): Promise<void> => hostRequest('clipboard', { action: 'write', text }).then(() => undefined),
|
|
221
319
|
writeImage: async (png: Promise<SimpleBlob>): Promise<void> => {
|
|
222
320
|
const file = await png;
|
|
223
|
-
await hostRequest('clipboard', { action: 'write', files: [{ name: 'image.png', type: file.type || 'image/png', data:
|
|
321
|
+
await hostRequest('clipboard', { action: 'write', files: [{ name: 'image.png', type: file.type || 'image/png', data: await blobBytes(file) }] });
|
|
224
322
|
},
|
|
225
323
|
readText: async (): Promise<string> => (await hostRequest<{ text?: string }>('clipboard', { action: 'readText' }))?.text ?? '',
|
|
226
324
|
},
|
|
@@ -228,7 +326,16 @@ export function nativeHostEnvironment(): Record<string, unknown> {
|
|
|
228
326
|
// Sign in with Apple (AppleSignInBridge.swift): the system sheet, and the credential's state.
|
|
229
327
|
appleSignIn: {
|
|
230
328
|
signIn: (options: { scopes: string[]; nonce?: string; state?: string }): Promise<unknown> => hostRequest('appleSignIn', { action: 'signIn', ...options }),
|
|
231
|
-
credentialState: async (user: string): Promise<string> =>
|
|
329
|
+
credentialState: async (user: string): Promise<string> =>
|
|
330
|
+
(await hostRequest<{ state?: string } | null>('appleSignIn', { action: 'credentialState', user }))?.state ?? 'unknown',
|
|
331
|
+
},
|
|
332
|
+
// `AVAudioEngine` (AudioEngineBridge.swift): the app's engine commands, answered in the order sent.
|
|
333
|
+
audioEngine: {
|
|
334
|
+
perform: (command: Record<string, unknown>): Promise<unknown> => hostRequest('audioEngine', command),
|
|
335
|
+
},
|
|
336
|
+
// Foundation Models (LanguageModelBridge.swift): the app's sessions and streams, a `next` answered as a stream grows.
|
|
337
|
+
languageModel: {
|
|
338
|
+
perform: (command: Record<string, unknown>): Promise<unknown> => hostRequest('languageModel', command),
|
|
232
339
|
},
|
|
233
340
|
// `AVAudioRecorder` (AudioRecorderBridge.swift): a recording ends as a file URL in Documents; `trim` writes another.
|
|
234
341
|
audioRecorder: {
|
package/src/index.ts
CHANGED
|
@@ -12,13 +12,12 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
export { run, type RunOptions, type Running } from './run';
|
|
15
|
-
export { Native, type NativeProps } from './native';
|
|
16
15
|
export { callNative, callNativeSync, nativeFunctions, hasNativeFunction, onNativeEvent } from './nativeFunctions';
|
|
17
16
|
export { installRuntime, type InstallOptions } from './runtime';
|
|
18
17
|
export { nativeBridge, launchArguments, type NativeBridge, type NativeFetchRequest, type NativeFetchResult, type WorkerEngine } from './bridge';
|
|
19
18
|
export { SimpleHeaders, SimpleRequest, SimpleResponse, SimpleAbortController, SimpleAbortSignal, createFetch } from './fetch';
|
|
20
19
|
export { SimpleURL, SimpleURLSearchParams } from './url';
|
|
21
|
-
export { SimpleBlob, SimpleFile, SimpleFormData, encodeMultipart, objectURL } from './blob';
|
|
20
|
+
export { LazyFile, SimpleBlob, SimpleFile, SimpleFormData, blobBytes, encodeMultipart, encodeMultipartAsync, objectURL, partsBytes, type BodyPart } from './blob';
|
|
22
21
|
export { NativeWebSocket, type NativeWebSocketEvent } from './webSocket';
|
|
23
22
|
export { createSubtleCrypto } from './subtle';
|
|
24
23
|
export { matchMedia, updateMediaEnvironment, type MediaEnvironment } from './matchMedia';
|
package/src/ios.ts
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a CI job needs to hand an app's iOS build to phones, outside Xcode:
|
|
3
|
+
* an ad hoc provisioning profile made fresh through the App Store Connect
|
|
4
|
+
* API, and the over-the-air install page around the exported IPA. The
|
|
5
|
+
* reusable workflows (.github/workflows/sb-ios-adhoc.yml in the SwiftBrowser
|
|
6
|
+
* repository) run them as `swiftbrowser-native ios-profile` and
|
|
7
|
+
* `swiftbrowser-native ios-install-page`, from the app's own
|
|
8
|
+
* node_modules, so they come at the version of the app's @swiftbrowser
|
|
9
|
+
* packages.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { sign } from 'node:crypto';
|
|
13
|
+
import { copyFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import { basename, join } from 'node:path';
|
|
16
|
+
|
|
17
|
+
export interface AppStoreConnectKey {
|
|
18
|
+
/** The key's ID (ASC_KEY_ID). */
|
|
19
|
+
keyId: string;
|
|
20
|
+
/** The team's issuer ID (ASC_ISSUER_ID). */
|
|
21
|
+
issuer: string;
|
|
22
|
+
/** The .p8 key's text (ASC_KEY_P8). It needs the Admin or App Manager role. */
|
|
23
|
+
key: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AdhocProfileOptions extends AppStoreConnectKey {
|
|
27
|
+
bundleId: string;
|
|
28
|
+
/** The profile's name, which the archive's PROVISIONING_PROFILE_SPECIFIER names. */
|
|
29
|
+
profileName: string;
|
|
30
|
+
/** The app's name, for the bundle ID when the team has none yet. */
|
|
31
|
+
appName: string;
|
|
32
|
+
/** Where profiles are installed; the user's home by default. */
|
|
33
|
+
home?: string;
|
|
34
|
+
/** For tests. */
|
|
35
|
+
fetch?: typeof fetch;
|
|
36
|
+
log?: (line: string) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface AdhocProfile {
|
|
40
|
+
uuid: string;
|
|
41
|
+
/** The files written, one per folder Xcode reads profiles from. */
|
|
42
|
+
files: string[];
|
|
43
|
+
devices: string[];
|
|
44
|
+
certificates: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A JWT for the App Store Connect API, good for 20 minutes. */
|
|
48
|
+
export function appStoreConnectToken({ keyId, issuer, key }: AppStoreConnectKey, now = Date.now()): string {
|
|
49
|
+
const encode = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString('base64url');
|
|
50
|
+
const seconds = Math.floor(now / 1000);
|
|
51
|
+
const body = `${encode({ alg: 'ES256', kid: keyId, typ: 'JWT' })}.${encode({ iss: issuer, iat: seconds, exp: seconds + 1200, aud: 'appstoreconnect-v1' })}`;
|
|
52
|
+
const signature = sign('sha256', Buffer.from(body), { key, dsaEncoding: 'ieee-p1363' });
|
|
53
|
+
return `${body}.${signature.toString('base64url')}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface Resource {
|
|
57
|
+
id: string;
|
|
58
|
+
attributes: Record<string, string | undefined>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Makes the ad hoc profile `profileName` for `bundleId` again, with every
|
|
63
|
+
* enabled iOS device and every valid distribution certificate of the team,
|
|
64
|
+
* so a phone registered since the last build is in the next one: registers
|
|
65
|
+
* the bundle ID when the team has none, deletes the profile of that name if
|
|
66
|
+
* there is one, and installs the new one where Xcode looks.
|
|
67
|
+
*/
|
|
68
|
+
export async function makeAdhocProfile(options: AdhocProfileOptions): Promise<AdhocProfile> {
|
|
69
|
+
const { bundleId, profileName, appName } = options;
|
|
70
|
+
const request = options.fetch ?? fetch;
|
|
71
|
+
const log = options.log ?? ((line: string) => console.log(line));
|
|
72
|
+
const authorization = `Bearer ${appStoreConnectToken(options)}`;
|
|
73
|
+
|
|
74
|
+
const api = async (method: string, path: string, body?: unknown): Promise<{ data: Resource | Resource[]; links?: { next?: string } } | null> => {
|
|
75
|
+
const response = await request(`https://api.appstoreconnect.apple.com${path}`, {
|
|
76
|
+
method,
|
|
77
|
+
headers: { authorization, 'content-type': 'application/json' },
|
|
78
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
79
|
+
});
|
|
80
|
+
const text = await response.text();
|
|
81
|
+
if (!response.ok) throw new Error(`${method} ${path}: ${response.status} ${text}`);
|
|
82
|
+
return text ? JSON.parse(text) : null;
|
|
83
|
+
};
|
|
84
|
+
/** Every page of a list. */
|
|
85
|
+
const list = async (path: string): Promise<Resource[]> => {
|
|
86
|
+
const items: Resource[] = [];
|
|
87
|
+
let next: string | null = path;
|
|
88
|
+
while (next) {
|
|
89
|
+
const page = await api('GET', next);
|
|
90
|
+
items.push(...((page?.data as Resource[]) ?? []));
|
|
91
|
+
const link = page?.links?.next;
|
|
92
|
+
next = link ? new URL(link).pathname + new URL(link).search : null;
|
|
93
|
+
}
|
|
94
|
+
return items;
|
|
95
|
+
};
|
|
96
|
+
const query = (params: Record<string, string>): string => new URLSearchParams(params).toString();
|
|
97
|
+
|
|
98
|
+
let bundle = (await list(`/v1/bundleIds?${query({ 'filter[identifier]': bundleId, limit: '200' })}`)).find(
|
|
99
|
+
(item) => item.attributes['identifier'] === bundleId,
|
|
100
|
+
);
|
|
101
|
+
if (!bundle) {
|
|
102
|
+
log(`Registering the bundle ID ${bundleId}.`);
|
|
103
|
+
bundle = (await api('POST', '/v1/bundleIds', { data: { type: 'bundleIds', attributes: { identifier: bundleId, name: appName, platform: 'IOS' } } }))!
|
|
104
|
+
.data as Resource;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const now = Date.now();
|
|
108
|
+
const certificates = (await list(`/v1/certificates?${query({ 'filter[certificateType]': 'DISTRIBUTION,IOS_DISTRIBUTION', limit: '200' })}`)).filter(
|
|
109
|
+
(item) => !item.attributes['expirationDate'] || Date.parse(item.attributes['expirationDate']) > now,
|
|
110
|
+
);
|
|
111
|
+
if (certificates.length === 0) throw new Error('The team has no valid distribution certificate.');
|
|
112
|
+
|
|
113
|
+
const devices = await list(`/v1/devices?${query({ 'filter[platform]': 'IOS', 'filter[status]': 'ENABLED', limit: '200' })}`);
|
|
114
|
+
if (devices.length === 0) throw new Error('The team has no enabled iOS devices: an ad hoc profile needs at least one.');
|
|
115
|
+
|
|
116
|
+
for (const old of await list(`/v1/profiles?${query({ 'filter[name]': profileName, limit: '200' })}`)) {
|
|
117
|
+
if (old.attributes['name'] !== profileName) continue;
|
|
118
|
+
log(`Deleting the old profile ${old.id}.`);
|
|
119
|
+
await api('DELETE', `/v1/profiles/${old.id}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const profile = (await api('POST', '/v1/profiles', {
|
|
123
|
+
data: {
|
|
124
|
+
type: 'profiles',
|
|
125
|
+
attributes: { name: profileName, profileType: 'IOS_APP_ADHOC' },
|
|
126
|
+
relationships: {
|
|
127
|
+
bundleId: { data: { type: 'bundleIds', id: bundle.id } },
|
|
128
|
+
certificates: { data: certificates.map((item) => ({ type: 'certificates', id: item.id })) },
|
|
129
|
+
devices: { data: devices.map((item) => ({ type: 'devices', id: item.id })) },
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
}))!.data as Resource;
|
|
133
|
+
|
|
134
|
+
const uuid = profile.attributes['uuid']!;
|
|
135
|
+
const content = Buffer.from(profile.attributes['profileContent']!, 'base64');
|
|
136
|
+
const home = options.home ?? homedir();
|
|
137
|
+
const files: string[] = [];
|
|
138
|
+
// Xcode 16 and later read the first; older ones the second.
|
|
139
|
+
for (const dir of [join(home, 'Library/Developer/Xcode/UserData/Provisioning Profiles'), join(home, 'Library/MobileDevice/Provisioning Profiles')]) {
|
|
140
|
+
mkdirSync(dir, { recursive: true });
|
|
141
|
+
const file = join(dir, `${uuid}.mobileprovision`);
|
|
142
|
+
writeFileSync(file, content);
|
|
143
|
+
files.push(file);
|
|
144
|
+
}
|
|
145
|
+
const names = devices.map((device) => `${device.attributes['name']} (${device.attributes['model'] ?? device.attributes['deviceClass']})`);
|
|
146
|
+
log(`Made "${profileName}" (${uuid}) for ${bundleId}: ${devices.length} device(s), ${certificates.length} certificate(s).`);
|
|
147
|
+
for (const name of names) log(`- ${name}`);
|
|
148
|
+
return { uuid, files, devices: names, certificates: certificates.length };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface InstallPageOptions {
|
|
152
|
+
/** The exported IPA. */
|
|
153
|
+
ipa: string;
|
|
154
|
+
/** Where the page will be served from (`https://ios.app.pages.dev`): the manifest names the IPA there. */
|
|
155
|
+
url: string;
|
|
156
|
+
bundleId: string;
|
|
157
|
+
/** The build's version (`1.0.42`), its CFBundleVersion. */
|
|
158
|
+
version: string;
|
|
159
|
+
/** The app's name on the page and in the install prompt; the IPA's name by default. */
|
|
160
|
+
title?: string;
|
|
161
|
+
/** The same app in the browser, linked from the page. */
|
|
162
|
+
web?: string;
|
|
163
|
+
/** The commit it was built from. */
|
|
164
|
+
commit?: string;
|
|
165
|
+
/** When it was built; now by default. */
|
|
166
|
+
built?: Date;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const escapeXml = (text: string): string => text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
170
|
+
|
|
171
|
+
/** The itms-services manifest for an IPA served at `ipaUrl`. */
|
|
172
|
+
export function installManifest({ ipaUrl, bundleId, version, title }: { ipaUrl: string; bundleId: string; version: string; title: string }): string {
|
|
173
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
174
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
175
|
+
<plist version="1.0"><dict><key>items</key><array><dict>
|
|
176
|
+
<key>assets</key><array><dict>
|
|
177
|
+
<key>kind</key><string>software-package</string>
|
|
178
|
+
<key>url</key><string>${escapeXml(ipaUrl)}</string>
|
|
179
|
+
</dict></array>
|
|
180
|
+
<key>metadata</key><dict>
|
|
181
|
+
<key>bundle-identifier</key><string>${escapeXml(bundleId)}</string>
|
|
182
|
+
<key>bundle-version</key><string>${escapeXml(version)}</string>
|
|
183
|
+
<key>kind</key><string>software</string>
|
|
184
|
+
<key>title</key><string>${escapeXml(title)}</string>
|
|
185
|
+
</dict>
|
|
186
|
+
</dict></array></dict></plist>
|
|
187
|
+
`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Writes the install page into `outDir`: the IPA, its manifest, an
|
|
192
|
+
* `index.html` whose button installs it over the air (on a phone the
|
|
193
|
+
* profile names), and the `_headers` Cloudflare Pages serves them with.
|
|
194
|
+
* Returns the files written, relative to `outDir`.
|
|
195
|
+
*/
|
|
196
|
+
export function writeInstallPage(outDir: string, options: InstallPageOptions): string[] {
|
|
197
|
+
const ipaName = basename(options.ipa);
|
|
198
|
+
const title = options.title ?? ipaName.replace(/\.ipa$/, '');
|
|
199
|
+
const base = options.url.replace(/\/+$/, '');
|
|
200
|
+
const built = (options.built ?? new Date()).toISOString().replace('T', ' ').slice(0, 16) + ' UTC';
|
|
201
|
+
const html = (text: string): string => escapeXml(text);
|
|
202
|
+
mkdirSync(outDir, { recursive: true });
|
|
203
|
+
copyFileSync(options.ipa, join(outDir, ipaName));
|
|
204
|
+
writeFileSync(join(outDir, 'manifest.plist'), installManifest({ ipaUrl: `${base}/${ipaName}`, bundleId: options.bundleId, version: options.version, title }));
|
|
205
|
+
writeFileSync(
|
|
206
|
+
join(outDir, '_headers'),
|
|
207
|
+
`/manifest.plist
|
|
208
|
+
Content-Type: application/xml
|
|
209
|
+
Cache-Control: no-store
|
|
210
|
+
/${ipaName}
|
|
211
|
+
Content-Type: application/octet-stream
|
|
212
|
+
Cache-Control: no-store
|
|
213
|
+
`,
|
|
214
|
+
);
|
|
215
|
+
const install = `itms-services://?action=download-manifest&url=${base}/manifest.plist`;
|
|
216
|
+
writeFileSync(
|
|
217
|
+
join(outDir, 'index.html'),
|
|
218
|
+
`<!doctype html>
|
|
219
|
+
<html lang="en">
|
|
220
|
+
<head>
|
|
221
|
+
<meta charset="utf-8" />
|
|
222
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
223
|
+
<meta name="color-scheme" content="light dark" />
|
|
224
|
+
<title>Install ${html(title)}</title>
|
|
225
|
+
<style>
|
|
226
|
+
:root { --bg: #f2f2f7; --card: #fff; --fg: #000; --muted: #6e6e73; --tint: #007aff; }
|
|
227
|
+
@media (prefers-color-scheme: dark) { :root { --bg: #000; --card: #1c1c1e; --fg: #fff; --muted: #98989f; --tint: #0a84ff; } }
|
|
228
|
+
* { box-sizing: border-box; }
|
|
229
|
+
body { margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 24px 16px;
|
|
230
|
+
background: var(--bg); color: var(--fg); font: 17px/1.4 -apple-system, BlinkMacSystemFont, system-ui, sans-serif; }
|
|
231
|
+
main { width: 100%; max-width: 420px; background: var(--card); border-radius: 22px; padding: 28px 24px; text-align: center; }
|
|
232
|
+
h1 { font-size: 28px; margin: 0 0 4px; }
|
|
233
|
+
p { color: var(--muted); margin: 8px 0; font-size: 15px; }
|
|
234
|
+
a.install { display: block; margin: 24px 0 16px; padding: 14px; border-radius: 14px; background: var(--tint);
|
|
235
|
+
color: #fff; font-weight: 600; text-decoration: none; }
|
|
236
|
+
a { color: var(--tint); }
|
|
237
|
+
</style>
|
|
238
|
+
</head>
|
|
239
|
+
<body>
|
|
240
|
+
<main>
|
|
241
|
+
<h1>${html(title)}</h1>
|
|
242
|
+
<p>Version ${html(options.version)} · built ${built}${options.commit ? ` from ${html(options.commit.slice(0, 7))}` : ''}</p>
|
|
243
|
+
<a class="install" href="${html(install)}">Install on this iPhone</a>
|
|
244
|
+
<p>Open this page in Safari on an iPhone whose device is registered with the team: an ad hoc build installs only on the devices its profile names. After installing, open Settings → General → VPN & Device Management if iOS asks you to trust the developer.</p>${
|
|
245
|
+
options.web ? `\n <p><a href="${html(options.web)}">Try it in the browser</a></p>` : ''
|
|
246
|
+
}
|
|
247
|
+
</main>
|
|
248
|
+
</body>
|
|
249
|
+
</html>
|
|
250
|
+
`,
|
|
251
|
+
);
|
|
252
|
+
return [ipaName, 'manifest.plist', '_headers', 'index.html'];
|
|
253
|
+
}
|
package/src/runtime.ts
CHANGED
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
35
|
import { nativeBridge, type NativeBridge } from './bridge';
|
|
36
|
-
import { atobPolyfill, base64Decode, btoaPolyfill, SimpleTextDecoder, SimpleTextEncoder } from './encoding';
|
|
36
|
+
import { atobPolyfill, base64Decode, btoaPolyfill, installNativeUTF8, SimpleTextDecoder, SimpleTextEncoder } from './encoding';
|
|
37
37
|
import { createFetch } from './fetch';
|
|
38
38
|
import { createImageGlobals } from './image';
|
|
39
39
|
import { SimpleURL, SimpleURLSearchParams } from './url';
|
|
@@ -128,7 +128,10 @@ export function installRuntime(native: NativeBridge, target: AnyGlobal = globalT
|
|
|
128
128
|
return String(value);
|
|
129
129
|
}
|
|
130
130
|
};
|
|
131
|
-
const log =
|
|
131
|
+
const log =
|
|
132
|
+
(level: string) =>
|
|
133
|
+
(...args: unknown[]) =>
|
|
134
|
+
native.log(level, args.map(format).join(' '));
|
|
132
135
|
const timersByLabel = new Map<string, number>();
|
|
133
136
|
const counts = new Map<string, number>();
|
|
134
137
|
define('console', {
|
|
@@ -182,7 +185,12 @@ export function installRuntime(native: NativeBridge, target: AnyGlobal = globalT
|
|
|
182
185
|
define('atob', atobPolyfill);
|
|
183
186
|
define('btoa', btoaPolyfill);
|
|
184
187
|
|
|
185
|
-
|
|
188
|
+
// Typed arrays beside the JSON both ways (docs/notes/bytes.md): a host with
|
|
189
|
+
// `hostRequestBytes` carries them for fetch, sockets and files alike.
|
|
190
|
+
const carriesBytes = typeof native.hostRequestBytes === 'function';
|
|
191
|
+
// UTF-8 on the host past a few dozen characters (encoding.ts).
|
|
192
|
+
installNativeUTF8(native.decodeUTF8 && native.encodeUTF8 ? { decode: native.decodeUTF8.bind(native), encode: native.encodeUTF8.bind(native) } : null);
|
|
193
|
+
const fetchGlobals = createFetch(native, { bytes: carriesBytes, streams: carriesBytes && native.fetchStreams?.() === true });
|
|
186
194
|
define('fetch', fetchGlobals.fetch);
|
|
187
195
|
define('Headers', fetchGlobals.Headers);
|
|
188
196
|
define('Request', fetchGlobals.Request);
|
|
@@ -204,12 +212,15 @@ export function installRuntime(native: NativeBridge, target: AnyGlobal = globalT
|
|
|
204
212
|
urlStatics.createObjectURL = (blob: SimpleBlob) => objectURL(blob);
|
|
205
213
|
urlStatics.revokeObjectURL = () => {};
|
|
206
214
|
}
|
|
207
|
-
const random = (count: number): Uint8Array => base64Decode(native.randomBytes(count));
|
|
215
|
+
const random = (count: number): Uint8Array => (native.randomValues ? native.randomValues(count) : base64Decode(native.randomBytes(count)));
|
|
208
216
|
setBodyRandomness(random);
|
|
209
217
|
|
|
210
218
|
// WebSockets (webSocket.ts) over the host's `URLSessionWebSocketTask`.
|
|
211
219
|
if (native.wsOpen && native.wsSend && native.wsClose) {
|
|
212
|
-
NativeWebSocket.install(
|
|
220
|
+
NativeWebSocket.install(
|
|
221
|
+
{ wsOpen: native.wsOpen.bind(native), wsSend: native.wsSend.bind(native), wsClose: native.wsClose.bind(native) },
|
|
222
|
+
{ bytes: carriesBytes },
|
|
223
|
+
);
|
|
213
224
|
define('WebSocket', NativeWebSocket);
|
|
214
225
|
define('__sbWebSocketEvent', NativeWebSocket.receive);
|
|
215
226
|
}
|
|
@@ -219,7 +230,11 @@ export function installRuntime(native: NativeBridge, target: AnyGlobal = globalT
|
|
|
219
230
|
|
|
220
231
|
// Host requests (hostRequests.ts): the share sheet, the pickers, the pasteboard, a sign-in.
|
|
221
232
|
if (native.hostRequest && !native.workerPostMessage) {
|
|
222
|
-
installHostRequests({
|
|
233
|
+
installHostRequests({
|
|
234
|
+
hostRequest: native.hostRequest.bind(native),
|
|
235
|
+
...(native.hostRequestBytes ? { hostRequestBytes: native.hostRequestBytes.bind(native) } : {}),
|
|
236
|
+
...(native.appDirectories ? { appDirectories: native.appDirectories.bind(native) } : {}),
|
|
237
|
+
});
|
|
223
238
|
define('__sbHostResponse', receiveHostResponse);
|
|
224
239
|
define('__sbFilesChanged', receiveFilesChanged);
|
|
225
240
|
// The escape hatch's functions (nativeFunctions.ts): `callNative` rides `hostRequest`.
|
|
@@ -251,14 +266,14 @@ export function installRuntime(native: NativeBridge, target: AnyGlobal = globalT
|
|
|
251
266
|
|
|
252
267
|
define('crypto', {
|
|
253
268
|
getRandomValues: <T extends ArrayBufferView>(array: T): T => {
|
|
254
|
-
const bytes =
|
|
269
|
+
const bytes = random(array.byteLength);
|
|
255
270
|
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).set(bytes.subarray(0, array.byteLength));
|
|
256
271
|
return array;
|
|
257
272
|
},
|
|
258
273
|
// Digests and HMAC on the host's CryptoKit (subtle.ts).
|
|
259
274
|
subtle: createSubtleCrypto(native),
|
|
260
275
|
randomUUID: (): string => {
|
|
261
|
-
const bytes =
|
|
276
|
+
const bytes = new Uint8Array(random(16));
|
|
262
277
|
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
|
|
263
278
|
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
|
|
264
279
|
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
package/src/subtle.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import type { NativeBridge } from './bridge';
|
|
10
10
|
import { base64Decode, base64Encode } from './encoding';
|
|
11
11
|
|
|
12
|
-
export type SubtleBridge = Pick<NativeBridge, 'digest' | 'hmac'>;
|
|
12
|
+
export type SubtleBridge = Pick<NativeBridge, 'digest' | 'hmac' | 'digestBytes' | 'hmacBytes'>;
|
|
13
13
|
|
|
14
14
|
type Algorithm = string | { name: string; hash?: string | { name: string } };
|
|
15
15
|
|
|
@@ -31,15 +31,16 @@ function algorithmName(algorithm: Algorithm): string {
|
|
|
31
31
|
function hashName(algorithm: Algorithm): string {
|
|
32
32
|
if (typeof algorithm === 'string') return algorithm.toUpperCase();
|
|
33
33
|
const hash = algorithm.hash;
|
|
34
|
-
return (typeof hash === 'string' ? hash : hash?.name ?? '').toUpperCase();
|
|
34
|
+
return (typeof hash === 'string' ? hash : (hash?.name ?? '')).toUpperCase();
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
function bytesOf(data: ArrayBuffer | ArrayBufferView): Uint8Array {
|
|
38
38
|
return data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
/** A result as an `ArrayBuffer` of its own: base64 from an older host, or a typed array. */
|
|
42
|
+
function toBuffer(result: string | Uint8Array): ArrayBuffer {
|
|
43
|
+
const bytes = typeof result === 'string' ? base64Decode(result) : result;
|
|
43
44
|
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
|
44
45
|
}
|
|
45
46
|
|
|
@@ -55,7 +56,7 @@ export function createSubtleCrypto(native: SubtleBridge) {
|
|
|
55
56
|
async digest(algorithm: Algorithm, data: ArrayBuffer | ArrayBufferView): Promise<ArrayBuffer> {
|
|
56
57
|
const name = algorithmName(algorithm);
|
|
57
58
|
if (!DIGESTS.has(name)) throw notSupported(`the ${name} digest`);
|
|
58
|
-
const result = native.digest?.(name, base64Encode(bytesOf(data)));
|
|
59
|
+
const result = native.digestBytes ? native.digestBytes(name, bytesOf(data)) : native.digest?.(name, base64Encode(bytesOf(data)));
|
|
59
60
|
if (!result) throw notSupported(`the ${name} digest`);
|
|
60
61
|
return toBuffer(result);
|
|
61
62
|
},
|
|
@@ -70,7 +71,10 @@ export function createSubtleCrypto(native: SubtleBridge) {
|
|
|
70
71
|
|
|
71
72
|
async sign(algorithm: Algorithm, key: HmacKey, data: ArrayBuffer | ArrayBufferView): Promise<ArrayBuffer> {
|
|
72
73
|
if (algorithmName(algorithm) !== 'HMAC' || key.algorithm?.name !== 'HMAC') throw notSupported(`signing with ${algorithmName(algorithm)}`);
|
|
73
|
-
const
|
|
74
|
+
const hash = key.algorithm.hash.name;
|
|
75
|
+
const result = native.hmacBytes
|
|
76
|
+
? native.hmacBytes(hash, key.bytes, bytesOf(data))
|
|
77
|
+
: native.hmac?.(hash, base64Encode(key.bytes), base64Encode(bytesOf(data)));
|
|
74
78
|
if (!result) throw notSupported('HMAC');
|
|
75
79
|
return toBuffer(result);
|
|
76
80
|
},
|