2026-09-08 01:42:41 +02:00
<!doctype html>
< html lang = "en" >
< head > < meta charset = "utf-8" > < title > Theseus — Settings< / title >
< style >
2026-09-08 01:51:03 +02:00
/* --acid-text: color used for text/inline emphasis. In dark mode it
equals --acid (bright acid green, ~10:1 on the dark ground). Light
mode overrides to #253A49 (BCH dark navy) which is ~12:1 on white,
because the BCH-teal --acid (#0AC18E) only manages ~2.9:1 as text.
--acid still drives fills, borders, and background tints where a
brand-green splash is wanted. */
2026-09-08 01:42:41 +02:00
:root{ --bg:#0b0e14; --panel:#141a24; --line:rgba(255,255,255,.09);
2026-09-08 01:51:03 +02:00
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d; --acid-text:#d6ff3d; }
2026-09-08 01:42:41 +02:00
*{box-sizing:border-box}
body{margin:0;height:100vh;background:var(--bg);color:var(--ink);font:15px/1.6 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
.app{display:flex;height:100vh}
/* left sidebar menu */
.side{flex:none;width:220px;background:#0e131c;border-right:1px solid var(--line);padding:20px 12px;display:flex;flex-direction:column;gap:2px}
2026-09-08 01:51:03 +02:00
.brand{font-weight:700;color:var(--acid-text);font-size:15px;padding:4px 12px 16px}
2026-09-08 01:42:41 +02:00
.side a{display:block;padding:9px 12px;border-radius:8px;color:var(--mut);text-decoration:none;font-size:14px;cursor:pointer}
.side a:hover{background:#ffffff0a;color:var(--ink)}
2026-09-08 01:51:03 +02:00
.side a.active{background:rgb(from var(--acid) r g b / .12);color:var(--acid-text)}
2026-09-08 01:42:41 +02:00
/* content */
.content{flex:1;overflow-y:auto;padding:2.4rem 2.4rem 4rem}
section{max-width:640px}
h1{font-size:1.4rem;margin:0 0 .2rem}
.lede{color:var(--mut);margin:0 0 1.8rem;font-size:13.5px}
h2.sub{font-size:11.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--dim);margin:1.8rem 0 .2rem;border-top:1px solid var(--line);padding-top:1.4rem}
.subd{color:var(--mut);margin:0 0 1rem;font-size:13px}
.row{display:flex;align-items:center;gap:16px;background:var(--panel);border:1px solid var(--line);
border-radius:12px;padding:14px 18px;margin-bottom:10px}
.row .txt{flex:1}
.row .t{font-weight:600}
.row .d{color:var(--mut);font-size:13px;margin-top:2px}
2026-09-27 20:37:30 +02:00
.row .acts{display:flex;gap:8px;align-items:center;margin-top:10px;flex-wrap:wrap}
.btn.small{padding:5px 10px;font-size:12px}
.btn.ghost{background:transparent;border-color:var(--line)}
2026-09-27 22:01:28 +02:00
.crumb{font-size:13px;color:var(--mut);margin:0 0 .6rem}.crumb a{color:var(--acid-text);cursor:pointer;text-decoration:none}.crumb a:hover{text-decoration:underline}
h1 .back{border:none;background:transparent;color:var(--mut);font:inherit;font-size:1.3rem;line-height:1;cursor:pointer;padding:0 8px 0 0;vertical-align:-1px}h1 .back:hover{color:var(--ink)}
.status{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px 18px;margin-bottom:10px}
.status .h{font-weight:600;margin-bottom:8px}
.srow{display:flex;gap:10px;align-items:center;font-size:13.5px;color:var(--ink);padding:3px 0}
.srow::before{content:"";width:9px;height:9px;border-radius:50%;background:var(--dim);flex:none}
.srow.ok::before{background:#3ddc84}.srow.warn::before{background:#f5a524}
.row.link{cursor:pointer}.row.link:hover{border-color:rgb(from var(--acid) r g b / .4)}.row .chev{color:var(--dim);font-size:20px;line-height:1}
2026-09-30 02:16:11 +02:00
/* Plug-in main-page row: the .txt area is a click target that navigates to
the plug-in's own settings sub-page (chevron on the title); .plug-ctl on
the right hosts the update button + on/off toggle, and stops the click
from bubbling so they don't navigate. */
.plug-row .plug-open{cursor:pointer;border-radius:8px;padding:2px 6px;margin:-2px -6px;transition:background .12s}
.plug-row .plug-open:hover{background:rgba(255,255,255,.04)}
.plug-row .plug-open:focus-visible{outline:2px solid rgb(from var(--acid) r g b / .5);outline-offset:2px}
.plug-row .plug-open .chev{color:var(--dim);font-size:15px;line-height:1;margin-left:4px;opacity:.6}
.plug-row .plug-open:hover .chev{opacity:1;color:var(--acid-text)}
.plug-row .plug-ctl{display:flex;align-items:center;gap:8px;flex:none;flex-wrap:wrap;justify-content:flex-end;max-width:60%}
2026-09-27 22:01:28 +02:00
.exlist{display:flex;flex-direction:column;gap:6px;margin-bottom:14px}
.exrow{display:flex;align-items:center;justify-content:space-between;gap:12px;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:9px 14px;font:13px ui-monospace,"Cascadia Code",Consolas,monospace}
.exempty{color:var(--dim);font-size:13px;margin:0 0 14px}
2026-09-08 01:42:41 +02:00
/* control column — mode select + optional value field on the same row so the
dropdown menus don't get cut off underneath, wraps only when narrow */
.ctl{display:flex;flex-direction:row;flex-wrap:wrap;gap:6px;align-items:center;justify-content:flex-end;flex:none;max-width:60%}
.ctl .coords{display:flex;gap:6px}
select,.ctl input{background:#1b2330;color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:13px;outline:none;min-width:140px}
select:focus,.ctl input:focus{border-color:#4b7bec}
.ctl input{width:170px} .ctl .coords input{width:92px;min-width:auto}
/* Chromium's native < select > popup uses the page's color-scheme; when it's
"light dark" (both accepted) Chromium picks by the OS, so a dark-theme app
on a light OS shows a light popup. main.js's applyTheme() maps to
nativeTheme.themeSource, which drives prefers-color-scheme — so pinning
color-scheme via that media query keeps the popup in sync automatically. */
:root { color-scheme: dark; }
2026-09-08 01:51:03 +02:00
@media (prefers-color-scheme: light) { :root { color-scheme: light; --acid: #0AC18E; --acid-text: #253A49; } }
2026-09-08 01:42:41 +02:00
/* Explicit option styling — Chromium respects it in the popup on Windows. */
select option { background: #1b2330; color: var(--ink); }
@media (prefers-color-scheme: light) { select option { background: #f1f3f7; color: #1a1f28; } }
code{font-family:ui-monospace,monospace;font-size:12px;background:#0e131b;border:1px solid var(--line);border-radius:5px;padding:1px 5px;color:#bfeae4}
.addeng{display:flex;gap:6px} .addeng input{flex:1;background:#1b2330;color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:8px 10px;font-size:13px;outline:none}
.addeng input:focus{border-color:#4b7bec}
2026-09-08 01:51:03 +02:00
.btn{border:1px solid color-mix(in srgb, var(--acid) 33%, transparent);background:rgb(from var(--acid) r g b / .12);color:var(--acid-text);border-radius:8px;padding:8px 14px;font-size:13px;cursor:pointer}
2026-09-08 01:42:41 +02:00
.btn:hover{background:rgb(from var(--acid) r g b / .22)}
/* segmented control — small toggle used elsewhere (kept for reuse) */
.segseg{display:inline-flex;background:#10151f;border:1px solid var(--line);border-radius:8px;padding:2px;gap:2px}
.segseg .seg{background:transparent;color:var(--mut);border:none;border-radius:6px;padding:6px 12px;font-size:13px;cursor:pointer;font:inherit;line-height:1.2}
.segseg .seg:hover{color:var(--ink)}
.segseg .seg.on{background:#1c2432;color:var(--ink);box-shadow:inset 0 0 0 1px var(--line)}
/* theme cards — three visual previews (System / Light / Dark) */
.themeCards{display:flex;gap:14px;margin:6px 0 4px;flex-wrap:wrap}
.themeCards .tc{background:transparent;border:2px solid var(--line);border-radius:10px;padding:10px;
display:flex;flex-direction:column;align-items:center;gap:8px;cursor:pointer;color:var(--ink);
font:inherit;font-size:13px;min-width:150px;transition:border-color .12s}
.themeCards .tc:hover{border-color:#4b7bec55}
.themeCards .tc.on{border-color:#4b7bec;box-shadow:0 0 0 1px #4b7bec inset}
.themeCards .mock{width:130px;height:82px;border-radius:6px;overflow:hidden;display:block;position:relative;
border:1px solid rgba(255,255,255,.08)}
.themeCards .mock .mm-chrome{position:absolute;left:0;right:0;top:0;height:22px;display:block}
.themeCards .mock .mm-chrome::before{content:"";position:absolute;left:8px;top:6px;width:8px;height:8px;border-radius:50%;background:#f6768a}
.themeCards .mock .mm-chrome::after {content:"";position:absolute;left:22px;top:6px;width:8px;height:8px;border-radius:50%;background:#f6c15c;box-shadow:14px 0 0 #6ec27d}
.themeCards .mock .mm-body{position:absolute;left:0;right:0;top:22px;bottom:0;display:block}
.themeCards .mock-light .mm-chrome{background:#e9ecf2}
.themeCards .mock-light .mm-body {background:#ffffff;background-image:linear-gradient(#0000000c 1px,transparent 1px);background-size:100% 12px;background-position:0 10px}
.themeCards .mock-dark .mm-chrome{background:#141b28}
.themeCards .mock-dark .mm-body {background:#0b0e14;background-image:linear-gradient(#ffffff10 1px,transparent 1px);background-size:100% 12px;background-position:0 10px}
.themeCards .mock-system .mm-chrome{background:linear-gradient(90deg,#141b28 0 50%,#e9ecf2 50% 100%)}
.themeCards .mock-system .mm-body{background:linear-gradient(90deg,#0b0e14 0 50%,#ffffff 50% 100%)}
.themeCards .tc-label{font-weight:500;color:var(--ink)}
.polrow{display:flex;align-items:center;gap:10px;background:#10151f;border:1px solid var(--line);border-radius:8px;padding:8px 12px;font-size:13px;cursor:pointer}
.polrow:hover{background:#141c28}
.polrow input{accent-color:var(--acid)}
feat(theseus/ariadne): settings panel — policy + per-source toggles + status report
Ariadne 0.1.13 exposed /api/status and per-source enable flags in
policy.json. Theseus's Plug-ins > Ariadne's Thread sub-page now wires those
into a full UI, no daemon restart, no UAC.
Added to the plugins-ariadne sub-page (after Status, before Remove):
Collision policy -- radio group (BCNR-first / ICANN-first) writes
C:\ProgramData\Ariadne\policy.json.policy; hot-reloaded
by the daemon within 5 s.
Sources -- 3-column grid, one row per source (snapshotHttps,
electrumWss, perQueryLookup, diskCache, localApi):
enable checkbox + last-state summary
(last success / last error / hit-miss counters /
disk-cache size+mtime). Toggle writes
policy.json.sources.<name>.enabled and re-polls after
the 5-s hot-reload tick so the state text catches up.
Status report -- <pre> JSON dump of GET http://127.0.0.1/api/status
with Copy report + Refresh report buttons. This is
the paste-me-into-support artefact for any diagnosis.
IPC wiring:
main.js
ariadne-get-status -> GET http://127.0.0.1/api/status ({ok, status|error})
ariadne-get-policy -> read C:\ProgramData\Ariadne\policy.json (or {})
ariadne-set-policy -> merge {policy}, write back (validates enum)
ariadne-set-source -> merge {sources.<name>.enabled}, write back
(validates against the known 5 names)
settings-preload.js
ariadneGetStatus, ariadneGetPolicy, ariadneSetPolicy, ariadneSetSource
All four handlers write policy.json as the local user; no UAC. Works because
install.ps1 grants BUILTIN\Users Modify on the file (0.1.7+).
Sub-page auto-refreshes state every time it opens (listens on the existing
'section' custom event dispatched by showSection).
Not building/shipping Theseus here -- this rides the next Theseus release.
Panel gracefully handles: daemon down (shows 'Daemon unreachable' with a
pointer to the Status toggle), localApi disabled (daemon returns 503, panel
shows the error), missing policy.json (all sources default to true).
2026-10-01 00:51:50 +02:00
/* Ariadne sources table -- 3-col grid instead of < table > so it inherits our row styling and reflows nicely. */
.ariadne-sources{display:flex;flex-direction:column;gap:1px;background:var(--line);border:1px solid var(--line);border-radius:8px;overflow:hidden;font-size:13px}
.ariadne-sources .asrc-head, .ariadne-sources .asrc-row{display:grid;grid-template-columns:1fr 70px minmax(180px,2fr);align-items:center;gap:12px;padding:8px 12px;background:#10151f}
.ariadne-sources .asrc-head{font-weight:600;color:var(--muted);background:#0c1119;font-size:12px;text-transform:uppercase;letter-spacing:.4px}
.ariadne-sources .asrc-row .aname{color:var(--ink);font-weight:500}
.ariadne-sources .asrc-row .aname small{display:block;color:var(--muted);font-weight:400;margin-top:2px;font-size:11.5px}
.ariadne-sources .asrc-row .astate{color:var(--muted);font-size:11.5px;font-family:ui-monospace,'SF Mono',Menlo,Consolas,monospace}
.ariadne-sources .asrc-row .astate.ok{color:var(--acid-text)}
.ariadne-sources .asrc-row .astate.err{color:#f6768a}
.ariadne-sources input[type=checkbox]{accent-color:var(--acid);width:18px;height:18px;cursor:pointer}
2026-09-08 01:42:41 +02:00
.pmuted{color:var(--mut)}
.ceng{display:flex;align-items:center;gap:8px;background:#10151f;border:1px solid var(--line);border-radius:8px;padding:6px 10px;margin-bottom:6px;font-size:13px}
.ceng .cs{font-size:14px;flex:none}
/* engine checklist */
.eng{display:flex;align-items:center;gap:10px;background:#10151f;border:1px solid var(--line);border-radius:8px;padding:6px 10px;margin-bottom:6px;font-size:13px}
.eng .eic{width:18px;height:18px;flex:none;display:grid;place-items:center}
.eng .eic .ei{width:16px;height:16px;border-radius:3px} .eng .eic .es{font-size:14px}
.eng .enm{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.eng .cx{cursor:pointer;color:var(--dim);border:none;background:transparent;font-size:13px} .eng .cx:hover{color:#f6768a}
.eng{cursor:default}
.eng .grip{flex:none;color:var(--dim);cursor:grab;font-size:14px;line-height:1;padding:0 2px;user-select:none}
.eng .grip:active{cursor:grabbing}
.eng.dragging{opacity:.45}
.eng.over{border-color:var(--acid);box-shadow:0 -2px 0 var(--acid) inset}
.eng.off{opacity:.55}
.eng.off .enm{color:var(--mut)}
fix(theseus): Presearch is frozen in the engine catalog
presearch.com has redirected every request, searches included, into a
dead host since 2026-09-28, so a user who picked it gets Cloudflare's
origin error instead of results. Rather than deleting the entry, a catalog
engine can now carry a frozen reason: it stays listed in Settings, greyed,
with the reason as its tooltip and an Unavailable badge where the switch
was; it is never enabled, never in the picker, never accepted as the
default from any path, and a profile that had it as default falls back at
startup. Turning it off still works, and deleting the field brings the
engine back exactly as the user had it.
2026-10-01 01:24:10 +02:00
.eng.frozen,.engcat .cat.frozen{opacity:.6}
.eng .kind,.engcat .cat .kind{font-size:10.5px;letter-spacing:.05em;color:var(--dim);padding:1px 6px;border:1px solid var(--line);border-radius:999px;white-space:nowrap}
.kind.warn{color:#f6768a;border-color:rgba(246,118,138,.45)}
2026-09-08 01:42:41 +02:00
.ehdr{font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--dim);margin:14px 0 6px;padding-top:2px}
.ehdr:first-child{margin-top:0}
2026-09-28 20:07:51 +02:00
/* default-engine dropdown: drawn by us so it can show the engine icons (a native < select > is text-only) */
.esel{display:flex;align-items:center;gap:8px;background:#1b2330;color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:6px 10px;font-size:13px;min-width:180px;cursor:pointer;user-select:none;outline:none}
.esel:focus-visible,.esel.open{border-color:#4b7bec}
.esel .eic,.eselmenu .eic{display:inline-flex;width:16px;height:16px;align-items:center;justify-content:center;flex:none}
.esel .eic .ei,.eselmenu .eic .ei{width:16px;height:16px;border-radius:3px} .esel .eic .es,.eselmenu .eic .es{font-size:14px;line-height:1}
.esel .enm{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.esel .caret{color:var(--mut);font-size:13px;line-height:1;flex:none}
.ctxmenu.eselmenu{min-width:200px;max-height:60vh;overflow-y:auto;padding:4px}
.ctxmenu.eselmenu .ehdr{margin:6px 8px 2px;padding:0} .ctxmenu.eselmenu .ehdr:first-child{margin-top:2px}
.ctxmenu.eselmenu .mi{display:flex;align-items:center;gap:8px;padding:6px 10px}
.ctxmenu.eselmenu .mi.cur{color:var(--acid-text)} .ctxmenu.eselmenu .mi.hl{background:#ffffff10}
.ctxmenu.eselmenu .mi .chk{margin-left:auto;font-size:12px}
@media (prefers-color-scheme: light){ .esel{background:#f1f5f8;color:#253A49} .ctxmenu.eselmenu .mi.hl{background:rgba(0,0,0,.05)} }
2026-09-08 01:42:41 +02:00
/* right-click context menu for an engine row */
.ctxmenu{position:fixed;z-index:9999;background:#1c222c;border:1px solid var(--line);border-radius:8px;
box-shadow:0 12px 34px #000c;padding:4px;min-width:180px;font-size:13px;color:var(--ink)}
.ctxmenu .mi{padding:7px 12px;border-radius:5px;cursor:pointer;white-space:nowrap}
.ctxmenu .mi:hover{background:#ffffff10}
.ctxmenu .mi.danger{color:#f6768a}
.ctxmenu .mi.danger:hover{background:rgba(246,118,138,.12)}
@media (prefers-color-scheme: light){
.ctxmenu{background:#ffffff;border-color:rgba(0,0,0,.15)}
.ctxmenu .mi:hover{background:rgba(0,0,0,.05)}
}
/* engine catalog panel — appears under the enabled list when "+ Add" is clicked */
.engcat{margin-top:6px;padding:12px 12px 10px;background:#0e131c;border:1px solid var(--line);border-radius:10px}
.engcat .cat{display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;font-size:13px}
.engcat .cat:hover{background:#141b26}
.engcat .cat .eic{width:18px;height:18px;flex:none;display:grid;place-items:center}
.engcat .cat .eic .ei{width:16px;height:16px;border-radius:3px} .engcat .cat .eic .es{font-size:14px}
.engcat .cat .enm{flex:1}
.engcat .cat .kind{font-size:10.5px;letter-spacing:.05em;color:var(--dim);padding:1px 6px;border:1px solid var(--line);border-radius:999px}
.engcat .cat .add{background:transparent;border:1px solid var(--line);color:var(--ink);border-radius:6px;padding:4px 10px;font-size:12px;cursor:pointer}
2026-09-08 01:51:03 +02:00
.engcat .cat .add:hover{background:rgb(from var(--acid) r g b / .14);border-color:color-mix(in srgb, var(--acid) 33%, transparent);color:var(--acid-text)}
2026-09-08 01:42:41 +02:00
.engcat .cempty2{color:var(--dim);font-size:12.5px;padding:6px 8px}
.sw.sm{width:38px;height:22px} .sw.sm .knob{width:15px;height:15px} .sw.sm input:checked + .track .knob{transform:translateX(16px)}
.ceng .cn{font-weight:600} .ceng .cu{color:var(--dim);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
.ceng .cx{cursor:pointer;color:#8b98a9;border:none;background:transparent;font-size:13px} .ceng .cx:hover{color:#f6768a}
.cempty{color:var(--dim);font-size:12.5px}
/* toggle */
.sw{position:relative;width:46px;height:26px;flex:none;cursor:pointer}
.sw input{opacity:0;width:0;height:0}
.track{position:absolute;inset:0;background:#2b3444;border:1px solid var(--line);border-radius:999px;transition:.15s}
.knob{position:absolute;top:3px;left:3px;width:18px;height:18px;border-radius:50%;background:#8b98a9;transition:.15s}
.sw input:checked + .track{background:rgb(from var(--acid) r g b / .25);border-color:color-mix(in srgb, var(--acid) 33%, transparent)}
feat(theseus/settings): Extensions as compact rows with a detail view
The Extensions list was a stack of tall cards — description, author,
capabilities and buttons on every one — so seven add-ons filled the
page before the user found the toggle. Rows are now one line each,
Firefox-style: icon, name, built-in badge, version, a short update
status, the on/off switch and a ⋯ menu, grouped Enabled / Disabled /
Failed to load. Clicking a row opens the detail view in place: back
arrow, description, update status, author, version, type, folder with
Show folder, and a Permissions block that explains each declared
capability in plain words. The ⋯ menu (and right-click) offers Turn
on/off, Details, Show folder and, for non-bundled extensions, Remove —
a new addons-remove IPC that deletes the folder under the extensions
directory, refuses bundled add-ons (they would only be reseeded), and
clears the dock prefs it left behind.
2026-09-22 20:43:50 +02:00
/* Extensions: compact one-line rows (Firefox about:addons style); click a
row for the detail view, controls on the right stay clickable in place. */
.xgrp{font-size:11.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--dim);margin:14px 0 6px}
.xrow{display:flex;align-items:center;gap:12px;background:var(--panel);border:1px solid var(--line);border-radius:10px;
padding:9px 12px;margin-bottom:6px;cursor:pointer;min-height:44px}
.xrow:hover{background:#18202c}
.xrow:focus-visible{outline:2px solid color-mix(in srgb, var(--acid) 50%, transparent);outline-offset:1px}
.xrow .xi{width:22px;height:22px;flex:none;display:inline-grid;place-items:center;font-size:17px;line-height:1}
.xrow .xi img,.xhead .xi img{width:100%;height:100%;object-fit:contain;display:block}
.xrow .xn{flex:1;min-width:0;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.xrow .xv,.xhead .xv{color:var(--dim);font-weight:400;font-size:12.5px;margin-left:6px}
.xrow .xs{color:var(--dim);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:38%}
.xrow .xs.warn{color:#f6768a} .xrow .xs.upd{color:var(--acid-text)}
.xrow .xctl,.xhead .xctl{display:flex;align-items:center;gap:6px;flex:none}
.xbadge{display:inline-block;font-size:10px;letter-spacing:.05em;padding:1px 6px;border-radius:3px;background:rgba(214,255,61,.14);color:var(--acid-text);font-weight:700;margin-left:6px;vertical-align:middle}
.xbadge.off{background:rgba(255,255,255,.06);color:var(--dim)}
.xmore{width:30px;height:30px;border-radius:7px;border:none;background:transparent;color:var(--mut);font-size:18px;line-height:1;cursor:pointer}
.xmore:hover{background:#ffffff14;color:var(--ink)}
.xhead{display:flex;align-items:flex-start;gap:12px;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 14px 14px 10px}
.xhead .xi{width:36px;height:36px;flex:none;display:inline-grid;place-items:center;font-size:26px;line-height:1}
.xhead .xtitle{flex:1;min-width:0} .xhead .xtitle .t{font-weight:700;font-size:15px} .xhead .xtitle .d{color:var(--mut);font-size:13px;margin-top:3px}
fix(theseus): extension updates re-check, announce and install in place
Updates published after launch never showed: the add-on channel was
checked once, 30 s after boot, and staged copies only applied on the
next launch with nothing telling the user. On 2026-09-22 Aegis 0.8.3
and VPN 0.1.3 landed minutes after the app's only check and stayed
invisible through manual scans made earlier and a restart made before
they were published.
Now: the check repeats every 4 hours; every check (boot, timer, manual,
add-on-driven) reports what is staged to the chrome, which shows a chip
for staged extensions; clicking it, or the "Update to vX" button that
appears on the extension's row and detail in Settings, promotes the
staged folder over the installed one and rebuilds the add-on host, so
the new version runs without a restart. Plug-ins (Aegis) are excluded
from the chip and the hot swap — a wallet updates from its own panel
and applies on the next launch.
Also from the same review: the new-tab button follows the last tab and
parks after the scroll arrow only when the strip overflows; Tor sits
left of the Aegis chip; plug-ins no longer appear in Settings ›
Extensions (they have Plug-ins); the extension detail view has a
labelled Back button, a close button and a Check now button; the
promotion helper returns what it promoted and accepts a filter.
2026-09-22 21:25:33 +02:00
.xback{height:32px;padding:0 12px 0 8px;border-radius:8px;border:1px solid var(--line);background:transparent;color:var(--ink);font:inherit;font-size:13px;font-weight:600;cursor:pointer;flex:none;margin-top:2px;white-space:nowrap}
.xupd{padding:5px 12px;font-size:12.5px;font-weight:600;background:var(--acid);color:#0b0e14;border-color:transparent} .xupd:hover{filter:brightness(1.08);background:var(--acid)}
feat(theseus/settings): Extensions as compact rows with a detail view
The Extensions list was a stack of tall cards — description, author,
capabilities and buttons on every one — so seven add-ons filled the
page before the user found the toggle. Rows are now one line each,
Firefox-style: icon, name, built-in badge, version, a short update
status, the on/off switch and a ⋯ menu, grouped Enabled / Disabled /
Failed to load. Clicking a row opens the detail view in place: back
arrow, description, update status, author, version, type, folder with
Show folder, and a Permissions block that explains each declared
capability in plain words. The ⋯ menu (and right-click) offers Turn
on/off, Details, Show folder and, for non-bundled extensions, Remove —
a new addons-remove IPC that deletes the folder under the extensions
directory, refuses bundled add-ons (they would only be reseeded), and
clears the dock prefs it left behind.
2026-09-22 20:43:50 +02:00
.xback:hover{background:#ffffff14}
.xdt{margin-top:10px;background:var(--panel);border:1px solid var(--line);border-radius:12px;overflow:hidden}
.xdt .xr{display:flex;align-items:center;gap:16px;padding:11px 14px;border-top:1px solid var(--line);font-size:13.5px}
.xdt .xr:first-child{border-top:none}
.xdt .k{flex:0 0 160px;color:var(--mut)} .xdt .v{flex:1;min-width:0;overflow-wrap:anywhere;color:var(--ink);display:flex;align-items:center;gap:10px;justify-content:space-between}
.xdt .v code{word-break:break-all}
.xdt .v .btn{padding:5px 10px;font-size:12px;flex:none}
.xperm{list-style:none;margin:0;padding:0} .xperm li{padding:9px 14px;border-top:1px solid var(--line);font-size:13.5px;display:flex;gap:10px}
.xperm li:first-child{border-top:none} .xperm li code{flex:0 0 150px;align-self:flex-start}
.xperm li span{color:var(--mut)}
.xdanger{border-color:rgba(246,118,138,.4);color:#f6768a;background:rgba(246,118,138,.08)} .xdanger:hover{background:rgba(246,118,138,.16)}
@media (prefers-color-scheme: light){ .xrow:hover{background:#eef2f6} .xmore:hover,.xback:hover{background:rgba(0,0,0,.06)} }
2026-09-08 01:42:41 +02:00
.sw input:checked + .track .knob{transform:translateX(20px);background:var(--acid)}
.note{color:var(--dim);font-size:12.5px;margin-top:1.4rem;border-top:1px solid var(--line);padding-top:1rem}
/* light theme (placed last so these win over the dark base rules) */
@media (prefers-color-scheme: light){
fix(theseus/settings/light): map every dark-navy card to a BCH-palette surface
Screenshot showed Registries > Collision policy rows rendering as
solid dark bars in light mode — .polrow hardcoded background:#10151f
with no light override, so the whole card blob-ed dark on the white
page and the text disappeared. Same story for .segseg, .engcat,
select/input backgrounds, .ctxmenu, .themeCards etc.
The light-media block gains a proper mapping keyed to the user's
BCH palette (#0AC18E / #253A49 / #F8FDFF):
- --bg → #F8FDFF (BCH white), --ink → #253A49 (BCH dark)
- .polrow → #f4f8fb card on white; hover #eaf0f5; SELECTED row
gets an acid-green tint via :has(input:checked) so BCDN-first
reads as the chosen option without an ugly dark bar
- .segseg, .engcat, .themeCards, .ctxmenu, select/input all get
the same treatment — subtle off-white surfaces on top of the
panel, dark-navy text
- Sidebar (.side) already had a light override; refined the border
and hover states to use rgba(37,58,73,X) so they match the ink
Verified via CDP: --bg=#F8FDFF, --ink=#253A49, .polrow color=
rgb(37,58,73), engcat bg=rgb(244,248,251), theme card bg=white.
2026-09-08 18:49:53 +02:00
:root{ --bg:#F8FDFF; --panel:#ffffff; --line:rgba(37,58,73,.15); --ink:#253A49; --mut:#4a5262; --dim:#7b8494; }
.side{ background:#f1f5f8; border-right-color:rgba(37,58,73,.10); }
.side a{ color:#4a5262; }
.side a:hover{ background:rgba(37,58,73,.06); color:#253A49; }
/* Every dark-hardcoded card / control needs a light equivalent. All the
#10151f / #0e131c / #1b2330 tones map to a subtle off-white so they
still read as cards against the #F8FDFF page, with rgba(37,58,73,X)
hover tints keyed to BCH dark for consistent depth. */
.polrow{ background:#f4f8fb; color:#253A49; }
.polrow:hover{ background:#eaf0f5; }
feat(theseus/ariadne): settings panel — policy + per-source toggles + status report
Ariadne 0.1.13 exposed /api/status and per-source enable flags in
policy.json. Theseus's Plug-ins > Ariadne's Thread sub-page now wires those
into a full UI, no daemon restart, no UAC.
Added to the plugins-ariadne sub-page (after Status, before Remove):
Collision policy -- radio group (BCNR-first / ICANN-first) writes
C:\ProgramData\Ariadne\policy.json.policy; hot-reloaded
by the daemon within 5 s.
Sources -- 3-column grid, one row per source (snapshotHttps,
electrumWss, perQueryLookup, diskCache, localApi):
enable checkbox + last-state summary
(last success / last error / hit-miss counters /
disk-cache size+mtime). Toggle writes
policy.json.sources.<name>.enabled and re-polls after
the 5-s hot-reload tick so the state text catches up.
Status report -- <pre> JSON dump of GET http://127.0.0.1/api/status
with Copy report + Refresh report buttons. This is
the paste-me-into-support artefact for any diagnosis.
IPC wiring:
main.js
ariadne-get-status -> GET http://127.0.0.1/api/status ({ok, status|error})
ariadne-get-policy -> read C:\ProgramData\Ariadne\policy.json (or {})
ariadne-set-policy -> merge {policy}, write back (validates enum)
ariadne-set-source -> merge {sources.<name>.enabled}, write back
(validates against the known 5 names)
settings-preload.js
ariadneGetStatus, ariadneGetPolicy, ariadneSetPolicy, ariadneSetSource
All four handlers write policy.json as the local user; no UAC. Works because
install.ps1 grants BUILTIN\Users Modify on the file (0.1.7+).
Sub-page auto-refreshes state every time it opens (listens on the existing
'section' custom event dispatched by showSection).
Not building/shipping Theseus here -- this rides the next Theseus release.
Panel gracefully handles: daemon down (shows 'Daemon unreachable' with a
pointer to the Status toggle), localApi disabled (daemon returns 503, panel
shows the error), missing policy.json (all sources default to true).
2026-10-01 00:51:50 +02:00
.ariadne-sources{background:#e6ecf1;border-color:#e6ecf1}
.ariadne-sources .asrc-head, .ariadne-sources .asrc-row{background:#f4f8fb;color:#253A49}
.ariadne-sources .asrc-head{background:#eaf0f5;color:#5a7080}
.ariadne-sources .asrc-row .aname{color:#253A49}
.ariadne-sources .asrc-row .aname small{color:#5a7080}
#ariadneStatusJson{background:#f4f8fb !important;color:#253A49}
fix(theseus/settings/light): map every dark-navy card to a BCH-palette surface
Screenshot showed Registries > Collision policy rows rendering as
solid dark bars in light mode — .polrow hardcoded background:#10151f
with no light override, so the whole card blob-ed dark on the white
page and the text disappeared. Same story for .segseg, .engcat,
select/input backgrounds, .ctxmenu, .themeCards etc.
The light-media block gains a proper mapping keyed to the user's
BCH palette (#0AC18E / #253A49 / #F8FDFF):
- --bg → #F8FDFF (BCH white), --ink → #253A49 (BCH dark)
- .polrow → #f4f8fb card on white; hover #eaf0f5; SELECTED row
gets an acid-green tint via :has(input:checked) so BCDN-first
reads as the chosen option without an ugly dark bar
- .segseg, .engcat, .themeCards, .ctxmenu, select/input all get
the same treatment — subtle off-white surfaces on top of the
panel, dark-navy text
- Sidebar (.side) already had a light override; refined the border
and hover states to use rgba(37,58,73,X) so they match the ink
Verified via CDP: --bg=#F8FDFF, --ink=#253A49, .polrow color=
rgb(37,58,73), engcat bg=rgb(244,248,251), theme card bg=white.
2026-09-08 18:49:53 +02:00
.polrow input:checked ~ span,
.polrow:has(input:checked){ background:rgb(from var(--acid) r g b / .10); border-color:color-mix(in srgb, var(--acid) 35%, transparent); }
.segseg{ background:#eef1f6; }
.segseg .seg.on{ background:#ffffff; color:#253A49; box-shadow:inset 0 0 0 1px rgba(37,58,73,.12); }
.engcat{ background:#f4f8fb; }
.engcat .cat:hover{ background:#eaf0f5; }
select, .ctl input, .addeng input{ background:#f1f5f8; color:#253A49; }
select option{ background:#ffffff; color:#253A49; }
.track{ background:#d3dae3; }
2026-09-08 01:42:41 +02:00
.knob{ background:#8a93a2; }
fix(theseus/settings/light): map every dark-navy card to a BCH-palette surface
Screenshot showed Registries > Collision policy rows rendering as
solid dark bars in light mode — .polrow hardcoded background:#10151f
with no light override, so the whole card blob-ed dark on the white
page and the text disappeared. Same story for .segseg, .engcat,
select/input backgrounds, .ctxmenu, .themeCards etc.
The light-media block gains a proper mapping keyed to the user's
BCH palette (#0AC18E / #253A49 / #F8FDFF):
- --bg → #F8FDFF (BCH white), --ink → #253A49 (BCH dark)
- .polrow → #f4f8fb card on white; hover #eaf0f5; SELECTED row
gets an acid-green tint via :has(input:checked) so BCDN-first
reads as the chosen option without an ugly dark bar
- .segseg, .engcat, .themeCards, .ctxmenu, select/input all get
the same treatment — subtle off-white surfaces on top of the
panel, dark-navy text
- Sidebar (.side) already had a light override; refined the border
and hover states to use rgba(37,58,73,X) so they match the ink
Verified via CDP: --bg=#F8FDFF, --ink=#253A49, .polrow color=
rgb(37,58,73), engcat bg=rgb(244,248,251), theme card bg=white.
2026-09-08 18:49:53 +02:00
code, .ceng, .eng{ background:#eef2f6; color:#253A49; }
.themeCards .tc{ background:#ffffff; border-color:rgba(37,58,73,.15); }
.themeCards .tc:hover{ border-color:rgba(37,58,73,.30); }
.ctxmenu{ background:#ffffff; border-color:rgba(37,58,73,.15); color:#253A49; }
.ctxmenu .mi:hover{ background:rgba(37,58,73,.06); }
2026-09-08 01:42:41 +02:00
}
< / style > < / head >
< body >
< div class = "app" >
< nav class = "side" >
< div class = "brand" > ⛓ Theseus< / div >
< a data-sec = "general" class = "active" > General< / a >
2026-10-03 19:05:25 +02:00
< a data-sec = "language" > Language< / a >
2026-09-08 01:42:41 +02:00
< a data-sec = "search" > Search< / a >
< a data-sec = "passwords" > Passwords< / a >
< a data-sec = "performance" > Performance< / a >
< a data-sec = "privacy" > Privacy< / a >
2026-09-08 23:00:41 +02:00
< a data-sec = "plugins" > Plug-ins< / a >
2026-09-08 01:42:41 +02:00
< a data-sec = "addons" > Extensions< / a >
< / nav >
< div class = "content" >
<!-- GENERAL -->
< section id = "general" >
< h1 > General< / h1 >
< p class = "lede" > Changes apply immediately and are saved for next time.< / p >
2026-10-03 19:05:25 +02:00
< h2 class = "sub" style = "border-top:0;padding-top:0;margin-top:0" > Startup< / h2 >
2026-09-08 01:42:41 +02:00
< div class = "row" >
2026-10-03 23:01:56 +02:00
< 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 >
2026-09-08 01:42:41 +02:00
< 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" >
< div class = "txt" >
< div class = "t" > Updates< / div >
2026-10-03 19:09:47 +02:00
< div class = "d" > Theseus checks the release manifest shortly after launch — and retries with backoff if that first attempt is offline — then every 6 hours while it is running. Click below to check right now.< / div >
2026-09-08 01:42:41 +02:00
< / div >
2026-09-08 12:36:21 +02:00
< div id = "updStatus" class = "pmuted" style = "font-size:12.5px" > Loading…< / div >
2026-09-08 01:42:41 +02:00
< div style = "display:flex;gap:8px;flex-wrap:wrap" >
< button id = "updCheck" class = "btn" > Check for updates< / button >
< / div >
< / div >
2026-10-02 23:49:39 +02:00
< h2 class = "sub" > Quick links< / h2 >
< p class = "subd" > Opera-style strip on the left edge with shortcuts to web apps. Click a row's URL to edit. Shows the first letter of the title when there's no icon.< / p >
< div class = "row" >
< div class = "txt" > < div class = "t" > Show the strip< / div > < div class = "d" > A thin vertical column on the left side of every page.< / div > < / div >
< label class = "sw" > < input type = "checkbox" id = "quickLinksShow" > < span class = "track" > < span class = "knob" > < / span > < / span > < / label >
< / div >
< div class = "row" style = "flex-direction:column;align-items:stretch;gap:8px" >
< div class = "txt" > < div class = "t" > Links< / div > < / div >
< div id = "qlList" style = "display:flex;flex-direction:column;gap:6px" > < / div >
< div style = "display:flex;gap:6px;flex-wrap:wrap" >
< input id = "qlTitle" type = "text" placeholder = "Title (e.g. Discord)" style = "flex:1;min-width:120px;background:#1b2330;color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:13px" >
< input id = "qlUrl" type = "text" placeholder = "https://discord.com/app" style = "flex:2;min-width:200px;background:#1b2330;color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:13px" >
< button id = "qlAdd" class = "btn" > Add< / button >
< / div >
< / div >
2026-09-08 01:42:41 +02:00
< h2 class = "sub" > Appearance< / h2 >
< p class = "subd" > Choose a theme for the browser. < b > System< / b > follows your operating system's light/dark setting.< / p >
< div class = "themeCards" id = "theme" role = "radiogroup" aria-label = "Theme" >
< button type = "button" class = "tc" data-val = "system" role = "radio" aria-checked = "false" >
< span class = "mock mock-system" > < span class = "mm-chrome" > < / span > < span class = "mm-body" > < / span > < / span >
< span class = "tc-label" > System< / span >
< / button >
< button type = "button" class = "tc" data-val = "light" role = "radio" aria-checked = "false" >
< span class = "mock mock-light" > < span class = "mm-chrome" > < / span > < span class = "mm-body" > < / span > < / span >
< span class = "tc-label" > Light< / span >
< / button >
< button type = "button" class = "tc" data-val = "dark" role = "radio" aria-checked = "false" >
< span class = "mock mock-dark" > < span class = "mm-chrome" > < / span > < span class = "mm-body" > < / span > < / span >
< span class = "tc-label" > Dark< / span >
< / button >
< / div >
2026-09-09 00:51:05 +02:00
< h2 class = "sub" style = "margin-top:1.8rem" > Developer tools< / h2 >
< p class = "subd" > Where the Chromium DevTools panel appears when you press < b > F12< / b > or < b > Ctrl+Shift+I< / b > . You can always drag it out to a separate window from inside the panel.< / p >
< div class = "row" style = "flex-direction:column;align-items:stretch;gap:10px" >
< div id = "devToolsDockList" style = "display:flex;flex-direction:column;gap:6px" >
< label class = "polrow" > < input type = "radio" name = "devToolsDock" value = "bottom" > < span > < b > Bottom panel< / b > < span class = "pmuted" > — docked under the tab, like Chrome's default. Best for wide screens.< / span > < / span > < / label >
< label class = "polrow" > < input type = "radio" name = "devToolsDock" value = "sidebar" > < span > < b > Right sidebar< / b > < span class = "pmuted" > — DevTools takes the right side. If an add-on sidebar is open, it hides while DevTools is up.< / span > < / span > < / label >
< label class = "polrow" > < input type = "radio" name = "devToolsDock" value = "two-sidebars" > < span > < b > Two sidebars< / b > < span class = "pmuted" > — DevTools opens on the right, and the add-on sidebar stays where it is. The two share the right area.< / span > < / span > < / label >
< / div >
< / div >
2026-09-08 01:42:41 +02:00
< h2 class = "sub" style = "margin-top:1.8rem" > Registries< / h2 >
< p class = "subd" > How Theseus picks between the < b > BCNR / BCDN< / b > and < b > ICANN / IANA< / b > , when a name exists in both.< / p >
< div class = "row" style = "flex-direction:column;align-items:stretch;gap:10px" >
< div class = "txt" > < div class = "t" > Collision policy< / div >
< div class = "d" > A name only exists in both registries when its TLD isn't BCNR-unique (e.g. < code > .de< / code > ). BCNR-unique TLDs (that only exist on BCNR) never conflict.< / div > < / div >
< div id = "policyList" style = "display:flex;flex-direction:column;gap:6px" >
< label class = "polrow" > < input type = "radio" name = "collisionPolicy" value = "bcnr-first" > < span > < b > BCDN first< / b > < span class = "pmuted" > — BCDN wins conflicts; falls back to ICANN for anything BCDN doesn't have.< / span > < / span > < / label >
< label class = "polrow" > < input type = "radio" name = "collisionPolicy" value = "icann-first" > < span > < b > ICANN first< / b > < span class = "pmuted" > — ICANN wins conflicts; BCDN fills gaps for non-ICANN TLDs.< / span > < / span > < / label >
< label class = "polrow" > < input type = "radio" name = "collisionPolicy" value = "soft" > < span > < b > Ask each time< / b > < span class = "pmuted" > — an "Open with…" prompt on conflict, remembered per name or per TLD.< / span > < / span > < / label >
< / div >
< / div >
< div class = "row" style = "flex-direction:column;align-items:stretch;gap:8px" >
< div class = "txt" > < div class = "t" > Remembered choices< / div >
< div class = "d" > "Always use …" picks you made from the switcher or the prompt. Reset them to be asked again.< / div > < / div >
< div id = "colSummary" class = "pmuted" style = "font-size:12.5px" > < / div >
< div > < button id = "resetCollisions" class = "btn" > Reset remembered choices< / button > < / div >
< / div >
Theseus: warn before opening a name a blocklist flags
The blocklist consumer existed in the resolver library and the gateway, but
the browser opened a flagged name without comment. Now the indexer process
reads the subscribed lists from the chain every ten minutes and hands the
flags to main. A flagged name loads a warning page naming the reason, the
list and the report; the user may continue, and that is remembered per name.
Two gates, because content is reached two ways. loadBns shows the real
interstitial. serveBns refuses with an inline page on every path that skips
it: reload, back and forward, bns:// links, web app windows. The inline page
has no button, since a page at the site's own origin must not be able to
approve itself; only blocked.html may ask to continue, checked by file URL.
Settings › Naming has the policy: warn (default), never open, or ignore the
lists. The csam reason is never offered a way through. A list that cannot be
read keeps the last known flags and never stops a name from resolving.
The gateway put its own warning in front of flagged sites, which this
browser could not get past: it fetches files itself, with no cookie jar. It
now sends x-bns-policy: client and the gateway stays out of the way for a
client that says it decides for itself.
dev/blocklist-selftest.js runs the real protocol handler and decision
functions against a scratch profile.
2026-10-04 15:50:35 +02:00
< div class = "row" style = "flex-direction:column;align-items:stretch;gap:10px" >
< div class = "txt" > < div class = "t" > Flagged names< / div >
< div class = "d" > A blocklist is a public, on-chain list of names reported as dangerous: phishing, impersonation, malware, financial scams. A flagged name is still registered; this decides what Theseus does before opening one.< / div > < / div >
< div style = "display:flex;flex-direction:column;gap:6px" >
< label class = "polrow" > < input type = "radio" name = "blocklistPolicy" value = "warn" > < span > < b > Warn me< / b > < span class = "pmuted" > — a warning page first; you can continue, and Theseus remembers that per name.< / span > < / span > < / label >
< label class = "polrow" > < input type = "radio" name = "blocklistPolicy" value = "refuse" > < span > < b > Never open< / b > < span class = "pmuted" > — flagged names are not opened at all.< / span > < / span > < / label >
< label class = "polrow" > < input type = "radio" name = "blocklistPolicy" value = "off" > < span > < b > Ignore the lists< / b > < span class = "pmuted" > — no warnings.< / span > < / span > < / label >
< / div >
< div id = "blkSummary" class = "pmuted" style = "font-size:12.5px" > < / div >
< div > < button id = "resetBlocklist" class = "btn" > Forget "continue anyway" choices< / button > < / div >
< / div >
2026-09-29 00:18:00 +02:00
< h2 class = "sub" > About< / h2 >
< div class = "row" >
< div class = "txt" > < div class = "t" > Theseus Navigator is free software< / div >
< div class = "d" > Licensed under the < a href = "https://code.silentmode.st/silentmode/theseus/src/branch/master/LICENSE" target = "_blank" rel = "noopener" > Mozilla Public License 2.0< / a > .
Ships components under their own licenses: < a href = "https://code.silentmode.st/silentmode/theseus/src/branch/master/THIRD-PARTY-NOTICES.md" target = "_blank" rel = "noopener" > third-party notices< / a > .
The names and marks are reserved: < a href = "https://code.silentmode.st/silentmode/theseus/src/branch/master/TRADEMARKS.md" target = "_blank" rel = "noopener" > trademarks< / a > .< / div > < / div >
< / div >
2026-09-08 23:00:41 +02:00
< / section >
2026-10-03 19:05:25 +02:00
<!-- LANGUAGE -->
<!-- One language setting for the whole browser: it's what we send to sites
as Accept-Language (so a server that has your language serves you in
it), and it's the target for the in-page translator (so a server that
doesn't is still readable). The translate chip in the address bar
lights up when the two don't match. -->
< section id = "language" hidden >
< h1 > Language< / h1 >
< p class = "lede" > Your language. Websites that have it serve you in it; pages that don't can be translated in place.< / p >
< div class = "row" style = "border-top:0;padding-top:0;margin-top:0" >
< div class = "txt" > < div class = "t" > Your language< / div > < div class = "d" id = "webLangHint" > Sent to every site in the < b > Accept-Language< / b > header; the translate chip's target. Also switchable from the globe icon in the address bar.< / div > < / div >
< div class = "ctl" >
< select id = "webLangPick" > < / select >
< input id = "webLangOther" type = "text" placeholder = "BCP-47, e.g. cs-CZ" hidden style = "width:150px" >
< / div >
< / div >
< h2 class = "sub" > Page translation< / h2 >
< p class = "subd" > When the page's declared language is different from yours, Theseus can translate its visible text in place. The source stays untouched — click the chip again to revert.< / p >
2026-10-04 14:20:18 +02:00
< div class = "row" style = "flex-direction:column;align-items:stretch;gap:10px" >
< div class = "txt" > < div class = "t" > Translator engine< / div >
< div class = "d" > Where the translation runs. Both read the chip the same way; the difference is who sees the page's text. Details at < a href = "https://bergamot.x" target = "_blank" rel = "noopener" > bergamot.x< / a > .< / div > < / div >
< div id = "translateProviderList" style = "display:flex;flex-direction:column;gap:6px" >
< label class = "polrow" > < input type = "radio" name = "translateProvider" value = "libretranslate" > < span > < b > LibreTranslate — hosted< / b > < span class = "pmuted" > — Silent Mode's server does the translation; the page's text is POSTed to < code > silentmode.st/libre< / code > (or any peer you add). Fast and ready today.< / span > < / span > < / label >
< label class = "polrow" > < input type = "radio" name = "translateProvider" value = "bergamot" > < span > < b > Bergamot — on-device< / b > < span class = "pmuted" > — WebAssembly engine on your computer; nothing leaves the device. Needs a one-time per-language model download. < b style = "color:var(--warn)" > Coming in a later release< / b > — picking it today still routes through LibreTranslate with a one-time notice.< / span > < / span > < / label >
< / div >
< / div >
2026-10-03 19:05:25 +02:00
< div class = "row" >
Theseus: one language chip, auto-translate, picker owns up to what works
Two chips carried the same word in two shapes — a globe (Accept-Language)
and a translate chip (chip lights when page lang differs) — both labelled
"RU" at the same time for a Russian user. The chip for translation is
gone. The globe menu now covers both: a "Translate this page from X to Y"
item appears at the top when the loaded page is in another supported
language, flipping to "Show original" while a translation is on screen.
The chip's own code still shows the user's language (EN, RU, …); its
tooltip switches to "Translated to <X>. Menu: Show original." when a
translation is up, so the one chip reads the whole state.
With "Translate automatically" on, Theseus translates in place on
did-finish-load the first time it sees a supported source + target
mismatch for the active tab — no chip-click needed. A `_tr.autoTried`
latch keeps it to one attempt per document (a failing backend doesn't
retry on every reflow), and the latch resets on did-start-navigation so
the next page gets a fresh shot. The setting copy in Settings › Language
now says "Translate automatically" instead of "Offer to translate", so
the switch's label matches the behaviour.
The picker (both in Settings and in the globe menu) still lists every
language in WEBSITE_LANGUAGE_QUICK, but entries whose base code isn't
on the translator backend (en, es, fr, de, el, ru today) are shown
greyed out with "— translator coming later", and "Other… (Accept-Language
only, no translation)" is explicit about what free-form tags buy you.
The menu is a roadmap, not a lie: a user picking one of the greyed
entries sets Accept-Language and nothing else surprises them.
2026-10-03 19:37:04 +02:00
< div class = "txt" > < div class = "t" > Translate automatically< / div > < div class = "d" > When the page's declared language is different from yours (and both are supported by the translator), Theseus translates it in place as soon as it loads. Turn this off to leave pages in their original language — the globe chip's menu still offers a one-click translation.< / div > < / div >
2026-10-03 19:05:25 +02:00
< label class = "sw" > < input type = "checkbox" id = "translateAutoOffer" > < span class = "track" > < span class = "knob" > < / span > < / span > < / label >
< / div >
< div class = "row" style = "flex-direction:column;align-items:stretch;gap:8px" >
< div class = "txt" > < div class = "t" > Translation peers< / div >
< div class = "d" > Ordered list of LibreTranslate-compatible endpoints. The first one that answers wins; a dead or rate-limited peer is skipped and the next is tried. Keep Silent Mode's own instances at the top; add your own self-hosted LibreTranslate for privacy or unlimited use. On-device Bergamot (WASM) is coming in a later release.< / div > < / div >
< div id = "translateEndpointList" style = "display:flex;flex-direction:column;gap:6px" > < / div >
< div style = "display:flex;gap:6px;flex-wrap:wrap" >
< input id = "translatePeerUrl" type = "text" placeholder = "https://your-libretranslate/translate" style = "flex:1;min-width:220px;background:#1b2330;color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:13px" >
< button id = "translatePeerAdd" class = "btn" > Add peer< / button >
< / div >
< / div >
< div class = "row" >
< div class = "txt" > < div class = "t" > API key (optional)< / div > < div class = "d" > Shared across all peers in the list. Some LibreTranslate instances need a key for the paid tier or to raise the rate limit; leave blank for peers that don't.< / div > < / div >
< input id = "translateApiKey" type = "text" placeholder = "(none)" style = "min-width:200px;background:#1b2330;color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:13px" >
< / div >
< / section >
2026-09-08 23:00:41 +02:00
<!-- PLUG - INS -->
2026-09-30 02:16:11 +02:00
<!-- Compact main page: one row per plug - in with title / status / update
controls on the left and the on/off toggle on the right. Clicking
the TITLE opens that plug-in's own settings sub-page (details +
Uninstall). Toggle and buttons stopPropagation so they don't
navigate. -->
2026-09-08 23:00:41 +02:00
< section id = "plugins" hidden >
< h1 > Plug-ins< / h1 >
2026-09-30 02:16:11 +02:00
< p class = "lede" > Silent Mode components that live alongside Theseus — each with its own on/off, updates and settings page.< / p >
feat: community extensions — publish with a BCDN name, install from Settings, theseus.x catalog
Anyone who owns a BCDN name can now publish a Theseus extension, and every
Theseus can install it with the publisher's signature verified locally.
Gateway (Argus/src/gateway/public-gateway.mjs):
PUT /api/ext/<name>/<id>/<version> takes the gzipped tar, checks two BCH
message signatures against the name's current NFT owner (one authorises
the upload, one is stored in the channel), inspects the package
(addon.json at the root, id/version/main match, 8 MB cap), enforces
first-publisher ownership of an id and monotonic versions, and writes the
tarball, the extension's updates.json and community/catalog.json to Sia.
GET /api/ext/catalog reads the catalog back with CORS.
Theseus:
lib/publisher-sig.mjs recovers the signer of a channel entry; main.js
compares it with the publisher name's owner from Theseus's own chain
index before installing or updating, so neither the relay nor a tampered
catalog can pass off code under a trusted name. addon-updater.js gains
installCommunity() and accepts publisher-signed entries in the regular
update check (operator Ed25519 entries unchanged). Settings › Extensions
shows the community catalog with Install / Update; Settings › Plug-ins
links to theseus.x/plug-ins.
theseus.x:
/plug-ins/ is a separate page for the first-party plug-ins (Aegis,
Ariadne's Thread) with live versions and hashes; /extensions/ lists the
bundled extensions, the community catalog, and how to build and publish;
/extensions/publish/ signs and uploads a package in the browser with the
wallet that holds the publisher's name (session helper + wallet bundle
copied alongside).
2026-09-20 15:26:30 +02:00
< div class = "row" style = "justify-content:flex-end;gap:8px" > < a class = "btn" style = "text-decoration:none;display:inline-flex;align-items:center" href = "https://theseus.x/plug-ins/" target = "_blank" rel = "noopener" title = "Current signed versions, hashes and how plug-ins update" > About plug-ins on theseus.x ↗< / a > < / div >
2026-09-30 02:16:11 +02:00
<!-- Ariadne's Thread -->
< div class = "row plug-row" >
< div class = "txt plug-open" data-go = "plugins/ariadne" role = "button" tabindex = "0" title = "Open Ariadne's Thread settings" >
< div class = "t" > Ariadne's Thread < span class = "pmuted" style = "font-weight:400" > — system-wide resolver< / span > < span class = "chev" > › < / span > < / div >
< div class = "d" > Resolves BCDN names for every browser on this machine — not just Theseus.< / div >
< div id = "ariadneStatusMain" class = "pmuted" style = "font-size:12.5px;margin-top:4px" > checking…< / div >
2026-09-08 01:42:41 +02:00
< / div >
2026-09-30 02:16:11 +02:00
< div class = "plug-ctl" >
< button id = "ariadneInstallMain" class = "btn small" hidden > Install< / button >
< button id = "ariadneUpdateMain" class = "btn small" hidden > Update< / button >
< button id = "ariadneCheckMain" class = "btn small" title = "Refresh Ariadne status" > Refresh< / button >
< label class = "sw" id = "ariadneSwWrap" title = "Turn Ariadne on/off" >
< input type = "checkbox" id = "ariadneToggleMain" >
< span class = "track" > < span class = "knob" > < / span > < / span >
< / label >
< / div >
< / div >
<!-- Aegis -->
< div class = "row plug-row" >
< div class = "txt plug-open" data-go = "plugins/aegis" role = "button" tabindex = "0" title = "Open Aegis settings" >
< div class = "t" > Aegis < span class = "pmuted" style = "font-weight:400" > — built-in wallet< / span > < span class = "chev" > › < / span > < / div >
< div class = "d" > Multi-chain wallet (BCH, BTC, TRX, ETH, SOL, SC, DGB). Ships in the box, updates over-the-air.< / div >
< div id = "aegisStatusMain" class = "pmuted" style = "font-size:12.5px;margin-top:4px" > loading…< / div >
2026-09-08 01:42:41 +02:00
< / div >
2026-09-30 02:16:11 +02:00
< div class = "plug-ctl" >
< button id = "aegisCheckMain" class = "btn small" > Check for updates< / button >
< button id = "aegisApplyMain" class = "btn small" hidden > Apply update< / button >
2026-09-08 01:42:41 +02:00
< / div >
< / div >
2026-09-30 02:16:11 +02:00
< / section >
<!-- PLUG - INS › ARIADNE (sub - page) -->
< section id = "plugins-ariadne" class = "subpage" data-parent = "plugins" hidden >
< div class = "crumb" > < a data-go = "plugins" > Plug-ins< / a > › Ariadne's Thread< / div >
< h1 > < button class = "back" data-go = "plugins" title = "Back to Plug-ins" aria-label = "Back" > ‹ < / button > Ariadne's Thread< / h1 >
< 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 >
2026-10-03 22:43:30 +02:00
< div class = "row plug-row" >
2026-09-08 18:09:55 +02:00
< div class = "txt" >
2026-09-30 02:16:11 +02:00
< div class = "t" > System-wide resolver< / div >
< div class = "d" id = "ariadneStatusSub" > checking…< / div >
2026-09-08 18:09:55 +02:00
< / div >
2026-10-03 22:43:30 +02:00
< 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 >
2026-09-30 02:16:11 +02:00
< / div >
< div id = "ariadneMissing" class = "pmuted" style = "font-size:12.5px;margin:-4px 4px 8px" hidden >
2026-10-03 22:43:30 +02:00
Ariadne's Thread isn't installed on this machine. Click < b > Install< / b > above to run
2026-09-30 02:16:11 +02:00
the bundled installer, or grab it manually from
< a href = "https://silentmode.st/tools/" target = "_blank" rel = "noopener" > silentmode.st/tools< / a > .
< / div >
feat(theseus/ariadne): settings panel — policy + per-source toggles + status report
Ariadne 0.1.13 exposed /api/status and per-source enable flags in
policy.json. Theseus's Plug-ins > Ariadne's Thread sub-page now wires those
into a full UI, no daemon restart, no UAC.
Added to the plugins-ariadne sub-page (after Status, before Remove):
Collision policy -- radio group (BCNR-first / ICANN-first) writes
C:\ProgramData\Ariadne\policy.json.policy; hot-reloaded
by the daemon within 5 s.
Sources -- 3-column grid, one row per source (snapshotHttps,
electrumWss, perQueryLookup, diskCache, localApi):
enable checkbox + last-state summary
(last success / last error / hit-miss counters /
disk-cache size+mtime). Toggle writes
policy.json.sources.<name>.enabled and re-polls after
the 5-s hot-reload tick so the state text catches up.
Status report -- <pre> JSON dump of GET http://127.0.0.1/api/status
with Copy report + Refresh report buttons. This is
the paste-me-into-support artefact for any diagnosis.
IPC wiring:
main.js
ariadne-get-status -> GET http://127.0.0.1/api/status ({ok, status|error})
ariadne-get-policy -> read C:\ProgramData\Ariadne\policy.json (or {})
ariadne-set-policy -> merge {policy}, write back (validates enum)
ariadne-set-source -> merge {sources.<name>.enabled}, write back
(validates against the known 5 names)
settings-preload.js
ariadneGetStatus, ariadneGetPolicy, ariadneSetPolicy, ariadneSetSource
All four handlers write policy.json as the local user; no UAC. Works because
install.ps1 grants BUILTIN\Users Modify on the file (0.1.7+).
Sub-page auto-refreshes state every time it opens (listens on the existing
'section' custom event dispatched by showSection).
Not building/shipping Theseus here -- this rides the next Theseus release.
Panel gracefully handles: daemon down (shows 'Daemon unreachable' with a
pointer to the Status toggle), localApi disabled (daemon returns 503, panel
shows the error), missing policy.json (all sources default to true).
2026-10-01 00:51:50 +02:00
< 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 >
< div class = "row" style = "flex-direction:column;align-items:stretch;gap:6px" >
< label class = "polrow" > < input type = "radio" name = "ariadnePolicy" value = "bcnr-first" > < span > < b > BCNR first< / b > < span class = "pmuted" > — use BCNR when the name is on chain; forward to ICANN on miss. Default.< / span > < / span > < / label >
< label class = "polrow" > < input type = "radio" name = "ariadnePolicy" value = "icann-first" > < span > < b > ICANN first< / b > < span class = "pmuted" > — forward to ICANN; use BCNR only when ICANN returns NXDOMAIN.< / span > < / span > < / label >
< / div >
< h2 class = "sub" > Sources< / h2 >
< p class = "subd" > Each index source can be toggled independently to isolate a path or measure without it. Changes hot-reload every 5 s via < code > C:\ProgramData\Ariadne\policy.json< / code > — no restart. State column shows the last attempt result for each.< / p >
< div class = "ariadne-sources" role = "table" aria-label = "Ariadne index sources" >
< div class = "asrc-head" role = "row" > < span > Source< / span > < span > Enabled< / span > < span > State< / span > < / div >
< div id = "ariadneSourcesBody" > < / div >
< / div >
< h2 class = "sub" > Status report< / h2 >
< p class = "subd" > Full snapshot of what the daemon currently sees — daemon version, permitted TLDs, index size, last fetch per source. Paste into a support conversation.< / p >
< div class = "row" style = "justify-content:flex-end;gap:8px" >
< button id = "ariadneStatusCopy" class = "btn" > Copy report< / button >
< button id = "ariadneStatusRefresh" class = "btn" > Refresh report< / button >
< / div >
< pre id = "ariadneStatusJson" style = "max-height:320px;overflow:auto;background:rgba(0,0,0,.25);padding:12px;border-radius:6px;font-size:11.5px;font-family:ui-monospace,'SF Mono',Menlo,Consolas,monospace;line-height:1.45;margin:8px 0 0" > Loading…< / pre >
2026-09-30 02:16:11 +02:00
< h2 class = "sub" > Remove< / h2 >
< p class = "subd" > Uninstall the resolver from this machine. Theseus itself keeps working — its built-in resolver isn't affected.< / p >
< div class = "row" >
< div class = "txt" >
< div class = "t" > Uninstall Ariadne's Thread< / div >
< div class = "d" > Removes the two scheduled tasks and deletes < code > C:\ProgramData\Ariadne\< / code > . Prompts for admin.< / div >
2026-09-08 18:09:55 +02:00
< / div >
2026-09-30 02:16:11 +02:00
< button id = "ariadneUninstallSub" class = "btn" hidden style = "color:#f6768a;border-color:rgba(246,118,138,.35)" > Uninstall< / button >
2026-09-08 18:09:55 +02:00
< / div >
2026-09-08 01:42:41 +02:00
< / section >
2026-09-30 02:16:11 +02:00
<!-- PLUG - INS › AEGIS (sub - page) -->
< section id = "plugins-aegis" class = "subpage" data-parent = "plugins" hidden >
< div class = "crumb" > < a data-go = "plugins" > Plug-ins< / a > › Aegis< / div >
< h1 > < button class = "back" data-go = "plugins" title = "Back to Plug-ins" aria-label = "Back" > ‹ < / button > Aegis< / h1 >
< p class = "lede" > Multi-chain wallet (BCH, BTC, TRX, ETH, SOL, SC, DGB) that ships in the box. Bundled with Theseus, but updates are delivered < b > over-the-air< / b > : new signed versions land without a Theseus release. Runs entirely inside this browser — never system-wide.< / p >
< h2 class = "sub" style = "border-top:0;padding-top:0;margin-top:0" > Status< / h2 >
< div class = "row" >
< div class = "txt" >
< div class = "t" > Aegis wallet< / div >
< div class = "d" id = "aegisStatusSub" > loading…< / div >
< / div >
< / div >
< div class = "row" style = "justify-content:flex-end;gap:8px" >
< button id = "aegisCheckSub" class = "btn" > Check for updates< / button >
< button id = "aegisApplySub" class = "btn" hidden > Apply update now< / button >
< / div >
< div class = "note" style = "margin-top:1rem" > Aegis lives in the Extensions folder alongside every other add-on; to remove it, use < a data-go = "addons" style = "color:var(--acid-text);cursor:pointer" > Extensions< / a > .< / div >
< / section >
2026-09-08 01:42:41 +02:00
<!-- SEARCH -->
< section id = "search" hidden >
< h1 > Search< / h1 >
< p class = "lede" > Pick what your address bar and the toolbar dropdown search with.< / p >
< div class = "row" >
< div class = "txt" > < div class = "t" > Default search engine< / div > < div class = "d" > Used when you type into the address bar.< / div > < / div >
2026-09-28 20:07:51 +02:00
< div class = "ctl" > < div class = "esel" id = "searchEngine" tabindex = "0" role = "combobox" aria-haspopup = "listbox" aria-expanded = "false" title = "Default search engine" > < span class = "eic" > < / span > < span class = "enm" > < / span > < span class = "caret" > ▾< / span > < / div > < / div >
2026-09-08 01:42:41 +02:00
< / div >
< div class = "row" style = "flex-direction:column;align-items:stretch;gap:10px" >
< div class = "txt" style = "display:flex;align-items:center;justify-content:space-between;gap:12px" >
< div >
< div class = "t" > Additional search engines< / div >
< div class = "d" > These appear in the toolbar dropdown. Drag to reorder. Toggle off to move an engine back to the catalog.< / div >
< / div >
< button id = "engAddBtn" class = "btn" type = "button" > + Add search engine< / button >
< / div >
< div id = "engineList" > < / div >
<!-- Catalog: hidden until "Add" is clicked. Three tiers, top to bottom:
1. curated built-ins the user hasn't enabled (tier="catalog")
2. wider bank filtered by a search box (tier="extra")
3. custom-URL form -->
< div id = "engineCatalog" class = "engcat" hidden >
< div class = "ehdr" > Add from catalog< / div >
< div id = "catalogList" > < / div >
< div class = "ehdr" style = "display:flex;align-items:center;justify-content:space-between;gap:8px" >
< span > Discover more engines< / span >
< input id = "engineFilter" type = "search" placeholder = "Filter by name…" autocomplete = "off"
style="background:#1b2330;color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:4px 8px;font-size:12px;min-width:auto;width:170px">
< / div >
< div id = "extraList" > < / div >
< div class = "ehdr" > Or add a custom URL< / div >
< div class = "addeng" >
< input id = "engSym" placeholder = "🔍" style = "max-width:52px;text-align:center;flex:none" >
< input id = "engName" placeholder = "Name (e.g. My SearXNG)" >
< input id = "engUrl" placeholder = "https://example.com/search?q=%s" >
< button id = "engAdd" class = "btn" > Add< / button >
< / div >
< / div >
< / div >
< / section >
<!-- PASSWORDS -->
< section id = "passwords" hidden >
< h1 > Passwords< / h1 >
< p class = "lede" > Local password vault. Set once, unlocked with a master password. Encrypted at rest; nothing leaves your machine.< / p >
<!-- State A: no vault yet — set up -->
< div id = "pwSetup" hidden >
< div class = "row" style = "flex-direction:column;align-items:stretch;gap:10px" >
< div class = "txt" > < div class = "t" > Master password< / div >
< div class = "d" > Used to unlock the vault every session. This is separate from your Ariadne wallet passphrase — memorize it, we can't recover it.< / div > < / div >
< div class = "addeng" > < input id = "pwSetupPw1" type = "password" placeholder = "Master password" > < input id = "pwSetupPw2" type = "password" placeholder = "Confirm" > < / div >
< / div >
< div class = "row" style = "flex-direction:column;align-items:stretch;gap:10px" >
< div class = "txt" > < div class = "t" > Seed for deterministic passwords< / div >
< div class = "d" > The "Generate" button in an entry derives a password from this seed. Same seed on another device → same passwords for the same site + username.< / div > < / div >
< div style = "display:flex;flex-direction:column;gap:6px" >
< label class = "polrow" > < input type = "radio" name = "pwSeedSource" value = "mnemonic" checked > < span > < b > Use my Ariadne wallet mnemonic< / b > < span class = "pmuted" > — unified identity, one seed to back up< / span > < / span > < / label >
< label class = "polrow" > < input type = "radio" name = "pwSeedSource" value = "generate" > < span > < b > Generate a new independent seed< / b > < span class = "pmuted" > — isolated from any BCH funds< / span > < / span > < / label >
< / div >
< textarea id = "pwSetupMnemonic" placeholder = "12 or 24 BIP39 words separated by spaces" rows = "3" style = "background:#1b2330;color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:8px 10px;font-size:13px;font-family:ui-monospace,monospace;outline:none;resize:vertical" > < / textarea >
< div class = "pmuted" style = "font-size:12px" > The mnemonic is used only to derive the password-purpose subtree (m/1381'/0'). It is not stored — only the derived subtree key is persisted, encrypted with your master password.< / div >
< / div >
< div class = "row" style = "justify-content:flex-end" >
< button id = "pwSetupBtn" class = "btn" type = "button" > Create vault< / button >
< / div >
< / div >
<!-- State B: vault exists but locked -->
< div id = "pwLocked" hidden >
< div class = "row" >
< div class = "txt" > < div class = "t" > Unlock vault< / div > < div class = "d" > Enter your master password to view or add entries.< / div > < / div >
< div class = "ctl" style = "align-items:stretch" > < input id = "pwUnlockPw" type = "password" placeholder = "Master password" > < button id = "pwUnlockBtn" class = "btn" type = "button" > Unlock< / button > < / div >
< / div >
< div id = "pwUnlockErr" class = "pmuted" style = "color:#f6768a;font-size:12.5px;margin-top:4px" hidden > < / div >
Theseus: quick-unlock PIN for the vault, shared with extensions
The vault re-locks on every restart and only the master password opened
it, so every extension that needs it (Aegis, now Pithos) either asked for
the master password itself or grew its own PIN. Theseus now owns one:
- Settings > Passwords sets, changes or removes a 6-digit PIN. The PIN
wraps the master password (PBKDF2-SHA256, 600k iterations, AES-256-GCM)
and the result is sealed with the OS keystore (safeStorage: DPAPI /
Keychain / libsecret), so a copied vault-pin.json cannot be brute-forced
elsewhere. Every unlock still ends at the master password.
- Three wrong PINs in a row require the master password. The strike count
lives in the same file, so a restart does not reset it; a successful
master-password unlock does. A PIN whose password no longer opens the
vault (password changed) is dropped.
- unlock.html is Theseus's own prompt, over the whole window: PIN pad, or
the master password. Extensions call api.vault.requestUnlock({ reason })
(vault-derive capability) and get { ok } back; what the user typed never
reaches them. Settings' locked screen offers "Unlock with PIN" through
the same prompt.
2026-10-03 20:33:26 +02:00
< div class = "row" id = "pwPinUnlockRow" hidden >
Theseus: one PIN — the vault PIN, offered to Aegis through api.vault.pin
Theseus and Aegis each wrapped the same master password under their
own PIN: two offline targets, two guess budgets, and two PINs to keep
in step. The vault PIN is now the only one. Built-in add-ons get
api.vault.pin {status, unlock, set, clear} (advertised by
features.vaultPin); unlock(pin) opens the vault in main and answers
only { ok } or why not, so the master password stays in main.
The policy is the one Aegis's PIN screens describe: five wrong PINs
lock the PIN for 15 minutes, every further wrong one locks it again,
and the master password always works. The unlock prompt uses the same
PIN pad and the same wording as Aegis, and Settings says so.
2026-10-04 04:15:15 +02:00
< div class = "txt" > < div class = "t" > Or use your PIN< / div > < div class = "d" > Five wrong PINs lock it for 15 minutes; the master password always works.< / div > < / div >
Theseus: quick-unlock PIN for the vault, shared with extensions
The vault re-locks on every restart and only the master password opened
it, so every extension that needs it (Aegis, now Pithos) either asked for
the master password itself or grew its own PIN. Theseus now owns one:
- Settings > Passwords sets, changes or removes a 6-digit PIN. The PIN
wraps the master password (PBKDF2-SHA256, 600k iterations, AES-256-GCM)
and the result is sealed with the OS keystore (safeStorage: DPAPI /
Keychain / libsecret), so a copied vault-pin.json cannot be brute-forced
elsewhere. Every unlock still ends at the master password.
- Three wrong PINs in a row require the master password. The strike count
lives in the same file, so a restart does not reset it; a successful
master-password unlock does. A PIN whose password no longer opens the
vault (password changed) is dropped.
- unlock.html is Theseus's own prompt, over the whole window: PIN pad, or
the master password. Extensions call api.vault.requestUnlock({ reason })
(vault-derive capability) and get { ok } back; what the user typed never
reaches them. Settings' locked screen offers "Unlock with PIN" through
the same prompt.
2026-10-03 20:33:26 +02:00
< div class = "ctl" > < button id = "pwPinUnlockBtn" class = "btn" type = "button" > Unlock with PIN< / button > < / div >
< / div >
2026-09-08 01:42:41 +02:00
< / div >
<!-- State C: vault unlocked -->
< div id = "pwUnlocked" hidden >
< div class = "row" style = "justify-content:space-between" >
< div class = "txt" > < div class = "t" > Your passwords< / div > < div class = "d" > Reveal, copy, or edit any entry. The vault re-locks when Theseus quits.< / div > < / div >
< button id = "pwLockBtn" class = "btn" type = "button" > Lock now< / button >
< / div >
Theseus: quick-unlock PIN for the vault, shared with extensions
The vault re-locks on every restart and only the master password opened
it, so every extension that needs it (Aegis, now Pithos) either asked for
the master password itself or grew its own PIN. Theseus now owns one:
- Settings > Passwords sets, changes or removes a 6-digit PIN. The PIN
wraps the master password (PBKDF2-SHA256, 600k iterations, AES-256-GCM)
and the result is sealed with the OS keystore (safeStorage: DPAPI /
Keychain / libsecret), so a copied vault-pin.json cannot be brute-forced
elsewhere. Every unlock still ends at the master password.
- Three wrong PINs in a row require the master password. The strike count
lives in the same file, so a restart does not reset it; a successful
master-password unlock does. A PIN whose password no longer opens the
vault (password changed) is dropped.
- unlock.html is Theseus's own prompt, over the whole window: PIN pad, or
the master password. Extensions call api.vault.requestUnlock({ reason })
(vault-derive capability) and get { ok } back; what the user typed never
reaches them. Settings' locked screen offers "Unlock with PIN" through
the same prompt.
2026-10-03 20:33:26 +02:00
< h2 class = "sub" > Quick-unlock PIN< / h2 >
< div class = "row" >
< div class = "txt" > < div class = "t" > PIN< / div >
Theseus: one PIN — the vault PIN, offered to Aegis through api.vault.pin
Theseus and Aegis each wrapped the same master password under their
own PIN: two offline targets, two guess budgets, and two PINs to keep
in step. The vault PIN is now the only one. Built-in add-ons get
api.vault.pin {status, unlock, set, clear} (advertised by
features.vaultPin); unlock(pin) opens the vault in main and answers
only { ok } or why not, so the master password stays in main.
The policy is the one Aegis's PIN screens describe: five wrong PINs
lock the PIN for 15 minutes, every further wrong one locks it again,
and the master password always works. The unlock prompt uses the same
PIN pad and the same wording as Aegis, and Settings says so.
2026-10-04 04:15:15 +02:00
< div class = "d" id = "pinDesc" > A 6-digit PIN that unlocks the vault instead of the master password, here, in Aegis and in extensions such as Pithos. Five wrong PINs lock it for 15 minutes; the master password always works.< / div > < / div >
Theseus: quick-unlock PIN for the vault, shared with extensions
The vault re-locks on every restart and only the master password opened
it, so every extension that needs it (Aegis, now Pithos) either asked for
the master password itself or grew its own PIN. Theseus now owns one:
- Settings > Passwords sets, changes or removes a 6-digit PIN. The PIN
wraps the master password (PBKDF2-SHA256, 600k iterations, AES-256-GCM)
and the result is sealed with the OS keystore (safeStorage: DPAPI /
Keychain / libsecret), so a copied vault-pin.json cannot be brute-forced
elsewhere. Every unlock still ends at the master password.
- Three wrong PINs in a row require the master password. The strike count
lives in the same file, so a restart does not reset it; a successful
master-password unlock does. A PIN whose password no longer opens the
vault (password changed) is dropped.
- unlock.html is Theseus's own prompt, over the whole window: PIN pad, or
the master password. Extensions call api.vault.requestUnlock({ reason })
(vault-derive capability) and get { ok } back; what the user typed never
reaches them. Settings' locked screen offers "Unlock with PIN" through
the same prompt.
2026-10-03 20:33:26 +02:00
< div class = "ctl" > < button id = "pinSetBtn" class = "btn" type = "button" > Set a PIN< / button > < button id = "pinClearBtn" class = "btn" type = "button" hidden > Remove< / button > < / div >
< / div >
< div id = "pinForm" class = "row" style = "flex-direction:column;align-items:stretch;gap:8px" hidden >
< div class = "addeng" > < input id = "pinMaster" type = "password" placeholder = "Master password" autocomplete = "current-password" > < / div >
< div class = "addeng" > < input id = "pinNew1" type = "password" inputmode = "numeric" maxlength = "6" placeholder = "New 6-digit PIN" autocomplete = "off" > < input id = "pinNew2" type = "password" inputmode = "numeric" maxlength = "6" placeholder = "Repeat PIN" autocomplete = "off" > < / div >
< div id = "pinErr" class = "pmuted" style = "color:#f6768a;font-size:12.5px" hidden > < / div >
< div style = "display:flex;justify-content:flex-end;gap:6px" > < button id = "pinCancel" class = "btn" type = "button" > Cancel< / button > < button id = "pinSave" class = "btn" type = "button" > Save PIN< / button > < / div >
< / div >
2026-09-08 01:42:41 +02:00
< div id = "pwList" > < / div >
< h2 class = "sub" > Add an entry< / h2 >
< div class = "row" style = "flex-direction:column;align-items:stretch;gap:8px" >
< div class = "addeng" > < input id = "pwAddDomain" placeholder = "Site (e.g. github.com)" > < input id = "pwAddUser" placeholder = "Username or email" > < / div >
< div style = "display:flex;flex-direction:column;gap:6px" >
< label class = "polrow" > < input type = "radio" name = "pwAddKind" value = "generated" checked > < span > < b > Generate deterministically< / b > < span class = "pmuted" > — derived from your seed; same across devices< / span > < / span > < / label >
< label class = "polrow" > < input type = "radio" name = "pwAddKind" value = "literal" > < span > < b > Paste an existing password< / b > < span class = "pmuted" > — for legacy accounts you already set elsewhere< / span > < / span > < / label >
< / div >
< input id = "pwAddLiteral" type = "password" placeholder = "Paste password" hidden >
< div class = "addeng" > < button id = "pwAddPreview" class = "btn" type = "button" style = "flex:none" > Preview< / button > < input id = "pwAddPreviewOut" readonly placeholder = "preview appears here" style = "font-family:ui-monospace,monospace" > < / div >
< div style = "display:flex;justify-content:flex-end" > < button id = "pwAddBtn" class = "btn" type = "button" > Save entry< / button > < / div >
< / div >
2026-10-04 04:10:50 +02:00
< div class = "note" > On a site with a saved login, a key appears in the address bar while the vault is unlocked: click it to fill the login.< / div >
2026-09-08 01:42:41 +02:00
< / div >
< / section >
<!-- NAMING -->
<!-- Registries moved into General (above). -->
<!-- PERFORMANCE -->
< section id = "performance" hidden >
< h1 > Performance< / h1 >
2026-09-27 22:01:28 +02:00
< p class = "lede" > Keep Theseus light on resources. Trackers, ads and cookie pop-ups are under < a data-go = "privacy" style = "color:var(--acid-text);cursor:pointer" > Privacy< / a > .< / p >
2026-09-08 01:42:41 +02:00
< div class = "row" >
< div class = "txt" > < div class = "t" > Throttle inactive tabs< / div > < div class = "d" > Background and inactive tabs use far less CPU. Recommended.< / div > < / div >
< label class = "sw" > < input type = "checkbox" id = "backgroundThrottle" > < span class = "track" > < span class = "knob" > < / span > < / span > < / label >
< / div >
2026-10-03 14:14:47 +02:00
< div class = "row" >
< div class = "txt" > < div class = "t" > Stop tabs in the background< / div > < div class = "d" > A tab you switch away from stops running — scripts, timers, audio and video — until you come back to it. Right-click a tab and choose “Keep running in background” for music, calls or anything that should keep going.< / div > < / div >
< label class = "sw" > < input type = "checkbox" id = "freezeBackgroundTabs" > < span class = "track" > < span class = "knob" > < / span > < / span > < / label >
< / div >
Theseus: start extensions on first use, Startup switches in Settings
Every enabled add-on used to be activated synchronously in initAddons(),
during app.whenReady and before the window exists. That is the largest
launch cost left (boot tracer, 2026-10-03). An add-on can now say
"activation": "on-demand" in addon.json. It is then listed at launch but
not started. Its declared surfaces stay live: "panels" (new: sidebar
panels declared up front), toolbar-menu, context-menu-items and the
page-inject bridge, whose source is read on the first matching page.
activate() runs on first real use: a panel opened, a menu or context
item picked, a message from its panel, tab or page bridge, a Settings
addon-invoke, a wiz:// link (for Aegis). All of those go through
AddonHost.dispatch(), which starts the add-on and waits for it, so no
call is dropped. Concurrent callers share one activation, and
activations run one at a time.
Startup stays the default: the host cannot tell what an older add-on
does in activate(). request-filter add-ons and add-ons that declare no
surface are forced to startup. If activate() returns a promise, calls
wait for it (at most 5 s). api.startAtLaunch(bool) lets an on-demand
add-on ask to be started at launch again (for live relay sessions).
Converted: notepad, screenshot, translate, docx-editor, pdf-editor, vpn
(none has launch-time work: no file association, no auto-connect, and
add-on file tabs are not part of the saved session). Shield and Cookie
Pop-ups stay startup: Shield owns the request filter and must see the
first request; Cookie Pop-ups costs ~6 ms and acts unasked on every
page. Aegis stays startup and untouched: another session owns it. See
NOTE-aegis-on-demand.md (next commit).
Settings › Performance › Startup:
- "Start extensions when first used" (default on). Off = all at launch;
switching it off starts the waiting add-ons immediately.
- "Start the wallet at launch". Shown disabled with a hint until the
installed Aegis manifest allows on-demand. It applies with no Settings
change once Aegis opts in.
- "Preload common menus" gates prewarmOverlays().
- "Use lightest" preset.
New keys are plain SETTINGS_DEFAULTS through the existing settings-set.
No new IPC channels.
Measured: boot-trace, fresh profile, --seconds 20 so the 30 s add-on OTA
poll can't swap Aegis mid-series; warm runs 2-3 of two paired series.
- Add-on activation at launch: 121-154 ms -> 104-174 ms. The six
converted add-ons went from 16-20 ms to 0. The rest is Shield (83-148
ms, noisy) and Aegis (15 ms in this tree's 0.9.0).
- Toolbar painted: 1278-1584 ms -> 1282-1481 ms (within noise).
- With "Preload common menus" off: 0 overlays prewarmed, 8 processes
instead of 11, about 50-70 MB less at 15 s.
The bundled add-on versions are not bumped. Existing profiles keep their
old addon.json, and so stay on startup activation, until those add-ons
ship with a higher version (seedBundledAddons only reseeds a strictly
newer bundle).
2026-10-03 16:05:32 +02:00
< h2 class = "sub" > Startup< / h2 >
< div class = "row" >
< div class = "txt" > < div class = "t" > Start extensions when first used< / div > < div class = "d" > Extensions such as Notepad, Screenshot, Translate and the document editors start the first time you open them, instead of every time Theseus starts. Their buttons and menu items are there either way. Shield and Cookie Pop-ups always start right away. < span id = "startupNow" > < / span > < / div > < / div >
< label class = "sw" > < input type = "checkbox" id = "extensionsOnDemand" > < span class = "track" > < span class = "knob" > < / span > < / span > < / label >
< / div >
< div class = "row" id = "walletAtLaunchRow" >
< div class = "txt" > < div class = "t" > Start the wallet at launch< / div > < div class = "d" id = "walletAtLaunchHint" > Aegis is ready the moment a site asks for it, at the cost of a slower start.< / div > < / div >
< label class = "sw" > < input type = "checkbox" id = "walletAtLaunch" > < span class = "track" > < span class = "knob" > < / span > < / span > < / label >
< / div >
< div class = "row" >
< div class = "txt" > < div class = "t" > Preload common menus< / div > < div class = "d" > After the first page loads, get address suggestions, the link preview and site info ready in the background so they open instantly. Off saves memory; each opens a moment slower the first time.< / div > < / div >
< label class = "sw" > < input type = "checkbox" id = "preloadMenus" > < span class = "track" > < span class = "knob" > < / span > < / span > < / label >
< / div >
< div class = "row" >
< div class = "txt" > < div class = "t" > Lightweight start< / div > < div class = "d" > Set all of the above to the lightest choice.< / div > < / div >
< button class = "btn small" id = "lightStart" > Use lightest< / button >
< / div >
2026-09-27 22:01:28 +02:00
< / section >
<!-- PRIVACY -->
< section id = "privacy" hidden >
< h1 > Privacy< / h1 >
< p class = "lede" > What Theseus keeps from sites: trackers, ads, cookie pop-ups, your device and your whereabouts.< / p >
< div class = "status" id = "guard" >
< div class = "h" > Theseus is on guard< / div >
< div class = "srow" id = "stShieldRow" > < span id = "stShield" > Shield…< / span > < / div >
< div class = "srow" id = "stConsentRow" > < span id = "stConsent" > Cookie pop-ups…< / span > < / div >
< div class = "srow" id = "stTorRow" > < span id = "stTor" > Tor…< / span > < / div >
< div class = "srow ok" > < span id = "stVersion" > Theseus< / span > < / div >
< / div >
< h2 class = "sub" > Tracking protection< / h2 >
2026-09-27 20:37:30 +02:00
< div class = "row" id = "shieldCard" hidden >
< div class = "txt" > < div class = "t" > Shield — block trackers and ads< / div >
< div class = "d" > Requests to known tracking and advertising hosts never leave Theseus. < span id = "shieldStats" > < / span > < / div >
< div class = "acts" > < button class = "btn small" id = "shieldUpdate" > Update rules< / button > < button class = "btn small ghost" id = "shieldPanel" > Open panel< / button > < / div > < / div >
< label class = "sw" > < input type = "checkbox" id = "shieldOn" > < span class = "track" > < span class = "knob" > < / span > < / span > < / label >
< / div >
< div class = "row" id = "consentCard" hidden >
< div class = "txt" > < div class = "t" > Cookie pop-ups< / div >
< div class = "d" > Consent banners are answered for you before they get in the way. < span id = "consentStats" > < / span > < / div >
< div class = "acts" > < select id = "consentMode" > < option value = "optOut" > Reject all but essentials< / option > < option value = "optIn" > Just make it go away< / option > < / select > < button class = "btn small ghost" id = "consentPanel" > Open panel< / button > < / div > < / div >
< label class = "sw" > < input type = "checkbox" id = "consentOn" > < span class = "track" > < span class = "knob" > < / span > < / span > < / label >
< / div >
2026-09-27 22:01:28 +02:00
< div class = "row link" data-go = "privacy/exceptions" >
< div class = "txt" > < div class = "t" > Manage exceptions< / div > < div class = "d" id = "exSummary" > Sites where Shield is allowed through or cookie pop-ups are left alone.< / div > < / div >
< span class = "chev" > › < / span >
< / div >
feat(theseus): DNS over HTTPS and Global Privacy Control
DNS over HTTPS through Chromium's secure DNS (app.configureHostResolver),
under Privacy › Network: Default protection (encrypted via the chosen
provider, plain if that fails — the default), Increased protection
(always the provider, never plain) or Off, with Quad9, Cloudflare,
Mullvad, AdGuard or a custom resolver URL. Any DoH mode also turns on
Chromium's built-in resolver, as Chrome does. Silent Mode names never
touch DNS, and Tor resolves remotely through the SOCKS proxy, so
neither path goes around it.
Global Privacy Control, on by default, under Tracking protection: the
Sec-GPC header on every request (added in the one request-header hook
beside the client hints) and navigator.globalPrivacyControl in pages.
2026-09-27 22:10:06 +02:00
< div class = "row" >
< div class = "txt" > < div class = "t" > Tell sites not to sell or share my data< / div > < div class = "d" > Sends the Global Privacy Control signal with every request. Sites in California, Colorado, Connecticut and other places with a privacy law must honour it.< / div > < / div >
< label class = "sw" > < input type = "checkbox" id = "gpc" > < span class = "track" > < span class = "knob" > < / span > < / span > < / label >
< / div >
< p class = "note" > Shield and Cookie Pop-ups also sit in the toolbar's extension row if you want them there: right-click the row and choose "Show hidden".< / p >
2026-09-27 22:01:28 +02:00
< h2 class = "sub" > Device access< / h2 >
2026-09-08 01:42:41 +02:00
< div class = "row" >
< div class = "txt" > < div class = "t" > WebRTC IP policy< / div >
< div class = "d" > Controls which IP addresses WebRTC may reveal — the same thing the "WebRTC Network Limiter" extension does, built in. < b > Public interface only< / b > hides your local IP; < b > Disable non-proxied UDP< / b > is strongest. Tor forces the strongest automatically.< / div > < / div >
< div class = "ctl" > < select id = "webrtcMode" >
< option value = "default" > Allow all (default)< / option >
< option value = "public_only" > Public interface only< / option >
< option value = "public_private" > Public + private interfaces< / option >
< option value = "disable_udp" > Disable non-proxied UDP< / option >
< / select > < / div >
< / div >
< div class = "row" >
< div class = "txt" > < div class = "t" > Block camera< / div > < div class = "d" > Deny camera by default — also hides its name from fingerprinting.< / div > < / div >
< label class = "sw" > < input type = "checkbox" id = "blockCamera" > < span class = "track" > < span class = "knob" > < / span > < / span > < / label >
< / div >
< div class = "row" >
< div class = "txt" > < div class = "t" > Block microphone< / div > < div class = "d" > Deny microphone by default — also hides its name from fingerprinting.< / div > < / div >
< label class = "sw" > < input type = "checkbox" id = "blockMicrophone" > < span class = "track" > < span class = "knob" > < / span > < / span > < / label >
< / div >
< div class = "row" >
< div class = "txt" > < div class = "t" > Hide media devices< / div > < div class = "d" > Blank the labels and IDs of all cameras, microphones < b > and speakers< / b > from < code > enumerateDevices()< / code > — closes a WebRTC fingerprinting leak, like Firefox.< / div > < / div >
< label class = "sw" > < input type = "checkbox" id = "hideMediaDevices" > < span class = "track" > < span class = "knob" > < / span > < / span > < / label >
< / div >
< h2 class = "sub" > Anti-fingerprinting< / h2 >
< p class = "subd" > For each: < b > Show< / b > (real), < b > Hide< / b > (neutral value), < b > Spoof< / b > (a decoy), or < b > Manual< / b > (set your own).< / p >
< div class = "row" >
< div class = "txt" > < div class = "t" > Timezone< / div > < div class = "d" > What sites read via JavaScript (Intl / Date).< / div > < / div >
< div class = "ctl" >
< select id = "timezoneMode" > < option value = "show" > Show real< / option > < option value = "hide" > Hide (UTC)< / option > < option value = "spoof" > Spoof (auto)< / option > < option value = "manual" > Manual…< / option > < / select >
<!-- Native <select> instead of an <input list=""> datalist — the
datalist popup was flaky in Electron and never rendered on some
displays; a real select is unambiguous. -->
< select id = "timezoneValue" hidden >
< option value = "UTC" > UTC< / option >
< option value = "Europe/London" > Europe/London< / option >
< option value = "Europe/Berlin" > Europe/Berlin< / option >
< option value = "Europe/Paris" > Europe/Paris< / option >
< option value = "Europe/Madrid" > Europe/Madrid< / option >
< option value = "Europe/Rome" > Europe/Rome< / option >
< option value = "Europe/Moscow" > Europe/Moscow< / option >
< option value = "America/New_York" > America/New_York< / option >
< option value = "America/Chicago" > America/Chicago< / option >
< option value = "America/Denver" > America/Denver< / option >
< option value = "America/Los_Angeles" > America/Los_Angeles< / option >
< option value = "America/Sao_Paulo" > America/Sao_Paulo< / option >
< option value = "America/Mexico_City" > America/Mexico_City< / option >
< option value = "America/Toronto" > America/Toronto< / option >
< option value = "Asia/Tokyo" > Asia/Tokyo< / option >
< option value = "Asia/Shanghai" > Asia/Shanghai< / option >
< option value = "Asia/Seoul" > Asia/Seoul< / option >
< option value = "Asia/Kolkata" > Asia/Kolkata< / option >
< option value = "Asia/Dubai" > Asia/Dubai< / option >
< option value = "Asia/Singapore" > Asia/Singapore< / option >
< option value = "Asia/Bangkok" > Asia/Bangkok< / option >
< option value = "Australia/Sydney" > Australia/Sydney< / option >
< option value = "Australia/Perth" > Australia/Perth< / option >
< option value = "Africa/Nairobi" > Africa/Nairobi< / option >
< option value = "Africa/Cairo" > Africa/Cairo< / option >
< option value = "Africa/Johannesburg" > Africa/Johannesburg< / option >
< option value = "__other__" > Other…< / option >
< / select >
< input id = "timezoneValueOther" type = "text" placeholder = "IANA zone, e.g. America/Anchorage" hidden >
< / div >
< / div >
< div class = "row" >
2026-10-01 00:48:19 +02:00
< div class = "txt" > < div class = "t" > Location< / div > < div class = "d" > < b > Hide< / b > denies geolocation entirely; < b > Manual< / b > reports the coordinates of a country you pick, so a page's < code > navigator.geolocation< / code > answers with a plausible position instead of your real one.< / div > < / div >
Theseus 0.3.63: lazy tab restore, Privacy language simplified, chip reloads the page
Session restore now paints the full strip from the saved titles + favicons and
loads only the ACTIVE tab's page; every other restored tab lives as a dormant
WebContentsView and navigates for the first time when the user clicks it. For
a 20-tab user that drops cold start from 20 renderer loads racing chrome.html
to one, so launch is roughly flat whatever the tab count — fixes the "not
responding" freeze on a session with many restored tabs. session.json is now
v3 ({v:3, tabs:[{url,title,favicon}], active}); v1/v2 session files still
parse (their tabs restore lazy without a cached title, which arrives on first
activation). Reload on a dormant tab materialises it.
Privacy › Anti-fingerprinting › Language is now two modes — Automatic (system
language) and Manual — matching the General › Website language row and the
URL-bar globe chip. The old Spoof-choose top-10 and Hide-en-US modes are gone
from the UI; legacy saved values auto-migrate to Automatic on first open. The
Manual list is the same 24 languages the General row uses, kept in one place
(WEB_LANG_LIST), so all three surfaces stay in sync.
Changing the language via the globe chip or either settings row now reloads
the active tab — the server picked the response body from Accept-Language on
the original request, so an already-rendered page can't adopt the new language
on its own. A reload is what a user clicking a one-click language switch
expects.
The Location row's country dropdown now stacks under the mode dropdown on its
own line when Manual is picked, so an open menu above it can't visually cover
it (the row's flex-row max-60% layout could wrap it where another dropdown's
overlay sat).
Also: the settings-update broadcast now reaches every open settings tab, not
only the chrome — so changing the chip updates both the General Website-
language row and the Privacy Anti-fingerprinting Language row live without a
Settings refresh.
2026-10-02 21:42:31 +02:00
<!-- Column layout so the country dropdown always lands on its own line
below the mode dropdown when Manual is picked — flex-row with a
60% max width was wrapping it where other open menus could cover it. -->
< div class = "ctl" style = "flex-direction:column;align-items:stretch" >
2026-10-01 00:48:19 +02:00
< select id = "locationMode" > < option value = "show" > Show real< / option > < option value = "hide" > Hide (block)< / option > < option value = "manual" > Manual…< / option > < / select >
Theseus 0.3.63: lazy tab restore, Privacy language simplified, chip reloads the page
Session restore now paints the full strip from the saved titles + favicons and
loads only the ACTIVE tab's page; every other restored tab lives as a dormant
WebContentsView and navigates for the first time when the user clicks it. For
a 20-tab user that drops cold start from 20 renderer loads racing chrome.html
to one, so launch is roughly flat whatever the tab count — fixes the "not
responding" freeze on a session with many restored tabs. session.json is now
v3 ({v:3, tabs:[{url,title,favicon}], active}); v1/v2 session files still
parse (their tabs restore lazy without a cached title, which arrives on first
activation). Reload on a dormant tab materialises it.
Privacy › Anti-fingerprinting › Language is now two modes — Automatic (system
language) and Manual — matching the General › Website language row and the
URL-bar globe chip. The old Spoof-choose top-10 and Hide-en-US modes are gone
from the UI; legacy saved values auto-migrate to Automatic on first open. The
Manual list is the same 24 languages the General row uses, kept in one place
(WEB_LANG_LIST), so all three surfaces stay in sync.
Changing the language via the globe chip or either settings row now reloads
the active tab — the server picked the response body from Accept-Language on
the original request, so an already-rendered page can't adopt the new language
on its own. A reload is what a user clicking a one-click language switch
expects.
The Location row's country dropdown now stacks under the mode dropdown on its
own line when Manual is picked, so an open menu above it can't visually cover
it (the row's flex-row max-60% layout could wrap it where another dropdown's
overlay sat).
Also: the settings-update broadcast now reaches every open settings tab, not
only the chrome — so changing the chip updates both the General Website-
language row and the Privacy Anti-fingerprinting Language row live without a
Settings refresh.
2026-10-02 21:42:31 +02:00
< select id = "locationCountry" hidden > <!-- Populated in JS from the COUNTRIES table --> < / select >
2026-09-08 01:42:41 +02:00
< / div >
< / div >
< div class = "note" > These reduce tracking and hide your IP, but a custom browser can still be fingerprinted. For maximum anonymity, use the Tor Browser.< / div >
2026-09-27 22:01:28 +02:00
< h2 class = "sub" > Network< / h2 >
< div class = "row" >
< div class = "txt" > < div class = "t" > Tor onion routing< / div > < div class = "d" id = "torStat" > Off< / div > < / div >
< label class = "sw" > < input type = "checkbox" id = "torOn" > < span class = "track" > < span class = "knob" > < / span > < / span > < / label >
< / div >
< div class = "row link" id = "vpnRow" >
< div class = "txt" > < div class = "t" > VPN< / div > < div class = "d" > Route everything through a Silent Mode exit, or your own. Managed in its panel.< / div > < / div >
< span class = "chev" > › < / span >
< / div >
feat(theseus): DNS over HTTPS and Global Privacy Control
DNS over HTTPS through Chromium's secure DNS (app.configureHostResolver),
under Privacy › Network: Default protection (encrypted via the chosen
provider, plain if that fails — the default), Increased protection
(always the provider, never plain) or Off, with Quad9, Cloudflare,
Mullvad, AdGuard or a custom resolver URL. Any DoH mode also turns on
Chromium's built-in resolver, as Chrome does. Silent Mode names never
touch DNS, and Tor resolves remotely through the SOCKS proxy, so
neither path goes around it.
Global Privacy Control, on by default, under Tracking protection: the
Sec-GPC header on every request (added in the one request-header hook
beside the client hints) and navigator.globalPrivacyControl in pages.
2026-09-27 22:10:06 +02:00
< div class = "row" >
< div class = "txt" > < div class = "t" > DNS over HTTPS< / div > < div class = "d" > Encrypts name lookups so your network cannot see or alter which sites you are about to visit. Silent Mode names never use DNS; this covers the rest of the web.< / div > < / div >
< div class = "ctl" > < select id = "dohMode" >
< option value = "automatic" > Default protection< / option >
< option value = "secure" > Increased protection< / option >
< option value = "off" > Off< / option >
< / select > < / div >
< / div >
< div class = "row" id = "dohProviderRow" >
< div class = "txt" > < div class = "t" > Provider< / div > < div class = "d" id = "dohHelp" > < / div > < / div >
< div class = "ctl" >
< select id = "dohProvider" >
< option value = "quad9" > Quad9< / option >
< option value = "cloudflare" > Cloudflare< / option >
< option value = "mullvad" > Mullvad< / option >
< option value = "adguard" > AdGuard< / option >
< option value = "custom" > Custom…< / option >
< / select >
< input id = "dohCustom" type = "text" placeholder = "https://…/dns-query" hidden style = "margin-top:6px;width:260px" >
< / div >
< / div >
2026-09-27 22:01:28 +02:00
< h2 class = "sub" > Browsing data< / h2 >
2026-09-08 01:42:41 +02:00
< p class = "subd" > By default Theseus keeps < b > nothing< / b > across sessions — everything toggled on here is wiped when you quit. Untoggle a bucket to keep it (e.g. cookies to stay signed in on trusted sites).< / p >
< div class = "row" >
< div class = "txt" > < div class = "t" > Clear cookies on quit< / div > < div class = "d" > Drops session + persistent cookies. You'll sign in again next launch.< / div > < / div >
< label class = "sw" > < input type = "checkbox" id = "clearCookiesOnQuit" > < span class = "track" > < span class = "knob" > < / span > < / span > < / label >
< / div >
< div class = "row" >
< div class = "txt" > < div class = "t" > Clear HTTP cache on quit< / div > < div class = "d" > Drops cached images / scripts / stylesheets. Sites re-download; small disk win.< / div > < / div >
< label class = "sw" > < input type = "checkbox" id = "clearCacheOnQuit" > < span class = "track" > < span class = "knob" > < / span > < / span > < / label >
< / div >
< div class = "row" >
< div class = "txt" > < div class = "t" > Clear site storage on quit< / div > < div class = "d" > Drops localStorage, IndexedDB, service workers, and the cache API. Web-app state resets.< / div > < / div >
< label class = "sw" > < input type = "checkbox" id = "clearStorageOnQuit" > < span class = "track" > < span class = "knob" > < / span > < / span > < / label >
< / div >
< div class = "row" >
2026-10-03 23:01:56 +02:00
< 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 >
2026-09-08 01:42:41 +02:00
< 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" >
< button id = "clearNow" class = "btn" type = "button" > Clear all now< / button >
< / div >
2026-10-04 04:10:50 +02:00
< div class = "note" > These switches never touch the password vault: logins saved in Settings › Passwords stay in its encrypted vault.< / div >
2026-09-08 01:42:41 +02:00
< / section >
<!-- ADD - ONS -->
2026-09-27 22:01:28 +02:00
<!-- PRIVACY › EXCEPTIONS (sub - page) -->
< section id = "privacy-exceptions" class = "subpage" data-parent = "privacy" hidden >
< div class = "crumb" > < a data-go = "privacy" > Privacy< / a > › Exceptions< / div >
< h1 > < button class = "back" data-go = "privacy" title = "Back to Privacy" aria-label = "Back" > ‹ < / button > Exceptions< / h1 >
< p class = "lede" > Sites you asked Shield or Cookie Pop-ups to leave alone. Remove one to protect it again.< / p >
< h2 class = "sub" > Shield allowed through< / h2 >
< div class = "exlist" id = "shieldEx" > < / div >
< h2 class = "sub" > Cookie pop-ups left alone< / h2 >
< div class = "exlist" id = "consentEx" > < / div >
< / section >
2026-09-08 01:42:41 +02:00
< section id = "addons" hidden >
< h1 > Extensions< / h1 >
< p class = "lede" > Small modules that add capabilities to Theseus. Extensions live as folders under
2026-09-21 01:55:25 +02:00
< code style = "background:transparent;border:none;padding:0" id = "addonsPathHint" > %APPDATA%\Theseus\extensions\< / code > . Drop a folder in, restart, it's live.
2026-09-08 01:42:41 +02:00
Bundled reference extensions (like the Notepad) are copied there on first run — you can edit or remove them
without losing anything the browser needs.< / p >
feat(theseus/settings): Extensions as compact rows with a detail view
The Extensions list was a stack of tall cards — description, author,
capabilities and buttons on every one — so seven add-ons filled the
page before the user found the toggle. Rows are now one line each,
Firefox-style: icon, name, built-in badge, version, a short update
status, the on/off switch and a ⋯ menu, grouped Enabled / Disabled /
Failed to load. Clicking a row opens the detail view in place: back
arrow, description, update status, author, version, type, folder with
Show folder, and a Permissions block that explains each declared
capability in plain words. The ⋯ menu (and right-click) offers Turn
on/off, Details, Show folder and, for non-bundled extensions, Remove —
a new addons-remove IPC that deletes the folder under the extensions
directory, refuses bundled add-ons (they would only be reseeded), and
clears the dock prefs it left behind.
2026-09-22 20:43:50 +02:00
< div id = "addonsMain" >
2026-09-08 01:42:41 +02:00
< div class = "row" style = "justify-content:flex-end;gap:8px" >
2026-09-20 15:33:33 +02:00
< a class = "btn" style = "text-decoration:none;display:inline-flex;align-items:center" href = "https://theseus.x/extensions/" target = "_blank" rel = "noopener" title = "What ships, current signed versions, and how updates are verified" > Extensions ↗< / a >
2026-09-08 02:27:36 +02:00
< button id = "addonsCheckUpdates" class = "btn" type = "button" > Check for updates< / button >
2026-09-08 01:42:41 +02:00
< button id = "addonsReload" class = "btn" type = "button" > Reload< / button >
< button id = "addonsOpenDir" class = "btn" type = "button" > Open extensions folder< / button >
< / div >
2026-09-08 02:27:36 +02:00
< div id = "addonsUpdStatus" class = "pmuted" style = "font-size:12.5px;margin-top:6px;text-align:right" > —< / div >
2026-09-08 01:42:41 +02:00
< h2 class = "sub" style = "border-top:0;padding-top:0;margin-top:1.5rem" > Installed< / h2 >
< div id = "addonsList" > < div class = "d" style = "color:var(--dim)" > Loading…< / div > < / div >
2026-09-21 03:28:04 +02:00
< div class = "note" style = "margin-top:1.5rem" > To install a new extension, browse the catalogue at
< a href = "https://theseus.x/extensions/" target = "_blank" rel = "noopener" > theseus.x/extensions< / a >
and use the Install button there. Theseus checks each publisher's signature against the name's current
owner on chain before installing — the relay can't substitute code under a trusted name. Extensions run
with full app access — treat installing one like installing an unsigned executable, and only load
extensions whose source you trust.< / div >
feat(theseus/settings): Extensions as compact rows with a detail view
The Extensions list was a stack of tall cards — description, author,
capabilities and buttons on every one — so seven add-ons filled the
page before the user found the toggle. Rows are now one line each,
Firefox-style: icon, name, built-in badge, version, a short update
status, the on/off switch and a ⋯ menu, grouped Enabled / Disabled /
Failed to load. Clicking a row opens the detail view in place: back
arrow, description, update status, author, version, type, folder with
Show folder, and a Permissions block that explains each declared
capability in plain words. The ⋯ menu (and right-click) offers Turn
on/off, Details, Show folder and, for non-bundled extensions, Remove —
a new addons-remove IPC that deletes the folder under the extensions
directory, refuses bundled add-ons (they would only be reseeded), and
clears the dock prefs it left behind.
2026-09-22 20:43:50 +02:00
< / div >
< div id = "addonDetail" hidden > < / div >
2026-09-08 01:42:41 +02:00
< / section >
< / div >
< / div >
< script >
const C = window.cfg;
Theseus 0.3.63: lazy tab restore, Privacy language simplified, chip reloads the page
Session restore now paints the full strip from the saved titles + favicons and
loads only the ACTIVE tab's page; every other restored tab lives as a dormant
WebContentsView and navigates for the first time when the user clicks it. For
a 20-tab user that drops cold start from 20 renderer loads racing chrome.html
to one, so launch is roughly flat whatever the tab count — fixes the "not
responding" freeze on a session with many restored tabs. session.json is now
v3 ({v:3, tabs:[{url,title,favicon}], active}); v1/v2 session files still
parse (their tabs restore lazy without a cached title, which arrives on first
activation). Reload on a dormant tab materialises it.
Privacy › Anti-fingerprinting › Language is now two modes — Automatic (system
language) and Manual — matching the General › Website language row and the
URL-bar globe chip. The old Spoof-choose top-10 and Hide-en-US modes are gone
from the UI; legacy saved values auto-migrate to Automatic on first open. The
Manual list is the same 24 languages the General row uses, kept in one place
(WEB_LANG_LIST), so all three surfaces stay in sync.
Changing the language via the globe chip or either settings row now reloads
the active tab — the server picked the response body from Accept-Language on
the original request, so an already-rendered page can't adopt the new language
on its own. A reload is what a user clicking a one-click language switch
expects.
The Location row's country dropdown now stacks under the mode dropdown on its
own line when Manual is picked, so an open menu above it can't visually cover
it (the row's flex-row max-60% layout could wrap it where another dropdown's
overlay sat).
Also: the settings-update broadcast now reaches every open settings tab, not
only the chrome — so changing the chip updates both the General Website-
language row and the Privacy Anti-fingerprinting Language row live without a
Settings refresh.
2026-10-02 21:42:31 +02:00
// Shared language picker list (same as WEBSITE_LANGUAGE_QUICK in main.js and
// the globe menu). Used by General › Website language AND Privacy › Anti-
// fingerprinting › Language — both edit the same languageMode/languageValue
// setting, so they share the same list.
const WEB_LANG_LIST = [
{ tag: "en-GB", label: "English" },
{ tag: "es-ES", label: "Español" },
{ tag: "fr-FR", label: "Français" },
{ tag: "pt-PT", label: "Português" },
{ tag: "ru-RU", label: "Русский" },
{ tag: "de-DE", label: "Deutsch" },
{ tag: "it-IT", label: "Italiano" },
{ tag: "tr-TR", label: "Türkçe" },
{ tag: "pl-PL", label: "Polski" },
{ tag: "nl-NL", label: "Nederlands" },
{ tag: "el-GR", label: "Ελληνικά" },
{ tag: "cs-CZ", label: "Čeština" },
{ tag: "sv-SE", label: "Svenska" },
{ tag: "fi-FI", label: "Suomi" },
{ tag: "zh-CN", label: "中文(简体)" },
{ tag: "hi-IN", label: "हिन्दी" },
{ tag: "ar", label: "العربية" },
{ tag: "id-ID", label: "Bahasa Indonesia" },
{ tag: "ja-JP", label: "日本語" },
{ tag: "vi-VN", label: "Tiếng Việt" },
{ tag: "ko-KR", label: "한국어" },
{ tag: "th-TH", label: "ไทย" },
{ tag: "he-IL", label: "עברית" },
{ tag: "zh-TW", label: "中文(繁體)" },
];
2026-10-02 23:04:59 +02:00
// Updates panel — intentionally runs STANDALONE, before anything else.
// Previously this lived inside the big C.get().then((s)=>…) settings init,
// and any throw earlier in that block (an unrelated feature failing on a
// specific profile) left this panel stuck on "Loading…" forever because
// the version read never ran. It only needs the appVersion/recheckUpdate
// IPCs and the two DOM elements — all available at script time — so run it
// immediately and in its own try, isolated from everything else.
(function initUpdatesPanel() {
const updStatus = document.getElementById("updStatus");
const updCheck = document.getElementById("updCheck");
if (!updStatus) return;
try {
if (C & & typeof C.appVersion === "function") {
C.appVersion().then((v) => {
updStatus.textContent = v ? `You're on v${v}.` : "Version unavailable — try Check for updates.";
}).catch((e) => {
updStatus.textContent = "Could not read version: " + (e?.message || e) + " — try Check for updates.";
});
} else {
updStatus.textContent = "This build is missing the version IPC — restart Theseus to refresh the panel.";
}
if (updCheck) updCheck.onclick = async () => {
const orig = updCheck.textContent;
updCheck.disabled = true; updCheck.textContent = "Checking…";
updStatus.textContent = "Checking…";
try {
if (!C || typeof C.recheckUpdate !== "function") throw new Error("recheck-update IPC not exposed");
const r = await C.recheckUpdate();
if (r & & r.updateAvailable & & r.updateAvailable.version) {
updStatus.innerHTML = `< b style = "color:var(--acid-text)" > v${r.updateAvailable.version}< / b > is available — the update chip in the toolbar will offer it.`;
} else {
updStatus.textContent = `You're on the latest (v${(r & & r.currentVersion) || "?"}).`;
}
} catch (e) { updStatus.textContent = "Check failed: " + (e?.message || e); }
finally { updCheck.disabled = false; updCheck.textContent = orig; }
};
} catch (e) {
updStatus.textContent = "Updates panel init failed: " + (e?.message || e);
console.error("Updates panel init failed:", e);
}
})();
2026-09-08 01:42:41 +02:00
// sidebar navigation
2026-09-08 02:23:27 +02:00
// "naming" is intentionally absent: the Registries section was folded
// into General in 0.3.21. Leaving the id in this list threw a
// TypeError inside showSection (getElementById("naming") → null),
// which broke every click after General/Search/Passwords.
2026-09-27 22:01:28 +02:00
// Pages are < section id = "..." > elements; a sub-page is < section id = "parent-sub"
// class="subpage" data-parent="parent"> and is addressed as "parent/sub". The
// address bar follows (theseus://settings/< slug > ) and the hash mirrors it, so
// every page has a link.
const sections = [...document.querySelectorAll("section[id]")].map((x) => x.id);
let currentSlug = "general";
function showSection(slug) {
slug = String(slug || "general").toLowerCase().replace(/^#/, "").replace(/^\/+|\/+$/g, "");
const [sec, sub] = slug.split("/");
const id = sub ? `${sec}-${sub}` : sec;
if (!sections.includes(id)) return false;
2026-09-08 01:42:41 +02:00
document.querySelectorAll(".side a").forEach((x) => x.classList.toggle("active", x.dataset.sec === sec));
2026-09-27 22:01:28 +02:00
for (const s of sections) document.getElementById(s).hidden = (s !== id);
try { document.querySelector(".content").scrollTop = 0; } catch {}
currentSlug = sub ? `${sec}/${sub}` : sec;
try { history.replaceState(null, "", "#" + currentSlug); } catch {}
try { C & & C.reportSection & & C.reportSection(currentSlug); } catch {}
document.dispatchEvent(new CustomEvent("section", { detail: currentSlug }));
return true;
2026-09-08 01:42:41 +02:00
}
document.querySelectorAll(".side a").forEach((a) => a.onclick = () => showSection(a.dataset.sec));
2026-09-27 22:01:28 +02:00
document.addEventListener("click", (e) => { const g = e.target.closest("[data-go]"); if (g) { e.preventDefault(); showSection(g.dataset.go); } });
// Main-process asks us to jump to a page (open-settings, theseus://settings/… links).
2026-09-08 01:42:41 +02:00
if (C & & C.onFocusSection) C.onFocusSection((sec) => showSection(sec));
2026-09-27 22:01:28 +02:00
// Land on the page named by `settings.html#< slug > ` when opened via main.
feat(theseus/aegis): 0.6.1 — in-panel vault setup/unlock, BCH wallet imports, opt-in fiat prices, WizardConnect
Aegis Wallet 0.4.4 → 0.6.1:
- Vault lifecycle from the wallet gate. The locked / not-yet-created states
now show a master-password form (with optional BIP39 mnemonic on setup)
instead of redirecting users to Settings › Passwords. New
api.vault.lifecycle {status, setup, unlock, lock} in addons-host, gated by
the existing "vault-derive" capability. api.openSettings(section) also
added; settings.html honours a #section hash on open.
- Imported BCH wallets (design M.1a, read-only). Paste a mnemonic + BIP44
path or a WIF; the cashaddr is derived in the add-on, the signer material
goes to a separate wallet-imports.enc via api.vault.imports {list, add,
remove, signer}. Argus password-vault gains createImports / unlockImports /
saveImports with its own KDF salt so the imports key is disjoint from the
passwords key. lib/chain-bch-imported.js is a single-address Electrum
adapter; spend support is deferred to M.1b.
- Opt-in USD prices via CoinGecko (lib/prices.js), off by default, persisted
in add-on storage. Fiat lines under balances, in the wallet picker, and a
portfolio total when 2+ wallets are open. Settings tab is now reachable
while the vault is locked so the toggle is always available.
- WizardConnect wallet-side pairing for BCH wallets (lib/wc.js, lib/wc-sign.js).
@wizardconnect/{core,wallet} are loaded dynamically via api.import to stay
on the right side of LGPL §4d. Sign requests go through approvalModal and
are restricted to P2PKH inputs with SIGHASH_ALL|FORKID|UTXOS.
- DGB adapter load is now soft-fail: when Aegis runs from userData/addons the
bundled ESM can't resolve peer deps, so DGB becomes unavailable instead of
taking the whole add-on down.
2026-09-09 10:33:21 +02:00
try {
const initSec = String(location.hash || "").replace(/^#/, "").toLowerCase();
2026-09-27 22:01:28 +02:00
showSection(initSec || "general");
feat(theseus/aegis): 0.6.1 — in-panel vault setup/unlock, BCH wallet imports, opt-in fiat prices, WizardConnect
Aegis Wallet 0.4.4 → 0.6.1:
- Vault lifecycle from the wallet gate. The locked / not-yet-created states
now show a master-password form (with optional BIP39 mnemonic on setup)
instead of redirecting users to Settings › Passwords. New
api.vault.lifecycle {status, setup, unlock, lock} in addons-host, gated by
the existing "vault-derive" capability. api.openSettings(section) also
added; settings.html honours a #section hash on open.
- Imported BCH wallets (design M.1a, read-only). Paste a mnemonic + BIP44
path or a WIF; the cashaddr is derived in the add-on, the signer material
goes to a separate wallet-imports.enc via api.vault.imports {list, add,
remove, signer}. Argus password-vault gains createImports / unlockImports /
saveImports with its own KDF salt so the imports key is disjoint from the
passwords key. lib/chain-bch-imported.js is a single-address Electrum
adapter; spend support is deferred to M.1b.
- Opt-in USD prices via CoinGecko (lib/prices.js), off by default, persisted
in add-on storage. Fiat lines under balances, in the wallet picker, and a
portfolio total when 2+ wallets are open. Settings tab is now reachable
while the vault is locked so the toggle is always available.
- WizardConnect wallet-side pairing for BCH wallets (lib/wc.js, lib/wc-sign.js).
@wizardconnect/{core,wallet} are loaded dynamically via api.import to stay
on the right side of LGPL §4d. Sign requests go through approvalModal and
are restricted to P2PKH inputs with SIGHASH_ALL|FORKID|UTXOS.
- DGB adapter load is now soft-fail: when Aegis runs from userData/addons the
bundled ESM can't resolve peer deps, so DGB becomes unavailable instead of
taking the whole add-on down.
2026-09-09 10:33:21 +02:00
} catch {}
2026-09-08 01:42:41 +02:00
2026-09-27 20:37:30 +02:00
// Protections (Performance): Shield + Cookie Pop-ups, driven through the
// add-ons' own message handlers. A card hides when its add-on is off.
(function () {
const inv = (id, msg, p) => (C.addonInvoke ? C.addonInvoke(id, msg, p) : Promise.reject(new Error("unavailable")));
const el = (i) => document.getElementById(i);
async function refresh() {
try {
const s = await inv("blocker", "state");
el("shieldOn").checked = !!s.enabled;
const L = s.lists || {};
el("shieldStats").textContent = `${Number(s.blockedTotal || 0).toLocaleString()} blocked since install · rules ${L.source === "online" & & L.updatedAt ? "updated " + new Date(L.updatedAt).toLocaleDateString() : "bundled"}${s.refreshing ? " · refreshing…" : ""}`;
el("shieldCard").hidden = false;
} catch { el("shieldCard").hidden = true; }
try {
const s = await inv("consent", "state");
el("consentOn").checked = !!s.enabled; el("consentMode").value = s.mode || "optOut";
el("consentStats").textContent = `${Number(s.handledTotal || 0).toLocaleString()} pop-ups answered since install.`;
el("consentCard").hidden = false;
} catch { el("consentCard").hidden = true; }
}
el("shieldOn").addEventListener("change", () => inv("blocker", "set-enabled", { enabled: el("shieldOn").checked }).then(refresh, refresh));
el("shieldUpdate").addEventListener("click", () => { el("shieldUpdate").disabled = true; inv("blocker", "refresh-lists").catch(() => {}).then(() => { el("shieldUpdate").disabled = false; refresh(); }); });
el("shieldPanel").addEventListener("click", () => C.openPanel & & C.openPanel("blocker:main"));
el("consentOn").addEventListener("change", () => inv("consent", "set-enabled", { enabled: el("consentOn").checked }).then(refresh, refresh));
el("consentMode").addEventListener("change", () => inv("consent", "set-mode", { mode: el("consentMode").value }).then(refresh, refresh));
el("consentPanel").addEventListener("click", () => C.openPanel & & C.openPanel("consent:main"));
2026-09-27 22:01:28 +02:00
// Status card, Network rows and the exceptions sub-page.
function renderList(id, hosts, onRemove) {
const box = el(id); box.innerHTML = "";
if (!hosts || !hosts.length) { box.innerHTML = '< p class = "exempty" > No sites yet.< / p > '; return; }
for (const h of hosts) {
const row = document.createElement("div"); row.className = "exrow";
const name = document.createElement("span"); name.textContent = h;
const btn = document.createElement("button"); btn.className = "btn small ghost"; btn.type = "button"; btn.textContent = "Remove";
btn.onclick = () => { btn.disabled = true; onRemove(h); };
row.append(name, btn); box.appendChild(row);
}
}
async function refreshExtra() {
let shieldEx = [], consentEx = [];
try { shieldEx = (await inv("blocker", "list-exceptions")) || []; } catch {}
try { consentEx = (await inv("consent", "list-exceptions")) || []; } catch {}
renderList("shieldEx", shieldEx, (h) => inv("blocker", "allow-site", { host: h, allowed: false }).then(refreshAll, refreshAll));
renderList("consentEx", consentEx, (h) => inv("consent", "skip-site", { host: h, skipped: false }).then(refreshAll, refreshAll));
const n = shieldEx.length + consentEx.length;
el("exSummary").textContent = n ? `${n} site${n === 1 ? "" : "s"}: ${[...shieldEx, ...consentEx].slice(0, 3).join(", ")}${n > 3 ? "…" : ""}` : "Sites where Shield is allowed through or cookie pop-ups are left alone.";
try {
const s = await inv("blocker", "state");
el("stShield").textContent = s.enabled ? `Shield is on — ${Number(s.blockedTotal || 0).toLocaleString()} requests blocked so far` : "Shield is off";
el("stShieldRow").className = "srow " + (s.enabled ? "ok" : "warn");
} catch { el("stShield").textContent = "Shield is not installed"; el("stShieldRow").className = "srow warn"; }
try {
const s = await inv("consent", "state");
el("stConsent").textContent = s.enabled ? `Cookie pop-ups are answered for you — ${Number(s.handledTotal || 0).toLocaleString()} so far` : "Cookie pop-ups are left to you";
el("stConsentRow").className = "srow " + (s.enabled ? "ok" : "warn");
} catch { el("stConsent").textContent = "Cookie Pop-ups is not installed"; el("stConsentRow").className = "srow warn"; }
try {
const t = C.torState ? await C.torState() : "off";
el("torOn").checked = t !== "off";
el("torStat").textContent = t === "on" ? "On — page traffic leaves through the Tor network." : t === "connecting" ? "Connecting…" : "Off — traffic goes out directly (or through the VPN when it is on).";
el("stTor").textContent = t === "on" ? "Tor is on" : t === "connecting" ? "Tor is connecting" : "Tor is off — turn it on below when you need it";
el("stTorRow").className = "srow " + (t === "on" ? "ok" : "");
} catch {}
try { const v = C.appVersion ? await C.appVersion() : ""; el("stVersion").textContent = v ? `Theseus ${v}` : "Theseus"; } catch {}
}
const refreshAll = () => refresh().then(refreshExtra, refreshExtra);
el("torOn").addEventListener("change", () => { if (C.toggleTor) C.toggleTor().catch(() => {}); setTimeout(refreshExtra, 1200); setTimeout(refreshExtra, 4000); });
2026-10-01 00:48:19 +02:00
// VPN row: only offer it when the VPN add-on is installed AND its panel
// registered. Otherwise clicking it used to open whichever add-on was
// first in the sidebar list (usually Aegis). Hide it in that case.
(async () => {
const vpnRow = el("vpnRow");
try {
const list = await (C.listAddons ? C.listAddons() : Promise.resolve({}));
const panels = (list & & list.sidebarPanels) || [];
const vpn = panels.find((p) => p.panelId === "vpn:main");
if (!vpn) { vpnRow.hidden = true; return; }
vpnRow.addEventListener("click", () => C.openPanel & & C.openPanel("vpn:main"));
} catch { vpnRow.hidden = true; }
})();
2026-09-27 22:01:28 +02:00
document.addEventListener("section", (e) => { if (String(e.detail).startsWith("privacy")) refreshAll(); });
refreshAll();
2026-09-27 20:37:30 +02:00
})();
feat(theseus): DNS over HTTPS and Global Privacy Control
DNS over HTTPS through Chromium's secure DNS (app.configureHostResolver),
under Privacy › Network: Default protection (encrypted via the chosen
provider, plain if that fails — the default), Increased protection
(always the provider, never plain) or Off, with Quad9, Cloudflare,
Mullvad, AdGuard or a custom resolver URL. Any DoH mode also turns on
Chromium's built-in resolver, as Chrome does. Silent Mode names never
touch DNS, and Tor resolves remotely through the SOCKS proxy, so
neither path goes around it.
Global Privacy Control, on by default, under Tracking protection: the
Sec-GPC header on every request (added in the one request-header hook
beside the client hints) and navigator.globalPrivacyControl in pages.
2026-09-27 22:10:06 +02:00
// DNS over HTTPS controls: mode select, provider select (+ custom URL) shown
// unless the mode is Off; the help line explains the current mode.
(function () {
const el = (i) => document.getElementById(i);
const HELP = {
automatic: "Encrypted when your network's resolver offers it, plain otherwise. The provider below is used when the system's resolver has no encrypted endpoint.",
secure: "Always encrypted through the chosen provider, never plain. If the provider is unreachable, sites will not load.",
off: "Lookups go to the system resolver in the clear.",
};
function show(mode, provider) {
el("dohProviderRow").hidden = mode === "off";
el("dohCustom").hidden = provider !== "custom";
el("dohHelp").textContent = HELP[mode] || HELP.automatic;
}
C.get().then((s) => {
el("dohMode").value = s.dohMode || "automatic"; el("dohProvider").value = s.dohProvider || "quad9"; el("dohCustom").value = s.dohCustom || "";
show(el("dohMode").value, el("dohProvider").value);
el("dohMode").addEventListener("change", () => { C.set("dohMode", el("dohMode").value); show(el("dohMode").value, el("dohProvider").value); });
el("dohProvider").addEventListener("change", () => { C.set("dohProvider", el("dohProvider").value); show(el("dohMode").value, el("dohProvider").value); if (el("dohProvider").value === "custom") el("dohCustom").focus(); });
el("dohCustom").addEventListener("change", () => C.set("dohCustom", el("dohCustom").value.trim()));
});
})();
Theseus: start extensions on first use, Startup switches in Settings
Every enabled add-on used to be activated synchronously in initAddons(),
during app.whenReady and before the window exists. That is the largest
launch cost left (boot tracer, 2026-10-03). An add-on can now say
"activation": "on-demand" in addon.json. It is then listed at launch but
not started. Its declared surfaces stay live: "panels" (new: sidebar
panels declared up front), toolbar-menu, context-menu-items and the
page-inject bridge, whose source is read on the first matching page.
activate() runs on first real use: a panel opened, a menu or context
item picked, a message from its panel, tab or page bridge, a Settings
addon-invoke, a wiz:// link (for Aegis). All of those go through
AddonHost.dispatch(), which starts the add-on and waits for it, so no
call is dropped. Concurrent callers share one activation, and
activations run one at a time.
Startup stays the default: the host cannot tell what an older add-on
does in activate(). request-filter add-ons and add-ons that declare no
surface are forced to startup. If activate() returns a promise, calls
wait for it (at most 5 s). api.startAtLaunch(bool) lets an on-demand
add-on ask to be started at launch again (for live relay sessions).
Converted: notepad, screenshot, translate, docx-editor, pdf-editor, vpn
(none has launch-time work: no file association, no auto-connect, and
add-on file tabs are not part of the saved session). Shield and Cookie
Pop-ups stay startup: Shield owns the request filter and must see the
first request; Cookie Pop-ups costs ~6 ms and acts unasked on every
page. Aegis stays startup and untouched: another session owns it. See
NOTE-aegis-on-demand.md (next commit).
Settings › Performance › Startup:
- "Start extensions when first used" (default on). Off = all at launch;
switching it off starts the waiting add-ons immediately.
- "Start the wallet at launch". Shown disabled with a hint until the
installed Aegis manifest allows on-demand. It applies with no Settings
change once Aegis opts in.
- "Preload common menus" gates prewarmOverlays().
- "Use lightest" preset.
New keys are plain SETTINGS_DEFAULTS through the existing settings-set.
No new IPC channels.
Measured: boot-trace, fresh profile, --seconds 20 so the 30 s add-on OTA
poll can't swap Aegis mid-series; warm runs 2-3 of two paired series.
- Add-on activation at launch: 121-154 ms -> 104-174 ms. The six
converted add-ons went from 16-20 ms to 0. The rest is Shield (83-148
ms, noisy) and Aegis (15 ms in this tree's 0.9.0).
- Toolbar painted: 1278-1584 ms -> 1282-1481 ms (within noise).
- With "Preload common menus" off: 0 overlays prewarmed, 8 processes
instead of 11, about 50-70 MB less at 15 s.
The bundled add-on versions are not bumped. Existing profiles keep their
old addon.json, and so stay on startup activation, until those add-ons
ship with a higher version (seedBundledAddons only reseeds a strictly
newer bundle).
2026-10-03 16:05:32 +02:00
// Performance › Startup. The three switches are plain TOGGLES; this adds
// what they can't say on their own: how many extensions are running now,
// whether the wallet switch can do anything yet (only once Aegis's
// manifest allows on-demand starts — until then it always starts at
// launch, so the switch is shown but off-limits), and the preset.
(function () {
const $ = (id) => document.getElementById(id);
const onDemand = $("extensionsOnDemand"), wallet = $("walletAtLaunch"), menus = $("preloadMenus");
if (!onDemand || !wallet || !menus) return;
let aegisOnDemand = false;
function syncWallet() {
const usable = aegisOnDemand & & onDemand.checked;
wallet.disabled = !usable;
$("walletAtLaunchRow").style.opacity = usable ? "" : ".55";
$("walletAtLaunchHint").textContent = !aegisOnDemand
? "Aegis still starts with Theseus every time, so there is nothing to choose yet. This switch takes over once Aegis can start on first use."
: !onDemand.checked
? "Every extension starts at launch while the switch above is off."
: "Aegis is ready the moment a site asks for it, at the cost of a slower start. Off, it starts the first time a site, a link or its panel needs it.";
}
async function refresh() {
let list = {};
try { list = await (C.listAddons ? C.listAddons() : Promise.resolve({})); } catch {}
const inst = (list.installed || []).filter((a) => a.id & & !a.error);
const aegis = inst.find((a) => a.id === "aegis");
$("walletAtLaunchRow").hidden = !aegis;
aegisOnDemand = !!(aegis & & aegis.activation === "on-demand");
const on = inst.filter((a) => a.enabled);
$("startupNow").textContent = on.length ? `Running now: ${on.filter((a) => a.running).length} of ${on.length}.` : "";
syncWallet();
}
onDemand.addEventListener("change", syncWallet);
$("lightStart").addEventListener("click", () => {
const want = { extensionsOnDemand: true, walletAtLaunch: false, preloadMenus: false };
for (const [k, v] of Object.entries(want)) { $(k).checked = v; C.set(k, v); }
syncWallet();
});
refresh();
// Running counts change as extensions start; refresh when the page is shown.
document.addEventListener("visibilitychange", () => { if (!document.hidden) refresh(); });
})();
const TOGGLES = ["restoreSession", "backgroundThrottle", "freezeBackgroundTabs", "extensionsOnDemand", "walletAtLaunch", "preloadMenus", "blockCamera", "blockMicrophone", "hideMediaDevices", "gpc",
2026-10-02 23:49:39 +02:00
"clearCookiesOnQuit", "clearCacheOnQuit", "clearStorageOnQuit", "clearHistoryOnQuit",
Theseus: in-page translator (LibreTranslate client)
The Website-language setting only tells servers what the user prefers via
Accept-Language — many static sites (including names on BCDN) serve one
language and ignore it, so e.g. hello.bch loads in English for every user,
in every language. This adds a translator that converts the page's visible
text in place, so a Lithuanian user reads hello.bch in Lithuanian without
asking the server for anything.
The URL-bar grows a translate chip next to the website-language globe. The
chip lights up when the page's declared `<html lang>` differs from the
user's preferred language. Click it once to translate in place; click again
to revert — originals are kept in a renderer-local state slot and swapped
back without a reload. Right-click opens the chip menu (change target /
translator settings).
The engine lives behind a swappable adapter in main — this ships with the
LibreTranslate backend (POST /translate with {q, source, target, format}).
The endpoint defaults to the LibreTranslate public tier but is settable in
Settings › General › Translate pages, so a user with a self-hosted
LibreTranslate (or Silent Mode's own translate.silentmode.st once it is
up) swaps it there without a code change. On-device Bergamot (the WASM
engine Firefox Translations uses) will plug into the same adapter in a
later release — same contract (array of texts in, array of translations
out), the chip and revert path are already engine-agnostic.
The fetch goes through session.defaultSession.fetch so Tor and add-on
proxy rules apply uniformly, chunks the batch at ~3.8 KB per POST so a
large page spreads across several requests, times each one out at 45 s,
and reports a failure to the chip's tooltip so a dead endpoint reads as
such and not as a silent no-op. The injected walker skips SCRIPT / STYLE
/ CODE / PRE / NOSCRIPT / TEXTAREA and contentEditable subtrees, keeps a
reference to each text node and the original text, and reverts by
restoring from that pair.
2026-10-03 15:01:40 +02:00
"quickLinksShow", "translateAutoOffer"];
// Text inputs that round-trip through settings-set on change. Trimmed; a
// cleared field writes an empty string, which main re-defaults from
// SETTINGS_DEFAULTS on next launch.
Theseus: translator peers list — fall back when a mirror is down
The chip ran against a single endpoint, which is the fastest way to go
dark: libretranslate.com's public tier moved behind an API key in late
2026, and most of the historical public mirrors (libretranslate.de,
argosopentech, lt.vern.cc, translate.terraprint.co) either 502 at any
given time, serve a parked page, or started requiring a key of their
own. One URL in settings meant one of those going down meant the chip
stopped working.
Settings.translateEndpoints is now an ordered list. The translator tries
each peer in order and returns the first non-error answer; a dead peer
is logged and skipped. Order is preserved — the first entry is the
primary. Shipped defaults put Silent Mode's own instances
(translate.silentmode.st, the BNS name translate.x) at the top and keep
libretranslate.com as the last-resort entry; neither Silent Mode URL
answers today, but a user's chip starts working as soon as either goes
live without a browser release.
Settings › General › Translate pages grew a list editor (same shape as
the quick-links one): PRIMARY tag on row 0, add a peer, remove any row;
bns:// URLs are accepted so a BNS translator doesn't have to be fronted
by an https host. The single-URL `translateEndpoint` setting carried
over from the earlier draft is migrated on load — a custom URL goes to
the front of the list, the historical default is dropped.
2026-10-03 15:19:09 +02:00
const TEXT_FIELDS = ["translateApiKey"];
2026-09-08 01:42:41 +02:00
C.get().then((s) => {
for (const k of TOGGLES) {
const el = document.getElementById(k); if (!el) continue;
el.checked = !!s[k];
el.addEventListener("change", () => C.set(k, el.checked));
}
Theseus: in-page translator (LibreTranslate client)
The Website-language setting only tells servers what the user prefers via
Accept-Language — many static sites (including names on BCDN) serve one
language and ignore it, so e.g. hello.bch loads in English for every user,
in every language. This adds a translator that converts the page's visible
text in place, so a Lithuanian user reads hello.bch in Lithuanian without
asking the server for anything.
The URL-bar grows a translate chip next to the website-language globe. The
chip lights up when the page's declared `<html lang>` differs from the
user's preferred language. Click it once to translate in place; click again
to revert — originals are kept in a renderer-local state slot and swapped
back without a reload. Right-click opens the chip menu (change target /
translator settings).
The engine lives behind a swappable adapter in main — this ships with the
LibreTranslate backend (POST /translate with {q, source, target, format}).
The endpoint defaults to the LibreTranslate public tier but is settable in
Settings › General › Translate pages, so a user with a self-hosted
LibreTranslate (or Silent Mode's own translate.silentmode.st once it is
up) swaps it there without a code change. On-device Bergamot (the WASM
engine Firefox Translations uses) will plug into the same adapter in a
later release — same contract (array of texts in, array of translations
out), the chip and revert path are already engine-agnostic.
The fetch goes through session.defaultSession.fetch so Tor and add-on
proxy rules apply uniformly, chunks the batch at ~3.8 KB per POST so a
large page spreads across several requests, times each one out at 45 s,
and reports a failure to the chip's tooltip so a dead endpoint reads as
such and not as a silent no-op. The injected walker skips SCRIPT / STYLE
/ CODE / PRE / NOSCRIPT / TEXTAREA and contentEditable subtrees, keeps a
reference to each text node and the original text, and reverts by
restoring from that pair.
2026-10-03 15:01:40 +02:00
for (const k of TEXT_FIELDS) {
const el = document.getElementById(k); if (!el) continue;
el.value = String(s[k] ?? "");
el.addEventListener("change", () => C.set(k, el.value.trim()));
}
2026-10-02 23:49:39 +02:00
// ---- Quick-links list editor (General) --------------------------------
// Add/remove rows; each change writes the whole settings.quickLinks array.
// The strip view and the main window layout react through settings-set.
(function () {
const list = document.getElementById("qlList");
const titleIn = document.getElementById("qlTitle");
const urlIn = document.getElementById("qlUrl");
const addBtn = document.getElementById("qlAdd");
if (!list || !addBtn) return;
const esc = (v) => String(v || "").replace(/[& < >"']/g, (c) => ({ "&":"& ","< ":"< ",">":"> ","\"":"" ","'":"' " })[c]);
function render(links) {
list.innerHTML = "";
if (!links.length) {
const empty = document.createElement("div");
empty.className = "pmuted"; empty.style.fontSize = "12.5px"; empty.textContent = "No links yet — add one below.";
list.appendChild(empty);
return;
}
for (const L of links) {
const row = document.createElement("div");
row.style.cssText = "display:flex;align-items:center;gap:8px;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:8px 12px";
row.innerHTML = `< div style = "width:28px;height:28px;border-radius:6px;background:rgba(255,255,255,.06);display:grid;place-items:center;font-weight:600;color:var(--acid-text);flex:none" > ${esc((L.title || "?").slice(0,1).toUpperCase())}< / div >
< div style = "flex:1;min-width:0" > < div style = "font-weight:600" > ${esc(L.title || L.url)}< / div > < div style = "font-size:12px;color:var(--mut);word-break:break-all" > ${esc(L.url)}< / div > < / div >
< button class = "btn ghost" data-del = "${esc(L.id)}" > Remove< / button > `;
list.appendChild(row);
}
list.querySelectorAll("[data-del]").forEach((b) => b.onclick = async () => {
const cur = (await C.get()).quickLinks || [];
C.set("quickLinks", cur.filter((x) => x.id !== b.dataset.del));
});
}
render(s.quickLinks || []);
addBtn.onclick = async () => {
const t = String(titleIn.value || "").trim();
const u = String(urlIn.value || "").trim();
if (!u) return;
const url = /^https?:\/\//i.test(u) ? u : "https://" + u;
let title = t;
if (!title) { try { title = new URL(url).host.replace(/^www\./, ""); } catch { title = url; } }
const cur = (await C.get()).quickLinks || [];
const id = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 24) + "-" + Date.now().toString(36).slice(-4);
C.set("quickLinks", [...cur, { id, url, title }]);
titleIn.value = ""; urlIn.value = "";
};
// Live-sync from main (edits from other surfaces, or the strip's own add).
if (C.onSettingsUpdate) C.onSettingsUpdate((next) => render(next.quickLinks || []));
})();
Theseus: translator peers list — fall back when a mirror is down
The chip ran against a single endpoint, which is the fastest way to go
dark: libretranslate.com's public tier moved behind an API key in late
2026, and most of the historical public mirrors (libretranslate.de,
argosopentech, lt.vern.cc, translate.terraprint.co) either 502 at any
given time, serve a parked page, or started requiring a key of their
own. One URL in settings meant one of those going down meant the chip
stopped working.
Settings.translateEndpoints is now an ordered list. The translator tries
each peer in order and returns the first non-error answer; a dead peer
is logged and skipped. Order is preserved — the first entry is the
primary. Shipped defaults put Silent Mode's own instances
(translate.silentmode.st, the BNS name translate.x) at the top and keep
libretranslate.com as the last-resort entry; neither Silent Mode URL
answers today, but a user's chip starts working as soon as either goes
live without a browser release.
Settings › General › Translate pages grew a list editor (same shape as
the quick-links one): PRIMARY tag on row 0, add a peer, remove any row;
bns:// URLs are accepted so a BNS translator doesn't have to be fronted
by an https host. The single-URL `translateEndpoint` setting carried
over from the earlier draft is migrated on load — a custom URL goes to
the front of the list, the historical default is dropped.
2026-10-03 15:19:09 +02:00
// ---- Translation-peer list editor (General) ---------------------------
// Same shape as the Quick-links editor above: add a row, trash a row,
// each change writes the whole array. The chip + translator service read
// the list in order and skip dead peers; the top entry is the primary.
(function () {
const list = document.getElementById("translateEndpointList");
const urlIn = document.getElementById("translatePeerUrl");
const addBtn = document.getElementById("translatePeerAdd");
if (!list || !addBtn) return;
const esc = (v) => String(v || "").replace(/[& < >"']/g, (c) => ({ "&":"& ","< ":"< ",">":"> ","\"":"" ","'":"' " })[c]);
function hostOf(u) { try { return new URL(u).host; } catch { return u; } }
function render(peers) {
list.innerHTML = "";
if (!peers.length) {
const empty = document.createElement("div");
empty.className = "pmuted"; empty.style.fontSize = "12.5px";
empty.textContent = "No peers configured — add one below, or restart to restore the defaults.";
list.appendChild(empty);
return;
}
for (let i = 0; i < peers.length ; i + + ) {
const url = peers[i];
const row = document.createElement("div");
row.style.cssText = "display:flex;align-items:center;gap:8px;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:8px 12px";
const primary = i === 0 ? '< span style = "font-size:11px;color:var(--acid-text);background:rgba(214,255,61,.08);border:1px solid rgba(214,255,61,.3);border-radius:6px;padding:2px 6px;margin-right:6px" > PRIMARY< / span > ' : '';
row.innerHTML = `< div style = "flex:1;min-width:0" > < div style = "font-weight:600" > ${primary}${esc(hostOf(url))}< / div > < div style = "font-size:12px;color:var(--mut);word-break:break-all" > ${esc(url)}< / div > < / div >
< button class = "btn ghost" data-del = "${i}" > Remove< / button > `;
list.appendChild(row);
}
list.querySelectorAll("[data-del]").forEach((b) => b.onclick = async () => {
const i = Number(b.dataset.del);
const cur = (await C.get()).translateEndpoints || [];
C.set("translateEndpoints", cur.filter((_, idx) => idx !== i));
});
}
render(s.translateEndpoints || []);
addBtn.onclick = async () => {
const u = String(urlIn.value || "").trim();
if (!u) return;
const url = /^https?:\/\//i.test(u) || /^bns:\/\//i.test(u) ? u : "https://" + u;
const cur = (await C.get()).translateEndpoints || [];
if (cur.includes(url)) { urlIn.value = ""; return; }
C.set("translateEndpoints", [...cur, url]);
urlIn.value = "";
};
if (C.onSettingsUpdate) C.onSettingsUpdate((next) => render(next.translateEndpoints || []));
})();
2026-10-04 14:20:18 +02:00
// ---- Translator engine (Language) — radio between the hosted
// LibreTranslate backend and the on-device Bergamot engine. Bergamot
// is wired as a provider but its WASM runtime is not bundled yet;
// picking it today still routes through LibreTranslate (see main.js
// translatorBergamot stub). Lives in the same Language section so
// the user sees the two choices beside each other and side effects
// (chip tooltip) stay in context.
(function () {
const radios = document.querySelectorAll('input[name="translateProvider"]');
if (!radios.length) return;
const current = s.translateProvider || "libretranslate";
radios.forEach((r) => {
r.checked = (r.value === current);
r.addEventListener("change", () => { if (r.checked) C.set("translateProvider", r.value); });
});
if (C.onSettingsUpdate) C.onSettingsUpdate((next) => {
const v = next.translateProvider || "libretranslate";
radios.forEach((r) => { r.checked = (r.value === v); });
});
})();
2026-09-30 02:16:11 +02:00
// ---- website language (General page) — friendly wrapper over the
// same languageMode/languageValue setting the Anti-fingerprinting Language
// row edits. "Auto" = languageMode="show" (follow OS); anything else
// switches to languageMode="manual" and pins languageValue.
Theseus 0.3.63: lazy tab restore, Privacy language simplified, chip reloads the page
Session restore now paints the full strip from the saved titles + favicons and
loads only the ACTIVE tab's page; every other restored tab lives as a dormant
WebContentsView and navigates for the first time when the user clicks it. For
a 20-tab user that drops cold start from 20 renderer loads racing chrome.html
to one, so launch is roughly flat whatever the tab count — fixes the "not
responding" freeze on a session with many restored tabs. session.json is now
v3 ({v:3, tabs:[{url,title,favicon}], active}); v1/v2 session files still
parse (their tabs restore lazy without a cached title, which arrives on first
activation). Reload on a dormant tab materialises it.
Privacy › Anti-fingerprinting › Language is now two modes — Automatic (system
language) and Manual — matching the General › Website language row and the
URL-bar globe chip. The old Spoof-choose top-10 and Hide-en-US modes are gone
from the UI; legacy saved values auto-migrate to Automatic on first open. The
Manual list is the same 24 languages the General row uses, kept in one place
(WEB_LANG_LIST), so all three surfaces stay in sync.
Changing the language via the globe chip or either settings row now reloads
the active tab — the server picked the response body from Accept-Language on
the original request, so an already-rendered page can't adopt the new language
on its own. A reload is what a user clicking a one-click language switch
expects.
The Location row's country dropdown now stacks under the mode dropdown on its
own line when Manual is picked, so an open menu above it can't visually cover
it (the row's flex-row max-60% layout could wrap it where another dropdown's
overlay sat).
Also: the settings-update broadcast now reaches every open settings tab, not
only the chrome — so changing the chip updates both the General Website-
language row and the Privacy Anti-fingerprinting Language row live without a
Settings refresh.
2026-10-02 21:42:31 +02:00
// The language list is defined once at the top of the script (WEB_LANG_LIST)
// so the Privacy › Anti-fingerprinting row and the globe menu use the same
// order and labels. Alias here for the General row's init block.
const WEB_LANG_QUICK = WEB_LANG_LIST;
2026-10-01 22:14:38 +02:00
// Plain language name for a locale tag — no regional qualifier so en-US
// reads as "English", not "American English" (one picker row per language,
// so the regional half is noise). Pass just the base to Intl.DisplayNames.
2026-10-01 00:48:19 +02:00
const langNameFor = (tag) => {
2026-10-01 22:14:38 +02:00
const base = String(tag || "").split("-")[0];
try { return new Intl.DisplayNames(["en"], { type: "language" }).of(base) || tag; }
2026-10-01 00:48:19 +02:00
catch { return tag; }
};
2026-09-30 02:16:11 +02:00
(async () => {
const webLangPick = document.getElementById("webLangPick");
const webLangOther = document.getElementById("webLangOther");
const hint = document.getElementById("webLangHint");
if (!webLangPick) return;
let osLoc = "en-US";
try { osLoc = (C.systemLocale & & await C.systemLocale()) || "en-US"; } catch {}
2026-10-03 23:27:05 +02:00
// Partitioned into two < optgroup > s so the dropdown reads as a map,
// not a lottery: the languages the translator actually handles come
// first in their own group, every other entry sits under the second
// group with a greyed "— translator coming later" tail. "Other…"
// stays below for a user who needs to pin a tag that is not on the
// list for Accept-Language purposes only.
Theseus: one language chip, auto-translate, picker owns up to what works
Two chips carried the same word in two shapes — a globe (Accept-Language)
and a translate chip (chip lights when page lang differs) — both labelled
"RU" at the same time for a Russian user. The chip for translation is
gone. The globe menu now covers both: a "Translate this page from X to Y"
item appears at the top when the loaded page is in another supported
language, flipping to "Show original" while a translation is on screen.
The chip's own code still shows the user's language (EN, RU, …); its
tooltip switches to "Translated to <X>. Menu: Show original." when a
translation is up, so the one chip reads the whole state.
With "Translate automatically" on, Theseus translates in place on
did-finish-load the first time it sees a supported source + target
mismatch for the active tab — no chip-click needed. A `_tr.autoTried`
latch keeps it to one attempt per document (a failing backend doesn't
retry on every reflow), and the latch resets on did-start-navigation so
the next page gets a fresh shot. The setting copy in Settings › Language
now says "Translate automatically" instead of "Offer to translate", so
the switch's label matches the behaviour.
The picker (both in Settings and in the globe menu) still lists every
language in WEBSITE_LANGUAGE_QUICK, but entries whose base code isn't
on the translator backend (en, es, fr, de, el, ru today) are shown
greyed out with "— translator coming later", and "Other… (Accept-Language
only, no translation)" is explicit about what free-form tags buy you.
The menu is a roadmap, not a lie: a user picking one of the greyed
entries sets Accept-Language and nothing else surprises them.
2026-10-03 19:37:04 +02:00
const SUPPORTED = new Set(["en", "es", "fr", "de", "el", "ru"]);
const baseOf = (t) => String(t || "").split("-")[0].toLowerCase();
2026-10-03 23:27:05 +02:00
const supportedLangs = WEB_LANG_QUICK.filter((L) => SUPPORTED.has(baseOf(L.tag)));
const otherLangs = WEB_LANG_QUICK.filter((L) => !SUPPORTED.has(baseOf(L.tag)));
const opts = [
`< option value = "__auto__" > Automatic (${langNameFor(osLoc)})< / option > `,
`< optgroup label = "Supported today" > `,
...supportedLangs.map((L) => `< option value = "${L.tag}" > ${L.label}< / option > `),
`< / optgroup > `,
`< optgroup label = "Translator coming later (Accept-Language only)" > `,
...otherLangs.map((L) => `< option value = "${L.tag}" disabled > ${L.label}< / option > `),
`< / optgroup > `,
`< option value = "__other__" > Other… (Accept-Language only, no translation)< / option > `,
];
2026-09-30 02:16:11 +02:00
webLangPick.innerHTML = opts.join("");
const paint = (cur) => {
const mode = cur.languageMode || "show";
if (mode !== "manual") {
webLangPick.value = "__auto__"; webLangOther.hidden = true;
hint.innerHTML = mode === "show"
2026-10-01 00:48:19 +02:00
? `Following your operating system (${langNameFor(osLoc)}). Also switchable from the globe icon in the address bar.`
2026-09-30 02:16:11 +02:00
: `Anti-fingerprinting is currently overriding this (mode: < b > ${mode}< / b > ). Change it under < b > Privacy → Anti-fingerprinting< / b > .`;
return;
}
2026-10-01 00:48:19 +02:00
const tag = cur.languageValue || "en-GB";
2026-09-30 02:16:11 +02:00
const known = new Set(WEB_LANG_QUICK.map((L) => L.tag));
if (known.has(tag)) { webLangPick.value = tag; webLangOther.hidden = true; }
else { webLangPick.value = "__other__"; webLangOther.hidden = false; webLangOther.value = tag; }
2026-10-01 00:48:19 +02:00
hint.textContent = `Websites see you in ${langNameFor(tag)}. Also switchable from the globe icon in the address bar.`;
2026-09-30 02:16:11 +02:00
};
paint(s);
webLangPick.addEventListener("change", () => {
const v = webLangPick.value;
if (v === "__auto__") { C.set("languageMode", "show"); webLangOther.hidden = true; }
else if (v === "__other__") { webLangOther.hidden = false; setTimeout(() => webLangOther.focus(), 0); }
else { C.set("languageMode", "manual"); C.set("languageValue", v); webLangOther.hidden = true; }
});
webLangOther.addEventListener("change", () => {
const v = String(webLangOther.value || "").trim();
if (!v) return;
C.set("languageMode", "manual"); C.set("languageValue", v);
});
// Keep in sync when the toolbar chip or the Anti-fingerprinting row
// writes to the same underlying setting.
if (C.onSettingsUpdate) C.onSettingsUpdate((next) => paint(next));
})();
2026-09-08 01:42:41 +02:00
// search engines: default picker + custom-engine list + add form
const sel = document.getElementById("searchEngine");
const esc = (s) => String(s || "").replace(/< /g, "< ");
2026-09-28 19:47:21 +02:00
// e.favicon is a file:// URL (a bundled catalog icon, or a custom engine's
// icon cached under the profile — see main.js); null means "not cached
// yet" or "no icon known", and the emoji stands in. No network fetch
// happens from here.
2026-09-08 13:12:55 +02:00
const engIcon = (e) => {
const sym = (e.sym || "🔍").replace(/'/g, "' ");
return e.favicon
2026-09-28 19:47:21 +02:00
? `< img class = "ei" src = "${esc(e.favicon)}" onerror = "this.replaceWith(Object.assign(document.createElement('span'),{className:'es',textContent:'${sym}'}))" > `
2026-09-08 13:12:55 +02:00
: `< span class = "es" > ${sym}< / span > `;
};
2026-09-08 01:42:41 +02:00
const ENGINE_KINDS = [
{ key: "search", label: "Search engines" },
{ key: "llm", label: "AI answer engines" },
];
2026-09-28 20:07:51 +02:00
// Default engine: our own dropdown over the ENABLED engines (the ones in the
// toolbar picker), grouped like it, each row with the engine's icon.
let selEngines = [], selCurrent = "";
function paintDefaultEngine() {
const cur = selEngines.find((e) => e.id === selCurrent) || selEngines[0];
sel.querySelector(".eic").innerHTML = cur ? engIcon(cur) : "";
sel.querySelector(".enm").textContent = cur ? cur.name : "";
}
2026-09-08 01:42:41 +02:00
function renderEngines(d) {
const enabled = d.engines.filter((e) => e.enabled);
2026-09-28 20:07:51 +02:00
selEngines = enabled; selCurrent = d.current;
paintDefaultEngine();
2026-09-08 01:42:41 +02:00
// Main list = engines the user has INSTALLED. The toggle only flips
// enabled/disabled — the row STAYS. Right-click a row → "Remove from
// list" is what actually removes an engine (back to the catalog for
// built-ins, permanently for customs).
const list = document.getElementById("engineList");
const installed = d.engines.filter((e) => e.installed);
fix(theseus): Presearch is frozen in the engine catalog
presearch.com has redirected every request, searches included, into a
dead host since 2026-09-28, so a user who picked it gets Cloudflare's
origin error instead of results. Rather than deleting the entry, a catalog
engine can now carry a frozen reason: it stays listed in Settings, greyed,
with the reason as its tooltip and an Unavailable badge where the switch
was; it is never enabled, never in the picker, never accepted as the
default from any path, and a profile that had it as default falls back at
startup. Turning it off still works, and deleting the field brings the
engine back exactly as the user had it.
2026-10-01 01:24:10 +02:00
// A frozen engine (see SEARCH_ENGINES in main.js) keeps its row, greyed, with the reason as its
// tooltip and an "Unavailable" badge where the switch would be.
const rowFor = (e) => `< div class = "eng${e.enabled ? " " : " off " } $ { e . frozen ? " frozen " : " " } " data-id = "${e.id}" data-kind = "${e.kind || " search " } " data-builtin = "${e.builtin ? 1 : 0}" draggable = "true" $ { e . frozen ? ` title = "${esc(e.frozen)}" ` : " " } > ` +
2026-09-08 01:42:41 +02:00
`< span class = "grip" title = "Drag to reorder" > ⠿< / span > ` +
`< span class = "eic" > ${engIcon(e)}< / span > < span class = "enm" > ${esc(e.name)}< / span > ` +
fix(theseus): Presearch is frozen in the engine catalog
presearch.com has redirected every request, searches included, into a
dead host since 2026-09-28, so a user who picked it gets Cloudflare's
origin error instead of results. Rather than deleting the entry, a catalog
engine can now carry a frozen reason: it stays listed in Settings, greyed,
with the reason as its tooltip and an Unavailable badge where the switch
was; it is never enabled, never in the picker, never accepted as the
default from any path, and a profile that had it as default falls back at
startup. Turning it off still works, and deleting the field brings the
engine back exactly as the user had it.
2026-10-01 01:24:10 +02:00
(e.frozen
? `< span class = "kind warn" > Unavailable< / span > `
: `< label class = "sw sm" title = "${e.enabled ? " Turn off " : " Turn on " } " > < input type = "checkbox" data-id = "${e.id}" $ { e . enabled ? " checked " : " " } > < span class = "track" > < span class = "knob" > < / span > < / span > < / label > `) +
2026-09-08 01:42:41 +02:00
`< / div > `;
list.innerHTML = ENGINE_KINDS.map(({ key, label }) => {
const rows = installed.filter((e) => (e.kind || "search") === key).map(rowFor).join("");
if (!rows) return "";
return `< div class = "ehdr" > ${label}< / div > ${rows}`;
}).join("");
// Two catalog panes, split by tier — filtered by !installed now, not
// !enabled (a toggled-off engine stays in the enabled list, not here):
// catalog — curated first-class built-ins the user hasn't installed
// extra — wider bank, filtered live by the "Discover more" search box
const cat = document.getElementById("catalogList");
const extra = document.getElementById("extraList");
const filterInput = document.getElementById("engineFilter");
fix(theseus): Presearch is frozen in the engine catalog
presearch.com has redirected every request, searches included, into a
dead host since 2026-09-28, so a user who picked it gets Cloudflare's
origin error instead of results. Rather than deleting the entry, a catalog
engine can now carry a frozen reason: it stays listed in Settings, greyed,
with the reason as its tooltip and an Unavailable badge where the switch
was; it is never enabled, never in the picker, never accepted as the
default from any path, and a profile that had it as default falls back at
startup. Turning it off still works, and deleting the field brings the
engine back exactly as the user had it.
2026-10-01 01:24:10 +02:00
const catRow = (e) => `< div class = "cat${e.frozen ? " frozen " : " " } " data-id = "${e.id}" $ { e . frozen ? ` title = "${esc(e.frozen)}" ` : " " } > ` +
2026-09-08 01:42:41 +02:00
`< span class = "eic" > ${engIcon(e)}< / span > ` +
`< span class = "enm" > ${esc(e.name)}< / span > ` +
`< span class = "kind" > ${(e.kind || "search") === "llm" ? "AI" : "Search"}< / span > ` +
fix(theseus): Presearch is frozen in the engine catalog
presearch.com has redirected every request, searches included, into a
dead host since 2026-09-28, so a user who picked it gets Cloudflare's
origin error instead of results. Rather than deleting the entry, a catalog
engine can now carry a frozen reason: it stays listed in Settings, greyed,
with the reason as its tooltip and an Unavailable badge where the switch
was; it is never enabled, never in the picker, never accepted as the
default from any path, and a profile that had it as default falls back at
startup. Turning it off still works, and deleting the field brings the
engine back exactly as the user had it.
2026-10-01 01:24:10 +02:00
(e.frozen ? `< span class = "kind warn" > Unavailable< / span > ` : `< button class = "add" data-add = "${e.id}" > + Add< / button > `) + `< / div > `;
2026-09-08 01:42:41 +02:00
const uninstalled = d.engines.filter((e) => e.builtin & & !e.installed);
const catalogOff = uninstalled.filter((e) => (e.tier || "catalog") === "catalog");
const extraOff = uninstalled.filter((e) => e.tier === "extra");
if (cat) {
cat.innerHTML = catalogOff.length
? catalogOff.map(catRow).join("")
: `< div class = "cempty2" > All curated engines are already in your list. Discover more below or add a custom URL.< / div > `;
cat.querySelectorAll(".add").forEach((b) => b.onclick = () => C.setEngineEnabled(b.dataset.add, true).then(renderEngines));
}
if (extra) {
const paintExtras = (q) => {
const filt = String(q || "").trim().toLowerCase();
const shown = filt ? extraOff.filter((e) => e.name.toLowerCase().includes(filt)) : extraOff;
extra.innerHTML = shown.length
? shown.map(catRow).join("")
: `< div class = "cempty2" > ${filt ? "No engines match that filter." : "All discoverable engines are already in your list."}< / div > `;
extra.querySelectorAll(".add").forEach((b) => b.onclick = () => C.setEngineEnabled(b.dataset.add, true).then(renderEngines));
};
paintExtras(filterInput ? filterInput.value : "");
if (filterInput & & !filterInput.dataset.wired) {
filterInput.dataset.wired = "1";
filterInput.addEventListener("input", () => paintExtras(filterInput.value));
}
}
// Toggle: pure on/off in the enabled set — the row stays visible either way.
list.querySelectorAll('input[type="checkbox"]').forEach((cb) => cb.onchange = () => C.setEngineEnabled(cb.dataset.id, cb.checked).then(renderEngines));
// Right-click any row → context menu with "Remove from list" (moves a
// built-in back to the catalog; deletes a custom entirely).
list.querySelectorAll(".eng").forEach((row) => {
row.addEventListener("contextmenu", (e) => {
e.preventDefault();
openEngineMenu(row, e.clientX, e.clientY);
});
});
// drag-and-drop reorder — same-kind only (dropping a Search engine into
// the LLM section would just re-group visually on next render, so we
// reject cross-kind drags outright).
let dragId = null;
let dragKind = null;
const sameKind = (row) => row.dataset.kind === dragKind;
list.querySelectorAll(".eng").forEach((row) => {
row.addEventListener("dragstart", (e) => { dragId = row.dataset.id; dragKind = row.dataset.kind; e.dataTransfer.effectAllowed = "move"; row.classList.add("dragging"); });
row.addEventListener("dragend", () => { row.classList.remove("dragging"); list.querySelectorAll(".eng").forEach((r) => r.classList.remove("over")); });
row.addEventListener("dragover", (e) => {
if (!sameKind(row)) { e.dataTransfer.dropEffect = "none"; return; }
e.preventDefault(); e.dataTransfer.dropEffect = "move";
if (row.dataset.id !== dragId) row.classList.add("over");
});
row.addEventListener("dragleave", () => row.classList.remove("over"));
row.addEventListener("drop", (e) => {
e.preventDefault(); row.classList.remove("over");
if (!dragId || dragId === row.dataset.id || !sameKind(row)) return;
const ids = [...list.querySelectorAll(".eng")].map((el) => el.dataset.id);
const from = ids.indexOf(dragId), to = ids.indexOf(row.dataset.id);
ids.splice(from, 1); ids.splice(to, 0, dragId);
C.setEngineOrder(ids).then(renderEngines);
});
});
}
// Floating right-click menu for an engine row. Only one open at a time.
let ctxOpen = null;
function closeEngineMenu() { if (ctxOpen) { ctxOpen.remove(); ctxOpen = null; } }
function openEngineMenu(row, x, y) {
closeEngineMenu();
const id = row.dataset.id;
const builtin = row.dataset.builtin === "1";
const m = document.createElement("div");
m.className = "ctxmenu";
m.innerHTML = `< div class = "mi danger" data-act = "remove" > Remove from list< / div > `;
document.body.appendChild(m);
// Position, keeping the menu inside the viewport.
const rect = m.getBoundingClientRect();
const vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
m.style.left = Math.min(x, vw - rect.width - 6) + "px";
m.style.top = Math.min(y, vh - rect.height - 6) + "px";
m.querySelector('[data-act="remove"]').onclick = () => {
closeEngineMenu();
const call = builtin ? C.removeFromList(id) : C.removeEngine(id);
call.then(renderEngines);
};
ctxOpen = m;
setTimeout(() => {
const off = (ev) => { if (!m.contains(ev.target)) { closeEngineMenu(); document.removeEventListener("mousedown", off); document.removeEventListener("keydown", esc); } };
const esc = (ev) => { if (ev.key === "Escape") { closeEngineMenu(); document.removeEventListener("mousedown", off); document.removeEventListener("keydown", esc); } };
document.addEventListener("mousedown", off);
document.addEventListener("keydown", esc);
}, 0);
}
2026-09-28 20:07:51 +02:00
let selMenu = null;
function closeDefaultEngineMenu() { if (selMenu) { selMenu.remove(); selMenu = null; sel.classList.remove("open"); sel.setAttribute("aria-expanded", "false"); } }
function openDefaultEngineMenu() {
closeEngineMenu(); closeDefaultEngineMenu();
const m = document.createElement("div");
m.className = "ctxmenu eselmenu"; m.setAttribute("role", "listbox");
m.innerHTML = ENGINE_KINDS.map(({ key, label }) => {
const rows = selEngines.filter((e) => (e.kind || "search") === key)
.map((e) => `< div class = "mi${e.id === selCurrent ? " cur " : " " } " role = "option" data-id = "${esc(e.id)}" > < span class = "eic" > ${engIcon(e)}< / span > < span class = "enm" > ${esc(e.name)}< / span > ${e.id === selCurrent ? '< span class = "chk" > ✓< / span > ' : ""}< / div > `).join("");
return rows ? `< div class = "ehdr" > ${label}< / div > ${rows}` : "";
}).join("");
document.body.appendChild(m);
const r = sel.getBoundingClientRect(), mr = m.getBoundingClientRect();
const vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
m.style.minWidth = r.width + "px";
m.style.left = Math.max(6, Math.min(r.left, vw - mr.width - 6)) + "px";
m.style.top = (r.bottom + 4 + mr.height > vh - 6 ? Math.max(6, r.top - 4 - mr.height) : r.bottom + 4) + "px";
const items = [...m.querySelectorAll(".mi")];
let hl = Math.max(0, items.findIndex((i) => i.dataset.id === selCurrent));
const paintHl = () => { items.forEach((i, k) => i.classList.toggle("hl", k === hl)); items[hl]?.scrollIntoView({ block: "nearest" }); };
const choose = (id) => { closeDefaultEngineMenu(); if (id & & id !== selCurrent) { selCurrent = id; paintDefaultEngine(); C.set("searchEngine", id); } sel.focus(); };
items.forEach((i, k) => { i.onmouseenter = () => { hl = k; paintHl(); }; i.onclick = () => choose(i.dataset.id); });
paintHl();
const key = (ev) => {
if (ev.key === "Escape") { ev.preventDefault(); closeDefaultEngineMenu(); sel.focus(); }
else if (ev.key === "ArrowDown") { ev.preventDefault(); hl = Math.min(items.length - 1, hl + 1); paintHl(); }
else if (ev.key === "ArrowUp") { ev.preventDefault(); hl = Math.max(0, hl - 1); paintHl(); }
else if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); choose(items[hl]?.dataset.id); }
else if (ev.key === "Tab") closeDefaultEngineMenu();
};
const off = (ev) => { if (!m.contains(ev.target) & & ev.target !== sel & & !sel.contains(ev.target)) closeDefaultEngineMenu(); };
const cleanup = () => { document.removeEventListener("mousedown", off); document.removeEventListener("keydown", key, true); window.removeEventListener("blur", cleanup); };
document.addEventListener("mousedown", off);
document.addEventListener("keydown", key, true);
window.addEventListener("blur", closeDefaultEngineMenu, { once: true });
const origRemove = m.remove.bind(m); m.remove = () => { cleanup(); origRemove(); };
selMenu = m; sel.classList.add("open"); sel.setAttribute("aria-expanded", "true");
}
sel.onclick = () => (selMenu ? closeDefaultEngineMenu() : openDefaultEngineMenu());
sel.onkeydown = (ev) => { if (selMenu) return; if (ev.key === "Enter" || ev.key === " " || ev.key === "ArrowDown" || ev.key === "ArrowUp") { ev.preventDefault(); openDefaultEngineMenu(); } };
// The toolbar picker changes the same setting; keep the control in step.
if (C.onSettingsUpdate) C.onSettingsUpdate((next) => { if (next & & typeof next.searchEngine === "string" & & next.searchEngine !== selCurrent) { selCurrent = next.searchEngine; paintDefaultEngine(); } });
2026-09-08 01:42:41 +02:00
// appearance (theme) — three visual cards: system | light | dark. Any
// unrecognised saved value falls back to "system" (follow the OS).
const th = document.getElementById("theme");
let themeValue = ["system", "light", "dark"].includes(s.theme) ? s.theme : "system";
const paintTheme = () => th.querySelectorAll(".tc").forEach((b) => {
const on = b.dataset.val === themeValue;
b.classList.toggle("on", on);
b.setAttribute("aria-checked", on ? "true" : "false");
});
paintTheme();
th.querySelectorAll(".tc").forEach((b) => b.onclick = () => {
themeValue = b.dataset.val; paintTheme(); C.set("theme", themeValue);
});
// WebRTC IP policy
const wm = document.getElementById("webrtcMode");
wm.value = s.webrtcMode || "public_only";
wm.onchange = () => C.set("webrtcMode", wm.value);
// Toolbar sizing dropdowns removed — the toolbar drag-handles do the
// same job in-place. The stored urlBarSize / searchBoxSize + the drag-
// set widthPx settings still exist in main; they just have no UI here.
document.getElementById("engAdd").onclick = () => {
const name = document.getElementById("engName").value.trim();
const url = document.getElementById("engUrl").value.trim();
const sym = document.getElementById("engSym").value.trim();
if (!name || !url.includes("%s")) { alert("Enter a name and a URL containing %s (where the query goes)."); return; }
C.addEngine({ name, url, sym }).then((d) => {
document.getElementById("engName").value = ""; document.getElementById("engUrl").value = ""; document.getElementById("engSym").value = "";
renderEngines(d);
});
};
// "+ Add search engine" toggles the catalog panel below the enabled list.
const catBtn = document.getElementById("engAddBtn");
const catBox = document.getElementById("engineCatalog");
if (catBtn & & catBox) {
catBtn.onclick = () => {
const open = catBox.hidden;
catBox.hidden = !open;
catBtn.textContent = open ? "− Hide catalog" : "+ Add search engine";
};
}
C.engines().then(renderEngines);
// anti-fingerprinting mode selectors, with value field(s) shown on "manual"
const bind = (mode, showValIf, apply) => {
const m = document.getElementById(mode);
m.value = s[mode] || "show";
apply(m.value === "manual");
m.addEventListener("change", () => { C.set(mode, m.value); apply(m.value === "manual"); });
};
// Value fields: < select > for tz + lang manual mode, < select > city for
// location manual mode. Each has an "Other…" sentinel at the end that
// reveals a text input so the user can enter a value not in the built-in
// list (any IANA zone, any BCP-47 locale). selectWithOther handles the
// round-trip: if a saved value isn't in the predefined options, "Other"
// is auto-selected on load and the input pre-fills with that value.
function selectWithOther(selectId, otherInputId, settingsKey = selectId) {
const sel = document.getElementById(selectId);
const inp = document.getElementById(otherInputId);
const saved = s[settingsKey] ?? "";
const known = new Set([...sel.options].map((o) => o.value).filter((v) => v & & v !== "__other__"));
const isCustom = saved & & !known.has(saved);
sel.value = isCustom ? "__other__" : saved;
if (isCustom) inp.value = saved;
const applyVis = () => { inp.hidden = sel.value !== "__other__"; };
applyVis();
sel.addEventListener("change", () => {
if (sel.value === "__other__") { applyVis(); setTimeout(() => inp.focus(), 0); return; }
C.set(settingsKey, sel.value); applyVis();
});
inp.addEventListener("change", () => { const v = String(inp.value).trim(); if (v) C.set(settingsKey, v); });
return sel;
}
const tzV = selectWithOther("timezoneValue", "timezoneValueOther");
const tzOther = document.getElementById("timezoneValueOther");
// "Manual" mode reveals both the select AND the Other input (if Other was picked).
const applyTzVis = (manual) => { tzV.hidden = !manual; tzOther.hidden = !manual || tzV.value !== "__other__"; };
bind("timezoneMode", null, applyTzVis);
2026-10-03 19:05:25 +02:00
// Language row moved out of Privacy › Anti-fingerprinting: the setting
// (languageMode / languageValue) still drives anti-fingerprinting, but
// the only control for it lives under Settings › Language now. Legacy
// "hide" and "spoof" values migrate to "show" (Automatic) on load so
// the new picker always has a valid state.
if (s.languageMode === "hide" || s.languageMode === "spoof") C.set("languageMode", "show");
2026-10-01 00:48:19 +02:00
// Location: three modes — Show real / Hide / Manual (pick a country).
// Manual writes the country's representative lat/lon into locationLat +
// locationLon, which is what navigator.geolocation returns to pages via
// the override in main.js. Legacy `spoof` (region) and free-form city
// picks are gone from the UI; effLocation() in main still handles a
// saved `spoof` mode so old profiles keep working until they change it.
// COUNTRIES: ISO-3166 alpha-2 → [ label, lat, lon ]. Coord is the
// country's capital (or biggest city) so pages that geocode the position
// land in the right country. Alphabetised for the dropdown.
const COUNTRIES = {
AR: ["Argentina", -34.6037, -58.3816],
AT: ["Austria", 48.2082, 16.3738],
AU: ["Australia", -33.8688, 151.2093],
BE: ["Belgium", 50.8503, 4.3517],
BR: ["Brazil", -23.5505, -46.6333],
CA: ["Canada", 43.6532, -79.3832],
CH: ["Switzerland", 47.3769, 8.5417],
CL: ["Chile", -33.4489, -70.6693],
CN: ["China", 31.2304, 121.4737],
CO: ["Colombia", 4.7110, -74.0721],
CZ: ["Czechia", 50.0755, 14.4378],
DE: ["Germany", 52.5200, 13.4050],
DK: ["Denmark", 55.6761, 12.5683],
EG: ["Egypt", 30.0444, 31.2357],
ES: ["Spain", 40.4168, -3.7038],
FI: ["Finland", 60.1699, 24.9384],
FR: ["France", 48.8566, 2.3522],
GB: ["United Kingdom", 51.5074, -0.1278],
GR: ["Greece", 37.9838, 23.7275],
HK: ["Hong Kong", 22.3193, 114.1694],
ID: ["Indonesia", -6.2088, 106.8456],
IE: ["Ireland", 53.3498, -6.2603],
IL: ["Israel", 32.0853, 34.7818],
IN: ["India", 19.0760, 72.8777],
IT: ["Italy", 41.9028, 12.4964],
JP: ["Japan", 35.6762, 139.6503],
KE: ["Kenya", -1.2921, 36.8219],
KR: ["South Korea", 37.5665, 126.9780],
MA: ["Morocco", 33.5731, -7.5898],
MX: ["Mexico", 19.4326, -99.1332],
MY: ["Malaysia", 3.1390, 101.6869],
NG: ["Nigeria", 6.5244, 3.3792],
NL: ["Netherlands", 52.3676, 4.9041],
NO: ["Norway", 59.9139, 10.7522],
NZ: ["New Zealand", -36.8485, 174.7633],
PE: ["Peru", -12.0464, -77.0428],
PH: ["Philippines", 14.5995, 120.9842],
PL: ["Poland", 52.2297, 21.0122],
PT: ["Portugal", 38.7223, -9.1393],
RU: ["Russia", 55.7558, 37.6173],
SA: ["Saudi Arabia", 24.7136, 46.6753],
SE: ["Sweden", 59.3293, 18.0686],
SG: ["Singapore", 1.3521, 103.8198],
TH: ["Thailand", 13.7563, 100.5018],
TR: ["Turkey", 41.0082, 28.9784],
TW: ["Taiwan", 25.0330, 121.5654],
UA: ["Ukraine", 50.4501, 30.5234],
US: ["United States", 40.7128, -74.0060],
VN: ["Vietnam", 10.8231, 106.6297],
ZA: ["South Africa", -26.2041, 28.0473],
2026-09-08 01:42:41 +02:00
};
2026-10-01 00:48:19 +02:00
// Legacy region → representative country (Spoof mode is retired but
// migrating old profiles keeps them roughly where they were).
const REGION_TO_COUNTRY = {
europe: "DE", asia: "JP", north_america: "US", south_america: "BR",
africa: "KE", middle_east: "AE", australia: "AU",
2026-09-08 01:42:41 +02:00
};
2026-10-01 00:48:19 +02:00
const locMode = document.getElementById("locationMode");
const locCountry = document.getElementById("locationCountry");
// Build the country dropdown from the map above.
locCountry.innerHTML = Object.entries(COUNTRIES)
.sort((a, b) => a[1][0].localeCompare(b[1][0]))
.map(([code, [name]]) => `< option value = "${code}" > ${name}< / option > `)
.join("");
// Restore prior selection: prefer an explicit locationCountry; otherwise
// recover the country whose coords match the saved lat/lon; else Germany.
const initialCountry = (() => {
if (s.locationCountry & & COUNTRIES[s.locationCountry]) return s.locationCountry;
2026-09-08 01:42:41 +02:00
const la = Number(s.locationLat), lo = Number(s.locationLon);
2026-10-01 00:48:19 +02:00
for (const [code, [, x, y]] of Object.entries(COUNTRIES)) {
if (Math.abs(x - la) < 0.05 & & Math . abs ( y - lo ) < 0 . 05 ) return code ;
}
return "DE";
})();
locCountry.value = initialCountry;
// Migrate legacy spoof/region on this render pass so the mode dropdown
// has a valid value and the settings file is normalised on next save.
const initialMode = (() => {
if (s.locationMode === "spoof") {
const code = REGION_TO_COUNTRY[s.locationRegion] || "DE";
const [, la, lo] = COUNTRIES[code];
C.set("locationCountry", code);
C.set("locationLat", String(la));
C.set("locationLon", String(lo));
C.set("locationMode", "manual");
locCountry.value = code;
return "manual";
}
return s.locationMode || "show";
})();
const applyLoc = () => { locCountry.hidden = locMode.value !== "manual"; };
locMode.value = initialMode;
applyLoc();
locMode.addEventListener("change", () => { C.set("locationMode", locMode.value); applyLoc(); });
locCountry.addEventListener("change", () => {
const code = locCountry.value;
const c = COUNTRIES[code]; if (!c) return;
C.set("locationCountry", code);
C.set("locationLat", String(c[1]));
C.set("locationLon", String(c[2]));
2026-09-08 01:42:41 +02:00
});
// Storage: "Clear all now" wipes everything the toggles cover, without
// waiting for quit. Confirm first — this signs the user out of everything.
const clrBtn = document.getElementById("clearNow");
if (clrBtn) clrBtn.onclick = async () => {
if (!confirm("Clear cookies, cache, site storage, and history now?\n\nYou'll be signed out of everything and open tabs won't be restored.")) return;
clrBtn.disabled = true; clrBtn.textContent = "Clearing…";
try {
await C.clearBrowsingData({ cookies: true, cache: true, storage: true, history: true });
clrBtn.textContent = "Cleared ✓";
} catch (e) { clrBtn.textContent = "Clear failed"; console.error(e); }
setTimeout(() => { clrBtn.textContent = "Clear all now"; clrBtn.disabled = false; }, 1600);
};
// ---- Naming section: BCNR/ICANN collision policy + remembered choices ----
function refreshCollisions() {
C.collisionState().then((cs) => {
// pick the current radio
document.querySelectorAll('input[name="collisionPolicy"]').forEach((r) => { r.checked = (r.value === cs.policy); });
const nn = Object.keys(cs.byName || {}).length, tn = Object.keys(cs.byTld || {}).length;
const bc = (cs.bcnrTlds || []).length;
document.getElementById("colSummary").textContent =
`Remembered: ${nn} name${nn === 1 ? "" : "s"}, ${tn} TLD${tn === 1 ? "" : "s"}. `
+ `BCNR-native TLDs on chain: ${bc}.`;
}).catch(() => {});
}
refreshCollisions();
document.querySelectorAll('input[name="collisionPolicy"]').forEach((r) => {
r.addEventListener("change", () => { if (r.checked) C.setCollisionPolicy(r.value).then(refreshCollisions); });
});
document.getElementById("resetCollisions").onclick = () => {
C.resetCollisions().then(refreshCollisions);
};
Theseus: warn before opening a name a blocklist flags
The blocklist consumer existed in the resolver library and the gateway, but
the browser opened a flagged name without comment. Now the indexer process
reads the subscribed lists from the chain every ten minutes and hands the
flags to main. A flagged name loads a warning page naming the reason, the
list and the report; the user may continue, and that is remembered per name.
Two gates, because content is reached two ways. loadBns shows the real
interstitial. serveBns refuses with an inline page on every path that skips
it: reload, back and forward, bns:// links, web app windows. The inline page
has no button, since a page at the site's own origin must not be able to
approve itself; only blocked.html may ask to continue, checked by file URL.
Settings › Naming has the policy: warn (default), never open, or ignore the
lists. The csam reason is never offered a way through. A list that cannot be
read keeps the last known flags and never stops a name from resolving.
The gateway put its own warning in front of flagged sites, which this
browser could not get past: it fetches files itself, with no cookie jar. It
now sends x-bns-policy: client and the gateway stays out of the way for a
client that says it decides for itself.
dev/blocklist-selftest.js runs the real protocol handler and decision
functions against a scratch profile.
2026-10-04 15:50:35 +02:00
// ---- Naming section: blocklists ----
function refreshBlocklist() {
C.blocklistState().then((b) => {
document.querySelectorAll('input[name="blocklistPolicy"]').forEach((r) => { r.checked = (r.value === b.policy); });
const lists = (b.lists || []).map((l) => l.name || l.address).join(", ") || "none";
const n = (b.flagged || []).length, o = (b.overrides || []).length;
const read = b.ok === null ? "not read yet"
: b.ok ? `read ${new Date(b.readAt).toLocaleTimeString()}`
: `could not be read (${b.error || "unreachable"}); the last known flags stay in force`;
document.getElementById("blkSummary").textContent =
`Subscribed: ${lists} — ${read}. Flagged now: ${n} name${n === 1 ? "" : "s"}. `
+ `You chose to continue to ${o} name${o === 1 ? "" : "s"}.`;
}).catch(() => {});
}
refreshBlocklist();
document.querySelectorAll('input[name="blocklistPolicy"]').forEach((r) => {
r.addEventListener("change", () => { if (r.checked) C.setBlocklistPolicy(r.value).then(refreshBlocklist); });
});
document.getElementById("resetBlocklist").onclick = () => { C.resetBlocklist().then(refreshBlocklist); };
2026-09-09 00:51:05 +02:00
// ---- Developer tools dock position --------------------------------------
// The active radio reflects the current setting; changing it just calls
// C.set — the F12 handler in main.js reads settings.devToolsDock each
// time DevTools opens, so no restart is needed for the choice to apply.
const devToolsDock = s.devToolsDock || "bottom";
document.querySelectorAll('input[name="devToolsDock"]').forEach((r) => {
r.checked = (r.value === devToolsDock);
r.addEventListener("change", () => { if (r.checked) C.set("devToolsDock", r.value); });
});
2026-09-30 02:16:11 +02:00
// ---- Plug-ins: Ariadne's Thread + Aegis --------------------------------
// Two views per plug-in that share one truth source:
// Main page (#plugins) — compact row: status + update button
// + on/off toggle on the right; title
// is a link into the sub-page.
// Sub-page (#plugins-ariadne, #plugins-aegis) — full details + the
// uninstall / apply buttons.
// Every state-changing IPC re-runs the shared refresh, so both views
// repaint together — the toggle in the main row and in the sub-page
// reflect the same underlying scheduled-task state.
(function () {
// ARIADNE ————————————————————————————————————————————————————————
// Runs as two elevated scheduled tasks ("BNS Resolver Daemon" + "BNS
// Sia Bridge"). Toggling / installing / uninstalling prompts UAC.
const q = (id) => document.getElementById(id);
const arStatMain = q("ariadneStatusMain");
const arStatSub = q("ariadneStatusSub");
const arSwMain = q("ariadneToggleMain");
const arSwSub = q("ariadneToggleSub");
const arSwWrap = q("ariadneSwWrap");
const arInstallMain = q("ariadneInstallMain");
const arInstallSub = q("ariadneInstallSub");
const arUpdateMain = q("ariadneUpdateMain");
const arUpdateSub = q("ariadneUpdateSub");
const arCheckMain = q("ariadneCheckMain");
const arRefreshSub = q("ariadneRefreshSub");
const arUninstall = q("ariadneUninstallSub");
const arMissing = q("ariadneMissing");
let arBusy = false; // during install/update/toggle: freeze the switch so refreshes don't fight
2026-09-08 01:42:41 +02:00
2026-09-30 02:16:11 +02:00
async function refreshAriadne() {
try {
const r = await C.ariadneState();
const verSuffix = r.installedVersion ? ` (v${r.installedVersion})` : "";
const bundledSuffix = r.bundledVersion ? ` (bundled v${r.bundledVersion})` : "";
// Main row: short one-liner.
if (r.state === "running") {
arStatMain.innerHTML = `< b style = "color:var(--acid-text)" > On< / b > ${verSuffix} — resolving names system-wide.`;
} else if (r.state === "stopped") {
arStatMain.innerHTML = `< b > Off< / b > ${verSuffix} — only Theseus resolves BCDN names.`;
} else {
arStatMain.innerHTML = `< b > Not installed< / b > .${bundledSuffix}`;
}
// Sub-page row: fuller description.
if (r.state === "running") {
arStatSub.innerHTML = `< b style = "color:var(--acid-text)" > Running< / b > ${verSuffix} — every browser on this machine resolves BCDN names.`;
} else if (r.state === "stopped") {
arStatSub.innerHTML = `< b > Stopped< / b > ${verSuffix} — only Theseus resolves BCDN names; other browsers won't.`;
} else {
arStatSub.innerHTML = `< b > Not installed< / b > on this machine.${bundledSuffix}`;
}
Theseus: read BNS names from Ariadne's indexer; its own is the standby
Migration step 3 (DESIGN-bns-indexer-service.md). Ariadne's Thread now owns
BNS indexing on the machine, so Theseus no longer runs a second electrum
indexer beside it.
bns-indexer.js keeps its process and its messages to main.js, but inside
it is now an index host on the shared source chain:
- Ariadne's indexer over its pipe, trusted only after ariadne-helper.exe
has checked the server process on that connection (found through
Ariadne's uninstall key), then pushes;
- the local copies: Ariadne's files for both scopes, Theseus's own raw
copy, the bundled one. The richest wins.
- Theseus's own index copy, written from the pipe data.
The shared core runs as Theseus's own indexer only while Ariadne is
unhealthy. That means: no pipe 4 s after launch, a pipe that fails the
check, a pipe that went silent, or an index not confirmed for 10 min while
Ariadne is not paused. The own indexer warm-starts from Ariadne's
snapshot, so there is no download and no cold sync. It hands back after
90 s of health, so a flapping service does not start and stop it. Economy
is not a failure and never triggers a takeover. With no checkable Ariadne
(portable, not installed, older than the pipe) the own indexer starts at
once, as in 0.3.70. The one thing Theseus does on Ariadne's side is run
the indexer's task at launch when "Launch at start" is off.
main.js passes the shared module paths (packaged as .mjs, which is why the
shared modules no longer import each other) and keeps the host's status.
The Ariadne panel takes its state from the indexer task when one exists,
and the sub-page says where Theseus's names come from.
2026-10-03 17:06:24 +02:00
// Where Theseus's own BNS names come from right now: Ariadne's
// indexer (live), Ariadne's local copy, or Theseus's own indexer
// (the fallback while Ariadne's is not available).
const ix = r.index;
if (ix) {
const src = ix.source === "pipe" ? "Ariadne's indexer (live)"
: ix.source === "theseus-indexer" ? `Theseus's own indexer${ix.ownIndexer?.reason ? ` (${ix.ownIndexer.reason})` : ""}`
: ix.source === "own-copy" ? "Theseus's saved copy"
: String(ix.source || "").startsWith("local:") ? "a local snapshot copy" : "none yet";
const line = document.createElement("div");
line.className = "pmuted";
line.style.marginTop = "4px";
line.textContent = `Theseus reads names from ${src}${ix.ariadne?.paused ? " — Ariadne is in Economy, sync paused" : ""}.`;
arStatSub.appendChild(line);
}
2026-09-30 02:16:11 +02:00
// Toggle state on both views. When not installed the toggle is
// disabled — user has to hit Install first.
if (!arBusy) {
const on = r.state === "running";
const installed = r.state !== "not-installed";
arSwMain.checked = arSwSub.checked = on;
arSwMain.disabled = arSwSub.disabled = !installed;
if (arSwWrap) arSwWrap.title = installed ? "Turn Ariadne on/off" : "Install Ariadne first";
}
// Install / Update buttons: install only when missing, update only
// when an update is available.
const missing = r.state === "not-installed";
arInstallMain.hidden = arInstallSub.hidden = !missing;
arUpdateMain.hidden = arUpdateSub.hidden = !(r.canUpdate & & !missing);
arUninstall.hidden = !r.hasUninstaller;
arMissing.hidden = !missing;
} catch (e) {
const msg = "Status check failed: " + (e?.message || e);
arStatMain.textContent = msg; arStatSub.textContent = msg;
2026-09-08 18:09:55 +02:00
}
2026-09-30 02:16:11 +02:00
}
function wire(btn, action, busyLabel) {
if (!btn) return;
btn.onclick = async () => {
const orig = btn.textContent;
btn.disabled = true; btn.textContent = busyLabel;
arBusy = true;
try { await action(); } catch {}
finally { arBusy = false; btn.disabled = false; btn.textContent = orig; refreshAriadne(); }
};
}
// Toggle: uses whichever switch the user flipped, mirrors the other.
async function toggle(desired) {
arBusy = true;
arSwMain.disabled = arSwSub.disabled = true;
try { await C.ariadneToggle(desired); }
catch {}
finally { arBusy = false; refreshAriadne(); }
}
arSwMain.addEventListener("change", () => toggle(arSwMain.checked));
arSwSub.addEventListener("change", () => toggle(arSwSub.checked));
wire(arInstallMain, () => C.ariadneInstall(), "Installing…");
wire(arInstallSub, () => C.ariadneInstall(), "Installing… (accept the UAC prompt)");
wire(arUpdateMain, () => C.ariadneUpdate(), "Updating…");
wire(arUpdateSub, () => C.ariadneUpdate(), "Updating… (accept the UAC prompt)");
wire(arUninstall, () => C.ariadneUninstall(), "Uninstalling… (accept the UAC prompt)");
arCheckMain.onclick = refreshAriadne;
arRefreshSub.onclick = refreshAriadne;
refreshAriadne();
feat(theseus/ariadne): settings panel — policy + per-source toggles + status report
Ariadne 0.1.13 exposed /api/status and per-source enable flags in
policy.json. Theseus's Plug-ins > Ariadne's Thread sub-page now wires those
into a full UI, no daemon restart, no UAC.
Added to the plugins-ariadne sub-page (after Status, before Remove):
Collision policy -- radio group (BCNR-first / ICANN-first) writes
C:\ProgramData\Ariadne\policy.json.policy; hot-reloaded
by the daemon within 5 s.
Sources -- 3-column grid, one row per source (snapshotHttps,
electrumWss, perQueryLookup, diskCache, localApi):
enable checkbox + last-state summary
(last success / last error / hit-miss counters /
disk-cache size+mtime). Toggle writes
policy.json.sources.<name>.enabled and re-polls after
the 5-s hot-reload tick so the state text catches up.
Status report -- <pre> JSON dump of GET http://127.0.0.1/api/status
with Copy report + Refresh report buttons. This is
the paste-me-into-support artefact for any diagnosis.
IPC wiring:
main.js
ariadne-get-status -> GET http://127.0.0.1/api/status ({ok, status|error})
ariadne-get-policy -> read C:\ProgramData\Ariadne\policy.json (or {})
ariadne-set-policy -> merge {policy}, write back (validates enum)
ariadne-set-source -> merge {sources.<name>.enabled}, write back
(validates against the known 5 names)
settings-preload.js
ariadneGetStatus, ariadneGetPolicy, ariadneSetPolicy, ariadneSetSource
All four handlers write policy.json as the local user; no UAC. Works because
install.ps1 grants BUILTIN\Users Modify on the file (0.1.7+).
Sub-page auto-refreshes state every time it opens (listens on the existing
'section' custom event dispatched by showSection).
Not building/shipping Theseus here -- this rides the next Theseus release.
Panel gracefully handles: daemon down (shows 'Daemon unreachable' with a
pointer to the Status toggle), localApi disabled (daemon returns 503, panel
shows the error), missing policy.json (all sources default to true).
2026-10-01 00:51:50 +02:00
// ---- Ariadne 0.1.13+ sub-page: policy + sources + status report -----
// All read/write against the daemon's /api/status (over 127.0.0.1) and
// C:\ProgramData\Ariadne\policy.json (user-writable ACL) -- no UAC.
const arPolicyRadios = document.querySelectorAll('input[name="ariadnePolicy"]');
const arSourcesBody = q("ariadneSourcesBody");
const arStatusJson = q("ariadneStatusJson");
const arStatusCopy = q("ariadneStatusCopy");
const arStatusRefr = q("ariadneStatusRefresh");
const SOURCE_DESCRIPTIONS = {
snapshotHttps: { label: "HTTPS snapshot (VPS + Sia)", hint: "dl.silentmode.st + s3.silentmode.st, polled every 30 s with ETag" },
electrumWss: { label: "Electrum WSS (chipnet)", hint: "direct chain read via WebSocket; secondary refresh" },
perQueryLookup: { label: "Per-query indexer fallback", hint: "navigate.st/api/name/< host> on index miss, 30-s LRU" },
diskCache: { label: "Disk cache", hint: "C:\\ProgramData\\Ariadne\\bns-name-snapshot.json — read at boot, written on any HTTPS win" },
localApi: { label: "Local BNS indexer API", hint: "http://127.0.0.1/api/tlds, /api/name/:n, /api/registry, /api/status" },
};
function fmtTime(iso) {
if (!iso) return "never";
try { const d = new Date(iso); const s = Math.round((Date.now() - d.getTime()) / 1000); return s < 60 ? ` $ { s } s ago ` : s < 3600 ? ` $ { Math . round ( s / 60 ) } m ago ` : d . toLocaleString ( ) ; } catch { return String ( iso ) ; }
}
function sourceState(name, st) {
if (!st) return { text: "no state yet", ok: false, err: false };
if (name === "diskCache") return { text: st.exists ? `${(st.size||0).toLocaleString()} B · ${fmtTime(st.mtime)}` : "no file yet", ok: !!st.exists, err: false };
if (name === "localApi") return { text: st.enabled ? "listening" : "disabled", ok: !!st.enabled, err: false };
if (name === "perQueryLookup") return { text: `${st.hits||0} hit · ${st.misses||0} miss · ${st.errors||0} err · last ${fmtTime(st.lastAttempt)}`, ok: (st.hits||0) > 0 || !st.lastAttempt, err: (st.errors||0) > 0 };
if (st.lastError) return { text: `error: ${st.lastError.slice(0, 80)} · last ${fmtTime(st.lastAttempt)}`, ok: false, err: true };
if (st.lastSuccess) return { text: `ok · last success ${fmtTime(st.lastSuccess)}` + (st.refreshes ? ` · ${st.refreshes} refresh${st.refreshes>1?"es":""}` : ""), ok: true, err: false };
return { text: `waiting · last try ${fmtTime(st.lastAttempt)}`, ok: false, err: false };
}
let arLastStatus = null;
async function refreshAriadneStatus() {
arStatusJson.textContent = "Loading…";
arSourcesBody.innerHTML = "";
const r = await C.ariadneGetStatus();
if (!r || !r.ok) {
arStatusJson.textContent = `Cannot reach the daemon: ${r?.error || "unknown error"}\n\nIs Ariadne's Thread running? Turn it on from the Status section above.`;
arSourcesBody.innerHTML = '< div class = "asrc-row" > < span class = "aname" style = "grid-column:1 / -1;color:var(--muted)" > Daemon unreachable — start it from Status above.< / span > < / div > ';
return;
}
arLastStatus = r.status;
arStatusJson.textContent = JSON.stringify(r.status, null, 2);
const sources = r.status.sources || {};
arSourcesBody.innerHTML = "";
for (const name of Object.keys(SOURCE_DESCRIPTIONS)) {
const d = SOURCE_DESCRIPTIONS[name];
const st = sources[name] || {};
const s = sourceState(name, st);
const row = document.createElement("div");
row.className = "asrc-row";
row.innerHTML = `< span class = "aname" > ${d.label}< small > ${d.hint}< / small > < / span > ` +
`< span > < input type = "checkbox" data-src = "${name}" $ { st . enabled ? " checked " : " " } > < / span > ` +
`< span class = "astate ${s.ok ? " ok " : s . err ? " err " : " " } " > ${s.text}< / span > `;
arSourcesBody.appendChild(row);
}
// Wire the enable checkboxes AFTER they're in the DOM.
arSourcesBody.querySelectorAll('input[type="checkbox"][data-src]').forEach((cb) => {
cb.addEventListener("change", async () => {
cb.disabled = true;
const w = await C.ariadneSetSource(cb.dataset.src, cb.checked);
cb.disabled = false;
if (!w?.ok) { alert(`Could not save: ${w?.error || "unknown error"}`); cb.checked = !cb.checked; return; }
// Poll again after the daemon's 5-s hot-reload tick so state text catches up.
setTimeout(refreshAriadneStatus, 6000);
});
});
}
async function loadAriadnePolicy() {
const r = await C.ariadneGetPolicy();
const p = (r?.policy?.policy || "bcnr-first").toLowerCase();
arPolicyRadios.forEach((rb) => { rb.checked = (rb.value === p); });
}
arPolicyRadios.forEach((rb) => {
rb.addEventListener("change", async () => {
if (!rb.checked) return;
const w = await C.ariadneSetPolicy(rb.value);
if (!w?.ok) alert(`Could not save policy: ${w?.error || "unknown error"}`);
});
});
arStatusRefr.onclick = refreshAriadneStatus;
arStatusCopy.onclick = async () => {
if (!arLastStatus) return;
try { await navigator.clipboard.writeText(JSON.stringify(arLastStatus, null, 2)); arStatusCopy.textContent = "Copied"; setTimeout(() => (arStatusCopy.textContent = "Copy report"), 1500); }
catch { arStatusCopy.textContent = "Copy failed"; setTimeout(() => (arStatusCopy.textContent = "Copy report"), 1500); }
};
// Load once on script boot; also every time the user navigates INTO the
// Ariadne sub-page (fresh state, no cached-stale JSON on re-entry).
loadAriadnePolicy(); refreshAriadneStatus();
document.addEventListener("section", (e) => {
if (e.detail === "plugins/ariadne") { loadAriadnePolicy(); refreshAriadneStatus(); }
});
2026-09-30 02:16:11 +02:00
// AEGIS ——————————————————————————————————————————————————————————
// Bundled add-on shipped OTA. Same addons-check-updates IPC the
// Extensions page uses, filtered for the aegis id. Legacy id
// "bchwallet" still matched for upgrades from pre-rename installs.
const aeStatMain = q("aegisStatusMain");
const aeStatSub = q("aegisStatusSub");
const aeCheckMain = q("aegisCheckMain");
const aeCheckSub = q("aegisCheckSub");
const aeApplyMain = q("aegisApplyMain");
const aeApplySub = q("aegisApplySub");
const isAegis = (a) => a & & (a.id === "aegis" || a.id === "bchwallet");
async function refreshAegis() {
aeApplyMain.hidden = aeApplySub.hidden = true;
try {
const [listRes, stagedRes] = await Promise.all([
C.listAddons ? C.listAddons() : Promise.resolve({}),
C.listStagedAddonUpdates ? C.listStagedAddonUpdates() : Promise.resolve([]),
]);
const installed = (listRes & & listRes.installed) || [];
const staged = Array.isArray(stagedRes) ? stagedRes : [];
const cur = installed.find(isAegis);
const upd = staged.find(isAegis);
if (!cur) {
const msg = "Aegis isn't installed. Reinstall Theseus to add it back, or open Extensions to load it manually.";
aeStatMain.textContent = msg; aeStatSub.textContent = msg;
return;
}
const curVer = cur.version || "?";
if (upd & & upd.version) {
aeStatMain.innerHTML = `< b style = "color:var(--acid-text)" > v${upd.version}< / b > ready — you're on v${curVer}.`;
aeStatSub.innerHTML = `< b style = "color:var(--acid-text)" > v${upd.version}< / b > ready — click Apply update now to switch without restarting Theseus. You're on v${curVer}.`;
aeApplyMain.hidden = aeApplySub.hidden = false;
} else {
aeStatMain.textContent = `On v${curVer} — updates over-the-air, checked at boot and every 6h.`;
aeStatSub.textContent = `You're on v${curVer}. Updates arrive over-the-air; the next check runs at boot and every 6h.`;
}
} catch (e) {
const msg = "Status check failed: " + (e?.message || e);
aeStatMain.textContent = msg; aeStatSub.textContent = msg;
2026-09-21 02:43:10 +02:00
}
2026-09-30 02:16:11 +02:00
}
function wireCheck(btn) {
btn.onclick = async () => {
const orig = btn.textContent;
btn.disabled = true; btn.textContent = "Checking…";
const noneMsg = "You're on the latest Aegis.";
try {
const res = await (C.checkAddonUpdates ? C.checkAddonUpdates() : Promise.resolve({}));
const staged = (res & & Array.isArray(res.staged)) ? res.staged : [];
if (!staged.find(isAegis)) {
aeStatMain.textContent = noneMsg; aeStatSub.textContent = noneMsg;
}
refreshAegis();
} catch (e) {
const msg = "Check failed: " + (e?.message || e);
aeStatMain.textContent = msg; aeStatSub.textContent = msg;
}
finally { btn.disabled = false; btn.textContent = orig; }
};
}
wireCheck(aeCheckMain);
wireCheck(aeCheckSub);
// Apply staged update without restarting Theseus. Falls back to a
// full restart when the hot-apply IPC isn't wired (older Theseus).
function wireApply(btn) {
btn.onclick = async () => {
const orig = btn.textContent;
btn.disabled = true; btn.textContent = "Applying…";
try {
if (C & & C.applyStagedAddons) {
const r = await C.applyStagedAddons();
if (r & & r.ok === false) throw new Error(r.err || "apply failed");
const msg = "Update applied. Reopen the Aegis panel to see the new version.";
aeStatMain.textContent = msg; aeStatSub.textContent = msg;
refreshAegis();
} else if (C & & C.restartApp) {
C.restartApp();
} else {
const msg = "Update staged — restart Theseus to switch.";
aeStatMain.textContent = msg; aeStatSub.textContent = msg;
}
} catch (e) {
const msg = "Apply failed: " + (e?.message || e) + " — restart Theseus to fall back.";
aeStatMain.textContent = msg; aeStatSub.textContent = msg;
} finally { btn.disabled = false; btn.textContent = orig; }
};
}
wireApply(aeApplyMain);
wireApply(aeApplySub);
refreshAegis();
})();
2026-09-08 18:09:55 +02:00
2026-10-02 23:04:59 +02:00
// Updates panel moved OUT of this block — it runs standalone right after
// the shared WEB_LANG_LIST so a thrown exception anywhere in this big
// C.get().then((s)=>…) init can never block it again.
2026-09-08 01:42:41 +02:00
// ---- Passwords section: three states (setup / locked / unlocked) ---------
// The vault lives in main.js — this UI just calls IPC. No plaintext ever
// sits in this DOM except the value produced by a specific Show/Copy click.
const pwSetupEl = document.getElementById("pwSetup");
const pwLockedEl = document.getElementById("pwLocked");
const pwUnlockedEl = document.getElementById("pwUnlocked");
const pwListEl = document.getElementById("pwList");
function pwShow(which) {
pwSetupEl.hidden = which !== "setup";
pwLockedEl.hidden = which !== "locked";
pwUnlockedEl.hidden = which !== "unlocked";
}
async function pwRefresh() {
const st = await C.pwStatus();
if (!st.setup) return pwShow("setup");
Theseus: quick-unlock PIN for the vault, shared with extensions
The vault re-locks on every restart and only the master password opened
it, so every extension that needs it (Aegis, now Pithos) either asked for
the master password itself or grew its own PIN. Theseus now owns one:
- Settings > Passwords sets, changes or removes a 6-digit PIN. The PIN
wraps the master password (PBKDF2-SHA256, 600k iterations, AES-256-GCM)
and the result is sealed with the OS keystore (safeStorage: DPAPI /
Keychain / libsecret), so a copied vault-pin.json cannot be brute-forced
elsewhere. Every unlock still ends at the master password.
- Three wrong PINs in a row require the master password. The strike count
lives in the same file, so a restart does not reset it; a successful
master-password unlock does. A PIN whose password no longer opens the
vault (password changed) is dropped.
- unlock.html is Theseus's own prompt, over the whole window: PIN pad, or
the master password. Extensions call api.vault.requestUnlock({ reason })
(vault-derive capability) and get { ok } back; what the user typed never
reaches them. Settings' locked screen offers "Unlock with PIN" through
the same prompt.
2026-10-03 20:33:26 +02:00
const pin = await C.pinStatus().catch(() => null);
if (!st.unlocked) {
document.getElementById("pwPinUnlockRow").hidden = !(pin & & pin.pinSet);
return pwShow("locked");
}
2026-09-08 01:42:41 +02:00
pwShow("unlocked");
Theseus: quick-unlock PIN for the vault, shared with extensions
The vault re-locks on every restart and only the master password opened
it, so every extension that needs it (Aegis, now Pithos) either asked for
the master password itself or grew its own PIN. Theseus now owns one:
- Settings > Passwords sets, changes or removes a 6-digit PIN. The PIN
wraps the master password (PBKDF2-SHA256, 600k iterations, AES-256-GCM)
and the result is sealed with the OS keystore (safeStorage: DPAPI /
Keychain / libsecret), so a copied vault-pin.json cannot be brute-forced
elsewhere. Every unlock still ends at the master password.
- Three wrong PINs in a row require the master password. The strike count
lives in the same file, so a restart does not reset it; a successful
master-password unlock does. A PIN whose password no longer opens the
vault (password changed) is dropped.
- unlock.html is Theseus's own prompt, over the whole window: PIN pad, or
the master password. Extensions call api.vault.requestUnlock({ reason })
(vault-derive capability) and get { ok } back; what the user typed never
reaches them. Settings' locked screen offers "Unlock with PIN" through
the same prompt.
2026-10-03 20:33:26 +02:00
renderPin(pin);
2026-09-08 01:42:41 +02:00
const res = await C.pwList();
renderPwList(res.ok ? res.entries : []);
}
Theseus: quick-unlock PIN for the vault, shared with extensions
The vault re-locks on every restart and only the master password opened
it, so every extension that needs it (Aegis, now Pithos) either asked for
the master password itself or grew its own PIN. Theseus now owns one:
- Settings > Passwords sets, changes or removes a 6-digit PIN. The PIN
wraps the master password (PBKDF2-SHA256, 600k iterations, AES-256-GCM)
and the result is sealed with the OS keystore (safeStorage: DPAPI /
Keychain / libsecret), so a copied vault-pin.json cannot be brute-forced
elsewhere. Every unlock still ends at the master password.
- Three wrong PINs in a row require the master password. The strike count
lives in the same file, so a restart does not reset it; a successful
master-password unlock does. A PIN whose password no longer opens the
vault (password changed) is dropped.
- unlock.html is Theseus's own prompt, over the whole window: PIN pad, or
the master password. Extensions call api.vault.requestUnlock({ reason })
(vault-derive capability) and get { ok } back; what the user typed never
reaches them. Settings' locked screen offers "Unlock with PIN" through
the same prompt.
2026-10-03 20:33:26 +02:00
// ---- Quick-unlock PIN ------------------------------------------------
function renderPin(pin) {
const set = !!(pin & & pin.pinSet);
document.getElementById("pinSetBtn").textContent = set ? "Change PIN" : "Set a PIN";
document.getElementById("pinClearBtn").hidden = !set;
const desc = document.getElementById("pinDesc");
Theseus: one PIN — the vault PIN, offered to Aegis through api.vault.pin
Theseus and Aegis each wrapped the same master password under their
own PIN: two offline targets, two guess budgets, and two PINs to keep
in step. The vault PIN is now the only one. Built-in add-ons get
api.vault.pin {status, unlock, set, clear} (advertised by
features.vaultPin); unlock(pin) opens the vault in main and answers
only { ok } or why not, so the master password stays in main.
The policy is the one Aegis's PIN screens describe: five wrong PINs
lock the PIN for 15 minutes, every further wrong one locks it again,
and the master password always works. The unlock prompt uses the same
PIN pad and the same wording as Aegis, and Settings says so.
2026-10-04 04:15:15 +02:00
const base = "A 6-digit PIN that unlocks the vault instead of the master password, here, in Aegis and in extensions such as Pithos. Five wrong PINs lock it for 15 minutes; the master password always works.";
desc.textContent = set & & pin.lockedMs > 0 ? base + ` Locked after wrong tries — it works again in ${Math.max(1, Math.ceil(pin.lockedMs / 60000))} min, or at once after a master-password unlock.`
2026-10-04 03:39:14 +02:00
: set & & pin.hardware === "tpm" ? base + " It is tied to this computer's security chip (TPM), which allows only a few wrong guesses an hour, even to malware or a copied disk."
: set ? base + " This computer has no usable security chip, so the PIN only stops casual use: anything running as your Windows account, or a copy of this disk with your Windows password, can find it in minutes and with it your master password."
: pin & & pin.storable === false ? base + " This system has no protected keystore, so a PIN cannot be stored safely here."
Theseus: quick-unlock PIN for the vault, shared with extensions
The vault re-locks on every restart and only the master password opened
it, so every extension that needs it (Aegis, now Pithos) either asked for
the master password itself or grew its own PIN. Theseus now owns one:
- Settings > Passwords sets, changes or removes a 6-digit PIN. The PIN
wraps the master password (PBKDF2-SHA256, 600k iterations, AES-256-GCM)
and the result is sealed with the OS keystore (safeStorage: DPAPI /
Keychain / libsecret), so a copied vault-pin.json cannot be brute-forced
elsewhere. Every unlock still ends at the master password.
- Three wrong PINs in a row require the master password. The strike count
lives in the same file, so a restart does not reset it; a successful
master-password unlock does. A PIN whose password no longer opens the
vault (password changed) is dropped.
- unlock.html is Theseus's own prompt, over the whole window: PIN pad, or
the master password. Extensions call api.vault.requestUnlock({ reason })
(vault-derive capability) and get { ok } back; what the user typed never
reaches them. Settings' locked screen offers "Unlock with PIN" through
the same prompt.
2026-10-03 20:33:26 +02:00
: base;
2026-10-04 03:39:14 +02:00
document.getElementById("pinSetBtn").disabled = !set & & !!pin & & pin.storable === false;
Theseus: quick-unlock PIN for the vault, shared with extensions
The vault re-locks on every restart and only the master password opened
it, so every extension that needs it (Aegis, now Pithos) either asked for
the master password itself or grew its own PIN. Theseus now owns one:
- Settings > Passwords sets, changes or removes a 6-digit PIN. The PIN
wraps the master password (PBKDF2-SHA256, 600k iterations, AES-256-GCM)
and the result is sealed with the OS keystore (safeStorage: DPAPI /
Keychain / libsecret), so a copied vault-pin.json cannot be brute-forced
elsewhere. Every unlock still ends at the master password.
- Three wrong PINs in a row require the master password. The strike count
lives in the same file, so a restart does not reset it; a successful
master-password unlock does. A PIN whose password no longer opens the
vault (password changed) is dropped.
- unlock.html is Theseus's own prompt, over the whole window: PIN pad, or
the master password. Extensions call api.vault.requestUnlock({ reason })
(vault-derive capability) and get { ok } back; what the user typed never
reaches them. Settings' locked screen offers "Unlock with PIN" through
the same prompt.
2026-10-03 20:33:26 +02:00
}
const pinForm = document.getElementById("pinForm");
const pinErr = document.getElementById("pinErr");
document.getElementById("pinSetBtn").onclick = () => { pinForm.hidden = false; pinErr.hidden = true; document.getElementById("pinMaster").focus(); };
document.getElementById("pinCancel").onclick = () => {
pinForm.hidden = true;
for (const id of ["pinMaster", "pinNew1", "pinNew2"]) document.getElementById(id).value = "";
};
document.getElementById("pinSave").onclick = async () => {
pinErr.hidden = true;
const master = document.getElementById("pinMaster").value;
const p1 = document.getElementById("pinNew1").value;
const p2 = document.getElementById("pinNew2").value;
const fail = (m) => { pinErr.textContent = m; pinErr.hidden = false; };
if (!/^\d{6}$/.test(p1)) return fail("The PIN must be 6 digits.");
if (p1 !== p2) return fail("The two PINs don't match.");
if (!master) return fail("Enter your master password to bind the PIN to it.");
const res = await C.pinSet(p1, master);
if (!res.ok) return fail(res.err === "wrong master password" ? "Wrong master password." : res.err);
document.getElementById("pinCancel").click();
pwRefresh();
};
document.getElementById("pinClearBtn").onclick = async () => {
if (!confirm("Remove the PIN? The vault will need the master password again.")) return;
await C.pinClear();
pwRefresh();
};
document.getElementById("pwPinUnlockBtn").onclick = async () => {
const r = await C.pinUnlock();
if (r & & r.ok) pwRefresh();
};
2026-09-08 01:42:41 +02:00
function renderPwList(entries) {
if (!entries.length) {
pwListEl.innerHTML = `< div class = "cempty" style = "padding:12px 0" > No entries yet — add one below.< / div > `;
return;
}
pwListEl.innerHTML = entries.map((e) => `< div class = "eng" data-id = "${esc(e.id)}" > ` +
2026-10-03 09:50:10 +02:00
`< span class = "eic" > < span class = "es" > 🔑< / span > < / span > ` +
2026-09-08 01:42:41 +02:00
`< span class = "enm" > < b > ${esc(e.domain)}< / b > < span class = "pmuted" > · ${esc(e.username || "—")}< / span > < span class = "pmuted" style = "font-size:11px" > · ${e.kind === "generated" ? "generated" : "pasted"}< / span > < / span > ` +
`< button class = "cx pwShow" title = "Show + copy" > 👁< / button > ` +
`< button class = "cx pwDel" title = "Remove" > ✕< / button > ` +
`< / div > `).join("");
pwListEl.querySelectorAll(".pwShow").forEach((b) => b.onclick = async (ev) => {
const id = ev.target.closest(".eng").dataset.id;
const res = await C.pwGet(id);
if (!res.ok) return alert("Couldn't read: " + res.err);
try { await navigator.clipboard.writeText(res.password); }
catch { /* browser may block clipboard in dev — fall through to a prompt */ prompt("Password (copy manually):", res.password); return; }
b.textContent = "copied ✓"; setTimeout(() => (b.textContent = "👁"), 1600);
});
pwListEl.querySelectorAll(".pwDel").forEach((b) => b.onclick = async (ev) => {
const id = ev.target.closest(".eng").dataset.id;
if (!confirm("Remove this entry?")) return;
await C.pwRemove(id); pwRefresh();
});
}
// Setup — create vault
document.querySelectorAll('input[name="pwSeedSource"]').forEach((r) => r.addEventListener("change", () => {
document.getElementById("pwSetupMnemonic").hidden = document.querySelector('input[name="pwSeedSource"]:checked').value !== "mnemonic";
}));
document.getElementById("pwSetupBtn").onclick = async () => {
const p1 = document.getElementById("pwSetupPw1").value;
const p2 = document.getElementById("pwSetupPw2").value;
if (!p1 || p1.length < 8 ) return alert ( " Master password must be at least 8 characters . " ) ;
if (p1 !== p2) return alert("Passwords don't match.");
const kind = document.querySelector('input[name="pwSeedSource"]:checked').value;
const seedSource = kind === "mnemonic"
? { kind: "mnemonic", mnemonic: document.getElementById("pwSetupMnemonic").value }
: { kind: "generate" };
if (kind === "mnemonic" & & !seedSource.mnemonic.trim()) return alert("Paste your mnemonic or switch to 'Generate a new independent seed'.");
const res = await C.pwSetup(p1, seedSource);
if (!res.ok) return alert("Setup failed: " + res.err);
// Vault created AND unlocked by main. Clear the setup fields.
document.getElementById("pwSetupPw1").value = "";
document.getElementById("pwSetupPw2").value = "";
document.getElementById("pwSetupMnemonic").value = "";
pwRefresh();
};
// Unlock
document.getElementById("pwUnlockBtn").onclick = async () => {
const err = document.getElementById("pwUnlockErr");
err.hidden = true;
const pw = document.getElementById("pwUnlockPw").value;
const res = await C.pwUnlock(pw);
if (!res.ok) { err.textContent = res.err; err.hidden = false; return; }
document.getElementById("pwUnlockPw").value = "";
pwRefresh();
};
document.getElementById("pwUnlockPw").addEventListener("keydown", (e) => { if (e.key === "Enter") document.getElementById("pwUnlockBtn").click(); });
// Lock
document.getElementById("pwLockBtn").onclick = async () => { await C.pwLock(); pwRefresh(); };
// Add-entry form: toggle literal input; wire preview + save
document.querySelectorAll('input[name="pwAddKind"]').forEach((r) => r.addEventListener("change", () => {
const kind = document.querySelector('input[name="pwAddKind"]:checked').value;
document.getElementById("pwAddLiteral").hidden = kind !== "literal";
document.getElementById("pwAddPreview").hidden = kind !== "generated";
document.getElementById("pwAddPreviewOut").hidden = kind !== "generated";
}));
document.getElementById("pwAddPreview").onclick = async () => {
const domain = document.getElementById("pwAddDomain").value.trim();
const username = document.getElementById("pwAddUser").value.trim();
if (!domain) return alert("Enter a site.");
const res = await C.pwGenerate({ domain, username });
if (!res.ok) return alert("Preview failed: " + res.err);
document.getElementById("pwAddPreviewOut").value = res.password;
};
document.getElementById("pwAddBtn").onclick = async () => {
const domain = document.getElementById("pwAddDomain").value.trim();
const username = document.getElementById("pwAddUser").value.trim();
if (!domain) return alert("Enter a site.");
const kind = document.querySelector('input[name="pwAddKind"]:checked').value;
const spec = { domain, username };
if (kind === "literal") {
const lit = document.getElementById("pwAddLiteral").value;
if (!lit) return alert("Paste the password to save.");
spec.literal = lit;
}
const res = await C.pwAdd(spec);
if (!res.ok) return alert("Add failed: " + res.err);
document.getElementById("pwAddDomain").value = "";
document.getElementById("pwAddUser").value = "";
document.getElementById("pwAddLiteral").value = "";
document.getElementById("pwAddPreviewOut").value = "";
pwRefresh();
};
// Initial state — decide which panel to show now, and every time the user
// switches to Passwords in the sidebar (so a lock elsewhere is reflected).
pwRefresh();
document.querySelector('.side a[data-sec="passwords"]').addEventListener("click", pwRefresh);
});
// ---- Add-ons management ----
const addonsList = document.getElementById("addonsList");
2026-09-09 02:43:28 +02:00
// Per-addon update state, keyed by addon id. Populated by loadAddonUpdates()
// (staged: from listStagedAddonUpdates, background-polled) and by the
// manual "Check for updates" button (report: fresh per-addon status).
// renderAddons reads both when drawing each card so the update line lives
// inside the card — no separate "Pending updates" strip at the top.
const addonUpdates = { staged: {}, report: {} };
function updateLineFor(a) {
const st = addonUpdates.staged[a.id];
const rep = addonUpdates.report[a.id];
if (st) {
return '< div class = "d" style = "color:var(--acid);margin-top:4px" > ↻ Update < b > v' + escapeHtml(st.version) + '< / b > staged — restart Theseus to apply.< / div > ';
}
if (!rep) return "";
let msg = "", cls = "color:var(--dim)";
switch (rep.status) {
case "up-to-date": msg = "Up to date."; break;
case "no-update-url": return ""; // don't clutter cards that never opted in
case "fetch-failed": msg = "Update check failed — " + escapeHtml(rep.detail || "network"); cls = "color:#f6768a"; break;
case "signature-invalid": msg = "Endpoint offered v" + escapeHtml(rep.newVer || "?") + " with a BAD signature — rejected."; cls = "color:#f6768a"; break;
case "sha256-mismatch": msg = "Endpoint offered v" + escapeHtml(rep.newVer || "?") + " but the tarball hash didn't match."; cls = "color:#f6768a"; break;
case "extract-failed": msg = "v" + escapeHtml(rep.newVer || "?") + " downloaded but wouldn't extract — " + escapeHtml(rep.detail || ""); cls = "color:#f6768a"; break;
case "manifest-mismatch": msg = "Extracted manifest didn't match signed values."; cls = "color:#f6768a"; break;
case "already-staged": msg = "↻ v" + escapeHtml(rep.newVer || "?") + " already staged — restart to apply."; cls = "color:var(--acid)"; break;
case "staged": msg = "↻ Staged v" + escapeHtml(rep.newVer || "?") + " — restart to apply."; cls = "color:var(--acid)"; break;
default: msg = escapeHtml(rep.status || "unknown");
}
return '< div class = "d" style = "' + cls + ';margin-top:4px" > ' + msg + '< / div > ';
}
feat(theseus/settings): Extensions as compact rows with a detail view
The Extensions list was a stack of tall cards — description, author,
capabilities and buttons on every one — so seven add-ons filled the
page before the user found the toggle. Rows are now one line each,
Firefox-style: icon, name, built-in badge, version, a short update
status, the on/off switch and a ⋯ menu, grouped Enabled / Disabled /
Failed to load. Clicking a row opens the detail view in place: back
arrow, description, update status, author, version, type, folder with
Show folder, and a Permissions block that explains each declared
capability in plain words. The ⋯ menu (and right-click) offers Turn
on/off, Details, Show folder and, for non-bundled extensions, Remove —
a new addons-remove IPC that deletes the folder under the extensions
directory, refuses bundled add-ons (they would only be reseeded), and
clears the dock prefs it left behind.
2026-09-22 20:43:50 +02:00
// ---- Extensions list: compact rows + detail view ----------------------
let lastAddonSnap = null; // last snapshot, so the detail view can re-render
let addonDetailId = null; // id shown in the detail view, or null (list)
const addonIconHtml = (icon) => /^data:image\//i.test(icon || "")
? '< img src = "' + escapeAttr(icon) + '" alt = "" > '
: escapeHtml(icon || "•");
// Short status for the row: staged update or the last check's problem.
function addonShortStatus(a) {
const st = addonUpdates.staged[a.id]; if (st) return { cls: "upd", text: "↻ v" + st.version + " staged — restart to apply" };
const rep = addonUpdates.report[a.id]; if (!rep) return null;
if (["fetch-failed", "signature-invalid", "sha256-mismatch", "extract-failed", "manifest-mismatch"].includes(rep.status)) return { cls: "warn", text: "Update check failed" };
if (rep.status === "staged" || rep.status === "already-staged") return { cls: "upd", text: "↻ v" + (rep.newVer || "?") + " staged — restart to apply" };
return null;
}
const CAP_TEXT = {
"sidebar-panel": "Adds a panel to the sidebar.",
"toolbar-menu": "Adds a dropdown menu to its toolbar button.",
"context-menu-item": "Adds entries to the page right-click menu.",
"open-tab": "Can open its own pages as full tabs.",
"page-inject": "Runs code inside the web pages it declares — it can read and change what those pages show.",
"capture-tab": "Can take screenshots of the current tab.",
"vault-derive": "Derives its own keys from your password vault. It never sees the vault itself.",
"approval-modal": "Can ask you to approve an action in a dialog over the page.",
"session-proxy": "Can route the browser's traffic through a proxy it controls.",
};
2026-09-08 01:42:41 +02:00
function renderAddons(snap) {
feat(theseus/settings): Extensions as compact rows with a detail view
The Extensions list was a stack of tall cards — description, author,
capabilities and buttons on every one — so seven add-ons filled the
page before the user found the toggle. Rows are now one line each,
Firefox-style: icon, name, built-in badge, version, a short update
status, the on/off switch and a ⋯ menu, grouped Enabled / Disabled /
Failed to load. Clicking a row opens the detail view in place: back
arrow, description, update status, author, version, type, folder with
Show folder, and a Permissions block that explains each declared
capability in plain words. The ⋯ menu (and right-click) offers Turn
on/off, Details, Show folder and, for non-bundled extensions, Remove —
a new addons-remove IPC that deletes the folder under the extensions
directory, refuses bundled add-ons (they would only be reseeded), and
clears the dock prefs it left behind.
2026-09-22 20:43:50 +02:00
lastAddonSnap = snap;
fix(theseus): extension updates re-check, announce and install in place
Updates published after launch never showed: the add-on channel was
checked once, 30 s after boot, and staged copies only applied on the
next launch with nothing telling the user. On 2026-09-22 Aegis 0.8.3
and VPN 0.1.3 landed minutes after the app's only check and stayed
invisible through manual scans made earlier and a restart made before
they were published.
Now: the check repeats every 4 hours; every check (boot, timer, manual,
add-on-driven) reports what is staged to the chrome, which shows a chip
for staged extensions; clicking it, or the "Update to vX" button that
appears on the extension's row and detail in Settings, promotes the
staged folder over the installed one and rebuilds the add-on host, so
the new version runs without a restart. Plug-ins (Aegis) are excluded
from the chip and the hot swap — a wallet updates from its own panel
and applies on the next launch.
Also from the same review: the new-tab button follows the last tab and
parks after the scroll arrow only when the strip overflows; Tor sits
left of the Aegis chip; plug-ins no longer appear in Settings ›
Extensions (they have Plug-ins); the extension detail view has a
labelled Back button, a close button and a Check now button; the
promotion helper returns what it promoted and accepts a filter.
2026-09-22 21:25:33 +02:00
// Plug-ins (Aegis, category "plugin") have their own cards under Plug-ins.
const items = ((snap & & snap.installed) || []).filter((a) => a.category !== "plugin");
2026-09-08 01:42:41 +02:00
if (!items.length) {
feat(theseus/settings): Extensions as compact rows with a detail view
The Extensions list was a stack of tall cards — description, author,
capabilities and buttons on every one — so seven add-ons filled the
page before the user found the toggle. Rows are now one line each,
Firefox-style: icon, name, built-in badge, version, a short update
status, the on/off switch and a ⋯ menu, grouped Enabled / Disabled /
Failed to load. Clicking a row opens the detail view in place: back
arrow, description, update status, author, version, type, folder with
Show folder, and a Permissions block that explains each declared
capability in plain words. The ⋯ menu (and right-click) offers Turn
on/off, Details, Show folder and, for non-bundled extensions, Remove —
a new addons-remove IPC that deletes the folder under the extensions
directory, refuses bundled add-ons (they would only be reseeded), and
clears the dock prefs it left behind.
2026-09-22 20:43:50 +02:00
addonsList.innerHTML = '< div class = "d" style = "color:var(--dim)" > No extensions installed. Install one from theseus.x/extensions, or drop a folder into the extensions directory.< / div > ';
2026-09-08 01:42:41 +02:00
return;
}
feat(theseus/settings): Extensions as compact rows with a detail view
The Extensions list was a stack of tall cards — description, author,
capabilities and buttons on every one — so seven add-ons filled the
page before the user found the toggle. Rows are now one line each,
Firefox-style: icon, name, built-in badge, version, a short update
status, the on/off switch and a ⋯ menu, grouped Enabled / Disabled /
Failed to load. Clicking a row opens the detail view in place: back
arrow, description, update status, author, version, type, folder with
Show folder, and a Permissions block that explains each declared
capability in plain words. The ⋯ menu (and right-click) offers Turn
on/off, Details, Show folder and, for non-bundled extensions, Remove —
a new addons-remove IPC that deletes the folder under the extensions
directory, refuses bundled add-ons (they would only be reseeded), and
clears the dock prefs it left behind.
2026-09-22 20:43:50 +02:00
const rowFor = (a) => {
2026-09-08 01:42:41 +02:00
if (a.error) {
feat(theseus/settings): Extensions as compact rows with a detail view
The Extensions list was a stack of tall cards — description, author,
capabilities and buttons on every one — so seven add-ons filled the
page before the user found the toggle. Rows are now one line each,
Firefox-style: icon, name, built-in badge, version, a short update
status, the on/off switch and a ⋯ menu, grouped Enabled / Disabled /
Failed to load. Clicking a row opens the detail view in place: back
arrow, description, update status, author, version, type, folder with
Show folder, and a Permissions block that explains each declared
capability in plain words. The ⋯ menu (and right-click) offers Turn
on/off, Details, Show folder and, for non-bundled extensions, Remove —
a new addons-remove IPC that deletes the folder under the extensions
directory, refuses bundled add-ons (they would only be reseeded), and
clears the dock prefs it left behind.
2026-09-22 20:43:50 +02:00
return '< div class = "xrow" data-id = "" data-folder = "' + escapeAttr(a.folder) + '" title = "' + escapeAttr(a.error) + '" > < span class = "xi" > ⚠< / span > < span class = "xn" > Load failed < span class = "xv" > ' + escapeHtml(a.folder) + '< / span > < / span > < span class = "xs warn" > ' + escapeHtml(a.error) + '< / span > < span class = "xctl" > < button class = "btn" data-reveal = "' + escapeAttr(a.folder) + '" style = "padding:5px 10px;font-size:12px" > Show folder< / button > < / span > < / div > ';
2026-09-08 01:42:41 +02:00
}
feat(theseus/settings): Extensions as compact rows with a detail view
The Extensions list was a stack of tall cards — description, author,
capabilities and buttons on every one — so seven add-ons filled the
page before the user found the toggle. Rows are now one line each,
Firefox-style: icon, name, built-in badge, version, a short update
status, the on/off switch and a ⋯ menu, grouped Enabled / Disabled /
Failed to load. Clicking a row opens the detail view in place: back
arrow, description, update status, author, version, type, folder with
Show folder, and a Permissions block that explains each declared
capability in plain words. The ⋯ menu (and right-click) offers Turn
on/off, Details, Show folder and, for non-bundled extensions, Remove —
a new addons-remove IPC that deletes the folder under the extensions
directory, refuses bundled add-ons (they would only be reseeded), and
clears the dock prefs it left behind.
2026-09-22 20:43:50 +02:00
const s = addonShortStatus(a);
fix(theseus): extension updates re-check, announce and install in place
Updates published after launch never showed: the add-on channel was
checked once, 30 s after boot, and staged copies only applied on the
next launch with nothing telling the user. On 2026-09-22 Aegis 0.8.3
and VPN 0.1.3 landed minutes after the app's only check and stayed
invisible through manual scans made earlier and a restart made before
they were published.
Now: the check repeats every 4 hours; every check (boot, timer, manual,
add-on-driven) reports what is staged to the chrome, which shows a chip
for staged extensions; clicking it, or the "Update to vX" button that
appears on the extension's row and detail in Settings, promotes the
staged folder over the installed one and rebuilds the add-on host, so
the new version runs without a restart. Plug-ins (Aegis) are excluded
from the chip and the hot swap — a wallet updates from its own panel
and applies on the next launch.
Also from the same review: the new-tab button follows the last tab and
parks after the scroll arrow only when the strip overflows; Tor sits
left of the Aegis chip; plug-ins no longer appear in Settings ›
Extensions (they have Plug-ins); the extension detail view has a
labelled Back button, a close button and a Check now button; the
promotion helper returns what it promoted and accepts a filter.
2026-09-22 21:25:33 +02:00
const st = addonUpdates.staged[a.id];
feat(theseus/settings): Extensions as compact rows with a detail view
The Extensions list was a stack of tall cards — description, author,
capabilities and buttons on every one — so seven add-ons filled the
page before the user found the toggle. Rows are now one line each,
Firefox-style: icon, name, built-in badge, version, a short update
status, the on/off switch and a ⋯ menu, grouped Enabled / Disabled /
Failed to load. Clicking a row opens the detail view in place: back
arrow, description, update status, author, version, type, folder with
Show folder, and a Permissions block that explains each declared
capability in plain words. The ⋯ menu (and right-click) offers Turn
on/off, Details, Show folder and, for non-bundled extensions, Remove —
a new addons-remove IPC that deletes the folder under the extensions
directory, refuses bundled add-ons (they would only be reseeded), and
clears the dock prefs it left behind.
2026-09-22 20:43:50 +02:00
return '< div class = "xrow" data-id = "' + escapeAttr(a.id) + '" tabindex = "0" title = "' + escapeAttr(a.description || " " ) + ' " > '
+ '< span class = "xi" > ' + addonIconHtml(a.icon) + '< / span > '
+ '< span class = "xn" > ' + escapeHtml(a.name) + (a.bundled ? '< span class = "xbadge" > BUILT-IN< / span > ' : '') + '< span class = "xv" > ' + escapeHtml(a.version) + '< / span > < / span > '
fix(theseus): extension updates re-check, announce and install in place
Updates published after launch never showed: the add-on channel was
checked once, 30 s after boot, and staged copies only applied on the
next launch with nothing telling the user. On 2026-09-22 Aegis 0.8.3
and VPN 0.1.3 landed minutes after the app's only check and stayed
invisible through manual scans made earlier and a restart made before
they were published.
Now: the check repeats every 4 hours; every check (boot, timer, manual,
add-on-driven) reports what is staged to the chrome, which shows a chip
for staged extensions; clicking it, or the "Update to vX" button that
appears on the extension's row and detail in Settings, promotes the
staged folder over the installed one and rebuilds the add-on host, so
the new version runs without a restart. Plug-ins (Aegis) are excluded
from the chip and the hot swap — a wallet updates from its own panel
and applies on the next launch.
Also from the same review: the new-tab button follows the last tab and
parks after the scroll arrow only when the strip overflows; Tor sits
left of the Aegis chip; plug-ins no longer appear in Settings ›
Extensions (they have Plug-ins); the extension detail view has a
labelled Back button, a close button and a Check now button; the
promotion helper returns what it promoted and accepts a filter.
2026-09-22 21:25:33 +02:00
+ (st ? '' : (s ? '< span class = "xs ' + s.cls + '" > ' + escapeHtml(s.text) + '< / span > ' : ''))
+ '< span class = "xctl" > ' + (st ? '< button class = "btn xupd" data-apply = "' + escapeAttr(a.id) + '" title = "Signed update staged — installs now, no restart" > Update to ' + escapeHtml(st.version) + '< / button > ' : '')
+ '< label class = "sw sm" title = "' + (a.enabled ? " On " : " Off " ) + ' " > < input type = "checkbox" data-toggle = "' + escapeAttr(a.id) + '" ' + ( a . enabled ? " checked " : " " ) + ' > < span class = "track" > < span class = "knob" > < / span > < / span > < / label > '
feat(theseus/settings): Extensions as compact rows with a detail view
The Extensions list was a stack of tall cards — description, author,
capabilities and buttons on every one — so seven add-ons filled the
page before the user found the toggle. Rows are now one line each,
Firefox-style: icon, name, built-in badge, version, a short update
status, the on/off switch and a ⋯ menu, grouped Enabled / Disabled /
Failed to load. Clicking a row opens the detail view in place: back
arrow, description, update status, author, version, type, folder with
Show folder, and a Permissions block that explains each declared
capability in plain words. The ⋯ menu (and right-click) offers Turn
on/off, Details, Show folder and, for non-bundled extensions, Remove —
a new addons-remove IPC that deletes the folder under the extensions
directory, refuses bundled add-ons (they would only be reseeded), and
clears the dock prefs it left behind.
2026-09-22 20:43:50 +02:00
+ '< button class = "xmore" data-more = "' + escapeAttr(a.id) + '" title = "More options" aria-label = "More options" > ⋯< / button > < / span > '
+ '< / div > ';
2026-09-08 13:12:55 +02:00
};
feat(theseus/settings): Extensions as compact rows with a detail view
The Extensions list was a stack of tall cards — description, author,
capabilities and buttons on every one — so seven add-ons filled the
page before the user found the toggle. Rows are now one line each,
Firefox-style: icon, name, built-in badge, version, a short update
status, the on/off switch and a ⋯ menu, grouped Enabled / Disabled /
Failed to load. Clicking a row opens the detail view in place: back
arrow, description, update status, author, version, type, folder with
Show folder, and a Permissions block that explains each declared
capability in plain words. The ⋯ menu (and right-click) offers Turn
on/off, Details, Show folder and, for non-bundled extensions, Remove —
a new addons-remove IPC that deletes the folder under the extensions
directory, refuses bundled add-ons (they would only be reseeded), and
clears the dock prefs it left behind.
2026-09-22 20:43:50 +02:00
const enabled = items.filter((a) => !a.error & & a.enabled);
const disabled = items.filter((a) => !a.error & & !a.enabled);
const failed = items.filter((a) => a.error);
2026-09-08 13:12:55 +02:00
let html = "";
feat(theseus/settings): Extensions as compact rows with a detail view
The Extensions list was a stack of tall cards — description, author,
capabilities and buttons on every one — so seven add-ons filled the
page before the user found the toggle. Rows are now one line each,
Firefox-style: icon, name, built-in badge, version, a short update
status, the on/off switch and a ⋯ menu, grouped Enabled / Disabled /
Failed to load. Clicking a row opens the detail view in place: back
arrow, description, update status, author, version, type, folder with
Show folder, and a Permissions block that explains each declared
capability in plain words. The ⋯ menu (and right-click) offers Turn
on/off, Details, Show folder and, for non-bundled extensions, Remove —
a new addons-remove IPC that deletes the folder under the extensions
directory, refuses bundled add-ons (they would only be reseeded), and
clears the dock prefs it left behind.
2026-09-22 20:43:50 +02:00
if (enabled.length) html += '< div class = "xgrp" > Enabled< / div > ' + enabled.map(rowFor).join("");
if (disabled.length) html += '< div class = "xgrp" > Disabled< / div > ' + disabled.map(rowFor).join("");
if (failed.length) html += '< div class = "xgrp" > Failed to load< / div > ' + failed.map(rowFor).join("");
2026-09-08 13:12:55 +02:00
addonsList.innerHTML = html;
feat(theseus/settings): Extensions as compact rows with a detail view
The Extensions list was a stack of tall cards — description, author,
capabilities and buttons on every one — so seven add-ons filled the
page before the user found the toggle. Rows are now one line each,
Firefox-style: icon, name, built-in badge, version, a short update
status, the on/off switch and a ⋯ menu, grouped Enabled / Disabled /
Failed to load. Clicking a row opens the detail view in place: back
arrow, description, update status, author, version, type, folder with
Show folder, and a Permissions block that explains each declared
capability in plain words. The ⋯ menu (and right-click) offers Turn
on/off, Details, Show folder and, for non-bundled extensions, Remove —
a new addons-remove IPC that deletes the folder under the extensions
directory, refuses bundled add-ons (they would only be reseeded), and
clears the dock prefs it left behind.
2026-09-22 20:43:50 +02:00
wireAddonControls(addonsList);
addonsList.querySelectorAll('.xrow[data-id]').forEach((row) => {
const open = () => { if (row.dataset.id) openAddonDetail(row.dataset.id); };
row.addEventListener("click", (e) => { if (e.target.closest(".xctl")) return; open(); });
row.addEventListener("keydown", (e) => { if ((e.key === "Enter" || e.key === " ") & & !e.target.closest(".xctl")) { e.preventDefault(); open(); } });
row.addEventListener("contextmenu", (e) => { e.preventDefault(); if (row.dataset.id) openAddonMenu(row.dataset.id, e.clientX, e.clientY); });
2026-09-08 01:42:41 +02:00
});
feat(theseus/settings): Extensions as compact rows with a detail view
The Extensions list was a stack of tall cards — description, author,
capabilities and buttons on every one — so seven add-ons filled the
page before the user found the toggle. Rows are now one line each,
Firefox-style: icon, name, built-in badge, version, a short update
status, the on/off switch and a ⋯ menu, grouped Enabled / Disabled /
Failed to load. Clicking a row opens the detail view in place: back
arrow, description, update status, author, version, type, folder with
Show folder, and a Permissions block that explains each declared
capability in plain words. The ⋯ menu (and right-click) offers Turn
on/off, Details, Show folder and, for non-bundled extensions, Remove —
a new addons-remove IPC that deletes the folder under the extensions
directory, refuses bundled add-ons (they would only be reseeded), and
clears the dock prefs it left behind.
2026-09-22 20:43:50 +02:00
if (addonDetailId) renderAddonDetail();
}
// Toggle / ⋯ / Show folder — shared by the list rows and the detail header.
function wireAddonControls(root) {
root.querySelectorAll('input[data-toggle]').forEach((cb) => {
cb.addEventListener("change", async () => { await C.setAddonEnabled(cb.dataset.toggle, cb.checked); loadAddons(); });
2026-09-08 01:42:41 +02:00
});
feat(theseus/settings): Extensions as compact rows with a detail view
The Extensions list was a stack of tall cards — description, author,
capabilities and buttons on every one — so seven add-ons filled the
page before the user found the toggle. Rows are now one line each,
Firefox-style: icon, name, built-in badge, version, a short update
status, the on/off switch and a ⋯ menu, grouped Enabled / Disabled /
Failed to load. Clicking a row opens the detail view in place: back
arrow, description, update status, author, version, type, folder with
Show folder, and a Permissions block that explains each declared
capability in plain words. The ⋯ menu (and right-click) offers Turn
on/off, Details, Show folder and, for non-bundled extensions, Remove —
a new addons-remove IPC that deletes the folder under the extensions
directory, refuses bundled add-ons (they would only be reseeded), and
clears the dock prefs it left behind.
2026-09-22 20:43:50 +02:00
root.querySelectorAll('button[data-reveal]').forEach((btn) => btn.addEventListener("click", (e) => { e.stopPropagation(); C.revealAddon(btn.dataset.reveal); }));
fix(theseus): extension updates re-check, announce and install in place
Updates published after launch never showed: the add-on channel was
checked once, 30 s after boot, and staged copies only applied on the
next launch with nothing telling the user. On 2026-09-22 Aegis 0.8.3
and VPN 0.1.3 landed minutes after the app's only check and stayed
invisible through manual scans made earlier and a restart made before
they were published.
Now: the check repeats every 4 hours; every check (boot, timer, manual,
add-on-driven) reports what is staged to the chrome, which shows a chip
for staged extensions; clicking it, or the "Update to vX" button that
appears on the extension's row and detail in Settings, promotes the
staged folder over the installed one and rebuilds the add-on host, so
the new version runs without a restart. Plug-ins (Aegis) are excluded
from the chip and the hot swap — a wallet updates from its own panel
and applies on the next launch.
Also from the same review: the new-tab button follows the last tab and
parks after the scroll arrow only when the strip overflows; Tor sits
left of the Aegis chip; plug-ins no longer appear in Settings ›
Extensions (they have Plug-ins); the extension detail view has a
labelled Back button, a close button and a Check now button; the
promotion helper returns what it promoted and accepts a filter.
2026-09-22 21:25:33 +02:00
root.querySelectorAll('button[data-apply]').forEach((btn) => btn.addEventListener("click", async (e) => {
e.stopPropagation();
btn.disabled = true; btn.textContent = "Installing…";
try { await C.applyStagedAddons(btn.dataset.apply); } catch {}
await loadAddonUpdates(); loadAddons();
}));
root.querySelectorAll('button[data-check]').forEach((btn) => btn.addEventListener("click", async (e) => {
e.stopPropagation();
btn.disabled = true; const orig = btn.textContent; btn.textContent = "Checking…";
try {
const res = await C.checkAddonUpdates();
const rep = (res & & res.report || []).find((r) => r.id === btn.dataset.check);
addonUpdates.report = addonUpdates.report || {};
for (const r of (res & & res.report || [])) addonUpdates.report[r.id] = r;
void rep;
} catch {}
await loadAddonUpdates(); loadAddons();
btn.disabled = false; btn.textContent = orig;
}));
feat(theseus/settings): Extensions as compact rows with a detail view
The Extensions list was a stack of tall cards — description, author,
capabilities and buttons on every one — so seven add-ons filled the
page before the user found the toggle. Rows are now one line each,
Firefox-style: icon, name, built-in badge, version, a short update
status, the on/off switch and a ⋯ menu, grouped Enabled / Disabled /
Failed to load. Clicking a row opens the detail view in place: back
arrow, description, update status, author, version, type, folder with
Show folder, and a Permissions block that explains each declared
capability in plain words. The ⋯ menu (and right-click) offers Turn
on/off, Details, Show folder and, for non-bundled extensions, Remove —
a new addons-remove IPC that deletes the folder under the extensions
directory, refuses bundled add-ons (they would only be reseeded), and
clears the dock prefs it left behind.
2026-09-22 20:43:50 +02:00
root.querySelectorAll('button[data-more]').forEach((btn) => btn.addEventListener("click", (e) => {
e.stopPropagation(); const r = btn.getBoundingClientRect(); openAddonMenu(btn.dataset.more, r.right, r.bottom + 4, true);
}));
}
const addonById = (id) => ((lastAddonSnap & & lastAddonSnap.installed) || []).find((a) => a.id === id) || null;
// Floating menu (same look as the search-engine row menu). alignRight
// anchors the menu's right edge at x — for the ⋯ button.
let addonMenuEl = null;
function closeAddonMenu() { if (addonMenuEl) { addonMenuEl.remove(); addonMenuEl = null; } }
function openAddonMenu(id, x, y, alignRight) {
closeAddonMenu();
const a = addonById(id); if (!a) return;
const items = [
{ label: a.enabled ? "Turn off" : "Turn on", act: () => C.setAddonEnabled(a.id, !a.enabled).then(loadAddons) },
{ label: "Details", act: () => openAddonDetail(a.id) },
{ label: "Show folder", act: () => C.revealAddon(a.folder) },
];
if (!a.bundled) items.push({ label: "Remove…", danger: true, act: () => removeAddon(a) });
const m = document.createElement("div");
m.className = "ctxmenu";
m.innerHTML = items.map((it, i) => '< div class = "mi' + (it.danger ? " danger " : " " ) + ' " data-i = "' + i + '" > ' + escapeHtml(it.label) + '< / div > ').join("");
document.body.appendChild(m);
const rect = m.getBoundingClientRect();
const vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
const left = alignRight ? x - rect.width : x;
m.style.left = Math.max(6, Math.min(left, vw - rect.width - 6)) + "px";
m.style.top = Math.min(y, vh - rect.height - 6) + "px";
m.querySelectorAll(".mi").forEach((el) => el.onclick = () => { closeAddonMenu(); items[Number(el.dataset.i)].act(); });
addonMenuEl = m;
setTimeout(() => {
const off = (ev) => { if (!m.contains(ev.target)) { closeAddonMenu(); document.removeEventListener("mousedown", off); document.removeEventListener("keydown", esc); } };
const esc = (ev) => { if (ev.key === "Escape") { closeAddonMenu(); document.removeEventListener("mousedown", off); document.removeEventListener("keydown", esc); } };
document.addEventListener("mousedown", off);
document.addEventListener("keydown", esc);
}, 0);
}
async function removeAddon(a) {
if (!confirm('Remove "' + a.name + '"?\n\nIts folder under the extensions directory is deleted. Data it kept in its own store stays until you delete it from the extensions folder.')) return;
if (!C.removeAddon) { C.revealAddon(a.folder); return; }
const r = await C.removeAddon(a.id);
if (!r || !r.ok) alert("Could not remove: " + (r & & r.error || "unknown error"));
if (addonDetailId === a.id) closeAddonDetail();
loadAddons();
}
// Detail view: replaces the list in place (back arrow returns).
function openAddonDetail(id) { addonDetailId = id; renderAddonDetail(); }
function closeAddonDetail() {
addonDetailId = null;
document.getElementById("addonDetail").hidden = true;
document.getElementById("addonsMain").hidden = false;
}
function renderAddonDetail() {
const a = addonById(addonDetailId);
const box = document.getElementById("addonDetail");
if (!a) { closeAddonDetail(); return; }
document.getElementById("addonsMain").hidden = true;
box.hidden = false;
const caps = (a.capabilities || []);
const kind = a.bundled ? "Built into Theseus — ships with every release; turning it off hides it, a newer Theseus reseeds it" : "Installed extension — remove it from the ⋯ menu";
fix(theseus): extension updates re-check, announce and install in place
Updates published after launch never showed: the add-on channel was
checked once, 30 s after boot, and staged copies only applied on the
next launch with nothing telling the user. On 2026-09-22 Aegis 0.8.3
and VPN 0.1.3 landed minutes after the app's only check and stayed
invisible through manual scans made earlier and a restart made before
they were published.
Now: the check repeats every 4 hours; every check (boot, timer, manual,
add-on-driven) reports what is staged to the chrome, which shows a chip
for staged extensions; clicking it, or the "Update to vX" button that
appears on the extension's row and detail in Settings, promotes the
staged folder over the installed one and rebuilds the add-on host, so
the new version runs without a restart. Plug-ins (Aegis) are excluded
from the chip and the hot swap — a wallet updates from its own panel
and applies on the next launch.
Also from the same review: the new-tab button follows the last tab and
parks after the scroll arrow only when the strip overflows; Tor sits
left of the Aegis chip; plug-ins no longer appear in Settings ›
Extensions (they have Plug-ins); the extension detail view has a
labelled Back button, a close button and a Check now button; the
promotion helper returns what it promoted and accepts a filter.
2026-09-22 21:25:33 +02:00
const st = addonUpdates.staged[a.id];
const upd = (st
? '< div class = "d" style = "margin:0;color:var(--acid-text)" > v' + escapeHtml(st.version) + ' is ready.< / div > '
: updateLineFor(a).replace("margin-top:4px", "margin:0") || '< div class = "d" style = "margin:0;color:var(--dim)" > ' + (a.bundled ? "Checked at start and every few hours; Check now asks the channel right away." : "Checked at start and every few hours; Check now asks the channel right away.") + '< / div > ')
+ (st ? '< button class = "btn xupd" data-apply = "' + escapeAttr(a.id) + '" > Update to ' + escapeHtml(st.version) + '< / button > ' : '< button class = "btn" data-check = "' + escapeAttr(a.id) + '" > Check now< / button > ');
feat(theseus/settings): Extensions as compact rows with a detail view
The Extensions list was a stack of tall cards — description, author,
capabilities and buttons on every one — so seven add-ons filled the
page before the user found the toggle. Rows are now one line each,
Firefox-style: icon, name, built-in badge, version, a short update
status, the on/off switch and a ⋯ menu, grouped Enabled / Disabled /
Failed to load. Clicking a row opens the detail view in place: back
arrow, description, update status, author, version, type, folder with
Show folder, and a Permissions block that explains each declared
capability in plain words. The ⋯ menu (and right-click) offers Turn
on/off, Details, Show folder and, for non-bundled extensions, Remove —
a new addons-remove IPC that deletes the folder under the extensions
directory, refuses bundled add-ons (they would only be reseeded), and
clears the dock prefs it left behind.
2026-09-22 20:43:50 +02:00
box.innerHTML =
fix(theseus): extension updates re-check, announce and install in place
Updates published after launch never showed: the add-on channel was
checked once, 30 s after boot, and staged copies only applied on the
next launch with nothing telling the user. On 2026-09-22 Aegis 0.8.3
and VPN 0.1.3 landed minutes after the app's only check and stayed
invisible through manual scans made earlier and a restart made before
they were published.
Now: the check repeats every 4 hours; every check (boot, timer, manual,
add-on-driven) reports what is staged to the chrome, which shows a chip
for staged extensions; clicking it, or the "Update to vX" button that
appears on the extension's row and detail in Settings, promotes the
staged folder over the installed one and rebuilds the add-on host, so
the new version runs without a restart. Plug-ins (Aegis) are excluded
from the chip and the hot swap — a wallet updates from its own panel
and applies on the next launch.
Also from the same review: the new-tab button follows the last tab and
parks after the scroll arrow only when the strip overflows; Tor sits
left of the Aegis chip; plug-ins no longer appear in Settings ›
Extensions (they have Plug-ins); the extension detail view has a
labelled Back button, a close button and a Check now button; the
promotion helper returns what it promoted and accepts a filter.
2026-09-22 21:25:33 +02:00
'< div class = "xhead" > < button class = "xback" id = "xback" title = "Back to the list" aria-label = "Back" > ‹ Back< / button > '
feat(theseus/settings): Extensions as compact rows with a detail view
The Extensions list was a stack of tall cards — description, author,
capabilities and buttons on every one — so seven add-ons filled the
page before the user found the toggle. Rows are now one line each,
Firefox-style: icon, name, built-in badge, version, a short update
status, the on/off switch and a ⋯ menu, grouped Enabled / Disabled /
Failed to load. Clicking a row opens the detail view in place: back
arrow, description, update status, author, version, type, folder with
Show folder, and a Permissions block that explains each declared
capability in plain words. The ⋯ menu (and right-click) offers Turn
on/off, Details, Show folder and, for non-bundled extensions, Remove —
a new addons-remove IPC that deletes the folder under the extensions
directory, refuses bundled add-ons (they would only be reseeded), and
clears the dock prefs it left behind.
2026-09-22 20:43:50 +02:00
+ '< span class = "xi" > ' + addonIconHtml(a.icon) + '< / span > '
Theseus: start extensions on first use, Startup switches in Settings
Every enabled add-on used to be activated synchronously in initAddons(),
during app.whenReady and before the window exists. That is the largest
launch cost left (boot tracer, 2026-10-03). An add-on can now say
"activation": "on-demand" in addon.json. It is then listed at launch but
not started. Its declared surfaces stay live: "panels" (new: sidebar
panels declared up front), toolbar-menu, context-menu-items and the
page-inject bridge, whose source is read on the first matching page.
activate() runs on first real use: a panel opened, a menu or context
item picked, a message from its panel, tab or page bridge, a Settings
addon-invoke, a wiz:// link (for Aegis). All of those go through
AddonHost.dispatch(), which starts the add-on and waits for it, so no
call is dropped. Concurrent callers share one activation, and
activations run one at a time.
Startup stays the default: the host cannot tell what an older add-on
does in activate(). request-filter add-ons and add-ons that declare no
surface are forced to startup. If activate() returns a promise, calls
wait for it (at most 5 s). api.startAtLaunch(bool) lets an on-demand
add-on ask to be started at launch again (for live relay sessions).
Converted: notepad, screenshot, translate, docx-editor, pdf-editor, vpn
(none has launch-time work: no file association, no auto-connect, and
add-on file tabs are not part of the saved session). Shield and Cookie
Pop-ups stay startup: Shield owns the request filter and must see the
first request; Cookie Pop-ups costs ~6 ms and acts unasked on every
page. Aegis stays startup and untouched: another session owns it. See
NOTE-aegis-on-demand.md (next commit).
Settings › Performance › Startup:
- "Start extensions when first used" (default on). Off = all at launch;
switching it off starts the waiting add-ons immediately.
- "Start the wallet at launch". Shown disabled with a hint until the
installed Aegis manifest allows on-demand. It applies with no Settings
change once Aegis opts in.
- "Preload common menus" gates prewarmOverlays().
- "Use lightest" preset.
New keys are plain SETTINGS_DEFAULTS through the existing settings-set.
No new IPC channels.
Measured: boot-trace, fresh profile, --seconds 20 so the 30 s add-on OTA
poll can't swap Aegis mid-series; warm runs 2-3 of two paired series.
- Add-on activation at launch: 121-154 ms -> 104-174 ms. The six
converted add-ons went from 16-20 ms to 0. The rest is Shield (83-148
ms, noisy) and Aegis (15 ms in this tree's 0.9.0).
- Toolbar painted: 1278-1584 ms -> 1282-1481 ms (within noise).
- With "Preload common menus" off: 0 overlays prewarmed, 8 processes
instead of 11, about 50-70 MB less at 15 s.
The bundled add-on versions are not bumped. Existing profiles keep their
old addon.json, and so stay on startup activation, until those add-ons
ship with a higher version (seedBundledAddons only reseeds a strictly
newer bundle).
2026-10-03 16:05:32 +02:00
+ '< div class = "xtitle" > < div class = "t" > ' + escapeHtml(a.name) + (a.bundled ? '< span class = "xbadge" > BUILT-IN< / span > ' : '') + (a.enabled ? (a.running ? '' : '< span class = "xbadge off" title = "Starts the first time you use it" > NOT STARTED< / span > ') : '< span class = "xbadge off" > OFF< / span > ') + '< span class = "xv" > ' + escapeHtml(a.version) + '< / span > < / div > '
feat(theseus/settings): Extensions as compact rows with a detail view
The Extensions list was a stack of tall cards — description, author,
capabilities and buttons on every one — so seven add-ons filled the
page before the user found the toggle. Rows are now one line each,
Firefox-style: icon, name, built-in badge, version, a short update
status, the on/off switch and a ⋯ menu, grouped Enabled / Disabled /
Failed to load. Clicking a row opens the detail view in place: back
arrow, description, update status, author, version, type, folder with
Show folder, and a Permissions block that explains each declared
capability in plain words. The ⋯ menu (and right-click) offers Turn
on/off, Details, Show folder and, for non-bundled extensions, Remove —
a new addons-remove IPC that deletes the folder under the extensions
directory, refuses bundled add-ons (they would only be reseeded), and
clears the dock prefs it left behind.
2026-09-22 20:43:50 +02:00
+ '< div class = "d" > ' + escapeHtml(a.description || "No description.") + '< / div > < / div > '
+ '< span class = "xctl" > < label class = "sw sm" title = "' + (a.enabled ? " On " : " Off " ) + ' " > < input type = "checkbox" data-toggle = "' + escapeAttr(a.id) + '" ' + ( a . enabled ? " checked " : " " ) + ' > < span class = "track" > < span class = "knob" > < / span > < / span > < / label > '
fix(theseus): extension updates re-check, announce and install in place
Updates published after launch never showed: the add-on channel was
checked once, 30 s after boot, and staged copies only applied on the
next launch with nothing telling the user. On 2026-09-22 Aegis 0.8.3
and VPN 0.1.3 landed minutes after the app's only check and stayed
invisible through manual scans made earlier and a restart made before
they were published.
Now: the check repeats every 4 hours; every check (boot, timer, manual,
add-on-driven) reports what is staged to the chrome, which shows a chip
for staged extensions; clicking it, or the "Update to vX" button that
appears on the extension's row and detail in Settings, promotes the
staged folder over the installed one and rebuilds the add-on host, so
the new version runs without a restart. Plug-ins (Aegis) are excluded
from the chip and the hot swap — a wallet updates from its own panel
and applies on the next launch.
Also from the same review: the new-tab button follows the last tab and
parks after the scroll arrow only when the strip overflows; Tor sits
left of the Aegis chip; plug-ins no longer appear in Settings ›
Extensions (they have Plug-ins); the extension detail view has a
labelled Back button, a close button and a Check now button; the
promotion helper returns what it promoted and accepts a filter.
2026-09-22 21:25:33 +02:00
+ '< button class = "xmore" data-more = "' + escapeAttr(a.id) + '" title = "More options" aria-label = "More options" > ⋯< / button > '
+ '< button class = "xmore" id = "xclose" title = "Close" aria-label = "Close" > ✕< / button > < / span > < / div > '
feat(theseus/settings): Extensions as compact rows with a detail view
The Extensions list was a stack of tall cards — description, author,
capabilities and buttons on every one — so seven add-ons filled the
page before the user found the toggle. Rows are now one line each,
Firefox-style: icon, name, built-in badge, version, a short update
status, the on/off switch and a ⋯ menu, grouped Enabled / Disabled /
Failed to load. Clicking a row opens the detail view in place: back
arrow, description, update status, author, version, type, folder with
Show folder, and a Permissions block that explains each declared
capability in plain words. The ⋯ menu (and right-click) offers Turn
on/off, Details, Show folder and, for non-bundled extensions, Remove —
a new addons-remove IPC that deletes the folder under the extensions
directory, refuses bundled add-ons (they would only be reseeded), and
clears the dock prefs it left behind.
2026-09-22 20:43:50 +02:00
+ '< div class = "xdt" > '
fix(theseus): extension updates re-check, announce and install in place
Updates published after launch never showed: the add-on channel was
checked once, 30 s after boot, and staged copies only applied on the
next launch with nothing telling the user. On 2026-09-22 Aegis 0.8.3
and VPN 0.1.3 landed minutes after the app's only check and stayed
invisible through manual scans made earlier and a restart made before
they were published.
Now: the check repeats every 4 hours; every check (boot, timer, manual,
add-on-driven) reports what is staged to the chrome, which shows a chip
for staged extensions; clicking it, or the "Update to vX" button that
appears on the extension's row and detail in Settings, promotes the
staged folder over the installed one and rebuilds the add-on host, so
the new version runs without a restart. Plug-ins (Aegis) are excluded
from the chip and the hot swap — a wallet updates from its own panel
and applies on the next launch.
Also from the same review: the new-tab button follows the last tab and
parks after the scroll arrow only when the strip overflows; Tor sits
left of the Aegis chip; plug-ins no longer appear in Settings ›
Extensions (they have Plug-ins); the extension detail view has a
labelled Back button, a close button and a Check now button; the
promotion helper returns what it promoted and accepts a filter.
2026-09-22 21:25:33 +02:00
+ '< div class = "xr" > < span class = "k" > Updates< / span > < span class = "v" > ' + upd + '< / span > < / div > '
feat(theseus/settings): Extensions as compact rows with a detail view
The Extensions list was a stack of tall cards — description, author,
capabilities and buttons on every one — so seven add-ons filled the
page before the user found the toggle. Rows are now one line each,
Firefox-style: icon, name, built-in badge, version, a short update
status, the on/off switch and a ⋯ menu, grouped Enabled / Disabled /
Failed to load. Clicking a row opens the detail view in place: back
arrow, description, update status, author, version, type, folder with
Show folder, and a Permissions block that explains each declared
capability in plain words. The ⋯ menu (and right-click) offers Turn
on/off, Details, Show folder and, for non-bundled extensions, Remove —
a new addons-remove IPC that deletes the folder under the extensions
directory, refuses bundled add-ons (they would only be reseeded), and
clears the dock prefs it left behind.
2026-09-22 20:43:50 +02:00
+ '< div class = "xr" > < span class = "k" > Author< / span > < span class = "v" > ' + escapeHtml(a.author || "—") + '< / span > < / div > '
+ '< div class = "xr" > < span class = "k" > Version< / span > < span class = "v" > ' + escapeHtml(a.version) + '< / span > < / div > '
+ '< div class = "xr" > < span class = "k" > Type< / span > < span class = "v" > ' + escapeHtml(kind) + '< / span > < / div > '
+ '< div class = "xr" > < span class = "k" > Folder< / span > < span class = "v" > < code style = "background:transparent;border:none;padding:0;color:var(--mut)" > ' + escapeHtml(a.folder) + '< / code > < button class = "btn" data-reveal = "' + escapeAttr(a.folder) + '" > Show folder< / button > < / span > < / div > '
+ '< / div > '
+ '< h2 class = "sub" style = "margin-top:1.4rem" > Permissions< / h2 > '
+ '< div class = "xdt" > ' + (caps.length
? '< ul class = "xperm" > ' + caps.map((c) => '< li > < code > ' + escapeHtml(c) + '< / code > < span > ' + escapeHtml(CAP_TEXT[c] || "Framework capability.") + '< / span > < / li > ').join("") + '< / ul > '
: '< div class = "xr" > < span class = "v" style = "color:var(--dim)" > No special capabilities — it only uses the basic add-on API.< / span > < / div > ')
+ '< / div > '
+ '< div class = "d" style = "color:var(--dim);margin-top:10px" > Every extension runs inside Theseus with the same access as the browser itself; these entries are the extension points it declared, not a sandbox.< / div > '
+ (a.bundled ? '' : '< div style = "margin-top:1.2rem;display:flex;justify-content:flex-end" > < button class = "btn xdanger" id = "xremove" > Remove extension…< / button > < / div > ');
document.getElementById("xback").onclick = closeAddonDetail;
fix(theseus): extension updates re-check, announce and install in place
Updates published after launch never showed: the add-on channel was
checked once, 30 s after boot, and staged copies only applied on the
next launch with nothing telling the user. On 2026-09-22 Aegis 0.8.3
and VPN 0.1.3 landed minutes after the app's only check and stayed
invisible through manual scans made earlier and a restart made before
they were published.
Now: the check repeats every 4 hours; every check (boot, timer, manual,
add-on-driven) reports what is staged to the chrome, which shows a chip
for staged extensions; clicking it, or the "Update to vX" button that
appears on the extension's row and detail in Settings, promotes the
staged folder over the installed one and rebuilds the add-on host, so
the new version runs without a restart. Plug-ins (Aegis) are excluded
from the chip and the hot swap — a wallet updates from its own panel
and applies on the next launch.
Also from the same review: the new-tab button follows the last tab and
parks after the scroll arrow only when the strip overflows; Tor sits
left of the Aegis chip; plug-ins no longer appear in Settings ›
Extensions (they have Plug-ins); the extension detail view has a
labelled Back button, a close button and a Check now button; the
promotion helper returns what it promoted and accepts a filter.
2026-09-22 21:25:33 +02:00
document.getElementById("xclose").onclick = closeAddonDetail;
feat(theseus/settings): Extensions as compact rows with a detail view
The Extensions list was a stack of tall cards — description, author,
capabilities and buttons on every one — so seven add-ons filled the
page before the user found the toggle. Rows are now one line each,
Firefox-style: icon, name, built-in badge, version, a short update
status, the on/off switch and a ⋯ menu, grouped Enabled / Disabled /
Failed to load. Clicking a row opens the detail view in place: back
arrow, description, update status, author, version, type, folder with
Show folder, and a Permissions block that explains each declared
capability in plain words. The ⋯ menu (and right-click) offers Turn
on/off, Details, Show folder and, for non-bundled extensions, Remove —
a new addons-remove IPC that deletes the folder under the extensions
directory, refuses bundled add-ons (they would only be reseeded), and
clears the dock prefs it left behind.
2026-09-22 20:43:50 +02:00
const rm = document.getElementById("xremove"); if (rm) rm.onclick = () => removeAddon(a);
wireAddonControls(box);
2026-09-08 01:42:41 +02:00
}
feat(theseus/settings): Extensions as compact rows with a detail view
The Extensions list was a stack of tall cards — description, author,
capabilities and buttons on every one — so seven add-ons filled the
page before the user found the toggle. Rows are now one line each,
Firefox-style: icon, name, built-in badge, version, a short update
status, the on/off switch and a ⋯ menu, grouped Enabled / Disabled /
Failed to load. Clicking a row opens the detail view in place: back
arrow, description, update status, author, version, type, folder with
Show folder, and a Permissions block that explains each declared
capability in plain words. The ⋯ menu (and right-click) offers Turn
on/off, Details, Show folder and, for non-bundled extensions, Remove —
a new addons-remove IPC that deletes the folder under the extensions
directory, refuses bundled add-ons (they would only be reseeded), and
clears the dock prefs it left behind.
2026-09-22 20:43:50 +02:00
document.addEventListener("keydown", (e) => { if (e.key === "Escape" & & addonDetailId & & !addonMenuEl) closeAddonDetail(); });
2026-09-21 03:28:04 +02:00
// Discovery of new community extensions happens at theseus.x/extensions —
// Settings only shows what's installed on this machine. The install button
// on that page invokes the same "addons-install-community" IPC and Theseus
// refreshes the installed list on its own.
2026-09-08 01:42:41 +02:00
function escapeHtml(s) { return String(s || "").replace(/[& < >"']/g, (c) => ({ "&":"& ","< ":"< ",">":"> ",'"':"" ","'":"' " })[c]); }
function escapeAttr(s) { return escapeHtml(s); }
async function loadAddons() {
try { renderAddons(await C.listAddons()); }
catch (e) { addonsList.textContent = "Failed to load extensions: " + (e?.message || e); }
}
2026-09-09 02:43:28 +02:00
// Refresh the staged-updates map from the background poll. Whatever came
// back through listStagedAddonUpdates is what promoteStagedUpdates will
// pick up on the next launch — the per-card badge reflects exactly that.
async function loadAddonUpdates() {
let staged = [];
try { staged = await C.listStagedAddonUpdates(); } catch { staged = []; }
addonUpdates.staged = {};
for (const s of (staged || [])) addonUpdates.staged[s.id] = { version: s.version, name: s.name };
}
2026-09-08 01:42:41 +02:00
document.getElementById("addonsReload").addEventListener("click", async () => {
2026-09-09 02:43:28 +02:00
await C.reloadAddons(); await loadAddonUpdates(); loadAddons();
2026-09-08 01:42:41 +02:00
});
document.getElementById("addonsOpenDir").addEventListener("click", () => C.openAddonsDir());
2026-09-09 02:43:28 +02:00
document.querySelector('.side a[data-sec="addons"]').addEventListener("click", async () => {
2026-09-21 03:28:04 +02:00
await loadAddonUpdates(); loadAddons();
2026-09-09 02:43:28 +02:00
});
2026-09-08 23:00:41 +02:00
// Plug-ins tab: refresh Ariadne's live daemon state on every visit so it
// doesn't display stale "checking…" text if the background poll finished
// while another section was open. Aegis card loads on page-init and its
// "Check for updates" button is user-initiated, so we don't auto-poll it
// (that would fire a network request every tab-switch).
document.querySelector('.side a[data-sec="plugins"]').addEventListener("click", () => {
const ar = document.getElementById("ariadneRefresh");
if (ar) ar.click();
});
2026-09-08 02:27:36 +02:00
2026-09-09 02:43:28 +02:00
// (Old separate "Pending updates" strip lived here. Per-card update
// line is now painted by updateLineFor() inside each addon row —
// loadAddonUpdates() populates addonUpdates.staged before renderAddons.)
2026-09-08 02:27:36 +02:00
document.getElementById("addonsCheckUpdates").addEventListener("click", async () => {
const btn = document.getElementById("addonsCheckUpdates");
const status = document.getElementById("addonsUpdStatus");
btn.disabled = true; const orig = btn.textContent; btn.textContent = "Checking…";
2026-09-08 18:14:07 +02:00
status.innerHTML = "";
2026-09-08 02:27:36 +02:00
try {
2026-09-08 18:14:07 +02:00
const res = await C.checkAddonUpdates();
// Back-compat: some callers still pass a bare array. Normalize.
const report = Array.isArray(res) ? [] : (res?.report || []);
const skipped = Array.isArray(res) ? null : (res?.skipped || null);
const staged = Array.isArray(res) ? res : (res?.staged || []);
if (skipped === "no-pubkeys") {
status.textContent = "Update endpoint disabled — no operator pubkey baked into this build.";
} else {
2026-09-09 02:43:28 +02:00
// Fold the per-addon report into addonUpdates.report so the
// per-card update line reflects the freshest check. A summary at
// the top counts staged/updated vs. up-to-date, but the per-addon
// detail lives on each card.
addonUpdates.report = {};
for (const r of report) addonUpdates.report[r.id] = r;
const stagedNow = report.filter((r) => r.status === "staged" || r.status === "already-staged").length;
const failed = report.filter((r) => ["fetch-failed","signature-invalid","sha256-mismatch","extract-failed","manifest-mismatch"].includes(r.status)).length;
if (stagedNow) {
status.textContent = stagedNow + " update" + (stagedNow > 1 ? "s" : "") + " staged; restart Theseus to apply.";
} else if (failed) {
status.textContent = failed + " failed — see the extension card" + (failed > 1 ? "s" : "") + " below.";
} else if (!report.length) {
status.textContent = "No extensions with an update endpoint.";
} else {
status.textContent = "All extensions up to date.";
}
2026-09-08 02:27:36 +02:00
}
2026-09-09 02:43:28 +02:00
await loadAddonUpdates();
loadAddons();
2026-09-08 02:27:36 +02:00
} catch (e) {
status.textContent = "Check failed: " + (e?.message || e);
} finally {
btn.disabled = false; btn.textContent = orig;
}
});
2026-09-08 01:42:41 +02:00
// Populate on first paint so the tab is ready when the user clicks in.
2026-09-21 03:28:04 +02:00
(async () => { await loadAddonUpdates(); loadAddons(); })();
2026-09-08 01:42:41 +02:00
< / script >
< / body >
< / html >