claude-code-hub 1.21.0 → 1.23.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/lib/apps.js CHANGED
@@ -85,10 +85,45 @@ function manifestError(m, id, root) {
85
85
  keysError(m.keys) ??
86
86
  pluginError(m.plugin, root) ??
87
87
  requiresError(m.requires) ??
88
- listingError(m.listing, root)
88
+ listingError(m.listing, root) ??
89
+ settingsError(m.settings)
89
90
  );
90
91
  }
91
92
 
93
+ const SETTING_KEY_RE = /^[a-zA-Z][a-zA-Z0-9]{0,31}$/;
94
+ const MAX_SETTINGS = 20;
95
+ const plainText = (v, max) => typeof v === 'string' && v.length > 0 && v.length <= max && !/[<>]/.test(v);
96
+ const validSettingValue = (s, v) => (s.type === 'boolean' ? typeof v === 'boolean' : s.options.some((o) => o.value === v));
97
+
98
+ function settingError(s) {
99
+ if (!isObject(s) || typeof s.key !== 'string' || !SETTING_KEY_RE.test(s.key)) return `settings entry has no key matching ${SETTING_KEY_RE}`;
100
+ const at = `settings "${s.key}"`;
101
+ if (!plainText(s.label, 60)) return `${at}: label is not plain text of 60 characters or fewer`;
102
+ if (s.help !== undefined && !plainText(s.help, 200)) return `${at}: help is not plain text of 200 characters or fewer`;
103
+ if (s.type === 'boolean') return validSettingValue(s, s.default) ? null : `${at}: default is not a boolean`;
104
+ if (s.type !== 'choice') return `${at}: type is not boolean or choice`;
105
+ const { options } = s;
106
+ if (!Array.isArray(options) || options.length < 2 || options.length > 6) return `${at}: options is not a list of 2 to 6`;
107
+ for (const o of options) {
108
+ if (!isObject(o) || typeof o.value !== 'string' || !o.value || !plainText(o.label, 30))
109
+ return `${at}: an option has no string value or plain-text label`;
110
+ }
111
+ if (new Set(options.map((o) => o.value)).size !== options.length) return `${at}: two options share a value`;
112
+ if (!validSettingValue(s, s.default)) return `${at}: default is not one of the option values`;
113
+ return null;
114
+ }
115
+
116
+ function settingsError(settings) {
117
+ if (settings === undefined) return null;
118
+ if (!Array.isArray(settings) || settings.length > MAX_SETTINGS) return `settings is not a list of up to ${MAX_SETTINGS}`;
119
+ for (const s of settings) {
120
+ const err = settingError(s);
121
+ if (err) return err;
122
+ }
123
+ if (new Set(settings.map((s) => s.key)).size !== settings.length) return 'two settings share a key';
124
+ return null;
125
+ }
126
+
92
127
  const PLUGIN_NAME_RE = /^[\w.-]+$/;
93
128
  const VERSION_RE = /^\d+\.\d+\.\d+$/;
94
129
 
@@ -121,7 +156,7 @@ const IMAGE_RE = /\.(png|jpe?g|webp)$/i;
121
156
  const SPDX_RE = /^[A-Za-z0-9.+-]+$/;
122
157
  const isHttps = (u) => typeof u === 'string' && /^https:\/\/[^\s/]+/.test(u);
123
158
  const textError = (key, v, max) =>
124
- v === undefined || (typeof v === 'string' && v.length > 0 && v.length <= max && !/[<>]/.test(v))
159
+ v === undefined || plainText(v, max)
125
160
  ? null
126
161
  : `listing.${key} is not plain text of ${max} characters or fewer`;
127
162
 
@@ -283,7 +318,8 @@ function loadApp(app, { flagPort, hubRoot = HUB_ROOT, resolve = require.resolve,
283
318
  provides: manifest.provides ?? {},
284
319
  publishes: manifest.publishes ?? [],
285
320
  keeps: manifest.keys?.keeps ?? [],
321
+ settings: manifest.settings ?? [],
286
322
  };
287
323
  }
288
324
 
289
- module.exports = { APPS, HUB_ROOT, ID_RE, selectApps, appRoot, loadApp, manifestError, readApp, resolvePlugin, conflicts };
325
+ module.exports = { APPS, HUB_ROOT, ID_RE, selectApps, appRoot, loadApp, manifestError, readApp, resolvePlugin, conflicts, isObject, validSettingValue };
package/lib/settings.js CHANGED
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const { selectApps } = require('./apps');
3
+ const { isObject, selectApps, validSettingValue } = require('./apps');
4
4
 
5
5
  const CONFIG_DIR_SWITCH = ['palette', 'swap'];
6
6
  const DEFAULT_POOL_SIZE = 3;
@@ -38,9 +38,41 @@ function appsShape(apps) {
38
38
  return JSON.stringify(apps.filter((a) => a.state !== 'off').map((a) => [a.id, a.state, a.deferredStart]));
39
39
  }
40
40
 
41
+ const objectOr = (v) => (isObject(v) ? v : {});
42
+
43
+ // An app's settings from its manifest list and the `settings` of its `apps` entry. A saved value
44
+ // that the manifest no longer allows gives the default.
45
+ function settingValues(schema, saved) {
46
+ const own = objectOr(saved);
47
+ return Object.fromEntries(schema.map((s) => [s.key, Object.hasOwn(own, s.key) && validSettingValue(s, own[s.key]) ? own[s.key] : s.default]));
48
+ }
49
+
50
+ function settingsPatchError(schema = [], settings) {
51
+ if (!isObject(settings)) return 'settings must be an object';
52
+ for (const [key, value] of Object.entries(settings)) {
53
+ const s = schema.find((x) => x.key === key);
54
+ if (!s) return `This app has no setting "${key}"`;
55
+ if (!validSettingValue(s, value))
56
+ return s.type === 'boolean' ? `${key} must be a boolean` : `${key} must be one of ${s.options.map((o) => o.value).join(', ')}`;
57
+ }
58
+ return null;
59
+ }
60
+
61
+ // Only a value that differs from the default is written.
62
+ function withSettings(entry, schema, patch) {
63
+ const values = { ...objectOr(entry.settings) };
64
+ for (const [key, value] of Object.entries(patch)) {
65
+ if (value === schema.find((s) => s.key === key).default) delete values[key];
66
+ else values[key] = value;
67
+ }
68
+ if (Object.keys(values).length) entry.settings = values;
69
+ else delete entry.settings;
70
+ }
71
+
41
72
  // `saved` is config.json as on disk. Returns the new config, or {error}. Only the keys the
42
- // patch names change; an app entry keeps its other fields (port, path, source).
43
- function applySettings(saved, patch, base) {
73
+ // patch names change; an app entry keeps its other fields (port, path, source). `schemas` maps an
74
+ // app id to the settings list of its manifest.
75
+ function applySettings(saved, patch, base, schemas = {}) {
44
76
  if (!patch || typeof patch !== 'object') return { error: 'body must be an object' };
45
77
  const next = { ...saved };
46
78
  if (patch.configDirSwitch !== undefined) {
@@ -53,7 +85,7 @@ function applySettings(saved, patch, base) {
53
85
  next.poolSize = patch.poolSize;
54
86
  }
55
87
  if (patch.app !== undefined) {
56
- const { id, state, deferredStart } = patch.app ?? {};
88
+ const { id, state, deferredStart, settings } = patch.app ?? {};
57
89
  const current = appSettings(saved.apps, base);
58
90
  if (!current.some((a) => a.id === id)) return { error: `Unknown app "${id}"` };
59
91
  if (state !== undefined && !APP_STATES.includes(state))
@@ -62,6 +94,8 @@ function applySettings(saved, patch, base) {
62
94
  return { error: 'deferredStart must be a boolean' };
63
95
  if (state === 'off' && current.every((a) => a.id === id || a.state === 'off'))
64
96
  return { error: 'At least one app must stay on' };
97
+ const settingsErr = settings === undefined ? null : settingsPatchError(schemas[id], settings);
98
+ if (settingsErr) return { error: settingsErr };
65
99
  let entries = Array.isArray(saved.apps) ? [...saved.apps] : [];
66
100
  // Listed apps come first, so a new entry appended at the end would move its tab. Listing
67
101
  // every app in its current order keeps the order.
@@ -76,6 +110,7 @@ function applySettings(saved, patch, base) {
76
110
  entry.hidden = state === 'hidden';
77
111
  }
78
112
  if (deferredStart !== undefined) entry.deferredStart = deferredStart;
113
+ if (settings !== undefined) withSettings(entry, schemas[id], settings);
79
114
  entries[i] = entry;
80
115
  next.apps = entries;
81
116
  }
@@ -90,4 +125,5 @@ module.exports = {
90
125
  applySettings,
91
126
  configDirSwitch,
92
127
  poolSize,
128
+ settingValues,
93
129
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-hub",
3
- "version": "1.21.0",
3
+ "version": "1.23.0",
4
4
  "description": "Unified hub for Claude Code tools — Marketplace + Kanban in one PWA",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -46,10 +46,10 @@
46
46
  },
47
47
  "homepage": "https://github.com/NikiforovAll/claude-code-hub#readme",
48
48
  "dependencies": {
49
- "claude-code-cost": "1.5.0",
50
- "claude-code-kanban": "7.16.0",
51
- "claude-code-marketplace": "1.6.0",
52
- "claude-code-memory-explorer": "1.3.2",
49
+ "claude-code-cost": "1.5.1",
50
+ "claude-code-kanban": "7.19.0",
51
+ "claude-code-marketplace": "1.7.0",
52
+ "claude-code-memory-explorer": "1.3.3",
53
53
  "express": "^4.21.0",
54
54
  "open": "^10.1.0"
55
55
  },
@@ -8,9 +8,12 @@
8
8
  // hub's proxy port, so a page framed by something other than the hub would otherwise paint the hub's last theme.
9
9
  const VARS_KEY = 'claude-hub:vars';
10
10
  const WELCOME_WAIT_MS = 2000;
11
+ // A press in one of these types text, so the host never gets it.
12
+ const FIELDS =
13
+ 'input:not([type=checkbox], [type=radio], [type=button], [type=submit], [type=reset], [type=range], [type=color], [type=file], [type=image]), textarea, select, [contenteditable]';
11
14
 
12
15
  // keys.js comes first in the served file. Required as a module, client.js loads it itself.
13
- const { comboOf, keyParts } = typeof ClaudeHubKeys === 'object' ? ClaudeHubKeys : require('./keys');
16
+ const { comboOf, keyParts, keySigs } = typeof ClaudeHubKeys === 'object' ? ClaudeHubKeys : require('./keys');
14
17
 
15
18
  function isVars(v) {
16
19
  return (
@@ -90,6 +93,7 @@
90
93
  const pending = new Map();
91
94
  const tokenWaiters = new Set();
92
95
  const themesFns = new Set();
96
+ const settingsFns = new Set();
93
97
  const queued = [];
94
98
  // Topic → payload: the latest publish before welcome.
95
99
  const outbox = new Map();
@@ -97,8 +101,11 @@
97
101
  let origin = null;
98
102
  let welcome = null;
99
103
  let forward = null;
104
+ let appOrigins = new Set();
105
+ let linkOpened = null;
100
106
  let themeBinding = null;
101
107
  let lastTheme = null;
108
+ let settings = null;
102
109
  let swatchesPainted = false;
103
110
  let nextId = 0;
104
111
 
@@ -170,6 +177,11 @@
170
177
  return welcome.themes.map(({ id, label }) => ({ id, label }));
171
178
  }
172
179
 
180
+ function setSettings(s) {
181
+ settings = Object.freeze(s && typeof s === 'object' && !Array.isArray(s) ? { ...s } : {});
182
+ for (const fn of settingsFns) fn(settings);
183
+ }
184
+
173
185
  function onWelcome(m) {
174
186
  if (m.protocol !== 1 || welcome) return;
175
187
  welcome = {
@@ -178,6 +190,8 @@
178
190
  themes: Array.isArray(m.themes) ? m.themes : [],
179
191
  };
180
192
  forward = strings(m.forward);
193
+ appOrigins = strings(m.origins);
194
+ setSettings(m.settings);
181
195
  setStatus('live');
182
196
  if (welcome.themes.length) for (const fn of themesFns) fn(pickerThemes());
183
197
  themesFns.clear();
@@ -213,6 +227,9 @@
213
227
  case 'hub:terminalToken':
214
228
  for (const done of [...tokenWaiters]) done(typeof m.token === 'string' ? m.token : null);
215
229
  return;
230
+ case 'hub:settings':
231
+ if (welcome) setSettings(m.settings);
232
+ return;
216
233
  }
217
234
  }
218
235
 
@@ -220,34 +237,95 @@
220
237
  return !!forward && forward.has(comboOf(e));
221
238
  }
222
239
 
240
+ // Protocol §5.2. The app page that frames this one, such as a cck pane, claims the presses it acts on.
241
+ let hostKeys = null;
242
+ let hostOrigin = null;
243
+
244
+ function onHostMessage(e) {
245
+ const m = e.data;
246
+ if (!framed || e.source !== win.parent || win.parent === win.top) return;
247
+ if (m?.type !== 'hub:hostKeys' || !Array.isArray(m.keys)) return;
248
+ if (!hostKeys) win.parent.postMessage({ type: 'hub:hostReady' }, e.origin);
249
+ hostOrigin = e.origin;
250
+ hostKeys = new Set(m.keys.map((k) => keySigs(k)[0]));
251
+ }
252
+
253
+ const keyMessage = (type, e) => ({
254
+ type,
255
+ key: e.key,
256
+ code: e.code,
257
+ ctrl: e.ctrlKey,
258
+ alt: e.altKey,
259
+ shift: e.shiftKey,
260
+ meta: e.metaKey,
261
+ });
262
+
223
263
  function onKeydown(e) {
224
- if (!forwards(e)) return;
264
+ if (forwards(e)) {
265
+ e.preventDefault();
266
+ return post(keyMessage('hub:keydown', e));
267
+ }
268
+ if (!hostKeys || e.defaultPrevented || !keySigs(e).some((s) => hostKeys.has(s))) return;
269
+ if (e.target?.closest?.(FIELDS)) return;
225
270
  e.preventDefault();
226
- post({
227
- type: 'hub:keydown',
228
- key: e.key,
229
- code: e.code,
230
- ctrl: e.ctrlKey,
231
- alt: e.altKey,
232
- shift: e.shiftKey,
233
- meta: e.metaKey,
234
- });
271
+ win.parent.postMessage(keyMessage('hub:hostKey', e), hostOrigin);
235
272
  }
236
273
 
237
- function run(action, params, resolve) {
274
+ function run(action, params, opts, resolve) {
275
+ const pane = opts?.open === 'pane';
238
276
  if (status === 'live') {
239
277
  const id = String(++nextId);
240
278
  pending.set(id, resolve);
241
- return post({ type: 'hub:invoke', id, action, params });
279
+ return post({ type: 'hub:invoke', id, action, params, ...(pane && { open: 'pane' }) });
242
280
  }
243
281
  const target = standaloneFn(action)?.(params);
244
282
  if (!target) return resolve({ ok: false, reason: 'unhandled' });
283
+ if (pane) return resolve({ ok: true, handledBy: 'standalone', url: target });
245
284
  win.open(target, '_blank', 'noopener');
246
285
  resolve({ ok: true, handledBy: 'standalone' });
247
286
  }
248
287
 
288
+ // A link with no target loads in the app's frame, and most sites refuse framing; in the installed
289
+ // window a _blank one opens nothing. Capture on the window runs before a modal's stopPropagation
290
+ // can hide the click. The hub page refuses framing, so a link to it opens outside too.
291
+ function onLinkClick(e) {
292
+ if (!welcome || e.defaultPrevented || (e.type === 'auxclick') !== (e.button === 1)) return;
293
+ const a = e.target?.closest?.('a[href]');
294
+ if (!a || a.hasAttribute?.('download')) return;
295
+ const target = a.getAttribute?.('target') || '';
296
+ if (target && !target.startsWith('_')) return;
297
+ let url;
298
+ try {
299
+ // An SVG <a> has an SVGAnimatedString href, so the attribute is read instead.
300
+ url = new URL(a.getAttribute?.('href') ?? a.href, doc.baseURI);
301
+ } catch (_) {
302
+ return;
303
+ }
304
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return;
305
+ const inApp = url.origin === win.location.origin || appOrigins.has(url.origin);
306
+ if (inApp && target !== '_blank') return;
307
+ e.preventDefault();
308
+ hub.openExternal(url.href);
309
+ // Apps written before this listener open the same link from their own click handler.
310
+ linkOpened = url.href;
311
+ win.setTimeout(() => {
312
+ linkOpened = null;
313
+ });
314
+ }
315
+
316
+ function sameUrl(url, href) {
317
+ try {
318
+ return new URL(String(url), doc.baseURI).href === href;
319
+ } catch (_) {
320
+ return false;
321
+ }
322
+ }
323
+
249
324
  win.addEventListener('message', onMessage);
325
+ win.addEventListener('message', onHostMessage);
250
326
  doc.addEventListener('keydown', onKeydown);
327
+ win.addEventListener('click', onLinkClick, true);
328
+ win.addEventListener('auxclick', onLinkClick, true);
251
329
 
252
330
  const loaded =
253
331
  doc.readyState === 'complete'
@@ -316,6 +394,18 @@
316
394
  if (welcome.themes.length) fn(pickerThemes());
317
395
  return () => {};
318
396
  },
397
+ // The values of the settings in the app's manifest `settings`, as {key: value}. Null before
398
+ // welcome, standalone, or with no welcome: the app then uses its own defaults.
399
+ get settings() {
400
+ return settings;
401
+ },
402
+ // Calls fn with the values at welcome and after each change in the hub's settings. A call after
403
+ // welcome gets the current values at once.
404
+ onSettings(fn) {
405
+ settingsFns.add(fn);
406
+ if (settings) fn(settings);
407
+ return () => settingsFns.delete(fn);
408
+ },
319
409
  subscribe(topic, fn) {
320
410
  const fns = topicFns(topic);
321
411
  fns.add(fn);
@@ -344,10 +434,10 @@
344
434
  if (status === 'live') post({ type: 'hub:publish', topic, payload });
345
435
  else if (waiting()) outbox.set(topic, payload);
346
436
  },
347
- invoke(action, params = {}) {
437
+ invoke(action, params = {}, opts = {}) {
348
438
  return new Promise((resolve) => {
349
- if (waiting()) queued.push(() => run(action, params, resolve));
350
- else run(action, params, resolve);
439
+ if (waiting()) queued.push(() => run(action, params, opts, resolve));
440
+ else run(action, params, opts, resolve);
351
441
  });
352
442
  },
353
443
  can(action) {
@@ -378,6 +468,7 @@
378
468
  },
379
469
  // In the hub's installed PWA window, a framed page's own _blank open does nothing.
380
470
  openExternal(url) {
471
+ if (linkOpened !== null && sameUrl(url, linkOpened)) return;
381
472
  if (origin) post({ type: 'hub:openExternal', url });
382
473
  else win.open(url, '_blank', 'noopener');
383
474
  },
@@ -32,7 +32,21 @@ var ClaudeHubKeys = (() => {
32
32
  return combo.split('+').map((k) => mods[k] ?? named[k] ?? (k.length === 1 ? k.toUpperCase() : k));
33
33
  }
34
34
 
35
- return { comboOf, keyParts };
35
+ // A host claim (protocol §5.2) names a press by its physical code, or by its character when the layout
36
+ // moves the key: {ctrlKey?, altKey?, shiftKey?, metaKey?, code, key: 'Unidentified'} or {…, key, code: ''}.
37
+ // A press matches a claim when one of its two signatures is the claim's first. AltGr types a
38
+ // character, so its press matches by character only. cck's frames use the same format.
39
+ function keySigs(e) {
40
+ const mods = [e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift', e.metaKey && 'meta']
41
+ .filter(Boolean)
42
+ .join('+');
43
+ return [
44
+ e.code && !e.getModifierState?.('AltGraph') && `${mods}|c:${e.code}`,
45
+ typeof e.key === 'string' && e.key !== 'Unidentified' && `${mods}|k:${e.key.toLowerCase()}`,
46
+ ].filter(Boolean);
47
+ }
48
+
49
+ return { comboOf, keyParts, keySigs };
36
50
  })();
37
51
 
38
52
  if (typeof module === 'object' && module.exports) module.exports = ClaudeHubKeys;
@@ -20,6 +20,8 @@
20
20
  status: 'standalone',
21
21
  themes: [],
22
22
  inHub: false,
23
+ settings: null,
24
+ onSettings: () => off,
23
25
  onStatus: () => off,
24
26
  onActive: () => off,
25
27
  onThemes: () => off,
@@ -27,9 +29,10 @@
27
29
  bindTheme: () => off,
28
30
  handle() {},
29
31
  publish() {},
30
- invoke(action, params = {}) {
32
+ invoke(action, params = {}, opts = {}) {
31
33
  const target = standaloneFn(action)?.(params);
32
34
  if (!target) return Promise.resolve({ ok: false, reason: 'unhandled' });
35
+ if (opts.open === 'pane') return Promise.resolve({ ok: true, handledBy: 'standalone', url: target });
33
36
  win.open(target, '_blank', 'noopener');
34
37
  return Promise.resolve({ ok: true, handledBy: 'standalone' });
35
38
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-hub",
3
- "version": "0.2.11",
3
+ "version": "0.2.12",
4
4
  "description": "Claude Code Hub integration: a skill to build on and extend the hub, with links to its docs, the extension mechanism and custom themes",
5
5
  "author": {
6
6
  "name": "NikiforovAll"
@@ -70,6 +70,8 @@ The page loads `/vendor/claude-hub-sdk.js` as the first element in `<body>`, a c
70
70
 
71
71
  The SDK writes the hub's color variables (`--accent`, `--bg-surface`, …) on `document.body`, so the app's CSS reads them and follows every hub theme, the user's own themes too. `bindTheme` keeps light/dark and the color theme in step. `onActive` tells a hidden tab to pause polling. The SDK forwards the hub's keys by itself; an element that eats keys first, such as a terminal, asks `forwards(e)`. An app that uses a `Ctrl+Alt` combo of its own lists it in `keys.keeps` in `hub-app.json`, for example `{"keeps": ["ctrl+alt+n"]}`, so the hub does not bind it, and the user cannot give it to a hub action in `config.json` `keys`. The user can change the hub's own keys there, so an app takes them from `welcome.forward`, never from a fixed list, and a help dialog shows each hub key with `hub.keyLabel(action)`, for example `hub.keyLabel('hub.projectPicker')` → `['Ctrl', 'Alt', 'P']`. Page: `extensibility/reference/manifest/#kept-keys`.
72
72
 
73
+ An app that has user preferences lists them in `settings` of `hub-app.json` (`boolean` or `choice`, each with `key`, `label` and `default`). The hub shows them in its settings modal (`Ctrl+,`), stores them in `config.json` once for every config dir, and sends them to the page: read `hub.settings`, or `hub.onSettings(fn)` for the values at welcome and on each change. Alone, the app uses its own defaults. Page: `extensibility/reference/manifest/#settings`.
74
+
73
75
  ## Connect to other tools
74
76
 
75
77
  Apps never talk to each other directly. Each page talks to the hub page through the SDK, and the hub passes the message on. There is no server-to-server channel. Page: `extensibility/reference/connect/`.
package/public/app.js CHANGED
@@ -549,7 +549,7 @@ function onIframeLoad(appId) {
549
549
  if (appId === activeApp) hideLoading();
550
550
  }
551
551
 
552
- function welcomeFor(data) {
552
+ function welcomeFor(data, appId) {
553
553
  const common = Array.isArray(data.protocol) ? HUB_PROTOCOLS.filter((v) => data.protocol.includes(v)) : [];
554
554
  if (!common.length) return null;
555
555
  return {
@@ -559,13 +559,25 @@ function welcomeFor(data) {
559
559
  keys: actionKeys(),
560
560
  themes: hubThemes,
561
561
  actions: Object.keys(actions),
562
+ origins: Object.keys(apps).map(originOf),
563
+ settings: apps[appId]?.settings ?? {},
562
564
  };
563
565
  }
564
566
 
567
+ // Protocol §12.2. An app's settings go to its tab and the pages nested in it, no other app.
568
+ function sendSettings(appId, list) {
569
+ if (!apps[appId]) return;
570
+ const settings = Object.fromEntries(list.map((s) => [s.key, s.value]));
571
+ apps[appId].settings = settings;
572
+ const message = { type: 'hub:settings', settings };
573
+ if (liveApps.has(appId)) postTo(appId, message);
574
+ for (const [win, nested] of nestedApps) if (nested.appId === appId) postNested(win, message);
575
+ }
576
+
565
577
  const helloTopics = (data) => new Set(Array.isArray(data.subscribes) ? data.subscribes : []);
566
578
 
567
579
  function onHello(appId, data) {
568
- const welcome = welcomeFor(data);
580
+ const welcome = welcomeFor(data, appId);
569
581
  if (!welcome) return;
570
582
  liveApps.set(appId, helloTopics(data));
571
583
  postTo(appId, welcome);
@@ -578,10 +590,13 @@ function onHello(appId, data) {
578
590
  }
579
591
 
580
592
  // A url such as '@evil.example/' turns the app host into userinfo, and the token rides in the fragment.
593
+ function srcOnOrigin(appId, src) {
594
+ return URL.parse(src)?.origin === originOf(appId) ? src : null;
595
+ }
596
+
581
597
  function navigateApp(appId, url) {
582
- const src = appSrc(appId, url);
583
- if (!URL.canParse(src) || new URL(src).origin !== originOf(appId)) return;
584
- loadApp(appId, src, { link: true });
598
+ const src = srcOnOrigin(appId, appSrc(appId, url));
599
+ if (src) loadApp(appId, src, { link: true });
585
600
  }
586
601
 
587
602
  function isPlainObject(v) {
@@ -603,20 +618,22 @@ function fillUrl(template, params) {
603
618
  }
604
619
 
605
620
  // Protocol §7. The result does not wait for the handler: the hub queues nothing.
606
- function onInvoke({ id, action, params = {} }, send) {
621
+ function onInvoke({ id, action, params = {}, open }, send) {
607
622
  if (typeof id !== 'string') return;
608
623
  const reply = (result) => send({ type: 'hub:result', id, ...result });
609
624
  const spec = typeof action === 'string' && Object.hasOwn(actions, action) ? actions[action] : null;
610
625
  if (!spec) return reply({ ok: false, reason: 'unhandled' });
611
626
  if (badParams(spec.params, params)) return reply({ ok: false, reason: 'bad-params' });
612
627
  const { app: handler, mode, url: template } = spec;
628
+ const url = template && fillUrl(template, params);
629
+ const paneSrc = open === 'pane' && url && srcOnOrigin(handler, apps[handler].url + url);
630
+ if (paneSrc) return reply({ ok: true, handledBy: handler, url: paneSrc });
613
631
  switchTab(handler);
614
632
  if (mode === 'message' && liveApps.has(handler)) {
615
633
  postTo(handler, { type: 'hub:action', id, action, params });
616
- } else {
634
+ } else if (url && !guardedApps.has(handler)) {
617
635
  // Setting src reloads the app, so a guarded app only gets the switch.
618
- const url = template && fillUrl(template, params);
619
- if (url && !guardedApps.has(handler)) navigateApp(handler, url);
636
+ navigateApp(handler, url);
620
637
  }
621
638
  reply({ ok: true, handledBy: handler });
622
639
  }
@@ -668,7 +685,7 @@ function onNestedMessage(e) {
668
685
  const appId = data.type === 'hub:hello' && underTab(e.source) ? appIdByOrigin(e.origin) : known?.appId;
669
686
  if (!appId || e.origin !== originOf(appId)) return;
670
687
  if (data.type === 'hub:hello') {
671
- const welcome = welcomeFor(data);
688
+ const welcome = welcomeFor(data, appId);
672
689
  if (!welcome) return;
673
690
  const topics = helloTopics(data);
674
691
  nestedApps.set(e.source, { appId, topics });
@@ -1336,6 +1353,8 @@ async function saveSetting(patch) {
1336
1353
  settingsModal.data = await sendJson('POST', '/api/settings', patch);
1337
1354
  settingsModal.error = null;
1338
1355
  configDirSwitch = settingsModal.data.configDirSwitch;
1356
+ const appId = patch.app?.settings && patch.app.id;
1357
+ if (appId) sendSettings(appId, settingsModal.data.apps.find((a) => a.id === appId)?.settings ?? []);
1339
1358
  } catch (err) {
1340
1359
  settingsModal.error = err.message;
1341
1360
  } finally {
@@ -1462,7 +1481,7 @@ function renderSettings() {
1462
1481
  '“Launcher only” keeps the app running but gives it no tab or number key; open it from the app launcher. “On demand” starts it the first time you open it.',
1463
1482
  ),
1464
1483
  );
1465
- body.replaceChildren(dirs, appsSec);
1484
+ body.replaceChildren(dirs, appsSec, ...s.apps.filter((a) => a.settings?.length).map(appSettingsSection));
1466
1485
 
1467
1486
  let status = el('span', '', 'Saved as you change it');
1468
1487
  if (settingsModal.error) status = el('span', 'set-error', settingsModal.error);
@@ -1483,6 +1502,26 @@ function renderSettings() {
1483
1502
  }
1484
1503
  }
1485
1504
 
1505
+ const BOOLEAN_CHOICES = [
1506
+ [false, 'Off'],
1507
+ [true, 'On'],
1508
+ ];
1509
+
1510
+ // The settings an app lists in its manifest. They apply at once, with no restart.
1511
+ function appSettingsSection(a) {
1512
+ const sec = el('section', 'set-sec');
1513
+ sec.append(el('h4', '', a.name));
1514
+ for (const setting of a.settings) {
1515
+ const choices = setting.type === 'boolean' ? BOOLEAN_CHOICES : setting.options.map((o) => [o.value, o.label]);
1516
+ const seg = segmented(setting.label, choices, setting.value, (v) =>
1517
+ saveSetting({ app: { id: a.id, settings: { [setting.key]: v } } }),
1518
+ );
1519
+ seg.dataset.focus = `app:${a.id}:${setting.key}`;
1520
+ sec.append(settingRow(setting.label, seg, setting.help));
1521
+ }
1522
+ return sec;
1523
+ }
1524
+
1486
1525
  function kbd(text) {
1487
1526
  return el('kbd', '', text);
1488
1527
  }
package/server.js CHANGED
@@ -30,6 +30,7 @@ const {
30
30
  poolSize,
31
31
  copyLive,
32
32
  MAX_POOL_SIZE,
33
+ settingValues,
33
34
  } = require('./lib/settings');
34
35
  const { version: HUB_VERSION } = require('./package.json');
35
36
 
@@ -929,6 +930,7 @@ function appsConfig() {
929
930
  publishes: a.publishes,
930
931
  };
931
932
  if (a.hidden) entry.hidden = true;
933
+ if (a.settings.length) entry.settings = appValues.get(a.id);
932
934
  if (TERMINAL_TOKEN && a.id === TERMINAL_APP.id) entry.terminalToken = TERMINAL_TOKEN;
933
935
  return [a.id, entry];
934
936
  }),
@@ -1066,14 +1068,34 @@ function appName(a) {
1066
1068
  return appNames.get(a.id);
1067
1069
  }
1068
1070
 
1071
+ // Only a running app has its manifest loaded, so only it lists settings.
1072
+ const SETTING_SCHEMAS = Object.fromEntries(
1073
+ ENABLED_APPS.filter((a) => a.settings.length).map((a) => [a.id, a.settings]),
1074
+ );
1075
+ const savedEntry = (saved, id) => (Array.isArray(saved.apps) ? saved.apps.find((e) => e?.id === id) : undefined);
1076
+ // App settings apply at once, so the hub keeps their values here and in each page's welcome.
1077
+ const appValues = new Map();
1078
+ function loadAppValues(saved) {
1079
+ for (const [id, schema] of Object.entries(SETTING_SCHEMAS))
1080
+ appValues.set(id, settingValues(schema, savedEntry(saved, id)?.settings));
1081
+ }
1082
+ loadAppValues(hubConfig);
1083
+
1069
1084
  function settingsView(saved) {
1070
1085
  const apps = appSettings(Array.isArray(saved.apps) ? saved.apps : [], CONFIG_BASE);
1086
+ const settingsOf = (id) => SETTING_SCHEMAS[id].map((s) => ({ ...s, value: appValues.get(id)[s.key] }));
1071
1087
  return {
1072
1088
  configDirSwitch: configDirSwitch(hubConfig),
1073
1089
  poolSize: currentPoolSize(),
1074
1090
  poolSizeFlag: POOL_SIZE_FLAG !== null,
1075
1091
  maxPoolSize: MAX_POOL_SIZE,
1076
- apps: apps.map((a) => ({ id: a.id, state: a.state, deferredStart: a.deferredStart, name: appName(a) })),
1092
+ apps: apps.map((a) => ({
1093
+ id: a.id,
1094
+ state: a.state,
1095
+ deferredStart: a.deferredStart,
1096
+ name: appName(a),
1097
+ ...(SETTING_SCHEMAS[a.id] && { settings: settingsOf(a.id) }),
1098
+ })),
1077
1099
  restartNeeded: appsShape(apps) !== RUNNING_APPS,
1078
1100
  };
1079
1101
  }
@@ -1102,13 +1124,14 @@ app.post('/api/settings', (req, res) => {
1102
1124
  }
1103
1125
  const saved = readSettingsConfig(res);
1104
1126
  if (!saved) return;
1105
- const r = applySettings(saved, req.body, CONFIG_BASE);
1127
+ const r = applySettings(saved, req.body, CONFIG_BASE, SETTING_SCHEMAS);
1106
1128
  if (r.error) {
1107
1129
  res.status(400).json({ error: r.error });
1108
1130
  return;
1109
1131
  }
1110
1132
  writeHubConfig(r.config);
1111
1133
  copyLive(r.config, hubConfig);
1134
+ loadAppValues(r.config);
1112
1135
  res.json(settingsView(r.config));
1113
1136
  });
1114
1137