Merge branch 'master' into claude/zen-archimedes-463527

This commit is contained in:
Local Dev 2026-10-03 23:02:46 +02:00
commit 8b74f5cbf9
9 changed files with 271 additions and 33 deletions

View file

@ -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=",

View file

@ -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-<stamp>/ 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 };
}

View file

@ -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 {

View file

@ -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 }; });

View file

@ -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); }

View file

@ -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'),

21
main.js
View file

@ -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(); });

View file

@ -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",

View file

@ -273,7 +273,7 @@
<p class="lede">Changes apply immediately and are saved for next time.</p>
<h2 class="sub" style="border-top:0;padding-top:0;margin-top:0">Startup</h2>
<div class="row">
<div class="txt"><div class="t">Open previous windows and tabs</div><div class="d">Restore the tabs from your last session when Theseus starts.</div></div>
<div class="txt"><div class="t">Open previous windows and tabs</div><div class="d">Restore the tabs from your last session when Theseus starts, even when history is cleared on quit.</div></div>
<label class="sw"><input type="checkbox" id="restoreSession"><span class="track"><span class="knob"></span></span></label>
</div>
<div class="row" style="flex-direction:column;align-items:stretch;gap:8px">
@ -440,26 +440,26 @@
<p class="lede">A local daemon that resolves BCDN names for <b>every browser on this machine</b> (Chrome, Edge, Firefox, etc.), not just Theseus. Turning it off means non-Theseus browsers stop resolving <code>.bch</code> / <code>.x</code> / other BCNR TLDs; Theseus keeps working either way, since it has its own built-in resolver.</p>
<h2 class="sub" style="border-top:0;padding-top:0;margin-top:0">Status</h2>
<div class="row">
<div class="row plug-row">
<div class="txt">
<div class="t">System-wide resolver</div>
<div class="d" id="ariadneStatusSub">checking…</div>
</div>
<label class="sw" title="Turn Ariadne on/off">
<input type="checkbox" id="ariadneToggleSub">
<span class="track"><span class="knob"></span></span>
</label>
<div class="plug-ctl">
<button id="ariadneInstallSub" class="btn small" hidden>Install</button>
<button id="ariadneUpdateSub" class="btn small" hidden>Update</button>
<button id="ariadneRefreshSub" class="btn small" title="Refresh Ariadne status">Refresh</button>
<label class="sw" title="Turn Ariadne on/off">
<input type="checkbox" id="ariadneToggleSub">
<span class="track"><span class="knob"></span></span>
</label>
</div>
</div>
<div id="ariadneMissing" class="pmuted" style="font-size:12.5px;margin:-4px 4px 8px" hidden>
Ariadne's Thread isn't installed on this machine. Click <b>Install</b> below to run
Ariadne's Thread isn't installed on this machine. Click <b>Install</b> above to run
the bundled installer, or grab it manually from
<a href="https://silentmode.st/tools/" target="_blank" rel="noopener">silentmode.st/tools</a>.
</div>
<div class="row" style="justify-content:flex-end;gap:8px">
<button id="ariadneInstallSub" class="btn" hidden>Install</button>
<button id="ariadneUpdateSub" class="btn" hidden>Update</button>
<button id="ariadneRefreshSub" class="btn">Refresh</button>
</div>
<h2 class="sub">Collision policy</h2>
<p class="subd">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.</p>
@ -812,7 +812,7 @@
<label class="sw"><input type="checkbox" id="clearStorageOnQuit"><span class="track"><span class="knob"></span></span></label>
</div>
<div class="row">
<div class="txt"><div class="t">Clear history on quit</div><div class="d">Drops navigation history + the saved-tabs session file (overrides "Reopen previous tabs").</div></div>
<div class="txt"><div class="t">Clear history on quit</div><div class="d">Drops back/forward history and address-bar history. With "Open previous windows and tabs" on, the tabs themselves still reopen.</div></div>
<label class="sw"><input type="checkbox" id="clearHistoryOnQuit"><span class="track"><span class="knob"></span></span></label>
</div>
<div class="row" style="justify-content:flex-end">