theseus/settings.html
Local Dev 1f394bb524 Theseus 0.3.124 (cont'd): the General row is Theseus ID, not Theseus Shield
The subagent's first pass took the Settings brief's parenthetical note
("previously Theseus ID") to mean "this row sits where Theseus ID used
to live", and wired the row to the Shield (adblocker) add-on with its
on/off toggle. The intent was simpler: just put Theseus ID back as a
regular chevron row in General. Dropped the Shield toggle/opener wiring
and reset the row to label and link Theseus ID, matching the Theseus ID
chevron that used to live below Updates (now consolidated).
2026-10-27 21:59:07 +02:00

3663 lines
243 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Theseus — Settings</title>
<style>
/* --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. */
:root{ --bg:#0b0e14; --panel:#141a24; --line:rgba(255,255,255,.09);
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d; --acid-text:#d6ff3d; }
*{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}
.brand{font-weight:700;color:var(--acid-text);font-size:15px;padding:4px 12px 16px}
.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)}
.side a.active{background:rgb(from var(--acid) r g b / .12);color:var(--acid-text)}
/* 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}
.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)}
.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}
/* Advanced-disclosure block: hidden by default, click Advanced to reveal
per-resolver RPC/endpoint/scope fields in Names & Certificates. */
details.adv{margin-top:4px}
details.adv>summary{cursor:pointer;color:var(--mut);font-size:12.5px;list-style:none;padding:2px 0;user-select:none}
details.adv>summary::-webkit-details-marker{display:none}
details.adv>summary::before{content:"▸ ";color:var(--dim);display:inline-block;width:1em;transition:transform .15s}
details.adv[open]>summary::before{content:"▾ "}
details.adv>summary:hover{color:var(--acid-text)}
details.adv label{display:block;color:var(--mut);font-size:12px;margin:10px 0 4px;letter-spacing:.04em;text-transform:uppercase}
details.adv input[type=text],details.adv select{width:100%;max-width:460px;background:#1b2330;color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:13px;outline:none}
details.adv input[type=text]:focus,details.adv select:focus{border-color:#4b7bec}
.muted{color:var(--mut)}
/* 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%}
.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}
/* 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; }
@media (prefers-color-scheme: light) { :root { color-scheme: light; --acid: #0AC18E; --acid-text: #253A49; } }
/* 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}
.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}
.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)}
/* 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}
.pmuted{color:var(--mut)}
/* Settings › Apps */
.webapp{display:flex;align-items:center;gap:12px;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:10px 14px;margin-bottom:8px}
.webapp img,.webapp .ph{width:32px;height:32px;border-radius:7px;flex:none;display:grid;place-items:center;font-size:20px}
.webapp .nm{flex:1;min-width:0} .webapp .nm b{display:block;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.webapp .nm span{color:var(--mut);font-size:12.5px}
.webapp .btn.rm{background:transparent;color:#f6768a;border-color:rgba(246,118,138,.4)}
.webapp .btn.rm:hover{background:rgba(246,118,138,.12)}
.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)}
.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)}
.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}
/* 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)} }
/* 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}
.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)}
.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)}
/* 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}
.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)}
.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)} }
.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){
: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; }
.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}
.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; }
.knob{ background:#8a93a2; }
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); }
}
/* PIN setup dialog — the same pad as the unlock prompt and Aegis. */
.pinwiz{position:fixed;inset:0;background:rgba(0,0,0,.5);display:grid;place-items:center;z-index:50;padding:16px}
/* Settings › Sidebar: a web app's icon, as on the strip. */
.qlico svg, .qlico img{width:18px;height:18px;display:block}
/* display:grid / display:flex would otherwise beat the hidden attribute */
.pinwiz[hidden], .row[hidden]{display:none}
.pinwiz-box{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:20px 22px 16px;width:min(400px,100%);box-shadow:0 20px 60px #000c}
.pinwiz-box h3{margin:6px 0 4px;font-size:17px}
.pinwiz-text{color:var(--mut);font-size:13px;line-height:20px;min-height:40px;margin:0 0 14px}
.pinwiz-steps{display:flex;gap:6px}
.pinwiz-steps span{flex:1;height:3px;border-radius:2px;background:var(--line)}
.pinwiz-steps span.on{background:var(--acid)}
.pinwiz-box input[type=password]{width:100%;box-sizing:border-box;padding:10px 12px;border-radius:8px;border:1px solid var(--line);background:#1b2330;color:var(--ink);font:inherit;font-size:14px}
.pinwiz-err{color:#f6768a;font-size:12.5px;line-height:18px;min-height:36px;margin:8px 0 2px;text-align:center}
.pinwiz-act{display:flex;justify-content:space-between;gap:8px;margin-top:6px}
.pinwiz-act .btn:disabled{opacity:.45;cursor:default}
.pinwiz-len{display:flex;justify-content:center;gap:6px;margin:0 0 12px}
.pinwiz-len button{border:1px solid var(--line);background:transparent;color:var(--mut);border-radius:999px;padding:4px 12px;font-size:12.5px;cursor:pointer}
.pinwiz-len button.on{border-color:var(--acid);color:var(--acid-text);background:rgb(from var(--acid) r g b / .12)}
.pinpad{display:flex;flex-direction:column;align-items:center;gap:14px}
.pinpad .pindots{display:flex;gap:12px}
.pinpad .pindot{width:14px;height:14px;border-radius:50%;border:1.5px solid var(--dim);transition:background .12s,border-color .12s}
.pinpad .pindot.optional{border-style:dashed;opacity:.6} /* digits 7 and 8: allowed, not needed */
.pinpad .pindot.on{background:var(--acid);border-color:var(--acid)}
.pinpad .pinkeys{display:grid;grid-template-columns:repeat(3,76px);gap:10px}
.pinpad .pinkeys button{height:56px;border-radius:12px;border:1px solid var(--line);background:#1b2330;color:var(--ink);font:500 24px system-ui,sans-serif;cursor:pointer}
/* Keys only lighten on hover; an acid border lighting up on each key the pointer crossed read as flashing. */
.pinpad .pinkeys button{transition:background-color .08s,transform .06s}
.pinpad .pinkeys button:hover{background:#232c3b}
.pinpad .pinkeys button:active{transform:scale(.96)}
.pinpad .pinkeys button.util{background:transparent;font-size:13px;color:var(--dim)}
@media (prefers-color-scheme: light){
.pinwiz-box input[type=password], .pinpad .pinkeys button{background:#f1f5f8;color:#253A49}
.pinpad .pinkeys button:hover{background:#e4e9f0}
.pinpad .pinkeys button.util{background:transparent}
}
</style></head>
<body>
<div class="app">
<nav class="side">
<div class="brand">⛓ Theseus</div>
<a data-sec="general" class="active">General</a>
<a data-sec="language">Language</a>
<a data-sec="search">Search</a>
<a data-sec="ai-engines">AI engines</a>
<a data-sec="passwords">Passwords</a>
<a data-sec="performance">Performance</a>
<a data-sec="privacy">Privacy</a>
<a data-sec="plugins">System</a>
<a data-sec="aegis">Aegis</a>
<a data-sec="addons">Extensions</a>
<a data-sec="sidebar">Sidebar</a>
<a data-sec="apps">Apps</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>
<!-- Default-browser row — moved to the top of General. Hidden until the
platform-check (Windows only, today) reveals it. -->
<div class="row" id="defaultBrowserRow" hidden>
<div class="txt">
<div class="t">Make Theseus the default browser</div>
<div class="d">Register Theseus for <code>http</code> and <code>https</code> links. Windows still asks you to confirm the switch in <b>Settings &rsaquo; Default apps</b>; the button opens that page.</div>
<div class="acts"><button class="btn small ghost" id="openSysDefaults">Open Default Apps…</button></div>
</div>
<label class="sw"><input type="checkbox" id="defaultBrowser"><span class="track"><span class="knob"></span></span></label>
</div>
<!-- Theseus ID — chevron to the ID sub-page. No toggle: identity is
not an on/off, it is managed on its own page. -->
<div class="row link" data-go="theseusid">
<div class="txt"><div class="t">Theseus ID</div><div class="d">Your Silent Mode account, kept in your Theseus Vault.</div></div>
<span class="chev">›</span>
</div>
<!-- Startup — the restoreSession toggle lives here as a one-line row. -->
<div class="row">
<div class="txt"><div class="t">Startup</div><div class="d">Restore the tabs from your last session when Theseus starts.</div></div>
<label class="sw"><input type="checkbox" id="restoreSession"><span class="track"><span class="knob"></span></span></label>
</div>
<!-- Updates — version + Check button, compressed to one line. The init
in initUpdatesPanel() still writes to #updStatus / #updCheck. -->
<div class="row">
<div class="txt"><div class="t">Updates</div><div class="d" id="updStatus">Loading…</div></div>
<div class="ctl"><button id="updCheck" class="btn">Check for updates</button></div>
</div>
<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>
<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>
<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>
<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>
<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>
</section>
<!-- 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>
<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>
<div class="row">
<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>
<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>
<!-- PLUG-INS -->
<!-- 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. -->
<!-- NAMES & CERTIFICATES — resolvers for other blockchain name systems,
opt-in CA bundles scoped by TLD. Phase 1 (0.3.95): schema + UI only;
toggles persist to extra-resolvers.json. Phase 2 (0.3.96–0.3.99): the
resolver adapters and the setCertificateVerifyProc hook land under
these toggles, no further UI churn. Each row's "Not wired yet" caption
goes away as its adapter lights up. -->
<section id="names" hidden>
<div class="crumb"><a data-go="plugins">System</a> ›</div>
<h1>Names &amp; Certificates</h1>
<p class="lede">Decide which name systems Theseus can resolve, and which certificate roots it will trust. BNS (<i>.x</i>, <i>.bch</i>, <i>.sia</i>, …) is always on; everything else is off by default.</p>
<h2 class="sub" style="border-top:0;padding-top:0;margin-top:0">Name resolvers</h2>
<div class="row">
<div class="txt">
<div class="t">Ethereum Name Service (ENS)</div>
<div class="d"><i>.eth</i> → content hash from the ENS registry on Ethereum, served via an IPFS/IPNS gateway.</div>
<div class="acts"><details class="adv"><summary>Advanced</summary>
<label>Ethereum RPC URL</label><input type="text" id="rvEnsRpc" placeholder="https://ethereum-rpc.publicnode.com">
<label>IPFS gateway</label><input type="text" id="rvEnsGateway" placeholder="https://ipfs.io">
</details></div>
</div>
<label class="sw"><input type="checkbox" id="rvEnsOn"><span class="track"><span class="knob"></span></span></label>
</div>
<div class="row">
<div class="txt">
<div class="t">Namecoin</div>
<div class="d"><i>.bit</i> → record from the Namecoin blockchain; <code>translate</code> or <code>alias</code> redirects are followed. <b>Needs an RPC URL</b> — Namecoin has no public free endpoint; set yours in Advanced.</div>
<div class="acts"><details class="adv"><summary>Advanced</summary>
<label>Namecoin JSON-RPC URL</label><input type="text" id="rvNmcRpc" placeholder="http://user:pass@127.0.0.1:8336">
</details></div>
</div>
<label class="sw"><input type="checkbox" id="rvNmcOn"><span class="track"><span class="knob"></span></span></label>
</div>
<div class="row">
<div class="txt">
<div class="t">Handshake (HNS)</div>
<div class="d">User-registered TLDs on the Handshake chain, served through the public <code>hns.to</code> web gateway. The gateway proxies the request to Handshake's own resolver and serves the on-chain content. Handshake auctioned many 3-letter and ICANN-style TLDs on-chain, so the default collision policy keeps ICANN TLDs in clearnet.</div>
<div class="acts"><details class="adv"><summary>Advanced</summary>
<label>Handshake gateway</label><input type="text" id="rvHnsEndpoint" placeholder="https://hns.to">
<label>Collision policy</label>
<select id="rvHnsCollisionPolicy">
<option value="icann-wins">ICANN wins (safer; HNS only resolves TLDs the DNS root doesn't delegate)</option>
<option value="hns-wins">HNS wins (prefer the blockchain's answer; may change where familiar TLDs point)</option>
</select>
</details></div>
</div>
<label class="sw"><input type="checkbox" id="rvHnsOn"><span class="track"><span class="knob"></span></span></label>
</div>
<div class="row">
<div class="txt">
<div class="t">Solana Name Service (SNS)</div>
<div class="d"><i>.sol</i> → URL or IPFS record on the Solana name registry, read through Bonfida's public SDK proxy. The proxy sits behind Cloudflare; if your network is blocked, paste an alternative in Advanced.</div>
<div class="acts"><details class="adv"><summary>Advanced</summary>
<label>SNS proxy URL</label><input type="text" id="rvSnsRpc" placeholder="https://sns-sdk-proxy.bonfida.workers.dev">
<label>IPFS gateway (for ipfs records)</label><input type="text" id="rvSnsGateway" placeholder="https://ipfs.io">
</details></div>
</div>
<label class="sw"><input type="checkbox" id="rvSnsOn"><span class="track"><span class="knob"></span></span></label>
</div>
<div class="row">
<div class="txt">
<div class="t">Unstoppable Domains</div>
<div class="d"><i>.crypto</i>, <i>.nft</i>, <i>.wallet</i>, <i>.bitcoin</i>, <i>.blockchain</i>, <i>.dao</i>, <i>.888</i>, <i>.polygon</i>, <i>.unstoppable</i>, and more. Follows <code>browser.redirect_url</code> first, then IPFS records.
<br>⚠ <i>.x</i> also exists on Unstoppable — BNS wins on <i>.x</i>, so a Sirius registration always takes precedence (this adapter skips <i>.x</i> entirely).</div>
<div class="acts"><details class="adv"><summary>Advanced</summary>
<label>Resolver endpoint</label><input type="text" id="rvUdEndpoint" placeholder="https://resolve.unstoppabledomains.com">
<label>IPFS gateway (for ipfs records)</label><input type="text" id="rvUdGateway" placeholder="https://ipfs.io">
</details></div>
</div>
<label class="sw"><input type="checkbox" id="rvUdOn"><span class="track"><span class="knob"></span></span></label>
</div>
<h2 class="sub">Certificate trust (TLS)</h2>
<div class="row">
<div class="txt">
<div class="t">Built-in Mozilla CA bundle</div>
<div class="d">The standard trust anchors that ship with Theseus.</div>
</div>
<label class="sw" title="Always on"><input type="checkbox" checked disabled><span class="track"><span class="knob"></span></span></label>
</div>
<div class="row">
<div class="txt">
<div class="t">Russian National CA</div>
<div class="d">
Lets Theseus open Russian government sites (gosuslugi.ru, nalog.gov.ru, …) that use the Russian national cert chain. <b>Does not</b> bypass network-level blocks — use Tor for that.
<br>⚠ Trust is scoped to the TLDs below only. The CA cannot sign for other sites while this scope is in place. Mozilla and Chrome do not trust this CA by default. <span class="muted" id="rvNotWiredRuCa">Not wired yet — coming in 0.3.99.</span>
</div>
<div class="acts"><details class="adv"><summary>Advanced</summary>
<label>Scope — this CA is trusted only for hosts under these TLDs (comma-separated)</label>
<input type="text" id="caRuScope" placeholder="ru, рф, gov.ru, online.ru">
</details></div>
</div>
<label class="sw"><input type="checkbox" id="caRuOn"><span class="track"><span class="knob"></span></span></label>
</div>
<h2 class="sub">Preferences file</h2>
<div class="row">
<div class="txt">
<div class="t">Reset to defaults</div>
<div class="d">Clears every switch above and restores the defaults (BNS on, everything else off). Does not affect site history.</div>
<div class="acts"><button class="btn small ghost" id="rvReset">Reset Names &amp; Certificates</button></div>
</div>
</div>
<p class="muted" style="margin-top:1.4rem;font-size:12.5px;line-height:1.5">
Your choices are stored in <code>%APPDATA%\Theseus\extra-resolvers.json</code>. They land in the file now and will come alive as each resolver is wired — the plan is 0.3.96 ENS, 0.3.97 Namecoin + Handshake, 0.3.98 SNS + Unstoppable, 0.3.99 Russian CA.
</p>
</section>
<!-- "System" (section id stays "plugins" so existing deep links and the
plugins-ariadne / plugins-aegis sub-page crumbs keep working — the
nav just reads System in the sidebar now). -->
<section id="plugins" hidden>
<h1>System</h1>
<p class="lede">The Silent Mode components that live alongside Theseus and the name systems the browser can resolve.</p>
<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>
<div class="row link" data-go="ariadne">
<div class="txt"><div class="t">Ariadne's Thread</div><div class="d">System-wide BCDN resolver.</div></div>
<span class="chev">›</span>
</div>
<div class="row link" data-go="aegis">
<div class="txt"><div class="t">Aegis</div><div class="d">Built-in multi-chain wallet.</div></div>
<span class="chev">›</span>
</div>
<div class="row link" data-go="names">
<div class="txt"><div class="t">Names &amp; Certificates</div><div class="d">Which name systems Theseus can resolve, and which certificate roots it trusts.</div></div>
<span class="chev">›</span>
</div>
<!-- Hidden hooks: the plug-in init code wires several controls below by
id (status, toggles, install/update/apply buttons). Keep them in the
DOM so the sub-pages (plugins-ariadne / plugins-aegis) still work;
moving the plug-ins to top-level nav entries did not change their
sub-pages. These are offscreen placeholders that mirror the sub-page
controls, read by the shared refresh functions. -->
<div hidden aria-hidden="true" id="pluginsMainHooks">
<span id="ariadneStatusMain"></span>
<button id="ariadneInstallMain"></button>
<button id="ariadneUpdateMain"></button>
<button id="ariadneCheckMain"></button>
<span id="ariadneSwWrap"><input type="checkbox" id="ariadneToggleMain"></span>
<span id="aegisStatusMain"></span>
<button id="aegisCheckMain"></button>
<button id="aegisApplyMain"></button>
</div>
</section>
<!-- Ariadne's Thread landing — no longer a top-level nav entry. The
System section's chevron row navigates here; the plugins-ariadne
sub-page's own crumb then goes back to System. -->
<section id="ariadne" hidden>
<div class="crumb"><a data-go="plugins">System</a> ›</div>
<h1>Ariadne's Thread</h1>
<p class="lede">A local daemon that resolves BCDN names for <b>every browser on this machine</b>, not just Theseus. Theseus has its own built-in resolver either way; this plug-in is for Chrome, Edge, Firefox and the rest.</p>
<div class="row link" data-go="plugins/ariadne">
<div class="txt">
<div class="t">Open Ariadne's Thread settings</div>
<div class="d">Install, update, uninstall; collision policy, sources and the live status report.</div>
</div>
<span class="chev">›</span>
</div>
</section>
<!-- Top-level landing section for Aegis. The chevron opens the Aegis
panel in the sidebar (openPanel), falling back to its sub-page under
Plug-ins if the panel isn't registered. -->
<section id="aegis" hidden>
<h1>Aegis</h1>
<p class="lede">Multi-chain wallet (BCH, BTC, TRX, ETH, SOL, SC, DGB) that ships with Theseus and updates over-the-air.</p>
<div class="row link" id="aegisOpenRow">
<div class="txt">
<div class="t">Open the Aegis wallet</div>
<div class="d">Opens Aegis in the sidebar panel beside the page.</div>
</div>
<span class="chev">›</span>
</div>
<div class="row link" data-go="plugins/aegis">
<div class="txt">
<div class="t">Aegis plug-in settings</div>
<div class="d">Version, updates and Apply update now.</div>
</div>
<span class="chev">›</span>
</div>
</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>
<div class="row plug-row">
<div class="txt">
<div class="t">System-wide resolver</div>
<div class="d" id="ariadneStatusSub">checking…</div>
</div>
<div class="plug-ctl">
<button id="ariadneInstallSub" class="btn small" hidden>Install</button>
<button id="ariadneUpdateSub" class="btn small" hidden>Update</button>
<button id="ariadneRefreshSub" class="btn small" title="Refresh Ariadne status">Refresh</button>
<label class="sw" title="Turn Ariadne on/off">
<input type="checkbox" id="ariadneToggleSub">
<span class="track"><span class="knob"></span></span>
</label>
</div>
</div>
<div id="ariadneMissing" class="pmuted" style="font-size:12.5px;margin:-4px 4px 8px" hidden>
Ariadne's Thread isn't installed on this machine. Click <b>Install</b> above to run
the bundled installer, or grab it manually from
<a href="https://silentmode.st/tools/" target="_blank" rel="noopener">silentmode.st/tools</a>.
</div>
<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>
<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>
</div>
<button id="ariadneUninstallSub" class="btn" hidden style="color:#f6768a;border-color:rgba(246,118,138,.35)">Uninstall</button>
</div>
</section>
<!-- 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>
<!-- 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>
<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>
</div>
<div class="row" style="flex-direction:column;align-items:stretch;gap:10px">
<div class="txt">
<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>
<div id="engineList"></div>
<!-- Add from catalog is visible by default; the wider bank + the
custom-URL form live behind "+ Show more", which sits at the
bottom of the catalog so the common case (pick one from the
curated list) is one click. -->
<div class="ehdr">Add from catalog</div>
<div id="catalogList"></div>
<div style="display:flex;justify-content:flex-start;margin-top:6px">
<button id="engAddBtn" class="btn" type="button">+ Show more</button>
</div>
<div id="engineCatalog" class="engcat" hidden>
<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>
<!-- AI ENGINES — mirrors the Search layout but shows only kind="llm"
engines, so picking an AI launcher and picking a web-search engine
live in separate pages. The default-engine picker here sets the
defaultLlmId (the sidebar strip's Pick AI target), not the toolbar's
search engine. Renderers share engIcon + rowFor; see renderAiEngines
below for the paint. -->
<section id="ai-engines" hidden>
<h1>AI engines</h1>
<p class="lede">Which AI launchers show up in the sidebar's Pick-AI dropdown, and which one opens when you click the sparkle button.</p>
<div class="row">
<div class="txt"><div class="t">Default AI engine</div><div class="d">Opens when you click the sparkle button in the sidebar strip.</div></div>
<div class="ctl"><div class="esel" id="defaultLlm" tabindex="0" role="combobox" aria-haspopup="listbox" aria-expanded="false" title="Default AI engine"><span class="eic"></span><span class="enm"></span><span class="caret">▾</span></div></div>
</div>
<div class="row" style="flex-direction:column;align-items:stretch;gap:10px">
<div class="txt">
<div class="t">Enabled AI engines</div>
<div class="d">These appear in the sidebar strip's Pick-AI dropdown. Drag to reorder. Toggle off to move an engine back to the catalog.</div>
</div>
<div id="llmEngineList"></div>
<div class="ehdr">Add from catalog</div>
<div id="llmCatalogList"></div>
<div style="display:flex;justify-content:flex-start;margin-top:6px">
<button id="llmAddBtn" class="btn" type="button">+ Show more</button>
</div>
<div id="llmEngineCatalog" class="engcat" hidden>
<div class="ehdr" style="display:flex;align-items:center;justify-content:space-between;gap:8px">
<span>Discover more AI engines</span>
<input id="llmEngineFilter" 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="llmExtraList"></div>
<div class="ehdr">Or add a custom URL</div>
<div class="addeng">
<input id="llmEngSym" placeholder="✨" style="max-width:52px;text-align:center;flex:none">
<input id="llmEngName" placeholder="Name (e.g. My Chat)">
<input id="llmEngUrl" placeholder="https://example.com/chat?q=%s">
<button id="llmEngAdd" 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">Your vault is locked</div><div class="d" id="pwLockedDesc">Unlock it with your master password to view or add entries.</div></div>
<div class="ctl"><button id="pwUnlockBtn" class="btn" type="button">Unlock</button></div>
</div>
</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>
<h2 class="sub">Quick-unlock PIN</h2>
<div class="row">
<div class="txt"><div class="t">PIN</div>
<div class="d" id="pinDesc">A PIN of 6 to 8 digits 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>
<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>
<!-- Step-by-step PIN setup: master password → choose PIN → repeat → done. -->
<div id="pinWiz" class="pinwiz" hidden>
<div class="pinwiz-box" role="dialog" aria-modal="true" aria-labelledby="pinWizTitle">
<div class="pinwiz-steps"><span data-step="1"></span><span data-step="2"></span><span data-step="3"></span></div>
<h3 id="pinWizTitle"></h3>
<p id="pinWizText" class="pinwiz-text"></p>
<div id="pinWizBody"></div>
<div id="pinWizErr" class="pinwiz-err"></div>
<div class="pinwiz-act"><button id="pinWizCancel" class="btn ghost" type="button">Cancel</button><button id="pinWizNext" class="btn" type="button">Next</button></div>
</div>
</div>
<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>
<div class="note">On a site with a saved login, Theseus offers it under the sign-in form, and a key appears in the address bar while the vault is unlocked.</div>
</div>
<h2 class="sub">Signing in</h2>
<div class="row">
<div class="txt"><div class="t">Offer to save passwords</div><div class="d">After you sign in or sign up on a site, Theseus asks whether to keep the login in your vault. You can also ask it to check your PIN or password before it fills that login.</div></div>
<label class="sw"><input type="checkbox" id="pwOfferSave"><span class="track"><span class="knob"></span></span></label>
</div>
<div class="row">
<div class="txt"><div class="t">Offer saved logins on sign-in forms</div><div class="d">When you click into a sign-in form, saved logins for that site appear under it. While the vault is locked, Theseus offers to unlock it.</div></div>
<label class="sw"><input type="checkbox" id="pwOfferFill"><span class="track"><span class="knob"></span></span></label>
</div>
<div class="row" id="pwNeverRow" hidden>
<div class="txt"><div class="t">Never saved</div><div class="d" id="pwNeverList"></div></div>
<div class="ctl"><button id="pwNeverClear" class="btn ghost" type="button">Clear list</button></div>
</div>
</section>
<!-- THESEUS ID: DESIGN-theseus-id.md §5.5 -->
<section id="theseusid" hidden>
<h1>Theseus ID</h1>
<p class="lede">Your Silent Mode account, kept in your Theseus Vault. Sign in once and Silent Mode projects know you. A project gets a signature from your ID, never a key, and it can't see your wallets unless you link one.</p>
<div id="tidNoVault" hidden>
<div class="row">
<div class="txt"><div class="t">Set up the Theseus Vault first</div><div class="d">Your Theseus ID is made from your vault, so the same recovery phrase gives you the same ID on every device.</div></div>
<div class="ctl"><button class="btn" type="button" data-go="passwords">Set up the vault</button></div>
</div>
</div>
<div id="tidLocked" hidden>
<div class="row">
<div class="txt"><div class="t">Your vault is locked</div><div class="d">Unlock it to see the projects you sign in to.</div></div>
<div class="ctl"><button id="tidUnlockBtn" class="btn" type="button">Unlock</button></div>
</div>
</div>
<div id="tidMain" hidden>
<h2 class="sub">How projects see you</h2>
<div class="row" style="flex-direction:column;align-items:stretch;gap:8px">
<label class="polrow"><input type="radio" name="tidMode" value="project"><span><b>A different ID for each project</b> <span class="pmuted">— recommended. Projects can't tell your IDs belong to the same person.</span></span></label>
<label class="polrow"><input type="radio" name="tidMode" value="one"><span><b>One ID for every project</b> <span class="pmuted">— projects can recognise you as the same person.</span></span></label>
<div class="pmuted" style="font-size:12.5px">This applies to projects you haven't signed in to yet. A project you already use keeps its ID unless you change it below.</div>
</div>
<div class="row">
<div class="txt"><div class="t">Your One ID</div><div class="d"><code id="tidOneId" style="word-break:break-all"></code></div></div>
<div class="ctl"><button id="tidCopyOne" class="btn ghost" type="button">Copy</button></div>
</div>
<div class="row">
<div class="txt"><div class="t">Sign in to Silent Mode projects automatically</div><div class="d">After the first time, Hephaestus, Sirius, Pithos and other Silent Mode projects sign you in without asking while your vault is unlocked. Other sites always ask, unless you tick "Always sign me in" for them.</div></div>
<label class="sw"><input type="checkbox" id="tidAuto"><span class="track"><span class="knob"></span></span></label>
</div>
<h2 class="sub">Signed-in projects</h2>
<div id="tidProjects"></div>
<h2 class="sub">Recovery</h2>
<div class="row">
<div class="txt"><div class="t">If you lose this device</div><div class="d" id="tidRecoveryText"></div></div>
<div class="ctl"><button id="tidRecoveryBtn" class="btn ghost" type="button">Show recovery key</button></div>
</div>
<div id="tidRecoveryOut" class="row" hidden style="flex-direction:column;align-items:stretch"><code id="tidRecoveryKey" style="word-break:break-all"></code><div class="pmuted" style="font-size:12.5px">This 64-character key restores your Theseus IDs. Keep it somewhere safe and private: anyone with it can sign in as you. It is a secret key, not a recovery phrase.</div></div>
</div>
</section>
<!-- NAMING -->
<!-- Registries moved into General (above). -->
<!-- PERFORMANCE -->
<section id="performance" hidden>
<h1>Performance</h1>
<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>
<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>
<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>
<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">Start Pithos at launch</div><div class="d">Pithos boots its control server for the Sia S3 gateway right away. Off: it wakes on first panel open or when you visit a pithos.sia page.</div></div>
<label class="sw"><input type="checkbox" id="pithosAtLaunch"><span class="track"><span class="knob"></span></span></label>
</div>
<div class="row">
<div class="txt"><div class="t">Connect Hermes relays at launch</div><div class="d">Open your Nostr relays shortly after launch so Hermes messages arrive before you open the panel. Off: relays connect the first time you open Messages.</div></div>
<label class="sw"><input type="checkbox" id="hermesAutoRestore"><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>
</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>
<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>
<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>
<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>
<h2 class="sub">Device access</h2>
<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>
<div class="row">
<div class="txt"><div class="t">Block screen capture</div><div class="d">Refuse <code>getDisplayMedia()</code> outright, so no site or extension can even ask. Off by default because screen capture always opens a picker first — nothing is shared until you choose a screen or window and confirm.</div></div>
<label class="sw"><input type="checkbox" id="blockScreenCapture"><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">
<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>
<!-- 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">
<select id="locationMode"><option value="show">Show real</option><option value="hide">Hide (block)</option><option value="manual">Manual…</option></select>
<select id="locationCountry" hidden><!-- Populated in JS from the COUNTRIES table --></select>
</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>
<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>
<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>
<h2 class="sub">Browsing data</h2>
<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">
<div class="txt"><div class="t">Keep my apps and vault sites signed in</div><div class="d">When cookies or site storage are cleared on quit, these keep theirs, so you stay signed in: the web apps in the sidebar (WhatsApp, Telegram…), the apps you installed from Settings › Apps, and every site with a login saved in Settings › Passwords. Everything else is still cleared. If a site signs you out anyway, Theseus offers your saved login on its sign-in form.</div></div>
<label class="sw"><input type="checkbox" id="keepSignInsOnQuit"><span class="track"><span class="knob"></span></span></label>
</div>
<div class="row">
<div class="txt"><div class="t">Clear history on quit</div><div class="d">Drops back/forward history and address-bar history. With "Open previous windows and tabs" on, the tabs themselves still reopen.</div></div>
<label class="sw"><input type="checkbox" id="clearHistoryOnQuit"><span class="track"><span class="knob"></span></span></label>
</div>
<div class="row" style="justify-content:flex-end">
<button id="clearNow" class="btn" type="button">Clear all now</button>
</div>
<div class="note">These switches never touch the password vault: logins saved in Settings › Passwords stay in its encrypted vault.</div>
</section>
<!-- ADD-ONS -->
<!-- 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>
<section id="sidebar" hidden>
<h1>Sidebar</h1>
<p class="lede">The strip on the left edge: web apps that open in a panel beside the page, and extension panels. Drag a panel's right edge to resize it (double-click the edge to reset). The bar over an open panel maximizes it over the page, restores it, or closes it.</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>
<!-- How panels open — compact one-row header + two side-by-side option
cards (click a card to select). Radios stay in the DOM so the init
code keeps working; the card is really a label wrapping the radio. -->
<div class="row" style="flex-direction:column;align-items:stretch;gap:8px">
<div class="txt"><div class="t">How panels open</div><div class="d">Choose how the sidebar panel sits with the page.</div></div>
<div class="sb-choice">
<label class="sb-opt"><input type="radio" name="sidebarPanelMode" value="overlay"><div><b>Over the page</b><div class="pmuted">Panel floats; click page to close.</div></div></label>
<label class="sb-opt"><input type="radio" name="sidebarPanelMode" value="push"><div><b>Push the page aside</b><div class="pmuted">Page narrows; panel stays open.</div></div></label>
</div>
</div>
<style>
.sb-choice { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.sb-opt { display: flex; gap: 10px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; cursor: pointer; background: var(--panel); align-items: flex-start; }
.sb-opt:hover { background: var(--card-hi, rgba(255,255,255,.04)); }
.sb-opt input { margin-top: 2px; }
.sb-opt:has(input:checked) { border-color: var(--acid, #d6ff3d); background: rgba(214,255,61,.08); }
@media (max-width: 540px) { .sb-choice { grid-template-columns: 1fr; } }
</style>
<div class="row">
<div class="txt"><div class="t">Reset panel widths</div><div class="d">Blanks every saved width so each app opens at its default.</div></div>
<div class="ctl"><button class="btn small ghost" id="sidebarResetWidths">Reset</button></div>
</div>
<div class="row">
<div class="txt"><div class="t">AI engine in the sidebar</div><div class="d">Sparkle button opens your default AI; <a data-go="search" style="color:var(--acid-text);cursor:pointer">Search</a> picks it.</div></div>
</div>
<!-- Card A: Theseus native apps (Hermes, Pithos) — bundled system apps.
Rendered from sidebarLeftPanels(); filtered to the native-app ids. -->
<h2 class="sub">Theseus native apps</h2>
<p class="subd">System apps bundled with Theseus. They open from the sidebar strip.</p>
<div class="row" style="flex-direction:column;align-items:stretch;gap:6px">
<div id="sbNativeApps" style="display:flex;flex-direction:column;gap:6px"></div>
</div>
<!-- Card B: Third-party apps — the sidebar quick-links (Proton,
Telegram, WhatsApp, YouTube, X, …). Same qlList the Web apps
section used to drive, with the add-input below. -->
<h2 class="sub">Third-party apps</h2>
<p class="subd">Sites that open in the panel. Switch one off to take it off the strip; it stays here, still signed in, for switching back on. A site you installed as an app can be added from Apps.</p>
<div class="row" style="flex-direction:column;align-items:stretch;gap:8px">
<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>
<h2 class="sub">Extension panels</h2>
<p class="subd">Extensions that open from the strip. Switch one off to take it off the strip; the extension keeps working. The native apps above live under their own card.</p>
<div class="row" style="flex-direction:column;align-items:stretch;gap:6px">
<div id="sbPanels" style="display:flex;flex-direction:column;gap:6px"></div>
</div>
<h2 class="sub">When the panel steps aside</h2>
<p class="subd">The open panel hides (and keeps its place) when:</p>
<div class="row">
<div class="txt"><div class="t">You open a new tab</div></div>
<label class="sw"><input type="checkbox" id="sidebarHideOnNewTab"><span class="track"><span class="knob"></span></span></label>
</div>
<div class="row">
<div class="txt"><div class="t">The right side panel opens</div></div>
<label class="sw"><input type="checkbox" id="sidebarHideOnRightPanel"><span class="track"><span class="knob"></span></span></label>
</div>
<div class="row">
<div class="txt"><div class="t">You click the page</div><div class="d">Panels that open over the page. A panel that pushes the page aside stays open.</div></div>
<label class="sw"><input type="checkbox" id="sidebarHideOnPageClick"><span class="track"><span class="knob"></span></span></label>
</div>
</section>
<section id="apps" hidden>
<h1>Apps</h1>
<p class="lede">Sites you installed as apps. Each opens in its own window with its own taskbar button and shortcuts, and runs on the same Theseus underneath: same names, same protections, same sign-ins.</p>
<div id="appsList"></div>
<p class="subd" id="appsEmpty" hidden>No apps installed yet. When a site can be installed, an install chip appears at the right end of the address bar.</p>
</section>
<section id="addons" hidden>
<h1>Extensions</h1>
<p class="lede">Small modules that add capabilities to Theseus. Extensions live as folders under
<code style="background:transparent;border:none;padding:0" id="addonsPathHint">%APPDATA%\Theseus\extensions\</code>. Drop a folder in, restart, it's live.
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>
<div id="addonsMain">
<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/extensions/" target="_blank" rel="noopener" title="What ships, current signed versions, and how updates are verified">Extensions ↗</a>
<button id="addonsCheckUpdates" class="btn" type="button">Check for updates</button>
<button id="addonsReload" class="btn" type="button">Reload</button>
<button id="addonsOpenDir" class="btn" type="button">Open extensions folder</button>
</div>
<div id="addonsUpdStatus" class="pmuted" style="font-size:12.5px;margin-top:6px;text-align:right">—</div>
<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>
<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>
</div>
<div id="addonDetail" hidden></div>
</section>
</div>
</div>
<script src="strip-icons.js"></script>
<script>
const C = window.cfg;
// 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: "中文(繁體)" },
];
// 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 ? `Theseus current version 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);
}
})();
// General › Default handling. Hidden on platforms without a backing path
// (today: everything other than Windows).
(async () => {
const row = document.getElementById("defaultBrowserRow");
const defBrowser = document.getElementById("defaultBrowser");
const openSys = document.getElementById("openSysDefaults");
async function refresh() {
try {
const d = await cfg.defaultBrowserState();
if (!d.supported) return;
row.hidden = false;
defBrowser.checked = !!d.isDefault;
} catch {}
}
defBrowser.addEventListener("change", async () => {
await cfg.setDefaultBrowser(defBrowser.checked);
// Windows does not let an app assert itself as default; we land the
// user on the Settings page where that choice lives.
if (defBrowser.checked) await cfg.openSystemDefaults();
setTimeout(refresh, 1000);
});
openSys.addEventListener("click", () => cfg.openSystemDefaults());
refresh();
})();
// Aegis landing-section chevron — opens Aegis's own sidebar panel. Falls
// back to the plug-in sub-page if the panel isn't registered.
(function () {
const row = document.getElementById("aegisOpenRow");
if (!row) return;
row.addEventListener("click", async (e) => {
// The row's own data-go would route to Settings, not the Aegis panel.
e.preventDefault(); e.stopPropagation();
if (C && typeof C.openPanel === "function") {
try { await C.openPanel("aegis:main"); return; } catch {}
}
try { showSection("plugins/aegis"); } catch {}
});
})();
// sidebar navigation
// "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.
// 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;
document.querySelectorAll(".side a").forEach((x) => x.classList.toggle("active", x.dataset.sec === sec));
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;
}
document.querySelectorAll(".side a").forEach((a) => a.onclick = () => showSection(a.dataset.sec));
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).
if (C && C.onFocusSection) C.onFocusSection((sec) => showSection(sec));
// Land on the page named by `settings.html#<slug>` when opened via main.
try {
const initSec = String(location.hash || "").replace(/^#/, "").toLowerCase();
showSection(initSec || "general");
} catch {}
// 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"));
// 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); });
// 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; }
})();
document.addEventListener("section", (e) => { if (String(e.detail).startsWith("privacy")) refreshAll(); });
refreshAll();
})();
// 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()));
});
})();
// 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, pithosAtLaunch: false, hermesAutoRestore: 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", "pithosAtLaunch", "hermesAutoRestore", "preloadMenus", "blockCamera", "blockMicrophone", "hideMediaDevices", "blockScreenCapture", "gpc",
"clearCookiesOnQuit", "keepSignInsOnQuit", "clearCacheOnQuit", "clearStorageOnQuit", "clearHistoryOnQuit",
"pwOfferSave", "pwOfferFill",
"quickLinksShow", "translateAutoOffer",
"sidebarHideOnNewTab", "sidebarHideOnRightPanel", "sidebarHideOnPageClick"];
// 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.
const TEXT_FIELDS = ["translateApiKey"];
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));
}
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()));
}
// ---- 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) => ({ "&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;" })[c]);
// A row's width shares its key with the strip: "q:<id>".
const widthBtnHtml = (w, key) => w ? `<button class="btn ghost" data-width="${esc(key)}" title="Panel width ${w} px">Reset width</button>` : "";
// On/off: whether the app or extension panel is on the strip. Off keeps
// it here, with its sign-in and width, for switching back on.
const onStripHtml = (on, key) => `<label class="sw" title="${on ? "On the strip" : "Off the strip"}"><input type="checkbox" data-onstrip="${esc(key)}"${on ? " checked" : ""}><span class="track"><span class="knob"></span></span></label>`;
function wireOnStrip(root) {
root.querySelectorAll("[data-onstrip]").forEach((i) => i.onchange = async () => {
const h = new Set((await C.get()).leftPanelsHidden || []);
if (i.checked) h.delete(i.dataset.onstrip); else h.add(i.dataset.onstrip);
C.set("leftPanelsHidden", [...h]);
});
}
let cur = s;
function render(links) {
const widths = cur.leftPanelWidths || {};
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;
}
links.forEach((L, i) => {
const key = "q:" + L.id;
const row = document.createElement("div");
// Same drag-to-reorder rows as Search engines (.eng styles).
row.className = "eng";
row.draggable = true;
row.dataset.id = L.id;
row.style.cssText = "display:flex;align-items:center;gap:8px;flex-wrap:wrap;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:8px 12px";
const on = !(cur.leftPanelsHidden || []).includes(key);
row.innerHTML = `<span class="grip" title="Drag to reorder">⠿</span><span style="display:contents;${on ? "" : "opacity:.5"}">${iconBox(L)}</span>
<div style="flex:1;min-width:140px;${on ? "" : "opacity:.5"}"><div style="font-weight:600">${esc(L.title || L.url)}</div><div style="font-size:12px;color:var(--mut);word-break:break-all">${on ? "" : "Off the strip · "}${esc(L.url)}${widths[key] ? " · " + Number(widths[key]) + " px" : ""}</div></div>
${widthBtnHtml(widths[key], key)}
<button class="btn ghost" data-del="${esc(L.id)}">Remove</button>
${onStripHtml(on, key)}`;
list.appendChild(row);
});
list.querySelectorAll("[data-del]").forEach((b) => b.onclick = async () => {
const now = (await C.get()).quickLinks || [];
C.set("quickLinks", now.filter((x) => x.id !== b.dataset.del));
});
// Drag to reorder; the strip follows through settings-set.
let dragId = null;
list.querySelectorAll(".eng").forEach((row) => {
row.addEventListener("dragstart", (e) => { dragId = row.dataset.id; 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) => { 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", async (e) => {
e.preventDefault(); row.classList.remove("over");
if (!dragId || dragId === row.dataset.id) return;
const now = ((await C.get()).quickLinks || []).slice();
const from = now.findIndex((x) => x.id === dragId), to = now.findIndex((x) => x.id === row.dataset.id);
if (from < 0 || to < 0) return;
const [moved] = now.splice(from, 1);
now.splice(to, 0, moved);
C.set("quickLinks", now);
});
});
wireWidth(list);
wireOnStrip(list);
// A site without a bundled icon whose favicon does not load: its letter.
list.querySelectorAll("img[data-fav]").forEach((img) => { img.onerror = () => { const d = img.parentElement; img.remove(); d.textContent = d.dataset.letter || "?"; }; });
}
// The same icon the strip shows: the bundled mark, else the site's
// favicon, else the first letter of the title.
function iconBox(L) {
const box = "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";
const svg = window.StripIcons && window.StripIcons.svgFor(L);
if (svg) return `<div class="qlico" style="${box}">${svg}</div>`;
const letter = esc((L.title || "?").slice(0, 1).toUpperCase());
let fav = ""; try { if (/^https?:/i.test(L.url)) fav = new URL("/favicon.ico", L.url).href; } catch {}
return `<div class="qlico" style="${box}" data-letter="${letter}">${fav ? `<img data-fav src="${esc(fav)}" alt="">` : letter}</div>`;
}
// Shared with the extension-panel list below.
window.__sbWireWidth = wireWidth;
function wireWidth(root) {
root.querySelectorAll("[data-width]").forEach((b) => b.onclick = async () => {
const w = { ...((await C.get()).leftPanelWidths || {}) };
delete w[b.dataset.width];
C.set("leftPanelWidths", w);
});
}
window.__sbButtons = { widthBtnHtml, onStripHtml, wireOnStrip };
render(s.quickLinks || []);
// Typing a well-known app's name shows the rest of it in grey, and its
// address in the address field; → at the end of a field takes the grey
// part. An address can be started without "https://" too.
{
const KNOWN = (window.StripIcons && window.StripIcons.KNOWN) || [];
const urlPlaceholder = urlIn.placeholder;
const titleSuggestion = () => {
const v = titleIn.value.trim().toLowerCase();
if (!v) return "";
const k = KNOWN.find((x) => x.title.toLowerCase().startsWith(v));
return k ? k.title : "";
};
const urlSuggestion = () => {
const t = titleIn.value.trim().toLowerCase();
const byTitle = KNOWN.find((x) => x.title.toLowerCase() === t);
const v = urlIn.value.trim().toLowerCase();
if (!v) return byTitle ? byTitle.url : "";
const forms = (u) => [u, u.replace(/^https:\/\//, ""), u.replace(/^https:\/\/(www\.)?/, "")];
for (const k of byTitle ? [byTitle, ...KNOWN] : KNOWN) for (const f of forms(k.url)) if (f.toLowerCase().startsWith(v)) return f;
return "";
};
function suggest(input, get) {
const wrap = document.createElement("span");
wrap.style.cssText = `position:relative;display:flex;flex:${input.style.flex || "1"};min-width:${input.style.minWidth || "0"}`;
input.parentNode.insertBefore(wrap, input);
wrap.appendChild(input);
input.style.flex = "1"; input.style.minWidth = "0"; input.style.width = "100%"; input.style.boxSizing = "border-box";
const ghost = document.createElement("span");
wrap.appendChild(ghost);
const sync = () => {
const cs = getComputedStyle(input);
ghost.style.cssText = `position:absolute;inset:0;pointer-events:none;white-space:pre;overflow:hidden;display:flex;align-items:center;box-sizing:border-box;font:${cs.font};padding:${cs.padding};border:${cs.borderTopWidth} solid transparent`;
const sug = get(), v = input.value;
const fits = sug && sug.toLowerCase().startsWith(v.toLowerCase()) && sug.length > v.length;
input.dataset.suggest = fits ? sug : "";
ghost.textContent = "";
if (fits && v) {
const typed = document.createElement("span"); typed.style.color = "transparent"; typed.textContent = v;
const rest = document.createElement("span"); rest.style.color = "var(--dim)"; rest.textContent = sug.slice(v.length);
ghost.append(typed, rest);
}
};
input.addEventListener("input", sync);
input.addEventListener("keydown", (e) => {
if (e.key !== "ArrowRight" || !input.dataset.suggest) return;
if (input.selectionStart !== input.value.length || input.selectionEnd !== input.value.length) return;
e.preventDefault();
input.value = input.dataset.suggest;
input.dispatchEvent(new Event("input"));
});
return sync;
}
const syncUrl = suggest(urlIn, urlSuggestion);
const syncTitle = suggest(titleIn, titleSuggestion);
// The address field's grey hint is the matched app's address.
titleIn.addEventListener("input", () => { const u = urlSuggestion(); urlIn.placeholder = (!urlIn.value && u) ? u + " →" : urlPlaceholder; syncUrl(); });
syncTitle(); syncUrl();
}
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) => { cur = next; render(next.quickLinks || []); });
})();
// ---- Extension panels on the strip (Sidebar) ---------------------------
// Two cards: native apps (Hermes + Pithos) are rendered into #sbNativeApps,
// every other left panel into #sbPanels. The native-id test matches a
// panel.id of the form "p:<addon>:<panel>" (what sidebar-left-panels
// returns), so hermes:main / pithos:main land in the native card.
(function () {
const nativeBox = document.getElementById("sbNativeApps");
const box = document.getElementById("sbPanels");
if ((!nativeBox && !box) || !C.sidebarLeftPanels) return;
const esc = (v) => String(v || "").replace(/[&<>"']/g, (c) => ({ "&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;" })[c]);
const NATIVE_IDS = new Set(["hermes", "pithos"]);
const addonOf = (pid) => String(pid || "").replace(/^p:/, "").split(":")[0];
const isNative = (p) => NATIVE_IDS.has(addonOf(p.id));
let cur = s;
function rowHtml(p, widths, B) {
const icon = /^data:image\//.test(p.icon) ? `<img src="${esc(p.icon)}" alt="" style="width:20px;height:20px">` : esc(p.icon || (p.title || "?").slice(0, 1));
return `<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:8px 12px">
<div style="width:28px;height:28px;border-radius:6px;background:rgba(255,255,255,.06);display:grid;place-items:center;flex:none">${icon}</div>
<div style="flex:1;min-width:140px;${p.hidden ? "opacity:.5" : ""}"><div style="font-weight:600">${esc(p.title)}</div><div style="font-size:12px;color:var(--mut)">${p.hidden ? "Off the strip" : "On the strip"}${widths[p.id] ? " · " + Number(widths[p.id]) + " px" : ""}</div></div>
${B ? B.widthBtnHtml(widths[p.id], p.id) : ""}
${B ? B.onStripHtml(!p.hidden, p.id) : ""}</div>`;
}
async function render() {
let panels = []; try { panels = (await C.sidebarLeftPanels()) || []; } catch {}
const widths = cur.leftPanelWidths || {};
const B = window.__sbButtons;
const native = panels.filter(isNative);
const rest = panels.filter((p) => !isNative(p));
if (nativeBox) {
nativeBox.innerHTML = native.length
? native.map((p) => rowHtml(p, widths, B)).join("")
: `<div class="pmuted" style="font-size:12.5px">Hermes and Pithos are not available.</div>`;
if (B) B.wireOnStrip(nativeBox);
if (window.__sbWireWidth) window.__sbWireWidth(nativeBox);
}
if (box) {
box.innerHTML = rest.length
? rest.map((p) => rowHtml(p, widths, B)).join("")
: `<div class="pmuted" style="font-size:12.5px">No extension opens from the strip.</div>`;
if (B) B.wireOnStrip(box);
if (window.__sbWireWidth) window.__sbWireWidth(box);
}
}
render();
document.addEventListener("section", (e) => { if (String(e.detail) === "sidebar") render(); });
if (C.onSettingsUpdate) C.onSettingsUpdate((next) => { cur = next; render(); });
})();
// ---- 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) => ({ "&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;" })[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 || []));
})();
// ---- 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); });
});
})();
// ---- 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.
// 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;
// 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.
const langNameFor = (tag) => {
const base = String(tag || "").split("-")[0];
try { return new Intl.DisplayNames(["en"], { type: "language" }).of(base) || tag; }
catch { return tag; }
};
(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 {}
// 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.
const SUPPORTED = new Set(["en", "es", "fr", "de", "el", "ru"]);
const baseOf = (t) => String(t || "").split("-")[0].toLowerCase();
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>`,
];
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"
? `Following your operating system (${langNameFor(osLoc)}). Also switchable from the globe icon in the address bar.`
: `Anti-fingerprinting is currently overriding this (mode: <b>${mode}</b>). Change it under <b>Privacy → Anti-fingerprinting</b>.`;
return;
}
const tag = cur.languageValue || "en-GB";
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; }
hint.textContent = `Websites see you in ${langNameFor(tag)}. Also switchable from the globe icon in the address bar.`;
};
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));
})();
// search engines: default picker + custom-engine list + add form
const sel = document.getElementById("searchEngine");
const esc = (s) => String(s || "").replace(/</g, "&lt;");
// 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.
const engIcon = (e) => {
const sym = (e.sym || "🔍").replace(/'/g, "&#39;");
return e.favicon
? `<img class="ei" src="${esc(e.favicon)}" onerror="this.replaceWith(Object.assign(document.createElement('span'),{className:'es',textContent:'${sym}'}))">`
: `<span class="es">${sym}</span>`;
};
// The Search page only shows kind=search. LLMs live on their own page
// (section #ai-engines), painted below by renderAiEngines.
const ENGINE_KINDS = [
{ key: "search", label: "Search engines" },
];
// 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 : "";
}
function renderEngines(d) {
const enabled = d.engines.filter((e) => e.enabled);
selEngines = enabled; selCurrent = d.current;
paintDefaultEngine();
// 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);
// 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)}"` : ""}>` +
`<span class="grip" title="Drag to reorder">⠿</span>` +
`<span class="eic">${engIcon(e)}</span><span class="enm">${esc(e.name)}</span>` +
(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>`) +
`</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");
const catRow = (e) => `<div class="cat${e.frozen ? " frozen" : ""}" data-id="${e.id}"${e.frozen ? ` title="${esc(e.frozen)}"` : ""}>` +
`<span class="eic">${engIcon(e)}</span>` +
`<span class="enm">${esc(e.name)}</span>` +
`<span class="kind">${(e.kind || "search") === "llm" ? "AI" : "Search"}</span>` +
(e.frozen ? `<span class="kind warn">Unavailable</span>` : `<button class="add" data-add="${e.id}">+ Add</button>`) + `</div>`;
// Search catalog + extras — kind=search only; LLMs live under the
// AI engines page's own catalog, below.
const uninstalled = d.engines.filter((e) => e.builtin && !e.installed && (e.kind || "search") === "search");
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));
// Also paint the AI engines page (kind=llm). Kept intentionally
// light-weight: same row/cat markup, no drag, no right-click menu, no
// custom URL form — those live on the Search page and are rare for AI
// launchers. The Default AI picker is a plain <select>-style button.
renderLlmEngines(d);
// 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);
}
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(); } });
// ---- AI engines page (section #ai-engines) ------------------------------
// Mirrors the Search page's shape but limited to kind=llm. The Default
// AI engine button writes settings.defaultLlmId (same key the sidebar
// strip reads); toggles reuse C.setEngineEnabled. The LLM catalog shows
// installable AI launchers; "Discover more" is wired for symmetry even
// though no LLM is on the "extra" tier today.
const defLlmSel = document.getElementById("defaultLlm");
let defLlmId = s.defaultLlmId || null;
function paintDefaultLlm(enabledLlms) {
if (!defLlmSel) return;
const cur = enabledLlms.find((e) => e.id === defLlmId) || enabledLlms[0];
defLlmSel.querySelector(".eic").innerHTML = cur ? engIcon(cur) : "";
defLlmSel.querySelector(".enm").textContent = cur ? cur.name : "No AI engines enabled";
}
function renderLlmEngines(d) {
const installed = d.engines.filter((e) => e.installed && (e.kind || "search") === "llm");
const enabledLlms = d.engines.filter((e) => e.enabled && (e.kind || "search") === "llm");
paintDefaultLlm(enabledLlms);
const list = document.getElementById("llmEngineList");
if (!list) return;
list.innerHTML = installed.length
? installed.map(rowFor).join("")
: `<div class="cempty2">No AI engines in your list. Click + Add AI engine below.</div>`;
list.querySelectorAll('input[type="checkbox"]').forEach((cb) => cb.onchange = () => C.setEngineEnabled(cb.dataset.id, cb.checked).then(renderEngines));
list.querySelectorAll(".eng").forEach((row) => {
row.addEventListener("contextmenu", (ev) => { ev.preventDefault(); openEngineMenu(row, ev.clientX, ev.clientY); });
});
const cat = document.getElementById("llmCatalogList");
const extra = document.getElementById("llmExtraList");
const filterInput = document.getElementById("llmEngineFilter");
const uninstalled = d.engines.filter((e) => e.builtin && !e.installed && (e.kind || "search") === "llm");
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 AI engines are already in your list.</div>`;
cat.querySelectorAll(".add").forEach((b) => b.onclick = () => C.setEngineEnabled(b.dataset.add, true).then(renderEngines));
}
if (extra) {
const paint = (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 AI engines match that filter." : "No extra AI engines yet."}</div>`;
extra.querySelectorAll(".add").forEach((b) => b.onclick = () => C.setEngineEnabled(b.dataset.add, true).then(renderEngines));
};
paint(filterInput ? filterInput.value : "");
if (filterInput && !filterInput.dataset.wired) {
filterInput.dataset.wired = "1";
filterInput.addEventListener("input", () => paint(filterInput.value));
}
}
}
// Default AI engine dropdown — mirrors the Search engine picker's menu.
let llmMenu = null;
function closeDefaultLlmMenu() { if (llmMenu) { llmMenu.remove(); llmMenu = null; defLlmSel?.classList.remove("open"); defLlmSel?.setAttribute("aria-expanded", "false"); } }
function openDefaultLlmMenu() {
if (!defLlmSel) return;
closeEngineMenu(); closeDefaultEngineMenu(); closeDefaultLlmMenu();
const enabledLlms = selEngines.filter((e) => (e.kind || "search") === "llm");
const m = document.createElement("div");
m.className = "ctxmenu eselmenu"; m.setAttribute("role", "listbox");
m.innerHTML = enabledLlms.map((e) => `<div class="mi${e.id === defLlmId ? " cur" : ""}" role="option" data-id="${esc(e.id)}"><span class="eic">${engIcon(e)}</span><span class="enm">${esc(e.name)}</span>${e.id === defLlmId ? '<span class="chk">✓</span>' : ""}</div>`).join("")
|| `<div class="cempty2" style="padding:10px 12px">Turn an AI engine on below first.</div>`;
document.body.appendChild(m);
const r = defLlmSel.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")];
const choose = (id) => { closeDefaultLlmMenu(); if (id && id !== defLlmId) { defLlmId = id; C.set("defaultLlmId", id); paintDefaultLlm(enabledLlms); } defLlmSel.focus(); };
items.forEach((i) => { i.onclick = () => choose(i.dataset.id); });
const off = (ev) => { if (!m.contains(ev.target) && ev.target !== defLlmSel && !defLlmSel.contains(ev.target)) closeDefaultLlmMenu(); };
const key = (ev) => { if (ev.key === "Escape") { ev.preventDefault(); closeDefaultLlmMenu(); defLlmSel.focus(); } };
const cleanup = () => { document.removeEventListener("mousedown", off); document.removeEventListener("keydown", key, true); };
document.addEventListener("mousedown", off);
document.addEventListener("keydown", key, true);
const origRemove = m.remove.bind(m); m.remove = () => { cleanup(); origRemove(); };
llmMenu = m; defLlmSel.classList.add("open"); defLlmSel.setAttribute("aria-expanded", "true");
}
if (defLlmSel) {
defLlmSel.onclick = () => (llmMenu ? closeDefaultLlmMenu() : openDefaultLlmMenu());
defLlmSel.onkeydown = (ev) => { if (llmMenu) return; if (ev.key === "Enter" || ev.key === " " || ev.key === "ArrowDown" || ev.key === "ArrowUp") { ev.preventDefault(); openDefaultLlmMenu(); } };
}
// LLM custom-URL add form — same shape as the Search page's engAdd.
const llmAddBtn = document.getElementById("llmAddBtn");
const llmCatBox = document.getElementById("llmEngineCatalog");
if (llmAddBtn && llmCatBox) {
llmAddBtn.onclick = () => {
const open = llmCatBox.hidden;
llmCatBox.hidden = !open;
llmAddBtn.textContent = open ? "− Show less" : "+ Show more";
};
}
const llmEngAdd = document.getElementById("llmEngAdd");
if (llmEngAdd) {
llmEngAdd.onclick = () => {
const name = document.getElementById("llmEngName").value.trim();
const url = document.getElementById("llmEngUrl").value.trim();
const sym = document.getElementById("llmEngSym").value.trim();
if (!name || !url.includes("%s")) { alert("Enter a name and a URL containing %s (where the prompt goes)."); return; }
C.addEngine({ name, url, sym, kind: "llm" }).then((d) => {
document.getElementById("llmEngName").value = ""; document.getElementById("llmEngUrl").value = ""; document.getElementById("llmEngSym").value = "";
renderEngines(d);
});
};
}
if (C.onSettingsUpdate) C.onSettingsUpdate((next) => { if (next && typeof next.defaultLlmId !== "undefined" && next.defaultLlmId !== defLlmId) { defLlmId = next.defaultLlmId; C.engines().then((d) => paintDefaultLlm(d.engines.filter((e) => e.enabled && (e.kind || "search") === "llm"))); } });
// 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);
});
};
// "+ Show more" opens the Discover-more engines list and the custom-URL
// form. The curated catalog above it is always visible — the common case
// (pick a curated engine) is one click.
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 ? "− Show less" : "+ Show more";
};
}
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);
// 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");
// 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 = {
AE: ["United Arab Emirates", 25.2048, 55.2708],
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],
};
// 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",
};
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;
const la = Number(s.locationLat), lo = Number(s.locationLon);
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") {
// Every region must map to a COUNTRIES entry: this runs before the
// rest of the page is wired, so a throw here (middle_east -> AE, once
// missing from COUNTRIES) left Plug-ins and everything below it dead.
const code = COUNTRIES[REGION_TO_COUNTRY[s.locationRegion]] ? 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]));
});
// 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);
};
// ---- 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); };
// ---- 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.
// Sidebar › How panels open. Also reflected in the strip gear's menu
// (now a one-item shortcut back to this page, since the radios + Reset
// moved here in 0.3.100).
{
const sync = (mode) => document.querySelectorAll('input[name="sidebarPanelMode"]').forEach((r) => { r.checked = (r.value === (mode === "push" ? "push" : "overlay")); });
sync(s.sidebarPanelMode);
document.querySelectorAll('input[name="sidebarPanelMode"]').forEach((r) => r.addEventListener("change", () => { if (r.checked) C.set("sidebarPanelMode", r.value); }));
if (C.onSettingsUpdate) C.onSettingsUpdate((next) => sync(next.sidebarPanelMode));
}
// Sidebar › Reset panel widths — blanks every saved width.
{
const btn = document.getElementById("sidebarResetWidths");
if (btn && C.sidebarResetWidths) {
btn.addEventListener("click", async () => {
if (!confirm("Reset every saved panel width? Each app will open at its default width next time.")) return;
const orig = btn.textContent;
btn.disabled = true; btn.textContent = "Resetting…";
try { await C.sidebarResetWidths(); } catch (e) { console.warn("reset widths failed", e); }
finally { btn.disabled = false; btn.textContent = orig; }
});
}
}
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); });
});
// ---- 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
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}`;
}
// 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);
}
// 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;
}
}
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();
// ---- 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/&lt;host&gt; 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(); }
});
// 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;
}
}
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();
})();
// 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.
// ---- 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 pwRenderNever() {
const s = await C.get();
const list = Array.isArray(s.pwNeverSave) ? s.pwNeverSave : [];
document.getElementById("pwNeverRow").hidden = !list.length;
document.getElementById("pwNeverList").textContent = list.length
? `Theseus won't offer to save passwords on: ${list.join(", ")}.` : "";
}
document.getElementById("pwNeverClear").onclick = async () => { await C.set("pwNeverSave", []); pwRenderNever(); };
async function pwRefresh() {
pwRenderNever().catch(() => {});
const st = await C.pwStatus();
if (!st.setup) return pwShow("setup");
const pin = await C.pinStatus().catch(() => null);
if (!st.unlocked) {
document.getElementById("pwLockedDesc").textContent = pin && pin.pinSet
? "Unlock it with your PIN or master password to view or add entries."
: "Unlock it with your master password to view or add entries.";
return pwShow("locked");
}
pwShow("unlocked");
renderPin(pin);
const res = await C.pwList();
renderPwList(res.ok ? res.entries : []);
}
// ---- 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");
const base = "A PIN of 6 to 8 digits 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.`
: 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."
: base;
document.getElementById("pinSetBtn").disabled = !set && !!pin && pin.storable === false;
}
// Step-by-step PIN setup. Step 1 proves the master password (the PIN
// wraps it), step 2 picks the PIN on a pad, step 3 repeats it, then
// main seals it. The master password stays in this closure only until
// the dialog closes.
const pinWiz = {
el: document.getElementById("pinWiz"),
step: 0, master: "", first: "", entry: "", busy: false,
open() {
Object.assign(this, { step: 1, master: "", first: "", entry: "", busy: false });
this.el.hidden = false;
this.render();
},
close() {
Object.assign(this, { step: 0, master: "", first: "", entry: "" });
this.el.hidden = true;
},
error(msg) { document.getElementById("pinWizErr").textContent = msg || ""; },
// Step 2 takes 6 to 8 digits (eight dots, the first six marked as
// needed); step 3 must repeat exactly as many. Next, or Enter, moves on;
// nothing advances by itself, so a slip can still be fixed with ⌫.
get len() { return this.step === 3 ? this.first.length : 8; },
ready() { return this.step === 3 ? this.entry.length === this.first.length : this.entry.length >= 6; },
press(k) {
if (this.busy || (this.step !== 2 && this.step !== 3)) return;
if (k === "back") this.entry = this.entry.slice(0, -1);
else if (k === "clear") this.entry = "";
else if (this.entry.length < this.len) this.entry += k;
this.error("");
this.paintDots();
},
paintDots() {
document.querySelectorAll("#pinWizBody .pindot").forEach((d, i) => d.classList.toggle("on", i < this.entry.length));
const nextBtn = document.getElementById("pinWizNext");
if (this.step === 2 || this.step === 3) nextBtn.disabled = !this.ready();
},
pad() {
const keys = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "clear", "0", "back"];
return `<div class="pinpad"><div class="pindots">${Array.from({ length: this.len }, (_, i) => `<span class="pindot${this.step === 2 && i >= 6 ? " optional" : ""}"></span>`).join("")}</div>` +
`<div class="pinkeys">${keys.map((k) => `<button type="button" data-k="${k}" class="${k === "back" || k === "clear" ? "util" : ""}" aria-label="${k === "back" ? "Delete" : k === "clear" ? "Clear" : k}">${k === "back" ? "⌫" : k === "clear" ? "Clear" : k}</button>`).join("")}</div></div>`;
},
render() {
const title = document.getElementById("pinWizTitle"), text = document.getElementById("pinWizText");
const body = document.getElementById("pinWizBody"), nextBtn = document.getElementById("pinWizNext");
document.querySelectorAll(".pinwiz-steps span").forEach((s) => s.classList.toggle("on", +s.dataset.step <= this.step));
this.error("");
if (this.step === 1) {
title.textContent = "Confirm your master password";
text.textContent = "The PIN unlocks the vault by opening your master password, so Theseus needs it once to set the PIN up.";
body.innerHTML = `<input id="pinWizPw" type="password" placeholder="Master password" autocomplete="current-password">`;
const inp = document.getElementById("pinWizPw");
inp.addEventListener("keydown", (e) => { if (e.key === "Enter") this.next(); });
setTimeout(() => inp.focus(), 0);
nextBtn.hidden = false; nextBtn.textContent = "Next";
} else if (this.step === 2) {
title.textContent = "Choose a PIN";
text.textContent = "Type 6 to 8 digits, then press Next. Longer is stronger.";
body.innerHTML = this.pad();
nextBtn.hidden = false; nextBtn.textContent = "Next";
} else if (this.step === 3) {
title.textContent = "Repeat your PIN";
text.textContent = `Type the same ${this.len} digits again, then press Save.`;
body.innerHTML = this.pad();
nextBtn.hidden = false; nextBtn.textContent = "Save PIN";
} else if (this.step === 4) {
title.textContent = "PIN set";
text.textContent = this.hardware === "tpm"
? "Your PIN now unlocks the vault here, in Aegis and in extensions. It is tied to this computer's security chip, which allows only a few wrong guesses an hour."
: "Your PIN now unlocks the vault here, in Aegis and in extensions. This computer has no usable security chip, so the PIN only stops casual use.";
body.innerHTML = "";
nextBtn.hidden = false; nextBtn.textContent = "Done";
}
body.querySelectorAll("[data-k]").forEach((b) => b.onclick = () => this.press(b.dataset.k));
nextBtn.disabled = false;
if (this.step === 2 || this.step === 3) this.paintDots(); // Next waits for enough digits
document.getElementById("pinWizCancel").hidden = this.step === 4;
},
async next() {
if (this.busy) return;
if (this.step === 1) {
const pw = document.getElementById("pinWizPw").value;
if (!pw) return this.error("Enter your master password.");
this.busy = true;
const r = await C.pinCheckMaster(pw).catch((e) => ({ ok: false, err: e.message }));
this.busy = false;
if (!r.ok) return this.error("Wrong master password.");
this.master = pw; this.step = 2; this.render();
} else if (this.step === 2) {
if (!this.ready()) return this.error("Type at least 6 digits.");
this.first = this.entry; this.entry = ""; this.step = 3; this.render();
} else if (this.step === 3) {
if (!this.ready()) return;
if (this.entry !== this.first) {
this.first = ""; this.entry = ""; this.step = 2; this.render();
return this.error("The PINs didn't match. Choose your PIN again.");
}
this.busy = true;
document.getElementById("pinWizText").textContent = "Saving… the security chip can take a few seconds.";
const res = await C.pinSet(this.first, this.master).catch((e) => ({ ok: false, err: e.message }));
this.busy = false;
if (!res.ok) { this.entry = ""; this.render(); return this.error(res.err === "wrong master password" ? "Wrong master password." : res.err); }
this.hardware = res.hardware; this.master = ""; this.first = ""; this.entry = "";
this.step = 4; this.render();
pwRefresh();
} else if (this.step === 4) {
this.close();
}
},
};
document.getElementById("pinSetBtn").onclick = () => pinWiz.open();
document.getElementById("pinWizCancel").onclick = () => pinWiz.close();
document.getElementById("pinWizNext").onclick = () => pinWiz.next();
pinWiz.el.addEventListener("mousedown", (e) => { if (e.target === pinWiz.el && pinWiz.step !== 4) pinWiz.close(); });
document.addEventListener("keydown", (e) => {
if (pinWiz.el.hidden) return;
if (e.key === "Escape") return pinWiz.close();
if (/^[0-9]$/.test(e.key)) pinWiz.press(e.key);
else if (e.key === "Backspace" && (pinWiz.step === 2 || pinWiz.step === 3)) pinWiz.press("back");
else if (e.key === "Enter" && (pinWiz.step === 2 || pinWiz.step === 3)) { e.preventDefault(); pinWiz.next(); }
});
document.getElementById("pinClearBtn").onclick = async () => {
if (!confirm("Remove the PIN? The vault will need the master password again.")) return;
await C.pinClear();
pwRefresh();
};
// Unlock opens Theseus's own centred prompt: the PIN pad when a PIN is
// set, the master password otherwise.
document.getElementById("pwUnlockBtn").onclick = async () => {
const r = await C.pinUnlock();
if (r && r.ok) pwRefresh();
};
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)}">` +
`<span class="eic"><span class="es">🔑</span></span>` +
`<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 pwAsk" title="${e.confirm ? "Asks for your PIN or password before filling. Click to stop asking." : "Fills without asking. Click to require your PIN or password first."}" aria-pressed="${e.confirm ? "true" : "false"}" style="${e.confirm ? "" : "opacity:.35"}">🔒</button>` +
`<button class="cx pwShow" title="Show + copy">👁</button>` +
`<button class="cx pwDel" title="Remove">✕</button>` +
`</div>`).join("");
pwListEl.querySelectorAll(".pwAsk").forEach((b) => b.onclick = async (ev) => {
const id = ev.target.closest(".eng").dataset.id;
const on = b.getAttribute("aria-pressed") !== "true";
// Turning the check off needs the check itself.
if (!on) { const r = await C.pwConfirm(`Stop asking before filling this login?`); if (!r || !r.ok) return; }
await C.pwUpdate(id, { confirm: on });
pwRefresh();
});
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();
};
// 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);
});
// ---- Settings › Theseus ID (DESIGN-theseus-id.md §5.5) ----
// Built with createElement/textContent only: project names come from
// origin lists fetched from the projects themselves.
(() => {
const $ = (id) => document.getElementById(id);
const el = (tag, attrs = {}, ...kids) => {
const n = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === "class") n.className = v;
else if (k === "style") n.style.cssText = v;
else if (k.startsWith("on")) n.addEventListener(k.slice(2), v);
else n.setAttribute(k, v);
}
for (const c of kids.flat()) if (c != null && c !== false) n.append(c instanceof Node ? c : String(c));
return n;
};
const short = (a) => (a && a.length > 20 ? `${a.slice(0, 12)}…${a.slice(-4)}` : a || "");
const when = (iso) => { if (!iso) return "never"; const d = new Date(iso); return isNaN(d) ? "" : d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); };
const unwrap = async (p) => { const r = await p; if (!r || !r.ok) throw Object.assign(new Error(r?.error?.message || "failed"), { code: r?.error?.code }); return r.result; };
let view = null;
function show(which) { for (const id of ["tidNoVault", "tidLocked", "tidMain"]) $(id).hidden = id !== which; }
async function refresh() {
let ov;
try { ov = await unwrap(C.tidOverview()); } catch (e) { show("tidLocked"); return; }
view = ov;
if (ov.vault === "none") return show("tidNoVault");
if (ov.vault === "locked") return show("tidLocked");
show("tidMain");
for (const r of document.querySelectorAll('input[name="tidMode"]')) r.checked = r.value === ov.defaultMode;
$("tidOneId").textContent = ov.oneId;
$("tidAuto").checked = !!ov.autoFirstParty;
$("tidRecoveryText").textContent = ov.mnemonicVault
? "Your IDs come back from your vault's recovery phrase on any device. The recovery key below does the same job if you keep it instead."
: "Your vault was made without a recovery phrase, so only this key (or a backup of the vault file with its master password) brings your IDs back. Write it down somewhere safe.";
renderProjects(ov.projects || []);
}
function renderProjects(list) {
const box = $("tidProjects");
box.replaceChildren();
if (!list.length) { box.append(el("div", { class: "cempty", style: "padding:12px 0" }, "No projects yet. When a site asks you to sign in with Theseus ID, it shows up here.")); return; }
for (const p of list) {
const other = p.mode === "one" ? "project" : "one";
const act = async (fn, after) => {
try { await unwrap(fn()); } catch (e) { alert(e.message); }
if (after) after();
refresh();
};
const row = el("div", { class: "row", style: "flex-direction:column;align-items:stretch;gap:6px" },
el("div", { style: "display:flex;justify-content:space-between;gap:12px;align-items:baseline;flex-wrap:wrap" },
el("div", {}, el("b", {}, p.name), " ", el("span", { class: "pmuted", style: "font-size:12px" }, p.projectId, p.firstParty ? " · Silent Mode" : "")),
el("span", { class: "pmuted", style: "font-size:12px" }, "last used ", when(p.lastUsed))),
el("div", { class: "pmuted", style: "font-size:12.5px" },
p.mode === "one" ? "Uses your One ID " : "Uses an ID only this project sees ",
el("code", { title: p.account }, short(p.account)),
p.origins && p.origins.length ? ` · from ${p.origins.join(", ")}` : ""),
p.moving ? el("div", { style: "font-size:12.5px;color:var(--acid-text)" },
`Moving to ${short(p.moving.to.account)} — ${p.name} confirms the move the next time you sign in. `,
el("button", { class: "btn ghost", type: "button", style: "padding:2px 8px;font-size:12px", onclick: () => act(() => C.tidCancelMove(p.projectId)) }, "Cancel move")) : null,
el("div", { style: "display:flex;gap:6px;flex-wrap:wrap;align-items:center" },
el("label", { class: "polrow", style: "margin-right:auto" },
(() => { const c = el("input", { type: "checkbox" }); c.checked = p.always; c.addEventListener("change", () => act(() => C.tidSetAlways(p.projectId, c.checked))); return c; })(),
el("span", {}, "Always sign me in")),
el("button", { class: "btn ghost", type: "button", title: p.supportsMove ? "" : `${p.name} can't move accounts to a new ID yet`,
...(p.supportsMove ? {} : { disabled: "" }),
onclick: () => { if (confirm(`Move ${p.name} to ${other === "one" ? "your One ID" : "an ID only it sees"}? Your account there moves with you: the next sign-in proves both IDs are yours.`)) act(() => C.tidMove(p.projectId, { mode: other })); } },
other === "one" ? "Use my One ID" : "Use a private ID"),
el("button", { class: "btn ghost", type: "button", ...(p.supportsMove ? {} : { disabled: "" }),
onclick: () => { if (confirm(`Give ${p.name} a brand-new ID? Do this if you think this ID's key leaked. Your account moves to the new ID at the next sign-in.`)) act(() => C.tidRotate(p.projectId)); } }, "New ID"),
el("button", { class: "btn ghost", type: "button",
onclick: () => { if (confirm(`Stop signing in to ${p.name}? Theseus forgets this project and won't sign you in there again until you approve it. Your account at ${p.name} still exists.`)) act(() => C.tidRevoke(p.projectId)); } }, "Revoke")));
box.append(row);
}
}
for (const r of document.querySelectorAll('input[name="tidMode"]')) r.addEventListener("change", async () => {
try { await unwrap(C.tidSetDefaultMode(r.value)); } catch (e) { alert(e.message); }
const using = (view && view.projects || []).filter((p) => p.mode !== r.value);
if (using.length) alert(`New projects will use ${r.value === "one" ? "your One ID" : "a private ID each"}. The ${using.length} project${using.length === 1 ? "" : "s"} you already use keep their IDs; change them one by one below.`);
refresh();
});
$("tidAuto").addEventListener("change", async () => { try { await unwrap(C.tidSetAuto($("tidAuto").checked)); } catch (e) { alert(e.message); } refresh(); });
$("tidCopyOne").onclick = async () => { try { await navigator.clipboard.writeText($("tidOneId").textContent); $("tidCopyOne").textContent = "Copied"; setTimeout(() => ($("tidCopyOne").textContent = "Copy"), 1500); } catch {} };
$("tidUnlockBtn").onclick = async () => { await C.tidUnlock(); refresh(); };
$("tidRecoveryBtn").onclick = async () => {
try {
const key = await unwrap(C.tidRecoveryKey());
$("tidRecoveryKey").textContent = key;
$("tidRecoveryOut").hidden = false;
} catch (e) { if (e.code !== "denied") alert(e.message); }
};
// The recovery key never stays on screen once the user leaves the page.
document.addEventListener("section", (e) => {
$("tidRecoveryOut").hidden = true; $("tidRecoveryKey").textContent = "";
if (e.detail === "theseusid") refresh();
});
if (location.hash === "#theseusid") refresh();
})();
// ---- Add-ons management ----
const addonsList = document.getElementById("addonsList");
// 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>';
}
// ---- 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.",
"screen-capture": "Can ask to record a screen or window — including other applications — and save it to Downloads. You pick the source each time.",
"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.",
"tab-control": "Can open, read and operate your web tabs for a program on this computer that you connect to it, on sites you approve one by one.",
};
// Extensions list ordering rules:
// HIDDEN_IDS — Hermes and Pithos are Theseus native apps; they appear in
// the Sidebar settings card instead of here.
// LAST_IDS — docx-editor and pdf-editor sort last within each group so
// the smaller utilities (Notepad, Screenshot, Translate, …)
// stay near the top of the list.
const ADDONS_HIDDEN_IDS = new Set(["hermes", "pithos"]);
const ADDONS_LAST_IDS = ["docx-editor", "pdf-editor"];
function addonSortLast(a, b) {
const ai = ADDONS_LAST_IDS.indexOf(a.id);
const bi = ADDONS_LAST_IDS.indexOf(b.id);
const ar = ai < 0 ? -1 : ai;
const br = bi < 0 ? -1 : bi;
return ar - br;
}
function renderAddons(snap) {
lastAddonSnap = snap;
// Plug-ins (Aegis, category "plugin") have their own cards under Plug-ins.
const items = ((snap && snap.installed) || [])
.filter((a) => a.category !== "plugin" && !ADDONS_HIDDEN_IDS.has(a.id));
if (!items.length) {
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>';
return;
}
const rowFor = (a) => {
if (a.error) {
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>';
}
const s = addonShortStatus(a);
const st = addonUpdates.staged[a.id];
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>'
+ (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>'
+ '<button class="xmore" data-more="' + escapeAttr(a.id) + '" title="More options" aria-label="More options">⋯</button></span>'
+ '</div>';
};
// Stable sort inside each bucket keeps registration order; the compare
// only moves ADDONS_LAST_IDS to the end of their bucket.
const enabled = items.filter((a) => !a.error && a.enabled).sort(addonSortLast);
const disabled = items.filter((a) => !a.error && !a.enabled).sort(addonSortLast);
const failed = items.filter((a) => a.error);
let html = "";
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("");
addonsList.innerHTML = html;
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); });
});
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(); });
});
root.querySelectorAll('button[data-reveal]').forEach((btn) => btn.addEventListener("click", (e) => { e.stopPropagation(); C.revealAddon(btn.dataset.reveal); }));
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;
}));
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";
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>');
box.innerHTML =
'<div class="xhead"><button class="xback" id="xback" title="Back to the list" aria-label="Back">‹ Back</button>'
+ '<span class="xi">' + addonIconHtml(a.icon) + '</span>'
+ '<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>'
+ '<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>'
+ '<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>'
+ '<div class="xdt">'
+ '<div class="xr"><span class="k">Updates</span><span class="v">' + upd + '</span></div>'
+ '<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;
document.getElementById("xclose").onclick = closeAddonDetail;
const rm = document.getElementById("xremove"); if (rm) rm.onclick = () => removeAddon(a);
wireAddonControls(box);
}
document.addEventListener("keydown", (e) => { if (e.key === "Escape" && addonDetailId && !addonMenuEl) closeAddonDetail(); });
// 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.
function escapeHtml(s) { return String(s || "").replace(/[&<>"']/g, (c) => ({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;" })[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); }
}
// 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 };
}
document.getElementById("addonsReload").addEventListener("click", async () => {
await C.reloadAddons(); await loadAddonUpdates(); loadAddons();
});
document.getElementById("addonsOpenDir").addEventListener("click", () => C.openAddonsDir());
// Apps: sites installed as apps (webapps.js). Until this page existed the
// only way to see or remove one was the address-bar chip while on the site,
// or the app window's own menu. The list is small, so it is simply re-read
// whenever the page is shown or main says the set changed.
(function () {
const A = window.cfg;
const listEl = document.getElementById("appsList"), emptyEl = document.getElementById("appsEmpty");
if (!listEl || !emptyEl || !A || !A.webapps) return;
const escA = (s) => String(s == null ? "" : s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
async function renderApps() {
let apps = []; try { apps = (await A.webapps()) || []; } catch {}
emptyEl.hidden = apps.length > 0;
listEl.innerHTML = apps.map((a) => {
let when = ""; try { if (a.installedAt) when = new Date(a.installedAt).toLocaleDateString(); } catch {}
return `<div class="webapp" data-key="${escA(a.key)}">` +
(a.icon ? `<img src="${escA(a.icon)}" alt="">` : `<span class="ph">🧩</span>`) +
`<div class="nm"><b>${escA(a.name)}</b><span>${escA(a.host)}${when ? " · installed " + escA(when) : ""}${a.open ? " · open now" : ""}</span></div>` +
`<button class="btn" data-act="open">Open</button>` +
`<button class="btn" data-act="sidebar" title="Also open it in the left sidebar panel">Add to sidebar</button>` +
`<button class="btn rm" data-act="remove">Remove…</button></div>`;
}).join("");
}
listEl.addEventListener("click", async (e) => {
const b = e.target.closest("button[data-act]"); if (!b) return;
const key = b.closest(".webapp").dataset.key;
if (b.dataset.act === "open") { await A.webappOpen(key); renderApps(); }
else if (b.dataset.act === "sidebar") {
const app = ((await A.webapps()) || []).find((x) => x.key === key);
if (!app || !app.startUrl) return;
const links = ((await A.get()).quickLinks || []).slice();
const host = (u) => { try { return new URL(u).host; } catch { return ""; } };
if (!links.some((L) => host(L.url) === host(app.startUrl))) {
links.push({ id: "app-" + String(key).replace(/[^a-z0-9]+/gi, "-").slice(0, 32), url: app.startUrl, title: app.name || host(app.startUrl) });
await A.set("quickLinks", links);
}
b.textContent = "In the sidebar"; b.disabled = true;
}
else { await A.webappRemove(key); renderApps(); }
});
document.addEventListener("section", (e) => { if (String(e.detail) === "apps") renderApps(); });
if (A.onWebappsChanged) A.onWebappsChanged(renderApps);
renderApps();
})();
document.querySelector('.side a[data-sec="addons"]').addEventListener("click", async () => {
await loadAddonUpdates(); loadAddons();
});
// 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();
});
// (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.)
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…";
status.innerHTML = "";
try {
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 {
// 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.";
}
}
await loadAddonUpdates();
loadAddons();
} catch (e) {
status.textContent = "Check failed: " + (e?.message || e);
} finally {
btn.disabled = false; btn.textContent = orig;
}
});
// Populate on first paint so the tab is ready when the user clicks in.
(async () => { await loadAddonUpdates(); loadAddons(); })();
// Names & Certificates (0.3.95 — UI shell only; adapters land in 0.3.96–0.3.99).
// The toggles + Advanced fields persist to %APPDATA%\Theseus\extra-resolvers.json
// via main's extra-resolvers-get/set IPC. Nothing resolves yet; this is the
// user's chance to pre-configure defaults before each adapter ships.
(function () {
if (!C || !C.resolversGet || !C.resolversSet) return; // older preload — skip
const el = (i) => document.getElementById(i);
const BINDS = [
// id key type
["rvEnsOn", "resolvers.ens.enabled", "bool"],
["rvEnsRpc", "resolvers.ens.rpc", "text"],
["rvEnsGateway", "resolvers.ens.gateway", "text"],
["rvNmcOn", "resolvers.namecoin.enabled", "bool"],
["rvNmcRpc", "resolvers.namecoin.rpc", "text"],
["rvHnsOn", "resolvers.handshake.enabled", "bool"],
["rvHnsEndpoint", "resolvers.handshake.endpoint", "text"],
["rvHnsCollisionPolicy", "resolvers.handshake.collision_policy", "text"],
["rvSnsOn", "resolvers.sns.enabled", "bool"],
["rvSnsRpc", "resolvers.sns.rpc", "text"],
["rvSnsGateway", "resolvers.sns.gateway", "text"],
["rvUdOn", "resolvers.unstoppable.enabled","bool"],
["rvUdEndpoint", "resolvers.unstoppable.endpoint","text"],
["rvUdGateway", "resolvers.unstoppable.gateway","text"],
["caRuOn", "ca_bundles.ru_national.enabled","bool"],
["caRuScope", "ca_bundles.ru_national.scope_tlds","csv"],
];
function dig(obj, path) {
return path.split(".").reduce((o, k) => (o == null ? o : o[k]), obj);
}
function hydrate(state) {
for (const [id, key, type] of BINDS) {
const node = el(id); if (!node) continue;
const v = dig(state, key);
if (type === "bool") node.checked = !!v;
else if (type === "csv") node.value = Array.isArray(v) ? v.join(", ") : (v || "");
else node.value = v != null ? String(v) : "";
}
}
function readValue(id, type) {
const node = el(id); if (!node) return null;
if (type === "bool") return !!node.checked;
if (type === "csv") return node.value.split(/\s*,\s*/).map((s) => s.trim()).filter(Boolean);
return node.value;
}
async function save(id, key, type) {
const value = readValue(id, type);
try { await C.resolversSet(key, value); } catch (e) { console.warn("resolversSet failed", key, e); }
}
// Fire-once wiring. Load current state when the section first shows,
// persist each change through resolversSet.
let wired = false;
async function ensureWired() {
if (wired) return; wired = true;
try {
const state = await C.resolversGet();
hydrate(state || {});
} catch (e) { console.warn("resolversGet failed", e); }
for (const [id, key, type] of BINDS) {
const node = el(id); if (!node) continue;
const evt = type === "bool" ? "change" : "change"; // input events fire too often for text fields
node.addEventListener(evt, () => save(id, key, type));
}
const resetBtn = el("rvReset");
if (resetBtn) {
resetBtn.addEventListener("click", async () => {
if (!confirm("Reset every Names & Certificates toggle to defaults? BNS stays on; everything else goes off and Advanced fields are cleared.")) return;
try {
if (C.resolversReset) await C.resolversReset();
const state = await C.resolversGet();
hydrate(state || {});
} catch (e) { console.warn("resolversReset failed", e); }
});
}
}
document.addEventListener("section", (e) => { if (e.detail === "names") ensureWired(); });
// If the user lands on #names directly, the section event already fired
// during showSection() before this IIFE registered the listener, so we
// kick wiring manually when the page is already visible.
try { if (!document.getElementById("names").hidden) ensureWired(); } catch {}
})();
</script>
</body>
</html>