create-mithril-lynx 2.4.0 → 2.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +152 -13
- package/package.json +1 -1
- package/src/index.js +812 -12
- package/templates/_shared/ts/package.json +1 -1
- package/templates/_shared/ts/src/rspeedy-env.d.ts +18 -0
- package/templates/android/app-scripts/android-prepare.mjs +53 -0
- package/templates/android/host/app/build.gradle.kts +4 -0
- package/templates/android/host/app/src/main/AndroidManifest.xml +1 -0
- package/templates/android/host/app/src/main/java/package-path/MainActivity.kt +34 -0
- package/templates/android/host/app/src/main/java/package-path/MithrilLynxNavigationModule.kt +28 -0
- package/templates/android/host/app/src/main/res/xml/config.xml +8 -0
- package/templates/android/host/app/src/main/res/xml/file_paths.xml +10 -0
- package/templates/android/host/app/src/main/res/xml/network_security_config.xml +9 -0
- package/templates/basic-activity/ts/src/app-bar.ts +3 -4
- package/templates/basic-activity/ts/src/background.ts +13 -1
- /package/templates/android/host/app/src/main/res/values/{themes.xml → styles.xml} +0 -0
package/src/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import fs from "node:fs";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
-
import { execSync } from "node:child_process";
|
|
6
|
+
import { execFileSync, execSync } from "node:child_process";
|
|
7
7
|
|
|
8
8
|
import { cancel, confirm, intro, isCancel, multiselect, outro, select, spinner, text } from "@clack/prompts";
|
|
9
9
|
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
findAndroidDir,
|
|
18
18
|
findProjectRoot,
|
|
19
19
|
mergeFontLists,
|
|
20
|
+
normalizeSource,
|
|
20
21
|
stripFontBlock,
|
|
21
22
|
} from "./fonts-wire.js";
|
|
22
23
|
|
|
@@ -24,9 +25,10 @@ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
|
|
24
25
|
const packageRoot = path.join(scriptDir, "..");
|
|
25
26
|
const cwd = process.cwd();
|
|
26
27
|
|
|
27
|
-
//
|
|
28
|
-
// the current release, same policy as this tool's own v1.
|
|
28
|
+
// Resolve the newest compatible framework packages when a project is scaffolded.
|
|
29
29
|
const MITHRIL_LYNX_VERSION = "latest";
|
|
30
|
+
const MITHRIL_LYNX_UI_VERSION = "latest";
|
|
31
|
+
const MITHRIL_LYNX_UI_STYLES_IMPORT = '@import "mithril-lynx-ui/styles.css";';
|
|
30
32
|
|
|
31
33
|
const TEMPLATES = [
|
|
32
34
|
{ value: "hello-world", label: "Hello World", hint: "recommended" },
|
|
@@ -36,6 +38,101 @@ const TEMPLATES = [
|
|
|
36
38
|
const TEMPLATE_VALUES = TEMPLATES.map((t) => t.value);
|
|
37
39
|
|
|
38
40
|
const ANDROID_TEMPLATE_ROOT = path.join(packageRoot, "templates", "android");
|
|
41
|
+
const LYNX_ANDROID_PLUGINS_VERSION = "0.4.0";
|
|
42
|
+
const LYNX_ANDROID_LEGACY_VERSIONS = [
|
|
43
|
+
{ plugins: "0.2.0", sqlite: "0.3.0", aggregate: "0.3.0" },
|
|
44
|
+
{ plugins: "0.2.0", sqlite: "0.3.0", aggregate: "0.2.0" },
|
|
45
|
+
];
|
|
46
|
+
const LYNX_ANDROID_JS_VERSION = "latest";
|
|
47
|
+
const LYNX_ANDROID_JS_LEGACY_VERSIONS = new Set(["^0.2.0"]);
|
|
48
|
+
|
|
49
|
+
const ANDROID_PLUGINS = [
|
|
50
|
+
{
|
|
51
|
+
id: "battery",
|
|
52
|
+
label: "Battery",
|
|
53
|
+
hint: "level and charging state · no permission",
|
|
54
|
+
artifact: "lynx-android-battery",
|
|
55
|
+
kotlinImport: "dev.lynx.android.plugins.battery.LynxBatteryPlugin",
|
|
56
|
+
registry: "LynxBatteryPlugin",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: "camera",
|
|
60
|
+
label: "Camera",
|
|
61
|
+
hint: "external photo capture · FileProvider",
|
|
62
|
+
artifact: "lynx-android-camera",
|
|
63
|
+
kotlinImport: "dev.lynx.android.plugins.camera.LynxCameraPlugin",
|
|
64
|
+
registry: "LynxCameraPlugin",
|
|
65
|
+
activityResult: true,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: "device",
|
|
69
|
+
label: "Device",
|
|
70
|
+
hint: "non-identifying device information · no permission",
|
|
71
|
+
artifact: "lynx-android-device",
|
|
72
|
+
kotlinImport: "dev.lynx.android.plugins.device.LynxDevicePlugin",
|
|
73
|
+
registry: "LynxDevicePlugin",
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: "geolocation",
|
|
77
|
+
label: "Geolocation",
|
|
78
|
+
hint: "one-shot foreground location · runtime permission",
|
|
79
|
+
artifact: "lynx-android-geolocation",
|
|
80
|
+
kotlinImport: "dev.lynx.android.plugins.geolocation.LynxGeolocationPlugin",
|
|
81
|
+
registry: "LynxGeolocationPlugin",
|
|
82
|
+
permissionResult: true,
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: "network",
|
|
86
|
+
label: "Network",
|
|
87
|
+
hint: "network snapshot · no permission",
|
|
88
|
+
artifact: "lynx-android-network",
|
|
89
|
+
kotlinImport: "dev.lynx.android.plugins.network.LynxNetworkPlugin",
|
|
90
|
+
registry: "LynxNetworkPlugin",
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: "vibration",
|
|
94
|
+
label: "Vibration",
|
|
95
|
+
hint: "bounded vibration · VIBRATE permission",
|
|
96
|
+
artifact: "lynx-android-vibration",
|
|
97
|
+
kotlinImport: "dev.lynx.android.plugins.vibration.LynxVibrationPlugin",
|
|
98
|
+
registry: "LynxVibrationPlugin",
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
id: "maps",
|
|
102
|
+
label: "Maps",
|
|
103
|
+
hint: "native maps · offline packages · optional foreground location",
|
|
104
|
+
artifact: "lynx-android-maps",
|
|
105
|
+
kotlinImport: "dev.lynx.android.plugins.maps.LynxMapsPlugin",
|
|
106
|
+
registry: "LynxMapsPlugin",
|
|
107
|
+
permissionResult: true,
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
id: "sqlite",
|
|
111
|
+
label: "SQLite",
|
|
112
|
+
hint: "app-private SQL database · no permission",
|
|
113
|
+
artifact: "lynx-android-sqlite",
|
|
114
|
+
kotlinImport: "dev.lynx.android.plugins.sqlite.LynxSqlitePlugin",
|
|
115
|
+
registry: "LynxSqlitePlugin",
|
|
116
|
+
},
|
|
117
|
+
];
|
|
118
|
+
const ANDROID_PLUGIN_BY_ID = new Map(ANDROID_PLUGINS.map((plugin) => [plugin.id, plugin]));
|
|
119
|
+
const CONNECTOR_CONFIG_RELATIVE_PATH = path.join("android", "connectors.json");
|
|
120
|
+
const CONNECTOR_DEPENDENCY_START = " // <create-mithril-lynx:android-connectors>";
|
|
121
|
+
const CONNECTOR_DEPENDENCY_END = " // </create-mithril-lynx:android-connectors>";
|
|
122
|
+
const MAIN_ACTIVITY_CONNECTOR_MARKERS = {
|
|
123
|
+
imports: {
|
|
124
|
+
start: "// <create-mithril-lynx:connector-imports>",
|
|
125
|
+
end: "// </create-mithril-lynx:connector-imports>",
|
|
126
|
+
},
|
|
127
|
+
registration: {
|
|
128
|
+
start: " // <create-mithril-lynx:connector-registration>",
|
|
129
|
+
end: " // </create-mithril-lynx:connector-registration>",
|
|
130
|
+
},
|
|
131
|
+
callbacks: {
|
|
132
|
+
start: " // <create-mithril-lynx:connector-callbacks>",
|
|
133
|
+
end: " // </create-mithril-lynx:connector-callbacks>",
|
|
134
|
+
},
|
|
135
|
+
};
|
|
39
136
|
|
|
40
137
|
// Files that text substitution must never touch (binaries).
|
|
41
138
|
const BINARY_EXTENSIONS = new Set([".jar", ".png", ".jpg", ".jpeg", ".webp", ".gif", ".ttf", ".otf", ".ttc", ".keystore", ".jks", ".so"]);
|
|
@@ -137,6 +234,26 @@ function appClassNameFor(rawName) {
|
|
|
137
234
|
return name === "MainActivity" ? `${name}App` : name;
|
|
138
235
|
}
|
|
139
236
|
|
|
237
|
+
/** Android files users edit from the JS project, before syncing them into the
|
|
238
|
+
* sibling Gradle host with `bun run android:prepare`. */
|
|
239
|
+
const ANDROID_DEVELOPMENT_FILES = [
|
|
240
|
+
["AndroidManifest.xml", ["AndroidManifest.xml"]],
|
|
241
|
+
["strings.xml", ["res", "values", "strings.xml"]],
|
|
242
|
+
["styles.xml", ["res", "values", "styles.xml"]],
|
|
243
|
+
["config.xml", ["res", "xml", "config.xml"]],
|
|
244
|
+
["network_security_config.xml", ["res", "xml", "network_security_config.xml"]],
|
|
245
|
+
["file_paths.xml", ["res", "xml", "file_paths.xml"]],
|
|
246
|
+
];
|
|
247
|
+
|
|
248
|
+
function seedAndroidDevelopmentFiles(targetDir, androidDir) {
|
|
249
|
+
const sourceRoot = path.join(androidDir, "app", "src", "main");
|
|
250
|
+
const destinationRoot = path.join(targetDir, "android");
|
|
251
|
+
fs.mkdirSync(destinationRoot, { recursive: true });
|
|
252
|
+
for (const [name, sourceParts] of ANDROID_DEVELOPMENT_FILES) {
|
|
253
|
+
fs.copyFileSync(path.join(sourceRoot, ...sourceParts), path.join(destinationRoot, name));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
140
257
|
function fontFamilyFor(filePath) {
|
|
141
258
|
return path
|
|
142
259
|
.basename(filePath, path.extname(filePath))
|
|
@@ -155,6 +272,495 @@ function splitList(value) {
|
|
|
155
272
|
.filter(Boolean);
|
|
156
273
|
}
|
|
157
274
|
|
|
275
|
+
function normalizeAndroidPlugins(ids) {
|
|
276
|
+
const selected = [...new Set(ids)];
|
|
277
|
+
const unknown = selected.filter((id) => id !== "all" && !ANDROID_PLUGIN_BY_ID.has(id));
|
|
278
|
+
if (unknown.length > 0) {
|
|
279
|
+
cancel(`Unknown Android plugin${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}. ` +
|
|
280
|
+
`Choose from ${ANDROID_PLUGINS.map((plugin) => plugin.id).join(", ")}, or all.`);
|
|
281
|
+
process.exit(1);
|
|
282
|
+
}
|
|
283
|
+
if (selected.includes("all") && selected.length > 1) {
|
|
284
|
+
cancel('"all" cannot be combined with individual Android plugins.');
|
|
285
|
+
process.exit(1);
|
|
286
|
+
}
|
|
287
|
+
return selected;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function connectorNpmPackages(pluginIds) {
|
|
291
|
+
return pluginIds.length === 0 ? [] : ["lynx-android-plugins"];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function mapsConfigurationUpdate(projectRoot, pluginIds) {
|
|
295
|
+
if (!pluginIds.includes("maps") && !pluginIds.includes("all")) return [];
|
|
296
|
+
const configPath = path.join(projectRoot, "android", "maps.json");
|
|
297
|
+
if (fs.existsSync(configPath)) return [];
|
|
298
|
+
return [{ path: configPath, previous: null, next: `${JSON.stringify({ url: "", sha256: "", version: "" }, null, 2)}\n` }];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function updatedConnectorPackageJson(projectRoot, currentIds, nextIds) {
|
|
302
|
+
const packagePath = path.join(projectRoot, "package.json");
|
|
303
|
+
const originalSource = fs.readFileSync(packagePath, "utf8");
|
|
304
|
+
const pkg = JSON.parse(originalSource);
|
|
305
|
+
if (pkg.dependencies == null || typeof pkg.dependencies !== "object" || Array.isArray(pkg.dependencies)) {
|
|
306
|
+
throw new Error("package.json dependencies cannot be managed. No files were changed.");
|
|
307
|
+
}
|
|
308
|
+
const current = new Set(connectorNpmPackages(currentIds));
|
|
309
|
+
const next = new Set(connectorNpmPackages(nextIds));
|
|
310
|
+
let changed = false;
|
|
311
|
+
for (const name of new Set([...current, ...next])) {
|
|
312
|
+
if (name in (pkg.devDependencies ?? {})) {
|
|
313
|
+
throw new Error(`${name} is in devDependencies. Move it to dependencies before managing connectors.`);
|
|
314
|
+
}
|
|
315
|
+
const version = pkg.dependencies[name];
|
|
316
|
+
const isManagedVersion = version === LYNX_ANDROID_JS_VERSION || LYNX_ANDROID_JS_LEGACY_VERSIONS.has(version);
|
|
317
|
+
if (version != null && !isManagedVersion) {
|
|
318
|
+
throw new Error(`${name} has a custom version. No files were changed.`);
|
|
319
|
+
}
|
|
320
|
+
if (next.has(name) && version !== LYNX_ANDROID_JS_VERSION) {
|
|
321
|
+
pkg.dependencies[name] = LYNX_ANDROID_JS_VERSION;
|
|
322
|
+
changed = true;
|
|
323
|
+
} else if (!next.has(name) && isManagedVersion) {
|
|
324
|
+
delete pkg.dependencies[name];
|
|
325
|
+
changed = true;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return { packagePath, source: changed ? `${JSON.stringify(pkg, null, 2)}\n` : originalSource };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function projectPackageManager(projectRoot) {
|
|
332
|
+
for (const [lock, manager] of [
|
|
333
|
+
["bun.lock", "bun"], ["bun.lockb", "bun"], ["pnpm-lock.yaml", "pnpm"],
|
|
334
|
+
["yarn.lock", "yarn"], ["package-lock.json", "npm"],
|
|
335
|
+
]) {
|
|
336
|
+
if (fs.existsSync(path.join(projectRoot, lock))) return manager;
|
|
337
|
+
}
|
|
338
|
+
return detectPackageManager();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function androidPluginHostConfiguration(pluginIds, versions = {
|
|
342
|
+
plugins: LYNX_ANDROID_PLUGINS_VERSION,
|
|
343
|
+
sqlite: LYNX_ANDROID_PLUGINS_VERSION,
|
|
344
|
+
aggregate: LYNX_ANDROID_PLUGINS_VERSION,
|
|
345
|
+
}) {
|
|
346
|
+
const useAggregate = pluginIds.includes("all");
|
|
347
|
+
const plugins = useAggregate ? ANDROID_PLUGINS : pluginIds.map((id) => ANDROID_PLUGIN_BY_ID.get(id));
|
|
348
|
+
const gradleDependencies = useAggregate
|
|
349
|
+
? ` implementation("io.github.carlos-sweb:lynx-android-plugins:${versions.aggregate}")`
|
|
350
|
+
: plugins.map((plugin) =>
|
|
351
|
+
` implementation("io.github.carlos-sweb:${plugin.artifact}:${plugin.id === "sqlite" ? versions.sqlite : versions.plugins}")`,
|
|
352
|
+
).join("\n");
|
|
353
|
+
const imports = useAggregate
|
|
354
|
+
? ["import dev.lynx.android.plugins.LynxAndroidPlugins"]
|
|
355
|
+
: plugins.map((plugin) => `import ${plugin.kotlinImport}`);
|
|
356
|
+
const registrations = useAggregate
|
|
357
|
+
? " LynxAndroidPlugins.register(builder)"
|
|
358
|
+
: plugins.map((plugin) => ` ${plugin.registry}.register(builder)`).join("\n");
|
|
359
|
+
const handlesPermission = useAggregate || plugins.some((plugin) => plugin.permissionResult);
|
|
360
|
+
const handlesActivity = useAggregate || plugins.some((plugin) => plugin.activityResult);
|
|
361
|
+
const permissionHandler = useAggregate
|
|
362
|
+
? "LynxAndroidPlugins.onRequestPermissionsResult(requestCode, permissions, grantResults)"
|
|
363
|
+
: plugins.filter((plugin) => plugin.permissionResult).map((plugin) =>
|
|
364
|
+
`${plugin.registry}.onRequestPermissionsResult(requestCode, permissions, grantResults)`,
|
|
365
|
+
).join(" || ");
|
|
366
|
+
const activityRegistry = useAggregate ? "LynxAndroidPlugins" : "LynxCameraPlugin";
|
|
367
|
+
|
|
368
|
+
return {
|
|
369
|
+
pluginIds,
|
|
370
|
+
handlesPermission,
|
|
371
|
+
handlesActivity,
|
|
372
|
+
gradleDependencies: gradleDependencies || " // No Lynx Android connector selected.",
|
|
373
|
+
imports: imports.join("\n"),
|
|
374
|
+
registrations: registrations || " // No Lynx Android connector selected.",
|
|
375
|
+
permissionHandler: handlesPermission
|
|
376
|
+
? permissionHandler
|
|
377
|
+
: "false",
|
|
378
|
+
activityHandler: handlesActivity
|
|
379
|
+
? `${activityRegistry}.onActivityResult(requestCode, resultCode, data)`
|
|
380
|
+
: "false",
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function buildAndroidConnectorRegistry(packageName, pluginIds) {
|
|
385
|
+
if (pluginIds.length === 0) return null;
|
|
386
|
+
const configuration = androidPluginHostConfiguration(pluginIds);
|
|
387
|
+
return [
|
|
388
|
+
"// GENERATED by create-mithril-lynx. Manage with add-android-plugin/remove-android-plugin.",
|
|
389
|
+
`package ${packageName}`,
|
|
390
|
+
"",
|
|
391
|
+
...(configuration.handlesActivity ? ["import android.content.Intent"] : []),
|
|
392
|
+
"import com.lynx.tasm.LynxViewBuilder",
|
|
393
|
+
configuration.imports,
|
|
394
|
+
"",
|
|
395
|
+
"object LynxAndroidConnectorRegistry {",
|
|
396
|
+
" fun register(builder: LynxViewBuilder) {",
|
|
397
|
+
configuration.registrations,
|
|
398
|
+
" }",
|
|
399
|
+
...(configuration.handlesPermission ? [
|
|
400
|
+
"",
|
|
401
|
+
" fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray): Boolean =",
|
|
402
|
+
` ${configuration.permissionHandler}`,
|
|
403
|
+
] : []),
|
|
404
|
+
...(configuration.handlesActivity ? [
|
|
405
|
+
"",
|
|
406
|
+
" fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean =",
|
|
407
|
+
` ${configuration.activityHandler}`,
|
|
408
|
+
] : []),
|
|
409
|
+
"}",
|
|
410
|
+
"",
|
|
411
|
+
].join("\n");
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// The previous generated format is accepted only for a one-time, exact migration.
|
|
415
|
+
function buildLegacyAndroidConnectorRegistry(packageName, pluginIds) {
|
|
416
|
+
const configuration = androidPluginHostConfiguration(pluginIds);
|
|
417
|
+
return [
|
|
418
|
+
"// GENERATED by create-mithril-lynx. Manage with add-android-plugin/remove-android-plugin.",
|
|
419
|
+
`package ${packageName}`,
|
|
420
|
+
"",
|
|
421
|
+
"import android.content.Intent",
|
|
422
|
+
"import com.lynx.tasm.LynxViewBuilder",
|
|
423
|
+
configuration.imports,
|
|
424
|
+
"",
|
|
425
|
+
"object LynxAndroidConnectorRegistry {",
|
|
426
|
+
" fun register(builder: LynxViewBuilder) {",
|
|
427
|
+
configuration.registrations,
|
|
428
|
+
" }",
|
|
429
|
+
"",
|
|
430
|
+
" fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray): Boolean =",
|
|
431
|
+
` ${configuration.permissionHandler}`,
|
|
432
|
+
"",
|
|
433
|
+
" fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean =",
|
|
434
|
+
` ${configuration.activityHandler}`,
|
|
435
|
+
"}",
|
|
436
|
+
"",
|
|
437
|
+
].filter((line, index, lines) => line !== "" || lines[index - 1] !== "").join("\n");
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function mainActivityConnectorSections(pluginIds) {
|
|
441
|
+
const configuration = androidPluginHostConfiguration(pluginIds);
|
|
442
|
+
const callbacks = [];
|
|
443
|
+
if (configuration.handlesPermission) {
|
|
444
|
+
callbacks.push([
|
|
445
|
+
" override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {",
|
|
446
|
+
" if (LynxAndroidConnectorRegistry.onRequestPermissionsResult(requestCode, permissions, grantResults)) return",
|
|
447
|
+
" super.onRequestPermissionsResult(requestCode, permissions, grantResults)",
|
|
448
|
+
" }",
|
|
449
|
+
].join("\n"));
|
|
450
|
+
}
|
|
451
|
+
if (configuration.handlesActivity) {
|
|
452
|
+
callbacks.push([
|
|
453
|
+
' @Deprecated("Needed for the external camera intent result")',
|
|
454
|
+
" override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {",
|
|
455
|
+
" if (LynxAndroidConnectorRegistry.onActivityResult(requestCode, resultCode, data)) return",
|
|
456
|
+
" super.onActivityResult(requestCode, resultCode, data)",
|
|
457
|
+
" }",
|
|
458
|
+
].join("\n"));
|
|
459
|
+
}
|
|
460
|
+
return {
|
|
461
|
+
imports: configuration.handlesActivity ? "import android.content.Intent\n" : "",
|
|
462
|
+
registration: pluginIds.length > 0 ? " LynxAndroidConnectorRegistry.register(builder)\n" : "",
|
|
463
|
+
callbacks: callbacks.length > 0 ? `${callbacks.join("\n\n")}\n` : "",
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function managedMainActivityBlock(source, name) {
|
|
468
|
+
const { start, end } = MAIN_ACTIVITY_CONNECTOR_MARKERS[name];
|
|
469
|
+
const startIndex = source.indexOf(start);
|
|
470
|
+
const endIndex = source.indexOf(end);
|
|
471
|
+
if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex ||
|
|
472
|
+
source.indexOf(start, startIndex + start.length) !== -1 || source.indexOf(end, endIndex + end.length) !== -1) {
|
|
473
|
+
throw new Error(`MainActivity.kt has missing or duplicate connector ${name} markers. No files were changed.`);
|
|
474
|
+
}
|
|
475
|
+
const startLineBegin = source.lastIndexOf("\n", startIndex - 1) + 1;
|
|
476
|
+
const startLineEnd = source.indexOf("\n", startIndex);
|
|
477
|
+
const endLineBegin = source.lastIndexOf("\n", endIndex - 1) + 1;
|
|
478
|
+
const endLineEnd = source.indexOf("\n", endIndex);
|
|
479
|
+
if (startLineEnd === -1 || endLineEnd === -1 || endLineBegin <= startLineEnd ||
|
|
480
|
+
source.slice(startLineBegin, startLineEnd) !== start || source.slice(endLineBegin, endLineEnd) !== end) {
|
|
481
|
+
throw new Error(`MainActivity.kt has modified connector ${name} markers. No files were changed.`);
|
|
482
|
+
}
|
|
483
|
+
return { from: startLineEnd + 1, to: endLineBegin, body: source.slice(startLineEnd + 1, endLineBegin) };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function replaceManagedMainActivitySections(source, sections) {
|
|
487
|
+
let result = source;
|
|
488
|
+
for (const name of Object.keys(MAIN_ACTIVITY_CONNECTOR_MARKERS)) {
|
|
489
|
+
const block = managedMainActivityBlock(result, name);
|
|
490
|
+
result = `${result.slice(0, block.from)}${sections[name]}${result.slice(block.to)}`;
|
|
491
|
+
}
|
|
492
|
+
return result;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function replaceLegacyMainActivityFragment(source, fragment, replacement) {
|
|
496
|
+
if (source.split(fragment).length !== 2) {
|
|
497
|
+
throw new Error("MainActivity.kt has customized legacy connector code. No files were changed.");
|
|
498
|
+
}
|
|
499
|
+
return source.replace(fragment, replacement);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function markLegacyMainActivity(source) {
|
|
503
|
+
let result = source.replace(/\r\n/g, "\n");
|
|
504
|
+
const old = mainActivityConnectorSections(["all"]);
|
|
505
|
+
for (const name of Object.keys(MAIN_ACTIVITY_CONNECTOR_MARKERS)) {
|
|
506
|
+
const { start, end } = MAIN_ACTIVITY_CONNECTOR_MARKERS[name];
|
|
507
|
+
result = replaceLegacyMainActivityFragment(result, old[name], `${start}\n${old[name]}${end}\n`);
|
|
508
|
+
}
|
|
509
|
+
return result;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function prepareMainActivity(source, currentPluginIds) {
|
|
513
|
+
const hasMarkers = Object.values(MAIN_ACTIVITY_CONNECTOR_MARKERS)
|
|
514
|
+
.some(({ start, end }) => source.includes(start) || source.includes(end));
|
|
515
|
+
if (!hasMarkers) {
|
|
516
|
+
if (currentPluginIds == null) {
|
|
517
|
+
throw new Error("MainActivity.kt is missing managed connector markers. No files were changed.");
|
|
518
|
+
}
|
|
519
|
+
return { source: markLegacyMainActivity(source), legacy: true };
|
|
520
|
+
}
|
|
521
|
+
const expected = mainActivityConnectorSections(currentPluginIds ?? []);
|
|
522
|
+
for (const name of Object.keys(MAIN_ACTIVITY_CONNECTOR_MARKERS)) {
|
|
523
|
+
if (normalizeSource(managedMainActivityBlock(source, name).body) !== normalizeSource(expected[name])) {
|
|
524
|
+
throw new Error(`MainActivity.kt connector ${name} block was customized. No files were changed.`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return { source, legacy: false };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function mainActivityOutsideConnectorBlocks(source) {
|
|
531
|
+
return replaceManagedMainActivitySections(source, { imports: "", registration: "", callbacks: "" });
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function connectorConfigPath(projectRoot) {
|
|
535
|
+
return path.join(projectRoot, CONNECTOR_CONFIG_RELATIVE_PATH);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function connectorRegistryPath(androidDir, androidId) {
|
|
539
|
+
return path.join(androidDir, "app", "src", "main", "java", ...androidId.split("."), "LynxAndroidConnectorRegistry.kt");
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function connectorDependencyBlock(pluginIds, versions) {
|
|
543
|
+
return [
|
|
544
|
+
CONNECTOR_DEPENDENCY_START,
|
|
545
|
+
androidPluginHostConfiguration(pluginIds, versions).gradleDependencies,
|
|
546
|
+
CONNECTOR_DEPENDENCY_END,
|
|
547
|
+
].join("\n");
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function atomicWrite(filePath, contents) {
|
|
551
|
+
const temporaryPath = `${filePath}.tmp-${process.pid}`;
|
|
552
|
+
fs.writeFileSync(temporaryPath, contents);
|
|
553
|
+
fs.renameSync(temporaryPath, filePath);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function connectorStateUpdates({ projectRoot, androidDir, androidId, pluginIds }) {
|
|
557
|
+
const configPath = connectorConfigPath(projectRoot);
|
|
558
|
+
const registryPath = connectorRegistryPath(androidDir, androidId);
|
|
559
|
+
const mainActivityPath = path.join(path.dirname(registryPath), "MainActivity.kt");
|
|
560
|
+
const gradlePath = path.join(androidDir, "app", "build.gradle.kts");
|
|
561
|
+
const gradleSource = fs.readFileSync(gradlePath, "utf8");
|
|
562
|
+
const start = gradleSource.indexOf(CONNECTOR_DEPENDENCY_START);
|
|
563
|
+
const end = gradleSource.indexOf(CONNECTOR_DEPENDENCY_END);
|
|
564
|
+
if (start === -1 || end === -1 || end < start || gradleSource.indexOf(CONNECTOR_DEPENDENCY_START, start + 1) !== -1) {
|
|
565
|
+
throw new Error("The Android connector dependency block is missing or was modified. No files were changed.");
|
|
566
|
+
}
|
|
567
|
+
const currentRegistry = fs.existsSync(registryPath) ? fs.readFileSync(registryPath, "utf8") : null;
|
|
568
|
+
const currentConfig = fs.existsSync(configPath) ? readConnectorConfig(configPath) : null;
|
|
569
|
+
const currentConfigSource = currentConfig == null ? null : fs.readFileSync(configPath, "utf8");
|
|
570
|
+
const mainActivitySource = fs.readFileSync(mainActivityPath, "utf8");
|
|
571
|
+
const mainActivityEol = mainActivitySource.includes("\r\n") ? "\r\n" : "\n";
|
|
572
|
+
const preparedMainActivity = prepareMainActivity(mainActivitySource.replace(/\r\n/g, "\n"), currentConfig?.plugins ?? null);
|
|
573
|
+
const currentDependencyBlock = gradleSource.slice(start, end + CONNECTOR_DEPENDENCY_END.length);
|
|
574
|
+
const managedDependencyBlocks = currentConfig == null ? [] : [
|
|
575
|
+
connectorDependencyBlock(currentConfig.plugins),
|
|
576
|
+
...LYNX_ANDROID_LEGACY_VERSIONS.map((versions) => connectorDependencyBlock(currentConfig.plugins, versions)),
|
|
577
|
+
];
|
|
578
|
+
if (currentConfig != null && !managedDependencyBlocks.some((block) =>
|
|
579
|
+
normalizeSource(currentDependencyBlock) === normalizeSource(block))) {
|
|
580
|
+
throw new Error("The Android connector dependency block was modified outside the generator. No files were changed.");
|
|
581
|
+
}
|
|
582
|
+
const expectedRegistry = currentConfig == null ? null : preparedMainActivity.legacy
|
|
583
|
+
? buildLegacyAndroidConnectorRegistry(androidId, currentConfig.plugins)
|
|
584
|
+
: buildAndroidConnectorRegistry(androidId, currentConfig.plugins);
|
|
585
|
+
if ((currentRegistry == null) !== (expectedRegistry == null) ||
|
|
586
|
+
(currentRegistry != null && normalizeSource(currentRegistry) !== normalizeSource(expectedRegistry))) {
|
|
587
|
+
throw new Error("LynxAndroidConnectorRegistry.kt was modified outside the generator. No files were changed.");
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const nextConfiguration = androidPluginHostConfiguration(pluginIds);
|
|
591
|
+
const outsideMainActivity = mainActivityOutsideConnectorBlocks(preparedMainActivity.source);
|
|
592
|
+
const externalIntentImport = /^import android\.content\.Intent\s*$/m.test(outsideMainActivity);
|
|
593
|
+
const outsideWithoutIntentImport = outsideMainActivity.replace(/^import android\.content\.Intent\s*$/gm, "");
|
|
594
|
+
if (nextConfiguration.handlesActivity && externalIntentImport) {
|
|
595
|
+
throw new Error("MainActivity.kt already imports Intent outside the managed connector block. No files were changed.");
|
|
596
|
+
}
|
|
597
|
+
if (!nextConfiguration.handlesActivity &&
|
|
598
|
+
managedMainActivityBlock(preparedMainActivity.source, "imports").body.includes("import android.content.Intent") &&
|
|
599
|
+
/\bIntent\b/.test(outsideWithoutIntentImport) && !externalIntentImport) {
|
|
600
|
+
throw new Error("MainActivity.kt uses the managed Intent import outside connector callbacks. No files were changed.");
|
|
601
|
+
}
|
|
602
|
+
const nextRegistry = buildAndroidConnectorRegistry(androidId, pluginIds);
|
|
603
|
+
if (nextRegistry == null) {
|
|
604
|
+
if (/\bLynxAndroidConnectorRegistry\b/.test(outsideMainActivity)) {
|
|
605
|
+
throw new Error("MainActivity.kt references the connector registry outside the managed block. No files were changed.");
|
|
606
|
+
}
|
|
607
|
+
const javaRoot = path.join(androidDir, "app", "src", "main", "java");
|
|
608
|
+
for (const file of walkFiles(javaRoot)) {
|
|
609
|
+
if (file === registryPath || file === mainActivityPath || !/\.(kt|java)$/.test(file)) continue;
|
|
610
|
+
if (fs.readFileSync(file, "utf8").includes("LynxAndroidConnectorRegistry")) {
|
|
611
|
+
throw new Error(`${path.basename(file)} references the connector registry. No files were changed.`);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
const nextMainActivity = replaceManagedMainActivitySections(
|
|
616
|
+
preparedMainActivity.source,
|
|
617
|
+
mainActivityConnectorSections(pluginIds),
|
|
618
|
+
).replace(/\n/g, mainActivityEol);
|
|
619
|
+
const updatedGradle = `${gradleSource.slice(0, start)}${connectorDependencyBlock(pluginIds)}${gradleSource.slice(end + CONNECTOR_DEPENDENCY_END.length)}`;
|
|
620
|
+
return [
|
|
621
|
+
{ path: gradlePath, previous: gradleSource, next: updatedGradle },
|
|
622
|
+
{ path: mainActivityPath, previous: mainActivitySource, next: nextMainActivity },
|
|
623
|
+
{ path: registryPath, previous: currentRegistry, next: nextRegistry },
|
|
624
|
+
{ path: configPath, previous: currentConfigSource, next: `${JSON.stringify({ version: 1, plugins: pluginIds }, null, 2)}\n` },
|
|
625
|
+
];
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function applyManagedUpdates(updates) {
|
|
629
|
+
const changed = updates.filter(({ previous, next }) => previous !== next);
|
|
630
|
+
for (const update of changed) {
|
|
631
|
+
const present = fs.existsSync(update.path);
|
|
632
|
+
if ((update.previous == null) !== !present ||
|
|
633
|
+
(present && fs.readFileSync(update.path, "utf8") !== update.previous)) {
|
|
634
|
+
throw new Error(`${path.basename(update.path)} changed during connector preparation. No files were changed.`);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
const applied = [];
|
|
638
|
+
try {
|
|
639
|
+
for (const update of changed) {
|
|
640
|
+
if (update.next == null) {
|
|
641
|
+
fs.unlinkSync(update.path);
|
|
642
|
+
} else {
|
|
643
|
+
fs.mkdirSync(path.dirname(update.path), { recursive: true });
|
|
644
|
+
atomicWrite(update.path, update.next);
|
|
645
|
+
}
|
|
646
|
+
applied.push(update);
|
|
647
|
+
}
|
|
648
|
+
} catch (error) {
|
|
649
|
+
const rollbackErrors = [];
|
|
650
|
+
for (const update of applied.reverse()) {
|
|
651
|
+
try {
|
|
652
|
+
if (update.previous == null) fs.unlinkSync(update.path);
|
|
653
|
+
else atomicWrite(update.path, update.previous);
|
|
654
|
+
} catch (rollbackError) {
|
|
655
|
+
rollbackErrors.push(`${path.basename(update.path)}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
if (rollbackErrors.length > 0) {
|
|
659
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)}. Rollback failed for ${rollbackErrors.join(", ")}.`);
|
|
660
|
+
}
|
|
661
|
+
throw error;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function readConnectorConfig(configPath) {
|
|
666
|
+
let config;
|
|
667
|
+
try {
|
|
668
|
+
config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
669
|
+
} catch {
|
|
670
|
+
throw new Error("android/connectors.json is not valid JSON. No files were changed.");
|
|
671
|
+
}
|
|
672
|
+
if (config?.version !== 1 || !Array.isArray(config.plugins) || config.plugins.some((id) => typeof id !== "string")) {
|
|
673
|
+
throw new Error("android/connectors.json has an unsupported shape. No files were changed.");
|
|
674
|
+
}
|
|
675
|
+
return { plugins: normalizeAndroidPlugins(config.plugins) };
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function requireManagedConnectorProject() {
|
|
679
|
+
const projectRoot = findProjectRoot(cwd);
|
|
680
|
+
if (projectRoot == null) throw new Error("No mithril-lynx project found here.");
|
|
681
|
+
const androidDir = findAndroidDir(projectRoot);
|
|
682
|
+
if (androidDir == null) throw new Error("No sibling Android host found for this project.");
|
|
683
|
+
const configPath = connectorConfigPath(projectRoot);
|
|
684
|
+
if (!fs.existsSync(configPath)) {
|
|
685
|
+
throw new Error(
|
|
686
|
+
"This Android host predates managed connectors or was customized. " +
|
|
687
|
+
"It is intentionally not patched automatically.",
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
const namespaceMatch = fs.readFileSync(path.join(androidDir, "app", "build.gradle.kts"), "utf8").match(/namespace\s*=\s*"([^"]+)"/);
|
|
691
|
+
if (namespaceMatch == null) throw new Error("Could not determine the Android namespace. No files were changed.");
|
|
692
|
+
return { projectRoot, androidDir, androidId: namespaceMatch[1], config: readConnectorConfig(configPath) };
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
async function runAndroidPluginCommand(command, args) {
|
|
696
|
+
intro(`create-mithril-lynx ${command}`);
|
|
697
|
+
let managed;
|
|
698
|
+
try {
|
|
699
|
+
managed = requireManagedConnectorProject();
|
|
700
|
+
} catch (error) {
|
|
701
|
+
cancel(error instanceof Error ? error.message : String(error));
|
|
702
|
+
process.exit(1);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
if (command === "list-android-plugins") {
|
|
706
|
+
outro(
|
|
707
|
+
managed.config.plugins.length === 0
|
|
708
|
+
? "No Android connectors are enabled."
|
|
709
|
+
: `Enabled Android connectors: ${managed.config.plugins.join(", ")}`,
|
|
710
|
+
);
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const rawPlugins = args[0];
|
|
715
|
+
if (rawPlugins == null) {
|
|
716
|
+
cancel(`Pass a comma-separated connector list, e.g. ${command} battery,geolocation.`);
|
|
717
|
+
process.exit(1);
|
|
718
|
+
}
|
|
719
|
+
const requested = normalizeAndroidPlugins(splitList(rawPlugins));
|
|
720
|
+
const skipInstall = args.includes("--no-install");
|
|
721
|
+
let next;
|
|
722
|
+
if (command === "add-android-plugin") {
|
|
723
|
+
next = managed.config.plugins.includes("all") || requested.includes("all")
|
|
724
|
+
? ["all"]
|
|
725
|
+
: normalizeAndroidPlugins([...managed.config.plugins, ...requested]);
|
|
726
|
+
} else {
|
|
727
|
+
if (managed.config.plugins.includes("all") && !requested.includes("all")) {
|
|
728
|
+
cancel('The aggregate "all" connector must be removed as a whole.');
|
|
729
|
+
process.exit(1);
|
|
730
|
+
}
|
|
731
|
+
next = managed.config.plugins.filter((plugin) => !requested.includes(plugin));
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
let dependenciesChanged = false;
|
|
735
|
+
try {
|
|
736
|
+
const npm = updatedConnectorPackageJson(managed.projectRoot, managed.config.plugins, next);
|
|
737
|
+
const previousPackageSource = fs.readFileSync(npm.packagePath, "utf8");
|
|
738
|
+
dependenciesChanged = npm.source !== previousPackageSource;
|
|
739
|
+
applyManagedUpdates([
|
|
740
|
+
...connectorStateUpdates({ ...managed, pluginIds: next }),
|
|
741
|
+
...mapsConfigurationUpdate(managed.projectRoot, next),
|
|
742
|
+
{ path: npm.packagePath, previous: previousPackageSource, next: npm.source },
|
|
743
|
+
]);
|
|
744
|
+
if (dependenciesChanged) {
|
|
745
|
+
if (!skipInstall) {
|
|
746
|
+
const manager = projectPackageManager(managed.projectRoot);
|
|
747
|
+
try {
|
|
748
|
+
execFileSync(manager, ["install"], { cwd: managed.projectRoot, stdio: "inherit" });
|
|
749
|
+
} catch {
|
|
750
|
+
throw new Error(`Connector configuration was updated, but ${manager} install failed. Run it in the project to finish installation.`);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
} catch (error) {
|
|
755
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
756
|
+
cancel(message);
|
|
757
|
+
process.exitCode = 1;
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
outro((next.length === 0 ? "No Android connectors are enabled." : `Enabled Android connectors: ${next.join(", ")}.`) +
|
|
761
|
+
(skipInstall && dependenciesChanged ? `\nRun ${projectPackageManager(managed.projectRoot)} install to update dependencies.` : ""));
|
|
762
|
+
}
|
|
763
|
+
|
|
158
764
|
/** Renames `file` on any font past the first with the same basename, so
|
|
159
765
|
* e.g. two different "Inter" downloads (different weights) don't collide
|
|
160
766
|
* once copied into src/assets/fonts/. */
|
|
@@ -215,6 +821,7 @@ function parseAndroidArgs(args) {
|
|
|
215
821
|
fontPath: readOption(args, "with-font"),
|
|
216
822
|
fontFamily: readOption(args, "font-family"),
|
|
217
823
|
findFontTerm: readOption(args, "find-font"),
|
|
824
|
+
plugins: readOption(args, "android-plugins"),
|
|
218
825
|
};
|
|
219
826
|
}
|
|
220
827
|
|
|
@@ -227,6 +834,7 @@ const OPTIONS_WITH_VALUE = new Set([
|
|
|
227
834
|
"--with-font",
|
|
228
835
|
"--font-family",
|
|
229
836
|
"--find-font",
|
|
837
|
+
"--android-plugins",
|
|
230
838
|
]);
|
|
231
839
|
|
|
232
840
|
function findPositional(args) {
|
|
@@ -369,6 +977,46 @@ async function findFontInteractively(term) {
|
|
|
369
977
|
return results;
|
|
370
978
|
}
|
|
371
979
|
|
|
980
|
+
/** Prompt for any number of Fontsource families during interactive scaffolding. */
|
|
981
|
+
async function addFontsInteractively() {
|
|
982
|
+
const fonts = [];
|
|
983
|
+
let addAnother = await confirm({
|
|
984
|
+
message: "Would you like to add fonts?",
|
|
985
|
+
initialValue: false,
|
|
986
|
+
});
|
|
987
|
+
if (isCancel(addAnother)) return null;
|
|
988
|
+
|
|
989
|
+
while (addAnother) {
|
|
990
|
+
const term = await text({
|
|
991
|
+
message: "Search for a font",
|
|
992
|
+
placeholder: "e.g. Inter",
|
|
993
|
+
validate(value) {
|
|
994
|
+
if (!value?.trim()) return "Enter a font name to search.";
|
|
995
|
+
},
|
|
996
|
+
});
|
|
997
|
+
if (isCancel(term)) return null;
|
|
998
|
+
|
|
999
|
+
const selected = await findFontInteractively(term.trim());
|
|
1000
|
+
if (selected == null) return null;
|
|
1001
|
+
fonts.push(
|
|
1002
|
+
...selected.map((font) => ({
|
|
1003
|
+
sourcePath: font.sourcePath,
|
|
1004
|
+
file: path.basename(font.sourcePath),
|
|
1005
|
+
family: font.family,
|
|
1006
|
+
})),
|
|
1007
|
+
);
|
|
1008
|
+
dedupeFontFiles(fonts);
|
|
1009
|
+
|
|
1010
|
+
addAnother = await confirm({
|
|
1011
|
+
message: "Would you like to add another font?",
|
|
1012
|
+
initialValue: false,
|
|
1013
|
+
});
|
|
1014
|
+
if (isCancel(addAnother)) return null;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
return fonts;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
372
1020
|
// ---------------------------------------------------------------------------
|
|
373
1021
|
// Android host scaffolding
|
|
374
1022
|
// ---------------------------------------------------------------------------
|
|
@@ -412,10 +1060,30 @@ async function resolveAndroidOptions(android, { rawName, projectName, canPrompt
|
|
|
412
1060
|
}
|
|
413
1061
|
appName = appName ?? path.basename(rawName);
|
|
414
1062
|
|
|
1063
|
+
let plugins = android.plugins == null ? [] : normalizeAndroidPlugins(splitList(android.plugins));
|
|
1064
|
+
if (android.plugins == null && canPrompt) {
|
|
1065
|
+
plugins = await multiselect({
|
|
1066
|
+
message: "Which Lynx Android connectors should the host include?",
|
|
1067
|
+
initialValues: [],
|
|
1068
|
+
options: [
|
|
1069
|
+
...ANDROID_PLUGINS.map((plugin) => ({ value: plugin.id, label: plugin.label, hint: plugin.hint })),
|
|
1070
|
+
{ value: "all", label: "All connectors", hint: "convenience aggregate; includes every manifest contribution" },
|
|
1071
|
+
],
|
|
1072
|
+
});
|
|
1073
|
+
if (isCancel(plugins)) return null;
|
|
1074
|
+
plugins = normalizeAndroidPlugins(plugins);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
415
1077
|
const fonts = await resolveFontsFromFlags(android, { baseDir: cwd, requireFonts: false });
|
|
416
1078
|
if (fonts == null) return null;
|
|
1079
|
+
if (canPrompt && android.fontPath == null && android.findFontTerm == null) {
|
|
1080
|
+
const interactiveFonts = await addFontsInteractively();
|
|
1081
|
+
if (interactiveFonts == null) return null;
|
|
1082
|
+
fonts.push(...interactiveFonts);
|
|
1083
|
+
dedupeFontFiles(fonts);
|
|
1084
|
+
}
|
|
417
1085
|
|
|
418
|
-
return { androidId, appName, fonts };
|
|
1086
|
+
return { androidId, appName, fonts, plugins };
|
|
419
1087
|
}
|
|
420
1088
|
|
|
421
1089
|
/**
|
|
@@ -584,7 +1252,7 @@ async function runAddFont(args) {
|
|
|
584
1252
|
}
|
|
585
1253
|
|
|
586
1254
|
function scaffoldAndroid({ targetDir, rawName, options }) {
|
|
587
|
-
const { androidId, appName, fonts } = options;
|
|
1255
|
+
const { androidId, appName, fonts, plugins } = options;
|
|
588
1256
|
|
|
589
1257
|
const androidDirName = `${path.basename(rawName.replace(/^@[^/]+\//, ""))}-android`;
|
|
590
1258
|
const androidDir = path.join(path.dirname(targetDir), androidDirName);
|
|
@@ -616,6 +1284,8 @@ function scaffoldAndroid({ targetDir, rawName, options }) {
|
|
|
616
1284
|
// and templates/android/host/.../NoopGenericResourceFetcher.kt.
|
|
617
1285
|
const sdkDir = findAndroidSdk();
|
|
618
1286
|
|
|
1287
|
+
const pluginHost = androidPluginHostConfiguration(plugins);
|
|
1288
|
+
|
|
619
1289
|
// 3. Text substitutions across the whole Android host (skipping the
|
|
620
1290
|
// gradle-wrapper.jar, the .ttf and any other binary).
|
|
621
1291
|
replaceInTree(androidDir, [
|
|
@@ -623,6 +1293,7 @@ function scaffoldAndroid({ targetDir, rawName, options }) {
|
|
|
623
1293
|
["{{APP_CLASS}}", appClass],
|
|
624
1294
|
["{{APP_NAME}}", xmlEscape(appName)],
|
|
625
1295
|
["{{ANDROID_DIR}}", androidDirName],
|
|
1296
|
+
["{{ANDROID_PLUGIN_DEPENDENCIES}}", pluginHost.gradleDependencies],
|
|
626
1297
|
[
|
|
627
1298
|
"{{SDK_DIR}}",
|
|
628
1299
|
sdkDir != null
|
|
@@ -641,10 +1312,23 @@ function scaffoldAndroid({ targetDir, rawName, options }) {
|
|
|
641
1312
|
}
|
|
642
1313
|
}
|
|
643
1314
|
|
|
644
|
-
// 5.
|
|
1315
|
+
// 5. Give app developers editable Android XML in their project, instead of
|
|
1316
|
+
// making them traverse into the sibling Gradle host. android:prepare copies
|
|
1317
|
+
// these exact files back into their Android resource locations.
|
|
1318
|
+
seedAndroidDevelopmentFiles(targetDir, androidDir);
|
|
1319
|
+
|
|
1320
|
+
// 6. The managed MainActivity sections, registry, and Gradle dependency block
|
|
1321
|
+
// are rendered together so post-install connector changes stay consistent.
|
|
1322
|
+
applyManagedUpdates([
|
|
1323
|
+
...connectorStateUpdates({ projectRoot: targetDir, androidDir, androidId, pluginIds: plugins }),
|
|
1324
|
+
...mapsConfigurationUpdate(targetDir, plugins),
|
|
1325
|
+
]);
|
|
1326
|
+
|
|
1327
|
+
// 7. The scripts that join the two halves, inside the JS project.
|
|
645
1328
|
const scriptsDir = path.join(targetDir, "scripts");
|
|
646
1329
|
fs.mkdirSync(scriptsDir, { recursive: true });
|
|
647
1330
|
fs.copyFileSync(path.join(ANDROID_TEMPLATE_ROOT, "app-scripts", "android.mjs"), path.join(scriptsDir, "android.mjs"));
|
|
1331
|
+
fs.copyFileSync(path.join(ANDROID_TEMPLATE_ROOT, "app-scripts", "android-prepare.mjs"), path.join(scriptsDir, "android-prepare.mjs"));
|
|
648
1332
|
|
|
649
1333
|
const androidRelDir = path.relative(targetDir, androidDir).split(path.sep).join("/");
|
|
650
1334
|
replaceInFile(path.join(scriptsDir, "android.mjs"), [
|
|
@@ -654,20 +1338,28 @@ function scaffoldAndroid({ targetDir, rawName, options }) {
|
|
|
654
1338
|
// A name with quotes would break keytool's -dname=CN=...
|
|
655
1339
|
["{{APP_NAME}}", appName.replace(/["\\]/g, "")],
|
|
656
1340
|
]);
|
|
1341
|
+
replaceInFile(path.join(scriptsDir, "android-prepare.mjs"), [["{{ANDROID_REL_DIR}}", androidRelDir]]);
|
|
657
1342
|
|
|
658
|
-
return { androidDir, androidDirName, androidRelDir, androidId, appName, appClass, fonts, sdkDir };
|
|
1343
|
+
return { androidDir, androidDirName, androidRelDir, androidId, appName, appClass, fonts, plugins: pluginHost.pluginIds, sdkDir };
|
|
659
1344
|
}
|
|
660
1345
|
|
|
661
1346
|
function patchJsProject({ targetDir, android }) {
|
|
662
1347
|
// package.json: the Android scripts.
|
|
663
1348
|
const pkgPath = path.join(targetDir, "package.json");
|
|
664
1349
|
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
1350
|
+
for (const name of connectorNpmPackages(android.plugins)) pkg.dependencies[name] = LYNX_ANDROID_JS_VERSION;
|
|
1351
|
+
const buildCommand = pkg.scripts.build;
|
|
665
1352
|
pkg.scripts = {
|
|
666
1353
|
...pkg.scripts,
|
|
1354
|
+
// Keep project-owned Android XML authoritative for every build path:
|
|
1355
|
+
// `bun run build`, `npm run build`, and android.mjs's package-manager
|
|
1356
|
+
// invocation all execute this command before rspeedy starts.
|
|
1357
|
+
build: "node scripts/android-prepare.mjs && " + buildCommand,
|
|
667
1358
|
android: "node scripts/android.mjs",
|
|
668
1359
|
"android:apk": "node scripts/android.mjs --apk",
|
|
669
1360
|
"android:release": "node scripts/android.mjs --release",
|
|
670
1361
|
"android:sync": "node scripts/android.mjs --sync-only",
|
|
1362
|
+
"android:prepare": "node scripts/android-prepare.mjs",
|
|
671
1363
|
"android:keystore": "node scripts/android.mjs --keystore",
|
|
672
1364
|
};
|
|
673
1365
|
fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
@@ -701,6 +1393,7 @@ function patchJsProject({ targetDir, android }) {
|
|
|
701
1393
|
"npm run android # build the bundle, copy it to assets, install and launch on the device",
|
|
702
1394
|
"npm run android:apk # build the debug APK only",
|
|
703
1395
|
"npm run android:sync # bundle -> assets only, no Gradle",
|
|
1396
|
+
"bun run android:prepare # android/*.xml -> sibling Android host",
|
|
704
1397
|
"",
|
|
705
1398
|
"# Signed release APK (generate the keystore once):",
|
|
706
1399
|
"KEYSTORE_PASSWORD='...' npm run android:keystore",
|
|
@@ -709,6 +1402,11 @@ function patchJsProject({ targetDir, android }) {
|
|
|
709
1402
|
"",
|
|
710
1403
|
`- Application ID: \`${android.androidId}\``,
|
|
711
1404
|
`- Application class: \`${android.appClass}\` · Activity: \`MainActivity\``,
|
|
1405
|
+
"- Android XML: edit `android/*.xml`; every `bun run build` synchronizes those files to the sibling host before bundling. Run `bun run android:prepare` when you only need the synchronization.",
|
|
1406
|
+
"- Android system Back bridge: the host pre-registers `MithrilLynxNavigationModule`; connect a `mithril-lynx/route` app with `route.listenBackButton({ onCanGoBackChange: (value) => NativeModules.MithrilLynxNavigationModule?.setCanGoBack(value) })`. The Basic Activity template is already connected.",
|
|
1407
|
+
android.plugins.length > 0
|
|
1408
|
+
? `- Android connectors: ${android.plugins.join(", ")}. The host registers the native modules and package.json includes their typed JavaScript facades. See [lynx-android-plugins](https://github.com/carlos-sweb/lynx-android-plugins#javascript-facades).`
|
|
1409
|
+
: "- Android connectors: none selected. Run `npx create-mithril-lynx add-android-plugin <name>` from this app directory to opt into one.",
|
|
712
1410
|
...android.fonts.map(
|
|
713
1411
|
(f) =>
|
|
714
1412
|
`- Font \`${f.family}\` (\`src/assets/fonts/${f.file}\`): \`lynx.addFont()\` — DEV inlines a \`data:\` URI for Lynx Go; PROD uses \`asset:///fonts/${f.file}\` via \`AssetFontFaceLoader\` ([lynx#9431](https://github.com/lynx-family/lynx/issues/9431)).`,
|
|
@@ -719,6 +1417,79 @@ function patchJsProject({ targetDir, android }) {
|
|
|
719
1417
|
}
|
|
720
1418
|
}
|
|
721
1419
|
|
|
1420
|
+
function addUiThemeClass(source, rootClass, themeClass, sourcePath) {
|
|
1421
|
+
const themedClass = `class: "${rootClass} ${themeClass}"`;
|
|
1422
|
+
if (source.includes(themedClass)) return source;
|
|
1423
|
+
|
|
1424
|
+
const currentClass = `class: "${rootClass}"`;
|
|
1425
|
+
const index = source.indexOf(currentClass);
|
|
1426
|
+
if (index === -1) {
|
|
1427
|
+
throw new Error(`Cannot configure mithril-lynx-ui: expected root class ${JSON.stringify(rootClass)} in ${sourcePath}.`);
|
|
1428
|
+
}
|
|
1429
|
+
return `${source.slice(0, index)}${themedClass}${source.slice(index + currentClass.length)}`;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
function patchUiProject({ targetDir, template }) {
|
|
1433
|
+
const templateUi = {
|
|
1434
|
+
"hello-world": {
|
|
1435
|
+
themeClass: "luna-dark",
|
|
1436
|
+
roots: [{ file: "src/index.ts", className: "App" }],
|
|
1437
|
+
},
|
|
1438
|
+
blank: {
|
|
1439
|
+
themeClass: "luna-light",
|
|
1440
|
+
roots: [{ file: "src/index.ts", className: "Page" }],
|
|
1441
|
+
},
|
|
1442
|
+
"basic-activity": {
|
|
1443
|
+
themeClass: "luna-light",
|
|
1444
|
+
roots: ["home", "detail"].map((screen) => ({
|
|
1445
|
+
file: `src/screens/${screen}.ts`,
|
|
1446
|
+
className: "Page",
|
|
1447
|
+
})),
|
|
1448
|
+
},
|
|
1449
|
+
}[template];
|
|
1450
|
+
if (templateUi == null) throw new Error(`Cannot configure mithril-lynx-ui for unknown template "${template}".`);
|
|
1451
|
+
|
|
1452
|
+
const packagePath = path.join(targetDir, "package.json");
|
|
1453
|
+
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
|
1454
|
+
packageJson.dependencies ??= {};
|
|
1455
|
+
packageJson.dependencies["mithril-lynx-ui"] = MITHRIL_LYNX_UI_VERSION;
|
|
1456
|
+
|
|
1457
|
+
const stylesheetPath = path.join(targetDir, "src", "style.css");
|
|
1458
|
+
const stylesheet = fs.readFileSync(stylesheetPath, "utf8");
|
|
1459
|
+
const updatedStylesheet = stylesheet.includes(MITHRIL_LYNX_UI_STYLES_IMPORT)
|
|
1460
|
+
? stylesheet
|
|
1461
|
+
: `${MITHRIL_LYNX_UI_STYLES_IMPORT}\n\n${stylesheet}`;
|
|
1462
|
+
|
|
1463
|
+
const themedSources = templateUi.roots.map(({ file, className }) => {
|
|
1464
|
+
const sourcePath = path.join(targetDir, file);
|
|
1465
|
+
const source = fs.readFileSync(sourcePath, "utf8");
|
|
1466
|
+
return [sourcePath, addUiThemeClass(source, className, templateUi.themeClass, file)];
|
|
1467
|
+
});
|
|
1468
|
+
|
|
1469
|
+
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
|
1470
|
+
if (updatedStylesheet !== stylesheet) fs.writeFileSync(stylesheetPath, updatedStylesheet);
|
|
1471
|
+
for (const [sourcePath, source] of themedSources) fs.writeFileSync(sourcePath, source);
|
|
1472
|
+
|
|
1473
|
+
const readmePath = path.join(targetDir, "README.md");
|
|
1474
|
+
if (fs.existsSync(readmePath)) {
|
|
1475
|
+
const readme = fs.readFileSync(readmePath, "utf8");
|
|
1476
|
+
if (!readme.includes("## UI components")) {
|
|
1477
|
+
const section = [
|
|
1478
|
+
"",
|
|
1479
|
+
"## UI components",
|
|
1480
|
+
"",
|
|
1481
|
+
`This project was scaffolded with \`mithril-lynx-ui\`. Its stylesheet is imported from \`src/style.css\`, and the app root uses the \`${templateUi.themeClass}\` Luna theme.`,
|
|
1482
|
+
"",
|
|
1483
|
+
"Import components from their package entry points, for example `import { Button } from \"mithril-lynx-ui/button\";`. Components are headless by default; add their documented `ui-*` classes to opt into the supplied styles.",
|
|
1484
|
+
"",
|
|
1485
|
+
"See the [mithril-lynx-ui documentation](https://github.com/carlos-sweb/mithril-lynx-ui#readme) for component APIs and styling details.",
|
|
1486
|
+
"",
|
|
1487
|
+
].join("\n");
|
|
1488
|
+
fs.appendFileSync(readmePath, section);
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
|
|
722
1493
|
// ---------------------------------------------------------------------------
|
|
723
1494
|
|
|
724
1495
|
const USAGE = `
|
|
@@ -727,17 +1498,27 @@ create-mithril-lynx — scaffold a mithril-lynx app (and, optionally, its Androi
|
|
|
727
1498
|
Usage:
|
|
728
1499
|
npm create mithril-lynx@latest [name] [options]
|
|
729
1500
|
npx create-mithril-lynx <name> --blank --android
|
|
1501
|
+
npx create-mithril-lynx <name> --blank --with-ui
|
|
730
1502
|
npx create-mithril-lynx add-font --with-font ./Foo.ttf
|
|
731
1503
|
npx create-mithril-lynx add-font --find-font Inter
|
|
1504
|
+
npx create-mithril-lynx add-android-plugin battery,geolocation
|
|
732
1505
|
|
|
733
1506
|
Template (prompted for if omitted):
|
|
734
1507
|
--hello-world | --blank | --basic-activity
|
|
735
1508
|
|
|
1509
|
+
UI components:
|
|
1510
|
+
--with-ui add mithril-lynx-ui and import its stylesheet;
|
|
1511
|
+
offered during interactive setup
|
|
1512
|
+
|
|
736
1513
|
Android host:
|
|
737
|
-
--android, --target android, target=android
|
|
1514
|
+
--android, --target android, --target=android, target=android
|
|
738
1515
|
scaffold the sibling Gradle project <name>-android/
|
|
739
1516
|
--android-id <id> applicationId / namespace (default com.example.<name>)
|
|
740
1517
|
--app-name <name> launcher label (default: the project name)
|
|
1518
|
+
--android-plugins <list> native connectors to include: battery, camera,
|
|
1519
|
+
device, geolocation, network, vibration, maps,
|
|
1520
|
+
sqlite, or all.
|
|
1521
|
+
Comma-separate individual connectors.
|
|
741
1522
|
--with-font <file.ttf> bundle the font into the JS project and register it
|
|
742
1523
|
with lynx.addFont() (DEV: data: URI for Lynx Go;
|
|
743
1524
|
PROD: asset:/// + AssetFontFaceLoader — see README).
|
|
@@ -760,9 +1541,14 @@ Post-init (run inside an existing app directory):
|
|
|
760
1541
|
print the lines to paste by hand.
|
|
761
1542
|
Same --with-font / --find-font / --font-family
|
|
762
1543
|
flags as above.
|
|
1544
|
+
add-android-plugin <list> add managed Android connector(s)
|
|
1545
|
+
remove-android-plugin <list>
|
|
1546
|
+
remove managed Android connector(s)
|
|
1547
|
+
list-android-plugins show managed Android connectors
|
|
763
1548
|
|
|
764
1549
|
Other:
|
|
765
|
-
--no-install don't install dependencies
|
|
1550
|
+
--no-install don't install dependencies during scaffold or
|
|
1551
|
+
post-init connector changes
|
|
766
1552
|
-h, --help print this
|
|
767
1553
|
`.trim();
|
|
768
1554
|
|
|
@@ -784,16 +1570,20 @@ async function main() {
|
|
|
784
1570
|
await runAddFont(args.slice(1));
|
|
785
1571
|
return;
|
|
786
1572
|
}
|
|
1573
|
+
if (["add-android-plugin", "remove-android-plugin", "list-android-plugins"].includes(args[0])) {
|
|
1574
|
+
await runAndroidPluginCommand(args[0], args.slice(1));
|
|
1575
|
+
return;
|
|
1576
|
+
}
|
|
787
1577
|
|
|
788
1578
|
const positional = findPositional(args);
|
|
789
1579
|
const templateFlag = TEMPLATE_VALUES.find((t) => args.includes(`--${t}`));
|
|
790
1580
|
const noInstall = args.includes("--no-install");
|
|
1581
|
+
let withUi = args.includes("--with-ui");
|
|
791
1582
|
const nonInteractive = positional != null && templateFlag != null;
|
|
792
1583
|
const android = parseAndroidArgs(args);
|
|
793
1584
|
|
|
794
|
-
//
|
|
795
|
-
|
|
796
|
-
if (android.fontPath != null || android.findFontTerm != null || android.fontFamily != null) {
|
|
1585
|
+
// Android-only options imply the host rather than being silently ignored.
|
|
1586
|
+
if (android.fontPath != null || android.findFontTerm != null || android.fontFamily != null || android.plugins != null) {
|
|
797
1587
|
android.requested = true;
|
|
798
1588
|
}
|
|
799
1589
|
|
|
@@ -831,6 +1621,14 @@ async function main() {
|
|
|
831
1621
|
if (isCancel(template)) return bail();
|
|
832
1622
|
}
|
|
833
1623
|
|
|
1624
|
+
if (!withUi && !nonInteractive) {
|
|
1625
|
+
withUi = await confirm({
|
|
1626
|
+
message: "Include mithril-lynx-ui components?",
|
|
1627
|
+
initialValue: false,
|
|
1628
|
+
});
|
|
1629
|
+
if (isCancel(withUi)) return bail();
|
|
1630
|
+
}
|
|
1631
|
+
|
|
834
1632
|
let withAndroid = android.requested;
|
|
835
1633
|
if (!withAndroid && !nonInteractive) {
|
|
836
1634
|
withAndroid = await confirm({
|
|
@@ -871,6 +1669,8 @@ async function main() {
|
|
|
871
1669
|
]);
|
|
872
1670
|
replaceInFile(path.join(targetDir, "README.md"), [["{{PROJECT_NAME}}", projectName]]);
|
|
873
1671
|
|
|
1672
|
+
if (withUi) patchUiProject({ targetDir, template });
|
|
1673
|
+
|
|
874
1674
|
let androidResult = null;
|
|
875
1675
|
if (withAndroid) {
|
|
876
1676
|
androidResult = scaffoldAndroid({ targetDir, rawName, options: androidOptions });
|