Theseus: bundle Pithos 0.3.9

This commit is contained in:
Local Dev 2026-10-04 03:18:25 +02:00
parent 6fd44db1d6
commit 556a22b062
3 changed files with 75 additions and 14 deletions

View file

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

@ -184,7 +184,7 @@ pre.snippet { background: var(--surface-2); padding: 10px 12px; border-radius: 8
.steps span.on { background: var(--accent-soft); color: var(--accent); font-weight: 600; } .steps span.on { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
/* modal + toasts */ /* modal + toasts */
.modal-backdrop { position: fixed; inset: 0; background: rgb(0 0 0 / .45); display: grid; place-items: center; padding: 16px; z-index: 10; } .modal-backdrop { position: fixed; inset: 0; background: rgb(0 0 0 / .45); display: grid; place-items: center; padding: 16px; z-index: 25; }
.modal { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 22px; width: min(560px, 100%); max-height: 90vh; overflow: auto; } .modal { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 22px; width: min(560px, 100%); max-height: 90vh; overflow: auto; }
.modal h2 { margin-bottom: 14px; } .modal h2 { margin-bottom: 14px; }
.modal .foot { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; } .modal .foot { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
@ -399,3 +399,20 @@ body.has-foot.has-player .toasts { bottom: 112px; }
.narrow-only { display: inline-flex; align-items: center; } .narrow-only { display: inline-flex; align-items: center; }
table.files td.actions { padding-left: 2px; } table.files td.actions { padding-left: 2px; }
} }
/* Player volume */
.pl-volume { position: relative; display: flex; align-items: center; gap: 4px; flex: none; }
.pl-volpop { display: flex; align-items: center; }
.pl-volpop > .pl-btn { display: none; }
.pl-vol { width: 90px; accent-color: var(--accent); margin: 0; }
.pl-volbtn { font-size: 15px; }
@media (max-width: 760px) {
.pl-volpop { display: none; position: absolute; bottom: calc(100% + 10px); right: -6px; padding: 8px 10px; gap: 6px;
background: var(--surface); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 8px 24px rgb(0 0 0 / .2); }
.pl-volume.open .pl-volpop { display: flex; }
.pl-volpop > .pl-btn { display: grid; }
.pl-vol { width: 120px; }
}
/* "More options" disclosure in dialogs */
details.more-opts > summary { cursor: pointer; color: var(--muted); font-size: 13px; }
details.more-opts > summary:hover { color: var(--text); }

View file

@ -1291,7 +1291,8 @@ async function accessDialog(user, bucket) {
opts.map(([val, label, desc]) => h('label', { class: 'check', style: 'align-items:flex-start' }, opts.map(([val, label, desc]) => h('label', { class: 'check', style: 'align-items:flex-start' },
h('input', { type: 'radio', name: 'acc', checked: choice === val, onchange: () => { choice = val; } }), h('input', { type: 'radio', name: 'acc', checked: choice === val, onchange: () => { choice = val; } }),
h('span', {}, h('strong', {}, label), h('br'), h('span', { class: 'small muted' }, desc)))), h('span', {}, h('strong', {}, label), h('br'), h('span', { class: 'small muted' }, desc)))),
h('p', { class: 'small muted', style: 'margin:0' }, 'Public objects are served at ', h('code', {}, `${endpoint}/${bucket}/<key>`), '. Writes and deletes always need a key.'), h('p', { class: 'small muted', style: 'margin:0' }, 'Public objects are served at ', h('code', {}, `${endpoint}/${bucket}/<key>`), '. Writes and deletes always need a key. You can change this at any time.'),
localOnly() && h('div', { class: 'banner info small', style: 'margin:0' }, h('span', {}, 'Right now s3d listens only on this computer, so "public" means public to programs on this computer. Other people reach public files once s3d has a public address.')),
h('p', { class: 'small muted', style: 'margin:0' }, 'Access applies to the whole bucket: s3d cannot make a single folder public, or give other people write rights. Keep public files in a bucket of their own, or send a share link for a single file.')), h('p', { class: 'small muted', style: 'margin:0' }, 'Access applies to the whole bucket: s3d cannot make a single folder public, or give other people write rights. Keep public files in a bucket of their own, or send a share link for a single file.')),
[{ label: 'Cancel', result: null }, { label: 'Save', kind: 'primary', submit: true, value: () => choice }]); [{ label: 'Cancel', result: null }, { label: 'Save', kind: 'primary', submit: true, value: () => choice }]);
if (!v || v === current) return; if (!v || v === current) return;
@ -1526,26 +1527,40 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) {
try { const r = await api('DELETE', `${base}/prefix?prefix=${encodeURIComponent(p)}`); toast(`Deleted ${r.deleted} objects`); load(); } catch (e) { fail(e); } try { const r = await api('DELETE', `${base}/prefix?prefix=${encodeURIComponent(p)}`); toast(`Deleted ${r.deleted} objects`); load(); } catch (e) { fail(e); }
} }
// Share: the link is made as soon as the dialog opens and remade when the
// validity or address changes. s3d listens only on this computer, so
// without an address of your own the link opens only here; it says so.
async function share(key) { async function share(key) {
const expires = h('select', {}, [['3600', '1 hour'], ['86400', '1 day'], ['604800', '7 days (max)']].map(([v, l]) => h('option', { value: v }, l))); const expires = h('select', {}, [['3600', '1 hour'], ['86400', '1 day'], ['604800', '7 days (max)']].map(([v, l]) => h('option', { value: v, selected: v === '86400' }, l)));
const savedEndpoint = localStorage.getItem('pithos.publicEndpoint') || ''; const savedEndpoint = localStorage.getItem('pithos.publicEndpoint') || '';
const endpoint = h('input', { type: 'url', value: savedEndpoint, placeholder: `http://${state.status?.config.apiAddress}` }); const endpoint = h('input', { type: 'url', value: savedEndpoint, placeholder: 'https://files.example.com' });
const out = h('div'); const out = h('div', {}, h('p', { class: 'muted small' }, 'Making the link…'));
const note = h('div');
let link = '';
const gen = async () => { const gen = async () => {
const q = new URLSearchParams({ key, expires: expires.value }); const q = new URLSearchParams({ key, expires: expires.value });
if (endpoint.value.trim()) { q.set('endpoint', endpoint.value.trim()); localStorage.setItem('pithos.publicEndpoint', endpoint.value.trim()); } const own = endpoint.value.trim();
try { localStorage.setItem('pithos.publicEndpoint', own); } catch {}
if (own) q.set('endpoint', own);
try { try {
const { url } = await api('GET', `${base}/presign?${q}`); ({ url: link } = await api('GET', `${base}/presign?${q}`));
put(out, secretRow('Link', url)); put(out, secretRow('Link', link));
} catch (e) { fail(e); } } catch (e) { put(out, h('p', { class: 'error small' }, e.message)); }
return false; put(note, !own && localOnly() && h('div', { class: 'banner info small', style: 'margin:0' },
h('span', {}, 'This link opens only on this computer: s3d listens on ', h('code', {}, state.status?.config.apiAddress || '127.0.0.1'), ', which other devices cannot reach. To share with other people, s3d needs a public address (for example your own server in front of it).')));
}; };
expires.addEventListener('change', gen);
endpoint.addEventListener('change', gen);
gen();
await modal('Share link', h('div', { class: 'stack' }, await modal('Share link', h('div', { class: 'stack' },
h('p', { class: 'small muted', style: 'margin:0' }, 'A signed link that works without a key until it expires.'), h('p', { class: 'small muted', style: 'margin:0' }, 'A signed link that works without a key until it expires.'),
h('label', { class: 'field' }, h('span', {}, 'Valid for'), expires), h('label', { class: 'field' }, h('span', {}, 'Valid for'), expires),
h('label', { class: 'field' }, h('span', {}, 'Public address of s3d'), endpoint, h('small', {}, 'The address recipients will use to reach s3d (e.g. behind a reverse proxy). Leave blank for the local endpoint.')), out,
out), note,
[{ label: 'Close', result: true }, { label: 'Create link', kind: 'primary', submit: true, value: gen }]); h('details', { class: 'more-opts', open: !!savedEndpoint },
h('summary', {}, 'Use my own public address'),
h('label', { class: 'field', style: 'margin-top:8px' }, endpoint, h('small', {}, 'The address where other people reach this s3d, e.g. a reverse proxy on your server. The link is remade when you change it.')))),
[{ label: 'Copy link', value: async () => { if (link) await copy(link, 'Link copied'); return false; } }, { label: 'Done', kind: 'primary', submit: true, result: true }]);
} }
load(); load();
@ -1577,9 +1592,33 @@ function player() {
const prevBtn = h('button', { type: 'button', class: 'pl-btn', 'aria-label': 'Previous', title: 'Previous', onclick: () => prev() }, '⏮'); const prevBtn = h('button', { type: 'button', class: 'pl-btn', 'aria-label': 'Previous', title: 'Previous', onclick: () => prev() }, '⏮');
const nextBtn = h('button', { type: 'button', class: 'pl-btn', 'aria-label': 'Next', title: 'Next', onclick: () => next() }, '⏭'); const nextBtn = h('button', { type: 'button', class: 'pl-btn', 'aria-label': 'Next', title: 'Next', onclick: () => next() }, '⏭');
const closeBtn = h('button', { type: 'button', class: 'pl-btn', 'aria-label': 'Stop and close the player', title: 'Close', onclick: () => close() }, '✕'); const closeBtn = h('button', { type: 'button', class: 'pl-btn', 'aria-label': 'Stop and close the player', title: 'Close', onclick: () => close() }, '✕');
// Volume: a slider beside a mute button; on narrow screens the button
// opens the slider in a small pop-up above it. Remembered between visits.
const saved = (() => { try { return JSON.parse(localStorage.getItem('pithos.volume') || 'null'); } catch { return null; } })();
audio.volume = saved && isFinite(saved.v) ? Math.min(1, Math.max(0, saved.v)) : 1;
audio.muted = !!saved?.m;
const volIcon = () => (audio.muted || audio.volume === 0 ? '🔇' : audio.volume < 0.5 ? '🔉' : '🔊');
const vol = h('input', { type: 'range', min: '0', max: '1', step: '0.05', value: String(audio.volume), class: 'pl-vol', 'aria-label': 'Volume',
oninput: () => { audio.volume = +vol.value; if (audio.volume > 0) audio.muted = false; } });
const volBtn = h('button', { type: 'button', class: 'pl-btn pl-volbtn', title: 'Mute', 'aria-label': 'Mute', onclick: () => {
if (matchMedia('(max-width: 760px)').matches) { volBox.classList.toggle('open'); return; }
audio.muted = !audio.muted;
} }, volIcon());
const muteInPop = h('button', { type: 'button', class: 'pl-btn', title: 'Mute', 'aria-label': 'Mute', onclick: () => { audio.muted = !audio.muted; } }, '🔇');
const volBox = h('div', { class: 'pl-volume' }, volBtn, h('div', { class: 'pl-volpop' }, muteInPop, vol));
audio.addEventListener('volumechange', () => {
volBtn.textContent = volIcon();
const m = audio.muted ? 'Unmute' : 'Mute';
volBtn.title = muteInPop.title = m; muteInPop.setAttribute('aria-label', m);
muteInPop.textContent = audio.muted ? '🔈' : '🔇';
vol.value = String(audio.muted ? 0 : audio.volume);
try { localStorage.setItem('pithos.volume', JSON.stringify({ v: audio.volume, m: audio.muted })); } catch {}
});
document.addEventListener('mousedown', (e) => { if (!volBox.contains(e.target)) volBox.classList.remove('open'); });
const el = h('div', { class: 'player', role: 'region', 'aria-label': 'Music player', hidden: true }, const el = h('div', { class: 'player', role: 'region', 'aria-label': 'Music player', hidden: true },
h('div', { class: 'pl-controls' }, prevBtn, playBtn, nextBtn), h('div', { class: 'pl-controls' }, prevBtn, playBtn, nextBtn),
h('div', { class: 'pl-info' }, h('div', { class: 'pl-head' }, title, where), h('div', { class: 'pl-seek' }, cur, seek, dur)), h('div', { class: 'pl-info' }, h('div', { class: 'pl-head' }, title, where), h('div', { class: 'pl-seek' }, cur, seek, dur)),
volBox,
closeBtn); closeBtn);
document.body.append(el); document.body.append(el);
@ -1801,6 +1840,11 @@ $('#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';
// s3d's S3 port is reachable only from this computer (the default).
function localOnly() {
const a = state.status?.config?.apiAddress || '';
return /^(127\.|localhost|\[::1\])/.test(a);
}
function cmpSemver(a, b) { function cmpSemver(a, b) {
const pa = String(a || '0').split('.').map((n) => Number(n) || 0); const pa = String(a || '0').split('.').map((n) => Number(n) || 0);
const pb = String(b || '0').split('.').map((n) => Number(n) || 0); const pb = String(b || '0').split('.').map((n) => Number(n) || 0);