@geastack/cli 0.1.51 → 0.1.53
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/docs/SPEC.md +2 -2
- 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 +32 -0
- package/src/boards/custom-target.mjs +309 -0
- package/src/boards/resolve.mjs +143 -0
- package/src/boards/targets.mjs +45 -0
- package/src/boards/usb.mjs +211 -0
- package/src/commands/apps.mjs +236 -0
- package/src/commands/board.mjs +402 -0
- package/src/commands/doctor.mjs +91 -0
- package/src/context.mjs +45 -52
- 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 +86 -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/setup-wizard.mjs +6 -10
- package/src/taurus/adapter.mjs +52 -0
package/docs/SPEC.md
CHANGED
|
@@ -228,8 +228,8 @@ Use predictable exit codes:
|
|
|
228
228
|
1. Implement `gea doctor` for toolchain discovery and app manifest validation.
|
|
229
229
|
2. Implement `gea dev --target web` by delegating to the simulator repo.
|
|
230
230
|
3. Implement `gea build --target web`.
|
|
231
|
-
4. Implement `gea flash --board <alias>`
|
|
232
|
-
`
|
|
231
|
+
4. Implement `gea flash --board <alias>` natively (ESP-IDF, esptool, OTA and
|
|
232
|
+
device transports live in `src/esp32` and `src/device`).
|
|
233
233
|
5. Add `create-geastack` with one JSX app template.
|
|
234
234
|
|
|
235
235
|
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.53",
|
|
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.46"
|
|
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
|
+
}
|