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 +142 -6
- package/package.json +1 -1
- package/src/index.js +776 -12
- package/templates/_shared/ts/package.json +1 -1
- package/templates/_shared/ts/src/rspeedy-env.d.ts +9 -0
- package/templates/android/app-scripts/android-prepare.mjs +8 -0
- package/templates/android/host/app/build.gradle.kts +4 -0
- package/templates/android/host/app/src/main/java/package-path/MainActivity.kt +7 -0
- package/templates/basic-activity/ts/src/background.ts +5 -1
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
|
-
|
|
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` |
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
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"]);
|
|
@@ -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
|
|
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
|
-
//
|
|
831
|
-
|
|
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 });
|
|
@@ -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
|
-
|
|
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", () => {
|