@satinhq/kit 0.1.0-beta.1 → 0.1.0-beta.2

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/NOTICE CHANGED
@@ -12,7 +12,7 @@ is a separately installed build dependency. Consumer Vite builds emit NOTICE.txt
12
12
  and licenses.json from their actual emitted module graph, including runtime peers.
13
13
  Development dependencies are not assumed to be bundled.
14
14
 
15
- ## Satin 0.1.0-beta.1
15
+ ## Satin 0.1.0-beta.2
16
16
 
17
17
  License: MIT
18
18
  Source: https://opensource.org/license/mit
@@ -1,7 +1,7 @@
1
1
  [
2
2
  {
3
3
  "name": "Satin",
4
- "version": "0.1.0-beta.1",
4
+ "version": "0.1.0-beta.2",
5
5
  "license": "MIT",
6
6
  "source": "https://opensource.org/license/mit",
7
7
  "text": "MIT License\n\nCopyright (c) 2026 Satin contributors\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n",
@@ -57,7 +57,7 @@ export async function checkDeployment(expected, pageUrl, fetcher = fetch, includ
57
57
  fail('HTTPS is required for installation and service workers.', page.href, false, 'Serve the app over trusted HTTPS.');
58
58
  return failures;
59
59
  }
60
- const request = async (path, kind, allowDirectoryCanonicalization = false) => {
60
+ const request = async (path, kind, allowDirectoryCanonicalization = false, attempt = 0) => {
61
61
  const url = new URL(path, page).href;
62
62
  const controller = new AbortController();
63
63
  const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
@@ -68,7 +68,7 @@ export async function checkDeployment(expected, pageUrl, fetcher = fetch, includ
68
68
  cache: 'no-store',
69
69
  redirect: path === expected.scope + 'sw.js' ? 'manual' : 'follow',
70
70
  });
71
- definite = true;
71
+ definite = response.status < 500 && response.status !== 408 && response.status !== 429;
72
72
  if (response.status !== 200)
73
73
  throw new Error(`HTTP ${response.status}`);
74
74
  const finalUrl = new URL(response.url || url);
@@ -119,6 +119,12 @@ export async function checkDeployment(expected, pageUrl, fetcher = fetch, includ
119
119
  }
120
120
  catch (error) {
121
121
  const reason = error instanceof Error ? error.message : String(error);
122
+ // Retry transport failures only. A mismatched deployment cannot heal with backoff.
123
+ if ((!definite || controller.signal.aborted) && attempt < 2) {
124
+ clearTimeout(timer);
125
+ await new Promise(resolve => setTimeout(resolve, 250 * 2 ** attempt));
126
+ return request(path, kind, allowDirectoryCanonicalization, attempt + 1);
127
+ }
122
128
  if (controller.signal.aborted) {
123
129
  fail(`${label(url)} did not finish within ${requestTimeoutMs / 1000} s`, url, true, 'Check the server or connection, then try again.');
124
130
  }
@@ -100,7 +100,7 @@ onPass) {
100
100
  title.textContent = standalone
101
101
  ? 'Wrong Home Screen icon'
102
102
  : unverified
103
- ? 'Could not verify this app'
103
+ ? "Can't reach the app right now"
104
104
  : 'This app is misconfigured';
105
105
  const explanation = document.createElement('p');
106
106
  const action = document.createElement('p');
@@ -119,11 +119,12 @@ onPass) {
119
119
  return;
120
120
  }
121
121
  if (unverified) {
122
- explanation.textContent = 'The connection may be slow or offline.';
123
- action.textContent = 'Try again when your connection is ready.';
122
+ explanation.textContent = 'Check your connection and try again.';
123
+ action.remove();
124
124
  const retry = document.createElement('button');
125
125
  retry.textContent = 'Retry';
126
126
  retry.addEventListener('click', () => {
127
+ retry.disabled = true;
127
128
  void installPageCheck(expected, check, 30_000, onPass);
128
129
  });
129
130
  content.append(retry);
@@ -173,8 +173,13 @@ export function installLaunchWatchdog(budgetMs) {
173
173
  if (progress?.type === 'SATIN_DOWNLOAD_PROGRESS' &&
174
174
  progress.downloading &&
175
175
  Number.isFinite(progress.loaded) &&
176
- progress.loaded >= 0)
176
+ progress.loaded >= 0) {
177
177
  receiveProgress(progress);
178
+ // A first install can download before the application module is available.
179
+ // Controlled documents are cached launches/updates and keep their existing policy.
180
+ if (!navigator.serviceWorker?.controller && !revealed)
181
+ window.__SATIN_LAUNCH_SCREEN__?.progress(progress);
182
+ }
178
183
  });
179
184
  // A client's worker messages are queued until DOMContentLoaded, and a bootstrap module that
180
185
  // never finishes holds DOMContentLoaded back: the worker's "this resource stalled" message
@@ -1892,4 +1892,4 @@ export function DraftEditor() {
1892
1892
 
1893
1893
  ## Framework source fingerprint
1894
1894
 
1895
- SHA-256 of sorted TS/TSX/CSS source paths and bytes, excluding tests: b5ce591e73a0ec3a6d860cfa0434aa456a6128aa55251eed3e46b4e35ad8d223
1895
+ SHA-256 of sorted TS/TSX/CSS source paths and bytes, excluding tests: 1dfb5233c6482a7c54b0a396ffaa7079d66757b8816b1584c0eb39aad9428415
@@ -1,7 +1,9 @@
1
1
  import { type PropsWithChildren } from 'react';
2
2
  export type GlassDisplacementPolicy = 'auto' | 'disabled';
3
3
  /** The app paint layer remains untouched. Liquid regions own their retained image. */
4
- export declare function GlassMaterialRoot({ children, displacement, onLaunchReady, }: PropsWithChildren<{
4
+ export declare function GlassMaterialRoot({ children, displacement, prepared, onLaunchReady, }: PropsWithChildren<{
5
5
  displacement: GlassDisplacementPolicy;
6
+ /** Lazy renderer modules wait for the verified offline bundle on first install. */
7
+ prepared?: boolean;
6
8
  onLaunchReady?: (ready: boolean) => void;
7
9
  }>): import("react").JSX.Element;
@@ -12,7 +12,7 @@ function readAccessibilityPreferences() {
12
12
  };
13
13
  }
14
14
  /** The app paint layer remains untouched. Liquid regions own their retained image. */
15
- export function GlassMaterialRoot({ children, displacement, onLaunchReady, }) {
15
+ export function GlassMaterialRoot({ children, displacement, prepared = true, onLaunchReady, }) {
16
16
  const scene = useRef(null);
17
17
  const [chromeLayer, setChromeLayer] = useState(null);
18
18
  const renderTest = 'passed';
@@ -35,5 +35,5 @@ export function GlassMaterialRoot({ children, displacement, onLaunchReady, }) {
35
35
  const registerRegion = useCallback((_registration) => () => { }, []);
36
36
  const diagnostics = useMemo(() => ({ accessibility, capability, renderTest, lensBoost: 1, mapRegenerationCount: 0 }), [accessibility, capability.reason, capability.state, renderTest]);
37
37
  const context = useMemo(() => ({ chromeLayer, diagnostics, registerRegion }), [chromeLayer, diagnostics, registerRegion]);
38
- return (_jsxs(GlassMaterialContext, { value: context, children: [_jsx("div", { ref: scene, "data-satin-glass-capability": capability.state, "data-satin-scene-paint-layer": "true", "data-satin-diagnostic-glass-off": import.meta.env.SATIN_DIAGNOSTIC_GLASS_OFF || undefined, children: _jsx(MaterialEffectRoot, { scene: scene, chrome: chromeLayer, enabled: capability.state === 'displacement', capabilitySettled: true, onLaunchReady: onLaunchReady, children: children }) }), _jsx("div", { ref: setChromeLayer, "data-satin-glass-capability": capability.state, "data-satin-glass-chrome-layer": "true", "data-satin-glass-contrast": accessibility.increasedContrast ? 'more' : 'standard', "data-satin-glass-motion": accessibility.reducedMotion ? 'reduced' : 'full', "data-satin-glass-transparency": accessibility.reducedTransparency ? 'reduced' : 'full' })] }));
38
+ return (_jsxs(GlassMaterialContext, { value: context, children: [_jsx("div", { ref: scene, "data-satin-glass-capability": capability.state, "data-satin-scene-paint-layer": "true", "data-satin-diagnostic-glass-off": import.meta.env.SATIN_DIAGNOSTIC_GLASS_OFF || undefined, children: _jsx(MaterialEffectRoot, { scene: scene, chrome: chromeLayer, enabled: prepared && capability.state === 'displacement', capabilitySettled: prepared, onLaunchReady: onLaunchReady, children: children }) }), _jsx("div", { ref: setChromeLayer, "data-satin-glass-capability": capability.state, "data-satin-glass-chrome-layer": "true", "data-satin-glass-contrast": accessibility.increasedContrast ? 'more' : 'standard', "data-satin-glass-motion": accessibility.reducedMotion ? 'reduced' : 'full', "data-satin-glass-transparency": accessibility.reducedTransparency ? 'reduced' : 'full' })] }));
39
39
  }
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { fallbackGlassRequested } from './fallback/selection.js';
3
- import { glassFallbackRequired, reportGlassError, subscribeGlassFallback } from './errors.js';
3
+ import { glassFallbackRequired, reportGlassError, subscribeGlassFallback, watchGlassTransportRecovery, } from './errors.js';
4
4
  import { GlassRegionRegistry } from './regionRegistry.js';
5
5
  import { observeTabSources } from '../tabs/sourceRevision.js';
6
6
  import { useCallback, useEffect, useMemo, useState, useSyncExternalStore, } from 'react';
@@ -11,6 +11,10 @@ import { isSupportedTabCount } from './tabBarModel.js';
11
11
  /** One renderer per app. Registration is semantic; backdrop discovery is framework-owned. */
12
12
  export function MaterialEffectRoot({ children, scene, chrome, enabled, capabilitySettled, onLaunchReady, }) {
13
13
  const automaticFallback = useSyncExternalStore(subscribeGlassFallback, glassFallbackRequired, () => false);
14
+ useEffect(() => {
15
+ if (automaticFallback)
16
+ return watchGlassTransportRecovery();
17
+ }, [automaticFallback]);
14
18
  const [requestedFallback] = useState(fallbackGlassRequested);
15
19
  const backend = requestedFallback || automaticFallback ? 'fallback' : 'webgpu';
16
20
  const [regions] = useState(() => new GlassRegionRegistry());
@@ -12,7 +12,7 @@ export type GlassError = Readonly<{
12
12
  recovering?: true;
13
13
  }>;
14
14
  export declare function subscribeGlassFallback(listener: () => void): () => void;
15
- /** Successful resource reconstruction can retire only an adapter-outage fallback. */
15
+ /** Retire a device outage only after resource reconstruction and presentation settle. */
16
16
  export declare function setGlassAdapterRecovery(pending: boolean): void;
17
17
  export declare function glassFallbackRequired(): boolean;
18
18
  export declare function reportGlassError(code: GlassErrorCode, cause: unknown, stage: string, reason?: string, recovery?: {
@@ -20,3 +20,5 @@ export declare function reportGlassError(code: GlassErrorCode, cause: unknown, s
20
20
  }): GlassError;
21
21
  export declare function subscribeGlassErrors(listener: (error: GlassError) => void): () => void;
22
22
  export declare function getGlassErrors(): readonly GlassError[];
23
+ /** Retry a failed transport on a visible page; device reconstruction keeps its own owner. */
24
+ export declare function watchGlassTransportRecovery(): () => void;
@@ -1,10 +1,13 @@
1
1
  import { automaticGlassFallbackAllowed, strictGlassRequested } from './failurePolicy.js';
2
2
  import { recordGraphicsEvent } from '../graphicsProbe.js';
3
3
  const history = [];
4
- // Independent of diagnostic history. Only a reconstructed adapter outage can clear fallback.
4
+ // Diagnostic history survives recovery; it is not the fallback state.
5
5
  let fallbackRequired = false;
6
6
  let permanentFallback = false;
7
7
  let adapterRecoveryPending = false;
8
+ let transportRecoveryPending = false;
9
+ let nextTransportRetry = 0;
10
+ let transportFailures = 0;
8
11
  const fallbackListeners = new Set();
9
12
  export function subscribeGlassFallback(listener) {
10
13
  fallbackListeners.add(listener);
@@ -12,7 +15,7 @@ export function subscribeGlassFallback(listener) {
12
15
  fallbackListeners.delete(listener);
13
16
  };
14
17
  }
15
- /** Successful resource reconstruction can retire only an adapter-outage fallback. */
18
+ /** Retire a device outage only after resource reconstruction and presentation settle. */
16
19
  export function setGlassAdapterRecovery(pending) {
17
20
  adapterRecoveryPending = pending;
18
21
  if (!pending && fallbackRequired && !permanentFallback) {
@@ -50,7 +53,17 @@ export function reportGlassError(code, cause, stage, reason, recovery) {
50
53
  history.shift();
51
54
  const release = automaticGlassFallbackAllowed();
52
55
  if (release && !error.recovering) {
53
- if (!adapterRecoveryPending || (code !== 'adapter-unavailable' && code !== 'readiness-timeout'))
56
+ const transportFailure = code === 'worker-startup' ||
57
+ (!adapterRecoveryPending &&
58
+ ['adapter-unavailable', 'device-request', 'readiness-timeout'].includes(code)) ||
59
+ (code === 'presentation' &&
60
+ /import.*module|module.*script|loading.*chunk|load failed/i.test(message));
61
+ if (transportFailure) {
62
+ transportRecoveryPending = true;
63
+ nextTransportRetry = Date.now() + Math.min(3000 * 2 ** transportFailures++, 30_000);
64
+ }
65
+ else if (!adapterRecoveryPending ||
66
+ ['gpu-unavailable', 'unsupported-configuration', 'shader-compilation', 'validation'].includes(code))
54
67
  permanentFallback = true;
55
68
  fallbackRequired = true;
56
69
  for (const listener of fallbackListeners)
@@ -76,3 +89,36 @@ export function subscribeGlassErrors(listener) {
76
89
  export function getGlassErrors() {
77
90
  return history.slice();
78
91
  }
92
+ /** Retry a failed transport on a visible page; device reconstruction keeps its own owner. */
93
+ export function watchGlassTransportRecovery() {
94
+ let timer;
95
+ const retry = () => {
96
+ clearTimeout(timer);
97
+ if (!fallbackRequired ||
98
+ permanentFallback ||
99
+ !transportRecoveryPending ||
100
+ adapterRecoveryPending)
101
+ return;
102
+ if (document.visibilityState !== 'visible')
103
+ return;
104
+ const delay = nextTransportRetry - Date.now();
105
+ if (delay > 0) {
106
+ timer = setTimeout(retry, delay);
107
+ return;
108
+ }
109
+ transportRecoveryPending = false;
110
+ fallbackRequired = false;
111
+ for (const listener of fallbackListeners)
112
+ listener();
113
+ };
114
+ document.addEventListener('visibilitychange', retry);
115
+ window.addEventListener('pageshow', retry);
116
+ window.addEventListener('focus', retry);
117
+ retry();
118
+ return () => {
119
+ clearTimeout(timer);
120
+ document.removeEventListener('visibilitychange', retry);
121
+ window.removeEventListener('pageshow', retry);
122
+ window.removeEventListener('focus', retry);
123
+ };
124
+ }
@@ -0,0 +1,7 @@
1
+ /** Parking WebGPU retains its last pixels. CSS ownership must hide those pixels,
2
+ * including lift/selection canvases, without destroying the renderer's reusable canvas. */
3
+ export declare class FallbackCanvasOwnership {
4
+ private visibility;
5
+ update(canvases: Iterable<HTMLCanvasElement>): void;
6
+ dispose(): void;
7
+ }
@@ -0,0 +1,23 @@
1
+ /** Parking WebGPU retains its last pixels. CSS ownership must hide those pixels,
2
+ * including lift/selection canvases, without destroying the renderer's reusable canvas. */
3
+ export class FallbackCanvasOwnership {
4
+ visibility = new Map();
5
+ update(canvases) {
6
+ const live = new Set(canvases);
7
+ for (const [canvas, visibility] of this.visibility) {
8
+ if (live.has(canvas))
9
+ continue;
10
+ canvas.style.visibility = visibility;
11
+ this.visibility.delete(canvas);
12
+ }
13
+ for (const canvas of live) {
14
+ if (this.visibility.has(canvas))
15
+ continue;
16
+ this.visibility.set(canvas, canvas.style.visibility);
17
+ canvas.style.visibility = 'hidden';
18
+ }
19
+ }
20
+ dispose() {
21
+ this.update([]);
22
+ }
23
+ }
@@ -1,4 +1,4 @@
1
- import { animateElement } from '../../motion/animate.js';
1
+ import { animateElement, timelineStart } from '../../motion/animate.js';
2
2
  /** CSS owns backdrop sampling, including video that cannot be read into a GPU texture. */
3
3
  export class FallbackMaterial {
4
4
  element = document.createElement('div');
@@ -61,6 +61,7 @@ export class FallbackMaterial {
61
61
  this.paint(specs[specs.length - 1], appearance);
62
62
  const count = Math.max(...specs.map(spec => this.shapes(spec).length));
63
63
  this.ensure(count, appearance, specs[specs.length - 1]);
64
+ const start = timelineStart(at);
64
65
  this.parts.forEach((part, index) => {
65
66
  const frames = specs.map((spec, sample) => {
66
67
  const shape = this.shapes(spec)[index];
@@ -70,7 +71,7 @@ export class FallbackMaterial {
70
71
  };
71
72
  });
72
73
  const animation = animateElement(part, frames, { duration, easing: 'linear', fill: 'both' });
73
- animation.startTime = at;
74
+ animation.startTime = start;
74
75
  this.animations.push(animation);
75
76
  });
76
77
  return Promise.all(this.animations.map(animation => animation.finished)).then(() => { }, () => { });
@@ -1,6 +1,7 @@
1
1
  import type { GlassRegionRegistry } from '../regionRegistry.js';
2
2
  /** Registered DOM owners already follow scroll, sheets and navigation on the compositor. */
3
3
  export declare class FallbackRegions {
4
+ private canvases;
4
5
  private entries;
5
6
  private unsubscribe;
6
7
  private frame;
@@ -4,11 +4,13 @@ import { sampleMenuCorner } from '../lens/menuCornerMotion.js';
4
4
  import { confirmHeldSources, heldPresentations } from '../presentationFrame.js';
5
5
  import { animateElement, timelineStart } from '../../motion/animate.js';
6
6
  import { FallbackMaterial } from './material.js';
7
+ import { FallbackCanvasOwnership } from './canvasOwnership.js';
7
8
  import { GLASS_SHAPE_TRACK_EVENT, glassShapeTrack } from '../shapeTrackRegistry.js';
8
9
  import { trackedShapeSpec } from '../shapeTrack.js';
9
10
  import { glassRadius } from '../regions.js';
10
11
  /** Registered DOM owners already follow scroll, sheets and navigation on the compositor. */
11
12
  export class FallbackRegions {
13
+ canvases = new FallbackCanvasOwnership();
12
14
  entries = new Map();
13
15
  unsubscribe = [];
14
16
  frame = 0;
@@ -43,12 +45,15 @@ export class FallbackRegions {
43
45
  }
44
46
  refresh() {
45
47
  const registrations = this.registry?.values() ?? [];
48
+ this.canvases.update([
49
+ ...registrations.flatMap(entry => [entry.canvas, ...(entry.overlay ? [entry.overlay] : [])]),
50
+ ...[...(this.registry?.containers.values() ?? [])].map(entry => entry.canvas),
51
+ ]);
46
52
  const live = new Set(registrations.map(entry => entry.id));
47
53
  for (const [id, entry] of this.entries) {
48
54
  if (!live.has(id)) {
49
55
  entry.ink?.cancel();
50
56
  entry.material.dispose();
51
- entry.restoreCanvas();
52
57
  this.entries.delete(id);
53
58
  }
54
59
  }
@@ -90,18 +95,10 @@ export class FallbackRegions {
90
95
  const signature = JSON.stringify([region, this.appearance]);
91
96
  let entry = this.entries.get(id);
92
97
  if (!entry) {
93
- // A live demotion can leave the last GPU frame on this canvas. Retain the
94
- // canvas for presentation receipts, but let only the CSS material paint.
95
- const canvas = registration.canvas;
96
- const visibility = canvas.style.visibility;
97
- canvas.style.visibility = 'hidden';
98
98
  entry = {
99
99
  material: new FallbackMaterial(element),
100
100
  signature: '',
101
101
  generation: 0,
102
- restoreCanvas: () => {
103
- canvas.style.visibility = visibility;
104
- },
105
102
  };
106
103
  this.entries.set(id, entry);
107
104
  }
@@ -129,7 +126,7 @@ export class FallbackRegions {
129
126
  if (track.epoch === Number.MAX_VALUE || frames.length === 1)
130
127
  entry.material.paint(frames[0], this.appearance);
131
128
  else
132
- void entry.material.play(frames, track.duration, timelineStart(track.epoch - performance.timeOrigin), this.appearance);
129
+ void entry.material.play(frames, track.duration, track.epoch - performance.timeOrigin, this.appearance);
133
130
  continue;
134
131
  }
135
132
  if (!emerge || emerge.ready === false) {
@@ -204,7 +201,7 @@ export class FallbackRegions {
204
201
  };
205
202
  });
206
203
  entry.ink = animateElement(ink, frames, { duration, easing: 'linear', fill: 'both' });
207
- entry.ink.startTime = at;
204
+ entry.ink.startTime = timelineStart(at);
208
205
  }
209
206
  if (emerge.closing)
210
207
  this.registry?.emergeInk({
@@ -242,8 +239,8 @@ export class FallbackRegions {
242
239
  this.entries.forEach(entry => {
243
240
  entry.ink?.cancel();
244
241
  entry.material.dispose();
245
- entry.restoreCanvas();
246
242
  });
247
243
  this.entries.clear();
244
+ this.canvases.dispose();
248
245
  }
249
246
  }
@@ -3,6 +3,7 @@ import type { RestorePath } from '../lens/tabRestoreMotion.js';
3
3
  import type { ScreenId } from '../lens/Scene.js';
4
4
  /** Material replacement only: semantic controls and measured motion keep their existing owners. */
5
5
  export declare class FallbackGlassRuntime {
6
+ private canvases;
6
7
  private regions;
7
8
  private dock;
8
9
  private thumb;
@@ -25,6 +26,7 @@ export declare class FallbackGlassRuntime {
25
26
  private scroll;
26
27
  private dockSpec;
27
28
  private minimizeTrack;
29
+ private playMinimizeTrack;
28
30
  restoreMinimizingTabBar(path?: RestorePath): boolean;
29
31
  private thumbSpec;
30
32
  private paintTabs;
@@ -6,8 +6,10 @@ import { tabHeldGeometry } from '../webgpu/interactionProfiles.js';
6
6
  import { WorkerMinimize } from '../webgpu/minimize.js';
7
7
  import { FallbackMaterial } from './material.js';
8
8
  import { FallbackRegions } from './regions.js';
9
+ import { FallbackCanvasOwnership } from './canvasOwnership.js';
9
10
  /** Material replacement only: semantic controls and measured motion keep their existing owners. */
10
11
  export class FallbackGlassRuntime {
12
+ canvases = new FallbackCanvasOwnership();
11
13
  regions;
12
14
  dock;
13
15
  thumb;
@@ -25,18 +27,23 @@ export class FallbackGlassRuntime {
25
27
  Object.assign(globalThis, {
26
28
  __SATIN_GLASS_RENDERER__: { backend: 'fallback', errors: getGlassErrors },
27
29
  });
30
+ const band = options.elements.band;
31
+ const shell = band.canvas.closest('[data-satin-liquid]');
32
+ this.canvases.update([
33
+ band.canvas,
34
+ band.rimCanvas,
35
+ ...(shell?.querySelectorAll('canvas[data-satin-webgpu],canvas[data-satin-glass-lift]') ?? []),
36
+ ]);
28
37
  this.regions = new FallbackRegions(options.regions, options.appearance);
29
38
  this.dock = new FallbackMaterial(options.elements.chrome.dock);
30
39
  this.thumb = new FallbackMaterial(options.elements.chrome.dock);
31
40
  this.dock.element.style.zIndex = '-2';
32
41
  this.thumb.element.style.zIndex = '-1';
33
42
  this.minimizeDom = new WorkerMinimize(options, path => this.restoreMinimizingTabBar(path), {
34
- begin: x => {
35
- this.tabPointerDown(x, this.options.geometry.tabBarSelectedIndex ?? 0);
36
- },
43
+ begin: x => this.tabPointerDown(x, this.options.geometry.tabBarSelectedIndex ?? 0),
37
44
  move: x => this.tabPointerMove(x),
38
45
  end: cancelled => this.tabPointerUp(cancelled),
39
- });
46
+ }, track => this.playMinimizeTrack(track));
40
47
  this.bindScroll();
41
48
  this.unsubscribeAppearance = options.subscribeAppearance?.(() => this.commitAppearance(options.currentAppearance?.() ?? options.appearance));
42
49
  this.updateGeometry({});
@@ -90,6 +97,9 @@ export class FallbackGlassRuntime {
90
97
  }
91
98
  minimizeTrack(direction, path = 'tap') {
92
99
  const track = this.minimizeDom.start(direction, this.dockSpec(), path);
100
+ this.playMinimizeTrack(track);
101
+ }
102
+ playMinimizeTrack(track) {
93
103
  void this.dock.play(track.specs, track.duration, track.at, this.options.appearance);
94
104
  void this.thumb.play(track.specs.map(spec => this.thumbSpec(spec)), track.duration, track.at, this.options.appearance);
95
105
  }
@@ -272,6 +282,7 @@ export class FallbackGlassRuntime {
272
282
  this.unsubscribeAppearance?.();
273
283
  this.minimizeDom.dispose();
274
284
  this.regions.dispose();
285
+ this.canvases.dispose();
275
286
  this.dock.dispose();
276
287
  this.thumb.dispose();
277
288
  }
@@ -21,6 +21,7 @@ export declare class WebGPUClient {
21
21
  };
22
22
  private adapterDeadline?;
23
23
  private failed?;
24
+ private startingTransport;
24
25
  private frames;
25
26
  private webgpu;
26
27
  private currentAppearance;
@@ -45,5 +46,7 @@ export declare class WebGPUClient {
45
46
  private focus;
46
47
  private bindLifecycle;
47
48
  resume(): void;
49
+ /** Dispose a failed transport before constructing its replacement. */
50
+ close(): void;
48
51
  park(): Promise<unknown>;
49
52
  }
@@ -30,6 +30,7 @@ export class WebGPUClient {
30
30
  }
31
31
  adapterDeadline;
32
32
  failed;
33
+ startingTransport = false;
33
34
  frames = new PageFrameHost();
34
35
  webgpu = false;
35
36
  currentAppearance = () => readDarkAppearance() ? 'dark' : 'light';
@@ -68,7 +69,26 @@ export class WebGPUClient {
68
69
  })), motionPreparationEnabled);
69
70
  glassTiming('client-start');
70
71
  try {
71
- await this.initialize(canvas, resolution, worker, webgpu, appearance, execution);
72
+ for (let attempt = 0;; attempt++) {
73
+ this.startingTransport = true;
74
+ try {
75
+ await this.initialize(canvas, resolution, worker, webgpu, appearance, execution);
76
+ break;
77
+ }
78
+ catch (error) {
79
+ const code = glassFailureCode('renderer initialization', error);
80
+ const retryable = this.startingTransport &&
81
+ ['worker-startup', 'readiness-timeout', 'device-request'].includes(code);
82
+ if (!retryable || attempt === 2)
83
+ throw error;
84
+ this.close();
85
+ this.failed = undefined;
86
+ await new Promise(resolve => setTimeout(resolve, 250 * 2 ** attempt));
87
+ }
88
+ finally {
89
+ this.startingTransport = false;
90
+ }
91
+ }
72
92
  }
73
93
  catch (error) {
74
94
  reportGlassError(glassFailureCode('renderer initialization', error), error, 'renderer initialization');
@@ -153,7 +173,7 @@ export class WebGPUClient {
153
173
  return;
154
174
  }
155
175
  if (glassError) {
156
- reportGlassError(glassError.code, glassError.message, glassError.stage, glassError.reason);
176
+ reportGlassError(glassError.code, glassError.message, glassError.stage, glassError.reason, this.startingTransport ? { recovering: true } : undefined);
157
177
  return;
158
178
  }
159
179
  const request = this.pending.get(id);
@@ -174,12 +194,18 @@ export class WebGPUClient {
174
194
  };
175
195
  }
176
196
  else {
177
- const transport = new Worker(new URL('./entry.worker.js', import.meta.url), {
178
- type: 'module',
179
- name: 'satin-glass-webgpu',
180
- });
197
+ let transport;
198
+ try {
199
+ transport = new Worker(new URL('./entry.worker.js', import.meta.url), {
200
+ type: 'module',
201
+ name: 'satin-glass-webgpu',
202
+ });
203
+ }
204
+ catch (error) {
205
+ throw new Error(`Worker startup: ${String(error)}`, { cause: error });
206
+ }
181
207
  transport.onmessage = receive;
182
- transport.onerror = event => this.failTransport(new Error(event.message || 'Glass worker failed'), 'worker startup/runtime');
208
+ transport.onerror = event => this.failTransport(new Error(`Worker startup/runtime: ${event.message || 'Glass worker failed'}`), 'worker startup/runtime');
183
209
  transport.onmessageerror = () => this.failTransport(Error('Glass worker message could not be deserialized'), 'worker transport');
184
210
  this.transport = {
185
211
  postMessage: (data, transfer = []) => transport.postMessage(data, transfer),
@@ -205,7 +231,7 @@ export class WebGPUClient {
205
231
  }
206
232
  catch (error) {
207
233
  this.failed = error instanceof Error ? error : Error(String(error));
208
- reportGlassError(glassFailureCode('WebGPU startup', this.failed), this.failed, 'WebGPU startup');
234
+ // init owns the bounded startup retry and terminal report.
209
235
  this.transport?.terminate();
210
236
  for (const pending of this.pending.values())
211
237
  pending.reject(this.failed);
@@ -216,6 +242,7 @@ export class WebGPUClient {
216
242
  clearTimeout(timeout);
217
243
  }
218
244
  }
245
+ this.startingTransport = false;
219
246
  const offscreen = this.local ? canvas : canvas.transferControlToOffscreen();
220
247
  await this.request({
221
248
  type: 'init',
@@ -233,7 +260,8 @@ export class WebGPUClient {
233
260
  failTransport(error, stage) {
234
261
  this.adapterDeadline?.cancel();
235
262
  this.failed = error;
236
- reportGlassError(glassFailureCode(stage, error), error, stage);
263
+ if (!this.startingTransport)
264
+ reportGlassError(glassFailureCode(stage, error), error, stage);
237
265
  for (const pending of this.pending.values())
238
266
  pending.reject(error);
239
267
  this.pending.clear();
@@ -324,6 +352,26 @@ export class WebGPUClient {
324
352
  this.bindLifecycle();
325
353
  this.visibility();
326
354
  }
355
+ /** Dispose a failed transport before constructing its replacement. */
356
+ close() {
357
+ this.frames.cancel();
358
+ this.adapterDeadline?.cancel();
359
+ this.adapterDeadline = undefined;
360
+ document.removeEventListener('visibilitychange', this.visibility, true);
361
+ window.removeEventListener('pagehide', this.visibility, true);
362
+ window.removeEventListener('pageshow', this.visibility);
363
+ window.removeEventListener('focus', this.focus);
364
+ document.removeEventListener('visibilitychange', this.adapterVisibility, true);
365
+ window.removeEventListener('pagehide', this.adapterVisibility, true);
366
+ window.removeEventListener('pageshow', this.adapterVisibility);
367
+ window.removeEventListener('focus', this.adapterVisibility);
368
+ this.transport?.terminate();
369
+ this.transport = undefined;
370
+ this.local = undefined;
371
+ for (const pending of this.pending.values())
372
+ pending.reject(Error('Glass transport retired'));
373
+ this.pending.clear();
374
+ }
327
375
  park() {
328
376
  this.frames.cancel();
329
377
  document.removeEventListener('visibilitychange', this.visibility, true);
@@ -1,6 +1,9 @@
1
1
  /** Classification retains the raw message; it never chooses a replacement. */
2
2
  export function glassFailureCode(stage, cause) {
3
3
  const message = cause instanceof Error ? `${cause.name}: ${cause.message}` : String(cause), text = stage + ' ' + message;
4
+ // Preserve the failed transport owner when a worker reports its own device loss.
5
+ if (/worker.*(?:startup|runtime|transport|rejection)/i.test(text))
6
+ return 'worker-startup';
4
7
  if (/device\.lost|device.?lost/i.test(text))
5
8
  return 'device-lost';
6
9
  if (/out.?of.?memory/i.test(text))
@@ -19,8 +22,6 @@ export function glassFailureCode(stage, cause) {
19
22
  return 'context-configure';
20
23
  if (/readiness|2\.5 seconds|2500 ms/i.test(text))
21
24
  return 'readiness-timeout';
22
- if (/worker.*(?:startup|runtime|transport|rejection)/i.test(text))
23
- return 'worker-startup';
24
25
  if (/unsupported|invalid glass|Unknown.*(?:layer|region)|exceeds.*supported/i.test(text))
25
26
  return 'unsupported-configuration';
26
27
  return 'presentation';
@@ -22,7 +22,9 @@ export declare class LayerDevice {
22
22
  private stopped;
23
23
  private adapterPending;
24
24
  private recovering;
25
- private recoveryBlocked;
25
+ private recoveryBlockedUntil;
26
+ private recoveryTimer?;
27
+ private recoveryFailures;
26
28
  private forcedNullAdapters;
27
29
  private lastLoss?;
28
30
  readonly stats: {
@@ -43,6 +45,8 @@ export declare class LayerDevice {
43
45
  stop(): void;
44
46
  /** Foreground signals wake the single acquisition already in flight. */
45
47
  retryAdapter(): Promise<GPUDevice> | undefined;
48
+ private scheduleRecovery;
49
+ private waitUntilVisible;
46
50
  forceNullAdaptersForTest(attempts: number): void;
47
51
  private requestAdapter;
48
52
  private acquire;
@@ -34,7 +34,9 @@ export class LayerDevice {
34
34
  stopped = false;
35
35
  adapterPending = false;
36
36
  recovering = false;
37
- recoveryBlocked = false;
37
+ recoveryBlockedUntil = 0;
38
+ recoveryTimer;
39
+ recoveryFailures = 0;
38
40
  forcedNullAdapters = 0;
39
41
  lastLoss;
40
42
  stats = {
@@ -57,8 +59,10 @@ export class LayerDevice {
57
59
  async ready() {
58
60
  if (this.device)
59
61
  return this.device;
60
- if (this.recoveryBlocked)
61
- throw Error('Glass device repeatedly lost during recovery');
62
+ if (Date.now() < this.recoveryBlockedUntil) {
63
+ this.scheduleRecovery();
64
+ throw Error('Glass device recovery is cooling down');
65
+ }
62
66
  if (this.starting)
63
67
  return this.starting;
64
68
  this.starting = this.acquire();
@@ -79,8 +83,13 @@ export class LayerDevice {
79
83
  }
80
84
  }
81
85
  failedDevice?.destroy();
82
- if (!this.stopped)
86
+ if (!this.stopped) {
83
87
  this.reportError(this.recovering ? 'device recreation' : 'device acquisition', error);
88
+ if (this.recovering) {
89
+ this.recoveryFailures++;
90
+ this.scheduleRecovery();
91
+ }
92
+ }
84
93
  throw error;
85
94
  }
86
95
  finally {
@@ -91,21 +100,53 @@ export class LayerDevice {
91
100
  setAdapterVisible(visible) {
92
101
  this.adapterVisible = visible;
93
102
  this.retryWake?.();
103
+ if (!visible) {
104
+ clearTimeout(this.recoveryTimer);
105
+ this.recoveryTimer = undefined;
106
+ }
107
+ else if (this.recovering && !this.starting && !this.device)
108
+ this.scheduleRecovery();
94
109
  }
95
110
  stop() {
96
111
  this.stopped = true;
112
+ clearTimeout(this.recoveryTimer);
97
113
  this.retryWake?.();
98
114
  }
99
115
  /** Foreground signals wake the single acquisition already in flight. */
100
116
  retryAdapter() {
101
- if (this.device || this.recoveryBlocked)
117
+ if (this.device || this.stopped || !this.adapterVisible)
118
+ return;
119
+ if (Date.now() < this.recoveryBlockedUntil) {
120
+ this.scheduleRecovery();
102
121
  return;
122
+ }
103
123
  if (this.starting) {
104
124
  this.retryWake?.();
105
125
  return;
106
126
  }
107
127
  return this.ready();
108
128
  }
129
+ scheduleRecovery() {
130
+ if (this.stopped || !this.adapterVisible || this.recoveryTimer)
131
+ return;
132
+ const delay = Math.max(this.recoveryBlockedUntil - Date.now(), Math.min(1000 * 2 ** this.recoveryFailures, 30_000));
133
+ this.recoveryTimer = setTimeout(() => {
134
+ this.recoveryTimer = undefined;
135
+ void this.retryAdapter()?.catch(() => {
136
+ /* ready records and schedules the next attempt. */
137
+ });
138
+ }, delay);
139
+ }
140
+ async waitUntilVisible() {
141
+ while (!this.adapterVisible && !this.stopped) {
142
+ await new Promise(resolve => {
143
+ this.retryWake = resolve;
144
+ });
145
+ this.retryWake = undefined;
146
+ }
147
+ if (this.stopped)
148
+ throw Error('Glass renderer stopped during adapter acquisition');
149
+ }
109
150
  forceNullAdaptersForTest(attempts) {
110
151
  if (!Number.isInteger(attempts) || attempts < 0 || attempts > 100)
111
152
  throw Error('Null-adapter attempts must be an integer between 0 and 100');
@@ -167,6 +208,15 @@ export class LayerDevice {
167
208
  })).catch(error => {
168
209
  throw Error(`requestDevice rejected: ${String(error)}`);
169
210
  });
211
+ // requestDevice may resolve after a quick re-background. Do not rebuild or
212
+ // announce readiness against a suspended presentation generation.
213
+ try {
214
+ await this.waitUntilVisible();
215
+ }
216
+ catch (error) {
217
+ device.destroy();
218
+ throw error;
219
+ }
170
220
  this.device = device;
171
221
  this.faults.length = 0;
172
222
  registerGPUReporter(device, (stage, error) => {
@@ -207,11 +257,13 @@ export class LayerDevice {
207
257
  }
208
258
  if (invalidationError) {
209
259
  this.reportError('device recreation', invalidationError);
260
+ this.scheduleRecovery();
210
261
  return;
211
262
  }
212
263
  // WebKit can report destroyed for an unexpected loss as well.
213
264
  if (this.lossTimes.length > 3) {
214
- this.recoveryBlocked = true;
265
+ this.recoveryBlockedUntil = this.lossTimes[0] + 60_000;
266
+ this.scheduleRecovery();
215
267
  this.reportError('device recreation', 'Glass device repeatedly lost during recovery');
216
268
  return;
217
269
  }
@@ -227,6 +279,9 @@ export class LayerDevice {
227
279
  for (const listener of this.listeners)
228
280
  listener(device);
229
281
  this.stats.restores++;
282
+ this.recoveryFailures = 0;
283
+ clearTimeout(this.recoveryTimer);
284
+ this.recoveryTimer = undefined;
230
285
  this.recovering = false;
231
286
  if (lagTraceEnabled())
232
287
  glassTiming('device-restored');
@@ -374,8 +374,26 @@ export class WorkerMinimize {
374
374
  },
375
375
  };
376
376
  }
377
- if (atRest(f))
378
- return spec;
377
+ if (atRest(f)) {
378
+ // A retargeted track includes its pre-onset frames. They must use the current
379
+ // slot too; returning the captured spec preserves the old expanded width.
380
+ return {
381
+ ...spec,
382
+ frameWidth: geometry.dockWidth,
383
+ frameHeight: geometry.dockHeight,
384
+ shape: {
385
+ ...spec.shape,
386
+ first: {
387
+ ...spec.shape.first,
388
+ centerX: geometry.dockWidth / 2,
389
+ centerY: geometry.dockHeight / 2,
390
+ halfWidth: geometry.dockWidth / 2,
391
+ halfHeight: geometry.dockHeight / 2,
392
+ radius: geometry.dockRadius,
393
+ },
394
+ },
395
+ };
396
+ }
379
397
  const g = this.options.geometry, p = minimizePlacement(f.progress, g.dockWidth, g.dockHeight, g.tabBarMinimizedOffsetX), height = g.dockHeight + (48 - g.dockHeight) * f.heightProgress, y = 7 * f.heightProgress, pill = spec.tabSelection?.capsule;
380
398
  const bar = {
381
399
  centerX: p.x + p.width / 2,
@@ -304,6 +304,12 @@ export class ProductionGlassRuntime {
304
304
  static async create(options) {
305
305
  if (this.shared) {
306
306
  const runtime = await this.shared;
307
+ if (runtime.client.status().failed) {
308
+ runtime.teardown();
309
+ runtime.client.close();
310
+ this.shared = undefined;
311
+ return this.create(options);
312
+ }
307
313
  runtime.rebind(options);
308
314
  await runtime.prepare();
309
315
  return runtime;
@@ -317,7 +323,9 @@ export class ProductionGlassRuntime {
317
323
  catch (error) {
318
324
  // A rejected create never reaches the React owner's cleanup. Retire its
319
325
  // listeners and captures here before a release app mounts CSS material.
320
- runtime.dispose(options);
326
+ runtime.teardown();
327
+ runtime.client.close();
328
+ this.shared = undefined;
321
329
  throw error;
322
330
  }
323
331
  })();
@@ -19,13 +19,12 @@ export declare class SourcePixels {
19
19
  private worker?;
20
20
  private next;
21
21
  private failure?;
22
+ private retrying?;
22
23
  private pending;
23
24
  private create;
24
25
  constructor(create?: () => Transport);
25
- /** The upload worker failed (its device was lost, or a reply never came). The instance stays
26
- * failed for images that need the worker: the bounded recovery of a source preparation replaces
27
- * it (`ProductionGlassRuntime.replaceFailedSourcePixels`), so a failure is reported once per
28
- * attempt and never retried in a loop. Canvas readbacks do not use the worker and still work. */
26
+ /** Exhausted upload failures let the production owner replace this source stager.
27
+ * Canvas readbacks remain available because they do not use the worker. */
29
28
  get failed(): boolean;
30
29
  private fail;
31
30
  /** Stage `image`'s premultiplied bytes for its upload (`takeSourcePixels`). When `capture` is a
@@ -39,4 +38,5 @@ export declare class SourcePixels {
39
38
  * in WebKit (157 ms for a 1320 x 5091 capture in the Catalyst host), more than half of the
40
39
  * stage. Measured for captures up to 1320 x 6495; the cause inside WebKit was not sampled. */
41
40
  prepare(image: ImageBitmap, capture?: ImageBitmapSource): Promise<void>;
41
+ private prepareOnce;
42
42
  }
@@ -32,6 +32,7 @@ export class SourcePixels {
32
32
  worker;
33
33
  next = 0;
34
34
  failure;
35
+ retrying;
35
36
  pending = new Map();
36
37
  create;
37
38
  constructor(create = () => new Worker(new URL('./sourcePixels.worker.js', import.meta.url), {
@@ -40,10 +41,8 @@ export class SourcePixels {
40
41
  })) {
41
42
  this.create = create;
42
43
  }
43
- /** The upload worker failed (its device was lost, or a reply never came). The instance stays
44
- * failed for images that need the worker: the bounded recovery of a source preparation replaces
45
- * it (`ProductionGlassRuntime.replaceFailedSourcePixels`), so a failure is reported once per
46
- * attempt and never retried in a loop. Canvas readbacks do not use the worker and still work. */
44
+ /** Exhausted upload failures let the production owner replace this source stager.
45
+ * Canvas readbacks remain available because they do not use the worker. */
47
46
  get failed() {
48
47
  return !!this.failure;
49
48
  }
@@ -51,7 +50,9 @@ export class SourcePixels {
51
50
  this.failure = error;
52
51
  this.worker?.terminate();
53
52
  this.worker = undefined;
54
- reportGlassError('worker-startup', error, 'source upload worker');
53
+ reportGlassError('worker-startup', error, 'source upload worker', undefined, {
54
+ recovering: true,
55
+ });
55
56
  for (const job of this.pending.values()) {
56
57
  clearTimeout(job.timer);
57
58
  job.reject(error);
@@ -69,6 +70,30 @@ export class SourcePixels {
69
70
  * in WebKit (157 ms for a 1320 x 5091 capture in the Catalyst host), more than half of the
70
71
  * stage. Measured for captures up to 1320 x 6495; the cause inside WebKit was not sampled. */
71
72
  async prepare(image, capture = image) {
73
+ for (let attempt = 0;; attempt++) {
74
+ try {
75
+ if (this.retrying)
76
+ await this.retrying;
77
+ return await this.prepareOnce(image, capture);
78
+ }
79
+ catch (error) {
80
+ if (!this.failure)
81
+ throw error;
82
+ if (attempt === 2) {
83
+ reportGlassError('worker-startup', error, 'source upload worker');
84
+ throw new Error(`Source upload worker startup failed: ${String(error)}`, { cause: error });
85
+ }
86
+ // Concurrent uploads share one restart. Each retry clones from the retained
87
+ // source, never from the bitmap already transferred to the failed worker.
88
+ this.retrying ??= new Promise(resolve => setTimeout(resolve, 250 * 2 ** attempt)).then(() => {
89
+ this.failure = undefined;
90
+ this.retrying = undefined;
91
+ });
92
+ await this.retrying;
93
+ }
94
+ }
95
+ }
96
+ async prepareOnce(image, capture) {
72
97
  const readbackStarted = glassNow();
73
98
  const pixels = canvasPixels(capture);
74
99
  if (pixels) {
@@ -83,8 +108,17 @@ export class SourcePixels {
83
108
  if (this.failure)
84
109
  throw this.failure;
85
110
  if (!this.worker) {
86
- this.worker = this.create();
111
+ try {
112
+ this.worker = this.create();
113
+ }
114
+ catch (error) {
115
+ this.fail(error instanceof Error ? error : Error(String(error)));
116
+ throw error;
117
+ }
118
+ const worker = this.worker;
87
119
  this.worker.onmessage = event => {
120
+ if (this.worker !== worker)
121
+ return;
88
122
  const data = event.data;
89
123
  if (data.id === 0 && data.error) {
90
124
  this.fail(Error(data.error));
@@ -109,8 +143,14 @@ export class SourcePixels {
109
143
  prepared.set(job.image, new Uint8Array(data.buffer));
110
144
  job.resolve();
111
145
  };
112
- this.worker.onerror = event => this.fail(Error(event.message || 'Source upload worker failed'));
113
- this.worker.onmessageerror = () => this.fail(Error('Source upload worker reply could not be decoded'));
146
+ this.worker.onerror = event => {
147
+ if (this.worker === worker)
148
+ this.fail(Error(event.message || 'Source upload worker failed'));
149
+ };
150
+ this.worker.onmessageerror = () => {
151
+ if (this.worker === worker)
152
+ this.fail(Error('Source upload worker reply could not be decoded'));
153
+ };
114
154
  }
115
155
  const started = glassNow(), clone = await createImageBitmap(capture);
116
156
  if (this.failure) {
@@ -159,6 +159,7 @@ export function SatinRoot({ background: legacyBackground, app: suppliedApp, offl
159
159
  const downloadedBytes = useRef(0);
160
160
  const [launchAttempt, setLaunchAttempt] = useState(0);
161
161
  const [glassReady, setGlassReady] = useState(false);
162
+ const [bundlePrepared, setBundlePrepared] = useState(import.meta.env.DEV || !app);
162
163
  const [glassError, setGlassError] = useState(null);
163
164
  const [glassFailed, setGlassFailed] = useState(false);
164
165
  useLayoutEffect(() => {
@@ -235,10 +236,13 @@ export function SatinRoot({ background: legacyBackground, app: suppliedApp, offl
235
236
  downloadActivity.current = performance.now();
236
237
  downloadedBytes.current = progress.loaded;
237
238
  }
238
- // Release launch rings confirm updates; first-install bytes
239
- // still drive readiness/watchdogs without promising an update.
240
- if (app.updateProfile !== 'release')
241
- setLaunchProgress(progress);
239
+ // First-install bytes describe a real download in every profile.
240
+ // Applied-update confirmation remains a separate successor phase.
241
+ setLaunchProgress(progress);
242
+ }).then(ready => {
243
+ if (!cancelled && ready)
244
+ setBundlePrepared(true);
245
+ return ready;
242
246
  }),
243
247
  prepareLaunchContent(rootRef.current, contentPreparation.signal),
244
248
  ]);
@@ -489,7 +493,7 @@ export function SatinRoot({ background: legacyBackground, app: suppliedApp, offl
489
493
  return;
490
494
  return startDevFidelityAssertions(initialHistoryLength.current);
491
495
  }, []);
492
- return (_jsx(RuntimeContext, { value: runtime, children: _jsxs(MediaPlayerRoot, { onActivityChange: onMediaActivityChange, children: [_jsx("div", { ...props, ref: rootRef, className: className, "data-satin-role": "chrome", "data-satin-root": "true", "data-satin-launch": launch, "data-satin-presentation": presentationPending ? 'preparing' : 'ready', inert: launch !== 'ready' || presentationPending, "data-satin-layout-horizontal": nextLayout.layoutClass.horizontal, "data-satin-layout-vertical": nextLayout.layoutClass.vertical, style: geometryStyles, children: _jsx(AdaptiveTabsContext, { value: tabs, children: _jsx("div", { "data-satin-status-band-source": "scene", children: _jsxs(GlassMaterialRoot, { displacement: glassDisplacement, onLaunchReady: setGlassReady, children: [DeveloperMenu && app && launch === 'ready' ? (_jsx(Suspense, { fallback: null, children: _jsx(DeveloperMenu, {}) })) : null, app ? (_jsx(OrientationGuard, { app: app, width: metrics.safeFrame.width, height: metrics.safeFrame.height, children: _jsx(ReachabilityProvider, { profile: app, offlinePresentation: offlinePresentation, children: children }) })) : (children)] }) }) }) }), launch === 'failed'
496
+ return (_jsx(RuntimeContext, { value: runtime, children: _jsxs(MediaPlayerRoot, { onActivityChange: onMediaActivityChange, children: [_jsx("div", { ...props, ref: rootRef, className: className, "data-satin-role": "chrome", "data-satin-root": "true", "data-satin-launch": launch, "data-satin-presentation": presentationPending ? 'preparing' : 'ready', inert: launch !== 'ready' || presentationPending, "data-satin-layout-horizontal": nextLayout.layoutClass.horizontal, "data-satin-layout-vertical": nextLayout.layoutClass.vertical, style: geometryStyles, children: _jsx(AdaptiveTabsContext, { value: tabs, children: _jsx("div", { "data-satin-status-band-source": "scene", children: _jsxs(GlassMaterialRoot, { displacement: glassDisplacement, prepared: bundlePrepared, onLaunchReady: setGlassReady, children: [DeveloperMenu && app && launch === 'ready' ? (_jsx(Suspense, { fallback: null, children: _jsx(DeveloperMenu, {}) })) : null, app ? (_jsx(OrientationGuard, { app: app, width: metrics.safeFrame.width, height: metrics.safeFrame.height, children: _jsx(ReachabilityProvider, { profile: app, offlinePresentation: offlinePresentation, children: children }) })) : (children)] }) }) }) }), launch === 'failed'
493
497
  ? createPortal(_jsxs("div", { "data-satin-role": "chrome", "data-satin-launch-error": "true", "data-satin-glass-error": glassError ? true : undefined, role: "alert", style: { zIndex: launchErrorLayer }, children: [!glassError ? (_jsx("img", { className: "satin-launch-error-icon", src: document.querySelector('#satin-launch-screen .satin-launch-icon img')?.src ??
494
498
  app?.launchScreen?.icon ??
495
499
  app?.icons[0]?.src, alt: "" })) : null, _jsxs("div", { className: "satin-launch-error-message", children: [_jsx("h1", { children: glassError ? 'Glass Renderer Failed' : 'Unable to Open App' }), glassError ? (_jsx("pre", { style: { whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }, children: `${glassError.code} · ${glassError.stage}\n${glassError.message}${glassError.reason ? `\n${glassError.reason}` : ''}` })) : (_jsx("p", { children: preparationError })), _jsx("button", { type: "button", onClick: async () => {
@@ -39,7 +39,8 @@ self.addEventListener('install', (event) => {
39
39
  await reportProgress()
40
40
  const activeDownloads = new Set()
41
41
  let failureReport
42
- const download = async (entry) => {
42
+ const download = async (entry, attempt = 0) => {
43
+ let received = 0
43
44
  let networkFailure = true
44
45
  let stalled = false
45
46
  const controller = new AbortController()
@@ -76,7 +77,7 @@ self.addEventListener('install', (event) => {
76
77
  progress.activity++
77
78
  await reportProgress()
78
79
  if (installFailure) throw new Error('Installation cancelled')
79
- networkFailure = false
80
+ networkFailure = response.status >= 500 || response.status === 408 || response.status === 429
80
81
  if (!response.ok || response.type === 'opaque') throw new Error('HTTP ' + response.status)
81
82
  if (response.redirected) throw new Error('Precache resource redirected')
82
83
  const mime = (response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase()
@@ -86,7 +87,6 @@ self.addEventListener('install', (event) => {
86
87
  networkFailure = true
87
88
  reader = response.body?.getReader()
88
89
  const chunks = []
89
- let received = 0
90
90
  if (reader) {
91
91
  while (true) {
92
92
  const { done, value } = await Promise.race([reader.read(), idleFailure])
@@ -112,6 +112,15 @@ self.addEventListener('install', (event) => {
112
112
  if (installFailure) throw new Error('Installation cancelled')
113
113
  await cache.put(request, new Response(blob, { status: response.status, statusText: response.statusText, headers: response.headers }))
114
114
  } catch (error) {
115
+ if (!installFailure && (networkFailure || stalled) && attempt < 2) {
116
+ clearTimeout(idleTimer)
117
+ activeDownloads.delete(cancelDownload)
118
+ // Retransmitted bytes are not extra installed bytes.
119
+ progress.loaded -= received
120
+ await new Promise(resolve => setTimeout(resolve, 250 * 2 ** attempt))
121
+ if (installFailure) throw error
122
+ return download(entry, attempt + 1)
123
+ }
115
124
  if (!installFailure) {
116
125
  installFailure = { type: 'SATIN_INSTALL_FAILURE', buildId: BUILD_ID, url: entry.url,
117
126
  reason: error instanceof Error ? error.message : String(error), networkFailure, stalled }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@satinhq/kit",
3
- "version": "0.1.0-beta.1",
3
+ "version": "0.1.0-beta.2",
4
4
  "type": "module",
5
5
  "description": "Opinionated single-target framework for agent-generated personal iOS Home Screen apps.",
6
6
  "main": "./dist/index.js",