From 4f745406d8ad70e9821b72ad1365784038ef914a Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sat, 3 Oct 2026 22:43:30 +0200 Subject: [PATCH 1/4] Settings > Ariadne's Thread: Refresh/Install/Update live in the Status row They had a row of their own with nothing else in it, which read as an empty card with a stray button. Same layout as the Plug-ins list row now: buttons beside the on/off switch. --- settings.html | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/settings.html b/settings.html index 64e2f32a..7489ec2f 100644 --- a/settings.html +++ b/settings.html @@ -440,26 +440,26 @@

A local daemon that resolves BCDN names for every browser on this machine (Chrome, Edge, Firefox, etc.), not just Theseus. Turning it off means non-Theseus browsers stop resolving .bch / .x / other BCNR TLDs; Theseus keeps working either way, since it has its own built-in resolver.

Status

-
+
System-wide resolver
checking…
- +
+ + + + +
-
- - - -

Collision policy

When a name exists on both BCNR and ICANN, which one wins? Applies machine-wide — every browser sees the same answer. Change is picked up within 5 seconds; no daemon restart, no UAC.

From 3d2e3c784b61791983fbb454d200c0621f8402a8 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sat, 3 Oct 2026 22:57:47 +0200 Subject: [PATCH 2/4] Theseus: bundle Pithos 0.3.4 Rebuilt from Pithos/ with scripts/build-theseus-addon.mjs. --- bundled-addons/pithos/addon.json | 2 +- bundled-addons/pithos/core/accounts.js | 91 ++++++++++++++++++++++ bundled-addons/pithos/core/login.js | 4 +- bundled-addons/pithos/core/server.js | 55 +++++++++++++- bundled-addons/pithos/ui/app.css | 3 + bundled-addons/pithos/ui/app.js | 100 ++++++++++++++++++++++--- 6 files changed, 243 insertions(+), 12 deletions(-) create mode 100644 bundled-addons/pithos/core/accounts.js diff --git a/bundled-addons/pithos/addon.json b/bundled-addons/pithos/addon.json index 8ea5cc94..93e7171a 100644 --- a/bundled-addons/pithos/addon.json +++ b/bundled-addons/pithos/addon.json @@ -1,7 +1,7 @@ { "id": "pithos", "name": "Pithos", - "version": "0.3.3", + "version": "0.3.4", "description": "Run s3d, the Sia S3 gateway, from the Theseus sidebar: connect it to a Sia indexer, create S3 users and access keys, browse and share buckets, and watch uploads reach Sia.", "author": "Silent Mode", "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM2RkYzk3Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMWU4ZjZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIyIDZoMjB2NWMwIDItMiAzLTIgNSA4IDMgMTQgMTEgMTQgMjEgMCAxMi05IDIxLTIyIDIxUzEwIDQ5IDEwIDM3YzAtMTAgNi0xOCAxNC0yMSAwLTItMi0zLTItNXoiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMTQgMzBoMzZNMTMgNDBoMzgiIHN0cm9rZT0iIzBiMWYxOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9Ii41NSIvPjwvc3ZnPgo=", diff --git a/bundled-addons/pithos/core/accounts.js b/bundled-addons/pithos/core/accounts.js new file mode 100644 index 00000000..b2c925f1 --- /dev/null +++ b/bundled-addons/pithos/core/accounts.js @@ -0,0 +1,91 @@ +// Switching the Sia account an s3d is connected to. s3d has no "log out": +// its connection (the app key), users, keys, buckets and upload buffer all +// live in its data folder. A switch moves that folder's contents into +// previous-account-/ beside it (never deleted), so s3d starts fresh +// and can be connected to another account; a restore swaps one back. +// +// s3d.yml stays where it is: it holds addresses and the admin password, not +// the account. pithos.json (the user's label for the account) moves with the +// data, because the label belongs to that account. + +import fs from 'node:fs'; +import path from 'node:path'; + +const ARCHIVE_PREFIX = 'previous-account-'; +const META = 'pithos-archive.json'; +const LABEL_FILE = 'pithos.json'; + +const keep = (name) => name === 's3d.yml' || name.startsWith(ARCHIVE_PREFIX); + +function stamp(d = new Date()) { + const p = (n) => String(n).padStart(2, '0'); + return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`; +} + +export function readLabel(dataDir) { + try { return String(JSON.parse(fs.readFileSync(path.join(dataDir, LABEL_FILE), 'utf8')).accountLabel || ''); } catch { return ''; } +} + +export function writeLabel(dataDir, label) { + const clean = String(label || '').trim().slice(0, 120); + fs.mkdirSync(dataDir, { recursive: true }); + let cur = {}; + try { cur = JSON.parse(fs.readFileSync(path.join(dataDir, LABEL_FILE), 'utf8')); } catch {} + fs.writeFileSync(path.join(dataDir, LABEL_FILE), JSON.stringify({ ...cur, accountLabel: clean }, null, 2)); + return clean; +} + +export function listArchives(dataDir) { + let names = []; + try { names = fs.readdirSync(dataDir).filter((n) => n.startsWith(ARCHIVE_PREFIX)); } catch { return []; } + return names.sort().reverse().map((name) => { + const dir = path.join(dataDir, name); + let meta = {}; + try { meta = JSON.parse(fs.readFileSync(path.join(dir, META), 'utf8')); } catch {} + return { + name, + archivedAt: meta.archivedAt || null, + indexerUrl: meta.indexerUrl || null, + label: meta.label || readLabel(dir), + users: meta.users || [], + hasData: fs.existsSync(path.join(dir, 's3d.db')), + }; + }); +} + +// Moves the current account's data aside. `meta` is stored with it for the +// restore list: { indexerUrl, users }. +export function archiveCurrent(dataDir, meta = {}) { + if (!fs.existsSync(path.join(dataDir, 's3d.db'))) throw new Error('there is no s3d data to move aside'); + let name = ARCHIVE_PREFIX + stamp(); + while (fs.existsSync(path.join(dataDir, name))) name += '-1'; + const dest = path.join(dataDir, name); + fs.mkdirSync(dest); + const label = readLabel(dataDir); + for (const entry of fs.readdirSync(dataDir)) { + if (keep(entry) || entry === name) continue; + fs.renameSync(path.join(dataDir, entry), path.join(dest, entry)); + } + fs.writeFileSync(path.join(dest, META), JSON.stringify({ archivedAt: new Date().toISOString(), label, ...meta }, null, 2)); + return { name, path: dest }; +} + +// Swaps an archived account back in. Whatever is current is archived first +// (when there is anything), so a restore never loses data either. +export function restoreArchive(dataDir, name, metaForCurrent = {}) { + if (!name.startsWith(ARCHIVE_PREFIX) || name.includes('/') || name.includes('\\') || name.includes('..')) throw new Error('not an account archive'); + const src = path.join(dataDir, name); + if (!fs.existsSync(path.join(src, 's3d.db'))) throw new Error('that archive has no s3d data'); + const archivedCurrent = fs.existsSync(path.join(dataDir, 's3d.db')) ? archiveCurrent(dataDir, metaForCurrent).name : null; + // Anything left (tmp folders from a fresh, unconnected s3d) is clutter. + for (const entry of fs.readdirSync(dataDir)) { + if (keep(entry)) continue; + fs.rmSync(path.join(dataDir, entry), { recursive: true, force: true }); + } + for (const entry of fs.readdirSync(src)) { + if (entry === META) continue; + fs.renameSync(path.join(src, entry), path.join(dataDir, entry)); + } + fs.rmSync(src, { recursive: true, force: true }); + return { restored: name, archivedCurrent }; +} diff --git a/bundled-addons/pithos/core/login.js b/bundled-addons/pithos/core/login.js index df10e4c1..e6ff849f 100644 --- a/bundled-addons/pithos/core/login.js +++ b/bundled-addons/pithos/core/login.js @@ -37,7 +37,9 @@ export class LoginSession extends EventEmitter { async start({ indexerUrl, phrase }) { if (this.term) throw new Error('a login is already in progress'); this.answers = { indexerUrl: indexerUrl || '', phrase: phrase || '' }; - this.info = {}; + // Kept in the state so "done" says which indexer this s3d now uses + // (a blank answer means s3d's default, sia.storage). + this.info = { indexerUrl: indexerUrl || 'https://sia.storage' }; this.set('preparing'); let pty = null; try { diff --git a/bundled-addons/pithos/core/server.js b/bundled-addons/pithos/core/server.js index 0ed58647..2ea42a34 100644 --- a/bundled-addons/pithos/core/server.js +++ b/bundled-addons/pithos/core/server.js @@ -21,6 +21,7 @@ import { LoginSession } from './login.js'; import { S3Client, S3Error, publicReadPolicy, readBody } from './s3.js'; import * as admin from './admin.js'; import { installS3d, S3D_VERSION, assetForPlatform } from './binary.js'; +import * as accounts from './accounts.js'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const UI_DIR = path.join(HERE, '..', 'ui'); @@ -82,7 +83,13 @@ export async function createPithos(opts = {}) { // recomputed when something that could change it happens. let regCache = null; async function registration() { - if (!regCache && daemon.binary) regCache = await cli.registration().catch(() => null); + // A "not connected" answer is re-checked after 30 s: the connection can + // be made outside Pithos (s3d login by hand, another Pithos). + if (regCache && !regCache.registered && Date.now() - (regCache.at || 0) > 30_000) regCache = null; + if (!regCache && daemon.binary) { + const r = await cli.registration().catch(() => null); + regCache = r ? { ...r, at: Date.now() } : null; + } return regCache; } @@ -211,6 +218,52 @@ export async function createPithos(opts = {}) { route('DELETE', '/api/keys/:id', async (req, url, p) => { await cli.deleteKey(p.id); return { ok: true }; }); // Upload pipeline + // Sia account: which one this s3d uses, a user-set label (Pithos cannot + // see the account's email), switching to another and restoring an old one. + route('GET', '/api/account', async () => { + const c = cfg(); + const reg = await registration(); + return { + indexerUrl: reg?.indexerUrl || null, + registered: !!reg?.registered, + label: accounts.readLabel(c.directory), + dataDir: c.directory, + archives: accounts.listArchives(c.directory), + }; + }); + route('PUT', '/api/account/label', async (req) => { + const { label } = await jsonBody(req); + return { label: accounts.writeLabel(cfg().directory, label) }; + }); + async function accountGuard() { + if (await probeExternal(cfg())) throw new HttpError(409, 'another s3d process is serving this config; stop it first'); + if (login.state !== 'idle' && !['done', 'already', 'failed'].includes(login.state)) throw new HttpError(409, 'a Sia login is in progress'); + } + async function currentMeta() { + const reg = await registration().catch(() => null); + const users = await cli.listUsers().catch(() => []); + return { indexerUrl: reg?.indexerUrl || null, users }; + } + route('POST', '/api/account/switch', async () => { + await accountGuard(); + const meta = await currentMeta(); + await daemon.stop(); + const r = accounts.archiveCurrent(cfg().directory, meta); + regCache = null; + login.cancel(); + return { archived: r.name, path: r.path }; + }); + route('POST', '/api/account/restore', async (req) => { + const { name } = await jsonBody(req); + await accountGuard(); + const meta = await currentMeta(); + await daemon.stop(); + const r = accounts.restoreArchive(cfg().directory, String(name || ''), meta); + regCache = null; + login.cancel(); + return r; + }); + route('GET', '/api/stats', () => admin.uploadStats(cfg())); route('POST', '/api/flush', async () => { await admin.flush(cfg()); return { ok: true }; }); diff --git a/bundled-addons/pithos/ui/app.css b/bundled-addons/pithos/ui/app.css index b182af8c..e8a63242 100644 --- a/bundled-addons/pithos/ui/app.css +++ b/bundled-addons/pithos/ui/app.css @@ -226,3 +226,6 @@ html, body { overflow-x: hidden; } .wizard-progress .bar { margin-top: 6px; } .wizard-nav { margin-top: 22px; padding-top: 14px; border-top: 1px solid var(--border); } .wizard-list { margin: 0 0 8px 1.2em; padding: 0; display: grid; gap: 4px; } + +.account-line { margin-top: 4px; } +.account-line a { color: var(--accent); } diff --git a/bundled-addons/pithos/ui/app.js b/bundled-addons/pithos/ui/app.js index b9be62b7..53cca242 100644 --- a/bundled-addons/pithos/ui/app.js +++ b/bundled-addons/pithos/ui/app.js @@ -225,6 +225,7 @@ function overview(main) { 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' }, + h('dt', {}, 'Sia account'), h('dd', {}, h('strong', {}, accountName(accountInfo)), ' · ', h('a', { href: '#/setup' }, accountInfo && accountInfo.registered ? 'Manage' : 'Connect')), 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')), @@ -282,6 +283,7 @@ function overview(main) { draw(); drawStats(); + loadAccount().then(draw); cleanup.push(onState(draw)); let lastState = daemonState(); cleanup.push(onState(() => { if (daemonState() !== lastState) { lastState = daemonState(); drawStats(); } })); @@ -639,13 +641,19 @@ function setup(main) { 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') { + const label = h('input', { type: 'text', placeholder: 'e.g. the email you sign in with', value: accountInfo?.label || '', maxlength: 120 }); put(body, steps, - h('h2', {}, l.state === 'done' ? 'Connected' : 'Already connected'), - h('p', {}, l.indexerUrl ? ['This s3d is registered with ', h('code', {}, l.indexerUrl), '.'] : 'This s3d is registered with an indexer.'), - h('div', { class: 'row' }, + h('h2', {}, l.state === 'done' ? 'Connected' : 'Connected to Sia'), + h('p', {}, l.indexerUrl ? ['This s3d uses a Sia account on ', h('code', {}, l.indexerUrl), '. All S3 users, buckets and files on it are stored, and paid for, through that account.'] : 'This s3d is registered with an indexer.'), + h('label', { class: 'field', style: 'max-width:420px' }, h('span', {}, 'Account label'), + h('div', { class: 'row', style: 'flex-wrap:nowrap' }, label, + h('button', { class: 'btn', onclick: async () => { try { await api('PUT', '/api/account/label', { label: label.value }); await loadAccount(); toast('Label saved'); } catch (e) { fail(e); } } }, 'Save')), + h('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' }, daemonState() !== 'running' && h('button', { class: 'btn primary', onclick: async () => { await daemonAction(daemonState() === 'crashed' ? 'restart' : 'start'); location.hash = '#/overview'; } }, 'Start s3d'), - h('a', { class: 'btn', href: '#/users' }, 'Create a user'), - h('button', { class: 'btn', onclick: () => api('POST', '/api/login/cancel').catch(fail) }, 'Done'))); + h('a', { class: 'btn', href: '#/users' }, 'Users & keys'), + h('button', { class: 'btn danger', onclick: switchAccount }, 'Switch Sia account…')), + archivesBlock()); return; } @@ -704,7 +712,61 @@ function setup(main) { 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.')), - h('div', { class: 'row' }, h('button', { class: 'btn primary', type: 'submit' }, 'Connect')))); + h('div', { class: 'row' }, h('button', { class: 'btn primary', type: 'submit' }, 'Connect'))), + archivesBlock()); + } + + // Earlier Sia accounts set aside by "Switch Sia account", restorable. + function archivesBlock() { + const list = accountInfo?.archives || []; + if (!list.length) return null; + return h('div', { style: 'margin-top:22px' }, + h('h3', {}, 'Earlier Sia accounts on this computer'), + h('p', { class: 'small muted', style: 'margin-top:0' }, 'Set aside by "Switch Sia account". Restoring one puts its users, keys and buckets back (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: () => restoreAccount(a) }, 'Restore'))))))); + } + + 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; + const ok = await modal('Switch Sia account?', h('div', { class: 'stack' }, + h('p', { style: 'margin:0' }, `s3d is connected to ${accountName(accountInfo)}. Switching connects it to a different Sia account.`), + h('ul', { class: 'small', style: 'margin:0;padding-left:1.2em' }, + h('li', {}, `The current account keeps its files. Its S3 users${users.length ? ` (${users.join(', ')})` : ''}, keys and buckets are set aside on this computer, not deleted, and can be restored here.`), + h('li', {}, 'The new account starts empty: you create users and keys again, and S3 apps need the new keys.'), + h('li', {}, 's3d stops now; the guide then connects the other account.')), + 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'))), + [{ label: 'Cancel', result: false }, { label: 'Switch account', kind: 'primary', submit: true, result: true }]); + 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 }); + location.hash = '#/start'; + } catch (e) { fail(e); } + } + + async function restoreAccount(a) { + const name = a.label || (a.indexerUrl ? new URL(a.indexerUrl).host : a.name); + const ok = await confirmModal(`Restore ${name}?`, 's3d stops and switches back to that Sia account with its users, keys and buckets. The current account is set aside and can be restored the same way.', 'Restore', 'primary'); + if (!ok) return; + try { + await api('POST', '/api/account/restore', { name: a.name }); + await api('POST', '/api/daemon/start').catch(() => {}); + toast(`Restored ${name}`); + await refreshStatus(); + await loadAccount(); + draw(); + } catch (e) { fail(e); } } async function start(e) { @@ -720,6 +782,7 @@ function setup(main) { async function cancel() { try { await api('POST', '/api/login/cancel'); } catch (e) { fail(e); } } draw(); + loadAccount().then(draw); cleanup.push(onState(draw)); } @@ -1003,12 +1066,31 @@ function buckets(main, [bucket, ...prefixParts]) { } function userPicker(userSel) { - return h('label', { class: 'row small' }, h('span', { class: 'muted' }, 'Acting as'), h('span', { style: 'width:180px' }, userSel)); + 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, + ' · ', h('a', { href: '#/setup' }, a && a.registered ? 'Manage' : 'Connect'))); + return el; } async function bucketList(head, body, user, userSel) { put(head, - h('div', {}, h('h1', {}, 'Buckets'), h('p', { class: 'muted' }, 'Buckets owned by the selected user.')), + h('div', {}, h('h1', {}, 'Buckets'), h('p', { class: 'muted' }, 'Top-level storage containers, like drives. Each belongs to one S3 user.'), accountLine()), h('div', { class: 'row' }, userPicker(userSel), h('button', { class: 'btn primary', onclick: create }, 'New bucket'))); const card = h('div', { class: 'card' }, h('p', { class: 'muted' }, 'Loading…')); put(body, card); @@ -1087,7 +1169,7 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) { const fileInput = h('input', { type: 'file', multiple: true, hidden: true, onchange: (e) => { uploadFiles([...e.target.files]); e.target.value = ''; } }); put(head, - h('div', { class: 'stack' }, crumbs), + h('div', { class: 'stack' }, crumbs, accountLine()), h('div', { class: 'row' }, userPicker(userSel), h('button', { class: 'btn', onclick: newFolder }, 'New folder'), h('button', { class: 'btn', onclick: () => accessDialog(user, bucket) }, 'Access'), From 71f0c96e935f387971f6554ce50bf7335d0ff229 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sat, 3 Oct 2026 23:01:56 +0200 Subject: [PATCH 3/4] Theseus: reopen the previous tabs even when history is cleared on quit "Open previous windows and tabs" and "Clear history on quit" both default to on, and the quit clear deleted session.json along with the history, so every launch started from the start page and the restore setting did nothing. The open tabs are what the user asked to reopen, not history: while restore is on, the quit clear keeps them and still drops back/forward and address-bar history. With restore off, the tab list is deleted as before. --- main.js | 21 ++++++++++++++------- settings.html | 4 ++-- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/main.js b/main.js index d58cb01e..525134e7 100644 --- a/main.js +++ b/main.js @@ -489,7 +489,7 @@ const SETTINGS_DEFAULTS = { // means a session leaves no trace on disk unless the user opts in per-type. clearCookiesOnQuit: true, // drop cookies + logins + saved-form data clearCacheOnQuit: true, // drop HTTP cache (images, scripts, etc.) - clearHistoryOnQuit: true, // drop navigation history + saved tabs + clearHistoryOnQuit: true, // drop navigation history (+ saved tabs unless restoreSession) clearStorageOnQuit: true, // drop localStorage / IndexedDB / service workers / cache API // Anti-fingerprinting — each: show (real) | hide (neutral) | spoof (auto decoy) | manual (user value) timezoneMode: "show", timezoneValue: "Europe/Berlin", // IANA zone for manual @@ -1134,11 +1134,14 @@ async function clearBrowsingData({ cookies = false, cache = false, storage = fal // navigation history lives in each webContents; drop it too when history-clear was asked. // (called separately by the before-quit hook, since history-clear also deletes session.json) } -async function clearHistoryNow() { +// keepSession: leave session.json (the open tabs) in place. The quit clear +// passes it while "Open previous windows and tabs" is on — both default to on, +// and deleting the tab list made the restore setting a no-op for everyone. +async function clearHistoryNow({ keepSession = false } = {}) { for (const t of tabs) { try { t.view.webContents.navigationHistory.clear(); } catch {} } - try { fs.unlinkSync(sessionFile()); } catch {} + if (!keepSession) { try { fs.unlinkSync(sessionFile()); } catch {} } // Address-bar suggestions history — wipe both in-memory + on-disk. history = []; clearTimeout(historySaveTimer); historySaveTimer = null; @@ -8144,9 +8147,9 @@ if (!process.env.THESEUS_NO_AUTOSTART && !app.requestSingleInstanceLock()) { if (quitClearing) return; quitClearing = true; flushAddonStores(); // add-on stores write coalesced; land them now - // Auto-clear per user settings. saveSession() runs first so restoreSession - // still works UNLESS the user asked to drop history — in which case we - // wipe the session file too so the next launch is genuinely blank. + // Auto-clear per user settings. saveSession() runs first; clearing + // history keeps that tab list while restoreSession is on, and deletes it + // otherwise so the next launch is genuinely blank. saveSession(); stopTor(); stopBnsPolling(); // silence the background delta refresh before exit @@ -8158,7 +8161,11 @@ if (!process.env.THESEUS_NO_AUTOSTART && !app.requestSingleInstanceLock()) { storage: settings.clearStorageOnQuit, }); // Session file AND the address-bar history (history.json). - if (settings.clearHistoryOnQuit) { await clearHistoryNow(); sessionDroppedForQuit = true; } + if (settings.clearHistoryOnQuit) { + const keepSession = !!settings.restoreSession; + await clearHistoryNow({ keepSession }); + if (!keepSession) sessionDroppedForQuit = true; + } })().catch((err) => console.error("before-quit clear failed:", err?.message)); Promise.race([clear, new Promise((r) => setTimeout(r, 5000))]) .finally(() => { quitCleared = true; app.quit(); }); diff --git a/settings.html b/settings.html index 7489ec2f..87278f15 100644 --- a/settings.html +++ b/settings.html @@ -273,7 +273,7 @@

Changes apply immediately and are saved for next time.

Startup

-
Open previous windows and tabs
Restore the tabs from your last session when Theseus starts.
+
Open previous windows and tabs
Restore the tabs from your last session when Theseus starts, even when history is cleared on quit.
@@ -812,7 +812,7 @@
-
Clear history on quit
Drops navigation history + the saved-tabs session file (overrides "Reopen previous tabs").
+
Clear history on quit
Drops back/forward history and address-bar history. With "Open previous windows and tabs" on, the tabs themselves still reopen.
From 718550935560fcf51f6149621477ad980092befb Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sat, 3 Oct 2026 23:02:25 +0200 Subject: [PATCH 4/4] Theseus 0.3.75: previous tabs reopen, extensions start on first use Ships 2437506 (the restore setting was a no-op with the default quit clear), the first-use loader for extensions with Aegis 0.29.0 bundled (~0.7 s less main-thread work at launch, ETH/SOL bridge fixed), the language picker as an overlay, Pithos 0.3.4 and the Ariadne status row. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5aae25df..83c3ec05 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "theseus-navigator", - "version": "0.3.74", + "version": "0.3.75", "license": "MPL-2.0", "description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.", "author": "Silent Mode",