lecodes-cli 0.19.2 → 0.20.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/README.md +1 -0
- package/dist/index.js +1324 -658
- package/package.json +9 -9
- package/runtime/materials/decal-relief.mat +178 -0
- package/runtime/materials/decal.mat +172 -0
- package/runtime/materials/lightmap-baked-lite.mat +175 -0
- package/runtime/materials/lightmap-baked.mat +176 -0
- package/runtime/materials/lightmap.mat +181 -0
- package/runtime/materials/lit.mat +43 -0
- package/runtime/materials/particles-quad.mat +171 -0
- package/runtime/materials/particles.mat +121 -0
- package/runtime/materials/shadow.mat +21 -0
- package/runtime/materials/terrain.mat +134 -0
- package/runtime/materials/unlit-transparent.mat +42 -0
- package/runtime/materials/unlit.mat +44 -0
- package/runtime/materials/video.mat +31 -0
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/assets/Roboto-Variable-DHm9jpd-.woff2 +0 -0
- package/runtime/web/assets/__vite-browser-external-BIHI7g3E.js +1 -0
- package/runtime/web/assets/basis-C64VHDVD.js +1 -0
- package/runtime/web/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
- package/runtime/web/assets/createViewerLite-C_bkKXLg.js +852 -0
- package/runtime/web/assets/draco-BiISTFcR.js +118 -0
- package/runtime/web/assets/draco_decoder-DsQ12WqX.wasm +0 -0
- package/runtime/web/assets/index-BTB5KV9x.js +2 -0
- package/runtime/web/assets/mapViewImpl-B2JcES8l.js +810 -0
- package/runtime/web/assets/maplibre-gl-worker-CJfwIrte.js +8 -0
- package/runtime/web/assets/neutral_ibl128-D-lzdkLN.ktx +0 -0
- package/runtime/web/assets/worker-Caf-yYEI.js +2 -0
- package/runtime/web/embed.html +32 -0
- package/runtime/web/embed.js +171 -0
- package/src/cmgenTool.ts +17 -9
- package/src/commands/app.ts +6 -1
- package/src/commands/appDesktop.ts +27 -4
- package/src/commands/appShared.ts +145 -3
- package/src/commands/appTemplates.ts +28 -4
- package/src/commands/assets.ts +3 -7
- package/src/commands/design.ts +9 -26
- package/src/commands/desktop.ts +9 -1
- package/src/commands/dev.ts +64 -0
- package/src/commands/projectTemplates.ts +17 -4
- package/src/commands/render.ts +9 -11
- package/src/commands/scene.ts +2 -6
- package/src/commands/shaders.ts +17 -1
- package/src/commands/shadersNew.ts +133 -0
- package/src/commands/test.ts +2 -1
- package/src/commands/thumbs.ts +3 -6
- package/src/commands/update.ts +42 -21
- package/src/desktopRenderer.ts +32 -2
- package/src/desktopScript.ts +1 -1
- package/src/dev/androidDev.ts +154 -0
- package/src/dev/devServer.ts +53 -6
- package/src/dev/webRunner.ts +22 -0
- package/src/index.ts +24 -4
- package/src/matcTool.ts +31 -19
- package/src/peerInstall.ts +160 -0
- package/src/peers.ts +48 -14
- package/src/projectEnv.ts +4 -0
- package/src/releases.ts +16 -0
package/src/cmgenTool.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readdirSync } from "node:fs"
|
|
2
2
|
import { homedir } from "node:os"
|
|
3
3
|
import { join, resolve } from "node:path"
|
|
4
|
-
import { downloadReleaseArchive, fetchReleaseProduct, semverNewer, type ManifestVersion } from "./releases"
|
|
4
|
+
import { downloadReleaseArchive, fetchReleaseProduct, newestForPlatform, semverNewer, type ManifestFile, type ManifestVersion } from "./releases"
|
|
5
5
|
import { CliError, note } from "./util"
|
|
6
6
|
|
|
7
7
|
/*
|
|
@@ -20,7 +20,8 @@ import { CliError, note } from "./util"
|
|
|
20
20
|
* stays network-free and still works standalone against a local build.
|
|
21
21
|
*
|
|
22
22
|
* No version pin, same reasoning as matc: the KTX1 layout is tied to the vendored Filament and
|
|
23
|
-
* changes rarely, so resolution takes the newest cached copy, else downloads
|
|
23
|
+
* changes rarely, so resolution takes the newest cached copy, else downloads the newest release
|
|
24
|
+
* PUBLISHED FOR THIS PLATFORM (see releases.ts newestForPlatform).
|
|
24
25
|
*/
|
|
25
26
|
|
|
26
27
|
const exeName = () => (process.platform === "win32" ? "cmgen.exe" : "cmgen")
|
|
@@ -63,15 +64,21 @@ export const installedCmgenVersions = (): string[] => {
|
|
|
63
64
|
export const fetchCmgenManifest = (): Promise<{ latest: string, versions: ManifestVersion[] }> =>
|
|
64
65
|
fetchReleaseProduct("cmgen", "cmgen (environment tool)")
|
|
65
66
|
|
|
66
|
-
|
|
67
|
+
/** The published cmgen this platform should get: the newest version carrying a build for it (see
|
|
68
|
+
* newestForPlatform — a release often ships one platform, so `latest` is the wrong question). */
|
|
69
|
+
const pickCmgen = (versions: ManifestVersion[]): { version: string, file: ManifestFile } => {
|
|
67
70
|
const key = platformKey()
|
|
68
|
-
const
|
|
69
|
-
if (!
|
|
71
|
+
const hit = newestForPlatform(versions, key)
|
|
72
|
+
if (!hit) {
|
|
70
73
|
throw new CliError(
|
|
71
|
-
`cmgen
|
|
74
|
+
`cmgen isn't published for ${key} — build filament's host tools locally and point ` +
|
|
72
75
|
`LECODES_CMGEN at the binary.`,
|
|
73
76
|
)
|
|
74
77
|
}
|
|
78
|
+
return hit
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const downloadCmgen = async (version: string, file: ManifestFile, dest: string): Promise<void> => {
|
|
75
82
|
note(`Downloading the environment tool (cmgen ${version}, ${(file.size / 1024 / 1024).toFixed(1)} MB)…`)
|
|
76
83
|
await downloadReleaseArchive(file, {
|
|
77
84
|
cacheRoot: cacheRoot(),
|
|
@@ -95,9 +102,10 @@ export const resolveCmgenExeCached = (): string | null => {
|
|
|
95
102
|
export const resolveCmgenExe = async (): Promise<string> => {
|
|
96
103
|
const cached = resolveCmgenExeCached()
|
|
97
104
|
if (cached) return cached
|
|
98
|
-
const {
|
|
99
|
-
const
|
|
100
|
-
|
|
105
|
+
const { versions } = await fetchCmgenManifest()
|
|
106
|
+
const { version, file } = pickCmgen(versions)
|
|
107
|
+
const dir = join(cacheRoot(), `cmgen-${version}`)
|
|
108
|
+
await downloadCmgen(version, file, dir)
|
|
101
109
|
const exe = findCmgenIn(dir)
|
|
102
110
|
if (!exe) throw new CliError(`cmgen cache at ${dir} is broken — delete the folder and retry.`)
|
|
103
111
|
return exe
|
package/src/commands/app.ts
CHANGED
|
@@ -291,7 +291,12 @@ const syncIos = async (root: string, config: AppConfig): Promise<SyncArtifacts>
|
|
|
291
291
|
for (const [key, value] of Object.entries(ios.entitlements ?? {})) {
|
|
292
292
|
(entitlementValues[key] ??= []).push(value)
|
|
293
293
|
}
|
|
294
|
-
|
|
294
|
+
for (const pkg of ios.packages ?? []) {
|
|
295
|
+
if (!pkg.url || !Array.isArray(pkg.products) || pkg.products.length === 0 || (!pkg.exact && !pkg.from) || (pkg.exact && pkg.from)) {
|
|
296
|
+
throw new CliError(`Plugin "${id}": manifest ios.packages entries need { url, exact | from, products: [...] } — got ${JSON.stringify(pkg)}`)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
runtimePlugins.push({ id, module, register, packages: ios.packages })
|
|
295
300
|
newLock[id] = plugin.lock
|
|
296
301
|
log(`${c.green("plugin")} ${id}@${plugin.lock.version} (${plugin.lock.tag} → Sources/${module})`)
|
|
297
302
|
}
|
|
@@ -4,7 +4,7 @@ import { basename, dirname, join, resolve } from "node:path"
|
|
|
4
4
|
import { buildBundle } from "./compile"
|
|
5
5
|
import { resolveDesktopExe, selectedDesktopVersion } from "../desktopRenderer"
|
|
6
6
|
import { CliError, c, flagBool, flagStr, log, note, success, warn, type Args } from "../util"
|
|
7
|
-
import { assertRendererSupportsBundle, projectRoot, readJson, resolveDesktopRenderer, writeJson, type AppConfig } from "./appShared"
|
|
7
|
+
import { assertRendererSupportsBundle, desktopPluginLibraries, projectRoot, readJson, resolveDesktopRenderer, writeJson, type AppConfig } from "./appShared"
|
|
8
8
|
import { loadIconMaster } from "./appIcon"
|
|
9
9
|
import { buildMacApp, macHostProvidedNames } from "./appDesktopMac"
|
|
10
10
|
|
|
@@ -199,8 +199,18 @@ export const buildDesktop = async (args: Args) => {
|
|
|
199
199
|
note(` assets: ${assetCount} files, ${mb(assetBytes)} → ${ASSETS_DIR}/`)
|
|
200
200
|
|
|
201
201
|
// 3) Pruned app.json — the host (≥1.3.0) reads name + desktop.window next to app.js, so the
|
|
202
|
-
// folder carries its own window config (title/size/min/resizable/fullscreen)
|
|
203
|
-
|
|
202
|
+
// folder carries its own window config (title/size/min/resizable/fullscreen); a newer host also
|
|
203
|
+
// reads desktop.mouseEmulateTouch + desktop.virtualKeyboard there (an older one skips the
|
|
204
|
+
// unknown fields).
|
|
205
|
+
const desktop = config.desktop
|
|
206
|
+
writeJson(join(out, "app.json"), {
|
|
207
|
+
name,
|
|
208
|
+
...(desktop ? { desktop: {
|
|
209
|
+
window: desktop.window,
|
|
210
|
+
...(desktop.mouseEmulateTouch !== undefined ? { mouseEmulateTouch: desktop.mouseEmulateTouch } : {}),
|
|
211
|
+
...(desktop.virtualKeyboard !== undefined ? { virtualKeyboard: desktop.virtualKeyboard } : {}),
|
|
212
|
+
} } : {}),
|
|
213
|
+
})
|
|
204
214
|
|
|
205
215
|
// 4) Window/taskbar icon (host ≥1.3.0 loads app.ico next to the exe at runtime).
|
|
206
216
|
if (config.icon) {
|
|
@@ -208,7 +218,20 @@ export const buildDesktop = async (args: Args) => {
|
|
|
208
218
|
note(" app.ico rendered from " + config.icon)
|
|
209
219
|
}
|
|
210
220
|
|
|
211
|
-
// 5)
|
|
221
|
+
// 5) Native plugins (packages/desktop/plugin-sdk/lecodes_plugin.h): the project's plugins/ dir +
|
|
222
|
+
// every declared plugin's desktop library → plugins/ next to the exe, where a host WITH the plugin
|
|
223
|
+
// loader (added after 1.4.0) scans at startup; an older host ignores the folder.
|
|
224
|
+
// A declared library that's missing is a build error (strict).
|
|
225
|
+
{
|
|
226
|
+
const { files } = desktopPluginLibraries(root, { strict: true })
|
|
227
|
+
if (files.length > 0) {
|
|
228
|
+
mkdirSync(join(out, "plugins"), { recursive: true })
|
|
229
|
+
for (const f of files) cpSync(f, join(out, "plugins", basename(f)))
|
|
230
|
+
note(` plugins: ${files.map((f) => basename(f)).join(", ")} → plugins/`)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// 6) A short README (BOM so double-click-Notepad shows it right).
|
|
212
235
|
writeFileSync(join(out, "README.txt"), "\uFEFF" + readmeText(name, exeName, version))
|
|
213
236
|
|
|
214
237
|
success(`Portable build: ${out} (${mb(dirSize(out))})`)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process"
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
|
|
3
3
|
import { homedir } from "node:os"
|
|
4
4
|
import { dirname, join, resolve } from "node:path"
|
|
5
5
|
import { findProjectRoot } from "../manifest"
|
|
@@ -16,6 +16,7 @@ import { CliError, c, flagStr, log, note, warn, type Args } from "../util"
|
|
|
16
16
|
export const FIRST_PARTY_PLUGINS: Record<string, string> = {
|
|
17
17
|
"camera": "letary/lecodes-plugins",
|
|
18
18
|
"qr-scanner": "letary/lecodes-plugins",
|
|
19
|
+
"map": "letary/lecodes-plugins",
|
|
19
20
|
"geolocation": "letary/lecodes-plugins",
|
|
20
21
|
"push": "letary/lecodes-plugins",
|
|
21
22
|
}
|
|
@@ -76,12 +77,60 @@ export interface AppConfig {
|
|
|
76
77
|
/** 3D buffer cap in px, e.g. [1920, 1080]: on a bigger window/monitor the 3D renders at most
|
|
77
78
|
* this size (aspect kept) — the per-machine answer to "fullscreen on a 4K display is slow". */
|
|
78
79
|
maxRenderSize?: [number, number],
|
|
80
|
+
/** The mouse button acts as a finger: a left click-drag scrolls + flings like touch (the wheel
|
|
81
|
+
* always scrolls, clicks are unaffected; a button inside a scrollable then gets the touch-style
|
|
82
|
+
* press feedback delay). For testing the touch experience without a touchscreen, or an app
|
|
83
|
+
* driven by a mouse-like device. `desktop run` forwards it as CREATOR_MOUSE_TOUCH (a set env
|
|
84
|
+
* wins); a built folder's host reads it from the staged app.json. Headless render/test ignore it. */
|
|
85
|
+
mouseEmulateTouch?: boolean,
|
|
86
|
+
/** On-screen keyboard for EVERY input/textarea (touch kiosks, a screen without a keyboard).
|
|
87
|
+
* `true` or a block; `desktop run` forwards it as CREATOR_VIRTUAL_KEYBOARD (a set env wins),
|
|
88
|
+
* a built folder's host reads it from the staged app.json. Headless render/test ignore it. */
|
|
89
|
+
virtualKeyboard?: boolean | DesktopVirtualKeyboard,
|
|
79
90
|
/** The macOS .app build (`lecodes app build desktop` on a Mac) — identity + signing +
|
|
80
91
|
* notarization. See appDesktopMac.ts for the whole story. */
|
|
81
92
|
macos?: MacDesktopConfig,
|
|
82
93
|
}
|
|
83
94
|
}
|
|
84
95
|
|
|
96
|
+
/** One style slot of the desktop on-screen keyboard — the SDK's style words, the subset the drawn
|
|
97
|
+
* keyboard reads. Unset fields keep the theme preset. */
|
|
98
|
+
export interface DesktopKeyboardSlot {
|
|
99
|
+
backgroundColor?: string
|
|
100
|
+
color?: string
|
|
101
|
+
borderRadius?: number
|
|
102
|
+
fontSize?: number
|
|
103
|
+
/** A family the app registered with `font()` / `Fonts`, so labels match the UI. */
|
|
104
|
+
fontFamily?: string
|
|
105
|
+
/** `board` only: band inset / key spacing (logical px). */
|
|
106
|
+
padding?: number
|
|
107
|
+
gap?: number
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** app.json `desktop.virtualKeyboard` as a block. */
|
|
111
|
+
export interface DesktopVirtualKeyboard {
|
|
112
|
+
/** Off/on; an object without it counts as on. */
|
|
113
|
+
enabled?: boolean
|
|
114
|
+
/** Layouts in order, the first is the start one; the language key appears with two or more.
|
|
115
|
+
* Shipped: "en", "ru". */
|
|
116
|
+
layouts?: ("en" | "ru")[]
|
|
117
|
+
/** Band height: ≤ 1 = a fraction of the window (default 0.32), > 1 = logical px. */
|
|
118
|
+
height?: number
|
|
119
|
+
/** Cap on the KEY AREA's width in logical px — on a wide kiosk the keys would otherwise stretch
|
|
120
|
+
* across the whole window. The band itself always spans the window; the keys sit centred in it. */
|
|
121
|
+
maxWidth?: number
|
|
122
|
+
/** Colour preset the style slots override. */
|
|
123
|
+
theme?: "dark" | "light"
|
|
124
|
+
style?: {
|
|
125
|
+
board?: DesktopKeyboardSlot
|
|
126
|
+
key?: DesktopKeyboardSlot
|
|
127
|
+
/** A held key (and an active shift). Inherits `key` first. */
|
|
128
|
+
keyPressed?: DesktopKeyboardSlot
|
|
129
|
+
/** Shift / backspace / enter / page keys. Inherits `key` first. */
|
|
130
|
+
keySpecial?: DesktopKeyboardSlot
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
85
134
|
/** app.json `desktop.macos`. Only NAMES live here (identity, keychain profile) — the certificate
|
|
86
135
|
* and the notarization password stay in the keychain. */
|
|
87
136
|
export interface MacDesktopConfig {
|
|
@@ -166,6 +215,21 @@ export const desktopDefaultViewport = (root: string): { width: number, height: n
|
|
|
166
215
|
}
|
|
167
216
|
}
|
|
168
217
|
|
|
218
|
+
/** A SwiftPM dependency a plugin's iOS target links (manifest ios.packages). */
|
|
219
|
+
export interface IosPackage {
|
|
220
|
+
url: string
|
|
221
|
+
/** Pinned release — the normal case (a plugin's sources compile against one API). */
|
|
222
|
+
exact?: string
|
|
223
|
+
/** Minimum release, SwiftPM picks the newest compatible major. Exclusive with `exact`. */
|
|
224
|
+
from?: string
|
|
225
|
+
/** Library products of that package the plugin target imports. */
|
|
226
|
+
products: string[]
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** SwiftPM's package identity for a url: the last path segment without `.git`. */
|
|
230
|
+
export const swiftPackageName = (url: string): string =>
|
|
231
|
+
(url.replace(/\/+$/, "").split("/").pop() ?? url).replace(/\.git$/, "")
|
|
232
|
+
|
|
169
233
|
export interface PluginManifest {
|
|
170
234
|
id: string
|
|
171
235
|
version?: string
|
|
@@ -178,6 +242,12 @@ export interface PluginManifest {
|
|
|
178
242
|
* by sync; the relay signs sends for Letary-published bundle ids with the shared team
|
|
179
243
|
* key, and the register route fail-fasts for anything outside that namespace. */
|
|
180
244
|
entitlements?: Record<string, unknown>,
|
|
245
|
+
/** Swift packages the vendored target links (e.g. the map plugin's maplibre-native). Each
|
|
246
|
+
* becomes a `.package(url:…)` in the generated LeCodesRuntime/Package.swift (exact or
|
|
247
|
+
* from — one of the two) and its `products` become `.product(name:package:)` deps of the
|
|
248
|
+
* plugin target. The package name is the URL's last path segment minus `.git`, as SwiftPM
|
|
249
|
+
* derives it. Two plugins naming the same url must agree on the version. */
|
|
250
|
+
packages?: IosPackage[],
|
|
181
251
|
} | null
|
|
182
252
|
android?: {
|
|
183
253
|
/** Dir vendored into the shell (default "android") — a Gradle-module-shaped tree minus
|
|
@@ -200,6 +270,15 @@ export interface PluginManifest {
|
|
|
200
270
|
* Bare ids resolve through the CLI's known-version table; "id:version" pins explicitly. */
|
|
201
271
|
gradlePlugins?: string[],
|
|
202
272
|
} | null
|
|
273
|
+
/** Desktop host (packages/desktop): a PREBUILT shared library per OS/arch behind the C ABI in
|
|
274
|
+
* `packages/desktop/plugin-sdk/lecodes_plugin.h` — no host rebuild, no source vendoring.
|
|
275
|
+
* `library` keys are `${process.platform}-${process.arch}` ("win32-x64", "linux-x64",
|
|
276
|
+
* "linux-arm64"), values plugin-dir-relative paths. `lecodes desktop run` / `render --desktop`
|
|
277
|
+
* forward the library's dir to the host (CREATOR_PLUGIN_DIRS); `app build desktop` stages the
|
|
278
|
+
* file into `<build>/plugins/`. A plugin without a build for the current target is skipped. */
|
|
279
|
+
desktop?: {
|
|
280
|
+
library?: Record<string, string>,
|
|
281
|
+
} | null
|
|
203
282
|
}
|
|
204
283
|
|
|
205
284
|
export interface LockEntry { repo: string, tag: string, commit: string, version: string }
|
|
@@ -400,7 +479,12 @@ export const ensureRepo = (repo: string, wantTag: string | null, lock: LockEntry
|
|
|
400
479
|
|
|
401
480
|
/** Resolve one app.json plugin entry to vendorable sources + manifest + lock pin. The platform
|
|
402
481
|
* being synced must be supported (its manifest branch non-null) — a hard error otherwise. */
|
|
403
|
-
export
|
|
482
|
+
export type PluginPlatform = "ios" | "android" | "desktop"
|
|
483
|
+
const PLUGIN_PLATFORM_LABEL: Record<PluginPlatform, string> = { ios: "iOS", android: "Android", desktop: "desktop" }
|
|
484
|
+
|
|
485
|
+
export function resolvePlugin(id: string, spec: string, lock: LockEntry | undefined, platform: PluginPlatform): ResolvedPlugin
|
|
486
|
+
export function resolvePlugin(id: string, spec: string, lock: LockEntry | undefined, platform: PluginPlatform, opts: { optional: true }): ResolvedPlugin | null
|
|
487
|
+
export function resolvePlugin(id: string, spec: string, lock: LockEntry | undefined, platform: PluginPlatform, opts?: { optional?: boolean }): ResolvedPlugin | null {
|
|
404
488
|
let repo: string
|
|
405
489
|
let subdir: string
|
|
406
490
|
let wantTag: string | null = null
|
|
@@ -424,7 +508,8 @@ export const resolvePlugin = (id: string, spec: string, lock: LockEntry | undefi
|
|
|
424
508
|
if (!manifest) throw new CliError(`Plugin "${id}": no lecodes-plugin.json in ${dir}`)
|
|
425
509
|
if (manifest.id !== id) throw new CliError(`Plugin "${id}": manifest declares id "${manifest.id}"`)
|
|
426
510
|
if (!manifest[platform]) {
|
|
427
|
-
|
|
511
|
+
if (opts?.optional) return null
|
|
512
|
+
throw new CliError(`Plugin "${id}" does not support ${PLUGIN_PLATFORM_LABEL[platform]} (manifest "${platform}" is null)`)
|
|
428
513
|
}
|
|
429
514
|
|
|
430
515
|
// Namespacing: third-party channel names must be vendor-prefixed ("acme.thing").
|
|
@@ -446,3 +531,60 @@ export const resolvePlugin = (id: string, spec: string, lock: LockEntry | undefi
|
|
|
446
531
|
export const latestPluginTag = (repo: string): string | null => {
|
|
447
532
|
try { return ensureRepo(repo, null, undefined).tag } catch { return null }
|
|
448
533
|
}
|
|
534
|
+
|
|
535
|
+
/* ---------------------------------- desktop native plugins ---------------------------------- */
|
|
536
|
+
|
|
537
|
+
/** The desktop host's plugin library target key (manifest `desktop.library` keys). */
|
|
538
|
+
export const desktopPluginTarget = (): string => `${process.platform}-${process.arch}`
|
|
539
|
+
|
|
540
|
+
const isDesktopPluginLibrary = (name: string): boolean => {
|
|
541
|
+
const n = name.toLowerCase()
|
|
542
|
+
return process.platform === "win32" ? n.endsWith(".dll") : n.endsWith(".so")
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Native plugin libraries for THIS project on THIS machine (packages/desktop/plugin-sdk/
|
|
546
|
+
* lecodes_plugin.h — runtime-loaded .dll/.so, no host rebuild):
|
|
547
|
+
* - every library in the project's own `plugins/` dir — the zero-manifest dev loop (build a
|
|
548
|
+
* DLL, drop it there, `lecodes desktop run`);
|
|
549
|
+
* - the `desktop.library[target]` of every app.json plugin whose manifest declares one
|
|
550
|
+
* (mobile-only plugins are skipped, not errors).
|
|
551
|
+
* `dirs` is what a run forwards to the host as CREATOR_PLUGIN_DIRS (the host scans them);
|
|
552
|
+
* `files` is what `app build desktop` copies into `<build>/plugins/`. `strict` turns a broken
|
|
553
|
+
* declaration into an error (builds); runs warn and go on without that plugin. */
|
|
554
|
+
export const desktopPluginLibraries = (root: string, opts: { strict?: boolean } = {}): { dirs: string[], files: string[] } => {
|
|
555
|
+
const dirs: string[] = []
|
|
556
|
+
const files: string[] = []
|
|
557
|
+
const local = join(root, "plugins")
|
|
558
|
+
if (existsSync(local) && statSync(local).isDirectory()) {
|
|
559
|
+
dirs.push(local)
|
|
560
|
+
for (const f of readdirSync(local)) if (isDesktopPluginLibrary(f)) files.push(join(local, f))
|
|
561
|
+
}
|
|
562
|
+
const config = readJson<AppConfig>(join(root, "app.json"))
|
|
563
|
+
const specs = Object.entries(config?.plugins ?? {})
|
|
564
|
+
if (specs.length === 0) return { dirs, files }
|
|
565
|
+
const lock = readJson<{ plugins?: Record<string, LockEntry> }>(join(root, "plugins.lock.json"))?.plugins ?? {}
|
|
566
|
+
const target = desktopPluginTarget()
|
|
567
|
+
for (const [id, spec] of specs) {
|
|
568
|
+
let plugin: ResolvedPlugin | null
|
|
569
|
+
try {
|
|
570
|
+
plugin = resolvePlugin(id, spec, lock[id], "desktop", { optional: true })
|
|
571
|
+
} catch (e) {
|
|
572
|
+
if (opts.strict) throw e
|
|
573
|
+
warn(`Plugin "${id}": ${(e as Error).message} — skipped for this desktop run`)
|
|
574
|
+
continue
|
|
575
|
+
}
|
|
576
|
+
const rel = plugin?.manifest.desktop?.library?.[target]
|
|
577
|
+
if (!plugin || !rel) continue // mobile-only, or no build for this OS/arch
|
|
578
|
+
const abs = resolve(plugin.dir, rel)
|
|
579
|
+
if (!existsSync(abs)) {
|
|
580
|
+
const msg = `Plugin "${id}": desktop library ${abs} is missing (manifest desktop.library["${target}"])`
|
|
581
|
+
if (opts.strict) throw new CliError(msg)
|
|
582
|
+
warn(`${msg} — skipped for this desktop run`)
|
|
583
|
+
continue
|
|
584
|
+
}
|
|
585
|
+
files.push(abs)
|
|
586
|
+
const dir = dirname(abs)
|
|
587
|
+
if (!dirs.includes(dir)) dirs.push(dir)
|
|
588
|
+
}
|
|
589
|
+
return { dirs, files }
|
|
590
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { swiftPackageName, type IosPackage } from "./appShared"
|
|
2
|
+
|
|
1
3
|
/* Templates for `lecodes app init ios` / `lecodes app sync` — distilled 1:1 from the
|
|
2
4
|
* hand-built, device-verified reference shell (docs/xcode-app-plan.md). Three ownership
|
|
3
5
|
* tiers: App/ Swift files are USER-owned after generation (init writes once, sync never
|
|
@@ -696,6 +698,8 @@ export interface RuntimePlugin {
|
|
|
696
698
|
register: string
|
|
697
699
|
/** Plugin id, for the generated comments. */
|
|
698
700
|
id: string
|
|
701
|
+
/** Swift packages the target links (manifest ios.packages). */
|
|
702
|
+
packages?: IosPackage[]
|
|
699
703
|
}
|
|
700
704
|
|
|
701
705
|
/** The SDK binary products (all vend the same `LeCodesSDK` module): core = UI only,
|
|
@@ -711,13 +715,33 @@ export type SdkVariant = "core" | "2d" | "3d" | "full"
|
|
|
711
715
|
* SDK. Moving the version is the CLI's job — `lecodes app update` (or app.json ios.sdk). */
|
|
712
716
|
export const runtimePackageTemplate = (sdkVersion: string, variant: SdkVariant, plugins: RuntimePlugin[]): string => {
|
|
713
717
|
const product = `LeCodesSDK-${variant}`
|
|
714
|
-
|
|
718
|
+
// Third-party Swift packages (manifest ios.packages), one `.package(…)` per url. Two plugins
|
|
719
|
+
// naming the same url must agree on the pin — SwiftPM has one resolution per package.
|
|
720
|
+
const packages = new Map<string, IosPackage>()
|
|
721
|
+
for (const p of plugins) {
|
|
722
|
+
for (const pkg of p.packages ?? []) {
|
|
723
|
+
const prior = packages.get(pkg.url)
|
|
724
|
+
if (prior && (prior.exact !== pkg.exact || prior.from !== pkg.from)) {
|
|
725
|
+
throw new Error(`Plugin "${p.id}" pins ${pkg.url} at ${pkg.exact ?? `from ${pkg.from}`} but another plugin pins ${prior.exact ?? `from ${prior.from}`} — the shell can link one version.`)
|
|
726
|
+
}
|
|
727
|
+
packages.set(pkg.url, pkg)
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
const packageDecls = [...packages.values()].map(pkg =>
|
|
731
|
+
`\n .package(url: "${pkg.url}", ${pkg.exact ? `exact: "${pkg.exact}"` : `from: "${pkg.from}"`}),`).join("")
|
|
732
|
+
const pluginTargets = plugins.map(p => {
|
|
733
|
+
const deps = [
|
|
734
|
+
`.product(name: "${product}", package: "lecodes-ios-sdk")`,
|
|
735
|
+
...(p.packages ?? []).flatMap(pkg => pkg.products.map(name => `.product(name: "${name}", package: "${swiftPackageName(pkg.url)}")`)),
|
|
736
|
+
]
|
|
737
|
+
return `
|
|
715
738
|
// plugin: ${p.id} (vendored — see plugins.lock.json)
|
|
716
739
|
.target(
|
|
717
740
|
name: "${p.module}",
|
|
718
|
-
dependencies: [.
|
|
741
|
+
dependencies: [${deps.join(", ")}],
|
|
719
742
|
path: "Sources/${p.module}"
|
|
720
|
-
),`
|
|
743
|
+
),`
|
|
744
|
+
}).join("")
|
|
721
745
|
const runtimeDeps = [`.product(name: "${product}", package: "lecodes-ios-sdk")`, ...plugins.map(p => `"${p.module}"`)]
|
|
722
746
|
return `// swift-tools-version:5.9
|
|
723
747
|
//
|
|
@@ -736,7 +760,7 @@ let package = Package(
|
|
|
736
760
|
.library(name: "LeCodesRuntime", targets: ["LeCodesRuntime"]),
|
|
737
761
|
],
|
|
738
762
|
dependencies: [
|
|
739
|
-
.package(url: "https://github.com/letary/lecodes-ios-sdk.git", exact: "${sdkVersion}")
|
|
763
|
+
.package(url: "https://github.com/letary/lecodes-ios-sdk.git", exact: "${sdkVersion}"),${packageDecls}
|
|
740
764
|
],
|
|
741
765
|
targets: [
|
|
742
766
|
.target(
|
package/src/commands/assets.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { type Args } from "../util"
|
|
2
2
|
import { resolveCmgenExe } from "../cmgenTool"
|
|
3
|
+
import { loadPeer } from "../peerInstall"
|
|
3
4
|
|
|
4
5
|
/*
|
|
5
6
|
* `lecodes assets <convert|probe|doctor|sky> …` — the LeCodes asset pipeline (packages/lecodes-assets):
|
|
@@ -9,12 +10,7 @@ import { resolveCmgenExe } from "../cmgenTool"
|
|
|
9
10
|
* forwarded untouched (`assets` gets `_raw` from main). See docs/animation-plan.md §2.9.
|
|
10
11
|
*/
|
|
11
12
|
export const assets = async (args: Args & { _raw?: string[] }) => {
|
|
12
|
-
|
|
13
|
-
try {
|
|
14
|
-
mod = await import("lecodes-assets/cli")
|
|
15
|
-
} catch {
|
|
16
|
-
throw new CliError("The asset pipeline isn't installed. Run: npm install -g lecodes-assets")
|
|
17
|
-
}
|
|
13
|
+
const mod = await loadPeer<typeof import("lecodes-assets/cli")>("lecodes-assets", "cli", { for: "lecodes assets" })
|
|
18
14
|
const raw = args._raw ?? args._
|
|
19
15
|
// `sky` shells out to filament's cmgen, which the asset package deliberately never downloads
|
|
20
16
|
// itself (it stays network-free and usable standalone). The CLI owns release artifacts, so it
|
package/src/commands/design.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { compileDesignScreen } from "../compile/designCompile"
|
|
|
5
5
|
import { openBrowser } from "../browserAuth"
|
|
6
6
|
import { CLAUDE_MD, DESIGN_MACROS_DTS, HOME_SCREEN_TS, META_JSON, META_JSON_DESKTOP, README_HINT, SPEC_MD, TABS_TS, TOKENS_TS } from "./designTemplates"
|
|
7
7
|
import { CliError, c, flagBool, flagStr, log, logErr, note, warnErr, type Args } from "../util"
|
|
8
|
+
import { loadPeer } from "../peerInstall"
|
|
8
9
|
import { loadConfig, normalizeApiUrl, requireApiUrl, requireToken } from "../config"
|
|
9
10
|
import {
|
|
10
11
|
createDesignComment, deleteDesignComment, deleteDesignCommentMessage, disableDesignShare,
|
|
@@ -87,12 +88,7 @@ const loadDesignServer = async (
|
|
|
87
88
|
if (!existsSync(ctx.designDir)) {
|
|
88
89
|
throw new CliError(`No ${ctx.dirName}/ folder here. Run "lecodes design init" to scaffold it.`)
|
|
89
90
|
}
|
|
90
|
-
|
|
91
|
-
try {
|
|
92
|
-
mod = await import("lecodes-design/server")
|
|
93
|
-
} catch {
|
|
94
|
-
throw new CliError("The design canvas isn't installed. Run: npm install -g lecodes-design")
|
|
95
|
-
}
|
|
91
|
+
const mod = await loadPeer<typeof import("lecodes-design/server")>("lecodes-design", "server", { for: "lecodes design" })
|
|
96
92
|
const missing = needs.filter((name) => typeof mod[name] !== "function")
|
|
97
93
|
if (missing.length) {
|
|
98
94
|
throw new CliError(
|
|
@@ -236,23 +232,14 @@ const snapshot = async (ctx: DesignContext, args: Args) => {
|
|
|
236
232
|
const allStates = flagBool(args, "all-states")
|
|
237
233
|
if (stateFlag !== undefined && allStates) throw new CliError("Use either --state <name> or --all-states, not both.")
|
|
238
234
|
|
|
239
|
-
|
|
240
|
-
try {
|
|
241
|
-
renderer = await import("lecodes-renderer/headless")
|
|
242
|
-
} catch {
|
|
243
|
-
throw new CliError("The renderer isn't installed. Run: npm install -g lecodes-renderer")
|
|
244
|
-
}
|
|
235
|
+
const renderer = await loadPeer<typeof import("lecodes-renderer/headless")>("lecodes-renderer", "headless", { for: "lecodes design render" })
|
|
245
236
|
|
|
246
237
|
// Enumerating / validating states parses the screen source — that discovery lives in the design
|
|
247
238
|
// package. The canonical-only path (no flag) needs no discovery, so base snapshot stays lean.
|
|
248
239
|
let discover: ((source: string) => { states: string[], canonical: string }) | null = null
|
|
249
240
|
if (stateFlag !== undefined || allStates) {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
discover = (src) => { const d = ds.discoverScreenStates(src); return { states: d.states, canonical: d.canonical } }
|
|
253
|
-
} catch {
|
|
254
|
-
throw new CliError("Rendering specific states needs the design canvas. Run: npm install -g lecodes-design")
|
|
255
|
-
}
|
|
241
|
+
const ds = await loadPeer<typeof import("lecodes-design/server")>("lecodes-design", "server", { for: "lecodes design render --state" })
|
|
242
|
+
discover = (src) => { const d = ds.discoverScreenStates(src); return { states: d.states, canonical: d.canonical } }
|
|
256
243
|
}
|
|
257
244
|
|
|
258
245
|
// A stuck screen (runaway loop) shouldn't hang the CLI forever.
|
|
@@ -330,8 +317,8 @@ const serve = async (ctx: DesignContext, args: Args) => {
|
|
|
330
317
|
|
|
331
318
|
// The MCP render_screen tool renders headless through lecodes-renderer, injected here so the
|
|
332
319
|
// design server keeps no dependency on it. Absent renderer → the tool returns an install hint.
|
|
333
|
-
|
|
334
|
-
|
|
320
|
+
// Opportunistic: never triggers an install from inside the MCP server (the tool reports the hint).
|
|
321
|
+
const renderer = await loadPeer<typeof import("lecodes-renderer/headless")>("lecodes-renderer", "headless", { for: "lecodes design (render_screen)", install: false })
|
|
335
322
|
const renderScreen = renderer
|
|
336
323
|
? async (id: string, format: "json" | "png" | "both", state?: string) => {
|
|
337
324
|
const r = renderer!
|
|
@@ -740,12 +727,8 @@ export const flushDesignComments = async (args: Args): Promise<CommentFlush | nu
|
|
|
740
727
|
const platform = optionalPlatform(ctx)
|
|
741
728
|
if (!platform) return null
|
|
742
729
|
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
designServer = await import("lecodes-design/server")
|
|
746
|
-
} catch {
|
|
747
|
-
return null
|
|
748
|
-
}
|
|
730
|
+
const designServer = await loadPeer<typeof import("lecodes-design/server")>("lecodes-design", "server", { for: "lecodes design", install: false })
|
|
731
|
+
if (!designServer) return null
|
|
749
732
|
// `settleQueued` must take `delivered` (4 params): settling a half-failed push with the older
|
|
750
733
|
// signature would drop the writes that DIDN'T go through instead of retrying them.
|
|
751
734
|
if (typeof designServer.queuedThreads !== "function") return null
|
package/src/commands/desktop.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { basename } from "node:path"
|
|
1
2
|
import { CliError, c, flagBool, flagStr, log, note, type Args } from "../util"
|
|
2
3
|
import { findProjectRoot } from "../manifest"
|
|
3
4
|
import { buildBundle } from "./compile"
|
|
4
|
-
import { assertRendererSupportsBundle, readAppConfigAt, resolveDesktopRenderer } from "./appShared"
|
|
5
|
+
import { assertRendererSupportsBundle, desktopPluginLibraries, readAppConfigAt, resolveDesktopRenderer } from "./appShared"
|
|
5
6
|
import { buildDesktop } from "./appDesktop"
|
|
6
7
|
import {
|
|
7
8
|
DESKTOP_RENDERER_VERSION,
|
|
@@ -57,15 +58,20 @@ const run = async (args: Args) => {
|
|
|
57
58
|
const renderer = resolveDesktopRenderer(root, args)
|
|
58
59
|
const { js, resources } = await buildBundle(args, undefined, { localAssets: true, desktopRenderer: renderer })
|
|
59
60
|
assertRendererSupportsBundle(renderer, js)
|
|
61
|
+
// Native plugins: the project's plugins/ dir + declared plugin libraries, scanned by the host.
|
|
62
|
+
const { dirs: pluginDirs, files: pluginFiles } = desktopPluginLibraries(root)
|
|
63
|
+
if (pluginFiles.length > 0) note(`Native plugins: ${pluginFiles.map((f) => basename(f)).join(", ")}`)
|
|
60
64
|
note(`Launching LeCodes Desktop${renderer === "vulkan" ? " (Vulkan)" : ""}… (close the window to return)`)
|
|
61
65
|
await runDesktopApp(js, resources, {
|
|
62
66
|
renderer,
|
|
67
|
+
pluginDirs,
|
|
63
68
|
width: Number(flagStr(args, "width")) || win?.width,
|
|
64
69
|
height: Number(flagStr(args, "height")) || win?.height,
|
|
65
70
|
minWidth: win?.minWidth,
|
|
66
71
|
minHeight: win?.minHeight,
|
|
67
72
|
resizable: win?.resizable,
|
|
68
73
|
title: flagStr(args, "title") ?? win?.title ?? appCfg?.name,
|
|
74
|
+
storageScope: appCfg?.name,
|
|
69
75
|
// --fullscreen forces it on; --windowed forces it off (over app.json); else the config decides.
|
|
70
76
|
fullscreen: flagBool(args, "fullscreen") ? true : flagBool(args, "windowed") ? false : win?.fullscreen,
|
|
71
77
|
scale: resolveScale(flagStr(args, "scale"), win?.scale),
|
|
@@ -73,6 +79,8 @@ const run = async (args: Args) => {
|
|
|
73
79
|
// from app.json only. Both are desktop-level (the machine/app), not window-level.
|
|
74
80
|
renderScale: resolveRenderScale(flagStr(args, "render-scale"), appCfg?.desktop?.renderScale),
|
|
75
81
|
maxRenderSize: appCfg?.desktop?.maxRenderSize,
|
|
82
|
+
mouseEmulateTouch: appCfg?.desktop?.mouseEmulateTouch,
|
|
83
|
+
virtualKeyboard: appCfg?.desktop?.virtualKeyboard,
|
|
76
84
|
// --fps: the host's own corner counter. A flag and not app.json on purpose — it is something you
|
|
77
85
|
// switch on while looking at a frame, not a property of the app.
|
|
78
86
|
fps: flagBool(args, "fps"),
|
package/src/commands/dev.ts
CHANGED
|
@@ -5,7 +5,10 @@ import { basename, join } from "node:path"
|
|
|
5
5
|
import { findProjectRoot, readManifest, LECODES_DIR, type Manifest } from "../manifest"
|
|
6
6
|
import { loadIgnore } from "../ignore"
|
|
7
7
|
import { startDevServer } from "../dev/devServer"
|
|
8
|
+
import { resolveWebRunnerDir } from "../dev/webRunner"
|
|
9
|
+
import { openBrowser } from "../browserAuth"
|
|
8
10
|
import { runDesktopDev } from "../desktopRenderer"
|
|
11
|
+
import { androidCrashLines, launchOnAndroid, type AndroidDevSession } from "../dev/androidDev"
|
|
9
12
|
import { readAppConfigAt } from "./appShared"
|
|
10
13
|
import { resolveScale } from "./desktop"
|
|
11
14
|
import { CliError, c, flagBool, flagStr, log, note, toKey, warnErr, type Args } from "../util"
|
|
@@ -20,6 +23,11 @@ import { CliError, c, flagBool, flagStr, log, note, toKey, warnErr, type Args }
|
|
|
20
23
|
* backend. Custom shaders compile locally too (matc, downloaded on first use — matcTool.ts);
|
|
21
24
|
* only when matc can't be fetched do they fall back to server-compiled artifacts (push once).
|
|
22
25
|
*
|
|
26
|
+
* `--web` opens the same project in the browser: the dev server also serves the Web Lite runner
|
|
27
|
+
* (viewer-lite's dist-embed, vendored as runtime/web) under /web/ — CSS-flexbox UI + OGL 3D + the
|
|
28
|
+
* web twins of the camera/map plugins, no wasm. Same bundle and hot reload; browser console
|
|
29
|
+
* output streams back here like a device's. The URL is printed whenever the runner is available.
|
|
30
|
+
*
|
|
23
31
|
* Works inside a cloned project or standalone in any folder with an entry .ts file.
|
|
24
32
|
* The desktop host speaks websocket since 1.0.0 (host_ws); `?t=poll` remains the fallback
|
|
25
33
|
* for older desktop builds (or any host without a websocket bridge).
|
|
@@ -116,9 +124,14 @@ export const dev = async (args: Args) => {
|
|
|
116
124
|
const origin = `http://${host}:${port}`
|
|
117
125
|
const token = loadDevToken(root)
|
|
118
126
|
|
|
127
|
+
const webDir = resolveWebRunnerDir()
|
|
128
|
+
if (flagBool(args, "web") && !webDir) {
|
|
129
|
+
throw new CliError("No Web Lite runner found for --web: this CLI has no runtime/web (a monorepo checkout needs `bun run build:embed` in packages/viewer-lite).")
|
|
130
|
+
}
|
|
119
131
|
const server = await startDevServer({
|
|
120
132
|
root, manifest, name, origin, port, token,
|
|
121
133
|
entryOverride: flagStr(args, "entry"),
|
|
134
|
+
webDir,
|
|
122
135
|
log: note,
|
|
123
136
|
}).catch((e: unknown) => {
|
|
124
137
|
const msg = e instanceof Error ? e.message : String(e)
|
|
@@ -148,6 +161,7 @@ export const dev = async (args: Args) => {
|
|
|
148
161
|
}
|
|
149
162
|
|
|
150
163
|
const debug = process.env.LECODES_DEV_DEBUG === "1"
|
|
164
|
+
const underBun = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined"
|
|
151
165
|
let timer: NodeJS.Timeout | null = null
|
|
152
166
|
const onChange = (file: string | null) => {
|
|
153
167
|
if (debug) note(`[watch] event: ${file ?? "(null)"} → ${shouldRecompile(file) ? "recompile" : "skip"}`)
|
|
@@ -180,6 +194,10 @@ export const dev = async (args: Args) => {
|
|
|
180
194
|
log("")
|
|
181
195
|
if (!flagBool(args, "no-qr")) await printQr(server.url)
|
|
182
196
|
log(`Scan the QR with the LeCodes app, or open: ${c.bold(server.url)}`)
|
|
197
|
+
// The browser runner: localhost is a secure context (camera works there), the LAN address is
|
|
198
|
+
// what a phone's browser can reach.
|
|
199
|
+
const webLocal = server.webUrl ? server.webUrl.replace(`http://${host}:`, "http://localhost:") : null
|
|
200
|
+
if (server.webUrl && webLocal) log(`In the browser (Web Lite): ${c.bold(webLocal)} (LAN: ${server.webUrl})`)
|
|
183
201
|
note(`Older desktop builds (no websocket bridge): ${server.url}?t=poll`)
|
|
184
202
|
if (alternatives.length > 0) {
|
|
185
203
|
note(`Device can't reach it? This machine also has ${alternatives.map((a) => `${a.address} (${a.iface})`).join(", ")} — retry with --host <ip>.`)
|
|
@@ -188,6 +206,52 @@ export const dev = async (args: Args) => {
|
|
|
188
206
|
log("")
|
|
189
207
|
log(`Watching ${root} — save a file to hot-reload. Ctrl+C to stop.`)
|
|
190
208
|
|
|
209
|
+
// --web: open the browser runner (the server keeps serving it either way).
|
|
210
|
+
if (flagBool(args, "web") && webLocal) {
|
|
211
|
+
note("Opening the browser…")
|
|
212
|
+
openBrowser(webLocal)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// --android: open the dev URL in the LeCodes app on the phone (adb; see dev/androidDev.ts). With
|
|
216
|
+
// --time <s> the run is bounded — screenshot to --png, force-stop, exit — which is the shape a
|
|
217
|
+
// scripted check wants; without it the app runs until Ctrl+C, and Ctrl+C force-stops it (a
|
|
218
|
+
// device run has no frame cap, and a 3D scene left rendering heats a charging phone).
|
|
219
|
+
if (flagBool(args, "android")) {
|
|
220
|
+
const seconds = Number(flagStr(args, "time")) || 0
|
|
221
|
+
const png = flagStr(args, "png")
|
|
222
|
+
// Under Bun the websocket channel never delivers (see the warning above): the phone would sit on
|
|
223
|
+
// "Connected — loading the project…" forever. The path-form poll url works on both runtimes.
|
|
224
|
+
const deviceUrl = `http://127.0.0.1:${port}${server.url.slice(origin.length)}`.replace(/\/dev\.js$/, underBun ? "/dev-poll.js" : "/dev.js")
|
|
225
|
+
let session: AndroidDevSession
|
|
226
|
+
try {
|
|
227
|
+
session = launchOnAndroid({ port, url: deviceUrl, serial: flagStr(args, "serial"), apk: flagStr(args, "apk"), packageName: flagStr(args, "package") })
|
|
228
|
+
} catch (e) {
|
|
229
|
+
server.close(); watcher?.close()
|
|
230
|
+
throw e
|
|
231
|
+
}
|
|
232
|
+
note(`Android: ${session.serial} → ${deviceUrl} (adb reverse; device logs stream here as [device:log])`)
|
|
233
|
+
const finish = (code: number) => {
|
|
234
|
+
session.stop()
|
|
235
|
+
server.close(); watcher?.close()
|
|
236
|
+
process.exit(code)
|
|
237
|
+
}
|
|
238
|
+
process.on("SIGINT", () => { note("Stopping the app on the phone…"); finish(0) })
|
|
239
|
+
if (seconds > 0) {
|
|
240
|
+
setTimeout(() => {
|
|
241
|
+
const crashes = androidCrashLines(session)
|
|
242
|
+
if (png) {
|
|
243
|
+
if (session.screenshot(png)) note(`Wrote ${png} (device screenshot after ${seconds} s)`)
|
|
244
|
+
else warnErr(`Screenshot failed (adb screencap).`)
|
|
245
|
+
}
|
|
246
|
+
if (crashes.length > 0) {
|
|
247
|
+
warnErr(`The app crashed on the phone:\n${crashes.join("\n")}`)
|
|
248
|
+
finish(1)
|
|
249
|
+
}
|
|
250
|
+
finish(0)
|
|
251
|
+
}, seconds * 1000)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
191
255
|
// --desktop: open the dev URL in the native host too (same server, same hot reload — the host
|
|
192
256
|
// speaks the websocket channel). Window config comes from app.json's desktop block. The window
|
|
193
257
|
// closing does NOT stop the server: phones stay connected, and re-running with --desktop is the
|