lecodes-cli 2.4.1 → 2.4.3
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 +36134 -11203
- package/package.json +10 -9
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/VERSION.json +3 -3
- package/runtime/web/assets/ar-D6oDL4pw.js +1 -0
- package/runtime/web/assets/ar-mVXX3kRQ.js +2 -0
- package/runtime/web/assets/qrAnchorWorker-Cb5E8cF-.js +1 -0
- package/runtime/web/assets/qr_anchor_wasm_bg-D_ePkCCu.wasm +0 -0
- package/runtime/web/index.html +1 -0
- package/runtime/web/player.js +12 -12
- package/runtime/web-lite/assets/{index-B2zEJlWS.js → index-oPsDD47E.js} +1 -1
- package/runtime/web-lite/assets/scene3d-BAl_QQyi.js +1105 -0
- package/runtime/web-lite/features.json +2 -2
- package/runtime/web-lite/index.html +2 -1
- package/runtime/web-lite/lite.js +9 -9
- package/runtime/web-lite/plugins/ar/_virtual_lecodes-half-resources-CNYmVR88.js +1 -0
- package/runtime/web-lite/plugins/ar/assets/qrAnchorWorker-DcxVKAqo.js +1 -0
- package/runtime/web-lite/plugins/ar/assets/qr_anchor_wasm_bg-D_ePkCCu.wasm +0 -0
- package/runtime/web-lite/plugins/ar/index-C2uuuLhz.js +2 -0
- package/runtime/web-lite/plugins.json +1 -1
- package/src/commands/app/android.ts +68 -19
- package/src/commands/app/index.ts +84 -17
- package/src/commands/app/shared.ts +157 -24
- package/src/commands/app/splash.ts +251 -0
- package/src/commands/app/templates/android.ts +148 -25
- package/src/commands/app/templates/ios.ts +123 -12
- package/src/commands/init/index.ts +195 -195
- package/src/commands/plugin.ts +3 -3
- package/src/commands/push.ts +2 -2
- package/src/commands/shared.ts +79 -79
- package/src/commands/thumbs.ts +185 -185
- package/src/commands/web.ts +12 -5
- package/src/compile/collect.ts +2 -3
- package/src/compile/collectLocal.ts +13 -2
- package/src/compile/designCompile.ts +2 -3
- package/src/compile/projectCompile.ts +3 -0
- package/src/compile/screenEntry.ts +123 -123
- package/src/dev/devServer.ts +63 -7
- package/src/dev/https.ts +50 -0
- package/src/dev/serve.ts +15 -9
- package/src/dev/webRunner.ts +11 -3
- package/src/platform/serverDiff.ts +2 -3
- package/src/plugin/emitPackage.ts +18 -5
- package/src/plugin/emitWeb.ts +17 -8
- package/src/plugin/firstParty.ts +30 -0
- package/src/plugin/generate.ts +52 -24
- package/src/plugin/manifest.ts +66 -7
- package/src/project/ignore.ts +164 -164
- package/src/project/localFiles.ts +44 -7
- package/src/web/build.ts +33 -9
- package/src/web/halves.ts +76 -0
- package/runtime/web/assets/ar-CHvaraG4.js +0 -1
- package/runtime/web-lite/assets/scene3d-Bew8g7Ws.js +0 -1081
- package/runtime/web-lite/plugins/ar/index-CHvaraG4.js +0 -1
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process"
|
|
2
2
|
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
|
|
3
3
|
import { homedir } from "node:os"
|
|
4
|
-
import { dirname, join, resolve } from "node:path"
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path"
|
|
5
5
|
import { findProjectRoot, readManifest, type Manifest } from "../../project/manifest"
|
|
6
6
|
import { loadConfig, requireApiUrl } from "../../platform/config"
|
|
7
7
|
import { DEFAULT_IGNORE_FILE } from "../../project/ignore"
|
|
8
8
|
import { CliError, c, log, note, warn } from "../../cli"
|
|
9
9
|
import { pascal, readManifest as readPluginManifest, type IosPackage, type PluginManifest as PluginManifest2 } from "../../plugin/manifest"
|
|
10
|
-
import {
|
|
10
|
+
import { PLUGIN_PLATFORMS, isLocalPluginSpec, pluginEntriesOf, pluginSpecOf, type PluginEntry } from "lecodes-sdk/compile"
|
|
11
11
|
|
|
12
12
|
/** The 2.x manifest (plugin/manifest.ts), plus the desktop branch the desktop flow reads: a
|
|
13
13
|
* PREBUILT shared library per OS/arch behind the C ABI in hosts/desktop/plugin-sdk — another
|
|
@@ -23,7 +23,12 @@ export type PluginManifest = PluginManifest2 & { desktop?: { library?: Record<st
|
|
|
23
23
|
/** First-party plugin registry: id → GitHub repo holding plugins/<id>/. Third-party plugins
|
|
24
24
|
* use a `github:owner/repo[@tag]` spec in app.json instead (manifest at the repo root); a plugin
|
|
25
25
|
* of the project itself a PATH (`./plugins/<id>` — the folder with its lecodes-plugin.json,
|
|
26
|
-
* generated by `lecodes plugin gen`; nothing to publish).
|
|
26
|
+
* generated by `lecodes plugin gen`; nothing to publish).
|
|
27
|
+
* What a SHELL can vendor: the plugins of the repo's released v* tags (a sync checks out the
|
|
28
|
+
* newest). The newer first-party plugins (ar, speech, audio-recorder, webrtc) are in the viewers
|
|
29
|
+
* and in the SDK, and in no tag yet — an id joins this list with the tag that carries it; listing
|
|
30
|
+
* one before would make `app sync` clone a tag without its folder. compile, link and the web
|
|
31
|
+
* builds never read this list (the header lists app.json's keys; the web halves are Web Lite's). */
|
|
27
32
|
export const FIRST_PARTY_PLUGINS: Record<string, string> = {
|
|
28
33
|
"camera": "letary/lecodes-plugins",
|
|
29
34
|
"qr-scanner": "letary/lecodes-plugins",
|
|
@@ -44,22 +49,52 @@ export interface AppConfig {
|
|
|
44
49
|
/** Icon backdrop ("#RGB"/"#RRGGBB", default white): the Android adaptive-icon background
|
|
45
50
|
* layer, and what a transparent master is flattened onto for the opacity-required iOS icon. */
|
|
46
51
|
iconBackground?: string
|
|
47
|
-
|
|
52
|
+
/** The launch screen of both shells, rendered by every sync (splash.ts): a full-screen colour
|
|
53
|
+
* (`background`, any opaque CSS color, default `iconBackground`, default white) and, optionally,
|
|
54
|
+
* `image` (project-root-relative PNG/JPEG) fitted CENTRED into a square of a fixed logical side
|
|
55
|
+
* on both platforms — `size` pt / dp, a whole number, default 160 — never at its pixel size:
|
|
56
|
+
* sync renders every density from the one master (iOS @1x/@2x/@3x = size × 1/2/3 px, Android
|
|
57
|
+
* mdpi…xxxhdpi = size × 1/1.5/2/3/4 px), so a master under 3 × size px warns (it would upscale)
|
|
58
|
+
* and a non-square one is fitted into the square keeping its aspect. `size` above 192 warns
|
|
59
|
+
* (Android 12+ shows only the inner 192 dp circle of the splash icon, the rest is cut; iOS and
|
|
60
|
+
* Android before 12 show it whole), above 288 (the Android 12+ icon canvas) is refused.
|
|
61
|
+
* Recommend a square mark: Android 12+ crops the splash icon to a circle. No image = the colour
|
|
62
|
+
* alone. With the key, the generated files become CLI-owned (iOS: LaunchScreen.storyboard + the
|
|
63
|
+
* LeCodesSplash imageset / LeCodesSplashBackground colorset; Android: the lecodes-runtime
|
|
64
|
+
* module's res, through `Theme.LeCodesRuntime` — the parent the app's theme must name). Absent
|
|
65
|
+
* = nothing is generated: the storyboard stays the person's, the Android window stays black. */
|
|
66
|
+
splash?: { background?: string, image?: string, size?: number }
|
|
67
|
+
/** id → a spec for every platform (`"*"`, `"^2.0.0"`, `"github:owner/repo@v1"`, `"./plugins/<id>"`)
|
|
68
|
+
* or a spec per platform (`{ "web": "./plugins/ar-8thwall" }`; a platform it does not name is
|
|
69
|
+
* `"*"`). Read in the list form too (readAppConfigAt); a platform's spec is pluginSpecOf's. */
|
|
70
|
+
plugins?: Record<string, PluginEntry>
|
|
48
71
|
/** The custom URL scheme the shells open on (`myshop` → `myshop://…`): iOS CFBundleURLTypes,
|
|
49
72
|
* Android a BROWSABLE intent-filter on the activity. A link reaches the app as `app.launchUrl`
|
|
50
73
|
* / the "url" event — what `Browser.open(url, { returnUrl })` resolves on after an OAuth redirect.
|
|
51
74
|
* Lowercase letters, digits, `+ - .`, starting with a letter. */
|
|
52
75
|
urlScheme?: string
|
|
76
|
+
/** The verified https links the shells open on (`"https://shop.example/pay/"`: a URL prefix,
|
|
77
|
+
* several allowed): iOS universal links (`applinks:<host>` in the entitlements), Android App
|
|
78
|
+
* Links (an `autoVerify` intent-filter per host, with the paths). A link reaches the app as
|
|
79
|
+
* `app.launchUrl` / the "url" event — the return of `Browser.open(url, { returnUrl })` when
|
|
80
|
+
* the page (a bank's app, a checkout) sends the person back by https. The site must serve the
|
|
81
|
+
* two proofs (sync prints them): /.well-known/apple-app-site-association and
|
|
82
|
+
* /.well-known/assetlinks.json. One list for both platforms, like `plugins`. */
|
|
83
|
+
appLinks?: string[]
|
|
53
84
|
/** `remote`: lecodesignore-style globs of resources to STREAM from the server instead of
|
|
54
85
|
* embedding in the app (server-backed projects only — a streamed asset needs a server URL). */
|
|
55
86
|
resources?: { remote?: string[] }
|
|
56
87
|
/** Over-the-air JS updates: where the shell checks for newer compiled code at launch
|
|
57
|
-
* (natively, before the JS engine — offline is a no-op
|
|
88
|
+
* (natively, before the JS engine — offline is a no-op). The first launch of an installed
|
|
89
|
+
* build waits for the check (≤ 5 s, the splash held) and runs the newest bundle; every later
|
|
90
|
+
* launch runs the local bundle at once and new code applies next launch.
|
|
58
91
|
* `true` = this project's live bundle on the platform (server-backed only); or an https
|
|
59
92
|
* URL to a compiled bundle on any hosting (string or `{ "url": … }`). Absent = no OTA.
|
|
60
93
|
* Both shells: iOS reads it from Info.plist (LeCodesUpdater), Android from the manifest
|
|
61
94
|
* meta-data the CLI-owned lecodes-runtime module carries (LeCodesShell.boot). A download runs
|
|
62
|
-
* only when it is of the shell's SDK major and not older than the bundle running
|
|
95
|
+
* only when it is of the shell's SDK major and not older than the bundle running; a store
|
|
96
|
+
* update keeps a downloaded bundle only when it is newer code (by compile date) than the one
|
|
97
|
+
* the update ships. */
|
|
63
98
|
update?: boolean | string | { url?: string }
|
|
64
99
|
/** `variant`: SDK product — "auto" (default: sync picks the smallest one covering the engines
|
|
65
100
|
* the compiled bundle actually uses) or core|2d|3d|full to force one.
|
|
@@ -221,17 +256,94 @@ export const urlSchemeOf = (config: Pick<AppConfig, "urlScheme"> | null | undefi
|
|
|
221
256
|
return scheme
|
|
222
257
|
}
|
|
223
258
|
|
|
259
|
+
/** One app.json "appLinks" entry, parsed: the prefix as written, its host, its path ("" for the
|
|
260
|
+
* host's root — every path of the host). */
|
|
261
|
+
export interface AppLink { url: string, host: string, path: string }
|
|
262
|
+
|
|
263
|
+
/** app.json "appLinks", checked: https URL prefixes with a host, no query / fragment (a prefix
|
|
264
|
+
* ends at a path). Empty when the key is absent. */
|
|
265
|
+
export const appLinksOf = (config: Pick<AppConfig, "appLinks"> | null | undefined): AppLink[] => {
|
|
266
|
+
const raw = config?.appLinks
|
|
267
|
+
if (raw === undefined) return []
|
|
268
|
+
if (!Array.isArray(raw)) throw new CliError(`app.json "appLinks" must be a list of https URL prefixes (["https://shop.example/pay/"]) — got ${JSON.stringify(raw)}.`)
|
|
269
|
+
const links: AppLink[] = []
|
|
270
|
+
for (const entry of raw) {
|
|
271
|
+
let url: URL
|
|
272
|
+
try { url = new URL(typeof entry === "string" ? entry : "") } catch { url = null as any }
|
|
273
|
+
if (!url || url.protocol !== "https:" || !url.hostname) {
|
|
274
|
+
throw new CliError(`app.json "appLinks": ${JSON.stringify(entry)} is not an https URL prefix ("https://shop.example/pay/"). A custom scheme goes in "urlScheme".`)
|
|
275
|
+
}
|
|
276
|
+
if (url.search || url.hash || url.username || url.port) {
|
|
277
|
+
throw new CliError(`app.json "appLinks": ${JSON.stringify(entry)} — a prefix is a host and a path, no query, fragment, port or credentials.`)
|
|
278
|
+
}
|
|
279
|
+
const path = url.pathname === "/" ? "" : url.pathname
|
|
280
|
+
if (links.some(l => l.host === url.hostname && l.path === path)) continue
|
|
281
|
+
links.push({ url: `https://${url.hostname}${path}`, host: url.hostname, path })
|
|
282
|
+
}
|
|
283
|
+
return links
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** The iOS entitlement "appLinks" means: one `applinks:<host>` per host (the paths live in the
|
|
287
|
+
* site's apple-app-site-association). */
|
|
288
|
+
export const associatedDomainsOf = (links: AppLink[]): string[] => [...new Set(links.map(l => `applinks:${l.host}`))]
|
|
289
|
+
|
|
290
|
+
/** What the sites named by "appLinks" must serve for the OS to trust the links — printed by sync
|
|
291
|
+
* so nobody composes them by hand: the AASA for iOS (`teamId.bundleId` + the paths), assetlinks
|
|
292
|
+
* for Android (the package + the signing certificate's SHA-256, which `lecodes app keystore show`
|
|
293
|
+
* prints). One block per host. */
|
|
294
|
+
export const appLinksSiteNote = (links: AppLink[], ids: { platform: "ios", teamId?: string, bundleId?: string } | { platform: "android", applicationId?: string }): string[] => {
|
|
295
|
+
const lines: string[] = []
|
|
296
|
+
const hosts = [...new Set(links.map(l => l.host))]
|
|
297
|
+
for (const host of hosts) {
|
|
298
|
+
const paths = links.filter(l => l.host === host).map(l => l.path)
|
|
299
|
+
const patterns = paths.includes("") ? ["*"] : paths.map(p => `${p}*`)
|
|
300
|
+
if (ids.platform === "ios") {
|
|
301
|
+
const appId = `${ids.teamId ?? "<TEAM_ID>"}.${ids.bundleId ?? "<BUNDLE_ID>"}`
|
|
302
|
+
lines.push(`https://${host}/.well-known/apple-app-site-association (Content-Type: application/json, no redirect — Apple's CDN fetches it at install):`)
|
|
303
|
+
lines.push(JSON.stringify({ applinks: { details: [{ appIDs: [appId], components: patterns.map(p => ({ "/": p })) }] } }))
|
|
304
|
+
} else {
|
|
305
|
+
lines.push(`https://${host}/.well-known/assetlinks.json (Content-Type: application/json):`)
|
|
306
|
+
lines.push(JSON.stringify([{
|
|
307
|
+
relation: ["delegate_permission/common.handle_all_urls"],
|
|
308
|
+
target: { namespace: "android_app", package_name: ids.applicationId ?? "<APPLICATION_ID>", sha256_cert_fingerprints: ["<SHA-256 of the signing certificate — `lecodes app keystore show`>"] },
|
|
309
|
+
}]))
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return lines
|
|
313
|
+
}
|
|
314
|
+
|
|
224
315
|
/** app.json wherever a command needs the project-level desktop/window config. Tolerant of a
|
|
225
316
|
* missing or broken file — standalone projects (no app.json) must keep working everywhere
|
|
226
317
|
* this is consulted. */
|
|
227
318
|
export const readAppConfigAt = (root: string): AppConfig | null => {
|
|
228
319
|
const config = readJson<AppConfig & { plugins?: unknown }>(join(root, "app.json"))
|
|
229
|
-
// `"plugins"` is read in
|
|
230
|
-
// every command below works on id →
|
|
231
|
-
|
|
320
|
+
// `"plugins"` is read in any shape (`["camera"]`, `{ "camera": "*" }`, `{ "ar": { "web": … } }`,
|
|
321
|
+
// the SDK's rule): every command below works on id → entry, and a list read as "no plugins"
|
|
322
|
+
// vendored nothing. An entry of no shape is refused here, by its key — the SDK reads it as "*".
|
|
323
|
+
if (config && "plugins" in config) {
|
|
324
|
+
checkPluginEntries(config.plugins)
|
|
325
|
+
config.plugins = pluginEntriesOf(config.plugins)
|
|
326
|
+
}
|
|
232
327
|
return config as AppConfig | null
|
|
233
328
|
}
|
|
234
329
|
|
|
330
|
+
const SPEC_WORDS = `a spec ("*", "^2.0.0", "github:owner/repo[@tag]", "./plugins/<id>") or a spec per platform ({ ${PLUGIN_PLATFORMS.map((p) => `"${p}"`).join(", ")} } → spec; a platform not named is "*")`
|
|
331
|
+
|
|
332
|
+
const checkPluginEntries = (plugins: unknown): void => {
|
|
333
|
+
if (plugins === undefined || Array.isArray(plugins)) return
|
|
334
|
+
if (!plugins || typeof plugins !== "object") throw new CliError(`app.json "plugins": a list of ids, or id → ${SPEC_WORDS}.`)
|
|
335
|
+
for (const [id, value] of Object.entries(plugins as Record<string, unknown>)) {
|
|
336
|
+
if (typeof value === "string" && value.trim() !== "") continue
|
|
337
|
+
const ok = !!value && typeof value === "object" && !Array.isArray(value) &&
|
|
338
|
+
Object.entries(value as Record<string, unknown>).every(([p, s]) => (PLUGIN_PLATFORMS as readonly string[]).includes(p) && typeof s === "string" && s.trim() !== "")
|
|
339
|
+
if (!ok) throw new CliError(`app.json "plugins"."${id}": ${SPEC_WORDS} — got ${JSON.stringify(value)}.`)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** app.json's plugins as one platform takes them: id → that platform's spec. */
|
|
344
|
+
export const pluginSpecsFor = (config: Pick<AppConfig, "plugins"> | null | undefined, platform: PluginPlatform): [string, string][] =>
|
|
345
|
+
Object.entries(config?.plugins ?? {}).map(([id, entry]) => [id, pluginSpecOf(entry, platform)])
|
|
346
|
+
|
|
235
347
|
/** The desktop host's own built-in fallback, mirrored where the CLI must fill a missing side. */
|
|
236
348
|
export const DESKTOP_DEFAULT_WINDOW = { width: 1280, height: 720 }
|
|
237
349
|
|
|
@@ -278,10 +390,6 @@ export type { IosPackage }
|
|
|
278
390
|
|
|
279
391
|
export interface LockEntry { repo: string, tag: string, commit: string, version: string }
|
|
280
392
|
|
|
281
|
-
/** A plugin spec that names a folder of the project (`./plugins/x`, `../x`, `/abs`, `file:x`). */
|
|
282
|
-
export const isLocalPluginSpec = (spec: string): boolean =>
|
|
283
|
-
spec.startsWith("./") || spec.startsWith("../") || spec.startsWith("/") || spec.startsWith("file:")
|
|
284
|
-
|
|
285
393
|
/** Resolve app.json "update" to the URL a shell's native updater checks (LeCodesUpdater on both
|
|
286
394
|
* platforms): absent or `true` = the project's live bundle on the platform —
|
|
287
395
|
* `/code/<uuid>.js?sdk=<version>`, the launcher's own URL, so a pushed project updates its shells
|
|
@@ -548,18 +656,43 @@ export const ensureRepo = (repo: string, wantTag: string | null, lock: LockEntry
|
|
|
548
656
|
|
|
549
657
|
/** Resolve one app.json plugin entry to vendorable sources + manifest + lock pin. The platform
|
|
550
658
|
* being synced must be supported (its manifest branch non-null) — a hard error otherwise. */
|
|
551
|
-
export type PluginPlatform = "ios" | "android" | "desktop"
|
|
552
|
-
const PLUGIN_PLATFORM_LABEL: Record<PluginPlatform, string> = { ios: "iOS", android: "Android", desktop: "desktop" }
|
|
659
|
+
export type PluginPlatform = "ios" | "android" | "desktop" | "web"
|
|
660
|
+
const PLUGIN_PLATFORM_LABEL: Record<PluginPlatform, string> = { ios: "iOS", android: "Android", desktop: "desktop", web: "the web" }
|
|
661
|
+
|
|
662
|
+
/** A plugin of the project itself (a local spec): its folder and its manifest. The key of app.json
|
|
663
|
+
* is the plugin's id: the folder's own (`"acme-dial": "./plugins/acme-dial"`), or — for a HALF
|
|
664
|
+
* FOLDER — the first-party plugin it implements (`"ar": { "web": "./plugins/ar-8thwall" }`). */
|
|
665
|
+
export const localPlugin = (root: string, id: string, spec: string): { dir: string, manifest: PluginManifest } => {
|
|
666
|
+
const dir = resolve(root, spec.startsWith("file:") ? spec.slice("file:".length) : spec)
|
|
667
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) throw new CliError(`Plugin "${id}": ${spec} is not a folder (${dir})`)
|
|
668
|
+
const folder = basename(dir)
|
|
669
|
+
const manifest: PluginManifest = readPluginManifest(dir, folder)
|
|
670
|
+
if (manifest.implements ? manifest.implements !== id : folder !== id) {
|
|
671
|
+
throw new CliError(manifest.implements
|
|
672
|
+
? `Plugin "${id}": ${spec} implements "${manifest.implements}" — app.json names it under that plugin: "${manifest.implements}": { "<platform>": "${spec}" }`
|
|
673
|
+
: `Plugin "${id}": ${spec} is the plugin "${folder}" — app.json names a plugin of the project by its folder's name`)
|
|
674
|
+
}
|
|
675
|
+
fillManifestDefaults(manifest, folder)
|
|
676
|
+
return { dir, manifest }
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** The key app.json names a local plugin under: its folder's name, or the first-party plugin a half
|
|
680
|
+
* folder implements. */
|
|
681
|
+
export const localPluginKey = (root: string, spec: string): string => {
|
|
682
|
+
const dir = resolve(root, spec.startsWith("file:") ? spec.slice("file:".length) : spec)
|
|
683
|
+
if (!existsSync(join(dir, "lecodes-plugin.json"))) return basename(dir)
|
|
684
|
+
return readPluginManifest(dir, basename(dir)).implements ?? basename(dir)
|
|
685
|
+
}
|
|
553
686
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
export function resolvePlugin(id: string,
|
|
687
|
+
/** `entry` is app.json's for the id, in either form: the platform's own spec is resolved (a
|
|
688
|
+
* per-platform entry that does not name the platform is the first-party plugin, `"*"`). */
|
|
689
|
+
export function resolvePlugin(id: string, entry: PluginEntry, lock: LockEntry | undefined, platform: PluginPlatform): ResolvedPlugin
|
|
690
|
+
export function resolvePlugin(id: string, entry: PluginEntry, lock: LockEntry | undefined, platform: PluginPlatform, opts: { optional: true }): ResolvedPlugin | null
|
|
691
|
+
export function resolvePlugin(id: string, entry: PluginEntry, lock: LockEntry | undefined, platform: PluginPlatform, opts?: { optional?: boolean }): ResolvedPlugin | null {
|
|
692
|
+
const spec = pluginSpecOf(entry, platform)
|
|
557
693
|
// A plugin of the project itself: its folder, as it is — no clone, no tag, nothing to publish.
|
|
558
694
|
if (isLocalPluginSpec(spec)) {
|
|
559
|
-
const dir =
|
|
560
|
-
if (!existsSync(dir)) throw new CliError(`Plugin "${id}": ${spec} is not a folder (${dir})`)
|
|
561
|
-
const manifest: PluginManifest = readPluginManifest(dir, id)
|
|
562
|
-
fillManifestDefaults(manifest, id)
|
|
695
|
+
const { dir, manifest } = localPlugin(projectRoot(), id, spec)
|
|
563
696
|
if (!manifest[platform]) {
|
|
564
697
|
if (opts?.optional) return null
|
|
565
698
|
throw new CliError(`Plugin "${id}" does not support ${PLUGIN_PLATFORM_LABEL[platform]} (manifest "${platform}" is null)`)
|
|
@@ -640,8 +773,8 @@ export const desktopPluginLibraries = (root: string, opts: { strict?: boolean }
|
|
|
640
773
|
dirs.push(local)
|
|
641
774
|
for (const f of readdirSync(local)) if (isDesktopPluginLibrary(f)) files.push(join(local, f))
|
|
642
775
|
}
|
|
643
|
-
const config = readJson<
|
|
644
|
-
const specs =
|
|
776
|
+
const config = readJson<{ plugins?: unknown }>(join(root, "app.json"))
|
|
777
|
+
const specs = pluginSpecsFor({ plugins: pluginEntriesOf(config?.plugins) }, "desktop")
|
|
645
778
|
if (specs.length === 0) return { dirs, files }
|
|
646
779
|
const lock = readJson<{ plugins?: Record<string, LockEntry> }>(join(root, "plugins.lock.json"))?.plugins ?? {}
|
|
647
780
|
const target = desktopPluginTarget()
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { dirname, join, resolve } from "node:path"
|
|
3
|
+
import type { JimpInstance } from "jimp"
|
|
4
|
+
import { Color } from "lecodes-sdk/color"
|
|
5
|
+
import { CliError, c, log, warn } from "../../cli"
|
|
6
|
+
import type { AppConfig } from "./shared"
|
|
7
|
+
import { splashStoryboardTemplate } from "./templates/ios"
|
|
8
|
+
import {
|
|
9
|
+
SPLASH_ICON_CANVAS_DP, runtimeThemeTemplate, runtimeThemeV31Template, splashIconTemplate, splashWindowTemplate,
|
|
10
|
+
type AndroidSplashResources,
|
|
11
|
+
} from "./templates/android"
|
|
12
|
+
|
|
13
|
+
/* The launch screen rendered from app.json "splash": { "background"?, "image"?, "size"? } — the
|
|
14
|
+
* colour and an optional mark fitted CENTRED into a fixed logical square, 160 pt / dp unless "size"
|
|
15
|
+
* says otherwise, on both shells (docs/plans/ota-splash-plan.md R6), every density rendered from the one master the way icon.ts renders the icons. The ownership
|
|
16
|
+
* rule is icon's: without the key sync never touches a launch-screen file (the storyboard stays the
|
|
17
|
+
* person's; removing the key later stops regeneration and deletes nothing); with it, these become
|
|
18
|
+
* CLI-owned:
|
|
19
|
+
* ios/App/Base.lproj/LaunchScreen.storyboard
|
|
20
|
+
* ios/App/Assets.xcassets/LeCodesSplashBackground.colorset/ + LeCodesSplash.imageset/
|
|
21
|
+
* On Android the resources live in the CLI-owned lecodes-runtime module, regenerated whole every
|
|
22
|
+
* sync — Theme.LeCodesRuntime is written with or without the key (black without), the app's
|
|
23
|
+
* user-owned Theme.LeCodesApp inherits it. jimp is imported lazily, as in icon.ts. */
|
|
24
|
+
|
|
25
|
+
/** The side of the square the mark is fitted into, pt on iOS and dp on Android, when app.json
|
|
26
|
+
* "splash" names no "size". */
|
|
27
|
+
export const SPLASH_DEFAULT_SIZE = 160
|
|
28
|
+
|
|
29
|
+
/** Android 12+ shows only this circle of its 288 dp splash-icon canvas (SPLASH_ICON_CANVAS_DP): a
|
|
30
|
+
* larger mark loses its corners / edges there — iOS and Android before 12 show it whole. */
|
|
31
|
+
const SPLASH_VISIBLE_CIRCLE_DP = 192
|
|
32
|
+
|
|
33
|
+
const IOS_SCALES = [1, 2, 3] as const
|
|
34
|
+
|
|
35
|
+
const ANDROID_DENSITIES = [
|
|
36
|
+
{ qualifier: "mdpi", scale: 1 },
|
|
37
|
+
{ qualifier: "hdpi", scale: 1.5 },
|
|
38
|
+
{ qualifier: "xhdpi", scale: 2 },
|
|
39
|
+
{ qualifier: "xxhdpi", scale: 3 },
|
|
40
|
+
{ qualifier: "xxxhdpi", scale: 4 },
|
|
41
|
+
] as const
|
|
42
|
+
|
|
43
|
+
export interface SplashMaster {
|
|
44
|
+
/** The decoded master (straight alpha), any shape; null = colour only. */
|
|
45
|
+
image: JimpInstance | null
|
|
46
|
+
/** The side of the square the image is fitted into, pt / dp (SPLASH_DEFAULT_SIZE by default). */
|
|
47
|
+
size: number
|
|
48
|
+
/** 0xRRGGBB. */
|
|
49
|
+
rgb: number
|
|
50
|
+
/** "#RRGGBB" for resource files. */
|
|
51
|
+
backgroundHex: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** app.json "splash" resolved, or null when the key is absent (nothing is generated then). */
|
|
55
|
+
export const loadSplashMaster = async (root: string, config: AppConfig): Promise<SplashMaster | null> => {
|
|
56
|
+
const splash = config.splash
|
|
57
|
+
if (splash === undefined) return null
|
|
58
|
+
if (splash === null || typeof splash !== "object" || Array.isArray(splash)) {
|
|
59
|
+
throw new CliError(`app.json "splash" must be an object — { "background"?: "#RRGGBB", "image"?: "./splash.png" } (got ${JSON.stringify(splash)})`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Any CSS color, opaque (a launch screen has nothing behind it): the SDK's one parser, as for
|
|
63
|
+
// the icon backdrop — which is also the default.
|
|
64
|
+
const [given, from] = splash.background !== undefined
|
|
65
|
+
? [splash.background, "splash.background"]
|
|
66
|
+
: [config.iconBackground ?? "#FFFFFF", "iconBackground"]
|
|
67
|
+
const rgba = Color.tryParse(given)
|
|
68
|
+
if (!rgba || (Color.toPackedRgba(rgba) & 0xff) !== 0xff) {
|
|
69
|
+
throw new CliError(`app.json "${from}" (the splash colour) must be an opaque CSS color — #RRGGBB, a name, rgb() / hsl() (got ${JSON.stringify(given)})`)
|
|
70
|
+
}
|
|
71
|
+
const rgb = Color.toPackedRgb(rgba)
|
|
72
|
+
const backgroundHex = "#" + rgb.toString(16).padStart(6, "0").toUpperCase()
|
|
73
|
+
|
|
74
|
+
// The logical side: past the 288 dp canvas the Android 12+ inset would go negative; past the
|
|
75
|
+
// 192 dp circle the system cuts the mark there.
|
|
76
|
+
const size = splash.size ?? SPLASH_DEFAULT_SIZE
|
|
77
|
+
if (typeof size !== "number" || !Number.isInteger(size) || size <= 0) {
|
|
78
|
+
throw new CliError(`app.json "splash.size" must be a positive whole number of pt / dp (got ${JSON.stringify(splash.size)})`)
|
|
79
|
+
}
|
|
80
|
+
if (size > SPLASH_ICON_CANVAS_DP) {
|
|
81
|
+
throw new CliError(`app.json "splash.size" is at most ${SPLASH_ICON_CANVAS_DP} — the Android 12+ splash-icon canvas (got ${size})`)
|
|
82
|
+
}
|
|
83
|
+
if (size > SPLASH_VISIBLE_CIRCLE_DP) {
|
|
84
|
+
warn(`"splash.size" ${size} is over ${SPLASH_VISIBLE_CIRCLE_DP}: Android 12+ shows only the inner ${SPLASH_VISIBLE_CIRCLE_DP} dp circle of the splash icon, so the image's corners / edges beyond it are cut (iOS and Android before 12 show it whole).`)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (splash.image === undefined) return { image: null, size, rgb, backgroundHex }
|
|
88
|
+
if (typeof splash.image !== "string" || !splash.image) {
|
|
89
|
+
throw new CliError(`app.json "splash.image" must be a project-root-relative path to a PNG / JPEG (got ${JSON.stringify(splash.image)})`)
|
|
90
|
+
}
|
|
91
|
+
const rel = splash.image
|
|
92
|
+
const path = resolve(root, rel)
|
|
93
|
+
if (!existsSync(path)) throw new CliError(`app.json "splash.image": ${rel} not found`)
|
|
94
|
+
const { Jimp } = await import("jimp")
|
|
95
|
+
let image: JimpInstance
|
|
96
|
+
try {
|
|
97
|
+
image = (await Jimp.read(path)) as JimpInstance
|
|
98
|
+
} catch (e) {
|
|
99
|
+
throw new CliError(`Could not decode "splash.image" ${rel} (${(e as Error).message}) — use a PNG or JPEG.`)
|
|
100
|
+
}
|
|
101
|
+
// Under 3 × size the iOS @3x / Android xxhdpi renderings are an upscale. xxxhdpi (4 × size)
|
|
102
|
+
// upscales a master between the two a little; that is accepted, no warning.
|
|
103
|
+
const minPx = 3 * size
|
|
104
|
+
if (Math.max(image.width, image.height) < minPx) {
|
|
105
|
+
warn(`"splash.image" ${rel} is ${image.width}×${image.height} — under ${minPx}px (3 × "size" ${size}), so the @3x / xxhdpi renderings are upscaled and may look soft.`)
|
|
106
|
+
}
|
|
107
|
+
return { image, size, rgb, backgroundHex }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The master fitted into a transparent px×px square, centred, aspect kept — every rendering is
|
|
111
|
+
* square, so the size×size frame on both platforms never stretches it. */
|
|
112
|
+
const renderSquare = async (master: JimpInstance, px: number): Promise<Buffer> => {
|
|
113
|
+
const { Jimp } = await import("jimp")
|
|
114
|
+
const canvas = new Jimp({ width: px, height: px, color: 0x00000000 })
|
|
115
|
+
const f = px / Math.max(master.width, master.height)
|
|
116
|
+
const w = Math.max(1, Math.round(master.width * f))
|
|
117
|
+
const h = Math.max(1, Math.round(master.height * f))
|
|
118
|
+
const fitted = master.clone() as JimpInstance
|
|
119
|
+
fitted.resize({ w, h })
|
|
120
|
+
canvas.composite(fitted, Math.round((px - w) / 2), Math.round((px - h) / 2))
|
|
121
|
+
return canvas.getBuffer("image/png")
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const writeFile = (abs: string, content: Uint8Array | string) => {
|
|
125
|
+
mkdirSync(dirname(abs), { recursive: true })
|
|
126
|
+
writeFileSync(abs, content)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/* ------------------------------------------ iOS ------------------------------------------ */
|
|
130
|
+
|
|
131
|
+
const hex2 = (v: number) => "0x" + v.toString(16).padStart(2, "0").toUpperCase()
|
|
132
|
+
|
|
133
|
+
const colorsetJson = (rgb: number): string => `{
|
|
134
|
+
"colors" : [
|
|
135
|
+
{
|
|
136
|
+
"color" : {
|
|
137
|
+
"color-space" : "srgb",
|
|
138
|
+
"components" : {
|
|
139
|
+
"alpha" : "1.000",
|
|
140
|
+
"blue" : "${hex2(rgb & 0xff)}",
|
|
141
|
+
"green" : "${hex2((rgb >> 8) & 0xff)}",
|
|
142
|
+
"red" : "${hex2((rgb >> 16) & 0xff)}"
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
"idiom" : "universal"
|
|
146
|
+
}
|
|
147
|
+
],
|
|
148
|
+
"info" : {
|
|
149
|
+
"author" : "xcode",
|
|
150
|
+
"version" : 1
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
`
|
|
154
|
+
|
|
155
|
+
const IMAGESET_JSON = `{
|
|
156
|
+
"images" : [
|
|
157
|
+
${IOS_SCALES.map(s => ` {
|
|
158
|
+
"filename" : "LeCodesSplash${s === 1 ? "" : `@${s}x`}.png",
|
|
159
|
+
"idiom" : "universal",
|
|
160
|
+
"scale" : "${s}x"
|
|
161
|
+
}`).join(",\n")}
|
|
162
|
+
],
|
|
163
|
+
"info" : {
|
|
164
|
+
"author" : "xcode",
|
|
165
|
+
"version" : 1
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
`
|
|
169
|
+
|
|
170
|
+
/** The launch storyboard + its colorset / imageset. Null = app.json has no "splash": nothing is
|
|
171
|
+
* touched. Without an image the imageset a previous sync wrote is removed (it is CLI-owned). */
|
|
172
|
+
export const writeIosSplash = async (iosDir: string, master: SplashMaster | null): Promise<void> => {
|
|
173
|
+
if (!master) return
|
|
174
|
+
const assets = join(iosDir, "App", "Assets.xcassets")
|
|
175
|
+
writeFile(join(assets, "LeCodesSplashBackground.colorset", "Contents.json"), colorsetJson(master.rgb))
|
|
176
|
+
const imageset = join(assets, "LeCodesSplash.imageset")
|
|
177
|
+
if (master.image) {
|
|
178
|
+
for (const s of IOS_SCALES) {
|
|
179
|
+
writeFile(join(imageset, `LeCodesSplash${s === 1 ? "" : `@${s}x`}.png`), await renderSquare(master.image, master.size * s))
|
|
180
|
+
}
|
|
181
|
+
writeFile(join(imageset, "Contents.json"), IMAGESET_JSON)
|
|
182
|
+
} else {
|
|
183
|
+
rmSync(imageset, { recursive: true, force: true })
|
|
184
|
+
}
|
|
185
|
+
writeFile(join(iosDir, "App", "Base.lproj", "LaunchScreen.storyboard"), splashStoryboardTemplate(!!master.image, master.rgb, master.size))
|
|
186
|
+
log(`${c.green("splash")} ${master.backgroundHex}${master.image ? ` + the image at ${master.size} pt (@1x/@2x/@3x)` : ""} → ios/App/Base.lproj/LaunchScreen.storyboard`)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/* ---------------------------------------- Android ---------------------------------------- */
|
|
190
|
+
|
|
191
|
+
/** The splash resources of the runtime module; every one of them is removed when it has no
|
|
192
|
+
* place (no key, no image), so a stale one never outlives its app.json value. */
|
|
193
|
+
const ANDROID_SPLASH_FILES = {
|
|
194
|
+
v31: join("values-v31", "lecodes_splash.xml"),
|
|
195
|
+
window: join("drawable", "lecodes_splash_window.xml"),
|
|
196
|
+
icon: join("drawable", "lecodes_splash_icon.xml"),
|
|
197
|
+
png: (qualifier: string) => join(`drawable-${qualifier}`, "lecodes_splash.png"),
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** lecodes-runtime/src/main/res: Theme.LeCodesRuntime always (black without the key), and with a
|
|
201
|
+
* master the colour, the layer-list window background, the v31 system splash and the densities. */
|
|
202
|
+
export const writeAndroidSplash = async (androidDir: string, master: SplashMaster | null): Promise<void> => {
|
|
203
|
+
const resDir = join(androidDir, "lecodes-runtime", "src", "main", "res")
|
|
204
|
+
const res: AndroidSplashResources | undefined = master ? { backgroundHex: master.backgroundHex, image: !!master.image, size: master.size } : undefined
|
|
205
|
+
writeFile(join(resDir, "values", "lecodes_splash.xml"), runtimeThemeTemplate(res))
|
|
206
|
+
const drop = (rel: string) => rmSync(join(resDir, rel), { force: true })
|
|
207
|
+
|
|
208
|
+
if (!res) {
|
|
209
|
+
drop(ANDROID_SPLASH_FILES.v31)
|
|
210
|
+
drop(ANDROID_SPLASH_FILES.window)
|
|
211
|
+
} else {
|
|
212
|
+
writeFile(join(resDir, ANDROID_SPLASH_FILES.v31), runtimeThemeV31Template(res))
|
|
213
|
+
writeFile(join(resDir, ANDROID_SPLASH_FILES.window), splashWindowTemplate(res))
|
|
214
|
+
}
|
|
215
|
+
if (master?.image) {
|
|
216
|
+
for (const { qualifier, scale } of ANDROID_DENSITIES) {
|
|
217
|
+
writeFile(join(resDir, ANDROID_SPLASH_FILES.png(qualifier)), await renderSquare(master.image, Math.round(master.size * scale)))
|
|
218
|
+
}
|
|
219
|
+
writeFile(join(resDir, ANDROID_SPLASH_FILES.icon), splashIconTemplate(master.size))
|
|
220
|
+
} else {
|
|
221
|
+
for (const { qualifier } of ANDROID_DENSITIES) drop(ANDROID_SPLASH_FILES.png(qualifier))
|
|
222
|
+
drop(ANDROID_SPLASH_FILES.icon)
|
|
223
|
+
}
|
|
224
|
+
if (master) {
|
|
225
|
+
log(`${c.green("splash")} ${master.backgroundHex}${master.image ? ` + the image at ${master.size} dp (mdpi…xxxhdpi)` : ""} → android/lecodes-runtime/src/main/res/ (Theme.LeCodesRuntime)`)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The edit an app's USER-owned theme needs for the splash to show, or null when it inherits
|
|
230
|
+
* Theme.LeCodesRuntime already (and sets no window background of its own over it). Read by sync
|
|
231
|
+
* (a warning, when "splash" is set) and doctor. */
|
|
232
|
+
export const androidSplashThemeHint = (androidDir: string): string | null => {
|
|
233
|
+
const path = join(androidDir, "app", "src", "main", "res", "values", "themes.xml")
|
|
234
|
+
if (!existsSync(path)) return null
|
|
235
|
+
const xml = readFileSync(path, "utf8")
|
|
236
|
+
// The init template's theme; a person who renamed it is taken at their word when some style of
|
|
237
|
+
// the file inherits the runtime theme.
|
|
238
|
+
const style = /<style\b[^>]*\bname="Theme\.LeCodesApp"[^>]*?(?:\/>|>([\s\S]*?)<\/style>)/.exec(xml)
|
|
239
|
+
if (!style) {
|
|
240
|
+
return /\bparent="Theme\.LeCodesRuntime"/.test(xml) ? null
|
|
241
|
+
: `app/src/main/res/values/themes.xml: the app's theme needs parent="Theme.LeCodesRuntime" and no android:windowBackground of its own for the splash to show — see "Splash screen" in android/README.md`
|
|
242
|
+
}
|
|
243
|
+
const inherits = /\bparent="Theme\.LeCodesRuntime"/.test(style[0].slice(0, style[0].indexOf(">") + 1))
|
|
244
|
+
const ownBackground = /name="android:windowBackground"/.test(style[1] ?? "")
|
|
245
|
+
if (inherits && !ownBackground) return null
|
|
246
|
+
const edits = [
|
|
247
|
+
!inherits && `parent="Theme.LeCodesRuntime"`,
|
|
248
|
+
ownBackground && `no android:windowBackground item`,
|
|
249
|
+
].filter(Boolean).join(" and ")
|
|
250
|
+
return `app/src/main/res/values/themes.xml: give Theme.LeCodesApp ${edits} — the splash is drawn by that theme (see "Splash screen" in android/README.md)`
|
|
251
|
+
}
|