lecodes-cli 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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
@@ -12,6 +12,7 @@ import { renderDevScreen, renderPrelude } from "./clientTemplates"
12
12
  import { upgradeWebSocket, type WsConnection } from "./wsServer"
13
13
  import { toKey } from "../project/paths"
14
14
  import { makeStackDemangler } from "../compile/nativeStack"
15
+ import type { WebRunner } from "./webRunner"
15
16
 
16
17
  /*
17
18
  * The `lecodes dev` LAN server: serves the project to devices and hot-reloads it on change.
@@ -24,9 +25,12 @@ import { makeStackDemangler } from "../compile/nativeStack"
24
25
  * GET /t/<tok>/version {"v":N} — polling fallback
25
26
  * POST /t/<tok>/log log/error backchannel for polling clients
26
27
  * 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
28
+ * GET /t/<tok>/web/[file] the page of `dev --web` (lecodes-web-frame's dist-page): the app
29
+ * in a device, the buttons, the console — around an <iframe> of…
30
+ * GET /t/<tok>/web/player/[file] …the web player (lecodes-web-player's dist), opened as
31
+ * player/?dev=../.. — it loads bundle.js?t=web, the bundle WITHOUT
32
+ * the native prelude; the player owns reload (ws / version
33
+ * polling) and the log backchannel. A phone is sent here at once
30
34
  *
31
35
  * Compile is eager: the command calls recompile() on every file change; a successful compile bumps
32
36
  * `v` and broadcasts {type:"reload", v} to connected sockets (polling clients see /version move).
@@ -44,8 +48,9 @@ export type DevServerOptions = {
44
48
  port: number
45
49
  token: string
46
50
  entryOverride?: string
47
- /** The Web Lite runner folder to serve under /web/ (src/dev/webRunner.ts); absent = no browser route. */
48
- webDir?: string | null
51
+ /** What is served under /web/ (src/dev/webRunner.ts): the player, and the page around it; absent =
52
+ * no browser route. */
53
+ web?: WebRunner | null
49
54
  /** Diagnostics (compile errors, device logs); defaults to stderr-style silence-safe noop. */
50
55
  log?: (msg: string) => void
51
56
  }
@@ -89,6 +94,32 @@ const sendText = (res: ServerResponse, status: number, type: string, body: strin
89
94
  res.end(body)
90
95
  }
91
96
 
97
+ /** A working-tree file with byte-range support. AVPlayer (the iOS host's video / audio players)
98
+ * opens an HTTP source with `Range: bytes=0-1` and fails the item with -11850 "The server is not
99
+ * correctly configured" when the answer is a plain 200, so an asset route must answer ranges. */
100
+ const sendFile = (req: IncomingMessage, res: ServerResponse, abs: string, type: string) => {
101
+ const size = statSync(abs).size
102
+ const headers = { "content-type": type, "cache-control": "no-store", "accept-ranges": "bytes" }
103
+ const m = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range ?? "")
104
+ if (m && (m[1] || m[2])) {
105
+ // bytes=a-b, bytes=a- (to the end), bytes=-n (the last n bytes)
106
+ const start = m[1] ? Number(m[1]) : Math.max(0, size - Number(m[2]))
107
+ const end = m[1] && m[2] ? Math.min(Number(m[2]), size - 1) : size - 1
108
+ if (start >= size || start > end) {
109
+ res.writeHead(416, { ...headers, "content-range": `bytes */${size}` })
110
+ res.end()
111
+ return
112
+ }
113
+ res.writeHead(206, { ...headers, "content-range": `bytes ${start}-${end}/${size}`, "content-length": end - start + 1 })
114
+ if (req.method === "HEAD") return void res.end()
115
+ createReadStream(abs, { start, end }).pipe(res)
116
+ return
117
+ }
118
+ res.writeHead(200, { ...headers, "content-length": size })
119
+ if (req.method === "HEAD") return void res.end()
120
+ createReadStream(abs).pipe(res)
121
+ }
122
+
92
123
  const sendJson = (res: ServerResponse, status: number, body: unknown) =>
93
124
  sendText(res, status, "application/json", JSON.stringify(body))
94
125
 
@@ -138,7 +169,7 @@ export const startDevServer = async (options: DevServerOptions): Promise<DevServ
138
169
  const ambiguous = ambiguousEntryWarning(entries)
139
170
  if (ambiguous) warnings.push(ambiguous)
140
171
  }
141
- const js = await compileProject({ entries, name, publicUrl: origin, entryOverride, header: true, fontsHeader, iconsHeader })
172
+ const js = await compileProject({ entries, name, publicUrl: origin, entryOverride, header: true, fontsHeader, iconsHeader, onWarning: (w) => warnings.push(w) })
142
173
  const changed = bundleJs === null || stripVolatile(js) !== stripVolatile(bundleJs)
143
174
  if (changed) {
144
175
  bundleJs = js
@@ -195,14 +226,30 @@ export const startDevServer = async (options: DevServerOptions): Promise<DevServ
195
226
  const t = url.searchParams.get("t")
196
227
  return t === "poll" ? "poll" : t === "web" ? "web" : "ws"
197
228
  }
198
- const withPrelude = (js: string, url: URL, v: number, forced?: "poll") => {
229
+ // The prelude talks back to the origin THIS request came through (the Host header): a phone on
230
+ // `adb reverse` reached us as 127.0.0.1:<port> and can't see the LAN address the QR carries.
231
+ const withPrelude = (js: string, url: URL, v: number, reqOrigin: string, forced?: "poll") => {
199
232
  const transport = forced ?? transportOf(url)
200
- return transport === "web" ? js : js + "\n" + renderPrelude({ origin, base, transport, v })
233
+ return transport === "web" ? js : js + "\n" + renderPrelude({ origin: reqOrigin, base, transport, v })
234
+ }
235
+ const web = options.web ?? null
236
+ /** A file of a built folder; the entry files revalidate — a CLI update swaps them under the same
237
+ * url — while what is under assets/ carries its hash in its name. */
238
+ const sendBuilt = (res: ServerResponse, dir: string, rel: string) => {
239
+ if (rel.split("/").some((seg) => seg === ".." || seg === "" || seg === ".")) return sendText(res, 400, "text/plain", "Bad path")
240
+ const abs = join(dir, ...rel.split("/"))
241
+ try {
242
+ if (!statSync(abs).isFile()) throw new Error("not a file")
243
+ } catch {
244
+ return sendText(res, 404, "text/plain", "Not found")
245
+ }
246
+ res.writeHead(200, { "content-type": contentType(rel), "cache-control": rel.startsWith("assets/") ? "max-age=31536000, immutable" : "no-store" })
247
+ createReadStream(abs).pipe(res)
201
248
  }
202
- const webDir = options.webDir ?? null
203
249
 
204
250
  const handle = async (req: IncomingMessage, res: ServerResponse) => {
205
- const url = new URL(req.url ?? "/", origin)
251
+ const reqOrigin = req.headers.host ? `http://${req.headers.host}` : origin
252
+ const url = new URL(req.url ?? "/", reqOrigin)
206
253
  const path = url.pathname
207
254
  // Browsers ask for it on every page load (the /web/ runner) — keep the console clean.
208
255
  if (path === "/favicon.ico") { res.writeHead(204); res.end(); return }
@@ -210,40 +257,32 @@ export const startDevServer = async (options: DevServerOptions): Promise<DevServ
210
257
  const route = path.slice(base.length)
211
258
 
212
259
  if (route === "/dev.js") {
213
- return sendText(res, 200, "text/javascript", withPrelude(devJs, url, -1))
260
+ return sendText(res, 200, "text/javascript", withPrelude(devJs, url, -1, reqOrigin))
214
261
  }
215
262
  // The poll transport as a PATH: the Android app's initial.js only fetches a launch url that ends
216
263
  // in ".js" (anything else goes to le.codes/api/code), so `?t=poll` can't reach it. Same bundle,
217
264
  // prelude polls instead of the websocket — what `dev --android` opens under Bun (no ws there).
218
265
  if (route === "/dev-poll.js") {
219
- return sendText(res, 200, "text/javascript", withPrelude(devJs, url, -1, "poll"))
266
+ return sendText(res, 200, "text/javascript", withPrelude(devJs, url, -1, reqOrigin, "poll"))
220
267
  }
221
268
  if (route === "/bundle.js") {
222
269
  if (bundleJs === null) return sendText(res, 503, "text/plain", "No successful compile yet — check the terminal.")
223
270
  res.setHeader("x-lecodes-v", String(v)) // the browser runner learns the version it runs
224
- return sendText(res, 200, "text/javascript", withPrelude(bundleJs, url, v))
225
- }
226
- // The browser runner: a static folder with relative asset paths, so it needs the trailing slash.
227
- if (route === "/web") {
228
- res.writeHead(302, { location: `${base}/web/${url.search}` })
229
- res.end()
230
- return
271
+ return sendText(res, 200, "text/javascript", withPrelude(bundleJs, url, v, reqOrigin))
231
272
  }
232
- if (route.startsWith("/web/")) {
233
- if (!webDir) return sendText(res, 404, "text/plain", "No Web Lite runner vendored with this CLI (lecodes-viewer-lite dist-embed).")
234
- const rel = route === "/web/" ? "embed.html" : route.slice("/web/".length)
235
- if (rel.split("/").some((seg) => seg === ".." || seg === "" || seg === ".")) return sendText(res, 400, "text/plain", "Bad path")
236
- const abs = join(webDir, ...rel.split("/"))
237
- try {
238
- if (!statSync(abs).isFile()) throw new Error("not a file")
239
- } catch {
240
- return sendText(res, 404, "text/plain", "Not found")
241
- }
242
- // The entry files (html, embed.js) must revalidate — a CLI update swaps them under the same URL.
243
- const hashed = rel.startsWith("assets/")
244
- res.writeHead(200, { "content-type": contentType(rel), "cache-control": hashed ? "max-age=31536000, immutable" : "no-store" })
245
- createReadStream(abs).pipe(res)
246
- return
273
+ // The browser: built folders with relative asset paths, so each needs its trailing slash. The
274
+ // player is told the server it runs the project of (`dev`: this one, two folders up).
275
+ if (route === "/web" || route.startsWith("/web/")) {
276
+ if (!web) return sendText(res, 404, "text/plain", "No web player vendored with this CLI (lecodes-web-player's dist).")
277
+ const redirect = (to: string) => { res.writeHead(302, { location: to }); res.end() }
278
+ const query = (extra?: [string, string]) => { if (extra) url.searchParams.set(...extra); const q = url.searchParams.toString(); return q ? `?${q}` : "" }
279
+ const rel = route.slice("/web".length)
280
+ if (rel === "/player" || (rel === "/player/" && !url.searchParams.has("dev"))) return redirect(`${base}/web/player/${query(["dev", "../.."])}`)
281
+ if (rel.startsWith("/player/")) return sendBuilt(res, web.player, rel === "/player/" ? "index.html" : rel.slice("/player/".length))
282
+ // no page around the player in this CLI: the player alone
283
+ if (!web.page) return redirect(`${base}/web/player/${query(["dev", "../.."])}`)
284
+ if (rel === "") return redirect(`${base}/web/${query()}`)
285
+ return sendBuilt(res, web.page, rel === "/" ? "index.html" : rel.slice(1))
247
286
  }
248
287
  if (route === "/version") {
249
288
  return sendJson(res, 200, { v })
@@ -274,9 +313,7 @@ export const startDevServer = async (options: DevServerOptions): Promise<DevServ
274
313
  }
275
314
  const abs = resolveLocalPath(root, key)
276
315
  if (!abs || !statSync(abs).isFile()) return sendText(res, 404, "text/plain", "Not found")
277
- res.writeHead(200, { "content-type": contentType(key), "cache-control": "no-store" })
278
- createReadStream(abs).pipe(res)
279
- return
316
+ return sendFile(req, res, abs, contentType(key))
280
317
  }
281
318
  return sendText(res, 404, "text/plain", "Not found")
282
319
  }
@@ -325,7 +362,7 @@ export const startDevServer = async (options: DevServerOptions): Promise<DevServ
325
362
 
326
363
  return {
327
364
  url: `${origin}${base}/dev.js`,
328
- webUrl: webDir ? `${origin}${base}/web/` : null,
365
+ webUrl: web ? `${origin}${base}/web/` : null,
329
366
  base,
330
367
  port: boundPort,
331
368
  v: () => v,
@@ -0,0 +1,180 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs"
2
+ import { homedir } from "node:os"
3
+ import { join } from "node:path"
4
+ import { spawnSync } from "node:child_process"
5
+ import { CliError, note, warnErr } from "../cli"
6
+
7
+ /*
8
+ * `lecodes dev --ios` — open the dev URL in the LeCodes app on a simulator or a plugged-in iPhone,
9
+ * through Xcode's tools (`xcrun simctl` for a simulator, `xcrun devicectl` for a device). The twin of
10
+ * dev/androidDev.ts, on a Mac only.
11
+ *
12
+ * The url reaches the app as a LAUNCH ARGUMENT (`--lecodes-url <url>`, read by LeCodesViewController
13
+ * and seeded as the launch URL before the launcher boots — hosts/apple/lecodes): a simulator has no
14
+ * universal links (no AASA), and `simctl openurl` on an http url opens Safari. A simulator shares the
15
+ * Mac's network, so it gets the 127.0.0.1 url (no Wi-Fi involved, like adb reverse); a device gets the
16
+ * LAN url the QR shows — there is no reverse tunnel over USB for iPhones.
17
+ *
18
+ * Like Android, every exit path terminates the app: a device run has no frame cap, and a 3D scene
19
+ * left rendering is a sustained GPU load on a charging phone.
20
+ */
21
+
22
+ export const DEFAULT_IOS_BUNDLE = "com.letary.lecodes"
23
+
24
+ export type IosDevOptions = {
25
+ /** The url for a simulator (127.0.0.1) and the one for a device (the LAN address). */
26
+ simulatorUrl: string
27
+ deviceUrl: string
28
+ /** A simulator's or a device's udid (or a simulator's name); omitted = the booted simulator, else the paired device. */
29
+ udid?: string
30
+ /** A built LeCodesApp.app to install first. */
31
+ app?: string
32
+ bundleId?: string
33
+ }
34
+
35
+ export type IosDevSession = {
36
+ kind: "simulator" | "device"
37
+ udid: string
38
+ name: string
39
+ bundleId: string
40
+ /** `simctl io screenshot` → the file; false on a device (no screenshot channel). */
41
+ screenshot: (file: string) => boolean
42
+ /** Terminate the app. Idempotent. */
43
+ stop: () => void
44
+ /** Crash reports of the app written since the launch (the simulator's, on this Mac). */
45
+ crashLines: () => string[]
46
+ }
47
+
48
+ const run = (args: string[]): { ok: boolean, out: string } => {
49
+ const r = spawnSync("xcrun", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 })
50
+ const out = `${r.stdout ?? ""}${r.stderr ?? ""}`.trim()
51
+ return { ok: r.status === 0, out }
52
+ }
53
+
54
+ type Simulator = { udid: string, name: string, state: string, runtime: string }
55
+ type Device = { udid: string, name: string, model: string, state: string }
56
+
57
+ /** The simulators (`simctl list devices`), every runtime. */
58
+ export const listSimulators = (): Simulator[] => {
59
+ const { ok, out } = run(["simctl", "list", "devices", "--json"])
60
+ if (!ok) return []
61
+ try {
62
+ const json = JSON.parse(out) as { devices: Record<string, { udid: string, name: string, state: string, isAvailable?: boolean }[]> }
63
+ const all: Simulator[] = []
64
+ for (const [runtime, list] of Object.entries(json.devices)) {
65
+ for (const d of list) if (d.isAvailable !== false) all.push({ udid: d.udid, name: d.name, state: d.state, runtime: runtime.split(".").pop() ?? runtime })
66
+ }
67
+ return all
68
+ } catch { return [] }
69
+ }
70
+
71
+ /** The paired physical devices (`devicectl list devices`). */
72
+ export const listDevices = (): Device[] => {
73
+ const tmp = join(process.env.TMPDIR ?? "/tmp", `lecodes-devicectl-${process.pid}.json`)
74
+ const { ok } = run(["devicectl", "list", "devices", "--json-output", tmp])
75
+ if (!ok || !existsSync(tmp)) return []
76
+ try {
77
+ const json = JSON.parse(readFileSync(tmp, "utf8")) as { result?: { devices?: { identifier: string, deviceProperties?: { name?: string }, hardwareProperties?: { marketingName?: string }, connectionProperties?: { pairingState?: string, tunnelState?: string } }[] } }
78
+ return (json.result?.devices ?? []).map((d) => ({
79
+ udid: d.identifier,
80
+ name: d.deviceProperties?.name ?? d.identifier,
81
+ model: d.hardwareProperties?.marketingName ?? "",
82
+ state: d.connectionProperties?.tunnelState ?? d.connectionProperties?.pairingState ?? "unknown",
83
+ }))
84
+ } catch { return [] } finally { try { unlinkSync(tmp) } catch { /* gone */ } }
85
+ }
86
+
87
+ const pickTarget = (udid: string | undefined): { kind: "simulator" | "device", udid: string, name: string } => {
88
+ if (process.platform !== "darwin") throw new CliError("--ios needs a Mac with Xcode (xcrun simctl / devicectl).")
89
+ const sims = listSimulators()
90
+ if (udid) {
91
+ const sim = sims.find((s) => s.udid === udid || s.name === udid)
92
+ if (sim) return { kind: "simulator", udid: sim.udid, name: `${sim.name} (${sim.runtime})` }
93
+ const dev = listDevices().find((d) => d.udid === udid || d.name === udid)
94
+ if (dev) return { kind: "device", udid: dev.udid, name: `${dev.name}${dev.model ? ` (${dev.model})` : ""}` }
95
+ throw new CliError(`No simulator or device "${udid}" — simulators: ${sims.map((s) => `${s.name} [${s.udid}]`).join(", ") || "none"}.`)
96
+ }
97
+ const booted = sims.filter((s) => s.state === "Booted")
98
+ if (booted.length === 1) return { kind: "simulator", udid: booted[0].udid, name: `${booted[0].name} (${booted[0].runtime})` }
99
+ if (booted.length > 1) throw new CliError(`Several booted simulators: ${booted.map((s) => `${s.name} [${s.udid}]`).join(", ")} — pick one with --udid <id|name>.`)
100
+ const devices = listDevices()
101
+ if (devices.length === 1) return { kind: "device", udid: devices[0].udid, name: `${devices[0].name}${devices[0].model ? ` (${devices[0].model})` : ""}` }
102
+ if (devices.length > 1) throw new CliError(`Several devices: ${devices.map((d) => `${d.name} [${d.udid}]`).join(", ")} — pick one with --udid <id|name>.`)
103
+ throw new CliError("No booted simulator and no paired iPhone: boot one in Simulator.app (or `xcrun simctl boot \"iPhone 17\"`), or plug a phone in and trust this Mac.")
104
+ }
105
+
106
+ const simulatorHasApp = (udid: string, bundleId: string): boolean => run(["simctl", "get_app_container", udid, bundleId]).ok
107
+
108
+ const deviceHasApp = (udid: string, bundleId: string): boolean => {
109
+ const tmp = join(process.env.TMPDIR ?? "/tmp", `lecodes-devicectl-apps-${process.pid}.json`)
110
+ const { ok } = run(["devicectl", "device", "info", "apps", "--device", udid, "--json-output", tmp])
111
+ if (!ok || !existsSync(tmp)) return false
112
+ try {
113
+ const json = JSON.parse(readFileSync(tmp, "utf8")) as { result?: { apps?: { bundleIdentifier?: string }[] } }
114
+ return (json.result?.apps ?? []).some((a) => a.bundleIdentifier === bundleId)
115
+ } catch { return false } finally { try { unlinkSync(tmp) } catch { /* gone */ } }
116
+ }
117
+
118
+ /** Install (optional), launch the app on the dev url. Returns the session handle. */
119
+ export const launchOnIos = (opts: IosDevOptions): IosDevSession => {
120
+ const target = pickTarget(opts.udid)
121
+ const bundleId = opts.bundleId ?? process.env.LECODES_IOS_BUNDLE ?? DEFAULT_IOS_BUNDLE
122
+ const launchedAt = Date.now()
123
+
124
+ if (opts.app) {
125
+ if (!existsSync(opts.app)) throw new CliError(`--app: ${opts.app} does not exist.`)
126
+ note(`Installing ${opts.app} on ${target.name}…`)
127
+ const r = target.kind === "simulator"
128
+ ? run(["simctl", "install", target.udid, opts.app])
129
+ : run(["devicectl", "device", "install", "app", "--device", target.udid, opts.app])
130
+ if (!r.ok) throw new CliError(`install failed: ${r.out}`)
131
+ }
132
+ const installed = target.kind === "simulator" ? simulatorHasApp(target.udid, bundleId) : deviceHasApp(target.udid, bundleId)
133
+ if (!installed) {
134
+ throw new CliError(`${bundleId} is not installed on ${target.name} — pass --app <LeCodesApp.app> (hosts/apple/app: xcodebuild -scheme LeCodesApp -destination 'generic/platform=iOS Simulator' build → DerivedData/…/LeCodesApp.app).`)
135
+ }
136
+
137
+ const url = target.kind === "simulator" ? opts.simulatorUrl : opts.deviceUrl
138
+ let pid: string | null = null
139
+ if (target.kind === "simulator") {
140
+ run(["simctl", "terminate", target.udid, bundleId])
141
+ const start = run(["simctl", "launch", target.udid, bundleId, "--lecodes-url", url])
142
+ if (!start.ok) throw new CliError(`Could not launch ${bundleId} on ${target.name}: ${start.out}`)
143
+ pid = /:\s*(\d+)\s*$/.exec(start.out)?.[1] ?? null
144
+ } else {
145
+ const tmp = join(process.env.TMPDIR ?? "/tmp", `lecodes-devicectl-launch-${process.pid}.json`)
146
+ const start = run(["devicectl", "device", "process", "launch", "--device", target.udid, "--terminate-existing", "--json-output", tmp, bundleId, "--lecodes-url", url])
147
+ if (!start.ok) throw new CliError(`Could not launch ${bundleId} on ${target.name}: ${start.out}`)
148
+ try {
149
+ const json = JSON.parse(readFileSync(tmp, "utf8")) as { result?: { process?: { processIdentifier?: number } } }
150
+ pid = json.result?.process?.processIdentifier?.toString() ?? null
151
+ } catch { pid = null } finally { try { unlinkSync(tmp) } catch { /* gone */ } }
152
+ if (!pid) warnErr("devicectl reported no pid — Ctrl+C will not terminate the app on the phone.")
153
+ }
154
+
155
+ let stopped = false
156
+ return {
157
+ kind: target.kind, udid: target.udid, name: target.name, bundleId,
158
+ screenshot: (file: string) => {
159
+ if (target.kind !== "simulator") return false
160
+ return run(["simctl", "io", target.udid, "screenshot", file]).ok
161
+ },
162
+ stop: () => {
163
+ if (stopped) return
164
+ stopped = true
165
+ if (target.kind === "simulator") run(["simctl", "terminate", target.udid, bundleId])
166
+ else if (pid) run(["devicectl", "device", "process", "terminate", "--device", target.udid, "--pid", pid])
167
+ },
168
+ crashLines: () => {
169
+ // A simulator app's crash lands in this Mac's DiagnosticReports as <AppName>-<date>.ips.
170
+ if (target.kind !== "simulator") return []
171
+ const dir = join(homedir(), "Library", "Logs", "DiagnosticReports")
172
+ if (!existsSync(dir)) return []
173
+ const appName = bundleId.split(".").pop() ?? ""
174
+ return readdirSync(dir)
175
+ .filter((f) => f.endsWith(".ips") && (f.startsWith("LeCodes") || (appName && f.toLowerCase().startsWith(appName.toLowerCase()))))
176
+ .filter((f) => statSync(join(dir, f)).mtimeMs >= launchedAt)
177
+ .map((f) => join(dir, f))
178
+ },
179
+ }
180
+ }
@@ -1,22 +1,90 @@
1
- import { existsSync } from "node:fs"
1
+ import { spawnSync } from "node:child_process"
2
+ import { existsSync, readdirSync, statSync } from "node:fs"
2
3
  import { createRequire } from "node:module"
3
4
  import { dirname, join } from "node:path"
4
5
  import { distRoot } from "../hosts/distRoot"
5
6
 
6
7
  /**
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/).
8
+ * What `lecodes dev --web` serves to a browser, two built folders:
9
+ * player the web player (lecodes-web-player's dist: index.html + player.js + assets/ — the
10
+ * runtime module, the archives, the plugins' web halves): the app, filling its window
11
+ * page the page around it (lecodes-web-frame's dist-page: the app in a device, the buttons,
12
+ * the console), which holds the player in an <iframe>; absent = the player alone
13
+ *
14
+ * Each comes in one of two shapes:
15
+ * - its package's own build, in a CHECKOUT of the core (the package has its sources beside it):
16
+ * built here when it is older than what it is built from — a stale build is yesterday's host
17
+ * under today's SDK, and it fails in ways that name nothing;
18
+ * - vendored next to the CLI as runtime/web and runtime/web-page (scripts/vendor-runtime.ts —
19
+ * the npm package and the standalone binary).
20
+ * `null` = no player available (the dev server then serves everything but /web/).
13
21
  */
14
- export const resolveWebRunnerDir = (): string | null => {
15
- const candidates = [join(distRoot, "runtime", "web")]
22
+ export type WebRunner = { player: string, page: string | null }
23
+
24
+ type Built = {
25
+ /** The package, as npm names it. */
26
+ pkg: string
27
+ /** Its build, a folder of the package. */
28
+ dist: string
29
+ /** The file a build has. */
30
+ entry: string
31
+ /** The folder it is vendored as, beside the CLI. */
32
+ vendored: string
33
+ /** What it is built from, relative to the repo. */
34
+ sources: string[]
35
+ /** The arguments of the `vite build` that makes it. */
36
+ build: string[]
37
+ }
38
+
39
+ const PLAYER: Built = {
40
+ pkg: "lecodes-web-player", dist: "dist", entry: "player.js", vendored: "web", build: [],
41
+ sources: ["hosts/web-player/src", "hosts/web-player/build", "hosts/web-player/index.html", "hosts/web-host/src", "renderers/web-canvas/src", "renderers/web-shared/src", "runtime/web/src", "runtime/web/native/dist", "hosts/plugins/plugins"],
42
+ }
43
+ const PAGE: Built = {
44
+ // (the package's own build is the element, a library; the page is another)
45
+ pkg: "lecodes-web-frame", dist: "dist-page", entry: "page.js", vendored: "web-page", build: ["-c", "vite.page.config.ts"],
46
+ sources: ["hosts/web-frame/src", "hosts/web-frame/page", "hosts/web-player/src/protocol.ts"],
47
+ }
48
+
49
+ export const resolveWebRunner = (log: (message: string) => void = () => {}): WebRunner | null => {
50
+ const player = resolve(PLAYER, log)
51
+ return player ? { player, page: resolve(PAGE, log) } : null
52
+ }
53
+
54
+ const resolve = (built: Built, log: (message: string) => void): string | null => {
55
+ const has = (dir: string) => existsSync(join(dir, "index.html")) && existsSync(join(dir, built.entry))
56
+ const checkout = checkoutOf(built.pkg)
57
+ if (checkout) {
58
+ const dist = join(checkout, built.dist)
59
+ const repo = join(checkout, "..", "..")
60
+ if (!has(dist) || newest(built.sources.map((s) => join(repo, s))) > statSync(join(dist, built.entry)).mtimeMs) {
61
+ log(`Building ${built.pkg} (its sources are newer than its build)…`)
62
+ const done = spawnSync("bunx", ["vite", "build", ...built.build], { cwd: checkout, stdio: "pipe", encoding: "utf8", shell: process.platform === "win32" })
63
+ if (done.status !== 0) log(`${built.pkg} did not build — the browser gets the build that is there:\n${done.stderr || done.stdout}`)
64
+ }
65
+ if (has(dist)) return dist
66
+ }
67
+ const vendored = join(distRoot, "runtime", built.vendored)
68
+ return has(vendored) ? vendored : null
69
+ }
70
+
71
+ /** A package when it is a checkout (its sources are there), else null. */
72
+ const checkoutOf = (pkg: string): string | null => {
16
73
  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
74
+ const dir = dirname(createRequire(import.meta.url).resolve(`${pkg}/package.json`))
75
+ return existsSync(join(dir, "vite.config.ts")) && existsSync(join(dir, "src")) ? dir : null
76
+ } catch { return null }
77
+ }
78
+
79
+ /** The latest modification under the paths (0 when none exists). */
80
+ const newest = (paths: string[]): number => {
81
+ let latest = 0
82
+ const walk = (path: string) => {
83
+ let stat
84
+ try { stat = statSync(path) } catch { return }
85
+ if (!stat.isDirectory()) { latest = Math.max(latest, stat.mtimeMs); return }
86
+ for (const name of readdirSync(path)) if (name !== "node_modules" && name !== "ios" && name !== "android") walk(join(path, name))
87
+ }
88
+ for (const path of paths) walk(path)
89
+ return latest
22
90
  }
@@ -14,7 +14,7 @@ import { CliError, note } from "../cli"
14
14
  * releases/versions.json — published by scripts/release/release-windows.ps1 from the desktop
15
15
  * build's in-tree filament host tools, so the KTX it writes always matches the engine the hosts
16
16
  * ship). Cache: ~/.lecodes/cmgen/cmgen-<version>/. LECODES_CMGEN overrides resolution entirely
17
- * (local dev: packages/desktop/build/creator-gl/filament/tools/cmgen/cmgen.exe).
17
+ * (local dev: hosts/desktop/build/creator-gl/filament/tools/cmgen/cmgen.exe).
18
18
  *
19
19
  * The CLI resolves it and hands the path to lecodes-assets through LECODES_CMGEN, so that package
20
20
  * stays network-free and still works standalone against a local build.