From 4101bae7c98b1fc583a452a1ab640793de90a9da Mon Sep 17 00:00:00 2001 From: Local Dev Date: Tue, 6 Oct 2026 00:13:11 +0200 Subject: [PATCH] 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// 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. --- core/server.js | 25 +++++++++++------ theseus/index.js | 4 +++ ui/app.css | 10 +++++++ ui/app.js | 72 ++++++++++++++++++++++++++++++++++++++---------- 4 files changed, 89 insertions(+), 22 deletions(-) diff --git a/core/server.js b/core/server.js index 64b023e..97b6a4e 100644 --- a/core/server.js +++ b/core/server.js @@ -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 }; }); diff --git a/theseus/index.js b/theseus/index.js index 3d1fa15..1595b5c 100644 --- a/theseus/index.js +++ b/theseus/index.js @@ -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//: 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" diff --git a/ui/app.css b/ui/app.css index 71ba1bf..28de4f6 100644 --- a/ui/app.css +++ b/ui/app.css @@ -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; } diff --git a/ui/app.js b/ui/app.js index 3cee78b..777b26f 100644 --- a/ui/app.js +++ b/ui/app.js @@ -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); }