lecodes-cli 1.0.0 → 2.0.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 (104) hide show
  1. package/dist/index.js +6211 -3649
  2. package/package.json +12 -8
  3. package/runtime/materials/lightmap.mat +115 -139
  4. package/runtime/materials/particles-quad.mat +6 -2
  5. package/runtime/materials/terrain-lightmap.mat +123 -0
  6. package/runtime/materials/terrain.mat +36 -55
  7. package/runtime/sdk-types.json +1 -1
  8. package/runtime/web/VERSION.json +5 -0
  9. package/runtime/web/assets/camera-5XOM8eHl.js +1 -0
  10. package/runtime/web/assets/creator-2d-D9WKYM6h.js +1 -0
  11. package/runtime/web/assets/creator-2d-ukvEmNEp.wasm +0 -0
  12. package/runtime/web/assets/creator-full-BBXzQlhq.js +1 -0
  13. package/runtime/web/assets/creator-full-D2dSDdDj.wasm +0 -0
  14. package/runtime/web/assets/creator-full.js-BYswGsqR.symbols +8124 -0
  15. package/runtime/web/assets/creator-ui-DIc92ion.wasm +0 -0
  16. package/runtime/web/assets/creator-ui-_1Z_AX2M.js +1 -0
  17. package/runtime/web/assets/geolocation-BjTKN9_P.js +1 -0
  18. package/runtime/web/assets/inspect-C7x78PNs.js +2 -0
  19. package/runtime/web/assets/map-CyBxOSV0.js +1 -0
  20. package/runtime/web/assets/map-XzoOFt0E.js +2 -0
  21. package/runtime/web/assets/mapImpl-bxvQ2h1Z.js +823 -0
  22. package/runtime/web/assets/maplibre-gl-worker-B7LXXUkR.js +8 -0
  23. package/runtime/web/assets/materials-uri4FvPE.bin +0 -0
  24. package/runtime/web/assets/materials-world-CQWt8TAm.bin +0 -0
  25. package/runtime/web/assets/qr-scanner-CK_cYoJR.js +1 -0
  26. package/runtime/web/assets/uberarchive-DLH-kBhM.bin +0 -0
  27. package/runtime/web/index.html +36 -0
  28. package/runtime/web/player.js +21 -0
  29. package/runtime/web-page/index.html +41 -0
  30. package/runtime/web-page/page.css +1 -0
  31. package/runtime/web-page/page.js +31 -0
  32. package/src/cli/help.ts +25 -0
  33. package/src/cli/index.ts +7 -7
  34. package/src/commands/app/android.ts +3 -1
  35. package/src/commands/app/desktop.ts +1 -1
  36. package/src/commands/app/desktopMac.ts +10 -9
  37. package/src/commands/app/icon.ts +9 -6
  38. package/src/commands/app/index.ts +3 -1
  39. package/src/commands/app/shared.ts +13 -5
  40. package/src/commands/app/templates/android.ts +10 -11
  41. package/src/commands/app/templates/ios.ts +1 -1
  42. package/src/commands/assets.ts +1 -1
  43. package/src/commands/compile.ts +15 -2
  44. package/src/commands/design/index.ts +4 -4
  45. package/src/commands/design/snapshot.ts +3 -3
  46. package/src/commands/desktop.ts +5 -1
  47. package/src/commands/dev.ts +82 -29
  48. package/src/commands/index.ts +64 -60
  49. package/src/commands/init/index.ts +11 -15
  50. package/src/commands/init/templates.ts +64 -21
  51. package/src/commands/lightmap.ts +459 -200
  52. package/src/commands/navmesh.ts +2 -2
  53. package/src/commands/plugin.ts +46 -0
  54. package/src/commands/render.ts +22 -16
  55. package/src/commands/scene.ts +6 -1
  56. package/src/commands/serve.ts +58 -0
  57. package/src/commands/shaders/new.ts +1 -1
  58. package/src/commands/test.ts +402 -398
  59. package/src/commands/update/index.ts +3 -3
  60. package/src/compile/designCompile.ts +1 -1
  61. package/src/compile/headlessBundle.ts +133 -133
  62. package/src/compile/nativeStack.ts +1 -1
  63. package/src/compile/projectCompile.ts +10 -2
  64. package/src/compile/sceneCompile.ts +8 -22
  65. package/src/declarations/lecodes-3d-editor.d.ts +4 -0
  66. package/src/declarations/lecodes-assets.d.ts +1 -1
  67. package/src/declarations/{lecodes-renderer.d.ts → lecodes-headless.d.ts} +3 -3
  68. package/src/dev/clientTemplates.ts +18 -14
  69. package/src/dev/devServer.ts +75 -38
  70. package/src/dev/iosDev.ts +180 -0
  71. package/src/dev/webRunner.ts +82 -14
  72. package/src/hosts/cmgenTool.ts +1 -1
  73. package/src/hosts/desktopRenderer.ts +98 -27
  74. package/src/hosts/desktopScript.ts +150 -35
  75. package/src/hosts/distRoot.ts +1 -1
  76. package/src/hosts/matcTool.ts +1 -1
  77. package/src/hosts/peerInstall.ts +5 -5
  78. package/src/hosts/peers.ts +2 -2
  79. package/src/index.ts +4 -1
  80. package/src/plugin/contract.ts +438 -0
  81. package/src/plugin/emitKotlin.ts +357 -0
  82. package/src/plugin/emitSdk.ts +259 -0
  83. package/src/plugin/emitSwift.ts +336 -0
  84. package/src/plugin/emitWeb.ts +383 -0
  85. package/src/plugin/generate.ts +157 -0
  86. package/src/plugin/lines.ts +28 -0
  87. package/src/plugin/lower.ts +231 -0
  88. package/src/plugin/manifest.ts +192 -0
  89. package/src/project/ignore.ts +164 -163
  90. package/runtime/materials/lightmap-baked-lite.mat +0 -180
  91. package/runtime/materials/lightmap-baked.mat +0 -181
  92. package/runtime/scene-harness.json +0 -1
  93. package/runtime/web/assets/__vite-browser-external-BIHI7g3E.js +0 -1
  94. package/runtime/web/assets/basis-C64VHDVD.js +0 -1
  95. package/runtime/web/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
  96. package/runtime/web/assets/createViewerLite-DHqTFhWB.js +0 -866
  97. package/runtime/web/assets/draco-BiISTFcR.js +0 -118
  98. package/runtime/web/assets/draco_decoder-DsQ12WqX.wasm +0 -0
  99. package/runtime/web/assets/index-Q06c6oHx.js +0 -2
  100. package/runtime/web/assets/mapViewImpl-B2JcES8l.js +0 -810
  101. package/runtime/web/assets/maplibre-gl-worker-CJfwIrte.js +0 -8
  102. package/runtime/web/assets/worker-Caf-yYEI.js +0 -2
  103. package/runtime/web/embed.html +0 -32
  104. package/runtime/web/embed.js +0 -171
@@ -53,14 +53,14 @@ const cleanBundleName = (name: string) => name.replace(/[/:]/g, "").trim() || "A
53
53
 
54
54
  /* ------------------------------------- host bundle ------------------------------------- */
55
55
 
56
- /** The .app that owns a host executable (…/X.app/Contents/MacOS/lecodes-app → …/X.app). */
56
+ /** The .app that owns a host executable (…/X.app/Contents/MacOS/lecodes-desktop → …/X.app). */
57
57
  export const macHostAppOf = (exe: string): string => {
58
58
  const app = resolve(exe, "..", "..", "..")
59
59
  if (!app.endsWith(".app") || !existsSync(join(app, "Contents", "Info.plist"))) {
60
60
  throw new CliError(
61
61
  `The macOS host must be an .app bundle (got ${exe}). Install LeCodes Desktop.app, or point ` +
62
- `LECODES_DESKTOP_EXE at <Build>.app/Contents/MacOS/lecodes-app — a bare SwiftPM binary has no ` +
63
- `resource bundles and can't be re-signed into an app.`,
62
+ `LECODES_DESKTOP_EXE at <Build>.app/Contents/MacOS/lecodes-desktop — a bare build-tree binary has no ` +
63
+ `Resources/runtime and can't be re-signed into an app (hosts/desktop/package-macos.sh makes the bundle).`,
64
64
  )
65
65
  }
66
66
  return app
@@ -120,7 +120,7 @@ export const assertMacHostCanBundle = (exe: string) => {
120
120
  `This LeCodes Desktop host (${macHostAppOf(exe)}) predates project bundles — an app built from it would ` +
121
121
  "open the host's home screen instead of the project.\n" +
122
122
  " Run `lecodes desktop update` (needs a published macOS host with project-bundle support), or point " +
123
- "LECODES_DESKTOP_EXE at a current build (…/LeCodes Desktop.app/Contents/MacOS/lecodes-app).",
123
+ "LECODES_DESKTOP_EXE at a current build (…/LeCodes Desktop.app/Contents/MacOS/lecodes-desktop).",
124
124
  )
125
125
  }
126
126
 
@@ -318,16 +318,17 @@ const writeIcns = async (root: string, config: AppConfig, dest: string, tmp: str
318
318
 
319
319
  /* ---------------------------------------- probe ---------------------------------------- */
320
320
 
321
- /** `--probe`: launch the built app for a few seconds and grab the presented 3D frame through the
322
- * host's LECODES_SNAPSHOT hook. The window shows briefly — the macOS host has no hidden mode. */
321
+ /** `--probe`: launch the built app for a few seconds and grab its last frame through the host's
322
+ * headless render contract (CREATOR_MAX_FRAMES + CREATOR_DUMP_PNG, the same as render --desktop).
323
+ * The window shows: the probe proves the SHIPPED bundle launches from cwd "/", not the hidden path. */
323
324
  const probeMacApp = async (app: string, out: string, after: number) => {
324
325
  const shot = join(out, `${basename(app, ".app")}-probe.png`)
325
326
  rmSync(shot, { force: true })
326
- note(`Probe: launching the built app for ${after + 2}s (the window will show)…`)
327
+ note(`Probe: launching the built app for ~${after + 2}s (the window will show)…`)
327
328
  await new Promise<void>((done, fail) => {
328
- const child = spawn(join(app, "Contents", "MacOS", "lecodes-app"), [], {
329
+ const child = spawn(join(app, "Contents", "MacOS", "lecodes-desktop"), [], {
329
330
  cwd: "/", // what Finder gives a double-clicked app — proves nothing leans on the cwd
330
- env: { ...process.env, LECODES_SNAPSHOT: shot, LECODES_SNAPSHOT_AFTER: String(after), LECODES_EXIT_AFTER: String(after + 2) },
331
+ env: { ...process.env, CREATOR_DUMP_PNG: shot, CREATOR_MAX_FRAMES: String((after + 2) * 60) },
331
332
  stdio: ["ignore", "pipe", "pipe"],
332
333
  })
333
334
  const tail: string[] = []
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
2
2
  import { dirname, join, resolve } from "node:path"
3
3
  import type { JimpInstance } from "jimp"
4
+ import { Color } from "lecodes-sdk/color"
4
5
  import { CliError, c, log, warn } from "../../cli"
5
6
  import type { AppConfig } from "./shared"
6
7
 
@@ -36,12 +37,14 @@ export const loadIconMaster = async (root: string, config: AppConfig): Promise<I
36
37
  const path = resolve(root, rel)
37
38
  if (!existsSync(path)) throw new CliError(`app.json "icon": ${rel} not found`)
38
39
 
39
- const raw = (config.iconBackground ?? "#FFFFFF").trim()
40
- const expanded = /^#[0-9a-fA-F]{3}$/.test(raw) ? "#" + [...raw.slice(1)].map(ch => ch + ch).join("") : raw
41
- const m = /^#([0-9a-fA-F]{6})$/.exec(expanded)
42
- if (!m) throw new CliError(`app.json "iconBackground" must be #RGB or #RRGGBB (got "${config.iconBackground}")`)
43
- const backgroundHex = "#" + m[1].toUpperCase()
44
- const background = (parseInt(m[1], 16) * 0x100 + 0xff) >>> 0
40
+ // Any CSS color, opaque (an icon backdrop has no alpha): the SDK's one parser.
41
+ const rgba = Color.tryParse(config.iconBackground ?? "#FFFFFF")
42
+ if (!rgba || (Color.toPackedRgba(rgba) & 0xff) !== 0xff) {
43
+ throw new CliError(`app.json "iconBackground" must be an opaque CSS color — #RRGGBB, a name, rgb() / hsl() (got ${JSON.stringify(config.iconBackground)})`)
44
+ }
45
+ const rgb = Color.toPackedRgb(rgba)
46
+ const backgroundHex = "#" + rgb.toString(16).padStart(6, "0").toUpperCase()
47
+ const background = (rgb * 0x100 + 0xff) >>> 0
45
48
 
46
49
  const { Jimp } = await import("jimp")
47
50
  let image: JimpInstance
@@ -379,7 +379,9 @@ const syncIos = async (root: string, config: AppConfig): Promise<SyncArtifacts>
379
379
  // host's setFetchLocal reads Resources/ by basename, falling back to the SDK resources
380
380
  // bundle for the sdkProvided names.
381
381
  const preload = /^\/\/ preload: (.*)$/m.exec(bundleJs)?.[1].split(";").map((s) => s.trim()).filter(Boolean) ?? []
382
- const IMPLICIT_PRELOADS = new Set(["uberarchive", "capture"]) // engine-synthesized, not project files
382
+ // engine-synthesized, not project files: the ubershader archive, the capture flag and the
383
+ // engine's own materials (`material:<name>` — they ride the SDK)
384
+ const IMPLICIT_PRELOADS = { has: (name: string) => name === "uberarchive" || name === "capture" || name.startsWith("material:") }
383
385
  const byName = new Map<string, BundleResource[]>()
384
386
  for (const r of resources) {
385
387
  if (r.remote || !r.absPath) continue
@@ -62,7 +62,7 @@ export interface AppConfig {
62
62
  signing?: { storeFile?: string, keyAlias?: string },
63
63
  minify?: boolean,
64
64
  }
65
- /** Desktop host (packages/desktop). `window` sizes are LOGICAL units — the host multiplies by
65
+ /** Desktop host (hosts/desktop). `window` sizes are LOGICAL units — the host multiplies by
66
66
  * the display scale. `entry` picks a per-target entry file (default: the detected project
67
67
  * entry) — see docs/desktop-target-plan.md. */
68
68
  desktop?: {
@@ -80,6 +80,14 @@ export interface AppConfig {
80
80
  /** 3D buffer cap in px, e.g. [1920, 1080]: on a bigger window/monitor the 3D renders at most
81
81
  * this size (aspect kept) — the per-machine answer to "fullscreen on a 4K display is slow". */
82
82
  maxRenderSize?: [number, number],
83
+ /** Frame-rate CAP for the host's render loop: `"display"` (default — follow the screen's refresh
84
+ * rate, 120 on a ProMotion Mac) or a number, meaning "at most this many fps" (the screen's
85
+ * maximum still wins when it is lower — vsync is the real ceiling, so this can never be a
86
+ * target above it). For an app that wants to spend less: a UI-heavy app on a laptop, a game
87
+ * whose feel is tuned for 60. `desktop run` forwards it as CREATOR_FRAME_RATE (a set env wins);
88
+ * a built .app's host reads it from the staged app.json. Honoured by the macOS host
89
+ * (2026-09-09); the Windows/Linux host is vsync-locked and ignores it for now. */
90
+ frameRate?: number | "display",
83
91
  /** The mouse button acts as a finger: a left click-drag scrolls + flings like touch (the wheel
84
92
  * always scrolls, clicks are unaffected; a button inside a scrollable then gets the touch-style
85
93
  * press feedback delay). For testing the touch experience without a touchscreen, or an app
@@ -97,7 +105,7 @@ export interface AppConfig {
97
105
  }
98
106
 
99
107
  /** One style slot of the desktop on-screen keyboard — the SDK's style words, the subset the drawn
100
- * keyboard reads. Unset fields keep the theme preset. */
108
+ * keyboard reads (colors: any CSS color string). Unset fields keep the theme preset. */
101
109
  export interface DesktopKeyboardSlot {
102
110
  backgroundColor?: string
103
111
  color?: string
@@ -274,8 +282,8 @@ export interface PluginManifest {
274
282
  * Bare ids resolve through the CLI's known-version table; "id:version" pins explicitly. */
275
283
  gradlePlugins?: string[],
276
284
  } | null
277
- /** Desktop host (packages/desktop): a PREBUILT shared library per OS/arch behind the C ABI in
278
- * `packages/desktop/plugin-sdk/lecodes_plugin.h` — no host rebuild, no source vendoring.
285
+ /** Desktop host (hosts/desktop): a PREBUILT shared library per OS/arch behind the C ABI in
286
+ * `hosts/desktop/plugin-sdk/lecodes_plugin.h` — no host rebuild, no source vendoring.
279
287
  * `library` keys are `${process.platform}-${process.arch}` ("win32-x64", "linux-x64",
280
288
  * "linux-arm64"), values plugin-dir-relative paths. `lecodes desktop run` / `render --desktop`
281
289
  * forward the library's dir to the host (CREATOR_PLUGIN_DIRS); `app build desktop` stages the
@@ -533,7 +541,7 @@ const isDesktopPluginLibrary = (name: string): boolean => {
533
541
  return process.platform === "win32" ? n.endsWith(".dll") : n.endsWith(".so")
534
542
  }
535
543
 
536
- /** Native plugin libraries for THIS project on THIS machine (packages/desktop/plugin-sdk/
544
+ /** Native plugin libraries for THIS project on THIS machine (hosts/desktop/plugin-sdk/
537
545
  * lecodes_plugin.h — runtime-loaded .dll/.so, no host rebuild):
538
546
  * - every library in the project's own `plugins/` dir — the zero-manifest dev loop (build a
539
547
  * DLL, drop it there, `lecodes desktop run`);
@@ -144,20 +144,18 @@ dependencies {
144
144
 
145
145
  /** lecodes-runtime/src/main/kotlin/io/letary/lecodes/runtime/LeCodesRuntime.kt — the generated
146
146
  * registration + host-hook surface. Plugins are called by FQN (no import collisions); the
147
- * activity hooks are always present and inert without a consuming plugin (the Android analog
148
- * of iOS's buffered LeCodesAppHooks — here the only launch-time state, a notification tap's
149
- * intent extra, survives until the host forwards it, so no buffering is needed). */
147
+ * activity hooks forward to the SDK's LeCodesAppHooks, which NAMES NO PLUGIN — a plugin that
148
+ * consumes activity moments (push: a notification tap is an intent) attaches its handler there. */
150
149
  export const runtimeKotlinTemplate = (plugins: AndroidRuntimePlugin[], projectUuid?: string): string => {
151
150
  const identity = projectUuid ? ` engine.bootProjectUuid = "${projectUuid}"\n` : ""
152
151
  const calls = plugins.map(p => ` ${p.register}.register(engine, context)\n`).join("")
153
152
  const body = identity + calls
154
- const push = plugins.find(p => p.id === "push")
155
- const pushNs = push ? push.register.split(".").slice(0, -1).join(".") : null
156
153
  return `// Generated by \`lecodes app sync\` — regenerated on every sync, do not edit.
157
154
  package io.letary.lecodes.runtime
158
155
 
159
156
  import android.content.Context
160
157
  import android.content.Intent
158
+ import io.letary.lecodes.LeCodesAppHooks
161
159
  import io.letary.lecodes.LecodesEngine
162
160
 
163
161
  object LeCodesRuntime {
@@ -170,16 +168,17 @@ ${body === "" ? " // no plugins installed\n" : body} }
170
168
  /** Cold create only (savedInstanceState == null): forward the launch intent so a plugin
171
169
  * can park launch-time state (a push notification tap) before the engine boots. */
172
170
  fun onActivityLaunch(intent: Intent) {
173
- ${push ? ` ${pushNs}.PushPlugin.parkLaunchPayload(intent)\n` : " // no plugin consumes launch intents\n"} }
171
+ LeCodesAppHooks.launchIntent(intent)
172
+ }
174
173
 
175
174
  /** Warm intent (Activity.onNewIntent). @return true when a plugin consumed it — the
176
175
  * caller must then SKIP its generic deep-link handling. */
177
- fun onNewIntent(intent: Intent): Boolean {
178
- ${push ? ` return ${pushNs}.PushPlugin.handleTapIntent(intent)\n` : " return false\n"} }
176
+ fun onNewIntent(intent: Intent): Boolean = LeCodesAppHooks.newIntent(intent)
179
177
 
180
178
  /** Activity.onResume(true) / onPause(false). */
181
179
  fun setForeground(foreground: Boolean) {
182
- ${push ? ` ${pushNs}.PushPlugin.setForeground(foreground)\n` : " // no plugin tracks foreground state\n"} }
180
+ LeCodesAppHooks.foreground(foreground)
181
+ }
183
182
  }
184
183
  `
185
184
  }
@@ -321,8 +320,8 @@ R8 only touches the dex — the engine's native libraries and \`app.js\` are not
321
320
  ## Icons
322
321
 
323
322
  The easy path: set \`app.json\` \`"icon"\` to a square ≥1024px PNG/JPEG in the project (e.g.
324
- \`"icon": "icon.png"\`, optional \`"iconBackground": "#RRGGBB"\` — the adaptive-icon backdrop,
325
- default white). Every \`lecodes app sync\` then renders the full launcher set: legacy density
323
+ \`"icon": "icon.png"\`, optional \`"iconBackground": "#RRGGBB"\` (any opaque CSS color) — the adaptive-icon
324
+ backdrop, default white). Every \`lecodes app sync\` then renders the full launcher set: legacy density
326
325
  PNGs, the API 26+ adaptive icon (your image at 72dp on the 108dp canvas), and an Android 13
327
326
  themed-icon (monochrome) layer that reuses the foreground silhouette.
328
327
 
@@ -485,7 +485,7 @@ export const VIEW_CONTROLLER = `//
485
485
  // Generated by lecodes app init — yours to edit.
486
486
  //
487
487
  // Runs the bundled app.js (produced by \`lecodes app sync\`) offline: everything the
488
- // script needs is provided by the SDK core (_creatorUI / _creatorUtils / console)
488
+ // script needs is provided by the SDK core (_creatorTree / the host bridges / console)
489
489
  // plus the plugins registered below via LeCodesRuntime.
490
490
  //
491
491
 
@@ -3,7 +3,7 @@ import { resolveCmgenExe } from "../hosts/cmgenTool"
3
3
  import { loadPeer } from "../hosts/peerInstall"
4
4
 
5
5
  /*
6
- * `lecodes assets …` — the asset pipeline (packages/lecodes-assets): FBX → GLB, the GLB doctor,
6
+ * `lecodes assets …` — the asset pipeline (tools/assets): FBX → GLB, the GLB doctor,
7
7
  * retargeting, Unity packs, skies. The package is an optional peer with its OWN argv parser and
8
8
  * help, so the argv is forwarded untouched (`passthrough`) — `lecodes assets --help` is theirs.
9
9
  */
@@ -4,7 +4,7 @@ import { getProject } from "../platform/api"
4
4
  import { findProjectRoot, readManifest, type Manifest } from "../project/manifest"
5
5
  import { collectEntries } from "../compile/collect"
6
6
  import { collectLocalEntries } from "../compile/collectLocal"
7
- import { ambiguousEntryWarning, compileProject, type CompileEntry } from "../compile/projectCompile"
7
+ import { ambiguousEntryWarning, compileProject, localAssetNames, type CompileEntry } from "../compile/projectCompile"
8
8
  import { applyProjectFonts } from "../compile/fonts"
9
9
  import { applyLocalShaders, desktopShaderPlatform, type StagedShader } from "../compile/shaders"
10
10
  import type { DesktopRenderer } from "../hosts/desktopRenderer"
@@ -134,12 +134,14 @@ export const buildBundle = async (bundle: BundleOptions, opts: BuildBundleOption
134
134
  localAssets = !!shell || (!!opts.localAssets && bundle.publicUrl === undefined && !bundle.remoteAssets)
135
135
 
136
136
  note("Resolving project assets…")
137
+ const tResolve = performance.now()
137
138
  const project = await getProject(apiUrl, token, manifest.uuid)
138
139
  // Local-asset compiles use URL-less resources as-is instead of degrading, so skip that warning
139
140
  // there (an unpushed resources.remote match still warns below).
140
141
  ;({ entries, warnings } = collectEntries(root, manifest, project, { warnMissingUrl: !localAssets }))
141
142
  name = manifest.name
142
143
  publicUrl = bundle.publicUrl ?? apiUrl
144
+ note(`Resolved ${entries.length} entries in ${((performance.now() - tResolve) / 1000).toFixed(1)} s`)
143
145
 
144
146
  // Local-asset compile: everything resolves from the tree except the resources.remote matches,
145
147
  // which keep their server URL — and can't without one, so an unpushed match falls back to local.
@@ -162,7 +164,9 @@ export const buildBundle = async (bundle: BundleOptions, opts: BuildBundleOption
162
164
  if (opts.localAssets && bundle.remoteAssets) warnErr("This is a local project (no server) — assets always load from the working tree.")
163
165
  // localShaders: a local-asset compile compiles the .mat files itself below, so the
164
166
  // "push the project once" degradation notice would be wrong.
167
+ const tEntries = performance.now()
165
168
  ;({ entries, warnings } = collectLocalEntries(root, null, "", { localShaders: localAssets && (opts.localShaders ?? !shell) }))
169
+ note(`Collected ${entries.length} entries in ${((performance.now() - tEntries) / 1000).toFixed(1)} s`)
166
170
  name = basename(root)
167
171
  if (shell && shell.remotePatterns.length > 0) warnErr("resources.remote needs a server-backed project (a streamed asset needs a server URL) — bundling everything.")
168
172
  }
@@ -187,6 +191,7 @@ export const buildBundle = async (bundle: BundleOptions, opts: BuildBundleOption
187
191
  // vendors faces into assets/fonts/ so the bundle stays self-contained (fetchLocal / --public-url).
188
192
  // Shell compiles pass publicUrl "" here so the `// fonts:` header carries no face URLs — the
189
193
  // host must register faces from the bundle, never wait on the network before first paint.
194
+ const tPrep = performance.now()
190
195
  const fontsHeader = await applyProjectFonts(entries, (manifest && !localAssets)
191
196
  ? { mode: "cdn", root, publicUrl }
192
197
  : {
@@ -199,8 +204,10 @@ export const buildBundle = async (bundle: BundleOptions, opts: BuildBundleOption
199
204
  // project. The returned pack list becomes the `// icons:` attribution header.
200
205
  if (opts.extraEntries) entries.push(...opts.extraEntries)
201
206
  const iconsHeader = await applyAppAssetIcons(entries)
207
+ note(`Prepared shaders, fonts and icons in ${((performance.now() - tPrep) / 1000).toFixed(1)} s`)
202
208
 
203
209
  note("Compiling…")
210
+ const tCompile = performance.now()
204
211
  const entryOverride = opts.entryOverride ?? bundle.entry
205
212
  if (!entryOverride) {
206
213
  const ambiguous = ambiguousEntryWarning(entries)
@@ -212,14 +219,20 @@ export const buildBundle = async (bundle: BundleOptions, opts: BuildBundleOption
212
219
  localAssets, fontsHeader, iconsHeader,
213
220
  minify: bundle.minify,
214
221
  sourcemap: bundle.sourcemap,
222
+ onWarning: (w) => warnings.push(w),
215
223
  })
224
+ note(`Compiled in ${((performance.now() - tCompile) / 1000).toFixed(1)} s (${(js.length / 1024).toFixed(0)} KB)`)
216
225
 
217
226
  for (const w of warnings) warnErr(w)
218
227
 
219
228
  // Resource inventory for shell/render callers (includes faces the font pass vendored into entries).
229
+ // The NAME is what the bundle passes to fetchLocal, so it has to come from where the bundle got
230
+ // it: a basename two project files share is disambiguated there, and a caller that re-derived the
231
+ // basename here would stage both packs' SM_Sign_01.glb onto one file again.
232
+ const localNames = localAssetNames(entries)
220
233
  const resources: BundleResource[] = entries
221
234
  .filter((e) => e.type === "resource")
222
- .map((e) => ({ path: e.path.replace(/^\//, ""), name: e.path.split(/[\\/]/).pop() ?? e.path, absPath: e.absPath, remote: !!e.remote }))
235
+ .map((e) => ({ path: e.path.replace(/^\//, ""), name: localNames.get(e.path) ?? e.path.split(/[\\/]/).pop() ?? e.path, absPath: e.absPath, remote: !!e.remote }))
223
236
  // Compiled shader artifacts ship the same way (copied next to app.js, loaded by name), flagged
224
237
  // so the desktop packager keeps them — they have no asset() reference for its scan to match.
225
238
  for (const artifact of stagedShaders) resources.push({ path: artifact.name, name: artifact.name, absPath: artifact.absPath, remote: false, shader: true })
@@ -20,7 +20,7 @@ export { flushDesignComments, type CommentFlush } from "./comments"
20
20
  *
21
21
  * design [serve] start the dev server + browser canvas (needs `lecodes-design`)
22
22
  * design init scaffold the design folder (screens/, shared/, meta.json, spec.md, CLAUDE.md)
23
- * design snapshot render screens headless to JSON/PNG (snapshot.ts, needs `lecodes-renderer`)
23
+ * design snapshot render screens headless to JSON/PNG (snapshot.ts, needs `lecodes-headless`)
24
24
  * design arrange auto-layout the flow graph (--new: only place unplaced screens)
25
25
  * design check static coherence: spec.md links, flow warnings, untagged screens
26
26
  * design share toggle the public share link (design must be pushed first)
@@ -51,10 +51,10 @@ const serve = async (flags: FlagValues<typeof serveFlags>): Promise<void> => {
51
51
  const ctx = getContext(flags.dir)
52
52
  const designServer = await loadDesignServer(ctx)
53
53
 
54
- // The MCP render_screen tool renders headless through lecodes-renderer, injected here so the
54
+ // The MCP render_screen tool renders headless through lecodes-headless, injected here so the
55
55
  // design server keeps no dependency on it. Absent renderer → the tool returns an install hint.
56
56
  // Opportunistic: never triggers an install from inside the MCP server (the tool reports the hint).
57
- const renderer = await loadPeer<typeof import("lecodes-renderer/headless")>("lecodes-renderer", "headless", { for: "lecodes design (render_screen)", install: false })
57
+ const renderer = await loadPeer<typeof import("lecodes-headless/headless")>("lecodes-headless", "headless", { for: "lecodes design (render_screen)", install: false })
58
58
  const renderScreen = renderer
59
59
  ? async (id: string, format: "json" | "png" | "both", state?: string) => {
60
60
  const js = await compileDesignScreen({
@@ -104,7 +104,7 @@ const serve = async (flags: FlagValues<typeof serveFlags>): Promise<void> => {
104
104
 
105
105
  log(`${c.bold("LeCodes Design")} — ${ctx.name}`)
106
106
  log(`Canvas: ${c.bold(server.url)}`)
107
- log(`MCP: ${c.bold(server.url + "/mcp")}${renderer ? "" : c.dim(" (render_screen off — npm install -g lecodes-renderer)")}`)
107
+ log(`MCP: ${c.bold(server.url + "/mcp")}${renderer ? "" : c.dim(" (render_screen off — npm install -g lecodes-headless)")}`)
108
108
  log(`Watching: ${ctx.dirName}/ (screens hot-reload on save; Ctrl+C to stop)`)
109
109
  if (sync) log(`Comments: ${c.dim("in sync with the platform — replies and resolves go back automatically")}`)
110
110
  if (!flags["no-open"]) openBrowser(server.url)
@@ -7,7 +7,7 @@ import { readMetaFrames } from "../../project/designMeta"
7
7
  import { dirFlag, getContext, type DesignServer } from "./context"
8
8
 
9
9
  /*
10
- * `lecodes design snapshot [screen…]` — render design screens headless through lecodes-renderer,
10
+ * `lecodes design snapshot [screen…]` — render design screens headless through lecodes-headless,
11
11
  * the way the board's MCP render_screen tool does, for AI feedback without a browser. One requested
12
12
  * screen prints its JSON to stdout; anything else lands as files in <dir>/.snapshots/ (or --out-dir).
13
13
  * States: `--state <s>` renders one named state of each screen, `--all-states` every discovered
@@ -26,7 +26,7 @@ const listScreens = (designDir: string): string[] => {
26
26
 
27
27
  export default defineCommand({
28
28
  name: "snapshot",
29
- summary: "Render design screens headless to JSON / PNG (needs lecodes-renderer)",
29
+ summary: "Render design screens headless to JSON / PNG (needs lecodes-headless)",
30
30
  usage: "[screen…]",
31
31
  description: "One screen → its JSON on stdout; otherwise one file per screen in <dir>/.snapshots/ (<id>.json, <id>@<state>.json, PNGs with --png). --state and --all-states need lecodes-design to discover a screen's states from its source. A stuck screen (runaway loop) is killed after 120 s.",
32
32
  flags: {
@@ -56,7 +56,7 @@ export default defineCommand({
56
56
  const allStates = flags["all-states"]
57
57
  if (stateFlag !== undefined && allStates) throw new CliError("Use either --state <name> or --all-states, not both.")
58
58
 
59
- const renderer = await loadPeer<typeof import("lecodes-renderer/headless")>("lecodes-renderer", "headless", { for: "lecodes design render" })
59
+ const renderer = await loadPeer<typeof import("lecodes-headless/headless")>("lecodes-headless", "headless", { for: "lecodes design render" })
60
60
 
61
61
  // Enumerating / validating states parses the screen source — that discovery lives in the design
62
62
  // package. The canonical-only path (no flag) needs no discovery, so base snapshot stays lean.
@@ -51,13 +51,14 @@ const run = defineCommand({
51
51
  "render-scale": str("3D render resolution 0.25..1 (over app.json desktop.renderScale)", { value: "<n>" }),
52
52
  renderer: str("host + shader variant: gl | vulkan (default: app.json desktop.renderer, else gl)", { value: "<gl|vulkan>" }),
53
53
  fps: bool("draw the host's frame counter in the corner (composited fps + the 3D fps when they diverge)"),
54
+ perf: bool("the host's profiler: a [perf] line a second (GPU frame time, CPU split, what the camera sees) + a second overlay row; implies --fps"),
54
55
  server: bool("launch as a multiplayer server"),
55
56
  port: num("(--server) listen port"),
56
57
  "max-clients": num("(--server) client cap"),
57
58
  connect: str("join a server as a client", { value: "<ip:port>" }),
58
59
  ...bundleFlags,
59
60
  },
60
- examples: ["lecodes desktop run", "lecodes desktop run --scale fit --fps", "lecodes desktop run --server --port 7777"],
61
+ examples: ["lecodes desktop run", "lecodes desktop run --scale fit --fps", "lecodes desktop run --perf", "lecodes desktop run --server --port 7777"],
61
62
  run: async ({ flags, raw }) => {
62
63
  // Window defaults come from app.json's desktop block (flags win); standalone projects
63
64
  // without an app.json keep the host's built-in 1280×720.
@@ -89,11 +90,14 @@ const run = defineCommand({
89
90
  // from app.json only. Both are desktop-level (the machine/app), not window-level.
90
91
  renderScale: resolveRenderScale(flags["render-scale"], appCfg?.desktop?.renderScale),
91
92
  maxRenderSize: appCfg?.desktop?.maxRenderSize,
93
+ // Frame-rate cap (app.json only — like renderScale it is a property of the app, not of a run).
94
+ frameRate: appCfg?.desktop?.frameRate,
92
95
  mouseEmulateTouch: appCfg?.desktop?.mouseEmulateTouch,
93
96
  virtualKeyboard: appCfg?.desktop?.virtualKeyboard,
94
97
  // --fps: the host's own corner counter. A flag and not app.json on purpose — it is something you
95
98
  // switch on while looking at a frame, not a property of the app.
96
99
  fps: flags.fps,
100
+ perf: flags.perf,
97
101
  net: flags.server
98
102
  ? { role: "server", port: flags.port, maxClients: flags["max-clients"] }
99
103
  : flags.connect ? { role: "client", address: flags.connect } : undefined,
@@ -5,10 +5,11 @@ import { basename, join } from "node:path"
5
5
  import { findProjectRoot, readManifest, LECODES_DIR, type Manifest } from "../project/manifest"
6
6
  import { loadIgnore } from "../project/ignore"
7
7
  import { startDevServer } from "../dev/devServer"
8
- import { resolveWebRunnerDir } from "../dev/webRunner"
8
+ import { resolveWebRunner } from "../dev/webRunner"
9
9
  import { openBrowser } from "../platform/browserAuth"
10
10
  import { runDesktopDev } from "../hosts/desktopRenderer"
11
11
  import { androidCrashLines, launchOnAndroid, type AndroidDevSession } from "../dev/androidDev"
12
+ import { launchOnIos, type IosDevSession } from "../dev/iosDev"
12
13
  import { readAppConfigAt } from "./app/shared"
13
14
  import { resolveScale } from "./desktop"
14
15
  import { CliError, bool, c, defineCommand, log, note, num, str, warnErr } from "../cli"
@@ -20,15 +21,17 @@ import { toKey } from "../project/paths"
20
21
  *
21
22
  * Starts a LAN dev server (compile + assets + reload channel), prints a QR code, and watches the
22
23
  * working tree. The device opens the QR URL like any project URL; every save recompiles and
23
- * hot-swaps the running app (`_creatorUtils.run` — fresh context, rollback on error). Device
24
+ * hot-swaps the running app (`_creatorApp.run` — fresh context, rollback on error). Device
24
25
  * console output and errors stream back to this terminal. Fully local: no login, no le.codes
25
26
  * backend. Custom shaders compile locally too (matc, downloaded on first use — matcTool.ts);
26
27
  * only when matc can't be fetched do they fall back to server-compiled artifacts (push once).
27
28
  *
28
- * `--web` opens the same project in the browser: the dev server also serves the Web Lite runner
29
- * (viewer-lite's dist-embed, vendored as runtime/web) under /web/ — CSS-flexbox UI + OGL 3D + the
30
- * web twins of the camera/map plugins, no wasm. Same bundle and hot reload; browser console
31
- * output streams back here like a device's. The URL is printed whenever the runner is available.
29
+ * `--web` opens the same project in the browser: the dev server also serves, under /web/, the
30
+ * app in a device (lecodes-web-frame's page: the device, the orientation, the console) around the
31
+ * web player (lecodes-web-player's dist — the runtime as wasm: Filament, creator-2d, the tree UI,
32
+ * and the web halves of the plugins). A phone that opens the url gets the player alone, the app
33
+ * filling its window. Same bundle and hot reload; the browser's console output streams back here
34
+ * like a device's. The URL is printed whenever the player is available.
32
35
  *
33
36
  * `--android` opens the dev URL in the LeCodes app on the phone plugged in over adb (adb reverse:
34
37
  * no Wi-Fi/LAN address needed — see dev/androidDev.ts). With `--time <s>` the run is bounded —
@@ -36,6 +39,10 @@ import { toKey } from "../project/paths"
36
39
  * the app runs until Ctrl+C, and Ctrl+C force-stops it (a device run has no frame cap, and a 3D
37
40
  * scene left rendering heats a charging phone).
38
41
  *
42
+ * `--ios` is the Apple twin (dev/iosDev.ts): the LeCodes app on a booted simulator or a plugged-in
43
+ * iPhone through `xcrun simctl` / `devicectl`, the url as a launch argument. Same `--time` / `--png`
44
+ * shape (screenshots on a simulator).
45
+ *
39
46
  * `--desktop` opens the dev URL in the native host too (same server, same hot reload — the host
40
47
  * speaks the websocket channel). The window closing does NOT stop the server: phones stay
41
48
  * connected, and re-running with --desktop is the reopen. Host errors degrade to a warning — the
@@ -67,7 +74,7 @@ const WATCH_SKIP_FILES = new Set(["tsconfig.json", "jsconfig.json", ".gitignore"
67
74
  */
68
75
  const VIRTUAL_IFACE = /wsl|docker|hyper-v|vethernet|virtualbox|vmware|tap|tun|zerotier|tailscale|loopback/i
69
76
 
70
- const lanCandidates = (): { address: string, iface: string }[] => {
77
+ export const lanCandidates = (): { address: string, iface: string }[] => {
71
78
  const nets = networkInterfaces()
72
79
  const out: { address: string, iface: string, rank: number }[] = []
73
80
  for (const iface of Object.keys(nets)) {
@@ -110,7 +117,7 @@ const printQr = async (url: string) => {
110
117
  export default defineCommand({
111
118
  name: "dev",
112
119
  summary: "Run the project on a device / the desktop / the browser from this machine, with hot reload",
113
- description: "Starts a LAN dev server + prints a QR code; scan it with the LeCodes app. Saves recompile + hot-swap the running app; device logs/errors stream back here. No login needed. The /web/ URL (Web Lite runner) is served always; --web, --android and --desktop each also open the app on that host.",
120
+ description: "Starts a LAN dev server + prints a QR code; scan it with the LeCodes app. Saves recompile + hot-swap the running app; device logs/errors stream back here. No login needed. The /web/ URL (the web player) is served always; --web, --android and --desktop each also open the app on that host.",
114
121
  flags: {
115
122
  port: num("server port", { default: DEFAULT_PORT }),
116
123
  host: str("LAN address to advertise (default: auto-detect)", { value: "<ip>" }),
@@ -120,13 +127,17 @@ export default defineCommand({
120
127
  serial: str("(--android) the device to use", { value: "<id>" }),
121
128
  apk: str("(--android) install this build first", { value: "<file>" }),
122
129
  package: str("(--android) the app's package (default com.letary.lecodes)", { value: "<name>" }),
123
- time: num("(--android) bounded check: screenshot after this many seconds, force-stop, exit (exit 1 on a crash)", { value: "<s>" }),
124
- png: str("(--android --time) where the device screenshot goes", { value: "<file>" }),
125
- web: bool("also open the app in the browser (Web Lite host: same bundle + hot reload, camera/map plugins have web twins)"),
130
+ time: num("(--android | --ios) bounded check: screenshot after this many seconds, force-stop, exit (exit 1 on a crash)", { value: "<s>" }),
131
+ png: str("(--android | --ios, with --time) where the device screenshot goes", { value: "<file>" }),
132
+ ios: bool("open the dev url in the LeCodes app on a booted simulator or a plugged-in iPhone (Mac + Xcode: simctl / devicectl); a simulator gets the 127.0.0.1 url, a phone the LAN url; Ctrl+C terminates the app"),
133
+ udid: str("(--ios) the simulator (udid or name) or device (udid or name) to use", { value: "<id>" }),
134
+ app: str("(--ios) install this LeCodesApp.app first", { value: "<dir>" }),
135
+ bundle: str("(--ios) the app's bundle id (default com.letary.lecodes)", { value: "<id>" }),
136
+ web: bool("also open the app in the browser (the web player in a device: same bundle + hot reload, the console)"),
126
137
  desktop: bool("also open the app in the native desktop host (same hot reload; window from app.json's desktop block; closing it keeps the server)"),
127
138
  fps: bool("(--desktop) draw the host's frame counter"),
128
139
  },
129
- examples: ["lecodes dev", "lecodes dev --web", "lecodes dev --android --time 12 --png phone.png", "lecodes dev --desktop --fps"],
140
+ examples: ["lecodes dev", "lecodes dev --web", "lecodes dev --android --time 12 --png phone.png", "lecodes dev --ios --time 12 --png sim.png", "lecodes dev --desktop --fps"],
130
141
  run: async ({ flags }) => {
131
142
  // Inside a cloned project the server runs at its root; anywhere else it runs standalone against
132
143
  // the current directory (no login / manifest needed) — same rule as `lecodes design`.
@@ -142,9 +153,13 @@ export default defineCommand({
142
153
  const name = manifest?.name ?? basename(root)
143
154
 
144
155
  // Bun's node:http can't complete a websocket upgrade (writes to the upgraded socket are dropped),
145
- // so the reload push would silently never reach devices. Node is the shipped runtime.
146
- if (typeof (globalThis as { Bun?: unknown }).Bun !== "undefined") {
147
- warnErr("Running under Bun: the websocket reload channel doesn't work here — run `lecodes dev` with Node (or use ?t=poll on the device URL).")
156
+ // so the reload push would silently never reach devices: a phone that scanned a websocket url
157
+ // sat on "Connected — loading the project…" forever. Under Bun every device url — the QR, the
158
+ // printed one, what --ios / --android open — is the POLL form (/dev-poll.js: a path, since the
159
+ // launcher only fetches urls that end in .js). Node is the shipped runtime.
160
+ const underBun = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined"
161
+ if (underBun) {
162
+ warnErr("Running under Bun: no websocket reload channel here — devices poll instead (the QR points at /dev-poll.js). Node is the shipped runtime.")
148
163
  }
149
164
 
150
165
  const port = flags.port || DEFAULT_PORT
@@ -157,14 +172,14 @@ export default defineCommand({
157
172
  const origin = `http://${host}:${port}`
158
173
  const token = loadDevToken(root)
159
174
 
160
- const webDir = resolveWebRunnerDir()
161
- if (flags.web && !webDir) {
162
- 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).")
175
+ const web = resolveWebRunner(note)
176
+ if (flags.web && !web) {
177
+ throw new CliError("No web player found for --web: this CLI has no runtime/web (a monorepo checkout needs `bun run build` in hosts/web-player).")
163
178
  }
164
179
  const server = await startDevServer({
165
180
  root, manifest, name, origin, port, token,
166
181
  entryOverride: flags.entry,
167
- webDir,
182
+ web,
168
183
  log: note,
169
184
  }).catch((e: unknown) => {
170
185
  const msg = e instanceof Error ? e.message : String(e)
@@ -194,7 +209,6 @@ export default defineCommand({
194
209
  }
195
210
 
196
211
  const debug = process.env.LECODES_DEV_DEBUG === "1"
197
- const underBun = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined"
198
212
  let timer: NodeJS.Timeout | null = null
199
213
  const onChange = (file: string | null) => {
200
214
  if (debug) note(`[watch] event: ${file ?? "(null)"} → ${shouldRecompile(file) ? "recompile" : "skip"}`)
@@ -225,13 +239,15 @@ export default defineCommand({
225
239
  log("")
226
240
  log(`${c.bold("LeCodes Dev")} — ${name}`)
227
241
  log("")
228
- if (!flags["no-qr"]) await printQr(server.url)
229
- log(`Scan the QR with the LeCodes app, or open: ${c.bold(server.url)}`)
242
+ // The url a device opens: the websocket form, or the poll form under Bun (see above).
243
+ const deviceUrl = underBun ? server.url.replace(/\/dev\.js$/, "/dev-poll.js") : server.url
244
+ if (!flags["no-qr"]) await printQr(deviceUrl)
245
+ log(`Scan the QR with the LeCodes app, or open: ${c.bold(deviceUrl)}`)
230
246
  // The browser runner: localhost is a secure context (camera works there), the LAN address is
231
247
  // what a phone's browser can reach.
232
248
  const webLocal = server.webUrl ? server.webUrl.replace(`http://${host}:`, "http://localhost:") : null
233
- if (server.webUrl && webLocal) log(`In the browser (Web Lite): ${c.bold(webLocal)} (LAN: ${server.webUrl})`)
234
- note(`Older desktop builds (no websocket bridge): ${server.url}?t=poll`)
249
+ if (server.webUrl && webLocal) log(`In the browser: ${c.bold(webLocal)} (LAN: ${server.webUrl})`)
250
+ if (!underBun) note(`Older desktop builds (no websocket bridge): ${server.url}?t=poll`)
235
251
  if (alternatives.length > 0) {
236
252
  note(`Device can't reach it? This machine also has ${alternatives.map((a) => `${a.address} (${a.iface})`).join(", ")} — retry with --host <ip>.`)
237
253
  }
@@ -248,17 +264,16 @@ export default defineCommand({
248
264
  if (flags.android) {
249
265
  const seconds = flags.time ?? 0
250
266
  const png = flags.png
251
- // Under Bun the websocket channel never delivers (see the warning above): the phone would sit on
252
- // "Connected — loading the project…" forever. The path-form poll url works on both runtimes.
253
- const deviceUrl = `http://127.0.0.1:${port}${server.url.slice(origin.length)}`.replace(/\/dev\.js$/, underBun ? "/dev-poll.js" : "/dev.js")
267
+ // adb reverse: the phone reaches the server through its own loopback.
268
+ const androidUrl = `http://127.0.0.1:${port}${deviceUrl.slice(origin.length)}`
254
269
  let session: AndroidDevSession
255
270
  try {
256
- session = launchOnAndroid({ port, url: deviceUrl, serial: flags.serial, apk: flags.apk, packageName: flags.package })
271
+ session = launchOnAndroid({ port, url: androidUrl, serial: flags.serial, apk: flags.apk, packageName: flags.package })
257
272
  } catch (e) {
258
273
  server.close(); watcher?.close()
259
274
  throw e
260
275
  }
261
- note(`Android: ${session.serial} → ${deviceUrl} (adb reverse; device logs stream here as [device:log])`)
276
+ note(`Android: ${session.serial} → ${androidUrl} (adb reverse; device logs stream here as [device:log])`)
262
277
  const finish = (code: number) => {
263
278
  session.stop()
264
279
  server.close(); watcher?.close()
@@ -281,6 +296,42 @@ export default defineCommand({
281
296
  }
282
297
  }
283
298
 
299
+ if (flags.ios) {
300
+ const seconds = flags.time ?? 0
301
+ const png = flags.png
302
+ // A simulator shares the Mac's network: the loopback url, no Wi-Fi involved. A phone gets the
303
+ // LAN url the QR shows.
304
+ const path = deviceUrl.slice(origin.length)
305
+ let session: IosDevSession
306
+ try {
307
+ session = launchOnIos({ simulatorUrl: `http://127.0.0.1:${port}${path}`, deviceUrl, udid: flags.udid, app: flags.app, bundleId: flags.bundle })
308
+ } catch (e) {
309
+ server.close(); watcher?.close()
310
+ throw e
311
+ }
312
+ note(`iOS: ${session.name} → ${session.kind === "simulator" ? `http://127.0.0.1:${port}${path}` : deviceUrl} (device logs stream here as [device:log])`)
313
+ const finish = (code: number) => {
314
+ session.stop()
315
+ server.close(); watcher?.close()
316
+ process.exit(code)
317
+ }
318
+ process.on("SIGINT", () => { note(`Stopping the app on ${session.name}…`); finish(0) })
319
+ if (seconds > 0) {
320
+ setTimeout(() => {
321
+ const crashes = session.crashLines()
322
+ if (png) {
323
+ if (session.screenshot(png)) note(`Wrote ${png} (screenshot after ${seconds} s)`)
324
+ else warnErr(session.kind === "simulator" ? "Screenshot failed (simctl io screenshot)." : "Screenshots need a simulator (no capture channel to a phone).")
325
+ }
326
+ if (crashes.length > 0) {
327
+ warnErr(`The app crashed:\n${crashes.join("\n")}`)
328
+ finish(1)
329
+ }
330
+ finish(0)
331
+ }, seconds * 1000)
332
+ }
333
+ }
334
+
284
335
  if (flags.desktop) {
285
336
  const appCfg = readAppConfigAt(root)
286
337
  const win = appCfg?.desktop?.window
@@ -293,6 +344,8 @@ export default defineCommand({
293
344
  resizable: win?.resizable,
294
345
  title: win?.title ?? appCfg?.name ?? name,
295
346
  scale: resolveScale(undefined, win?.scale),
347
+ // The frame-rate cap is forwarded too: it changes how the game FEELS, and dev should match production.
348
+ frameRate: appCfg?.desktop?.frameRate,
296
349
  fps: flags.fps,
297
350
  }).then(
298
351
  () => note("Desktop window closed — the dev server keeps running (Ctrl+C to stop)."),