lecodes-cli 0.19.2 → 0.20.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.
@@ -291,7 +291,12 @@ const syncIos = async (root: string, config: AppConfig): Promise<SyncArtifacts>
291
291
  for (const [key, value] of Object.entries(ios.entitlements ?? {})) {
292
292
  (entitlementValues[key] ??= []).push(value)
293
293
  }
294
- runtimePlugins.push({ id, module, register })
294
+ for (const pkg of ios.packages ?? []) {
295
+ if (!pkg.url || !Array.isArray(pkg.products) || pkg.products.length === 0 || (!pkg.exact && !pkg.from) || (pkg.exact && pkg.from)) {
296
+ throw new CliError(`Plugin "${id}": manifest ios.packages entries need { url, exact | from, products: [...] } — got ${JSON.stringify(pkg)}`)
297
+ }
298
+ }
299
+ runtimePlugins.push({ id, module, register, packages: ios.packages })
295
300
  newLock[id] = plugin.lock
296
301
  log(`${c.green("plugin")} ${id}@${plugin.lock.version} (${plugin.lock.tag} → Sources/${module})`)
297
302
  }
@@ -4,7 +4,7 @@ import { basename, dirname, join, resolve } from "node:path"
4
4
  import { buildBundle } from "./compile"
5
5
  import { resolveDesktopExe, selectedDesktopVersion } from "../desktopRenderer"
6
6
  import { CliError, c, flagBool, flagStr, log, note, success, warn, type Args } from "../util"
7
- import { assertRendererSupportsBundle, projectRoot, readJson, resolveDesktopRenderer, writeJson, type AppConfig } from "./appShared"
7
+ import { assertRendererSupportsBundle, desktopPluginLibraries, projectRoot, readJson, resolveDesktopRenderer, writeJson, type AppConfig } from "./appShared"
8
8
  import { loadIconMaster } from "./appIcon"
9
9
  import { buildMacApp, macHostProvidedNames } from "./appDesktopMac"
10
10
 
@@ -208,7 +208,20 @@ export const buildDesktop = async (args: Args) => {
208
208
  note(" app.ico rendered from " + config.icon)
209
209
  }
210
210
 
211
- // 5) A short README (BOM so double-click-Notepad shows it right).
211
+ // 5) Native plugins (packages/desktop/plugin-sdk/lecodes_plugin.h): the project's plugins/ dir +
212
+ // every declared plugin's desktop library → plugins/ next to the exe, where a host WITH the plugin
213
+ // loader (added after 1.4.0) scans at startup; an older host ignores the folder.
214
+ // A declared library that's missing is a build error (strict).
215
+ {
216
+ const { files } = desktopPluginLibraries(root, { strict: true })
217
+ if (files.length > 0) {
218
+ mkdirSync(join(out, "plugins"), { recursive: true })
219
+ for (const f of files) cpSync(f, join(out, "plugins", basename(f)))
220
+ note(` plugins: ${files.map((f) => basename(f)).join(", ")} → plugins/`)
221
+ }
222
+ }
223
+
224
+ // 6) A short README (BOM so double-click-Notepad shows it right).
212
225
  writeFileSync(join(out, "README.txt"), "\uFEFF" + readmeText(name, exeName, version))
213
226
 
214
227
  success(`Portable build: ${out} (${mb(dirSize(out))})`)
@@ -1,5 +1,5 @@
1
1
  import { execFileSync } from "node:child_process"
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
3
3
  import { homedir } from "node:os"
4
4
  import { dirname, join, resolve } from "node:path"
5
5
  import { findProjectRoot } from "../manifest"
@@ -16,6 +16,7 @@ import { CliError, c, flagStr, log, note, warn, type Args } from "../util"
16
16
  export const FIRST_PARTY_PLUGINS: Record<string, string> = {
17
17
  "camera": "letary/lecodes-plugins",
18
18
  "qr-scanner": "letary/lecodes-plugins",
19
+ "map": "letary/lecodes-plugins",
19
20
  "geolocation": "letary/lecodes-plugins",
20
21
  "push": "letary/lecodes-plugins",
21
22
  }
@@ -166,6 +167,21 @@ export const desktopDefaultViewport = (root: string): { width: number, height: n
166
167
  }
167
168
  }
168
169
 
170
+ /** A SwiftPM dependency a plugin's iOS target links (manifest ios.packages). */
171
+ export interface IosPackage {
172
+ url: string
173
+ /** Pinned release — the normal case (a plugin's sources compile against one API). */
174
+ exact?: string
175
+ /** Minimum release, SwiftPM picks the newest compatible major. Exclusive with `exact`. */
176
+ from?: string
177
+ /** Library products of that package the plugin target imports. */
178
+ products: string[]
179
+ }
180
+
181
+ /** SwiftPM's package identity for a url: the last path segment without `.git`. */
182
+ export const swiftPackageName = (url: string): string =>
183
+ (url.replace(/\/+$/, "").split("/").pop() ?? url).replace(/\.git$/, "")
184
+
169
185
  export interface PluginManifest {
170
186
  id: string
171
187
  version?: string
@@ -178,6 +194,12 @@ export interface PluginManifest {
178
194
  * by sync; the relay signs sends for Letary-published bundle ids with the shared team
179
195
  * key, and the register route fail-fasts for anything outside that namespace. */
180
196
  entitlements?: Record<string, unknown>,
197
+ /** Swift packages the vendored target links (e.g. the map plugin's maplibre-native). Each
198
+ * becomes a `.package(url:…)` in the generated LeCodesRuntime/Package.swift (exact or
199
+ * from — one of the two) and its `products` become `.product(name:package:)` deps of the
200
+ * plugin target. The package name is the URL's last path segment minus `.git`, as SwiftPM
201
+ * derives it. Two plugins naming the same url must agree on the version. */
202
+ packages?: IosPackage[],
181
203
  } | null
182
204
  android?: {
183
205
  /** Dir vendored into the shell (default "android") — a Gradle-module-shaped tree minus
@@ -200,6 +222,15 @@ export interface PluginManifest {
200
222
  * Bare ids resolve through the CLI's known-version table; "id:version" pins explicitly. */
201
223
  gradlePlugins?: string[],
202
224
  } | null
225
+ /** Desktop host (packages/desktop): a PREBUILT shared library per OS/arch behind the C ABI in
226
+ * `packages/desktop/plugin-sdk/lecodes_plugin.h` — no host rebuild, no source vendoring.
227
+ * `library` keys are `${process.platform}-${process.arch}` ("win32-x64", "linux-x64",
228
+ * "linux-arm64"), values plugin-dir-relative paths. `lecodes desktop run` / `render --desktop`
229
+ * forward the library's dir to the host (CREATOR_PLUGIN_DIRS); `app build desktop` stages the
230
+ * file into `<build>/plugins/`. A plugin without a build for the current target is skipped. */
231
+ desktop?: {
232
+ library?: Record<string, string>,
233
+ } | null
203
234
  }
204
235
 
205
236
  export interface LockEntry { repo: string, tag: string, commit: string, version: string }
@@ -400,7 +431,12 @@ export const ensureRepo = (repo: string, wantTag: string | null, lock: LockEntry
400
431
 
401
432
  /** Resolve one app.json plugin entry to vendorable sources + manifest + lock pin. The platform
402
433
  * being synced must be supported (its manifest branch non-null) — a hard error otherwise. */
403
- export const resolvePlugin = (id: string, spec: string, lock: LockEntry | undefined, platform: "ios" | "android"): ResolvedPlugin => {
434
+ export type PluginPlatform = "ios" | "android" | "desktop"
435
+ const PLUGIN_PLATFORM_LABEL: Record<PluginPlatform, string> = { ios: "iOS", android: "Android", desktop: "desktop" }
436
+
437
+ export function resolvePlugin(id: string, spec: string, lock: LockEntry | undefined, platform: PluginPlatform): ResolvedPlugin
438
+ export function resolvePlugin(id: string, spec: string, lock: LockEntry | undefined, platform: PluginPlatform, opts: { optional: true }): ResolvedPlugin | null
439
+ export function resolvePlugin(id: string, spec: string, lock: LockEntry | undefined, platform: PluginPlatform, opts?: { optional?: boolean }): ResolvedPlugin | null {
404
440
  let repo: string
405
441
  let subdir: string
406
442
  let wantTag: string | null = null
@@ -424,7 +460,8 @@ export const resolvePlugin = (id: string, spec: string, lock: LockEntry | undefi
424
460
  if (!manifest) throw new CliError(`Plugin "${id}": no lecodes-plugin.json in ${dir}`)
425
461
  if (manifest.id !== id) throw new CliError(`Plugin "${id}": manifest declares id "${manifest.id}"`)
426
462
  if (!manifest[platform]) {
427
- throw new CliError(`Plugin "${id}" does not support ${platform === "ios" ? "iOS" : "Android"} (manifest "${platform}" is null)`)
463
+ if (opts?.optional) return null
464
+ throw new CliError(`Plugin "${id}" does not support ${PLUGIN_PLATFORM_LABEL[platform]} (manifest "${platform}" is null)`)
428
465
  }
429
466
 
430
467
  // Namespacing: third-party channel names must be vendor-prefixed ("acme.thing").
@@ -446,3 +483,60 @@ export const resolvePlugin = (id: string, spec: string, lock: LockEntry | undefi
446
483
  export const latestPluginTag = (repo: string): string | null => {
447
484
  try { return ensureRepo(repo, null, undefined).tag } catch { return null }
448
485
  }
486
+
487
+ /* ---------------------------------- desktop native plugins ---------------------------------- */
488
+
489
+ /** The desktop host's plugin library target key (manifest `desktop.library` keys). */
490
+ export const desktopPluginTarget = (): string => `${process.platform}-${process.arch}`
491
+
492
+ const isDesktopPluginLibrary = (name: string): boolean => {
493
+ const n = name.toLowerCase()
494
+ return process.platform === "win32" ? n.endsWith(".dll") : n.endsWith(".so")
495
+ }
496
+
497
+ /** Native plugin libraries for THIS project on THIS machine (packages/desktop/plugin-sdk/
498
+ * lecodes_plugin.h — runtime-loaded .dll/.so, no host rebuild):
499
+ * - every library in the project's own `plugins/` dir — the zero-manifest dev loop (build a
500
+ * DLL, drop it there, `lecodes desktop run`);
501
+ * - the `desktop.library[target]` of every app.json plugin whose manifest declares one
502
+ * (mobile-only plugins are skipped, not errors).
503
+ * `dirs` is what a run forwards to the host as CREATOR_PLUGIN_DIRS (the host scans them);
504
+ * `files` is what `app build desktop` copies into `<build>/plugins/`. `strict` turns a broken
505
+ * declaration into an error (builds); runs warn and go on without that plugin. */
506
+ export const desktopPluginLibraries = (root: string, opts: { strict?: boolean } = {}): { dirs: string[], files: string[] } => {
507
+ const dirs: string[] = []
508
+ const files: string[] = []
509
+ const local = join(root, "plugins")
510
+ if (existsSync(local) && statSync(local).isDirectory()) {
511
+ dirs.push(local)
512
+ for (const f of readdirSync(local)) if (isDesktopPluginLibrary(f)) files.push(join(local, f))
513
+ }
514
+ const config = readJson<AppConfig>(join(root, "app.json"))
515
+ const specs = Object.entries(config?.plugins ?? {})
516
+ if (specs.length === 0) return { dirs, files }
517
+ const lock = readJson<{ plugins?: Record<string, LockEntry> }>(join(root, "plugins.lock.json"))?.plugins ?? {}
518
+ const target = desktopPluginTarget()
519
+ for (const [id, spec] of specs) {
520
+ let plugin: ResolvedPlugin | null
521
+ try {
522
+ plugin = resolvePlugin(id, spec, lock[id], "desktop", { optional: true })
523
+ } catch (e) {
524
+ if (opts.strict) throw e
525
+ warn(`Plugin "${id}": ${(e as Error).message} — skipped for this desktop run`)
526
+ continue
527
+ }
528
+ const rel = plugin?.manifest.desktop?.library?.[target]
529
+ if (!plugin || !rel) continue // mobile-only, or no build for this OS/arch
530
+ const abs = resolve(plugin.dir, rel)
531
+ if (!existsSync(abs)) {
532
+ const msg = `Plugin "${id}": desktop library ${abs} is missing (manifest desktop.library["${target}"])`
533
+ if (opts.strict) throw new CliError(msg)
534
+ warn(`${msg} — skipped for this desktop run`)
535
+ continue
536
+ }
537
+ files.push(abs)
538
+ const dir = dirname(abs)
539
+ if (!dirs.includes(dir)) dirs.push(dir)
540
+ }
541
+ return { dirs, files }
542
+ }
@@ -1,3 +1,5 @@
1
+ import { swiftPackageName, type IosPackage } from "./appShared"
2
+
1
3
  /* Templates for `lecodes app init ios` / `lecodes app sync` — distilled 1:1 from the
2
4
  * hand-built, device-verified reference shell (docs/xcode-app-plan.md). Three ownership
3
5
  * tiers: App/ Swift files are USER-owned after generation (init writes once, sync never
@@ -696,6 +698,8 @@ export interface RuntimePlugin {
696
698
  register: string
697
699
  /** Plugin id, for the generated comments. */
698
700
  id: string
701
+ /** Swift packages the target links (manifest ios.packages). */
702
+ packages?: IosPackage[]
699
703
  }
700
704
 
701
705
  /** The SDK binary products (all vend the same `LeCodesSDK` module): core = UI only,
@@ -711,13 +715,33 @@ export type SdkVariant = "core" | "2d" | "3d" | "full"
711
715
  * SDK. Moving the version is the CLI's job — `lecodes app update` (or app.json ios.sdk). */
712
716
  export const runtimePackageTemplate = (sdkVersion: string, variant: SdkVariant, plugins: RuntimePlugin[]): string => {
713
717
  const product = `LeCodesSDK-${variant}`
714
- const pluginTargets = plugins.map(p => `
718
+ // Third-party Swift packages (manifest ios.packages), one `.package(…)` per url. Two plugins
719
+ // naming the same url must agree on the pin — SwiftPM has one resolution per package.
720
+ const packages = new Map<string, IosPackage>()
721
+ for (const p of plugins) {
722
+ for (const pkg of p.packages ?? []) {
723
+ const prior = packages.get(pkg.url)
724
+ if (prior && (prior.exact !== pkg.exact || prior.from !== pkg.from)) {
725
+ throw new Error(`Plugin "${p.id}" pins ${pkg.url} at ${pkg.exact ?? `from ${pkg.from}`} but another plugin pins ${prior.exact ?? `from ${prior.from}`} — the shell can link one version.`)
726
+ }
727
+ packages.set(pkg.url, pkg)
728
+ }
729
+ }
730
+ const packageDecls = [...packages.values()].map(pkg =>
731
+ `\n .package(url: "${pkg.url}", ${pkg.exact ? `exact: "${pkg.exact}"` : `from: "${pkg.from}"`}),`).join("")
732
+ const pluginTargets = plugins.map(p => {
733
+ const deps = [
734
+ `.product(name: "${product}", package: "lecodes-ios-sdk")`,
735
+ ...(p.packages ?? []).flatMap(pkg => pkg.products.map(name => `.product(name: "${name}", package: "${swiftPackageName(pkg.url)}")`)),
736
+ ]
737
+ return `
715
738
  // plugin: ${p.id} (vendored — see plugins.lock.json)
716
739
  .target(
717
740
  name: "${p.module}",
718
- dependencies: [.product(name: "${product}", package: "lecodes-ios-sdk")],
741
+ dependencies: [${deps.join(", ")}],
719
742
  path: "Sources/${p.module}"
720
- ),`).join("")
743
+ ),`
744
+ }).join("")
721
745
  const runtimeDeps = [`.product(name: "${product}", package: "lecodes-ios-sdk")`, ...plugins.map(p => `"${p.module}"`)]
722
746
  return `// swift-tools-version:5.9
723
747
  //
@@ -736,7 +760,7 @@ let package = Package(
736
760
  .library(name: "LeCodesRuntime", targets: ["LeCodesRuntime"]),
737
761
  ],
738
762
  dependencies: [
739
- .package(url: "https://github.com/letary/lecodes-ios-sdk.git", exact: "${sdkVersion}"),
763
+ .package(url: "https://github.com/letary/lecodes-ios-sdk.git", exact: "${sdkVersion}"),${packageDecls}
740
764
  ],
741
765
  targets: [
742
766
  .target(
@@ -1,7 +1,8 @@
1
+ import { basename } from "node:path"
1
2
  import { CliError, c, flagBool, flagStr, log, note, type Args } from "../util"
2
3
  import { findProjectRoot } from "../manifest"
3
4
  import { buildBundle } from "./compile"
4
- import { assertRendererSupportsBundle, readAppConfigAt, resolveDesktopRenderer } from "./appShared"
5
+ import { assertRendererSupportsBundle, desktopPluginLibraries, readAppConfigAt, resolveDesktopRenderer } from "./appShared"
5
6
  import { buildDesktop } from "./appDesktop"
6
7
  import {
7
8
  DESKTOP_RENDERER_VERSION,
@@ -57,9 +58,13 @@ const run = async (args: Args) => {
57
58
  const renderer = resolveDesktopRenderer(root, args)
58
59
  const { js, resources } = await buildBundle(args, undefined, { localAssets: true, desktopRenderer: renderer })
59
60
  assertRendererSupportsBundle(renderer, js)
61
+ // Native plugins: the project's plugins/ dir + declared plugin libraries, scanned by the host.
62
+ const { dirs: pluginDirs, files: pluginFiles } = desktopPluginLibraries(root)
63
+ if (pluginFiles.length > 0) note(`Native plugins: ${pluginFiles.map((f) => basename(f)).join(", ")}`)
60
64
  note(`Launching LeCodes Desktop${renderer === "vulkan" ? " (Vulkan)" : ""}… (close the window to return)`)
61
65
  await runDesktopApp(js, resources, {
62
66
  renderer,
67
+ pluginDirs,
63
68
  width: Number(flagStr(args, "width")) || win?.width,
64
69
  height: Number(flagStr(args, "height")) || win?.height,
65
70
  minWidth: win?.minWidth,
@@ -5,6 +5,8 @@ import { basename, join } from "node:path"
5
5
  import { findProjectRoot, readManifest, LECODES_DIR, type Manifest } from "../manifest"
6
6
  import { loadIgnore } from "../ignore"
7
7
  import { startDevServer } from "../dev/devServer"
8
+ import { resolveWebRunnerDir } from "../dev/webRunner"
9
+ import { openBrowser } from "../browserAuth"
8
10
  import { runDesktopDev } from "../desktopRenderer"
9
11
  import { readAppConfigAt } from "./appShared"
10
12
  import { resolveScale } from "./desktop"
@@ -20,6 +22,11 @@ import { CliError, c, flagBool, flagStr, log, note, toKey, warnErr, type Args }
20
22
  * backend. Custom shaders compile locally too (matc, downloaded on first use — matcTool.ts);
21
23
  * only when matc can't be fetched do they fall back to server-compiled artifacts (push once).
22
24
  *
25
+ * `--web` opens the same project in the browser: the dev server also serves the Web Lite runner
26
+ * (viewer-lite's dist-embed, vendored as runtime/web) under /web/ — CSS-flexbox UI + OGL 3D + the
27
+ * web twins of the camera/map plugins, no wasm. Same bundle and hot reload; browser console
28
+ * output streams back here like a device's. The URL is printed whenever the runner is available.
29
+ *
23
30
  * Works inside a cloned project or standalone in any folder with an entry .ts file.
24
31
  * The desktop host speaks websocket since 1.0.0 (host_ws); `?t=poll` remains the fallback
25
32
  * for older desktop builds (or any host without a websocket bridge).
@@ -116,9 +123,14 @@ export const dev = async (args: Args) => {
116
123
  const origin = `http://${host}:${port}`
117
124
  const token = loadDevToken(root)
118
125
 
126
+ const webDir = resolveWebRunnerDir()
127
+ if (flagBool(args, "web") && !webDir) {
128
+ throw new CliError("No Web Lite runner found for --web: this CLI has no runtime/web (a monorepo checkout needs `bun run build:embed` in packages/viewer-lite).")
129
+ }
119
130
  const server = await startDevServer({
120
131
  root, manifest, name, origin, port, token,
121
132
  entryOverride: flagStr(args, "entry"),
133
+ webDir,
122
134
  log: note,
123
135
  }).catch((e: unknown) => {
124
136
  const msg = e instanceof Error ? e.message : String(e)
@@ -180,6 +192,10 @@ export const dev = async (args: Args) => {
180
192
  log("")
181
193
  if (!flagBool(args, "no-qr")) await printQr(server.url)
182
194
  log(`Scan the QR with the LeCodes app, or open: ${c.bold(server.url)}`)
195
+ // The browser runner: localhost is a secure context (camera works there), the LAN address is
196
+ // what a phone's browser can reach.
197
+ const webLocal = server.webUrl ? server.webUrl.replace(`http://${host}:`, "http://localhost:") : null
198
+ if (server.webUrl && webLocal) log(`In the browser (Web Lite): ${c.bold(webLocal)} (LAN: ${server.webUrl})`)
183
199
  note(`Older desktop builds (no websocket bridge): ${server.url}?t=poll`)
184
200
  if (alternatives.length > 0) {
185
201
  note(`Device can't reach it? This machine also has ${alternatives.map((a) => `${a.address} (${a.iface})`).join(", ")} — retry with --host <ip>.`)
@@ -188,6 +204,12 @@ export const dev = async (args: Args) => {
188
204
  log("")
189
205
  log(`Watching ${root} — save a file to hot-reload. Ctrl+C to stop.`)
190
206
 
207
+ // --web: open the browser runner (the server keeps serving it either way).
208
+ if (flagBool(args, "web") && webLocal) {
209
+ note("Opening the browser…")
210
+ openBrowser(webLocal)
211
+ }
212
+
191
213
  // --desktop: open the dev URL in the native host too (same server, same hot reload — the host
192
214
  // speaks the websocket channel). Window config comes from app.json's desktop block. The window
193
215
  // closing does NOT stop the server: phones stay connected, and re-running with --desktop is the
@@ -23,7 +23,7 @@ lecodes render src/screens/cart.ts # ONE screen module, without booting the ap
23
23
  # --state <name> picks the branch). Same flags as above.
24
24
  lecodes render --desktop --png shot.png # NATIVE render: real Filament 3D + UI + 2D composited —
25
25
  # use this to SEE 3D scenes / procedural geometry (headless
26
- # renders record 3D as JSON only). Windows/Linux; downloads
26
+ # renders record 3D as JSON — simulated, not drawn). Windows/Linux; downloads
27
27
  # the renderer on first use. --frames <n> for slow loads.
28
28
  lecodes render --script <file> # drive the app through a scenario (format below)
29
29
  lecodes render --desktop --view iso --png iso.png # 3D: inspection camera fitted to the WHOLE
@@ -72,8 +72,9 @@ advance in fixed 1/60 steps driven by \`wait\`/\`frames\`/\`drag\`/\`key\` — n
72
72
  so a flow that passes once passes every time. \`Input.key(code)\` reads the keys a flow holds and
73
73
  \`Input.on('keydown' | 'keyup', e => e.code)\` fires for each \`key\`/\`keyDown\`/\`keyUp\` step
74
74
  (events land before the next loop tick); \`stick\` values are what \`Input.gamepad(0).axis()\` reads.
75
- Headless draws UI + the 2D scene (sprites, tilemaps, Box2D); 3D is captured as JSON and Jolt does
76
- not simulate — use \`--desktop\` for 3D pixels. \`console.log\` + \`expect.log\` is the assertion
75
+ Headless draws UI + the 2D scene (sprites, tilemaps, Box2D); 3D is captured as JSON with REAL Jolt
76
+ physics (bodies fall, characters collide, triggers fire, ragdolls drop — a character with no ground
77
+ under it falls, like on device) — use \`--desktop\` for 3D pixels. \`console.log\` + \`expect.log\` is the assertion
77
78
  channel for game state that has no UI.
78
79
 
79
80
  ## Make the app testable
@@ -4,7 +4,7 @@ import { compileHeadlessBundle } from "../compile/headlessBundle"
4
4
  import { screenTargetFromArgs } from "../compile/screenEntry"
5
5
  import { findProjectRoot } from "../manifest"
6
6
  import { buildBundle } from "./compile"
7
- import { desktopDefaultViewport, readAppConfigAt, resolveDesktopRenderer } from "./appShared"
7
+ import { desktopDefaultViewport, desktopPluginLibraries, readAppConfigAt, resolveDesktopRenderer } from "./appShared"
8
8
  import { runDesktopRender } from "../desktopRenderer"
9
9
  import { evaluateDesktopRun, translateScenario } from "../desktopScript"
10
10
  import { CliError, c, flagStr, flagBool, isNullSink, logErr, note, warnErr, type Args } from "../util"
@@ -196,6 +196,8 @@ const renderDesktop = async (args: Args) => {
196
196
 
197
197
  // One renderer decision: the host binary AND the staged .filamat variant (see appShared).
198
198
  const desktopRenderer = resolveDesktopRenderer(root, args)
199
+ // Native plugins (project plugins/ dir + declared libraries) — the host scans CREATOR_PLUGIN_DIRS.
200
+ const { dirs: pluginDirs } = desktopPluginLibraries(root)
199
201
  const { js, resources } = await buildBundle(args, undefined, {
200
202
  localAssets: true, desktopRenderer,
201
203
  extraEntries: screen ? [screen.entry] : undefined, entryOverride: screen?.entryPath,
@@ -212,7 +214,7 @@ const renderDesktop = async (args: Args) => {
212
214
  const timeoutMs = Number(flagStr(args, "timeout")) || (60000 + plan.steps.length * 5000)
213
215
  note(`Running ${plan.steps.length} step${plan.steps.length === 1 ? "" : "s"} (native)…`)
214
216
  const { output } = await runDesktopRender(js, resources, {
215
- renderer: desktopRenderer,
217
+ renderer: desktopRenderer, pluginDirs,
216
218
  width, height, frames: plan.frameCap, outPng: pngFlag, logs, timeoutMs, script: plan.text, fixedDtMs,
217
219
  })
218
220
  const result = evaluateDesktopRun(plan, output)
@@ -234,7 +236,7 @@ const renderDesktop = async (args: Args) => {
234
236
  const timeoutMs = Number(flagStr(args, "timeout")) || 60000
235
237
  note(screen ? `Rendering ${screen.label} (native)…` : "Rendering (native)…")
236
238
  await runDesktopRender(js, resources, {
237
- renderer: desktopRenderer,
239
+ renderer: desktopRenderer, pluginDirs,
238
240
  width, height, frames, outPng: pngFlag, logs, timeoutMs, fixedDtMs, camera, view,
239
241
  })
240
242
  note(`Wrote ${pngFlag} (${width}x${height}, native render${view ? `, ${view.split(";")[0]} view` : camera ? ", custom camera" : ""})`)
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs"
2
2
  import { dirname, resolve } from "node:path"
3
3
  import { compileHeadlessBundle } from "../compile/headlessBundle"
4
- import { desktopDefaultViewport, resolveDesktopRenderer } from "./appShared"
4
+ import { desktopDefaultViewport, desktopPluginLibraries, resolveDesktopRenderer } from "./appShared"
5
5
  import { fileScenarioIO, requireRenderer, resolveFixtures, resolveSafeAreaChoice } from "./render"
6
6
  import { buildBundle } from "./compile"
7
7
  import { findProjectRoot } from "../manifest"
@@ -261,6 +261,7 @@ export const test = async (args: Args) => {
261
261
  const finalPng = resolve(caseDir, "final.png")
262
262
  const { output } = await runDesktopRender(native!.js, native!.resources, {
263
263
  renderer: desktopRenderer,
264
+ pluginDirs: desktopPluginLibraries(root).dirs,
264
265
  width: Number(flagStr(args, "width")) || kase.width || projectViewport?.width || 390,
265
266
  height: Number(flagStr(args, "height")) || kase.height || projectViewport?.height || 844,
266
267
  frames: plan.frameCap, outPng: finalPng, logs: flagBool(args, "logs"),
@@ -1,7 +1,7 @@
1
1
  import { execFileSync, spawn, spawnSync } from "node:child_process"
2
2
  import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
3
3
  import { homedir, tmpdir } from "node:os"
4
- import { join, resolve } from "node:path"
4
+ import { delimiter, join, resolve } from "node:path"
5
5
  import { cachedArtifactSha, downloadReleaseArchive, fetchReleaseProduct, semverNewer, type ManifestVersion } from "./releases"
6
6
  import { CliError, note, warnErr } from "./util"
7
7
  import { makeStackDemangler } from "./compile/nativeStack"
@@ -299,10 +299,20 @@ export type DesktopWindowOpts = {
299
299
  /** Multiplayer launch facts for the host (`--server --port N --max-clients N` / `--connect ip:port`);
300
300
  * the SDK reads them through `Net.launch` and decides (docs/multiplayer-plan.md). */
301
301
  net?: { role: "server" | "client"; address?: string; port?: number; maxClients?: number }
302
+ /** Dirs the host scans for native plugin libraries (CREATOR_PLUGIN_DIRS; the loader was added
303
+ * after host 1.4.0 — an older host ignores the env). See appShared.desktopPluginLibraries. */
304
+ pluginDirs?: string[]
305
+ }
306
+
307
+ /** CREATOR_PLUGIN_DIRS: the project's plugin dirs first, then whatever the environment already
308
+ * carried (a `.env` CREATOR_PLUGIN_DIRS keeps working alongside the project's `plugins/`). */
309
+ const pluginEnv = (dirs: string[] | undefined): Record<string, string> => {
310
+ const all = [...(dirs ?? []), ...(process.env.CREATOR_PLUGIN_DIRS ? [process.env.CREATOR_PLUGIN_DIRS] : [])]
311
+ return all.length > 0 ? { CREATOR_PLUGIN_DIRS: all.join(delimiter) } : {}
302
312
  }
303
313
 
304
314
  const windowEnv = (opts: DesktopWindowOpts): Record<string, string> => {
305
- const env: Record<string, string> = {}
315
+ const env: Record<string, string> = { ...pluginEnv(opts.pluginDirs) }
306
316
  // A lone width/height fills the other side from the host default so it isn't silently ignored.
307
317
  const width = opts.width ?? (opts.height !== undefined ? 1280 : undefined)
308
318
  const height = opts.height ?? (opts.width !== undefined ? 720 : undefined)
@@ -356,6 +366,8 @@ export type DesktopRenderOptions = {
356
366
  /** Inspection camera: CREATOR_CAMERA ("ex,ey,ez;tx,ty,tz[;fov]") or CREATOR_VIEW ("iso[;fov]"). */
357
367
  camera?: string
358
368
  view?: string
369
+ /** Dirs the host scans for native plugin libraries (CREATOR_PLUGIN_DIRS). */
370
+ pluginDirs?: string[]
359
371
  }
360
372
 
361
373
  export type DesktopRenderOutcome = {
@@ -482,6 +494,7 @@ export const runDesktopRender = async (
482
494
  if (opts.fixedDtMs) env.CREATOR_FIXED_DT = String(opts.fixedDtMs)
483
495
  if (opts.camera) env.CREATOR_CAMERA = opts.camera
484
496
  if (opts.view) env.CREATOR_VIEW = opts.view
497
+ Object.assign(env, pluginEnv(opts.pluginDirs))
485
498
  // Frames the host prints name the minified bundle (`module.js:5:51631`); map them back to
486
499
  // project positions. Chunks stay raw so `[script]` marker parsing sees exactly what was sent —
487
500
  // the rewrite happens on the joined text (and best-effort per chunk for live --logs).
@@ -24,6 +24,9 @@ import { makeStackDemangler } from "../compile/nativeStack"
24
24
  * GET /t/<tok>/version {"v":N} — polling fallback
25
25
  * POST /t/<tok>/log log/error backchannel for polling clients
26
26
  * WS /t/<tok>/ws reload push + log/error backchannel
27
+ * GET /t/<tok>/web/[file] the Web Lite browser runner (viewer-lite dist-embed, `dev --web`):
28
+ * it loads bundle.js?t=web — the bundle WITHOUT the native prelude;
29
+ * the page owns reload (ws / version polling) and the log backchannel
27
30
  *
28
31
  * Compile is eager: the command calls recompile() on every file change; a successful compile bumps
29
32
  * `v` and broadcasts {type:"reload", v} to connected sockets (polling clients see /version move).
@@ -41,6 +44,8 @@ export type DevServerOptions = {
41
44
  port: number
42
45
  token: string
43
46
  entryOverride?: string
47
+ /** The Web Lite runner folder to serve under /web/ (src/dev/webRunner.ts); absent = no browser route. */
48
+ webDir?: string | null
44
49
  /** Diagnostics (compile errors, device logs); defaults to stderr-style silence-safe noop. */
45
50
  log?: (msg: string) => void
46
51
  }
@@ -51,6 +56,8 @@ export type RecompileResult =
51
56
 
52
57
  export type DevServer = {
53
58
  url: string
59
+ /** The browser runner page (`dev --web`), or null when no runner is vendored. */
60
+ webUrl: string | null
54
61
  /** The token base path ("/t/<token>"). */
55
62
  base: string
56
63
  /** The actually-bound port (differs from options.port when 0 was passed, e.g. in tests). */
@@ -68,7 +75,8 @@ const MIME: Record<string, string> = {
68
75
  mp3: "audio/mpeg", wav: "audio/wav", ogg: "audio/ogg", m4a: "audio/mp4",
69
76
  mp4: "video/mp4", webm: "video/webm",
70
77
  ttf: "font/ttf", otf: "font/otf", woff: "font/woff", woff2: "font/woff2",
71
- json: "application/json", txt: "text/plain", js: "text/javascript",
78
+ json: "application/json", txt: "text/plain", js: "text/javascript", mjs: "text/javascript",
79
+ css: "text/css", html: "text/html", wasm: "application/wasm", map: "application/json",
72
80
  }
73
81
 
74
82
  const contentType = (path: string): string => {
@@ -182,22 +190,54 @@ export const startDevServer = async (options: DevServerOptions): Promise<DevServ
182
190
  }
183
191
 
184
192
  // ---- http --------------------------------------------------------------------------------------
185
- const transportOf = (url: URL): "ws" | "poll" => (url.searchParams.get("t") === "poll" ? "poll" : "ws")
193
+ // "web" = the browser runner: no prelude at all (the page brings its own reload + log channel).
194
+ const transportOf = (url: URL): "ws" | "poll" | "web" => {
195
+ const t = url.searchParams.get("t")
196
+ return t === "poll" ? "poll" : t === "web" ? "web" : "ws"
197
+ }
198
+ const withPrelude = (js: string, url: URL, v: number) => {
199
+ const transport = transportOf(url)
200
+ return transport === "web" ? js : js + "\n" + renderPrelude({ origin, base, transport, v })
201
+ }
202
+ const webDir = options.webDir ?? null
186
203
 
187
204
  const handle = async (req: IncomingMessage, res: ServerResponse) => {
188
205
  const url = new URL(req.url ?? "/", origin)
189
206
  const path = url.pathname
207
+ // Browsers ask for it on every page load (the /web/ runner) — keep the console clean.
208
+ if (path === "/favicon.ico") { res.writeHead(204); res.end(); return }
190
209
  if (!path.startsWith(base + "/")) return sendText(res, 404, "text/plain", "Not found")
191
210
  const route = path.slice(base.length)
192
211
 
193
212
  if (route === "/dev.js") {
194
- return sendText(res, 200, "text/javascript",
195
- devJs + "\n" + renderPrelude({ origin, base, transport: transportOf(url), v: -1 }))
213
+ return sendText(res, 200, "text/javascript", withPrelude(devJs, url, -1))
196
214
  }
197
215
  if (route === "/bundle.js") {
198
216
  if (bundleJs === null) return sendText(res, 503, "text/plain", "No successful compile yet — check the terminal.")
199
- return sendText(res, 200, "text/javascript",
200
- bundleJs + "\n" + renderPrelude({ origin, base, transport: transportOf(url), v }))
217
+ res.setHeader("x-lecodes-v", String(v)) // the browser runner learns the version it runs
218
+ return sendText(res, 200, "text/javascript", withPrelude(bundleJs, url, v))
219
+ }
220
+ // The browser runner: a static folder with relative asset paths, so it needs the trailing slash.
221
+ if (route === "/web") {
222
+ res.writeHead(302, { location: `${base}/web/${url.search}` })
223
+ res.end()
224
+ return
225
+ }
226
+ if (route.startsWith("/web/")) {
227
+ if (!webDir) return sendText(res, 404, "text/plain", "No Web Lite runner vendored with this CLI (lecodes-viewer-lite dist-embed).")
228
+ const rel = route === "/web/" ? "embed.html" : route.slice("/web/".length)
229
+ if (rel.split("/").some((seg) => seg === ".." || seg === "" || seg === ".")) return sendText(res, 400, "text/plain", "Bad path")
230
+ const abs = join(webDir, ...rel.split("/"))
231
+ try {
232
+ if (!statSync(abs).isFile()) throw new Error("not a file")
233
+ } catch {
234
+ return sendText(res, 404, "text/plain", "Not found")
235
+ }
236
+ // The entry files (html, embed.js) must revalidate — a CLI update swaps them under the same URL.
237
+ const hashed = rel.startsWith("assets/")
238
+ res.writeHead(200, { "content-type": contentType(rel), "cache-control": hashed ? "max-age=31536000, immutable" : "no-store" })
239
+ createReadStream(abs).pipe(res)
240
+ return
201
241
  }
202
242
  if (route === "/version") {
203
243
  return sendJson(res, 200, { v })
@@ -279,6 +319,7 @@ export const startDevServer = async (options: DevServerOptions): Promise<DevServ
279
319
 
280
320
  return {
281
321
  url: `${origin}${base}/dev.js`,
322
+ webUrl: webDir ? `${origin}${base}/web/` : null,
282
323
  base,
283
324
  port: boundPort,
284
325
  v: () => v,
@@ -0,0 +1,22 @@
1
+ import { existsSync } from "node:fs"
2
+ import { createRequire } from "node:module"
3
+ import { dirname, join } from "node:path"
4
+ import { distRoot } from "../distRoot"
5
+
6
+ /**
7
+ * Where the browser runner for `lecodes dev --web` lives: viewer-lite's built `dist-embed`
8
+ * (embed.html + embed.js + assets/ — the Web Lite host as a static folder). Two shapes:
9
+ * - vendored next to the CLI as runtime/web (scripts/vendor-runtime.ts — the npm package and
10
+ * the standalone binary),
11
+ * - the lecodes-viewer-lite package's own dist-embed (monorepo dev after `bun run build:embed`).
12
+ * `null` = no runner available (the dev server then serves everything but /web/).
13
+ */
14
+ export const resolveWebRunnerDir = (): string | null => {
15
+ const candidates = [join(distRoot, "runtime", "web")]
16
+ try {
17
+ const pkg = createRequire(import.meta.url).resolve("lecodes-viewer-lite/package.json")
18
+ candidates.push(join(dirname(pkg), "dist-embed"))
19
+ } catch { /* no package: standalone */ }
20
+ for (const dir of candidates) if (existsSync(join(dir, "embed.html")) && existsSync(join(dir, "embed.js"))) return dir
21
+ return null
22
+ }
package/src/index.ts CHANGED
@@ -120,6 +120,9 @@ ${c.bold("Commands:")}
120
120
  --host <ip> LAN address to advertise (default: auto-detect)
121
121
  --entry <path> override the entrypoint
122
122
  --no-qr don't render the QR code
123
+ --web also open the app in the browser (Web Lite host:
124
+ same bundle + hot reload, camera/map plugins
125
+ have web twins; the /web/ URL is served always)
123
126
  --desktop also open the app in the native desktop host
124
127
  (same hot reload; window from app.json's
125
128
  desktop block; closing it keeps the server)