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.
Files changed (54) hide show
  1. package/dist/index.js +36134 -11203
  2. package/package.json +10 -9
  3. package/runtime/sdk-types.json +1 -1
  4. package/runtime/web/VERSION.json +3 -3
  5. package/runtime/web/assets/ar-D6oDL4pw.js +1 -0
  6. package/runtime/web/assets/ar-mVXX3kRQ.js +2 -0
  7. package/runtime/web/assets/qrAnchorWorker-Cb5E8cF-.js +1 -0
  8. package/runtime/web/assets/qr_anchor_wasm_bg-D_ePkCCu.wasm +0 -0
  9. package/runtime/web/index.html +1 -0
  10. package/runtime/web/player.js +12 -12
  11. package/runtime/web-lite/assets/{index-B2zEJlWS.js → index-oPsDD47E.js} +1 -1
  12. package/runtime/web-lite/assets/scene3d-BAl_QQyi.js +1105 -0
  13. package/runtime/web-lite/features.json +2 -2
  14. package/runtime/web-lite/index.html +2 -1
  15. package/runtime/web-lite/lite.js +9 -9
  16. package/runtime/web-lite/plugins/ar/_virtual_lecodes-half-resources-CNYmVR88.js +1 -0
  17. package/runtime/web-lite/plugins/ar/assets/qrAnchorWorker-DcxVKAqo.js +1 -0
  18. package/runtime/web-lite/plugins/ar/assets/qr_anchor_wasm_bg-D_ePkCCu.wasm +0 -0
  19. package/runtime/web-lite/plugins/ar/index-C2uuuLhz.js +2 -0
  20. package/runtime/web-lite/plugins.json +1 -1
  21. package/src/commands/app/android.ts +68 -19
  22. package/src/commands/app/index.ts +84 -17
  23. package/src/commands/app/shared.ts +157 -24
  24. package/src/commands/app/splash.ts +251 -0
  25. package/src/commands/app/templates/android.ts +148 -25
  26. package/src/commands/app/templates/ios.ts +123 -12
  27. package/src/commands/init/index.ts +195 -195
  28. package/src/commands/plugin.ts +3 -3
  29. package/src/commands/push.ts +2 -2
  30. package/src/commands/shared.ts +79 -79
  31. package/src/commands/thumbs.ts +185 -185
  32. package/src/commands/web.ts +12 -5
  33. package/src/compile/collect.ts +2 -3
  34. package/src/compile/collectLocal.ts +13 -2
  35. package/src/compile/designCompile.ts +2 -3
  36. package/src/compile/projectCompile.ts +3 -0
  37. package/src/compile/screenEntry.ts +123 -123
  38. package/src/dev/devServer.ts +63 -7
  39. package/src/dev/https.ts +50 -0
  40. package/src/dev/serve.ts +15 -9
  41. package/src/dev/webRunner.ts +11 -3
  42. package/src/platform/serverDiff.ts +2 -3
  43. package/src/plugin/emitPackage.ts +18 -5
  44. package/src/plugin/emitWeb.ts +17 -8
  45. package/src/plugin/firstParty.ts +30 -0
  46. package/src/plugin/generate.ts +52 -24
  47. package/src/plugin/manifest.ts +66 -7
  48. package/src/project/ignore.ts +164 -164
  49. package/src/project/localFiles.ts +44 -7
  50. package/src/web/build.ts +33 -9
  51. package/src/web/halves.ts +76 -0
  52. package/runtime/web/assets/ar-CHvaraG4.js +0 -1
  53. package/runtime/web-lite/assets/scene3d-Bew8g7Ws.js +0 -1081
  54. 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 { pluginSpecsOf } from "lecodes-sdk/compile"
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
- plugins?: Record<string, string>
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, new code applies next launch).
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 either shape (`["camera"]` or `{ "camera": "*" }`, the SDK's rule):
230
- // every command below works on id → spec, and a list read as "no plugins" vendored nothing.
231
- if (config && "plugins" in config) config.plugins = pluginSpecsOf(config.plugins)
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
- export function resolvePlugin(id: string, spec: string, lock: LockEntry | undefined, platform: PluginPlatform): ResolvedPlugin
555
- export function resolvePlugin(id: string, spec: string, lock: LockEntry | undefined, platform: PluginPlatform, opts: { optional: true }): ResolvedPlugin | null
556
- export function resolvePlugin(id: string, spec: string, lock: LockEntry | undefined, platform: PluginPlatform, opts?: { optional?: boolean }): ResolvedPlugin | null {
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 = resolve(projectRoot(), spec.startsWith("file:") ? spec.slice("file:".length) : spec)
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<AppConfig>(join(root, "app.json"))
644
- const specs = Object.entries(config?.plugins ?? {})
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
+ }