gestalt-mobile 0.37.1 → 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 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,
@@ -1026,6 +1026,7 @@ export async function composeRelayApp(options) {
1026
1026
  },
1027
1027
  logger: console,
1028
1028
  staticDir: options.staticDir,
1029
+ pwaIcon: options.pwaIcon,
1029
1030
  recentThreads: recentThreadsForRelay,
1030
1031
  bootstrap: {
1031
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');
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gestalt-mobile",
3
- "version": "0.37.1",
3
+ "version": "0.38.0",
4
4
  "description": "Mobile-first web relay for durable Codex development sessions",
5
5
  "keywords": [
6
6
  "codex",