2361 lines
156 KiB
HTML
2361 lines
156 KiB
HTML
<!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}
|
||
/* 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)}
|
||
.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); }
|
||
}
|
||
</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="passwords">Passwords</a>
|
||
<a data-sec="performance">Performance</a>
|
||
<a data-sec="privacy">Privacy</a>
|
||
<a data-sec="plugins">Plug-ins</a>
|
||
<a data-sec="addons">Extensions</a>
|
||
</nav>
|
||
<div class="content">
|
||
<!-- GENERAL -->
|
||
<section id="general">
|
||
<h1>General</h1>
|
||
<p class="lede">Changes apply immediately and are saved for next time.</p>
|
||
<h2 class="sub" style="border-top:0;padding-top:0;margin-top:0">Startup</h2>
|
||
<div class="row">
|
||
<div class="txt"><div class="t">Open previous windows and tabs</div><div class="d">Restore the tabs from your last session when Theseus starts.</div></div>
|
||
<label class="sw"><input type="checkbox" id="restoreSession"><span class="track"><span class="knob"></span></span></label>
|
||
</div>
|
||
<div class="row" style="flex-direction:column;align-items:stretch;gap:8px">
|
||
<div class="txt">
|
||
<div class="t">Updates</div>
|
||
<div class="d">Theseus checks the release manifest shortly after launch — and retries with backoff if that first attempt is offline — then every 6 hours while it is running. Click below to check right now.</div>
|
||
</div>
|
||
<div id="updStatus" class="pmuted" style="font-size:12.5px">Loading…</div>
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||
<button id="updCheck" class="btn">Check for updates</button>
|
||
</div>
|
||
</div>
|
||
<h2 class="sub">Quick links</h2>
|
||
<p class="subd">Opera-style strip on the left edge with shortcuts to web apps. Click a row's URL to edit. Shows the first letter of the title when there's no icon.</p>
|
||
<div class="row">
|
||
<div class="txt"><div class="t">Show the strip</div><div class="d">A thin vertical column on the left side of every page.</div></div>
|
||
<label class="sw"><input type="checkbox" id="quickLinksShow"><span class="track"><span class="knob"></span></span></label>
|
||
</div>
|
||
<div class="row" style="flex-direction:column;align-items:stretch;gap:8px">
|
||
<div class="txt"><div class="t">Links</div></div>
|
||
<div id="qlList" style="display:flex;flex-direction:column;gap:6px"></div>
|
||
<div style="display:flex;gap:6px;flex-wrap:wrap">
|
||
<input id="qlTitle" type="text" placeholder="Title (e.g. Discord)" style="flex:1;min-width:120px;background:#1b2330;color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:13px">
|
||
<input id="qlUrl" type="text" placeholder="https://discord.com/app" style="flex:2;min-width:200px;background:#1b2330;color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:13px">
|
||
<button id="qlAdd" class="btn">Add</button>
|
||
</div>
|
||
</div>
|
||
|
||
<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>
|
||
<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">
|
||
<div class="txt"><div class="t">Offer to translate</div><div class="d">Light up a translate chip in the address bar when the page's language differs from yours.</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. -->
|
||
<section id="plugins" hidden>
|
||
<h1>Plug-ins</h1>
|
||
<p class="lede">Silent Mode components that live alongside Theseus — each with its own on/off, updates and settings page.</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>
|
||
|
||
<!-- Ariadne's Thread -->
|
||
<div class="row plug-row">
|
||
<div class="txt plug-open" data-go="plugins/ariadne" role="button" tabindex="0" title="Open Ariadne's Thread settings">
|
||
<div class="t">Ariadne's Thread <span class="pmuted" style="font-weight:400">— system-wide resolver</span> <span class="chev">›</span></div>
|
||
<div class="d">Resolves BCDN names for every browser on this machine — not just Theseus.</div>
|
||
<div id="ariadneStatusMain" class="pmuted" style="font-size:12.5px;margin-top:4px">checking…</div>
|
||
</div>
|
||
<div class="plug-ctl">
|
||
<button id="ariadneInstallMain" class="btn small" hidden>Install</button>
|
||
<button id="ariadneUpdateMain" class="btn small" hidden>Update</button>
|
||
<button id="ariadneCheckMain" class="btn small" title="Refresh Ariadne status">Refresh</button>
|
||
<label class="sw" id="ariadneSwWrap" title="Turn Ariadne on/off">
|
||
<input type="checkbox" id="ariadneToggleMain">
|
||
<span class="track"><span class="knob"></span></span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Aegis -->
|
||
<div class="row plug-row">
|
||
<div class="txt plug-open" data-go="plugins/aegis" role="button" tabindex="0" title="Open Aegis settings">
|
||
<div class="t">Aegis <span class="pmuted" style="font-weight:400">— built-in wallet</span> <span class="chev">›</span></div>
|
||
<div class="d">Multi-chain wallet (BCH, BTC, TRX, ETH, SOL, SC, DGB). Ships in the box, updates over-the-air.</div>
|
||
<div id="aegisStatusMain" class="pmuted" style="font-size:12.5px;margin-top:4px">loading…</div>
|
||
</div>
|
||
<div class="plug-ctl">
|
||
<button id="aegisCheckMain" class="btn small">Check for updates</button>
|
||
<button id="aegisApplyMain" class="btn small" hidden>Apply update</button>
|
||
</div>
|
||
</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">
|
||
<div class="txt">
|
||
<div class="t">System-wide resolver</div>
|
||
<div class="d" id="ariadneStatusSub">checking…</div>
|
||
</div>
|
||
<label class="sw" title="Turn Ariadne on/off">
|
||
<input type="checkbox" id="ariadneToggleSub">
|
||
<span class="track"><span class="knob"></span></span>
|
||
</label>
|
||
</div>
|
||
<div id="ariadneMissing" class="pmuted" style="font-size:12.5px;margin:-4px 4px 8px" hidden>
|
||
Ariadne's Thread isn't installed on this machine. Click <b>Install</b> below to run
|
||
the bundled installer, or grab it manually from
|
||
<a href="https://silentmode.st/tools/" target="_blank" rel="noopener">silentmode.st/tools</a>.
|
||
</div>
|
||
<div class="row" style="justify-content:flex-end;gap:8px">
|
||
<button id="ariadneInstallSub" class="btn" hidden>Install</button>
|
||
<button id="ariadneUpdateSub" class="btn" hidden>Update</button>
|
||
<button id="ariadneRefreshSub" class="btn">Refresh</button>
|
||
</div>
|
||
|
||
<h2 class="sub">Collision policy</h2>
|
||
<p class="subd">When a name exists on both BCNR and ICANN, which one wins? Applies machine-wide — every browser sees the same answer. Change is picked up within 5 seconds; no daemon restart, no UAC.</p>
|
||
<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" style="display:flex;align-items:center;justify-content:space-between;gap:12px">
|
||
<div>
|
||
<div class="t">Additional search engines</div>
|
||
<div class="d">These appear in the toolbar dropdown. Drag to reorder. Toggle off to move an engine back to the catalog.</div>
|
||
</div>
|
||
<button id="engAddBtn" class="btn" type="button">+ Add search engine</button>
|
||
</div>
|
||
<div id="engineList"></div>
|
||
<!-- Catalog: hidden until "Add" is clicked. Three tiers, top to bottom:
|
||
1. curated built-ins the user hasn't enabled (tier="catalog")
|
||
2. wider bank filtered by a search box (tier="extra")
|
||
3. custom-URL form -->
|
||
<div id="engineCatalog" class="engcat" hidden>
|
||
<div class="ehdr">Add from catalog</div>
|
||
<div id="catalogList"></div>
|
||
<div class="ehdr" style="display:flex;align-items:center;justify-content:space-between;gap:8px">
|
||
<span>Discover more engines</span>
|
||
<input id="engineFilter" type="search" placeholder="Filter by name…" autocomplete="off"
|
||
style="background:#1b2330;color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:4px 8px;font-size:12px;min-width:auto;width:170px">
|
||
</div>
|
||
<div id="extraList"></div>
|
||
<div class="ehdr">Or add a custom URL</div>
|
||
<div class="addeng">
|
||
<input id="engSym" placeholder="🔍" style="max-width:52px;text-align:center;flex:none">
|
||
<input id="engName" placeholder="Name (e.g. My SearXNG)">
|
||
<input id="engUrl" placeholder="https://example.com/search?q=%s">
|
||
<button id="engAdd" class="btn">Add</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
<!-- PASSWORDS -->
|
||
<section id="passwords" hidden>
|
||
<h1>Passwords</h1>
|
||
<p class="lede">Local password vault. Set once, unlocked with a master password. Encrypted at rest; nothing leaves your machine.</p>
|
||
<!-- State A: no vault yet — set up -->
|
||
<div id="pwSetup" hidden>
|
||
<div class="row" style="flex-direction:column;align-items:stretch;gap:10px">
|
||
<div class="txt"><div class="t">Master password</div>
|
||
<div class="d">Used to unlock the vault every session. This is separate from your Ariadne wallet passphrase — memorize it, we can't recover it.</div></div>
|
||
<div class="addeng"><input id="pwSetupPw1" type="password" placeholder="Master password"><input id="pwSetupPw2" type="password" placeholder="Confirm"></div>
|
||
</div>
|
||
<div class="row" style="flex-direction:column;align-items:stretch;gap:10px">
|
||
<div class="txt"><div class="t">Seed for deterministic passwords</div>
|
||
<div class="d">The "Generate" button in an entry derives a password from this seed. Same seed on another device → same passwords for the same site + username.</div></div>
|
||
<div style="display:flex;flex-direction:column;gap:6px">
|
||
<label class="polrow"><input type="radio" name="pwSeedSource" value="mnemonic" checked><span><b>Use my Ariadne wallet mnemonic</b> <span class="pmuted">— unified identity, one seed to back up</span></span></label>
|
||
<label class="polrow"><input type="radio" name="pwSeedSource" value="generate"><span><b>Generate a new independent seed</b> <span class="pmuted">— isolated from any BCH funds</span></span></label>
|
||
</div>
|
||
<textarea id="pwSetupMnemonic" placeholder="12 or 24 BIP39 words separated by spaces" rows="3" style="background:#1b2330;color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:8px 10px;font-size:13px;font-family:ui-monospace,monospace;outline:none;resize:vertical"></textarea>
|
||
<div class="pmuted" style="font-size:12px">The mnemonic is used only to derive the password-purpose subtree (m/1381'/0'). It is not stored — only the derived subtree key is persisted, encrypted with your master password.</div>
|
||
</div>
|
||
<div class="row" style="justify-content:flex-end">
|
||
<button id="pwSetupBtn" class="btn" type="button">Create vault</button>
|
||
</div>
|
||
</div>
|
||
<!-- State B: vault exists but locked -->
|
||
<div id="pwLocked" hidden>
|
||
<div class="row">
|
||
<div class="txt"><div class="t">Unlock vault</div><div class="d">Enter your master password to view or add entries.</div></div>
|
||
<div class="ctl" style="align-items:stretch"><input id="pwUnlockPw" type="password" placeholder="Master password"><button id="pwUnlockBtn" class="btn" type="button">Unlock</button></div>
|
||
</div>
|
||
<div id="pwUnlockErr" class="pmuted" style="color:#f6768a;font-size:12.5px;margin-top:4px" hidden></div>
|
||
</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>
|
||
<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">There is no autofill yet (phase 2). Copy the password from an entry and paste it into the site.</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>
|
||
</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>
|
||
|
||
<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">Clear history on quit</div><div class="d">Drops navigation history + the saved-tabs session file (overrides "Reopen previous tabs").</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">There is no persistent password manager — passwords are never stored to disk regardless of these toggles. Use a dedicated password manager (Bitwarden, KeePass, etc.).</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="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>
|
||
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 ? `You're on v${v}.` : "Version unavailable — try Check for updates.";
|
||
}).catch((e) => {
|
||
updStatus.textContent = "Could not read version: " + (e?.message || e) + " — try Check for updates.";
|
||
});
|
||
} else {
|
||
updStatus.textContent = "This build is missing the version IPC — restart Theseus to refresh the panel.";
|
||
}
|
||
if (updCheck) updCheck.onclick = async () => {
|
||
const orig = updCheck.textContent;
|
||
updCheck.disabled = true; updCheck.textContent = "Checking…";
|
||
updStatus.textContent = "Checking…";
|
||
try {
|
||
if (!C || typeof C.recheckUpdate !== "function") throw new Error("recheck-update IPC not exposed");
|
||
const r = await C.recheckUpdate();
|
||
if (r && r.updateAvailable && r.updateAvailable.version) {
|
||
updStatus.innerHTML = `<b style="color:var(--acid-text)">v${r.updateAvailable.version}</b> is available — the update chip in the toolbar will offer it.`;
|
||
} else {
|
||
updStatus.textContent = `You're on the latest (v${(r && r.currentVersion) || "?"}).`;
|
||
}
|
||
} catch (e) { updStatus.textContent = "Check failed: " + (e?.message || e); }
|
||
finally { updCheck.disabled = false; updCheck.textContent = orig; }
|
||
};
|
||
} catch (e) {
|
||
updStatus.textContent = "Updates panel init failed: " + (e?.message || e);
|
||
console.error("Updates panel init failed:", e);
|
||
}
|
||
})();
|
||
// 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()));
|
||
});
|
||
})();
|
||
const TOGGLES = ["restoreSession", "backgroundThrottle", "freezeBackgroundTabs", "blockCamera", "blockMicrophone", "hideMediaDevices", "gpc",
|
||
"clearCookiesOnQuit", "clearCacheOnQuit", "clearStorageOnQuit", "clearHistoryOnQuit",
|
||
"quickLinksShow", "translateAutoOffer"];
|
||
// Text inputs that round-trip through settings-set on change. Trimmed; a
|
||
// cleared field writes an empty string, which main re-defaults from
|
||
// SETTINGS_DEFAULTS on next launch.
|
||
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) => ({ "&":"&","<":"<",">":">","\"":""","'":"'" })[c]);
|
||
function render(links) {
|
||
list.innerHTML = "";
|
||
if (!links.length) {
|
||
const empty = document.createElement("div");
|
||
empty.className = "pmuted"; empty.style.fontSize = "12.5px"; empty.textContent = "No links yet — add one below.";
|
||
list.appendChild(empty);
|
||
return;
|
||
}
|
||
for (const L of links) {
|
||
const row = document.createElement("div");
|
||
row.style.cssText = "display:flex;align-items:center;gap:8px;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:8px 12px";
|
||
row.innerHTML = `<div style="width:28px;height:28px;border-radius:6px;background:rgba(255,255,255,.06);display:grid;place-items:center;font-weight:600;color:var(--acid-text);flex:none">${esc((L.title || "?").slice(0,1).toUpperCase())}</div>
|
||
<div style="flex:1;min-width:0"><div style="font-weight:600">${esc(L.title || L.url)}</div><div style="font-size:12px;color:var(--mut);word-break:break-all">${esc(L.url)}</div></div>
|
||
<button class="btn ghost" data-del="${esc(L.id)}">Remove</button>`;
|
||
list.appendChild(row);
|
||
}
|
||
list.querySelectorAll("[data-del]").forEach((b) => b.onclick = async () => {
|
||
const cur = (await C.get()).quickLinks || [];
|
||
C.set("quickLinks", cur.filter((x) => x.id !== b.dataset.del));
|
||
});
|
||
}
|
||
render(s.quickLinks || []);
|
||
addBtn.onclick = async () => {
|
||
const t = String(titleIn.value || "").trim();
|
||
const u = String(urlIn.value || "").trim();
|
||
if (!u) return;
|
||
const url = /^https?:\/\//i.test(u) ? u : "https://" + u;
|
||
let title = t;
|
||
if (!title) { try { title = new URL(url).host.replace(/^www\./, ""); } catch { title = url; } }
|
||
const cur = (await C.get()).quickLinks || [];
|
||
const id = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 24) + "-" + Date.now().toString(36).slice(-4);
|
||
C.set("quickLinks", [...cur, { id, url, title }]);
|
||
titleIn.value = ""; urlIn.value = "";
|
||
};
|
||
// Live-sync from main (edits from other surfaces, or the strip's own add).
|
||
if (C.onSettingsUpdate) C.onSettingsUpdate((next) => render(next.quickLinks || []));
|
||
})();
|
||
// ---- Translation-peer list editor (General) ---------------------------
|
||
// Same shape as the Quick-links editor above: add a row, trash a row,
|
||
// each change writes the whole array. The chip + translator service read
|
||
// the list in order and skip dead peers; the top entry is the primary.
|
||
(function () {
|
||
const list = document.getElementById("translateEndpointList");
|
||
const urlIn = document.getElementById("translatePeerUrl");
|
||
const addBtn = document.getElementById("translatePeerAdd");
|
||
if (!list || !addBtn) return;
|
||
const esc = (v) => String(v || "").replace(/[&<>"']/g, (c) => ({ "&":"&","<":"<",">":">","\"":""","'":"'" })[c]);
|
||
function hostOf(u) { try { return new URL(u).host; } catch { return u; } }
|
||
function render(peers) {
|
||
list.innerHTML = "";
|
||
if (!peers.length) {
|
||
const empty = document.createElement("div");
|
||
empty.className = "pmuted"; empty.style.fontSize = "12.5px";
|
||
empty.textContent = "No peers configured — add one below, or restart to restore the defaults.";
|
||
list.appendChild(empty);
|
||
return;
|
||
}
|
||
for (let i = 0; i < peers.length; i++) {
|
||
const url = peers[i];
|
||
const row = document.createElement("div");
|
||
row.style.cssText = "display:flex;align-items:center;gap:8px;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:8px 12px";
|
||
const primary = i === 0 ? '<span style="font-size:11px;color:var(--acid-text);background:rgba(214,255,61,.08);border:1px solid rgba(214,255,61,.3);border-radius:6px;padding:2px 6px;margin-right:6px">PRIMARY</span>' : '';
|
||
row.innerHTML = `<div style="flex:1;min-width:0"><div style="font-weight:600">${primary}${esc(hostOf(url))}</div><div style="font-size:12px;color:var(--mut);word-break:break-all">${esc(url)}</div></div>
|
||
<button class="btn ghost" data-del="${i}">Remove</button>`;
|
||
list.appendChild(row);
|
||
}
|
||
list.querySelectorAll("[data-del]").forEach((b) => b.onclick = async () => {
|
||
const i = Number(b.dataset.del);
|
||
const cur = (await C.get()).translateEndpoints || [];
|
||
C.set("translateEndpoints", cur.filter((_, idx) => idx !== i));
|
||
});
|
||
}
|
||
render(s.translateEndpoints || []);
|
||
addBtn.onclick = async () => {
|
||
const u = String(urlIn.value || "").trim();
|
||
if (!u) return;
|
||
const url = /^https?:\/\//i.test(u) || /^bns:\/\//i.test(u) ? u : "https://" + u;
|
||
const cur = (await C.get()).translateEndpoints || [];
|
||
if (cur.includes(url)) { urlIn.value = ""; return; }
|
||
C.set("translateEndpoints", [...cur, url]);
|
||
urlIn.value = "";
|
||
};
|
||
if (C.onSettingsUpdate) C.onSettingsUpdate((next) => render(next.translateEndpoints || []));
|
||
})();
|
||
// ---- 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 {}
|
||
// Build options: "Automatic" first, then the quick list, then "Other…".
|
||
// No BCP-47 tag in visible labels — the tag surfaces only as the 2-letter
|
||
// chip in the URL bar.
|
||
const opts = [`<option value="__auto__">Automatic (${langNameFor(osLoc)})</option>`,
|
||
...WEB_LANG_QUICK.map((L) => `<option value="${L.tag}">${L.label}</option>`),
|
||
`<option value="__other__">Other…</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, "<");
|
||
// 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, "'");
|
||
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>`;
|
||
};
|
||
const ENGINE_KINDS = [
|
||
{ key: "search", label: "Search engines" },
|
||
{ key: "llm", label: "AI answer 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>`;
|
||
const uninstalled = d.engines.filter((e) => e.builtin && !e.installed);
|
||
const catalogOff = uninstalled.filter((e) => (e.tier || "catalog") === "catalog");
|
||
const extraOff = uninstalled.filter((e) => e.tier === "extra");
|
||
if (cat) {
|
||
cat.innerHTML = catalogOff.length
|
||
? catalogOff.map(catRow).join("")
|
||
: `<div class="cempty2">All curated engines are already in your list. Discover more below or add a custom URL.</div>`;
|
||
cat.querySelectorAll(".add").forEach((b) => b.onclick = () => C.setEngineEnabled(b.dataset.add, true).then(renderEngines));
|
||
}
|
||
if (extra) {
|
||
const paintExtras = (q) => {
|
||
const filt = String(q || "").trim().toLowerCase();
|
||
const shown = filt ? extraOff.filter((e) => e.name.toLowerCase().includes(filt)) : extraOff;
|
||
extra.innerHTML = shown.length
|
||
? shown.map(catRow).join("")
|
||
: `<div class="cempty2">${filt ? "No engines match that filter." : "All discoverable engines are already in your list."}</div>`;
|
||
extra.querySelectorAll(".add").forEach((b) => b.onclick = () => C.setEngineEnabled(b.dataset.add, true).then(renderEngines));
|
||
};
|
||
paintExtras(filterInput ? filterInput.value : "");
|
||
if (filterInput && !filterInput.dataset.wired) {
|
||
filterInput.dataset.wired = "1";
|
||
filterInput.addEventListener("input", () => paintExtras(filterInput.value));
|
||
}
|
||
}
|
||
// Toggle: pure on/off in the enabled set — the row stays visible either way.
|
||
list.querySelectorAll('input[type="checkbox"]').forEach((cb) => cb.onchange = () => C.setEngineEnabled(cb.dataset.id, cb.checked).then(renderEngines));
|
||
// Right-click any row → context menu with "Remove from list" (moves a
|
||
// built-in back to the catalog; deletes a custom entirely).
|
||
list.querySelectorAll(".eng").forEach((row) => {
|
||
row.addEventListener("contextmenu", (e) => {
|
||
e.preventDefault();
|
||
openEngineMenu(row, e.clientX, e.clientY);
|
||
});
|
||
});
|
||
// drag-and-drop reorder — same-kind only (dropping a Search engine into
|
||
// the LLM section would just re-group visually on next render, so we
|
||
// reject cross-kind drags outright).
|
||
let dragId = null;
|
||
let dragKind = null;
|
||
const sameKind = (row) => row.dataset.kind === dragKind;
|
||
list.querySelectorAll(".eng").forEach((row) => {
|
||
row.addEventListener("dragstart", (e) => { dragId = row.dataset.id; dragKind = row.dataset.kind; e.dataTransfer.effectAllowed = "move"; row.classList.add("dragging"); });
|
||
row.addEventListener("dragend", () => { row.classList.remove("dragging"); list.querySelectorAll(".eng").forEach((r) => r.classList.remove("over")); });
|
||
row.addEventListener("dragover", (e) => {
|
||
if (!sameKind(row)) { e.dataTransfer.dropEffect = "none"; return; }
|
||
e.preventDefault(); e.dataTransfer.dropEffect = "move";
|
||
if (row.dataset.id !== dragId) row.classList.add("over");
|
||
});
|
||
row.addEventListener("dragleave", () => row.classList.remove("over"));
|
||
row.addEventListener("drop", (e) => {
|
||
e.preventDefault(); row.classList.remove("over");
|
||
if (!dragId || dragId === row.dataset.id || !sameKind(row)) return;
|
||
const ids = [...list.querySelectorAll(".eng")].map((el) => el.dataset.id);
|
||
const from = ids.indexOf(dragId), to = ids.indexOf(row.dataset.id);
|
||
ids.splice(from, 1); ids.splice(to, 0, dragId);
|
||
C.setEngineOrder(ids).then(renderEngines);
|
||
});
|
||
});
|
||
}
|
||
// Floating right-click menu for an engine row. Only one open at a time.
|
||
let ctxOpen = null;
|
||
function closeEngineMenu() { if (ctxOpen) { ctxOpen.remove(); ctxOpen = null; } }
|
||
function openEngineMenu(row, x, y) {
|
||
closeEngineMenu();
|
||
const id = row.dataset.id;
|
||
const builtin = row.dataset.builtin === "1";
|
||
const m = document.createElement("div");
|
||
m.className = "ctxmenu";
|
||
m.innerHTML = `<div class="mi danger" data-act="remove">Remove from list</div>`;
|
||
document.body.appendChild(m);
|
||
// Position, keeping the menu inside the viewport.
|
||
const rect = m.getBoundingClientRect();
|
||
const vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
|
||
m.style.left = Math.min(x, vw - rect.width - 6) + "px";
|
||
m.style.top = Math.min(y, vh - rect.height - 6) + "px";
|
||
m.querySelector('[data-act="remove"]').onclick = () => {
|
||
closeEngineMenu();
|
||
const call = builtin ? C.removeFromList(id) : C.removeEngine(id);
|
||
call.then(renderEngines);
|
||
};
|
||
ctxOpen = m;
|
||
setTimeout(() => {
|
||
const off = (ev) => { if (!m.contains(ev.target)) { closeEngineMenu(); document.removeEventListener("mousedown", off); document.removeEventListener("keydown", esc); } };
|
||
const esc = (ev) => { if (ev.key === "Escape") { closeEngineMenu(); document.removeEventListener("mousedown", off); document.removeEventListener("keydown", esc); } };
|
||
document.addEventListener("mousedown", off);
|
||
document.addEventListener("keydown", esc);
|
||
}, 0);
|
||
}
|
||
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(); } });
|
||
// appearance (theme) — three visual cards: system | light | dark. Any
|
||
// unrecognised saved value falls back to "system" (follow the OS).
|
||
const th = document.getElementById("theme");
|
||
let themeValue = ["system", "light", "dark"].includes(s.theme) ? s.theme : "system";
|
||
const paintTheme = () => th.querySelectorAll(".tc").forEach((b) => {
|
||
const on = b.dataset.val === themeValue;
|
||
b.classList.toggle("on", on);
|
||
b.setAttribute("aria-checked", on ? "true" : "false");
|
||
});
|
||
paintTheme();
|
||
th.querySelectorAll(".tc").forEach((b) => b.onclick = () => {
|
||
themeValue = b.dataset.val; paintTheme(); C.set("theme", themeValue);
|
||
});
|
||
// WebRTC IP policy
|
||
const wm = document.getElementById("webrtcMode");
|
||
wm.value = s.webrtcMode || "public_only";
|
||
wm.onchange = () => C.set("webrtcMode", wm.value);
|
||
// Toolbar sizing dropdowns removed — the toolbar drag-handles do the
|
||
// same job in-place. The stored urlBarSize / searchBoxSize + the drag-
|
||
// set widthPx settings still exist in main; they just have no UI here.
|
||
document.getElementById("engAdd").onclick = () => {
|
||
const name = document.getElementById("engName").value.trim();
|
||
const url = document.getElementById("engUrl").value.trim();
|
||
const sym = document.getElementById("engSym").value.trim();
|
||
if (!name || !url.includes("%s")) { alert("Enter a name and a URL containing %s (where the query goes)."); return; }
|
||
C.addEngine({ name, url, sym }).then((d) => {
|
||
document.getElementById("engName").value = ""; document.getElementById("engUrl").value = ""; document.getElementById("engSym").value = "";
|
||
renderEngines(d);
|
||
});
|
||
};
|
||
// "+ Add search engine" toggles the catalog panel below the enabled list.
|
||
const catBtn = document.getElementById("engAddBtn");
|
||
const catBox = document.getElementById("engineCatalog");
|
||
if (catBtn && catBox) {
|
||
catBtn.onclick = () => {
|
||
const open = catBox.hidden;
|
||
catBox.hidden = !open;
|
||
catBtn.textContent = open ? "− Hide catalog" : "+ Add search engine";
|
||
};
|
||
}
|
||
C.engines().then(renderEngines);
|
||
// anti-fingerprinting mode selectors, with value field(s) shown on "manual"
|
||
const bind = (mode, showValIf, apply) => {
|
||
const m = document.getElementById(mode);
|
||
m.value = s[mode] || "show";
|
||
apply(m.value === "manual");
|
||
m.addEventListener("change", () => { C.set(mode, m.value); apply(m.value === "manual"); });
|
||
};
|
||
// Value fields: <select> for tz + lang manual mode, <select> city for
|
||
// location manual mode. Each has an "Other…" sentinel at the end that
|
||
// reveals a text input so the user can enter a value not in the built-in
|
||
// list (any IANA zone, any BCP-47 locale). selectWithOther handles the
|
||
// round-trip: if a saved value isn't in the predefined options, "Other"
|
||
// is auto-selected on load and the input pre-fills with that value.
|
||
function selectWithOther(selectId, otherInputId, settingsKey = selectId) {
|
||
const sel = document.getElementById(selectId);
|
||
const inp = document.getElementById(otherInputId);
|
||
const saved = s[settingsKey] ?? "";
|
||
const known = new Set([...sel.options].map((o) => o.value).filter((v) => v && v !== "__other__"));
|
||
const isCustom = saved && !known.has(saved);
|
||
sel.value = isCustom ? "__other__" : saved;
|
||
if (isCustom) inp.value = saved;
|
||
const applyVis = () => { inp.hidden = sel.value !== "__other__"; };
|
||
applyVis();
|
||
sel.addEventListener("change", () => {
|
||
if (sel.value === "__other__") { applyVis(); setTimeout(() => inp.focus(), 0); return; }
|
||
C.set(settingsKey, sel.value); applyVis();
|
||
});
|
||
inp.addEventListener("change", () => { const v = String(inp.value).trim(); if (v) C.set(settingsKey, v); });
|
||
return sel;
|
||
}
|
||
const tzV = selectWithOther("timezoneValue", "timezoneValueOther");
|
||
const tzOther = document.getElementById("timezoneValueOther");
|
||
// "Manual" mode reveals both the select AND the Other input (if Other was picked).
|
||
const applyTzVis = (manual) => { tzV.hidden = !manual; tzOther.hidden = !manual || tzV.value !== "__other__"; };
|
||
bind("timezoneMode", null, applyTzVis);
|
||
// 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 = {
|
||
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") {
|
||
const code = REGION_TO_COUNTRY[s.locationRegion] || "DE";
|
||
const [, la, lo] = COUNTRIES[code];
|
||
C.set("locationCountry", code);
|
||
C.set("locationLat", String(la));
|
||
C.set("locationLon", String(lo));
|
||
C.set("locationMode", "manual");
|
||
locCountry.value = code;
|
||
return "manual";
|
||
}
|
||
return s.locationMode || "show";
|
||
})();
|
||
const applyLoc = () => { locCountry.hidden = locMode.value !== "manual"; };
|
||
locMode.value = initialMode;
|
||
applyLoc();
|
||
locMode.addEventListener("change", () => { C.set("locationMode", locMode.value); applyLoc(); });
|
||
locCountry.addEventListener("change", () => {
|
||
const code = locCountry.value;
|
||
const c = COUNTRIES[code]; if (!c) return;
|
||
C.set("locationCountry", code);
|
||
C.set("locationLat", String(c[1]));
|
||
C.set("locationLon", String(c[2]));
|
||
});
|
||
|
||
// 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);
|
||
};
|
||
|
||
// ---- Developer tools dock position --------------------------------------
|
||
// The active radio reflects the current setting; changing it just calls
|
||
// C.set — the F12 handler in main.js reads settings.devToolsDock each
|
||
// time DevTools opens, so no restart is needed for the choice to apply.
|
||
const devToolsDock = s.devToolsDock || "bottom";
|
||
document.querySelectorAll('input[name="devToolsDock"]').forEach((r) => {
|
||
r.checked = (r.value === devToolsDock);
|
||
r.addEventListener("change", () => { if (r.checked) C.set("devToolsDock", r.value); });
|
||
});
|
||
|
||
// ---- 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/<host> on index miss, 30-s LRU" },
|
||
diskCache: { label: "Disk cache", hint: "C:\\ProgramData\\Ariadne\\bns-name-snapshot.json — read at boot, written on any HTTPS win" },
|
||
localApi: { label: "Local BNS indexer API", hint: "http://127.0.0.1/api/tlds, /api/name/:n, /api/registry, /api/status" },
|
||
};
|
||
function fmtTime(iso) {
|
||
if (!iso) return "never";
|
||
try { const d = new Date(iso); const s = Math.round((Date.now() - d.getTime()) / 1000); return s < 60 ? `${s} s ago` : s < 3600 ? `${Math.round(s/60)} m ago` : d.toLocaleString(); } catch { return String(iso); }
|
||
}
|
||
function sourceState(name, st) {
|
||
if (!st) return { text: "no state yet", ok: false, err: false };
|
||
if (name === "diskCache") return { text: st.exists ? `${(st.size||0).toLocaleString()} B · ${fmtTime(st.mtime)}` : "no file yet", ok: !!st.exists, err: false };
|
||
if (name === "localApi") return { text: st.enabled ? "listening" : "disabled", ok: !!st.enabled, err: false };
|
||
if (name === "perQueryLookup") return { text: `${st.hits||0} hit · ${st.misses||0} miss · ${st.errors||0} err · last ${fmtTime(st.lastAttempt)}`, ok: (st.hits||0) > 0 || !st.lastAttempt, err: (st.errors||0) > 0 };
|
||
if (st.lastError) return { text: `error: ${st.lastError.slice(0, 80)} · last ${fmtTime(st.lastAttempt)}`, ok: false, err: true };
|
||
if (st.lastSuccess) return { text: `ok · last success ${fmtTime(st.lastSuccess)}` + (st.refreshes ? ` · ${st.refreshes} refresh${st.refreshes>1?"es":""}` : ""), ok: true, err: false };
|
||
return { text: `waiting · last try ${fmtTime(st.lastAttempt)}`, ok: false, err: false };
|
||
}
|
||
let arLastStatus = null;
|
||
async function refreshAriadneStatus() {
|
||
arStatusJson.textContent = "Loading…";
|
||
arSourcesBody.innerHTML = "";
|
||
const r = await C.ariadneGetStatus();
|
||
if (!r || !r.ok) {
|
||
arStatusJson.textContent = `Cannot reach the daemon: ${r?.error || "unknown error"}\n\nIs Ariadne's Thread running? Turn it on from the Status section above.`;
|
||
arSourcesBody.innerHTML = '<div class="asrc-row"><span class="aname" style="grid-column:1 / -1;color:var(--muted)">Daemon unreachable — start it from Status above.</span></div>';
|
||
return;
|
||
}
|
||
arLastStatus = r.status;
|
||
arStatusJson.textContent = JSON.stringify(r.status, null, 2);
|
||
const sources = r.status.sources || {};
|
||
arSourcesBody.innerHTML = "";
|
||
for (const name of Object.keys(SOURCE_DESCRIPTIONS)) {
|
||
const d = SOURCE_DESCRIPTIONS[name];
|
||
const st = sources[name] || {};
|
||
const s = sourceState(name, st);
|
||
const row = document.createElement("div");
|
||
row.className = "asrc-row";
|
||
row.innerHTML = `<span class="aname">${d.label}<small>${d.hint}</small></span>` +
|
||
`<span><input type="checkbox" data-src="${name}" ${st.enabled ? "checked" : ""}></span>` +
|
||
`<span class="astate ${s.ok ? "ok" : s.err ? "err" : ""}">${s.text}</span>`;
|
||
arSourcesBody.appendChild(row);
|
||
}
|
||
// Wire the enable checkboxes AFTER they're in the DOM.
|
||
arSourcesBody.querySelectorAll('input[type="checkbox"][data-src]').forEach((cb) => {
|
||
cb.addEventListener("change", async () => {
|
||
cb.disabled = true;
|
||
const w = await C.ariadneSetSource(cb.dataset.src, cb.checked);
|
||
cb.disabled = false;
|
||
if (!w?.ok) { alert(`Could not save: ${w?.error || "unknown error"}`); cb.checked = !cb.checked; return; }
|
||
// Poll again after the daemon's 5-s hot-reload tick so state text catches up.
|
||
setTimeout(refreshAriadneStatus, 6000);
|
||
});
|
||
});
|
||
}
|
||
async function loadAriadnePolicy() {
|
||
const r = await C.ariadneGetPolicy();
|
||
const p = (r?.policy?.policy || "bcnr-first").toLowerCase();
|
||
arPolicyRadios.forEach((rb) => { rb.checked = (rb.value === p); });
|
||
}
|
||
arPolicyRadios.forEach((rb) => {
|
||
rb.addEventListener("change", async () => {
|
||
if (!rb.checked) return;
|
||
const w = await C.ariadneSetPolicy(rb.value);
|
||
if (!w?.ok) alert(`Could not save policy: ${w?.error || "unknown error"}`);
|
||
});
|
||
});
|
||
arStatusRefr.onclick = refreshAriadneStatus;
|
||
arStatusCopy.onclick = async () => {
|
||
if (!arLastStatus) return;
|
||
try { await navigator.clipboard.writeText(JSON.stringify(arLastStatus, null, 2)); arStatusCopy.textContent = "Copied"; setTimeout(() => (arStatusCopy.textContent = "Copy report"), 1500); }
|
||
catch { arStatusCopy.textContent = "Copy failed"; setTimeout(() => (arStatusCopy.textContent = "Copy report"), 1500); }
|
||
};
|
||
// Load once on script boot; also every time the user navigates INTO the
|
||
// Ariadne sub-page (fresh state, no cached-stale JSON on re-entry).
|
||
loadAriadnePolicy(); refreshAriadneStatus();
|
||
document.addEventListener("section", (e) => {
|
||
if (e.detail === "plugins/ariadne") { loadAriadnePolicy(); refreshAriadneStatus(); }
|
||
});
|
||
|
||
// 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 pwRefresh() {
|
||
const st = await C.pwStatus();
|
||
if (!st.setup) return pwShow("setup");
|
||
if (!st.unlocked) return pwShow("locked");
|
||
pwShow("unlocked");
|
||
const res = await C.pwList();
|
||
renderPwList(res.ok ? res.entries : []);
|
||
}
|
||
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 pwShow" title="Show + copy">👁</button>` +
|
||
`<button class="cx pwDel" title="Remove">✕</button>` +
|
||
`</div>`).join("");
|
||
pwListEl.querySelectorAll(".pwShow").forEach((b) => b.onclick = async (ev) => {
|
||
const id = ev.target.closest(".eng").dataset.id;
|
||
const res = await C.pwGet(id);
|
||
if (!res.ok) return alert("Couldn't read: " + res.err);
|
||
try { await navigator.clipboard.writeText(res.password); }
|
||
catch { /* browser may block clipboard in dev — fall through to a prompt */ prompt("Password (copy manually):", res.password); return; }
|
||
b.textContent = "copied ✓"; setTimeout(() => (b.textContent = "👁"), 1600);
|
||
});
|
||
pwListEl.querySelectorAll(".pwDel").forEach((b) => b.onclick = async (ev) => {
|
||
const id = ev.target.closest(".eng").dataset.id;
|
||
if (!confirm("Remove this entry?")) return;
|
||
await C.pwRemove(id); pwRefresh();
|
||
});
|
||
}
|
||
// Setup — create vault
|
||
document.querySelectorAll('input[name="pwSeedSource"]').forEach((r) => r.addEventListener("change", () => {
|
||
document.getElementById("pwSetupMnemonic").hidden = document.querySelector('input[name="pwSeedSource"]:checked').value !== "mnemonic";
|
||
}));
|
||
document.getElementById("pwSetupBtn").onclick = async () => {
|
||
const p1 = document.getElementById("pwSetupPw1").value;
|
||
const p2 = document.getElementById("pwSetupPw2").value;
|
||
if (!p1 || p1.length < 8) return alert("Master password must be at least 8 characters.");
|
||
if (p1 !== p2) return alert("Passwords don't match.");
|
||
const kind = document.querySelector('input[name="pwSeedSource"]:checked').value;
|
||
const seedSource = kind === "mnemonic"
|
||
? { kind: "mnemonic", mnemonic: document.getElementById("pwSetupMnemonic").value }
|
||
: { kind: "generate" };
|
||
if (kind === "mnemonic" && !seedSource.mnemonic.trim()) return alert("Paste your mnemonic or switch to 'Generate a new independent seed'.");
|
||
const res = await C.pwSetup(p1, seedSource);
|
||
if (!res.ok) return alert("Setup failed: " + res.err);
|
||
// Vault created AND unlocked by main. Clear the setup fields.
|
||
document.getElementById("pwSetupPw1").value = "";
|
||
document.getElementById("pwSetupPw2").value = "";
|
||
document.getElementById("pwSetupMnemonic").value = "";
|
||
pwRefresh();
|
||
};
|
||
// Unlock
|
||
document.getElementById("pwUnlockBtn").onclick = async () => {
|
||
const err = document.getElementById("pwUnlockErr");
|
||
err.hidden = true;
|
||
const pw = document.getElementById("pwUnlockPw").value;
|
||
const res = await C.pwUnlock(pw);
|
||
if (!res.ok) { err.textContent = res.err; err.hidden = false; return; }
|
||
document.getElementById("pwUnlockPw").value = "";
|
||
pwRefresh();
|
||
};
|
||
document.getElementById("pwUnlockPw").addEventListener("keydown", (e) => { if (e.key === "Enter") document.getElementById("pwUnlockBtn").click(); });
|
||
// Lock
|
||
document.getElementById("pwLockBtn").onclick = async () => { await C.pwLock(); pwRefresh(); };
|
||
// Add-entry form: toggle literal input; wire preview + save
|
||
document.querySelectorAll('input[name="pwAddKind"]').forEach((r) => r.addEventListener("change", () => {
|
||
const kind = document.querySelector('input[name="pwAddKind"]:checked').value;
|
||
document.getElementById("pwAddLiteral").hidden = kind !== "literal";
|
||
document.getElementById("pwAddPreview").hidden = kind !== "generated";
|
||
document.getElementById("pwAddPreviewOut").hidden = kind !== "generated";
|
||
}));
|
||
document.getElementById("pwAddPreview").onclick = async () => {
|
||
const domain = document.getElementById("pwAddDomain").value.trim();
|
||
const username = document.getElementById("pwAddUser").value.trim();
|
||
if (!domain) return alert("Enter a site.");
|
||
const res = await C.pwGenerate({ domain, username });
|
||
if (!res.ok) return alert("Preview failed: " + res.err);
|
||
document.getElementById("pwAddPreviewOut").value = res.password;
|
||
};
|
||
document.getElementById("pwAddBtn").onclick = async () => {
|
||
const domain = document.getElementById("pwAddDomain").value.trim();
|
||
const username = document.getElementById("pwAddUser").value.trim();
|
||
if (!domain) return alert("Enter a site.");
|
||
const kind = document.querySelector('input[name="pwAddKind"]:checked').value;
|
||
const spec = { domain, username };
|
||
if (kind === "literal") {
|
||
const lit = document.getElementById("pwAddLiteral").value;
|
||
if (!lit) return alert("Paste the password to save.");
|
||
spec.literal = lit;
|
||
}
|
||
const res = await C.pwAdd(spec);
|
||
if (!res.ok) return alert("Add failed: " + res.err);
|
||
document.getElementById("pwAddDomain").value = "";
|
||
document.getElementById("pwAddUser").value = "";
|
||
document.getElementById("pwAddLiteral").value = "";
|
||
document.getElementById("pwAddPreviewOut").value = "";
|
||
pwRefresh();
|
||
};
|
||
// Initial state — decide which panel to show now, and every time the user
|
||
// switches to Passwords in the sidebar (so a lock elsewhere is reflected).
|
||
pwRefresh();
|
||
document.querySelector('.side a[data-sec="passwords"]').addEventListener("click", pwRefresh);
|
||
});
|
||
|
||
// ---- Add-ons management ----
|
||
const addonsList = document.getElementById("addonsList");
|
||
// 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.",
|
||
"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.",
|
||
};
|
||
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");
|
||
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>';
|
||
};
|
||
const enabled = items.filter((a) => !a.error && a.enabled);
|
||
const disabled = items.filter((a) => !a.error && !a.enabled);
|
||
const failed = items.filter((a) => a.error);
|
||
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 ? '' : '<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) => ({ "&":"&","<":"<",">":">",'"':""","'":"'" })[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());
|
||
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(); })();
|
||
</script>
|
||
</body>
|
||
</html>
|