@swiftbrowser/web 0.1.0 → 0.3.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 (99) hide show
  1. package/README.md +141 -63
  2. package/app/index.html +21 -10
  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 +12 -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 +68 -3
  69. package/src/landing.ts +2 -2
  70. package/src/largeTitle.ts +79 -0
  71. package/src/layout/chart.ts +9 -3
  72. package/src/layout/engine.ts +681 -174
  73. package/src/layout/index.ts +3 -1
  74. package/src/layout/text.ts +217 -1
  75. package/src/layout/types.ts +31 -0
  76. package/src/layout/typography.ts +24 -7
  77. package/src/layout/video.ts +24 -0
  78. package/src/layoutDom.ts +66 -6
  79. package/src/main.ts +127 -4
  80. package/src/media.ts +132 -0
  81. package/src/mock.ts +4 -3
  82. package/src/notifications.ts +109 -0
  83. package/src/pathShape.ts +156 -0
  84. package/src/protocol.ts +782 -13
  85. package/src/renderer.ts +603 -44
  86. package/src/richText.ts +65 -0
  87. package/src/scanner.ts +234 -0
  88. package/src/scrollTo.ts +123 -0
  89. package/src/styles.css +521 -100
  90. package/src/styles.ts +61 -0
  91. package/src/swiftData.ts +128 -0
  92. package/src/symbols.ts +658 -28
  93. package/src/translation.ts +126 -0
  94. package/src/video.ts +299 -0
  95. package/src/vite-env.d.ts +6 -0
  96. package/src/wasm.ts +227 -32
  97. package/src/webAuth.ts +267 -0
  98. package/src/webSocket.ts +148 -0
  99. package/src/zoneinfo.ts +269 -0
@@ -0,0 +1,62 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta name="color-scheme" content="light dark" />
7
+ <meta name="referrer" content="no-referrer" />
8
+ <title>Signing in…</title>
9
+ <style>
10
+ body {
11
+ margin: 0;
12
+ min-height: 100vh;
13
+ display: flex;
14
+ align-items: center;
15
+ justify-content: center;
16
+ font: 17px/1.4 -apple-system, system-ui, sans-serif;
17
+ background: Canvas;
18
+ color: CanvasText;
19
+ }
20
+ p {
21
+ margin: 16px;
22
+ text-align: center;
23
+ opacity: 0.7;
24
+ }
25
+ </style>
26
+ </head>
27
+ <body>
28
+ <p id="status">Signing in…</p>
29
+ <script>
30
+ // The end of a sign-in (docs/ops-protocol.md, "Signing in"). The app's
31
+ // OAuth redirect (`icecubesapp://`) was swapped for this page, so the
32
+ // provider sent the sign-in window here, with `code` (or `error`) in the
33
+ // query. Hand the URL to the app's page (src/webAuth.ts) and close: by
34
+ // postMessage to the opener, and on a BroadcastChannel for when a
35
+ // provider's Cross-Origin-Opener-Policy cut the window off from it.
36
+ (function () {
37
+ var message = { type: 'sb-web-auth-callback', url: location.href };
38
+ var delivered = false;
39
+ try {
40
+ if (window.opener && !window.opener.closed) {
41
+ window.opener.postMessage(message, location.origin);
42
+ delivered = true;
43
+ }
44
+ } catch (error) {}
45
+ try {
46
+ var channel = new BroadcastChannel('sb-web-auth');
47
+ channel.postMessage(message);
48
+ channel.close();
49
+ delivered = true;
50
+ } catch (error) {}
51
+ // The code is single-use and the app has it now; keep it out of history.
52
+ try {
53
+ history.replaceState(null, '', location.pathname);
54
+ } catch (error) {}
55
+ document.getElementById('status').textContent = delivered
56
+ ? 'You can close this window.'
57
+ : 'Return to the app to finish signing in.';
58
+ if (delivered) setTimeout(function () { window.close(); }, 50);
59
+ })();
60
+ </script>
61
+ </body>
62
+ </html>
package/src/animation.ts CHANGED
@@ -15,6 +15,14 @@
15
15
  * - removed elements leave a visual clone (stripped of element ids) at their
16
16
  * old position which plays the `transition` backwards and is then dropped.
17
17
  *
18
+ * A frame tween is retargeted when a later layout pass moves the element
19
+ * again before it finished: the running tween is replaced by one from where
20
+ * the element is drawn now to its new frame, in the new commit's animation or,
21
+ * for a pass that is not animated (a custom `Layout` or `GeometryReader`
22
+ * answering the animated pass), in the time the old tween had left. Without
23
+ * that the old tween, which overrides the inline frame, kept drawing the
24
+ * element toward the stale frame until it ended and then snapped.
25
+ *
18
26
  * `Animation` maps to CSS easings; `spring` becomes a `linear()` easing
19
27
  * sampled from a damped spring where supported.
20
28
  */
@@ -165,6 +173,21 @@ export function easingFor(animation: Animation | undefined): string {
165
173
  * by which time the spring has settled to within ~1%.
166
174
  */
167
175
  export function springEasing(duration: number, bounce: number, samples = SPRING_SAMPLES): string {
176
+ // An animated pass asks for the same few curves once per tweened element.
177
+ const key = `${duration} ${bounce} ${samples}`;
178
+ let easing = springEasings.get(key);
179
+ if (easing === undefined) {
180
+ if (springEasings.size >= MAX_SPRING_EASINGS) springEasings.clear();
181
+ easing = sampleSpring(duration, bounce, samples);
182
+ springEasings.set(key, easing);
183
+ }
184
+ return easing;
185
+ }
186
+
187
+ const springEasings = new Map<string, string>();
188
+ const MAX_SPRING_EASINGS = 64;
189
+
190
+ function sampleSpring(duration: number, bounce: number, samples: number): string {
168
191
  const d = duration > 0 ? duration : 0.5;
169
192
  const zeta = Math.min(1, Math.max(0, 1 - bounce));
170
193
  const omega = (2 * Math.PI) / d;
@@ -178,6 +201,49 @@ export function springEasing(duration: number, bounce: number, samples = SPRING_
178
201
  return `linear(${points.join(', ')})`;
179
202
  }
180
203
 
204
+ /**
205
+ * The eased progress of `animation` `elapsed` milliseconds after it began
206
+ * (its delay included): 0 before it starts, its curve's value while it runs
207
+ * (a spring that bounces passes 1), 1 after. For what CSS cannot tween, such
208
+ * as a custom shape's path (src/pathShape.ts), in the curve its CSS
209
+ * counterpart (`easingFor`) uses. Repeats are not applied.
210
+ */
211
+ export function easedProgress(animation: Animation | undefined, elapsed: number): number {
212
+ const timing = timingFor(animation);
213
+ if (elapsed <= timing.delay) return 0;
214
+ if (timing.duration <= 0 || elapsed >= timing.delay + timing.duration) return 1;
215
+ const t = (elapsed - timing.delay) / timing.duration;
216
+ switch (animation?.type) {
217
+ case 'linear':
218
+ return t;
219
+ case 'easeIn':
220
+ return cubicBezier(0.42, 0, 1, 1, t);
221
+ case 'easeOut':
222
+ return cubicBezier(0, 0, 0.58, 1, t);
223
+ case 'spring': {
224
+ const d = animation.duration ?? 0.5;
225
+ const zeta = Math.min(1, Math.max(0, 1 - (animation.bounce ?? 0)));
226
+ return springPosition(t * (d > 0 ? d : 0.5), (2 * Math.PI) / (d > 0 ? d : 0.5), zeta);
227
+ }
228
+ default:
229
+ return cubicBezier(0.42, 0, 0.58, 1, t);
230
+ }
231
+ }
232
+
233
+ /** The y of the CSS cubic Bézier through (0,0), (x1,y1), (x2,y2), (1,1) at x = `t`. */
234
+ function cubicBezier(x1: number, y1: number, x2: number, y2: number, t: number): number {
235
+ const at = (a: number, b: number, s: number) => 3 * a * s * (1 - s) ** 2 + 3 * b * s * s * (1 - s) + s ** 3;
236
+ // Bisection on x (monotonic for CSS's curves).
237
+ let lo = 0;
238
+ let hi = 1;
239
+ for (let i = 0; i < 30; i++) {
240
+ const mid = (lo + hi) / 2;
241
+ if (at(x1, x2, mid) < t) lo = mid;
242
+ else hi = mid;
243
+ }
244
+ return at(y1, y2, (lo + hi) / 2);
245
+ }
246
+
181
247
  function springPosition(t: number, omega: number, zeta: number): number {
182
248
  if (zeta >= 1) {
183
249
  // Critically damped.
@@ -282,16 +348,24 @@ export const EXIT_CLONE_CLASS = 'sb-exit-clone';
282
348
  export interface AnimatorOptions {
283
349
  /** Kinds (by `data-sb-kind`) that animate their own enter/exit (navigation, sheets). */
284
350
  selfAnimatingKinds?: ReadonlySet<string>;
351
+ /**
352
+ * Plays what an updated element animates itself (a custom shape's path
353
+ * frames, src/pathShape.ts) in the animation its update has; returns
354
+ * whether it had anything to play.
355
+ */
356
+ playOwnFrames?: (el: HTMLElement, animation: Animation | undefined) => boolean;
285
357
  }
286
358
 
287
359
  export class Animator {
288
360
  /** The element clones of removed elements are appended to (the screen). */
289
361
  private readonly root: HTMLElement;
290
362
  private readonly selfAnimating: ReadonlySet<string>;
363
+ private readonly playOwnFrames: AnimatorOptions['playOwnFrames'];
291
364
 
292
365
  constructor(root: HTMLElement, options: AnimatorOptions = {}) {
293
366
  this.root = root;
294
367
  this.selfAnimating = options.selfAnimatingKinds ?? new Set();
368
+ this.playOwnFrames = options.playOwnFrames;
295
369
  }
296
370
 
297
371
  /** Computed values of the animated properties, or null when not rendered. */
@@ -304,12 +378,22 @@ export class Animator {
304
378
  return snap;
305
379
  }
306
380
 
307
- /** Rectangle of `el` relative to the animation root. */
308
- rectOf(el: HTMLElement): DOMRect | null {
309
- if (!el.isConnected) return null;
381
+ /** The animation root's rectangle in the viewport. */
382
+ rootRect(): DOMRect {
383
+ return this.root.getBoundingClientRect();
384
+ }
385
+
386
+ /**
387
+ * Rectangle of `el` relative to the animation root (`base`: its rectangle,
388
+ * if already read), or null when it is not drawn. A removed element's exit
389
+ * clone is appended to the root, outside whatever hid the element: a
390
+ * screen below the top one (`visibility: hidden`) or an ancestor at
391
+ * opacity 0 must not leave a clone over the visible screen.
392
+ */
393
+ rectOf(el: HTMLElement, base: DOMRect = this.rootRect()): DOMRect | null {
394
+ if (!el.isConnected || !isDrawn(el)) return null;
310
395
  const r = el.getBoundingClientRect();
311
396
  if (r.width === 0 && r.height === 0) return null;
312
- const base = this.root.getBoundingClientRect();
313
397
  return new DOMRect(r.left - base.left, r.top - base.top, r.width, r.height);
314
398
  }
315
399
 
@@ -323,7 +407,12 @@ export class Animator {
323
407
  for (const [el, rec] of pass.updated) {
324
408
  if (rec.tokenChanged) tokenRoots.set(el, rec.animation);
325
409
  }
326
- if (!commitAnimation && tokenRoots.size === 0) return;
410
+ if (!commitAnimation && tokenRoots.size === 0) {
411
+ // Not animated: frames still tweening toward a box this pass replaced
412
+ // carry on to the new box in the time they had left.
413
+ for (const change of frames) this.retargetFrame(change, null);
414
+ return;
415
+ }
327
416
 
328
417
  const animationFor = (chain: Iterable<HTMLElement>): Animation | undefined | false => {
329
418
  for (const el of chain) {
@@ -340,15 +429,50 @@ export class Animator {
340
429
  this.animateChanges(el, rec.before, animation);
341
430
  }
342
431
 
432
+ // What elements animate themselves (a custom shape's path).
433
+ if (this.playOwnFrames) {
434
+ for (const el of pass.updated.keys()) {
435
+ if (!el.isConnected) continue;
436
+ const animation = animationFor(ancestorsAndSelf(el));
437
+ if (animation !== false) this.playOwnFrames(el, animation);
438
+ }
439
+ }
440
+
343
441
  // Frame changes: tween left/top/width/height from the previous box. The
344
442
  // inline values are relative to the parent's origin, so apply the delta.
443
+ const tweens: { change: FrameChange; from: FrameRect; animation: Animation | undefined; moved: boolean }[] = [];
444
+ const jumped = new Map<HTMLElement, { x: number; y: number }>();
345
445
  for (const change of frames) {
346
446
  const { el, from, to } = change;
347
447
  if (!from || !el.isConnected || pass.inserted.has(el)) continue;
348
448
  if (this.selfAnimating.has(change.kind)) continue;
349
449
  const animation = animationFor(ancestorsAndSelf(el));
350
- if (animation === false) continue;
351
- this.animateFrame(el, from, to, animation);
450
+ if (animation === false) {
451
+ // Moved in place, unless it carries on with a tween of its own.
452
+ if (!this.retargetFrame(change, null)) jumped.set(el, { x: to.x - from.x, y: to.y - from.y });
453
+ continue;
454
+ }
455
+ tweens.push({ change, from, animation, moved: true });
456
+ }
457
+ // The root of an `.animation(_:value:)` subtree whose own box did not
458
+ // change still moves with an ancestor that jumped.
459
+ const changed = new Set(frames.map((change) => change.el));
460
+ for (const [el, animation] of tokenRoots) {
461
+ if (changed.has(el) || !el.isConnected || pass.inserted.has(el)) continue;
462
+ const box = { x: parseFloat(el.style.left) || 0, y: parseFloat(el.style.top) || 0, width: el.offsetWidth, height: el.offsetHeight };
463
+ tweens.push({ change: { el, kind: el.dataset['sbKind'] ?? '', from: box, to: box }, from: box, animation: animation ?? { type: 'default' }, moved: false });
464
+ }
465
+ // An animated element under an ancestor that jumps (outside the animated
466
+ // subtree) starts from where it was drawn, not from its old place in the
467
+ // moved ancestor: SwiftUI animates its position on screen, so a shape
468
+ // that grows in a stack that re-centers grows about its center.
469
+ const tweened = new Set(tweens.map((tween) => tween.change.el));
470
+ for (const { change, from, animation, moved } of tweens) {
471
+ const shift = jumpAbove(change.el, jumped, tweened, this.root);
472
+ if (!moved && !shift) continue;
473
+ const start = shift ? { ...from, x: from.x - shift.x, y: from.y - shift.y } : from;
474
+ if (this.retargetFrame({ ...change, from: start }, animation, shift)) continue;
475
+ this.animateFrame(change.el, start, change.to, animation);
352
476
  }
353
477
 
354
478
  // Inserted elements: only subtree roots (a nested fade would compound).
@@ -419,7 +543,76 @@ export class Animator {
419
543
  end['height'] = `${to.height}px`;
420
544
  }
421
545
  const timing = timingFor(animation);
422
- el.animate([start, end], { ...timing, fill: 'none' });
546
+ this.trackFrame(el, el.animate([start, end], { ...timing, fill: 'none' }));
547
+ }
548
+
549
+ /** The frame tween running on each element (see `retargetFrame`). */
550
+ private readonly frameTweens = new WeakMap<HTMLElement, globalThis.Animation>();
551
+
552
+ private trackFrame(el: HTMLElement, tween: globalThis.Animation): void {
553
+ this.frameTweens.set(el, tween);
554
+ tween.addEventListener('finish', () => {
555
+ if (this.frameTweens.get(el) === tween) this.frameTweens.delete(el);
556
+ });
557
+ tween.addEventListener('cancel', () => {
558
+ if (this.frameTweens.get(el) === tween) this.frameTweens.delete(el);
559
+ });
560
+ }
561
+
562
+ /**
563
+ * When `change.el` is still tweening toward the box this pass replaced,
564
+ * replace that tween with one from where the element is drawn now to its
565
+ * new inline frame: in `animation`'s timing when the pass animates the
566
+ * element, else (`null`) in the time the old tween had left, with its
567
+ * easing. Returns whether there was a tween to retarget.
568
+ */
569
+ retargetFrame(change: FrameChange, animation: Animation | undefined | null, shift: { x: number; y: number } | null = null): boolean {
570
+ const { el, from, to } = change;
571
+ const tween = this.frameTweens.get(el);
572
+ if (!tween || tween.playState === 'finished' || tween.playState === 'idle') return false;
573
+ if (!el.isConnected) {
574
+ tween.cancel();
575
+ return false;
576
+ }
577
+ // The drawn values: the tween's own properties from the computed style
578
+ // (it is still applied), the others from the box before this pass.
579
+ const tweened = new Set<string>();
580
+ for (const frame of (tween.effect as KeyframeEffect | null)?.getKeyframes() ?? []) {
581
+ for (const key of ['left', 'top', 'width', 'height']) if (key in frame) tweened.add(key);
582
+ }
583
+ const cs = getComputedStyle(el);
584
+ const left = parseFloat(el.style.left) || 0;
585
+ const top = parseFloat(el.style.top) || 0;
586
+ const prev = from ?? to;
587
+ const drawn: Record<'left' | 'top' | 'width' | 'height', number> = {
588
+ left: tweened.has('left') ? parseFloat(cs.left) - (shift?.x ?? 0) : left - (to.x - prev.x),
589
+ top: tweened.has('top') ? parseFloat(cs.top) - (shift?.y ?? 0) : top - (to.y - prev.y),
590
+ width: tweened.has('width') ? parseFloat(cs.width) : prev.width,
591
+ height: tweened.has('height') ? parseFloat(cs.height) : prev.height,
592
+ };
593
+ const target = { left, top, width: to.width, height: to.height };
594
+ let timing: KeyframeAnimationOptions;
595
+ if (animation === null) {
596
+ const computed = tween.effect?.getComputedTiming();
597
+ const end = Number(computed?.endTime ?? 0);
598
+ const now = Number(tween.currentTime ?? 0);
599
+ const remaining = Math.max(0, end - now);
600
+ const easing = tween.effect?.getTiming().easing ?? 'linear';
601
+ timing = { duration: remaining, easing };
602
+ } else {
603
+ timing = timingFor(animation);
604
+ }
605
+ tween.cancel();
606
+ const start: Keyframe = {};
607
+ const finish: Keyframe = {};
608
+ for (const key of ['left', 'top', 'width', 'height'] as const) {
609
+ if (!Number.isFinite(drawn[key]) || Math.abs(drawn[key] - target[key]) < 0.01) continue;
610
+ start[key] = `${drawn[key]}px`;
611
+ finish[key] = `${target[key]}px`;
612
+ }
613
+ if (Object.keys(start).length === 0 || !timing.duration) return true;
614
+ this.trackFrame(el, el.animate([start, finish], { ...timing, fill: 'none' }));
615
+ return true;
423
616
  }
424
617
 
425
618
  /** Play `transition` forwards on a freshly inserted element. */
@@ -484,6 +677,18 @@ export class Animator {
484
677
  }
485
678
  }
486
679
 
680
+ /** Whether `el` is drawn: displayed, not `visibility: hidden`, no ancestor at opacity 0. */
681
+ function isDrawn(el: HTMLElement): boolean {
682
+ if (typeof el.checkVisibility === 'function') {
683
+ return el.checkVisibility({ visibilityProperty: true, opacityProperty: true });
684
+ }
685
+ if (getComputedStyle(el).visibility === 'hidden') return false;
686
+ for (let e: HTMLElement | null = el; e; e = e.parentElement) {
687
+ if (getComputedStyle(e).opacity === '0') return false;
688
+ }
689
+ return true;
690
+ }
691
+
487
692
  /** Transitions are stored by the renderer per element (see `setTransition`). */
488
693
  const TRANSITIONS = new WeakMap<HTMLElement, Transition>();
489
694
 
@@ -519,3 +724,25 @@ function hasAncestorIn(el: HTMLElement, set: ReadonlySet<HTMLElement>): boolean
519
724
  }
520
725
  return false;
521
726
  }
727
+
728
+ /**
729
+ * How far the ancestors of `el` that jumped in this pass (moved without a
730
+ * tween) moved it, up to the nearest ancestor that tweens (which carries `el`
731
+ * from where it was drawn) or `root`; null when nothing above it jumped.
732
+ */
733
+ function jumpAbove(
734
+ el: HTMLElement,
735
+ jumped: ReadonlyMap<HTMLElement, { x: number; y: number }>,
736
+ tweened: ReadonlySet<HTMLElement>,
737
+ root: HTMLElement,
738
+ ): { x: number; y: number } | null {
739
+ let x = 0;
740
+ let y = 0;
741
+ for (let node = el.parentElement; node && node !== root && !tweened.has(node); node = node.parentElement) {
742
+ const jump = jumped.get(node);
743
+ if (!jump) continue;
744
+ x += jump.x;
745
+ y += jump.y;
746
+ }
747
+ return x || y ? { x, y } : null;
748
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Phase 19: the app's home directory, kept in IndexedDB.
3
+ *
4
+ * The module sees an iOS-like home at `/data` (`HOME`): `Documents`,
5
+ * `Library/Application Support` (`XDG_DATA_HOME`) and `Library/Caches`
6
+ * (`XDG_CACHE_HOME`), so `URL.documentsDirectory`, `FileManager.urls(for:in:)`
7
+ * and `Data.write(to:)` work as on a phone; `/tmp` is a second mount that
8
+ * nothing keeps. The page loads the home from the app's database before the
9
+ * module starts, and after any event (or executor run) that wrote to a file
10
+ * it compares the tree with what it last saved and writes the difference.
11
+ * One database per app, `sb-files:<App>`, with one object store, `entries`,
12
+ * keyed by `path` (relative to the home, `Documents/notes.txt`); a row is
13
+ * `{path, data}` for a file and `{path, directory: true}` for a directory.
14
+ * See docs/ops-protocol.md, "Phase 19".
15
+ */
16
+
17
+ import { Directory, File, type Inode } from '@bjorn3/browser_wasi_shim';
18
+
19
+ /** Where the home is mounted, and the environment that points Foundation at it. */
20
+ export const HOME = '/data';
21
+ export const TMP = '/tmp';
22
+ export const HOME_ENVIRONMENT: readonly string[] = [
23
+ `HOME=${HOME}`,
24
+ `XDG_DATA_HOME=${HOME}/Library/Application Support`,
25
+ `XDG_CACHE_HOME=${HOME}/Library/Caches`,
26
+ `TMPDIR=${TMP}`,
27
+ ];
28
+
29
+ /** The directories every home has, as on iOS. */
30
+ export const HOME_DIRECTORIES: readonly string[] = ['Documents', 'Library', 'Library/Application Support', 'Library/Caches'];
31
+
32
+ /** A home's contents: path → bytes for a file, `null` for a directory. */
33
+ export type HomeEntries = Map<string, Uint8Array | null>;
34
+
35
+ /** A writable WASI directory tree from saved entries, with the standard directories. */
36
+ export function homeDirectory(entries: HomeEntries): Directory {
37
+ const root = new Directory(new Map<string, Inode>());
38
+ const directoryAt = (parts: readonly string[]): Directory => {
39
+ let dir = root;
40
+ for (const part of parts) {
41
+ const existing = dir.contents.get(part);
42
+ if (existing instanceof Directory) {
43
+ dir = existing;
44
+ continue;
45
+ }
46
+ const next = new Directory(new Map<string, Inode>());
47
+ dir.contents.set(part, next);
48
+ dir = next;
49
+ }
50
+ return dir;
51
+ };
52
+ for (const path of HOME_DIRECTORIES) directoryAt(path.split('/'));
53
+ // Directories before files, shallow paths first, so a parent always exists.
54
+ const sorted = [...entries].sort(([a, aData], [b, bData]) => Number(aData !== null) - Number(bData !== null) || a.split('/').length - b.split('/').length);
55
+ for (const [path, data] of sorted) {
56
+ const parts = path.split('/').filter((part) => part !== '' && part !== '.' && part !== '..');
57
+ if (parts.length === 0) continue;
58
+ if (data === null) {
59
+ directoryAt(parts);
60
+ } else {
61
+ directoryAt(parts.slice(0, -1)).contents.set(parts[parts.length - 1]!, new File(data));
62
+ }
63
+ }
64
+ return root;
65
+ }
66
+
67
+ /** Every file and directory under `root`, by path (the live bytes of each file). */
68
+ export function homeEntries(root: Directory): HomeEntries {
69
+ const entries: HomeEntries = new Map();
70
+ const walk = (dir: Directory, prefix: string): void => {
71
+ for (const [name, child] of dir.contents) {
72
+ const path = prefix === '' ? name : `${prefix}/${name}`;
73
+ if (child instanceof Directory) {
74
+ entries.set(path, null);
75
+ walk(child, path);
76
+ } else if (child instanceof File) {
77
+ entries.set(path, child.data);
78
+ }
79
+ }
80
+ };
81
+ walk(root, '');
82
+ return entries;
83
+ }
84
+
85
+ function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
86
+ if (a.byteLength !== b.byteLength) return false;
87
+ for (let i = 0; i < a.byteLength; i++) if (a[i] !== b[i]) return false;
88
+ return true;
89
+ }
90
+
91
+ /** What changed from `saved` to `current`: entries to write (copied) and paths to delete. */
92
+ export interface HomeChanges {
93
+ put: [string, Uint8Array | null][];
94
+ remove: string[];
95
+ }
96
+
97
+ export function diffHome(saved: HomeEntries, current: HomeEntries): HomeChanges {
98
+ const put: [string, Uint8Array | null][] = [];
99
+ const remove: string[] = [];
100
+ for (const [path, data] of current) {
101
+ const before = saved.get(path);
102
+ if (before === undefined) {
103
+ put.push([path, data === null ? null : data.slice()]);
104
+ } else if (data === null ? before !== null : before === null || !sameBytes(before, data)) {
105
+ put.push([path, data === null ? null : data.slice()]);
106
+ }
107
+ }
108
+ for (const path of saved.keys()) if (!current.has(path)) remove.push(path);
109
+ return { put, remove };
110
+ }
111
+
112
+ /** `saved` with `changes` applied (what was just written). */
113
+ export function applyHomeChanges(saved: HomeEntries, changes: HomeChanges): HomeEntries {
114
+ const next: HomeEntries = new Map(saved);
115
+ for (const path of changes.remove) next.delete(path);
116
+ for (const [path, data] of changes.put) next.set(path, data);
117
+ return next;
118
+ }
119
+
120
+ /** Where an app's home lives between launches. */
121
+ export interface FilesPersistence {
122
+ load(): Promise<HomeEntries>;
123
+ /** Writes one set of changes; writes happen in the order they were made. */
124
+ apply(changes: HomeChanges): Promise<void>;
125
+ clear(): Promise<void>;
126
+ }
127
+
128
+ interface EntryRow {
129
+ path: string;
130
+ data?: Uint8Array;
131
+ directory?: boolean;
132
+ }
133
+
134
+ const ENTRIES = 'entries';
135
+
136
+ export function filesDatabaseName(app: string): string {
137
+ return `sb-files:${app}`;
138
+ }
139
+
140
+ function request<T>(req: IDBRequest<T>): Promise<T> {
141
+ return new Promise((resolve, reject) => {
142
+ req.onsuccess = () => resolve(req.result);
143
+ req.onerror = () => reject(req.error ?? new Error('IndexedDB request failed'));
144
+ });
145
+ }
146
+
147
+ function done(transaction: IDBTransaction): Promise<void> {
148
+ return new Promise((resolve, reject) => {
149
+ transaction.oncomplete = () => resolve();
150
+ transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB transaction aborted'));
151
+ transaction.onerror = () => reject(transaction.error ?? new Error('IndexedDB transaction failed'));
152
+ });
153
+ }
154
+
155
+ /** The app's home in IndexedDB (`factory` is the browser's unless a test passes one). */
156
+ export function indexedDBFiles(app: string, factory: IDBFactory = indexedDB): FilesPersistence {
157
+ let opened: Promise<IDBDatabase> | null = null;
158
+ const database = (): Promise<IDBDatabase> => {
159
+ opened ??= new Promise((resolve, reject) => {
160
+ const open = factory.open(filesDatabaseName(app), 1);
161
+ open.onupgradeneeded = () => {
162
+ if (!open.result.objectStoreNames.contains(ENTRIES)) open.result.createObjectStore(ENTRIES, { keyPath: 'path' });
163
+ };
164
+ open.onsuccess = () => resolve(open.result);
165
+ open.onerror = () => reject(open.error ?? new Error(`could not open ${filesDatabaseName(app)}`));
166
+ open.onblocked = () => reject(new Error(`${filesDatabaseName(app)} is blocked by another tab`));
167
+ });
168
+ return opened;
169
+ };
170
+ let queue: Promise<void> = Promise.resolve();
171
+
172
+ return {
173
+ async load() {
174
+ const db = await database();
175
+ const rows = await request(db.transaction(ENTRIES, 'readonly').objectStore(ENTRIES).getAll() as IDBRequest<EntryRow[]>);
176
+ const entries: HomeEntries = new Map();
177
+ for (const row of rows) entries.set(row.path, row.directory ? null : new Uint8Array(row.data ?? new Uint8Array()));
178
+ return entries;
179
+ },
180
+ apply(changes) {
181
+ queue = queue.then(async () => {
182
+ const db = await database();
183
+ const transaction = db.transaction(ENTRIES, 'readwrite');
184
+ const store = transaction.objectStore(ENTRIES);
185
+ for (const path of changes.remove) store.delete(path);
186
+ for (const [path, data] of changes.put) {
187
+ const row: EntryRow = data === null ? { path, directory: true } : { path, data };
188
+ store.put(row);
189
+ }
190
+ await done(transaction);
191
+ });
192
+ return queue;
193
+ },
194
+ async clear() {
195
+ const db = await database();
196
+ const transaction = db.transaction(ENTRIES, 'readwrite');
197
+ transaction.objectStore(ENTRIES).clear();
198
+ await done(transaction);
199
+ },
200
+ };
201
+ }
package/src/base64.ts ADDED
@@ -0,0 +1,43 @@
1
+ /** Base64 for the bytes that cross between Swift and the page (fetch bodies, image files); no DOM, so a worker can use it too. */
2
+
3
+ const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
4
+
5
+ /** Standard base64 (RFC 4648, padded); chunked so large bodies do not blow the call stack. */
6
+ export function base64Encode(bytes: Uint8Array): string {
7
+ if (typeof btoa === 'function') {
8
+ let binary = '';
9
+ const chunk = 0x8000;
10
+ for (let i = 0; i < bytes.length; i += chunk) {
11
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
12
+ }
13
+ return btoa(binary);
14
+ }
15
+ let out = '';
16
+ for (let i = 0; i < bytes.length; i += 3) {
17
+ const a = bytes[i]!;
18
+ const b = i + 1 < bytes.length ? bytes[i + 1]! : 0;
19
+ const c = i + 2 < bytes.length ? bytes[i + 2]! : 0;
20
+ const n = (a << 16) | (b << 8) | c;
21
+ out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]!;
22
+ out += i + 1 < bytes.length ? B64[(n >> 6) & 63]! : '=';
23
+ out += i + 2 < bytes.length ? B64[n & 63]! : '=';
24
+ }
25
+ return out;
26
+ }
27
+
28
+ export function base64Decode(text: string): Uint8Array {
29
+ if (typeof atob === 'function') {
30
+ const binary = atob(text);
31
+ const bytes = new Uint8Array(binary.length);
32
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
33
+ return bytes;
34
+ }
35
+ const clean = text.replace(/[^A-Za-z0-9+/]/g, '');
36
+ const out: number[] = [];
37
+ for (let i = 0; i < clean.length; i += 4) {
38
+ const n = [0, 1, 2, 3].reduce((acc, k) => (acc << 6) | (i + k < clean.length ? B64.indexOf(clean[i + k]!) : 0), 0);
39
+ const count = Math.min(3, Math.floor(((clean.length - i) * 6) / 8));
40
+ for (let k = 0; k < count; k++) out.push((n >> (16 - 8 * k)) & 0xff);
41
+ }
42
+ return new Uint8Array(out);
43
+ }