From 8186407b61c658e53ed38e2930fa09549874e876 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sat, 3 Oct 2026 19:34:55 +0200 Subject: [PATCH] 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. --- bundled-addons/pithos/addon.json | 20 +----- bundled-addons/pithos/core/server.js | 31 ++++++++- bundled-addons/pithos/index.js | 99 ++++++++++++++++++++++++---- bundled-addons/pithos/ui/app.css | 17 +++-- bundled-addons/pithos/ui/app.js | 79 ++++++++++++++++++---- 5 files changed, 198 insertions(+), 48 deletions(-) diff --git a/bundled-addons/pithos/addon.json b/bundled-addons/pithos/addon.json index 8c6a4c96..60fd25f3 100644 --- a/bundled-addons/pithos/addon.json +++ b/bundled-addons/pithos/addon.json @@ -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" } diff --git a/bundled-addons/pithos/core/server.js b/bundled-addons/pithos/core/server.js index 261ec7bb..6f62e8d5 100644 --- a/bundled-addons/pithos/core/server.js +++ b/bundled-addons/pithos/core/server.js @@ -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(); diff --git a/bundled-addons/pithos/index.js b/bundled-addons/pithos/index.js index 2d2657b5..b578dd27 100644 --- a/bundled-addons/pithos/index.js +++ b/bundled-addons/pithos/index.js @@ -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(); } }, }; diff --git a/bundled-addons/pithos/ui/app.css b/bundled-addons/pithos/ui/app.css index eb84d11b..c7f80d45 100644 --- a/bundled-addons/pithos/ui/app.css +++ b/bundled-addons/pithos/ui/app.css @@ -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; } diff --git a/bundled-addons/pithos/ui/app.js b/bundled-addons/pithos/ui/app.js index afe925d7..40d78cf3 100644 --- a/bundled-addons/pithos/ui/app.js +++ b/bundled-addons/pithos/ui/app.js @@ -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');