@geastack/cli 0.1.52 → 0.1.54
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 +14 -1
- package/docs/ESP32-WAVESHARE-AMOLED-QUICKSTART.md +2 -2
- package/docs/NPX-COMMANDS.md +3 -1
- package/docs/SETUP.md +81 -15
- package/docs/SPEC.md +8 -5
- package/package.json +2 -3
- package/src/apps/app-index-writer.mjs +15 -0
- package/src/apps/apple-icons.mjs +147 -0
- package/src/apps/bundle-writer.mjs +156 -0
- package/src/apps/launcher-catalog.mjs +114 -0
- package/src/apps/openai-icons.mjs +356 -0
- package/src/apps/zip-writer.mjs +104 -0
- package/src/ble/ble-ota.swift +291 -0
- package/src/boards/config.mjs +114 -0
- package/src/boards/custom-target.mjs +309 -0
- package/src/boards/resolve.mjs +145 -0
- package/src/boards/targets.mjs +45 -0
- package/src/boards/usb.mjs +252 -0
- package/src/chips.mjs +1 -1
- package/src/commands/apps.mjs +204 -0
- package/src/commands/board.mjs +402 -0
- package/src/commands/boards.mjs +334 -0
- package/src/commands/doctor.mjs +91 -0
- package/src/context.mjs +50 -54
- package/src/device/device.mjs +96 -0
- package/src/device/image.mjs +115 -0
- package/src/device/serial.mjs +430 -0
- package/src/device/wifi.mjs +190 -0
- package/src/esp32/build.mjs +321 -0
- package/src/esp32/capabilities.mjs +54 -0
- package/src/esp32/flash.mjs +181 -0
- package/src/esp32/idf-env.mjs +171 -0
- package/src/esp32/ota.mjs +80 -0
- package/src/esp32/partitions.mjs +59 -0
- package/src/esp32/sdkconfig.mjs +103 -0
- package/src/esp32/wifi-config.mjs +72 -0
- package/src/gea.mjs +89 -427
- package/src/geaos/adapter.mjs +119 -0
- package/src/heap-report.mjs +1276 -0
- package/src/manifest.mjs +135 -36
- package/src/rp2350/adapter.mjs +155 -0
- package/src/serial-devices.mjs +32 -20
- package/src/setup-wizard.mjs +11 -12
- package/src/taurus/adapter.mjs +52 -0
package/README.md
CHANGED
|
@@ -100,6 +100,18 @@ host bindings, and the other native packages from npm. A project can use a
|
|
|
100
100
|
local CLI with `npx gea` or a global installation with `gea`; neither command
|
|
101
101
|
depends on a GeaStack source checkout.
|
|
102
102
|
|
|
103
|
+
Boards are managed without editing JSON by hand; aliases live in
|
|
104
|
+
`~/.geastack/boards.json` (this machine) and the project's `.gea/boards.json`
|
|
105
|
+
(overrides):
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
gea boards discover # which registered board is on which USB port, its app and IP
|
|
109
|
+
gea boards list
|
|
110
|
+
gea boards set amoled host 192.168.1.100
|
|
111
|
+
gea boards rename amoled desk-amoled
|
|
112
|
+
gea boards remove desk-amoled
|
|
113
|
+
```
|
|
114
|
+
|
|
103
115
|
Custom boards remain editable after setup:
|
|
104
116
|
|
|
105
117
|
```sh
|
|
@@ -169,4 +181,5 @@ First implementation is in place:
|
|
|
169
181
|
- `list` and `inspect` helpers;
|
|
170
182
|
- `create-geastack` with a bundled counter starter, a blank application, and a
|
|
171
183
|
GitHub-backed rich example flow for web, embedded, GeaOS, iOS, macOS, and Android apps,
|
|
172
|
-
all with `.gea/boards.json
|
|
184
|
+
all with `.gea/boards.json`, plus `gea boards` for machine-wide aliases in
|
|
185
|
+
`~/.geastack/boards.json`.
|
|
@@ -90,7 +90,7 @@ You do not need to run that before every command unless `gea doctor` says ESP-ID
|
|
|
90
90
|
|
|
91
91
|
If the wizard finds a connected serial device, pick it. If no board is plugged in yet, that is fine; you can provide the port later.
|
|
92
92
|
|
|
93
|
-
The wizard writes the board
|
|
93
|
+
The wizard writes the board alias into your app's `.gea/boards.json` (or `~/.geastack/boards.json` with `--global`, for every project on this machine) and initializes the selected board target.
|
|
94
94
|
|
|
95
95
|
## 4. Flash
|
|
96
96
|
|
|
@@ -134,4 +134,4 @@ Most first-run issues are one of:
|
|
|
134
134
|
- ESP-IDF is not activated in the current shell.
|
|
135
135
|
- The USB cable is power-only.
|
|
136
136
|
- The serial port needs to be passed with `--port`.
|
|
137
|
-
- The board alias does not match
|
|
137
|
+
- The board alias does not match a name in `npx gea boards list` (`~/.geastack/boards.json` plus the app's `.gea/boards.json`). `npx gea boards discover` shows which registered board each USB port is.
|
package/docs/NPX-COMMANDS.md
CHANGED
|
@@ -94,7 +94,9 @@ packages are scoped and configured for restricted npmjs publication.
|
|
|
94
94
|
- ESP-IDF toolchain check/install only.
|
|
95
95
|
|
|
96
96
|
Known-board setup detects attached serial devices, asks for a stable USB serial,
|
|
97
|
-
shows a review screen, writes a board alias into the active boards config
|
|
97
|
+
shows a review screen, writes a board alias into the active boards config
|
|
98
|
+
(`--global` for `~/.geastack/boards.json`, `--local` for the project's
|
|
99
|
+
`.gea/boards.json`; by default the project config when it exists), then
|
|
98
100
|
initializes the selected board target so the next command can be
|
|
99
101
|
`npx gea flash --board <alias> --monitor`. In generated apps, that config is:
|
|
100
102
|
|
package/docs/SETUP.md
CHANGED
|
@@ -35,7 +35,7 @@ For ESP32 hardware:
|
|
|
35
35
|
- npm.
|
|
36
36
|
- Python 3.
|
|
37
37
|
- ESP-IDF v6.0.1.
|
|
38
|
-
-
|
|
38
|
+
- a board alias for your board (`~/.geastack/boards.json` or the project's `.gea/boards.json`, see Board Configuration).
|
|
39
39
|
|
|
40
40
|
For the Waveshare ESP32-S3 AMOLED board, use
|
|
41
41
|
[ESP32-WAVESHARE-AMOLED-QUICKSTART.md](ESP32-WAVESHARE-AMOLED-QUICKSTART.md).
|
|
@@ -224,9 +224,44 @@ manager or from python.org.
|
|
|
224
224
|
|
|
225
225
|
## Board Configuration
|
|
226
226
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
227
|
+
A board alias names one physical unit: `gea flash --board amoled` resolves
|
|
228
|
+
`amoled` to a target, a USB serial and, optionally, an IP. Aliases are
|
|
229
|
+
machine-local configuration and never come from a package. The CLI merges two
|
|
230
|
+
files, project over home:
|
|
231
|
+
|
|
232
|
+
| file | holds | written by |
|
|
233
|
+
| --- | --- | --- |
|
|
234
|
+
| `~/.geastack/boards.json` | every board on this machine (`GEA_HOME` relocates the directory) | `gea boards add --global`, `gea boards set` |
|
|
235
|
+
| `<project>/.gea/boards.json` | aliases specific to one application; overrides a home alias of the same name | `create-geastack` (empty), `gea boards add` |
|
|
236
|
+
|
|
237
|
+
`--boards-config <file>` (or `GEA_BOARDS_CONFIG`) replaces both with exactly
|
|
238
|
+
that file. By default `gea boards add` writes to the project config when the
|
|
239
|
+
project has one and to the home config otherwise; an existing alias is always
|
|
240
|
+
edited where it lives.
|
|
241
|
+
|
|
242
|
+
```sh
|
|
243
|
+
npx gea boards discover # what is plugged in: alias, app, IP
|
|
244
|
+
npx gea boards add # register a board (guided)
|
|
245
|
+
npx gea boards list # every alias and which file it lives in
|
|
246
|
+
npx gea boards set amoled host 192.168.1.100
|
|
247
|
+
npx gea boards rename amoled desk-amoled
|
|
248
|
+
npx gea boards remove desk-amoled
|
|
249
|
+
npx gea doctor
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
`gea boards discover` sends one `GEADEV PING` to each serial device without
|
|
253
|
+
resetting it; a board running gea firmware answers with its app id, its IP
|
|
254
|
+
(when it has joined WiFi) and its MAC, and the CLI pairs the reply with an
|
|
255
|
+
alias by USB serial. `--save` writes a reported IP into
|
|
256
|
+
`transports.ota.host`, which is what `gea ota`, `gea logs` and
|
|
257
|
+
`gea screenshot` use over WiFi.
|
|
258
|
+
|
|
259
|
+
### Entry shapes
|
|
260
|
+
|
|
261
|
+
Every entry names a `target` (`gea targets list`) and its `adapter`, then the
|
|
262
|
+
transports the board offers. The USB serial is the stable identity: on ESP32
|
|
263
|
+
boards it is the station MAC, and the CLI resolves it to today's `/dev` port
|
|
264
|
+
at call time, so never record a port path.
|
|
230
265
|
|
|
231
266
|
```json
|
|
232
267
|
{
|
|
@@ -234,22 +269,53 @@ Example:
|
|
|
234
269
|
"target": "esp32-s3-touch-amoled-2.06",
|
|
235
270
|
"adapter": "esp32-idf",
|
|
236
271
|
"transports": {
|
|
237
|
-
"usbSerial": {
|
|
238
|
-
|
|
239
|
-
|
|
272
|
+
"usbSerial": { "serial": "80:B5:4E:DA:73:88" },
|
|
273
|
+
"ota": { "host": "192.168.1.100" }
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
"rotary": {
|
|
277
|
+
"target": "esp32-s3-elecrow-rotary-2.1",
|
|
278
|
+
"adapter": "esp32-idf",
|
|
279
|
+
"transports": {
|
|
280
|
+
"usbSerial": { "serial": "14:C1:9F:26:65:08", "restartAfterFlash": "manual" }
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
"tufty": {
|
|
284
|
+
"target": "rp2350-tufty-2350",
|
|
285
|
+
"adapter": "rp2350-pico",
|
|
286
|
+
"transports": { "usbSerial": { "serial": "fa59949adbb4802f" } }
|
|
287
|
+
},
|
|
288
|
+
"linux": {
|
|
289
|
+
"target": "geaos",
|
|
290
|
+
"adapter": "geaos-linux",
|
|
291
|
+
"transports": {
|
|
292
|
+
"telnet": { "host": "192.168.7.2", "port": 2323 },
|
|
293
|
+
"fastboot": { "serial": "geaos001" }
|
|
240
294
|
}
|
|
295
|
+
},
|
|
296
|
+
"lokmat": {
|
|
297
|
+
"target": "lokmat-applp2max",
|
|
298
|
+
"adapter": "geaos-arm64",
|
|
299
|
+
"transports": {
|
|
300
|
+
"usbSerial": { "serial": "geaos01" },
|
|
301
|
+
"fastboot": { "serial": "0123456789ABCDEF" },
|
|
302
|
+
"mtk": { "workdir": "~/lokmat-root" }
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
"my-board": {
|
|
306
|
+
"target": "my-board",
|
|
307
|
+
"targetDefinition": "targets/my-board.json",
|
|
308
|
+
"adapter": "esp32-idf",
|
|
309
|
+
"transports": { "usbSerial": { "serial": "YOUR_BOARD_USB_SERIAL" } }
|
|
241
310
|
}
|
|
242
311
|
}
|
|
243
312
|
```
|
|
244
313
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
npx gea list targets
|
|
251
|
-
npx gea doctor
|
|
252
|
-
```
|
|
314
|
+
- `restartAfterFlash: "manual"` marks a board whose USB-Serial-JTAG port
|
|
315
|
+
re-enters ROM download mode after a flash; the CLI stops and asks for a
|
|
316
|
+
power cycle instead of pulsing the reset lines.
|
|
317
|
+
- `targetDefinition` points at a custom target composed by `gea setup` /
|
|
318
|
+
`gea chips`, relative to the file the alias lives in.
|
|
253
319
|
|
|
254
320
|
## Common Verification Flow
|
|
255
321
|
|
package/docs/SPEC.md
CHANGED
|
@@ -83,9 +83,12 @@ gea flash --app bouncing-balls-jsx --board amoled --monitor
|
|
|
83
83
|
gea flash --app css-3d-cube --target android
|
|
84
84
|
```
|
|
85
85
|
|
|
86
|
-
The CLI
|
|
87
|
-
|
|
88
|
-
|
|
86
|
+
The CLI resolves board aliases from two machine-local files merged project over
|
|
87
|
+
home: `~/.geastack/boards.json` (every board on the machine, `GEA_HOME`
|
|
88
|
+
relocates it) and the project's `.gea/boards.json`. `--boards-config` replaces
|
|
89
|
+
both. No package ships aliases. `gea boards` manages them: `list`, `show`,
|
|
90
|
+
`add`, `set <alias> <key> <value>`, `remove`, `rename`, and `discover`, which
|
|
91
|
+
identifies connected boards over USB (`GEADEV PING` reports app, IP and MAC).
|
|
89
92
|
|
|
90
93
|
### `gea chips`
|
|
91
94
|
|
|
@@ -228,8 +231,8 @@ Use predictable exit codes:
|
|
|
228
231
|
1. Implement `gea doctor` for toolchain discovery and app manifest validation.
|
|
229
232
|
2. Implement `gea dev --target web` by delegating to the simulator repo.
|
|
230
233
|
3. Implement `gea build --target web`.
|
|
231
|
-
4. Implement `gea flash --board <alias>`
|
|
232
|
-
`
|
|
234
|
+
4. Implement `gea flash --board <alias>` natively (ESP-IDF, esptool, OTA and
|
|
235
|
+
device transports live in `src/esp32` and `src/device`).
|
|
233
236
|
5. Add `create-geastack` with one JSX app template.
|
|
234
237
|
|
|
235
238
|
Status: implemented.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@geastack/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.54",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Command-line front door for GeaStack apps, targets, and local toolchains.",
|
|
6
6
|
"publishConfig": {
|
|
@@ -27,8 +27,7 @@
|
|
|
27
27
|
"create-geastack": "bin/create-geastack.mjs"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"
|
|
31
|
-
"@geastack/targets": "0.1.47"
|
|
30
|
+
"serialport": "^13.0.0"
|
|
32
31
|
},
|
|
33
32
|
"scripts": {
|
|
34
33
|
"check": "node --check bin/gea.mjs && node --check bin/create-geastack.mjs && find src test packages -name '*.mjs' -print0 | xargs -0 -n1 node --check",
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
// apps are discovered by the caller (cli manifest)
|
|
5
|
+
|
|
6
|
+
export function generateAppIndex({ apps, outputFile }) {
|
|
7
|
+
if (!Array.isArray(apps)) throw new Error('generateAppIndex requires apps')
|
|
8
|
+
if (!outputFile) throw new Error('generateAppIndex requires outputFile')
|
|
9
|
+
mkdirSync(path.dirname(outputFile), { recursive: true })
|
|
10
|
+
writeFileSync(
|
|
11
|
+
outputFile,
|
|
12
|
+
`// Auto-generated by gea apps index. Do not edit.\nexport const GEA_APPS = ${JSON.stringify(apps, null, 2)} as const\n`
|
|
13
|
+
)
|
|
14
|
+
return apps
|
|
15
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { spawnSync } from 'node:child_process'
|
|
4
|
+
|
|
5
|
+
import { ICON_SIZES } from './openai-icons.mjs'
|
|
6
|
+
|
|
7
|
+
export const IOS_APP_ICON_IMAGES = [
|
|
8
|
+
{ idiom: 'iphone', size: '20x20', scale: '2x', pixels: 40, filename: 'AppIcon-20@2x.png' },
|
|
9
|
+
{ idiom: 'iphone', size: '20x20', scale: '3x', pixels: 60, filename: 'AppIcon-20@3x.png' },
|
|
10
|
+
{ idiom: 'iphone', size: '29x29', scale: '2x', pixels: 58, filename: 'AppIcon-29@2x.png' },
|
|
11
|
+
{ idiom: 'iphone', size: '29x29', scale: '3x', pixels: 87, filename: 'AppIcon-29@3x.png' },
|
|
12
|
+
{ idiom: 'iphone', size: '40x40', scale: '2x', pixels: 80, filename: 'AppIcon-40@2x.png' },
|
|
13
|
+
{ idiom: 'iphone', size: '40x40', scale: '3x', pixels: 120, filename: 'AppIcon-40@3x.png' },
|
|
14
|
+
{ idiom: 'iphone', size: '60x60', scale: '2x', pixels: 120, filename: 'AppIcon-60@2x.png' },
|
|
15
|
+
{ idiom: 'iphone', size: '60x60', scale: '3x', pixels: 180, filename: 'AppIcon-60@3x.png' },
|
|
16
|
+
{ idiom: 'ipad', size: '20x20', scale: '1x', pixels: 20, filename: 'AppIcon-iPad-20.png' },
|
|
17
|
+
{ idiom: 'ipad', size: '20x20', scale: '2x', pixels: 40, filename: 'AppIcon-iPad-20@2x.png' },
|
|
18
|
+
{ idiom: 'ipad', size: '29x29', scale: '1x', pixels: 29, filename: 'AppIcon-iPad-29.png' },
|
|
19
|
+
{ idiom: 'ipad', size: '29x29', scale: '2x', pixels: 58, filename: 'AppIcon-iPad-29@2x.png' },
|
|
20
|
+
{ idiom: 'ipad', size: '40x40', scale: '1x', pixels: 40, filename: 'AppIcon-iPad-40.png' },
|
|
21
|
+
{ idiom: 'ipad', size: '40x40', scale: '2x', pixels: 80, filename: 'AppIcon-iPad-40@2x.png' },
|
|
22
|
+
{ idiom: 'ipad', size: '76x76', scale: '1x', pixels: 76, filename: 'AppIcon-iPad-76.png' },
|
|
23
|
+
{ idiom: 'ipad', size: '76x76', scale: '2x', pixels: 152, filename: 'AppIcon-iPad-76@2x.png' },
|
|
24
|
+
{ idiom: 'ipad', size: '83.5x83.5', scale: '2x', pixels: 167, filename: 'AppIcon-iPad-83.5@2x.png' },
|
|
25
|
+
{ idiom: 'ios-marketing', size: '1024x1024', scale: '1x', pixels: 1024, filename: 'AppIcon-1024.png' },
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
export const MACOS_ICONSET_IMAGES = [
|
|
29
|
+
{ filename: 'icon_16x16.png', pixels: 16 },
|
|
30
|
+
{ filename: 'icon_16x16@2x.png', pixels: 32 },
|
|
31
|
+
{ filename: 'icon_32x32.png', pixels: 32 },
|
|
32
|
+
{ filename: 'icon_32x32@2x.png', pixels: 64 },
|
|
33
|
+
{ filename: 'icon_128x128.png', pixels: 128 },
|
|
34
|
+
{ filename: 'icon_128x128@2x.png', pixels: 256 },
|
|
35
|
+
{ filename: 'icon_256x256.png', pixels: 256 },
|
|
36
|
+
{ filename: 'icon_256x256@2x.png', pixels: 512 },
|
|
37
|
+
{ filename: 'icon_512x512.png', pixels: 512 },
|
|
38
|
+
{ filename: 'icon_512x512@2x.png', pixels: 1024 },
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
export const MACOS_ICNS_CHUNKS = [
|
|
42
|
+
{ type: 'icp4', filename: 'icon_16x16.png' },
|
|
43
|
+
{ type: 'icp5', filename: 'icon_32x32.png' },
|
|
44
|
+
{ type: 'icp6', filename: 'icon_32x32@2x.png' },
|
|
45
|
+
{ type: 'ic07', filename: 'icon_128x128.png' },
|
|
46
|
+
{ type: 'ic08', filename: 'icon_256x256.png' },
|
|
47
|
+
{ type: 'ic09', filename: 'icon_512x512.png' },
|
|
48
|
+
{ type: 'ic10', filename: 'icon_512x512@2x.png' },
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
export function appleIconConfig() {
|
|
52
|
+
return Object.fromEntries(ICON_SIZES.map((size) => [String(size), `icons/icon-${size}.png`]))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function resolveAppleIconSourcePath(repoRoot, app) {
|
|
56
|
+
const sourcePath = path.resolve(repoRoot, app.root, 'icons', 'icon-source.png')
|
|
57
|
+
if (existsSync(sourcePath)) return sourcePath
|
|
58
|
+
|
|
59
|
+
for (const size of ['1024', '512', '256', '128', '64', '32']) {
|
|
60
|
+
const iconPath = app.icons?.[size]
|
|
61
|
+
if (!iconPath) continue
|
|
62
|
+
const resolved = path.resolve(repoRoot, app.root, iconPath)
|
|
63
|
+
if (existsSync(resolved)) return resolved
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
throw new Error(`Missing Apple icon source for ${app.id}: expected ${sourcePath} or a declared package icon`)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function resizePng(sourcePath, outputPath, pixels) {
|
|
70
|
+
mkdirSync(path.dirname(outputPath), { recursive: true })
|
|
71
|
+
const result = spawnSync('sips', ['-s', 'format', 'png', '-z', String(pixels), String(pixels), sourcePath, '--out', outputPath], {
|
|
72
|
+
encoding: 'utf8',
|
|
73
|
+
})
|
|
74
|
+
if (result.status !== 0) {
|
|
75
|
+
const detail = result.stderr || result.stdout || `exit ${result.status}`
|
|
76
|
+
throw new Error(`Failed to resize icon ${sourcePath} to ${pixels}x${pixels}: ${detail.trim()}`)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function writeIosAppIconContents(appIconSetDir) {
|
|
81
|
+
const contents = {
|
|
82
|
+
images: IOS_APP_ICON_IMAGES.map(({ idiom, size, scale, filename }) => ({
|
|
83
|
+
idiom,
|
|
84
|
+
size,
|
|
85
|
+
scale,
|
|
86
|
+
filename,
|
|
87
|
+
})),
|
|
88
|
+
info: {
|
|
89
|
+
author: 'xcode',
|
|
90
|
+
version: 1,
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
writeFileSync(path.join(appIconSetDir, 'Contents.json'), `${JSON.stringify(contents, null, 2)}\n`)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function prepareIosAppIconAssets({ repoRoot, app, assetsDir }) {
|
|
97
|
+
if (!repoRoot) throw new Error('prepareIosAppIconAssets requires repoRoot')
|
|
98
|
+
if (!app) throw new Error('prepareIosAppIconAssets requires app')
|
|
99
|
+
if (!assetsDir) throw new Error('prepareIosAppIconAssets requires assetsDir')
|
|
100
|
+
|
|
101
|
+
const sourcePath = resolveAppleIconSourcePath(repoRoot, app)
|
|
102
|
+
const appIconSetDir = path.join(assetsDir, 'AppIcon.appiconset')
|
|
103
|
+
rmSync(appIconSetDir, { recursive: true, force: true })
|
|
104
|
+
mkdirSync(appIconSetDir, { recursive: true })
|
|
105
|
+
|
|
106
|
+
for (const image of IOS_APP_ICON_IMAGES) {
|
|
107
|
+
resizePng(sourcePath, path.join(appIconSetDir, image.filename), image.pixels)
|
|
108
|
+
}
|
|
109
|
+
writeIosAppIconContents(appIconSetDir)
|
|
110
|
+
return appIconSetDir
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function writeIcnsFromIconset({ iconsetDir, icnsPath }) {
|
|
114
|
+
const chunks = MACOS_ICNS_CHUNKS.map(({ type, filename }) => {
|
|
115
|
+
const data = readFileSync(path.join(iconsetDir, filename))
|
|
116
|
+
const header = Buffer.alloc(8)
|
|
117
|
+
header.write(type, 0, 'ascii')
|
|
118
|
+
header.writeUInt32BE(data.length + 8, 4)
|
|
119
|
+
return Buffer.concat([header, data])
|
|
120
|
+
})
|
|
121
|
+
const length = 8 + chunks.reduce((sum, chunk) => sum + chunk.length, 0)
|
|
122
|
+
const header = Buffer.alloc(8)
|
|
123
|
+
header.write('icns', 0, 'ascii')
|
|
124
|
+
header.writeUInt32BE(length, 4)
|
|
125
|
+
writeFileSync(icnsPath, Buffer.concat([header, ...chunks], length))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function prepareMacosAppIcon({ repoRoot, app, buildDir, resourcesDir }) {
|
|
129
|
+
if (!repoRoot) throw new Error('prepareMacosAppIcon requires repoRoot')
|
|
130
|
+
if (!app) throw new Error('prepareMacosAppIcon requires app')
|
|
131
|
+
if (!buildDir) throw new Error('prepareMacosAppIcon requires buildDir')
|
|
132
|
+
if (!resourcesDir) throw new Error('prepareMacosAppIcon requires resourcesDir')
|
|
133
|
+
|
|
134
|
+
const sourcePath = resolveAppleIconSourcePath(repoRoot, app)
|
|
135
|
+
const iconsetDir = path.join(buildDir, 'AppIcon.iconset')
|
|
136
|
+
const icnsPath = path.join(resourcesDir, 'AppIcon.icns')
|
|
137
|
+
rmSync(iconsetDir, { recursive: true, force: true })
|
|
138
|
+
mkdirSync(iconsetDir, { recursive: true })
|
|
139
|
+
mkdirSync(resourcesDir, { recursive: true })
|
|
140
|
+
|
|
141
|
+
for (const image of MACOS_ICONSET_IMAGES) {
|
|
142
|
+
resizePng(sourcePath, path.join(iconsetDir, image.filename), image.pixels)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
writeIcnsFromIconset({ iconsetDir, icnsPath })
|
|
146
|
+
return icnsPath
|
|
147
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
import { writeZip } from './zip-writer.mjs'
|
|
7
|
+
|
|
8
|
+
const FORMAT = 'dev.gea.geaos.app.bundle'
|
|
9
|
+
const FORMAT_VERSION = 1
|
|
10
|
+
const ICON_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg'])
|
|
11
|
+
|
|
12
|
+
function sha256(buffer) {
|
|
13
|
+
return `sha256-${createHash('sha256').update(buffer).digest('hex')}`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function targetArtifact(repoRoot, app, target) {
|
|
17
|
+
if (target === 'geaos') {
|
|
18
|
+
return {
|
|
19
|
+
target: 'geaos-armv7',
|
|
20
|
+
sourcePath: path.join(repoRoot, 'targets', 'geaos', 'dist', app.id, app.id),
|
|
21
|
+
bundlePath: `bin/geaos-armv7/${app.id}`,
|
|
22
|
+
executable: true
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
throw new Error(`Unsupported bundle target '${target}'`)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readBundleFile(sourcePath, bundlePath, executable = false) {
|
|
29
|
+
if (!existsSync(sourcePath)) throw new Error(`Missing bundle input: ${sourcePath}`)
|
|
30
|
+
return {
|
|
31
|
+
name: bundlePath,
|
|
32
|
+
data: readFileSync(sourcePath),
|
|
33
|
+
executable
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function pngDimensions(buffer) {
|
|
38
|
+
const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
|
|
39
|
+
if (buffer.length < 24) return null
|
|
40
|
+
if (!signature.every((byte, index) => buffer[index] === byte)) return null
|
|
41
|
+
if (buffer.subarray(12, 16).toString('ascii') !== 'IHDR') return null
|
|
42
|
+
return {
|
|
43
|
+
width: buffer.readUInt32BE(16),
|
|
44
|
+
height: buffer.readUInt32BE(20),
|
|
45
|
+
format: 'png'
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function jpegDimensions(buffer) {
|
|
50
|
+
if (buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) return null
|
|
51
|
+
let offset = 2
|
|
52
|
+
while (offset + 9 < buffer.length) {
|
|
53
|
+
if (buffer[offset] !== 0xff) {
|
|
54
|
+
offset += 1
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
const marker = buffer[offset + 1]
|
|
58
|
+
offset += 2
|
|
59
|
+
if (marker === 0xd9 || marker === 0xda) break
|
|
60
|
+
if (offset + 2 > buffer.length) break
|
|
61
|
+
const length = buffer.readUInt16BE(offset)
|
|
62
|
+
if (length < 2 || offset + length > buffer.length) break
|
|
63
|
+
const isSof = (marker >= 0xc0 && marker <= 0xc3) || (marker >= 0xc5 && marker <= 0xc7) ||
|
|
64
|
+
(marker >= 0xc9 && marker <= 0xcb) || (marker >= 0xcd && marker <= 0xcf)
|
|
65
|
+
if (isSof && length >= 7) {
|
|
66
|
+
return {
|
|
67
|
+
height: buffer.readUInt16BE(offset + 3),
|
|
68
|
+
width: buffer.readUInt16BE(offset + 5),
|
|
69
|
+
format: 'jpeg'
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
offset += length
|
|
73
|
+
}
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function validateIconFile(sourcePath, expectedSize) {
|
|
78
|
+
const ext = path.extname(sourcePath).toLowerCase()
|
|
79
|
+
if (!ICON_EXTENSIONS.has(ext)) {
|
|
80
|
+
throw new Error(`Icon must be a PNG or JPEG: ${sourcePath}`)
|
|
81
|
+
}
|
|
82
|
+
const buffer = readFileSync(sourcePath)
|
|
83
|
+
const dimensions = ext === '.png' ? pngDimensions(buffer) : jpegDimensions(buffer)
|
|
84
|
+
if (!dimensions) throw new Error(`Icon has unsupported or invalid image data: ${sourcePath}`)
|
|
85
|
+
if (dimensions.width !== expectedSize || dimensions.height !== expectedSize) {
|
|
86
|
+
throw new Error(`Icon ${sourcePath} must be ${expectedSize}x${expectedSize}; got ${dimensions.width}x${dimensions.height}`)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function iconEntries(repoRoot, app) {
|
|
91
|
+
const entries = []
|
|
92
|
+
for (const [size, iconPath] of Object.entries(app.icons)) {
|
|
93
|
+
const sourcePath = path.resolve(repoRoot, app.root, iconPath)
|
|
94
|
+
validateIconFile(sourcePath, Number(size))
|
|
95
|
+
entries.push(readBundleFile(sourcePath, `icons/icon-${size}${path.extname(iconPath).toLowerCase()}`))
|
|
96
|
+
}
|
|
97
|
+
return entries
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function packageEntry(repoRoot, app) {
|
|
101
|
+
return readBundleFile(path.resolve(repoRoot, app.root, 'package.json'), 'package.json')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function createGeaBundle({ repoRoot, app, target = 'geaos', outputPath }) {
|
|
105
|
+
if (!repoRoot) throw new Error('createGeaBundle requires repoRoot')
|
|
106
|
+
if (!app) throw new Error('createGeaBundle requires app')
|
|
107
|
+
if (!app.targets?.[target] === true) throw new Error(`App '${app.id}' does not enable target '${target}'`)
|
|
108
|
+
if (!outputPath) throw new Error('createGeaBundle requires outputPath')
|
|
109
|
+
|
|
110
|
+
const artifact = targetArtifact(repoRoot, app, target)
|
|
111
|
+
const payloadEntries = [
|
|
112
|
+
packageEntry(repoRoot, app),
|
|
113
|
+
...iconEntries(repoRoot, app),
|
|
114
|
+
readBundleFile(artifact.sourcePath, artifact.bundlePath, artifact.executable)
|
|
115
|
+
]
|
|
116
|
+
const checksums = Object.fromEntries(payloadEntries.map((entry) => [entry.name, sha256(entry.data)]))
|
|
117
|
+
const icons = Object.fromEntries(
|
|
118
|
+
Object.entries(app.icons).map(([size, iconPath]) => [
|
|
119
|
+
size,
|
|
120
|
+
`icons/icon-${size}${path.extname(iconPath).toLowerCase()}`
|
|
121
|
+
])
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
const manifest = {
|
|
125
|
+
format: FORMAT,
|
|
126
|
+
formatVersion: FORMAT_VERSION,
|
|
127
|
+
createdAt: new Date().toISOString(),
|
|
128
|
+
app: {
|
|
129
|
+
id: app.id,
|
|
130
|
+
name: app.name,
|
|
131
|
+
packageName: app.packageName,
|
|
132
|
+
version: app.version,
|
|
133
|
+
runtime: app.runtime,
|
|
134
|
+
entry: app.entry,
|
|
135
|
+
launcher: app.launcher
|
|
136
|
+
},
|
|
137
|
+
icons,
|
|
138
|
+
artifacts: [
|
|
139
|
+
{
|
|
140
|
+
target: artifact.target,
|
|
141
|
+
path: artifact.bundlePath,
|
|
142
|
+
executable: artifact.executable
|
|
143
|
+
}
|
|
144
|
+
],
|
|
145
|
+
checksums
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
writeZip(outputPath, [
|
|
149
|
+
{
|
|
150
|
+
name: 'gea-bundle.json',
|
|
151
|
+
data: `${JSON.stringify(manifest, null, 2)}\n`
|
|
152
|
+
},
|
|
153
|
+
...payloadEntries
|
|
154
|
+
])
|
|
155
|
+
return manifest
|
|
156
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
// apps are discovered by the caller (cli manifest)
|
|
5
|
+
|
|
6
|
+
function q(value) {
|
|
7
|
+
return JSON.stringify(String(value ?? ''))
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function jsxText(value) {
|
|
11
|
+
return String(value ?? '')
|
|
12
|
+
.replace(/&/g, '&')
|
|
13
|
+
.replace(/</g, '<')
|
|
14
|
+
.replace(/>/g, '>')
|
|
15
|
+
.replace(/\{/g, '{')
|
|
16
|
+
.replace(/\}/g, '}')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function readJson(file) {
|
|
20
|
+
return JSON.parse(readFileSync(file, 'utf8'))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function preferredIcon(app) {
|
|
24
|
+
for (const size of ['64', '128', '32', '256', '512']) {
|
|
25
|
+
if (app.icons[size]) return { size, file: app.icons[size] }
|
|
26
|
+
}
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function configuredLauncherAppIds(repoRoot) {
|
|
31
|
+
const manifestPath = path.join(repoRoot, 'apps', 'app-launcher', 'package.json')
|
|
32
|
+
if (!existsSync(manifestPath)) return null
|
|
33
|
+
const manifest = readJson(manifestPath)
|
|
34
|
+
const ids = manifest?.gea?.launcherCatalog?.appIds
|
|
35
|
+
return Array.isArray(ids) ? ids.map((id) => String(id).trim()).filter(Boolean) : null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function orderedIncludeFilter(apps, includeAppIds) {
|
|
39
|
+
if (!includeAppIds || includeAppIds.length === 0) return apps
|
|
40
|
+
const byId = new Map(apps.map((app) => [app.id, app]))
|
|
41
|
+
return includeAppIds.map((id) => byId.get(id)).filter(Boolean)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function launcherApps(allApps, target, includeAppIds = null) {
|
|
45
|
+
const apps = allApps
|
|
46
|
+
.filter((app) => app.runtime === 'gea')
|
|
47
|
+
.filter((app) => app.targets?.[target] === true)
|
|
48
|
+
.filter((app) => app.launcher.hidden !== true)
|
|
49
|
+
.sort((a, b) => {
|
|
50
|
+
const order = a.launcher.order - b.launcher.order
|
|
51
|
+
return order || a.name.localeCompare(b.name)
|
|
52
|
+
})
|
|
53
|
+
return orderedIncludeFilter(apps, includeAppIds)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function copyLauncherIcon({ repoRoot, app, outputFile }) {
|
|
57
|
+
const icon = preferredIcon(app)
|
|
58
|
+
if (!icon) return ''
|
|
59
|
+
const sourcePath = path.resolve(repoRoot, app.root, icon.file)
|
|
60
|
+
if (!existsSync(sourcePath)) return ''
|
|
61
|
+
const iconsDir = path.join(path.dirname(outputFile), 'icons')
|
|
62
|
+
mkdirSync(iconsDir, { recursive: true })
|
|
63
|
+
const ext = path.extname(icon.file).toLowerCase() || '.png'
|
|
64
|
+
const outputName = `${app.id}-${icon.size}${ext}`
|
|
65
|
+
copyFileSync(sourcePath, path.join(iconsDir, outputName))
|
|
66
|
+
return `generated/icons/${outputName}`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function generateLauncherCatalog({ repoRoot, apps: allApps, target = 'geaos', outputFile }) {
|
|
70
|
+
if (!repoRoot) throw new Error('generateLauncherCatalog requires repoRoot')
|
|
71
|
+
if (!Array.isArray(allApps)) throw new Error('generateLauncherCatalog requires apps')
|
|
72
|
+
if (!outputFile) throw new Error('generateLauncherCatalog requires outputFile')
|
|
73
|
+
const includeAppIds = configuredLauncherAppIds(repoRoot)
|
|
74
|
+
const apps = launcherApps(allApps, target, includeAppIds)
|
|
75
|
+
const generatedDir = path.dirname(outputFile)
|
|
76
|
+
rmSync(path.join(generatedDir, 'icons'), { recursive: true, force: true })
|
|
77
|
+
mkdirSync(generatedDir, { recursive: true })
|
|
78
|
+
|
|
79
|
+
const lines = [
|
|
80
|
+
'// Auto-generated by gea apps launcher. Do not edit.',
|
|
81
|
+
"import { Apps } from '@geastack/core'",
|
|
82
|
+
"import '../components/LauncherButton.css'",
|
|
83
|
+
'',
|
|
84
|
+
'export function LauncherCatalog() {',
|
|
85
|
+
' return (',
|
|
86
|
+
' <div class="launcher-list" momentum>'
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
for (const app of apps) {
|
|
90
|
+
const iconSrc = copyLauncherIcon({ repoRoot, app, outputFile })
|
|
91
|
+
const style = app.launcher.accent ? ` style={{ borderColor: ${q(app.launcher.accent)} }}` : ''
|
|
92
|
+
lines.push(` <div class="launcher-card"${style} onClick={() => Apps.launch(${q(app.id)})}>`)
|
|
93
|
+
if (iconSrc) {
|
|
94
|
+
lines.push(` <img class="launcher-card-icon" src=${q(iconSrc)} />`)
|
|
95
|
+
} else {
|
|
96
|
+
lines.push(' <div class="launcher-card-icon launcher-card-icon-placeholder" />')
|
|
97
|
+
}
|
|
98
|
+
lines.push(
|
|
99
|
+
' <div class="launcher-card-copy">',
|
|
100
|
+
` <span class="launcher-card-title">${jsxText(app.name)}</span>`,
|
|
101
|
+
' </div>',
|
|
102
|
+
' </div>'
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
lines.push(
|
|
107
|
+
' </div>',
|
|
108
|
+
' )',
|
|
109
|
+
'}',
|
|
110
|
+
''
|
|
111
|
+
)
|
|
112
|
+
writeFileSync(outputFile, `${lines.join('\n')}`)
|
|
113
|
+
return apps
|
|
114
|
+
}
|