claude-code-hub 0.41.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/cookies.js +13 -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/lib/themes.json +548 -0
- package/package.json +12 -8
- 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 +386 -145
- package/public/index.html +23 -2
- package/public/locked.html +1 -1
- package/server.js +340 -118
package/public/app.js
CHANGED
|
@@ -1,9 +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();
|
|
6
|
-
|
|
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];
|
|
7
14
|
// {theme: 'dark'|'light', colorTheme: '<id>', colorThemes: {<configDir>: '<id>'}} — survives hub
|
|
8
15
|
// reloads so late-loading iframes and fresh sessions get the last chosen theme. Light/dark is
|
|
9
16
|
// global; the color theme is per config dir, and colorTheme is the last one picked anywhere, which
|
|
@@ -15,9 +22,14 @@ let activeConfigDir = null;
|
|
|
15
22
|
// hub-theme: starting unset means there is nothing to race against each sub-app's own
|
|
16
23
|
// project self-restore on boot. Once set it never returns to null.
|
|
17
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;
|
|
18
30
|
// The config dir the hub falls back to (~/.claude). Stays out of the window title.
|
|
19
31
|
let defaultConfigDir = null;
|
|
20
|
-
// 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.
|
|
21
33
|
const palette = {
|
|
22
34
|
open: false,
|
|
23
35
|
mode: 'project',
|
|
@@ -26,6 +38,7 @@ const palette = {
|
|
|
26
38
|
rows: [],
|
|
27
39
|
sel: 0,
|
|
28
40
|
query: '',
|
|
41
|
+
confirmRestart: null,
|
|
29
42
|
};
|
|
30
43
|
|
|
31
44
|
function loadThemeState() {
|
|
@@ -40,8 +53,10 @@ function loadThemeState() {
|
|
|
40
53
|
}
|
|
41
54
|
|
|
42
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.
|
|
43
57
|
function activeColorTheme() {
|
|
44
|
-
|
|
58
|
+
const id = themeState.colorThemes[activeConfigDir] ?? themeState.colorTheme;
|
|
59
|
+
return id && hubThemes.length && !themeVars[id] ? 'ember' : id;
|
|
45
60
|
}
|
|
46
61
|
|
|
47
62
|
function osTheme() {
|
|
@@ -50,14 +65,36 @@ function osTheme() {
|
|
|
50
65
|
|
|
51
66
|
// Sub-apps disagree on their first-run default (marketplace follows the OS, the rest start dark),
|
|
52
67
|
// so an unset theme resolves to the OS preference here and reaches every iframe on load.
|
|
53
|
-
function
|
|
54
|
-
return {
|
|
68
|
+
function themePayload() {
|
|
69
|
+
return { theme: themeState.theme ?? osTheme(), colorTheme: activeColorTheme() };
|
|
55
70
|
}
|
|
56
71
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
+
};
|
|
61
98
|
// The last applied variables, so the loading screen paints in the theme before /api/config lands.
|
|
62
99
|
const PALETTE_KEY = 'hub-palette';
|
|
63
100
|
|
|
@@ -68,7 +105,8 @@ function applyHubTheme() {
|
|
|
68
105
|
const root = document.documentElement;
|
|
69
106
|
root.classList.toggle('light', mode === 'light');
|
|
70
107
|
const key = `${activeColorTheme()}/${mode}`;
|
|
71
|
-
|
|
108
|
+
const core = themeVars[activeColorTheme()]?.[mode];
|
|
109
|
+
let vars = core && Object.fromEntries(Object.entries(HUB_VARS).map(([hub, name]) => [hub, core[name]]));
|
|
72
110
|
try {
|
|
73
111
|
if (vars) localStorage.setItem(PALETTE_KEY, JSON.stringify({ key, vars }));
|
|
74
112
|
else {
|
|
@@ -95,15 +133,6 @@ function parentOf(p) {
|
|
|
95
133
|
return i <= 0 ? '' : p.slice(0, i);
|
|
96
134
|
}
|
|
97
135
|
|
|
98
|
-
function projectMessage() {
|
|
99
|
-
return {
|
|
100
|
-
type: 'hub:project',
|
|
101
|
-
project: projectState.project,
|
|
102
|
-
encoded: projectState.encoded,
|
|
103
|
-
name: projectState.name,
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
|
|
107
136
|
function originOf(appId) {
|
|
108
137
|
return new URL(apps[appId].url).origin;
|
|
109
138
|
}
|
|
@@ -111,7 +140,7 @@ function originOf(appId) {
|
|
|
111
140
|
function postTo(appId, message) {
|
|
112
141
|
// Before the iframe commits its src its contentWindow is still on the hub's own
|
|
113
142
|
// origin, so a send addressed to the sub-app origin is refused and logged. The
|
|
114
|
-
//
|
|
143
|
+
// app's hello brings the current state anyway, so skipping here loses nothing.
|
|
115
144
|
if (!loadedApps.has(appId)) return;
|
|
116
145
|
iframes[appId]?.contentWindow?.postMessage(message, originOf(appId));
|
|
117
146
|
}
|
|
@@ -120,28 +149,62 @@ function appIdOf(source) {
|
|
|
120
149
|
return Object.keys(iframes).find((id) => iframes[id].contentWindow === source);
|
|
121
150
|
}
|
|
122
151
|
|
|
123
|
-
//
|
|
124
|
-
function
|
|
125
|
-
|
|
126
|
-
if (exceptWindow && iframe.contentWindow === exceptWindow) continue;
|
|
127
|
-
postTo(id, message);
|
|
128
|
-
}
|
|
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 });
|
|
129
155
|
}
|
|
130
156
|
|
|
131
157
|
function setProject(absPath) {
|
|
132
158
|
projectState = { project: absPath, encoded: encodeProjectPath(absPath), name: basename(absPath) };
|
|
133
|
-
|
|
159
|
+
for (const id of Object.keys(iframes)) sendProject(id);
|
|
134
160
|
}
|
|
135
161
|
|
|
136
|
-
function
|
|
162
|
+
function sendProject(appId) {
|
|
137
163
|
if (!projectState) return;
|
|
138
|
-
|
|
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);
|
|
139
199
|
}
|
|
140
200
|
|
|
141
201
|
// Sub-apps can't detect this themselves: inactive iframes are display:none, and a nested
|
|
142
202
|
// document's visibilityState still follows the top-level tab. Used for polling/auto-refresh.
|
|
143
203
|
function postActiveTo(appId) {
|
|
144
|
-
|
|
204
|
+
if (!liveApps.has(appId)) return;
|
|
205
|
+
const active = appId === activeApp;
|
|
206
|
+
if (active) sendMissed(appId);
|
|
207
|
+
postTo(appId, { type: 'hub:active', active });
|
|
145
208
|
}
|
|
146
209
|
|
|
147
210
|
async function init() {
|
|
@@ -151,14 +214,17 @@ async function init() {
|
|
|
151
214
|
showLoading();
|
|
152
215
|
const res = await fetch('/api/config');
|
|
153
216
|
const config = await res.json();
|
|
154
|
-
|
|
217
|
+
apps = config.apps;
|
|
155
218
|
defaultConfigDir = config.defaultConfigDir ?? null;
|
|
156
219
|
activeConfigDir = config.activeConfigDir ?? null;
|
|
157
220
|
applyTitle(config.activeConfigDir);
|
|
158
|
-
|
|
221
|
+
themeVars = config.themeVars ?? {};
|
|
222
|
+
hubThemes = config.themes ?? [];
|
|
223
|
+
actions = config.actions ?? {};
|
|
159
224
|
applyHubTheme();
|
|
160
225
|
buildIframes();
|
|
161
|
-
|
|
226
|
+
const saved = storedTab();
|
|
227
|
+
switchTab(apps[saved] ? saved : Object.keys(apps)[0]);
|
|
162
228
|
listenMessages();
|
|
163
229
|
listenKeys();
|
|
164
230
|
bindPalette();
|
|
@@ -172,19 +238,39 @@ function applyTitle(dir) {
|
|
|
172
238
|
document.title = dir && dir !== defaultConfigDir ? basename(dir) : 'Claude Code Hub';
|
|
173
239
|
}
|
|
174
240
|
|
|
175
|
-
function setApps(next) {
|
|
176
|
-
apps = next;
|
|
177
|
-
allowedOrigins = new Set(Object.values(apps).map((a) => new URL(a.url).origin));
|
|
178
|
-
}
|
|
179
|
-
|
|
180
241
|
// After a config-dir switch every child is a new process, possibly on a new port. Reloading each
|
|
181
242
|
// iframe from scratch also drops the sub-apps' in-memory project state, which belonged to the old
|
|
182
243
|
// dir; the hub's own projectState is cleared for the same reason.
|
|
183
244
|
function reloadIframes() {
|
|
184
245
|
loadedApps.clear();
|
|
185
246
|
projectState = null;
|
|
186
|
-
|
|
187
|
-
|
|
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;
|
|
188
274
|
}
|
|
189
275
|
|
|
190
276
|
// The terminal token rides in the fragment so it never reaches a server log or a Referer.
|
|
@@ -193,57 +279,17 @@ function appSrc(id, path = '') {
|
|
|
193
279
|
return apps[id].url + path + (token ? `#t=${token}` : '');
|
|
194
280
|
}
|
|
195
281
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
'Pinning sticky notes…',
|
|
203
|
-
'Moving cards to done…',
|
|
204
|
-
'Nudging agents…',
|
|
205
|
-
'Drawing columns…',
|
|
206
|
-
'Reading the backlog…',
|
|
207
|
-
],
|
|
208
|
-
cost: [
|
|
209
|
-
'Counting tokens…',
|
|
210
|
-
'Tallying the bill…',
|
|
211
|
-
'Stacking bars…',
|
|
212
|
-
'Crunching numbers…',
|
|
213
|
-
'Checking the cache hits…',
|
|
214
|
-
'Adding up the days…',
|
|
215
|
-
'Balancing the books…',
|
|
216
|
-
'Plotting the trend…',
|
|
217
|
-
'Weighing the models…',
|
|
218
|
-
],
|
|
219
|
-
memory: [
|
|
220
|
-
'Recalling memories…',
|
|
221
|
-
'Walking the tree…',
|
|
222
|
-
'Dusting off notes…',
|
|
223
|
-
'Reading CLAUDE.md…',
|
|
224
|
-
'Following imports…',
|
|
225
|
-
'Tracing the context…',
|
|
226
|
-
'Flipping through pages…',
|
|
227
|
-
'Connecting the dots…',
|
|
228
|
-
'Checking what sticks…',
|
|
229
|
-
],
|
|
230
|
-
marketplace: [
|
|
231
|
-
'Unpacking plugins…',
|
|
232
|
-
'Polishing skills…',
|
|
233
|
-
'Dusting off agents…',
|
|
234
|
-
'Wiring up hooks…',
|
|
235
|
-
'Fetching manifests…',
|
|
236
|
-
'Reading SKILL.md files…',
|
|
237
|
-
'Listing commands…',
|
|
238
|
-
'Scanning MCP servers…',
|
|
239
|
-
'Gathering plugins…',
|
|
240
|
-
],
|
|
241
|
-
};
|
|
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
|
+
}
|
|
242
288
|
|
|
243
289
|
let loadingTimer = null;
|
|
244
290
|
|
|
245
291
|
function showLoading() {
|
|
246
|
-
const verbs =
|
|
292
|
+
const verbs = loadingVerbs();
|
|
247
293
|
const text = document.getElementById('loading-text');
|
|
248
294
|
let i = Math.floor(Math.random() * verbs.length);
|
|
249
295
|
text.textContent = verbs[i];
|
|
@@ -261,23 +307,41 @@ function hideLoading() {
|
|
|
261
307
|
document.getElementById('loading-overlay').classList.add('fade-out');
|
|
262
308
|
}
|
|
263
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
|
+
|
|
264
320
|
function buildIframes() {
|
|
265
321
|
const container = document.getElementById('iframe-container');
|
|
266
322
|
for (const id of Object.keys(apps)) {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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;
|
|
275
335
|
}
|
|
276
336
|
}
|
|
277
337
|
|
|
278
338
|
function switchTab(appId) {
|
|
279
339
|
if (!apps[appId]) return;
|
|
340
|
+
if (appId !== activeApp) previousApp = activeApp;
|
|
280
341
|
activeApp = appId;
|
|
342
|
+
try {
|
|
343
|
+
localStorage.setItem(TAB_KEY, appId);
|
|
344
|
+
} catch {}
|
|
281
345
|
|
|
282
346
|
for (const [id, iframe] of Object.entries(iframes)) {
|
|
283
347
|
iframe.classList.toggle('hidden', id !== appId);
|
|
@@ -291,20 +355,73 @@ function switchTab(appId) {
|
|
|
291
355
|
}
|
|
292
356
|
|
|
293
357
|
function onIframeLoad(appId) {
|
|
358
|
+
liveApps.delete(appId);
|
|
294
359
|
loadedApps.add(appId);
|
|
295
360
|
guardedApps.delete(appId);
|
|
296
361
|
if (appId === activeApp) hideLoading();
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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 });
|
|
308
425
|
}
|
|
309
426
|
|
|
310
427
|
function listenMessages() {
|
|
@@ -312,26 +429,22 @@ function listenMessages() {
|
|
|
312
429
|
if (guardedApps.size) e.preventDefault();
|
|
313
430
|
});
|
|
314
431
|
window.addEventListener('message', (e) => {
|
|
315
|
-
|
|
432
|
+
const appId = appIdOf(e.source);
|
|
433
|
+
if (!appId || e.origin !== originOf(appId)) return;
|
|
316
434
|
const data = e.data ?? {};
|
|
317
|
-
if (data.type === 'hub:
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
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);
|
|
324
441
|
} else if (data.type === 'hub:keydown') {
|
|
325
442
|
handleForwardedKey(data);
|
|
326
443
|
} else if (data.type === 'hub:closeGuard') {
|
|
327
|
-
const appId = appIdOf(e.source);
|
|
328
|
-
if (!appId) return;
|
|
329
444
|
if (data.on === true) guardedApps.add(appId);
|
|
330
445
|
else guardedApps.delete(appId);
|
|
331
446
|
} else if (data.type === 'hub:terminalToken') {
|
|
332
447
|
// This hub page may have outlived the restart too, so the cached token is not trusted.
|
|
333
|
-
const appId = appIdOf(e.source);
|
|
334
|
-
if (!appId) return;
|
|
335
448
|
sendJson('GET', '/api/config')
|
|
336
449
|
.then((cfg) => {
|
|
337
450
|
const token = cfg.apps?.[appId]?.terminalToken;
|
|
@@ -342,7 +455,7 @@ function listenMessages() {
|
|
|
342
455
|
.catch(() => {});
|
|
343
456
|
} else if (data.type === 'hub:openExternal') {
|
|
344
457
|
// In the installed PWA window a framed sub-app's own target=_blank opens
|
|
345
|
-
// nothing, so the
|
|
458
|
+
// nothing, so the SDK hands external links up to the top frame instead.
|
|
346
459
|
let url;
|
|
347
460
|
try {
|
|
348
461
|
url = new URL(String(data.url));
|
|
@@ -352,7 +465,7 @@ function listenMessages() {
|
|
|
352
465
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') return;
|
|
353
466
|
window.open(url.href, '_blank', 'noopener');
|
|
354
467
|
} else if (data.type === 'hub:theme') {
|
|
355
|
-
//
|
|
468
|
+
// colorTheme is optional and sticky.
|
|
356
469
|
if (data.theme !== 'light' && data.theme !== 'dark') return;
|
|
357
470
|
const hasColor = typeof data.colorTheme === 'string' && /^[a-z0-9-]{0,32}$/.test(data.colorTheme);
|
|
358
471
|
const changed = data.theme !== themeState.theme || (hasColor && data.colorTheme !== activeColorTheme());
|
|
@@ -364,32 +477,31 @@ function listenMessages() {
|
|
|
364
477
|
}
|
|
365
478
|
localStorage.setItem('hub-theme', JSON.stringify(themeState));
|
|
366
479
|
applyHubTheme();
|
|
367
|
-
|
|
480
|
+
// The sender gets the echo too, for the vars.
|
|
481
|
+
for (const id of Object.keys(iframes)) sendTheme(id);
|
|
368
482
|
}
|
|
369
483
|
});
|
|
370
484
|
}
|
|
371
485
|
|
|
372
486
|
function handleForwardedKey(d) {
|
|
373
|
-
// The modifier fields are new. A sub-app running an older service-worker-cached bundle sends
|
|
374
|
-
// {key} only; it forwards nothing but the pre-existing bindings, and each of those is
|
|
375
|
-
// unambiguous from the key alone, so normalize instead of dispatching twice.
|
|
376
|
-
const legacy = typeof d.alt !== 'boolean';
|
|
377
487
|
const e = {
|
|
378
488
|
key: d.key,
|
|
379
489
|
code: d.code,
|
|
380
|
-
|
|
381
|
-
|
|
490
|
+
ctrlKey: d.ctrl === true,
|
|
491
|
+
altKey: d.alt === true,
|
|
382
492
|
shiftKey: d.shift === true,
|
|
493
|
+
metaKey: d.meta === true,
|
|
383
494
|
};
|
|
384
495
|
bindings()[comboOf(e)]?.run();
|
|
385
496
|
}
|
|
386
497
|
|
|
387
|
-
// The one keymap. Its combo names are what
|
|
498
|
+
// The one keymap. Its combo names are what welcome.forward lists for the apps.
|
|
388
499
|
// inPalette: the binding still fires while the palette is open.
|
|
389
500
|
function bindings() {
|
|
390
501
|
const map = {
|
|
391
502
|
'ctrl+alt+p': { run: () => togglePalette('project'), inPalette: true },
|
|
392
503
|
'ctrl+alt+w': { run: () => togglePalette('configDir'), inPalette: true },
|
|
504
|
+
'ctrl+alt+a': { run: () => togglePalette('app'), inPalette: true },
|
|
393
505
|
'ctrl+alt+ArrowLeft': { run: () => cycleTab(-1) },
|
|
394
506
|
'ctrl+alt+ArrowRight': { run: () => cycleTab(1) },
|
|
395
507
|
};
|
|
@@ -401,7 +513,7 @@ function bindings() {
|
|
|
401
513
|
return map;
|
|
402
514
|
}
|
|
403
515
|
|
|
404
|
-
//
|
|
516
|
+
// The SDK's comboOf() is a copy of this: modifiers in ctrl, alt, shift, meta order, joined by
|
|
405
517
|
// '+' to the key as bindingKey() names it.
|
|
406
518
|
function comboOf(e) {
|
|
407
519
|
const mods = [e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift', e.metaKey && 'meta'];
|
|
@@ -578,6 +690,114 @@ async function removeConfigDir(dirPath) {
|
|
|
578
690
|
if (palette.open && palette.mode === 'configDir') renderPalette();
|
|
579
691
|
}
|
|
580
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
|
+
|
|
581
801
|
// `typed` is the trimmed query, `q` its lowercase form for matching.
|
|
582
802
|
const PALETTE_MODES = {
|
|
583
803
|
project: {
|
|
@@ -635,14 +855,33 @@ const PALETTE_MODES = {
|
|
|
635
855
|
li.append(el('span', 'age', 'active'));
|
|
636
856
|
return;
|
|
637
857
|
}
|
|
638
|
-
|
|
639
|
-
remove.type = 'button';
|
|
640
|
-
remove.title = 'Remove from list (Ctrl+D)';
|
|
641
|
-
remove.dataset.remove = row.path;
|
|
642
|
-
li.append(remove);
|
|
858
|
+
li.append(rowButton('palette-remove', '×', 'd', 'Remove from list (Ctrl+D)', `Remove ${row.path}`));
|
|
643
859
|
},
|
|
860
|
+
rowKeys: { d: (row) => removeConfigDir(row.path) },
|
|
644
861
|
commit: commitConfigDir,
|
|
645
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
|
+
},
|
|
646
885
|
};
|
|
647
886
|
|
|
648
887
|
function renderPalette() {
|
|
@@ -695,10 +934,11 @@ function openPalette(mode) {
|
|
|
695
934
|
palette.mode = mode;
|
|
696
935
|
palette.query = '';
|
|
697
936
|
palette.sel = null;
|
|
937
|
+
palette.confirmRestart = null;
|
|
698
938
|
input.value = '';
|
|
699
939
|
input.placeholder = spec.placeholder;
|
|
700
940
|
document.querySelector('#palette .palette-box').setAttribute('aria-label', spec.label);
|
|
701
|
-
|
|
941
|
+
setPaletteHint(spec.hint ?? '');
|
|
702
942
|
document.getElementById('palette').hidden = false;
|
|
703
943
|
// Ctrl+Alt+P usually arrives forwarded from a focused iframe. Without inert the sub-app can keep
|
|
704
944
|
// or take focus back, and then Escape is handled inside it — the palette stays open and only the
|
|
@@ -707,8 +947,7 @@ function openPalette(mode) {
|
|
|
707
947
|
renderPalette();
|
|
708
948
|
input.focus();
|
|
709
949
|
// Stale-while-revalidate: the cached list renders instantly, recency refreshes when this lands.
|
|
710
|
-
spec
|
|
711
|
-
.load()
|
|
950
|
+
Promise.resolve(spec.load?.())
|
|
712
951
|
.then(() => {
|
|
713
952
|
if (palette.open && palette.mode === mode) renderPalette();
|
|
714
953
|
})
|
|
@@ -754,7 +993,7 @@ async function sendJson(method, url, body) {
|
|
|
754
993
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
755
994
|
});
|
|
756
995
|
const data = await res.json().catch(() => ({}));
|
|
757
|
-
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 });
|
|
758
997
|
return data;
|
|
759
998
|
}
|
|
760
999
|
|
|
@@ -785,7 +1024,7 @@ async function commitConfigDir(row) {
|
|
|
785
1024
|
if (target === palette.configDirs.active) return;
|
|
786
1025
|
showLoading();
|
|
787
1026
|
try {
|
|
788
|
-
|
|
1027
|
+
apps = (await sendJson('POST', '/api/config-dirs/activate', { path: target })).apps;
|
|
789
1028
|
// The palette renders from this cache before its refetch lands, and initialSel keys off it.
|
|
790
1029
|
palette.configDirs.active = target;
|
|
791
1030
|
activeConfigDir = target;
|
|
@@ -832,22 +1071,24 @@ function bindPalette() {
|
|
|
832
1071
|
} else if (e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey)) {
|
|
833
1072
|
e.preventDefault();
|
|
834
1073
|
movePaletteSel(-1);
|
|
835
|
-
} 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())) {
|
|
836
1075
|
// Ctrl+D would otherwise bookmark the page in Chrome and Firefox.
|
|
1076
|
+
// Ctrl+R would otherwise reload the hub page.
|
|
837
1077
|
e.preventDefault();
|
|
1078
|
+
const run = PALETTE_MODES[palette.mode].rowKeys?.[e.key.toLowerCase()];
|
|
838
1079
|
const row = palette.rows[palette.sel];
|
|
839
|
-
if (row
|
|
1080
|
+
if (run && row && row.kind !== 'literal') run(row);
|
|
840
1081
|
}
|
|
841
1082
|
});
|
|
842
1083
|
document.getElementById('palette-list').addEventListener('click', (e) => {
|
|
843
|
-
const
|
|
844
|
-
|
|
1084
|
+
const btn = e.target.closest('[data-row-key]');
|
|
1085
|
+
const li = e.target.closest('.palette-row');
|
|
1086
|
+
if (btn) {
|
|
845
1087
|
e.stopPropagation();
|
|
846
|
-
|
|
1088
|
+
PALETTE_MODES[palette.mode].rowKeys[btn.dataset.rowKey](palette.rows[Number(li.dataset.idx)]);
|
|
847
1089
|
document.getElementById('palette-input').focus();
|
|
848
1090
|
return;
|
|
849
1091
|
}
|
|
850
|
-
const li = e.target.closest('.palette-row');
|
|
851
1092
|
if (!li) return;
|
|
852
1093
|
palette.sel = Number(li.dataset.idx);
|
|
853
1094
|
commitPalette();
|