@swiftbrowser/web 0.1.0 → 0.2.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 +43 -6
- package/app/index.html +11 -0
- package/node/dist/node/apps.d.ts +16 -0
- package/node/dist/node/apps.d.ts.map +1 -1
- package/node/dist/node/apps.js +9 -0
- package/node/dist/node/apps.js.map +1 -1
- package/node/dist/node/config.d.ts +14 -1
- package/node/dist/node/config.d.ts.map +1 -1
- package/node/dist/node/config.js +20 -3
- package/node/dist/node/config.js.map +1 -1
- package/node/dist/node/examples.d.ts +2 -1
- package/node/dist/node/examples.d.ts.map +1 -1
- package/node/dist/node/examples.js +11 -2
- package/node/dist/node/examples.js.map +1 -1
- package/node/dist/plugins/app-manifests.d.ts +4 -1
- package/node/dist/plugins/app-manifests.d.ts.map +1 -1
- package/node/dist/plugins/app-manifests.js +3 -2
- package/node/dist/plugins/app-manifests.js.map +1 -1
- package/node/dist/plugins/asset-catalogs.d.ts +15 -3
- package/node/dist/plugins/asset-catalogs.d.ts.map +1 -1
- package/node/dist/plugins/asset-catalogs.js +111 -13
- package/node/dist/plugins/asset-catalogs.js.map +1 -1
- package/node/dist/plugins/included-sites.d.ts +26 -0
- package/node/dist/plugins/included-sites.d.ts.map +1 -0
- package/node/dist/plugins/included-sites.js +70 -0
- package/node/dist/plugins/included-sites.js.map +1 -0
- package/node/dist/plugins/pdfImages.d.ts +27 -0
- package/node/dist/plugins/pdfImages.d.ts.map +1 -0
- package/node/dist/plugins/pdfImages.js +145 -0
- package/node/dist/plugins/pdfImages.js.map +1 -0
- package/node/dist/plugins/swift-wasm.d.ts +19 -1
- package/node/dist/plugins/swift-wasm.d.ts.map +1 -1
- package/node/dist/plugins/swift-wasm.js +45 -7
- package/node/dist/plugins/swift-wasm.js.map +1 -1
- package/node/dist/plugins/symbolTemplates.d.ts +22 -0
- package/node/dist/plugins/symbolTemplates.d.ts.map +1 -0
- package/node/dist/plugins/symbolTemplates.js +83 -0
- package/node/dist/plugins/symbolTemplates.js.map +1 -0
- package/package.json +4 -2
- package/plugins/app-manifests.ts +3 -2
- package/plugins/asset-catalogs.ts +119 -14
- package/plugins/included-sites.ts +74 -0
- package/plugins/pdfImages.ts +163 -0
- package/plugins/swift-wasm.ts +52 -6
- package/plugins/symbolTemplates.ts +88 -0
- package/public/_headers +7 -0
- package/public/app/oauth-callback.html +62 -0
- package/src/animation.ts +235 -8
- package/src/appFiles.ts +201 -0
- package/src/base64.ts +43 -0
- package/src/bitmapPlan.ts +143 -0
- package/src/bitmapStore.ts +206 -0
- package/src/bitmapWorker.ts +41 -0
- package/src/bitmapWorkerProtocol.ts +15 -0
- package/src/bitmaps.ts +283 -0
- package/src/blockedHosts.ts +29 -0
- package/src/canvasDrawing.ts +361 -0
- package/src/chartDom.ts +2 -2
- package/src/chromeColor.ts +179 -0
- package/src/clipboard.ts +78 -0
- package/src/colorMultiply.ts +93 -0
- package/src/colorPicker.ts +44 -0
- package/src/corsProxy.ts +45 -0
- package/src/gaugeRing.ts +74 -0
- package/src/gif.ts +297 -0
- package/src/hostRequests.ts +426 -0
- package/src/imageOps.ts +592 -0
- package/src/interaction.ts +18 -1
- package/src/landing.ts +2 -2
- package/src/layout/chart.ts +9 -3
- package/src/layout/engine.ts +279 -25
- package/src/layout/index.ts +3 -1
- package/src/layout/text.ts +217 -1
- package/src/layout/types.ts +31 -0
- package/src/layout/typography.ts +24 -7
- package/src/layout/video.ts +24 -0
- package/src/layoutDom.ts +53 -3
- package/src/main.ts +124 -4
- package/src/media.ts +132 -0
- package/src/mock.ts +2 -1
- package/src/notifications.ts +109 -0
- package/src/pathShape.ts +156 -0
- package/src/protocol.ts +761 -12
- package/src/renderer.ts +584 -36
- package/src/richText.ts +65 -0
- package/src/scanner.ts +234 -0
- package/src/scrollTo.ts +123 -0
- package/src/styles.css +212 -7
- package/src/styles.ts +31 -0
- package/src/swiftData.ts +128 -0
- package/src/symbols.ts +564 -21
- package/src/translation.ts +126 -0
- package/src/video.ts +299 -0
- package/src/vite-env.d.ts +6 -0
- package/src/wasm.ts +227 -32
- package/src/webAuth.ts +267 -0
- package/src/webSocket.ts +148 -0
- package/src/zoneinfo.ts +269 -0
package/src/wasm.ts
CHANGED
|
@@ -11,7 +11,11 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { ConsoleStdout, Directory, type Fd, File, OpenFile, PreopenDirectory, WASI, WASIProcExit, type Inode } from '@bjorn3/browser_wasi_shim';
|
|
14
|
-
import {
|
|
14
|
+
import { HOME, HOME_ENVIRONMENT, TMP, homeDirectory } from './appFiles';
|
|
15
|
+
import { zoneInfoEntries } from './zoneinfo';
|
|
16
|
+
import { base64Encode } from './base64';
|
|
17
|
+
import { performHostRequest, type HostRequestEnvironment } from './hostRequests';
|
|
18
|
+
import { isOp, type ElementId, type Op, type RendererEvent, type RequestOp, type SwiftDataOp } from './protocol';
|
|
15
19
|
|
|
16
20
|
export type OpsSink = (ops: Op[]) => void;
|
|
17
21
|
|
|
@@ -61,6 +65,24 @@ export interface LoadAppOptions {
|
|
|
61
65
|
bundle?: ReadonlyMap<string, Uint8Array>;
|
|
62
66
|
/** Phase 11: receives the app's `UserDefaults` whenever they change (a `defaults` op); the op never reaches `onOps`. */
|
|
63
67
|
onDefaults?: (values: Record<string, unknown>) => void;
|
|
68
|
+
/**
|
|
69
|
+
* Phase 18: receives each saved batch of the app's SwiftData stores (a
|
|
70
|
+
* `swiftdata` op), in order; the op never reaches `onOps`.
|
|
71
|
+
*/
|
|
72
|
+
onSwiftData?: (op: SwiftDataOp) => void;
|
|
73
|
+
/**
|
|
74
|
+
* Phase 19: the app's home, mounted writable at `/data` (`HOME`, with the
|
|
75
|
+
* XDG variables pointing at its `Library`); an empty one when not given. A
|
|
76
|
+
* fresh in-memory `/tmp` is mounted too.
|
|
77
|
+
*/
|
|
78
|
+
home?: Directory;
|
|
79
|
+
/** Phase 19: called after an event or executor run in which the app wrote to its files. */
|
|
80
|
+
onFilesWritten?: () => void;
|
|
81
|
+
/**
|
|
82
|
+
* Phase 13: how `request` ops (`fetch`, `image`) are performed; the
|
|
83
|
+
* browser's `fetch` and `Image` by default. Tests substitute both.
|
|
84
|
+
*/
|
|
85
|
+
requests?: HostRequestEnvironment;
|
|
64
86
|
}
|
|
65
87
|
|
|
66
88
|
/** An in-memory WASI directory tree from `path → bytes` (paths `/`-separated, no leading slash). */
|
|
@@ -101,6 +123,11 @@ interface SwiftBrowserExports {
|
|
|
101
123
|
sb_state_len?: () => number;
|
|
102
124
|
// Phase 4 (optional).
|
|
103
125
|
sb_run_jobs?: (nowMs: number) => number;
|
|
126
|
+
// Bytes beside the JSON (optional; docs/ops-protocol.md, "Bytes").
|
|
127
|
+
sb_bytes?: (ptr: number, len: number) => number;
|
|
128
|
+
sb_bytes_count?: () => number;
|
|
129
|
+
sb_bytes_ptr?: (index: number) => number;
|
|
130
|
+
sb_bytes_len?: (index: number) => number;
|
|
104
131
|
}
|
|
105
132
|
|
|
106
133
|
const WASI_MODULE = 'wasi_snapshot_preview1';
|
|
@@ -210,7 +237,42 @@ export async function loadApp(
|
|
|
210
237
|
const root = bundleDirectory(options.bundle);
|
|
211
238
|
fds.push(new PreopenDirectory('/bundle', root.contents));
|
|
212
239
|
}
|
|
213
|
-
|
|
240
|
+
// Phase 19: the home directory and the temporary directory.
|
|
241
|
+
fds.push(new PreopenDirectory(HOME, (options.home ?? homeDirectory(new Map())).contents));
|
|
242
|
+
fds.push(new PreopenDirectory(TMP, new Map<string, Inode>()));
|
|
243
|
+
// The zone files CoreFoundation reads for the viewer's `TZ` (zoneinfo.ts).
|
|
244
|
+
fds.push(new PreopenDirectory('/usr/share/zoneinfo', zoneInfoEntries()));
|
|
245
|
+
const given = new Set((options.env ?? []).map((entry) => entry.split('=', 1)[0]));
|
|
246
|
+
const env = [...HOME_ENVIRONMENT.filter((entry) => !given.has(entry.split('=', 1)[0])), ...(options.env ?? [])];
|
|
247
|
+
const wasi = new WASI(options.args ?? ['Counter'], env, fds, { debug: options.debug ?? false });
|
|
248
|
+
// Any call that can change a file marks the files as written; the loader
|
|
249
|
+
// reports that once per drain.
|
|
250
|
+
let filesWritten = false;
|
|
251
|
+
const imports = wasi.wasiImport as Record<string, (...args: never[]) => unknown>;
|
|
252
|
+
for (const name of ['fd_pwrite', 'fd_allocate', 'fd_filestat_set_size', 'path_create_directory', 'path_remove_directory',
|
|
253
|
+
'path_unlink_file', 'path_rename', 'path_link']) {
|
|
254
|
+
const original = imports[name];
|
|
255
|
+
if (!original) continue;
|
|
256
|
+
imports[name] = (...args: never[]) => {
|
|
257
|
+
filesWritten = true;
|
|
258
|
+
return original(...args);
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
const writeImport = imports['fd_write'];
|
|
262
|
+
if (writeImport) {
|
|
263
|
+
imports['fd_write'] = (...args: never[]) => {
|
|
264
|
+
if ((args[0] as unknown as number) > 2) filesWritten = true;
|
|
265
|
+
return writeImport(...args);
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
const openImport = imports['path_open'];
|
|
269
|
+
if (openImport) {
|
|
270
|
+
imports['path_open'] = (...args: never[]) => {
|
|
271
|
+
// oflags: CREAT (1) or TRUNC (8) change the tree or a file.
|
|
272
|
+
if (((args[4] as unknown as number) & 9) !== 0) filesWritten = true;
|
|
273
|
+
return openImport(...args);
|
|
274
|
+
};
|
|
275
|
+
}
|
|
214
276
|
|
|
215
277
|
const instance = await WebAssembly.instantiate(module, {
|
|
216
278
|
[WASI_MODULE]: wasi.wasiImport,
|
|
@@ -248,6 +310,18 @@ export async function loadApp(
|
|
|
248
310
|
throw new WasmLoadError(`${url} exited with code ${exitCode}`);
|
|
249
311
|
}
|
|
250
312
|
|
|
313
|
+
// Phase 13: `request` ops are answered asynchronously with a `response`
|
|
314
|
+
// event through the same path as the renderer's events, which also runs
|
|
315
|
+
// the executor so the awaiting Swift task resumes.
|
|
316
|
+
const requests = options.requests;
|
|
317
|
+
let deliver: (event: RendererEvent) => boolean = () => false;
|
|
318
|
+
const perform = (op: RequestOp): void => {
|
|
319
|
+
performHostRequest(op, requests).then(
|
|
320
|
+
(value) => deliver({ type: 'response', id: op.id, value }),
|
|
321
|
+
(error: unknown) => deliver({ type: 'response', id: op.id, error: error instanceof Error ? error.message : String(error) }),
|
|
322
|
+
);
|
|
323
|
+
};
|
|
324
|
+
|
|
251
325
|
const drain = (): void => {
|
|
252
326
|
let ops = readPendingOps(exports);
|
|
253
327
|
// Clear before applying so a renderer error never re-applies stale ops.
|
|
@@ -258,7 +332,20 @@ export async function loadApp(
|
|
|
258
332
|
if (latest && latest.op === 'defaults') options.onDefaults?.(latest.values);
|
|
259
333
|
ops = ops.filter((op) => op.op !== 'defaults');
|
|
260
334
|
}
|
|
335
|
+
if (ops.some((op) => op.op === 'swiftdata')) {
|
|
336
|
+
// Every save matters (each holds only what changed), in order.
|
|
337
|
+
for (const op of ops) if (op.op === 'swiftdata') options.onSwiftData?.(op);
|
|
338
|
+
ops = ops.filter((op) => op.op !== 'swiftdata');
|
|
339
|
+
}
|
|
340
|
+
if (ops.some((op) => op.op === 'request')) {
|
|
341
|
+
for (const op of ops) if (op.op === 'request') perform(op);
|
|
342
|
+
ops = ops.filter((op) => op.op !== 'request');
|
|
343
|
+
}
|
|
261
344
|
if (ops.length > 0) onOps(ops);
|
|
345
|
+
if (filesWritten) {
|
|
346
|
+
filesWritten = false;
|
|
347
|
+
options.onFilesWritten?.();
|
|
348
|
+
}
|
|
262
349
|
};
|
|
263
350
|
|
|
264
351
|
const supportsJsonEvents =
|
|
@@ -278,6 +365,32 @@ export async function loadApp(
|
|
|
278
365
|
runJobs();
|
|
279
366
|
};
|
|
280
367
|
|
|
368
|
+
/** `bytes` in linear memory for the next event (`sb_bytes`), as the event names them. */
|
|
369
|
+
const putBytes = (bytes: Uint8Array): { $bytes: number } | string => {
|
|
370
|
+
if (typeof exports.sb_bytes !== 'function') return base64Encode(bytes);
|
|
371
|
+
const ptr = writeBytes(exports, bytes);
|
|
372
|
+
return { $bytes: exports.sb_bytes(ptr, bytes.byteLength) };
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const sendEventJson = (event: RendererEvent): boolean => {
|
|
376
|
+
if (event.type === 'tap' && event.x === undefined) {
|
|
377
|
+
// A tap with a point (`onTapGesture { point in }`) goes as JSON.
|
|
378
|
+
sendEvent(event.id);
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
if (!supportsJsonEvents) return false;
|
|
382
|
+
// A fetched body crosses as bytes: copied into linear memory and named
|
|
383
|
+
// `{"$bytes": n}` in the event, or as base64 for a module without `sb_bytes`.
|
|
384
|
+
const json = event.type === 'response' ? JSON.stringify(event, (_key, value: unknown) => (value instanceof Uint8Array ? putBytes(value) : value)) : JSON.stringify(event);
|
|
385
|
+
const bytes = encoder.encode(json);
|
|
386
|
+
const ptr = writeBytes(exports, bytes);
|
|
387
|
+
exports.sb_event_json!(ptr, bytes.byteLength);
|
|
388
|
+
drain();
|
|
389
|
+
runJobs();
|
|
390
|
+
return true;
|
|
391
|
+
};
|
|
392
|
+
deliver = sendEventJson;
|
|
393
|
+
|
|
281
394
|
drain();
|
|
282
395
|
runJobs();
|
|
283
396
|
|
|
@@ -289,28 +402,7 @@ export async function loadApp(
|
|
|
289
402
|
supportsJobs,
|
|
290
403
|
runJobs,
|
|
291
404
|
sendEvent,
|
|
292
|
-
sendEventJson
|
|
293
|
-
if (event.type === 'tap') {
|
|
294
|
-
sendEvent(event.id);
|
|
295
|
-
return true;
|
|
296
|
-
}
|
|
297
|
-
if (!supportsJsonEvents) return false;
|
|
298
|
-
const bytes = encoder.encode(JSON.stringify(event));
|
|
299
|
-
const ptr = exports.sb_alloc!(bytes.byteLength) >>> 0;
|
|
300
|
-
// `memory.buffer` must be read after `sb_alloc`: allocating may grow
|
|
301
|
-
// linear memory and detach the previous ArrayBuffer.
|
|
302
|
-
const memory = exports.memory.buffer;
|
|
303
|
-
if (ptr + bytes.byteLength > memory.byteLength) {
|
|
304
|
-
throw new WasmLoadError(
|
|
305
|
-
`sb_alloc(${bytes.byteLength}) returned ${ptr}, outside linear memory (${memory.byteLength} bytes)`,
|
|
306
|
-
);
|
|
307
|
-
}
|
|
308
|
-
new Uint8Array(memory, ptr, bytes.byteLength).set(bytes);
|
|
309
|
-
exports.sb_event_json!(ptr, bytes.byteLength);
|
|
310
|
-
drain();
|
|
311
|
-
runJobs();
|
|
312
|
-
return true;
|
|
313
|
-
},
|
|
405
|
+
sendEventJson,
|
|
314
406
|
snapshotState(): string | null {
|
|
315
407
|
if (!supportsSnapshot) return null;
|
|
316
408
|
// `sb_state_ptr` refreshes the buffer, so call it before `sb_state_len`.
|
|
@@ -328,18 +420,32 @@ export async function loadApp(
|
|
|
328
420
|
};
|
|
329
421
|
}
|
|
330
422
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
throw new WasmLoadError(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
|
|
335
|
-
}
|
|
423
|
+
/** The module at `url`, whole or joined from the parts it was published as. */
|
|
424
|
+
export async function compile(url: string, fetchImpl: typeof fetch = fetch): Promise<WebAssembly.Module> {
|
|
425
|
+
const response = await fetchImpl(url);
|
|
336
426
|
const type = response.headers.get('content-type') ?? '';
|
|
337
|
-
if (typeof WebAssembly.compileStreaming === 'function' && type.includes('application/wasm')) {
|
|
338
|
-
|
|
427
|
+
if (response.ok && typeof WebAssembly.compileStreaming === 'function' && type.includes('application/wasm')) {
|
|
428
|
+
try {
|
|
429
|
+
return await WebAssembly.compileStreaming(response);
|
|
430
|
+
} catch (error) {
|
|
431
|
+
// A host may answer a missing file with its fallback page and still
|
|
432
|
+
// label it a Wasm module (Cloudflare Pages does, with the `/*.wasm`
|
|
433
|
+
// Content-Type rule in public/_headers): the module may be published
|
|
434
|
+
// as parts.
|
|
435
|
+
const joined = await fetchParts(url, fetchImpl);
|
|
436
|
+
if (joined) return WebAssembly.compile(joined);
|
|
437
|
+
throw error;
|
|
438
|
+
}
|
|
339
439
|
}
|
|
340
440
|
// Fall back for servers that mislabel the MIME type.
|
|
341
|
-
const bytes = await response.arrayBuffer();
|
|
441
|
+
const bytes = response.ok ? await response.arrayBuffer() : new ArrayBuffer(0);
|
|
342
442
|
if (bytes.byteLength < 8 || new DataView(bytes).getUint32(0, true) !== 0x6d736100) {
|
|
443
|
+
// A module larger than the host allows a file to be is published as
|
|
444
|
+
// parts (plugins/swift-wasm.ts); the host answers the whole one's URL
|
|
445
|
+
// with a 404 or its fallback page.
|
|
446
|
+
const joined = await fetchParts(url, fetchImpl);
|
|
447
|
+
if (joined) return WebAssembly.compile(joined);
|
|
448
|
+
if (!response.ok) throw new WasmLoadError(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
|
|
343
449
|
throw new WasmLoadError(
|
|
344
450
|
`${url} is not a Wasm binary (${bytes.byteLength} bytes, content-type "${type}"). ` +
|
|
345
451
|
'Has scripts/build-wasm.sh copied the module into Web/public/?',
|
|
@@ -348,6 +454,42 @@ async function compile(url: string): Promise<WebAssembly.Module> {
|
|
|
348
454
|
return WebAssembly.compile(bytes);
|
|
349
455
|
}
|
|
350
456
|
|
|
457
|
+
/**
|
|
458
|
+
* The module published as parts at `<url>.parts.json` (`{ size, parts }`),
|
|
459
|
+
* fetched in parallel and joined; null when there is no such manifest.
|
|
460
|
+
*/
|
|
461
|
+
export async function fetchParts(url: string, fetchImpl: typeof fetch = fetch): Promise<Uint8Array<ArrayBuffer> | null> {
|
|
462
|
+
const manifestURL = `${url}.parts.json`;
|
|
463
|
+
let manifest: { size?: unknown; parts?: unknown };
|
|
464
|
+
try {
|
|
465
|
+
const response = await fetchImpl(manifestURL);
|
|
466
|
+
if (!response.ok) return null;
|
|
467
|
+
manifest = (await response.json()) as typeof manifest;
|
|
468
|
+
} catch {
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
const { size, parts } = manifest;
|
|
472
|
+
if (typeof size !== 'number' || !Array.isArray(parts) || !parts.every((part) => typeof part === 'string')) {
|
|
473
|
+
throw new WasmLoadError(`${manifestURL} is not a parts manifest`);
|
|
474
|
+
}
|
|
475
|
+
const base = new URL(manifestURL, typeof location === 'undefined' ? 'http://localhost/' : location.href);
|
|
476
|
+
const chunks = await Promise.all(parts.map(async (part) => {
|
|
477
|
+
const partURL = new URL(part, base).href;
|
|
478
|
+
const response = await fetchImpl(partURL);
|
|
479
|
+
if (!response.ok) throw new WasmLoadError(`Failed to fetch ${partURL}: ${response.status} ${response.statusText}`);
|
|
480
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
481
|
+
}));
|
|
482
|
+
const joined = new Uint8Array(size);
|
|
483
|
+
let offset = 0;
|
|
484
|
+
for (const chunk of chunks) {
|
|
485
|
+
if (offset + chunk.byteLength > size) throw new WasmLoadError(`${manifestURL}: the parts are larger than ${size} bytes`);
|
|
486
|
+
joined.set(chunk, offset);
|
|
487
|
+
offset += chunk.byteLength;
|
|
488
|
+
}
|
|
489
|
+
if (offset !== size) throw new WasmLoadError(`${manifestURL}: the parts hold ${offset} of ${size} bytes`);
|
|
490
|
+
return joined;
|
|
491
|
+
}
|
|
492
|
+
|
|
351
493
|
function checkImports(module: WebAssembly.Module): void {
|
|
352
494
|
const foreign = WebAssembly.Module.imports(module).filter((i) => i.module !== WASI_MODULE);
|
|
353
495
|
if (foreign.length > 0) {
|
|
@@ -362,11 +504,61 @@ function checkImports(module: WebAssembly.Module): void {
|
|
|
362
504
|
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
363
505
|
const encoder = new TextEncoder();
|
|
364
506
|
|
|
507
|
+
/** Copies `bytes` into a buffer from `sb_alloc` and answers where it starts. */
|
|
508
|
+
function writeBytes(exports: SwiftBrowserExports, bytes: Uint8Array): number {
|
|
509
|
+
const ptr = exports.sb_alloc!(bytes.byteLength) >>> 0;
|
|
510
|
+
// `memory.buffer` must be read after `sb_alloc`: allocating may grow
|
|
511
|
+
// linear memory and detach the previous ArrayBuffer.
|
|
512
|
+
const memory = exports.memory.buffer;
|
|
513
|
+
if (ptr + bytes.byteLength > memory.byteLength) {
|
|
514
|
+
throw new WasmLoadError(`sb_alloc(${bytes.byteLength}) returned ${ptr}, outside linear memory (${memory.byteLength} bytes)`);
|
|
515
|
+
}
|
|
516
|
+
new Uint8Array(memory, ptr, bytes.byteLength).set(bytes);
|
|
517
|
+
return ptr;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** The pending ops' `count` byte buffers (`sb_bytes_*`), each copied out of linear memory. */
|
|
521
|
+
function readPendingBytes(exports: SwiftBrowserExports, count: number): Uint8Array[] {
|
|
522
|
+
if (count === 0 || typeof exports.sb_bytes_ptr !== 'function' || typeof exports.sb_bytes_len !== 'function') return [];
|
|
523
|
+
const out: Uint8Array[] = [];
|
|
524
|
+
for (let i = 0; i < count; i++) {
|
|
525
|
+
const ptr = exports.sb_bytes_ptr(i) >>> 0;
|
|
526
|
+
const len = exports.sb_bytes_len(i) >>> 0;
|
|
527
|
+
const memory = exports.memory.buffer;
|
|
528
|
+
if (ptr + len > memory.byteLength) {
|
|
529
|
+
throw new WasmLoadError(`sb_bytes_ptr/len(${i}) (${ptr}, ${len}) point outside linear memory (${memory.byteLength} bytes)`);
|
|
530
|
+
}
|
|
531
|
+
out.push(new Uint8Array(memory, ptr, len).slice());
|
|
532
|
+
}
|
|
533
|
+
return out;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** `value` with each `{"$bytes": n}` replaced by the `n`th buffer. */
|
|
537
|
+
function resolveBytes(value: unknown, buffers: readonly Uint8Array[]): unknown {
|
|
538
|
+
if (Array.isArray(value)) {
|
|
539
|
+
for (let i = 0; i < value.length; i++) value[i] = resolveBytes(value[i], buffers);
|
|
540
|
+
return value;
|
|
541
|
+
}
|
|
542
|
+
if (typeof value !== 'object' || value === null) return value;
|
|
543
|
+
const record = value as Record<string, unknown>;
|
|
544
|
+
const n = record['$bytes'];
|
|
545
|
+
if (typeof n === 'number' && Object.keys(record).length === 1) {
|
|
546
|
+
const buffer = buffers[n];
|
|
547
|
+
if (!buffer) throw new WasmLoadError(`Pending ops name bytes ${n}, but there are ${buffers.length}`);
|
|
548
|
+
return buffer;
|
|
549
|
+
}
|
|
550
|
+
for (const key in record) record[key] = resolveBytes(record[key], buffers);
|
|
551
|
+
return record;
|
|
552
|
+
}
|
|
553
|
+
|
|
365
554
|
/**
|
|
366
555
|
* Read the pending ops buffer. `memory.buffer` is re-read on every call: the
|
|
367
556
|
* ArrayBuffer is detached whenever linear memory grows.
|
|
368
557
|
*/
|
|
369
558
|
function readPendingOps(exports: SwiftBrowserExports): Op[] {
|
|
559
|
+
// Asked first: it tells Swift this page reads bytes beside the JSON, so
|
|
560
|
+
// the ops it serializes next name them (docs/ops-protocol.md, "Bytes").
|
|
561
|
+
const byteCount = typeof exports.sb_bytes_count === 'function' && typeof exports.sb_bytes_ptr === 'function' && typeof exports.sb_bytes_len === 'function' ? exports.sb_bytes_count() >>> 0 : 0;
|
|
370
562
|
const ptr = exports.sb_ops_ptr() >>> 0;
|
|
371
563
|
const len = exports.sb_ops_len() >>> 0;
|
|
372
564
|
if (len === 0) return [];
|
|
@@ -396,5 +588,8 @@ function readPendingOps(exports: SwiftBrowserExports): Op[] {
|
|
|
396
588
|
if (bad !== undefined) {
|
|
397
589
|
throw new WasmLoadError(`Unknown op in pending buffer: ${JSON.stringify(bad)}`);
|
|
398
590
|
}
|
|
591
|
+
// Image files and other bytes come beside the JSON (docs/ops-protocol.md, "Bytes").
|
|
592
|
+
const buffers = readPendingBytes(exports, byteCount);
|
|
593
|
+
if (buffers.length > 0) resolveBytes(parsed, buffers);
|
|
399
594
|
return parsed as Op[];
|
|
400
595
|
}
|
package/src/webAuth.ts
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signing in (docs/ops-protocol.md, "Signing in"): the page side of
|
|
3
|
+
* AuthenticationServices' `ASWebAuthenticationSession` and SwiftUI's
|
|
4
|
+
* `WebAuthenticationSession` (Sources/AuthenticationServices). The app sends a
|
|
5
|
+
* `webAuth` request with the provider's authorize URL; the page opens it in a
|
|
6
|
+
* popup window and answers with the URL the window ends at.
|
|
7
|
+
*
|
|
8
|
+
* An app's OAuth redirect is a URL in its own scheme (`icecubesapp://`), which
|
|
9
|
+
* a page cannot receive. The shim swaps it for the page's callback page,
|
|
10
|
+
* `/app/oauth-callback.html?sb_scheme=icecubesapp` (FoundationBridge's
|
|
11
|
+
* WebAuthRedirects.swift), in the authorize URL and in the app's own requests
|
|
12
|
+
* to the provider, so the provider redirects the popup there. That page
|
|
13
|
+
* (public/app/oauth-callback.html) posts its URL to this page, by
|
|
14
|
+
* `postMessage` to its opener and on a `BroadcastChannel` (which still works
|
|
15
|
+
* when a provider's `Cross-Origin-Opener-Policy` cut the popup off from its
|
|
16
|
+
* opener), and closes. The app rebuilds `icecubesapp://?code=…` from it.
|
|
17
|
+
*
|
|
18
|
+
* Browsers only open a popup shortly after a tap, and an app usually registers
|
|
19
|
+
* with the provider first (a network round trip). When the popup is blocked,
|
|
20
|
+
* the page asks the viewer to continue and opens it from that click. Closing
|
|
21
|
+
* the popup, or dismissing that prompt, fails the request with `cancelled: …`,
|
|
22
|
+
* which the app sees as `ASWebAuthenticationSessionError.canceledLogin`.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { WebAuthRequestParams } from './protocol';
|
|
26
|
+
|
|
27
|
+
/** Where the callback page is served, relative to the site's base. */
|
|
28
|
+
export const CALLBACK_PATH = 'app/oauth-callback.html';
|
|
29
|
+
/** The `type` of the callback page's message. */
|
|
30
|
+
export const CALLBACK_MESSAGE = 'sb-web-auth-callback';
|
|
31
|
+
/** The `BroadcastChannel` the callback page also posts on. */
|
|
32
|
+
export const CALLBACK_CHANNEL = 'sb-web-auth';
|
|
33
|
+
|
|
34
|
+
/** The value of a `webAuth` `start`: the URL the sign-in window ended at. */
|
|
35
|
+
export interface WebAuthResult {
|
|
36
|
+
url: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The parts of a `Window` the session uses (tests substitute them). */
|
|
40
|
+
export interface PopupLike {
|
|
41
|
+
readonly closed: boolean;
|
|
42
|
+
close(): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The browser facilities a sign-in uses; tests substitute them. */
|
|
46
|
+
export interface WebAuthHost {
|
|
47
|
+
/** Opens `url` in a popup window; null when the browser blocked it. */
|
|
48
|
+
open(url: string): PopupLike | null;
|
|
49
|
+
/** Calls `handler` with each URL the callback page reports; returns a function that stops listening. */
|
|
50
|
+
onCallback(handler: (url: string) => void): () => void;
|
|
51
|
+
/**
|
|
52
|
+
* After a blocked popup: asks the viewer to continue, and opens `url` from
|
|
53
|
+
* their click. Resolves with the popup, or null when they cancel.
|
|
54
|
+
*/
|
|
55
|
+
askToOpen?(url: string): Promise<PopupLike | null>;
|
|
56
|
+
/** How often to check whether the viewer closed the popup, in ms (default 500). */
|
|
57
|
+
pollInterval?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The callback page's URL for a page whose base URL (`import.meta.env.BASE_URL` resolved against the location) is `base`. */
|
|
61
|
+
export function callbackPageURL(base: string): string {
|
|
62
|
+
return new URL(CALLBACK_PATH, base).href;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The URL in a callback page's message, or null when `data` is not one. */
|
|
66
|
+
export function callbackURLOf(data: unknown): string | null {
|
|
67
|
+
if (typeof data !== 'object' || data === null) return null;
|
|
68
|
+
const message = data as { type?: unknown; url?: unknown };
|
|
69
|
+
return message.type === CALLBACK_MESSAGE && typeof message.url === 'string' ? message.url : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface PendingSignIn {
|
|
73
|
+
popup: PopupLike | null;
|
|
74
|
+
settle: (outcome: { url: string } | { error: string }) => void;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The page's sign-in sessions: one at a time, as on iOS. A new `start`
|
|
79
|
+
* cancels the one before it.
|
|
80
|
+
*/
|
|
81
|
+
export class WebAuthSessions {
|
|
82
|
+
private pending: PendingSignIn | null = null;
|
|
83
|
+
|
|
84
|
+
constructor(private readonly host: WebAuthHost) {}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Performs one `webAuth` request. `start` must be called within the task
|
|
88
|
+
* that received the request: it opens the popup before its first `await`.
|
|
89
|
+
*/
|
|
90
|
+
perform(params: WebAuthRequestParams): Promise<WebAuthResult | Record<string, never>> {
|
|
91
|
+
if (params.action === 'cancel') {
|
|
92
|
+
this.cancel('cancelled: the app cancelled the sign-in');
|
|
93
|
+
return Promise.resolve({});
|
|
94
|
+
}
|
|
95
|
+
if (!params.url || !/^https?:/i.test(params.url)) {
|
|
96
|
+
return Promise.reject(new Error(`unsupported: cannot sign in at ${params.url ?? '(no URL)'}`));
|
|
97
|
+
}
|
|
98
|
+
return this.start(params.url);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Ends the pending sign-in, if any, with `message`, and closes its window. */
|
|
102
|
+
cancel(message: string): void {
|
|
103
|
+
this.pending?.settle({ error: message });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private start(url: string): Promise<WebAuthResult> {
|
|
107
|
+
this.cancel('cancelled: another sign-in started');
|
|
108
|
+
return new Promise<WebAuthResult>((resolve, reject) => {
|
|
109
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
110
|
+
let closedSeen = false;
|
|
111
|
+
const pending: PendingSignIn = { popup: null, settle: () => {} };
|
|
112
|
+
const stopListening = this.host.onCallback((callback) => pending.settle({ url: callback }));
|
|
113
|
+
pending.settle = (outcome) => {
|
|
114
|
+
if (this.pending !== pending) return;
|
|
115
|
+
this.pending = null;
|
|
116
|
+
stopListening();
|
|
117
|
+
if (timer !== null) clearInterval(timer);
|
|
118
|
+
try {
|
|
119
|
+
if (pending.popup && !pending.popup.closed) pending.popup.close();
|
|
120
|
+
} catch {
|
|
121
|
+
// A window another origin owns may refuse; it is the viewer's then.
|
|
122
|
+
}
|
|
123
|
+
if ('url' in outcome) resolve({ url: outcome.url });
|
|
124
|
+
else reject(new Error(outcome.error));
|
|
125
|
+
};
|
|
126
|
+
this.pending = pending;
|
|
127
|
+
|
|
128
|
+
const watch = (popup: PopupLike): void => {
|
|
129
|
+
pending.popup = popup;
|
|
130
|
+
timer = setInterval(() => {
|
|
131
|
+
if (!popup.closed) return;
|
|
132
|
+
// The callback page posts, then closes itself: give its message one
|
|
133
|
+
// more tick to arrive before calling the sign-in cancelled.
|
|
134
|
+
if (!closedSeen) {
|
|
135
|
+
closedSeen = true;
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
pending.settle({ error: 'cancelled: the viewer closed the sign-in window' });
|
|
139
|
+
}, this.host.pollInterval ?? 500);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// Opened before any await, so a tap's activation still counts.
|
|
143
|
+
const popup = this.host.open(url);
|
|
144
|
+
if (popup) {
|
|
145
|
+
watch(popup);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (!this.host.askToOpen) {
|
|
149
|
+
pending.settle({ error: 'blocked: the browser blocked the sign-in window' });
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
this.host.askToOpen(url).then(
|
|
153
|
+
(opened) => {
|
|
154
|
+
if (this.pending !== pending) {
|
|
155
|
+
opened?.close();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (opened) watch(opened);
|
|
159
|
+
else pending.settle({ error: 'cancelled: the viewer did not continue to the sign-in window' });
|
|
160
|
+
},
|
|
161
|
+
(error: unknown) => pending.settle({ error: `blocked: ${error instanceof Error ? error.message : String(error)}` }),
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// The browser
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
/** A popup centered on the page, the size of a phone's sign-in sheet. */
|
|
172
|
+
function popupFeatures(): string {
|
|
173
|
+
const width = 480;
|
|
174
|
+
const height = 720;
|
|
175
|
+
const left = Math.max(0, Math.round(window.screenX + (window.outerWidth - width) / 2));
|
|
176
|
+
const top = Math.max(0, Math.round(window.screenY + (window.outerHeight - height) / 2));
|
|
177
|
+
return `popup,width=${width},height=${height},left=${left},top=${top}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The prompt shown when the browser blocked the popup: the provider's host and
|
|
182
|
+
* two buttons. Continue opens the popup from the viewer's click.
|
|
183
|
+
*/
|
|
184
|
+
function promptToOpen(url: string, open: (url: string) => PopupLike | null): Promise<PopupLike | null> {
|
|
185
|
+
let host = url;
|
|
186
|
+
try {
|
|
187
|
+
host = new URL(url).host;
|
|
188
|
+
} catch {
|
|
189
|
+
// Shown as is.
|
|
190
|
+
}
|
|
191
|
+
const overlay = document.createElement('div');
|
|
192
|
+
overlay.className = 'sb-web-auth-prompt';
|
|
193
|
+
overlay.setAttribute('role', 'dialog');
|
|
194
|
+
overlay.setAttribute('aria-modal', 'true');
|
|
195
|
+
overlay.setAttribute('aria-label', 'Sign in');
|
|
196
|
+
overlay.style.cssText =
|
|
197
|
+
'position:fixed;inset:0;z-index:2147483647;display:flex;align-items:center;justify-content:center;' +
|
|
198
|
+
'background:rgba(0,0,0,0.4);font:15px/1.4 -apple-system,system-ui,sans-serif;';
|
|
199
|
+
const card = document.createElement('div');
|
|
200
|
+
card.style.cssText =
|
|
201
|
+
'max-width:300px;margin:16px;padding:20px;border-radius:14px;background:Canvas;color:CanvasText;' +
|
|
202
|
+
'box-shadow:0 10px 40px rgba(0,0,0,0.3);text-align:center;';
|
|
203
|
+
const title = document.createElement('p');
|
|
204
|
+
title.style.cssText = 'margin:0 0 6px;font-weight:600;font-size:17px;';
|
|
205
|
+
title.textContent = `Sign in to ${host}`;
|
|
206
|
+
const body = document.createElement('p');
|
|
207
|
+
body.style.cssText = 'margin:0 0 16px;opacity:0.7;';
|
|
208
|
+
body.textContent = 'The sign-in page opens in a new window.';
|
|
209
|
+
const buttons = document.createElement('div');
|
|
210
|
+
buttons.style.cssText = 'display:flex;gap:8px;justify-content:center;';
|
|
211
|
+
const button = (label: string, action: string, primary: boolean): HTMLButtonElement => {
|
|
212
|
+
const element = document.createElement('button');
|
|
213
|
+
element.type = 'button';
|
|
214
|
+
element.textContent = label;
|
|
215
|
+
element.dataset['action'] = action;
|
|
216
|
+
element.style.cssText =
|
|
217
|
+
'flex:1;padding:10px 14px;border-radius:10px;border:0;font:inherit;cursor:pointer;' +
|
|
218
|
+
(primary ? 'background:#007aff;color:#fff;font-weight:600;' : 'background:rgba(120,120,128,0.2);color:inherit;');
|
|
219
|
+
return element;
|
|
220
|
+
};
|
|
221
|
+
const cancel = button('Cancel', 'cancel', false);
|
|
222
|
+
const proceed = button('Continue', 'continue', true);
|
|
223
|
+
buttons.append(cancel, proceed);
|
|
224
|
+
card.append(title, body, buttons);
|
|
225
|
+
overlay.append(card);
|
|
226
|
+
document.body.append(overlay);
|
|
227
|
+
proceed.focus();
|
|
228
|
+
return new Promise((resolve) => {
|
|
229
|
+
const finish = (popup: PopupLike | null): void => {
|
|
230
|
+
overlay.remove();
|
|
231
|
+
resolve(popup);
|
|
232
|
+
};
|
|
233
|
+
proceed.addEventListener('click', () => finish(open(url)));
|
|
234
|
+
cancel.addEventListener('click', () => finish(null));
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The real browser: `window.open`, the callback page's messages, and the prompt. */
|
|
239
|
+
export function browserWebAuthHost(): WebAuthHost | undefined {
|
|
240
|
+
if (typeof window === 'undefined' || typeof window.open !== 'function') return undefined;
|
|
241
|
+
const open = (url: string): PopupLike | null => window.open(url, 'sb-web-auth', popupFeatures());
|
|
242
|
+
const host: WebAuthHost = {
|
|
243
|
+
open,
|
|
244
|
+
onCallback(handler) {
|
|
245
|
+
const onMessage = (event: MessageEvent): void => {
|
|
246
|
+
if (event.origin !== location.origin) return;
|
|
247
|
+
const url = callbackURLOf(event.data);
|
|
248
|
+
if (url) handler(url);
|
|
249
|
+
};
|
|
250
|
+
window.addEventListener('message', onMessage);
|
|
251
|
+
let channel: BroadcastChannel | null = null;
|
|
252
|
+
if (typeof BroadcastChannel === 'function') {
|
|
253
|
+
channel = new BroadcastChannel(CALLBACK_CHANNEL);
|
|
254
|
+
channel.onmessage = (event: MessageEvent) => {
|
|
255
|
+
const url = callbackURLOf(event.data);
|
|
256
|
+
if (url) handler(url);
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
return () => {
|
|
260
|
+
window.removeEventListener('message', onMessage);
|
|
261
|
+
channel?.close();
|
|
262
|
+
};
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
if (typeof document !== 'undefined') host.askToOpen = (url) => promptToOpen(url, open);
|
|
266
|
+
return host;
|
|
267
|
+
}
|