Theseus: bundle Pithos 0.2.0 (sidebar panel)

Rebuilt from Pithos/ with scripts/build-theseus-addon.mjs. Pithos moves from a toolbar menu that opened a loopback tab to a left-sidebar panel. Existing installs get the same version over the extension update channel.
This commit is contained in:
Local Dev 2026-10-03 19:34:55 +02:00
parent 1e461e9b83
commit 8186407b61
5 changed files with 198 additions and 48 deletions

View file

@ -1,27 +1,13 @@
{
"id": "pithos",
"name": "Pithos",
"version": "0.1.0",
"description": "Run s3d, the Sia S3 gateway, from Theseus: connect it to a Sia indexer, create S3 users and access keys, browse and share buckets, and watch uploads reach Sia.",
"version": "0.2.0",
"description": "Run s3d, the Sia S3 gateway, from the Theseus sidebar: connect it to a Sia indexer, create S3 users and access keys, browse and share buckets, and watch uploads reach Sia.",
"author": "Silent Mode",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM2RkYzk3Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMWU4ZjZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIyIDZoMjB2NWMwIDItMiAzLTIgNSA4IDMgMTQgMTEgMTQgMjEgMCAxMi05IDIxLTIyIDIxUzEwIDQ5IDEwIDM3YzAtMTAgNi0xOCAxNC0yMSAwLTItMi0zLTItNXoiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMTQgMzBoMzZNMTMgNDBoMzgiIHN0cm9rZT0iIzBiMWYxOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9Ii41NSIvPjwvc3ZnPgo=",
"main": "index.js",
"capabilities": [
"toolbar-menu"
"sidebar-panel"
],
"toolbar-menu": {
"title": "Pithos",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM2RkYzk3Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMWU4ZjZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIyIDZoMjB2NWMwIDItMiAzLTIgNSA4IDMgMTQgMTEgMTQgMjEgMCAxMi05IDIxLTIyIDIxUzEwIDQ5IDEwIDM3YzAtMTAgNi0xOCAxNC0yMSAwLTItMi0zLTItNXoiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMTQgMzBoMzZNMTMgNDBoMzgiIHN0cm9rZT0iIzBiMWYxOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9Ii41NSIvPjwvc3ZnPgo=",
"items": [
{
"id": "open",
"label": "Open Pithos"
},
{
"id": "stop",
"label": "Stop s3d"
}
]
},
"updateURL": "https://navigate.st/bns/theseus.x/extensions/pithos/updates.json"
}

View file

@ -53,6 +53,9 @@ export async function createPithos(opts = {}) {
const login = new LoginSession(daemon, { registration: () => cli.registration() });
const secret = crypto.randomBytes(24).toString('base64url');
const sessions = new Set();
// One-time download links: id -> { path, exp }. Lets a host hand a file
// to its own download manager, which carries no session cookie.
const tickets = new Map();
const subscribers = new Set();
let versionCache = null;
@ -302,7 +305,16 @@ export async function createPithos(opts = {}) {
// DNS-rebinding guard: a page on evil.example resolving to 127.0.0.1 still
// sends its own Host header.
if (!hostAllowed(req.headers.host)) { res.writeHead(421); return res.end('misdirected request'); }
const url = new URL(req.url, `http://${req.headers.host}`);
let url = new URL(req.url, `http://${req.headers.host}`);
let viaTicket = false;
const dl = /^\/dl\/([A-Za-z0-9_-]{20,})$/.exec(url.pathname);
if (dl && req.method === 'GET') {
const t = tickets.get(dl[1]);
tickets.delete(dl[1]);
if (!t || t.exp < Date.now()) { res.writeHead(410); return res.end('link expired'); }
url = new URL(t.path, url);
viaTicket = true;
}
res.setHeader('x-frame-options', 'DENY');
res.setHeader('referrer-policy', 'no-referrer');
@ -336,7 +348,7 @@ export async function createPithos(opts = {}) {
}
if (url.pathname.startsWith('/api/')) {
if (!cookieSession(req)) throw new HttpError(401, 'not signed in');
if (!viaTicket && !cookieSession(req)) throw new HttpError(401, 'not signed in');
if (req.method !== 'GET') requireCsrf(req);
if (url.pathname === '/api/events') {
@ -387,6 +399,21 @@ export async function createPithos(opts = {}) {
url: baseUrl,
// Opening this URL signs the view in.
authUrl: `${baseUrl}?t=${secret}`,
// For hosts that call the API themselves (the Theseus sidebar bridge):
// a session cookie that never touches a browser.
internalSession() {
const id = crypto.randomBytes(24).toString('base64url');
sessions.add(id);
return `pithos=${id}`;
},
// A single-use, 60-second URL for one object download.
downloadUrl(apiPath) {
if (!/^\/api\/s3\/[^/]+\/buckets\/[^/]+\/object\?/.test(apiPath)) throw new Error('only object downloads');
const id = crypto.randomBytes(24).toString('base64url');
tickets.set(id, { path: apiPath, exp: Date.now() + 60_000 });
for (const [k, v] of tickets) if (v.exp < Date.now()) tickets.delete(k);
return `${baseUrl}dl/${id}`;
},
async close() {
login.cancel();
for (const s of subscribers) s.end();

View file

@ -1,7 +1,9 @@
// Pithos for Theseus. The add-on runs the same control server as the web
// console and desktop app, in Theseus's main process, and opens its UI in a
// normal tab at a random loopback port. The first URL carries a one-time
// secret that the server swaps for an HttpOnly cookie.
// console and desktop app, in Theseus's main process, and shows the same UI
// as a left-sidebar panel. The panel is the add-on's own ui/index.html, so
// no loopback address ever appears in the address bar: the UI's API calls,
// uploads and live events travel over the add-on IPC bridge (invoke / emit),
// and this file forwards them to the server with an internal session.
//
// Loaded with require() by the add-on host; core/ is ESM, hence import().
@ -10,6 +12,8 @@ const { pathToFileURL } = require("node:url");
let pithos = null;
let starting = null;
let cookie = null;
let events = null; // AbortController for the /api/events relay
module.exports = {
activate(api) {
@ -22,36 +26,103 @@ module.exports = {
if (!starting) {
starting = (async () => {
const { createPithos } = await import(pathToFileURL(path.join(__dirname, "core", "server.js")).href);
pithos = await createPithos({
const p = await createPithos({
host: "127.0.0.1",
port: 0,
hostName: "theseus",
binDir: path.join(dataDir, "bin"),
openExternal: (url) => api.openTab(url),
});
api.log(`control server on ${pithos.url}`);
return pithos;
cookie = p.internalSession();
pithos = p;
api.log(`control server on ${p.url}`);
return p;
})().finally(() => { starting = null; });
}
return starting;
}
api.onMessage("menu-select", async (payload) => {
const id = payload && payload.id;
if (id === "open") {
const p = await ensureServer();
api.openTab(p.authUrl);
} else if (id === "stop") {
if (pithos) await pithos.daemon.stop();
async function call(method, apiPath, { body, headers = {} } = {}) {
const p = await ensureServer();
const res = await fetch(new URL(apiPath, p.url), {
method,
headers: { cookie, "x-pithos": "1", ...headers },
body,
});
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { data = { error: text }; }
return { status: res.status, data };
}
// Relay the server's event stream to the panel as api.emit events.
async function startEvents() {
if (events) return;
const p = await ensureServer();
const ctl = new AbortController();
events = ctl;
try {
const res = await fetch(new URL("/api/events", p.url), { headers: { cookie }, signal: ctl.signal });
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf("\n\n")) >= 0) {
const block = buf.slice(0, i);
buf = buf.slice(i + 2);
const ev = /^event: (.+)$/m.exec(block);
const data = /^data: (.+)$/m.exec(block);
if (ev && data) api.emit("pithos-event", { event: ev[1], data: JSON.parse(data[1]) });
}
}
} catch (e) {
if (!ctl.signal.aborted) api.log("event relay stopped:", e.message);
} finally {
if (events === ctl) events = null;
}
}
api.onMessage("api", ({ method, path: apiPath, body }) =>
call(method || "GET", apiPath, body === undefined ? {} : { body: JSON.stringify(body), headers: { "content-type": "application/json" } }));
// Uploads arrive as one ArrayBuffer over IPC; forward it as the PUT body.
api.onMessage("upload", ({ path: apiPath, bytes, type }) =>
call("PUT", apiPath, { body: Buffer.from(bytes), headers: { "content-type": type || "application/octet-stream" } }));
// Hand the file to Theseus's own download manager through a one-time link.
api.onMessage("download", async ({ path: apiPath }) => {
const p = await ensureServer();
const { session } = require("electron");
session.defaultSession.downloadURL(p.downloadUrl(apiPath));
return { ok: true };
});
api.onMessage("events-start", () => { startEvents(); return { ok: true }; });
api.onMessage("open-external", ({ url }) => {
if (!/^https:\/\//.test(url)) throw new Error("only https links");
api.openTab(url);
return { ok: true };
});
// A full browser-tab view, for anyone who prefers it to the sidebar.
api.onMessage("open-in-tab", async () => {
const p = await ensureServer();
api.openTab(p.authUrl);
return { ok: true };
});
api.registerSidebarPanel({ id: "main", title: "Pithos", page: "ui/index.html" });
// The host has no quit hook for add-ons; without this an s3d we started
// would outlive Theseus.
process.on("exit", () => { try { pithos && pithos.daemon.child && pithos.daemon.child.kill(); } catch {} });
},
async deactivate() {
if (pithos) { const p = pithos; pithos = null; await p.close(); }
if (events) { events.abort(); events = null; }
if (pithos) { const p = pithos; pithos = null; cookie = null; await p.close(); }
},
};

View file

@ -74,12 +74,12 @@ nav a.active { background: var(--accent-soft); color: var(--accent); font-weight
.dot.starting, .dot.stopping { background: var(--warn); }
.dot.crashed { background: var(--danger); }
main { padding: 28px 32px 48px; max-width: 1100px; width: 100%; }
main { padding: 28px 32px 48px; max-width: 1100px; width: 100%; min-width: 0; }
.page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-bottom: 20px; flex-wrap: wrap; }
.page-head p { margin: 0; }
.grid { display: grid; gap: 16px; }
.grid.two { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); }
.grid.two { grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr)); }
.card {
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
padding: 18px 20px;
@ -95,7 +95,7 @@ dl.kv { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; ma
dl.kv dt { color: var(--muted); }
dl.kv dd { margin: 0; overflow-wrap: anywhere; }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(150px, 100%), 1fr)); gap: 12px; }
.stat { background: var(--surface-2); border-radius: 8px; padding: 12px 14px; }
.stat .v { font-size: 22px; font-weight: 650; font-variant-numeric: tabular-nums; }
.stat .l { color: var(--muted); font-size: 12.5px; }
@ -126,7 +126,7 @@ label.field { display: flex; flex-direction: column; gap: 4px; }
label.field > span { font-weight: 550; font-size: 13px; }
label.field > small { color: var(--muted); }
label.check { display: flex; align-items: center; gap: 8px; }
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; }
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr)); gap: 14px; }
/* tables */
table { width: 100%; border-collapse: collapse; }
@ -209,3 +209,12 @@ pre.snippet { background: var(--surface-2); padding: 10px 12px; border-radius: 8
.upload { grid-template-columns: 1fr 80px 50px; }
.hide-sm { display: none; }
}
/* Theseus sidebar: widen / open-in-tab controls */
.host-controls { display: flex; gap: 6px; flex-wrap: wrap; margin-top: auto; }
.host-controls + .daemon-pill { margin-top: 0; }
@media (max-width: 760px) { .host-controls { margin-top: 0; } }
/* Narrow hosts (Theseus sidebar): never scroll sideways. */
html, body { overflow-x: hidden; }
.card { min-width: 0; }

View file

@ -28,7 +28,18 @@ function put(el, ...children) {
el.replaceChildren(...children.flat(Infinity).filter((c) => c != null && c !== false));
}
// Inside the Theseus sidebar the page is the add-on's own file, with no
// server origin to fetch from: every call goes over the add-on IPC bridge
// and the add-on forwards it to the control server.
const bridge = window.silentmode && typeof window.silentmode.invoke === 'function' ? window.silentmode : null;
async function api(method, path, body, { raw } = {}) {
if (bridge) {
const r = await bridge.invoke('api', { method, path, body });
const data = r.data || {};
if (r.status >= 400) throw new Error(data.error || `HTTP ${r.status}`);
return data;
}
const opts = { method, headers: { 'x-pithos': '1' }, credentials: 'same-origin' };
if (body !== undefined) {
opts.headers['content-type'] = 'application/json';
@ -127,24 +138,32 @@ async function refreshStatus() {
} catch (e) { fail(e); }
}
function connectEvents() {
const es = new EventSource('/api/events');
es.addEventListener('status', (e) => {
const eventHandlers = {
status(d) {
if (!state.status) return;
state.status.daemon = JSON.parse(e.data);
state.status.daemon = d;
renderPill();
emit();
});
es.addEventListener('log', (e) => {
const l = JSON.parse(e.data);
},
log(l) {
if (l.seq <= state.logSeq) return;
state.logSeq = l.seq;
state.logs.push(l);
if (state.logs.length > 2000) state.logs.shift();
logSink?.(l);
});
es.addEventListener('login', (e) => { state.login = JSON.parse(e.data); emit(); });
es.addEventListener('install', (e) => { const p = JSON.parse(e.data); if (p.phase === 'extract') toast('Verified checksum, installing…'); });
},
login(d) { state.login = d; emit(); },
install(p) { if (p.phase === 'extract') toast('Verified checksum, installing…'); },
};
function connectEvents() {
if (bridge) {
bridge.on('pithos-event', ({ event, data }) => eventHandlers[event]?.(data));
bridge.invoke('events-start').catch(fail);
return;
}
const es = new EventSource('/api/events');
for (const [name, fn] of Object.entries(eventHandlers)) es.addEventListener(name, (e) => fn(JSON.parse(e.data)));
es.onerror = () => { /* EventSource reconnects on its own */ };
}
@ -410,6 +429,7 @@ function setup(main) {
}
async function openLink(url) {
if (bridge) { try { await bridge.invoke('open-external', { url }); } catch (e) { fail(e); } return; }
const host = state.status?.host;
if (host === 'desktop' || host === 'theseus') {
try { await api('POST', '/api/open-external', { url }); return; } catch { /* fall through */ }
@ -704,7 +724,12 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) {
h('td', { class: 'num muted' }, '–'), h('td', { class: 'hide-sm' }),
h('td', { class: 'actions' }, h('button', { class: 'btn small danger', onclick: (e) => { e.stopPropagation(); deleteFolder(f); } }, 'Delete')))),
objects.map((o) => h('tr', {},
h('td', {}, h('a', { href: `${base}/object?key=${encodeURIComponent(o.key)}`, download: '' }, o.key.slice(prefix.length))),
h('td', {}, h('a', {
href: `${base}/object?key=${encodeURIComponent(o.key)}`, download: '',
// The sidebar page has no server origin; Theseus's download
// manager fetches the file through a one-time link instead.
onclick: bridge ? (e) => { e.preventDefault(); bridge.invoke('download', { path: `${base}/object?key=${encodeURIComponent(o.key)}` }).then(() => toast('Download started')).catch(fail); } : null,
}, o.key.slice(prefix.length))),
h('td', { class: 'num' }, fmtBytes(o.size)),
h('td', { class: 'hide-sm muted' }, fmtDate(o.modified)),
h('td', { class: 'actions' },
@ -722,6 +747,22 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) {
const pct = h('span', { class: 'muted' }, '0%');
const row = h('div', { class: 'upload' }, h('span', {}, key), h('div', { class: 'bar' }, bar), pct);
uploads.append(row);
if (bridge) {
// The whole file crosses the IPC bridge as one buffer, so keep it to
// sizes a renderer can hold comfortably; S3 clients have no limit.
if (file.size > 1024 ** 3) { row.remove(); toast(`${file.name}: uploads from the sidebar are limited to 1 GiB; use an S3 client for larger files`, 'error'); continue; }
pct.textContent = 'uploading…';
bar.style.width = '100%';
bar.style.opacity = '.4';
file.arrayBuffer()
.then((bytes) => bridge.invoke('upload', { path: `${base}/object?key=${encodeURIComponent(key)}`, bytes, type: file.type }))
.then((r) => {
if (r.status >= 200 && r.status < 300) { pct.textContent = 'done'; bar.style.opacity = '1'; setTimeout(() => row.remove(), 1500); load(); }
else { pct.textContent = 'failed'; pct.className = 'error'; toast(`${file.name}: ${r.data?.error || `HTTP ${r.status}`}`, 'error'); }
})
.catch((e) => { pct.textContent = 'failed'; pct.className = 'error'; toast(`${file.name}: ${e.message}`, 'error'); });
continue;
}
const xhr = new XMLHttpRequest();
xhr.open('PUT', `${base}/object?key=${encodeURIComponent(key)}`);
xhr.setRequestHeader('x-pithos', '1');
@ -873,6 +914,21 @@ function logs(main) {
// ---------------------------------------------------------------- boot
// Theseus sidebar: the panel starts narrow; bucket tables want room, so offer
// the host's widen/restore, plus a regular tab for anyone who prefers one.
function addSidebarControls() {
const row = h('div', { class: 'host-controls' });
if (bridge.sidebar?.toggleMax) {
const widen = h('button', { class: 'btn small', title: 'Widen the sidebar', onclick: () => bridge.sidebar.toggleMax() }, '⤢ Widen');
const label = (max) => { widen.textContent = max ? '⤡ Narrow' : '⤢ Widen'; widen.title = max ? 'Back to the normal sidebar width' : 'Widen the sidebar'; };
bridge.sidebar.isMax?.().then(label).catch(() => {});
bridge.sidebar.onMaxChange?.(label);
row.append(widen);
}
row.append(h('button', { class: 'btn small', title: 'Open Pithos in a browser tab', onclick: () => bridge.invoke('open-in-tab').catch(fail) }, 'Open in tab'));
$('.sidebar').insertBefore(row, $('#daemon-pill'));
}
function showSignin() {
$('#app').hidden = true;
$('#signin').hidden = false;
@ -891,6 +947,7 @@ async function boot() {
}
$('#signin').hidden = true;
$('#app').hidden = false;
if (bridge) addSidebarControls();
// Backfill logs the server already holds, then stream.
try {
state.logs = await api('GET', '/api/logs');