@swiftbrowser/web 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +600 -0
- package/app/index.html +125 -0
- package/index.html +56 -0
- package/node/dist/node/apps.d.ts +58 -0
- package/node/dist/node/apps.d.ts.map +1 -0
- package/node/dist/node/apps.js +60 -0
- package/node/dist/node/apps.js.map +1 -0
- package/node/dist/node/config.d.ts +42 -0
- package/node/dist/node/config.d.ts.map +1 -0
- package/node/dist/node/config.js +99 -0
- package/node/dist/node/config.js.map +1 -0
- package/node/dist/node/examples.d.ts +39 -0
- package/node/dist/node/examples.d.ts.map +1 -0
- package/node/dist/node/examples.js +116 -0
- package/node/dist/node/examples.js.map +1 -0
- package/node/dist/node/index.d.ts +47 -0
- package/node/dist/node/index.d.ts.map +1 -0
- package/node/dist/node/index.js +52 -0
- package/node/dist/node/index.js.map +1 -0
- package/node/dist/plugins/app-manifests.d.ts +61 -0
- package/node/dist/plugins/app-manifests.d.ts.map +1 -0
- package/node/dist/plugins/app-manifests.js +168 -0
- package/node/dist/plugins/app-manifests.js.map +1 -0
- package/node/dist/plugins/asset-catalogs.d.ts +119 -0
- package/node/dist/plugins/asset-catalogs.d.ts.map +1 -0
- package/node/dist/plugins/asset-catalogs.js +488 -0
- package/node/dist/plugins/asset-catalogs.js.map +1 -0
- package/node/dist/plugins/bundle-resources.d.ts +49 -0
- package/node/dist/plugins/bundle-resources.d.ts.map +1 -0
- package/node/dist/plugins/bundle-resources.js +172 -0
- package/node/dist/plugins/bundle-resources.js.map +1 -0
- package/node/dist/plugins/swift-wasm.d.ts +33 -0
- package/node/dist/plugins/swift-wasm.d.ts.map +1 -0
- package/node/dist/plugins/swift-wasm.js +241 -0
- package/node/dist/plugins/swift-wasm.js.map +1 -0
- package/node/dist/src/assets.d.ts +60 -0
- package/node/dist/src/assets.d.ts.map +1 -0
- package/node/dist/src/assets.js +54 -0
- package/node/dist/src/assets.js.map +1 -0
- package/node/dist/src/devEvents.d.ts +31 -0
- package/node/dist/src/devEvents.d.ts.map +1 -0
- package/node/dist/src/devEvents.js +9 -0
- package/node/dist/src/devEvents.js.map +1 -0
- package/package.json +56 -0
- package/plugins/app-manifests.ts +192 -0
- package/plugins/asset-catalogs.ts +558 -0
- package/plugins/bundle-resources.ts +195 -0
- package/plugins/swift-wasm.ts +259 -0
- package/public/_headers +23 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/icon.svg +14 -0
- package/public/manifest.webmanifest +14 -0
- package/src/animation.ts +521 -0
- package/src/assets.ts +86 -0
- package/src/chartDom.ts +481 -0
- package/src/devEvents.ts +35 -0
- package/src/deviceMode.ts +80 -0
- package/src/ghost.ts +25 -0
- package/src/interaction.ts +1113 -0
- package/src/landing.css +261 -0
- package/src/landing.ts +141 -0
- package/src/layout/chart.ts +477 -0
- package/src/layout/controls.ts +116 -0
- package/src/layout/engine.ts +3581 -0
- package/src/layout/index.ts +91 -0
- package/src/layout/measure.ts +86 -0
- package/src/layout/text.ts +124 -0
- package/src/layout/types.ts +153 -0
- package/src/layout/typography.ts +188 -0
- package/src/layoutDom.ts +549 -0
- package/src/layoutEvents.ts +110 -0
- package/src/locale.ts +64 -0
- package/src/main.ts +554 -0
- package/src/mock.ts +752 -0
- package/src/nodeQueries.ts +60 -0
- package/src/protocol.ts +1421 -0
- package/src/renderer.ts +2882 -0
- package/src/styles.css +2834 -0
- package/src/styles.ts +373 -0
- package/src/swipe.ts +110 -0
- package/src/symbols.ts +595 -0
- package/src/typography.ts +76 -0
- package/src/vite-env.d.ts +26 -0
- package/src/wasm.ts +400 -0
package/src/locale.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which locale and time zone the app runs with (docs/ops-protocol.md,
|
|
3
|
+
* "Phase 11"). The app follows the viewer like an iOS app follows the device,
|
|
4
|
+
* within the languages it ships: a browser language whose ICU data the app
|
|
5
|
+
* carries (`locales` in its spec, default English) is used with its region
|
|
6
|
+
* (`en-GB` → British dates), anything else falls back to the first shipped
|
|
7
|
+
* language rather than to ICU's root locale. `?locale=fr-FR` on the app URL
|
|
8
|
+
* overrides the browser, like the "App Language" option of an Xcode scheme.
|
|
9
|
+
*
|
|
10
|
+
* Shared by the page and the Node side (tests), so no DOM or Node APIs.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const TAG = /^[A-Za-z]{2,3}(?:[-_][A-Za-z0-9]{2,8})*$/;
|
|
14
|
+
|
|
15
|
+
/** The language subtag of a BCP 47 tag, lowercased; null for junk. */
|
|
16
|
+
export function languageOf(tag: string): string | null {
|
|
17
|
+
const trimmed = tag.trim();
|
|
18
|
+
if (!TAG.test(trimmed)) return null;
|
|
19
|
+
return trimmed.split(/[-_]/)[0]!.toLowerCase();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** `fr_FR`, `FR-fr` → `fr-FR`; script subtags title-cased (`zh-Hant-TW`). */
|
|
23
|
+
export function canonicalTag(tag: string): string {
|
|
24
|
+
const parts = tag.trim().split(/[-_]/);
|
|
25
|
+
return parts
|
|
26
|
+
.map((part, index) => {
|
|
27
|
+
if (index === 0) return part.toLowerCase();
|
|
28
|
+
if (part.length === 4) return part[0]!.toUpperCase() + part.slice(1).toLowerCase();
|
|
29
|
+
return part.toUpperCase();
|
|
30
|
+
})
|
|
31
|
+
.join('-');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The locale to run with: the override when given, else the first preferred
|
|
36
|
+
* language the app ships (`shipped` holds language tags; `*` ships every
|
|
37
|
+
* language), else the first shipped language itself (`en` → `en`), else
|
|
38
|
+
* nothing (the module keeps its default, `en_001`).
|
|
39
|
+
*/
|
|
40
|
+
export function chooseLocale(
|
|
41
|
+
preferred: readonly string[],
|
|
42
|
+
shipped: readonly string[] | undefined,
|
|
43
|
+
override?: string | null,
|
|
44
|
+
): string | null {
|
|
45
|
+
if (override && languageOf(override)) return canonicalTag(override);
|
|
46
|
+
const languages = (shipped ?? ['en']).map((tag) => (tag === '*' ? '*' : languageOf(tag))).filter((l): l is string => l !== null);
|
|
47
|
+
const all = languages.includes('*');
|
|
48
|
+
for (const tag of preferred) {
|
|
49
|
+
const language = languageOf(tag);
|
|
50
|
+
if (language && (all || languages.includes(language))) return canonicalTag(tag);
|
|
51
|
+
}
|
|
52
|
+
const first = languages.find((l) => l !== '*');
|
|
53
|
+
return first ?? null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The viewer's IANA time zone (`Europe/Paris`), or null when the runtime cannot say. */
|
|
57
|
+
export function currentTimeZone(): string | null {
|
|
58
|
+
try {
|
|
59
|
+
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
60
|
+
return zone && /^[A-Za-z0-9_+\-/]+$/.test(zone) ? zone : null;
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
/// <reference path="./vite-env.d.ts" />
|
|
2
|
+
import { easingFor, springEasing, timingFor } from './animation';
|
|
3
|
+
import type { BuildEndPayload, BuildErrorPayload, BuildStartPayload } from './devEvents';
|
|
4
|
+
import { applyPageMode, decidePageMode, readViewportInsets, watchViewport, type PageMode } from './deviceMode';
|
|
5
|
+
import { EMPTY_CATALOG, catalogPath, isAssetCatalog, type AssetCatalog } from './assets';
|
|
6
|
+
import { createMockApp } from './mock';
|
|
7
|
+
import {
|
|
8
|
+
horizontalSizeClassFor,
|
|
9
|
+
isDynamicTypeSize,
|
|
10
|
+
type ColorScheme,
|
|
11
|
+
type DynamicTypeSize,
|
|
12
|
+
type HorizontalSizeClass,
|
|
13
|
+
type RendererEvent,
|
|
14
|
+
type ScenePhase,
|
|
15
|
+
} from './protocol';
|
|
16
|
+
import { Renderer, RendererError } from './renderer';
|
|
17
|
+
import { DEFAULT_TYPE_SIZE, applyTypeSizeVariables } from './typography';
|
|
18
|
+
import { JobScheduler, loadApp, type AppHandle, type JobTimers } from './wasm';
|
|
19
|
+
import { chooseLocale, currentTimeZone } from './locale';
|
|
20
|
+
import type { BundleManifest } from '../plugins/bundle-resources';
|
|
21
|
+
|
|
22
|
+
const THEME_KEY = 'sb-theme';
|
|
23
|
+
const TYPE_SIZE_KEY = 'sb-type-size';
|
|
24
|
+
type Theme = ColorScheme;
|
|
25
|
+
|
|
26
|
+
function $<T extends HTMLElement>(selector: string): T {
|
|
27
|
+
const el = document.querySelector<T>(selector);
|
|
28
|
+
if (!el) throw new Error(`Missing element ${selector}`);
|
|
29
|
+
return el;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const device = $<HTMLElement>('#device');
|
|
33
|
+
const screen = $<HTMLElement>('#screen');
|
|
34
|
+
const appName = $<HTMLElement>('#app-name');
|
|
35
|
+
const themeToggle = $<HTMLButtonElement>('#theme-toggle');
|
|
36
|
+
const typeSizeSelect = $<HTMLSelectElement>('#type-size');
|
|
37
|
+
const status = $<HTMLElement>('#status');
|
|
38
|
+
const buildStatus = $<HTMLElement>('#build-status');
|
|
39
|
+
|
|
40
|
+
const params = new URLSearchParams(location.search);
|
|
41
|
+
const useMock = params.get('mock') === '1';
|
|
42
|
+
const requestedApp = (params.get('app') ?? 'Counter').replace(/[^A-Za-z0-9_-]/g, '') || 'Counter';
|
|
43
|
+
|
|
44
|
+
// Frame mode (the fake iPhone bezel, desktop and tests) or device mode (a
|
|
45
|
+
// real phone: full screen, real safe areas, OS status bar). See deviceMode.ts;
|
|
46
|
+
// the inline script in app/index.html pre-set the attribute with the same rule.
|
|
47
|
+
const pageMode: PageMode = decidePageMode();
|
|
48
|
+
applyPageMode(pageMode);
|
|
49
|
+
const deviceMode = pageMode === 'device';
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Renderer (constructed first: the theme and type size controls feed it)
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
let app: AppHandle | null = null;
|
|
56
|
+
/** The app's asset catalog manifest (empty until `boot` fetched it, or when the app has none). */
|
|
57
|
+
let assets: AssetCatalog = EMPTY_CATALOG;
|
|
58
|
+
/** JSON events handed to the app, newest last (debugging and e2e tests). */
|
|
59
|
+
const sentEvents: RendererEvent[] = [];
|
|
60
|
+
/**
|
|
61
|
+
* Events the renderer produced before the app handle existed (the `geometry`
|
|
62
|
+
* events of the very first layout pass, which runs inside `loadApp` /
|
|
63
|
+
* `createMockApp`); delivered right after boot.
|
|
64
|
+
*/
|
|
65
|
+
let queuedEvents: RendererEvent[] = [];
|
|
66
|
+
|
|
67
|
+
function dispatch(event: RendererEvent): void {
|
|
68
|
+
if (!app) {
|
|
69
|
+
queuedEvents.push(event);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
if (event.type === 'tap') {
|
|
74
|
+
app.sendEvent(event.id);
|
|
75
|
+
} else if (event.type === 'batch' && !app.supportsBatchEvents) {
|
|
76
|
+
// The mock apps handle events one at a time.
|
|
77
|
+
event.events.forEach(dispatch);
|
|
78
|
+
} else if (app.sendEventJson(event)) {
|
|
79
|
+
// Recorded flattened, so tests see the individual events.
|
|
80
|
+
if (event.type === 'batch') sentEvents.push(...event.events);
|
|
81
|
+
else sentEvents.push(event);
|
|
82
|
+
}
|
|
83
|
+
} catch (error) {
|
|
84
|
+
showError(error);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const renderer = new Renderer(screen, dispatch);
|
|
89
|
+
|
|
90
|
+
// Phase 12: `.preferredColorScheme` from the app overrides the viewer's theme
|
|
91
|
+
// for this page (not persisted); `null` goes back to the toggle / OS setting.
|
|
92
|
+
screen.addEventListener('sb:color-scheme', (event) => {
|
|
93
|
+
const scheme = (event as CustomEvent<{ colorScheme: 'light' | 'dark' | null }>).detail.colorScheme;
|
|
94
|
+
if (scheme === 'light' || scheme === 'dark') applyTheme(scheme, false);
|
|
95
|
+
else applyTheme(deviceMode ? systemTheme() : (readStoredTheme() ?? 'light'), false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Text measured before web fonts finished loading may be off; lay out again.
|
|
99
|
+
if (typeof document.fonts?.ready?.then === 'function') {
|
|
100
|
+
document.fonts.ready.then(() => renderer.fontsChanged()).catch(() => undefined);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Theme
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
function readStoredTheme(): Theme | null {
|
|
108
|
+
try {
|
|
109
|
+
const value = localStorage.getItem(THEME_KEY);
|
|
110
|
+
return value === 'light' || value === 'dark' ? value : null;
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function currentTheme(): Theme {
|
|
117
|
+
return device.dataset['theme'] === 'dark' ? 'dark' : 'light';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function applyTheme(theme: Theme, persist: boolean): void {
|
|
121
|
+
device.dataset['theme'] = theme;
|
|
122
|
+
themeToggle.setAttribute('aria-pressed', String(theme === 'dark'));
|
|
123
|
+
themeToggle.textContent = theme === 'dark' ? 'Light mode' : 'Dark mode';
|
|
124
|
+
renderer.setEnvironment({ colorScheme: theme });
|
|
125
|
+
if (persist) {
|
|
126
|
+
try {
|
|
127
|
+
localStorage.setItem(THEME_KEY, theme);
|
|
128
|
+
} catch {
|
|
129
|
+
// Storage may be unavailable (private mode); the toggle still works.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The OS appearance (device mode follows it; the toolbar toggle is hidden there). */
|
|
135
|
+
const darkScheme = typeof matchMedia === 'function' ? matchMedia('(prefers-color-scheme: dark)') : null;
|
|
136
|
+
const systemTheme = (): Theme => (darkScheme?.matches ? 'dark' : 'light');
|
|
137
|
+
|
|
138
|
+
if (deviceMode) {
|
|
139
|
+
applyTheme(systemTheme(), false);
|
|
140
|
+
darkScheme?.addEventListener('change', () => {
|
|
141
|
+
applyTheme(systemTheme(), false);
|
|
142
|
+
sendEnvironment();
|
|
143
|
+
});
|
|
144
|
+
} else {
|
|
145
|
+
applyTheme(readStoredTheme() ?? 'light', false);
|
|
146
|
+
}
|
|
147
|
+
themeToggle.addEventListener('click', () => {
|
|
148
|
+
applyTheme(currentTheme() === 'dark' ? 'light' : 'dark', true);
|
|
149
|
+
sendEnvironment();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// Dynamic Type
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
function readStoredTypeSize(): DynamicTypeSize | null {
|
|
157
|
+
try {
|
|
158
|
+
const value = localStorage.getItem(TYPE_SIZE_KEY);
|
|
159
|
+
return isDynamicTypeSize(value) ? value : null;
|
|
160
|
+
} catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function currentTypeSize(): DynamicTypeSize {
|
|
166
|
+
const value = device.dataset['typeSize'];
|
|
167
|
+
return isDynamicTypeSize(value) ? value : DEFAULT_TYPE_SIZE;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Select a Dynamic Type size: the device records it (`data-type-size`), the
|
|
172
|
+
* text style CSS variables are rewritten so styled text rescales at once,
|
|
173
|
+
* and Swift is told through an `environment` event (by the caller).
|
|
174
|
+
*/
|
|
175
|
+
function applyTypeSize(size: DynamicTypeSize, persist: boolean): void {
|
|
176
|
+
device.dataset['typeSize'] = size;
|
|
177
|
+
applyTypeSizeVariables(device, size);
|
|
178
|
+
// The layout engine resolves text styles for this size and re-lays out.
|
|
179
|
+
renderer.setEnvironment({ dynamicTypeSize: size });
|
|
180
|
+
if (typeSizeSelect.value !== size) typeSizeSelect.value = size;
|
|
181
|
+
if (persist) {
|
|
182
|
+
try {
|
|
183
|
+
localStorage.setItem(TYPE_SIZE_KEY, size);
|
|
184
|
+
} catch {
|
|
185
|
+
// Storage may be unavailable; the control still works for the session.
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Device mode has no Text size control and always reports `large`.
|
|
191
|
+
applyTypeSize(deviceMode ? DEFAULT_TYPE_SIZE : (readStoredTypeSize() ?? DEFAULT_TYPE_SIZE), false);
|
|
192
|
+
typeSizeSelect.addEventListener('change', () => {
|
|
193
|
+
const value = typeSizeSelect.value;
|
|
194
|
+
applyTypeSize(isDynamicTypeSize(value) ? value : DEFAULT_TYPE_SIZE, true);
|
|
195
|
+
sendEnvironment();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// Device mode: real viewport and safe areas
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Phase 7: the horizontal size class Swift sees (`regular` from 700 CSS px of
|
|
204
|
+
* screen width). The frame is always compact; device mode follows the viewport.
|
|
205
|
+
*/
|
|
206
|
+
function currentSizeClass(): HorizontalSizeClass {
|
|
207
|
+
return horizontalSizeClassFor(screen.clientWidth);
|
|
208
|
+
}
|
|
209
|
+
/** The size class last sent in an `environment` event (null before the first). */
|
|
210
|
+
let sentSizeClass: HorizontalSizeClass | null = null;
|
|
211
|
+
|
|
212
|
+
if (deviceMode) {
|
|
213
|
+
// `#screen` fills the viewport (styles.css); the engine reads its size on
|
|
214
|
+
// every pass. The safe areas come from env(safe-area-inset-*): non-zero
|
|
215
|
+
// only in standalone (home screen) mode or landscape, which is what the
|
|
216
|
+
// real app would get.
|
|
217
|
+
renderer.setSafeArea(readViewportInsets());
|
|
218
|
+
let lastSize = `${screen.clientWidth}x${screen.clientHeight}`;
|
|
219
|
+
watchViewport(() => {
|
|
220
|
+
renderer.setSafeArea(readViewportInsets());
|
|
221
|
+
const size = `${screen.clientWidth}x${screen.clientHeight}`;
|
|
222
|
+
if (size !== lastSize) {
|
|
223
|
+
lastSize = size;
|
|
224
|
+
renderer.viewportChanged();
|
|
225
|
+
}
|
|
226
|
+
// A rotation or window resize may cross the compact / regular threshold.
|
|
227
|
+
if (sentSizeClass !== null && currentSizeClass() !== sentSizeClass) sendEnvironment();
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
// Phase 8: scene phase
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* `ScenePhase` from the document: `background` while hidden, else `active`
|
|
237
|
+
* when the window has focus and `inactive` when it does not.
|
|
238
|
+
*/
|
|
239
|
+
function currentScenePhase(): ScenePhase {
|
|
240
|
+
if (document.visibilityState === 'hidden') return 'background';
|
|
241
|
+
return document.hasFocus() ? 'active' : 'inactive';
|
|
242
|
+
}
|
|
243
|
+
/** The scene phase last sent in an `environment` event (null before the first). */
|
|
244
|
+
let sentScenePhase: ScenePhase | null = null;
|
|
245
|
+
|
|
246
|
+
const scenePhaseMayHaveChanged = (): void => {
|
|
247
|
+
if (sentScenePhase !== null && currentScenePhase() !== sentScenePhase) sendEnvironment();
|
|
248
|
+
};
|
|
249
|
+
document.addEventListener('visibilitychange', scenePhaseMayHaveChanged);
|
|
250
|
+
window.addEventListener('focus', scenePhaseMayHaveChanged);
|
|
251
|
+
window.addEventListener('blur', scenePhaseMayHaveChanged);
|
|
252
|
+
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
// App
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Tell Swift which color scheme, Dynamic Type size and (Phase 7) horizontal
|
|
259
|
+
* size class the device shows (no-op for Phase 1 modules; Phase 2 modules
|
|
260
|
+
* ignore `dynamicTypeSize`, pre-Phase 7 modules the size class).
|
|
261
|
+
*/
|
|
262
|
+
function sendEnvironment(): void {
|
|
263
|
+
if (!app?.supportsJsonEvents) return;
|
|
264
|
+
const horizontalSizeClass = currentSizeClass();
|
|
265
|
+
sentSizeClass = horizontalSizeClass;
|
|
266
|
+
// Phase 8: every environment event carries the scene phase.
|
|
267
|
+
const scenePhase = currentScenePhase();
|
|
268
|
+
sentScenePhase = scenePhase;
|
|
269
|
+
dispatch({
|
|
270
|
+
type: 'environment',
|
|
271
|
+
colorScheme: currentTheme(),
|
|
272
|
+
dynamicTypeSize: currentTypeSize(),
|
|
273
|
+
horizontalSizeClass,
|
|
274
|
+
scenePhase,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Deliver the events queued while the app was loading (after `environment`). */
|
|
279
|
+
function flushQueuedEvents(): void {
|
|
280
|
+
const queued = queuedEvents;
|
|
281
|
+
queuedEvents = [];
|
|
282
|
+
for (const event of queued) dispatch(event);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function showError(error: unknown): void {
|
|
286
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
287
|
+
status.textContent = message;
|
|
288
|
+
status.hidden = false;
|
|
289
|
+
device.dataset['state'] = 'error';
|
|
290
|
+
console.error(error);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function clearError(): void {
|
|
294
|
+
status.hidden = true;
|
|
295
|
+
status.textContent = '';
|
|
296
|
+
if (device.dataset['state'] === 'error') device.dataset['state'] = app ? 'ready' : 'loading';
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function setLoaded(name: string): void {
|
|
300
|
+
appName.textContent = name;
|
|
301
|
+
status.hidden = true;
|
|
302
|
+
device.dataset['state'] = 'ready';
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Phase 5: the app's asset catalog manifest (`/catalog/<App>.json`, see
|
|
307
|
+
* plugins/asset-catalogs.ts). An app without a catalog gets a 404, and a
|
|
308
|
+
* missing or broken manifest must never keep the app from loading, so every
|
|
309
|
+
* failure yields the empty catalog.
|
|
310
|
+
*/
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
// Phase 11: locale, time zone, saved defaults and the resource bundle
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
const defaultsKey = `sb-defaults:${requestedApp}`;
|
|
316
|
+
|
|
317
|
+
/** The saved `UserDefaults` of this app, as the JSON the module reads from `SB_DEFAULTS`. */
|
|
318
|
+
function readStoredDefaults(): string | null {
|
|
319
|
+
try {
|
|
320
|
+
const text = localStorage.getItem(defaultsKey);
|
|
321
|
+
if (text === null) return null;
|
|
322
|
+
const parsed: unknown = JSON.parse(text);
|
|
323
|
+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? text : null;
|
|
324
|
+
} catch {
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function storeDefaults(values: Record<string, unknown>): void {
|
|
330
|
+
try {
|
|
331
|
+
if (Object.keys(values).length === 0) localStorage.removeItem(defaultsKey);
|
|
332
|
+
else localStorage.setItem(defaultsKey, JSON.stringify(values));
|
|
333
|
+
} catch (error) {
|
|
334
|
+
console.warn('[sb] could not store UserDefaults', error);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** The app's entry in the registry (`locales`), when the page knows it. */
|
|
339
|
+
function registeredApp(): SBLandingApp | undefined {
|
|
340
|
+
try {
|
|
341
|
+
return __SB_APPS__.find((entry: SBLandingApp) => entry.name === requestedApp);
|
|
342
|
+
} catch {
|
|
343
|
+
return undefined;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* The WASI environment for this launch: the locale the app runs in (the
|
|
349
|
+
* viewer's language when the module carries its data, `?locale=` to override),
|
|
350
|
+
* the viewer's time zone, the saved defaults and the app's name.
|
|
351
|
+
*/
|
|
352
|
+
function launchEnvironment(): string[] {
|
|
353
|
+
const env: string[] = [`SB_APP_NAME=${requestedApp}`];
|
|
354
|
+
const locale = chooseLocale(navigator.languages ?? [navigator.language], registeredApp()?.locales, params.get('locale'));
|
|
355
|
+
if (locale) env.push(`SB_LOCALE=${locale}`);
|
|
356
|
+
const zone = currentTimeZone();
|
|
357
|
+
if (zone) env.push(`TZ=${zone}`);
|
|
358
|
+
const defaults = readStoredDefaults();
|
|
359
|
+
if (defaults !== null) env.push(`SB_DEFAULTS=${defaults}`);
|
|
360
|
+
return env;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** The app's resources (`/bundle/<App>.json` and its files), for the WASI `/bundle` mount; empty without a manifest. */
|
|
364
|
+
async function loadBundle(app: string): Promise<Map<string, Uint8Array>> {
|
|
365
|
+
const files = new Map<string, Uint8Array>();
|
|
366
|
+
try {
|
|
367
|
+
const response = await fetch(`${import.meta.env.BASE_URL}bundle/${app}.json`, { cache: 'no-cache' });
|
|
368
|
+
if (!response.ok) return files;
|
|
369
|
+
const manifest = (await response.json()) as BundleManifest;
|
|
370
|
+
if (!Array.isArray(manifest.files)) return files;
|
|
371
|
+
await Promise.all(
|
|
372
|
+
manifest.files.map(async (file) => {
|
|
373
|
+
const url = `${import.meta.env.BASE_URL}bundle/${app}/${file.path.split('/').map(encodeURIComponent).join('/')}`;
|
|
374
|
+
const data = await fetch(url, { cache: 'no-cache' });
|
|
375
|
+
if (data.ok) files.set(file.path, new Uint8Array(await data.arrayBuffer()));
|
|
376
|
+
else console.warn(`[sb] resource ${file.path} of ${app}: HTTP ${data.status}`);
|
|
377
|
+
}),
|
|
378
|
+
);
|
|
379
|
+
} catch (error) {
|
|
380
|
+
console.warn(`[sb] could not load the resources of ${app}`, error);
|
|
381
|
+
}
|
|
382
|
+
return files;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function loadAssetCatalog(app: string): Promise<AssetCatalog> {
|
|
386
|
+
try {
|
|
387
|
+
const response = await fetch(catalogPath(app), { cache: 'no-cache' });
|
|
388
|
+
if (!response.ok) return EMPTY_CATALOG;
|
|
389
|
+
const json: unknown = await response.json();
|
|
390
|
+
return isAssetCatalog(json) ? json : EMPTY_CATALOG;
|
|
391
|
+
} catch {
|
|
392
|
+
return EMPTY_CATALOG;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ---------------------------------------------------------------------------
|
|
397
|
+
// Hot reload: state snapshot across full reloads
|
|
398
|
+
// ---------------------------------------------------------------------------
|
|
399
|
+
|
|
400
|
+
const stateKey = `sb-state:${requestedApp}`;
|
|
401
|
+
|
|
402
|
+
/** Store the app's `@State` snapshot so the next boot can restore it. */
|
|
403
|
+
function stashState(): void {
|
|
404
|
+
if (!app || useMock) return;
|
|
405
|
+
try {
|
|
406
|
+
const snapshot = app.snapshotState();
|
|
407
|
+
if (snapshot) sessionStorage.setItem(stateKey, snapshot);
|
|
408
|
+
else sessionStorage.removeItem(stateKey);
|
|
409
|
+
} catch (error) {
|
|
410
|
+
console.warn('[sb] could not snapshot state', error);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Read and delete a stashed snapshot (it is only ever used once). */
|
|
415
|
+
function takeStashedState(): string | null {
|
|
416
|
+
try {
|
|
417
|
+
const snapshot = sessionStorage.getItem(stateKey);
|
|
418
|
+
if (snapshot !== null) sessionStorage.removeItem(stateKey);
|
|
419
|
+
return snapshot;
|
|
420
|
+
} catch {
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
window.addEventListener('beforeunload', stashState);
|
|
426
|
+
window.addEventListener('pagehide', stashState);
|
|
427
|
+
|
|
428
|
+
if (import.meta.hot) {
|
|
429
|
+
import.meta.hot.on('vite:beforeFullReload', () => stashState());
|
|
430
|
+
import.meta.hot.on('sb:build-start', (payload: BuildStartPayload) => {
|
|
431
|
+
buildStatus.textContent = `Rebuilding ${payload.apps.join(', ')}…`;
|
|
432
|
+
buildStatus.dataset['state'] = 'building';
|
|
433
|
+
buildStatus.hidden = false;
|
|
434
|
+
});
|
|
435
|
+
import.meta.hot.on('sb:build-end', (payload: BuildEndPayload) => {
|
|
436
|
+
if (payload.ok) {
|
|
437
|
+
buildStatus.textContent = `Built ${payload.apps.join(', ')} in ${(payload.durationMs / 1000).toFixed(1)}s`;
|
|
438
|
+
buildStatus.dataset['state'] = 'ok';
|
|
439
|
+
clearError();
|
|
440
|
+
// A full reload follows; the indicator is hidden again on the fresh page.
|
|
441
|
+
} else {
|
|
442
|
+
buildStatus.textContent = 'Build failed';
|
|
443
|
+
buildStatus.dataset['state'] = 'error';
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
import.meta.hot.on('sb:build-error', (payload: BuildErrorPayload) => {
|
|
447
|
+
// Keep the page usable: the running app stays up, the banner shows why
|
|
448
|
+
// the new build did not land.
|
|
449
|
+
status.textContent = `Build failed for ${payload.app}:\n${payload.output.trim()}`;
|
|
450
|
+
status.hidden = false;
|
|
451
|
+
console.error(`[sb] build failed for ${payload.app}\n${payload.output}`);
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
// Boot
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
|
|
459
|
+
async function boot(): Promise<void> {
|
|
460
|
+
const onOps = (ops: Parameters<Renderer['applyOps']>[0]): void => {
|
|
461
|
+
try {
|
|
462
|
+
renderer.applyOps(ops);
|
|
463
|
+
} catch (error) {
|
|
464
|
+
if (error instanceof RendererError) showError(error);
|
|
465
|
+
throw error;
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
// The catalog comes first: the first render pass already needs image sizes
|
|
470
|
+
// and colors. The mock app has no catalog (the fetch yields the empty one).
|
|
471
|
+
assets = await loadAssetCatalog(requestedApp);
|
|
472
|
+
renderer.setAssetCatalog(assets);
|
|
473
|
+
|
|
474
|
+
if (useMock) {
|
|
475
|
+
app = createMockApp(onOps, requestedApp);
|
|
476
|
+
setLoaded(`${requestedApp} (mock)`);
|
|
477
|
+
sendEnvironment();
|
|
478
|
+
flushQueuedEvents();
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const url = `${import.meta.env.BASE_URL}${requestedApp}.wasm`;
|
|
483
|
+
appName.textContent = `Loading ${requestedApp}…`;
|
|
484
|
+
device.dataset['state'] = 'loading';
|
|
485
|
+
const env = launchEnvironment();
|
|
486
|
+
const restored = takeStashedState();
|
|
487
|
+
if (restored !== null) {
|
|
488
|
+
env.push(`SB_STATE=${restored}`);
|
|
489
|
+
console.info(`[sb] restoring state for ${requestedApp}: ${restored}`);
|
|
490
|
+
}
|
|
491
|
+
const bundle = await loadBundle(requestedApp);
|
|
492
|
+
app = await loadApp(url, onOps, { args: [requestedApp], env, bundle, onDefaults: storeDefaults });
|
|
493
|
+
setLoaded(requestedApp);
|
|
494
|
+
sendEnvironment();
|
|
495
|
+
flushQueuedEvents();
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
boot().catch(showError);
|
|
499
|
+
|
|
500
|
+
// Debug handle for the console and e2e tests.
|
|
501
|
+
declare global {
|
|
502
|
+
interface Window {
|
|
503
|
+
__sb: {
|
|
504
|
+
renderer: Renderer;
|
|
505
|
+
app: () => AppHandle | null;
|
|
506
|
+
/** `frame` (fake iPhone bezel) or `device` (full screen on a real phone). */
|
|
507
|
+
mode: PageMode;
|
|
508
|
+
/** Non-tap events that reached the app through the JSON channel. */
|
|
509
|
+
sentEvents: readonly RendererEvent[];
|
|
510
|
+
/** Current device appearance, as sent in `environment` events. */
|
|
511
|
+
colorScheme: () => ColorScheme;
|
|
512
|
+
/** Current Dynamic Type size, as sent in `environment` events. */
|
|
513
|
+
typeSize: () => DynamicTypeSize;
|
|
514
|
+
/** Phase 7: the horizontal size class of `#screen` (`regular` from 700 CSS px), as sent in `environment` events. */
|
|
515
|
+
sizeClass: () => HorizontalSizeClass;
|
|
516
|
+
/** Phase 8: the scene phase from the document's visibility and focus, as sent in `environment` events. */
|
|
517
|
+
scenePhase: () => ScenePhase;
|
|
518
|
+
/** Current `@State` snapshot (null for mocks without one / Phase 1 modules). */
|
|
519
|
+
snapshotState: () => string | null;
|
|
520
|
+
/** Phase 5: the asset catalog manifest the renderer resolves named images and colors with. */
|
|
521
|
+
assets: () => AssetCatalog;
|
|
522
|
+
/** Animation → CSS timing helpers (src/animation.ts), for inspection and tests. */
|
|
523
|
+
animation: { easingFor: typeof easingFor; timingFor: typeof timingFor; springEasing: typeof springEasing };
|
|
524
|
+
/**
|
|
525
|
+
* Phase 4: run the app's executor now (`sb_run_jobs`), apply its ops and
|
|
526
|
+
* reschedule. Returns the delay until the next timer, or null when the
|
|
527
|
+
* module (or mock) has no executor.
|
|
528
|
+
*/
|
|
529
|
+
runJobs: () => number | null;
|
|
530
|
+
/** The executor driver class, so tests can run it against a stub. */
|
|
531
|
+
JobScheduler: new (
|
|
532
|
+
run: (nowMs: number) => number,
|
|
533
|
+
apply: () => void,
|
|
534
|
+
timers?: JobTimers,
|
|
535
|
+
onError?: (error: unknown) => void,
|
|
536
|
+
) => JobScheduler;
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
window.__sb = {
|
|
541
|
+
renderer,
|
|
542
|
+
app: () => app,
|
|
543
|
+
mode: pageMode,
|
|
544
|
+
sentEvents,
|
|
545
|
+
colorScheme: currentTheme,
|
|
546
|
+
typeSize: currentTypeSize,
|
|
547
|
+
sizeClass: currentSizeClass,
|
|
548
|
+
scenePhase: currentScenePhase,
|
|
549
|
+
snapshotState: () => app?.snapshotState() ?? null,
|
|
550
|
+
assets: () => assets,
|
|
551
|
+
animation: { easingFor, timingFor, springEasing },
|
|
552
|
+
runJobs: () => app?.runJobs() ?? null,
|
|
553
|
+
JobScheduler,
|
|
554
|
+
};
|