lecodes-cli 2.4.2 → 2.4.4

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.
@@ -49,6 +49,21 @@ export interface AppConfig {
49
49
  /** Icon backdrop ("#RGB"/"#RRGGBB", default white): the Android adaptive-icon background
50
50
  * layer, and what a transparent master is flattened onto for the opacity-required iOS icon. */
51
51
  iconBackground?: 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 }
52
67
  /** id → a spec for every platform (`"*"`, `"^2.0.0"`, `"github:owner/repo@v1"`, `"./plugins/<id>"`)
53
68
  * or a spec per platform (`{ "web": "./plugins/ar-8thwall" }`; a platform it does not name is
54
69
  * `"*"`). Read in the list form too (readAppConfigAt); a platform's spec is pluginSpecOf's. */
@@ -58,16 +73,30 @@ export interface AppConfig {
58
73
  * / the "url" event — what `Browser.open(url, { returnUrl })` resolves on after an OAuth redirect.
59
74
  * Lowercase letters, digits, `+ - .`, starting with a letter. */
60
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[]
61
84
  /** `remote`: lecodesignore-style globs of resources to STREAM from the server instead of
62
85
  * embedding in the app (server-backed projects only — a streamed asset needs a server URL). */
63
86
  resources?: { remote?: string[] }
64
87
  /** Over-the-air JS updates: where the shell checks for newer compiled code at launch
65
- * (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 — a 304 when
90
+ * the source is not newer than the build (If-Modified-Since from the embedded bundle's compile
91
+ * date: nothing downloaded, the embedded bundle runs); every later launch runs the local
92
+ * bundle at once and new code applies next launch.
66
93
  * `true` = this project's live bundle on the platform (server-backed only); or an https
67
94
  * URL to a compiled bundle on any hosting (string or `{ "url": … }`). Absent = no OTA.
68
95
  * Both shells: iOS reads it from Info.plist (LeCodesUpdater), Android from the manifest
69
96
  * meta-data the CLI-owned lecodes-runtime module carries (LeCodesShell.boot). A download runs
70
- * only when it is of the shell's SDK major and not older than the bundle running. */
97
+ * only when it is of the shell's SDK major and not older than the bundle running; a store
98
+ * update keeps a downloaded bundle only when it is newer code (by compile date) than the one
99
+ * the update ships. */
71
100
  update?: boolean | string | { url?: string }
72
101
  /** `variant`: SDK product — "auto" (default: sync picks the smallest one covering the engines
73
102
  * the compiled bundle actually uses) or core|2d|3d|full to force one.
@@ -229,6 +258,62 @@ export const urlSchemeOf = (config: Pick<AppConfig, "urlScheme"> | null | undefi
229
258
  return scheme
230
259
  }
231
260
 
261
+ /** One app.json "appLinks" entry, parsed: the prefix as written, its host, its path ("" for the
262
+ * host's root — every path of the host). */
263
+ export interface AppLink { url: string, host: string, path: string }
264
+
265
+ /** app.json "appLinks", checked: https URL prefixes with a host, no query / fragment (a prefix
266
+ * ends at a path). Empty when the key is absent. */
267
+ export const appLinksOf = (config: Pick<AppConfig, "appLinks"> | null | undefined): AppLink[] => {
268
+ const raw = config?.appLinks
269
+ if (raw === undefined) return []
270
+ 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)}.`)
271
+ const links: AppLink[] = []
272
+ for (const entry of raw) {
273
+ let url: URL
274
+ try { url = new URL(typeof entry === "string" ? entry : "") } catch { url = null as any }
275
+ if (!url || url.protocol !== "https:" || !url.hostname) {
276
+ 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".`)
277
+ }
278
+ if (url.search || url.hash || url.username || url.port) {
279
+ throw new CliError(`app.json "appLinks": ${JSON.stringify(entry)} — a prefix is a host and a path, no query, fragment, port or credentials.`)
280
+ }
281
+ const path = url.pathname === "/" ? "" : url.pathname
282
+ if (links.some(l => l.host === url.hostname && l.path === path)) continue
283
+ links.push({ url: `https://${url.hostname}${path}`, host: url.hostname, path })
284
+ }
285
+ return links
286
+ }
287
+
288
+ /** The iOS entitlement "appLinks" means: one `applinks:<host>` per host (the paths live in the
289
+ * site's apple-app-site-association). */
290
+ export const associatedDomainsOf = (links: AppLink[]): string[] => [...new Set(links.map(l => `applinks:${l.host}`))]
291
+
292
+ /** What the sites named by "appLinks" must serve for the OS to trust the links — printed by sync
293
+ * so nobody composes them by hand: the AASA for iOS (`teamId.bundleId` + the paths), assetlinks
294
+ * for Android (the package + the signing certificate's SHA-256, which `lecodes app keystore show`
295
+ * prints). One block per host. */
296
+ export const appLinksSiteNote = (links: AppLink[], ids: { platform: "ios", teamId?: string, bundleId?: string } | { platform: "android", applicationId?: string }): string[] => {
297
+ const lines: string[] = []
298
+ const hosts = [...new Set(links.map(l => l.host))]
299
+ for (const host of hosts) {
300
+ const paths = links.filter(l => l.host === host).map(l => l.path)
301
+ const patterns = paths.includes("") ? ["*"] : paths.map(p => `${p}*`)
302
+ if (ids.platform === "ios") {
303
+ const appId = `${ids.teamId ?? "<TEAM_ID>"}.${ids.bundleId ?? "<BUNDLE_ID>"}`
304
+ lines.push(`https://${host}/.well-known/apple-app-site-association (Content-Type: application/json, no redirect — Apple's CDN fetches it at install):`)
305
+ lines.push(JSON.stringify({ applinks: { details: [{ appIDs: [appId], components: patterns.map(p => ({ "/": p })) }] } }))
306
+ } else {
307
+ lines.push(`https://${host}/.well-known/assetlinks.json (Content-Type: application/json):`)
308
+ lines.push(JSON.stringify([{
309
+ relation: ["delegate_permission/common.handle_all_urls"],
310
+ target: { namespace: "android_app", package_name: ids.applicationId ?? "<APPLICATION_ID>", sha256_cert_fingerprints: ["<SHA-256 of the signing certificate — `lecodes app keystore show`>"] },
311
+ }]))
312
+ }
313
+ }
314
+ return lines
315
+ }
316
+
232
317
  /** app.json wherever a command needs the project-level desktop/window config. Tolerant of a
233
318
  * missing or broken file — standalone projects (no app.json) must keep working everywhere
234
319
  * this is consulted. */
@@ -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
+ }
@@ -1,3 +1,5 @@
1
+ import type { AppLink } from "../shared"
2
+
1
3
  /* Templates for `lecodes app init android` / `lecodes app sync` — the Android mirror of
2
4
  * ios.ts, distilled from the LetaryCreatorViewer host (lecodes-android :app). The
3
5
  * same three ownership tiers: app/ is USER-owned after generation (init writes once, sync
@@ -155,6 +157,75 @@ export const runtimeManifestTemplate = (updateUrl: string | undefined): string =
155
157
  </manifest>
156
158
  `
157
159
 
160
+ /** The window setup every shell's theme inherits (what Theme.LeCodesApp carried before the splash):
161
+ * edge-to-edge bars, no title, and the window background — black, or app.json "splash"'s
162
+ * layer-list. */
163
+ const runtimeThemeItems = (windowBackground: string): string => `
164
+ <item name="android:windowTranslucentNavigation">true</item>
165
+ <item name="android:statusBarColor">@android:color/transparent</item>
166
+ <item name="android:windowNoTitle">true</item>
167
+ <item name="android:windowTranslucentStatus">true</item>
168
+ <item name="android:windowBackground">${windowBackground}</item>`
169
+
170
+ /** What app.json "splash" resolved to, as the runtime module's resources need it (`size` = the side
171
+ * of the square the image is fitted into, dp). */
172
+ export interface AndroidSplashResources { backgroundHex: string, image: boolean, size: number }
173
+
174
+ /** The Android 12+ splash-icon canvas (an icon without an icon background), dp — the system shows
175
+ * its inner 192 dp circle. The image sits on it inset by (canvas − size) / 2. */
176
+ export const SPLASH_ICON_CANVAS_DP = 288
177
+
178
+ /** lecodes-runtime/src/main/res/values/lecodes_splash.xml — CLI-owned, written by init and every
179
+ * sync: Theme.LeCodesRuntime, the parent of the app's user-owned Theme.LeCodesApp (themes.xml).
180
+ * Without "splash" its window is black, as a shell's always was; with it, the colour and the
181
+ * layer-list lecodes_splash_window (API 23–30 show the splash as the window background). The
182
+ * parent needs appcompat on the module's classpath: SDK_RUNTIME_DEPS declares it. */
183
+ export const runtimeThemeTemplate = (splash?: AndroidSplashResources): string => `<?xml version="1.0" encoding="utf-8"?>
184
+ <!-- Generated by \`lecodes app sync\` — regenerated on every sync, do not edit. Theme.LeCodesRuntime is
185
+ the parent of the app's Theme.LeCodesApp (app/src/main/res/values/themes.xml); app.json "splash"
186
+ draws its window. -->
187
+ <resources>${splash ? `
188
+ <color name="lecodes_splash_background">${splash.backgroundHex}</color>` : ""}
189
+ <style name="Theme.LeCodesRuntime" parent="Theme.AppCompat.Light.NoActionBar">${runtimeThemeItems(splash ? "@drawable/lecodes_splash_window" : "@android:color/black")}
190
+ </style>
191
+ </resources>
192
+ `
193
+
194
+ /** lecodes-runtime/src/main/res/values-v31/lecodes_splash.xml — only with app.json "splash": the
195
+ * theme's Android 12+ twin (a v31 style replaces the base one whole, hence every item again) adding
196
+ * the system splash — the colour, and as the icon the image inset (288 − size) / 2 dp on each side
197
+ * (the 288 dp icon canvas with the image at its size, 160 dp by default) or nothing (transparent: the system would draw the launcher
198
+ * icon otherwise). */
199
+ export const runtimeThemeV31Template = (splash: AndroidSplashResources): string => `<?xml version="1.0" encoding="utf-8"?>
200
+ <!-- Generated by \`lecodes app sync\` from app.json "splash" — regenerated on every sync, do not edit. -->
201
+ <resources>
202
+ <style name="Theme.LeCodesRuntime" parent="Theme.AppCompat.Light.NoActionBar">${runtimeThemeItems("@drawable/lecodes_splash_window")}
203
+ <item name="android:windowSplashScreenBackground">@color/lecodes_splash_background</item>
204
+ <item name="android:windowSplashScreenAnimatedIcon">${splash.image ? "@drawable/lecodes_splash_icon" : "@android:color/transparent"}</item>
205
+ </style>
206
+ </resources>
207
+ `
208
+
209
+ /** lecodes-runtime/src/main/res/drawable/lecodes_splash_window.xml — the splash as a window
210
+ * background: the colour, and the image centred at size × size dp (drawable-*dpi/lecodes_splash.png). */
211
+ export const splashWindowTemplate = (splash: AndroidSplashResources): string => `<?xml version="1.0" encoding="utf-8"?>
212
+ <!-- Generated by \`lecodes app sync\` from app.json "splash" — regenerated on every sync, do not edit. -->
213
+ <layer-list xmlns:android="http://schemas.android.com/apk/res/android" android:opacity="opaque">
214
+ <item android:drawable="@color/lecodes_splash_background" />${splash.image ? `
215
+ <item android:width="${splash.size}dp" android:height="${splash.size}dp" android:gravity="center" android:drawable="@drawable/lecodes_splash" />` : ""}
216
+ </layer-list>
217
+ `
218
+
219
+ /** lecodes-runtime/src/main/res/drawable/lecodes_splash_icon.xml — Android 12+'s splash icon: the
220
+ * size dp image inset to the 288 dp canvas the system draws (and masks to a 192 dp circle). The
221
+ * inset is rounded down — an odd size leaves the image half a dp larger, which nobody sees. */
222
+ export const splashIconTemplate = (size: number): string => `<?xml version="1.0" encoding="utf-8"?>
223
+ <!-- Generated by \`lecodes app sync\` from app.json "splash" — regenerated on every sync, do not edit. -->
224
+ <inset xmlns:android="http://schemas.android.com/apk/res/android"
225
+ android:drawable="@drawable/lecodes_splash"
226
+ android:inset="${Math.floor((SPLASH_ICON_CANVAS_DP - size) / 2)}dp" />
227
+ `
228
+
158
229
  /** lecodes-runtime/src/main/kotlin/io/letary/lecodes/runtime/LeCodesRuntime.kt — the generated
159
230
  * registration + host-hook surface. Plugins are called by FQN (no import collisions); the
160
231
  * activity hooks forward to the SDK's LeCodesAppHooks, which NAMES NO PLUGIN — a plugin that
@@ -259,7 +330,8 @@ lecodes app build android # signed release .apk + .aab → ../dist/
259
330
  | \`app/\` (sources, manifest, res, build.gradle.kts, proguard-rules.pro) | **Yours** after init — sync never rewrites them (it only restamps the \`versionName\`/\`versionCode\` and \`isMinifyEnabled\`/\`isShrinkResources\` values, manages \`// lecodes:plugin\`-tagged lines in the plugins block, and — when \`app.json\` sets \`"icon"\` — the icon files below) |
260
331
  | \`app/src/main/assets/\` | CLI-owned — wiped and regenerated by every sync (the compiled \`app.js\` + its \`// preload:\` assets) |
261
332
  | \`res/mipmap-*/ic_launcher*\`, \`res/values/lecodes_icon.xml\` | CLI-owned **only when** \`app.json\` sets \`"icon"\` — regenerated by every sync (plus a value-only \`android:icon\` restamp in the manifest); without the key sync never touches icons |
262
- | \`settings.gradle.kts\`, \`build.gradle.kts\` (root), \`gradle.properties\`, \`lecodes-runtime/\`, \`plugins/\` | CLI-owned — regenerated by every sync |
333
+ | \`app/src/main/res/values/themes.xml\` | **Yours** — its \`Theme.LeCodesApp\` must keep \`parent="Theme.LeCodesRuntime"\` (the window setup and the splash, see "Splash screen") |
334
+ | \`settings.gradle.kts\`, \`build.gradle.kts\` (root), \`gradle.properties\`, \`lecodes-runtime/\`, \`plugins/\` | CLI-owned — regenerated by every sync (\`lecodes-runtime/src/main/res/\`: \`Theme.LeCodesRuntime\` and, when \`app.json\` sets \`"splash"\`, the splash resources) |
263
335
  | \`gradle/\`, \`gradlew\`, \`gradlew.bat\` | Written once at init |
264
336
 
265
337
  ## The SDK
@@ -346,23 +418,50 @@ Prefer hand-made icons? Leave \`"icon"\` unset and use Android Studio (right-cli
346
418
  New → Image Asset) — sync never touches icon files then. \`drawable/ic_launcher.xml\` is only
347
419
  the init-time placeholder.
348
420
 
421
+ ## Splash screen
422
+
423
+ \`"splash": { "background": "#RRGGBB", "image": "./splash.png", "size": 160 }\` in \`../app.json\`
424
+ (every key optional) makes every sync render the splash into \`lecodes-runtime/src/main/res/\`: the
425
+ colour (\`background\`, default \`"iconBackground"\`, default white) with the image fitted centred into
426
+ a \`size\`×\`size\` dp square (160 by default) — never at its pixel size — from
427
+ \`drawable-mdpi…xxxhdpi/lecodes_splash.png\` (size × 1/1.5/2/3/4 px: 160…640 at the default) of the one
428
+ master. Android 12+ shows it as the system splash (\`windowSplashScreenBackground\` +
429
+ \`windowSplashScreenAnimatedIcon\`: the image on the 288 dp icon canvas, inset (288 − size) / 2 dp,
430
+ of which the system shows only the inner 192 dp circle — keep the mark inside one; a \`size\` above
431
+ 192 is cut there, sync warns, and 288 is the most it takes), API 23–30 as the window background. Use
432
+ a square mark of at least 3 × size px (a non-square one is fitted into the square, a smaller one is
433
+ upscaled). No image = the colour
434
+ alone (and no icon on Android 12+, not the launcher one). The app holds the splash until its first
435
+ frame — through the first-launch wait of an OTA shell too.
436
+
437
+ It reaches the app through the theme: \`Theme.LeCodesApp\` in \`app/src/main/res/values/themes.xml\`
438
+ (yours) must say \`parent="Theme.LeCodesRuntime"\` and set no \`android:windowBackground\` of its
439
+ own. A shell generated before the key existed does not — \`lecodes app sync\` and \`lecodes app
440
+ doctor\` print the edit. Without the key the runtime theme keeps the black window shells always had.
441
+
349
442
  ## Notes
350
443
 
351
444
 
352
445
  ## Over-the-air updates
353
446
 
354
- Native, before the JS engine: \`LeCodesShell.boot(engine, context)\` in \`AppViewModel\` asks the
355
- source for newer code (a conditional GET; offline is a no-op), stores it and runs it on the NEXT
356
- launch — the current launch runs the newest LOCAL bundle. Configured in \`../app.json\`:
357
- \`"update": true\` = the project's live bundle on the platform (a pushed project), \`"update":
358
- { "url": "https://…/app.js" }\` = your own hosting, \`false\` = off. Sync writes the URL into
447
+ Native, before the JS engine: \`LeCodesShell.boot(engine, activity)\` in \`AppViewModel\` takes the
448
+ newest LOCAL bundle, then asks the source for newer code (a conditional GET; offline is a no-op)
449
+ and stores it. The FIRST launch of an installed binary waits for that answer (5 s at most, the
450
+ splash held on screen) and runs the newest bundle at once, so a fresh install from the store never
451
+ starts on the older code it shipped with; every later launch runs the local bundle at once and a
452
+ download applies on the NEXT launch. Configured in \`../app.json\`: \`"update": true\` = the
453
+ project's live bundle on the platform (a pushed project), \`"update": { "url": "https://…/app.js" }\`
454
+ = your own hosting, \`false\` = off. Sync writes the URL into
359
455
  \`lecodes-runtime/src/main/AndroidManifest.xml\` (meta-data \`io.letary.lecodes.UPDATE_URL\`). A
360
- download runs only when it is of this shell's SDK major and not older than the bundle running; a
361
- store update always wins over a downloaded bundle. A shell generated before OTA existed reads
362
- \`assets/app.js\` itself — replace that with:
456
+ download runs only when it is of this shell's SDK major and not older than the bundle running. A
457
+ store update keeps a downloaded bundle only when it is newer code than the one the update ships
458
+ (by compile date); otherwise the store build wins. A shell generated before OTA existed reads
459
+ \`assets/app.js\` itself, and one generated before the first-launch wait calls
460
+ \`LeCodesShell.boot(engine, getApplication<Application>())\` (which never waits) — replace either
461
+ with:
363
462
 
364
463
  \`\`\`kotlin
365
- if (!LeCodesShell.boot(engine, getApplication<Application>())) Log.e("LeCodes", "bundled app.js is missing — run \`lecodes app sync\`")
464
+ if (!LeCodesShell.boot(engine, activity)) Log.e("LeCodes", "bundled app.js is missing — run \`lecodes app sync\`")
366
465
  \`\`\`
367
466
  - Push: the notification service needs \`app/google-services.json\` from the le.codes Firebase
368
467
  project; \`lecodes app sync\` applies the google-services Gradle plugin only when that file
@@ -467,7 +566,31 @@ export const urlSchemeFilter = (urlScheme?: string): string => !urlScheme ? "" :
467
566
  </intent-filter>
468
567
  <!-- /lecodes:urlScheme -->`
469
568
 
470
- export const appManifestTemplate = (urlScheme?: string): string => `<?xml version="1.0" encoding="utf-8"?>
569
+ /** The verified-link intent-filters of app.json "appLinks" (`restampManifestAppLinks`): one
570
+ * `autoVerify` filter per host — the `<data>` elements of one filter cross-multiply, so a host's
571
+ * paths stay in its own filter; a root link (no path) takes the whole host. Empty without links. */
572
+ export const appLinksFilter = (links: AppLink[]): string => {
573
+ if (links.length === 0) return ""
574
+ const hosts = [...new Set(links.map(l => l.host))]
575
+ const filters = hosts.map(host => {
576
+ const paths = links.filter(l => l.host === host).map(l => l.path)
577
+ const prefixes = paths.includes("") ? [] : paths
578
+ return `
579
+ <intent-filter android:autoVerify="true">
580
+ <action android:name="android.intent.action.VIEW" />
581
+ <category android:name="android.intent.category.DEFAULT" />
582
+ <category android:name="android.intent.category.BROWSABLE" />
583
+ <data android:scheme="https" />
584
+ <data android:host="${host}" />${prefixes.map(p => `
585
+ <data android:pathPrefix="${p}" />`).join("")}
586
+ </intent-filter>`
587
+ }).join("")
588
+ return `
589
+ <!-- lecodes:appLinks — app.json "appLinks": the verified https links the app opens on (the "url" app event); each host serves /.well-known/assetlinks.json -->${filters}
590
+ <!-- /lecodes:appLinks -->`
591
+ }
592
+
593
+ export const appManifestTemplate = (urlScheme?: string, appLinks: AppLink[] = []): string => `<?xml version="1.0" encoding="utf-8"?>
471
594
  <!-- Generated by \`lecodes app init\` — yours to edit. Sync leaves it alone, except that with
472
595
  app.json "icon" set it restamps the android:icon VALUE to the generated @mipmap set.
473
596
  Plugin permissions/services arrive through the plugin modules' own manifests via the AGP
@@ -492,7 +615,7 @@ export const appManifestTemplate = (urlScheme?: string): string => `<?xml versio
492
615
  <intent-filter>
493
616
  <action android:name="android.intent.action.MAIN" />
494
617
  <category android:name="android.intent.category.LAUNCHER" />
495
- </intent-filter>${urlSchemeFilter(urlScheme)}
618
+ </intent-filter>${urlSchemeFilter(urlScheme)}${appLinksFilter(appLinks)}
496
619
  </activity>
497
620
  </application>
498
621
 
@@ -616,12 +739,14 @@ class AppViewModel(application: Application) : AndroidViewModel(application) {
616
739
  fun onActivityCreate(activity: Activity) {
617
740
  engine.attach(activity)
618
741
  if (!isCreated) {
619
- // OTA (native, no JS involved): LeCodesShell.boot starts the check FIRST — even a bundle
620
- // that crashes on boot can never block its own fix — then runs the newest local bundle
621
- // (a downloaded update, or the embedded assets/app.js). New code applies on the next
622
- // launch. Configured via app.json "update" (→ the runtime module's manifest meta-data);
623
- // offline: no-op.
624
- if (!LeCodesShell.boot(engine, getApplication<Application>())) {
742
+ // OTA (native, no JS involved): LeCodesShell.boot takes the newest local bundle (a
743
+ // downloaded update, or the embedded assets/app.js) and starts the check BEFORE it runs
744
+ // — even a bundle that crashes on boot can never block its own fix. The first launch of
745
+ // this binary waits for the check (5 s at most; the splash stays, the first frame is
746
+ // held) and runs the newest bundle; later launches run at once and new code applies on
747
+ // the next launch. Configured via app.json "update" (→ the runtime module's manifest
748
+ // meta-data); offline: no wait.
749
+ if (!LeCodesShell.boot(engine, activity)) {
625
750
  android.util.Log.e("LeCodes", "bundled app.js is missing — run \`lecodes app sync\`")
626
751
  }
627
752
  isCreated = true
@@ -631,13 +756,11 @@ class AppViewModel(application: Application) : AndroidViewModel(application) {
631
756
  `
632
757
 
633
758
  export const themesXmlTemplate = (): string => `<resources>
634
- <style name="Theme.LeCodesApp" parent="Theme.AppCompat.Light.NoActionBar">
635
- <item name="android:windowTranslucentNavigation">true</item>
636
- <item name="android:statusBarColor">@android:color/transparent</item>
637
- <item name="android:windowNoTitle">true</item>
638
- <item name="android:windowTranslucentStatus">true</item>
639
- <item name="android:windowBackground">@color/black</item>
640
- </style>
759
+ <!-- The app's theme. Its parent, Theme.LeCodesRuntime (the CLI-owned lecodes-runtime module),
760
+ carries the window setup and app.json "splash" — the launch window and Android 12+'s
761
+ system splash; keep that parent and set no android:windowBackground here, or the splash
762
+ is lost. Your own items go inside. -->
763
+ <style name="Theme.LeCodesApp" parent="Theme.LeCodesRuntime" />
641
764
  </resources>
642
765
  `
643
766