claude-code-hub 0.42.0 → 1.0.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 +6 -0
- package/lib/actions.js +39 -0
- package/lib/apps.js +136 -9
- package/lib/contain.js +78 -0
- package/lib/install.js +89 -0
- package/lib/kill-child.js +32 -0
- package/lib/net-guard.js +6 -1
- package/lib/ping.js +23 -0
- package/lib/themes.js +134 -0
- package/package.json +12 -9
- package/packages/claude-hub-sdk/src/client.js +371 -0
- package/packages/claude-hub-sdk/src/server.js +30 -0
- package/packages/claude-hub-sdk/src/stub.js +49 -0
- package/plugin/.claude-plugin/marketplace.json +14 -0
- package/plugin/plugins/claude-code-hub/.claude-plugin/plugin.json +8 -0
- package/plugin/plugins/claude-code-hub/skills/hub-builder/SKILL.md +30 -0
- package/plugin/plugins/claude-code-hub/skills/hub-builder/references/extensibility.md +93 -0
- package/plugin/plugins/claude-code-hub/skills/hub-builder/references/themes.md +57 -0
- package/public/app.js +382 -132
- package/public/index.html +23 -2
- package/public/locked.html +1 -1
- package/server.js +304 -115
package/public/app.js
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
let apps = {};
|
|
2
2
|
let activeApp = null;
|
|
3
|
+
// The app launcher's cursor starts here, so Ctrl+Alt+A then Enter goes back to the last tab.
|
|
4
|
+
let previousApp = null;
|
|
3
5
|
const iframes = {};
|
|
4
6
|
const loadedApps = new Set();
|
|
5
7
|
const guardedApps = new Set();
|
|
8
|
+
// App id → the topics its hub:hello subscribes to. An app is live from its hello until the next
|
|
9
|
+
// iframe load: e.source is the same WindowProxy across reloads, so a new document starts unknown.
|
|
10
|
+
const liveApps = new Map();
|
|
11
|
+
// Apps whose next document the hub loads from an action URL.
|
|
12
|
+
const linkLoads = new Set();
|
|
13
|
+
const HUB_PROTOCOLS = [1];
|
|
6
14
|
// {theme: 'dark'|'light', colorTheme: '<id>', colorThemes: {<configDir>: '<id>'}} — survives hub
|
|
7
15
|
// reloads so late-loading iframes and fresh sessions get the last chosen theme. Light/dark is
|
|
8
16
|
// global; the color theme is per config dir, and colorTheme is the last one picked anywhere, which
|
|
@@ -14,9 +22,14 @@ let activeConfigDir = null;
|
|
|
14
22
|
// hub-theme: starting unset means there is nothing to race against each sub-app's own
|
|
15
23
|
// project self-restore on boot. Once set it never returns to null.
|
|
16
24
|
let projectState = null;
|
|
25
|
+
// Topic → {from, payload}: the last value of each topic an app published. Cleared with projectState.
|
|
26
|
+
const published = new Map();
|
|
27
|
+
// App id → the published topics it missed while hidden. It gets their last value when it comes on screen.
|
|
28
|
+
const missed = new Map();
|
|
29
|
+
const MAX_EVENT_CHARS = 16 * 1024;
|
|
17
30
|
// The config dir the hub falls back to (~/.claude). Stays out of the window title.
|
|
18
31
|
let defaultConfigDir = null;
|
|
19
|
-
// mode: 'project' (Ctrl+Alt+P)
|
|
32
|
+
// mode: 'project' (Ctrl+Alt+P), 'configDir' (Ctrl+Alt+W) or 'app' (Ctrl+Alt+A). One widget, three row sources.
|
|
20
33
|
const palette = {
|
|
21
34
|
open: false,
|
|
22
35
|
mode: 'project',
|
|
@@ -25,6 +38,7 @@ const palette = {
|
|
|
25
38
|
rows: [],
|
|
26
39
|
sel: 0,
|
|
27
40
|
query: '',
|
|
41
|
+
confirmRestart: null,
|
|
28
42
|
};
|
|
29
43
|
|
|
30
44
|
function loadThemeState() {
|
|
@@ -39,8 +53,10 @@ function loadThemeState() {
|
|
|
39
53
|
}
|
|
40
54
|
|
|
41
55
|
// The color theme of the active config dir, falling back to the last one picked anywhere.
|
|
56
|
+
// An id the registry lacks shows as Ember everywhere, so the hub page and every app agree.
|
|
42
57
|
function activeColorTheme() {
|
|
43
|
-
|
|
58
|
+
const id = themeState.colorThemes[activeConfigDir] ?? themeState.colorTheme;
|
|
59
|
+
return id && hubThemes.length && !themeVars[id] ? 'ember' : id;
|
|
44
60
|
}
|
|
45
61
|
|
|
46
62
|
function osTheme() {
|
|
@@ -49,14 +65,36 @@ function osTheme() {
|
|
|
49
65
|
|
|
50
66
|
// Sub-apps disagree on their first-run default (marketplace follows the OS, the rest start dark),
|
|
51
67
|
// so an unset theme resolves to the OS preference here and reaches every iframe on load.
|
|
52
|
-
function
|
|
53
|
-
return {
|
|
68
|
+
function themePayload() {
|
|
69
|
+
return { theme: themeState.theme ?? osTheme(), colorTheme: activeColorTheme() };
|
|
54
70
|
}
|
|
55
71
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
72
|
+
function sendTheme(appId) {
|
|
73
|
+
const payload = themePayload();
|
|
74
|
+
const vars = themeVars[payload.colorTheme ?? 'ember']?.[payload.theme];
|
|
75
|
+
sendTopic(appId, 'theme.changed', vars ? { ...payload, vars } : payload);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// {<themeId>: {dark, light}}, each a map of the apps' core CSS variables, from /api/config, which
|
|
79
|
+
// derives it from lib/themes.json — the same registry that generates each sub-app's themes.css.
|
|
80
|
+
// Empty until config arrives, and empty if the registry is unreadable; then the hub keeps
|
|
81
|
+
// index.html's colors.
|
|
82
|
+
let themeVars = {};
|
|
83
|
+
// The picker list that welcome carries.
|
|
84
|
+
let hubThemes = [];
|
|
85
|
+
// {<action>: {app, params, url, mode}} from the manifests, one handler each, from /api/config.
|
|
86
|
+
let actions = {};
|
|
87
|
+
// The hub page's names for the core variables. --bg is the apps' --bg-deep, so the loading screen
|
|
88
|
+
// hands off to an iframe without a flash of another color.
|
|
89
|
+
const HUB_VARS = {
|
|
90
|
+
'--accent': '--accent',
|
|
91
|
+
'--bg': '--bg-deep',
|
|
92
|
+
'--surface': '--bg-surface',
|
|
93
|
+
'--surface-hover': '--bg-hover',
|
|
94
|
+
'--border': '--border',
|
|
95
|
+
'--text': '--text-primary',
|
|
96
|
+
'--text-dim': '--text-muted',
|
|
97
|
+
};
|
|
60
98
|
// The last applied variables, so the loading screen paints in the theme before /api/config lands.
|
|
61
99
|
const PALETTE_KEY = 'hub-palette';
|
|
62
100
|
|
|
@@ -67,7 +105,8 @@ function applyHubTheme() {
|
|
|
67
105
|
const root = document.documentElement;
|
|
68
106
|
root.classList.toggle('light', mode === 'light');
|
|
69
107
|
const key = `${activeColorTheme()}/${mode}`;
|
|
70
|
-
|
|
108
|
+
const core = themeVars[activeColorTheme()]?.[mode];
|
|
109
|
+
let vars = core && Object.fromEntries(Object.entries(HUB_VARS).map(([hub, name]) => [hub, core[name]]));
|
|
71
110
|
try {
|
|
72
111
|
if (vars) localStorage.setItem(PALETTE_KEY, JSON.stringify({ key, vars }));
|
|
73
112
|
else {
|
|
@@ -94,15 +133,6 @@ function parentOf(p) {
|
|
|
94
133
|
return i <= 0 ? '' : p.slice(0, i);
|
|
95
134
|
}
|
|
96
135
|
|
|
97
|
-
function projectMessage() {
|
|
98
|
-
return {
|
|
99
|
-
type: 'hub:project',
|
|
100
|
-
project: projectState.project,
|
|
101
|
-
encoded: projectState.encoded,
|
|
102
|
-
name: projectState.name,
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
|
|
106
136
|
function originOf(appId) {
|
|
107
137
|
return new URL(apps[appId].url).origin;
|
|
108
138
|
}
|
|
@@ -110,7 +140,7 @@ function originOf(appId) {
|
|
|
110
140
|
function postTo(appId, message) {
|
|
111
141
|
// Before the iframe commits its src its contentWindow is still on the hub's own
|
|
112
142
|
// origin, so a send addressed to the sub-app origin is refused and logged. The
|
|
113
|
-
//
|
|
143
|
+
// app's hello brings the current state anyway, so skipping here loses nothing.
|
|
114
144
|
if (!loadedApps.has(appId)) return;
|
|
115
145
|
iframes[appId]?.contentWindow?.postMessage(message, originOf(appId));
|
|
116
146
|
}
|
|
@@ -119,28 +149,62 @@ function appIdOf(source) {
|
|
|
119
149
|
return Object.keys(iframes).find((id) => iframes[id].contentWindow === source);
|
|
120
150
|
}
|
|
121
151
|
|
|
122
|
-
//
|
|
123
|
-
function
|
|
124
|
-
|
|
125
|
-
if (exceptWindow && iframe.contentWindow === exceptWindow) continue;
|
|
126
|
-
postTo(id, message);
|
|
127
|
-
}
|
|
152
|
+
// The last value lives in themeState and projectState, so a replay after welcome is a send.
|
|
153
|
+
function sendTopic(appId, topic, payload) {
|
|
154
|
+
if (liveApps.get(appId)?.has(topic)) postTo(appId, { type: 'hub:event', topic, payload });
|
|
128
155
|
}
|
|
129
156
|
|
|
130
157
|
function setProject(absPath) {
|
|
131
158
|
projectState = { project: absPath, encoded: encodeProjectPath(absPath), name: basename(absPath) };
|
|
132
|
-
|
|
159
|
+
for (const id of Object.keys(iframes)) sendProject(id);
|
|
133
160
|
}
|
|
134
161
|
|
|
135
|
-
function
|
|
162
|
+
function sendProject(appId) {
|
|
136
163
|
if (!projectState) return;
|
|
137
|
-
|
|
164
|
+
sendTopic(appId, 'project.changed', projectState);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Protocol §10. A payload with a string `project` gets the hub's encoded form and short name, as in project.changed.
|
|
168
|
+
function onPublish(appId, { topic, payload }) {
|
|
169
|
+
if (!apps[appId].publishes.includes(topic)) return;
|
|
170
|
+
if (payload !== null && !isPlainObject(payload)) return;
|
|
171
|
+
try {
|
|
172
|
+
if (JSON.stringify(payload).length > MAX_EVENT_CHARS) return;
|
|
173
|
+
} catch {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const event =
|
|
177
|
+
typeof payload?.project === 'string'
|
|
178
|
+
? { ...payload, encoded: encodeProjectPath(payload.project), projectName: basename(payload.project) }
|
|
179
|
+
: payload;
|
|
180
|
+
published.set(topic, { from: appId, payload: event });
|
|
181
|
+
for (const id of Object.keys(iframes)) {
|
|
182
|
+
if (id === appId) continue;
|
|
183
|
+
if (id === activeApp) sendTopic(id, topic, event);
|
|
184
|
+
else if (liveApps.get(id)?.has(topic)) missed.set(id, (missed.get(id) ?? new Set()).add(topic));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function sendPublished(appId) {
|
|
189
|
+
missed.delete(appId);
|
|
190
|
+
for (const [topic, { from, payload }] of published) if (from !== appId) sendTopic(appId, topic, payload);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function sendMissed(appId) {
|
|
194
|
+
for (const topic of missed.get(appId) ?? []) {
|
|
195
|
+
const last = published.get(topic);
|
|
196
|
+
if (last && last.from !== appId) sendTopic(appId, topic, last.payload);
|
|
197
|
+
}
|
|
198
|
+
missed.delete(appId);
|
|
138
199
|
}
|
|
139
200
|
|
|
140
201
|
// Sub-apps can't detect this themselves: inactive iframes are display:none, and a nested
|
|
141
202
|
// document's visibilityState still follows the top-level tab. Used for polling/auto-refresh.
|
|
142
203
|
function postActiveTo(appId) {
|
|
143
|
-
|
|
204
|
+
if (!liveApps.has(appId)) return;
|
|
205
|
+
const active = appId === activeApp;
|
|
206
|
+
if (active) sendMissed(appId);
|
|
207
|
+
postTo(appId, { type: 'hub:active', active });
|
|
144
208
|
}
|
|
145
209
|
|
|
146
210
|
async function init() {
|
|
@@ -154,10 +218,13 @@ async function init() {
|
|
|
154
218
|
defaultConfigDir = config.defaultConfigDir ?? null;
|
|
155
219
|
activeConfigDir = config.activeConfigDir ?? null;
|
|
156
220
|
applyTitle(config.activeConfigDir);
|
|
157
|
-
|
|
221
|
+
themeVars = config.themeVars ?? {};
|
|
222
|
+
hubThemes = config.themes ?? [];
|
|
223
|
+
actions = config.actions ?? {};
|
|
158
224
|
applyHubTheme();
|
|
159
225
|
buildIframes();
|
|
160
|
-
|
|
226
|
+
const saved = storedTab();
|
|
227
|
+
switchTab(apps[saved] ? saved : Object.keys(apps)[0]);
|
|
161
228
|
listenMessages();
|
|
162
229
|
listenKeys();
|
|
163
230
|
bindPalette();
|
|
@@ -177,8 +244,33 @@ function applyTitle(dir) {
|
|
|
177
244
|
function reloadIframes() {
|
|
178
245
|
loadedApps.clear();
|
|
179
246
|
projectState = null;
|
|
180
|
-
|
|
181
|
-
|
|
247
|
+
published.clear();
|
|
248
|
+
missed.clear();
|
|
249
|
+
// A new element, because assigning src is only a fragment navigation when the app's URL
|
|
250
|
+
// differs from the new one by the #t= fragment alone, and the old dir's document stays.
|
|
251
|
+
for (const id of Object.keys(iframes)) replaceIframe(id);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function replaceIframe(id) {
|
|
255
|
+
const old = iframes[id];
|
|
256
|
+
liveApps.delete(id);
|
|
257
|
+
linkLoads.delete(id);
|
|
258
|
+
loadedApps.delete(id);
|
|
259
|
+
const fresh = makeIframe(id);
|
|
260
|
+
fresh.className = old.className;
|
|
261
|
+
fresh.inert = old.inert;
|
|
262
|
+
const focused = document.activeElement === old;
|
|
263
|
+
old.replaceWith(fresh);
|
|
264
|
+
iframes[id] = fresh;
|
|
265
|
+
if (focused) fresh.focus();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// The old document stays live until the new one fires load, and a hub:action sent to it in between is lost.
|
|
269
|
+
function loadApp(id, src, { link = false } = {}) {
|
|
270
|
+
liveApps.delete(id);
|
|
271
|
+
if (link) linkLoads.add(id);
|
|
272
|
+
else linkLoads.delete(id);
|
|
273
|
+
iframes[id].src = src;
|
|
182
274
|
}
|
|
183
275
|
|
|
184
276
|
// The terminal token rides in the fragment so it never reaches a server log or a Referer.
|
|
@@ -187,57 +279,17 @@ function appSrc(id, path = '') {
|
|
|
187
279
|
return apps[id].url + path + (token ? `#t=${token}` : '');
|
|
188
280
|
}
|
|
189
281
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
'Pinning sticky notes…',
|
|
197
|
-
'Moving cards to done…',
|
|
198
|
-
'Nudging agents…',
|
|
199
|
-
'Drawing columns…',
|
|
200
|
-
'Reading the backlog…',
|
|
201
|
-
],
|
|
202
|
-
cost: [
|
|
203
|
-
'Counting tokens…',
|
|
204
|
-
'Tallying the bill…',
|
|
205
|
-
'Stacking bars…',
|
|
206
|
-
'Crunching numbers…',
|
|
207
|
-
'Checking the cache hits…',
|
|
208
|
-
'Adding up the days…',
|
|
209
|
-
'Balancing the books…',
|
|
210
|
-
'Plotting the trend…',
|
|
211
|
-
'Weighing the models…',
|
|
212
|
-
],
|
|
213
|
-
memory: [
|
|
214
|
-
'Recalling memories…',
|
|
215
|
-
'Walking the tree…',
|
|
216
|
-
'Dusting off notes…',
|
|
217
|
-
'Reading CLAUDE.md…',
|
|
218
|
-
'Following imports…',
|
|
219
|
-
'Tracing the context…',
|
|
220
|
-
'Flipping through pages…',
|
|
221
|
-
'Connecting the dots…',
|
|
222
|
-
'Checking what sticks…',
|
|
223
|
-
],
|
|
224
|
-
marketplace: [
|
|
225
|
-
'Unpacking plugins…',
|
|
226
|
-
'Polishing skills…',
|
|
227
|
-
'Dusting off agents…',
|
|
228
|
-
'Wiring up hooks…',
|
|
229
|
-
'Fetching manifests…',
|
|
230
|
-
'Reading SKILL.md files…',
|
|
231
|
-
'Listing commands…',
|
|
232
|
-
'Scanning MCP servers…',
|
|
233
|
-
'Gathering plugins…',
|
|
234
|
-
],
|
|
235
|
-
};
|
|
282
|
+
// From each app's manifest. Before /api/config lands there are none, and the first line is generic.
|
|
283
|
+
function loadingVerbs() {
|
|
284
|
+
const own = apps[activeApp]?.loading?.verbs ?? [];
|
|
285
|
+
const verbs = own.length ? own : Object.values(apps).flatMap((a) => a.loading?.verbs ?? []);
|
|
286
|
+
return verbs.length ? verbs : ['Loading…'];
|
|
287
|
+
}
|
|
236
288
|
|
|
237
289
|
let loadingTimer = null;
|
|
238
290
|
|
|
239
291
|
function showLoading() {
|
|
240
|
-
const verbs =
|
|
292
|
+
const verbs = loadingVerbs();
|
|
241
293
|
const text = document.getElementById('loading-text');
|
|
242
294
|
let i = Math.floor(Math.random() * verbs.length);
|
|
243
295
|
text.textContent = verbs[i];
|
|
@@ -255,23 +307,41 @@ function hideLoading() {
|
|
|
255
307
|
document.getElementById('loading-overlay').classList.add('fade-out');
|
|
256
308
|
}
|
|
257
309
|
|
|
310
|
+
function makeIframe(id) {
|
|
311
|
+
const iframe = document.createElement('iframe');
|
|
312
|
+
iframe.id = `iframe-${id}`;
|
|
313
|
+
iframe.src = appSrc(id);
|
|
314
|
+
iframe.className = 'hidden';
|
|
315
|
+
iframe.allow = 'clipboard-write; microphone';
|
|
316
|
+
iframe.addEventListener('load', () => onIframeLoad(id));
|
|
317
|
+
return iframe;
|
|
318
|
+
}
|
|
319
|
+
|
|
258
320
|
function buildIframes() {
|
|
259
321
|
const container = document.getElementById('iframe-container');
|
|
260
322
|
for (const id of Object.keys(apps)) {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
323
|
+
iframes[id] = makeIframe(id);
|
|
324
|
+
container.appendChild(iframes[id]);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const TAB_KEY = 'hub-tab';
|
|
329
|
+
|
|
330
|
+
function storedTab() {
|
|
331
|
+
try {
|
|
332
|
+
return localStorage.getItem(TAB_KEY);
|
|
333
|
+
} catch {
|
|
334
|
+
return null;
|
|
269
335
|
}
|
|
270
336
|
}
|
|
271
337
|
|
|
272
338
|
function switchTab(appId) {
|
|
273
339
|
if (!apps[appId]) return;
|
|
340
|
+
if (appId !== activeApp) previousApp = activeApp;
|
|
274
341
|
activeApp = appId;
|
|
342
|
+
try {
|
|
343
|
+
localStorage.setItem(TAB_KEY, appId);
|
|
344
|
+
} catch {}
|
|
275
345
|
|
|
276
346
|
for (const [id, iframe] of Object.entries(iframes)) {
|
|
277
347
|
iframe.classList.toggle('hidden', id !== appId);
|
|
@@ -285,20 +355,73 @@ function switchTab(appId) {
|
|
|
285
355
|
}
|
|
286
356
|
|
|
287
357
|
function onIframeLoad(appId) {
|
|
358
|
+
liveApps.delete(appId);
|
|
288
359
|
loadedApps.add(appId);
|
|
289
360
|
guardedApps.delete(appId);
|
|
290
361
|
if (appId === activeApp) hideLoading();
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function onHello(appId, data) {
|
|
365
|
+
const common = Array.isArray(data.protocol) ? HUB_PROTOCOLS.filter((v) => data.protocol.includes(v)) : [];
|
|
366
|
+
if (!common.length) return;
|
|
367
|
+
liveApps.set(appId, new Set(Array.isArray(data.subscribes) ? data.subscribes : []));
|
|
368
|
+
postTo(appId, {
|
|
369
|
+
type: 'hub:welcome',
|
|
370
|
+
protocol: Math.max(...common),
|
|
371
|
+
forward: Object.keys(bindings()),
|
|
372
|
+
themes: hubThemes,
|
|
373
|
+
actions: Object.keys(actions),
|
|
374
|
+
});
|
|
375
|
+
sendTheme(appId);
|
|
376
|
+
// A document the hub loaded from an action URL got its project from that URL. The hub's own
|
|
377
|
+
// project would undo the link, so it goes out only with the next change.
|
|
378
|
+
if (!linkLoads.delete(appId)) sendProject(appId);
|
|
379
|
+
sendPublished(appId);
|
|
380
|
+
postActiveTo(appId);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// A url such as '@evil.example/' turns the app host into userinfo, and the token rides in the fragment.
|
|
384
|
+
function navigateApp(appId, url) {
|
|
385
|
+
const src = appSrc(appId, url);
|
|
386
|
+
if (!URL.canParse(src) || new URL(src).origin !== originOf(appId)) return;
|
|
387
|
+
loadApp(appId, src, { link: true });
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function isPlainObject(v) {
|
|
391
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function badParams(declared, params) {
|
|
395
|
+
if (!isPlainObject(params)) return true;
|
|
396
|
+
const given = Object.entries(params);
|
|
397
|
+
if (given.some(([name, value]) => !Object.hasOwn(declared, name) || typeof value !== 'string')) return true;
|
|
398
|
+
return Object.entries(declared).some(([name, type]) => type === 'string' && !Object.hasOwn(params, name));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Null when the template names a param the call left out.
|
|
402
|
+
function fillUrl(template, params) {
|
|
403
|
+
const slot = /\{([^}]+)\}/g;
|
|
404
|
+
if ([...template.matchAll(slot)].some(([, name]) => !Object.hasOwn(params, name))) return null;
|
|
405
|
+
return template.replace(slot, (_, name) => encodeURIComponent(params[name]));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Protocol §7. The result does not wait for the handler: the hub queues nothing.
|
|
409
|
+
function onInvoke(appId, { id, action, params = {} }) {
|
|
410
|
+
if (typeof id !== 'string') return;
|
|
411
|
+
const reply = (result) => postTo(appId, { type: 'hub:result', id, ...result });
|
|
412
|
+
const spec = typeof action === 'string' && Object.hasOwn(actions, action) ? actions[action] : null;
|
|
413
|
+
if (!spec) return reply({ ok: false, reason: 'unhandled' });
|
|
414
|
+
if (badParams(spec.params, params)) return reply({ ok: false, reason: 'bad-params' });
|
|
415
|
+
const { app: handler, mode, url: template } = spec;
|
|
416
|
+
switchTab(handler);
|
|
417
|
+
if (mode === 'message' && liveApps.has(handler)) {
|
|
418
|
+
postTo(handler, { type: 'hub:action', id, action, params });
|
|
419
|
+
} else {
|
|
420
|
+
// Setting src reloads the app, so a guarded app only gets the switch.
|
|
421
|
+
const url = template && fillUrl(template, params);
|
|
422
|
+
if (url && !guardedApps.has(handler)) navigateApp(handler, url);
|
|
423
|
+
}
|
|
424
|
+
reply({ ok: true, handledBy: handler });
|
|
302
425
|
}
|
|
303
426
|
|
|
304
427
|
function listenMessages() {
|
|
@@ -309,13 +432,12 @@ function listenMessages() {
|
|
|
309
432
|
const appId = appIdOf(e.source);
|
|
310
433
|
if (!appId || e.origin !== originOf(appId)) return;
|
|
311
434
|
const data = e.data ?? {};
|
|
312
|
-
if (data.type === 'hub:
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
if (URL.canParse(src) && new URL(src).origin === originOf(data.app)) iframes[data.app].src = src;
|
|
435
|
+
if (data.type === 'hub:hello') {
|
|
436
|
+
onHello(appId, data);
|
|
437
|
+
} else if (data.type === 'hub:invoke') {
|
|
438
|
+
onInvoke(appId, data);
|
|
439
|
+
} else if (data.type === 'hub:publish') {
|
|
440
|
+
onPublish(appId, data);
|
|
319
441
|
} else if (data.type === 'hub:keydown') {
|
|
320
442
|
handleForwardedKey(data);
|
|
321
443
|
} else if (data.type === 'hub:closeGuard') {
|
|
@@ -333,7 +455,7 @@ function listenMessages() {
|
|
|
333
455
|
.catch(() => {});
|
|
334
456
|
} else if (data.type === 'hub:openExternal') {
|
|
335
457
|
// In the installed PWA window a framed sub-app's own target=_blank opens
|
|
336
|
-
// nothing, so the
|
|
458
|
+
// nothing, so the SDK hands external links up to the top frame instead.
|
|
337
459
|
let url;
|
|
338
460
|
try {
|
|
339
461
|
url = new URL(String(data.url));
|
|
@@ -343,7 +465,7 @@ function listenMessages() {
|
|
|
343
465
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') return;
|
|
344
466
|
window.open(url.href, '_blank', 'noopener');
|
|
345
467
|
} else if (data.type === 'hub:theme') {
|
|
346
|
-
//
|
|
468
|
+
// colorTheme is optional and sticky.
|
|
347
469
|
if (data.theme !== 'light' && data.theme !== 'dark') return;
|
|
348
470
|
const hasColor = typeof data.colorTheme === 'string' && /^[a-z0-9-]{0,32}$/.test(data.colorTheme);
|
|
349
471
|
const changed = data.theme !== themeState.theme || (hasColor && data.colorTheme !== activeColorTheme());
|
|
@@ -355,32 +477,31 @@ function listenMessages() {
|
|
|
355
477
|
}
|
|
356
478
|
localStorage.setItem('hub-theme', JSON.stringify(themeState));
|
|
357
479
|
applyHubTheme();
|
|
358
|
-
|
|
480
|
+
// The sender gets the echo too, for the vars.
|
|
481
|
+
for (const id of Object.keys(iframes)) sendTheme(id);
|
|
359
482
|
}
|
|
360
483
|
});
|
|
361
484
|
}
|
|
362
485
|
|
|
363
486
|
function handleForwardedKey(d) {
|
|
364
|
-
// The modifier fields are new. A sub-app running an older service-worker-cached bundle sends
|
|
365
|
-
// {key} only; it forwards nothing but the pre-existing bindings, and each of those is
|
|
366
|
-
// unambiguous from the key alone, so normalize instead of dispatching twice.
|
|
367
|
-
const legacy = typeof d.alt !== 'boolean';
|
|
368
487
|
const e = {
|
|
369
488
|
key: d.key,
|
|
370
489
|
code: d.code,
|
|
371
|
-
|
|
372
|
-
|
|
490
|
+
ctrlKey: d.ctrl === true,
|
|
491
|
+
altKey: d.alt === true,
|
|
373
492
|
shiftKey: d.shift === true,
|
|
493
|
+
metaKey: d.meta === true,
|
|
374
494
|
};
|
|
375
495
|
bindings()[comboOf(e)]?.run();
|
|
376
496
|
}
|
|
377
497
|
|
|
378
|
-
// The one keymap. Its combo names are what
|
|
498
|
+
// The one keymap. Its combo names are what welcome.forward lists for the apps.
|
|
379
499
|
// inPalette: the binding still fires while the palette is open.
|
|
380
500
|
function bindings() {
|
|
381
501
|
const map = {
|
|
382
502
|
'ctrl+alt+p': { run: () => togglePalette('project'), inPalette: true },
|
|
383
503
|
'ctrl+alt+w': { run: () => togglePalette('configDir'), inPalette: true },
|
|
504
|
+
'ctrl+alt+a': { run: () => togglePalette('app'), inPalette: true },
|
|
384
505
|
'ctrl+alt+ArrowLeft': { run: () => cycleTab(-1) },
|
|
385
506
|
'ctrl+alt+ArrowRight': { run: () => cycleTab(1) },
|
|
386
507
|
};
|
|
@@ -392,7 +513,7 @@ function bindings() {
|
|
|
392
513
|
return map;
|
|
393
514
|
}
|
|
394
515
|
|
|
395
|
-
//
|
|
516
|
+
// The SDK's comboOf() is a copy of this: modifiers in ctrl, alt, shift, meta order, joined by
|
|
396
517
|
// '+' to the key as bindingKey() names it.
|
|
397
518
|
function comboOf(e) {
|
|
398
519
|
const mods = [e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift', e.metaKey && 'meta'];
|
|
@@ -569,6 +690,114 @@ async function removeConfigDir(dirPath) {
|
|
|
569
690
|
if (palette.open && palette.mode === 'configDir') renderPalette();
|
|
570
691
|
}
|
|
571
692
|
|
|
693
|
+
// Lucide icons (ISC license) for the names the built-in apps and the Inspector use. Any other
|
|
694
|
+
// manifest icon shows the app's first letter.
|
|
695
|
+
const APP_ICONS = {
|
|
696
|
+
columns: [
|
|
697
|
+
['rect', { width: 18, height: 18, x: 3, y: 3, rx: 2 }],
|
|
698
|
+
['path', { d: 'M12 3v18' }],
|
|
699
|
+
],
|
|
700
|
+
store: [
|
|
701
|
+
['path', { d: 'm2 7 4.41-4.41A2 2 0 0 1 7.83 2h8.34a2 2 0 0 1 1.42.59L22 7' }],
|
|
702
|
+
['path', { d: 'M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8' }],
|
|
703
|
+
['path', { d: 'M15 22v-4a2 2 0 0 0-2-2h-2a2 2 0 0 0-2 2v4' }],
|
|
704
|
+
['path', { d: 'M2 7h20' }],
|
|
705
|
+
[
|
|
706
|
+
'path',
|
|
707
|
+
{
|
|
708
|
+
d: 'M22 7v3a2 2 0 0 1-2 2a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 16 12a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 12 12a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 8 12a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 4 12a2 2 0 0 1-2-2V7',
|
|
709
|
+
},
|
|
710
|
+
],
|
|
711
|
+
],
|
|
712
|
+
'dollar-sign': [
|
|
713
|
+
['line', { x1: 12, x2: 12, y1: 2, y2: 22 }],
|
|
714
|
+
['path', { d: 'M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6' }],
|
|
715
|
+
],
|
|
716
|
+
database: [
|
|
717
|
+
['ellipse', { cx: 12, cy: 5, rx: 9, ry: 3 }],
|
|
718
|
+
['path', { d: 'M3 5V19A9 3 0 0 0 21 19V5' }],
|
|
719
|
+
['path', { d: 'M3 12A9 3 0 0 0 21 12' }],
|
|
720
|
+
],
|
|
721
|
+
'scan-search': [
|
|
722
|
+
['path', { d: 'M3 7V5a2 2 0 0 1 2-2h2' }],
|
|
723
|
+
['path', { d: 'M17 3h2a2 2 0 0 1 2 2v2' }],
|
|
724
|
+
['path', { d: 'M21 17v2a2 2 0 0 1-2 2h-2' }],
|
|
725
|
+
['path', { d: 'M7 21H5a2 2 0 0 1-2-2v-2' }],
|
|
726
|
+
['circle', { cx: 12, cy: 12, r: 3 }],
|
|
727
|
+
['path', { d: 'm16 16-1.9-1.9' }],
|
|
728
|
+
],
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
732
|
+
|
|
733
|
+
function svgNode(tag, attrs) {
|
|
734
|
+
const node = document.createElementNS(SVG_NS, tag);
|
|
735
|
+
for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, String(v));
|
|
736
|
+
return node;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function appIcon(app) {
|
|
740
|
+
const shapes = APP_ICONS[app.icon];
|
|
741
|
+
if (!shapes) return el('span', 'palette-icon letter', (app.name || '?').slice(0, 1).toUpperCase());
|
|
742
|
+
const svg = svgNode('svg', {
|
|
743
|
+
viewBox: '0 0 24 24',
|
|
744
|
+
fill: 'none',
|
|
745
|
+
stroke: 'currentColor',
|
|
746
|
+
'stroke-width': 2,
|
|
747
|
+
'stroke-linecap': 'round',
|
|
748
|
+
'stroke-linejoin': 'round',
|
|
749
|
+
'aria-hidden': 'true',
|
|
750
|
+
});
|
|
751
|
+
svg.append(...shapes.map(([tag, attrs]) => svgNode(tag, attrs)));
|
|
752
|
+
const box = el('span', 'palette-icon');
|
|
753
|
+
box.append(svg);
|
|
754
|
+
return box;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function appRows(q) {
|
|
758
|
+
const list = Object.entries(apps).map(([id, a], i) => ({ kind: 'app', id, app: a, index: i }));
|
|
759
|
+
if (!q) return list;
|
|
760
|
+
const hit = list.filter((r) => r.app.name.toLowerCase().includes(q) || r.id.includes(q));
|
|
761
|
+
return hit.length ? hit : list.filter((r) => subseq(r.app.name.toLowerCase(), q));
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// A click on the button runs the mode's rowKeys entry for `key`, the same as Ctrl+<key>.
|
|
765
|
+
function rowButton(className, glyph, key, title, label) {
|
|
766
|
+
const btn = el('button', className, glyph);
|
|
767
|
+
btn.type = 'button';
|
|
768
|
+
btn.title = title;
|
|
769
|
+
btn.setAttribute('aria-label', label);
|
|
770
|
+
btn.dataset.rowKey = key;
|
|
771
|
+
return btn;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function setPaletteHint(text) {
|
|
775
|
+
document.getElementById('palette-hint').textContent = text;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// The terminal provider answers 409 with a terminal count; the second Ctrl+R on the same app sends force.
|
|
779
|
+
async function restartFromPalette(id) {
|
|
780
|
+
const name = apps[id]?.name ?? id;
|
|
781
|
+
const force = palette.confirmRestart === id;
|
|
782
|
+
palette.confirmRestart = null;
|
|
783
|
+
setPaletteHint(`Restarting ${name}…`);
|
|
784
|
+
try {
|
|
785
|
+
await withPaletteBusy(() => sendJson('POST', `/api/apps/${encodeURIComponent(id)}/restart`, { force }));
|
|
786
|
+
} catch (err) {
|
|
787
|
+
if (err.status === 409 && err.data?.terminals) {
|
|
788
|
+
palette.confirmRestart = id;
|
|
789
|
+
const n = err.data.terminals;
|
|
790
|
+
setPaletteHint(
|
|
791
|
+
`${name} has ${n} live terminal${n === 1 ? '' : 's'}. A restart ends ${n === 1 ? 'it' : 'them'}. Press Ctrl+R again to restart.`,
|
|
792
|
+
);
|
|
793
|
+
} else setPaletteHint(`${name} did not restart: ${err.message}`);
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
closePalette();
|
|
797
|
+
replaceIframe(id);
|
|
798
|
+
switchTab(id);
|
|
799
|
+
}
|
|
800
|
+
|
|
572
801
|
// `typed` is the trimmed query, `q` its lowercase form for matching.
|
|
573
802
|
const PALETTE_MODES = {
|
|
574
803
|
project: {
|
|
@@ -626,14 +855,33 @@ const PALETTE_MODES = {
|
|
|
626
855
|
li.append(el('span', 'age', 'active'));
|
|
627
856
|
return;
|
|
628
857
|
}
|
|
629
|
-
|
|
630
|
-
remove.type = 'button';
|
|
631
|
-
remove.title = 'Remove from list (Ctrl+D)';
|
|
632
|
-
remove.dataset.remove = row.path;
|
|
633
|
-
li.append(remove);
|
|
858
|
+
li.append(rowButton('palette-remove', '×', 'd', 'Remove from list (Ctrl+D)', `Remove ${row.path}`));
|
|
634
859
|
},
|
|
860
|
+
rowKeys: { d: (row) => removeConfigDir(row.path) },
|
|
635
861
|
commit: commitConfigDir,
|
|
636
862
|
},
|
|
863
|
+
app: {
|
|
864
|
+
placeholder: 'Search apps...',
|
|
865
|
+
label: 'Switch app',
|
|
866
|
+
hint: 'Enter switch · Ctrl+R restart the app',
|
|
867
|
+
empty: 'No matching app',
|
|
868
|
+
rows: (_typed, q) => appRows(q),
|
|
869
|
+
initialSel(rows) {
|
|
870
|
+
const prev = rows.findIndex((r) => r.id === previousApp);
|
|
871
|
+
return prev < 0 ? 0 : prev;
|
|
872
|
+
},
|
|
873
|
+
fillRow(li, row, q) {
|
|
874
|
+
li.append(appIcon(row.app), markedSpan('name', row.app.name, q));
|
|
875
|
+
const key = row.id === activeApp ? 'active' : row.index < 9 ? `Alt+${row.index + 1}` : '';
|
|
876
|
+
li.append(el('span', 'age', key));
|
|
877
|
+
li.append(rowButton('palette-restart', '↻', 'r', 'Restart the app (Ctrl+R)', `Restart ${row.app.name}`));
|
|
878
|
+
},
|
|
879
|
+
rowKeys: { r: (row) => restartFromPalette(row.id) },
|
|
880
|
+
commit(row) {
|
|
881
|
+
closePalette();
|
|
882
|
+
switchTab(row.id);
|
|
883
|
+
},
|
|
884
|
+
},
|
|
637
885
|
};
|
|
638
886
|
|
|
639
887
|
function renderPalette() {
|
|
@@ -686,10 +934,11 @@ function openPalette(mode) {
|
|
|
686
934
|
palette.mode = mode;
|
|
687
935
|
palette.query = '';
|
|
688
936
|
palette.sel = null;
|
|
937
|
+
palette.confirmRestart = null;
|
|
689
938
|
input.value = '';
|
|
690
939
|
input.placeholder = spec.placeholder;
|
|
691
940
|
document.querySelector('#palette .palette-box').setAttribute('aria-label', spec.label);
|
|
692
|
-
|
|
941
|
+
setPaletteHint(spec.hint ?? '');
|
|
693
942
|
document.getElementById('palette').hidden = false;
|
|
694
943
|
// Ctrl+Alt+P usually arrives forwarded from a focused iframe. Without inert the sub-app can keep
|
|
695
944
|
// or take focus back, and then Escape is handled inside it — the palette stays open and only the
|
|
@@ -698,8 +947,7 @@ function openPalette(mode) {
|
|
|
698
947
|
renderPalette();
|
|
699
948
|
input.focus();
|
|
700
949
|
// Stale-while-revalidate: the cached list renders instantly, recency refreshes when this lands.
|
|
701
|
-
spec
|
|
702
|
-
.load()
|
|
950
|
+
Promise.resolve(spec.load?.())
|
|
703
951
|
.then(() => {
|
|
704
952
|
if (palette.open && palette.mode === mode) renderPalette();
|
|
705
953
|
})
|
|
@@ -745,7 +993,7 @@ async function sendJson(method, url, body) {
|
|
|
745
993
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
746
994
|
});
|
|
747
995
|
const data = await res.json().catch(() => ({}));
|
|
748
|
-
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
996
|
+
if (!res.ok) throw Object.assign(new Error(data.error || `HTTP ${res.status}`), { status: res.status, data });
|
|
749
997
|
return data;
|
|
750
998
|
}
|
|
751
999
|
|
|
@@ -823,22 +1071,24 @@ function bindPalette() {
|
|
|
823
1071
|
} else if (e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey)) {
|
|
824
1072
|
e.preventDefault();
|
|
825
1073
|
movePaletteSel(-1);
|
|
826
|
-
} else if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && e.key.toLowerCase()
|
|
1074
|
+
} else if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && ['d', 'r'].includes(e.key.toLowerCase())) {
|
|
827
1075
|
// Ctrl+D would otherwise bookmark the page in Chrome and Firefox.
|
|
1076
|
+
// Ctrl+R would otherwise reload the hub page.
|
|
828
1077
|
e.preventDefault();
|
|
1078
|
+
const run = PALETTE_MODES[palette.mode].rowKeys?.[e.key.toLowerCase()];
|
|
829
1079
|
const row = palette.rows[palette.sel];
|
|
830
|
-
if (row
|
|
1080
|
+
if (run && row && row.kind !== 'literal') run(row);
|
|
831
1081
|
}
|
|
832
1082
|
});
|
|
833
1083
|
document.getElementById('palette-list').addEventListener('click', (e) => {
|
|
834
|
-
const
|
|
835
|
-
|
|
1084
|
+
const btn = e.target.closest('[data-row-key]');
|
|
1085
|
+
const li = e.target.closest('.palette-row');
|
|
1086
|
+
if (btn) {
|
|
836
1087
|
e.stopPropagation();
|
|
837
|
-
|
|
1088
|
+
PALETTE_MODES[palette.mode].rowKeys[btn.dataset.rowKey](palette.rows[Number(li.dataset.idx)]);
|
|
838
1089
|
document.getElementById('palette-input').focus();
|
|
839
1090
|
return;
|
|
840
1091
|
}
|
|
841
|
-
const li = e.target.closest('.palette-row');
|
|
842
1092
|
if (!li) return;
|
|
843
1093
|
palette.sel = Number(li.dataset.idx);
|
|
844
1094
|
commitPalette();
|