lecodes-cli 0.20.2 → 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 (154) hide show
  1. package/README.md +56 -57
  2. package/dist/index.js +11052 -7592
  3. package/package.json +14 -8
  4. package/runtime/materials/decal-relief.mat +12 -1
  5. package/runtime/materials/decal.mat +17 -3
  6. package/runtime/materials/lightmap.mat +117 -138
  7. package/runtime/materials/particles-quad.mat +6 -2
  8. package/runtime/materials/terrain-lightmap.mat +123 -0
  9. package/runtime/materials/terrain.mat +36 -55
  10. package/runtime/sdk-types.json +1 -1
  11. package/runtime/web/VERSION.json +5 -0
  12. package/runtime/web/assets/camera-5XOM8eHl.js +1 -0
  13. package/runtime/web/assets/creator-2d-D9WKYM6h.js +1 -0
  14. package/runtime/web/assets/creator-2d-ukvEmNEp.wasm +0 -0
  15. package/runtime/web/assets/creator-full-BBXzQlhq.js +1 -0
  16. package/runtime/web/assets/creator-full-D2dSDdDj.wasm +0 -0
  17. package/runtime/web/assets/creator-full.js-BYswGsqR.symbols +8124 -0
  18. package/runtime/web/assets/creator-ui-DIc92ion.wasm +0 -0
  19. package/runtime/web/assets/creator-ui-_1Z_AX2M.js +1 -0
  20. package/runtime/web/assets/geolocation-BjTKN9_P.js +1 -0
  21. package/runtime/web/assets/inspect-C7x78PNs.js +2 -0
  22. package/runtime/web/assets/map-CyBxOSV0.js +1 -0
  23. package/runtime/web/assets/map-XzoOFt0E.js +2 -0
  24. package/runtime/web/assets/mapImpl-bxvQ2h1Z.js +823 -0
  25. package/runtime/web/assets/maplibre-gl-worker-B7LXXUkR.js +8 -0
  26. package/runtime/web/assets/materials-uri4FvPE.bin +0 -0
  27. package/runtime/web/assets/materials-world-CQWt8TAm.bin +0 -0
  28. package/runtime/web/assets/qr-scanner-CK_cYoJR.js +1 -0
  29. package/runtime/web/assets/uberarchive-DLH-kBhM.bin +0 -0
  30. package/runtime/web/index.html +36 -0
  31. package/runtime/web/player.js +21 -0
  32. package/runtime/web-page/index.html +41 -0
  33. package/runtime/web-page/page.css +1 -0
  34. package/runtime/web-page/page.js +31 -0
  35. package/src/api.ts +1 -302
  36. package/src/cli/args.ts +37 -0
  37. package/src/cli/command.ts +113 -0
  38. package/src/cli/errors.ts +13 -0
  39. package/src/cli/help.ts +158 -0
  40. package/src/cli/index.ts +7 -0
  41. package/src/cli/output.ts +102 -0
  42. package/src/cli/run.ts +106 -0
  43. package/src/commands/{appAndroid.ts → app/android.ts} +454 -43
  44. package/src/commands/{appDesktop.ts → app/desktop.ts} +64 -33
  45. package/src/commands/{appDesktopMac.ts → app/desktopMac.ts} +34 -27
  46. package/src/commands/{appIcon.ts → app/icon.ts} +11 -8
  47. package/src/commands/{app.ts → app/index.ts} +400 -290
  48. package/src/commands/{appShared.ts → app/shared.ts} +43 -44
  49. package/src/commands/{appTemplatesAndroid.ts → app/templates/android.ts} +72 -39
  50. package/src/commands/{appTemplatesGradlew.ts → app/templates/gradlew.ts} +1 -1
  51. package/src/commands/{appTemplates.ts → app/templates/ios.ts} +2 -2
  52. package/src/commands/assets.ts +23 -21
  53. package/src/commands/clone.ts +48 -51
  54. package/src/commands/compile.ts +76 -89
  55. package/src/commands/create.ts +46 -47
  56. package/src/commands/design/comments.ts +361 -0
  57. package/src/commands/design/context.ts +101 -0
  58. package/src/commands/design/index.ts +391 -0
  59. package/src/commands/design/snapshot.ts +134 -0
  60. package/src/commands/{designTemplates.ts → design/templates.ts} +4 -3
  61. package/src/commands/desktop.ts +94 -82
  62. package/src/commands/dev.ts +260 -182
  63. package/src/commands/diff.ts +39 -51
  64. package/src/commands/index.ts +64 -0
  65. package/src/commands/{init.ts → init/index.ts} +195 -191
  66. package/src/commands/{projectTemplates.ts → init/templates.ts} +64 -21
  67. package/src/commands/install.ts +80 -67
  68. package/src/commands/lightmap.ts +543 -252
  69. package/src/commands/link.ts +87 -94
  70. package/src/commands/login.ts +25 -23
  71. package/src/commands/navmesh.ts +169 -139
  72. package/src/commands/plugin.ts +46 -0
  73. package/src/commands/pn.ts +201 -244
  74. package/src/commands/pull.ts +49 -56
  75. package/src/commands/push.ts +48 -54
  76. package/src/commands/render.ts +176 -111
  77. package/src/commands/scene.ts +63 -55
  78. package/src/commands/serve.ts +58 -0
  79. package/src/commands/{shaders.ts → shaders/index.ts} +61 -42
  80. package/src/commands/{shadersNew.ts → shaders/new.ts} +3 -3
  81. package/src/commands/shared.ts +79 -0
  82. package/src/commands/status.ts +22 -26
  83. package/src/commands/test.ts +94 -63
  84. package/src/commands/thumbs.ts +185 -175
  85. package/src/commands/update/index.ts +226 -0
  86. package/src/commands/{types.ts → update/types.ts} +31 -22
  87. package/src/compile/collect.ts +5 -5
  88. package/src/compile/collectLocal.ts +3 -3
  89. package/src/compile/designCompile.ts +4 -4
  90. package/src/compile/headlessBundle.ts +16 -12
  91. package/src/compile/nativeStack.ts +1 -1
  92. package/src/compile/projectCompile.ts +12 -4
  93. package/src/compile/sceneCompile.ts +14 -26
  94. package/src/compile/screenEntry.ts +123 -127
  95. package/src/compile/shaders.ts +3 -3
  96. package/src/{lecodes-3d-editor.d.ts → declarations/lecodes-3d-editor.d.ts} +4 -0
  97. package/src/{lecodes-assets.d.ts → declarations/lecodes-assets.d.ts} +1 -1
  98. package/src/{lecodes-renderer.d.ts → declarations/lecodes-headless.d.ts} +3 -3
  99. package/src/dev/androidDev.ts +1 -1
  100. package/src/dev/clientTemplates.ts +18 -14
  101. package/src/dev/devServer.ts +78 -41
  102. package/src/dev/iosDev.ts +180 -0
  103. package/src/dev/webRunner.ts +83 -15
  104. package/src/{cmgenTool.ts → hosts/cmgenTool.ts} +2 -2
  105. package/src/{desktopRenderer.ts → hosts/desktopRenderer.ts} +104 -32
  106. package/src/{desktopScript.ts → hosts/desktopScript.ts} +151 -36
  107. package/src/{distRoot.ts → hosts/distRoot.ts} +9 -5
  108. package/src/{matcTool.ts → hosts/matcTool.ts} +2 -2
  109. package/src/{peerInstall.ts → hosts/peerInstall.ts} +7 -7
  110. package/src/{peers.ts → hosts/peers.ts} +2 -2
  111. package/src/{releases.ts → hosts/releases.ts} +1 -1
  112. package/src/index.ts +35 -480
  113. package/src/platform/api.ts +302 -0
  114. package/src/{browserAuth.ts → platform/browserAuth.ts} +1 -1
  115. package/src/{config.ts → platform/config.ts} +1 -1
  116. package/src/{serverDiff.ts → platform/serverDiff.ts} +3 -3
  117. package/src/plugin/contract.ts +438 -0
  118. package/src/plugin/emitKotlin.ts +357 -0
  119. package/src/plugin/emitSdk.ts +259 -0
  120. package/src/plugin/emitSwift.ts +336 -0
  121. package/src/plugin/emitWeb.ts +383 -0
  122. package/src/plugin/generate.ts +157 -0
  123. package/src/plugin/lines.ts +28 -0
  124. package/src/plugin/lower.ts +231 -0
  125. package/src/plugin/manifest.ts +192 -0
  126. package/src/{projectEnv.ts → project/env.ts} +33 -2
  127. package/src/{ignore.ts → project/ignore.ts} +164 -163
  128. package/src/{localFiles.ts → project/localFiles.ts} +1 -1
  129. package/src/{manifest.ts → project/manifest.ts} +1 -1
  130. package/src/{project.ts → project/materialize.ts} +2 -2
  131. package/src/project/paths.ts +20 -0
  132. package/src/{textDiff.ts → project/textDiff.ts} +1 -1
  133. package/src/{types.ts → project/types.ts} +0 -0
  134. package/runtime/materials/lightmap-baked-lite.mat +0 -175
  135. package/runtime/materials/lightmap-baked.mat +0 -176
  136. package/runtime/scene-harness.json +0 -1
  137. package/runtime/web/assets/__vite-browser-external-BIHI7g3E.js +0 -1
  138. package/runtime/web/assets/basis-C64VHDVD.js +0 -1
  139. package/runtime/web/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
  140. package/runtime/web/assets/createViewerLite-Ct_PZdof.js +0 -852
  141. package/runtime/web/assets/draco-BiISTFcR.js +0 -118
  142. package/runtime/web/assets/draco_decoder-DsQ12WqX.wasm +0 -0
  143. package/runtime/web/assets/index-BMt7AnC5.js +0 -2
  144. package/runtime/web/assets/mapViewImpl-B2JcES8l.js +0 -810
  145. package/runtime/web/assets/maplibre-gl-worker-CJfwIrte.js +0 -8
  146. package/runtime/web/assets/worker-Caf-yYEI.js +0 -2
  147. package/runtime/web/embed.html +0 -32
  148. package/runtime/web/embed.js +0 -171
  149. package/src/commands/design.ts +0 -846
  150. package/src/commands/update.ts +0 -211
  151. package/src/util.ts +0 -146
  152. /package/src/{lecodes-design.d.ts → declarations/lecodes-design.d.ts} +0 -0
  153. /package/src/{qrcode-terminal.d.ts → declarations/qrcode-terminal.d.ts} +0 -0
  154. /package/src/{designMeta.ts → project/designMeta.ts} +0 -0
@@ -8,9 +8,9 @@ import {
8
8
  matcArgs, MATC_PLATFORMS, TARGET_MATRIX_GENERATION,
9
9
  type AnyMatcPlatform, type AnyShaderPlatform,
10
10
  } from "lecodes-sdk/compile"
11
- import { resolveMatcExe, resolveMatcExeCached } from "../matcTool"
12
- import { LECODES_DIR } from "../manifest"
13
- import { CliError } from "../util"
11
+ import { resolveMatcExe, resolveMatcExeCached } from "../hosts/matcTool"
12
+ import { LECODES_DIR } from "../project/manifest"
13
+ import { CliError } from "../cli"
14
14
  import type { CompileEntry } from "./projectCompile"
15
15
 
16
16
  /*
@@ -24,6 +24,10 @@ declare module "lecodes-3d-editor/server" {
24
24
 
25
25
  export function startSceneServer(options: SceneServerOptions): Promise<SceneServer>
26
26
 
27
+ /** The editor's harness — its half that runs in the scene's world — as module name → source:
28
+ * what the compile closure hands to the SDK's `sceneEditorEntries`. */
29
+ export function sceneHarness(): Record<string, string>
30
+
27
31
  /* The asset-preview cache (server/previewCache.ts) — `lecodes thumbs` writes into the same
28
32
  * `.lecodes/preview/<asset>.<hash>.png` layout the editor reads. */
29
33
 
@@ -1,6 +1,6 @@
1
1
  // Types for the optional `lecodes-assets` package's CLI entry (lazy-loaded by the `assets` command).
2
2
  // The real package is installed separately; this lets the dynamic import typecheck without it
3
- // present. Keep in sync with packages/lecodes-assets/src/cli.ts.
3
+ // present. Keep in sync with tools/assets/src/cli.ts.
4
4
  declare module "lecodes-assets/cli" {
5
5
  /** Runs `convert | probe | doctor` with the given argv (sub-command first). Resolves to the exit code. */
6
6
  export function runAssetsCli(argv: string[]): Promise<number>
@@ -1,7 +1,7 @@
1
- // Types for the optional `lecodes-renderer` package's node headless entry (lazy-loaded by the
1
+ // Types for the optional `lecodes-headless` package's node headless entry (lazy-loaded by the
2
2
  // `render` command). The real package is installed separately; this lets the dynamic import
3
3
  // typecheck without it present.
4
- declare module "lecodes-renderer/headless" {
4
+ declare module "lecodes-headless/headless" {
5
5
  export type RenderOptions = {
6
6
  width?: number
7
7
  height?: number
@@ -21,7 +21,7 @@ declare module "lecodes-renderer/headless" {
21
21
  safeArea?: [number, number, number, number]
22
22
  /** Receive the project's console.* output (unset → swallowed). */
23
23
  onConsole?: (level: string, text: string) => void
24
- /** Canned `_creatorUtils.fetch` responses (URL pattern → { status?, json?, text? }); they beat
24
+ /** Canned `_creatorFetch.fetch` responses (URL pattern → { status?, json?, text? }); they beat
25
25
  * every other fetch path, including waitNetwork. */
26
26
  fixtures?: Fixtures
27
27
  }
@@ -1,7 +1,7 @@
1
1
  import { existsSync, writeFileSync } from "node:fs"
2
2
  import { join } from "node:path"
3
3
  import { spawnSync } from "node:child_process"
4
- import { CliError, note, warnErr } from "../util"
4
+ import { CliError, note, warnErr } from "../cli"
5
5
 
6
6
  /*
7
7
  * `lecodes dev --android` — open the dev URL in the LeCodes app on the connected phone, through adb.
@@ -7,9 +7,9 @@
7
7
  * header says `// gl: disable`, so the app launcher creates no engine for it.
8
8
  *
9
9
  * - DEV_PRELUDE_JS — plain JS appended VERBATIM to every bundle the dev server serves (dev.js and
10
- * bundle.js alike). It only touches the raw `_creatorUtils` / `_creator` bridge — no SDK — so it
11
- * can ride along any compiled project without duplicating the injected runtime. Each
12
- * `_creatorUtils.run()` world therefore carries its own copy: the running world holds the live
10
+ * bundle.js alike). It only touches the raw `_creatorFetch` / `_creatorSocket` / `_creatorApp` /
11
+ * `_creator` bridge — no SDK — so it can ride along any compiled project without duplicating the
12
+ * injected runtime. Each `_creatorApp.run()` world therefore carries its own copy: the running world holds the live
13
13
  * connection; when it loads the next bundle, the NEW world's copy connects and the native
14
14
  * per-world socket teardown closes the old one. On eval error, run()'s rollback keeps the
15
15
  * current world (and its connection) alive, so the reject callback here reports + keeps serving.
@@ -33,6 +33,11 @@ UIScreen([
33
33
 
34
34
  export const DEV_PRELUDE_JS = `
35
35
  ;(function () {
36
+ // The raw bridges (sdk/src/bridges): fetch's onComplete hands an OWNED handle ({ id }) whose
37
+ // body is freed when the handle is collected — the readers / run take the NUMBER (handle.id).
38
+ var F = _creatorFetch
39
+ var S = _creatorSocket
40
+ var A = _creatorApp
36
41
  var ORIGIN = "__DEV_ORIGIN__"
37
42
  var BASE = "__DEV_BASE__"
38
43
  var TRANSPORT = "__DEV_TRANSPORT__"
@@ -44,12 +49,12 @@ export const DEV_PRELUDE_JS = `
44
49
  function post(obj) {
45
50
  var text = JSON.stringify(obj)
46
51
  if (TRANSPORT === "ws") {
47
- if (wsId !== null) { try { _creatorUtils.websocketSend(wsId, text) } catch (e) {} }
52
+ if (wsId !== null) { try { S.send(wsId, text) } catch (e) {} }
48
53
  return
49
54
  }
50
55
  try {
51
- _creatorUtils.fetch(ORIGIN + BASE + "/log", { method: "POST", body: text, headers: { "content-type": "application/json" } },
52
- function (id) { try { _creatorUtils.disposeFetch(id) } catch (e) {} }, function () {})
56
+ F.fetch(ORIGIN + BASE + "/log", { method: "POST", body: text, headers: { "content-type": "application/json" } },
57
+ function () {}, function () {})
53
58
  } catch (e) {}
54
59
  }
55
60
 
@@ -80,16 +85,16 @@ export const DEV_PRELUDE_JS = `
80
85
  function load(v) {
81
86
  if (busy) { queued = v; return }
82
87
  busy = true
83
- _creatorUtils.fetch(ORIGIN + BASE + "/bundle.js?v=" + v + "&t=" + TRANSPORT, {}, function (id, status) {
88
+ F.fetch(ORIGIN + BASE + "/bundle.js?v=" + v + "&t=" + TRANSPORT, {}, function (body, status) {
84
89
  if (status >= 400) { busy = false; report("bundle fetch: HTTP " + status); return }
85
90
  // Same engine bringup the app launcher does for a fresh launch (see desktop initial.ts).
86
91
  var head = ""
87
- try { head = _creatorUtils.fetchSlice(id, 0, 300) } catch (e) {}
92
+ try { head = F.slice(body.id, 0, 300) } catch (e) {}
88
93
  var preloads = head.match(/\\/\\/ preload: (.+)/)
89
94
  if (head.indexOf("// gl:") < 0) {
90
95
  try { _creator.createEngine(preloads ? preloads[1].split(";") : []) } catch (e) { report("createEngine: " + String(e)) }
91
96
  }
92
- _creatorUtils.run(id, function (err) {
97
+ A.run(body.id, function (err) {
93
98
  // Eval failed → native rollback kept THIS world (and its connection) alive. Keep serving.
94
99
  busy = false
95
100
  report(err)
@@ -109,9 +114,9 @@ export const DEV_PRELUDE_JS = `
109
114
  var url = ORIGIN.replace("http", "ws") + BASE + "/ws"
110
115
  var open = function () {
111
116
  try {
112
- wsId = _creatorUtils.websocketOpen(url, function (channel, data) {
117
+ wsId = S.open(url, function (channel, data) {
113
118
  if (channel === "open") {
114
- try { _creatorUtils.websocketSend(wsId, JSON.stringify({ type: "hello", v: V })) } catch (e) {}
119
+ try { S.send(wsId, JSON.stringify({ type: "hello", v: V })) } catch (e) {}
115
120
  } else if (channel === "message") {
116
121
  onServer(data)
117
122
  } else if (channel === "close" || channel === "error") {
@@ -124,10 +129,9 @@ export const DEV_PRELUDE_JS = `
124
129
  open()
125
130
  } else {
126
131
  var poll = function () {
127
- _creatorUtils.fetch(ORIGIN + BASE + "/version", {}, function (id, status) {
132
+ F.fetch(ORIGIN + BASE + "/version", {}, function (body, status) {
128
133
  var v = null
129
- try { v = _creatorUtils.fetchToJson(id).v } catch (e) {}
130
- try { _creatorUtils.disposeFetch(id) } catch (e) {}
134
+ try { v = F.toJson(body.id).v } catch (e) {}
131
135
  setTimeout(poll, 700)
132
136
  if (status < 400 && typeof v === "number" && v !== V && !busy) load(v)
133
137
  }, function () { setTimeout(poll, 1500) })
@@ -1,8 +1,8 @@
1
1
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"
2
2
  import { createReadStream, statSync } from "node:fs"
3
3
  import { join } from "node:path"
4
- import type { Manifest } from "../manifest"
5
- import { resolveLocalPath } from "../localFiles"
4
+ import type { Manifest } from "../project/manifest"
5
+ import { resolveLocalPath } from "../project/localFiles"
6
6
  import { collectLocalEntries } from "../compile/collectLocal"
7
7
  import { applyLocalShaders, isShaderArtifactName, shadersCacheDir } from "../compile/shaders"
8
8
  import { ambiguousEntryWarning, compileProject } from "../compile/projectCompile"
@@ -10,8 +10,9 @@ import { applyProjectFonts } from "../compile/fonts"
10
10
  import { applyAppAssetIcons } from "../compile/assetIcons"
11
11
  import { renderDevScreen, renderPrelude } from "./clientTemplates"
12
12
  import { upgradeWebSocket, type WsConnection } from "./wsServer"
13
- import { toKey } from "../util"
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
+ }