gestalt-mobile 0.37.0 → 0.38.0
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 +12 -0
- package/dist/server/server/app.js +3 -0
- package/dist/server/server/cli.js +11 -0
- package/dist/server/server/composition.js +17 -18
- package/dist/server/server/config.js +7 -0
- package/dist/server/server/features/agent-activity/registry.js +3 -1
- package/dist/server/server/features/autopilot/application/policy.js +1 -1
- package/dist/server/server/features/autopilot/application/service.js +45 -3
- package/dist/server/server/features/pwa/register-routes.js +36 -0
- package/dist/server/server/platform/pwa/load-pwa-icon.js +36 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -32,6 +32,17 @@ The command prints the loopback URL when it is ready. Open that URL in a
|
|
|
32
32
|
browser. Press Ctrl-C, or send SIGINT or SIGTERM, to stop the HTTP server,
|
|
33
33
|
active Codex subprocesses, and database cleanly.
|
|
34
34
|
|
|
35
|
+
Use `--icon <path>` to replace the installed PWA application icon with a local
|
|
36
|
+
SVG or PNG. Relative paths resolve from the directory where Mobile is started:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
gestalt-mobile --cwd . --icon ./branding/project-icon.svg
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Without `--icon`, Mobile keeps its packaged Gestalt icons. SVG icons are
|
|
43
|
+
declared scalable; PNG dimensions are read from the file. A square PNG of at
|
|
44
|
+
least 512×512 is recommended for consistent launcher presentation.
|
|
45
|
+
|
|
35
46
|
### Run this checkout
|
|
36
47
|
|
|
37
48
|
`npm run start` builds and runs the current checkout with the same managed
|
|
@@ -149,6 +160,7 @@ checks with `npm run format:check`, `npm run license:check`, `npm run check`,
|
|
|
149
160
|
| `--public-origin <origin>` | Loopback Vite origin | Exact browser origin for passkeys; required for non-loopback hosts |
|
|
150
161
|
| `--disable-passkey-auth` | Off | Disable passkey access control and serve every client directly |
|
|
151
162
|
| `--data-dir <path>` | XDG state directory | Directory containing `relay.sqlite` |
|
|
163
|
+
| `--icon <path>` | Gestalt icons | SVG or PNG used when installing the PWA |
|
|
152
164
|
| `--skills <profile>` | | Apply a saved global profile to every session |
|
|
153
165
|
| `--skills list` | | List global profiles without starting the server |
|
|
154
166
|
| `--help` | | Print usage without starting the application |
|
|
@@ -19,6 +19,7 @@ import { registerOrgPlanAttentionRoutes } from './features/org-plan-attention/re
|
|
|
19
19
|
import { registerAutopilotRoutes } from './features/autopilot/register-routes.js';
|
|
20
20
|
import { registerFileRoutes } from './features/files/register-routes.js';
|
|
21
21
|
import { registerMaintenanceRoutes } from './features/maintenance/register-routes.js';
|
|
22
|
+
import { registerPwaRoutes } from './features/pwa/register-routes.js';
|
|
22
23
|
export async function buildApp(deps) {
|
|
23
24
|
// Keep the default boundary finite even when an endpoint forgot a narrower schema.
|
|
24
25
|
const app = fastify({ logger: false, bodyLimit: 1024 * 1024 });
|
|
@@ -32,6 +33,8 @@ export async function buildApp(deps) {
|
|
|
32
33
|
return payload;
|
|
33
34
|
});
|
|
34
35
|
await app.register(fastifyCookie);
|
|
36
|
+
if (deps.pwaIcon)
|
|
37
|
+
registerPwaRoutes(app, deps.pwaIcon);
|
|
35
38
|
if (deps.staticDir)
|
|
36
39
|
await app.register(fastifyStatic, { root: deps.staticDir });
|
|
37
40
|
if (deps.auth)
|
|
@@ -14,6 +14,7 @@ import { composeRelayApp } from './composition.js';
|
|
|
14
14
|
import { CliUsageError, parseConfig } from './config.js';
|
|
15
15
|
import { LauncherProfileCatalog } from './platform/catalog/launcher-profile-catalog.js';
|
|
16
16
|
import { FilesystemSkillProfileStore } from './platform/skills/filesystem-skill-profile-store.js';
|
|
17
|
+
import { loadPwaIcon } from './platform/pwa/load-pwa-icon.js';
|
|
17
18
|
const runFile = promisify(execFile);
|
|
18
19
|
export const usage = `Usage: gestalt-mobile [options]
|
|
19
20
|
|
|
@@ -24,6 +25,7 @@ Options:
|
|
|
24
25
|
--public-origin <origin> Exact HTTPS browser origin for passkeys (localhost may use HTTP)
|
|
25
26
|
--disable-passkey-auth Disable passkey access control (unsafe on shared or network hosts)
|
|
26
27
|
--data-dir <path> Directory for persistent application data
|
|
28
|
+
--icon <path> SVG or PNG used as the installed PWA application icon
|
|
27
29
|
--skills <profile> Use a global skill profile for every session
|
|
28
30
|
--skills list List saved global skill profiles without starting the server
|
|
29
31
|
--help Show this help
|
|
@@ -205,6 +207,14 @@ export async function runCli(dependencies = {}) {
|
|
|
205
207
|
stderr.write(`Unknown skill profile: ${config.skillsProfile}\n\n${usage}\n`);
|
|
206
208
|
return 2;
|
|
207
209
|
}
|
|
210
|
+
let pwaIcon;
|
|
211
|
+
try {
|
|
212
|
+
pwaIcon = config.iconPath ? await loadPwaIcon(config.iconPath) : undefined;
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
stderr.write(`${error instanceof Error ? error.message : 'Invalid --icon file'}\n\n${usage}\n`);
|
|
216
|
+
return 2;
|
|
217
|
+
}
|
|
208
218
|
const diagnosis = await (dependencies.runStartupDoctor ?? runStartupDoctor)(cwd);
|
|
209
219
|
if (diagnosis.report)
|
|
210
220
|
stderr.write(diagnosis.report);
|
|
@@ -219,6 +229,7 @@ export async function runCli(dependencies = {}) {
|
|
|
219
229
|
relyingParty: config.relyingParty,
|
|
220
230
|
passkeyAuthEnabled: config.passkeyAuthEnabled,
|
|
221
231
|
staticDir: packagedClientDir(moduleUrl),
|
|
232
|
+
pwaIcon,
|
|
222
233
|
profiles: new LauncherProfileCatalog(),
|
|
223
234
|
installedCodexVersion,
|
|
224
235
|
installedKimiVersion,
|
|
@@ -149,18 +149,18 @@ export async function composeRelayApp(options) {
|
|
|
149
149
|
reconcile: async (sessionId) => {
|
|
150
150
|
const session = sessions.find(sessionId);
|
|
151
151
|
if (!session)
|
|
152
|
-
return;
|
|
152
|
+
return false;
|
|
153
153
|
// Kimi sessions own no child-process projection; one detached history
|
|
154
154
|
// read supplies the whole reconciliation, mirroring the codex tail.
|
|
155
155
|
if (session.provider === 'kimi') {
|
|
156
156
|
if (!kimiRuntime)
|
|
157
|
-
return;
|
|
157
|
+
return false;
|
|
158
158
|
const history = await kimiRuntime.readHistory(session);
|
|
159
159
|
// Forget may commit while the detached history reader is still in
|
|
160
160
|
// flight. Revalidate ownership before handing its result to the
|
|
161
161
|
// registry, whose publisher intentionally keeps the journal strict.
|
|
162
162
|
if (!sessions.find(sessionId))
|
|
163
|
-
return;
|
|
163
|
+
return false;
|
|
164
164
|
const occurredAt = new Date().toISOString();
|
|
165
165
|
activity.observe({
|
|
166
166
|
sessionId,
|
|
@@ -169,16 +169,22 @@ export async function composeRelayApp(options) {
|
|
|
169
169
|
...(session.threadId ? { threadId: session.threadId } : {}),
|
|
170
170
|
...(history.activeTurnId ? { turnId: history.activeTurnId } : {}),
|
|
171
171
|
});
|
|
172
|
-
return;
|
|
172
|
+
return true;
|
|
173
173
|
}
|
|
174
174
|
if (!runtime)
|
|
175
|
-
return;
|
|
175
|
+
return false;
|
|
176
176
|
const history = await runtime.readHistory(session);
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
177
|
+
const children = await runtime.listDirectChildren(session);
|
|
178
|
+
const childProcesses = new Map();
|
|
179
|
+
await mapWithConcurrency([...children], 4, async (child) => {
|
|
180
|
+
childProcesses.set(child.id, await runtime.inspectChildProcesses(session, child));
|
|
181
|
+
});
|
|
182
|
+
// The writer read is also asynchronous; it cannot publish after the
|
|
183
|
+
// durable owner has gone away either.
|
|
180
184
|
if (!sessions.find(sessionId))
|
|
181
|
-
return;
|
|
185
|
+
return false;
|
|
186
|
+
// Publish only after the entire authoritative read succeeds. A fresh
|
|
187
|
+
// root with stale child topology is not a valid supervision snapshot.
|
|
182
188
|
const occurredAt = new Date().toISOString();
|
|
183
189
|
activity.observe({
|
|
184
190
|
sessionId,
|
|
@@ -187,19 +193,11 @@ export async function composeRelayApp(options) {
|
|
|
187
193
|
...(session.threadId ? { threadId: session.threadId } : {}),
|
|
188
194
|
...(history.activeTurnId ? { turnId: history.activeTurnId } : {}),
|
|
189
195
|
});
|
|
190
|
-
const children = await runtime.listDirectChildren(session);
|
|
191
|
-
const childProcesses = new Map();
|
|
192
|
-
await mapWithConcurrency([...children], 4, async (child) => {
|
|
193
|
-
childProcesses.set(child.id, await runtime.inspectChildProcesses(session, child));
|
|
194
|
-
});
|
|
195
|
-
// The writer read is also asynchronous; it cannot publish after the
|
|
196
|
-
// durable owner has gone away either.
|
|
197
|
-
if (!sessions.find(sessionId))
|
|
198
|
-
return;
|
|
199
196
|
activity.childrenReconciled(sessionId, occurredAt, children.map((child) => ({
|
|
200
197
|
...child,
|
|
201
198
|
processes: childProcesses.get(child.id) ?? [],
|
|
202
199
|
})));
|
|
200
|
+
return true;
|
|
203
201
|
},
|
|
204
202
|
});
|
|
205
203
|
const autopilot = new AutopilotCoordinator({
|
|
@@ -1028,6 +1026,7 @@ export async function composeRelayApp(options) {
|
|
|
1028
1026
|
},
|
|
1029
1027
|
logger: console,
|
|
1030
1028
|
staticDir: options.staticDir,
|
|
1029
|
+
pwaIcon: options.pwaIcon,
|
|
1031
1030
|
recentThreads: recentThreadsForRelay,
|
|
1032
1031
|
bootstrap: {
|
|
1033
1032
|
workspaces,
|
|
@@ -15,6 +15,7 @@ const optionNames = new Set([
|
|
|
15
15
|
'--data-dir',
|
|
16
16
|
'--public-origin',
|
|
17
17
|
'--skills',
|
|
18
|
+
'--icon',
|
|
18
19
|
]);
|
|
19
20
|
const flagNames = new Set(['--disable-passkey-auth']);
|
|
20
21
|
export function parseConfig(args, cwd = process.cwd()) {
|
|
@@ -57,8 +58,14 @@ export function parseConfig(args, cwd = process.cwd()) {
|
|
|
57
58
|
root: resolve(cwd, values.get('--cwd') ?? '.'),
|
|
58
59
|
dataDir: values.get('--data-dir'),
|
|
59
60
|
skillsProfile: values.get('--skills'),
|
|
61
|
+
iconPath: values.has('--icon') ? resolveIconPath(cwd, values.get('--icon')) : undefined,
|
|
60
62
|
};
|
|
61
63
|
}
|
|
64
|
+
function resolveIconPath(cwd, value) {
|
|
65
|
+
if (!/\.(?:png|svg)$/i.test(value))
|
|
66
|
+
throw new CliUsageError('--icon must reference an SVG or PNG file');
|
|
67
|
+
return resolve(cwd, value);
|
|
68
|
+
}
|
|
62
69
|
export function normalizePublicOrigin(value) {
|
|
63
70
|
if (!/^[a-z][a-z\d+.-]*:\/\/[^/?#\s\\\\]+\/?$/i.test(value))
|
|
64
71
|
throw new CliUsageError('--public-origin must be a bare origin without a path, query, or fragment');
|
|
@@ -157,7 +157,9 @@ export class AgentActivityRegistry {
|
|
|
157
157
|
const now = this.options.now?.() ?? new Date().toISOString();
|
|
158
158
|
this.reconciling(sessionId, now);
|
|
159
159
|
try {
|
|
160
|
-
await this.options.reconcile?.(sessionId);
|
|
160
|
+
const observed = await this.options.reconcile?.(sessionId);
|
|
161
|
+
if (observed !== true)
|
|
162
|
+
throw new Error('AGENT_ACTIVITY_RECONCILE_NO_EVIDENCE');
|
|
161
163
|
if (this.#generation.get(sessionId) === generation)
|
|
162
164
|
this.reconciled(sessionId, this.options.now?.() ?? now);
|
|
163
165
|
}
|
|
@@ -91,7 +91,7 @@ export function decideAutopilot(input) {
|
|
|
91
91
|
return { kind: 'safetyPause', reason: 'actionRateExceeded' };
|
|
92
92
|
if (!activity || activity.confidence !== 'fresh')
|
|
93
93
|
return { kind: 'reconcile' };
|
|
94
|
-
if (Date.parse(now) - Date.parse(activity.root.
|
|
94
|
+
if (Date.parse(now) - Date.parse(activity.root.observedAt) > policy.staleAfterMs)
|
|
95
95
|
return { kind: 'reconcile' };
|
|
96
96
|
const disposition = classifyAgentActivity(activity);
|
|
97
97
|
if (disposition === 'attention')
|
|
@@ -417,9 +417,11 @@ export class AutopilotCoordinator {
|
|
|
417
417
|
case 'scheduleContinuation':
|
|
418
418
|
if (session?.activeTurnId || this.deps.pendingInteraction(sessionId))
|
|
419
419
|
break;
|
|
420
|
+
const plan = this.deps.plan(sessionId)?.plan;
|
|
420
421
|
if (this.deps.executorController &&
|
|
421
|
-
|
|
422
|
-
this.
|
|
422
|
+
plan &&
|
|
423
|
+
!this.requiresRootBoundary(prior, plan) &&
|
|
424
|
+
this.currentExecutor(sessionId, plan, prior.consecutiveNoProgress, prior.executor)) {
|
|
423
425
|
this.enqueue(sessionId, async () => {
|
|
424
426
|
await this.enforceSupervisedLifecycle(sessionId, 'stateChanged');
|
|
425
427
|
});
|
|
@@ -1012,6 +1014,7 @@ export class AutopilotCoordinator {
|
|
|
1012
1014
|
if (this.deps.executorController &&
|
|
1013
1015
|
retained &&
|
|
1014
1016
|
!current.executor?.replacement &&
|
|
1017
|
+
!this.requiresRootBoundary(current, retained.plan) &&
|
|
1015
1018
|
this.currentExecutor(sessionId, retained.plan, current.consecutiveNoProgress, current.executor)) {
|
|
1016
1019
|
const now = this.deps.now();
|
|
1017
1020
|
const cancelled = this.cancelScheduledControl(current, now);
|
|
@@ -1159,6 +1162,10 @@ export class AutopilotCoordinator {
|
|
|
1159
1162
|
!state?.requestedEnabled ||
|
|
1160
1163
|
this.deps.pendingInteraction(sessionId))
|
|
1161
1164
|
return false;
|
|
1165
|
+
// Checkpoints are root-owned report boundaries. Once an executor has made
|
|
1166
|
+
// one eligible, never mechanically resume it past the unreported result.
|
|
1167
|
+
if (this.requiresRootBoundary(state, retained.plan))
|
|
1168
|
+
return false;
|
|
1162
1169
|
const executor = this.currentExecutor(sessionId, retained.plan, state.consecutiveNoProgress, state.executor);
|
|
1163
1170
|
const executorChanged = executor ? this.persistExecutor(sessionId, executor) : false;
|
|
1164
1171
|
if (executor && (await this.reconcileSplitBrain(sessionId, executor)))
|
|
@@ -1167,6 +1174,18 @@ export class AutopilotCoordinator {
|
|
|
1167
1174
|
this.supersedeExecutorCommands(sessionId, retained.identity, retained.plan, executor);
|
|
1168
1175
|
if (executorChanged && this.wakeForExecutorChange(sessionId, executor))
|
|
1169
1176
|
return true;
|
|
1177
|
+
if (executor && this.executorTurnActive(executor, this.deps.activity(sessionId))) {
|
|
1178
|
+
// A malformed root yield must not send a duplicate continuation into a
|
|
1179
|
+
// still-running executor. A valid parked lease already owns its wake;
|
|
1180
|
+
// otherwise retain one bounded reconciliation watchdog as the fallback.
|
|
1181
|
+
const parkedLease = state.supervision?.waitLease;
|
|
1182
|
+
const parkedWakeLive = state.supervision?.outcome === 'parked' &&
|
|
1183
|
+
parkedLease &&
|
|
1184
|
+
this.parkedSubscriptions.get(sessionId) === parkedLease.id;
|
|
1185
|
+
if (!parkedWakeLive)
|
|
1186
|
+
this.armExecutorRefresh(sessionId, this.deps.policy.executorContinuationMaxMs);
|
|
1187
|
+
return true;
|
|
1188
|
+
}
|
|
1170
1189
|
const decision = decideSupervisedLifecycle({
|
|
1171
1190
|
plan: retained.plan,
|
|
1172
1191
|
event,
|
|
@@ -1249,6 +1268,21 @@ export class AutopilotCoordinator {
|
|
|
1249
1268
|
}
|
|
1250
1269
|
}
|
|
1251
1270
|
}
|
|
1271
|
+
requiresRootBoundary(state, plan) {
|
|
1272
|
+
const reportedL2 = new Set(state.checkpoints?.reportedL2Ids ?? []);
|
|
1273
|
+
const reportedL1 = new Set(state.checkpoints?.reportedL1Ids ?? []);
|
|
1274
|
+
const epochs = new Map(state.checkpoints?.completionEpochs?.map((entry) => [entry.target, entry]) ?? []);
|
|
1275
|
+
const epochNeedsReport = (target) => {
|
|
1276
|
+
const epoch = epochs.get(target);
|
|
1277
|
+
return Boolean(epoch?.reopened || epoch?.completed === false);
|
|
1278
|
+
};
|
|
1279
|
+
return plan.steps.some((l1) => l1.children.some((l2) => l2.state === 'DONE' &&
|
|
1280
|
+
(!reportedL2.has(JSON.stringify([l1.id, l2.id])) ||
|
|
1281
|
+
epochNeedsReport(checkpointTarget('l2', l1.id, l2.id)))) ||
|
|
1282
|
+
(l1.state === 'DONE' &&
|
|
1283
|
+
l1.reviewStatus === 'REVIEWED' &&
|
|
1284
|
+
(!reportedL1.has(l1.id) || epochNeedsReport(checkpointTarget('l1', l1.id)))));
|
|
1285
|
+
}
|
|
1252
1286
|
currentExecutor(sessionId, plan, continuationCount, persisted) {
|
|
1253
1287
|
const stepIndex = plan.steps.findIndex((step) => step.id === plan.currentStepId || step.state === 'WIP');
|
|
1254
1288
|
const index = stepIndex >= 0 ? stepIndex : plan.steps.findIndex((step) => step.state !== 'DONE');
|
|
@@ -1987,6 +2021,14 @@ export class AutopilotCoordinator {
|
|
|
1987
2021
|
child.state === 'awaitingAgent' ||
|
|
1988
2022
|
child.ownedProcesses?.some((process) => process.state === 'running' || process.state === 'detached-active')));
|
|
1989
2023
|
}
|
|
2024
|
+
executorTurnActive(executor, activity) {
|
|
2025
|
+
if (activity?.confidence !== 'fresh')
|
|
2026
|
+
return false;
|
|
2027
|
+
const child = activity.subagents.find((candidate) => (candidate.threadId ?? candidate.id) === executor.threadId &&
|
|
2028
|
+
candidate.taskPath === executor.taskPath &&
|
|
2029
|
+
(candidate.continuationGeneration ?? 1) === executor.continuationGeneration);
|
|
2030
|
+
return child?.state === 'working' || child?.state === 'awaitingAgent';
|
|
2031
|
+
}
|
|
1990
2032
|
/** Builds both GET and pre-commit event payloads from the same prospective facts. */
|
|
1991
2033
|
snapshotFor(next, prospectiveControl) {
|
|
1992
2034
|
const session = this.deps.session(next.sessionId);
|
|
@@ -2180,7 +2222,7 @@ export class AutopilotCoordinator {
|
|
|
2180
2222
|
// yield to. A later activity/watchdog event will evaluate it again.
|
|
2181
2223
|
const activity = this.deps.activity(sessionId);
|
|
2182
2224
|
if (activity?.confidence === 'fresh' &&
|
|
2183
|
-
Date.parse(this.deps.now()) - Date.parse(activity.root.
|
|
2225
|
+
Date.parse(this.deps.now()) - Date.parse(activity.root.observedAt) <=
|
|
2184
2226
|
this.deps.policy.staleAfterMs) {
|
|
2185
2227
|
this.evaluate(sessionId);
|
|
2186
2228
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Dyne.org foundation
|
|
3
|
+
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
|
+
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
const manifestBase = {
|
|
7
|
+
id: '/',
|
|
8
|
+
name: 'Gestalt Mobile',
|
|
9
|
+
short_name: 'Gestalt',
|
|
10
|
+
description: 'A mobile interface for durable Codex development sessions.',
|
|
11
|
+
start_url: '/',
|
|
12
|
+
scope: '/',
|
|
13
|
+
display: 'standalone',
|
|
14
|
+
background_color: '#141414',
|
|
15
|
+
theme_color: '#141414',
|
|
16
|
+
categories: ['productivity', 'utilities'],
|
|
17
|
+
};
|
|
18
|
+
/** Overrides only install metadata; ordinary client assets stay package-static. */
|
|
19
|
+
export function registerPwaRoutes(app, icon) {
|
|
20
|
+
const iconPath = `/install-icon.${icon.extension}`;
|
|
21
|
+
app.get('/manifest.webmanifest', async (_request, reply) => reply
|
|
22
|
+
.header('Cache-Control', 'no-cache')
|
|
23
|
+
.type('application/manifest+json')
|
|
24
|
+
.send({
|
|
25
|
+
...manifestBase,
|
|
26
|
+
icons: [
|
|
27
|
+
{
|
|
28
|
+
src: iconPath,
|
|
29
|
+
sizes: icon.sizes,
|
|
30
|
+
type: icon.contentType,
|
|
31
|
+
purpose: 'any',
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
}));
|
|
35
|
+
app.get(iconPath, async (_request, reply) => reply.header('Cache-Control', 'no-cache').type(icon.contentType).send(icon.bytes));
|
|
36
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Dyne.org foundation
|
|
3
|
+
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
|
+
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
import { readFile } from 'node:fs/promises';
|
|
7
|
+
import { extname } from 'node:path';
|
|
8
|
+
const maxIconBytes = 5 * 1024 * 1024;
|
|
9
|
+
const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
10
|
+
export async function loadPwaIcon(path) {
|
|
11
|
+
let bytes;
|
|
12
|
+
try {
|
|
13
|
+
bytes = await readFile(path);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
throw new Error(`Unable to read --icon file: ${path}`);
|
|
17
|
+
}
|
|
18
|
+
if (bytes.length === 0 || bytes.length > maxIconBytes)
|
|
19
|
+
throw new Error('--icon must be a non-empty SVG or PNG no larger than 5 MiB');
|
|
20
|
+
const extension = extname(path).toLowerCase();
|
|
21
|
+
if (extension === '.svg') {
|
|
22
|
+
if (!/<svg(?:\s|>)/i.test(bytes.toString('utf8')))
|
|
23
|
+
throw new Error('--icon SVG does not contain an <svg> root');
|
|
24
|
+
return { bytes, contentType: 'image/svg+xml', extension: 'svg', sizes: 'any' };
|
|
25
|
+
}
|
|
26
|
+
if (extension !== '.png' ||
|
|
27
|
+
bytes.length < 24 ||
|
|
28
|
+
!bytes.subarray(0, 8).equals(pngSignature) ||
|
|
29
|
+
bytes.toString('ascii', 12, 16) !== 'IHDR')
|
|
30
|
+
throw new Error('--icon PNG has an invalid header');
|
|
31
|
+
const width = bytes.readUInt32BE(16);
|
|
32
|
+
const height = bytes.readUInt32BE(20);
|
|
33
|
+
if (width === 0 || height === 0)
|
|
34
|
+
throw new Error('--icon PNG has invalid dimensions');
|
|
35
|
+
return { bytes, contentType: 'image/png', extension: 'png', sizes: `${width}x${height}` };
|
|
36
|
+
}
|