lecodes-cli 0.19.2 → 0.20.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 +1 -0
- package/dist/index.js +458 -280
- package/package.json +5 -5
- package/runtime/scene-harness.json +1 -1
- 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-BvHobVhp.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-CfNCCBjN.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/commands/app.ts +6 -1
- package/src/commands/appDesktop.ts +15 -2
- package/src/commands/appShared.ts +97 -3
- package/src/commands/appTemplates.ts +28 -4
- package/src/commands/desktop.ts +6 -1
- package/src/commands/dev.ts +22 -0
- package/src/commands/projectTemplates.ts +4 -3
- package/src/commands/render.ts +5 -3
- package/src/commands/test.ts +2 -1
- package/src/desktopRenderer.ts +15 -2
- package/src/dev/devServer.ts +47 -6
- package/src/dev/webRunner.ts +22 -0
- package/src/index.ts +3 -0
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
|
|
|
@@ -208,7 +208,20 @@ export const buildDesktop = async (args: Args) => {
|
|
|
208
208
|
note(" app.ico rendered from " + config.icon)
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
-
// 5)
|
|
211
|
+
// 5) Native plugins (packages/desktop/plugin-sdk/lecodes_plugin.h): the project's plugins/ dir +
|
|
212
|
+
// every declared plugin's desktop library → plugins/ next to the exe, where a host WITH the plugin
|
|
213
|
+
// loader (added after 1.4.0) scans at startup; an older host ignores the folder.
|
|
214
|
+
// A declared library that's missing is a build error (strict).
|
|
215
|
+
{
|
|
216
|
+
const { files } = desktopPluginLibraries(root, { strict: true })
|
|
217
|
+
if (files.length > 0) {
|
|
218
|
+
mkdirSync(join(out, "plugins"), { recursive: true })
|
|
219
|
+
for (const f of files) cpSync(f, join(out, "plugins", basename(f)))
|
|
220
|
+
note(` plugins: ${files.map((f) => basename(f)).join(", ")} → plugins/`)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// 6) A short README (BOM so double-click-Notepad shows it right).
|
|
212
225
|
writeFileSync(join(out, "README.txt"), "\uFEFF" + readmeText(name, exeName, version))
|
|
213
226
|
|
|
214
227
|
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
|
}
|
|
@@ -166,6 +167,21 @@ export const desktopDefaultViewport = (root: string): { width: number, height: n
|
|
|
166
167
|
}
|
|
167
168
|
}
|
|
168
169
|
|
|
170
|
+
/** A SwiftPM dependency a plugin's iOS target links (manifest ios.packages). */
|
|
171
|
+
export interface IosPackage {
|
|
172
|
+
url: string
|
|
173
|
+
/** Pinned release — the normal case (a plugin's sources compile against one API). */
|
|
174
|
+
exact?: string
|
|
175
|
+
/** Minimum release, SwiftPM picks the newest compatible major. Exclusive with `exact`. */
|
|
176
|
+
from?: string
|
|
177
|
+
/** Library products of that package the plugin target imports. */
|
|
178
|
+
products: string[]
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** SwiftPM's package identity for a url: the last path segment without `.git`. */
|
|
182
|
+
export const swiftPackageName = (url: string): string =>
|
|
183
|
+
(url.replace(/\/+$/, "").split("/").pop() ?? url).replace(/\.git$/, "")
|
|
184
|
+
|
|
169
185
|
export interface PluginManifest {
|
|
170
186
|
id: string
|
|
171
187
|
version?: string
|
|
@@ -178,6 +194,12 @@ export interface PluginManifest {
|
|
|
178
194
|
* by sync; the relay signs sends for Letary-published bundle ids with the shared team
|
|
179
195
|
* key, and the register route fail-fasts for anything outside that namespace. */
|
|
180
196
|
entitlements?: Record<string, unknown>,
|
|
197
|
+
/** Swift packages the vendored target links (e.g. the map plugin's maplibre-native). Each
|
|
198
|
+
* becomes a `.package(url:…)` in the generated LeCodesRuntime/Package.swift (exact or
|
|
199
|
+
* from — one of the two) and its `products` become `.product(name:package:)` deps of the
|
|
200
|
+
* plugin target. The package name is the URL's last path segment minus `.git`, as SwiftPM
|
|
201
|
+
* derives it. Two plugins naming the same url must agree on the version. */
|
|
202
|
+
packages?: IosPackage[],
|
|
181
203
|
} | null
|
|
182
204
|
android?: {
|
|
183
205
|
/** Dir vendored into the shell (default "android") — a Gradle-module-shaped tree minus
|
|
@@ -200,6 +222,15 @@ export interface PluginManifest {
|
|
|
200
222
|
* Bare ids resolve through the CLI's known-version table; "id:version" pins explicitly. */
|
|
201
223
|
gradlePlugins?: string[],
|
|
202
224
|
} | null
|
|
225
|
+
/** Desktop host (packages/desktop): a PREBUILT shared library per OS/arch behind the C ABI in
|
|
226
|
+
* `packages/desktop/plugin-sdk/lecodes_plugin.h` — no host rebuild, no source vendoring.
|
|
227
|
+
* `library` keys are `${process.platform}-${process.arch}` ("win32-x64", "linux-x64",
|
|
228
|
+
* "linux-arm64"), values plugin-dir-relative paths. `lecodes desktop run` / `render --desktop`
|
|
229
|
+
* forward the library's dir to the host (CREATOR_PLUGIN_DIRS); `app build desktop` stages the
|
|
230
|
+
* file into `<build>/plugins/`. A plugin without a build for the current target is skipped. */
|
|
231
|
+
desktop?: {
|
|
232
|
+
library?: Record<string, string>,
|
|
233
|
+
} | null
|
|
203
234
|
}
|
|
204
235
|
|
|
205
236
|
export interface LockEntry { repo: string, tag: string, commit: string, version: string }
|
|
@@ -400,7 +431,12 @@ export const ensureRepo = (repo: string, wantTag: string | null, lock: LockEntry
|
|
|
400
431
|
|
|
401
432
|
/** Resolve one app.json plugin entry to vendorable sources + manifest + lock pin. The platform
|
|
402
433
|
* being synced must be supported (its manifest branch non-null) — a hard error otherwise. */
|
|
403
|
-
export
|
|
434
|
+
export type PluginPlatform = "ios" | "android" | "desktop"
|
|
435
|
+
const PLUGIN_PLATFORM_LABEL: Record<PluginPlatform, string> = { ios: "iOS", android: "Android", desktop: "desktop" }
|
|
436
|
+
|
|
437
|
+
export function resolvePlugin(id: string, spec: string, lock: LockEntry | undefined, platform: PluginPlatform): ResolvedPlugin
|
|
438
|
+
export function resolvePlugin(id: string, spec: string, lock: LockEntry | undefined, platform: PluginPlatform, opts: { optional: true }): ResolvedPlugin | null
|
|
439
|
+
export function resolvePlugin(id: string, spec: string, lock: LockEntry | undefined, platform: PluginPlatform, opts?: { optional?: boolean }): ResolvedPlugin | null {
|
|
404
440
|
let repo: string
|
|
405
441
|
let subdir: string
|
|
406
442
|
let wantTag: string | null = null
|
|
@@ -424,7 +460,8 @@ export const resolvePlugin = (id: string, spec: string, lock: LockEntry | undefi
|
|
|
424
460
|
if (!manifest) throw new CliError(`Plugin "${id}": no lecodes-plugin.json in ${dir}`)
|
|
425
461
|
if (manifest.id !== id) throw new CliError(`Plugin "${id}": manifest declares id "${manifest.id}"`)
|
|
426
462
|
if (!manifest[platform]) {
|
|
427
|
-
|
|
463
|
+
if (opts?.optional) return null
|
|
464
|
+
throw new CliError(`Plugin "${id}" does not support ${PLUGIN_PLATFORM_LABEL[platform]} (manifest "${platform}" is null)`)
|
|
428
465
|
}
|
|
429
466
|
|
|
430
467
|
// Namespacing: third-party channel names must be vendor-prefixed ("acme.thing").
|
|
@@ -446,3 +483,60 @@ export const resolvePlugin = (id: string, spec: string, lock: LockEntry | undefi
|
|
|
446
483
|
export const latestPluginTag = (repo: string): string | null => {
|
|
447
484
|
try { return ensureRepo(repo, null, undefined).tag } catch { return null }
|
|
448
485
|
}
|
|
486
|
+
|
|
487
|
+
/* ---------------------------------- desktop native plugins ---------------------------------- */
|
|
488
|
+
|
|
489
|
+
/** The desktop host's plugin library target key (manifest `desktop.library` keys). */
|
|
490
|
+
export const desktopPluginTarget = (): string => `${process.platform}-${process.arch}`
|
|
491
|
+
|
|
492
|
+
const isDesktopPluginLibrary = (name: string): boolean => {
|
|
493
|
+
const n = name.toLowerCase()
|
|
494
|
+
return process.platform === "win32" ? n.endsWith(".dll") : n.endsWith(".so")
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Native plugin libraries for THIS project on THIS machine (packages/desktop/plugin-sdk/
|
|
498
|
+
* lecodes_plugin.h — runtime-loaded .dll/.so, no host rebuild):
|
|
499
|
+
* - every library in the project's own `plugins/` dir — the zero-manifest dev loop (build a
|
|
500
|
+
* DLL, drop it there, `lecodes desktop run`);
|
|
501
|
+
* - the `desktop.library[target]` of every app.json plugin whose manifest declares one
|
|
502
|
+
* (mobile-only plugins are skipped, not errors).
|
|
503
|
+
* `dirs` is what a run forwards to the host as CREATOR_PLUGIN_DIRS (the host scans them);
|
|
504
|
+
* `files` is what `app build desktop` copies into `<build>/plugins/`. `strict` turns a broken
|
|
505
|
+
* declaration into an error (builds); runs warn and go on without that plugin. */
|
|
506
|
+
export const desktopPluginLibraries = (root: string, opts: { strict?: boolean } = {}): { dirs: string[], files: string[] } => {
|
|
507
|
+
const dirs: string[] = []
|
|
508
|
+
const files: string[] = []
|
|
509
|
+
const local = join(root, "plugins")
|
|
510
|
+
if (existsSync(local) && statSync(local).isDirectory()) {
|
|
511
|
+
dirs.push(local)
|
|
512
|
+
for (const f of readdirSync(local)) if (isDesktopPluginLibrary(f)) files.push(join(local, f))
|
|
513
|
+
}
|
|
514
|
+
const config = readJson<AppConfig>(join(root, "app.json"))
|
|
515
|
+
const specs = Object.entries(config?.plugins ?? {})
|
|
516
|
+
if (specs.length === 0) return { dirs, files }
|
|
517
|
+
const lock = readJson<{ plugins?: Record<string, LockEntry> }>(join(root, "plugins.lock.json"))?.plugins ?? {}
|
|
518
|
+
const target = desktopPluginTarget()
|
|
519
|
+
for (const [id, spec] of specs) {
|
|
520
|
+
let plugin: ResolvedPlugin | null
|
|
521
|
+
try {
|
|
522
|
+
plugin = resolvePlugin(id, spec, lock[id], "desktop", { optional: true })
|
|
523
|
+
} catch (e) {
|
|
524
|
+
if (opts.strict) throw e
|
|
525
|
+
warn(`Plugin "${id}": ${(e as Error).message} — skipped for this desktop run`)
|
|
526
|
+
continue
|
|
527
|
+
}
|
|
528
|
+
const rel = plugin?.manifest.desktop?.library?.[target]
|
|
529
|
+
if (!plugin || !rel) continue // mobile-only, or no build for this OS/arch
|
|
530
|
+
const abs = resolve(plugin.dir, rel)
|
|
531
|
+
if (!existsSync(abs)) {
|
|
532
|
+
const msg = `Plugin "${id}": desktop library ${abs} is missing (manifest desktop.library["${target}"])`
|
|
533
|
+
if (opts.strict) throw new CliError(msg)
|
|
534
|
+
warn(`${msg} — skipped for this desktop run`)
|
|
535
|
+
continue
|
|
536
|
+
}
|
|
537
|
+
files.push(abs)
|
|
538
|
+
const dir = dirname(abs)
|
|
539
|
+
if (!dirs.includes(dir)) dirs.push(dir)
|
|
540
|
+
}
|
|
541
|
+
return { dirs, files }
|
|
542
|
+
}
|
|
@@ -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/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,9 +58,13 @@ 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,
|
package/src/commands/dev.ts
CHANGED
|
@@ -5,6 +5,8 @@ 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"
|
|
9
11
|
import { readAppConfigAt } from "./appShared"
|
|
10
12
|
import { resolveScale } from "./desktop"
|
|
@@ -20,6 +22,11 @@ import { CliError, c, flagBool, flagStr, log, note, toKey, warnErr, type Args }
|
|
|
20
22
|
* backend. Custom shaders compile locally too (matc, downloaded on first use — matcTool.ts);
|
|
21
23
|
* only when matc can't be fetched do they fall back to server-compiled artifacts (push once).
|
|
22
24
|
*
|
|
25
|
+
* `--web` opens the same project in the browser: the dev server also serves the Web Lite runner
|
|
26
|
+
* (viewer-lite's dist-embed, vendored as runtime/web) under /web/ — CSS-flexbox UI + OGL 3D + the
|
|
27
|
+
* web twins of the camera/map plugins, no wasm. Same bundle and hot reload; browser console
|
|
28
|
+
* output streams back here like a device's. The URL is printed whenever the runner is available.
|
|
29
|
+
*
|
|
23
30
|
* Works inside a cloned project or standalone in any folder with an entry .ts file.
|
|
24
31
|
* The desktop host speaks websocket since 1.0.0 (host_ws); `?t=poll` remains the fallback
|
|
25
32
|
* for older desktop builds (or any host without a websocket bridge).
|
|
@@ -116,9 +123,14 @@ export const dev = async (args: Args) => {
|
|
|
116
123
|
const origin = `http://${host}:${port}`
|
|
117
124
|
const token = loadDevToken(root)
|
|
118
125
|
|
|
126
|
+
const webDir = resolveWebRunnerDir()
|
|
127
|
+
if (flagBool(args, "web") && !webDir) {
|
|
128
|
+
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).")
|
|
129
|
+
}
|
|
119
130
|
const server = await startDevServer({
|
|
120
131
|
root, manifest, name, origin, port, token,
|
|
121
132
|
entryOverride: flagStr(args, "entry"),
|
|
133
|
+
webDir,
|
|
122
134
|
log: note,
|
|
123
135
|
}).catch((e: unknown) => {
|
|
124
136
|
const msg = e instanceof Error ? e.message : String(e)
|
|
@@ -180,6 +192,10 @@ export const dev = async (args: Args) => {
|
|
|
180
192
|
log("")
|
|
181
193
|
if (!flagBool(args, "no-qr")) await printQr(server.url)
|
|
182
194
|
log(`Scan the QR with the LeCodes app, or open: ${c.bold(server.url)}`)
|
|
195
|
+
// The browser runner: localhost is a secure context (camera works there), the LAN address is
|
|
196
|
+
// what a phone's browser can reach.
|
|
197
|
+
const webLocal = server.webUrl ? server.webUrl.replace(`http://${host}:`, "http://localhost:") : null
|
|
198
|
+
if (server.webUrl && webLocal) log(`In the browser (Web Lite): ${c.bold(webLocal)} (LAN: ${server.webUrl})`)
|
|
183
199
|
note(`Older desktop builds (no websocket bridge): ${server.url}?t=poll`)
|
|
184
200
|
if (alternatives.length > 0) {
|
|
185
201
|
note(`Device can't reach it? This machine also has ${alternatives.map((a) => `${a.address} (${a.iface})`).join(", ")} — retry with --host <ip>.`)
|
|
@@ -188,6 +204,12 @@ export const dev = async (args: Args) => {
|
|
|
188
204
|
log("")
|
|
189
205
|
log(`Watching ${root} — save a file to hot-reload. Ctrl+C to stop.`)
|
|
190
206
|
|
|
207
|
+
// --web: open the browser runner (the server keeps serving it either way).
|
|
208
|
+
if (flagBool(args, "web") && webLocal) {
|
|
209
|
+
note("Opening the browser…")
|
|
210
|
+
openBrowser(webLocal)
|
|
211
|
+
}
|
|
212
|
+
|
|
191
213
|
// --desktop: open the dev URL in the native host too (same server, same hot reload — the host
|
|
192
214
|
// speaks the websocket channel). Window config comes from app.json's desktop block. The window
|
|
193
215
|
// closing does NOT stop the server: phones stay connected, and re-running with --desktop is the
|
|
@@ -23,7 +23,7 @@ lecodes render src/screens/cart.ts # ONE screen module, without booting the ap
|
|
|
23
23
|
# --state <name> picks the branch). Same flags as above.
|
|
24
24
|
lecodes render --desktop --png shot.png # NATIVE render: real Filament 3D + UI + 2D composited —
|
|
25
25
|
# use this to SEE 3D scenes / procedural geometry (headless
|
|
26
|
-
# renders record 3D as JSON
|
|
26
|
+
# renders record 3D as JSON — simulated, not drawn). Windows/Linux; downloads
|
|
27
27
|
# the renderer on first use. --frames <n> for slow loads.
|
|
28
28
|
lecodes render --script <file> # drive the app through a scenario (format below)
|
|
29
29
|
lecodes render --desktop --view iso --png iso.png # 3D: inspection camera fitted to the WHOLE
|
|
@@ -72,8 +72,9 @@ advance in fixed 1/60 steps driven by \`wait\`/\`frames\`/\`drag\`/\`key\` — n
|
|
|
72
72
|
so a flow that passes once passes every time. \`Input.key(code)\` reads the keys a flow holds and
|
|
73
73
|
\`Input.on('keydown' | 'keyup', e => e.code)\` fires for each \`key\`/\`keyDown\`/\`keyUp\` step
|
|
74
74
|
(events land before the next loop tick); \`stick\` values are what \`Input.gamepad(0).axis()\` reads.
|
|
75
|
-
Headless draws UI + the 2D scene (sprites, tilemaps, Box2D); 3D is captured as JSON
|
|
76
|
-
|
|
75
|
+
Headless draws UI + the 2D scene (sprites, tilemaps, Box2D); 3D is captured as JSON with REAL Jolt
|
|
76
|
+
physics (bodies fall, characters collide, triggers fire, ragdolls drop — a character with no ground
|
|
77
|
+
under it falls, like on device) — use \`--desktop\` for 3D pixels. \`console.log\` + \`expect.log\` is the assertion
|
|
77
78
|
channel for game state that has no UI.
|
|
78
79
|
|
|
79
80
|
## Make the app testable
|
package/src/commands/render.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { compileHeadlessBundle } from "../compile/headlessBundle"
|
|
|
4
4
|
import { screenTargetFromArgs } from "../compile/screenEntry"
|
|
5
5
|
import { findProjectRoot } from "../manifest"
|
|
6
6
|
import { buildBundle } from "./compile"
|
|
7
|
-
import { desktopDefaultViewport, readAppConfigAt, resolveDesktopRenderer } from "./appShared"
|
|
7
|
+
import { desktopDefaultViewport, desktopPluginLibraries, readAppConfigAt, resolveDesktopRenderer } from "./appShared"
|
|
8
8
|
import { runDesktopRender } from "../desktopRenderer"
|
|
9
9
|
import { evaluateDesktopRun, translateScenario } from "../desktopScript"
|
|
10
10
|
import { CliError, c, flagStr, flagBool, isNullSink, logErr, note, warnErr, type Args } from "../util"
|
|
@@ -196,6 +196,8 @@ const renderDesktop = async (args: Args) => {
|
|
|
196
196
|
|
|
197
197
|
// One renderer decision: the host binary AND the staged .filamat variant (see appShared).
|
|
198
198
|
const desktopRenderer = resolveDesktopRenderer(root, args)
|
|
199
|
+
// Native plugins (project plugins/ dir + declared libraries) — the host scans CREATOR_PLUGIN_DIRS.
|
|
200
|
+
const { dirs: pluginDirs } = desktopPluginLibraries(root)
|
|
199
201
|
const { js, resources } = await buildBundle(args, undefined, {
|
|
200
202
|
localAssets: true, desktopRenderer,
|
|
201
203
|
extraEntries: screen ? [screen.entry] : undefined, entryOverride: screen?.entryPath,
|
|
@@ -212,7 +214,7 @@ const renderDesktop = async (args: Args) => {
|
|
|
212
214
|
const timeoutMs = Number(flagStr(args, "timeout")) || (60000 + plan.steps.length * 5000)
|
|
213
215
|
note(`Running ${plan.steps.length} step${plan.steps.length === 1 ? "" : "s"} (native)…`)
|
|
214
216
|
const { output } = await runDesktopRender(js, resources, {
|
|
215
|
-
renderer: desktopRenderer,
|
|
217
|
+
renderer: desktopRenderer, pluginDirs,
|
|
216
218
|
width, height, frames: plan.frameCap, outPng: pngFlag, logs, timeoutMs, script: plan.text, fixedDtMs,
|
|
217
219
|
})
|
|
218
220
|
const result = evaluateDesktopRun(plan, output)
|
|
@@ -234,7 +236,7 @@ const renderDesktop = async (args: Args) => {
|
|
|
234
236
|
const timeoutMs = Number(flagStr(args, "timeout")) || 60000
|
|
235
237
|
note(screen ? `Rendering ${screen.label} (native)…` : "Rendering (native)…")
|
|
236
238
|
await runDesktopRender(js, resources, {
|
|
237
|
-
renderer: desktopRenderer,
|
|
239
|
+
renderer: desktopRenderer, pluginDirs,
|
|
238
240
|
width, height, frames, outPng: pngFlag, logs, timeoutMs, fixedDtMs, camera, view,
|
|
239
241
|
})
|
|
240
242
|
note(`Wrote ${pngFlag} (${width}x${height}, native render${view ? `, ${view.split(";")[0]} view` : camera ? ", custom camera" : ""})`)
|
package/src/commands/test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs"
|
|
2
2
|
import { dirname, resolve } from "node:path"
|
|
3
3
|
import { compileHeadlessBundle } from "../compile/headlessBundle"
|
|
4
|
-
import { desktopDefaultViewport, resolveDesktopRenderer } from "./appShared"
|
|
4
|
+
import { desktopDefaultViewport, desktopPluginLibraries, resolveDesktopRenderer } from "./appShared"
|
|
5
5
|
import { fileScenarioIO, requireRenderer, resolveFixtures, resolveSafeAreaChoice } from "./render"
|
|
6
6
|
import { buildBundle } from "./compile"
|
|
7
7
|
import { findProjectRoot } from "../manifest"
|
|
@@ -261,6 +261,7 @@ export const test = async (args: Args) => {
|
|
|
261
261
|
const finalPng = resolve(caseDir, "final.png")
|
|
262
262
|
const { output } = await runDesktopRender(native!.js, native!.resources, {
|
|
263
263
|
renderer: desktopRenderer,
|
|
264
|
+
pluginDirs: desktopPluginLibraries(root).dirs,
|
|
264
265
|
width: Number(flagStr(args, "width")) || kase.width || projectViewport?.width || 390,
|
|
265
266
|
height: Number(flagStr(args, "height")) || kase.height || projectViewport?.height || 844,
|
|
266
267
|
frames: plan.frameCap, outPng: finalPng, logs: flagBool(args, "logs"),
|
package/src/desktopRenderer.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { execFileSync, spawn, spawnSync } from "node:child_process"
|
|
2
2
|
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
|
|
3
3
|
import { homedir, tmpdir } from "node:os"
|
|
4
|
-
import { join, resolve } from "node:path"
|
|
4
|
+
import { delimiter, join, resolve } from "node:path"
|
|
5
5
|
import { cachedArtifactSha, downloadReleaseArchive, fetchReleaseProduct, semverNewer, type ManifestVersion } from "./releases"
|
|
6
6
|
import { CliError, note, warnErr } from "./util"
|
|
7
7
|
import { makeStackDemangler } from "./compile/nativeStack"
|
|
@@ -299,10 +299,20 @@ export type DesktopWindowOpts = {
|
|
|
299
299
|
/** Multiplayer launch facts for the host (`--server --port N --max-clients N` / `--connect ip:port`);
|
|
300
300
|
* the SDK reads them through `Net.launch` and decides (docs/multiplayer-plan.md). */
|
|
301
301
|
net?: { role: "server" | "client"; address?: string; port?: number; maxClients?: number }
|
|
302
|
+
/** Dirs the host scans for native plugin libraries (CREATOR_PLUGIN_DIRS; the loader was added
|
|
303
|
+
* after host 1.4.0 — an older host ignores the env). See appShared.desktopPluginLibraries. */
|
|
304
|
+
pluginDirs?: string[]
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** CREATOR_PLUGIN_DIRS: the project's plugin dirs first, then whatever the environment already
|
|
308
|
+
* carried (a `.env` CREATOR_PLUGIN_DIRS keeps working alongside the project's `plugins/`). */
|
|
309
|
+
const pluginEnv = (dirs: string[] | undefined): Record<string, string> => {
|
|
310
|
+
const all = [...(dirs ?? []), ...(process.env.CREATOR_PLUGIN_DIRS ? [process.env.CREATOR_PLUGIN_DIRS] : [])]
|
|
311
|
+
return all.length > 0 ? { CREATOR_PLUGIN_DIRS: all.join(delimiter) } : {}
|
|
302
312
|
}
|
|
303
313
|
|
|
304
314
|
const windowEnv = (opts: DesktopWindowOpts): Record<string, string> => {
|
|
305
|
-
const env: Record<string, string> = {}
|
|
315
|
+
const env: Record<string, string> = { ...pluginEnv(opts.pluginDirs) }
|
|
306
316
|
// A lone width/height fills the other side from the host default so it isn't silently ignored.
|
|
307
317
|
const width = opts.width ?? (opts.height !== undefined ? 1280 : undefined)
|
|
308
318
|
const height = opts.height ?? (opts.width !== undefined ? 720 : undefined)
|
|
@@ -356,6 +366,8 @@ export type DesktopRenderOptions = {
|
|
|
356
366
|
/** Inspection camera: CREATOR_CAMERA ("ex,ey,ez;tx,ty,tz[;fov]") or CREATOR_VIEW ("iso[;fov]"). */
|
|
357
367
|
camera?: string
|
|
358
368
|
view?: string
|
|
369
|
+
/** Dirs the host scans for native plugin libraries (CREATOR_PLUGIN_DIRS). */
|
|
370
|
+
pluginDirs?: string[]
|
|
359
371
|
}
|
|
360
372
|
|
|
361
373
|
export type DesktopRenderOutcome = {
|
|
@@ -482,6 +494,7 @@ export const runDesktopRender = async (
|
|
|
482
494
|
if (opts.fixedDtMs) env.CREATOR_FIXED_DT = String(opts.fixedDtMs)
|
|
483
495
|
if (opts.camera) env.CREATOR_CAMERA = opts.camera
|
|
484
496
|
if (opts.view) env.CREATOR_VIEW = opts.view
|
|
497
|
+
Object.assign(env, pluginEnv(opts.pluginDirs))
|
|
485
498
|
// Frames the host prints name the minified bundle (`module.js:5:51631`); map them back to
|
|
486
499
|
// project positions. Chunks stay raw so `[script]` marker parsing sees exactly what was sent —
|
|
487
500
|
// the rewrite happens on the joined text (and best-effort per chunk for live --logs).
|
package/src/dev/devServer.ts
CHANGED
|
@@ -24,6 +24,9 @@ import { makeStackDemangler } from "../compile/nativeStack"
|
|
|
24
24
|
* GET /t/<tok>/version {"v":N} — polling fallback
|
|
25
25
|
* POST /t/<tok>/log log/error backchannel for polling clients
|
|
26
26
|
* WS /t/<tok>/ws reload push + log/error backchannel
|
|
27
|
+
* GET /t/<tok>/web/[file] the Web Lite browser runner (viewer-lite dist-embed, `dev --web`):
|
|
28
|
+
* it loads bundle.js?t=web — the bundle WITHOUT the native prelude;
|
|
29
|
+
* the page owns reload (ws / version polling) and the log backchannel
|
|
27
30
|
*
|
|
28
31
|
* Compile is eager: the command calls recompile() on every file change; a successful compile bumps
|
|
29
32
|
* `v` and broadcasts {type:"reload", v} to connected sockets (polling clients see /version move).
|
|
@@ -41,6 +44,8 @@ export type DevServerOptions = {
|
|
|
41
44
|
port: number
|
|
42
45
|
token: string
|
|
43
46
|
entryOverride?: string
|
|
47
|
+
/** The Web Lite runner folder to serve under /web/ (src/dev/webRunner.ts); absent = no browser route. */
|
|
48
|
+
webDir?: string | null
|
|
44
49
|
/** Diagnostics (compile errors, device logs); defaults to stderr-style silence-safe noop. */
|
|
45
50
|
log?: (msg: string) => void
|
|
46
51
|
}
|
|
@@ -51,6 +56,8 @@ export type RecompileResult =
|
|
|
51
56
|
|
|
52
57
|
export type DevServer = {
|
|
53
58
|
url: string
|
|
59
|
+
/** The browser runner page (`dev --web`), or null when no runner is vendored. */
|
|
60
|
+
webUrl: string | null
|
|
54
61
|
/** The token base path ("/t/<token>"). */
|
|
55
62
|
base: string
|
|
56
63
|
/** The actually-bound port (differs from options.port when 0 was passed, e.g. in tests). */
|
|
@@ -68,7 +75,8 @@ const MIME: Record<string, string> = {
|
|
|
68
75
|
mp3: "audio/mpeg", wav: "audio/wav", ogg: "audio/ogg", m4a: "audio/mp4",
|
|
69
76
|
mp4: "video/mp4", webm: "video/webm",
|
|
70
77
|
ttf: "font/ttf", otf: "font/otf", woff: "font/woff", woff2: "font/woff2",
|
|
71
|
-
json: "application/json", txt: "text/plain", js: "text/javascript",
|
|
78
|
+
json: "application/json", txt: "text/plain", js: "text/javascript", mjs: "text/javascript",
|
|
79
|
+
css: "text/css", html: "text/html", wasm: "application/wasm", map: "application/json",
|
|
72
80
|
}
|
|
73
81
|
|
|
74
82
|
const contentType = (path: string): string => {
|
|
@@ -182,22 +190,54 @@ export const startDevServer = async (options: DevServerOptions): Promise<DevServ
|
|
|
182
190
|
}
|
|
183
191
|
|
|
184
192
|
// ---- http --------------------------------------------------------------------------------------
|
|
185
|
-
|
|
193
|
+
// "web" = the browser runner: no prelude at all (the page brings its own reload + log channel).
|
|
194
|
+
const transportOf = (url: URL): "ws" | "poll" | "web" => {
|
|
195
|
+
const t = url.searchParams.get("t")
|
|
196
|
+
return t === "poll" ? "poll" : t === "web" ? "web" : "ws"
|
|
197
|
+
}
|
|
198
|
+
const withPrelude = (js: string, url: URL, v: number) => {
|
|
199
|
+
const transport = transportOf(url)
|
|
200
|
+
return transport === "web" ? js : js + "\n" + renderPrelude({ origin, base, transport, v })
|
|
201
|
+
}
|
|
202
|
+
const webDir = options.webDir ?? null
|
|
186
203
|
|
|
187
204
|
const handle = async (req: IncomingMessage, res: ServerResponse) => {
|
|
188
205
|
const url = new URL(req.url ?? "/", origin)
|
|
189
206
|
const path = url.pathname
|
|
207
|
+
// Browsers ask for it on every page load (the /web/ runner) — keep the console clean.
|
|
208
|
+
if (path === "/favicon.ico") { res.writeHead(204); res.end(); return }
|
|
190
209
|
if (!path.startsWith(base + "/")) return sendText(res, 404, "text/plain", "Not found")
|
|
191
210
|
const route = path.slice(base.length)
|
|
192
211
|
|
|
193
212
|
if (route === "/dev.js") {
|
|
194
|
-
return sendText(res, 200, "text/javascript",
|
|
195
|
-
devJs + "\n" + renderPrelude({ origin, base, transport: transportOf(url), v: -1 }))
|
|
213
|
+
return sendText(res, 200, "text/javascript", withPrelude(devJs, url, -1))
|
|
196
214
|
}
|
|
197
215
|
if (route === "/bundle.js") {
|
|
198
216
|
if (bundleJs === null) return sendText(res, 503, "text/plain", "No successful compile yet — check the terminal.")
|
|
199
|
-
|
|
200
|
-
|
|
217
|
+
res.setHeader("x-lecodes-v", String(v)) // the browser runner learns the version it runs
|
|
218
|
+
return sendText(res, 200, "text/javascript", withPrelude(bundleJs, url, v))
|
|
219
|
+
}
|
|
220
|
+
// The browser runner: a static folder with relative asset paths, so it needs the trailing slash.
|
|
221
|
+
if (route === "/web") {
|
|
222
|
+
res.writeHead(302, { location: `${base}/web/${url.search}` })
|
|
223
|
+
res.end()
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
if (route.startsWith("/web/")) {
|
|
227
|
+
if (!webDir) return sendText(res, 404, "text/plain", "No Web Lite runner vendored with this CLI (lecodes-viewer-lite dist-embed).")
|
|
228
|
+
const rel = route === "/web/" ? "embed.html" : route.slice("/web/".length)
|
|
229
|
+
if (rel.split("/").some((seg) => seg === ".." || seg === "" || seg === ".")) return sendText(res, 400, "text/plain", "Bad path")
|
|
230
|
+
const abs = join(webDir, ...rel.split("/"))
|
|
231
|
+
try {
|
|
232
|
+
if (!statSync(abs).isFile()) throw new Error("not a file")
|
|
233
|
+
} catch {
|
|
234
|
+
return sendText(res, 404, "text/plain", "Not found")
|
|
235
|
+
}
|
|
236
|
+
// The entry files (html, embed.js) must revalidate — a CLI update swaps them under the same URL.
|
|
237
|
+
const hashed = rel.startsWith("assets/")
|
|
238
|
+
res.writeHead(200, { "content-type": contentType(rel), "cache-control": hashed ? "max-age=31536000, immutable" : "no-store" })
|
|
239
|
+
createReadStream(abs).pipe(res)
|
|
240
|
+
return
|
|
201
241
|
}
|
|
202
242
|
if (route === "/version") {
|
|
203
243
|
return sendJson(res, 200, { v })
|
|
@@ -279,6 +319,7 @@ export const startDevServer = async (options: DevServerOptions): Promise<DevServ
|
|
|
279
319
|
|
|
280
320
|
return {
|
|
281
321
|
url: `${origin}${base}/dev.js`,
|
|
322
|
+
webUrl: webDir ? `${origin}${base}/web/` : null,
|
|
282
323
|
base,
|
|
283
324
|
port: boundPort,
|
|
284
325
|
v: () => v,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { existsSync } from "node:fs"
|
|
2
|
+
import { createRequire } from "node:module"
|
|
3
|
+
import { dirname, join } from "node:path"
|
|
4
|
+
import { distRoot } from "../distRoot"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Where the browser runner for `lecodes dev --web` lives: viewer-lite's built `dist-embed`
|
|
8
|
+
* (embed.html + embed.js + assets/ — the Web Lite host as a static folder). Two shapes:
|
|
9
|
+
* - vendored next to the CLI as runtime/web (scripts/vendor-runtime.ts — the npm package and
|
|
10
|
+
* the standalone binary),
|
|
11
|
+
* - the lecodes-viewer-lite package's own dist-embed (monorepo dev after `bun run build:embed`).
|
|
12
|
+
* `null` = no runner available (the dev server then serves everything but /web/).
|
|
13
|
+
*/
|
|
14
|
+
export const resolveWebRunnerDir = (): string | null => {
|
|
15
|
+
const candidates = [join(distRoot, "runtime", "web")]
|
|
16
|
+
try {
|
|
17
|
+
const pkg = createRequire(import.meta.url).resolve("lecodes-viewer-lite/package.json")
|
|
18
|
+
candidates.push(join(dirname(pkg), "dist-embed"))
|
|
19
|
+
} catch { /* no package: standalone */ }
|
|
20
|
+
for (const dir of candidates) if (existsSync(join(dir, "embed.html")) && existsSync(join(dir, "embed.js"))) return dir
|
|
21
|
+
return null
|
|
22
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -120,6 +120,9 @@ ${c.bold("Commands:")}
|
|
|
120
120
|
--host <ip> LAN address to advertise (default: auto-detect)
|
|
121
121
|
--entry <path> override the entrypoint
|
|
122
122
|
--no-qr don't render the QR code
|
|
123
|
+
--web also open the app in the browser (Web Lite host:
|
|
124
|
+
same bundle + hot reload, camera/map plugins
|
|
125
|
+
have web twins; the /web/ URL is served always)
|
|
123
126
|
--desktop also open the app in the native desktop host
|
|
124
127
|
(same hot reload; window from app.json's
|
|
125
128
|
desktop block; closing it keeps the server)
|