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.
- package/README.md +56 -57
- package/dist/index.js +11052 -7592
- package/package.json +14 -8
- package/runtime/materials/decal-relief.mat +12 -1
- package/runtime/materials/decal.mat +17 -3
- package/runtime/materials/lightmap.mat +117 -138
- package/runtime/materials/particles-quad.mat +6 -2
- package/runtime/materials/terrain-lightmap.mat +123 -0
- package/runtime/materials/terrain.mat +36 -55
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/VERSION.json +5 -0
- package/runtime/web/assets/camera-5XOM8eHl.js +1 -0
- package/runtime/web/assets/creator-2d-D9WKYM6h.js +1 -0
- package/runtime/web/assets/creator-2d-ukvEmNEp.wasm +0 -0
- package/runtime/web/assets/creator-full-BBXzQlhq.js +1 -0
- package/runtime/web/assets/creator-full-D2dSDdDj.wasm +0 -0
- package/runtime/web/assets/creator-full.js-BYswGsqR.symbols +8124 -0
- package/runtime/web/assets/creator-ui-DIc92ion.wasm +0 -0
- package/runtime/web/assets/creator-ui-_1Z_AX2M.js +1 -0
- package/runtime/web/assets/geolocation-BjTKN9_P.js +1 -0
- package/runtime/web/assets/inspect-C7x78PNs.js +2 -0
- package/runtime/web/assets/map-CyBxOSV0.js +1 -0
- package/runtime/web/assets/map-XzoOFt0E.js +2 -0
- package/runtime/web/assets/mapImpl-bxvQ2h1Z.js +823 -0
- package/runtime/web/assets/maplibre-gl-worker-B7LXXUkR.js +8 -0
- package/runtime/web/assets/materials-uri4FvPE.bin +0 -0
- package/runtime/web/assets/materials-world-CQWt8TAm.bin +0 -0
- package/runtime/web/assets/qr-scanner-CK_cYoJR.js +1 -0
- package/runtime/web/assets/uberarchive-DLH-kBhM.bin +0 -0
- package/runtime/web/index.html +36 -0
- package/runtime/web/player.js +21 -0
- package/runtime/web-page/index.html +41 -0
- package/runtime/web-page/page.css +1 -0
- package/runtime/web-page/page.js +31 -0
- package/src/api.ts +1 -302
- package/src/cli/args.ts +37 -0
- package/src/cli/command.ts +113 -0
- package/src/cli/errors.ts +13 -0
- package/src/cli/help.ts +158 -0
- package/src/cli/index.ts +7 -0
- package/src/cli/output.ts +102 -0
- package/src/cli/run.ts +106 -0
- package/src/commands/{appAndroid.ts → app/android.ts} +454 -43
- package/src/commands/{appDesktop.ts → app/desktop.ts} +64 -33
- package/src/commands/{appDesktopMac.ts → app/desktopMac.ts} +34 -27
- package/src/commands/{appIcon.ts → app/icon.ts} +11 -8
- package/src/commands/{app.ts → app/index.ts} +400 -290
- package/src/commands/{appShared.ts → app/shared.ts} +43 -44
- package/src/commands/{appTemplatesAndroid.ts → app/templates/android.ts} +72 -39
- package/src/commands/{appTemplatesGradlew.ts → app/templates/gradlew.ts} +1 -1
- package/src/commands/{appTemplates.ts → app/templates/ios.ts} +2 -2
- package/src/commands/assets.ts +23 -21
- package/src/commands/clone.ts +48 -51
- package/src/commands/compile.ts +76 -89
- package/src/commands/create.ts +46 -47
- package/src/commands/design/comments.ts +361 -0
- package/src/commands/design/context.ts +101 -0
- package/src/commands/design/index.ts +391 -0
- package/src/commands/design/snapshot.ts +134 -0
- package/src/commands/{designTemplates.ts → design/templates.ts} +4 -3
- package/src/commands/desktop.ts +94 -82
- package/src/commands/dev.ts +260 -182
- package/src/commands/diff.ts +39 -51
- package/src/commands/index.ts +64 -0
- package/src/commands/{init.ts → init/index.ts} +195 -191
- package/src/commands/{projectTemplates.ts → init/templates.ts} +64 -21
- package/src/commands/install.ts +80 -67
- package/src/commands/lightmap.ts +543 -252
- package/src/commands/link.ts +87 -94
- package/src/commands/login.ts +25 -23
- package/src/commands/navmesh.ts +169 -139
- package/src/commands/plugin.ts +46 -0
- package/src/commands/pn.ts +201 -244
- package/src/commands/pull.ts +49 -56
- package/src/commands/push.ts +48 -54
- package/src/commands/render.ts +176 -111
- package/src/commands/scene.ts +63 -55
- package/src/commands/serve.ts +58 -0
- package/src/commands/{shaders.ts → shaders/index.ts} +61 -42
- package/src/commands/{shadersNew.ts → shaders/new.ts} +3 -3
- package/src/commands/shared.ts +79 -0
- package/src/commands/status.ts +22 -26
- package/src/commands/test.ts +94 -63
- package/src/commands/thumbs.ts +185 -175
- package/src/commands/update/index.ts +226 -0
- package/src/commands/{types.ts → update/types.ts} +31 -22
- package/src/compile/collect.ts +5 -5
- package/src/compile/collectLocal.ts +3 -3
- package/src/compile/designCompile.ts +4 -4
- package/src/compile/headlessBundle.ts +16 -12
- package/src/compile/nativeStack.ts +1 -1
- package/src/compile/projectCompile.ts +12 -4
- package/src/compile/sceneCompile.ts +14 -26
- package/src/compile/screenEntry.ts +123 -127
- package/src/compile/shaders.ts +3 -3
- package/src/{lecodes-3d-editor.d.ts → declarations/lecodes-3d-editor.d.ts} +4 -0
- package/src/{lecodes-assets.d.ts → declarations/lecodes-assets.d.ts} +1 -1
- package/src/{lecodes-renderer.d.ts → declarations/lecodes-headless.d.ts} +3 -3
- package/src/dev/androidDev.ts +1 -1
- package/src/dev/clientTemplates.ts +18 -14
- package/src/dev/devServer.ts +78 -41
- package/src/dev/iosDev.ts +180 -0
- package/src/dev/webRunner.ts +83 -15
- package/src/{cmgenTool.ts → hosts/cmgenTool.ts} +2 -2
- package/src/{desktopRenderer.ts → hosts/desktopRenderer.ts} +104 -32
- package/src/{desktopScript.ts → hosts/desktopScript.ts} +151 -36
- package/src/{distRoot.ts → hosts/distRoot.ts} +9 -5
- package/src/{matcTool.ts → hosts/matcTool.ts} +2 -2
- package/src/{peerInstall.ts → hosts/peerInstall.ts} +7 -7
- package/src/{peers.ts → hosts/peers.ts} +2 -2
- package/src/{releases.ts → hosts/releases.ts} +1 -1
- package/src/index.ts +35 -480
- package/src/platform/api.ts +302 -0
- package/src/{browserAuth.ts → platform/browserAuth.ts} +1 -1
- package/src/{config.ts → platform/config.ts} +1 -1
- package/src/{serverDiff.ts → platform/serverDiff.ts} +3 -3
- package/src/plugin/contract.ts +438 -0
- package/src/plugin/emitKotlin.ts +357 -0
- package/src/plugin/emitSdk.ts +259 -0
- package/src/plugin/emitSwift.ts +336 -0
- package/src/plugin/emitWeb.ts +383 -0
- package/src/plugin/generate.ts +157 -0
- package/src/plugin/lines.ts +28 -0
- package/src/plugin/lower.ts +231 -0
- package/src/plugin/manifest.ts +192 -0
- package/src/{projectEnv.ts → project/env.ts} +33 -2
- package/src/{ignore.ts → project/ignore.ts} +164 -163
- package/src/{localFiles.ts → project/localFiles.ts} +1 -1
- package/src/{manifest.ts → project/manifest.ts} +1 -1
- package/src/{project.ts → project/materialize.ts} +2 -2
- package/src/project/paths.ts +20 -0
- package/src/{textDiff.ts → project/textDiff.ts} +1 -1
- package/src/{types.ts → project/types.ts} +0 -0
- package/runtime/materials/lightmap-baked-lite.mat +0 -175
- package/runtime/materials/lightmap-baked.mat +0 -176
- package/runtime/scene-harness.json +0 -1
- package/runtime/web/assets/__vite-browser-external-BIHI7g3E.js +0 -1
- package/runtime/web/assets/basis-C64VHDVD.js +0 -1
- package/runtime/web/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
- package/runtime/web/assets/createViewerLite-Ct_PZdof.js +0 -852
- package/runtime/web/assets/draco-BiISTFcR.js +0 -118
- package/runtime/web/assets/draco_decoder-DsQ12WqX.wasm +0 -0
- package/runtime/web/assets/index-BMt7AnC5.js +0 -2
- package/runtime/web/assets/mapViewImpl-B2JcES8l.js +0 -810
- package/runtime/web/assets/maplibre-gl-worker-CJfwIrte.js +0 -8
- package/runtime/web/assets/worker-Caf-yYEI.js +0 -2
- package/runtime/web/embed.html +0 -32
- package/runtime/web/embed.js +0 -171
- package/src/commands/design.ts +0 -846
- package/src/commands/update.ts +0 -211
- package/src/util.ts +0 -146
- /package/src/{lecodes-design.d.ts → declarations/lecodes-design.d.ts} +0 -0
- /package/src/{qrcode-terminal.d.ts → declarations/qrcode-terminal.d.ts} +0 -0
- /package/src/{designMeta.ts → project/designMeta.ts} +0 -0
package/src/compile/shaders.ts
CHANGED
|
@@ -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 "../
|
|
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
|
|
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-
|
|
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-
|
|
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 `
|
|
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
|
}
|
package/src/dev/androidDev.ts
CHANGED
|
@@ -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 "../
|
|
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 `
|
|
11
|
-
* can ride along any compiled project without duplicating the
|
|
12
|
-
* `
|
|
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 {
|
|
52
|
+
if (wsId !== null) { try { S.send(wsId, text) } catch (e) {} }
|
|
48
53
|
return
|
|
49
54
|
}
|
|
50
55
|
try {
|
|
51
|
-
|
|
52
|
-
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
117
|
+
wsId = S.open(url, function (channel, data) {
|
|
113
118
|
if (channel === "open") {
|
|
114
|
-
try {
|
|
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
|
-
|
|
132
|
+
F.fetch(ORIGIN + BASE + "/version", {}, function (body, status) {
|
|
128
133
|
var v = null
|
|
129
|
-
try { v =
|
|
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) })
|
package/src/dev/devServer.ts
CHANGED
|
@@ -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 "../
|
|
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
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
-
/**
|
|
48
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
if (
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
res.
|
|
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
|
-
|
|
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:
|
|
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
|
+
}
|