lecodes-viewer 0.19.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 (103) hide show
  1. package/README.md +5 -0
  2. package/dist-embed/VERSION.json +5 -0
  3. package/dist-embed/assets/Roboto-Variable-DHm9jpd-.woff2 +0 -0
  4. package/dist-embed/assets/__vite-browser-external-BIHI7g3E.js +1 -0
  5. package/dist-embed/assets/basis-C64VHDVD.js +1 -0
  6. package/dist-embed/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
  7. package/dist-embed/assets/createViewer2d-D12Wy3VF.js +1 -0
  8. package/dist-embed/assets/createViewerLite-CP-WHGLg.js +707 -0
  9. package/dist-embed/assets/creator-gl-BF2CQzeI.wasm +0 -0
  10. package/dist-embed/assets/creator-gl.js-B_0GrQRe.symbols +5379 -0
  11. package/dist-embed/assets/creator-ui-BX5Fvnbt.js +1 -0
  12. package/dist-embed/assets/creator-ui-CzKHLH2Q.wasm +0 -0
  13. package/dist-embed/assets/creator2d-BtQ-1_B0.wasm +0 -0
  14. package/dist-embed/assets/draco-BiISTFcR.js +118 -0
  15. package/dist-embed/assets/draco_decoder-DsQ12WqX.wasm +0 -0
  16. package/dist-embed/assets/host-CWKB1WDd.js +162 -0
  17. package/dist-embed/assets/index-CIK8PnUA.js +2 -0
  18. package/dist-embed/assets/lit-DUE9rhY9.filamat +0 -0
  19. package/dist-embed/assets/loadCreatorUiNode-UAdhjRTd.js +1 -0
  20. package/dist-embed/assets/neutral_ibl128-D-lzdkLN.ktx +0 -0
  21. package/dist-embed/assets/particles-DxYDmI1O.filamat +0 -0
  22. package/dist-embed/assets/particles-quad-DwYo9yCK.filamat +0 -0
  23. package/dist-embed/assets/shadow-C9xJpqlY.filamat +0 -0
  24. package/dist-embed/assets/uberarchive-uXjRYa9p.bin +0 -0
  25. package/dist-embed/assets/unlit-pe4tFY5b.filamat +0 -0
  26. package/dist-embed/assets/unlit-transparent-D9OyaOBR.filamat +0 -0
  27. package/dist-embed/assets/video-eedvhMvS.filamat +0 -0
  28. package/dist-embed/assets/worker-Caf-yYEI.js +2 -0
  29. package/dist-embed/embed.html +48 -0
  30. package/dist-embed/embed.js +187 -0
  31. package/embed.html +48 -0
  32. package/package.json +35 -0
  33. package/src/assets/banner.png +0 -0
  34. package/src/assets/image2.png +0 -0
  35. package/src/assets/landing.mp4 +0 -0
  36. package/src/assets/neutral_ibl128.ktx +0 -0
  37. package/src/assets/neutral_ibl256.ktx +0 -0
  38. package/src/assets/output.mp4 +0 -0
  39. package/src/assets/output2.mp4 +0 -0
  40. package/src/assets/texture.png +0 -0
  41. package/src/assets/vue.svg +1 -0
  42. package/src/canvas/createCanvasHost.ts +2 -0
  43. package/src/createViewer.ts +103 -0
  44. package/src/embed.ts +515 -0
  45. package/src/host.ts +428 -0
  46. package/src/lite-test.ts +160 -0
  47. package/src/main.ts +77 -0
  48. package/src/sourcemapError.ts +2 -0
  49. package/src/ui/CuiProps.gen.ts +103 -0
  50. package/src/ui/UIScreen.ts +486 -0
  51. package/src/ui/createBareUI.ts +32 -0
  52. package/src/ui/createMeasureSystem.ts +220 -0
  53. package/src/ui/createNodeElement.ts +77 -0
  54. package/src/ui/createPagerSystem.ts +345 -0
  55. package/src/ui/createTouchSystem.ts +2 -0
  56. package/src/ui/createUI.ts +806 -0
  57. package/src/ui/creator-ui.js +14 -0
  58. package/src/ui/creator-ui.wasm +0 -0
  59. package/src/ui/defaultStyle.css +176 -0
  60. package/src/ui/layers.ts +9 -0
  61. package/src/ui/mountCanvasUI.ts +213 -0
  62. package/src/ui/nativeViewSystem.ts +91 -0
  63. package/src/ui/presentPlayer.ts +156 -0
  64. package/src/ui/props-apply.ts +403 -0
  65. package/src/ui/router.ts +2 -0
  66. package/src/ui/safeAreaSpec.ts +3 -0
  67. package/src/ui/shells.ts +37 -0
  68. package/src/ui/toasts.ts +2 -0
  69. package/src/ui/transitions.ts +250 -0
  70. package/src/ui/ui.d.ts +78 -0
  71. package/src/ui/vListSystem.ts +212 -0
  72. package/src/ui/viewScale.ts +2 -0
  73. package/src/utils/animate.ts +2 -0
  74. package/src/utils/createUtils.ts +2 -0
  75. package/src/utils/fetch.ts +3 -0
  76. package/src/utils/fonts.ts +2 -0
  77. package/src/utils/globals.ts +2 -0
  78. package/src/utils/imageNode.ts +260 -0
  79. package/src/utils/openFilePicker.ts +2 -0
  80. package/src/utils/svg.ts +2 -0
  81. package/src/utils/videoNode.ts +2 -0
  82. package/src/utils/ws.ts +2 -0
  83. package/src/viewer/createViewer.ts +993 -0
  84. package/src/viewer/creator-gl.js +14 -0
  85. package/src/viewer/creator-gl.js.symbols +5379 -0
  86. package/src/viewer/creator-gl.wasm +0 -0
  87. package/src/viewer/lit.filamat +0 -0
  88. package/src/viewer/mediaplayer.ts +3 -0
  89. package/src/viewer/particles-quad.filamat +0 -0
  90. package/src/viewer/particles.filamat +0 -0
  91. package/src/viewer/physics.ts +140 -0
  92. package/src/viewer/shadow.filamat +0 -0
  93. package/src/viewer/uberarchive.bin +0 -0
  94. package/src/viewer/unlit-transparent.filamat +0 -0
  95. package/src/viewer/unlit.filamat +0 -0
  96. package/src/viewer/validateGlb.ts +262 -0
  97. package/src/viewer/video.filamat +0 -0
  98. package/src/viewer/wasmCrashSymbolizer.ts +133 -0
  99. package/src/viewer2d/createViewer2d.ts +438 -0
  100. package/src/viewer2d/creator2d.js +14 -0
  101. package/src/viewer2d/creator2d.wasm +0 -0
  102. package/src/viewer2d/sprite2d.ts +80 -0
  103. package/src/vite-env.d.ts +1 -0
package/src/embed.ts ADDED
@@ -0,0 +1,515 @@
1
+ import { createHost, type EngineMode, type CreatorMethods } from './host'
2
+ import type { PreviewError } from './sourcemapError'
3
+ import { parseFontsHeader, type BootFont } from './utils/fonts'
4
+ import { createUI } from './ui/createUI'
5
+ import { createCanvasUIRenderer } from './ui/mountCanvasUI'
6
+ import type { SafeAreaSpec } from './ui/safeAreaSpec'
7
+ import { SHELL_SAFE_AREAS, asShell, type SimulatorShell } from './ui/shells'
8
+
9
+ declare const __LECODES_EMBED_VERSION__: string | undefined
10
+
11
+ /*
12
+ * The simulator runner — one iframe-hosted host that drives both the landing's live demos and (via
13
+ * the same-origin window API) the editor's preview. Built by vite.embed.config.ts into a
14
+ * self-contained static folder (relative asset paths), iframed by embedders:
15
+ *
16
+ * <iframe src="/embed/?src=/examples/<slug>/app.js&zoom=0.75">
17
+ *
18
+ * A PUBLISHED platform app runs the same way with `?app=<uuid>` (docs/publish-plan.md): the runner
19
+ * reads the project's public policy (`/api/publish/<uuid>`), refuses when the browser/embed channel
20
+ * is off (or the embedding site isn't allowed), then fetches `/code/<uuid>.js` — same-origin, so the
21
+ * usual `isSafeSrc` rule holds. `?engine=lite|full` overrides the policy's engine: `lite` is the
22
+ * viewer-lite host (CSS-flexbox UI + OGL 3D, no wasm — what phones get), `full` is this runner's
23
+ * Filament/yoga host. See runPublished().
24
+ *
25
+ * The runner is drivable two ways, both funnelling through the single `mount()` primitive:
26
+ *
27
+ * 1. URL + postMessage (the landing). `?src=` boots a same-origin bundle; the embedder can then
28
+ * SWITCH bundles without reloading the iframe via
29
+ * iframe.contentWindow.postMessage({ type: 'lecodes-run', src: '/examples/…' }, iframeOrigin)
30
+ * and change language / display zoom / device shell with { type: 'lecodes-lang', lang } /
31
+ * { type: 'lecodes-zoom', zoom } / { type: 'lecodes-shell', shell }. Only the direct embedder
32
+ * (the parent frame) is trusted to post — see the message handler. The runner posts BACK
33
+ * { type: 'lecodes-orientation', orientation: 'landscape' | 'portrait' | 'auto' } when the app
34
+ * calls app.setOrientation — the embedder owns the phone frame, so it is the one to turn it.
35
+ *
36
+ * 2. A direct same-origin window API (the editor):
37
+ * iframe.contentWindow.lecodesEmbed.mount({ code, methods, safeArea, ui, zoom, keyboardTargets })
38
+ * returns the live host handles (uiRoot etc.) for cross-frame inspection. No RPC.
39
+ *
40
+ * Crispness: the app is displayed at a fixed CSS `zoom` (default 0.75, `?zoom=` / mount({zoom}) to
41
+ * override) so the preview matches a real device while the app keeps its true logical viewport — no
42
+ * blurry `transform: scale()`. The engines size their backing stores to real device pixels via the
43
+ * host's resolution factor (devicePixelRatio × zoom); see host.ts / docs/simulator-plan.md.
44
+ *
45
+ * A switch is a clean reset: the current host is destroyed (engine state isn't shareable between
46
+ * bundles) and the new bundle mounts fresh in the same document, so the runtime's JS/wasm/fonts stay
47
+ * warm and no new iframe is needed.
48
+ *
49
+ * Top-level form (no iframe): a page may load `embed.js` (stable entry name of the built runner)
50
+ * as its own module after setting `window.lecodesEmbedConfig` — same options as the URL params.
51
+ * The published page uses it for phones (docs/publish-plan.md §D3): the app then IS the document,
52
+ * so browser viewport/keyboard/fullscreen behave as for any first-class page.
53
+ *
54
+ * The #loader spinner (embed.html) covers engine bring-up and every switch. Lazy-start policy
55
+ * (autorun on scroll, Play for 3D) belongs to the embedder — it simply delays creating the iframe
56
+ * (or the first run message); the runner runs whatever it's given immediately.
57
+ */
58
+ const root = document.getElementById('app')!
59
+ const loader = document.getElementById('loader')
60
+
61
+ // Runner options come from the URL (`?src=…&zoom=…`, the iframe form) — or, when a page loads the
62
+ // runner as its own top-level module (`<script type="module" src="/embed/embed.js">` after setting
63
+ // `window.lecodesEmbedConfig = { app, engine, lang, zoom, shell }`), from that config; it wins over
64
+ // the URL. That's how the published page runs an app fullscreen on a phone with no iframe: the
65
+ // page IS the runner document, and only needs a `#app` (plus an optional `#loader`) in its markup.
66
+ const params = new URLSearchParams(location.search)
67
+ const pageConfig: Record<string, unknown> = (() => {
68
+ const cfg = (window as any).lecodesEmbedConfig
69
+ return cfg && typeof cfg === 'object' ? cfg : {}
70
+ })()
71
+ for (const [k, v] of Object.entries(pageConfig)) {
72
+ if (v !== undefined && v !== null && typeof v !== 'object') params.set(k, String(v))
73
+ }
74
+
75
+ // The simulator language, surfaced to the runtime through the same `window.getLanguage()` hook host
76
+ // apps use (viewer createUtils reads it live). Seeded from `?lang=`, updated by setLang / messages.
77
+ let currentLang = params.get('lang') || 'en'
78
+ ;(window as unknown as { getLanguage: () => string }).getLanguage = () => currentLang
79
+
80
+ // app.setOrientation in the iframe form: the embedder owns the phone frame, so tell it (any origin —
81
+ // the landing serves the runner from the CDN, and the payload is just the mode). Top-level form
82
+ // (the published phone page): undefined → createUtils tries screen.orientation.lock itself.
83
+ const orientationSink = window.parent !== window
84
+ ? (mode: string) => window.parent.postMessage({ type: 'lecodes-orientation', orientation: mode }, '*')
85
+ : undefined
86
+
87
+ // The host currently mounted through the URL/postMessage driver (declared early so applyZoom can nudge
88
+ // it on a live zoom change). The editor's direct mount() manages its own handle.
89
+ let current: MountResult | null = null
90
+
91
+ // Fixed display zoom (default 0.75) — parameterized so nothing hard-codes it. Applied on #app so the
92
+ // app lays out at its true logical viewport (clientWidth) but rasterizes at the final on-screen size.
93
+ const parseZoom = (raw: string | null | undefined): number | null => {
94
+ const z = raw != null ? Number(raw) : NaN
95
+ return Number.isFinite(z) && z > 0 ? z : null
96
+ }
97
+ let currentZoom = parseZoom(params.get('zoom')) ?? 0.75
98
+
99
+ // Device shell for the URL/postMessage driver (the landing's shell selector). It maps to the app's
100
+ // safe-area insets, which are baked into the host at mount — so a live shell change re-runs the
101
+ // current bundle (like a language switch). The editor's direct mount() passes its own safeArea and
102
+ // ignores this. `abstract` → undefined → the host keeps its neutral web insets.
103
+ let currentShell: SimulatorShell = asShell(params.get('shell'))
104
+
105
+ // Page-provided safe-area insets (top-level form only: `lecodesEmbedConfig.safeArea =
106
+ // { top, right, bottom, left }` px, live-updated via `lecodesEmbed.setSafeArea`). A page that runs
107
+ // the app fullscreen knows its real insets — its own banner, env(safe-area-inset-*) — and they win
108
+ // over the shell's emulated ones. null = the shell decides.
109
+ type Insets = { top?: number, right?: number, bottom?: number, left?: number }
110
+ const asInsets = (raw: unknown): SafeAreaSpec | null => {
111
+ if (!raw || typeof raw !== 'object') return null
112
+ const n = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? Math.round(v) : 0)
113
+ const i = raw as Insets
114
+ const box: [number, number, number, number] = [n(i.top), n(i.right), n(i.bottom), n(i.left)]
115
+ return { portrait: box, landscape: box }
116
+ }
117
+ let pageSafeArea: SafeAreaSpec | null = asInsets(pageConfig.safeArea)
118
+ const effectiveSafeArea = () => pageSafeArea ?? SHELL_SAFE_AREAS[currentShell]
119
+
120
+ // Frameless viewer-lite: at zoom 1 the runner's #app IS the (iframe or page) viewport, so the lite
121
+ // host needs no private iframe — vw/vh and fixed widgets resolve the same, and the app gets
122
+ // first-class page semantics (keyboard/visualViewport, fullscreen, no iOS iframe focus-scroll).
123
+ // Under a display zoom the private iframe stays: it's what keeps viewport units at the app's
124
+ // logical size while #app rasterizes scaled.
125
+ const liteFrameless = () => currentZoom === 1
126
+
127
+ const applyZoom = (zoom: number) => {
128
+ currentZoom = zoom
129
+ ;(root.style as any).zoom = String(zoom)
130
+ // A zoom change leaves #app's layout size fixed (the app keeps its logical viewport), so the host's
131
+ // ResizeObserver stays silent — but the resolution factor moved, so nudge the engines to re-size.
132
+ current?.refreshResolution()
133
+ }
134
+ applyZoom(currentZoom)
135
+
136
+ function setLoading(on: boolean) {
137
+ loader?.classList.toggle('embed-loader--on', on)
138
+ }
139
+
140
+ function showError(text: string) {
141
+ const el = document.createElement('div')
142
+ el.className = 'embed-error'
143
+ el.textContent = text
144
+ root.appendChild(el)
145
+ }
146
+
147
+ // Same-origin paths only — the runner must not become an open proxy for foreign code.
148
+ const isSafeSrc = (src: unknown): src is string =>
149
+ typeof src === 'string' && src.startsWith('/') && !src.startsWith('//')
150
+
151
+ // Header directives, as mountCreator sniffs them (see createViewer.ts).
152
+ function sniffHeader(code: string): { engine: EngineMode; preloads: Set<string>; bootFonts: BootFont[] } {
153
+ // Wide enough for a `// fonts:` line with several faces (URLs make it the longest header line).
154
+ const header = code.slice(0, 2000)
155
+ const preloads = new Set<string>()
156
+ const preload = header.match(/\/\/ preload: (.+)/)
157
+ if (preload) {
158
+ for (const item of preload[1].split(';')) preloads.add(item)
159
+ }
160
+ const deferEngine = header.includes('// gl: disable') || header.includes('// gl: defer')
161
+ const glEnabled = !header.includes('// gl: disable')
162
+ const is2d = header.includes('// 2d: enable')
163
+ const engine: EngineMode =
164
+ is2d && glEnabled ? 'dual'
165
+ : is2d ? '2d'
166
+ : deferEngine ? 'defer'
167
+ : 'gl'
168
+ return { engine, preloads, bootFonts: parseFontsHeader(header) }
169
+ }
170
+
171
+ export type MountOptions = {
172
+ /** Same-origin bundle URL to fetch and run (the landing path). */
173
+ src?: string
174
+ /** Compiled bundle text to run directly (the editor path). Takes precedence over `src`. */
175
+ code?: string
176
+ /** Initial language (else the current `?lang=` / setLang value). */
177
+ lang?: string
178
+ /** Display zoom for #app (default 0.75). */
179
+ zoom?: number
180
+ /** Device-emulation safe-area override (the embedder's iOS/Android shells). */
181
+ safeArea?: SafeAreaSpec
182
+ /** Method overrides assigned onto the 3D creator (the editor's webcam-AR seam). */
183
+ methods?: Partial<CreatorMethods>
184
+ /** UI renderer: DOM (default) or the canvas-UI parity renderer. */
185
+ ui?: 'dom' | 'canvas'
186
+ /** Extra windows to also poll physical keys from (the editor passes its parent). */
187
+ keyboardTargets?: Window[]
188
+ /** Called when the bundle throws, mapped back to original source. */
189
+ onError?: (e: PreviewError) => void
190
+ /** app.setOrientation sink ("landscape" | "portrait" | "auto") — the editor turns its phone box.
191
+ * Absent = the runner's default: post 'lecodes-orientation' to the parent frame, or (top-level
192
+ * form) a best-effort screen.orientation.lock on the page. */
193
+ onOrientation?: (mode: string) => void
194
+ }
195
+
196
+ export type MountResult = {
197
+ _creator: any
198
+ _creator2d: any
199
+ _creatorUI: any
200
+ _creatorUtils: any
201
+ /** The app root the renderer paints into — walked by the editor's element inspector. */
202
+ uiRoot: HTMLElement
203
+ /** Re-push the resolution factor to the engines after a zoom change (no layout change). */
204
+ refreshResolution(): void
205
+ destroy(): void
206
+ }
207
+
208
+ // The single mounting primitive both entry points call. Applies zoom, brings up the engines/UI for
209
+ // the given bundle, runs it, and returns the live host handles. The caller owns session/loader/UI —
210
+ // mount is pure setup + teardown.
211
+ export const mount = async (opts: MountOptions): Promise<MountResult> => {
212
+ if (opts.zoom !== undefined) applyZoom(opts.zoom)
213
+ if (opts.lang) currentLang = opts.lang
214
+
215
+ let code = opts.code
216
+ if (code === undefined) {
217
+ if (!opts.src) throw new Error('embed.mount: pass `code` or `src`')
218
+ const resp = await fetch(opts.src)
219
+ if (!resp.ok) throw new Error(`embed: ${opts.src} → HTTP ${resp.status}`)
220
+ code = await resp.text()
221
+ }
222
+
223
+ const { engine, preloads, bootFonts } = sniffHeader(code)
224
+ const host = await createHost(root, {
225
+ engine,
226
+ ui: opts.ui === 'canvas' ? createCanvasUIRenderer : createUI,
227
+ preloads,
228
+ bootFonts,
229
+ methods: opts.methods,
230
+ safeArea: opts.safeArea,
231
+ keyboardTargets: opts.keyboardTargets,
232
+ onOrientation: opts.onOrientation ?? orientationSink,
233
+ })
234
+
235
+ try {
236
+ host.run(code, { onError: opts.onError })
237
+ } catch {
238
+ // A synchronous boot throw is already reported through onError; swallow it so the host stays
239
+ // destroyable (whatever the bundle set up before throwing can still surface async errors).
240
+ }
241
+
242
+ // Clear the boot spinner (embed.html starts it on). The URL/postMessage driver manages loading
243
+ // separately via runFromSrc, but a direct mount() — the editor path — must clear it here, or the
244
+ // #loader overlay (z-index 9, opaque) hides the app that just mounted underneath it.
245
+ setLoading(false)
246
+
247
+ return {
248
+ _creator: host._creator,
249
+ _creator2d: host._creator2d,
250
+ _creatorUI: host._creatorUI,
251
+ _creatorUtils: host._creatorUtils,
252
+ uiRoot: root,
253
+ refreshResolution: () => host.refreshResolution(),
254
+ destroy: () => host.destroy(),
255
+ }
256
+ }
257
+
258
+ // ---- URL + postMessage driver (the landing) --------------------------------------------------------
259
+ // Wraps `mount` with the loader spinner, error overlay and switch semantics: a monotonic session
260
+ // guards against a slow fetch/mount losing the race to a newer request, and each switch destroys the
261
+ // previous host and wipes #app (host.destroy() removes its canvases, but screens/overlays the UI
262
+ // renderer appended — and our error overlays — are the runner's to clear).
263
+
264
+ let currentSrc: string | null = null
265
+ let currentApp: { uuid: string, engine: string | null } | null = null
266
+ let session = 0
267
+
268
+ /** Re-run whatever is mounted through the URL/postMessage driver (language / shell change). */
269
+ function rerunCurrent() {
270
+ if (currentApp) runPublished(currentApp.uuid, currentApp.engine)
271
+ else if (currentSrc) runFromSrc(currentSrc)
272
+ }
273
+
274
+ async function runFromSrc(src: string) {
275
+ const mySession = ++session
276
+ currentSrc = src
277
+ currentApp = null
278
+ setLoading(true)
279
+ current?.destroy()
280
+ current = null
281
+ root.replaceChildren()
282
+ try {
283
+ const result = await mount({
284
+ src,
285
+ safeArea: effectiveSafeArea(),
286
+ onError: (error) => {
287
+ const base = error.detail ? `${error.message}: ${error.detail}` : error.message
288
+ // The follow-up native emit carries the decoded top engine frame (see wasmCrashSymbolizer).
289
+ showError(error.nativeFrames?.length ? `${base} — in ${error.nativeFrames[0]}` : base)
290
+ },
291
+ })
292
+ if (mySession !== session) { result.destroy(); return }
293
+ current = result
294
+ } catch (e) {
295
+ if (mySession === session) showError(String(e))
296
+ } finally {
297
+ if (mySession === session) setLoading(false)
298
+ }
299
+ }
300
+
301
+ function setLang(lang: string) {
302
+ const l = lang || 'en'
303
+ if (l === currentLang) return
304
+ currentLang = l
305
+ // Re-run so screens already built in the old language rebuild.
306
+ rerunCurrent()
307
+ }
308
+
309
+ function setShell(shell: string) {
310
+ const s = asShell(shell)
311
+ if (s === currentShell) return
312
+ currentShell = s
313
+ // The safe area is applied when the host is built, so re-run to re-mount with the new insets.
314
+ rerunCurrent()
315
+ }
316
+
317
+ // ---- Published apps (?app=<uuid>) ------------------------------------------------------------------
318
+ // The platform's "open in browser" / iframe-embed channel. Policy first, then bundle: a project whose
319
+ // browser channel is off must not run here even though its bundle URL is guessable — the developer's
320
+ // printed QR points at the app, not at a web version they never looked at.
321
+
322
+ type PublishPolicy = {
323
+ uuid?: string
324
+ published: boolean
325
+ web: { enabled: boolean, engine: 'lite' | 'full', desktopFrame: boolean }
326
+ embed: { enabled: boolean, referers: string[] }
327
+ meta: { gl: string, twoD: boolean, ar: boolean } | null
328
+ firstPartyOrigins?: string[]
329
+ }
330
+
331
+ /** Origin of the page embedding this runner (null when top-level or unknown). ancestorOrigins is
332
+ * the reliable source (Chrome/Safari); the referrer is the Firefox fallback. */
333
+ const parentOrigin = (): string | null => {
334
+ if (window.parent === window) return null
335
+ const anc = (location as any).ancestorOrigins as DOMStringList | undefined
336
+ if (anc && anc.length > 0) return anc[0]
337
+ try { return document.referrer ? new URL(document.referrer).origin : null } catch { return null }
338
+ }
339
+
340
+ /** Does the embedding site match an allowlist entry? Entries are the URLs the developer typed
341
+ * ("https://example.com" or ".../some/page"): an origin match, or a referrer prefix match when
342
+ * the entry carries a path. Empty allowlist = any site. */
343
+ const embedderAllowed = (referers: string[], origin: string): boolean => {
344
+ if (referers.length === 0) return true
345
+ const referrer = document.referrer.toLowerCase()
346
+ return referers.some((raw) => {
347
+ const entry = raw.toLowerCase().replace(/\/+$/, '')
348
+ let entryOrigin: string
349
+ try { entryOrigin = new URL(entry.includes('://') ? entry : `https://${entry}`).origin } catch { return false }
350
+ if (entryOrigin !== origin.toLowerCase()) return false
351
+ const hasPath = entry.replace(/^https?:\/\//, '').includes('/')
352
+ return !hasPath || referrer.startsWith(entry)
353
+ })
354
+ }
355
+
356
+ const isProjectMember = async (uuid: string): Promise<boolean> => {
357
+ try {
358
+ const token = localStorage.getItem('accessToken')
359
+ if (!token) return false
360
+ const resp = await fetch(`/api/projects/${encodeURIComponent(uuid)}/publish`, { headers: { Authorization: `Bearer ${token}` } })
361
+ return resp.ok
362
+ } catch { return false }
363
+ }
364
+
365
+ // The bundle URL hosts fetch, bucketed like the mobile launcher (30 s) so a republish shows up
366
+ // promptly without defeating the cache on every load.
367
+ const publishedBundleUrl = (uuid: string) => `/code/${encodeURIComponent(uuid)}.js?ts=${Math.trunc(Date.now() / 30000) * 30000}`
368
+
369
+ async function runPublished(uuid: string, engineParam: string | null) {
370
+ const mySession = ++session
371
+ currentSrc = null
372
+ currentApp = { uuid, engine: engineParam }
373
+ setLoading(true)
374
+ current?.destroy()
375
+ current = null
376
+ root.replaceChildren()
377
+
378
+ // The top-level form may inline the policy (`lecodesEmbedConfig.policy` — the published page
379
+ // already rendered from it, no-store), saving the round trip; the iframe form fetches it.
380
+ const inline = pageConfig.policy as PublishPolicy | undefined
381
+ let policy: PublishPolicy
382
+ if (inline && typeof inline === 'object' && inline.uuid === uuid) {
383
+ policy = inline
384
+ } else {
385
+ try {
386
+ const resp = await fetch(`/api/publish/${encodeURIComponent(uuid)}`, { cache: 'no-store' })
387
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
388
+ policy = await resp.json()
389
+ } catch {
390
+ if (mySession === session) { setLoading(false); showError('This app isn’t available.') }
391
+ return
392
+ }
393
+ if (mySession !== session) return
394
+ }
395
+
396
+ const origin = parentOrigin()
397
+ // Top-level, or iframed by the platform itself (the published page / editor) → first party.
398
+ const firstParty = origin === null || origin === location.origin || (policy.firstPartyOrigins ?? []).includes(origin)
399
+ let refusal: string | null = null
400
+ if (!policy.published) refusal = 'This app isn’t published right now.'
401
+ else if (firstParty && !policy.web.enabled) refusal = 'This app can’t be opened in the browser — get the LeCodes app.'
402
+ else if (!firstParty && !policy.embed.enabled) refusal = 'This app can’t be embedded on other sites.'
403
+ else if (!firstParty && !embedderAllowed(policy.embed.referers, origin!)) refusal = 'This site isn’t allowed to embed this app.'
404
+ // Developer preview (`?preview=1`, the Publish dialog's "Preview"): a signed-in project member
405
+ // may run the app before the browser channel is on. Same-origin with the platform, so its
406
+ // access token is in our localStorage; the member-only publish endpoint is the proof.
407
+ if (refusal && policy.published && firstParty && params.get('preview') === '1' && await isProjectMember(uuid)) refusal = null
408
+ if (mySession !== session) return
409
+ if (refusal) { setLoading(false); showError(refusal); return }
410
+
411
+ // Engine: the URL may force one; the policy's is the default; 2D can only run on the full host.
412
+ const engine: 'lite' | 'full' =
413
+ policy.meta?.twoD ? 'full'
414
+ : engineParam === 'full' || engineParam === 'lite' ? engineParam
415
+ : policy.web.engine
416
+
417
+ const src = publishedBundleUrl(uuid)
418
+ currentSrc = src
419
+ try {
420
+ if (engine === 'lite') {
421
+ const resp = await fetch(src)
422
+ if (!resp.ok) throw new Error(`embed: ${src} → HTTP ${resp.status}`)
423
+ const code = await resp.text()
424
+ if (mySession !== session) return
425
+ // Lazy chunk: the viewer-lite host — no wasm at all. Under a display zoom it keeps its own
426
+ // iframe inside #app (the app's CSS viewport = the phone's); at zoom 1 it mounts frameless
427
+ // (see liteFrameless). Zoom applies through #app like the full host.
428
+ // Deep path on purpose: the package index re-exports the 3D engine, and a dynamic import
429
+ // keeps every export — the host alone lets a `gl: disable` app skip the engine chunk
430
+ // (host.ts loads it lazily on first 3D use).
431
+ const { mountCreatorLite } = await import('lecodes-viewer-lite/src/host')
432
+ const lite = await mountCreatorLite(root, code, undefined, {
433
+ safeArea: effectiveSafeArea(),
434
+ onOrientation: orientationSink,
435
+ frame: !liteFrameless(),
436
+ onError: (error) => showError(error.detail ? `${error.message}: ${error.detail}` : error.message),
437
+ })
438
+ if (mySession !== session) { lite.destroy(); return }
439
+ current = {
440
+ _creator: lite._creator, _creator2d: undefined, _creatorUI: lite._creatorUI, _creatorUtils: lite._creatorUtils,
441
+ uiRoot: (lite.uiRoot ?? root) as HTMLElement,
442
+ // The lite host sizes its canvas from its root box; a display-zoom change is a layout
443
+ // change there (the box moves), so nothing extra to push.
444
+ refreshResolution: () => {},
445
+ destroy: () => lite.destroy(),
446
+ }
447
+ } else {
448
+ const result = await mount({
449
+ src,
450
+ safeArea: effectiveSafeArea(),
451
+ onError: (error) => {
452
+ const base = error.detail ? `${error.message}: ${error.detail}` : error.message
453
+ showError(error.nativeFrames?.length ? `${base} — in ${error.nativeFrames[0]}` : base)
454
+ },
455
+ })
456
+ if (mySession !== session) { result.destroy(); return }
457
+ current = result
458
+ }
459
+ } catch (e) {
460
+ if (mySession === session) showError(String(e))
461
+ } finally {
462
+ if (mySession === session) setLoading(false)
463
+ }
464
+ }
465
+
466
+ // The typed window API (both entry points share it). Exposed for the editor's direct same-origin use.
467
+ ;(window as any).lecodesEmbed = {
468
+ version: (typeof __LECODES_EMBED_VERSION__ !== 'undefined' && __LECODES_EMBED_VERSION__) || 'dev',
469
+ mount,
470
+ setLang,
471
+ setZoom: applyZoom,
472
+ setShell,
473
+ /** Top-level form: the page's real insets (`{ top, right, bottom, left }` px), applied live to the
474
+ * running host (both engines expose `_setSafeAreaSpec`); null hands control back to the shell. */
475
+ setSafeArea(insets: Insets | null) {
476
+ pageSafeArea = asInsets(insets)
477
+ current?._creatorUI?._setSafeAreaSpec?.(effectiveSafeArea() ?? null)
478
+ },
479
+ destroy() { current?.destroy(); current = null; currentSrc = null; currentApp = null },
480
+ }
481
+
482
+ window.addEventListener('message', (e: MessageEvent) => {
483
+ // Only the direct embedder (the parent frame that hosts this iframe) may drive the runner. This is
484
+ // origin-agnostic on purpose: the landing serves the runner from the CDN (cross-origin to the page),
485
+ // so an `origin === location.origin` check would reject its own messages. `isSafeSrc` still confines
486
+ // `src` to same-origin bundle paths, so a stray poster can't make the runner fetch foreign code.
487
+ if (e.source !== window.parent) return
488
+ const data = e.data
489
+ if (!data) return
490
+ if (data.type === 'lecodes-run' && isSafeSrc(data.src)) {
491
+ runFromSrc(data.src)
492
+ } else if (data.type === 'lecodes-lang' && typeof data.lang === 'string') {
493
+ setLang(data.lang)
494
+ } else if (data.type === 'lecodes-zoom') {
495
+ const z = parseZoom(data.zoom)
496
+ if (z !== null) applyZoom(z)
497
+ } else if (data.type === 'lecodes-shell' && typeof data.shell === 'string') {
498
+ setShell(data.shell)
499
+ }
500
+ })
501
+
502
+ const initialSrc = params.get('src')
503
+ const initialApp = params.get('app')
504
+
505
+ if (initialApp) {
506
+ // A published platform app: policy-gated, same-origin bundle (see runPublished).
507
+ runPublished(initialApp, params.get('engine'))
508
+ } else if (initialSrc === null || initialSrc === '') {
509
+ // No initial bundle: keep the loader up and wait for the first run message / mount() call.
510
+ } else if (!isSafeSrc(initialSrc)) {
511
+ setLoading(false)
512
+ showError('embed: expected ?src=/path/to/app.js (same-origin)')
513
+ } else {
514
+ runFromSrc(initialSrc)
515
+ }