@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.
Files changed (98) hide show
  1. package/README.md +43 -6
  2. package/app/index.html +11 -0
  3. package/node/dist/node/apps.d.ts +16 -0
  4. package/node/dist/node/apps.d.ts.map +1 -1
  5. package/node/dist/node/apps.js +9 -0
  6. package/node/dist/node/apps.js.map +1 -1
  7. package/node/dist/node/config.d.ts +14 -1
  8. package/node/dist/node/config.d.ts.map +1 -1
  9. package/node/dist/node/config.js +20 -3
  10. package/node/dist/node/config.js.map +1 -1
  11. package/node/dist/node/examples.d.ts +2 -1
  12. package/node/dist/node/examples.d.ts.map +1 -1
  13. package/node/dist/node/examples.js +11 -2
  14. package/node/dist/node/examples.js.map +1 -1
  15. package/node/dist/plugins/app-manifests.d.ts +4 -1
  16. package/node/dist/plugins/app-manifests.d.ts.map +1 -1
  17. package/node/dist/plugins/app-manifests.js +3 -2
  18. package/node/dist/plugins/app-manifests.js.map +1 -1
  19. package/node/dist/plugins/asset-catalogs.d.ts +15 -3
  20. package/node/dist/plugins/asset-catalogs.d.ts.map +1 -1
  21. package/node/dist/plugins/asset-catalogs.js +111 -13
  22. package/node/dist/plugins/asset-catalogs.js.map +1 -1
  23. package/node/dist/plugins/included-sites.d.ts +26 -0
  24. package/node/dist/plugins/included-sites.d.ts.map +1 -0
  25. package/node/dist/plugins/included-sites.js +70 -0
  26. package/node/dist/plugins/included-sites.js.map +1 -0
  27. package/node/dist/plugins/pdfImages.d.ts +27 -0
  28. package/node/dist/plugins/pdfImages.d.ts.map +1 -0
  29. package/node/dist/plugins/pdfImages.js +145 -0
  30. package/node/dist/plugins/pdfImages.js.map +1 -0
  31. package/node/dist/plugins/swift-wasm.d.ts +19 -1
  32. package/node/dist/plugins/swift-wasm.d.ts.map +1 -1
  33. package/node/dist/plugins/swift-wasm.js +45 -7
  34. package/node/dist/plugins/swift-wasm.js.map +1 -1
  35. package/node/dist/plugins/symbolTemplates.d.ts +22 -0
  36. package/node/dist/plugins/symbolTemplates.d.ts.map +1 -0
  37. package/node/dist/plugins/symbolTemplates.js +83 -0
  38. package/node/dist/plugins/symbolTemplates.js.map +1 -0
  39. package/package.json +4 -2
  40. package/plugins/app-manifests.ts +3 -2
  41. package/plugins/asset-catalogs.ts +119 -14
  42. package/plugins/included-sites.ts +74 -0
  43. package/plugins/pdfImages.ts +163 -0
  44. package/plugins/swift-wasm.ts +52 -6
  45. package/plugins/symbolTemplates.ts +88 -0
  46. package/public/_headers +7 -0
  47. package/public/app/oauth-callback.html +62 -0
  48. package/src/animation.ts +235 -8
  49. package/src/appFiles.ts +201 -0
  50. package/src/base64.ts +43 -0
  51. package/src/bitmapPlan.ts +143 -0
  52. package/src/bitmapStore.ts +206 -0
  53. package/src/bitmapWorker.ts +41 -0
  54. package/src/bitmapWorkerProtocol.ts +15 -0
  55. package/src/bitmaps.ts +283 -0
  56. package/src/blockedHosts.ts +29 -0
  57. package/src/canvasDrawing.ts +361 -0
  58. package/src/chartDom.ts +2 -2
  59. package/src/chromeColor.ts +179 -0
  60. package/src/clipboard.ts +78 -0
  61. package/src/colorMultiply.ts +93 -0
  62. package/src/colorPicker.ts +44 -0
  63. package/src/corsProxy.ts +45 -0
  64. package/src/gaugeRing.ts +74 -0
  65. package/src/gif.ts +297 -0
  66. package/src/hostRequests.ts +426 -0
  67. package/src/imageOps.ts +592 -0
  68. package/src/interaction.ts +18 -1
  69. package/src/landing.ts +2 -2
  70. package/src/layout/chart.ts +9 -3
  71. package/src/layout/engine.ts +279 -25
  72. package/src/layout/index.ts +3 -1
  73. package/src/layout/text.ts +217 -1
  74. package/src/layout/types.ts +31 -0
  75. package/src/layout/typography.ts +24 -7
  76. package/src/layout/video.ts +24 -0
  77. package/src/layoutDom.ts +53 -3
  78. package/src/main.ts +124 -4
  79. package/src/media.ts +132 -0
  80. package/src/mock.ts +2 -1
  81. package/src/notifications.ts +109 -0
  82. package/src/pathShape.ts +156 -0
  83. package/src/protocol.ts +761 -12
  84. package/src/renderer.ts +584 -36
  85. package/src/richText.ts +65 -0
  86. package/src/scanner.ts +234 -0
  87. package/src/scrollTo.ts +123 -0
  88. package/src/styles.css +212 -7
  89. package/src/styles.ts +31 -0
  90. package/src/swiftData.ts +128 -0
  91. package/src/symbols.ts +564 -21
  92. package/src/translation.ts +126 -0
  93. package/src/video.ts +299 -0
  94. package/src/vite-env.d.ts +6 -0
  95. package/src/wasm.ts +227 -32
  96. package/src/webAuth.ts +267 -0
  97. package/src/webSocket.ts +148 -0
  98. 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 { isOp, type ElementId, type Op, type RendererEvent } from './protocol';
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
- const wasi = new WASI(options.args ?? ['Counter'], options.env ?? [], fds, { debug: options.debug ?? false });
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(event: RendererEvent): boolean {
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
- async function compile(url: string): Promise<WebAssembly.Module> {
332
- const response = await fetch(url);
333
- if (!response.ok) {
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
- return WebAssembly.compileStreaming(response);
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
+ }