@bitpub/cli 2.0.5 → 2.1.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/package.json +1 -1
- package/src/commands/browser.js +301 -1
- package/static/console.html +362 -41
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bitpub/cli",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "BitPub CLI — local-first shared memory for AI agents. Six daily verbs (save/load/list/find/sync/delete), zero-config private namespace, encrypted client-side.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"bitpub": "./bin/bitpub.js"
|
package/src/commands/browser.js
CHANGED
|
@@ -17,7 +17,7 @@ const http = require('http');
|
|
|
17
17
|
const path = require('path');
|
|
18
18
|
const fs = require('fs');
|
|
19
19
|
const os = require('os');
|
|
20
|
-
const { exec } = require('child_process');
|
|
20
|
+
const { exec, spawn } = require('child_process');
|
|
21
21
|
const { readConfig, BITPUB_DIR } = require('../config');
|
|
22
22
|
const { getSyncedNamespaces, initCache } = require('../db/cache');
|
|
23
23
|
const { isPrivateHcu, decrypt, isEncrypted } = require('../crypto');
|
|
@@ -108,6 +108,244 @@ const ASSET_MIME = {
|
|
|
108
108
|
'.webm': 'video/webm',
|
|
109
109
|
};
|
|
110
110
|
|
|
111
|
+
/* ── Bridge (apps → local CLI) ──────────────────────────────────────
|
|
112
|
+
*
|
|
113
|
+
* Sandboxed app iframes (default-src 'none', no allow-same-origin)
|
|
114
|
+
* cannot fetch, but they CAN postMessage to the parent window. The
|
|
115
|
+
* parent is the BitPub Browser shell; it forwards approved calls to
|
|
116
|
+
* this CLI process, which spawns a child and streams stdout/stderr
|
|
117
|
+
* back over Server-Sent Events.
|
|
118
|
+
*
|
|
119
|
+
* Security floor for v1:
|
|
120
|
+
* - Only functions listed in FUNCTIONS are runnable. Apps cannot
|
|
121
|
+
* execute arbitrary shell.
|
|
122
|
+
* - Arguments are typed and validated per function — no string
|
|
123
|
+
* interpolation into the shell.
|
|
124
|
+
* - The HTTP server binds to localhost only (default port 4141),
|
|
125
|
+
* so the surface is the same trust boundary as the CLI itself:
|
|
126
|
+
* anyone with shell access already has more power than the bridge.
|
|
127
|
+
* - The next layer (permission grants per app + audit log) is a
|
|
128
|
+
* console.html responsibility, not the CLI's.
|
|
129
|
+
*
|
|
130
|
+
* Functions in v1 are hardcoded here. The end state — functions
|
|
131
|
+
* resolved from slices, e.g. bitpub://group:tollbit.com/Functions/<id>
|
|
132
|
+
* — needs the same permission UX and a small sandbox for the script,
|
|
133
|
+
* so we ship the registry shape first and back-fill the resolver.
|
|
134
|
+
*/
|
|
135
|
+
const FUNCTIONS = {
|
|
136
|
+
'granola.ingest': {
|
|
137
|
+
description: 'Pull new Granola meetings into your private namespace.',
|
|
138
|
+
build(args) {
|
|
139
|
+
const limit = clampInt(args && args.limit, 1, 200, 5);
|
|
140
|
+
const script =
|
|
141
|
+
'set -e; ' +
|
|
142
|
+
'bitpub load bitpub://group:tollbit.com/Agents/granola-ingest/script > "$T" && ' +
|
|
143
|
+
`python3 "$T" --limit ${limit}`;
|
|
144
|
+
return {
|
|
145
|
+
cmd: 'bash',
|
|
146
|
+
args: ['-lc', script],
|
|
147
|
+
env: { T: path.join(os.tmpdir(), `.bitpub-fn-granola-ingest.py`) },
|
|
148
|
+
};
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
// Orchestration script for sales_brief.generate lives as a slice at
|
|
153
|
+
// bitpub://private:.../Apps/sales-brief-generator/orchestrator so the
|
|
154
|
+
// logic can be iterated without restarting the CLI. The function below
|
|
155
|
+
// is the thin entry point that loads + runs it.
|
|
156
|
+
'sales_brief.generate': {
|
|
157
|
+
description: 'Synthesize a bespoke sales brief PDF from selected call transcripts.',
|
|
158
|
+
build(args) {
|
|
159
|
+
const a = args || {};
|
|
160
|
+
const transcriptHcus = Array.isArray(a.transcript_hcus) ? a.transcript_hcus.slice(0, 25) : [];
|
|
161
|
+
const template = String(a.template || 'discovery').slice(0, 32).replace(/[^a-z_]/gi, '');
|
|
162
|
+
const customer = String(a.customer || '').slice(0, 120);
|
|
163
|
+
const dealStage = String(a.deal_stage || '').slice(0, 80);
|
|
164
|
+
const extraNotes = String(a.extra_notes || '').slice(0, 2000);
|
|
165
|
+
const outputDir = path.join(os.homedir(), 'Downloads', 'Briefs');
|
|
166
|
+
|
|
167
|
+
if (transcriptHcus.length === 0) {
|
|
168
|
+
throw new Error('no transcripts selected');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const payload = JSON.stringify({
|
|
172
|
+
transcript_hcus: transcriptHcus,
|
|
173
|
+
template,
|
|
174
|
+
customer,
|
|
175
|
+
deal_stage: dealStage,
|
|
176
|
+
extra_notes: extraNotes,
|
|
177
|
+
output_dir: outputDir,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const script =
|
|
181
|
+
'set -e; ' +
|
|
182
|
+
'bitpub load bitpub://private:agent_ewim9gf01nq3/Apps/sales-brief-generator/orchestrator > "$ORCH" && ' +
|
|
183
|
+
'python3 "$ORCH"';
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
cmd: 'bash',
|
|
187
|
+
args: ['-lc', script],
|
|
188
|
+
env: {
|
|
189
|
+
ORCH: path.join(os.tmpdir(), '.bitpub-fn-sales-brief.py'),
|
|
190
|
+
BRIEF_PAYLOAD: payload,
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
'sales_brief.open': {
|
|
197
|
+
description: 'Open a generated PDF in the default viewer.',
|
|
198
|
+
build(args) {
|
|
199
|
+
const p = String((args && args.path) || '');
|
|
200
|
+
if (!p || !p.startsWith('/') || p.includes('"')) throw new Error('invalid path');
|
|
201
|
+
return { cmd: 'bash', args: ['-lc', `open "${p}"`] };
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
'sales_brief.reveal': {
|
|
206
|
+
description: 'Reveal a generated PDF in Finder.',
|
|
207
|
+
build(args) {
|
|
208
|
+
const p = String((args && args.path) || '');
|
|
209
|
+
if (!p || !p.startsWith('/') || p.includes('"')) throw new Error('invalid path');
|
|
210
|
+
return { cmd: 'bash', args: ['-lc', `open -R "${p}"`] };
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
function clampInt(value, lo, hi, dflt) {
|
|
216
|
+
const n = parseInt(value, 10);
|
|
217
|
+
if (!Number.isFinite(n)) return dflt;
|
|
218
|
+
return Math.max(lo, Math.min(hi, n));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Test whether an HCU matches a simple prefix pattern.
|
|
223
|
+
*
|
|
224
|
+
* Two pattern shapes are supported, which is enough for the
|
|
225
|
+
* "deterministic read against a namespace folder" idiom that apps use:
|
|
226
|
+
*
|
|
227
|
+
* - exact: bitpub://private:tp/Apps/foo
|
|
228
|
+
* - prefix: bitpub://private:tp/Transcripts/Raw/*
|
|
229
|
+
*
|
|
230
|
+
* The trailing `/*` means "any direct or transitive descendant," which
|
|
231
|
+
* matches how `bitpub list <folder>` already behaves and what apps want
|
|
232
|
+
* when they say "give me all my transcripts."
|
|
233
|
+
*/
|
|
234
|
+
function hcuMatchesPattern(hcu, pattern) {
|
|
235
|
+
if (!hcu || !pattern) return false;
|
|
236
|
+
if (pattern.endsWith('/*')) {
|
|
237
|
+
return hcu.startsWith(pattern.slice(0, -2) + '/') || hcu === pattern.slice(0, -2);
|
|
238
|
+
}
|
|
239
|
+
return hcu === pattern;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/* Compact list of functions for the bridge router to advertise. */
|
|
243
|
+
function listFunctions() {
|
|
244
|
+
return Object.entries(FUNCTIONS).map(([id, fn]) => ({
|
|
245
|
+
id,
|
|
246
|
+
description: fn.description,
|
|
247
|
+
}));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Stream a function execution to the response as an SSE stream.
|
|
252
|
+
*
|
|
253
|
+
* Wire format:
|
|
254
|
+
* event: started data: { functionId }
|
|
255
|
+
* event: stdout data: { line }
|
|
256
|
+
* event: stderr data: { line }
|
|
257
|
+
* event: error data: { message }
|
|
258
|
+
* event: exit data: { code } ← terminal; connection closes
|
|
259
|
+
*
|
|
260
|
+
* Lines are flushed by newline so the UI shows progress as it happens
|
|
261
|
+
* instead of waiting for the whole stdout buffer to drain at exit.
|
|
262
|
+
*/
|
|
263
|
+
function handleBridgeRun(req, res, url) {
|
|
264
|
+
const functionId = url.searchParams.get('functionId') || '';
|
|
265
|
+
const argsParam = url.searchParams.get('args') || '{}';
|
|
266
|
+
let args = {};
|
|
267
|
+
try { args = JSON.parse(argsParam); } catch { /* ignore — treat as no args */ }
|
|
268
|
+
|
|
269
|
+
const fn = FUNCTIONS[functionId];
|
|
270
|
+
if (!fn) {
|
|
271
|
+
res.statusCode = 404;
|
|
272
|
+
res.setHeader('Content-Type', 'application/json');
|
|
273
|
+
res.end(JSON.stringify({ error: 'unknown_function', functionId }));
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
res.statusCode = 200;
|
|
278
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
279
|
+
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
|
280
|
+
res.setHeader('Connection', 'keep-alive');
|
|
281
|
+
// Defeat any reverse-proxy buffering between us and the parent.
|
|
282
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
283
|
+
|
|
284
|
+
function send(event, payload) {
|
|
285
|
+
if (res.writableEnded) return;
|
|
286
|
+
res.write(`event: ${event}\n`);
|
|
287
|
+
res.write(`data: ${JSON.stringify(payload)}\n\n`);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
send('started', { functionId });
|
|
291
|
+
|
|
292
|
+
let spec;
|
|
293
|
+
try {
|
|
294
|
+
spec = fn.build(args || {});
|
|
295
|
+
} catch (err) {
|
|
296
|
+
send('error', { message: `bad arguments: ${err.message}` });
|
|
297
|
+
send('exit', { code: 2 });
|
|
298
|
+
res.end();
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
let child;
|
|
303
|
+
try {
|
|
304
|
+
child = spawn(spec.cmd, spec.args, {
|
|
305
|
+
env: { ...process.env, ...(spec.env || {}) },
|
|
306
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
307
|
+
});
|
|
308
|
+
} catch (err) {
|
|
309
|
+
send('error', { message: `spawn failed: ${err.message}` });
|
|
310
|
+
send('exit', { code: 1 });
|
|
311
|
+
res.end();
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Buffer per-stream so we can emit one event per line — keeps the
|
|
316
|
+
// log readable and avoids splitting multi-byte UTF-8 sequences.
|
|
317
|
+
function lineSink(streamName) {
|
|
318
|
+
let buf = '';
|
|
319
|
+
return (chunk) => {
|
|
320
|
+
buf += chunk.toString('utf-8');
|
|
321
|
+
let i;
|
|
322
|
+
while ((i = buf.indexOf('\n')) >= 0) {
|
|
323
|
+
const line = buf.slice(0, i).replace(/\r$/, '');
|
|
324
|
+
buf = buf.slice(i + 1);
|
|
325
|
+
send(streamName, { line });
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
child.stdout.on('data', lineSink('stdout'));
|
|
330
|
+
child.stderr.on('data', lineSink('stderr'));
|
|
331
|
+
|
|
332
|
+
child.on('error', (err) => {
|
|
333
|
+
send('error', { message: err.message });
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
child.on('close', (code) => {
|
|
337
|
+
send('exit', { code });
|
|
338
|
+
res.end();
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// If the parent navigates away or the user cancels, reap the child.
|
|
342
|
+
req.on('close', () => {
|
|
343
|
+
if (child && !child.killed) {
|
|
344
|
+
try { child.kill('SIGTERM'); } catch { /* already gone */ }
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
111
349
|
function openInBrowser(url) {
|
|
112
350
|
const cmd = process.platform === 'darwin' ? 'open' :
|
|
113
351
|
process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
@@ -177,6 +415,68 @@ function startBrowserServer(opts = {}) {
|
|
|
177
415
|
return;
|
|
178
416
|
}
|
|
179
417
|
|
|
418
|
+
// Bridge: list registered functions (for the permission UI to
|
|
419
|
+
// describe what an app is about to run).
|
|
420
|
+
if (url.pathname === '/bridge/functions') {
|
|
421
|
+
res.setHeader('Content-Type', 'application/json');
|
|
422
|
+
res.end(JSON.stringify({ functions: listFunctions() }));
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Bridge: stream a function execution to the console as SSE.
|
|
427
|
+
// Console.html forwards the events back to the requesting app
|
|
428
|
+
// iframe via postMessage. See FUNCTIONS above for the registry.
|
|
429
|
+
if (url.pathname === '/bridge/run') {
|
|
430
|
+
handleBridgeRun(req, res, url);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Bridge: deterministic namespace read. Apps call this to fetch
|
|
435
|
+
// any slices that match an HCU pattern (exact or prefix-with-/*).
|
|
436
|
+
// No LLM, no MCP, no auth in the loop — this is the read path
|
|
437
|
+
// that the ingest/read split makes free. Returns full decrypted
|
|
438
|
+
// payload + metadata for each matching slice, capped to a sane
|
|
439
|
+
// limit so a runaway pattern doesn't ship the whole cache.
|
|
440
|
+
if (url.pathname === '/bridge/namespace/list') {
|
|
441
|
+
res.setHeader('Content-Type', 'application/json');
|
|
442
|
+
const pattern = url.searchParams.get('pattern') || '';
|
|
443
|
+
const limit = clampInt(url.searchParams.get('limit'), 1, 1000, 200);
|
|
444
|
+
const fields = (url.searchParams.get('fields') || 'preview').toLowerCase();
|
|
445
|
+
if (!pattern || !/^bitpub:\/\//.test(pattern)) {
|
|
446
|
+
res.statusCode = 400;
|
|
447
|
+
res.end(JSON.stringify({ error: 'bad_pattern', pattern }));
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
const matched = [];
|
|
451
|
+
for (const raw of getAllSlices()) {
|
|
452
|
+
if (!hcuMatchesPattern(raw.hcu, pattern)) continue;
|
|
453
|
+
const slice = (cfg && cfg.api_key) ? decryptSlice(raw, cfg.api_key) : raw;
|
|
454
|
+
let content = '';
|
|
455
|
+
try {
|
|
456
|
+
const payload = typeof slice.payload === 'string' ? JSON.parse(slice.payload) : slice.payload;
|
|
457
|
+
content = (payload && typeof payload.content === 'string') ? payload.content : '';
|
|
458
|
+
} catch { /* leave content blank if payload is malformed */ }
|
|
459
|
+
const item = {
|
|
460
|
+
hcu: slice.hcu,
|
|
461
|
+
version: slice.version,
|
|
462
|
+
last_synced: slice.last_synced,
|
|
463
|
+
author: slice.author,
|
|
464
|
+
byte_size: content.length,
|
|
465
|
+
};
|
|
466
|
+
if (fields === 'full') {
|
|
467
|
+
item.content = content;
|
|
468
|
+
} else {
|
|
469
|
+
// 'preview' = first 480 chars, lets the picker render a
|
|
470
|
+
// useful snippet without shipping every transcript body.
|
|
471
|
+
item.preview = content.slice(0, 480);
|
|
472
|
+
}
|
|
473
|
+
matched.push(item);
|
|
474
|
+
if (matched.length >= limit) break;
|
|
475
|
+
}
|
|
476
|
+
res.end(JSON.stringify({ pattern, count: matched.length, slices: matched }));
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
|
|
180
480
|
if (url.pathname === '/' || url.pathname === '/index.html') {
|
|
181
481
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
182
482
|
res.end(loadHtml());
|
package/static/console.html
CHANGED
|
@@ -473,7 +473,11 @@ svg { flex-shrink: 0; }
|
|
|
473
473
|
.file-row .f-when { font-size: 11px; color: var(--text-subtle); white-space: nowrap; font-variant-numeric: tabular-nums; }
|
|
474
474
|
|
|
475
475
|
/* ── Slice panel (blob) ─────────────────────────────── */
|
|
476
|
-
|
|
476
|
+
/* Slice-view wrapper. Slimmer top/bottom padding than the default
|
|
477
|
+
* .panel so the framed content (especially app iframes) sits closer
|
|
478
|
+
* to both edges of the main area. Top and bottom padding are kept
|
|
479
|
+
* equal so the visible margins around the framed window match. */
|
|
480
|
+
.blob-wrap { padding-top: 12px; padding-bottom: 12px; }
|
|
477
481
|
.blob-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 0 2px 12px; font-size: 12px; color: var(--text-muted); }
|
|
478
482
|
.blob-meta .sep { color: var(--border-strong); }
|
|
479
483
|
|
|
@@ -490,7 +494,28 @@ svg { flex-shrink: 0; }
|
|
|
490
494
|
.encrypted-note svg { color: var(--scope-private); }
|
|
491
495
|
.encrypted-note code { font-family: var(--mono); font-size: 12px; background: var(--bg-card); padding: 1px 5px; border-radius: 3px; border: 1px solid var(--border); }
|
|
492
496
|
|
|
493
|
-
|
|
497
|
+
/* Blob container — acts as a "browser window" framing whatever's
|
|
498
|
+
* inside (markdown / source / sandboxed iframe). For app slices we
|
|
499
|
+
* add .is-app so the container stretches to claim most of the viewport
|
|
500
|
+
* and reads like a real OS window. For markdown/raw content the
|
|
501
|
+
* container hugs its content as before. */
|
|
502
|
+
.blob-container {
|
|
503
|
+
background: var(--bg-card);
|
|
504
|
+
border: 1px solid var(--border);
|
|
505
|
+
border-radius: var(--radius);
|
|
506
|
+
overflow: hidden;
|
|
507
|
+
display: flex; flex-direction: column;
|
|
508
|
+
box-shadow: 0 1px 2px rgba(20,20,20,.03), 0 6px 16px rgba(20,20,20,.04);
|
|
509
|
+
}
|
|
510
|
+
/* Container height math (so top/bottom margins around the iframe
|
|
511
|
+
* read as symmetric): viewport - (addressbar 52 + snapshot-banner ~38
|
|
512
|
+
* + 12 top pad + 12 bottom pad + ~6 safety) ≈ viewport - 120. The
|
|
513
|
+
* safety pixels prevent the page from showing a scrollbar when the
|
|
514
|
+
* snapshot-banner wraps to a second line on narrow widths. */
|
|
515
|
+
.blob-container.is-app { min-height: calc(100vh - 120px); }
|
|
516
|
+
.blob-container.is-app > .app-frame,
|
|
517
|
+
.blob-container.is-app > .content-body,
|
|
518
|
+
.blob-container.is-app > .content-raw { flex: 1; min-height: 0; }
|
|
494
519
|
.blob-toolbar { padding: 8px 12px; background: var(--bg-canvas); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
495
520
|
.blob-toolbar .info { font-size: 11px; color: var(--text-subtle); font-variant-numeric: tabular-nums; }
|
|
496
521
|
.blob-toolbar .spacer { flex: 1; }
|
|
@@ -743,9 +768,14 @@ svg { flex-shrink: 0; }
|
|
|
743
768
|
}
|
|
744
769
|
.cell-state.deleted .pip { background: #b91c1c; text-decoration: none; }
|
|
745
770
|
|
|
746
|
-
/* ── Snapshot banner (slice view) ─────────────────────
|
|
771
|
+
/* ── Snapshot banner (slice view) ─────────────────────
|
|
772
|
+
* Acts as the "title bar" above the content/iframe. Carries the
|
|
773
|
+
* "Latest value v#" label plus the consolidated identity chips
|
|
774
|
+
* (scope, mutability, last-write freshness, author, tags) that used
|
|
775
|
+
* to live in a separate row. Reads as one strip on top of the
|
|
776
|
+
* browser-window-style container below. */
|
|
747
777
|
.snapshot-banner {
|
|
748
|
-
display: flex; align-items: center; gap:
|
|
778
|
+
display: flex; align-items: center; gap: 8px;
|
|
749
779
|
padding: 9px 14px;
|
|
750
780
|
background: var(--bg-canvas);
|
|
751
781
|
border: 1px solid var(--border);
|
|
@@ -756,8 +786,12 @@ svg { flex-shrink: 0; }
|
|
|
756
786
|
}
|
|
757
787
|
.snapshot-banner .title { color: var(--text); font-weight: 600; letter-spacing: -.005em; }
|
|
758
788
|
.snapshot-banner .ver { font-family: var(--mono); color: var(--text-muted); }
|
|
759
|
-
.snapshot-banner .
|
|
760
|
-
.snapshot-banner .
|
|
789
|
+
.snapshot-banner .meta-text { color: var(--text-muted); }
|
|
790
|
+
.snapshot-banner .sep { color: var(--border-strong); }
|
|
791
|
+
.snapshot-banner .scope-pill,
|
|
792
|
+
.snapshot-banner .cell-state,
|
|
793
|
+
.snapshot-banner .agent-chip,
|
|
794
|
+
.snapshot-banner .pill { flex-shrink: 0; }
|
|
761
795
|
.blob-container.has-banner { border-top-left-radius: 0; border-top-right-radius: 0; }
|
|
762
796
|
|
|
763
797
|
/* ── Write guide (slice view) ───────────────────────── */
|
|
@@ -835,9 +869,14 @@ svg { flex-shrink: 0; }
|
|
|
835
869
|
frame fills the blob container's horizontal space and grows to a
|
|
836
870
|
workable default height (the app can be taller; the user scrolls
|
|
837
871
|
the main pane to follow). */
|
|
872
|
+
/* The iframe itself stretches to fill its (flex-sized) parent. The
|
|
873
|
+
* parent .blob-container's min-height drives the visible size, so this
|
|
874
|
+
* just makes sure the iframe occupies it fully and a long app scrolls
|
|
875
|
+
* inside the frame rather than expanding the outer page. */
|
|
838
876
|
.app-frame {
|
|
839
877
|
display: block;
|
|
840
878
|
width: 100%;
|
|
879
|
+
flex: 1;
|
|
841
880
|
min-height: 480px;
|
|
842
881
|
border: 0;
|
|
843
882
|
background: var(--bg-page);
|
|
@@ -1176,20 +1215,41 @@ async function pollData() {
|
|
|
1176
1215
|
const k = s.hcu + '|' + (s.last_synced || '');
|
|
1177
1216
|
if (!oldKey.has(k)) arrivals.add(s.hcu);
|
|
1178
1217
|
}
|
|
1218
|
+
|
|
1219
|
+
// Nothing changed → skip the re-render entirely. Previously we
|
|
1220
|
+
// unconditionally called renderAll() every 30s, which tore down and
|
|
1221
|
+
// rebuilt the main pane — including any sandboxed iframe and the
|
|
1222
|
+
// state inside it (live bridge runs, scroll position, form input).
|
|
1223
|
+
if (arrivals.size === 0) {
|
|
1224
|
+
// Boot-race recovery still needs to fire on every tick: if the
|
|
1225
|
+
// welcome slice appears via some other path, navigate to it.
|
|
1226
|
+
maybeOpenWelcomeSlice();
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1179
1230
|
S.slices = nextSlices;
|
|
1180
1231
|
S.mode = data.mode || S.mode;
|
|
1181
1232
|
S.tree = buildTree(S.slices);
|
|
1182
1233
|
S.stats = computeStats(S.slices);
|
|
1183
1234
|
S.lastUpdated = maxTs(S.slices);
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
setTimeout(() => { S.newArrivals = new Set(); }, 2500);
|
|
1188
|
-
}
|
|
1189
|
-
// Boot-race recovery: if install.sh opened us with ?welcome=1 before
|
|
1190
|
-
// the welcome push had synced down, the slice may show up here on the
|
|
1191
|
-
// first poll. Take the user to it as soon as it appears.
|
|
1235
|
+
S.newArrivals = arrivals;
|
|
1236
|
+
triggerLivePulse();
|
|
1237
|
+
setTimeout(() => { S.newArrivals = new Set(); }, 2500);
|
|
1192
1238
|
maybeOpenWelcomeSlice();
|
|
1239
|
+
|
|
1240
|
+
// If we're currently viewing a slice and *that* slice didn't change,
|
|
1241
|
+
// only refresh the surrounding chrome (sidebar tree, address bar).
|
|
1242
|
+
// The main pane (which holds the iframe) is left alone so the app
|
|
1243
|
+
// keeps its DOM and runtime state.
|
|
1244
|
+
const viewedSliceChanged = S.view === 'slice' && S.selectedHcu && arrivals.has(S.selectedHcu);
|
|
1245
|
+
if (S.view === 'slice' && !viewedSliceChanged) {
|
|
1246
|
+
renderModeBadge();
|
|
1247
|
+
renderAddress();
|
|
1248
|
+
renderTree();
|
|
1249
|
+
$('clear-filter-btn').classList.toggle('hidden', !filterActive());
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1193
1253
|
renderAll();
|
|
1194
1254
|
}
|
|
1195
1255
|
|
|
@@ -2334,25 +2394,11 @@ function renderSlice(sl) {
|
|
|
2334
2394
|
// same info is now shown in the address bar at the top of the window
|
|
2335
2395
|
// — keeping it in the panel was redundant.)
|
|
2336
2396
|
|
|
2337
|
-
//
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
html += `<span class="sep">·</span>`;
|
|
2343
|
-
html += `<span>by</span> ${renderAgentChip(author)}`;
|
|
2344
|
-
if (tags.length) {
|
|
2345
|
-
html += `<span class="sep">·</span>`;
|
|
2346
|
-
for (const t of tags) {
|
|
2347
|
-
const active = S.filter.tags.has(t);
|
|
2348
|
-
html += `<span class="pill tag${active ? ' active' : ''}" onclick="toggleTag('${escAttr(t)}')">${esc(t)}${active ? '<span class="x">×</span>' : ''}</span>`;
|
|
2349
|
-
}
|
|
2350
|
-
}
|
|
2351
|
-
html += '</div>';
|
|
2352
|
-
|
|
2353
|
-
// Write-guide + neighbors surfaced above the content so they're visible
|
|
2354
|
-
// without scrolling — long slices can push them far offscreen otherwise.
|
|
2355
|
-
html += renderWriteGuide(sl);
|
|
2397
|
+
// Sibling navigation still goes above the content. The write-guide
|
|
2398
|
+
// (push/append/safe-write CLI snippets) is intentionally hidden in
|
|
2399
|
+
// this view — it's geek-only and the non-technical audience doesn't
|
|
2400
|
+
// need to see CLI invocations next to their app. A future "developer
|
|
2401
|
+
// mode" toggle can bring it back behind an opt-in.
|
|
2356
2402
|
html += renderSiblingsPanel(sl);
|
|
2357
2403
|
|
|
2358
2404
|
// Encrypted note when remote + private
|
|
@@ -2362,11 +2408,26 @@ function renderSlice(sl) {
|
|
|
2362
2408
|
</div>`;
|
|
2363
2409
|
}
|
|
2364
2410
|
|
|
2365
|
-
// Snapshot banner —
|
|
2366
|
-
|
|
2411
|
+
// Snapshot banner — now also carries the identity/mutability chips
|
|
2412
|
+
// that used to live in a separate `.blob-meta` row above. Consolidating
|
|
2413
|
+
// saves a row and reads as one continuous strip above the iframe,
|
|
2414
|
+
// closer to a browser-tab metadata bar than two stacked headers.
|
|
2415
|
+
const tagsHtml = tags.map(t => {
|
|
2416
|
+
const active = S.filter.tags.has(t);
|
|
2417
|
+
return `<span class="pill tag${active ? ' active' : ''}" onclick="toggleTag('${escAttr(t)}')">${esc(t)}${active ? '<span class="x">×</span>' : ''}</span>`;
|
|
2418
|
+
}).join('');
|
|
2419
|
+
html += `<div class="snapshot-banner" style="margin-top:${isEncryptedRemote ? '0' : '12px'}">
|
|
2367
2420
|
<span class="title">Latest value</span>
|
|
2368
2421
|
<span class="ver">v${ver}</span>
|
|
2369
|
-
<span class="
|
|
2422
|
+
<span class="sep">·</span>
|
|
2423
|
+
${renderScopePill(type, scope)}
|
|
2424
|
+
${renderCellState(sl)}
|
|
2425
|
+
<span class="sep">·</span>
|
|
2426
|
+
<span class="fresh-dot ${freshClass}" title="${fresh}" style="width:7px;height:7px"></span>
|
|
2427
|
+
<span class="meta-text">last write ${sl.last_synced ? timeAgo(sl.last_synced) : '—'}</span>
|
|
2428
|
+
<span class="sep">·</span>
|
|
2429
|
+
<span class="meta-text">by</span> ${renderAgentChip(author)}
|
|
2430
|
+
${tagsHtml ? '<span class="sep">·</span>' + tagsHtml : ''}
|
|
2370
2431
|
</div>`;
|
|
2371
2432
|
|
|
2372
2433
|
// Content-type dispatcher. The payload's declared format is the
|
|
@@ -2379,7 +2440,7 @@ function renderSlice(sl) {
|
|
|
2379
2440
|
// the v1 cut from product discussion.
|
|
2380
2441
|
const canRenderAsApp = kind === 'html' && (type === 'private' || type === 'group');
|
|
2381
2442
|
|
|
2382
|
-
html +=
|
|
2443
|
+
html += `<div class="blob-container has-banner${canRenderAsApp ? ' is-app' : ''}">`;
|
|
2383
2444
|
html += '<div class="blob-toolbar">';
|
|
2384
2445
|
html += '<div class="btn-group">';
|
|
2385
2446
|
const previewLabel = canRenderAsApp ? 'App' : 'Preview';
|
|
@@ -2454,14 +2515,22 @@ function detectContentKind(sl, content) {
|
|
|
2454
2515
|
* - referrerpolicy="no-referrer" so even allowed sub-resources can't
|
|
2455
2516
|
* leak the user's location.
|
|
2456
2517
|
*
|
|
2457
|
-
*
|
|
2458
|
-
*
|
|
2459
|
-
*
|
|
2518
|
+
* Bridge (added in this version):
|
|
2519
|
+
* - A small inline shim defines `window.bitpub.run(id, args, cb)`
|
|
2520
|
+
* and `window.bitpub.watch(...)`. Calls postMessage the parent;
|
|
2521
|
+
* parent (this console.html) forwards approved invocations to the
|
|
2522
|
+
* local CLI's /bridge/run SSE endpoint and streams events back.
|
|
2523
|
+
* - The iframe never reaches the network itself — only the parent
|
|
2524
|
+
* does. CSP can stay locked down (default-src 'none').
|
|
2460
2525
|
*/
|
|
2461
2526
|
function renderHtmlAppFrame(html) {
|
|
2462
2527
|
const csp = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; base-uri 'none'; form-action 'none'">`;
|
|
2463
2528
|
const baseTag = `<base target="_blank">`;
|
|
2464
|
-
|
|
2529
|
+
// The shim tag uses \x73 + escaped slash so the closing pattern can't
|
|
2530
|
+
// be detected by the outer HTML parser. The escapes are transparent to
|
|
2531
|
+
// the JS parser but defeat the HTML scanner's greedy script-end match.
|
|
2532
|
+
const shim = `<\x73cript>${BRIDGE_SHIM_SOURCE}<\/script>`;
|
|
2533
|
+
const headInject = `${csp}${baseTag}${shim}`;
|
|
2465
2534
|
|
|
2466
2535
|
let doc;
|
|
2467
2536
|
if (/<head[\s>]/i.test(html)) {
|
|
@@ -2480,6 +2549,258 @@ function renderHtmlAppFrame(html) {
|
|
|
2480
2549
|
return `<iframe class="app-frame" sandbox="allow-scripts" referrerpolicy="no-referrer" srcdoc="${srcdoc}"></iframe>`;
|
|
2481
2550
|
}
|
|
2482
2551
|
|
|
2552
|
+
/**
|
|
2553
|
+
* Source of the in-iframe bridge shim. Kept as a template-literal so
|
|
2554
|
+
* we can embed it verbatim in the srcdoc above. The shim's only job is
|
|
2555
|
+
* to round-trip postMessage between the app code and the parent
|
|
2556
|
+
* window — it neither validates inputs nor reaches the network. The
|
|
2557
|
+
* parent (handleBridgeMessage below) is the trust boundary.
|
|
2558
|
+
*
|
|
2559
|
+
* The shim exposes a deliberately small callback-based API:
|
|
2560
|
+
*
|
|
2561
|
+
* window.bitpub.run(functionId, args, {
|
|
2562
|
+
* onStart(payload) // bridge accepted the call
|
|
2563
|
+
* onStdout(line) // one line of stdout
|
|
2564
|
+
* onStderr(line) // one line of stderr
|
|
2565
|
+
* onError({message}) // bridge or spawn-level error
|
|
2566
|
+
* onExit(code) // terminal — handler removed after this
|
|
2567
|
+
* })
|
|
2568
|
+
*
|
|
2569
|
+
* Returns the requestId so the app can cancel later (not yet wired).
|
|
2570
|
+
*/
|
|
2571
|
+
const BRIDGE_SHIM_SOURCE = String.raw`
|
|
2572
|
+
(function () {
|
|
2573
|
+
if (window.bitpub) return;
|
|
2574
|
+
var nextId = 0;
|
|
2575
|
+
var runHandlers = new Map();
|
|
2576
|
+
var readHandlers = new Map();
|
|
2577
|
+
window.addEventListener('message', function (e) {
|
|
2578
|
+
var d = e.data;
|
|
2579
|
+
if (!d || d.__bitpub !== true) return;
|
|
2580
|
+
if (d.type === 'bitpub.event') {
|
|
2581
|
+
var h = runHandlers.get(d.requestId);
|
|
2582
|
+
if (!h) return;
|
|
2583
|
+
var p = d.payload || {};
|
|
2584
|
+
if (d.event === 'started' && h.onStart) h.onStart(p);
|
|
2585
|
+
else if (d.event === 'stdout' && h.onStdout) h.onStdout(p.line || '');
|
|
2586
|
+
else if (d.event === 'stderr' && h.onStderr) h.onStderr(p.line || '');
|
|
2587
|
+
else if (d.event === 'error' && h.onError) h.onError(p);
|
|
2588
|
+
else if (d.event === 'exit') {
|
|
2589
|
+
if (h.onExit) h.onExit(typeof p.code === 'number' ? p.code : null);
|
|
2590
|
+
runHandlers.delete(d.requestId);
|
|
2591
|
+
}
|
|
2592
|
+
} else if (d.type === 'bitpub.namespace.list.result') {
|
|
2593
|
+
var rh = readHandlers.get(d.requestId);
|
|
2594
|
+
if (!rh) return;
|
|
2595
|
+
readHandlers.delete(d.requestId);
|
|
2596
|
+
if (d.ok) rh.resolve({ slices: d.slices || [], count: d.count || 0, pattern: d.pattern || '' });
|
|
2597
|
+
else rh.reject(new Error(d.error || 'namespace read failed'));
|
|
2598
|
+
}
|
|
2599
|
+
});
|
|
2600
|
+
window.bitpub = {
|
|
2601
|
+
run: function (functionId, args, callbacks) {
|
|
2602
|
+
var requestId = ++nextId;
|
|
2603
|
+
runHandlers.set(requestId, callbacks || {});
|
|
2604
|
+
window.parent.postMessage({
|
|
2605
|
+
__bitpub: true,
|
|
2606
|
+
type: 'bitpub.run',
|
|
2607
|
+
requestId: requestId,
|
|
2608
|
+
functionId: String(functionId),
|
|
2609
|
+
args: args || {}
|
|
2610
|
+
}, '*');
|
|
2611
|
+
return requestId;
|
|
2612
|
+
},
|
|
2613
|
+
namespace: {
|
|
2614
|
+
// list(pattern, { fields, limit })
|
|
2615
|
+
// pattern bitpub://... or bitpub://.../* (folder + descendants)
|
|
2616
|
+
// fields 'preview' (first ~480 chars) | 'full' default 'preview'
|
|
2617
|
+
// limit max slices returned default 200
|
|
2618
|
+
// Returns Promise<{ slices, count, pattern }>.
|
|
2619
|
+
// This is the deterministic-read path: no LLM, no MCP, no auth
|
|
2620
|
+
// dance. Apps that need to render data already in the user's
|
|
2621
|
+
// namespace should use this, not bitpub.run.
|
|
2622
|
+
list: function (pattern, opts) {
|
|
2623
|
+
return new Promise(function (resolve, reject) {
|
|
2624
|
+
var requestId = ++nextId;
|
|
2625
|
+
readHandlers.set(requestId, { resolve: resolve, reject: reject });
|
|
2626
|
+
window.parent.postMessage({
|
|
2627
|
+
__bitpub: true,
|
|
2628
|
+
type: 'bitpub.namespace.list',
|
|
2629
|
+
requestId: requestId,
|
|
2630
|
+
pattern: String(pattern || ''),
|
|
2631
|
+
fields: (opts && opts.fields) || 'preview',
|
|
2632
|
+
limit: (opts && opts.limit) || 200
|
|
2633
|
+
}, '*');
|
|
2634
|
+
setTimeout(function () {
|
|
2635
|
+
if (!readHandlers.has(requestId)) return;
|
|
2636
|
+
readHandlers.delete(requestId);
|
|
2637
|
+
reject(new Error('namespace.list timed out'));
|
|
2638
|
+
}, 10000);
|
|
2639
|
+
});
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
};
|
|
2643
|
+
})();
|
|
2644
|
+
`;
|
|
2645
|
+
|
|
2646
|
+
/* ── Bridge router (parent side) ─────────────────────────────────────
|
|
2647
|
+
* Receives postMessages from rendered app iframes and forwards the
|
|
2648
|
+
* approved `bitpub.run(...)` calls to the local CLI's SSE endpoint at
|
|
2649
|
+
* /bridge/run. Each SSE event is reformatted as a `bitpub.event`
|
|
2650
|
+
* postMessage and sent back to the originating iframe so the app's
|
|
2651
|
+
* onStdout / onStderr / onExit callbacks fire.
|
|
2652
|
+
*
|
|
2653
|
+
* Trust model for v1:
|
|
2654
|
+
* - The iframe is at an opaque (null) origin, so we can't whitelist
|
|
2655
|
+
* by origin string. Instead we validate the message shape and
|
|
2656
|
+
* trust the in-process renderHtmlAppFrame to only mint iframes
|
|
2657
|
+
* for slices we already render (private + group scopes).
|
|
2658
|
+
* - The CLI enforces the function whitelist + argument types, so a
|
|
2659
|
+
* malformed message just gets rejected with HTTP 404 / SSE error.
|
|
2660
|
+
* - First UX iteration shows a non-blocking "Running" badge in the
|
|
2661
|
+
* chrome while at least one bridge call is active. Permission
|
|
2662
|
+
* banners with per-app grants come next iteration.
|
|
2663
|
+
*/
|
|
2664
|
+
const BRIDGE_RUNS = new Map(); // requestId → { es, source, functionId }
|
|
2665
|
+
let BRIDGE_RUN_COUNT = 0;
|
|
2666
|
+
|
|
2667
|
+
function ensureBridgeBadge() {
|
|
2668
|
+
let el = document.getElementById('bridge-badge');
|
|
2669
|
+
if (el) return el;
|
|
2670
|
+
el = document.createElement('div');
|
|
2671
|
+
el.id = 'bridge-badge';
|
|
2672
|
+
el.style.cssText = [
|
|
2673
|
+
'position:fixed', 'bottom:14px', 'right:14px', 'z-index:9999',
|
|
2674
|
+
'background:#1A1A19', 'color:#fff', 'padding:8px 12px',
|
|
2675
|
+
'border-radius:8px', 'font:500 12.5px -apple-system,BlinkMacSystemFont,sans-serif',
|
|
2676
|
+
'box-shadow:0 6px 24px rgba(0,0,0,.18), 0 2px 6px rgba(0,0,0,.12)',
|
|
2677
|
+
'display:none', 'align-items:center', 'gap:8px',
|
|
2678
|
+
].join(';');
|
|
2679
|
+
el.innerHTML = '<span style="width:8px;height:8px;border-radius:50%;background:#7ee787;display:inline-block;box-shadow:0 0 0 0 rgba(126,231,135,.6);animation:bp-pulse 1.6s ease-out infinite"></span><span id="bridge-badge-text">Running</span>';
|
|
2680
|
+
const style = document.createElement('style');
|
|
2681
|
+
style.textContent = '@keyframes bp-pulse{0%{box-shadow:0 0 0 0 rgba(126,231,135,.6)}70%{box-shadow:0 0 0 8px rgba(126,231,135,0)}100%{box-shadow:0 0 0 0 rgba(126,231,135,0)}}';
|
|
2682
|
+
document.head.appendChild(style);
|
|
2683
|
+
document.body.appendChild(el);
|
|
2684
|
+
return el;
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
function updateBridgeBadge() {
|
|
2688
|
+
const el = ensureBridgeBadge();
|
|
2689
|
+
if (BRIDGE_RUN_COUNT <= 0) { el.style.display = 'none'; return; }
|
|
2690
|
+
const label = BRIDGE_RUN_COUNT === 1
|
|
2691
|
+
? Array.from(BRIDGE_RUNS.values()).map(r => r.functionId).filter(Boolean)[0]
|
|
2692
|
+
: `${BRIDGE_RUN_COUNT} functions running`;
|
|
2693
|
+
document.getElementById('bridge-badge-text').textContent = `Running ${label}`;
|
|
2694
|
+
el.style.display = 'inline-flex';
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
function bridgeForward(source, requestId, event, payload) {
|
|
2698
|
+
if (!source) return;
|
|
2699
|
+
try {
|
|
2700
|
+
source.postMessage({
|
|
2701
|
+
__bitpub: true,
|
|
2702
|
+
type: 'bitpub.event',
|
|
2703
|
+
requestId, event, payload,
|
|
2704
|
+
}, '*');
|
|
2705
|
+
} catch (_) { /* iframe gone */ }
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
function handleBridgeRunMessage(source, msg) {
|
|
2709
|
+
const { requestId, functionId, args } = msg;
|
|
2710
|
+
if (typeof requestId !== 'number' || !functionId) return;
|
|
2711
|
+
|
|
2712
|
+
const qs = new URLSearchParams({
|
|
2713
|
+
functionId: String(functionId),
|
|
2714
|
+
args: JSON.stringify(args || {}),
|
|
2715
|
+
});
|
|
2716
|
+
|
|
2717
|
+
let es;
|
|
2718
|
+
try {
|
|
2719
|
+
es = new EventSource(`/bridge/run?${qs.toString()}`);
|
|
2720
|
+
} catch (err) {
|
|
2721
|
+
bridgeForward(source, requestId, 'error', { message: err.message });
|
|
2722
|
+
bridgeForward(source, requestId, 'exit', { code: 1 });
|
|
2723
|
+
return;
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
BRIDGE_RUNS.set(requestId, { es, source, functionId });
|
|
2727
|
+
BRIDGE_RUN_COUNT++;
|
|
2728
|
+
updateBridgeBadge();
|
|
2729
|
+
|
|
2730
|
+
function relay(name) {
|
|
2731
|
+
es.addEventListener(name, (ev) => {
|
|
2732
|
+
let payload = {};
|
|
2733
|
+
try { payload = JSON.parse(ev.data); } catch { /* ignore malformed */ }
|
|
2734
|
+
bridgeForward(source, requestId, name, payload);
|
|
2735
|
+
});
|
|
2736
|
+
}
|
|
2737
|
+
relay('started');
|
|
2738
|
+
relay('stdout');
|
|
2739
|
+
relay('stderr');
|
|
2740
|
+
relay('error');
|
|
2741
|
+
|
|
2742
|
+
es.addEventListener('exit', (ev) => {
|
|
2743
|
+
let payload = {};
|
|
2744
|
+
try { payload = JSON.parse(ev.data); } catch { /* ignore */ }
|
|
2745
|
+
bridgeForward(source, requestId, 'exit', payload);
|
|
2746
|
+
es.close();
|
|
2747
|
+
BRIDGE_RUNS.delete(requestId);
|
|
2748
|
+
BRIDGE_RUN_COUNT--;
|
|
2749
|
+
updateBridgeBadge();
|
|
2750
|
+
});
|
|
2751
|
+
|
|
2752
|
+
es.onerror = () => {
|
|
2753
|
+
// Connection loss / endpoint down. The bridge already emits an
|
|
2754
|
+
// explicit `exit` for graceful termination, so reaching here means
|
|
2755
|
+
// an unexpected drop — surface it and clean up.
|
|
2756
|
+
if (!BRIDGE_RUNS.has(requestId)) return;
|
|
2757
|
+
bridgeForward(source, requestId, 'error', { message: 'bridge stream closed unexpectedly' });
|
|
2758
|
+
bridgeForward(source, requestId, 'exit', { code: -1 });
|
|
2759
|
+
try { es.close(); } catch (_) {}
|
|
2760
|
+
BRIDGE_RUNS.delete(requestId);
|
|
2761
|
+
BRIDGE_RUN_COUNT--;
|
|
2762
|
+
updateBridgeBadge();
|
|
2763
|
+
};
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
async function handleBridgeNamespaceListMessage(source, msg) {
|
|
2767
|
+
const { requestId, pattern, fields, limit } = msg;
|
|
2768
|
+
if (typeof requestId !== 'number' || !pattern) return;
|
|
2769
|
+
const reply = (extra) => {
|
|
2770
|
+
try {
|
|
2771
|
+
source.postMessage({
|
|
2772
|
+
__bitpub: true,
|
|
2773
|
+
type: 'bitpub.namespace.list.result',
|
|
2774
|
+
requestId, pattern,
|
|
2775
|
+
...extra,
|
|
2776
|
+
}, '*');
|
|
2777
|
+
} catch (_) { /* iframe gone */ }
|
|
2778
|
+
};
|
|
2779
|
+
try {
|
|
2780
|
+
const qs = new URLSearchParams({
|
|
2781
|
+
pattern: String(pattern),
|
|
2782
|
+
fields: String(fields || 'preview'),
|
|
2783
|
+
limit: String(limit || 200),
|
|
2784
|
+
});
|
|
2785
|
+
const r = await fetch(`/bridge/namespace/list?${qs.toString()}`);
|
|
2786
|
+
const body = await r.json();
|
|
2787
|
+
if (!r.ok) {
|
|
2788
|
+
reply({ ok: false, error: body && body.error || `http ${r.status}` });
|
|
2789
|
+
return;
|
|
2790
|
+
}
|
|
2791
|
+
reply({ ok: true, slices: body.slices || [], count: body.count || 0 });
|
|
2792
|
+
} catch (err) {
|
|
2793
|
+
reply({ ok: false, error: err && err.message || 'fetch failed' });
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2797
|
+
window.addEventListener('message', (e) => {
|
|
2798
|
+
const d = e.data;
|
|
2799
|
+
if (!d || typeof d !== 'object' || d.__bitpub !== true) return;
|
|
2800
|
+
if (d.type === 'bitpub.run') handleBridgeRunMessage(e.source, d);
|
|
2801
|
+
else if (d.type === 'bitpub.namespace.list') handleBridgeNamespaceListMessage(e.source, d);
|
|
2802
|
+
});
|
|
2803
|
+
|
|
2483
2804
|
function renderWriteGuide(sl) {
|
|
2484
2805
|
const addr = sl.hcu;
|
|
2485
2806
|
const ver = sl.metadata.version || 1;
|