Pithos: name an account after the Sia Storage email, and tell desktop users about new builds

Sia Storage never tells an app who signed in, so accounts showed up as
"Sia account" until someone found the label field. The approval step,
where people are signing in to Sia Storage anyway, now asks for that
email and saves it as the label; the sidebar card names the account the
moment approval lands.

The desktop app had no way to learn about updates. Its footer now checks
Silent Mode's release list (through the local server, so the console's
CSP stays same-origin) and offers the matching installer or portable
build.
This commit is contained in:
Local Dev 2026-10-04 22:02:14 +02:00
parent 7741ed2ee9
commit 60f6b3b367
2 changed files with 87 additions and 3 deletions

View file

@ -164,6 +164,8 @@ export async function createPithos(opts = {}) {
const local = { const local = {
host: hostName, host: hostName,
pithos: pithosVersion, pithos: pithosVersion,
// electron-builder's portable exe sets this; updates then fetch the portable build.
portable: hostName === 'desktop' && !!process.env.PORTABLE_EXECUTABLE_FILE,
daemon: daemon.status(), daemon: daemon.status(),
external: await probeExternal(c), external: await probeExternal(c),
s3d: { ...v, pinned: S3D_VERSION, installable: !!assetForPlatform(), outdated: !!(v?.version && cmpVersion(v.version, S3D_VERSION) < 0) }, s3d: { ...v, pinned: S3D_VERSION, installable: !!assetForPlatform(), outdated: !!(v?.version && cmpVersion(v.version, S3D_VERSION) < 0) },
@ -531,6 +533,20 @@ export async function createPithos(opts = {}) {
// ---- free space on the Sia account ---------------------------------------- // ---- free space on the Sia account ----------------------------------------
// From the indexer (signed with s3d's app key). Cached for a minute. // From the indexer (signed with s3d's app key). Cached for a minute.
let spaceCache = { at: 0, value: null }; let spaceCache = { at: 0, value: null };
// ---- desktop updates: the newest Pithos desktop build on dl.silentmode.st ----
const RELEASES_URL = process.env.PITHOS_RELEASES_URL || 'https://dl.silentmode.st/releases-manifest.json';
let releaseCache = null;
route('GET', '/api/desktop-release', async (req, url) => {
if (releaseCache && Date.now() - releaseCache.at < 10 * 60_000 && url.searchParams.get('fresh') !== '1') return releaseCache.body;
const res = await fetch(`${RELEASES_URL}?t=${Math.floor(Date.now() / 60000)}`, { cache: 'no-store', signal: AbortSignal.timeout(15_000) }).catch((e) => { throw new HttpError(502, e.message); });
if (!res.ok) throw new HttpError(502, `the release list answered ${res.status}`);
const list = ((await res.json())?.releases || []).filter((e) => e.id === 'pithos-desktop' && /^\d+\.\d+\.\d+$/.test(e.version));
const latest = list.reduce((best, e) => (!best || cmpVersion(e.version, best.version) > 0 ? e : best), null);
const body = { latest: latest && { version: latest.version, date: latest.date || null, changes: String(latest.changes || '').slice(0, 600) } };
releaseCache = { at: Date.now(), body };
return body;
});
route('GET', '/api/space', async () => { route('GET', '/api/space', async () => {
// On Silent Mode this call is forwarded: the server's s3d answers for its own folder. // On Silent Mode this call is forwarded: the server's s3d answers for its own folder.
if (Date.now() - spaceCache.at < 60_000 && spaceCache.value) return spaceCache.value; if (Date.now() - spaceCache.at < 60_000 && spaceCache.value) return spaceCache.value;

View file

@ -158,7 +158,13 @@ const eventHandlers = {
if (state.logs.length > 2000) state.logs.shift(); if (state.logs.length > 2000) state.logs.shift();
logSink?.(l); logSink?.(l);
}, },
login(d) { state.login = d; emit(); }, login(d) {
const was = state.login?.state;
state.login = d;
emit();
// the account card names the new account as soon as it is connected
if (['done', 'already'].includes(d?.state) && d.state !== was) refreshNavAccount(true);
},
install(p) { if (p.phase === 'extract') toast('Verified checksum, installing…'); }, install(p) { if (p.phase === 'extract') toast('Verified checksum, installing…'); },
move(p) { progressSink?.(p); }, move(p) { progressSink?.(p); },
convert(p) { progressSink?.(p); }, convert(p) { progressSink?.(p); },
@ -432,6 +438,32 @@ function getStarted(main) {
); );
const go = (n) => { wiz.step = Math.max(0, Math.min(STEPS.length - 1, n)); draw(); }; const go = (n) => { wiz.step = Math.max(0, Math.min(STEPS.length - 1, n)); draw(); };
// Sia Storage never tells an app who signed in, so the person types the
// email while they are signing in there, and it becomes the account label.
// One input lives across redraws, so typing survives the login events.
function emailField() {
if (!wiz.emailInput) {
const inp = h('input', { type: 'email', placeholder: 'you@example.com', maxlength: 120, autocomplete: 'email' });
let timer;
const save = () => {
clearTimeout(timer);
const v = inp.value.trim();
if (v === (wiz.savedEmail || '')) return;
wiz.savedEmail = v;
api('PUT', '/api/account/label', { label: v }).then(() => refreshNavAccount(true)).catch(() => {});
};
inp.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(save, 800); });
inp.addEventListener('change', save);
wiz.emailInput = inp;
}
const inp = wiz.emailInput;
if (document.activeElement === inp) queueMicrotask(() => inp.focus());
return h('label', { class: 'field', style: 'max-width:420px;margin-top:16px' },
h('span', {}, 'Your Sia Storage email (optional)'),
inp,
h('small', {}, 'Sia Storage does not tell apps who you are, so Pithos names this account with the email you type here. You can change it later on Accounts.'));
}
const nav = (opts = {}) => h('div', { class: 'row wizard-nav' }, const nav = (opts = {}) => h('div', { class: 'row wizard-nav' },
wiz.step > 0 && wiz.step < STEPS.length - 1 && h('button', { class: 'btn', disabled: opts.backDisabled, onclick: () => go(wiz.step - 1) }, 'Back'), wiz.step > 0 && wiz.step < STEPS.length - 1 && h('button', { class: 'btn', disabled: opts.backDisabled, onclick: () => go(wiz.step - 1) }, 'Back'),
h('span', { style: 'flex:1' }), h('span', { style: 'flex:1' }),
@ -661,6 +693,7 @@ function getStarted(main) {
h('div', { class: 'row' }, h('div', { class: 'row' },
h('button', { class: wiz.autoOpened === false ? 'btn primary' : 'btn', onclick: () => openLink(l.approvalUrl) }, wiz.autoOpened === false ? 'Open the approval page ↗' : 'Open the approval page again ↗'), h('button', { class: wiz.autoOpened === false ? 'btn primary' : 'btn', onclick: () => openLink(l.approvalUrl) }, wiz.autoOpened === false ? 'Open the approval page ↗' : 'Open the approval page again ↗'),
h('button', { class: 'btn small', onclick: () => copy(l.approvalUrl) }, 'Copy link')), h('button', { class: 'btn small', onclick: () => copy(l.approvalUrl) }, 'Copy link')),
emailField(),
h('p', { class: 'muted row', style: 'margin-top:16px' }, h('span', { class: 'spinner' }), ' Waiting for your approval…'), h('p', { class: 'muted row', style: 'margin-top:16px' }, h('span', { class: 'spinner' }), ' Waiting for your approval…'),
nav({ next: false, extra: h('button', { class: 'btn danger', onclick: () => api('POST', '/api/login/cancel').then(() => go(3)).catch(fail) }, 'Cancel') }), nav({ next: false, extra: h('button', { class: 'btn danger', onclick: () => api('POST', '/api/login/cancel').then(() => go(3)).catch(fail) }, 'Cancel') }),
]; ];
@ -807,7 +840,7 @@ function setup(main) {
h('label', { class: 'field', style: 'max-width:420px' }, h('span', {}, 'Account label'), h('label', { class: 'field', style: 'max-width:420px' }, h('span', {}, 'Account label'),
h('div', { class: 'row', style: 'flex-wrap:nowrap' }, label, h('div', { class: 'row', style: 'flex-wrap:nowrap' }, label,
h('button', { class: 'btn', onclick: async () => { try { await api('PUT', '/api/account/label', { label: label.value }); await loadAccount(); refreshNavAccount(true); toast('Label saved'); } catch (e) { fail(e); } } }, 'Save')), h('button', { class: 'btn', onclick: async () => { try { await api('PUT', '/api/account/label', { label: label.value }); await loadAccount(); refreshNavAccount(true); toast('Label saved'); } catch (e) { fail(e); } } }, 'Save')),
h('small', {}, 'Pithos cannot see the login email, so name it here: the email you approved the connection with is a good label.')), h('small', {}, 'Sia Storage does not tell apps your email, so name the account here: the email you approved the connection with is a good label.')),
siaAccountBlock(accountInfo, async () => { try { accountInfo = await api('GET', '/api/account?fresh=1'); draw(); } catch (e) { fail(e); } }), 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' }, 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'), daemonState() !== 'running' && h('button', { class: 'btn primary', onclick: async () => { await daemonAction(daemonState() === 'crashed' ? 'restart' : 'start'); navTo('#/overview'); } }, 'Start s3d'),
@ -902,7 +935,7 @@ function setup(main) {
await refreshStatus(); await refreshStatus();
await loadAccount(); await loadAccount();
refreshNavAccount(true); refreshNavAccount(true);
Object.assign(wiz, { step: 1, accountReady: false, openedUrl: null, key: null }); Object.assign(wiz, { step: 1, accountReady: false, openedUrl: null, key: null, emailInput: null, savedEmail: '' });
navTo('#/start'); navTo('#/start');
} catch (e) { fail(e); } } catch (e) { fail(e); }
} }
@ -2498,6 +2531,40 @@ $('#unlock-btn').addEventListener('click', async () => {
// hours; a newer release replaces the version with "↑ vX". One click // hours; a newer release replaces the version with "↑ vX". One click
// downloads and verifies it, the next restarts Theseus to apply it. // downloads and verifies it, the next restarts Theseus to apply it.
const UPDATES_URL = 'https://navigate.st/bns/theseus.x/extensions/pithos/updates.json'; const UPDATES_URL = 'https://navigate.st/bns/theseus.x/extensions/pithos/updates.json';
// The desktop app: newer builds are listed in Silent Mode's release manifest
// (read by the local server). The chip downloads the matching installer, or
// the portable exe, in the browser.
function desktopUpdates(current, { ver, chip, check }) {
const file = state.status?.portable ? 'Pithos-portable.exe' : 'Pithos-Setup.exe';
const rest = () => { chip.hidden = true; ver.hidden = false; };
const flash = (text, cls, title) => {
chip.hidden = false; ver.hidden = true; chip.className = `brandupd ${cls}`; chip.textContent = text; chip.title = title || ''; chip.disabled = true; chip.onclick = null;
setTimeout(() => paint(), 2500);
};
let latest = null;
function paint() {
if (!latest || cmpSemver(latest.version, current) <= 0) return rest();
chip.hidden = false; ver.hidden = true; chip.className = 'brandupd'; chip.disabled = false;
chip.textContent = `↑ v${latest.version}`;
chip.title = `Pithos v${latest.version} is available${latest.changes ? ': ' + latest.changes : ''}. Click to download it, then run it to update.`;
chip.onclick = () => window.open(`https://dl.silentmode.st/latest/${file}`, '_blank', 'noopener');
}
async function scan(manual) {
check.classList.add('spin');
try {
latest = (await api('GET', `/api/desktop-release${manual ? '?fresh=1' : ''}`)).latest;
if (manual && !(latest && cmpSemver(latest.version, current) > 0)) flash('✓ Up to date', 'ok', `Pithos v${current} is the latest desktop release`);
else paint();
} catch (e) {
if (manual) flash('⚠ Check failed', 'err', e.message);
} finally { check.classList.remove('spin'); }
}
check.hidden = false;
check.addEventListener('click', () => scan(true));
setTimeout(() => scan(false), 1500);
setInterval(() => scan(false), 6 * 3600 * 1000);
}
// s3d's S3 port is reachable only from this computer (the default). // s3d's S3 port is reachable only from this computer (the default).
function localOnly() { function localOnly() {
const a = state.status?.config?.apiAddress || ''; const a = state.status?.config?.apiAddress || '';
@ -2521,6 +2588,7 @@ function setupFooter() {
if (bridge) { if (bridge) {
$('#brandlink').addEventListener('click', (e) => { e.preventDefault(); bridge.invoke('open-external', { url: 'https://pithos.sia/' }).catch(fail); }); $('#brandlink').addEventListener('click', (e) => { e.preventDefault(); bridge.invoke('open-external', { url: 'https://pithos.sia/' }).catch(fail); });
} }
if (!bridge && state.status?.host === 'desktop' && current) return desktopUpdates(current, { ver, chip, check });
if (!bridge || !current) return; // only the Theseus add-on updates itself if (!bridge || !current) return; // only the Theseus add-on updates itself
let latest = null; let latest = null;