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