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.
- package/README.md +5 -0
- package/dist-embed/VERSION.json +5 -0
- package/dist-embed/assets/Roboto-Variable-DHm9jpd-.woff2 +0 -0
- package/dist-embed/assets/__vite-browser-external-BIHI7g3E.js +1 -0
- package/dist-embed/assets/basis-C64VHDVD.js +1 -0
- package/dist-embed/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
- package/dist-embed/assets/createViewer2d-D12Wy3VF.js +1 -0
- package/dist-embed/assets/createViewerLite-CP-WHGLg.js +707 -0
- package/dist-embed/assets/creator-gl-BF2CQzeI.wasm +0 -0
- package/dist-embed/assets/creator-gl.js-B_0GrQRe.symbols +5379 -0
- package/dist-embed/assets/creator-ui-BX5Fvnbt.js +1 -0
- package/dist-embed/assets/creator-ui-CzKHLH2Q.wasm +0 -0
- package/dist-embed/assets/creator2d-BtQ-1_B0.wasm +0 -0
- package/dist-embed/assets/draco-BiISTFcR.js +118 -0
- package/dist-embed/assets/draco_decoder-DsQ12WqX.wasm +0 -0
- package/dist-embed/assets/host-CWKB1WDd.js +162 -0
- package/dist-embed/assets/index-CIK8PnUA.js +2 -0
- package/dist-embed/assets/lit-DUE9rhY9.filamat +0 -0
- package/dist-embed/assets/loadCreatorUiNode-UAdhjRTd.js +1 -0
- package/dist-embed/assets/neutral_ibl128-D-lzdkLN.ktx +0 -0
- package/dist-embed/assets/particles-DxYDmI1O.filamat +0 -0
- package/dist-embed/assets/particles-quad-DwYo9yCK.filamat +0 -0
- package/dist-embed/assets/shadow-C9xJpqlY.filamat +0 -0
- package/dist-embed/assets/uberarchive-uXjRYa9p.bin +0 -0
- package/dist-embed/assets/unlit-pe4tFY5b.filamat +0 -0
- package/dist-embed/assets/unlit-transparent-D9OyaOBR.filamat +0 -0
- package/dist-embed/assets/video-eedvhMvS.filamat +0 -0
- package/dist-embed/assets/worker-Caf-yYEI.js +2 -0
- package/dist-embed/embed.html +48 -0
- package/dist-embed/embed.js +187 -0
- package/embed.html +48 -0
- package/package.json +35 -0
- package/src/assets/banner.png +0 -0
- package/src/assets/image2.png +0 -0
- package/src/assets/landing.mp4 +0 -0
- package/src/assets/neutral_ibl128.ktx +0 -0
- package/src/assets/neutral_ibl256.ktx +0 -0
- package/src/assets/output.mp4 +0 -0
- package/src/assets/output2.mp4 +0 -0
- package/src/assets/texture.png +0 -0
- package/src/assets/vue.svg +1 -0
- package/src/canvas/createCanvasHost.ts +2 -0
- package/src/createViewer.ts +103 -0
- package/src/embed.ts +515 -0
- package/src/host.ts +428 -0
- package/src/lite-test.ts +160 -0
- package/src/main.ts +77 -0
- package/src/sourcemapError.ts +2 -0
- package/src/ui/CuiProps.gen.ts +103 -0
- package/src/ui/UIScreen.ts +486 -0
- package/src/ui/createBareUI.ts +32 -0
- package/src/ui/createMeasureSystem.ts +220 -0
- package/src/ui/createNodeElement.ts +77 -0
- package/src/ui/createPagerSystem.ts +345 -0
- package/src/ui/createTouchSystem.ts +2 -0
- package/src/ui/createUI.ts +806 -0
- package/src/ui/creator-ui.js +14 -0
- package/src/ui/creator-ui.wasm +0 -0
- package/src/ui/defaultStyle.css +176 -0
- package/src/ui/layers.ts +9 -0
- package/src/ui/mountCanvasUI.ts +213 -0
- package/src/ui/nativeViewSystem.ts +91 -0
- package/src/ui/presentPlayer.ts +156 -0
- package/src/ui/props-apply.ts +403 -0
- package/src/ui/router.ts +2 -0
- package/src/ui/safeAreaSpec.ts +3 -0
- package/src/ui/shells.ts +37 -0
- package/src/ui/toasts.ts +2 -0
- package/src/ui/transitions.ts +250 -0
- package/src/ui/ui.d.ts +78 -0
- package/src/ui/vListSystem.ts +212 -0
- package/src/ui/viewScale.ts +2 -0
- package/src/utils/animate.ts +2 -0
- package/src/utils/createUtils.ts +2 -0
- package/src/utils/fetch.ts +3 -0
- package/src/utils/fonts.ts +2 -0
- package/src/utils/globals.ts +2 -0
- package/src/utils/imageNode.ts +260 -0
- package/src/utils/openFilePicker.ts +2 -0
- package/src/utils/svg.ts +2 -0
- package/src/utils/videoNode.ts +2 -0
- package/src/utils/ws.ts +2 -0
- package/src/viewer/createViewer.ts +993 -0
- package/src/viewer/creator-gl.js +14 -0
- package/src/viewer/creator-gl.js.symbols +5379 -0
- package/src/viewer/creator-gl.wasm +0 -0
- package/src/viewer/lit.filamat +0 -0
- package/src/viewer/mediaplayer.ts +3 -0
- package/src/viewer/particles-quad.filamat +0 -0
- package/src/viewer/particles.filamat +0 -0
- package/src/viewer/physics.ts +140 -0
- package/src/viewer/shadow.filamat +0 -0
- package/src/viewer/uberarchive.bin +0 -0
- package/src/viewer/unlit-transparent.filamat +0 -0
- package/src/viewer/unlit.filamat +0 -0
- package/src/viewer/validateGlb.ts +262 -0
- package/src/viewer/video.filamat +0 -0
- package/src/viewer/wasmCrashSymbolizer.ts +133 -0
- package/src/viewer2d/createViewer2d.ts +438 -0
- package/src/viewer2d/creator2d.js +14 -0
- package/src/viewer2d/creator2d.wasm +0 -0
- package/src/viewer2d/sprite2d.ts +80 -0
- 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
|
+
}
|