claude-code-hub 1.22.0 → 1.24.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 +39 -3
- package/lib/settings.js +40 -4
- package/lib/themes.json +61 -61
- package/package.json +5 -5
- package/packages/claude-hub-sdk/src/client.js +65 -16
- package/packages/claude-hub-sdk/src/keys.js +15 -1
- package/packages/claude-hub-sdk/src/stub.js +4 -1
- package/plugin/plugins/claude-code-hub/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-hub/skills/hub-builder/references/extensibility.md +2 -0
- package/public/app.js +49 -11
- package/server.js +25 -2
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 || (
|
|
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
|
-
|
|
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/lib/themes.json
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"ink1": "#f0f1f3",
|
|
15
15
|
"ink2": "#c2c4c9",
|
|
16
16
|
"ink3": "#9a9da5",
|
|
17
|
-
"inkMuted": "#
|
|
17
|
+
"inkMuted": "#858891"
|
|
18
18
|
},
|
|
19
19
|
"light": {
|
|
20
20
|
"ember": "#e86f33",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"ink1": "#0a0a0a",
|
|
29
29
|
"ink2": "#2c2c2c",
|
|
30
30
|
"ink3": "#565656",
|
|
31
|
-
"inkMuted": "#
|
|
31
|
+
"inkMuted": "#666666"
|
|
32
32
|
}
|
|
33
33
|
},
|
|
34
34
|
{
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"ink1": "#ebdbb2",
|
|
47
47
|
"ink2": "#d5c4a1",
|
|
48
48
|
"ink3": "#bdae93",
|
|
49
|
-
"inkMuted": "#
|
|
49
|
+
"inkMuted": "#a69782"
|
|
50
50
|
},
|
|
51
51
|
"light": {
|
|
52
52
|
"ember": "#d65d0e",
|
|
@@ -59,8 +59,8 @@
|
|
|
59
59
|
"border": "#c9bc9d",
|
|
60
60
|
"ink1": "#3c3836",
|
|
61
61
|
"ink2": "#504945",
|
|
62
|
-
"ink3": "#
|
|
63
|
-
"inkMuted": "#
|
|
62
|
+
"ink3": "#5f564f",
|
|
63
|
+
"inkMuted": "#70645b"
|
|
64
64
|
}
|
|
65
65
|
},
|
|
66
66
|
{
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
"ink1": "#cdd6f4",
|
|
79
79
|
"ink2": "#bac2de",
|
|
80
80
|
"ink3": "#a6adc8",
|
|
81
|
-
"inkMuted": "#
|
|
81
|
+
"inkMuted": "#82879f"
|
|
82
82
|
},
|
|
83
83
|
"light": {
|
|
84
84
|
"ember": "#8839ef",
|
|
@@ -91,8 +91,8 @@
|
|
|
91
91
|
"border": "#bcc0cc",
|
|
92
92
|
"ink1": "#4c4f69",
|
|
93
93
|
"ink2": "#5c5f77",
|
|
94
|
-
"ink3": "#
|
|
95
|
-
"inkMuted": "#
|
|
94
|
+
"ink3": "#545770",
|
|
95
|
+
"inkMuted": "#63667d"
|
|
96
96
|
}
|
|
97
97
|
},
|
|
98
98
|
{
|
|
@@ -109,8 +109,8 @@
|
|
|
109
109
|
"border": "#3b4261",
|
|
110
110
|
"ink1": "#c0caf5",
|
|
111
111
|
"ink2": "#a9b1d6",
|
|
112
|
-
"ink3": "#
|
|
113
|
-
"inkMuted": "#
|
|
112
|
+
"ink3": "#959cbe",
|
|
113
|
+
"inkMuted": "#838aaf"
|
|
114
114
|
},
|
|
115
115
|
"light": {
|
|
116
116
|
"ember": "#2e7de9",
|
|
@@ -123,8 +123,8 @@
|
|
|
123
123
|
"border": "#a8aecb",
|
|
124
124
|
"ink1": "#343b58",
|
|
125
125
|
"ink2": "#565a6e",
|
|
126
|
-
"ink3": "#
|
|
127
|
-
"inkMuted": "#
|
|
126
|
+
"ink3": "#505466",
|
|
127
|
+
"inkMuted": "#5d627a"
|
|
128
128
|
}
|
|
129
129
|
},
|
|
130
130
|
{
|
|
@@ -141,8 +141,8 @@
|
|
|
141
141
|
"border": "#586e75",
|
|
142
142
|
"ink1": "#e2dfd0",
|
|
143
143
|
"ink2": "#b7c0bf",
|
|
144
|
-
"ink3": "#
|
|
145
|
-
"inkMuted": "#
|
|
144
|
+
"ink3": "#b7beb7",
|
|
145
|
+
"inkMuted": "#9faaa7"
|
|
146
146
|
},
|
|
147
147
|
"light": {
|
|
148
148
|
"ember": "#2280c2",
|
|
@@ -157,8 +157,8 @@
|
|
|
157
157
|
"border": "#cdc4ab",
|
|
158
158
|
"ink1": "#073642",
|
|
159
159
|
"ink2": "#4f6369",
|
|
160
|
-
"ink3": "#
|
|
161
|
-
"inkMuted": "#
|
|
160
|
+
"ink3": "#385861",
|
|
161
|
+
"inkMuted": "#4a666e"
|
|
162
162
|
}
|
|
163
163
|
},
|
|
164
164
|
{
|
|
@@ -175,8 +175,8 @@
|
|
|
175
175
|
"border": "#565a75",
|
|
176
176
|
"ink1": "#f8f8f2",
|
|
177
177
|
"ink2": "#d8d8d2",
|
|
178
|
-
"ink3": "#
|
|
179
|
-
"inkMuted": "#
|
|
178
|
+
"ink3": "#b2b5c6",
|
|
179
|
+
"inkMuted": "#9aa1bc"
|
|
180
180
|
},
|
|
181
181
|
"light": {
|
|
182
182
|
"ember": "#7c3aed",
|
|
@@ -189,8 +189,8 @@
|
|
|
189
189
|
"border": "#c9c9d6",
|
|
190
190
|
"ink1": "#16161e",
|
|
191
191
|
"ink2": "#34343f",
|
|
192
|
-
"ink3": "#
|
|
193
|
-
"inkMuted": "#
|
|
192
|
+
"ink3": "#575765",
|
|
193
|
+
"inkMuted": "#666674"
|
|
194
194
|
}
|
|
195
195
|
},
|
|
196
196
|
{
|
|
@@ -207,8 +207,8 @@
|
|
|
207
207
|
"border": "#4c566a",
|
|
208
208
|
"ink1": "#eceff4",
|
|
209
209
|
"ink2": "#d8dee9",
|
|
210
|
-
"ink3": "#
|
|
211
|
-
"inkMuted": "#
|
|
210
|
+
"ink3": "#bec4d1",
|
|
211
|
+
"inkMuted": "#a7b0c1"
|
|
212
212
|
},
|
|
213
213
|
"light": {
|
|
214
214
|
"ember": "#5e81ac",
|
|
@@ -222,7 +222,7 @@
|
|
|
222
222
|
"ink1": "#2e3440",
|
|
223
223
|
"ink2": "#3b4252",
|
|
224
224
|
"ink3": "#4c566a",
|
|
225
|
-
"inkMuted": "#
|
|
225
|
+
"inkMuted": "#5e687c"
|
|
226
226
|
}
|
|
227
227
|
},
|
|
228
228
|
{
|
|
@@ -239,8 +239,8 @@
|
|
|
239
239
|
"border": "#403d52",
|
|
240
240
|
"ink1": "#e0def4",
|
|
241
241
|
"ink2": "#c5c2dd",
|
|
242
|
-
"ink3": "#
|
|
243
|
-
"inkMuted": "#
|
|
242
|
+
"ink3": "#a09db9",
|
|
243
|
+
"inkMuted": "#8e8ba7"
|
|
244
244
|
},
|
|
245
245
|
"light": {
|
|
246
246
|
"ember": "#907aa9",
|
|
@@ -253,8 +253,8 @@
|
|
|
253
253
|
"border": "#cecacd",
|
|
254
254
|
"ink1": "#575279",
|
|
255
255
|
"ink2": "#635e87",
|
|
256
|
-
"ink3": "#
|
|
257
|
-
"inkMuted": "#
|
|
256
|
+
"ink3": "#5a557b",
|
|
257
|
+
"inkMuted": "#6a6485"
|
|
258
258
|
}
|
|
259
259
|
},
|
|
260
260
|
{
|
|
@@ -271,8 +271,8 @@
|
|
|
271
271
|
"border": "#475258",
|
|
272
272
|
"ink1": "#d3c6aa",
|
|
273
273
|
"ink2": "#bfb6a3",
|
|
274
|
-
"ink3": "#
|
|
275
|
-
"inkMuted": "#
|
|
274
|
+
"ink3": "#c3bea7",
|
|
275
|
+
"inkMuted": "#a8ab9a"
|
|
276
276
|
},
|
|
277
277
|
"light": {
|
|
278
278
|
"ember": "#6f8352",
|
|
@@ -285,8 +285,8 @@
|
|
|
285
285
|
"border": "#d3d2bf",
|
|
286
286
|
"ink1": "#3d484e",
|
|
287
287
|
"ink2": "#55636b",
|
|
288
|
-
"ink3": "#
|
|
289
|
-
"inkMuted": "#
|
|
288
|
+
"ink3": "#515b56",
|
|
289
|
+
"inkMuted": "#606a62"
|
|
290
290
|
}
|
|
291
291
|
},
|
|
292
292
|
{
|
|
@@ -304,7 +304,7 @@
|
|
|
304
304
|
"ink1": "#dcd7ba",
|
|
305
305
|
"ink2": "#c8c093",
|
|
306
306
|
"ink3": "#a6a69c",
|
|
307
|
-
"inkMuted": "#
|
|
307
|
+
"inkMuted": "#94948a"
|
|
308
308
|
},
|
|
309
309
|
"light": {
|
|
310
310
|
"ember": "#4d699b",
|
|
@@ -317,8 +317,8 @@
|
|
|
317
317
|
"border": "#d2ccb6",
|
|
318
318
|
"ink1": "#545464",
|
|
319
319
|
"ink2": "#66667a",
|
|
320
|
-
"ink3": "#
|
|
321
|
-
"inkMuted": "#
|
|
320
|
+
"ink3": "#565564",
|
|
321
|
+
"inkMuted": "#666469"
|
|
322
322
|
}
|
|
323
323
|
},
|
|
324
324
|
{
|
|
@@ -335,8 +335,8 @@
|
|
|
335
335
|
"border": "#4b5263",
|
|
336
336
|
"ink1": "#d7dae0",
|
|
337
337
|
"ink2": "#abb2bf",
|
|
338
|
-
"ink3": "#
|
|
339
|
-
"inkMuted": "#
|
|
338
|
+
"ink3": "#acb0ba",
|
|
339
|
+
"inkMuted": "#999ea8"
|
|
340
340
|
},
|
|
341
341
|
"light": {
|
|
342
342
|
"ember": "#4078f2",
|
|
@@ -349,8 +349,8 @@
|
|
|
349
349
|
"border": "#d4d4d6",
|
|
350
350
|
"ink1": "#383a42",
|
|
351
351
|
"ink2": "#50525a",
|
|
352
|
-
"ink3": "#
|
|
353
|
-
"inkMuted": "#
|
|
352
|
+
"ink3": "#575963",
|
|
353
|
+
"inkMuted": "#666871"
|
|
354
354
|
}
|
|
355
355
|
},
|
|
356
356
|
{
|
|
@@ -367,8 +367,8 @@
|
|
|
367
367
|
"border": "#5f7e97",
|
|
368
368
|
"ink1": "#d6deeb",
|
|
369
369
|
"ink2": "#b8c5d6",
|
|
370
|
-
"ink3": "#
|
|
371
|
-
"inkMuted": "#
|
|
370
|
+
"ink3": "#9bb0c3",
|
|
371
|
+
"inkMuted": "#899eab"
|
|
372
372
|
},
|
|
373
373
|
"light": {
|
|
374
374
|
"ember": "#0c969b",
|
|
@@ -381,8 +381,8 @@
|
|
|
381
381
|
"border": "#d0d0d0",
|
|
382
382
|
"ink1": "#403f53",
|
|
383
383
|
"ink2": "#545167",
|
|
384
|
-
"ink3": "#
|
|
385
|
-
"inkMuted": "#
|
|
384
|
+
"ink3": "#5a596f",
|
|
385
|
+
"inkMuted": "#68687e"
|
|
386
386
|
}
|
|
387
387
|
},
|
|
388
388
|
{
|
|
@@ -399,8 +399,8 @@
|
|
|
399
399
|
"border": "#5b595c",
|
|
400
400
|
"ink1": "#fcfcfa",
|
|
401
401
|
"ink2": "#d9d8d6",
|
|
402
|
-
"ink3": "#
|
|
403
|
-
"inkMuted": "#
|
|
402
|
+
"ink3": "#bfbfbe",
|
|
403
|
+
"inkMuted": "#ababab"
|
|
404
404
|
},
|
|
405
405
|
"light": {
|
|
406
406
|
"ember": "#c08a00",
|
|
@@ -413,8 +413,8 @@
|
|
|
413
413
|
"border": "#cdc8c5",
|
|
414
414
|
"ink1": "#2c292d",
|
|
415
415
|
"ink2": "#46434a",
|
|
416
|
-
"ink3": "#
|
|
417
|
-
"inkMuted": "#
|
|
416
|
+
"ink3": "#59565b",
|
|
417
|
+
"inkMuted": "#68656a"
|
|
418
418
|
}
|
|
419
419
|
},
|
|
420
420
|
{
|
|
@@ -431,8 +431,8 @@
|
|
|
431
431
|
"border": "#444d56",
|
|
432
432
|
"ink1": "#e1e4e8",
|
|
433
433
|
"ink2": "#d1d5da",
|
|
434
|
-
"ink3": "#
|
|
435
|
-
"inkMuted": "#
|
|
434
|
+
"ink3": "#a6adb4",
|
|
435
|
+
"inkMuted": "#929aa2"
|
|
436
436
|
},
|
|
437
437
|
"light": {
|
|
438
438
|
"ember": "#0366d6",
|
|
@@ -445,8 +445,8 @@
|
|
|
445
445
|
"border": "#d1d5da",
|
|
446
446
|
"ink1": "#24292e",
|
|
447
447
|
"ink2": "#444d56",
|
|
448
|
-
"ink3": "#
|
|
449
|
-
"inkMuted": "#
|
|
448
|
+
"ink3": "#575f68",
|
|
449
|
+
"inkMuted": "#666e78"
|
|
450
450
|
}
|
|
451
451
|
},
|
|
452
452
|
{
|
|
@@ -463,8 +463,8 @@
|
|
|
463
463
|
"border": "#2d3343",
|
|
464
464
|
"ink1": "#bfbdb6",
|
|
465
465
|
"ink2": "#a8a6a0",
|
|
466
|
-
"ink3": "#
|
|
467
|
-
"inkMuted": "#
|
|
466
|
+
"ink3": "#8d949b",
|
|
467
|
+
"inkMuted": "#7c838d"
|
|
468
468
|
},
|
|
469
469
|
"light": {
|
|
470
470
|
"ember": "#c97612",
|
|
@@ -477,8 +477,8 @@
|
|
|
477
477
|
"border": "#d8dde2",
|
|
478
478
|
"ink1": "#3d4149",
|
|
479
479
|
"ink2": "#5c6166",
|
|
480
|
-
"ink3": "#
|
|
481
|
-
"inkMuted": "#
|
|
480
|
+
"ink3": "#5a5e64",
|
|
481
|
+
"inkMuted": "#686d74"
|
|
482
482
|
}
|
|
483
483
|
},
|
|
484
484
|
{
|
|
@@ -495,8 +495,8 @@
|
|
|
495
495
|
"border": "#333333",
|
|
496
496
|
"ink1": "#dbd7ca",
|
|
497
497
|
"ink2": "#bfbaaa",
|
|
498
|
-
"ink3": "#
|
|
499
|
-
"inkMuted": "#
|
|
498
|
+
"ink3": "#99998e",
|
|
499
|
+
"inkMuted": "#7e8a7b"
|
|
500
500
|
},
|
|
501
501
|
"light": {
|
|
502
502
|
"ember": "#1c6b48",
|
|
@@ -509,8 +509,8 @@
|
|
|
509
509
|
"border": "#d6d6d6",
|
|
510
510
|
"ink1": "#393a34",
|
|
511
511
|
"ink2": "#4e4f47",
|
|
512
|
-
"ink3": "#
|
|
513
|
-
"inkMuted": "#
|
|
512
|
+
"ink3": "#5d5e56",
|
|
513
|
+
"inkMuted": "#6a6d64"
|
|
514
514
|
}
|
|
515
515
|
},
|
|
516
516
|
{
|
|
@@ -528,7 +528,7 @@
|
|
|
528
528
|
"ink1": "#ffffff",
|
|
529
529
|
"ink2": "#d8d6e8",
|
|
530
530
|
"ink3": "#b6b1d8",
|
|
531
|
-
"inkMuted": "#
|
|
531
|
+
"inkMuted": "#8f94c3"
|
|
532
532
|
},
|
|
533
533
|
"light": {
|
|
534
534
|
"ember": "#c936a6",
|
|
@@ -541,8 +541,8 @@
|
|
|
541
541
|
"border": "#c5bcdd",
|
|
542
542
|
"ink1": "#241b2f",
|
|
543
543
|
"ink2": "#3d3455",
|
|
544
|
-
"ink3": "#
|
|
545
|
-
"inkMuted": "#
|
|
544
|
+
"ink3": "#59507c",
|
|
545
|
+
"inkMuted": "#675f8b"
|
|
546
546
|
}
|
|
547
547
|
}
|
|
548
548
|
]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-hub",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.24.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.
|
|
50
|
-
"claude-code-kanban": "7.
|
|
51
|
-
"claude-code-marketplace": "1.
|
|
52
|
-
"claude-code-memory-explorer": "1.3.
|
|
49
|
+
"claude-code-cost": "1.6.0",
|
|
50
|
+
"claude-code-kanban": "7.21.0",
|
|
51
|
+
"claude-code-marketplace": "1.7.1",
|
|
52
|
+
"claude-code-memory-explorer": "1.3.4",
|
|
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();
|
|
@@ -101,6 +105,7 @@
|
|
|
101
105
|
let linkOpened = null;
|
|
102
106
|
let themeBinding = null;
|
|
103
107
|
let lastTheme = null;
|
|
108
|
+
let settings = null;
|
|
104
109
|
let swatchesPainted = false;
|
|
105
110
|
let nextId = 0;
|
|
106
111
|
|
|
@@ -172,6 +177,11 @@
|
|
|
172
177
|
return welcome.themes.map(({ id, label }) => ({ id, label }));
|
|
173
178
|
}
|
|
174
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
|
+
|
|
175
185
|
function onWelcome(m) {
|
|
176
186
|
if (m.protocol !== 1 || welcome) return;
|
|
177
187
|
welcome = {
|
|
@@ -181,6 +191,7 @@
|
|
|
181
191
|
};
|
|
182
192
|
forward = strings(m.forward);
|
|
183
193
|
appOrigins = strings(m.origins);
|
|
194
|
+
setSettings(m.settings);
|
|
184
195
|
setStatus('live');
|
|
185
196
|
if (welcome.themes.length) for (const fn of themesFns) fn(pickerThemes());
|
|
186
197
|
themesFns.clear();
|
|
@@ -216,6 +227,9 @@
|
|
|
216
227
|
case 'hub:terminalToken':
|
|
217
228
|
for (const done of [...tokenWaiters]) done(typeof m.token === 'string' ? m.token : null);
|
|
218
229
|
return;
|
|
230
|
+
case 'hub:settings':
|
|
231
|
+
if (welcome) setSettings(m.settings);
|
|
232
|
+
return;
|
|
219
233
|
}
|
|
220
234
|
}
|
|
221
235
|
|
|
@@ -223,28 +237,50 @@
|
|
|
223
237
|
return !!forward && forward.has(comboOf(e));
|
|
224
238
|
}
|
|
225
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
|
+
|
|
226
263
|
function onKeydown(e) {
|
|
227
|
-
if (
|
|
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;
|
|
228
270
|
e.preventDefault();
|
|
229
|
-
|
|
230
|
-
type: 'hub:keydown',
|
|
231
|
-
key: e.key,
|
|
232
|
-
code: e.code,
|
|
233
|
-
ctrl: e.ctrlKey,
|
|
234
|
-
alt: e.altKey,
|
|
235
|
-
shift: e.shiftKey,
|
|
236
|
-
meta: e.metaKey,
|
|
237
|
-
});
|
|
271
|
+
win.parent.postMessage(keyMessage('hub:hostKey', e), hostOrigin);
|
|
238
272
|
}
|
|
239
273
|
|
|
240
|
-
function run(action, params, resolve) {
|
|
274
|
+
function run(action, params, opts, resolve) {
|
|
275
|
+
const pane = opts?.open === 'pane';
|
|
241
276
|
if (status === 'live') {
|
|
242
277
|
const id = String(++nextId);
|
|
243
278
|
pending.set(id, resolve);
|
|
244
|
-
return post({ type: 'hub:invoke', id, action, params });
|
|
279
|
+
return post({ type: 'hub:invoke', id, action, params, ...(pane && { open: 'pane' }) });
|
|
245
280
|
}
|
|
246
281
|
const target = standaloneFn(action)?.(params);
|
|
247
282
|
if (!target) return resolve({ ok: false, reason: 'unhandled' });
|
|
283
|
+
if (pane) return resolve({ ok: true, handledBy: 'standalone', url: target });
|
|
248
284
|
win.open(target, '_blank', 'noopener');
|
|
249
285
|
resolve({ ok: true, handledBy: 'standalone' });
|
|
250
286
|
}
|
|
@@ -286,6 +322,7 @@
|
|
|
286
322
|
}
|
|
287
323
|
|
|
288
324
|
win.addEventListener('message', onMessage);
|
|
325
|
+
win.addEventListener('message', onHostMessage);
|
|
289
326
|
doc.addEventListener('keydown', onKeydown);
|
|
290
327
|
win.addEventListener('click', onLinkClick, true);
|
|
291
328
|
win.addEventListener('auxclick', onLinkClick, true);
|
|
@@ -357,6 +394,18 @@
|
|
|
357
394
|
if (welcome.themes.length) fn(pickerThemes());
|
|
358
395
|
return () => {};
|
|
359
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
|
+
},
|
|
360
409
|
subscribe(topic, fn) {
|
|
361
410
|
const fns = topicFns(topic);
|
|
362
411
|
fns.add(fn);
|
|
@@ -385,10 +434,10 @@
|
|
|
385
434
|
if (status === 'live') post({ type: 'hub:publish', topic, payload });
|
|
386
435
|
else if (waiting()) outbox.set(topic, payload);
|
|
387
436
|
},
|
|
388
|
-
invoke(action, params = {}) {
|
|
437
|
+
invoke(action, params = {}, opts = {}) {
|
|
389
438
|
return new Promise((resolve) => {
|
|
390
|
-
if (waiting()) queued.push(() => run(action, params, resolve));
|
|
391
|
-
else run(action, params, resolve);
|
|
439
|
+
if (waiting()) queued.push(() => run(action, params, opts, resolve));
|
|
440
|
+
else run(action, params, opts, resolve);
|
|
392
441
|
});
|
|
393
442
|
},
|
|
394
443
|
can(action) {
|
|
@@ -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
|
-
|
|
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.
|
|
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 {
|
|
@@ -560,13 +560,24 @@ function welcomeFor(data) {
|
|
|
560
560
|
themes: hubThemes,
|
|
561
561
|
actions: Object.keys(actions),
|
|
562
562
|
origins: Object.keys(apps).map(originOf),
|
|
563
|
+
settings: apps[appId]?.settings ?? {},
|
|
563
564
|
};
|
|
564
565
|
}
|
|
565
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
|
+
|
|
566
577
|
const helloTopics = (data) => new Set(Array.isArray(data.subscribes) ? data.subscribes : []);
|
|
567
578
|
|
|
568
579
|
function onHello(appId, data) {
|
|
569
|
-
const welcome = welcomeFor(data);
|
|
580
|
+
const welcome = welcomeFor(data, appId);
|
|
570
581
|
if (!welcome) return;
|
|
571
582
|
liveApps.set(appId, helloTopics(data));
|
|
572
583
|
postTo(appId, welcome);
|
|
@@ -579,10 +590,13 @@ function onHello(appId, data) {
|
|
|
579
590
|
}
|
|
580
591
|
|
|
581
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
|
+
|
|
582
597
|
function navigateApp(appId, url) {
|
|
583
|
-
const src = appSrc(appId, url);
|
|
584
|
-
if (
|
|
585
|
-
loadApp(appId, src, { link: true });
|
|
598
|
+
const src = srcOnOrigin(appId, appSrc(appId, url));
|
|
599
|
+
if (src) loadApp(appId, src, { link: true });
|
|
586
600
|
}
|
|
587
601
|
|
|
588
602
|
function isPlainObject(v) {
|
|
@@ -604,20 +618,22 @@ function fillUrl(template, params) {
|
|
|
604
618
|
}
|
|
605
619
|
|
|
606
620
|
// Protocol §7. The result does not wait for the handler: the hub queues nothing.
|
|
607
|
-
function onInvoke({ id, action, params = {} }, send) {
|
|
621
|
+
function onInvoke({ id, action, params = {}, open }, send) {
|
|
608
622
|
if (typeof id !== 'string') return;
|
|
609
623
|
const reply = (result) => send({ type: 'hub:result', id, ...result });
|
|
610
624
|
const spec = typeof action === 'string' && Object.hasOwn(actions, action) ? actions[action] : null;
|
|
611
625
|
if (!spec) return reply({ ok: false, reason: 'unhandled' });
|
|
612
626
|
if (badParams(spec.params, params)) return reply({ ok: false, reason: 'bad-params' });
|
|
613
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 });
|
|
614
631
|
switchTab(handler);
|
|
615
632
|
if (mode === 'message' && liveApps.has(handler)) {
|
|
616
633
|
postTo(handler, { type: 'hub:action', id, action, params });
|
|
617
|
-
} else {
|
|
634
|
+
} else if (url && !guardedApps.has(handler)) {
|
|
618
635
|
// Setting src reloads the app, so a guarded app only gets the switch.
|
|
619
|
-
|
|
620
|
-
if (url && !guardedApps.has(handler)) navigateApp(handler, url);
|
|
636
|
+
navigateApp(handler, url);
|
|
621
637
|
}
|
|
622
638
|
reply({ ok: true, handledBy: handler });
|
|
623
639
|
}
|
|
@@ -669,7 +685,7 @@ function onNestedMessage(e) {
|
|
|
669
685
|
const appId = data.type === 'hub:hello' && underTab(e.source) ? appIdByOrigin(e.origin) : known?.appId;
|
|
670
686
|
if (!appId || e.origin !== originOf(appId)) return;
|
|
671
687
|
if (data.type === 'hub:hello') {
|
|
672
|
-
const welcome = welcomeFor(data);
|
|
688
|
+
const welcome = welcomeFor(data, appId);
|
|
673
689
|
if (!welcome) return;
|
|
674
690
|
const topics = helloTopics(data);
|
|
675
691
|
nestedApps.set(e.source, { appId, topics });
|
|
@@ -1337,6 +1353,8 @@ async function saveSetting(patch) {
|
|
|
1337
1353
|
settingsModal.data = await sendJson('POST', '/api/settings', patch);
|
|
1338
1354
|
settingsModal.error = null;
|
|
1339
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 ?? []);
|
|
1340
1358
|
} catch (err) {
|
|
1341
1359
|
settingsModal.error = err.message;
|
|
1342
1360
|
} finally {
|
|
@@ -1463,7 +1481,7 @@ function renderSettings() {
|
|
|
1463
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.',
|
|
1464
1482
|
),
|
|
1465
1483
|
);
|
|
1466
|
-
body.replaceChildren(dirs, appsSec);
|
|
1484
|
+
body.replaceChildren(dirs, appsSec, ...s.apps.filter((a) => a.settings?.length).map(appSettingsSection));
|
|
1467
1485
|
|
|
1468
1486
|
let status = el('span', '', 'Saved as you change it');
|
|
1469
1487
|
if (settingsModal.error) status = el('span', 'set-error', settingsModal.error);
|
|
@@ -1484,6 +1502,26 @@ function renderSettings() {
|
|
|
1484
1502
|
}
|
|
1485
1503
|
}
|
|
1486
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
|
+
|
|
1487
1525
|
function kbd(text) {
|
|
1488
1526
|
return el('kbd', '', text);
|
|
1489
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) => ({
|
|
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
|
|