Theseus: bundle Pithos 0.3.13

This commit is contained in:
Local Dev 2026-10-04 14:39:45 +02:00
parent 0ce2ab95dd
commit f2913691aa
6 changed files with 129 additions and 16 deletions

View file

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

@ -62,12 +62,24 @@ export class Daemon extends EventEmitter {
logsSince(seq = 0) { return this.logs.filter((l) => l.seq > seq); }
// s3d migrates its database when it starts, and so does every one-shot
// command (users list, keys…) that opens it. Two of them at once failed
// with "table users already exists", so a start waits for running
// commands to finish, and commands wait while s3d is starting.
start() {
if (!this.binary) throw new Error('s3d binary not found');
if (this.child) return this.status();
if (this.child || this.pendingStart) return this.status();
this.exitCode = null;
this.needsLogin = false;
this.setState('starting');
if (this.cliRuns > 0) {
this.pendingStart = true;
this.once('cli-idle', () => { this.pendingStart = false; if (this.state === 'starting' && !this.child) this.spawnServer(); });
} else this.spawnServer();
return this.status();
}
spawnServer() {
const child = spawn(this.binary, [], {
cwd: this.cwd(), env: this.env(), stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
});
@ -91,6 +103,7 @@ export class Daemon extends EventEmitter {
async stop() {
const child = this.child;
if (!child && this.pendingStart) { this.pendingStart = false; this.setState('stopped'); return this.status(); }
if (!child) return this.status();
this.setState('stopping');
const exited = new Promise((r) => child.once('exit', r));
@ -103,15 +116,32 @@ export class Daemon extends EventEmitter {
// One-shot CLI run. Resolves { code, stdout, stderr }; never rejects on a
// non-zero exit so callers can surface s3d's own error text.
run(args, { timeout = 30_000 } = {}) {
if (!this.binary) return Promise.reject(new Error('s3d binary not found'));
return new Promise((resolve, reject) => {
execFile(this.binary, args, {
cwd: this.cwd(), env: this.env(), timeout, windowsHide: true, maxBuffer: 16 << 20,
}, (err, stdout, stderr) => {
if (err && typeof err.code !== 'number') return reject(err);
resolve({ code: err ? err.code : 0, stdout: stdout.replace(ANSI, ''), stderr: stderr.replace(ANSI, '') });
async run(args, { timeout = 30_000 } = {}) {
if (!this.binary) throw new Error('s3d binary not found');
if (this.state === 'starting') await this.settled(20_000);
this.cliRuns = (this.cliRuns || 0) + 1;
try {
return await new Promise((resolve, reject) => {
execFile(this.binary, args, {
cwd: this.cwd(), env: this.env(), timeout, windowsHide: true, maxBuffer: 16 << 20,
}, (err, stdout, stderr) => {
if (err && typeof err.code !== 'number') return reject(err);
resolve({ code: err ? err.code : 0, stdout: stdout.replace(ANSI, ''), stderr: stderr.replace(ANSI, '') });
});
});
} finally {
if (--this.cliRuns === 0) this.emit('cli-idle');
}
}
// Resolves once s3d is no longer starting (or after ms).
settled(ms) {
return new Promise((resolve) => {
if (this.state !== 'starting') return resolve();
const done = () => { clearTimeout(t); this.off('status', on); resolve(); };
const on = (st) => { if (st.state !== 'starting') done(); };
const t = setTimeout(done, ms);
this.on('status', on);
});
}
}

View file

@ -340,7 +340,9 @@ export async function createPithos(opts = {}) {
const autoFlush = createAutoFlush({
settings: () => {
const p = readPrefs(configFile);
return { enabled: p.autoFlush === true, minutes: p.autoFlushMinutes ?? DEFAULT_MINUTES };
// On unless the user turned it off: files left in the buffer otherwise
// never reach Sia, and nobody expects to have to push them.
return { enabled: p.autoFlush !== false, minutes: p.autoFlushMinutes ?? DEFAULT_MINUTES };
},
// Only an s3d that is up can be asked; a stopped one just means no flush.
stats: () => (daemon.state === 'starting' || daemon.state === 'stopping'

View file

@ -421,3 +421,24 @@ details.more-opts > summary:hover { color: var(--text); }
.space { margin-top: 6px; max-width: 420px; }
.space-bar { margin-top: 4px; height: 6px; }
.space-bar.full > i { background: var(--warn); }
/* Navigation: the Accounts card first, then pages, then the s3d group */
nav a.nav-account { display: flex; flex-direction: column; gap: 2px; padding: 9px 10px; margin-bottom: 6px;
border: 1px solid var(--border); border-radius: 10px; background: var(--surface-2); min-width: 0; }
nav a.nav-account:hover { border-color: var(--accent); background: var(--surface-2); }
nav a.nav-account.active { border-color: var(--accent); background: var(--accent-soft); color: var(--text); font-weight: 400; }
.na-label { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
nav a.nav-account.active .na-label { color: var(--accent); }
.na-name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.na-space { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); margin-top: 2px; }
.na-space .bar { height: 4px; }
.na-space .bar.full > i { background: var(--warn); }
.nav-group { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); padding: 12px 10px 4px; }
/* Full page / Back to sidebar as an icon beside the menu button (compact bar) */
.bar-btn { display: none; }
@media (max-width: 760px) {
.bar-btn { display: inline-grid; place-items: center; width: 34px; height: 34px; border: 1px solid var(--border); border-radius: 8px;
background: var(--surface); color: var(--text); font: inherit; font-size: 16px; cursor: pointer; }
.bar-btn:hover { border-color: var(--accent); color: var(--accent); }
}

View file

@ -238,7 +238,8 @@ function route() {
for (const a of document.querySelectorAll('#nav a')) a.classList.toggle('active', a.dataset.view === navView);
// Compact bar (narrow hosts): name the page, fold the menu away.
const here = document.querySelector(`#nav a[data-view="${navView}"]`);
$('#here').textContent = here ? here.textContent : '';
$('#here').textContent = here ? (here.dataset.title || here.textContent) : '';
refreshNavAccount();
setMenu(false);
const main = $('#main');
put(main);
@ -254,6 +255,7 @@ function overview(main) {
main.append(
h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Overview'), h('p', { class: 'muted' }, 'Your s3d gateway and its upload pipeline to Sia.'))),
banners,
pendingBanner(),
h('div', { class: 'grid two' }, daemonCard, statsCard),
);
@ -729,6 +731,7 @@ function otherAccounts(after) {
toast(`Switched to ${name}`);
await refreshStatus();
await loadAccount();
refreshNavAccount(true);
after();
} catch (e) { fail(e); }
}
@ -767,7 +770,7 @@ function setup(main) {
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.'),
h('label', { class: 'field', style: 'max-width:420px' }, h('span', {}, 'Account 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(); 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.')),
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' },
@ -863,6 +866,7 @@ function setup(main) {
toast(`Previous account set aside in ${r.archived}`);
await refreshStatus();
await loadAccount();
refreshNavAccount(true);
Object.assign(wiz, { step: 1, accountReady: false, openedUrl: null, key: null });
navTo('#/start');
} catch (e) { fail(e); }
@ -1282,6 +1286,50 @@ function siaAccountBlock(a, onRefresh) {
'The same phrase approved from two different logins makes two different accounts; the same phrase from the same login is the same account, even on another computer.'));
}
// "N files are waiting to upload to Sia" with Upload now. s3d holds small
// uploads until about 40 MB has built up; until then they exist only on this
// computer, which is easy to miss.
function pendingBanner() {
const el = h('div', { hidden: true });
const st = daemonState();
if (st !== 'running' && st !== 'external') return el;
Promise.all([api('GET', '/api/stats'), api('GET', '/api/autoflush').catch(() => null)]).then(([x, af]) => {
if (!x?.pendingObjects) return;
const n = x.pendingObjects;
const when = af?.enabled
? (af.dueAt ? `They go up by themselves in about ${Math.max(1, Math.ceil((af.dueAt - Date.now()) / 60_000))} min if nothing new arrives.` : `They go up by themselves after ${af.minutes} min with no new uploads.`)
: 'They go up only when about 40 MB has built up, or when you upload them now.';
const btn = h('button', { class: 'btn primary small', onclick: async () => {
btn.disabled = true; put(btn, h('span', { class: 'spinner' }), ' Uploading…');
try { await api('POST', '/api/flush'); toast('Your files are on Sia'); el.hidden = true; }
catch (e) { fail(e); btn.disabled = false; put(btn, 'Upload now'); }
} }, 'Upload now');
put(el, h('div', { class: 'banner info' },
h('span', {}, h('strong', {}, `${n.toLocaleString()} file${n === 1 ? '' : 's'} (${fmtBytes(x.pendingSize)}) waiting to upload to Sia. `),
h('span', { class: 'small' }, `Until then they are only on this computer. ${when}`)),
btn));
el.hidden = false;
}).catch(() => {});
return el;
}
// The Accounts card at the top of the navigation: which Sia account this is
// and how much room it has left. Refreshed at most every 30 s as pages change.
let navAccountAt = 0;
function refreshNavAccount(force) {
if (!force && Date.now() - navAccountAt < 30_000) return;
navAccountAt = Date.now();
loadAccount().then((a) => { $('#na-name').textContent = a?.registered ? accountName(a) : 'Not connected yet'; }).catch(() => {});
api('GET', '/api/space').then((s) => {
const el = $('#na-space');
if (!s?.available || !(s.maxPinnedData > 0)) { el.hidden = true; return; }
const pct = Math.min(100, Math.round((s.pinnedData / s.maxPinnedData) * 100));
put(el, h('span', {}, `${fmtBytes(s.remainingStorage)} free of ${fmtBytes(s.maxPinnedData)}`),
h('span', { class: `bar${pct >= 90 ? ' full' : ''}` }, h('i', { style: `width:${Math.max(pct, s.pinnedData > 0 ? 2 : 0)}%` })));
el.hidden = false;
}).catch(() => {});
}
// Space on the Sia account: "12.4 GiB of 46.6 GiB used · 34.2 GiB free" and a bar.
function spaceLine() {
const el = h('div', { class: 'space', hidden: true });
@ -1311,7 +1359,7 @@ async function bucketList(head, body, user, userSel) {
h('div', { class: 'row' }, userPicker(userSel), h('button', { class: 'btn primary', onclick: create }, 'New drive')));
const card = h('div', { class: 'card' }, h('p', { class: 'muted' }, 'Loading…'));
const bar = h('div', { class: 'view-bar' });
put(body, bar, card);
put(body, pendingBanner(), bar, card);
const base = `/api/s3/${encodeURIComponent(user)}/buckets`;
let mode = viewMode('drives', 'icons');
let list = [];
@ -2304,6 +2352,12 @@ function addSidebarControls() {
} }, '⤢ Full page'));
$('.sidebar').insertBefore(row, $('#daemon-pill'));
addMenuAction('⤢ Open as full page', () => row.querySelector('button').click());
addBarButton('⤢', 'Open as full page', () => row.querySelector('button').click());
}
// A one-click icon in the compact top bar, beside ☰ (narrow layouts only).
function addBarButton(icon, label, run) {
$('.sidebar').insertBefore(h('button', { type: 'button', class: 'bar-btn', title: label, 'aria-label': label, onclick: run }, icon), $('#nav-toggle'));
}
// An action at the end of the ☰ menu (narrow layouts only; wide ones show
@ -2445,6 +2499,7 @@ function addBackToSidebar() {
} }, '⇤ Back to sidebar'));
$('.sidebar').insertBefore(row, $('#daemon-pill'));
addMenuAction('⇤ Back to sidebar', () => row.querySelector('button').click());
addBarButton('⇤', 'Back to sidebar', () => row.querySelector('button').click());
}
function showSignin() {

View file

@ -32,10 +32,15 @@
<aside class="sidebar">
<div class="brand"><img src="icon.svg" alt="" width="28" height="28"><span>Pithos</span><span class="here" id="here"></span></div>
<nav id="nav">
<a href="#/account" data-view="account" data-title="Accounts" class="nav-account" title="Your Sia account: the storage everything here is kept in">
<span class="na-label">Accounts</span>
<span class="na-name" id="na-name">Sia account</span>
<span class="na-space" id="na-space" hidden></span>
</a>
<a href="#/overview" data-view="overview">Overview</a>
<a href="#/buckets" data-view="buckets">Drives</a>
<a href="#/users" data-view="users">Users &amp; keys</a>
<a href="#/account" data-view="account">Account</a>
<a href="#/buckets" data-view="buckets">Drives</a>
<div class="nav-group" role="presentation">s3d</div>
<a href="#/settings" data-view="settings">Settings</a>
<a href="#/logs" data-view="logs">Logs</a>
</nav>