2026-10-03 19:03:05 +02:00
// Pithos UI. Plain DOM, no build step: every host serves these files as-is.
// All user content (object keys, user names, log lines) goes in via
// textContent through h(); nothing here assigns innerHTML.
// ---------------------------------------------------------------- helpers
function h ( tag , attrs = { } , ... children ) {
const el = document . createElement ( tag ) ;
for ( const [ k , v ] of Object . entries ( attrs || { } ) ) {
if ( v == null || v === false ) continue ;
if ( k === 'class' ) el . className = v ;
else if ( k . startsWith ( 'on' ) ) el . addEventListener ( k . slice ( 2 ) , v ) ;
else if ( k === 'dataset' ) Object . assign ( el . dataset , v ) ;
else if ( v === true ) el . setAttribute ( k , '' ) ;
else el . setAttribute ( k , v ) ;
}
for ( const c of children . flat ( Infinity ) ) {
if ( c == null || c === false ) continue ;
el . append ( c instanceof Node ? c : String ( c ) ) ;
}
return el ;
}
const $ = ( sel , root = document ) => root . querySelector ( sel ) ;
// replaceChildren, but skipping the null/false that conditional children produce.
function put ( el , ... children ) {
el . replaceChildren ( ... children . flat ( Infinity ) . filter ( ( c ) => c != null && c !== false ) ) ;
}
2026-10-03 19:34:55 +02:00
// Inside the Theseus sidebar the page is the add-on's own file, with no
// server origin to fetch from: every call goes over the add-on IPC bridge
// and the add-on forwards it to the control server.
const bridge = window . silentmode && typeof window . silentmode . invoke === 'function' ? window . silentmode : null ;
2026-10-04 03:29:32 +02:00
// On pithos.sia (Theseus) pages have real addresses: pithos.sia/<user>/<drive>/<folder>,
// pithos.sia/settings… Elsewhere (sidebar, web console, desktop) they stay #/ routes.
const PATH _MODE = ! bridge && ( location . protocol === 'bns:' || location . hostname === 'pithos.sia' ) ;
2026-10-03 19:34:55 +02:00
2026-10-03 19:03:05 +02:00
async function api ( method , path , body , { raw } = { } ) {
2026-10-03 19:34:55 +02:00
if ( bridge ) {
const r = await bridge . invoke ( 'api' , { method , path , body } ) ;
const data = r . data || { } ;
if ( r . status >= 400 ) throw new Error ( data . error || ` HTTP ${ r . status } ` ) ;
return data ;
}
2026-10-03 19:03:05 +02:00
const opts = { method , headers : { 'x-pithos' : '1' } , credentials : 'same-origin' } ;
if ( body !== undefined ) {
opts . headers [ 'content-type' ] = 'application/json' ;
opts . body = JSON . stringify ( body ) ;
}
const res = await fetch ( path , opts ) ;
if ( res . status === 401 && path !== '/api/session' ) { showSignin ( ) ; throw new Error ( 'signed out' ) ; }
if ( raw ) return res ;
const data = await res . json ( ) . catch ( ( ) => ( { } ) ) ;
if ( ! res . ok ) throw new Error ( data . error || ` HTTP ${ res . status } ` ) ;
return data ;
}
function toast ( msg , kind = '' ) {
const el = h ( 'div' , { class : ` toast ${ kind } ` } , msg ) ;
$ ( '#toasts' ) . append ( el ) ;
setTimeout ( ( ) => el . remove ( ) , kind === 'error' ? 7000 : 3500 ) ;
}
const fail = ( e ) => { if ( e . message !== 'signed out' ) toast ( e . message , 'error' ) ; } ;
function fmtBytes ( n ) {
if ( n == null || isNaN ( n ) ) return '– ' ;
const u = [ 'B' , 'KiB' , 'MiB' , 'GiB' , 'TiB' , 'PiB' ] ;
let i = 0 ;
while ( n >= 1024 && i < u . length - 1 ) { n /= 1024 ; i ++ ; }
return ` ${ i ? n . toFixed ( n < 10 ? 2 : 1 ) : n } ${ u [ i ] } ` ;
}
function fmtDate ( s ) { if ( ! s ) return '– ' ; const d = new Date ( s ) ; return isNaN ( d ) ? s : d . toLocaleString ( ) ; }
function fmtDuration ( ms ) {
const s = Math . floor ( ms / 1000 ) ;
if ( s < 60 ) return ` ${ s } s ` ;
if ( s < 3600 ) return ` ${ Math . floor ( s / 60 ) } m ${ s % 60 } s ` ;
if ( s < 86400 ) return ` ${ Math . floor ( s / 3600 ) } h ${ Math . floor ( ( s % 3600 ) / 60 ) } m ` ;
return ` ${ Math . floor ( s / 86400 ) } d ${ Math . floor ( ( s % 86400 ) / 3600 ) } h ` ;
}
async function copy ( text , what = 'Copied' ) {
try { await navigator . clipboard . writeText ( text ) ; toast ( what ) ; }
catch { prompt ( 'Copy:' , text ) ; }
}
function modal ( title , body , buttons = [ ] ) {
return new Promise ( ( resolve ) => {
const close = ( v ) => { root . remove ( ) ; document . removeEventListener ( 'keydown' , onKey ) ; resolve ( v ) ; } ;
const onKey = ( e ) => { if ( e . key === 'Escape' ) close ( undefined ) ; } ;
const foot = h ( 'div' , { class : 'foot' } , buttons . map ( ( b ) => h ( 'button' , {
class : ` btn ${ b . kind || '' } ` , type : b . submit ? 'submit' : 'button' ,
onclick : b . submit ? null : async ( ) => { const v = b . value ? await b . value ( ) : b . result ; if ( v !== false ) close ( v ) ; } ,
} , b . label ) ) ) ;
const form = h ( 'form' , { class : 'modal' , onsubmit : async ( e ) => {
e . preventDefault ( ) ;
const b = buttons . find ( ( x ) => x . submit ) ;
const v = b . value ? await b . value ( ) : b . result ;
if ( v !== false ) close ( v ) ;
} } , h ( 'h2' , { } , title ) , body , foot ) ;
const root = h ( 'div' , { class : 'modal-backdrop' , onmousedown : ( e ) => { if ( e . target === root ) close ( undefined ) ; } } , form ) ;
$ ( '#modal-root' ) . append ( root ) ;
document . addEventListener ( 'keydown' , onKey ) ;
setTimeout ( ( ) => ( form . querySelector ( 'input, textarea, select' ) || form . querySelector ( '.btn.primary' ) ) ? . focus ( ) , 0 ) ;
} ) ;
}
function confirmModal ( title , text , label = 'Delete' , kind = 'danger' ) {
return modal ( title , h ( 'p' , { } , text ) , [
{ label : 'Cancel' , result : false } ,
{ label , kind : ` primary ${ kind === 'danger' ? '' : '' } ` , submit : true , result : true } ,
] ) . then ( ( v ) => v === true ) ;
}
function secretRow ( label , value ) {
return h ( 'div' , { class : 'stack' } ,
h ( 'span' , { class : 'small muted' } , label ) ,
h ( 'div' , { class : 'secret' } , h ( 'code' , { } , value ) , h ( 'button' , { class : 'btn small' , type : 'button' , onclick : ( ) => copy ( value ) } , 'Copy' ) ) ) ;
}
// ---------------------------------------------------------------- state
const state = {
status : null ,
login : { state : 'idle' } ,
logs : [ ] ,
logSeq : 0 ,
selectedUser : localStorage . getItem ( 'pithos.user' ) || '' ,
} ;
const listeners = new Set ( ) ;
const onState = ( fn ) => { listeners . add ( fn ) ; return ( ) => listeners . delete ( fn ) ; } ;
const emit = ( ) => listeners . forEach ( ( fn ) => fn ( ) ) ;
async function refreshStatus ( ) {
try {
state . status = await api ( 'GET' , '/api/status' ) ;
state . login = state . status . login ;
renderPill ( ) ;
emit ( ) ;
} catch ( e ) { fail ( e ) ; }
}
2026-10-03 19:34:55 +02:00
const eventHandlers = {
status ( d ) {
2026-10-03 19:03:05 +02:00
if ( ! state . status ) return ;
2026-10-03 19:34:55 +02:00
state . status . daemon = d ;
2026-10-03 19:03:05 +02:00
renderPill ( ) ;
emit ( ) ;
2026-10-03 19:34:55 +02:00
} ,
log ( l ) {
2026-10-03 19:03:05 +02:00
if ( l . seq <= state . logSeq ) return ;
state . logSeq = l . seq ;
state . logs . push ( l ) ;
if ( state . logs . length > 2000 ) state . logs . shift ( ) ;
logSink ? . ( l ) ;
2026-10-03 19:34:55 +02:00
} ,
login ( d ) { state . login = d ; emit ( ) ; } ,
install ( p ) { if ( p . phase === 'extract' ) toast ( 'Verified checksum, installing…' ) ; } ,
} ;
function connectEvents ( ) {
if ( bridge ) {
bridge . on ( 'pithos-event' , ( { event , data } ) => eventHandlers [ event ] ? . ( data ) ) ;
bridge . invoke ( 'events-start' ) . catch ( fail ) ;
return ;
}
const es = new EventSource ( '/api/events' ) ;
for ( const [ name , fn ] of Object . entries ( eventHandlers ) ) es . addEventListener ( name , ( e ) => fn ( JSON . parse ( e . data ) ) ) ;
2026-10-03 19:03:05 +02:00
es . onerror = ( ) => { /* EventSource reconnects on its own */ } ;
}
function daemonState ( ) {
const s = state . status ;
if ( ! s ) return 'stopped' ;
if ( s . daemon . state === 'stopped' && s . external ) return 'external' ;
if ( s . daemon . state === 'crashed' && s . daemon . needsLogin ) return 'not connected' ;
return s . daemon . state ;
}
function renderPill ( ) {
const st = daemonState ( ) ;
const label = { running : 's3d running' , starting : 'Starting…' , stopping : 'Stopping…' , crashed : 's3d stopped (error)' , 'not connected' : 'Not connected to Sia' , stopped : 's3d stopped' , external : 's3d running (external)' } [ st ] ;
const pill = $ ( '#daemon-pill' ) ;
pill . querySelector ( '.dot' ) . className = ` dot ${ st === 'external' ? 'running' : st === 'not connected' ? 'starting' : st } ` ;
pill . querySelector ( '.label' ) . textContent = label ;
}
// ---------------------------------------------------------------- router
let cleanup = [ ] ;
let logSink = null ;
2026-10-04 03:01:00 +02:00
const views = { start : getStarted , account , overview , buckets , users , setup , settings , logs } ;
// Pages that live under Account in the menu.
const NAV _OF = { start : 'account' , setup : 'account' } ;
2026-10-03 19:03:05 +02:00
2026-10-04 03:29:32 +02:00
// [page, ...parts] for the current address. On pithos.sia the first part is a
// page name or an S3 user (whose drives the rest addresses).
function currentRoute ( ) {
if ( ! PATH _MODE ) return location . hash . replace ( /^#\/?/ , '' ) . split ( '/' ) . filter ( ( x , i ) => i > 0 || x ) ;
const segs = location . pathname . split ( '/' ) . filter ( Boolean ) ;
if ( ! segs . length ) return [ ] ;
const first = decodeURIComponent ( segs [ 0 ] ) ;
if ( first === 'drives' ) return [ 'buckets' ] ;
if ( views [ first ] ) return segs ;
if ( state . selectedUser !== first ) { state . selectedUser = first ; try { localStorage . setItem ( 'pithos.user' , first ) ; } catch { } }
return [ 'buckets' , ... segs . slice ( 1 ) ] ;
}
// '#/page/parts' → the address it has on pithos.sia.
function hashToPath ( hash ) {
const [ name = 'overview' , ... rest ] = String ( hash ) . replace ( /^#\/?/ , '' ) . split ( '/' ) ;
if ( name === 'buckets' ) {
const user = state . selectedUser ;
if ( ! user ) return '/drives' ;
return ` / ${ encodeURIComponent ( user ) } ${ rest . length ? '/' + rest . join ( '/' ) : '' } ` ;
}
return ` / ${ [ name , ... rest ] . join ( '/' ) } ` ;
}
// Go to a page given as '#/page/parts', whichever kind of address this host uses.
function navTo ( hash ) {
if ( ! PATH _MODE ) { location . hash = hash ; return ; }
const p = hashToPath ( hash ) ;
if ( p !== location . pathname ) history . pushState ( null , '' , p ) ;
route ( ) ;
}
2026-10-03 19:03:05 +02:00
function route ( ) {
cleanup . forEach ( ( fn ) => fn ( ) ) ;
cleanup = [ ] ;
logSink = null ;
2026-10-03 20:42:23 +02:00
// First run lands on the guided setup until it has been finished once.
2026-10-04 03:01:00 +02:00
const fallback = ! setupDone ( ) && ! state . status ? . registration ? . registered ? 'account' : 'overview' ;
2026-10-04 03:29:32 +02:00
const [ name = fallback , ... rest ] = currentRoute ( ) ;
2026-10-03 20:42:23 +02:00
const view = views [ name ] ? name : fallback ;
2026-10-04 03:01:00 +02:00
const navView = NAV _OF [ view ] || view ;
for ( const a of document . querySelectorAll ( '#nav a' ) ) a . classList . toggle ( 'active' , a . dataset . view === navView ) ;
2026-10-04 02:16:11 +02:00
// Compact bar (narrow hosts): name the page, fold the menu away.
2026-10-04 03:01:00 +02:00
const here = document . querySelector ( ` #nav a[data-view=" ${ navView } "] ` ) ;
2026-10-04 02:16:11 +02:00
$ ( '#here' ) . textContent = here ? here . textContent : '' ;
2026-10-04 03:01:00 +02:00
setMenu ( false ) ;
2026-10-03 19:03:05 +02:00
const main = $ ( '#main' ) ;
put ( main ) ;
views [ view ] ( main , rest . map ( decodeURIComponent ) ) ;
}
// ---------------------------------------------------------------- overview
function overview ( main ) {
const daemonCard = h ( 'div' , { class : 'card' } ) ;
const statsCard = h ( 'div' , { class : 'card' } ) ;
const banners = h ( 'div' ) ;
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 ,
h ( 'div' , { class : 'grid two' } , daemonCard , statsCard ) ,
) ;
function draw ( ) {
const s = state . status ;
if ( ! s ) return ;
put ( banners , ... bannersFor ( s ) ) ;
const st = daemonState ( ) ;
const busy = st === 'starting' || st === 'stopping' ;
const running = st === 'running' || st === 'starting' ;
put ( daemonCard ,
h ( 'div' , { class : 'row spread' } , h ( 'h2' , { } , 'Gateway' ) , h ( 'span' , { class : ` badge ${ st === 'running' || st === 'external' ? 'ok' : st === 'crashed' ? 'warn' : '' } ` } , st ) ) ,
h ( 'dl' , { class : 'kv' } ,
2026-10-04 03:01:00 +02:00
h ( 'dt' , { } , 'Sia account' ) , h ( 'dd' , { } , h ( 'strong' , { } , accountName ( accountInfo ) ) , ' · ' , h ( 'a' , { href : '#/account' } , accountInfo && accountInfo . registered ? 'Manage' : 'Connect' ) ) ,
2026-10-03 19:03:05 +02:00
h ( 'dt' , { } , 'S3 endpoint' ) , h ( 'dd' , { } , h ( 'code' , { } , ` http:// ${ s . config . apiAddress } ` ) ) ,
s . config . https && [ h ( 'dt' , { } , 'S3 HTTPS' ) , h ( 'dd' , { } , h ( 'code' , { } , ` https:// ${ s . config . https } ` ) ) ] ,
h ( 'dt' , { } , 's3d' ) , h ( 'dd' , { } , s . s3d ? . version ? ` v ${ s . s3d . version } ` : ( s . daemon . binary ? 'unknown version' : 'not installed' ) ) ,
h ( 'dt' , { } , 'Uptime' ) , h ( 'dd' , { } , st === 'running' && s . daemon . startedAt ? fmtDuration ( Date . now ( ) - s . daemon . startedAt ) : '– ' ) ,
h ( 'dt' , { } , 'Config' ) , h ( 'dd' , { } , h ( 'code' , { } , s . config . file ) ) ,
h ( 'dt' , { } , 'Data' ) , h ( 'dd' , { } , h ( 'code' , { } , s . config . directory ) ) ,
) ,
h ( 'div' , { class : 'row' , style : 'margin-top:16px' } ,
st === 'external'
? h ( 'span' , { class : 'muted small' } , 'Another s3d process is serving this config. Stop it to manage it from here.' )
: [
h ( 'button' , { class : 'btn primary' , disabled : busy || running || ! s . daemon . binary , onclick : ( ) => daemonAction ( 'start' ) } , 'Start' ) ,
h ( 'button' , { class : 'btn' , disabled : busy || ! running , onclick : ( ) => daemonAction ( 'restart' ) } , 'Restart' ) ,
h ( 'button' , { class : 'btn danger' , disabled : busy || ! running , onclick : ( ) => daemonAction ( 'stop' ) } , 'Stop' ) ,
] ,
) ,
) ;
}
async function drawStats ( ) {
const st = daemonState ( ) ;
if ( st !== 'running' && st !== 'external' ) {
put ( statsCard , h ( 'h2' , { } , 'Upload pipeline' ) , h ( 'p' , { class : 'muted' } , 'Start s3d to see live upload stats.' ) ) ;
return ;
}
try {
const x = await api ( 'GET' , '/api/stats' ) ;
const stat = ( v , l , bad ) => h ( 'div' , { class : ` stat ${ bad ? 'bad' : '' } ` } , h ( 'div' , { class : 'v' } , v ) , h ( 'div' , { class : 'l' } , l ) ) ;
put ( statsCard ,
h ( 'div' , { class : 'row spread' } , h ( 'h2' , { } , 'Upload pipeline' ) ,
h ( 'button' , { class : 'btn small' , disabled : ! x . pendingObjects , title : 'Upload everything waiting in the local buffer now, even if slabs are not full' , onclick : flushNow } , 'Flush now' ) ) ,
h ( 'div' , { class : 'stats' } ,
stat ( x . pendingObjects , ` pending · ${ fmtBytes ( x . pendingSize ) } ` ) ,
stat ( x . uploadedObjects , ` on Sia · ${ fmtBytes ( x . uploadedSize ) } ` ) ,
stat ( x . unpinnedObjects , 'awaiting pin' ) ,
stat ( x . multipartUploads , 'open multipart' ) ,
stat ( x . failedUploads , 'failed uploads' , x . failedUploads > 0 ) ,
stat ( x . orphanedObjects , 'orphaned' , x . orphanedObjects > 0 ) ,
) ,
h ( 'p' , { class : 'small muted' , style : 'margin:12px 0 0' } , 'New objects wait in a local buffer and are packed into full slabs before upload, which keeps wasted space down.' ) ,
) ;
} catch ( e ) {
put ( statsCard , h ( 'h2' , { } , 'Upload pipeline' ) , h ( 'p' , { class : 'error' } , e . message ) ) ;
}
}
async function flushNow ( ev ) {
const btn = ev . currentTarget ;
btn . disabled = true ;
put ( btn , h ( 'span' , { class : 'spinner' } ) , ' Flushing…' ) ;
try { await api ( 'POST' , '/api/flush' ) ; toast ( 'All pending objects are on Sia' ) ; }
catch ( e ) { fail ( e ) ; }
drawStats ( ) ;
}
draw ( ) ;
drawStats ( ) ;
2026-10-03 22:57:47 +02:00
loadAccount ( ) . then ( draw ) ;
2026-10-03 19:03:05 +02:00
cleanup . push ( onState ( draw ) ) ;
let lastState = daemonState ( ) ;
cleanup . push ( onState ( ( ) => { if ( daemonState ( ) !== lastState ) { lastState = daemonState ( ) ; drawStats ( ) ; } } ) ) ;
const t = setInterval ( ( ) => { draw ( ) ; drawStats ( ) ; } , 4000 ) ;
cleanup . push ( ( ) => clearInterval ( t ) ) ;
}
function bannersFor ( s ) {
const out = [ ] ;
if ( ! s . daemon . binary ) {
out . push ( h ( 'div' , { class : 'banner warn' } ,
h ( 'span' , { } , ` s3d is not installed. Pithos can download the official v ${ s . s3d . pinned } release and check its SHA-256. ` ) ,
s . s3d . installable && h ( 'button' , { class : 'btn primary' , onclick : installBinary } , ` Install s3d v ${ s . s3d . pinned } ` ) ) ) ;
} else if ( s . s3d ? . outdated ) {
out . push ( h ( 'div' , { class : 'banner warn' } ,
h ( 'span' , { } , ` s3d v ${ s . s3d . version } is older than v ${ s . s3d . pinned } . User and key listing needs v0.2.0 or newer. ` ) ,
s . s3d . installable && h ( 'button' , { class : 'btn' , onclick : installBinary } , ` Install v ${ s . s3d . pinned } ` ) ) ) ;
}
if ( s . registration ? . registered === false || s . daemon . needsLogin ) {
out . push ( h ( 'div' , { class : 'banner info' } ,
h ( 'span' , { } , 'This s3d is not connected to a Sia indexer yet.' ) ,
2026-10-04 03:01:00 +02:00
h ( 'a' , { class : 'btn primary' , href : '#/account' } , 'Get started' ) ) ) ;
2026-10-03 19:03:05 +02:00
}
return out ;
}
async function installBinary ( ev ) {
const btn = ev . currentTarget ;
btn . disabled = true ;
put ( btn , h ( 'span' , { class : 'spinner' } ) , ' Downloading…' ) ;
try {
const r = await api ( 'POST' , '/api/binary/install' ) ;
toast ( ` Installed s3d v ${ r . version ? . version || '' } ` ) ;
} catch ( e ) { fail ( e ) ; }
refreshStatus ( ) ;
}
async function daemonAction ( action ) {
try {
await api ( 'POST' , ` /api/daemon/ ${ action } ` ) ;
await refreshStatus ( ) ;
} catch ( e ) { fail ( e ) ; }
}
2026-10-03 20:42:23 +02:00
// ---------------------------------------------------------------- get started (guided setup)
// One step per screen, Back / Next, from "no account" to a working S3 key.
// The connect step drives the same /api/login flow as the Sia connection
// page, but opens the indexer's approval page by itself and moves on as
// soon as the approval lands.
const SETUP _DONE _KEY = 'pithos.setupDone' ;
const wiz = { step : 0 , indexerUrl : 'https://sia.storage' , mode : 'new' , phrase : '' , ownIndexer : false , openedUrl : null , key : null } ;
function setupDone ( ) {
try { return localStorage . getItem ( SETUP _DONE _KEY ) === '1' ; } catch { return false ; }
}
function getStarted ( main ) {
const STEPS = [ 'Welcome' , 'Sia Storage account' , 'Install s3d' , 'Recovery phrase' , 'Connect' , 'Start gateway' , 'Access key' , 'Done' ] ;
const body = h ( 'div' , { class : 'card wizard' } ) ;
main . append (
2026-10-04 03:01:00 +02:00
h ( 'div' , { class : 'page-head' } , h ( 'div' , { } , h ( 'h1' , { } , setupDone ( ) ? 'Add a Sia account' : 'Get started' ) ,
h ( 'p' , { class : 'muted' } , setupDone ( ) ? 'Connect s3d to another Sia account, one step at a time.' : 'Set up your own S3 storage on Sia, one step at a time.' ) ) ) ,
2026-10-03 20:42:23 +02:00
body ,
) ;
const go = ( n ) => { wiz . step = Math . max ( 0 , Math . min ( STEPS . length - 1 , n ) ) ; draw ( ) ; } ;
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' ) ,
h ( 'span' , { style : 'flex:1' } ) ,
opts . extra ,
opts . next !== false && h ( 'button' , { class : 'btn primary' , disabled : ! ! opts . nextDisabled , onclick : opts . onNext || ( ( ) => go ( wiz . step + 1 ) ) } , opts . nextLabel || 'Next' ) ) ;
function progress ( ) {
return h ( 'div' , { class : 'wizard-progress' } ,
h ( 'div' , { class : 'small muted' } , ` Step ${ wiz . step + 1 } of ${ STEPS . length } · ${ STEPS [ wiz . step ] } ` ) ,
h ( 'div' , { class : 'bar' } , h ( 'i' , { style : ` width: ${ Math . round ( ( wiz . step / ( STEPS . length - 1 ) ) * 100 ) } % ` } ) ) ) ;
}
function draw ( ) {
const s = state . status ;
const registered = ! ! s ? . registration ? . registered || [ 'done' , 'already' ] . includes ( state . login . state ) ;
let content ;
switch ( wiz . step ) {
case 0 :
content = [
h ( 'h2' , { } , 'Your own S3 storage, on Sia' ) ,
h ( 'p' , { } , 'Pithos runs s3d, an S3-compatible gateway, on this computer. Your files are encrypted here and spread across independent Sia hosts.' ) ,
h ( 'p' , { } , 'This takes about five minutes:' ) ,
h ( 'ol' , { class : 'wizard-list' } ,
h ( 'li' , { } , 'Create a free Sia Storage account (50 GB free)' ) ,
h ( 'li' , { } , 'Install s3d' ) ,
h ( 'li' , { } , 'Choose your recovery phrase' ) ,
h ( 'li' , { } , 'Approve Pithos on Sia Storage' ) ,
h ( 'li' , { } , 'Start the gateway and create an access key' ) ) ,
registered && h ( 'div' , { class : 'banner info' } , h ( 'span' , { } , 'This s3d is already connected to Sia. You can skip ahead.' ) ,
h ( 'button' , { class : 'btn' , onclick : ( ) => go ( 5 ) } , 'Skip to the gateway' ) ) ,
nav ( { nextLabel : 'Get started' } ) ,
] ;
break ;
case 1 : {
const url = h ( 'input' , { type : 'url' , value : wiz . indexerUrl , oninput : ( e ) => { wiz . indexerUrl = e . target . value . trim ( ) ; } } ) ;
content = [
h ( 'h2' , { } , 'Create your Sia Storage account' ) ,
h ( 'p' , { } , 'Sia Storage runs the indexer that rents space from Sia hosts for you. The free plan gives you 50 GB; you sign up with Sia Storage directly, and Pithos never sees your account password.' ) ,
2026-10-03 21:25:25 +02:00
! wiz . ownIndexer && [
h ( 'div' , { class : 'row' } ,
h ( 'button' , { class : wiz . accountReady ? 'btn' : 'btn primary' , onclick : ( ) => openLink ( 'https://sia.storage' ) } , 'Open sia.storage ↗' ) ,
h ( 'span' , { class : 'muted small' } , 'Sign up or sign in there, then come back here.' ) ) ,
// Pithos cannot see the sia.storage tab, so the user confirms; the
// Connect step proves it anyway, since approving needs a signed-in account.
h ( 'label' , { class : 'check' , style : 'margin-top:16px' } ,
h ( 'input' , { type : 'checkbox' , checked : ! ! wiz . accountReady , onchange : ( e ) => { wiz . accountReady = e . target . checked ; draw ( ) ; } } ) ,
'I have created my Sia Storage account and I am signed in' ) ,
] ,
h ( 'label' , { class : 'check' , style : 'margin-top:12px' } ,
2026-10-03 20:42:23 +02:00
h ( 'input' , { type : 'checkbox' , checked : wiz . ownIndexer , onchange : ( e ) => { wiz . ownIndexer = e . target . checked ; if ( ! wiz . ownIndexer ) wiz . indexerUrl = 'https://sia.storage' ; draw ( ) ; } } ) ,
'I run my own indexer instead' ) ,
wiz . ownIndexer && h ( 'label' , { class : 'field' , style : 'margin-top:8px' } , h ( 'span' , { } , 'Indexer URL' ) , url ) ,
2026-10-03 21:25:25 +02:00
nav ( {
nextDisabled : ! wiz . ownIndexer && ! wiz . accountReady ,
onNext : ( ) => {
if ( ! /^https?:\/\/\S+$/ . test ( wiz . indexerUrl ) ) return toast ( 'Enter the indexer URL' , 'error' ) ;
go ( 2 ) ;
} ,
} ) ,
2026-10-03 20:42:23 +02:00
] ;
break ;
}
case 2 : {
const ok = ! ! s ? . daemon . binary && ! s ? . s3d ? . outdated && ! ! s ? . s3d ? . version ;
content = [
h ( 'h2' , { } , 'Install s3d' ) ,
ok
? h ( 'p' , { } , '✓ s3d ' , h ( 'code' , { } , ` v ${ s . s3d . version } ` ) , ' is installed.' )
: [
h ( 'p' , { } , s ? . daemon . binary ? ` Your s3d (v ${ s . s3d . version || '?' } ) is too old for Pithos. ` : 's3d is not installed yet.' ) ,
h ( 'p' , { class : 'muted small' } , ` Pithos downloads the official v ${ s ? . s3d ? . pinned } release from the Sia Foundation and checks its SHA-256 before installing it. ` ) ,
h ( 'button' , { class : 'btn primary' , disabled : ! s ? . s3d ? . installable , onclick : async ( e ) => { await installBinary ( e ) ; draw ( ) ; } } , ` Install s3d v ${ s ? . s3d ? . pinned } ` ) ,
] ,
nav ( { nextDisabled : ! ok } ) ,
] ;
break ;
}
case 3 : {
const phrase = h ( 'textarea' , { rows : 3 , autocomplete : 'off' , spellcheck : 'false' , placeholder : 'twelve words separated by spaces' , oninput : ( e ) => { wiz . phrase = e . target . value ; } } ) ;
phrase . value = wiz . phrase ;
const vaultOk = ! ! ( bridge && vaultInfo && vaultInfo . setup ) ;
content = [
h ( 'h2' , { } , 'Your recovery phrase' ) ,
h ( 'p' , { } , 'Twelve words that let you reconnect to the same storage later, on this computer or a new one. Your files cannot be reached without them.' ) ,
h ( 'div' , { class : 'stack' } ,
vaultOk && h ( 'label' , { class : 'check' , style : 'align-items:flex-start' } , h ( 'input' , { type : 'radio' , name : 'wm' , checked : wiz . mode === 'vault' , onchange : ( ) => { wiz . mode = 'vault' ; wiz . modeChosen = true ; draw ( ) ; } } ) ,
h ( 'span' , { } , h ( 'strong' , { } , 'Use my Theseus vault' ) , ' (recommended)' , h ( 'br' ) , h ( 'span' , { class : 'small muted' } , 'Nothing to write down. The phrase comes from your password vault, so backing up the vault backs up your storage too.' ) ) ) ,
h ( 'label' , { class : 'check' } , h ( 'input' , { type : 'radio' , name : 'wm' , checked : wiz . mode === 'new' , onchange : ( ) => { wiz . mode = 'new' ; wiz . modeChosen = true ; draw ( ) ; } } ) , 'Create a new phrase for me to write down' ) ,
h ( 'label' , { class : 'check' } , h ( 'input' , { type : 'radio' , name : 'wm' , checked : wiz . mode === 'existing' , onchange : ( ) => { wiz . mode = 'existing' ; wiz . modeChosen = true ; draw ( ) ; } } ) , 'I already have a phrase (reconnecting)' ) ) ,
wiz . mode === 'existing' && h ( 'label' , { class : 'field' , style : 'margin-top:12px' } , h ( 'span' , { } , 'Recovery phrase' ) , phrase ,
h ( 'small' , { } , 'Sent only to the s3d process on this computer.' ) ) ,
wiz . mode === 'new' && h ( 'p' , { class : 'muted small' , style : 'margin-top:12px' } , 'You will see the new phrase on the next screen and confirm you saved it before anything is registered.' ) ,
wiz . mode === 'vault' && h ( 'p' , { class : 'muted small' , style : 'margin-top:12px' } , 'Theseus may ask for your PIN or master password to open the vault.' ) ,
nav ( { nextLabel : 'Connect' , onNext : ( ) => {
if ( wiz . mode === 'existing' && wiz . phrase . trim ( ) . split ( /\s+/ ) . length !== 12 ) return toast ( 'A recovery phrase is 12 words' , 'error' ) ;
wiz . openedUrl = null ;
go ( 4 ) ;
startConnect ( ) ;
} } ) ,
] ;
break ;
}
case 4 :
content = connectStep ( registered ) ;
break ;
case 5 : {
const st = daemonState ( ) ;
const running = st === 'running' || st === 'external' ;
content = [
h ( 'h2' , { } , 'Start your gateway' ) ,
running
? h ( 'p' , { } , '✓ s3d is running. Your S3 endpoint is ' , h ( 'code' , { } , ` http:// ${ s . config . apiAddress } ` ) , '.' )
: st === 'starting'
? h ( 'p' , { class : 'muted row' } , h ( 'span' , { class : 'spinner' } ) , ' Starting s3d…' )
: [
st === 'crashed' && h ( 'div' , { class : 'banner warn' } , h ( 'span' , { } , 's3d stopped. The Logs page shows why.' ) , h ( 'a' , { class : 'btn' , href : '#/logs' } , 'Logs' ) ) ,
h ( 'p' , { } , 'Pithos starts s3d for you. It keeps running while Pithos is open.' ) ,
h ( 'button' , { class : 'btn primary' , onclick : ( ) => daemonAction ( st === 'crashed' ? 'restart' : 'start' ) } , 'Start s3d' ) ,
] ,
nav ( { nextDisabled : ! running } ) ,
] ;
break ;
}
case 6 : {
if ( wiz . key ) {
const k = wiz . key ;
const endpoint = ` http:// ${ s ? . config . apiAddress } ` ;
content = [
h ( 'h2' , { } , 'Your first access key' ) ,
h ( 'p' , { } , 'S3 apps sign in with this key pair. You can see it again under Users & keys.' ) ,
secretRow ( 'Access key ID' , k . accessKeyId ) ,
secretRow ( 'Secret key' , k . secretKey ) ,
2026-10-03 22:10:01 +02:00
h ( 'div' , { class : 'row' , style : 'margin-top:10px' } , h ( 'button' , { class : 'btn small' , type : 'button' , onclick : ( ) => saveCredentialsDialog ( k ) } , 'Save credentials…' ) ) ,
2026-10-03 20:42:23 +02:00
h ( 'p' , { class : 'small muted' , style : 'margin:14px 0 6px' } , 'Try it with the aws CLI:' ) ,
h ( 'pre' , { class : 'snippet' } , ` aws configure set aws_access_key_id ${ k . accessKeyId } --profile ${ k . user } \n aws configure set aws_secret_access_key ${ k . secretKey } --profile ${ k . user } \n aws configure set region us-east-1 --profile ${ k . user } \n aws --profile ${ k . user } --endpoint-url ${ endpoint } s3 mb s3://my-first-bucket ` ) ,
nav ( { backDisabled : true } ) ,
] ;
} else {
const name = h ( 'input' , { type : 'text' , value : 'me' , pattern : '[A-Za-z0-9][A-Za-z0-9._@-]{0,63}' } ) ;
content = [
h ( 'h2' , { } , 'Create an access key' ) ,
h ( 'p' , { } , 'Each S3 user owns its own buckets. Start with one for yourself; you can add more for apps or people later.' ) ,
h ( 'label' , { class : 'field' } , h ( 'span' , { } , 'User name' ) , name ) ,
nav ( { nextLabel : 'Create user and key' , onNext : async ( e ) => {
e . currentTarget . disabled = true ;
try {
const r = await api ( 'POST' , '/api/users' , { name : name . value . trim ( ) , withKey : true } ) ;
wiz . key = r . key ;
state . selectedUser = r . name ;
try { localStorage . setItem ( 'pithos.user' , r . name ) ; } catch { }
} catch ( err ) { fail ( err ) ; }
draw ( ) ;
} } ) ,
] ;
}
break ;
}
default :
try { localStorage . setItem ( SETUP _DONE _KEY , '1' ) ; } catch { }
content = [
h ( 'h2' , { } , '🎉 You are all set' ) ,
h ( 'p' , { } , 'Your S3 gateway is running and stores everything on Sia. Point any S3 app at it with the key you just created, or manage files right here.' ) ,
h ( 'div' , { class : 'row' } ,
h ( 'a' , { class : 'btn primary' , href : '#/buckets' } , 'Open buckets' ) ,
h ( 'a' , { class : 'btn' , href : '#/overview' } , 'Overview' ) ) ,
] ;
}
put ( body , progress ( ) , content ) ;
}
async function startConnect ( ) {
if ( state . status ? . registration ? . registered ) return ;
if ( wiz . mode === 'vault' && bridge ) {
try {
const r = await bridge . invoke ( 'connect-with-vault' , { indexerUrl : wiz . indexerUrl } ) ;
if ( r . status >= 400 ) throw new Error ( r . data ? . error || ` HTTP ${ r . status } ` ) ;
} catch ( e ) { fail ( e ) ; }
return ;
}
try {
await api ( 'POST' , '/api/login' , { indexerUrl : wiz . indexerUrl , phrase : wiz . mode === 'existing' ? wiz . phrase . trim ( ) : '' } ) ;
wiz . phrase = '' ;
} catch ( e ) { fail ( e ) ; }
}
function connectStep ( registered ) {
const l = state . login ;
const retry = h ( 'button' , { class : 'btn' , onclick : ( ) => go ( 3 ) } , 'Back to the phrase' ) ;
if ( registered ) {
// Approval landed: move on by itself.
setTimeout ( ( ) => { if ( wiz . step === 4 ) { refreshStatus ( ) ; go ( 5 ) ; } } , 1200 ) ;
return [ h ( 'h2' , { } , 'Connected' ) , h ( 'p' , { } , '✓ Pithos is approved on ' , h ( 'code' , { } , l . indexerUrl || wiz . indexerUrl ) , '. Continuing…' ) , nav ( { next : false } ) ] ;
}
switch ( l . state ) {
case 'confirm' : {
const ack = h ( 'input' , { type : 'checkbox' } ) ;
const go2 = h ( 'button' , { class : 'btn primary' , disabled : true , onclick : ( ) => api ( 'POST' , '/api/login/confirm' ) . catch ( fail ) } , 'I saved it — continue' ) ;
ack . addEventListener ( 'change' , ( ) => { go2 . disabled = ! ack . checked ; } ) ;
return [
h ( 'h2' , { } , 'Write down your recovery phrase' ) ,
h ( 'p' , { } , 'This is the only way to reconnect to your storage. Pithos does not keep it. Write it on paper or store it in a password manager.' ) ,
h ( 'div' , { class : 'phrase' } , ( l . generatedPhrase || '' ) . split ( /\s+/ ) . map ( ( w ) => h ( 'span' , { } , w ) ) ) ,
h ( 'div' , { class : 'row' , style : 'margin-top:12px' } , h ( 'button' , { class : 'btn small' , onclick : ( ) => copy ( l . generatedPhrase , 'Phrase copied: clear your clipboard after storing it' ) } , 'Copy' ) ) ,
h ( 'label' , { class : 'check' , style : 'margin:16px 0' } , ack , 'I have stored these 12 words somewhere safe' ) ,
nav ( { next : false , extra : go2 } ) ,
] ;
}
case 'approve' : {
// Open the approval page once, by itself; the button re-opens it.
if ( l . approvalUrl && wiz . openedUrl !== l . approvalUrl ) {
wiz . openedUrl = l . approvalUrl ;
wiz . autoOpened = null ;
openLink ( l . approvalUrl ) . then ( ( ok ) => { wiz . autoOpened = ok ; if ( wiz . step === 4 ) draw ( ) ; } ) ;
}
return [
h ( 'h2' , { } , 'Approve Pithos on Sia Storage' ) ,
h ( 'p' , { } , wiz . autoOpened === false
? 'Open the approval page, sign in to Sia Storage and approve the "S3d" app. Pithos continues on its own once you do.'
: 'The approval page has opened. Sign in to Sia Storage there and approve the "S3d" app. Pithos continues on its own once you do.' ) ,
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 : 'btn small' , onclick : ( ) => copy ( l . approvalUrl ) } , 'Copy link' ) ) ,
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' ) } ) ,
] ;
}
case 'terminal' :
return [
h ( 'h2' , { } , 'Finish in the console window' ) ,
h ( 'p' , { } , 'Terminal support could not be set up here, so a console window running "s3d login" has opened. Enter the indexer URL and your recovery phrase there, then open the link it prints. This page continues when the window closes.' ) ,
h ( 'p' , { class : 'muted row' } , h ( 'span' , { class : 'spinner' } ) , ' Waiting for the login window…' ) ,
nav ( { next : false } ) ,
] ;
case 'failed' :
return [
h ( 'h2' , { } , 'Connection failed' ) ,
h ( 'div' , { class : 'banner warn' } , h ( 'span' , { } , l . error || 'The login did not finish.' ) ) ,
// The phrase is dropped from memory once sent, so a retry with an
// existing phrase has to ask for it again; a blank one would make
// s3d generate a new phrase instead.
nav ( { next : false , extra : [ retry , h ( 'button' , { class : 'btn primary' , onclick : ( ) => {
wiz . openedUrl = null ;
if ( wiz . mode === 'existing' && ! wiz . phrase . trim ( ) ) return go ( 3 ) ;
startConnect ( ) ;
} } , 'Try again' ) ] } ) ,
] ;
case 'idle' :
return [ h ( 'h2' , { } , 'Connect to Sia' ) , h ( 'p' , { } , 'Ready to connect.' ) ,
nav ( { nextLabel : 'Connect' , onNext : ( ) => startConnect ( ) } ) ] ;
default : {
const what = l . state === 'preparing' ? 'Setting up terminal support (first time only)…' : 'Contacting the indexer…' ;
return [ h ( 'h2' , { } , 'Connecting' ) , h ( 'p' , { class : 'muted row' } , h ( 'span' , { class : 'spinner' } ) , ` ${ what } ` ) , nav ( { next : false } ) ] ;
}
}
}
if ( bridge && vaultInfo ? . setup && ! wiz . modeChosen ) wiz . mode = 'vault' ;
// Resume where things stand if the user comes back mid-setup.
if ( wiz . step === 0 && state . login . state === 'approve' ) wiz . step = 4 ;
draw ( ) ;
cleanup . push ( onState ( draw ) ) ;
}
2026-10-03 19:03:05 +02:00
// ---------------------------------------------------------------- setup (indexer login)
2026-10-04 03:01:00 +02:00
// Account: the Sia account this s3d stores and pays through. Before one is
// connected it is the step-by-step guide; after, the account itself, with
// adding another account and switching back to earlier ones.
function account ( main , rest ) {
const registered = ! ! state . status ? . registration ? . registered ;
if ( ! registered && [ 'idle' , 'failed' ] . includes ( state . login . state ) ) {
getStarted ( main , rest ) ;
// Accounts set aside earlier stay one click away while adding a new one.
const slot = h ( 'div' ) ;
main . append ( slot ) ;
loadAccount ( ) . then ( ( ) => { const b = otherAccounts ( ( ) => route ( ) ) ; if ( b ) put ( slot , h ( 'div' , { class : 'card' , style : 'margin-top:16px' } , b ) ) ; } ) ;
return ;
}
return setup ( main , rest ) ;
}
// Earlier Sia accounts set aside on this computer, each one click away.
function otherAccounts ( after ) {
const list = accountInfo ? . archives || [ ] ;
if ( ! list . length ) return null ;
async function restore ( a ) {
const name = a . label || ( a . indexerUrl ? new URL ( a . indexerUrl ) . host : a . name ) ;
const ok = await confirmModal ( ` Switch to ${ name } ? ` , 's3d stops and switches back to that Sia account with its users, keys and drives. The current account is set aside and you can switch back the same way.' , 'Switch' , 'primary' ) ;
if ( ! ok ) return ;
try {
await api ( 'POST' , '/api/account/restore' , { name : a . name } ) ;
await api ( 'POST' , '/api/daemon/start' ) . catch ( ( ) => { } ) ;
toast ( ` Switched to ${ name } ` ) ;
await refreshStatus ( ) ;
await loadAccount ( ) ;
after ( ) ;
} catch ( e ) { fail ( e ) ; }
}
return h ( 'div' , { } ,
h ( 'h3' , { } , 'Other accounts on this computer' ) ,
h ( 'p' , { class : 'small muted' , style : 'margin-top:0' } , 'Set aside when another account was added. Switching back restores its users, keys and drives; the current account is set aside the same way.' ) ,
h ( 'table' , { } , h ( 'tbody' , { } , list . map ( ( a ) => h ( 'tr' , { } ,
h ( 'td' , { } , h ( 'strong' , { } , a . label || ( a . indexerUrl ? new URL ( a . indexerUrl ) . host : a . name ) ) ,
h ( 'div' , { class : 'small muted' } , ` ${ a . archivedAt ? fmtDate ( a . archivedAt ) : a . name } ${ a . users . length ? ` · users: ${ a . users . join ( ', ' ) } ` : '' } ` ) ) ,
h ( 'td' , { class : 'actions' } , h ( 'button' , { class : 'btn small' , disabled : ! a . hasData , onclick : ( ) => restore ( a ) } , 'Switch to' ) ) ) ) ) ) ) ;
}
2026-10-03 19:03:05 +02:00
function setup ( main ) {
const body = h ( 'div' , { class : 'card' } ) ;
main . append (
2026-10-04 03:01:00 +02:00
h ( 'div' , { class : 'page-head' } , h ( 'div' , { } , h ( 'h1' , { } , 'Account' ) ,
h ( 'p' , { class : 'muted' } , 'The Sia account that stores, and pays for, everything on this s3d.' ) ) ) ,
2026-10-03 19:03:05 +02:00
body ,
) ;
let draft = { indexerUrl : 'https://sia.storage' , phrase : '' , mode : 'existing' } ;
function draw ( ) {
let l = state . login ;
// Registered already (e.g. on an earlier run): show that instead of the form.
if ( l . state === 'idle' && state . status ? . registration ? . registered ) l = { state : 'already' , indexerUrl : state . status . registration . indexerUrl } ;
const step = [ 'idle' , 'failed' ] . includes ( l . state ) ? 0 : [ 'done' , 'already' ] . includes ( l . state ) ? 2 : 1 ;
const steps = h ( 'div' , { class : 'steps' } , [ 'Recovery phrase' , 'Approve on the indexer' , 'Connected' ] . map ( ( t , i ) => h ( 'span' , { class : i === step ? 'on' : '' } , ` ${ i + 1 } . ${ t } ` ) ) ) ;
if ( l . state === 'done' || l . state === 'already' ) {
2026-10-03 22:57:47 +02:00
const label = h ( 'input' , { type : 'text' , placeholder : 'e.g. the email you sign in with' , value : accountInfo ? . label || '' , maxlength : 120 } ) ;
2026-10-04 03:01:00 +02:00
put ( body ,
h ( 'h2' , { } , accountInfo ? . label || ( l . indexerUrl ? new URL ( l . indexerUrl ) . host : 'Connected to Sia' ) ) ,
h ( 'p' , { class : 'row small' , style : 'margin-top:-6px' } , h ( 'span' , { class : 'badge ok' } , 'connected' ) , accountInfo ? . label && l . indexerUrl && h ( 'span' , { class : 'muted' } , new URL ( l . indexerUrl ) . host ) ) ,
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.' ) ,
2026-10-03 22:57:47 +02:00
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 ( 'small' , { } , 'Pithos cannot see which login the account belongs to. On sia.storage it is the account whose "My apps" lists "S3d".' ) ) ,
h ( 'div' , { class : 'row' , style : 'margin-top:16px' } ,
2026-10-04 03:29:32 +02:00
daemonState ( ) !== 'running' && h ( 'button' , { class : 'btn primary' , onclick : async ( ) => { await daemonAction ( daemonState ( ) === 'crashed' ? 'restart' : 'start' ) ; navTo ( '#/overview' ) ; } } , 'Start s3d' ) ,
2026-10-03 22:57:47 +02:00
h ( 'a' , { class : 'btn' , href : '#/users' } , 'Users & keys' ) ,
2026-10-04 03:01:00 +02:00
h ( 'button' , { class : 'btn' , onclick : switchAccount } , 'Add another account…' ) ) ,
2026-10-03 22:57:47 +02:00
archivesBlock ( ) ) ;
2026-10-03 19:03:05 +02:00
return ;
}
if ( l . state === 'confirm' ) {
const words = ( l . generatedPhrase || '' ) . split ( /\s+/ ) ;
const ack = h ( 'input' , { type : 'checkbox' , id : 'ack' } ) ;
const go = h ( 'button' , { class : 'btn primary' , disabled : true , onclick : ( ) => api ( 'POST' , '/api/login/confirm' ) . catch ( fail ) } , 'Continue' ) ;
ack . addEventListener ( 'change' , ( ) => { go . disabled = ! ack . checked ; } ) ;
put ( body , steps ,
h ( 'h2' , { } , 'Write down your new recovery phrase' ) ,
h ( 'p' , { } , 'This phrase is the only way to register this storage again on another machine or after a reinstall. Pithos does not keep it. Store it offline.' ) ,
h ( 'div' , { class : 'phrase' } , words . map ( ( w ) => h ( 'span' , { } , w ) ) ) ,
h ( 'div' , { class : 'row' , style : 'margin-top:14px' } , h ( 'button' , { class : 'btn small' , onclick : ( ) => copy ( l . generatedPhrase , 'Phrase copied - clear your clipboard after storing it' ) } , 'Copy' ) ) ,
h ( 'label' , { class : 'check' , style : 'margin:16px 0' } , ack , 'I have stored the phrase somewhere safe' ) ,
h ( 'div' , { class : 'row' } , go , h ( 'button' , { class : 'btn' , onclick : cancel } , 'Cancel' ) ) ) ;
return ;
}
if ( l . state === 'terminal' ) {
put ( body , steps ,
h ( 'h2' , { } , 'Finish in the console window' ) ,
h ( 'p' , { } , 'A console window running "s3d login" has opened. Enter the indexer URL and your recovery phrase there, then open the approval link it prints. This page updates when the window closes.' ) ,
h ( 'p' , { class : 'muted row' } , h ( 'span' , { class : 'spinner' } ) , ' Waiting for the login window…' ) ,
h ( 'div' , { class : 'row' } , h ( 'button' , { class : 'btn danger' , onclick : cancel } , 'Cancel' ) ) ) ;
return ;
}
if ( step === 1 ) {
const waiting = l . state === 'approve' ? null : h ( 'p' , { class : 'muted row' } , h ( 'span' , { class : 'spinner' } ) , ' Talking to the indexer…' ) ;
put ( body , steps ,
h ( 'h2' , { } , 'Approve the connection' ) ,
waiting ,
l . approvalUrl && [
h ( 'p' , { } , 'Open this link, sign in to your indexer account and approve the "S3d" app. This page continues by itself once you approve.' ) ,
h ( 'div' , { class : 'secret' } , h ( 'code' , { } , l . approvalUrl ) ) ,
h ( 'div' , { class : 'row' , style : 'margin-top:12px' } ,
h ( 'button' , { class : 'btn primary' , onclick : ( ) => openLink ( l . approvalUrl ) } , 'Open approval page' ) ,
h ( 'button' , { class : 'btn' , onclick : ( ) => copy ( l . approvalUrl ) } , 'Copy link' ) ) ,
h ( 'p' , { class : 'muted row' , style : 'margin-top:16px' } , h ( 'span' , { class : 'spinner' } ) , ' Waiting for approval…' ) ,
] ,
h ( 'div' , { class : 'row' , style : 'margin-top:12px' } , h ( 'button' , { class : 'btn danger' , onclick : cancel } , 'Cancel' ) ) ) ;
return ;
}
// step 0: form
const url = h ( 'input' , { type : 'url' , value : draft . indexerUrl , required : true , oninput : ( e ) => { draft . indexerUrl = e . target . value ; } } ) ;
const phrase = h ( 'textarea' , { rows : 3 , autocomplete : 'off' , spellcheck : 'false' , placeholder : 'twelve words separated by spaces' , oninput : ( e ) => { draft . phrase = e . target . value ; } } ) ;
phrase . value = draft . phrase ;
const radios = h ( 'div' , { class : 'stack' } ,
h ( 'label' , { class : 'check' } , h ( 'input' , { type : 'radio' , name : 'mode' , checked : draft . mode === 'existing' , onchange : ( ) => { draft . mode = 'existing' ; draw ( ) ; } } ) , 'I have a recovery phrase' ) ,
h ( 'label' , { class : 'check' } , h ( 'input' , { type : 'radio' , name : 'mode' , checked : draft . mode === 'new' , onchange : ( ) => { draft . mode = 'new' ; draw ( ) ; } } ) , 'Generate a new one' ) ) ;
put ( body , steps ,
l . state === 'failed' && h ( 'div' , { class : 'banner warn' } , h ( 'span' , { } , l . error || 'Login failed' ) ) ,
h ( 'form' , { class : 'stack' , onsubmit : start } ,
h ( 'label' , { class : 'field' } , h ( 'span' , { } , 'Indexer' ) , url , h ( 'small' , { } , 'https://sia.storage is the hosted indexer run by the Sia Foundation. Use your own indexd URL if you run one.' ) ) ,
radios ,
draft . mode === 'existing' && h ( 'label' , { class : 'field' } , h ( 'span' , { } , 'Recovery phrase' ) , phrase ,
h ( 'small' , { } , 'Sent only to the local s3d process. It derives this app\'s key and is not stored by Pithos.' ) ) ,
2026-10-03 22:57:47 +02:00
h ( 'div' , { class : 'row' } , h ( 'button' , { class : 'btn primary' , type : 'submit' } , 'Connect' ) ) ) ,
archivesBlock ( ) ) ;
}
function archivesBlock ( ) {
2026-10-04 03:01:00 +02:00
const b = otherAccounts ( draw ) ;
return b && h ( 'div' , { style : 'margin-top:22px' } , b ) ;
2026-10-03 22:57:47 +02:00
}
async function switchAccount ( ) {
let pending = null ;
if ( daemonState ( ) === 'running' ) { try { pending = await api ( 'GET' , '/api/stats' ) ; } catch { } }
const users = ( await api ( 'GET' , '/api/users' ) . catch ( ( ) => [ ] ) ) . map ( ( u ) => u . name ) ;
const waiting = pending && pending . pendingObjects > 0 ;
2026-10-04 03:01:00 +02:00
const ok = await modal ( 'Add another Sia account?' , h ( 'div' , { class : 'stack' } ,
h ( 'p' , { style : 'margin:0' } , ` s3d is connected to ${ accountName ( accountInfo ) } . s3d uses one Sia account at a time, so adding another sets this one aside on this computer; you can switch back to it any time on this page. ` ) ,
2026-10-03 22:57:47 +02:00
h ( 'ul' , { class : 'small' , style : 'margin:0;padding-left:1.2em' } ,
2026-10-04 03:01:00 +02:00
h ( 'li' , { } , ` The current account keeps its files. Its S3 users ${ users . length ? ` ( ${ users . join ( ', ' ) } ) ` : '' } , keys and drives are set aside on this computer, not deleted, and you can switch back to it on the Account page. ` ) ,
2026-10-03 22:57:47 +02:00
h ( 'li' , { } , 'The new account starts empty: you create users and keys again, and S3 apps need the new keys.' ) ,
2026-10-04 03:01:00 +02:00
h ( 'li' , { } , 's3d stops now; the guide then connects the new account.' ) ) ,
2026-10-03 22:57:47 +02:00
waiting && h ( 'div' , { class : 'banner warn' } ,
h ( 'span' , { } , ` ${ pending . pendingObjects } object(s) ( ${ fmtBytes ( pending . pendingSize ) } ) are still waiting to upload to Sia. ` ) ,
h ( 'button' , { class : 'btn' , type : 'button' , onclick : async ( e ) => { e . currentTarget . disabled = true ; try { await api ( 'POST' , '/api/flush' ) ; toast ( 'Pending uploads are on Sia' ) ; } catch ( err ) { fail ( err ) ; } } } , 'Flush now' ) ) ) ,
2026-10-04 03:01:00 +02:00
[ { label : 'Cancel' , result : false } , { label : 'Add account' , kind : 'primary' , submit : true , result : true } ] ) ;
2026-10-03 22:57:47 +02:00
if ( ! ok ) return ;
try {
const r = await api ( 'POST' , '/api/account/switch' ) ;
toast ( ` Previous account set aside in ${ r . archived } ` ) ;
await refreshStatus ( ) ;
await loadAccount ( ) ;
Object . assign ( wiz , { step : 1 , accountReady : false , openedUrl : null , key : null } ) ;
2026-10-04 03:29:32 +02:00
navTo ( '#/start' ) ;
2026-10-03 22:57:47 +02:00
} catch ( e ) { fail ( e ) ; }
}
2026-10-03 19:03:05 +02:00
async function start ( e ) {
e . preventDefault ( ) ;
const phrase = draft . mode === 'existing' ? draft . phrase . trim ( ) : '' ;
if ( draft . mode === 'existing' && phrase . split ( /\s+/ ) . length !== 12 ) return toast ( 'A recovery phrase is 12 words' , 'error' ) ;
try {
await api ( 'POST' , '/api/login' , { indexerUrl : draft . indexerUrl , phrase } ) ;
draft . phrase = '' ;
} catch ( err ) { fail ( err ) ; }
}
async function cancel ( ) { try { await api ( 'POST' , '/api/login/cancel' ) ; } catch ( e ) { fail ( e ) ; } }
draw ( ) ;
2026-10-03 22:57:47 +02:00
loadAccount ( ) . then ( draw ) ;
2026-10-03 19:03:05 +02:00
cleanup . push ( onState ( draw ) ) ;
}
async function openLink ( url ) {
2026-10-03 20:42:23 +02:00
if ( bridge ) { try { await bridge . invoke ( 'open-external' , { url } ) ; return true ; } catch ( e ) { fail ( e ) ; return false ; } }
2026-10-03 19:03:05 +02:00
const host = state . status ? . host ;
if ( host === 'desktop' || host === 'theseus' ) {
2026-10-03 20:42:23 +02:00
try { await api ( 'POST' , '/api/open-external' , { url } ) ; return true ; } catch { /* fall through */ }
2026-10-03 19:03:05 +02:00
}
2026-10-03 20:42:23 +02:00
// Returns false when a pop-up blocker stopped it (no click behind the call).
return ! ! window . open ( url , '_blank' , 'noopener' ) ;
2026-10-03 19:03:05 +02:00
}
// ---------------------------------------------------------------- users & keys
function users ( main ) {
const list = h ( 'div' , { class : 'card' } ) ;
main . append (
h ( 'div' , { class : 'page-head' } ,
h ( 'div' , { } , h ( 'h1' , { } , 'Users & keys' ) , h ( 'p' , { class : 'muted' } , 'Each S3 user owns its own buckets. Access keys let S3 clients (aws, rclone, apps) act as that user.' ) ) ,
2026-10-03 22:10:01 +02:00
h ( 'div' , { class : 'row' } ,
h ( 'button' , { class : 'btn' , onclick : openCredentialsDialog } , 'Open saved credentials' ) ,
h ( 'button' , { class : 'btn primary' , onclick : createUser } , 'New user' ) ) ) ,
2026-10-03 19:03:05 +02:00
list ,
) ;
let expanded = new Set ( ) ;
async function load ( ) {
let rows ;
try { rows = await api ( 'GET' , '/api/users' ) ; }
catch ( e ) { put ( list , h ( 'p' , { class : 'error' } , e . message ) ) ; return ; }
if ( ! rows . length ) {
put ( list , h ( 'div' , { class : 'empty' } , h ( 'p' , { } , 'No users yet.' ) , h ( 'button' , { class : 'btn primary' , onclick : createUser } , 'Create the first user' ) ) ) ;
return ;
}
const keys = await api ( 'GET' , '/api/keys' ) . catch ( ( ) => [ ] ) ;
put ( list , h ( 'table' , { } ,
h ( 'thead' , { } , h ( 'tr' , { } , h ( 'th' , { } , 'User' ) , h ( 'th' , { } , 'Access keys' ) , h ( 'th' , { } ) ) ) ,
h ( 'tbody' , { } , rows . flatMap ( ( u ) => {
const open = expanded . has ( u . name ) ;
const userKeys = keys . filter ( ( k ) => k . user === u . name ) ;
const main = h ( 'tr' , { class : 'clickable' , onclick : ( ) => { open ? expanded . delete ( u . name ) : expanded . add ( u . name ) ; load ( ) ; } } ,
h ( 'td' , { } , h ( 'strong' , { } , u . name ) ) ,
h ( 'td' , { } , ` ${ u . keys . length } ` ) ,
h ( 'td' , { class : 'actions' } ,
2026-10-04 03:29:32 +02:00
h ( 'button' , { class : 'btn small' , onclick : ( e ) => { e . stopPropagation ( ) ; state . selectedUser = u . name ; localStorage . setItem ( 'pithos.user' , u . name ) ; navTo ( '#/buckets' ) ; } } , 'Drives' ) ,
2026-10-03 19:03:05 +02:00
' ' ,
h ( 'button' , { class : 'btn small' , onclick : ( e ) => { e . stopPropagation ( ) ; newKey ( u . name ) ; } } , 'New key' ) ,
' ' ,
h ( 'button' , { class : 'btn small danger' , onclick : ( e ) => { e . stopPropagation ( ) ; deleteUser ( u . name ) ; } } , 'Delete' ) ) ) ;
if ( ! open ) return [ main ] ;
const detail = h ( 'tr' , { } , h ( 'td' , { colspan : 3 , style : 'background:var(--surface-2)' } ,
userKeys . length ? h ( 'table' , { } , h ( 'tbody' , { } , userKeys . map ( ( k ) => h ( 'tr' , { } ,
h ( 'td' , { } , h ( 'code' , { } , k . accessKeyId ) ) ,
h ( 'td' , { class : 'actions' } ,
h ( 'button' , { class : 'btn small' , onclick : ( ) => showKey ( k ) } , 'Show secret & config' ) ,
' ' ,
h ( 'button' , { class : 'btn small danger' , onclick : ( ) => deleteKey ( k . accessKeyId ) } , 'Revoke' ) ) ) ) ) )
: h ( 'p' , { class : 'muted' , style : 'margin:0' } , 'No keys. Create one to use S3 clients or browse buckets.' ) ) ) ;
return [ main , detail ] ;
} ) ) ) ) ;
}
async function createUser ( ) {
const name = h ( 'input' , { type : 'text' , required : true , pattern : '[A-Za-z0-9][A-Za-z0-9._@-]{0,63}' , placeholder : 'e.g. backups' } ) ;
const withKey = h ( 'input' , { type : 'checkbox' , checked : true } ) ;
2026-10-04 00:05:50 +02:00
// The user's own bucket, named after them unless edited.
const bucket = h ( 'input' , { type : 'text' , pattern : '[a-z0-9][a-z0-9-]{1,61}[a-z0-9]' , placeholder : 'bucket name' } ) ;
let bucketEdited = false ;
bucket . addEventListener ( 'input' , ( ) => { bucketEdited = true ; } ) ;
name . addEventListener ( 'input' , ( ) => { if ( ! bucketEdited ) bucket . value = suggestBucket ( name . value ) ; } ) ;
2026-10-03 19:03:05 +02:00
const v = await modal ( 'New user' , h ( 'div' , { class : 'stack' } ,
h ( 'label' , { class : 'field' } , h ( 'span' , { } , 'Name' ) , name , h ( 'small' , { } , 'Letters, digits, . _ @ -' ) ) ,
2026-10-04 00:09:55 +02:00
h ( 'label' , { class : 'field' } , h ( 'span' , { } , 'Their drive' ) , bucket , h ( 'small' , { } , 'Created with the user, like a home drive (an S3 bucket). Lowercase letters, digits and hyphens. They can add more later.' ) ) ,
2026-10-03 19:03:05 +02:00
h ( 'label' , { class : 'check' } , withKey , 'Create an access key now' ) ) ,
2026-10-04 00:05:50 +02:00
[ { label : 'Cancel' , result : null } , { label : 'Create' , kind : 'primary' , submit : true , value : ( ) => ( { name : name . value . trim ( ) , withKey : withKey . checked , bucket : bucket . value . trim ( ) || undefined } ) } ] ) ;
2026-10-03 19:03:05 +02:00
if ( ! v ) return ;
try {
const r = await api ( 'POST' , '/api/users' , v ) ;
2026-10-04 00:05:50 +02:00
toast ( r . bucket ? ` Created ${ r . name } with bucket ${ r . bucket } ` : r . bucketError ? ` Created ${ r . name } ; bucket not created: ${ r . bucketError } ` : ` Created ${ r . name } (start s3d to give them a bucket) ` ) ;
2026-10-03 19:03:05 +02:00
expanded . add ( r . name ) ;
if ( ! state . selectedUser ) { state . selectedUser = r . name ; localStorage . setItem ( 'pithos.user' , r . name ) ; }
await load ( ) ;
if ( r . key ) showKey ( r . key , true ) ;
} catch ( e ) { fail ( e ) ; }
}
async function newKey ( user ) {
try {
const k = await api ( 'POST' , '/api/keys' , { user } ) ;
expanded . add ( user ) ;
await load ( ) ;
showKey ( k , true ) ;
} catch ( e ) { fail ( e ) ; }
}
async function deleteUser ( name ) {
if ( ! ( await confirmModal ( ` Delete ${ name } ? ` , 'Its access keys stop working immediately. s3d refuses if the user still owns buckets.' ) ) ) return ;
try { await api ( 'DELETE' , ` /api/users/ ${ encodeURIComponent ( name ) } ` ) ; toast ( ` Deleted ${ name } ` ) ; load ( ) ; } catch ( e ) { fail ( e ) ; }
}
async function deleteKey ( id ) {
if ( ! ( await confirmModal ( 'Revoke this key?' , ` Clients using ${ id } will be refused from now on. ` , 'Revoke' ) ) ) return ;
try { await api ( 'DELETE' , ` /api/keys/ ${ encodeURIComponent ( id ) } ` ) ; toast ( 'Key revoked' ) ; load ( ) ; } catch ( e ) { fail ( e ) ; }
}
load ( ) ;
}
function showKey ( k , fresh ) {
const s = state . status ;
const endpoint = ` http:// ${ s ? . config . apiAddress || '127.0.0.1:8000' } ` ;
const aws = ` aws configure set aws_access_key_id ${ k . accessKeyId } --profile ${ k . user }
aws configure set aws _secret _access _key $ { k . secretKey } -- profile $ { k . user }
aws configure set region us - east - 1 -- profile $ { k . user }
aws -- profile $ { k . user } -- endpoint - url $ { endpoint } s3 ls ` ;
const rclone = ` [sia- ${ k . user } ]
type = s3
provider = Other
access _key _id = $ { k . accessKeyId }
secret _access _key = $ { k . secretKey }
endpoint = $ { endpoint }
region = us - east - 1
force _path _style = true ` ;
const env = ` AWS_ACCESS_KEY_ID= ${ k . accessKeyId }
AWS _SECRET _ACCESS _KEY = $ { k . secretKey }
AWS _REGION = us - east - 1
AWS _ENDPOINT _URL = $ { endpoint } ` ;
const tabs = { 'aws cli' : aws , rclone , env } ;
const pre = h ( 'pre' , { class : 'snippet' } , aws ) ;
const tabBtns = h ( 'div' , { class : 'row' } , Object . keys ( tabs ) . map ( ( t , i ) => h ( 'button' , {
type : 'button' , class : ` btn small ${ i === 0 ? 'primary' : '' } ` ,
onclick : ( e ) => { for ( const b of tabBtns . children ) b . classList . remove ( 'primary' ) ; e . currentTarget . classList . add ( 'primary' ) ; pre . textContent = tabs [ t ] ; } ,
} , t ) ) ) ;
modal ( fresh ? ` Access key for ${ k . user } ` : ` Key ${ k . accessKeyId } ` , h ( 'div' , { class : 'stack' } ,
fresh && h ( 'p' , { class : 'small muted' , style : 'margin:0' } , 'Treat the secret like a password. You can view it again here later.' ) ,
secretRow ( 'Access key ID' , k . accessKeyId ) ,
secretRow ( 'Secret key' , k . secretKey ) ,
2026-10-03 22:10:01 +02:00
h ( 'div' , { } , h ( 'button' , { class : 'btn small' , type : 'button' , onclick : ( ) => saveCredentialsDialog ( k ) } , 'Save credentials…' ) ) ,
2026-10-03 19:03:05 +02:00
h ( 'h3' , { style : 'margin-top:8px' } , 'Client config' ) ,
tabBtns , pre ,
h ( 'div' , { } , h ( 'button' , { class : 'btn small' , type : 'button' , onclick : ( ) => copy ( pre . textContent ) } , 'Copy snippet' ) ) ) ,
[ { label : 'Done' , kind : 'primary' , submit : true , result : true } ] ) ;
}
2026-10-03 22:10:01 +02:00
// ---------------------------------------------------------------- saved credentials (encrypted file)
// A key pair saved to a file the user keeps. Password files use PBKDF2-SHA256
// (600k) + AES-256-GCM in the page; vault files are sealed by the Theseus
// add-on with a key derived from the vault, so they need no extra password.
// Only the user name and date stay readable, to tell files apart.
const CRED _FORMAT = 'pithos-credentials' ;
const b64 = ( buf ) => btoa ( String . fromCharCode ( ... new Uint8Array ( buf ) ) ) ;
const unb64 = ( s ) => Uint8Array . from ( atob ( s ) , ( c ) => c . charCodeAt ( 0 ) ) ;
async function passwordKey ( password , salt , iterations ) {
const base = await crypto . subtle . importKey ( 'raw' , new TextEncoder ( ) . encode ( password ) , 'PBKDF2' , false , [ 'deriveKey' ] ) ;
return crypto . subtle . deriveKey ( { name : 'PBKDF2' , hash : 'SHA-256' , salt , iterations } , base , { name : 'AES-GCM' , length : 256 } , false , [ 'encrypt' , 'decrypt' ] ) ;
}
async function sealCredentials ( k , { mode , password } ) {
const payload = JSON . stringify ( {
user : k . user , accessKeyId : k . accessKeyId , secretKey : k . secretKey ,
endpoint : ` http:// ${ state . status ? . config . apiAddress || '127.0.0.1:8000' } ` , region : 'us-east-1' ,
} ) ;
const head = { format : CRED _FORMAT , v : 1 , user : k . user , saved : new Date ( ) . toISOString ( ) } ;
if ( mode === 'vault' ) {
const r = await bridge . invoke ( 'vault-seal' , { plaintext : payload } ) ;
return { ... head , protection : 'theseus-vault' , iv : r . iv , ct : r . ct } ;
}
const salt = crypto . getRandomValues ( new Uint8Array ( 16 ) ) ;
const iv = crypto . getRandomValues ( new Uint8Array ( 12 ) ) ;
const iterations = 600000 ;
const ct = await crypto . subtle . encrypt ( { name : 'AES-GCM' , iv } , await passwordKey ( password , salt , iterations ) , new TextEncoder ( ) . encode ( payload ) ) ;
return { ... head , protection : 'password' , kdf : { name : 'PBKDF2' , hash : 'SHA-256' , iterations , salt : b64 ( salt ) } , iv : b64 ( iv ) , ct : b64 ( ct ) } ;
}
async function openCredentials ( file , password ) {
if ( ! file || file . format !== CRED _FORMAT || file . v !== 1 ) throw new Error ( 'This is not a Pithos credentials file' ) ;
let text ;
if ( file . protection === 'theseus-vault' ) {
if ( ! bridge ) throw new Error ( 'This file is protected with a Theseus vault. Open it in Pithos inside Theseus.' ) ;
text = ( await bridge . invoke ( 'vault-open' , { iv : file . iv , ct : file . ct } ) ) . plaintext ;
} else {
try {
const key = await passwordKey ( password , unb64 ( file . kdf . salt ) , file . kdf . iterations ) ;
text = new TextDecoder ( ) . decode ( await crypto . subtle . decrypt ( { name : 'AES-GCM' , iv : unb64 ( file . iv ) } , key , unb64 ( file . ct ) ) ) ;
} catch { throw new Error ( 'Wrong password, or the file is damaged' ) ; }
}
return JSON . parse ( text ) ;
}
async function saveFile ( name , text ) {
if ( bridge ) {
const r = await bridge . invoke ( 'save-file' , { name , text } ) ;
return ! ! r . saved ;
}
const url = URL . createObjectURL ( new Blob ( [ text ] , { type : 'application/json' } ) ) ;
const a = h ( 'a' , { href : url , download : name } ) ;
document . body . append ( a ) ;
a . click ( ) ;
a . remove ( ) ;
setTimeout ( ( ) => URL . revokeObjectURL ( url ) , 5000 ) ;
return true ;
}
async function saveCredentialsDialog ( k ) {
const vaultOk = ! ! ( bridge && vaultInfo && vaultInfo . setup && vaultInfo . prompt ) ;
let mode = vaultOk ? 'vault' : 'password' ;
const p1 = h ( 'input' , { type : 'password' , placeholder : 'Password (at least 8 characters)' , autocomplete : 'new-password' } ) ;
const p2 = h ( 'input' , { type : 'password' , placeholder : 'Repeat the password' , autocomplete : 'new-password' } ) ;
const pwBox = h ( 'div' , { class : 'stack' , hidden : mode === 'vault' } , p1 , p2 ,
h ( 'small' , { class : 'muted' } , 'Pithos cannot recover this password. Without it the file cannot be opened.' ) ) ;
const choice = vaultOk && h ( 'div' , { class : 'stack' } ,
h ( 'label' , { class : 'check' , style : 'align-items:flex-start' } ,
h ( 'input' , { type : 'radio' , name : 'credmode' , checked : true , onchange : ( ) => { mode = 'vault' ; pwBox . hidden = true ; } } ) ,
h ( 'span' , { } , h ( 'strong' , { } , 'Protect with my Theseus vault' ) , h ( 'br' ) , h ( 'span' , { class : 'small muted' } , 'No extra password. Opens in Pithos with the same vault unlocked.' ) ) ) ,
h ( 'label' , { class : 'check' , style : 'align-items:flex-start' } ,
h ( 'input' , { type : 'radio' , name : 'credmode' , onchange : ( ) => { mode = 'password' ; pwBox . hidden = false ; } } ) ,
h ( 'span' , { } , h ( 'strong' , { } , 'Protect with a password' ) , h ( 'br' ) , h ( 'span' , { class : 'small muted' } , 'Opens in Pithos anywhere, with the password.' ) ) ) ) ;
await modal ( 'Save credentials' , h ( 'div' , { class : 'stack' } ,
h ( 'p' , { class : 'small muted' , style : 'margin:0' } , ` Saves the access key and secret for ${ k . user } to an encrypted file. Open it later under Users & keys. ` ) ,
choice , pwBox ) ,
[ { label : 'Cancel' , result : null } , { label : 'Save file' , kind : 'primary' , submit : true , value : async ( ) => {
if ( mode === 'password' ) {
if ( p1 . value . length < 8 ) { toast ( 'Use at least 8 characters' , 'error' ) ; return false ; }
if ( p1 . value !== p2 . value ) { toast ( "The two passwords don't match" , 'error' ) ; return false ; }
}
try {
const sealed = await sealCredentials ( k , { mode , password : p1 . value } ) ;
if ( await saveFile ( ` pithos- ${ k . user } - ${ k . accessKeyId . slice ( 0 , 6 ) } .pithoskey ` , JSON . stringify ( sealed , null , 2 ) ) ) toast ( 'Credentials saved' ) ;
return true ;
} catch ( e ) { fail ( e ) ; return false ; }
} } ] ) ;
}
function openCredentialsDialog ( ) {
const input = h ( 'input' , { type : 'file' , accept : '.pithoskey,application/json' } ) ;
input . addEventListener ( 'change' , async ( ) => {
const f = input . files [ 0 ] ;
if ( ! f ) return ;
let file ;
try { file = JSON . parse ( await f . text ( ) ) ; } catch { return toast ( 'This is not a Pithos credentials file' , 'error' ) ; }
let password = '' ;
if ( file . protection === 'password' ) {
const pw = h ( 'input' , { type : 'password' , placeholder : 'Password' , autocomplete : 'current-password' } ) ;
password = await modal ( ` Open credentials ${ file . user ? ` for ${ file . user } ` : '' } ` , h ( 'label' , { class : 'field' } , h ( 'span' , { } , 'Password' ) , pw ) ,
[ { label : 'Cancel' , result : null } , { label : 'Open' , kind : 'primary' , submit : true , value : ( ) => pw . value } ] ) ;
if ( ! password ) return ;
}
try { showKey ( await openCredentials ( file , password ) ) ; } catch ( e ) { fail ( e ) ; }
} ) ;
input . click ( ) ;
}
2026-10-03 19:03:05 +02:00
// ---------------------------------------------------------------- buckets & objects
2026-10-04 02:16:11 +02:00
// View types for drives and folders: Icons, List or Details, remembered per page.
const VIEW _TYPES = [ [ 'icons' , '▦' , 'Icons' ] , [ 'list' , '☰' , 'List' ] , [ 'details' , '≣' , 'Details' ] ] ;
function viewMode ( page , fallback ) {
try { const v = localStorage . getItem ( ` pithos.view. ${ page } ` ) ; if ( VIEW _TYPES . some ( ( [ k ] ) => k === v ) ) return v ; } catch { }
return fallback ;
}
function viewSwitch ( page , current , onChange ) {
return h ( 'div' , { class : 'seg' , role : 'group' , 'aria-label' : 'View' } , VIEW _TYPES . map ( ( [ key , icon , label ] ) =>
h ( 'button' , { type : 'button' , class : key === current ? 'on' : '' , title : label , 'aria-label' : label , 'aria-pressed' : String ( key === current ) ,
onclick : ( ) => { try { localStorage . setItem ( ` pithos.view. ${ page } ` , key ) ; } catch { } onChange ( key ) ; } } , icon ) ) ) ;
}
// "⋯" menu for one item: [label, action, kind?] rows; closes on any outside click.
function itemMenu ( items ) {
const btn = h ( 'button' , { type : 'button' , class : 'btn small more' , title : 'More' , 'aria-label' : 'More actions' , 'aria-haspopup' : 'menu' } , '⋯' ) ;
btn . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
document . querySelectorAll ( '.menu' ) . forEach ( ( m ) => m . remove ( ) ) ;
const menu = h ( 'div' , { class : 'menu' , role : 'menu' } , items . filter ( Boolean ) . map ( ( [ label , run , kind ] ) =>
h ( 'button' , { type : 'button' , role : 'menuitem' , class : kind || '' , onclick : ( ev ) => { ev . stopPropagation ( ) ; menu . remove ( ) ; run ( ) ; } } , label ) ) ) ;
document . body . append ( menu ) ;
const r = btn . getBoundingClientRect ( ) ;
const w = menu . offsetWidth , ht = menu . offsetHeight ;
menu . style . left = Math . max ( 8 , Math . min ( r . right - w , innerWidth - w - 8 ) ) + 'px' ;
menu . style . top = ( r . bottom + 4 + ht > innerHeight - 8 ? Math . max ( 8 , r . top - ht - 4 ) : r . bottom + 4 ) + 'px' ;
const done = ( ) => { menu . remove ( ) ; document . removeEventListener ( 'mousedown' , away , true ) ; document . removeEventListener ( 'keydown' , esc , true ) ; } ;
const away = ( ev ) => { if ( ! menu . contains ( ev . target ) ) done ( ) ; } ;
const esc = ( ev ) => { if ( ev . key === 'Escape' ) { ev . stopPropagation ( ) ; done ( ) ; btn . focus ( ) ; } } ;
setTimeout ( ( ) => { document . addEventListener ( 'mousedown' , away , true ) ; document . addEventListener ( 'keydown' , esc , true ) ; } , 0 ) ;
menu . querySelector ( 'button' ) ? . focus ( ) ;
} ) ;
return btn ;
}
2026-10-04 03:29:32 +02:00
const go = ( hash ) => ( ) => navTo ( hash ) ;
2026-10-04 03:01:00 +02:00
const ICONS = {
download : '<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2.5v8M4.5 7.5 8 11l3.5-3.5M3 13.5h10"/></svg>' ,
share : '<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M6.6 9.4a2.6 2.6 0 0 0 3.7 0l2.2-2.2a2.6 2.6 0 0 0-3.7-3.7l-.9.9"/><path d="M9.4 6.6a2.6 2.6 0 0 0-3.7 0L3.5 8.8a2.6 2.6 0 0 0 3.7 3.7l.9-.9"/></svg>' ,
} ;
function iconButton ( name , label , run ) {
const b = h ( 'button' , { type : 'button' , class : 'btn small icon' , title : label , 'aria-label' : label , onclick : ( e ) => { e . stopPropagation ( ) ; run ( ) ; } } ) ;
b . innerHTML = ICONS [ name ] ; // fixed markup above, never user data
return b ;
}
2026-10-04 02:16:11 +02:00
const onEnter = ( fn ) => ( e ) => { if ( e . key === 'Enter' && e . target === e . currentTarget ) fn ( ) ; } ;
2026-10-04 00:05:50 +02:00
const EXT = ( key ) => ( key . split ( '/' ) . pop ( ) . match ( /.([a-z0-9]+)$/i ) || [ ] ) [ 1 ] ? . toLowerCase ( ) || '' ;
function fileKind ( key ) {
const e = EXT ( key ) ;
if ( [ 'png' , 'jpg' , 'jpeg' , 'gif' , 'webp' , 'avif' , 'bmp' , 'svg' , 'ico' ] . includes ( e ) ) return 'image' ;
if ( [ 'mp3' , 'wav' , 'ogg' , 'oga' , 'm4a' , 'aac' , 'flac' , 'opus' ] . includes ( e ) ) return 'audio' ;
if ( [ 'mp4' , 'webm' , 'mov' , 'm4v' , 'ogv' ] . includes ( e ) ) return 'video' ;
if ( [ 'txt' , 'md' , 'json' , 'csv' , 'log' , 'xml' , 'yml' , 'yaml' , 'toml' , 'ini' , 'html' , 'htm' , 'css' , 'js' , 'mjs' , 'ts' , 'py' , 'go' , 'rs' , 'sh' , 'sql' ] . includes ( e ) ) return 'text' ;
return null ;
}
function suggestBucket ( name ) {
let b = String ( name || '' ) . toLowerCase ( ) . replace ( /[^a-z0-9-]+/g , '-' ) . replace ( /-+/g , '-' ) . replace ( /^-+|-+$/g , '' ) ;
if ( b && b . length < 3 ) b += '-files' ;
return b . slice ( 0 , 63 ) . replace ( /-+$/ , '' ) ;
}
2026-10-04 02:16:11 +02:00
function fileType ( key ) {
const e = EXT ( key ) ;
return ( { image : 'Image' , audio : 'Audio' , video : 'Video' , text : 'Text' } [ fileKind ( key ) ] || 'File' ) + ( e ? ` (. ${ e } ) ` : '' ) ;
}
2026-10-04 00:05:50 +02:00
function fileIcon ( key ) {
return { image : '🖼️' , audio : '🎵' , video : '🎬' , text : '📄' } [ fileKind ( key ) ] || '📦' ;
}
2026-10-03 19:03:05 +02:00
function buckets ( main , [ bucket , ... prefixParts ] ) {
const head = h ( 'div' , { class : 'page-head' } ) ;
const body = h ( 'div' ) ;
main . append ( head , body ) ;
let users = [ ] ;
2026-10-04 03:29:32 +02:00
const userSel = h ( 'select' , { onchange : ( e ) => { state . selectedUser = e . target . value ; localStorage . setItem ( 'pithos.user' , e . target . value ) ; if ( PATH _MODE ) navTo ( '#/buckets' ) ; else { location . hash = '#/buckets' ; route ( ) ; } } } ) ;
2026-10-03 19:03:05 +02:00
( async ( ) => {
try { users = await api ( 'GET' , '/api/users' ) ; }
catch ( e ) { put ( body , h ( 'div' , { class : 'card' } , h ( 'p' , { class : 'error' } , e . message ) ) ) ; return ; }
const usable = users . filter ( ( u ) => u . keys . length ) ;
if ( ! usable . length ) {
2026-10-04 00:09:55 +02:00
put ( head , h ( 'div' , { } , h ( 'h1' , { } , 'Drives' ) ) ) ;
2026-10-03 19:03:05 +02:00
put ( body , h ( 'div' , { class : 'card empty' } , h ( 'p' , { } , users . length ? 'None of your users has an access key yet.' : 'Create a user first; buckets belong to users.' ) , h ( 'a' , { class : 'btn primary' , href : '#/users' } , 'Users & keys' ) ) ) ;
return ;
}
if ( ! usable . some ( ( u ) => u . name === state . selectedUser ) ) state . selectedUser = usable [ 0 ] . name ;
put ( userSel , ... usable . map ( ( u ) => h ( 'option' , { value : u . name , selected : u . name === state . selectedUser } , u . name ) ) ) ;
const st = daemonState ( ) ;
if ( st !== 'running' && st !== 'external' ) {
2026-10-04 00:09:55 +02:00
put ( head , h ( 'div' , { } , h ( 'h1' , { } , 'Drives' ) ) ) ;
2026-10-03 19:03:05 +02:00
put ( body , h ( 'div' , { class : 'card empty' } , h ( 'p' , { } , 's3d is not running.' ) , h ( 'button' , { class : 'btn primary' , onclick : async ( ) => { await daemonAction ( 'start' ) ; setTimeout ( route , 1500 ) ; } } , 'Start s3d' ) ) ) ;
return ;
}
if ( bucket ) objectBrowser ( head , body , state . selectedUser , bucket , prefixParts . join ( '/' ) , userSel ) ;
else bucketList ( head , body , state . selectedUser , userSel ) ;
} ) ( ) ;
2026-10-04 00:05:50 +02:00
// Follow s3d: the page was drawn for one state; redraw when it flips.
const runningNow = ( ) => [ 'running' , 'external' ] . includes ( daemonState ( ) ) ;
let wasRunning = runningNow ( ) ;
cleanup . push ( onState ( ( ) => { if ( runningNow ( ) !== wasRunning ) { wasRunning = runningNow ( ) ; route ( ) ; } } ) ) ;
2026-10-03 19:03:05 +02:00
}
function userPicker ( userSel ) {
2026-10-03 22:57:47 +02:00
return h ( 'label' , { class : 'row small' , title : 'The S3 user whose buckets you see. All users share the one Sia account below.' } ,
h ( 'span' , { class : 'muted' } , 'S3 user' ) , h ( 'span' , { style : 'width:180px' } , userSel ) ) ;
}
// ---- Sia account (one per s3d) -------------------------------------------
let accountInfo = null ;
async function loadAccount ( ) {
try { accountInfo = await api ( 'GET' , '/api/account' ) ; } catch { accountInfo = null ; }
return accountInfo ;
}
const accountName = ( a ) => ( a && ( a . label || ( a . indexerUrl ? new URL ( a . indexerUrl ) . host : '' ) ) ) || 'not connected' ;
// "Sia account: …" line for page headers; fills itself in.
function accountLine ( ) {
const el = h ( 'div' , { class : 'small muted account-line' } , 'Sia account: …' ) ;
loadAccount ( ) . then ( ( a ) => put ( el ,
'Sia account: ' , h ( 'strong' , { } , accountName ( a ) ) ,
a && a . label && a . indexerUrl ? h ( 'span' , { } , ` ( ${ new URL ( a . indexerUrl ) . host } ) ` ) : null ,
2026-10-04 03:01:00 +02:00
' · ' , h ( 'a' , { href : '#/account' } , a && a . registered ? 'Manage' : 'Connect' ) ) ) ;
2026-10-03 22:57:47 +02:00
return el ;
2026-10-03 19:03:05 +02:00
}
async function bucketList ( head , body , user , userSel ) {
put ( head ,
2026-10-04 00:09:55 +02:00
h ( 'div' , { } , h ( 'h1' , { } , 'Drives' ) , h ( 'p' , { class : 'muted' } , 'Each drive is an S3 bucket: top-level storage owned by one S3 user. S3 apps call it a bucket.' ) , accountLine ( ) ) ,
h ( 'div' , { class : 'row' } , userPicker ( userSel ) , h ( 'button' , { class : 'btn primary' , onclick : create } , 'New drive' ) ) ) ;
2026-10-03 19:03:05 +02:00
const card = h ( 'div' , { class : 'card' } , h ( 'p' , { class : 'muted' } , 'Loading…' ) ) ;
2026-10-04 02:16:11 +02:00
const bar = h ( 'div' , { class : 'view-bar' } ) ;
put ( body , bar , card ) ;
2026-10-03 19:03:05 +02:00
const base = ` /api/s3/ ${ encodeURIComponent ( user ) } /buckets ` ;
2026-10-04 02:16:11 +02:00
let mode = viewMode ( 'drives' , 'icons' ) ;
let list = [ ] ;
2026-10-03 19:03:05 +02:00
async function load ( ) {
try {
2026-10-04 02:16:11 +02:00
list = await api ( 'GET' , base ) ;
draw ( ) ;
2026-10-03 19:03:05 +02:00
} catch ( e ) { put ( card , h ( 'p' , { class : 'error' } , e . message ) ) ; }
}
2026-10-04 02:16:11 +02:00
// Buckets read as drives: the top level you put folders into.
function draw ( ) {
put ( bar , list . length ? viewSwitch ( 'drives' , mode , ( m ) => { mode = m ; draw ( ) ; } ) : null ) ;
if ( ! list . length ) { put ( card , h ( 'div' , { class : 'empty' } , h ( 'p' , { } , 'No drives yet.' ) , h ( 'button' , { class : 'btn primary' , onclick : create } , 'Create a drive' ) ) ) ; return ; }
const open = ( b ) => go ( ` #/buckets/ ${ encodeURIComponent ( b . name ) } ` ) ;
const menu = ( b ) => itemMenu ( [ [ 'Open' , open ( b ) ] , [ 'Access…' , ( ) => accessDialog ( user , b . name ) . then ( load ) ] , [ 'Delete' , ( ) => remove ( b . name ) , 'danger' ] ] ) ;
if ( mode === 'details' ) {
put ( card , h ( 'table' , { class : 'files' } ,
h ( 'thead' , { } , h ( 'tr' , { } , h ( 'th' , { } , 'Drive' ) , h ( 'th' , { } , 'Contents' ) , h ( 'th' , { class : 'hide-sm' } , 'Access' ) , h ( 'th' , { } ) ) ) ,
h ( 'tbody' , { } , list . map ( ( b ) => h ( 'tr' , { class : 'clickable' , tabindex : '0' , onclick : open ( b ) , onkeydown : onEnter ( open ( b ) ) } ,
h ( 'td' , { class : 'name' } , h ( 'span' , { class : 'drive-icon sm' , 'aria-hidden' : 'true' } ) , h ( 'span' , { class : 'label' } , b . name ) ) ,
h ( 'td' , { class : 'muted' } , usageLine ( b . name ) ) ,
h ( 'td' , { class : 'hide-sm' } , accessBadge ( b . name ) ) ,
h ( 'td' , { class : 'actions' , onclick : ( e ) => e . stopPropagation ( ) } , menu ( b ) ) ) ) ) ) ) ;
} else if ( mode === 'list' ) {
put ( card , h ( 'ul' , { class : 'rows' } , list . map ( ( b ) => h ( 'li' , { class : 'clickable' , tabindex : '0' , onclick : open ( b ) , onkeydown : onEnter ( open ( b ) ) } ,
h ( 'span' , { class : 'drive-icon sm' , 'aria-hidden' : 'true' } ) ,
h ( 'span' , { class : 'label' } , b . name ) ,
h ( 'span' , { class : 'meta' } , usageLine ( b . name ) ) ,
h ( 'span' , { onclick : ( e ) => e . stopPropagation ( ) } , menu ( b ) ) ) ) ) ) ;
} else {
put ( card , h ( 'div' , { class : 'tiles' } , list . map ( ( b ) => h ( 'div' , { class : 'tile' , tabindex : '0' , role : 'button' , onclick : open ( b ) , onkeydown : onEnter ( open ( b ) ) } ,
h ( 'div' , { class : 'drive-icon' , 'aria-hidden' : 'true' } ) ,
h ( 'div' , { class : 'tile-name' } , b . name ) ,
usageLine ( b . name ) ,
h ( 'div' , { class : 'tile-meta' } , accessBadge ( b . name ) ) ,
h ( 'div' , { class : 'tile-more' , onclick : ( e ) => e . stopPropagation ( ) } , menu ( b ) ) ) ) ) ) ;
}
}
function accessBadge ( bucketName ) {
const el = h ( 'span' , { } , h ( 'span' , { class : 'badge' } , '…' ) ) ;
api ( 'GET' , ` ${ base } / ${ encodeURIComponent ( bucketName ) } /policy ` ) . then ( ( p ) => {
put ( el , h ( 'span' , { class : ` badge ${ p . public === 'private' ? '' : 'warn' } ` } , { private : '🔒 private' , read : '🌐 public' , 'read-list' : '🌐 public + list' , custom : 'custom' } [ p . public ] ) ) ;
} ) . catch ( ( ) => put ( el , '?' ) ) ;
return el ;
}
2026-10-04 00:09:55 +02:00
// Files and size on a drive, counted in the background.
function usageLine ( bucketName ) {
2026-10-04 02:16:11 +02:00
const el = h ( 'span' , { class : 'small muted usage' } , 'counting…' ) ;
2026-10-04 00:09:55 +02:00
api ( 'GET' , ` ${ base } / ${ encodeURIComponent ( bucketName ) } /usage ` )
. then ( ( u ) => put ( el , ` ${ u . files . toLocaleString ( ) } ${ u . partial ? '+' : '' } file ${ u . files === 1 ? '' : 's' } · ${ fmtBytes ( u . bytes ) } ${ u . partial ? '+' : '' } ` ) )
. catch ( ( ) => put ( el , '' ) ) ;
return el ;
}
2026-10-03 19:03:05 +02:00
async function create ( ) {
2026-10-04 00:09:55 +02:00
const name = h ( 'input' , { type : 'text' , required : true , pattern : '[a-z0-9][a-z0-9.\\-]{1,61}[a-z0-9]' , placeholder : 'my-drive' } ) ;
2026-10-03 19:03:05 +02:00
const v = await modal ( 'New bucket' , h ( 'label' , { class : 'field' } , h ( 'span' , { } , 'Name' ) , name , h ( 'small' , { } , '3-63 characters: lowercase letters, digits, dots and hyphens. Names are unique across this s3d.' ) ) ,
[ { label : 'Cancel' , result : null } , { label : 'Create' , kind : 'primary' , submit : true , value : ( ) => name . value . trim ( ) } ] ) ;
if ( ! v ) return ;
try { await api ( 'POST' , base , { name : v } ) ; toast ( ` Created ${ v } ` ) ; load ( ) ; } catch ( e ) { fail ( e ) ; }
}
async function remove ( name ) {
if ( ! ( await confirmModal ( ` Delete bucket ${ name } ? ` , 'The bucket must be empty. This cannot be undone.' ) ) ) return ;
try { await api ( 'DELETE' , ` ${ base } / ${ encodeURIComponent ( name ) } ` ) ; toast ( ` Deleted ${ name } ` ) ; load ( ) ; } catch ( e ) { fail ( e ) ; }
}
load ( ) ;
}
async function accessDialog ( user , bucket ) {
const base = ` /api/s3/ ${ encodeURIComponent ( user ) } /buckets/ ${ encodeURIComponent ( bucket ) } /policy ` ;
let current = 'private' ;
try { current = ( await api ( 'GET' , base ) ) . public ; } catch ( e ) { return fail ( e ) ; }
const opts = [
[ 'private' , 'Private' , 'Only this user\'s access keys can read.' ] ,
[ 'read' , 'Public read' , 'Anyone who knows an object\'s URL can download it.' ] ,
[ 'read-list' , 'Public read + list' , 'Anyone can also list the bucket\'s contents.' ] ,
] ;
let choice = current === 'custom' ? 'private' : current ;
const endpoint = ` http:// ${ state . status ? . config . apiAddress } ` ;
const v = await modal ( ` Access for ${ bucket } ` , h ( 'div' , { class : 'stack' } ,
current === 'custom' && h ( 'div' , { class : 'banner warn' } , 'This bucket has a custom policy. Saving replaces it.' ) ,
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 ( 'span' , { } , h ( 'strong' , { } , label ) , h ( 'br' ) , h ( 'span' , { class : 'small muted' } , desc ) ) ) ) ,
2026-10-04 03:18:25 +02:00
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.' ) ) ,
2026-10-04 00:05:50 +02:00
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.' ) ) ,
2026-10-03 19:03:05 +02:00
[ { label : 'Cancel' , result : null } , { label : 'Save' , kind : 'primary' , submit : true , value : ( ) => choice } ] ) ;
if ( ! v || v === current ) return ;
try { await api ( 'PUT' , base , { mode : v } ) ; toast ( 'Access updated' ) ; } catch ( e ) { fail ( e ) ; }
}
function objectBrowser ( head , body , user , bucket , prefix , userSel ) {
if ( prefix && ! prefix . endsWith ( '/' ) ) prefix += '/' ;
const base = ` /api/s3/ ${ encodeURIComponent ( user ) } /buckets/ ${ encodeURIComponent ( bucket ) } ` ;
2026-10-04 03:29:32 +02:00
const go = ( p ) => navTo ( ` #/buckets/ ${ encodeURIComponent ( bucket ) } ${ p ? '/' + p . split ( '/' ) . filter ( Boolean ) . map ( encodeURIComponent ) . join ( '/' ) : '' } ` ) ;
2026-10-03 19:03:05 +02:00
const crumbs = h ( 'div' , { class : 'crumbs' } ,
2026-10-04 03:29:32 +02:00
h ( 'button' , { onclick : ( ) => navTo ( '#/buckets' ) } , 'Drives' ) , h ( 'span' , { class : 'sep' } , '/' ) ,
2026-10-03 19:03:05 +02:00
h ( 'button' , { onclick : ( ) => go ( '' ) } , bucket ) ,
prefix . split ( '/' ) . filter ( Boolean ) . map ( ( part , i , all ) => [ h ( 'span' , { class : 'sep' } , '/' ) , h ( 'button' , { onclick : ( ) => go ( all . slice ( 0 , i + 1 ) . join ( '/' ) ) } , part ) ] ) ) ;
const fileInput = h ( 'input' , { type : 'file' , multiple : true , hidden : true , onchange : ( e ) => { uploadFiles ( [ ... e . target . files ] ) ; e . target . value = '' ; } } ) ;
put ( head ,
2026-10-03 22:57:47 +02:00
h ( 'div' , { class : 'stack' } , crumbs , accountLine ( ) ) ,
2026-10-03 19:03:05 +02:00
h ( 'div' , { class : 'row' } , userPicker ( userSel ) ,
h ( 'button' , { class : 'btn' , onclick : newFolder } , 'New folder' ) ,
h ( 'button' , { class : 'btn' , onclick : ( ) => accessDialog ( user , bucket ) } , 'Access' ) ,
h ( 'button' , { class : 'btn primary' , onclick : ( ) => fileInput . click ( ) } , 'Upload' ) , fileInput ) ) ;
const uploads = h ( 'div' , { class : 'uploads' } ) ;
const card = h ( 'div' , { class : 'card dropzone' } , h ( 'p' , { class : 'muted' } , 'Loading…' ) ) ;
2026-10-04 02:16:11 +02:00
const bar = h ( 'div' , { class : 'view-bar' } ) ;
put ( body , uploads , bar , card ) ;
let mode = viewMode ( 'files' , 'details' ) ;
2026-10-03 19:03:05 +02:00
let depth = 0 ;
card . addEventListener ( 'dragenter' , ( e ) => { e . preventDefault ( ) ; depth ++ ; card . classList . add ( 'over' ) ; } ) ;
card . addEventListener ( 'dragleave' , ( ) => { if ( -- depth <= 0 ) { depth = 0 ; card . classList . remove ( 'over' ) ; } } ) ;
card . addEventListener ( 'dragover' , ( e ) => e . preventDefault ( ) ) ;
card . addEventListener ( 'drop' , ( e ) => {
e . preventDefault ( ) ; depth = 0 ; card . classList . remove ( 'over' ) ;
uploadFiles ( [ ... e . dataTransfer . files ] ) ;
} ) ;
let pages = [ ] ;
async function load ( token ) {
try {
const q = new URLSearchParams ( { prefix } ) ;
if ( token ) q . set ( 'token' , token ) ;
const page = await api ( 'GET' , ` ${ base } /objects? ${ q } ` ) ;
pages = token ? [ ... pages , page ] : [ page ] ;
draw ( ) ;
} catch ( e ) { put ( card , h ( 'p' , { class : 'error' } , e . message ) ) ; }
}
function draw ( ) {
const folders = pages . flatMap ( ( p ) => p . folders ) ;
const objects = pages . flatMap ( ( p ) => p . objects ) . filter ( ( o ) => o . key !== prefix ) ;
const last = pages [ pages . length - 1 ] ;
2026-10-04 02:16:11 +02:00
put ( bar , folders . length || objects . length ? viewSwitch ( 'files' , mode , ( m ) => { mode = m ; draw ( ) ; } ) : null ) ;
2026-10-03 19:03:05 +02:00
if ( ! folders . length && ! objects . length ) {
2026-10-04 02:16:11 +02:00
put ( card , h ( 'div' , { class : 'empty' } , h ( 'p' , { } , prefix ? 'This folder is empty.' : 'This drive is empty.' ) , h ( 'p' , { class : 'small' } , 'Drag files here or use Upload.' ) ) ) ;
2026-10-03 19:03:05 +02:00
return ;
}
2026-10-04 02:16:11 +02:00
const folderName = ( f ) => f . slice ( prefix . length ) . replace ( /\/$/ , '' ) ;
const fileName = ( o ) => o . key . slice ( prefix . length ) ;
const folderMenu = ( f ) => itemMenu ( [ [ 'Open' , ( ) => go ( f ) ] , [ 'Delete' , ( ) => deleteFolder ( f ) , 'danger' ] ] ) ;
2026-10-04 02:23:07 +02:00
const fileMenu = ( o ) => itemMenu ( [ [ fileKind ( o . key ) === 'audio' ? 'Play' : 'Open' , ( ) => openViewer ( o ) ] , [ 'Download' , ( ) => downloadObject ( o . key ) ] , [ 'Share link…' , ( ) => share ( o . key ) ] , [ 'Delete' , ( ) => deleteObject ( o . key ) , 'danger' ] ] ) ;
2026-10-04 02:16:11 +02:00
const more = last ? . truncated && h ( 'div' , { class : 'row' , style : 'justify-content:center;margin-top:12px' } , h ( 'button' , { class : 'btn' , onclick : ( ) => load ( last . nextToken ) } , 'Load more' ) ) ;
const stop = ( e ) => e . stopPropagation ( ) ;
2026-10-04 03:01:00 +02:00
// Narrow screens: Download and Share as icons beside the ⋯ menu.
const quickIcons = ( o ) => h ( 'span' , { class : 'narrow-only icon-row' } ,
iconButton ( 'download' , 'Download' , ( ) => downloadObject ( o . key ) ) ,
iconButton ( 'share' , 'Share link' , ( ) => share ( o . key ) ) ) ;
2026-10-04 02:16:11 +02:00
if ( mode === 'details' ) {
put ( card , h ( 'table' , { class : 'files' } ,
2026-10-04 03:01:00 +02:00
h ( 'thead' , { } , h ( 'tr' , { } , h ( 'th' , { } , 'Name' ) , h ( 'th' , { class : 'num hide-sm' } , 'Size' ) , h ( 'th' , { class : 'hide-sm' } , 'Type' ) , h ( 'th' , { class : 'hide-sm' } , 'Modified' ) , h ( 'th' , { } ) ) ) ,
2026-10-04 02:16:11 +02:00
h ( 'tbody' , { } ,
folders . map ( ( f ) => h ( 'tr' , { class : 'clickable' , tabindex : '0' , onclick : ( ) => go ( f ) , onkeydown : onEnter ( ( ) => go ( f ) ) } ,
2026-10-04 03:01:00 +02:00
h ( 'td' , { class : 'name' } , h ( 'div' , { class : 'cell-name' } , h ( 'span' , { class : 'ficon' , 'aria-hidden' : 'true' } , '📁' ) , h ( 'span' , { class : 'label' } , folderName ( f ) ) ) ) ,
h ( 'td' , { class : 'num muted hide-sm' } , '– ' ) , h ( 'td' , { class : 'hide-sm muted' } , 'Folder' ) , h ( 'td' , { class : 'hide-sm' } ) ,
2026-10-04 02:16:11 +02:00
h ( 'td' , { class : 'actions' , onclick : stop } , folderMenu ( f ) ) ) ) ,
objects . map ( ( o ) => h ( 'tr' , { class : 'clickable' , tabindex : '0' , onclick : ( ) => openViewer ( o ) , onkeydown : onEnter ( ( ) => openViewer ( o ) ) } ,
2026-10-04 03:01:00 +02:00
h ( 'td' , { class : 'name' } , h ( 'div' , { class : 'cell-name' } , h ( 'span' , { class : 'ficon' , 'aria-hidden' : 'true' } , fileIcon ( o . key ) ) , h ( 'span' , { class : 'label' , title : fileName ( o ) } , fileName ( o ) ) ) ) ,
h ( 'td' , { class : 'num hide-sm' } , fmtBytes ( o . size ) ) ,
2026-10-04 02:16:11 +02:00
h ( 'td' , { class : 'hide-sm muted' } , fileType ( o . key ) ) ,
h ( 'td' , { class : 'hide-sm muted' } , fmtDate ( o . modified ) ) ,
h ( 'td' , { class : 'actions' , onclick : stop } ,
h ( 'span' , { class : 'wide-only' } , h ( 'button' , { class : 'btn small' , onclick : ( ) => downloadObject ( o . key ) } , 'Download' ) , ' ' ,
h ( 'button' , { class : 'btn small' , onclick : ( ) => share ( o . key ) } , 'Share' ) , ' ' ) ,
2026-10-04 03:01:00 +02:00
quickIcons ( o ) , fileMenu ( o ) ) ) ) ) ) ,
2026-10-04 02:16:11 +02:00
more ) ;
} else if ( mode === 'list' ) {
put ( card , h ( 'ul' , { class : 'rows' } ,
folders . map ( ( f ) => h ( 'li' , { class : 'clickable' , tabindex : '0' , onclick : ( ) => go ( f ) , onkeydown : onEnter ( ( ) => go ( f ) ) } ,
h ( 'span' , { class : 'ficon' , 'aria-hidden' : 'true' } , '📁' ) , h ( 'span' , { class : 'label' } , folderName ( f ) ) , h ( 'span' , { class : 'meta' } ) ,
h ( 'span' , { onclick : stop } , folderMenu ( f ) ) ) ) ,
objects . map ( ( o ) => h ( 'li' , { class : 'clickable' , tabindex : '0' , onclick : ( ) => openViewer ( o ) , onkeydown : onEnter ( ( ) => openViewer ( o ) ) } ,
h ( 'span' , { class : 'ficon' , 'aria-hidden' : 'true' } , fileIcon ( o . key ) ) , h ( 'span' , { class : 'label' , title : fileName ( o ) } , fileName ( o ) ) ,
2026-10-04 03:01:00 +02:00
h ( 'span' , { class : 'meta hide-sm' } , fmtBytes ( o . size ) ) ,
h ( 'span' , { class : 'row-actions' , onclick : stop } , quickIcons ( o ) , fileMenu ( o ) ) ) ) ) ,
2026-10-04 02:16:11 +02:00
more ) ;
} else {
put ( card , h ( 'div' , { class : 'tiles files' } ,
folders . map ( ( f ) => h ( 'div' , { class : 'tile' , tabindex : '0' , role : 'button' , onclick : ( ) => go ( f ) , onkeydown : onEnter ( ( ) => go ( f ) ) } ,
h ( 'div' , { class : 'thumb' } , h ( 'span' , { class : 'big-icon' , 'aria-hidden' : 'true' } , '📁' ) ) ,
h ( 'div' , { class : 'tile-name' } , folderName ( f ) ) ,
h ( 'div' , { class : 'tile-more' , onclick : stop } , folderMenu ( f ) ) ) ) ,
objects . map ( ( o ) => h ( 'div' , { class : 'tile' , tabindex : '0' , role : 'button' , onclick : ( ) => openViewer ( o ) , onkeydown : onEnter ( ( ) => openViewer ( o ) ) } ,
thumbFor ( o ) ,
h ( 'div' , { class : 'tile-name' , title : fileName ( o ) } , fileName ( o ) ) ,
h ( 'div' , { class : 'small muted' } , fmtBytes ( o . size ) ) ,
h ( 'div' , { class : 'tile-more' , onclick : stop } , fileMenu ( o ) ) ) ) ) ,
more ) ;
}
}
// Icons view: pictures show themselves once scrolled into view; larger
// ones (and everything else) keep a type icon.
const thumbs = new IntersectionObserver ( ( entries ) => {
for ( const en of entries ) {
if ( ! en . isIntersecting ) continue ;
thumbs . unobserve ( en . target ) ;
const key = en . target . dataset . key ;
const path = ` ${ base } /object?key= ${ encodeURIComponent ( key ) } ` ;
( bridge ? bridge . invoke ( 'view-url' , { path } ) . then ( ( r ) => r . url ) : Promise . resolve ( ` ${ path } &inline=1 ` ) )
. then ( ( src ) => { const img = h ( 'img' , { alt : '' , src } ) ; img . onload = ( ) => put ( en . target , img ) ; } )
. catch ( ( ) => { } ) ;
}
} , { rootMargin : '200px' } ) ;
cleanup . push ( ( ) => thumbs . disconnect ( ) ) ;
function thumbFor ( o ) {
const el = h ( 'div' , { class : 'thumb' } , h ( 'span' , { class : 'big-icon' , 'aria-hidden' : 'true' } , fileIcon ( o . key ) ) ) ;
if ( fileKind ( o . key ) === 'image' && o . size <= 8 * 1024 * * 2 ) { el . dataset . key = o . key ; thumbs . observe ( el ) ; }
return el ;
2026-10-03 19:03:05 +02:00
}
function uploadFiles ( files ) {
for ( const file of files ) {
if ( file . size > 5 * 1024 * * 3 ) { toast ( ` ${ file . name } : single uploads are limited to 5 GiB ` , 'error' ) ; continue ; }
const key = prefix + file . name ;
const bar = h ( 'i' ) ;
const pct = h ( 'span' , { class : 'muted' } , '0%' ) ;
const row = h ( 'div' , { class : 'upload' } , h ( 'span' , { } , key ) , h ( 'div' , { class : 'bar' } , bar ) , pct ) ;
uploads . append ( row ) ;
2026-10-03 19:34:55 +02:00
if ( bridge ) {
// The whole file crosses the IPC bridge as one buffer, so keep it to
// sizes a renderer can hold comfortably; S3 clients have no limit.
if ( file . size > 1024 * * 3 ) { row . remove ( ) ; toast ( ` ${ file . name } : uploads from the sidebar are limited to 1 GiB; use an S3 client for larger files ` , 'error' ) ; continue ; }
pct . textContent = 'uploading…' ;
bar . style . width = '100%' ;
bar . style . opacity = '.4' ;
file . arrayBuffer ( )
. then ( ( bytes ) => bridge . invoke ( 'upload' , { path : ` ${ base } /object?key= ${ encodeURIComponent ( key ) } ` , bytes , type : file . type } ) )
. then ( ( r ) => {
if ( r . status >= 200 && r . status < 300 ) { pct . textContent = 'done' ; bar . style . opacity = '1' ; setTimeout ( ( ) => row . remove ( ) , 1500 ) ; load ( ) ; }
else { pct . textContent = 'failed' ; pct . className = 'error' ; toast ( ` ${ file . name } : ${ r . data ? . error || ` HTTP ${ r . status } ` } ` , 'error' ) ; }
} )
. catch ( ( e ) => { pct . textContent = 'failed' ; pct . className = 'error' ; toast ( ` ${ file . name } : ${ e . message } ` , 'error' ) ; } ) ;
continue ;
}
2026-10-03 19:03:05 +02:00
const xhr = new XMLHttpRequest ( ) ;
xhr . open ( 'PUT' , ` ${ base } /object?key= ${ encodeURIComponent ( key ) } ` ) ;
xhr . setRequestHeader ( 'x-pithos' , '1' ) ;
if ( file . type ) xhr . setRequestHeader ( 'content-type' , file . type ) ;
xhr . upload . onprogress = ( e ) => { if ( e . lengthComputable ) { const p = Math . round ( ( e . loaded / e . total ) * 100 ) ; bar . style . width = p + '%' ; pct . textContent = p + '%' ; } } ;
xhr . onload = ( ) => {
if ( xhr . status >= 200 && xhr . status < 300 ) { pct . textContent = 'done' ; setTimeout ( ( ) => row . remove ( ) , 1500 ) ; load ( ) ; }
else { pct . textContent = 'failed' ; pct . className = 'error' ; let m = xhr . statusText ; try { m = JSON . parse ( xhr . responseText ) . error ; } catch { } toast ( ` ${ file . name } : ${ m } ` , 'error' ) ; }
} ;
xhr . onerror = ( ) => { pct . textContent = 'failed' ; pct . className = 'error' ; toast ( ` ${ file . name } : network error ` , 'error' ) ; } ;
xhr . send ( file ) ;
}
}
2026-10-04 00:05:50 +02:00
// An empty "<name>/" marker object keeps the folder visible while empty.
2026-10-03 19:03:05 +02:00
async function newFolder ( ) {
const name = h ( 'input' , { type : 'text' , required : true , pattern : '[^/]+' , placeholder : 'photos' } ) ;
2026-10-04 00:05:50 +02:00
const v = await modal ( 'New folder' , h ( 'label' , { class : 'field' } , h ( 'span' , { } , 'Name' ) , name ) ,
[ { label : 'Cancel' , result : null } , { label : 'Create' , kind : 'primary' , submit : true , value : ( ) => name . value . trim ( ) } ] ) ;
if ( ! v ) return ;
try { await api ( 'PUT' , ` ${ base } /folder?prefix= ${ encodeURIComponent ( prefix + v + '/' ) } ` ) ; toast ( ` Created ${ v } ` ) ; load ( ) ; } catch ( e ) { fail ( e ) ; }
}
function downloadObject ( key ) {
const path = ` ${ base } /object?key= ${ encodeURIComponent ( key ) } ` ;
// The sidebar page has no server origin; Theseus's download manager
// fetches the file through a one-time link instead.
if ( bridge ) return bridge . invoke ( 'download' , { path } ) . then ( ( ) => toast ( 'Download started' ) ) . catch ( fail ) ;
const a = h ( 'a' , { href : path , download : '' } ) ;
document . body . append ( a ) ;
a . click ( ) ;
a . remove ( ) ;
}
// Viewer: images, audio, video and text open inside Pithos; anything else
// offers a download.
async function openViewer ( o ) {
const name = o . key . slice ( prefix . length ) ;
const kind = fileKind ( o . key ) ;
2026-10-04 02:23:07 +02:00
if ( kind === 'audio' ) return playFolder ( o ) ;
2026-10-04 00:05:50 +02:00
const path = ` ${ base } /object?key= ${ encodeURIComponent ( o . key ) } ` ;
const src = async ( ) => ( bridge ? ( await bridge . invoke ( 'view-url' , { path } ) ) . url : ` ${ path } &inline=1 ` ) ;
let content ;
try {
if ( kind === 'image' ) content = h ( 'img' , { class : 'viewer-media' , src : await src ( ) , alt : name } ) ;
else if ( kind === 'audio' ) content = h ( 'audio' , { class : 'viewer-audio' , src : await src ( ) , controls : true , autoplay : true } ) ;
else if ( kind === 'video' ) content = h ( 'video' , { class : 'viewer-media' , src : await src ( ) , controls : true , autoplay : true } ) ;
else if ( kind === 'text' ) {
const t = await api ( 'GET' , ` ${ base } /text?key= ${ encodeURIComponent ( o . key ) } ` ) ;
content = h ( 'div' , { } , h ( 'pre' , { class : 'snippet viewer-text' } , t . text ) , t . truncated && h ( 'p' , { class : 'small muted' } , ` Showing the first 512 KiB of ${ fmtBytes ( t . size ) } . ` ) ) ;
} else content = h ( 'div' , { class : 'empty' } , h ( 'p' , { } , 'Pithos cannot show this kind of file.' ) , h ( 'p' , { class : 'small' } , 'Download it to open it on your computer.' ) ) ;
} catch ( e ) { return fail ( e ) ; }
await modal ( name , h ( 'div' , { class : 'viewer' } , content , h ( 'p' , { class : 'small muted' , style : 'margin:10px 0 0' } , ` ${ fmtBytes ( o . size ) } · ${ fmtDate ( o . modified ) } ` ) ) ,
[ { label : 'Share' , value : ( ) => { share ( o . key ) ; return false ; } } , { label : 'Download' , value : ( ) => { downloadObject ( o . key ) ; return false ; } } , { label : 'Close' , kind : 'primary' , submit : true , result : true } ] ) ;
2026-10-03 19:03:05 +02:00
}
2026-10-04 02:23:07 +02:00
// Music plays in the docked player, on through this folder's other tracks.
function playFolder ( o ) {
const where = ` ${ bucket } / ${ prefix } ` . replace ( /\/$/ , '' ) ;
const tracks = pages . flatMap ( ( p ) => p . objects ) . filter ( ( x ) => fileKind ( x . key ) === 'audio' ) . map ( ( x ) => {
const path = ` ${ base } /object?key= ${ encodeURIComponent ( x . key ) } ` ;
return { key : x . key , name : x . key . slice ( prefix . length ) , where ,
src : async ( ) => ( bridge ? ( await bridge . invoke ( 'view-url' , { path } ) ) . url : ` ${ path } &inline=1 ` ) } ;
} ) ;
player ( ) . play ( tracks , Math . max ( 0 , tracks . findIndex ( ( t ) => t . key === o . key ) ) ) ;
}
2026-10-03 19:03:05 +02:00
async function deleteObject ( key ) {
if ( ! ( await confirmModal ( 'Delete object?' , key ) ) ) return ;
try { await api ( 'DELETE' , ` ${ base } /object?key= ${ encodeURIComponent ( key ) } ` ) ; toast ( 'Deleted' ) ; load ( ) ; } catch ( e ) { fail ( e ) ; }
}
async function deleteFolder ( p ) {
if ( ! ( await confirmModal ( 'Delete folder?' , ` Every object under ${ p } will be deleted. ` ) ) ) return ;
try { const r = await api ( 'DELETE' , ` ${ base } /prefix?prefix= ${ encodeURIComponent ( p ) } ` ) ; toast ( ` Deleted ${ r . deleted } objects ` ) ; load ( ) ; } catch ( e ) { fail ( e ) ; }
}
2026-10-04 03:18:25 +02:00
// 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.
2026-10-03 19:03:05 +02:00
async function share ( key ) {
2026-10-04 03:18:25 +02:00
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 ) ) ) ;
2026-10-03 19:03:05 +02:00
const savedEndpoint = localStorage . getItem ( 'pithos.publicEndpoint' ) || '' ;
2026-10-04 03:18:25 +02:00
const endpoint = h ( 'input' , { type : 'url' , value : savedEndpoint , placeholder : 'https://files.example.com' } ) ;
const out = h ( 'div' , { } , h ( 'p' , { class : 'muted small' } , 'Making the link…' ) ) ;
const note = h ( 'div' ) ;
let link = '' ;
2026-10-03 19:03:05 +02:00
const gen = async ( ) => {
const q = new URLSearchParams ( { key , expires : expires . value } ) ;
2026-10-04 03:18:25 +02:00
const own = endpoint . value . trim ( ) ;
try { localStorage . setItem ( 'pithos.publicEndpoint' , own ) ; } catch { }
if ( own ) q . set ( 'endpoint' , own ) ;
2026-10-03 19:03:05 +02:00
try {
2026-10-04 03:18:25 +02:00
( { url : link } = await api ( 'GET' , ` ${ base } /presign? ${ q } ` ) ) ;
put ( out , secretRow ( 'Link' , link ) ) ;
} catch ( e ) { put ( out , h ( 'p' , { class : 'error small' } , e . message ) ) ; }
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).' ) ) ) ;
2026-10-03 19:03:05 +02:00
} ;
2026-10-04 03:18:25 +02:00
expires . addEventListener ( 'change' , gen ) ;
endpoint . addEventListener ( 'change' , gen ) ;
gen ( ) ;
2026-10-03 19:03:05 +02:00
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 ( 'label' , { class : 'field' } , h ( 'span' , { } , 'Valid for' ) , expires ) ,
2026-10-04 03:18:25 +02:00
out ,
note ,
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 } ] ) ;
2026-10-03 19:03:05 +02:00
}
load ( ) ;
}
2026-10-04 02:23:07 +02:00
// ---------------------------------------------------------------- music player
// A player docked at the bottom of Pithos: it lives outside the page, so it
// keeps playing while you browse other folders, drives or pages, and plays
// on through the folder it was started from.
let musicPlayer = null ;
function player ( ) {
if ( musicPlayer ) return musicPlayer ;
const audio = new Audio ( ) ;
audio . preload = 'metadata' ;
let queue = [ ] ;
let at = - 1 ;
let seeking = false ;
let retried = false ;
const mmss = ( t ) => ( isFinite ( t ) ? ` ${ Math . floor ( t / 60 ) } : ${ String ( Math . floor ( t % 60 ) ) . padStart ( 2 , '0' ) } ` : '0:00' ) ;
const title = h ( 'div' , { class : 'pl-title' } ) ;
const where = h ( 'div' , { class : 'pl-where small muted' } ) ;
const cur = h ( 'span' , { class : 'pl-time' } , '0:00' ) ;
const dur = h ( 'span' , { class : 'pl-time' } , '0:00' ) ;
const seek = h ( 'input' , { type : 'range' , min : '0' , max : '0' , step : '1' , value : '0' , 'aria-label' : 'Position' ,
oninput : ( ) => { seeking = true ; cur . textContent = mmss ( + seek . value ) ; } ,
onchange : ( ) => { audio . currentTime = + seek . value ; seeking = false ; } } ) ;
const playBtn = h ( 'button' , { type : 'button' , class : 'pl-btn pl-main' , 'aria-label' : 'Play' , onclick : ( ) => ( audio . paused ? audio . play ( ) . catch ( ( ) => { } ) : audio . pause ( ) ) } , '▶' ) ;
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 closeBtn = h ( 'button' , { type : 'button' , class : 'pl-btn' , 'aria-label' : 'Stop and close the player' , title : 'Close' , onclick : ( ) => close ( ) } , '✕' ) ;
2026-10-04 03:18:25 +02:00
// 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' ) ; } ) ;
2026-10-04 02:23:07 +02:00
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-info' } , h ( 'div' , { class : 'pl-head' } , title , where ) , h ( 'div' , { class : 'pl-seek' } , cur , seek , dur ) ) ,
2026-10-04 03:18:25 +02:00
volBox ,
2026-10-04 02:23:07 +02:00
closeBtn ) ;
document . body . append ( el ) ;
const showPlaying = ( ) => { const p = ! audio . paused ; playBtn . textContent = p ? '⏸' : '▶' ; playBtn . setAttribute ( 'aria-label' , p ? 'Pause' : 'Play' ) ; if ( navigator . mediaSession ) navigator . mediaSession . playbackState = p ? 'playing' : 'paused' ; } ;
audio . addEventListener ( 'play' , showPlaying ) ;
audio . addEventListener ( 'pause' , showPlaying ) ;
audio . addEventListener ( 'playing' , ( ) => { retried = false ; } ) ;
audio . addEventListener ( 'loadedmetadata' , ( ) => { seek . max = String ( Math . floor ( audio . duration ) || 0 ) ; dur . textContent = mmss ( audio . duration ) ; } ) ;
audio . addEventListener ( 'timeupdate' , ( ) => { if ( ! seeking ) { seek . value = String ( Math . floor ( audio . currentTime ) ) ; cur . textContent = mmss ( audio . currentTime ) ; } } ) ;
audio . addEventListener ( 'ended' , ( ) => { if ( at < queue . length - 1 ) next ( ) ; } ) ;
// Theseus view links last 10 minutes: when one runs out mid-track, fetch a
// fresh link and carry on from the same spot.
audio . addEventListener ( 'error' , ( ) => {
if ( ! queue [ at ] || retried ) return toast ( ` Cannot play ${ queue [ at ] ? . name || 'this file' } ` , 'error' ) ;
retried = true ;
load ( at , audio . currentTime , true ) ;
} ) ;
async function load ( i , from = 0 , play = true ) {
at = i ;
const t = queue [ i ] ;
title . textContent = t . name ;
title . title = t . name ;
where . textContent = t . where ;
prevBtn . disabled = i <= 0 ;
nextBtn . disabled = i >= queue . length - 1 ;
let src ;
try { src = await t . src ( ) ; } catch ( e ) { return fail ( e ) ; }
if ( at !== i ) return ; // another track was chosen meanwhile
audio . src = src ;
if ( from ) audio . addEventListener ( 'loadedmetadata' , ( ) => { audio . currentTime = from ; } , { once : true } ) ;
if ( play ) audio . play ( ) . catch ( ( ) => { } ) ;
if ( navigator . mediaSession && window . MediaMetadata ) navigator . mediaSession . metadata = new MediaMetadata ( { title : t . name . replace ( /\.[^.]+$/ , '' ) , album : t . where , artist : 'Pithos' } ) ;
}
const next = ( ) => { if ( at < queue . length - 1 ) load ( at + 1 ) ; } ;
const prev = ( ) => { if ( audio . currentTime > 3 || at <= 0 ) audio . currentTime = 0 ; else load ( at - 1 ) ; } ;
function close ( ) {
audio . pause ( ) ;
audio . removeAttribute ( 'src' ) ;
audio . load ( ) ;
queue = [ ] ; at = - 1 ;
el . hidden = true ;
document . body . classList . remove ( 'has-player' ) ;
}
if ( navigator . mediaSession ) {
const ms = navigator . mediaSession ;
try {
ms . setActionHandler ( 'play' , ( ) => audio . play ( ) . catch ( ( ) => { } ) ) ;
ms . setActionHandler ( 'pause' , ( ) => audio . pause ( ) ) ;
ms . setActionHandler ( 'previoustrack' , prev ) ;
ms . setActionHandler ( 'nexttrack' , next ) ;
ms . setActionHandler ( 'seekto' , ( d ) => { audio . currentTime = d . seekTime ; } ) ;
} catch { }
}
musicPlayer = {
// tracks: [{ key, name, where, src: async () => url }]
play ( tracks , index ) {
queue = tracks ;
el . hidden = false ;
document . body . classList . add ( 'has-player' ) ;
load ( index ) ;
} ,
get current ( ) { return queue [ at ] ? . key || null ; } ,
} ;
return musicPlayer ;
}
2026-10-03 19:03:05 +02:00
// ---------------------------------------------------------------- settings
function settings ( main ) {
const card = h ( 'div' , { class : 'card' } , h ( 'p' , { class : 'muted' } , 'Loading…' ) ) ;
main . append ( h ( 'div' , { class : 'page-head' } , h ( 'div' , { } , h ( 'h1' , { } , 'Settings' ) , h ( 'p' , { class : 'muted' } , 'Edits s3d.yml. s3d reads it at start, so a restart applies the changes.' ) ) ) , card ) ;
const fields = [
[ 'Network' , [
[ 'apiAddress' , 'S3 API address' , 'text' , 'host:port the S3 API listens on. Use 0.0.0.0:8000 to serve other machines.' ] ,
[ 'apiHTTPSAddress' , 'S3 HTTPS address' , 'text' , 'Optional. s3d serves a self-signed certificate here.' ] ,
[ 'adminAddress' , 'Admin API address' , 'text' , 'Pithos reads stats from here. Keep it on loopback.' ] ,
[ 'adminPassword' , 'Admin API password' , 'password' , 'Generated on first start.' ] ,
[ 's3.hostBases' , 'Virtual-host bases' , 'list' , 'Comma-separated domains for bucket.domain style addressing.' ] ,
] ] ,
[ 'Storage' , [
[ 'directory' , 'Data directory' , 'text' , 'Database and upload buffer. Changing it points s3d at a different store.' ] ,
[ 'sia.diskUsageLimit' , 'Buffer disk limit (bytes)' , 'number' , 'Upper bound for objects waiting to upload.' ] ,
[ 'sia.uploadThreads' , 'Upload threads' , 'number' , '' ] ,
[ 'sia.dataShards' , 'Data shards' , 'number' , 'Erasure coding: data shards (default 10). Applies to new uploads only.' ] ,
[ 'sia.parityShards' , 'Parity shards' , 'number' , 'Erasure coding: parity shards (default 20).' ] ,
] ] ,
[ 'Logging' , [
[ 'log.stdout.level' , 'Console log level' , 'select:debug,info,warn,error' , '' ] ,
[ 'log.file.enabled' , 'Write a log file' , 'bool' , '' ] ,
[ 'log.file.level' , 'File log level' , 'select:debug,info,warn,error' , '' ] ,
] ] ,
] ;
( async ( ) => {
let cfg ;
try { cfg = await api ( 'GET' , '/api/config' ) ; } catch ( e ) { put ( card , h ( 'p' , { class : 'error' } , e . message ) ) ; return ; }
const inputs = { } ;
const sections = fields . map ( ( [ title , list ] ) => h ( 'div' , { class : 'stack' , style : 'margin-bottom:22px' } , h ( 'h2' , { } , title ) , h ( 'div' , { class : 'form-grid' } , list . map ( ( [ key , label , type , help ] ) => {
const v = cfg . values [ key ] ;
let input ;
if ( type === 'bool' ) input = h ( 'input' , { type : 'checkbox' , checked : ! ! v } ) ;
else if ( type . startsWith ( 'select:' ) ) input = h ( 'select' , { } , h ( 'option' , { value : '' } , '(default)' ) , type . slice ( 7 ) . split ( ',' ) . map ( ( o ) => h ( 'option' , { value : o , selected : v === o } , o ) ) ) ;
else input = h ( 'input' , { type : type === 'list' ? 'text' : type , value : Array . isArray ( v ) ? v . join ( ', ' ) : ( v ? ? '' ) , placeholder : '(default)' , autocomplete : 'off' } ) ;
inputs [ key ] = { input , type , orig : v } ;
return type === 'bool'
? h ( 'label' , { class : 'check' } , input , label )
: h ( 'label' , { class : 'field' } , h ( 'span' , { } , label ) , input , help && h ( 'small' , { } , help ) ) ;
} ) ) ) ) ;
put ( card ,
h ( 'p' , { class : 'small muted' } , 'File: ' , h ( 'code' , { } , cfg . file ) ) ,
... sections ,
h ( 'div' , { class : 'row' } , h ( 'button' , { class : 'btn primary' , onclick : save } , 'Save' ) ) ) ;
async function save ( ) {
const patch = { } ;
for ( const [ key , { input , type , orig } ] of Object . entries ( inputs ) ) {
let v ;
if ( type === 'bool' ) v = input . checked ;
else if ( type === 'number' ) v = input . value === '' ? null : Number ( input . value ) ;
else if ( type === 'list' ) v = input . value . split ( ',' ) . map ( ( s ) => s . trim ( ) ) . filter ( Boolean ) ;
else v = input . value . trim ( ) || null ;
const same = JSON . stringify ( v ? ? null ) === JSON . stringify ( orig ? ? null ) || ( type === 'list' && ! v . length && orig == null ) || ( type === 'bool' && ! v && orig == null ) ;
if ( ! same ) patch [ key ] = v ;
}
if ( ! Object . keys ( patch ) . length ) return toast ( 'Nothing changed' ) ;
try {
const r = await api ( 'PUT' , '/api/config' , patch ) ;
for ( const k of Object . keys ( patch ) ) inputs [ k ] . orig = patch [ k ] ;
if ( r . restartNeeded ) {
if ( await confirmModal ( 'Restart s3d?' , 'The new settings apply after a restart.' , 'Restart now' , 'primary' ) ) await daemonAction ( 'restart' ) ;
} else toast ( 'Saved' ) ;
refreshStatus ( ) ;
} catch ( e ) { fail ( e ) ; }
}
} ) ( ) ;
}
// ---------------------------------------------------------------- logs
function logs ( main ) {
const box = h ( 'div' , { class : 'logs' } ) ;
let follow = true ;
box . addEventListener ( 'scroll' , ( ) => { follow = box . scrollTop + box . clientHeight >= box . scrollHeight - 20 ; } ) ;
main . append (
h ( 'div' , { class : 'page-head' } , h ( 'div' , { } , h ( 'h1' , { } , 'Logs' ) , h ( 'p' , { class : 'muted' } , 'Output from the s3d process Pithos started.' ) ) ,
h ( 'button' , { class : 'btn' , onclick : ( ) => copy ( state . logs . map ( ( l ) => l . line ) . join ( '\n' ) , 'Logs copied' ) } , 'Copy all' ) ) ,
box ) ;
const line = ( l ) => h ( 'div' , { class : l . stream } , l . line ) ;
box . append ( ... state . logs . map ( line ) ) ;
if ( ! state . logs . length ) box . append ( h ( 'div' , { class : 'sys' } , 'No output yet.' ) ) ;
box . scrollTop = box . scrollHeight ;
logSink = ( l ) => {
box . append ( line ( l ) ) ;
while ( box . childElementCount > 2000 ) box . firstChild . remove ( ) ;
if ( follow ) box . scrollTop = box . scrollHeight ;
} ;
}
// ---------------------------------------------------------------- boot
2026-10-04 02:16:11 +02:00
// Theseus sidebar: Pithos fits the narrow panel; for more room it opens as a
// regular tab, which steps back like any tab when another one is chosen. The
// panel closes so only one Pithos shows.
2026-10-03 19:34:55 +02:00
function addSidebarControls ( ) {
2026-10-04 02:16:11 +02:00
// The panel may have been widened by an earlier Pithos; start narrow.
bridge . sidebar ? . isMax ? . ( ) . then ( ( max ) => { if ( max ) bridge . sidebar . restore ? . ( ) ? ? bridge . sidebar . toggleMax ? . ( ) ; } ) . catch ( ( ) => { } ) ;
const row = h ( 'div' , { class : 'host-controls' } ,
h ( 'button' , { class : 'btn small' , title : 'Open Pithos in a tab and close this panel' , onclick : async ( ) => {
2026-10-04 03:29:32 +02:00
try { await bridge . invoke ( 'open-in-tab' , { path : hashToPath ( location . hash || '#/overview' ) } ) ; await bridge . sidebar ? . close ? . ( ) ; } catch ( e ) { fail ( e ) ; }
2026-10-04 02:16:11 +02:00
} } , '⤢ Full page' ) ) ;
2026-10-03 19:34:55 +02:00
$ ( '.sidebar' ) . insertBefore ( row , $ ( '#daemon-pill' ) ) ;
2026-10-04 03:01:00 +02:00
addMenuAction ( '⤢ Open as full page' , ( ) => row . querySelector ( 'button' ) . click ( ) ) ;
}
// An action at the end of the ☰ menu (narrow layouts only; wide ones show
// the same thing as a button under the navigation).
function addMenuAction ( label , run ) {
if ( ! $ ( '#nav .nav-sep' ) ) $ ( '#nav' ) . append ( h ( 'div' , { class : 'nav-sep' , role : 'separator' } ) ) ;
$ ( '#nav' ) . append ( h ( 'a' , { href : '#' , class : 'nav-action' , onclick : ( e ) => { e . preventDefault ( ) ; setMenu ( false ) ; run ( ) ; } } , label ) ) ;
2026-10-03 19:34:55 +02:00
}
2026-10-03 20:42:23 +02:00
// Theseus: Pithos opens only with the vault unlocked. The add-on asks Theseus
// for the unlock, and Theseus shows its own PIN / master-password prompt.
let vaultInfo = null ;
async function vaultGate ( ) {
let r ;
try { r = await bridge . invoke ( 'gate' ) ; } catch ( e ) { fail ( e ) ; r = { ok : false } ; }
vaultInfo = r ;
if ( r . ok ) { $ ( '#locked' ) . hidden = true ; return true ; }
$ ( '#app' ) . hidden = true ;
$ ( '#locked' ) . hidden = false ;
return false ;
}
// Re-lock when the vault locks (Settings › Lock now), so secrets are not left
// on screen. The add-on watches from the main process and pushes the change.
function watchVaultLock ( ) {
bridge . on ( 'pithos-vault' , ( st ) => {
vaultInfo = { ... vaultInfo , ... st } ;
if ( st . setup && st . prompt && ! st . unlocked && $ ( '#locked' ) . hidden ) {
$ ( '#app' ) . hidden = true ;
$ ( '#locked' ) . hidden = false ;
}
} ) ;
}
$ ( '#unlock-btn' ) . addEventListener ( 'click' , async ( ) => {
2026-10-04 03:29:32 +02:00
if ( PATH _MODE ) {
try { const r = await api ( 'POST' , '/api/host/unlock' ) ; if ( r . ok ) location . reload ( ) ; } catch ( e ) { fail ( e ) ; }
return ;
}
2026-10-03 20:42:23 +02:00
if ( await vaultGate ( ) ) {
const first = $ ( '#app' ) . dataset . booted !== '1' ;
$ ( '#app' ) . hidden = false ;
if ( first ) location . reload ( ) ;
}
} ) ;
2026-10-04 02:38:21 +02:00
// ---- Footer: pithos.sia, the version, and (in Theseus) the updater -----------
// Like Aegis: checks the extension channel when Pithos opens and every few
// hours; a newer release replaces the version with "↑ vX". One click
// downloads and verifies it, the next restarts Theseus to apply it.
const UPDATES _URL = 'https://navigate.st/bns/theseus.x/extensions/pithos/updates.json' ;
2026-10-04 03:18:25 +02:00
// 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 ) ;
}
2026-10-04 02:38:21 +02:00
function cmpSemver ( a , b ) {
const pa = String ( a || '0' ) . split ( '.' ) . map ( ( n ) => Number ( n ) || 0 ) ;
const pb = String ( b || '0' ) . split ( '.' ) . map ( ( n ) => Number ( n ) || 0 ) ;
for ( let i = 0 ; i < Math . max ( pa . length , pb . length ) ; i ++ ) { const d = ( pa [ i ] || 0 ) - ( pb [ i ] || 0 ) ; if ( d ) return d < 0 ? - 1 : 1 ; }
return 0 ;
}
function setupFooter ( ) {
const foot = $ ( '#brandfoot' ) ;
const ver = $ ( '#brandver' ) ;
const chip = $ ( '#brandupd' ) ;
const check = $ ( '#brandcheck' ) ;
const current = state . status ? . pithos || null ;
ver . textContent = current ? ` v ${ current } ` : '' ;
foot . hidden = false ;
document . body . classList . add ( 'has-foot' ) ;
if ( bridge ) {
$ ( '#brandlink' ) . addEventListener ( 'click' , ( e ) => { e . preventDefault ( ) ; bridge . invoke ( 'open-external' , { url : 'https://pithos.sia/' } ) . catch ( fail ) ; } ) ;
}
if ( ! bridge || ! current ) return ; // only the Theseus add-on updates itself
let latest = null ;
let busy = false ;
const showChip = ( text , cls , title , onclick ) => {
chip . hidden = false ; ver . hidden = true ;
chip . className = ` brandupd ${ cls ? ' ' + cls : '' } ` ;
chip . textContent = text ; chip . title = title || '' ;
chip . disabled = ! onclick ; chip . onclick = onclick || null ;
} ;
const rest = ( ) => { chip . hidden = true ; ver . hidden = false ; } ;
const flash = ( text , cls , title ) => { showChip ( text , cls , title , null ) ; setTimeout ( ( ) => { if ( ! busy && chip . classList . contains ( cls ) ) paint ( ) ; } , 2500 ) ; } ;
function paint ( ) {
if ( latest && cmpSemver ( latest , current ) > 0 ) showChip ( ` ↑ v ${ latest } ` , '' , ` Pithos v ${ latest } is available. Click to install it. ` , install ) ;
else rest ( ) ;
}
async function scan ( manual ) {
check . classList . add ( 'spin' ) ;
try {
const r = await fetch ( ` ${ UPDATES _URL } ?t= ${ Math . floor ( Date . now ( ) / 60000 ) } ` , { cache : 'no-store' } ) ;
if ( ! r . ok ) throw new Error ( ` HTTP ${ r . status } ` ) ;
const list = ( await r . json ( ) ) ? . addons || [ ] ;
latest = list . reduce ( ( best , e ) => ( ! best || cmpSemver ( e . version , best ) > 0 ? e . version : best ) , null ) ;
if ( busy ) return ;
if ( manual && ! ( latest && cmpSemver ( latest , current ) > 0 ) ) flash ( '✓ Up to date' , 'ok' , ` Pithos v ${ current } is the latest release ` ) ;
else paint ( ) ;
} catch ( e ) {
if ( manual ) flash ( '⚠ Check failed' , 'err' , e . message ) ;
} finally { check . classList . remove ( 'spin' ) ; }
}
async function install ( ) {
busy = true ;
showChip ( 'Downloading…' , 'wait' , 'Downloading and verifying the signed release' , null ) ;
try {
const r = await bridge . invoke ( 'update' , { step : 'stage' } ) ;
if ( r ? . fallback ) {
busy = false ;
return showChip ( 'Update in Settings' , '' , 'This Theseus updates extensions in Settings › Extensions' , ( ) => bridge . invoke ( 'open-settings' , { section : 'addons' } ) . catch ( fail ) ) ;
}
if ( ! r ? . staged ) {
busy = false ;
return r ? . status === 'up-to-date' ? flash ( '✓ Up to date' , 'ok' , '' ) : flash ( '⚠ Update failed' , 'err' , r ? . detail || r ? . status || '' ) ;
}
const next = r . next ? ` v ${ r . next } ` : '' ;
showChip ( ` ↻ Restart to apply ${ next } ` , '' , ` Pithos ${ next } is ready. Theseus restarts to apply it and reopens your tabs. ` , async ( ) => {
try {
const a = await bridge . invoke ( 'update' , { step : 'apply' } ) ;
if ( a ? . deferred ) showChip ( ` Applies on next start ${ next } ` , 'ok' , 'Pithos updates the next time Theseus starts' , null ) ;
} catch ( e ) { flash ( '⚠ Restart failed' , 'err' , e . message ) ; }
} ) ;
} catch ( e ) { busy = false ; flash ( '⚠ Update failed' , 'err' , e . message ) ; }
}
check . hidden = false ;
check . addEventListener ( 'click' , ( ) => scan ( true ) ) ;
setTimeout ( ( ) => scan ( false ) , 1500 ) ;
setInterval ( ( ) => scan ( false ) , 6 * 3600 * 1000 ) ;
}
2026-10-04 00:05:50 +02:00
// The full-page tab opened from the Theseus panel: one button back.
function addBackToSidebar ( ) {
const row = h ( 'div' , { class : 'host-controls' } ,
h ( 'button' , { class : 'btn small' , title : 'Show Pithos in the sidebar again and close this page' , onclick : async ( ) => {
try { await api ( 'POST' , '/api/host/show-panel' ) ; } catch ( e ) { return fail ( e ) ; }
window . close ( ) ; // works for tabs the add-on opened; harmless otherwise
} } , '⇤ Back to sidebar' ) ) ;
$ ( '.sidebar' ) . insertBefore ( row , $ ( '#daemon-pill' ) ) ;
2026-10-04 03:01:00 +02:00
addMenuAction ( '⇤ Back to sidebar' , ( ) => row . querySelector ( 'button' ) . click ( ) ) ;
2026-10-04 00:05:50 +02:00
}
2026-10-03 19:03:05 +02:00
function showSignin ( ) {
$ ( '#app' ) . hidden = true ;
$ ( '#signin' ) . hidden = false ;
}
async function boot ( ) {
const s = await api ( 'GET' , '/api/session' ) ;
2026-10-04 03:29:32 +02:00
// pithos.sia in Theseus: locked with the vault, like the sidebar panel.
if ( s . locked ) { $ ( '#locked' ) . hidden = false ; return ; }
2026-10-03 19:03:05 +02:00
if ( ! s . authenticated ) {
showSignin ( ) ;
if ( ! s . passwordLogin ) {
$ ( '#signin-hint' ) . textContent = 'Open Pithos with the sign-in link printed when it started.' ;
$ ( '#signin-form' ) . querySelector ( 'input' ) . hidden = true ;
$ ( '#signin-form' ) . querySelector ( 'button' ) . hidden = true ;
}
return ;
}
$ ( '#signin' ) . hidden = true ;
2026-10-03 20:42:23 +02:00
if ( bridge && ! ( await vaultGate ( ) ) ) return ;
2026-10-03 19:03:05 +02:00
$ ( '#app' ) . hidden = false ;
2026-10-03 20:42:23 +02:00
$ ( '#app' ) . dataset . booted = '1' ;
if ( bridge ) { addSidebarControls ( ) ; watchVaultLock ( ) ; }
2026-10-03 19:03:05 +02:00
// Backfill logs the server already holds, then stream.
try {
state . logs = await api ( 'GET' , '/api/logs' ) ;
state . logSeq = state . logs . at ( - 1 ) ? . seq || 0 ;
} catch { }
await refreshStatus ( ) ;
2026-10-04 02:38:21 +02:00
setupFooter ( ) ;
2026-10-04 00:05:50 +02:00
if ( ! bridge && state . status ? . host === 'theseus' ) addBackToSidebar ( ) ;
2026-10-03 19:03:05 +02:00
connectEvents ( ) ;
2026-10-04 03:29:32 +02:00
if ( PATH _MODE ) {
window . addEventListener ( 'popstate' , route ) ;
// In-app links are written as #/ routes; follow them as real addresses.
document . addEventListener ( 'click' , ( e ) => {
const a = e . target . closest ? . ( 'a[href^="#/"]' ) ;
if ( ! a || e . defaultPrevented || e . button !== 0 || e . metaKey || e . ctrlKey || e . shiftKey ) return ;
e . preventDefault ( ) ;
navTo ( a . getAttribute ( 'href' ) ) ;
} ) ;
} else window . addEventListener ( 'hashchange' , route ) ;
2026-10-03 19:03:05 +02:00
route ( ) ;
setInterval ( refreshStatus , 15000 ) ;
}
2026-10-04 03:01:00 +02:00
function setMenu ( open ) {
$ ( '.sidebar' ) . classList . toggle ( 'nav-open' , open ) ;
2026-10-04 02:16:11 +02:00
$ ( '#nav-toggle' ) . setAttribute ( 'aria-expanded' , String ( open ) ) ;
2026-10-04 03:01:00 +02:00
}
$ ( '#nav-toggle' ) . addEventListener ( 'click' , ( e ) => { e . stopPropagation ( ) ; setMenu ( ! $ ( '.sidebar' ) . classList . contains ( 'nav-open' ) ) ; } ) ;
document . addEventListener ( 'mousedown' , ( e ) => {
if ( $ ( '.sidebar' ) . classList . contains ( 'nav-open' ) && ! $ ( '#nav' ) . contains ( e . target ) && ! $ ( '#nav-toggle' ) . contains ( e . target ) ) setMenu ( false ) ;
2026-10-04 02:16:11 +02:00
} ) ;
2026-10-04 03:01:00 +02:00
document . addEventListener ( 'keydown' , ( e ) => { if ( e . key === 'Escape' && $ ( '.sidebar' ) . classList . contains ( 'nav-open' ) ) { setMenu ( false ) ; $ ( '#nav-toggle' ) . focus ( ) ; } } ) ;
$ ( '#nav' ) . addEventListener ( 'click' , ( e ) => { if ( e . target . closest ( 'a' ) ) setMenu ( false ) ; } ) ;
2026-10-04 02:16:11 +02:00
2026-10-03 19:03:05 +02:00
$ ( '#signin-form' ) . addEventListener ( 'submit' , async ( e ) => {
e . preventDefault ( ) ;
const err = $ ( '#signin-error' ) ;
err . hidden = true ;
try {
await api ( 'POST' , '/api/session' , { password : $ ( '#signin-password' ) . value } ) ;
location . reload ( ) ;
} catch ( ex ) { err . textContent = ex . message ; err . hidden = false ; }
} ) ;
boot ( ) . catch ( fail ) ;