Pithos: a clearer Share dialog, pithos.sia/l links, and a restart prompt after in-place updates

The Share dialog showed a bare code box and repeated its own title. It
now lists the links already made for that file, folder or drive as cards
(address, expiry, file count, Copy, Open, Switch off) above one line to
make another, copies a new link straight away, and also shows the
pithos.sia/l/<id>/<name> form, which Theseus and the pithos.sia site send
on to navigate.st/sia.

After an in-place update Theseus swaps an add-on's files but Node keeps
the modules already loaded, so the engine kept running the old version
while the footer showed the new one (a whole-drive link then failed with
'a file is required'). The engine now reports the version it loaded and
the one installed; when they differ the footer offers Restart to finish.
This commit is contained in:
Local Dev 2026-10-06 00:13:11 +02:00
parent 6d8cb9a144
commit 4101bae7c9
4 changed files with 89 additions and 22 deletions

View file

@ -42,6 +42,15 @@ class HttpError extends Error {
constructor(status, message) { super(message); this.status = status; }
}
// Pithos's own version: addon.json in the Theseus add-on, package.json elsewhere.
function readOwnVersion() {
for (const f of ['../addon.json', '../package.json']) {
try { const v = JSON.parse(fs.readFileSync(new URL(f, import.meta.url), 'utf8')).version; if (v) return v; } catch {}
}
return null;
}
const LOADED_VERSION = readOwnVersion();
export async function createPithos(opts = {}) {
const {
host = '127.0.0.1',
@ -90,13 +99,11 @@ export async function createPithos(opts = {}) {
const tickets = new Map();
const subscribers = new Set();
let versionCache = null;
// Pithos's own version: addon.json in the Theseus add-on, package.json elsewhere.
const pithosVersion = (() => {
for (const f of ['../addon.json', '../package.json']) {
try { const v = JSON.parse(fs.readFileSync(new URL(f, import.meta.url), 'utf8')).version; if (v) return v; } catch {}
}
return null;
})();
// The version of the code running (read when this module loaded) and the one
// on disk now. Theseus can swap an add-on's files in place, but Node keeps
// the modules it already loaded, so they differ until Theseus restarts.
const pithosVersion = LOADED_VERSION;
const installedVersion = readOwnVersion();
let hosted = null; // "On Silent Mode", installed below
const broadcast = (event, data) => {
@ -182,6 +189,8 @@ export async function createPithos(opts = {}) {
const local = {
host: hostName,
pithos: pithosVersion,
// differs from pithos after an in-place update until the host restarts
installed: readOwnVersion() || installedVersion,
// electron-builder's portable exe sets this; updates then fetch the portable build.
portable: hostName === 'desktop' && !!process.env.PORTABLE_EXECUTABLE_FILE,
platform: process.platform,
@ -535,7 +544,7 @@ export async function createPithos(opts = {}) {
const out = await res.json().catch(() => ({}));
if (!res.ok) throw new HttpError(res.status === 429 ? 429 : 502, out.error || `navigate.st answered ${res.status}`);
const list = readSiaLinks();
list.unshift({ id: out.id, url: out.url, deleteToken: out.deleteToken, user: p.user, bucket: p.bucket, key: key || null, prefix: prefix || null, until, created: Date.now(), files: body.files ? body.files.length : 1 });
list.unshift({ id: out.id, url: out.url, deleteToken: out.deleteToken, user: p.user, bucket: p.bucket, key: key || null, prefix: key ? null : (prefix ?? null), until, created: Date.now(), files: body.files ? body.files.length : 1 });
writeSiaLinks(list);
return { id: out.id, url: out.url, until };
});

View file

@ -180,6 +180,10 @@ module.exports = {
const DROP = new Set(["set-cookie", "connection", "keep-alive", "transfer-encoding"]);
api.registerSiteRoute({ host: "pithos.sia", handle: async (request) => {
const u = new URL(request.url);
// pithos.sia/l/<id>/<name>: a public link, served by navigate.st/sia
if (/^\/l\/[A-Za-z0-9_-]{4,64}(\/|$)/.test(u.pathname)) {
return new Response(null, { status: 302, headers: { location: `https://navigate.st/sia/${u.pathname.slice(3)}${u.search}`, "cache-control": "no-store" } });
}
const fetchSite = request.headers.get("sec-fetch-site");
const origin = request.headers.get("origin");
const own = fetchSite ? fetchSite === "same-origin" || fetchSite === "none"

View file

@ -465,3 +465,13 @@ a.brand:hover > span:first-of-type { color: var(--accent); }
.access-chip.private { color: var(--muted); }
.access-chip.public { color: var(--accent); border-color: var(--accent); }
@media (max-width: 760px) { .crumb-row { row-gap: 6px; } .chip { font-size: 12px; padding: 2px 8px; } }
/* Public links in the Share dialogs */
.link-card { border: 1px solid var(--border); border-radius: 10px; padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; background: var(--surface-2); }
.link-card.fresh { border-color: var(--accent); }
.link-card .link-url { color: var(--accent); font-family: ui-monospace, Consolas, monospace; font-size: 13px; word-break: break-all; text-decoration: none; }
.link-card .link-url:hover { text-decoration: underline; }
.link-card code { font-size: 12px; word-break: break-all; }
.link-card .row { gap: 6px; flex-wrap: wrap; }
.link-make { gap: 10px; align-items: center; flex-wrap: wrap; }
.link-card a.btn { text-decoration: none; }

View file

@ -1921,7 +1921,7 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) {
endpoint.addEventListener('change', gen);
gen();
await modal('Share', h('div', { class: 'stack' },
siaLinkBlock({ key }),
siaLinkBlock({ key, heading: true }),
h('details', { class: 'more-opts', open: !!savedEndpoint },
h('summary', {}, 'Link for this computer, or your own server'),
h('div', { class: 'stack', style: 'margin-top:8px' },
@ -1935,30 +1935,64 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) {
// A public link anyone can open, at navigate.st/sia: the file is read from
// your Sia account through Silent Mode, so it works with this computer off.
function siaLinkBlock({ key, prefix }) {
// The links already made for this file, folder or drive come first, each
// with Copy, Open and Switch off; then one line to make a new one.
function siaLinkBlock({ key, prefix, heading = false }) {
const folder = prefix != null && key == null;
const days = h('select', {}, [['30', '1 month'], ['365', '1 year'], ['1825', '5 years']].map(([v, l]) => h('option', { value: v, selected: v === '365' }, l)));
const out = h('div');
const mine = (l) => l.bucket === bucket && (folder ? l.key == null && (l.prefix ?? null) === prefix : l.key === key);
const days = h('select', { 'aria-label': 'Works for' }, [['30', '1 month'], ['365', '1 year'], ['1825', '5 years']].map(([v, l]) => h('option', { value: v, selected: v === '365' }, l)));
const list = h('div', { class: 'stack' });
const err = h('p', { class: 'error small', hidden: true });
const theseusForm = (u) => { try { return `pithos.sia/l/${new URL(u).pathname.replace(/^\/sia\//, '')}`; } catch { return ''; } };
const card = (l, fresh) => h('div', { class: `link-card${fresh ? ' fresh' : ''}` },
h('a', { class: 'link-url', href: l.url, target: '_blank', rel: 'noopener', title: l.url }, l.url.replace(/^https:\/\//, '')),
h('div', { class: 'small muted' }, `Works until ${new Date(l.until * 1000).toLocaleDateString()}${l.files > 1 ? ` · ${l.files} files` : ''}`,
theseusForm(l.url) && [' · in Theseus also ', h('code', {}, theseusForm(l.url))]),
h('div', { class: 'row' },
h('button', { type: 'button', class: 'btn small primary', onclick: () => copy(l.url, 'Link copied') }, 'Copy link'),
h('a', { class: 'btn small', href: l.url, target: '_blank', rel: 'noopener' }, 'Open'),
h('button', { type: 'button', class: 'btn small danger', onclick: async () => {
if (!(await confirmModal('Switch this link off?', 'Anyone who has it will no longer be able to open it.', 'Switch off'))) return;
try { await api('DELETE', `/api/sia-links/${encodeURIComponent(l.id)}`); toast('Link switched off'); refresh(); } catch (e) { fail(e); }
} }, 'Switch off')));
let freshId = null;
async function refresh() {
let links = [];
try { links = (await api('GET', '/api/sia-links')).filter(mine); } catch {}
put(list, links.map((l) => card(l, l.id === freshId)));
put(btn, links.length ? 'Make another link' : 'Create public link');
}
const btn = h('button', { type: 'button', class: 'btn primary', onclick: async () => {
btn.disabled = true; put(btn, h('span', { class: 'spinner' }), ' Making the link…');
btn.disabled = true; err.hidden = true;
put(btn, h('span', { class: 'spinner' }), ' Making the link…');
try {
const r = await api('POST', `${base}/sia-link`, { key, prefix, days: Number(days.value) });
put(out, secretRow('Public link', r.url), h('p', { class: 'small muted', style: 'margin:0' }, `Works until ${new Date(r.until * 1000).toLocaleDateString()}. Switch it off any time in Settings › Public links.`));
put(btn, 'Make another link');
} catch (e) { put(out, h('p', { class: 'error small', style: 'margin:0' }, e.message)); put(btn, 'Try again'); }
freshId = r.id;
await refresh();
copy(r.url, 'Link created and copied');
} catch (e) {
// an engine from before this version reads a whole-drive link as a file
err.textContent = folder && /a file is required/.test(e.message)
? 'Pithos is still running its previous engine. Restart Theseus to finish the update, then try again.'
: e.message;
err.hidden = false;
await refresh();
}
btn.disabled = false;
} }, 'Create public link');
refresh();
return h('div', { class: 'stack' },
h('h3', { style: 'margin:0' }, 'Public link'),
heading && h('h3', { style: 'margin:0' }, 'Public link'),
h('p', { class: 'small muted', style: 'margin:0' }, folder
? `A page with the files ${prefix ? 'in this folder' : 'at the top of this drive'} that anyone can open, on any device, even with this computer off. Files in sub-folders are not included.`
: 'Anyone with the link can open this file on any device, even with this computer off. It is read from your Sia account through navigate.st; only this file is shared.'),
h('div', { class: 'row' }, h('label', { class: 'row small' }, h('span', { class: 'muted' }, 'Works for'), days), btn),
out);
? `Anyone with the link sees ${prefix ? 'the files in this folder' : 'the files at the top of this drive'} and can open them on any device, even with this computer off. Sub-folders are not included.`
: 'Anyone with the link can open this file on any device, even with this computer off. Only this file is shared.'),
list,
h('div', { class: 'row link-make' }, h('label', { class: 'row small' }, h('span', { class: 'muted' }, 'Works for'), days), btn),
err);
}
async function siaFolderLink(prefix) {
await modal(`Public link for ${prefix ? prefix.slice(0, -1).split('/').pop() : bucket}`, siaLinkBlock({ prefix }), [{ label: 'Done', kind: 'primary', submit: true, result: true }]);
await modal(`Share ${prefix ? prefix.slice(0, -1).split('/').pop() : bucket}`, siaLinkBlock({ prefix }), [{ label: 'Done', kind: 'primary', submit: true, result: true }]);
}
// On Silent Mode a link is a rule on the server, and the file is stored
@ -2732,6 +2766,16 @@ function setupFooter() {
}
check.hidden = false;
check.addEventListener('click', () => scan(true));
// An update applied in place swaps the files, but the engine keeps running
// the version it loaded until Theseus restarts.
const installed = state.status?.installed;
if (installed && installed !== current) {
busy = true;
showChip(`↻ Restart to finish v${installed}`, '', `Pithos v${installed} is installed, but its engine still runs v${current} until Theseus restarts. Theseus reopens your tabs.`, async () => {
try { await bridge.invoke('update', { step: 'apply' }); } catch (e) { flash('⚠ Restart failed', 'err', e.message); }
});
return;
}
setTimeout(() => scan(false), 1500);
setInterval(() => scan(false), 6 * 3600 * 1000);
}