Compare commits

...

5 commits

Author SHA1 Message Date
Local Dev
7fa7f71be4 pithos-hosted: public folders by name, as pages, at navigate.st/s3
Public files were addressed by a 32-character account id, and a public
folder answered with JSON, so a link was unusable to send to anyone.
Accounts that signed up with a BNS name can now be addressed by it
(/p/alice.x/<drive>/<folder>/), a public folder opened in a browser is a
page with its files, small pictures and a player (tools still get JSON),
and every link and redirect on it is relative so navigate.st/s3/ serves
the same pages. Pithos shows the navigate.st/s3 address when a folder is
made public.
2026-10-04 16:10:11 +02:00
Local Dev
fa1ecc954a Pithos 0.3.14: accounts at the top, Cancel works, updates without a restart
The Accounts page opens with every Sia account on this computer side by
side, with Switch to and Add another account; Cancel closes dialogs
again and Escape works after a ⋯ menu; the footer's update applies the
new version in place on a Theseus that supports it.
2026-10-04 15:56:01 +02:00
Local Dev
db3ebc681c Pithos: switch or add Sia accounts at the top of the Accounts page
Switching to another account, or adding one, was at the bottom of the
page, under the label editor and a long explanation of which account is
which. The page now opens with the accounts on this computer side by
side: the one in use (with its free space), each set-aside account with
Switch to, and Add another account. The account's details follow.
2026-10-04 15:36:27 +02:00
Local Dev
c6c7615ae3 Pithos: the update button applies the new version without a restart
On a Theseus that can apply an extension in place, the footer's update
downloads the new version and reloads Pithos on it straight away. Older
Theseus versions still offer Restart to apply.
2026-10-04 15:28:26 +02:00
Local Dev
f59dced33a Pithos: Cancel closes dialogs again, and Escape works after a menu
Cancel did nothing in the confirm dialogs (delete, switch account) and
in Add another account: the dialog helper read a Cancel result of false
as "keep the dialog open", a meaning meant only for actions like Copy
link. A fixed result now always closes; only an action that returns
false keeps the dialog. Picking an item from a ⋯ menu also left that
menu's Escape listener behind, so Escape no longer closed any dialog
opened from it.
2026-10-04 15:25:45 +02:00
9 changed files with 220 additions and 36 deletions

28
hosted/package-lock.json generated Normal file
View file

@ -0,0 +1,28 @@
{
"name": "pithos-hosted",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pithos-hosted",
"version": "0.1.0",
"license": "MPL-2.0",
"dependencies": {
"@bitauth/libauth": "3.1.0-next.8"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@bitauth/libauth": {
"version": "3.1.0-next.8",
"resolved": "https://registry.npmjs.org/@bitauth/libauth/-/libauth-3.1.0-next.8.tgz",
"integrity": "sha512-Pm+Ju+YP3JeBLLTiVrBnia2wwE4G17r4XqpvPRMcklElJTe8J6x3JgKRg1by0Xm3ZY6UFxACkEAoSA+x419/zA==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
}
}
}

View file

@ -139,7 +139,7 @@ export async function createHosted(cfg, log = console.log) {
...st,
host: 'hosted',
config: st.config ? { apiAddress: null, adminAddress: null, file: null, directory: null, https: null, exists: true } : undefined,
hosted: { id: a.id, state: a.state, publicUrl: cfg.publicUrl, usage: usageOf(a.id), downloadCapBytes: cfg.downloadCapBytes || null },
hosted: { id: a.id, name: a.gate?.type === 'bns' ? a.gate.name : null, state: a.state, publicUrl: cfg.publicUrl, usage: usageOf(a.id), downloadCapBytes: cfg.downloadCapBytes || null },
};
}
@ -388,10 +388,18 @@ export async function createHosted(cfg, log = console.log) {
} finally { release(); }
}
// /p/<account id or BNS name>/<drive>/<path>. Also reached as
// navigate.st/s3/…, so every link and redirect here is relative.
async function servePublic(req, res, url) {
const m = /^\/p\/([0-9a-f]{32})\/([^/]+)\/?(.*)$/.exec(url.pathname);
const m = /^\/p\/([^/]+)\/([^/]+)(\/?)(.*)$/.exec(url.pathname);
if (!m) throw httpError(404, 'not found');
const [, id, bucket] = m;
const [, who, bucket, slash] = m;
let whoName = who;
try { whoName = decodeURIComponent(who); } catch {}
const id = /^[0-9a-f]{32}$/.test(who) ? who : sup.accountByName(whoName);
if (!id) throw httpError(404, 'not found');
// The drive itself without its slash: add it, so relative links resolve.
if (!slash && !m[4]) { res.writeHead(301, { location: `${encodeURIComponent(bucket)}/${url.search}` }); return res.end(); }
let key;
try { key = decodeURIComponent(m[3]); } catch { throw httpError(404, 'not found'); }
if (key.split('/').some((x) => x === '.' || x === '..')) throw httpError(404, 'not found');
@ -403,11 +411,17 @@ export async function createHosted(cfg, log = console.log) {
const client = await sup.bucketClient(inst, bucket);
if (!client) throw httpError(404, 'not found');
const page = await client.listObjects(bucket, { prefix: key, token: url.searchParams.get('token') || undefined });
return json(res, 200, {
const listing = {
folders: page.folders.map((f) => f.slice(key.length)),
files: page.objects.filter((o) => o.key !== key).map((o) => ({ name: o.key.slice(key.length), size: o.size, modified: o.modified })),
next: page.truncated ? page.nextToken : null,
});
};
// Browsers get a page with links and a player; tools get the JSON.
if (/text\/html/.test(req.headers.accept || '') && !url.searchParams.has('json')) {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'content-security-policy': "default-src 'self'; style-src 'unsafe-inline'; img-src 'self'; media-src 'self'; script-src 'unsafe-inline'; frame-ancestors 'none'", 'x-content-type-options': 'nosniff' });
return res.end(folderPage({ owner: whoName, bucket, key, listing, token: url.searchParams.get('token') }));
}
return json(res, 200, listing);
}
return serveObject(req, res, id, bucket, key);
}
@ -546,6 +560,42 @@ export async function createHosted(cfg, log = console.log) {
};
}
// A public folder as a page: breadcrumbs, folders, files (links are relative
// to the folder, so the page works under /p/ and under navigate.st/s3/),
// pictures shown small, music and video playable in place.
const PAGE_ESC = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const fmtSize = (n) => { const u = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; let i = 0; n = Number(n) || 0; while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } return `${i ? n.toFixed(1) : n} ${u[i]}`; };
export function folderPage({ owner, bucket, key, listing, token }) {
const parts = key.split('/').filter(Boolean);
const up = (n) => '../'.repeat(n) || './';
const crumbs = [`<a href="${up(parts.length)}">${PAGE_ESC(bucket)}</a>`, ...parts.map((p, i) => i === parts.length - 1 ? `<span>${PAGE_ESC(p)}</span>` : `<a href="${up(parts.length - 1 - i)}">${PAGE_ESC(p)}</a>`)].join(' / ');
const enc = (name) => name.split('/').map(encodeURIComponent).join('/');
const kind = (n) => (/\.(png|jpe?g|gif|webp|avif|svg)$/i.test(n) ? 'img' : /\.(mp3|m4a|aac|ogg|oga|opus|flac|wav)$/i.test(n) ? 'audio' : /\.(mp4|webm|mov|m4v)$/i.test(n) ? 'video' : '');
const rows = [
...listing.folders.map((f) => `<li class="dir"><a href="${enc(f)}">📁 ${PAGE_ESC(f.replace(/\/$/, ''))}</a></li>`),
...listing.files.map((f) => {
const k = kind(f.name);
const media = k === 'img' ? `<img loading="lazy" src="${enc(f.name)}" alt="">` : k ? `<button type="button" class="play" data-src="${enc(f.name)}" data-kind="${k}">▶</button>` : '';
return `<li>${media}<a href="${enc(f.name)}">${PAGE_ESC(f.name)}</a><span class="sz">${fmtSize(f.size)}</span></li>`;
}),
].join('');
const more = listing.next ? `<p><a href="?token=${encodeURIComponent(listing.next)}">More…</a></p>` : '';
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>${PAGE_ESC(parts.at(-1) || bucket)} · ${PAGE_ESC(owner)}</title>
<style>body{margin:0;background:#0b100e;color:#e6ece9;font:15px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif}main{max-width:860px;margin:0 auto;padding:28px 18px 120px}
a{color:#3ddc97;text-decoration:none}a:hover{text-decoration:underline}.owner{color:#8e9c96;font-size:13px}h1{font-size:20px;margin:4px 0 18px;font-weight:600}h1 span{color:#e6ece9}
ul{list-style:none;padding:0;margin:0;border:1px solid rgba(255,255,255,.08);border-radius:10px;overflow:hidden}li{display:flex;align-items:center;gap:10px;padding:9px 14px;border-top:1px solid rgba(255,255,255,.06);min-width:0}li:first-child{border-top:none}
li a{flex:1;min-width:0;overflow-wrap:anywhere}li img{width:44px;height:44px;object-fit:cover;border-radius:6px;flex:none}.sz{color:#8e9c96;font-size:13px;white-space:nowrap}
.play{flex:none;width:32px;height:32px;border-radius:50%;border:0;background:#3ddc97;color:#06231a;cursor:pointer;font-size:13px}
#player{position:fixed;left:0;right:0;bottom:0;background:#121a17;border-top:1px solid rgba(255,255,255,.08);padding:10px 16px;display:none}#player.on{display:block}#player audio,#player video{width:100%;max-height:50vh}.empty{color:#8e9c96;padding:18px}footer{color:#5f6b66;font-size:12.5px;margin-top:18px}</style></head>
<body><main><div class="owner">${PAGE_ESC(owner)} · public folder</div><h1>${crumbs}</h1>
${rows ? `<ul>${rows}</ul>` : '<p class="empty">This folder is empty.</p>'}${more}
<footer>Shared with Pithos on Silent Mode. <a href="?json">JSON</a></footer></main>
<div id="player"></div>
<script>document.addEventListener('click',function(e){var b=e.target.closest('.play');if(!b)return;var p=document.getElementById('player');var m=document.createElement(b.dataset.kind);m.src=b.dataset.src;m.controls=true;m.autoplay=true;p.innerHTML='';p.appendChild(m);p.className='on';});</script>
</body></html>`;
}
function json(res, status, body) {
const data = JSON.stringify(body);
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });

View file

@ -43,7 +43,23 @@ export function createSupervisor(cfg, log = console.log, { reservedKey = () => f
return readJson(accountFile(id), null);
}
function saveAccount(a) { writeJson(accountFile(a.id), a); }
function saveAccount(a) { writeJson(accountFile(a.id), a); nameIndex = null; }
// Accounts by the BNS name they signed up with (one slot per name), for
// public addresses like /p/alice.x/<drive>/… instead of the 32-hex id.
let nameIndex = null;
function accountByName(name) {
if (!nameIndex) {
nameIndex = new Map();
let ids = [];
try { ids = fs.readdirSync(usersDir); } catch {}
for (const id of ids) {
const a = account(id);
if (a?.gate?.type === 'bns' && a.gate.name) nameIndex.set(String(a.gate.name).toLowerCase(), id);
}
}
return nameIndex.get(String(name).toLowerCase()) || null;
}
function createAccount({ id, pubkey, gate }) {
const slots = readJson(slotsFile, { next: 0 });
@ -216,7 +232,7 @@ export function createSupervisor(cfg, log = console.log, { reservedKey = () => f
}
return {
dir, dataDir, configFile, account, createAccount, saveAccount, setState,
dir, dataDir, configFile, account, accountByName, createAccount, saveAccount, setState,
load, ensure, close, flushQuietly, bucketClient, mapBuckets, indexKeys, hold, keyTaken,
ownerOfKey: (akid) => (Object.hasOwn(keyIndex, akid) ? keyIndex[akid] : null),
async closeAll() {

View file

@ -0,0 +1,28 @@
// Public folder pages are served at /p/<who>/<drive>/<path>/ and also as
// navigate.st/s3/…, so every link in them must be relative to the folder.
import test from 'node:test';
import assert from 'node:assert/strict';
import { folderPage } from '../server.js';
test('folder page links are relative, names escaped, media playable', () => {
const html = folderPage({
owner: 'alice.x', bucket: 'music-house', key: 'Deep/Night/',
listing: { folders: ['Remixes/'], files: [{ name: 'A <i>song & co.mp3', size: 9_437_184 }, { name: 'cover.jpg', size: 2048 }, { name: 'notes.txt', size: 12 }], next: null },
});
assert.match(html, /<a href="Remixes\/">/);
assert.match(html, /href="A%20%3Ci%3Esong%20%26%20co\.mp3"/);
assert.match(html, /A &lt;i&gt;song &amp; co\.mp3/);
assert.doesNotMatch(html, /<i>song/);
assert.match(html, /data-kind="audio"/);
assert.match(html, /<img loading="lazy" src="cover\.jpg"/);
// Breadcrumbs climb with ../ only: drive root is two levels up.
assert.match(html, /<a href="\.\.\/\.\.\/">music-house<\/a> \/ <a href="\.\.\/">Deep<\/a> \/ <span>Night<\/span>/);
assert.doesNotMatch(html, /href="\/p\//);
});
test('drive root page has no stray parents', () => {
const html = folderPage({ owner: '0123', bucket: 'd', key: '', listing: { folders: [], files: [], next: 'tok/en' } });
assert.match(html, /This folder is empty/);
assert.match(html, /href="\?token=tok%2Fen"/);
assert.match(html, /<a href="\.\/">d<\/a>/);
});

View file

@ -1,6 +1,6 @@
{
"name": "pithos",
"version": "0.3.13",
"version": "0.3.14",
"description": "A control panel for s3d, the Sia S3 gateway: daemon, users and keys, buckets, upload pipeline.",
"license": "MPL-2.0",
"type": "module",

View file

@ -1,7 +1,7 @@
{
"id": "pithos",
"name": "Pithos",
"version": "0.3.13",
"version": "0.3.14",
"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=",

View file

@ -131,6 +131,12 @@ module.exports = {
// Theseus asks the user before restarting. Older Theseus builds lack
// these hooks; the panel then points to Settings > Extensions.
api.onMessage("update", async ({ step = "stage" } = {}) => {
// In place, no restart (Theseus 0.3.77+): Theseus swaps the new version in,
// restarts the add-on and reloads this panel.
if (step === "apply-now") {
if (typeof api.applySelfUpdate !== "function") return { ok: false, reason: "needs a restart" };
return await api.applySelfUpdate();
}
if (step === "apply") {
if (typeof api.restartApp !== "function") return { fallback: "settings" };
return await api.restartApp();
@ -138,7 +144,7 @@ module.exports = {
if (typeof api.checkAndStageSelfUpdate !== "function") return { fallback: "settings" };
const r = await api.checkAndStageSelfUpdate();
const staged = r?.status === "staged" || r?.status === "already-staged";
return { staged, status: r?.status || "unknown", detail: r?.detail || null, current: r?.current || null, next: r?.next || null };
return { staged, status: r?.status || "unknown", detail: r?.detail || null, current: r?.current || null, next: r?.next || null, inPlace: typeof api.applySelfUpdate === "function" };
});
api.onMessage("open-settings", ({ section = "" } = {}) => { api.openSettings?.(String(section)); return { ok: true }; });

View file

@ -442,3 +442,14 @@ nav a.nav-account.active .na-label { color: var(--accent); }
background: var(--surface); color: var(--text); font: inherit; font-size: 16px; cursor: pointer; }
.bar-btn:hover { border-color: var(--accent); color: var(--accent); }
}
/* Account switcher on the Accounts page */
.acct-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 1fr)); gap: 10px; }
.acct-tile { display: flex; flex-direction: column; gap: 4px; padding: 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface-2); min-width: 0; align-items: flex-start; }
.acct-tile .row.spread { width: 100%; }
.acct-tile.current { border-color: var(--accent); background: var(--accent-soft); }
.acct-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
.acct-tile .btn { margin-top: auto; }
.acct-tile.add { appearance: none; font: inherit; color: var(--text); cursor: pointer; border-style: dashed; background: transparent; text-align: left; justify-content: center; }
.acct-tile.add:hover { border-color: var(--accent); }
.acct-plus { font-size: 20px; line-height: 1; color: var(--accent); }

View file

@ -91,12 +91,15 @@ function modal(title, body, buttons = []) {
const onKey = (e) => { if (e.key === 'Escape') close(undefined); };
const foot = h('div', { class: 'foot' }, buttons.map((b) => h('button', {
class: `btn ${b.kind || ''}`, type: b.submit ? 'submit' : 'button',
onclick: b.submit ? null : async () => { const v = b.value ? await b.value() : b.result; if (v !== false) close(v); },
// A fixed result (Cancel: false/null) always closes; only a value()
// action that returns false keeps the dialog open (e.g. Copy link).
onclick: b.submit ? null : async () => { if (!b.value) return close(b.result); const v = await b.value(); if (v !== false) close(v); },
}, b.label)));
const form = h('form', { class: 'modal', onsubmit: async (e) => {
e.preventDefault();
const b = buttons.find((x) => x.submit);
const v = b.value ? await b.value() : b.result;
if (!b.value) return close(b.result);
const v = await b.value();
if (v !== false) close(v);
} }, h('h2', {}, title), body, foot);
const root = h('div', { class: 'modal-backdrop', onmousedown: (e) => { if (e.target === root) close(undefined); } }, form);
@ -718,22 +721,50 @@ function account(main, rest) {
}
// Earlier Sia accounts set aside on this computer, each one click away.
const archiveName = (a) => a.label || (a.indexerUrl ? new URL(a.indexerUrl).host : a.name);
async function switchToArchive(a, after) {
const name = archiveName(a);
const ok = await confirmModal(`Switch to ${name}?`, 's3d stops and switches back to that Sia account with its users, keys and drives. The current account is set aside and you can switch back the same way.', 'Switch', 'primary');
if (!ok) return;
try {
await api('POST', '/api/account/restore', { name: a.name });
await api('POST', '/api/daemon/start').catch(() => {});
toast(`Switched to ${name}`);
await refreshStatus();
await loadAccount();
refreshNavAccount(true);
after();
} catch (e) { fail(e); }
}
// The account switcher: every Sia account on this computer side by side, the
// one in use marked, the others one click away, and Add another account.
function accountSwitcher(onAdd, after) {
const list = accountInfo?.archives || [];
const cur = h('div', { class: 'acct-tile current' },
h('div', { class: 'row spread' }, h('span', { class: 'badge ok' }, 'in use'), h('span', { class: 'small muted' }, accountInfo?.indexerUrl ? new URL(accountInfo.indexerUrl).host : '')),
h('strong', { class: 'acct-name' }, accountName(accountInfo)),
h('span', { class: 'small muted', id: 'acct-space' }, ' '));
api('GET', '/api/space').then((sp) => { if (sp?.available) put(cur.querySelector('#acct-space'), `${fmtBytes(sp.remainingStorage)} free of ${fmtBytes(sp.maxPinnedData)}`); }).catch(() => {});
return h('div', { class: 'card acct-switcher' },
h('div', { class: 'row spread', style: 'margin-bottom:10px' }, h('h2', { style: 'margin:0' }, 'Your Sia accounts'),
list.length ? h('span', { class: 'small muted' }, `${list.length + 1} on this computer`) : null),
h('div', { class: 'acct-tiles' },
cur,
list.map((a) => h('div', { class: 'acct-tile' },
h('span', { class: 'small muted' }, a.archivedAt ? `set aside ${fmtDate(a.archivedAt)}` : 'set aside'),
h('strong', { class: 'acct-name' }, archiveName(a)),
h('span', { class: 'small muted' }, [a.fingerprint && `key ${a.fingerprint}`, a.users.length && `users: ${a.users.join(', ')}`].filter(Boolean).join(' · ')),
a.movedTo ? h('span', { class: 'small muted' }, `Drives moved to ${a.movedTo}; kept as a backup.`)
: h('button', { class: 'btn small', disabled: !a.hasData, onclick: () => switchToArchive(a, after) }, 'Switch to'))),
h('button', { type: 'button', class: 'acct-tile add', onclick: onAdd }, h('span', { class: 'acct-plus' }, '+'), h('strong', {}, 'Add another account'), h('span', { class: 'small muted' }, 'This one is set aside, ready to switch back to'))));
}
function otherAccounts(after) {
const list = accountInfo?.archives || [];
if (!list.length) return null;
async function restore(a) {
const name = a.label || (a.indexerUrl ? new URL(a.indexerUrl).host : a.name);
const ok = await confirmModal(`Switch to ${name}?`, 's3d stops and switches back to that Sia account with its users, keys and drives. The current account is set aside and you can switch back the same way.', 'Switch', 'primary');
if (!ok) return;
try {
await api('POST', '/api/account/restore', { name: a.name });
await api('POST', '/api/daemon/start').catch(() => {});
toast(`Switched to ${name}`);
await refreshStatus();
await loadAccount();
refreshNavAccount(true);
after();
} catch (e) { fail(e); }
return switchToArchive(a, after);
}
return h('div', {},
h('h3', {}, 'Other accounts on this computer'),
@ -747,9 +778,11 @@ function otherAccounts(after) {
function setup(main) {
const body = h('div', { class: 'card' });
const switcher = h('div', { hidden: true, style: 'margin-bottom:16px' });
main.append(
h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Account'),
h('p', { class: 'muted' }, 'The Sia account that stores, and pays for, everything on this s3d.'))),
h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Accounts'),
h('p', { class: 'muted' }, 'Your Sia accounts on this computer. The one in use stores, and pays for, everything on this s3d.'))),
switcher,
body,
);
@ -764,7 +797,10 @@ function setup(main) {
if (l.state === 'done' || l.state === 'already') {
const label = h('input', { type: 'text', placeholder: 'e.g. the email you sign in with', value: accountInfo?.label || '', maxlength: 120 });
put(switcher, accountSwitcher(switchAccount, draw));
switcher.hidden = false;
put(body,
h('div', { class: 'small muted', style: 'text-transform:uppercase;letter-spacing:.06em;margin-bottom:4px' }, 'Account in use'),
h('h2', {}, accountInfo?.label || (l.indexerUrl ? new URL(l.indexerUrl).host : 'Connected to Sia')),
h('p', { class: 'row small', style: 'margin-top:-6px' }, h('span', { class: 'badge ok' }, 'connected'), accountInfo?.label && l.indexerUrl && h('span', { class: 'muted' }, new URL(l.indexerUrl).host)),
h('p', {}, l.indexerUrl ? ['This s3d uses a Sia account on ', h('code', {}, l.indexerUrl), '. All S3 users, drives and files on it are stored, and paid for, through that account.'] : 'This s3d is registered with an indexer.'),
@ -775,11 +811,10 @@ function setup(main) {
siaAccountBlock(accountInfo, async () => { try { accountInfo = await api('GET', '/api/account?fresh=1'); draw(); } catch (e) { fail(e); } }),
h('div', { class: 'row', style: 'margin-top:16px' },
daemonState() !== 'running' && h('button', { class: 'btn primary', onclick: async () => { await daemonAction(daemonState() === 'crashed' ? 'restart' : 'start'); navTo('#/overview'); } }, 'Start s3d'),
h('a', { class: 'btn', href: '#/users' }, 'Users & keys'),
h('button', { class: 'btn', onclick: switchAccount }, 'Add another account…')),
archivesBlock());
h('a', { class: 'btn', href: '#/users' }, 'Users & keys')));
return;
}
switcher.hidden = true;
if (l.state === 'confirm') {
const words = (l.generatedPhrase || '').split(/\s+/);
@ -1162,16 +1197,19 @@ function itemMenu(items) {
btn.addEventListener('click', (e) => {
e.stopPropagation();
document.querySelectorAll('.menu').forEach((m) => m.remove());
const menu = h('div', { class: 'menu', role: 'menu' }, items.filter(Boolean).map(([label, run, kind]) =>
h('button', { type: 'button', role: 'menuitem', class: kind || '', onclick: (ev) => { ev.stopPropagation(); menu.remove(); run(); } }, label)));
// Picking an item must also drop the listeners, or a stale Escape handler
// swallows Escape for every dialog opened afterwards.
let menu;
function done() { menu?.remove(); document.removeEventListener('mousedown', away, true); document.removeEventListener('keydown', esc, true); }
menu = h('div', { class: 'menu', role: 'menu' }, items.filter(Boolean).map(([label, run, kind]) =>
h('button', { type: 'button', role: 'menuitem', class: kind || '', onclick: (ev) => { ev.stopPropagation(); done(); run(); } }, label)));
document.body.append(menu);
const r = btn.getBoundingClientRect();
const w = menu.offsetWidth, ht = menu.offsetHeight;
menu.style.left = Math.max(8, Math.min(r.right - w, innerWidth - w - 8)) + 'px';
menu.style.top = (r.bottom + 4 + ht > innerHeight - 8 ? Math.max(8, r.top - ht - 4) : r.bottom + 4) + 'px';
const done = () => { menu.remove(); document.removeEventListener('mousedown', away, true); document.removeEventListener('keydown', esc, true); };
const away = (ev) => { if (!menu.contains(ev.target)) done(); };
const esc = (ev) => { if (ev.key === 'Escape') { ev.stopPropagation(); done(); btn.focus(); } };
function away(ev) { if (!menu.contains(ev.target)) done(); }
function esc(ev) { if (ev.key === 'Escape') { ev.stopPropagation(); done(); btn.focus(); } }
setTimeout(() => { document.addEventListener('mousedown', away, true); document.addEventListener('keydown', esc, true); }, 0);
menu.querySelector('button')?.focus();
});
@ -2035,8 +2073,9 @@ async function folderShareDialog(user, bucket, folder) {
});
put(progress, '');
if (r.type === 'public') {
const at = `${hostedUrl()}/p/${state.status.hosted.id}/${bucket}/${folder}`;
put(out, secretRow(r.rights === 'read-list' ? 'Public address (lists the folder as JSON)' : 'Public address: add a file name', at),
const who = state.status.hosted.name || state.status.hosted.id;
const at = `https://navigate.st/s3/${encodeURIComponent(who)}/${encodeURIComponent(bucket)}/${folder.split('/').map(encodeURIComponent).join('/')}`;
put(out, secretRow(r.rights === 'read-list' ? 'Public address (a page anyone can open)' : 'Public address: add a file name', at),
r.rights !== 'read-list' && h('p', { class: 'small muted', style: 'margin:0' }, 'For example ', h('code', {}, `${at}index.html`), '.'));
}
else put(out, h('p', { class: 'small muted', style: 'margin:0' }, 'Give these to the person. The secret is shown here, and in this list, only to you.'),
@ -2476,6 +2515,12 @@ function setupFooter() {
return r?.status === 'up-to-date' ? flash('✓ Up to date', 'ok', '') : flash('⚠ Update failed', 'err', r?.detail || r?.status || '');
}
const next = r.next ? ` v${r.next}` : '';
// Newer Theseus applies it in place: the panel reloads on the new version.
if (r.inPlace) {
showChip(`Updating to${next}…`, 'wait', 'Pithos reloads on the new version in a moment', null);
const a = await bridge.invoke('update', { step: 'apply-now' }).catch(() => null);
if (a?.ok) return;
}
showChip(`↻ Restart to apply${next}`, '', `Pithos${next} is ready. Theseus restarts to apply it and reopens your tabs.`, async () => {
try {
const a = await bridge.invoke('update', { step: 'apply' });