create-mithril-lynx 2.5.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 CHANGED
@@ -32,29 +32,146 @@ npm install
32
32
  npm run android # bundle -> assets -> installDebug -> launch on the device
33
33
  ```
34
34
 
35
+ ### With Android connectors
36
+
37
+ Android projects can opt into the published
38
+ [`lynx-android-plugins`](https://github.com/carlos-sweb/lynx-android-plugins)
39
+ artifacts at scaffold time. The generator adds the selected Maven libraries,
40
+ their JavaScript facade packages, manifest contributions, native-module
41
+ registrations, and the Activity callbacks required for camera and geolocation.
42
+
43
+ ```bash
44
+ npx create-mithril-lynx my-app --blank --android \
45
+ --android-plugins battery,geolocation,network
46
+ ```
47
+
48
+ Available connector names are `battery`, `camera`, `device`, `geolocation`,
49
+ `network`, `vibration`, `maps`, `sqlite`, and `all`. The interactive Android flow offers the
50
+ same selection. `all` deliberately includes every manifest contribution;
51
+ prefer individual names for production apps.
52
+
53
+ The generated Android host declares Maven Central dependencies at version
54
+ `0.4.0`, including `io.github.carlos-sweb:lynx-android-plugins:0.4.0` for `all`
55
+ and the individual connector artifacts when selecting specific names.
56
+ Adding or removing connectors also upgrades managed dependencies from the
57
+ previous template versions (`0.2.0`, or `0.3.0` for SQLite and `all`).
58
+
59
+ The generated project receives the `lynx-android-plugins` JavaScript package
60
+ with a small typed API for each selected feature. For example,
61
+ `import { gps } from "lynx-android-plugins/geolocation"`
62
+ followed by `await gps.get({ highAccuracy: true })` returns one location.
63
+ See the [JavaScript facades](https://github.com/carlos-sweb/lynx-android-plugins#javascript-facades)
64
+ for the complete API.
65
+
66
+ For offline maps, select `--android-plugins maps` (or add `maps` after
67
+ scaffolding). This creates `android/maps.json`. Supply the HTTPS URL, SHA-256,
68
+ and version of a Chile PMTiles archive there. Every build runs
69
+ `android:prepare` first and copies that config into the Android host. Import
70
+ `Maps` from `lynx-android-plugins/maps` and render it inside a sized parent:
71
+
72
+ ```ts
73
+ import { Maps } from "lynx-android-plugins/maps";
74
+ m(Maps, { latitude: -33.532290, longitude: -71.584904, variant: "full" });
75
+ ```
76
+
77
+ The map is downloaded only when the user taps the native download button.
78
+ Afterward it renders offline. The archive must be hosted by the app developer;
79
+ this project does not offer a public map-tile service. The Maps module is
80
+ prepared for Maven/npm version `0.2.0` and is not usable from the published
81
+ `0.1.0` artifacts.
82
+
83
+ For app-private SQLite storage, select `sqlite` and import
84
+ `sqlite` from `lynx-android-plugins/sqlite`. Calls are asynchronous and run in
85
+ the generated Android host; they do not run in stock Lynx Go. See the
86
+ [SQLite connector guide](https://github.com/carlos-sweb/lynx-android-plugins/blob/main/docs/sqlite.md)
87
+ for SQL parameters, migrations, transactions, and result types. The generated
88
+ host uses Maven version `0.4.0` for SQLite and `all`, and installs the JavaScript
89
+ facade from the latest npm release of `lynx-android-plugins`.
90
+
91
+ ### Add connectors after scaffolding
92
+
93
+ Projects generated with this version can update their selected connectors
94
+ without recreating the app:
95
+
96
+ ```bash
97
+ cd my-app
98
+ npx create-mithril-lynx add-android-plugin battery,geolocation
99
+ npx create-mithril-lynx remove-android-plugin battery
100
+ npx create-mithril-lynx list-android-plugins
101
+ ```
102
+
103
+ The generator stores its selection in `android/connectors.json` and updates its
104
+ own Gradle dependency block, connector registry, and marked connector sections
105
+ of `MainActivity.kt`. It generates permission and activity callbacks only for
106
+ connectors that need them; with no connectors, it removes the generated
107
+ registry. It also adds or removes the `lynx-android-plugins` dependency in
108
+ `package.json` and runs the detected package manager's install command when
109
+ that dependency changes. Existing managed hosts are migrated when their old
110
+ generated connector code is unchanged. A custom facade version, a modified
111
+ managed block, or an older host without `android/connectors.json` causes the
112
+ command to stop before replacing those edits.
113
+ Use `--no-install` after an add/remove command to update configuration without
114
+ running the package manager immediately.
115
+ To refresh an existing host without changing its selection, add an already
116
+ enabled connector again (for example, `add-android-plugin battery --no-install`).
117
+
35
118
  The interactive prompt offers it too, and the literal `target=android` form works as well:
36
119
 
37
120
  ```bash
38
121
  npm create mithril-lynx@latest my-app target=android
39
122
  ```
40
123
 
41
- Every flag, in one table — `npx create-mithril-lynx --help` prints the same list:
124
+ ### CLI reference
125
+
126
+ These are the project-creation flags. Run `npx create-mithril-lynx --help` to
127
+ see them together with the post-scaffold commands below.
42
128
 
43
129
  | Flag | Effect |
44
130
  |---|---|
45
131
  | `<name>` (positional) | Project name, and the directory to create. Omit it and you're prompted. |
46
132
  | `--hello-world` / `--blank` / `--basic-activity` | Template to scaffold. Omit it and you're prompted. |
47
- | `--no-install` | Don't install dependencies (also skips that prompt). |
133
+ | `--no-install` | Skip dependency installation after scaffolding; also skips the install prompt. For connector commands, prevents the package manager from running. |
48
134
  | `-h`, `--help` | Print usage and exit. |
135
+ | `--with-ui` | Add `mithril-lynx-ui` and import its stylesheet. Offered during interactive setup; works independently of `--android`. |
49
136
  | `--android` / `--target android` / `--target=android` / `target=android` | Also scaffold the sibling `<name>-android/` Gradle project. |
50
137
  | `--android-id <id>` | `applicationId` / `namespace` (default `com.example.<name>`). |
51
138
  | `--app-name <name>` | Launcher label (default: the project name). |
139
+ | `--android-plugins <list>` | Android connectors to include: comma-separated `battery`, `camera`, `device`, `geolocation`, `network`, `vibration`, `maps`, `sqlite`, or `all`. Implies `--android`. |
52
140
  | `--with-font <file.ttf>` | Bundle the font into `src/assets/fonts/` and register it with `lynx.addFont()` in a loop. **DEV** (`npm run dev` / Lynx Go): `require()` inlines a `data:` URI so the font shows up in Explorer. **PROD** (`npm run build` / APK): `asset:///fonts/<file>` resolved by `AssetFontFaceLoader` on the host (keeps the bundle small — embedding `data:` URIs in the APK re-introduces the cold-start cost tracked in [lynx#9431](https://github.com/lynx-family/lynx/issues/9431)). Comma-separate for more than one file. |
53
141
  | `--find-font <term>` | Search [Fontsource](https://fontsource.org)'s catalog for `<term>`, prompt you to pick a family and one or more weights/styles, download each `.ttf`, and bundle them exactly like `--with-font`. Needs a real terminal (the picking is inherently interactive — use `--with-font <file.ttf>` in scripts/CI). Mutually exclusive with `--with-font`. Comma-separate terms for more than one — quote the whole thing if any term has a space. |
54
142
  | `--font-family <name>` | Override the family name derived from the font's file name (or from Fontsource, with `--find-font`). Only valid with exactly one font. |
55
143
 
56
- Anything not listed — in particular the four Android flags — requires `--android`
57
- (or one of its aliases); `--with-font`/`--find-font` imply it on their own.
144
+ `--android-id` and `--app-name` configure the host when one is generated.
145
+ `--android-plugins` and the font flags imply `--android`; `--with-ui` works
146
+ with or without the Android host.
147
+
148
+ During interactive Android setup, the CLI asks whether to add fonts. If you
149
+ choose yes, search Fontsource, select a family and its variants, then choose
150
+ whether to add another font. The setup continues after you answer no. The
151
+ `--find-font` flag remains available for scripted scaffolding.
152
+
153
+ ### Commands for an existing project
154
+
155
+ Run these from the generated app directory. Connector commands require an
156
+ Android host created by this tool and manage only the generated connector
157
+ configuration.
158
+
159
+ | Command | Arguments and options | Effect |
160
+ |---|---|---|
161
+ | `add-font` | `--with-font <file.ttf>` or `--find-font <term>`; optional `--font-family <name>` | Add fonts to the existing app and wire up the generated font registration and CSS. Font options are the same as during scaffolding. |
162
+ | `add-android-plugin` | `<list>`; optional `--no-install` | Add comma-separated Android connectors (the same names as `--android-plugins`) and update dependencies. `--no-install` leaves dependency installation to you. |
163
+ | `remove-android-plugin` | `<list>`; optional `--no-install` | Remove managed connectors and update dependencies. If `all` is enabled, remove `all` as a whole. |
164
+ | `list-android-plugins` | — | Show the connectors currently enabled for the Android host. |
165
+
166
+ ### UI components
167
+
168
+ Add `mithril-lynx-ui` to a new app and import its stylesheet with:
169
+
170
+ ```bash
171
+ npx create-mithril-lynx my-app --blank --with-ui
172
+ ```
173
+
174
+ For component APIs and styling, see the [`mithril-lynx-ui` documentation](https://github.com/carlos-sweb/mithril-lynx-ui#readme).
58
175
 
59
176
  ### More than one font
60
177
 
@@ -126,6 +243,7 @@ The sibling `<name>-android/` project is a complete, no-Android-Studio Gradle CL
126
243
  - `local.properties` with `sdk.dir` auto-detected from `ANDROID_HOME`/`ANDROID_SDK_ROOT` (with a written warning if it can't be found);
127
244
  - the Kotlin host: the `Application` (registers `AssetFontFaceLoader`), `MainActivity` (async `AssetTemplateProvider`, splash screen, `NoopGenericResourceFetcher` for the fast `data:` font path — [lynx#9431](https://github.com/lynx-family/lynx/issues/9431)), and a predefined native bridge for `mithril-lynx/route`'s Android system Back support;
128
245
  - a project-local `android/` folder with editable `AndroidManifest.xml`, `strings.xml`, `styles.xml`, `config.xml`, `network_security_config.xml`, and `file_paths.xml`, plus `bun run android:prepare` to copy them into the Gradle host;
246
+ - opt-in Maven Central connectors for battery, camera, device, geolocation, network, vibration, maps, and SQLite, each registered only when selected with `--android-plugins`;
129
247
  - an opt-in release signing setup driven by a gitignored `keystore.properties`.
130
248
 
131
249
  And in the JS project, a `scripts/android.mjs` bridge plus npm scripts:
@@ -135,7 +253,7 @@ npm run android # build + sync + installDebug + launch, printing Tota
135
253
  npm run android:apk # build + sync + assembleDebug
136
254
  npm run android:release # build + sync + assembleRelease (signed if keystore.properties exists)
137
255
  npm run android:sync # build + sync bundle into app/src/main/assets/, no Gradle
138
- bun run android:prepare # manually sync android/*.xml -> sibling Android host
256
+ bun run android:prepare # manually sync android/*.xml and maps.json -> sibling Android host
139
257
  npm run android:keystore # generate release.keystore + keystore.properties (KEYSTORE_PASSWORD=...)
140
258
  ```
141
259
 
@@ -183,7 +301,25 @@ create-mithril-lynx/
183
301
 
184
302
  `src/index.js` copies, in order, `templates/_shared/common` → `templates/_shared/ts` → `templates/<template>/common` → `templates/<template>/ts` into the target directory — later copies overwrite same-named files from earlier ones, which is exactly how **Basic Activity**'s own routing-based `src/background.ts` replaces `_shared/ts`'s generic single-view one (Hello World and Blank don't ship their own, so they keep the shared file). It then renames `gitignore` to `.gitignore` (npm doesn't publish dotfiles reliably otherwise) and replaces `{{PROJECT_NAME}}`/`{{MITHRIL_LYNX_VERSION}}` placeholders in `package.json` and `README.md`.
185
303
 
186
- With `--android` it then copies `templates/android/host` into a sibling `<name>-android/` (renaming `package-path` to the real package directory and `App.kt` to the Application class) and substitutes `{{PACKAGE_NAME}}`, `{{APP_CLASS}}`, `{{APP_NAME}}`, `{{ANDROID_DIR}}` and `{{SDK_DIR}}` across the tree. It also seeds the generated project's `android/` folder with `AndroidManifest.xml`, `strings.xml`, `styles.xml`, `config.xml`, `network_security_config.xml`, and `file_paths.xml`. `bun run build` (and the Android build commands that invoke it) automatically synchronizes those local copies to the Gradle host first; use `bun run android:prepare` (or `npm run android:prepare`) when you only want that synchronization. Binary files (the Gradle wrapper jar, a `--with-font` `.ttf`) are never text-substituted. With `--with-font`, each font is copied into the JS project (`src/assets/fonts/`) *and* seeded into the Android host's `assets/fonts/`, wired up with a `FONTS` loop + `lynx.addFont()` in `src/background.ts` plus a generated `src/fonts.css` (`text { font-family }` for the first font, one class per font when there are several) imported from `src/style.css`. `scripts/android.mjs` keeps `assets/fonts/` in sync on every `npm run android` / `android:sync`.
304
+ Scaffolding handles the Android host and fonts as follows:
305
+
306
+ 1. **Android host (`--android`)**
307
+ - Copies `templates/android/host` into a sibling `<name>-android/` project.
308
+ - Renames `package-path` to the generated package directory and `App.kt` to the application class.
309
+ - Replaces `{{PACKAGE_NAME}}`, `{{APP_CLASS}}`, `{{APP_NAME}}`, `{{ANDROID_DIR}}` and `{{SDK_DIR}}` placeholders.
310
+ - Seeds the app's editable `android/` folder with `AndroidManifest.xml`, `strings.xml`, `styles.xml`, `config.xml`, `network_security_config.xml` and `file_paths.xml`.
311
+
312
+ 2. **Android preparation**
313
+ - `bun run build` and Android build commands run `android:prepare` before building, copying the editable config files into the Gradle host.
314
+ - To copy the files without building, run `bun run android:prepare` (or `npm run android:prepare`).
315
+
316
+ 3. **Fonts (`--with-font`)**
317
+ - Copies each `.ttf` into `src/assets/fonts/` and registers it from `src/background.ts` with a generated `FONTS` loop and `lynx.addFont()`.
318
+ - Generates `src/fonts.css`, imported by `src/style.css`; it sets the first font as the default `text` font and creates a class for each font variant.
319
+ - When an Android host exists, also copies fonts into its `assets/fonts/`. `scripts/android.mjs` syncs that folder during `npm run android` and `npm run android:sync`.
320
+
321
+ 4. **Binary files**
322
+ - The Gradle wrapper JAR and font `.ttf` files are copied as binary assets; placeholder substitution does not modify them.
187
323
 
188
324
  **Not carried over from v1 (yet)**: the JavaScript variant. The old tool generated either TypeScript or JavaScript for every template; this rewrite ships TypeScript only for now — doubling every template for a parallel JS copy wasn't part of this pass.
189
325
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-mithril-lynx",
3
- "version": "2.5.0",
3
+ "version": "2.5.2",
4
4
  "description": "Scaffolds a new mithril-lynx app (and, optionally, its Android host).",
5
5
  "license": "MIT",
6
6
  "type": "module",
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
- // The mithril-lynx version pinned in the generated package.json — tracks
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"]);
@@ -175,6 +272,495 @@ function splitList(value) {
175
272
  .filter(Boolean);
176
273
  }
177
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
+
178
764
  /** Renames `file` on any font past the first with the same basename, so
179
765
  * e.g. two different "Inter" downloads (different weights) don't collide
180
766
  * once copied into src/assets/fonts/. */
@@ -235,6 +821,7 @@ function parseAndroidArgs(args) {
235
821
  fontPath: readOption(args, "with-font"),
236
822
  fontFamily: readOption(args, "font-family"),
237
823
  findFontTerm: readOption(args, "find-font"),
824
+ plugins: readOption(args, "android-plugins"),
238
825
  };
239
826
  }
240
827
 
@@ -247,6 +834,7 @@ const OPTIONS_WITH_VALUE = new Set([
247
834
  "--with-font",
248
835
  "--font-family",
249
836
  "--find-font",
837
+ "--android-plugins",
250
838
  ]);
251
839
 
252
840
  function findPositional(args) {
@@ -389,6 +977,46 @@ async function findFontInteractively(term) {
389
977
  return results;
390
978
  }
391
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
+
392
1020
  // ---------------------------------------------------------------------------
393
1021
  // Android host scaffolding
394
1022
  // ---------------------------------------------------------------------------
@@ -432,10 +1060,30 @@ async function resolveAndroidOptions(android, { rawName, projectName, canPrompt
432
1060
  }
433
1061
  appName = appName ?? path.basename(rawName);
434
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
+
435
1077
  const fonts = await resolveFontsFromFlags(android, { baseDir: cwd, requireFonts: false });
436
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
+ }
437
1085
 
438
- return { androidId, appName, fonts };
1086
+ return { androidId, appName, fonts, plugins };
439
1087
  }
440
1088
 
441
1089
  /**
@@ -604,7 +1252,7 @@ async function runAddFont(args) {
604
1252
  }
605
1253
 
606
1254
  function scaffoldAndroid({ targetDir, rawName, options }) {
607
- const { androidId, appName, fonts } = options;
1255
+ const { androidId, appName, fonts, plugins } = options;
608
1256
 
609
1257
  const androidDirName = `${path.basename(rawName.replace(/^@[^/]+\//, ""))}-android`;
610
1258
  const androidDir = path.join(path.dirname(targetDir), androidDirName);
@@ -636,6 +1284,8 @@ function scaffoldAndroid({ targetDir, rawName, options }) {
636
1284
  // and templates/android/host/.../NoopGenericResourceFetcher.kt.
637
1285
  const sdkDir = findAndroidSdk();
638
1286
 
1287
+ const pluginHost = androidPluginHostConfiguration(plugins);
1288
+
639
1289
  // 3. Text substitutions across the whole Android host (skipping the
640
1290
  // gradle-wrapper.jar, the .ttf and any other binary).
641
1291
  replaceInTree(androidDir, [
@@ -643,6 +1293,7 @@ function scaffoldAndroid({ targetDir, rawName, options }) {
643
1293
  ["{{APP_CLASS}}", appClass],
644
1294
  ["{{APP_NAME}}", xmlEscape(appName)],
645
1295
  ["{{ANDROID_DIR}}", androidDirName],
1296
+ ["{{ANDROID_PLUGIN_DEPENDENCIES}}", pluginHost.gradleDependencies],
646
1297
  [
647
1298
  "{{SDK_DIR}}",
648
1299
  sdkDir != null
@@ -666,7 +1317,14 @@ function scaffoldAndroid({ targetDir, rawName, options }) {
666
1317
  // these exact files back into their Android resource locations.
667
1318
  seedAndroidDevelopmentFiles(targetDir, androidDir);
668
1319
 
669
- // 6. The scripts that join the two halves, inside the JS project.
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.
670
1328
  const scriptsDir = path.join(targetDir, "scripts");
671
1329
  fs.mkdirSync(scriptsDir, { recursive: true });
672
1330
  fs.copyFileSync(path.join(ANDROID_TEMPLATE_ROOT, "app-scripts", "android.mjs"), path.join(scriptsDir, "android.mjs"));
@@ -682,13 +1340,14 @@ function scaffoldAndroid({ targetDir, rawName, options }) {
682
1340
  ]);
683
1341
  replaceInFile(path.join(scriptsDir, "android-prepare.mjs"), [["{{ANDROID_REL_DIR}}", androidRelDir]]);
684
1342
 
685
- return { androidDir, androidDirName, androidRelDir, androidId, appName, appClass, fonts, sdkDir };
1343
+ return { androidDir, androidDirName, androidRelDir, androidId, appName, appClass, fonts, plugins: pluginHost.pluginIds, sdkDir };
686
1344
  }
687
1345
 
688
1346
  function patchJsProject({ targetDir, android }) {
689
1347
  // package.json: the Android scripts.
690
1348
  const pkgPath = path.join(targetDir, "package.json");
691
1349
  const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
1350
+ for (const name of connectorNpmPackages(android.plugins)) pkg.dependencies[name] = LYNX_ANDROID_JS_VERSION;
692
1351
  const buildCommand = pkg.scripts.build;
693
1352
  pkg.scripts = {
694
1353
  ...pkg.scripts,
@@ -745,6 +1404,9 @@ function patchJsProject({ targetDir, android }) {
745
1404
  `- Application class: \`${android.appClass}\` · Activity: \`MainActivity\``,
746
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.",
747
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.",
748
1410
  ...android.fonts.map(
749
1411
  (f) =>
750
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)).`,
@@ -755,6 +1417,79 @@ function patchJsProject({ targetDir, android }) {
755
1417
  }
756
1418
  }
757
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
+
758
1493
  // ---------------------------------------------------------------------------
759
1494
 
760
1495
  const USAGE = `
@@ -763,17 +1498,27 @@ create-mithril-lynx — scaffold a mithril-lynx app (and, optionally, its Androi
763
1498
  Usage:
764
1499
  npm create mithril-lynx@latest [name] [options]
765
1500
  npx create-mithril-lynx <name> --blank --android
1501
+ npx create-mithril-lynx <name> --blank --with-ui
766
1502
  npx create-mithril-lynx add-font --with-font ./Foo.ttf
767
1503
  npx create-mithril-lynx add-font --find-font Inter
1504
+ npx create-mithril-lynx add-android-plugin battery,geolocation
768
1505
 
769
1506
  Template (prompted for if omitted):
770
1507
  --hello-world | --blank | --basic-activity
771
1508
 
1509
+ UI components:
1510
+ --with-ui add mithril-lynx-ui and import its stylesheet;
1511
+ offered during interactive setup
1512
+
772
1513
  Android host:
773
- --android, --target android, target=android
1514
+ --android, --target android, --target=android, target=android
774
1515
  scaffold the sibling Gradle project <name>-android/
775
1516
  --android-id <id> applicationId / namespace (default com.example.<name>)
776
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.
777
1522
  --with-font <file.ttf> bundle the font into the JS project and register it
778
1523
  with lynx.addFont() (DEV: data: URI for Lynx Go;
779
1524
  PROD: asset:/// + AssetFontFaceLoader — see README).
@@ -796,9 +1541,14 @@ Post-init (run inside an existing app directory):
796
1541
  print the lines to paste by hand.
797
1542
  Same --with-font / --find-font / --font-family
798
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
799
1548
 
800
1549
  Other:
801
- --no-install don't install dependencies
1550
+ --no-install don't install dependencies during scaffold or
1551
+ post-init connector changes
802
1552
  -h, --help print this
803
1553
  `.trim();
804
1554
 
@@ -820,16 +1570,20 @@ async function main() {
820
1570
  await runAddFont(args.slice(1));
821
1571
  return;
822
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
+ }
823
1577
 
824
1578
  const positional = findPositional(args);
825
1579
  const templateFlag = TEMPLATE_VALUES.find((t) => args.includes(`--${t}`));
826
1580
  const noInstall = args.includes("--no-install");
1581
+ let withUi = args.includes("--with-ui");
827
1582
  const nonInteractive = positional != null && templateFlag != null;
828
1583
  const android = parseAndroidArgs(args);
829
1584
 
830
- // --with-font/--find-font/--font-family only make sense with an Android
831
- // host: imply it rather than ignoring them silently.
832
- 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) {
833
1587
  android.requested = true;
834
1588
  }
835
1589
 
@@ -867,6 +1621,14 @@ async function main() {
867
1621
  if (isCancel(template)) return bail();
868
1622
  }
869
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
+
870
1632
  let withAndroid = android.requested;
871
1633
  if (!withAndroid && !nonInteractive) {
872
1634
  withAndroid = await confirm({
@@ -907,6 +1669,8 @@ async function main() {
907
1669
  ]);
908
1670
  replaceInFile(path.join(targetDir, "README.md"), [["{{PROJECT_NAME}}", projectName]]);
909
1671
 
1672
+ if (withUi) patchUiProject({ targetDir, template });
1673
+
910
1674
  let androidResult = null;
911
1675
  if (withAndroid) {
912
1676
  androidResult = scaffoldAndroid({ targetDir, rawName, options: androidOptions });
@@ -9,7 +9,7 @@
9
9
  "preview": "rspeedy preview"
10
10
  },
11
11
  "dependencies": {
12
- "mithril-runtime": "^1.1.0",
12
+ "mithril-runtime": "latest",
13
13
  "mithril-lynx": "{{MITHRIL_LYNX_VERSION}}"
14
14
  },
15
15
  "devDependencies": {
@@ -8,5 +8,14 @@ declare module "@lynx-js/types" {
8
8
  MithrilLynxNavigationModule?: {
9
9
  setCanGoBack(canGoBack: boolean): void;
10
10
  };
11
+ LynxBatteryPlugin?: { getStatus(requestId: string): void };
12
+ LynxCameraPlugin?: { takePhoto(requestId: string): void };
13
+ LynxDevicePlugin?: { getInfo(requestId: string): void };
14
+ LynxGeolocationPlugin?: { getCurrentPosition(requestId: string, highAccuracy: boolean): void };
15
+ LynxNetworkPlugin?: { getInfo(requestId: string): void };
16
+ LynxVibrationPlugin?: {
17
+ vibrate(requestId: string, durationMs: number): void;
18
+ cancel(requestId: string): void;
19
+ };
11
20
  }
12
21
  }
@@ -42,4 +42,12 @@ for (const [name, destination] of files) {
42
42
  console.log(` → ${path.relative(projectRoot, target)}`);
43
43
  }
44
44
 
45
+ const mapsConfig = path.join(sourceDir, "maps.json");
46
+ if (fs.existsSync(mapsConfig)) {
47
+ const mapsTarget = path.join(androidDir, "app", "src", "main", "assets", "lynx_maps", "config.json");
48
+ fs.mkdirSync(path.dirname(mapsTarget), { recursive: true });
49
+ fs.copyFileSync(mapsConfig, mapsTarget);
50
+ console.log(` → ${path.relative(projectRoot, mapsTarget)}`);
51
+ }
52
+
45
53
  console.log("\n ✔ Android resources prepared.\n");
@@ -98,6 +98,10 @@ dependencies {
98
98
  // gesture then never works).
99
99
  implementation("androidx.viewpager2:viewpager2:1.1.0")
100
100
 
101
+ // <create-mithril-lynx:android-connectors>
102
+ {{ANDROID_PLUGIN_DEPENDENCIES}}
103
+ // </create-mithril-lynx:android-connectors>
104
+
101
105
  implementation("androidx.appcompat:appcompat:1.7.0")
102
106
  implementation("androidx.core:core-splashscreen:1.0.1")
103
107
  }
@@ -1,6 +1,8 @@
1
1
  package {{PACKAGE_NAME}}
2
2
 
3
3
  import android.os.Bundle
4
+ // <create-mithril-lynx:connector-imports>
5
+ // </create-mithril-lynx:connector-imports>
4
6
  import androidx.activity.OnBackPressedCallback
5
7
  import androidx.appcompat.app.AppCompatActivity
6
8
  import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
@@ -27,6 +29,8 @@ class MainActivity : AppCompatActivity() {
27
29
  // Lets mithril-lynx/route tell Android when its in-memory history can
28
30
  // handle Back. See MithrilLynxNavigationModule and the callback below.
29
31
  builder.registerModule("MithrilLynxNavigationModule", MithrilLynxNavigationModule::class.java)
32
+ // <create-mithril-lynx:connector-registration>
33
+ // </create-mithril-lynx:connector-registration>
30
34
  // lynx-family/lynx's own explorer/android registers a
31
35
  // GenericResourceFetcher unconditionally (LynxViewShellActivity —
32
36
  // "used inside LynxEngine for resource loading capabilities of
@@ -65,6 +69,9 @@ class MainActivity : AppCompatActivity() {
65
69
  backCallback?.isEnabled = canGoBack
66
70
  }
67
71
 
72
+ // <create-mithril-lynx:connector-callbacks>
73
+ // </create-mithril-lynx:connector-callbacks>
74
+
68
75
  override fun onDestroy() {
69
76
  backCallback = null
70
77
  super.onDestroy()
@@ -34,7 +34,11 @@ declare const module: {
34
34
  accept(path: string, callback: () => void): void;
35
35
  };
36
36
  };
37
- declare const require: (id: string) => unknown;
37
+ // Overloads: HMR reloads the two screen modules; DEV font registration
38
+ // prepended by --with-font/--find-font requires .ttf paths as string URLs.
39
+ declare function require(id: "./screens/home.js"): typeof homeModule;
40
+ declare function require(id: "./screens/detail.js"): typeof detailModule;
41
+ declare function require(id: string): string;
38
42
 
39
43
  if (module.hot) {
40
44
  module.hot.accept("./screens/home.js", () => {