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.
- package/dist/index.js +20169 -12323
- package/package.json +8 -8
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/VERSION.json +3 -3
- package/runtime/web/player.js +17 -17
- package/runtime/web-lite/lite.js +3 -3
- package/runtime/web-page/page.js +6 -6
- package/src/commands/app/android.ts +68 -19
- package/src/commands/app/index.ts +58 -8
- package/src/commands/app/shared.ts +87 -2
- 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/shared.ts +79 -79
- package/src/commands/thumbs.ts +185 -185
- package/src/compile/screenEntry.ts +123 -123
- package/src/plugin/emitPackage.ts +18 -5
- package/src/plugin/generate.ts +11 -3
- package/src/plugin/manifest.ts +33 -4
- package/src/project/ignore.ts +164 -164
|
@@ -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
|
|
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
|
-
| \`
|
|
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,
|
|
355
|
-
source for newer code (a conditional GET; offline is a no-op)
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
|
361
|
-
store update
|
|
362
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
620
|
-
//
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
|
|
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
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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
|
|