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

This commit is contained in:
Local Dev 2026-10-06 00:13:56 +02:00
parent 62c2f269af
commit c2bc27b0a1
5 changed files with 90 additions and 23 deletions

View file

@ -1,7 +1,7 @@
{ {
"id": "pithos", "id": "pithos",
"name": "Pithos", "name": "Pithos",
"version": "0.3.20", "version": "0.3.21",
"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.", "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", "author": "Silent Mode",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM2RkYzk3Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMWU4ZjZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIyIDZoMjB2NWMwIDItMiAzLTIgNSA4IDMgMTQgMTEgMTQgMjEgMCAxMi05IDIxLTIyIDIxUzEwIDQ5IDEwIDM3YzAtMTAgNi0xOCAxNC0yMSAwLTItMi0zLTItNXoiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMTQgMzBoMzZNMTMgNDBoMzgiIHN0cm9rZT0iIzBiMWYxOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9Ii41NSIvPjwvc3ZnPgo=", "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM2RkYzk3Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMWU4ZjZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIyIDZoMjB2NWMwIDItMiAzLTIgNSA4IDMgMTQgMTEgMTQgMjEgMCAxMi05IDIxLTIyIDIxUzEwIDQ5IDEwIDM3YzAtMTAgNi0xOCAxNC0yMSAwLTItMi0zLTItNXoiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMTQgMzBoMzZNMTMgNDBoMzgiIHN0cm9rZT0iIzBiMWYxOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9Ii41NSIvPjwvc3ZnPgo=",

View file

@ -42,6 +42,15 @@ class HttpError extends Error {
constructor(status, message) { super(message); this.status = status; } 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 = {}) { export async function createPithos(opts = {}) {
const { const {
host = '127.0.0.1', host = '127.0.0.1',
@ -90,13 +99,11 @@ export async function createPithos(opts = {}) {
const tickets = new Map(); const tickets = new Map();
const subscribers = new Set(); const subscribers = new Set();
let versionCache = null; let versionCache = null;
// Pithos's own version: addon.json in the Theseus add-on, package.json elsewhere. // The version of the code running (read when this module loaded) and the one
const pithosVersion = (() => { // on disk now. Theseus can swap an add-on's files in place, but Node keeps
for (const f of ['../addon.json', '../package.json']) { // the modules it already loaded, so they differ until Theseus restarts.
try { const v = JSON.parse(fs.readFileSync(new URL(f, import.meta.url), 'utf8')).version; if (v) return v; } catch {} const pithosVersion = LOADED_VERSION;
} const installedVersion = readOwnVersion();
return null;
})();
let hosted = null; // "On Silent Mode", installed below let hosted = null; // "On Silent Mode", installed below
const broadcast = (event, data) => { const broadcast = (event, data) => {
@ -182,6 +189,8 @@ export async function createPithos(opts = {}) {
const local = { const local = {
host: hostName, host: hostName,
pithos: pithosVersion, 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. // electron-builder's portable exe sets this; updates then fetch the portable build.
portable: hostName === 'desktop' && !!process.env.PORTABLE_EXECUTABLE_FILE, portable: hostName === 'desktop' && !!process.env.PORTABLE_EXECUTABLE_FILE,
platform: process.platform, platform: process.platform,
@ -535,7 +544,7 @@ export async function createPithos(opts = {}) {
const out = await res.json().catch(() => ({})); const out = await res.json().catch(() => ({}));
if (!res.ok) throw new HttpError(res.status === 429 ? 429 : 502, out.error || `navigate.st answered ${res.status}`); if (!res.ok) throw new HttpError(res.status === 429 ? 429 : 502, out.error || `navigate.st answered ${res.status}`);
const list = readSiaLinks(); 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); writeSiaLinks(list);
return { id: out.id, url: out.url, until }; 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"]); const DROP = new Set(["set-cookie", "connection", "keep-alive", "transfer-encoding"]);
api.registerSiteRoute({ host: "pithos.sia", handle: async (request) => { api.registerSiteRoute({ host: "pithos.sia", handle: async (request) => {
const u = new URL(request.url); 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 fetchSite = request.headers.get("sec-fetch-site");
const origin = request.headers.get("origin"); const origin = request.headers.get("origin");
const own = fetchSite ? fetchSite === "same-origin" || fetchSite === "none" 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.private { color: var(--muted); }
.access-chip.public { color: var(--accent); border-color: var(--accent); } .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; } } @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); endpoint.addEventListener('change', gen);
gen(); gen();
await modal('Share', h('div', { class: 'stack' }, await modal('Share', h('div', { class: 'stack' },
siaLinkBlock({ key }), siaLinkBlock({ key, heading: true }),
h('details', { class: 'more-opts', open: !!savedEndpoint }, h('details', { class: 'more-opts', open: !!savedEndpoint },
h('summary', {}, 'Link for this computer, or your own server'), h('summary', {}, 'Link for this computer, or your own server'),
h('div', { class: 'stack', style: 'margin-top:8px' }, 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 // 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. // 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 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 mine = (l) => l.bucket === bucket && (folder ? l.key == null && (l.prefix ?? null) === prefix : l.key === key);
const out = h('div'); 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 () => { 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 { try {
const r = await api('POST', `${base}/sia-link`, { key, prefix, days: Number(days.value) }); 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.`)); freshId = r.id;
put(btn, 'Make another link'); await refresh();
} catch (e) { put(out, h('p', { class: 'error small', style: 'margin:0' }, e.message)); put(btn, 'Try again'); } 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; btn.disabled = false;
} }, 'Create public link'); } }, 'Create public link');
refresh();
return h('div', { class: 'stack' }, 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 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 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. It is read from your Sia account through navigate.st; only this file is shared.'), : 'Anyone with the link can open this file on any device, even with this computer off. Only this file is shared.'),
h('div', { class: 'row' }, h('label', { class: 'row small' }, h('span', { class: 'muted' }, 'Works for'), days), btn), list,
out); h('div', { class: 'row link-make' }, h('label', { class: 'row small' }, h('span', { class: 'muted' }, 'Works for'), days), btn),
err);
} }
async function siaFolderLink(prefix) { 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 // 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.hidden = false;
check.addEventListener('click', () => scan(true)); 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); setTimeout(() => scan(false), 1500);
setInterval(() => scan(false), 6 * 3600 * 1000); setInterval(() => scan(false), 6 * 3600 * 1000);
} }