lecodes-cli 0.19.2 → 0.20.1

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 (59) hide show
  1. package/README.md +1 -0
  2. package/dist/index.js +1324 -658
  3. package/package.json +9 -9
  4. package/runtime/materials/decal-relief.mat +178 -0
  5. package/runtime/materials/decal.mat +172 -0
  6. package/runtime/materials/lightmap-baked-lite.mat +175 -0
  7. package/runtime/materials/lightmap-baked.mat +176 -0
  8. package/runtime/materials/lightmap.mat +181 -0
  9. package/runtime/materials/lit.mat +43 -0
  10. package/runtime/materials/particles-quad.mat +171 -0
  11. package/runtime/materials/particles.mat +121 -0
  12. package/runtime/materials/shadow.mat +21 -0
  13. package/runtime/materials/terrain.mat +134 -0
  14. package/runtime/materials/unlit-transparent.mat +42 -0
  15. package/runtime/materials/unlit.mat +44 -0
  16. package/runtime/materials/video.mat +31 -0
  17. package/runtime/sdk-types.json +1 -1
  18. package/runtime/web/assets/Roboto-Variable-DHm9jpd-.woff2 +0 -0
  19. package/runtime/web/assets/__vite-browser-external-BIHI7g3E.js +1 -0
  20. package/runtime/web/assets/basis-C64VHDVD.js +1 -0
  21. package/runtime/web/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
  22. package/runtime/web/assets/createViewerLite-C_bkKXLg.js +852 -0
  23. package/runtime/web/assets/draco-BiISTFcR.js +118 -0
  24. package/runtime/web/assets/draco_decoder-DsQ12WqX.wasm +0 -0
  25. package/runtime/web/assets/index-BTB5KV9x.js +2 -0
  26. package/runtime/web/assets/mapViewImpl-B2JcES8l.js +810 -0
  27. package/runtime/web/assets/maplibre-gl-worker-CJfwIrte.js +8 -0
  28. package/runtime/web/assets/neutral_ibl128-D-lzdkLN.ktx +0 -0
  29. package/runtime/web/assets/worker-Caf-yYEI.js +2 -0
  30. package/runtime/web/embed.html +32 -0
  31. package/runtime/web/embed.js +171 -0
  32. package/src/cmgenTool.ts +17 -9
  33. package/src/commands/app.ts +6 -1
  34. package/src/commands/appDesktop.ts +27 -4
  35. package/src/commands/appShared.ts +145 -3
  36. package/src/commands/appTemplates.ts +28 -4
  37. package/src/commands/assets.ts +3 -7
  38. package/src/commands/design.ts +9 -26
  39. package/src/commands/desktop.ts +9 -1
  40. package/src/commands/dev.ts +64 -0
  41. package/src/commands/projectTemplates.ts +17 -4
  42. package/src/commands/render.ts +9 -11
  43. package/src/commands/scene.ts +2 -6
  44. package/src/commands/shaders.ts +17 -1
  45. package/src/commands/shadersNew.ts +133 -0
  46. package/src/commands/test.ts +2 -1
  47. package/src/commands/thumbs.ts +3 -6
  48. package/src/commands/update.ts +42 -21
  49. package/src/desktopRenderer.ts +32 -2
  50. package/src/desktopScript.ts +1 -1
  51. package/src/dev/androidDev.ts +154 -0
  52. package/src/dev/devServer.ts +53 -6
  53. package/src/dev/webRunner.ts +22 -0
  54. package/src/index.ts +24 -4
  55. package/src/matcTool.ts +31 -19
  56. package/src/peerInstall.ts +160 -0
  57. package/src/peers.ts +48 -14
  58. package/src/projectEnv.ts +4 -0
  59. package/src/releases.ts +16 -0
package/src/cmgenTool.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readdirSync } from "node:fs"
2
2
  import { homedir } from "node:os"
3
3
  import { join, resolve } from "node:path"
4
- import { downloadReleaseArchive, fetchReleaseProduct, semverNewer, type ManifestVersion } from "./releases"
4
+ import { downloadReleaseArchive, fetchReleaseProduct, newestForPlatform, semverNewer, type ManifestFile, type ManifestVersion } from "./releases"
5
5
  import { CliError, note } from "./util"
6
6
 
7
7
  /*
@@ -20,7 +20,8 @@ import { CliError, note } from "./util"
20
20
  * stays network-free and still works standalone against a local build.
21
21
  *
22
22
  * No version pin, same reasoning as matc: the KTX1 layout is tied to the vendored Filament and
23
- * changes rarely, so resolution takes the newest cached copy, else downloads `latest`.
23
+ * changes rarely, so resolution takes the newest cached copy, else downloads the newest release
24
+ * PUBLISHED FOR THIS PLATFORM (see releases.ts newestForPlatform).
24
25
  */
25
26
 
26
27
  const exeName = () => (process.platform === "win32" ? "cmgen.exe" : "cmgen")
@@ -63,15 +64,21 @@ export const installedCmgenVersions = (): string[] => {
63
64
  export const fetchCmgenManifest = (): Promise<{ latest: string, versions: ManifestVersion[] }> =>
64
65
  fetchReleaseProduct("cmgen", "cmgen (environment tool)")
65
66
 
66
- const downloadCmgen = async (version: string, versions: ManifestVersion[], dest: string): Promise<void> => {
67
+ /** The published cmgen this platform should get: the newest version carrying a build for it (see
68
+ * newestForPlatform — a release often ships one platform, so `latest` is the wrong question). */
69
+ const pickCmgen = (versions: ManifestVersion[]): { version: string, file: ManifestFile } => {
67
70
  const key = platformKey()
68
- const file = versions.find((v) => v.version === version)?.files[key]
69
- if (!file) {
71
+ const hit = newestForPlatform(versions, key)
72
+ if (!hit) {
70
73
  throw new CliError(
71
- `cmgen ${version} [${key}] isn't published — build filament's host tools locally and point ` +
74
+ `cmgen isn't published for ${key} — build filament's host tools locally and point ` +
72
75
  `LECODES_CMGEN at the binary.`,
73
76
  )
74
77
  }
78
+ return hit
79
+ }
80
+
81
+ const downloadCmgen = async (version: string, file: ManifestFile, dest: string): Promise<void> => {
75
82
  note(`Downloading the environment tool (cmgen ${version}, ${(file.size / 1024 / 1024).toFixed(1)} MB)…`)
76
83
  await downloadReleaseArchive(file, {
77
84
  cacheRoot: cacheRoot(),
@@ -95,9 +102,10 @@ export const resolveCmgenExeCached = (): string | null => {
95
102
  export const resolveCmgenExe = async (): Promise<string> => {
96
103
  const cached = resolveCmgenExeCached()
97
104
  if (cached) return cached
98
- const { latest, versions } = await fetchCmgenManifest()
99
- const dir = join(cacheRoot(), `cmgen-${latest}`)
100
- await downloadCmgen(latest, versions, dir)
105
+ const { versions } = await fetchCmgenManifest()
106
+ const { version, file } = pickCmgen(versions)
107
+ const dir = join(cacheRoot(), `cmgen-${version}`)
108
+ await downloadCmgen(version, file, dir)
101
109
  const exe = findCmgenIn(dir)
102
110
  if (!exe) throw new CliError(`cmgen cache at ${dir} is broken — delete the folder and retry.`)
103
111
  return exe
@@ -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
 
@@ -199,8 +199,18 @@ export const buildDesktop = async (args: Args) => {
199
199
  note(` assets: ${assetCount} files, ${mb(assetBytes)} → ${ASSETS_DIR}/`)
200
200
 
201
201
  // 3) Pruned app.json — the host (≥1.3.0) reads name + desktop.window next to app.js, so the
202
- // folder carries its own window config (title/size/min/resizable/fullscreen).
203
- writeJson(join(out, "app.json"), { name, ...(config.desktop ? { desktop: { window: config.desktop.window } } : {}) })
202
+ // folder carries its own window config (title/size/min/resizable/fullscreen); a newer host also
203
+ // reads desktop.mouseEmulateTouch + desktop.virtualKeyboard there (an older one skips the
204
+ // unknown fields).
205
+ const desktop = config.desktop
206
+ writeJson(join(out, "app.json"), {
207
+ name,
208
+ ...(desktop ? { desktop: {
209
+ window: desktop.window,
210
+ ...(desktop.mouseEmulateTouch !== undefined ? { mouseEmulateTouch: desktop.mouseEmulateTouch } : {}),
211
+ ...(desktop.virtualKeyboard !== undefined ? { virtualKeyboard: desktop.virtualKeyboard } : {}),
212
+ } } : {}),
213
+ })
204
214
 
205
215
  // 4) Window/taskbar icon (host ≥1.3.0 loads app.ico next to the exe at runtime).
206
216
  if (config.icon) {
@@ -208,7 +218,20 @@ export const buildDesktop = async (args: Args) => {
208
218
  note(" app.ico rendered from " + config.icon)
209
219
  }
210
220
 
211
- // 5) A short README (BOM so double-click-Notepad shows it right).
221
+ // 5) Native plugins (packages/desktop/plugin-sdk/lecodes_plugin.h): the project's plugins/ dir +
222
+ // every declared plugin's desktop library → plugins/ next to the exe, where a host WITH the plugin
223
+ // loader (added after 1.4.0) scans at startup; an older host ignores the folder.
224
+ // A declared library that's missing is a build error (strict).
225
+ {
226
+ const { files } = desktopPluginLibraries(root, { strict: true })
227
+ if (files.length > 0) {
228
+ mkdirSync(join(out, "plugins"), { recursive: true })
229
+ for (const f of files) cpSync(f, join(out, "plugins", basename(f)))
230
+ note(` plugins: ${files.map((f) => basename(f)).join(", ")} → plugins/`)
231
+ }
232
+ }
233
+
234
+ // 6) A short README (BOM so double-click-Notepad shows it right).
212
235
  writeFileSync(join(out, "README.txt"), "\uFEFF" + readmeText(name, exeName, version))
213
236
 
214
237
  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
  }
@@ -76,12 +77,60 @@ export interface AppConfig {
76
77
  /** 3D buffer cap in px, e.g. [1920, 1080]: on a bigger window/monitor the 3D renders at most
77
78
  * this size (aspect kept) — the per-machine answer to "fullscreen on a 4K display is slow". */
78
79
  maxRenderSize?: [number, number],
80
+ /** The mouse button acts as a finger: a left click-drag scrolls + flings like touch (the wheel
81
+ * always scrolls, clicks are unaffected; a button inside a scrollable then gets the touch-style
82
+ * press feedback delay). For testing the touch experience without a touchscreen, or an app
83
+ * driven by a mouse-like device. `desktop run` forwards it as CREATOR_MOUSE_TOUCH (a set env
84
+ * wins); a built folder's host reads it from the staged app.json. Headless render/test ignore it. */
85
+ mouseEmulateTouch?: boolean,
86
+ /** On-screen keyboard for EVERY input/textarea (touch kiosks, a screen without a keyboard).
87
+ * `true` or a block; `desktop run` forwards it as CREATOR_VIRTUAL_KEYBOARD (a set env wins),
88
+ * a built folder's host reads it from the staged app.json. Headless render/test ignore it. */
89
+ virtualKeyboard?: boolean | DesktopVirtualKeyboard,
79
90
  /** The macOS .app build (`lecodes app build desktop` on a Mac) — identity + signing +
80
91
  * notarization. See appDesktopMac.ts for the whole story. */
81
92
  macos?: MacDesktopConfig,
82
93
  }
83
94
  }
84
95
 
96
+ /** One style slot of the desktop on-screen keyboard — the SDK's style words, the subset the drawn
97
+ * keyboard reads. Unset fields keep the theme preset. */
98
+ export interface DesktopKeyboardSlot {
99
+ backgroundColor?: string
100
+ color?: string
101
+ borderRadius?: number
102
+ fontSize?: number
103
+ /** A family the app registered with `font()` / `Fonts`, so labels match the UI. */
104
+ fontFamily?: string
105
+ /** `board` only: band inset / key spacing (logical px). */
106
+ padding?: number
107
+ gap?: number
108
+ }
109
+
110
+ /** app.json `desktop.virtualKeyboard` as a block. */
111
+ export interface DesktopVirtualKeyboard {
112
+ /** Off/on; an object without it counts as on. */
113
+ enabled?: boolean
114
+ /** Layouts in order, the first is the start one; the language key appears with two or more.
115
+ * Shipped: "en", "ru". */
116
+ layouts?: ("en" | "ru")[]
117
+ /** Band height: ≤ 1 = a fraction of the window (default 0.32), > 1 = logical px. */
118
+ height?: number
119
+ /** Cap on the KEY AREA's width in logical px — on a wide kiosk the keys would otherwise stretch
120
+ * across the whole window. The band itself always spans the window; the keys sit centred in it. */
121
+ maxWidth?: number
122
+ /** Colour preset the style slots override. */
123
+ theme?: "dark" | "light"
124
+ style?: {
125
+ board?: DesktopKeyboardSlot
126
+ key?: DesktopKeyboardSlot
127
+ /** A held key (and an active shift). Inherits `key` first. */
128
+ keyPressed?: DesktopKeyboardSlot
129
+ /** Shift / backspace / enter / page keys. Inherits `key` first. */
130
+ keySpecial?: DesktopKeyboardSlot
131
+ }
132
+ }
133
+
85
134
  /** app.json `desktop.macos`. Only NAMES live here (identity, keychain profile) — the certificate
86
135
  * and the notarization password stay in the keychain. */
87
136
  export interface MacDesktopConfig {
@@ -166,6 +215,21 @@ export const desktopDefaultViewport = (root: string): { width: number, height: n
166
215
  }
167
216
  }
168
217
 
218
+ /** A SwiftPM dependency a plugin's iOS target links (manifest ios.packages). */
219
+ export interface IosPackage {
220
+ url: string
221
+ /** Pinned release — the normal case (a plugin's sources compile against one API). */
222
+ exact?: string
223
+ /** Minimum release, SwiftPM picks the newest compatible major. Exclusive with `exact`. */
224
+ from?: string
225
+ /** Library products of that package the plugin target imports. */
226
+ products: string[]
227
+ }
228
+
229
+ /** SwiftPM's package identity for a url: the last path segment without `.git`. */
230
+ export const swiftPackageName = (url: string): string =>
231
+ (url.replace(/\/+$/, "").split("/").pop() ?? url).replace(/\.git$/, "")
232
+
169
233
  export interface PluginManifest {
170
234
  id: string
171
235
  version?: string
@@ -178,6 +242,12 @@ export interface PluginManifest {
178
242
  * by sync; the relay signs sends for Letary-published bundle ids with the shared team
179
243
  * key, and the register route fail-fasts for anything outside that namespace. */
180
244
  entitlements?: Record<string, unknown>,
245
+ /** Swift packages the vendored target links (e.g. the map plugin's maplibre-native). Each
246
+ * becomes a `.package(url:…)` in the generated LeCodesRuntime/Package.swift (exact or
247
+ * from — one of the two) and its `products` become `.product(name:package:)` deps of the
248
+ * plugin target. The package name is the URL's last path segment minus `.git`, as SwiftPM
249
+ * derives it. Two plugins naming the same url must agree on the version. */
250
+ packages?: IosPackage[],
181
251
  } | null
182
252
  android?: {
183
253
  /** Dir vendored into the shell (default "android") — a Gradle-module-shaped tree minus
@@ -200,6 +270,15 @@ export interface PluginManifest {
200
270
  * Bare ids resolve through the CLI's known-version table; "id:version" pins explicitly. */
201
271
  gradlePlugins?: string[],
202
272
  } | null
273
+ /** Desktop host (packages/desktop): a PREBUILT shared library per OS/arch behind the C ABI in
274
+ * `packages/desktop/plugin-sdk/lecodes_plugin.h` — no host rebuild, no source vendoring.
275
+ * `library` keys are `${process.platform}-${process.arch}` ("win32-x64", "linux-x64",
276
+ * "linux-arm64"), values plugin-dir-relative paths. `lecodes desktop run` / `render --desktop`
277
+ * forward the library's dir to the host (CREATOR_PLUGIN_DIRS); `app build desktop` stages the
278
+ * file into `<build>/plugins/`. A plugin without a build for the current target is skipped. */
279
+ desktop?: {
280
+ library?: Record<string, string>,
281
+ } | null
203
282
  }
204
283
 
205
284
  export interface LockEntry { repo: string, tag: string, commit: string, version: string }
@@ -400,7 +479,12 @@ export const ensureRepo = (repo: string, wantTag: string | null, lock: LockEntry
400
479
 
401
480
  /** Resolve one app.json plugin entry to vendorable sources + manifest + lock pin. The platform
402
481
  * 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 => {
482
+ export type PluginPlatform = "ios" | "android" | "desktop"
483
+ const PLUGIN_PLATFORM_LABEL: Record<PluginPlatform, string> = { ios: "iOS", android: "Android", desktop: "desktop" }
484
+
485
+ export function resolvePlugin(id: string, spec: string, lock: LockEntry | undefined, platform: PluginPlatform): ResolvedPlugin
486
+ export function resolvePlugin(id: string, spec: string, lock: LockEntry | undefined, platform: PluginPlatform, opts: { optional: true }): ResolvedPlugin | null
487
+ export function resolvePlugin(id: string, spec: string, lock: LockEntry | undefined, platform: PluginPlatform, opts?: { optional?: boolean }): ResolvedPlugin | null {
404
488
  let repo: string
405
489
  let subdir: string
406
490
  let wantTag: string | null = null
@@ -424,7 +508,8 @@ export const resolvePlugin = (id: string, spec: string, lock: LockEntry | undefi
424
508
  if (!manifest) throw new CliError(`Plugin "${id}": no lecodes-plugin.json in ${dir}`)
425
509
  if (manifest.id !== id) throw new CliError(`Plugin "${id}": manifest declares id "${manifest.id}"`)
426
510
  if (!manifest[platform]) {
427
- throw new CliError(`Plugin "${id}" does not support ${platform === "ios" ? "iOS" : "Android"} (manifest "${platform}" is null)`)
511
+ if (opts?.optional) return null
512
+ throw new CliError(`Plugin "${id}" does not support ${PLUGIN_PLATFORM_LABEL[platform]} (manifest "${platform}" is null)`)
428
513
  }
429
514
 
430
515
  // Namespacing: third-party channel names must be vendor-prefixed ("acme.thing").
@@ -446,3 +531,60 @@ export const resolvePlugin = (id: string, spec: string, lock: LockEntry | undefi
446
531
  export const latestPluginTag = (repo: string): string | null => {
447
532
  try { return ensureRepo(repo, null, undefined).tag } catch { return null }
448
533
  }
534
+
535
+ /* ---------------------------------- desktop native plugins ---------------------------------- */
536
+
537
+ /** The desktop host's plugin library target key (manifest `desktop.library` keys). */
538
+ export const desktopPluginTarget = (): string => `${process.platform}-${process.arch}`
539
+
540
+ const isDesktopPluginLibrary = (name: string): boolean => {
541
+ const n = name.toLowerCase()
542
+ return process.platform === "win32" ? n.endsWith(".dll") : n.endsWith(".so")
543
+ }
544
+
545
+ /** Native plugin libraries for THIS project on THIS machine (packages/desktop/plugin-sdk/
546
+ * lecodes_plugin.h — runtime-loaded .dll/.so, no host rebuild):
547
+ * - every library in the project's own `plugins/` dir — the zero-manifest dev loop (build a
548
+ * DLL, drop it there, `lecodes desktop run`);
549
+ * - the `desktop.library[target]` of every app.json plugin whose manifest declares one
550
+ * (mobile-only plugins are skipped, not errors).
551
+ * `dirs` is what a run forwards to the host as CREATOR_PLUGIN_DIRS (the host scans them);
552
+ * `files` is what `app build desktop` copies into `<build>/plugins/`. `strict` turns a broken
553
+ * declaration into an error (builds); runs warn and go on without that plugin. */
554
+ export const desktopPluginLibraries = (root: string, opts: { strict?: boolean } = {}): { dirs: string[], files: string[] } => {
555
+ const dirs: string[] = []
556
+ const files: string[] = []
557
+ const local = join(root, "plugins")
558
+ if (existsSync(local) && statSync(local).isDirectory()) {
559
+ dirs.push(local)
560
+ for (const f of readdirSync(local)) if (isDesktopPluginLibrary(f)) files.push(join(local, f))
561
+ }
562
+ const config = readJson<AppConfig>(join(root, "app.json"))
563
+ const specs = Object.entries(config?.plugins ?? {})
564
+ if (specs.length === 0) return { dirs, files }
565
+ const lock = readJson<{ plugins?: Record<string, LockEntry> }>(join(root, "plugins.lock.json"))?.plugins ?? {}
566
+ const target = desktopPluginTarget()
567
+ for (const [id, spec] of specs) {
568
+ let plugin: ResolvedPlugin | null
569
+ try {
570
+ plugin = resolvePlugin(id, spec, lock[id], "desktop", { optional: true })
571
+ } catch (e) {
572
+ if (opts.strict) throw e
573
+ warn(`Plugin "${id}": ${(e as Error).message} — skipped for this desktop run`)
574
+ continue
575
+ }
576
+ const rel = plugin?.manifest.desktop?.library?.[target]
577
+ if (!plugin || !rel) continue // mobile-only, or no build for this OS/arch
578
+ const abs = resolve(plugin.dir, rel)
579
+ if (!existsSync(abs)) {
580
+ const msg = `Plugin "${id}": desktop library ${abs} is missing (manifest desktop.library["${target}"])`
581
+ if (opts.strict) throw new CliError(msg)
582
+ warn(`${msg} — skipped for this desktop run`)
583
+ continue
584
+ }
585
+ files.push(abs)
586
+ const dir = dirname(abs)
587
+ if (!dirs.includes(dir)) dirs.push(dir)
588
+ }
589
+ return { dirs, files }
590
+ }
@@ -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,5 +1,6 @@
1
- import { CliError, type Args } from "../util"
1
+ import { type Args } from "../util"
2
2
  import { resolveCmgenExe } from "../cmgenTool"
3
+ import { loadPeer } from "../peerInstall"
3
4
 
4
5
  /*
5
6
  * `lecodes assets <convert|probe|doctor|sky> …` — the LeCodes asset pipeline (packages/lecodes-assets):
@@ -9,12 +10,7 @@ import { resolveCmgenExe } from "../cmgenTool"
9
10
  * forwarded untouched (`assets` gets `_raw` from main). See docs/animation-plan.md §2.9.
10
11
  */
11
12
  export const assets = async (args: Args & { _raw?: string[] }) => {
12
- let mod: typeof import("lecodes-assets/cli")
13
- try {
14
- mod = await import("lecodes-assets/cli")
15
- } catch {
16
- throw new CliError("The asset pipeline isn't installed. Run: npm install -g lecodes-assets")
17
- }
13
+ const mod = await loadPeer<typeof import("lecodes-assets/cli")>("lecodes-assets", "cli", { for: "lecodes assets" })
18
14
  const raw = args._raw ?? args._
19
15
  // `sky` shells out to filament's cmgen, which the asset package deliberately never downloads
20
16
  // itself (it stays network-free and usable standalone). The CLI owns release artifacts, so it
@@ -5,6 +5,7 @@ import { compileDesignScreen } from "../compile/designCompile"
5
5
  import { openBrowser } from "../browserAuth"
6
6
  import { CLAUDE_MD, DESIGN_MACROS_DTS, HOME_SCREEN_TS, META_JSON, META_JSON_DESKTOP, README_HINT, SPEC_MD, TABS_TS, TOKENS_TS } from "./designTemplates"
7
7
  import { CliError, c, flagBool, flagStr, log, logErr, note, warnErr, type Args } from "../util"
8
+ import { loadPeer } from "../peerInstall"
8
9
  import { loadConfig, normalizeApiUrl, requireApiUrl, requireToken } from "../config"
9
10
  import {
10
11
  createDesignComment, deleteDesignComment, deleteDesignCommentMessage, disableDesignShare,
@@ -87,12 +88,7 @@ const loadDesignServer = async (
87
88
  if (!existsSync(ctx.designDir)) {
88
89
  throw new CliError(`No ${ctx.dirName}/ folder here. Run "lecodes design init" to scaffold it.`)
89
90
  }
90
- let mod: typeof import("lecodes-design/server")
91
- try {
92
- mod = await import("lecodes-design/server")
93
- } catch {
94
- throw new CliError("The design canvas isn't installed. Run: npm install -g lecodes-design")
95
- }
91
+ const mod = await loadPeer<typeof import("lecodes-design/server")>("lecodes-design", "server", { for: "lecodes design" })
96
92
  const missing = needs.filter((name) => typeof mod[name] !== "function")
97
93
  if (missing.length) {
98
94
  throw new CliError(
@@ -236,23 +232,14 @@ const snapshot = async (ctx: DesignContext, args: Args) => {
236
232
  const allStates = flagBool(args, "all-states")
237
233
  if (stateFlag !== undefined && allStates) throw new CliError("Use either --state <name> or --all-states, not both.")
238
234
 
239
- let renderer: typeof import("lecodes-renderer/headless")
240
- try {
241
- renderer = await import("lecodes-renderer/headless")
242
- } catch {
243
- throw new CliError("The renderer isn't installed. Run: npm install -g lecodes-renderer")
244
- }
235
+ const renderer = await loadPeer<typeof import("lecodes-renderer/headless")>("lecodes-renderer", "headless", { for: "lecodes design render" })
245
236
 
246
237
  // Enumerating / validating states parses the screen source — that discovery lives in the design
247
238
  // package. The canonical-only path (no flag) needs no discovery, so base snapshot stays lean.
248
239
  let discover: ((source: string) => { states: string[], canonical: string }) | null = null
249
240
  if (stateFlag !== undefined || allStates) {
250
- try {
251
- const ds = await import("lecodes-design/server")
252
- discover = (src) => { const d = ds.discoverScreenStates(src); return { states: d.states, canonical: d.canonical } }
253
- } catch {
254
- throw new CliError("Rendering specific states needs the design canvas. Run: npm install -g lecodes-design")
255
- }
241
+ const ds = await loadPeer<typeof import("lecodes-design/server")>("lecodes-design", "server", { for: "lecodes design render --state" })
242
+ discover = (src) => { const d = ds.discoverScreenStates(src); return { states: d.states, canonical: d.canonical } }
256
243
  }
257
244
 
258
245
  // A stuck screen (runaway loop) shouldn't hang the CLI forever.
@@ -330,8 +317,8 @@ const serve = async (ctx: DesignContext, args: Args) => {
330
317
 
331
318
  // The MCP render_screen tool renders headless through lecodes-renderer, injected here so the
332
319
  // design server keeps no dependency on it. Absent renderer → the tool returns an install hint.
333
- let renderer: typeof import("lecodes-renderer/headless") | null = null
334
- try { renderer = await import("lecodes-renderer/headless") } catch { renderer = null }
320
+ // Opportunistic: never triggers an install from inside the MCP server (the tool reports the hint).
321
+ const renderer = await loadPeer<typeof import("lecodes-renderer/headless")>("lecodes-renderer", "headless", { for: "lecodes design (render_screen)", install: false })
335
322
  const renderScreen = renderer
336
323
  ? async (id: string, format: "json" | "png" | "both", state?: string) => {
337
324
  const r = renderer!
@@ -740,12 +727,8 @@ export const flushDesignComments = async (args: Args): Promise<CommentFlush | nu
740
727
  const platform = optionalPlatform(ctx)
741
728
  if (!platform) return null
742
729
 
743
- let designServer: typeof import("lecodes-design/server")
744
- try {
745
- designServer = await import("lecodes-design/server")
746
- } catch {
747
- return null
748
- }
730
+ const designServer = await loadPeer<typeof import("lecodes-design/server")>("lecodes-design", "server", { for: "lecodes design", install: false })
731
+ if (!designServer) return null
749
732
  // `settleQueued` must take `delivered` (4 params): settling a half-failed push with the older
750
733
  // signature would drop the writes that DIDN'T go through instead of retrying them.
751
734
  if (typeof designServer.queuedThreads !== "function") return null
@@ -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,15 +58,20 @@ 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,
66
71
  minHeight: win?.minHeight,
67
72
  resizable: win?.resizable,
68
73
  title: flagStr(args, "title") ?? win?.title ?? appCfg?.name,
74
+ storageScope: appCfg?.name,
69
75
  // --fullscreen forces it on; --windowed forces it off (over app.json); else the config decides.
70
76
  fullscreen: flagBool(args, "fullscreen") ? true : flagBool(args, "windowed") ? false : win?.fullscreen,
71
77
  scale: resolveScale(flagStr(args, "scale"), win?.scale),
@@ -73,6 +79,8 @@ const run = async (args: Args) => {
73
79
  // from app.json only. Both are desktop-level (the machine/app), not window-level.
74
80
  renderScale: resolveRenderScale(flagStr(args, "render-scale"), appCfg?.desktop?.renderScale),
75
81
  maxRenderSize: appCfg?.desktop?.maxRenderSize,
82
+ mouseEmulateTouch: appCfg?.desktop?.mouseEmulateTouch,
83
+ virtualKeyboard: appCfg?.desktop?.virtualKeyboard,
76
84
  // --fps: the host's own corner counter. A flag and not app.json on purpose — it is something you
77
85
  // switch on while looking at a frame, not a property of the app.
78
86
  fps: flagBool(args, "fps"),
@@ -5,7 +5,10 @@ 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"
11
+ import { androidCrashLines, launchOnAndroid, type AndroidDevSession } from "../dev/androidDev"
9
12
  import { readAppConfigAt } from "./appShared"
10
13
  import { resolveScale } from "./desktop"
11
14
  import { CliError, c, flagBool, flagStr, log, note, toKey, warnErr, type Args } from "../util"
@@ -20,6 +23,11 @@ import { CliError, c, flagBool, flagStr, log, note, toKey, warnErr, type Args }
20
23
  * backend. Custom shaders compile locally too (matc, downloaded on first use — matcTool.ts);
21
24
  * only when matc can't be fetched do they fall back to server-compiled artifacts (push once).
22
25
  *
26
+ * `--web` opens the same project in the browser: the dev server also serves the Web Lite runner
27
+ * (viewer-lite's dist-embed, vendored as runtime/web) under /web/ — CSS-flexbox UI + OGL 3D + the
28
+ * web twins of the camera/map plugins, no wasm. Same bundle and hot reload; browser console
29
+ * output streams back here like a device's. The URL is printed whenever the runner is available.
30
+ *
23
31
  * Works inside a cloned project or standalone in any folder with an entry .ts file.
24
32
  * The desktop host speaks websocket since 1.0.0 (host_ws); `?t=poll` remains the fallback
25
33
  * for older desktop builds (or any host without a websocket bridge).
@@ -116,9 +124,14 @@ export const dev = async (args: Args) => {
116
124
  const origin = `http://${host}:${port}`
117
125
  const token = loadDevToken(root)
118
126
 
127
+ const webDir = resolveWebRunnerDir()
128
+ if (flagBool(args, "web") && !webDir) {
129
+ 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).")
130
+ }
119
131
  const server = await startDevServer({
120
132
  root, manifest, name, origin, port, token,
121
133
  entryOverride: flagStr(args, "entry"),
134
+ webDir,
122
135
  log: note,
123
136
  }).catch((e: unknown) => {
124
137
  const msg = e instanceof Error ? e.message : String(e)
@@ -148,6 +161,7 @@ export const dev = async (args: Args) => {
148
161
  }
149
162
 
150
163
  const debug = process.env.LECODES_DEV_DEBUG === "1"
164
+ const underBun = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined"
151
165
  let timer: NodeJS.Timeout | null = null
152
166
  const onChange = (file: string | null) => {
153
167
  if (debug) note(`[watch] event: ${file ?? "(null)"} → ${shouldRecompile(file) ? "recompile" : "skip"}`)
@@ -180,6 +194,10 @@ export const dev = async (args: Args) => {
180
194
  log("")
181
195
  if (!flagBool(args, "no-qr")) await printQr(server.url)
182
196
  log(`Scan the QR with the LeCodes app, or open: ${c.bold(server.url)}`)
197
+ // The browser runner: localhost is a secure context (camera works there), the LAN address is
198
+ // what a phone's browser can reach.
199
+ const webLocal = server.webUrl ? server.webUrl.replace(`http://${host}:`, "http://localhost:") : null
200
+ if (server.webUrl && webLocal) log(`In the browser (Web Lite): ${c.bold(webLocal)} (LAN: ${server.webUrl})`)
183
201
  note(`Older desktop builds (no websocket bridge): ${server.url}?t=poll`)
184
202
  if (alternatives.length > 0) {
185
203
  note(`Device can't reach it? This machine also has ${alternatives.map((a) => `${a.address} (${a.iface})`).join(", ")} — retry with --host <ip>.`)
@@ -188,6 +206,52 @@ export const dev = async (args: Args) => {
188
206
  log("")
189
207
  log(`Watching ${root} — save a file to hot-reload. Ctrl+C to stop.`)
190
208
 
209
+ // --web: open the browser runner (the server keeps serving it either way).
210
+ if (flagBool(args, "web") && webLocal) {
211
+ note("Opening the browser…")
212
+ openBrowser(webLocal)
213
+ }
214
+
215
+ // --android: open the dev URL in the LeCodes app on the phone (adb; see dev/androidDev.ts). With
216
+ // --time <s> the run is bounded — screenshot to --png, force-stop, exit — which is the shape a
217
+ // scripted check wants; without it the app runs until Ctrl+C, and Ctrl+C force-stops it (a
218
+ // device run has no frame cap, and a 3D scene left rendering heats a charging phone).
219
+ if (flagBool(args, "android")) {
220
+ const seconds = Number(flagStr(args, "time")) || 0
221
+ const png = flagStr(args, "png")
222
+ // Under Bun the websocket channel never delivers (see the warning above): the phone would sit on
223
+ // "Connected — loading the project…" forever. The path-form poll url works on both runtimes.
224
+ const deviceUrl = `http://127.0.0.1:${port}${server.url.slice(origin.length)}`.replace(/\/dev\.js$/, underBun ? "/dev-poll.js" : "/dev.js")
225
+ let session: AndroidDevSession
226
+ try {
227
+ session = launchOnAndroid({ port, url: deviceUrl, serial: flagStr(args, "serial"), apk: flagStr(args, "apk"), packageName: flagStr(args, "package") })
228
+ } catch (e) {
229
+ server.close(); watcher?.close()
230
+ throw e
231
+ }
232
+ note(`Android: ${session.serial} → ${deviceUrl} (adb reverse; device logs stream here as [device:log])`)
233
+ const finish = (code: number) => {
234
+ session.stop()
235
+ server.close(); watcher?.close()
236
+ process.exit(code)
237
+ }
238
+ process.on("SIGINT", () => { note("Stopping the app on the phone…"); finish(0) })
239
+ if (seconds > 0) {
240
+ setTimeout(() => {
241
+ const crashes = androidCrashLines(session)
242
+ if (png) {
243
+ if (session.screenshot(png)) note(`Wrote ${png} (device screenshot after ${seconds} s)`)
244
+ else warnErr(`Screenshot failed (adb screencap).`)
245
+ }
246
+ if (crashes.length > 0) {
247
+ warnErr(`The app crashed on the phone:\n${crashes.join("\n")}`)
248
+ finish(1)
249
+ }
250
+ finish(0)
251
+ }, seconds * 1000)
252
+ }
253
+ }
254
+
191
255
  // --desktop: open the dev URL in the native host too (same server, same hot reload — the host
192
256
  // speaks the websocket channel). Window config comes from app.json's desktop block. The window
193
257
  // closing does NOT stop the server: phones stay connected, and re-running with --desktop is the