// Pithos for Theseus. The add-on runs the same control server as the web // 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(). const path = require("node:path"); 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) { // The pinned s3d release lands in the per-add-on data folder, never in the // add-on folder itself (that one is replaced on update). const dataDir = path.join(api.dataDir || path.join(api.folder, "..", "..", "extensions-data"), "pithos"); async function ensureServer() { if (pithos) return pithos; if (!starting) { starting = (async () => { const { createPithos } = await import(pathToFileURL(path.join(__dirname, "core", "server.js")).href); const p = await createPithos({ host: "127.0.0.1", port: 0, hostName: "theseus", binDir: path.join(dataDir, "bin"), openExternal: (url) => api.openTab(url), }); cookie = p.internalSession(); pithos = p; api.log(`control server on ${p.url}`); return p; })().finally(() => { starting = null; }); } return starting; } 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 }; }); // ---- Theseus vault: PIN gate and the vault-derived recovery phrase ------ // Older Theseus builds have the vault but no requestUnlock (no PIN prompt); // Pithos then runs ungated, as before. const vault = api.vault || null; const canPrompt = !!(vault && typeof vault.requestUnlock === "function"); async function vaultStatus() { if (!vault || !vault.lifecycle) return { setup: false, unlocked: false, prompt: false }; const st = await vault.lifecycle.status(); return { setup: !!st.setup, unlocked: !!st.unlocked, prompt: canPrompt }; } api.onMessage("vault-status", () => vaultStatus()); // Watch for the vault locking (Settings › Lock now, or a lock from another // extension) and tell the panel at once. This runs in the main process: // a hidden panel's own timers are throttled to about once a minute. if (canPrompt) { let lastUnlocked = null; setInterval(async () => { try { const st = await vaultStatus(); if (st.unlocked !== lastUnlocked) { lastUnlocked = st.unlocked; api.emit("pithos-vault", st); } } catch {} }, 3000).unref?.(); } // Pithos shows access-key secrets and can delete buckets, so in Theseus it // opens only with the vault unlocked: Theseus asks for the PIN (or the // master password after three wrong tries) in its own prompt. api.onMessage("gate", async () => { const st = await vaultStatus(); if (!st.setup || !st.prompt || st.unlocked) return { ok: true, ...st }; const r = await vault.requestUnlock({ reason: "Open Pithos, your S3 storage on Sia." }); return { ...r, ...(await vaultStatus()) }; }); // Connect with a recovery phrase derived from the vault: nothing to write // down, and restoring the vault restores access. The phrase is built and // handed to s3d here; it never reaches the panel page. // CHANGING THE PURPOSE PATH OR THE DERIVATION CUTS USERS OFF FROM THEIR DATA. api.onMessage("connect-with-vault", async ({ indexerUrl } = {}) => { const st = await vaultStatus(); if (!st.setup) throw new Error("Set up the password vault in Settings › Passwords first."); if (!st.unlocked) { if (!canPrompt) throw new Error("Unlock the password vault in Settings › Passwords first."); const r = await vault.requestUnlock({ reason: "Create the recovery phrase for your Sia storage." }); if (!r.ok) throw new Error("The vault stayed locked."); } const bytes = await vault.derive("pithos/sia-recovery/v1"); const bip39 = api.require("bip39"); const phrase = bip39.entropyToMnemonic(Buffer.from(bytes.subarray(0, 16)).toString("hex")); bytes.fill(0); return call("POST", "/api/login", { body: JSON.stringify({ indexerUrl, phrase }), headers: { "content-type": "application/json" }, }); }); // Left edge, beside the quick links. Theseus builds without left panels // ignore `side` and show it in the right sidebar. api.registerSidebarPanel({ id: "main", title: "Pithos", page: "ui/index.html", side: "left" }); // 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 (events) { events.abort(); events = null; } if (pithos) { const p = pithos; pithos = null; cookie = null; await p.close(); } }, };