theseus/chrome.html
Local Dev ee0548fec9 Ship Theseus 0.2.2 972f6209 (Extensions rename + draggable sidebar + session-proxy)
Setup    972f6209639122f32f032d5f2f9fc5a4808e0d4a810f88ae38ec9a1275ac52ac
Portable a9771f054ec36b9aff6ddee958cecf7e84cdacd4d7932aa8385c445aab4d29be

User-visible rename: the Settings tab and its labels say "Extensions"
now instead of "Add-ons". Internal identifiers (disabledAddons, the
addons/ folder, IPC channels, capability strings) stay put — code
churn wasn't worth it, and users only see the user-facing text.

Draggable sidebar. sidebar-preload.js now injects a 5px grip strip
along the LEFT edge of every panel document. mousedown+mousemove
streams delta-x px to main via sidebar-drag IPC; main clamps to
[200, 800] and debounces a save to settings.sidebarWidth. Width is
restored on next launch. The default is still 340. Faint acid-green
highlight on hover so the affordance is discoverable.

New extension capability: session-proxy. An extension whose addon.json
declares "session-proxy" gets api.setSessionProxy(rules) which routes
to session.defaultSession.setProxy — the same primitive Tor already
uses under the hood. Rules can be a string ("socks5://host:port") or
an object matching Electron's setProxy shape; null clears. The
capability is opt-in: an extension without the declaration gets an
error if it tries to call setSessionProxy. This is the framework
surface a private 3-VPS relay extension would build on (extension
folder stays on the operator's disk only; nothing about it appears in
the public build).

Deployed: scp installers + manifest + tools/ + releases/ pages to
VPS, sia-upload of both trees, verified HEAD 200 and manifest 0.2.2.
2026-08-31 16:00:34 +02:00

605 lines
36 KiB
HTML

<!doctype html>
<html>
<head><meta charset="utf-8">
<style>
:root {
color-scheme: light dark; font-family: system-ui, sans-serif;
--bg:#0f1420; --surface:#1b2330; --surface2:#171e2a; --active:#263041cc;
--ink:#e7eaf1; --mut:#b9c2d0; --dim:#8b98a9; --faint:#5e6678;
--line:rgba(255,255,255,.20); --line2:rgba(255,255,255,.13); --hover:rgba(255,255,255,.08);
}
@media (prefers-color-scheme: light) {
:root {
--bg:#e9ecf1; --surface:#ffffff; --surface2:#f1f3f7; --active:#dbe1ec;
--ink:#1a1f28; --mut:#3c4453; --dim:#697280; --faint:#98a1b0;
--line:rgba(0,0,0,.18); --line2:rgba(0,0,0,.11); --hover:rgba(0,0,0,.06);
}
}
[hidden] { display: none !important; }
body { margin: 0; height: 100vh; background: var(--bg); color: var(--ink); user-select: none; overflow: hidden; }
.tabs { display: flex; align-items: flex-end; gap: 4px; padding: 6px 8px 0; height: 34px; overflow: hidden; }
/* Same-size tabs: each tab claims an equal share of the row, capped at
200px so 2 tabs don't stretch across the whole window. min-width lets
many tabs shrink cleanly while still showing a couple letters. */
.tab { display: flex; align-items: center; gap: 8px; flex: 1 1 0; max-width: 200px; min-width: 60px; padding: 6px 10px;
background: var(--surface); border: 1px solid var(--line2); border-bottom: none; border-radius: 8px 8px 0 0;
font-size: 12.5px; cursor: default; color: var(--mut); user-select: none; }
.tab.dragging { opacity: .45; cursor: grabbing; }
.tab.dropbefore { box-shadow: -2px 0 0 var(--acid); }
.tab.dropafter { box-shadow: 2px 0 0 var(--acid); }
.tab.active { background: var(--active); color: var(--ink); }
.tab .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
.tab .fav { width: 14px; height: 14px; flex: none; border-radius: 2px; object-fit: contain; }
.tab .spin { width: 10px; height: 10px; flex: none; border: 1.5px solid #ffffff2e; border-top-color: #d6ff3d; border-radius: 50%; animation: spin .7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.tab .x { opacity: .5; cursor: pointer; padding: 0 2px; border-radius: 4px; }
.tab .x:hover { opacity: 1; background: var(--line2); }
.newtab { padding: 4px 10px; cursor: pointer; color: var(--dim); border-radius: 6px; font-size: 16px; }
.newtab:hover { background: var(--hover); color: var(--ink); }
.bar { display: flex; gap: 6px; align-items: center; padding: 6px 10px; }
.nav { display: flex; gap: 1px; }
.ic { width: 34px; height: 32px; display: grid; place-items: center; border-radius: 8px; cursor: pointer;
color: var(--mut); background: transparent; border: none; padding: 0; }
.ic svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
.ic:hover { background: var(--line2); color: var(--ink); }
.ic.active { background: rgba(214,255,61,.12); color: var(--acid); }
.ic:active { background: var(--line); }
.ic:disabled { opacity: .28; cursor: default; background: transparent; }
/* downloads button — a small badge sits over the icon when there's activity */
.dlbtn { position: relative; }
.dlbtn.spin::before { content: ""; position: absolute; inset: 4px; border-radius: 999px;
border: 1.5px solid transparent; border-top-color: #4b7bec; animation: dlspin .9s infinite linear; }
@keyframes dlspin { to { transform: rotate(360deg); } }
.dlbadge { position: absolute; top: 2px; right: 2px; min-width: 14px; height: 14px; padding: 0 3px;
background: #4b7bec; color: #fff; border-radius: 7px; font-size: 9.5px; font-weight: 700;
display: grid; place-items: center; line-height: 1; box-shadow: 0 0 0 2px var(--bg); }
.dlbtn.done .dlbadge { background: #4fd1a5; }
.dlbtn.err .dlbadge { background: #f6768a; }
/* indeterminate loading bar under the toolbar — collapses when idle so it
doesn't take a permanent 2px strip below the address bar */
.loadbar { height: 0; overflow: hidden; background: transparent; transition: height .12s linear; }
.loadbar.on { height: 2px; }
.loadbar.on::after { content: ""; display: block; height: 100%; width: 35%; border-radius: 2px;
background: linear-gradient(90deg, transparent, #4b7bec, #d6ff3d, transparent); animation: loadslide 1.1s infinite linear; }
@keyframes loadslide { 0% { transform: translateX(-110%); } 100% { transform: translateX(400%); } }
/* address bar */
.urlwrap { flex: 1; display: flex; align-items: center; gap: 4px; background: var(--surface); border: 1px solid var(--line);
border-radius: 999px; padding: 0 6px 0 4px; }
.urlwrap:focus-within { border-color: #4b7bec; }
/* Site security shield — always present. Colour communicates state:
neutral (default) for home / resolving, green when the connection is
secure (BCNR chain-verified OR https:// clearnet), red when it isn't
(nxdomain / resolver error / plain http). Shape stays constant so the
button doesn't jump around when the state changes. */
.secbadge { display: grid; place-items: center; width: 28px; height: 26px; border-radius: 6px; border: none;
background: transparent; cursor: pointer; padding: 0; color: var(--dim); }
.secbadge:hover { background: var(--line2); }
.secbadge svg { width: 16px; height: 16px; fill: currentColor; }
.secbadge.secure { color: #3fb950; } /* GitHub-style saturated green */
.secbadge.insecure { color: #f6768a; }
.secbadge.warn { color: #f6ad55; } /* kept for legacy call sites */
input { padding: 7px 6px; border-radius: 999px; border: none; background: transparent; color: inherit; font-size: 13.5px; outline: none; }
#url { flex: 1; }
/* Registry indicator at the END of the address bar. Two shapes:
- Toggle (segmented control) when this host could be served by EITHER
registry — a single pill split in half, active side filled, inactive
side transparent and clickable to switch.
- Single pill when there's no alternative (pure ICANN or a BCNR-unique TLD).*/
#reg { display: inline-flex; align-items: center; }
.reg { font-size: 10.5px; letter-spacing: .03em; padding: 2px 10px; white-space: nowrap;
cursor: default; user-select: none; transition: background .12s, color .12s, border-color .12s; }
/* Single-chip form (non-collision hosts) */
.reg.single { border-radius: 999px; border: 1px solid; }
.reg.single.bcdn.active { background: rgba(214,255,61,.15); color: #d6ff3d; border-color: #d6ff3d55; }
.reg.single.icann.active { background: rgba(76,158,255,.15); color: #4c9eff; border-color: #4c9eff55; }
/* Toggle (segmented control) form for collision candidates */
.tog { display: inline-flex; border-radius: 999px; overflow: hidden; border: 1px solid #ffffff26; }
.tog .reg { border: 0; }
.tog .reg + .reg { border-left: 1px solid #ffffff26; }
.tog .reg.active.bcdn { background: rgba(214,255,61,.18); color: #d6ff3d; }
.tog .reg.active.icann { background: rgba(76,158,255,.18); color: #4c9eff; }
.tog .reg.switch { background: transparent; color: #7b8494; cursor: pointer; }
.tog .reg.switch:hover { color: #e5e7eb; background: rgba(255,255,255,.05); }
.star { border: none; background: transparent; cursor: pointer; color: var(--dim); font-size: 15px; padding: 2px 4px; border-radius: 6px; }
.star:hover { background: var(--line2); color: var(--ink); } .star.on { color: #ffd23d; }
/* Update-available chip — shows when the releases manifest names a newer
version than this build. Two buttons: main body opens the download URL
in the system browser; ✕ dismisses for the session. */
.upchip { display: inline-flex; align-items: center; gap: 0; background: rgba(214,255,61,.14);
border: 1px solid #d6ff3d55; border-radius: 999px; color: #eaffb0; font-size: 12px;
font-weight: 600; padding: 0; overflow: hidden; }
.upchip .upcore { background: transparent; border: none; color: inherit; font: inherit;
padding: 5px 10px 5px 12px; cursor: pointer; white-space: nowrap; }
.upchip .upcore:hover { background: rgba(214,255,61,.10); }
.upchip .updismiss { background: transparent; border: none; border-left: 1px solid #d6ff3d33;
color: inherit; opacity: .55; padding: 5px 8px; cursor: pointer; font-size: 11px; }
.upchip .updismiss:hover { opacity: 1; }
/* Password-fill chip — only visible when the vault is unlocked AND the
current site has matching credentials. Count sits to the left of the key. */
.pwchip { display: inline-flex; align-items: center; gap: 3px; border: none; background: transparent; color: #3fb950; cursor: pointer; padding: 2px 5px; border-radius: 6px; font-size: 11px; font-weight: 700; }
.pwchip:hover { background: rgba(63,185,80,.14); }
.pwchip svg { width: 14px; height: 14px; }
/* search box: engine icon (click = pick engine) + a wide typing area */
.searchbox { display: flex; align-items: center; width: 300px; background: var(--surface); border: 1px solid var(--line);
border-radius: 999px; padding: 0 12px 0 3px; }
.searchbox:focus-within { border-color: #4b7bec; }
/* dropdown button shows the SELECTED engine's favicon (emoji fallback) + a caret */
.engine-btn { display: flex; align-items: center; gap: 3px; height: 30px; border: none; cursor: pointer;
border-radius: 999px 0 0 999px; padding: 0 4px 0 8px; background: transparent; }
.engine-btn:hover { background-color: var(--hover); }
.engine-btn #engineIcon { display: grid; place-items: center; width: 16px; height: 16px; }
.engine-btn .eimg { width: 16px; height: 16px; border-radius: 3px; }
.engine-btn .esym { font-size: 14px; line-height: 1; }
.engine-btn .cv { font-size: 8px; color: var(--dim); }
#search { flex: 1; padding: 7px 4px; }
.tor { padding: 7px 10px; border-radius: 8px; border: 1px solid var(--line); background: #2b2f3a; color: var(--ink); cursor: pointer; white-space: nowrap; font-size: 12.5px; }
.tor.connecting { background: #7a5c14; } .tor.on { background: #6b3fa0; }
.logo { display: flex; align-items: center; gap: 6px; font-weight: 700; color: #d6ff3d; white-space: nowrap;
padding: 6px 12px; border: 1px solid #d6ff3d33; border-radius: 8px; cursor: pointer; background: rgba(214,255,61,.08); font-size: 13px; }
.logo:hover { background: rgba(214,255,61,.16); }
.logo .gear { font-size: 12px; opacity: .8; }
/* favorites bar — new-tab page only, single compact row */
.bookmarks { display: flex; align-items: center; gap: 4px; padding: 3px 10px 5px; height: 26px; overflow: hidden; }
.bm { display: flex; align-items: center; gap: 6px; max-width: 180px; padding: 3px 9px; border-radius: 6px;
background: var(--surface2); border: 1px solid var(--hover); color: var(--mut); font-size: 12px; cursor: pointer; white-space: nowrap; }
.bm:hover { background: var(--active); color: var(--ink); }
.bm .bt { overflow: hidden; text-overflow: ellipsis; }
.bm .bx { opacity: 0; cursor: pointer; font-size: 11px; } .bm:hover .bx { opacity: .55; }
.bm .bx:hover { opacity: 1; color: #f6768a; }
.bm-empty { color: var(--faint); font-size: 11.5px; white-space: nowrap; }
/* Right-click menu on the bookmarks bar (same visual as settings ctxmenu). */
.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); }
.ctxmenu .mi.off { color: var(--faint); cursor: default; } .ctxmenu .mi.off:hover { background: transparent; }
.ctxmenu .sep { height: 1px; background: var(--line); margin: 4px 0; }
@media (prefers-color-scheme: light) {
.ctxmenu { background: #ffffff; border-color: rgba(0,0,0,.15); }
.ctxmenu .mi:hover { background: rgba(0,0,0,.05); }
}
.tordisc { font-size: 11.5px; color: #d9c7f2; background: #2a1c40; border-top: 1px solid #6b3fa055; padding: 5px 14px; }
.tordisc a { color: #d6ff3d; }
.bcnrbar { display: flex; align-items: center; gap: 10px; font-size: 11.5px; color: var(--mut);
background: #1a2417; border-top: 1px solid #d6ff3d33; padding: 6px 14px; }
.bcnrbar .breg { font-size: 10px; letter-spacing: .03em; padding: 1px 7px; border-radius: 999px;
background: rgba(214,255,61,.14); color: #d6ff3d; border: 1px solid #d6ff3d33; }
.bcnrbar b { color: var(--ink); }
.bcnrbar .bopen { margin-left: auto; padding: 4px 11px; border-radius: 7px; border: 1px solid #d6ff3d55;
background: rgba(214,255,61,.12); color: #eaffb0; cursor: pointer; font-size: 11.5px; }
.bcnrbar .bopen:hover { background: rgba(214,255,61,.22); }
.bcnrbar .bdismiss { color: var(--dim); text-decoration: none; } .bcnrbar .bdismiss:hover { color: var(--ink); }
</style></head>
<body>
<div class="tabs" id="tabs"></div>
<div class="bar">
<div class="nav">
<button class="ic" id="back" title="Back"><svg viewBox="0 0 16 16"><path d="M10 3 L5 8 L10 13"/></svg></button>
<button class="ic" id="fwd" title="Forward"><svg viewBox="0 0 16 16"><path d="M6 3 L11 8 L6 13"/></svg></button>
<button class="ic" id="reload" title="Reload"><svg viewBox="0 0 16 16"><path d="M13 8 a5 5 0 1 1 -1.5 -3.6"/><path d="M13 2.5 L13 5 L10.5 5"/></svg></button>
<button class="ic" id="home" title="Home"><svg viewBox="0 0 16 16"><path d="M2.5 7.5 L8 3 L13.5 7.5"/><path d="M4 6.7 L4 13 L12 13 L12 6.7"/></svg></button>
</div>
<div class="urlwrap">
<button class="secbadge" id="secbadge" title="Site information">
<!-- Heraldic shield outline matching the reference image: flared top
shoulders, concave sides tapering to a sharp bottom point. Single
filled path, `currentColor` so state class toggles the tint. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" aria-hidden="true">
<path d="M8 1.2 C6 1.2 3.5 1.7 2.5 2.3 Q2 2.6 2 3.5 L2 7 C2 11.2 4.2 13.7 8 15 C11.8 13.7 14 11.2 14 7 L14 3.5 Q14 2.6 13.5 2.3 C12.5 1.7 10 1.2 8 1.2 Z"/>
</svg>
</button>
<input id="url" placeholder="Ask a search engine or enter web address" spellcheck="false">
<span class="reg" id="reg" hidden></span>
<button class="pwchip" id="pwchip" title="Fill password for this site" hidden><span id="pwchipCount"></span><svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="6" cy="9" r="3.2" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M8.8 8.6 L14 8.6 M13.2 8.6 L13.2 11 M11.5 8.6 L11.5 10.4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></button>
<button class="star" id="star" title="Save this page"></button>
</div>
<div class="searchbox">
<button id="engineBtn" class="engine-btn" title="Choose search engine"><span id="engineIcon"></span><span class="cv"></span></button>
<input id="search" placeholder="Search" spellcheck="false">
</div>
<button class="ic dlbtn" id="downloads" title="Downloads">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 2 V10 M4.5 6.5 L8 10 L11.5 6.5"/><path d="M3 12.5 L3 13.5 L13 13.5 L13 12.5"/></svg>
<span class="dlbadge" id="dlbadge" hidden>0</span>
</button>
<button class="ic" id="sidebarbtn" title="Toggle extension sidebar" hidden>
<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="2" y="3" width="12" height="10" rx="1.5"/><path d="M10 3 L10 13"/></svg>
</button>
<span class="upchip" id="upchip" hidden><button class="upcore" id="upDownload" title=""></button><button class="updismiss" id="upDismiss" title="Not now"></button></span>
<button class="tor" id="tor" title="Route traffic through Tor">🧅 Tor: Off</button>
<button class="logo" id="logo" title="Settings"><span>⛓ Theseus</span><span class="gear"></span></button>
</div>
<div class="loadbar" id="loadbar"></div>
<div class="bookmarks" id="bookmarks" hidden></div>
<div id="tordisc" class="tordisc" hidden>
Onion mode hides your IP from sites and your ISP (including .bch lookups).
It is <b>not full anonymity</b> — this browser can still be fingerprinted; for that use the Tor Browser.
<a href="#" id="tordismiss">got it</a>
</div>
<div id="bcnrbar" class="bcnrbar" hidden>
<span class="breg" id="bcnrreg">BCDN</span>
<span><b id="bcnrhost"></b> also exists on <b>BCDN</b> — the decentralized registry.</span>
<button id="bcnropen" class="bopen">Open on BCDN</button>
<a href="#" id="bcnrdismiss" class="bdismiss">dismiss</a>
</div>
<script>
const $ = (id) => document.getElementById(id);
const T = window.theseus;
function syncHeight() { requestAnimationFrame(() => T.setChromeHeight(document.body.scrollHeight)); }
const RELOAD_SVG = `<svg viewBox="0 0 16 16"><path d="M13 8 a5 5 0 1 1 -1.5 -3.6"/><path d="M13 2.5 L13 5 L10.5 5"/></svg>`;
const STOP_SVG = `<svg viewBox="0 0 16 16"><path d="M4.5 4.5 L11.5 11.5 M11.5 4.5 L4.5 11.5"/></svg>`;
function goURL() {
const v = $("url").value.trim();
if (!v) return;
// Blur so the tabs-event handler can update the URL bar to the actual
// loaded page — the "don't clobber the typed text" guard would otherwise
// keep the search keyword displayed while the results are loading.
$("url").blur();
T.navigate(v);
}
// Address-bar suggestions — debounced call to main on every keystroke.
// Main computes matches from history and shows/hides a floating overlay
// WebContentsView anchored under the address bar's client rect.
let suggestTimer = null;
let pickerOpen = false;
const askSuggest = () => {
const q = $("url").value;
const r = $("url").getBoundingClientRect();
const rect = { x: Math.round(r.left), y: Math.round(r.bottom + 4), w: Math.round(r.width + 40) };
T.suggestAddress(q, rect);
// main hides itself when there are zero matches; assume open otherwise.
pickerOpen = true;
};
const debouncedSuggest = () => { clearTimeout(suggestTimer); suggestTimer = setTimeout(askSuggest, 80); };
$("url").addEventListener("input", debouncedSuggest);
$("url").addEventListener("focus", debouncedSuggest);
$("url").addEventListener("blur", () => {
// Delay: gives a click on the picker time to register before we close.
setTimeout(() => { T.closeAddressPicker(); pickerOpen = false; }, 160);
});
$("url").addEventListener("keydown", (e) => {
if (e.key === "Enter") { T.closeAddressPicker(); pickerOpen = false; goURL(); return; }
if (e.key === "Escape") { T.closeAddressPicker(); pickerOpen = false; return; }
if (!pickerOpen) return;
if (e.key === "ArrowDown") { e.preventDefault(); T.addressCursor("next"); }
else if (e.key === "ArrowUp") { e.preventDefault(); T.addressCursor("prev"); }
});
// Enter submits the search; keep the query visible so the user can refine
// it or search again — clearing it on submit lost context and made refining
// annoying (esp. when the engine's own results page uses its own search box).
$("search").addEventListener("keydown", (e) => { if (e.key === "Enter") { const q = $("search").value.trim(); if (q) T.search(q); } });
$("back").onclick = () => T.back();
$("fwd").onclick = () => T.forward();
// Shift-click on the reload button = hard reload (bypass cache), same
// convention as Chrome/Firefox. Keyboard alternative: Ctrl+Shift+R.
$("reload").onclick = (e) => T.reload(e.shiftKey);
$("home").onclick = () => T.goHome();
$("logo").onclick = () => T.openSettings();
$("secbadge").onclick = () => { const r = $("secbadge").getBoundingClientRect(); T.toggleSiteInfo({ x: Math.round(r.left), y: Math.round(r.bottom + 4) }); };
// ---- search-engine picker: the magnifier button opens a custom overlay
// dropdown (real favicons); here we only track the current engine's name. ----
function setEnginePlaceholder(d) {
const cur = (d.engines || []).find((e) => e.id === d.current);
$("search").placeholder = cur ? "Search with " + cur.name : "Search";
const icon = $("engineIcon");
const sym = (cur && cur.sym) || "🔍";
if (cur && cur.favicon)
icon.innerHTML = `<img class="eimg" src="${cur.favicon.replace(/"/g, "&quot;")}" onerror="this.replaceWith(Object.assign(document.createElement('span'),{className:'esym',textContent:'${sym}'}))">`;
else icon.innerHTML = `<span class="esym">${sym}</span>`;
}
$("engineBtn").onclick = () => {
const r = $("engineBtn").getBoundingClientRect();
T.toggleEnginePicker({ x: Math.round(r.left), y: Math.round(r.bottom + 4) });
};
T.getSearchEngines().then(setEnginePlaceholder);
T.onEngines && T.onEngines(setEnginePlaceholder);
// ---- downloads button: badge + click toggles the panel ----
const dlBtn = $("downloads"), dlBadge = $("dlbadge");
function renderDownloads(items) {
const list = items || [];
const active = list.filter((d) => d.state === "progressing").length;
const doneNew = list.filter((d) => d.state === "completed").length;
const failed = list.filter((d) => d.state === "interrupted").length;
dlBtn.classList.toggle("spin", active > 0);
dlBtn.classList.toggle("done", active === 0 && doneNew > 0 && failed === 0);
dlBtn.classList.toggle("err", active === 0 && failed > 0);
const badge = active || (doneNew + failed);
if (badge > 0) { dlBadge.hidden = false; dlBadge.textContent = badge > 99 ? "99+" : String(badge); }
else { dlBadge.hidden = true; }
}
dlBtn.onclick = () => {
const r = dlBtn.getBoundingClientRect();
T.toggleDownloads({ x: Math.round(r.right - 340), y: Math.round(r.bottom + 4) });
};
T.getDownloads && T.getDownloads().then(renderDownloads);
T.onDownloads && T.onDownloads(renderDownloads);
// ---- add-on sidebar toggle ----
// Only surfaces the button when at least one add-on has registered a
// sidebar panel; a fresh install with no add-ons keeps the toolbar clean.
const sideBtn = $("sidebarbtn");
function renderSidebarState(d) {
if (!d) return;
const has = Array.isArray(d.panels) && d.panels.length > 0;
sideBtn.hidden = !has;
sideBtn.classList.toggle("active", !!d.visible);
if (has && d.panels[0]) sideBtn.title = "Toggle " + d.panels[0].title;
}
sideBtn.onclick = () => T.toggleSidebar && T.toggleSidebar();
T.sidebarState && T.sidebarState().then(renderSidebarState);
T.onSidebarState && T.onSidebarState(renderSidebarState);
// ---- favorites bar (new-tab page only) ----
let current = { url: "", title: "" }, bookmarks = [];
function renderBookmarks() {
const box = $("bookmarks");
box.innerHTML = bookmarks.length
? bookmarks.map((b, i) => `<div class="bm" data-i="${i}" title="${(b.url||"").replace(/"/g,"&quot;")}"><span class="bt">${(b.title||b.url).replace(/</g,"&lt;")}</span><span class="bx" data-x="${i}">✕</span></div>`).join("")
: `<span class="bm-empty">No saved pages yet — click ☆ or right-click here to add one.</span>`;
box.querySelectorAll(".bm").forEach((el) => el.onclick = (e) => {
const i = Number(el.dataset.i);
if (e.target.dataset.x !== undefined) T.removeBookmark(bookmarks[i].url);
else T.navigate(bookmarks[i].url);
});
updateStar(); syncHeight();
}
// Right-click context menu on the bookmarks bar. Two modes:
// - On a specific bookmark: Open / Edit title / Remove
// - On empty area OR the placeholder text: Add current page (or Remove
// if the page is already bookmarked)
function closeBmMenu() { const m = document.getElementById("bmMenu"); if (m) m.remove(); }
function openBmMenu(x, y, ctx) {
closeBmMenu();
const items = [];
if (ctx.kind === "item") {
const bm = bookmarks[ctx.index];
items.push({ label: "Open", act: () => T.navigate(bm.url) });
items.push({ label: "Edit title…", act: () => {
const nt = prompt("Edit bookmark title", bm.title || bm.url);
if (nt != null && nt.trim() && nt !== bm.title) {
// No dedicated update IPC yet — round-trip: remove + re-add with new title at same URL.
T.removeBookmark(bm.url).then(() => T.addBookmark({ title: nt.trim(), url: bm.url }));
}
}});
items.push({ label: "Remove", act: () => T.removeBookmark(bm.url), danger: true });
items.push({ sep: true });
}
if (current.url) {
const saved = bookmarks.some((b) => b.url === current.url);
items.push(saved
? { label: "Remove current page", act: () => T.removeBookmark(current.url), danger: true }
: { label: "Add current page", act: () => T.addBookmark({ title: current.title || current.url, url: current.url }) });
} else {
items.push({ label: "Add current page", disabled: true });
}
const m = document.createElement("div");
m.id = "bmMenu"; m.className = "ctxmenu";
m.innerHTML = items.map((it, i) => it.sep
? `<div class="sep"></div>`
: `<div class="mi${it.danger ? " danger" : ""}${it.disabled ? " off" : ""}" data-i="${i}">${it.label.replace(/</g, "&lt;")}</div>`
).join("");
document.body.appendChild(m);
const r = m.getBoundingClientRect();
const vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
m.style.left = Math.min(x, vw - r.width - 6) + "px";
m.style.top = Math.min(y, vh - r.height - 6) + "px";
m.querySelectorAll(".mi").forEach((el) => {
const it = items[Number(el.dataset.i)];
if (it.disabled) return;
el.onclick = () => { closeBmMenu(); try { it.act(); } catch (e) { console.error(e); } };
});
setTimeout(() => {
const off = (ev) => { if (!m.contains(ev.target)) { closeBmMenu(); document.removeEventListener("mousedown", off); document.removeEventListener("keydown", esc); } };
const esc = (ev) => { if (ev.key === "Escape") { closeBmMenu(); document.removeEventListener("mousedown", off); document.removeEventListener("keydown", esc); } };
document.addEventListener("mousedown", off);
document.addEventListener("keydown", esc);
}, 0);
}
$("bookmarks").addEventListener("contextmenu", (e) => {
e.preventDefault();
const bm = e.target.closest(".bm");
openBmMenu(e.clientX, e.clientY, bm ? { kind: "item", index: Number(bm.dataset.i) } : { kind: "bar" });
});
function updateStar() {
const saved = !!current.url && bookmarks.some((b) => b.url === current.url);
const s = $("star"); s.textContent = saved ? "★" : "☆"; s.classList.toggle("on", saved);
s.title = saved ? "Remove from saved pages" : "Save this page";
}
$("star").onclick = () => {
if (!current.url) return;
if (bookmarks.some((b) => b.url === current.url)) T.removeBookmark(current.url);
else T.addBookmark({ title: current.title || current.url, url: current.url });
};
T.getBookmarks().then((b) => { bookmarks = b || []; renderBookmarks(); });
T.onBookmarks((b) => { bookmarks = b || []; renderBookmarks(); });
// ---- password-fill chip ----
// Visible only when the vault is unlocked AND the current site has matching
// credentials. Click opens a floating picker of matches; clicking a match
// injects the fill into the active tab.
T.onPwAvailability && T.onPwAvailability((d) => {
const chip = $("pwchip"); if (!chip) return;
const n = d && d.count ? d.count : 0;
chip.hidden = n === 0;
$("pwchipCount").textContent = n > 1 ? String(n) : "";
chip.title = n === 1 ? "Fill password for this site" : `${n} saved credentials for this site`;
});
$("pwchip") && ($("pwchip").onclick = () => {
const r = $("pwchip").getBoundingClientRect();
T.togglePwFill({ x: Math.round(r.right - 280), y: Math.round(r.bottom + 4) });
});
// ---- update-available chip ----
// Main sends "update-available" with { version, setupUrl, portableUrl, ... }
// when the releases manifest names a newer Theseus. Null clears the chip
// (up-to-date, or dismissed this session).
T.onUpdateAvailable && T.onUpdateAvailable((d) => {
const chip = $("upchip"); if (!chip) return;
if (!d) { chip.hidden = true; return; }
chip.hidden = false;
// Prefer the installer URL when both are available — that's the flow
// that upgrades in place. Users on the portable can right-click the
// chip and choose the portable URL from the shell menu (future).
const url = d.setupUrl || d.portableUrl || "";
const core = $("upDownload");
core.textContent = `↓ Update to ${d.version}`;
core.title = url ? `Open ${url}` : "Download the new version";
core.onclick = () => { if (url) T.openUpdateDownload(url); };
});
$("upDismiss") && ($("upDismiss").onclick = () => T.dismissUpdate());
// ---- security badge + minimal registry indicator ----
// Shield colour communicates connection state at a glance:
// neutral — home / resolving (no site OR pending)
// secure — BCDN chain-verified (kind:"ok") OR https:// clearnet
// insecure — nxdomain / resolver error / plain http:// clearnet
function setBadge(d) {
const b = $("secbadge");
let cls = "secbadge", title = "Site information";
const isHttps = typeof d?.url === "string" && d.url.startsWith("https:");
const isHttp = typeof d?.url === "string" && d.url.startsWith("http:") && !isHttps;
if (!d || d.kind === "home") title = "Theseus";
else if (d.kind === "ok") { cls = "secbadge secure"; title = "Secure · BCDN via " + (d.source || "on-chain") + " — click for details"; }
else if (d.kind === "web" && isHttps) { cls = "secbadge secure"; title = "Secure · ICANN (HTTPS) — click for details"; }
else if (d.kind === "web" && isHttp) { cls = "secbadge insecure"; title = "Not secure · plain HTTP — click for details"; }
else if (d.kind === "web") title = "Connection · ICANN — click for details";
else if (d.kind === "resolving") title = "Resolving…";
else if (d.kind === "nxdomain") { cls = "secbadge insecure"; title = "Not registered on BCDN"; }
else if (d.kind === "error") { cls = "secbadge insecure"; title = "Resolution error"; }
b.className = cls; b.title = title;
}
function setReg(d) {
const r = $("reg");
if (!d || d.kind === "home") { r.hidden = true; r.innerHTML = ""; return; }
// Keep chips visible during "resolving" so the switch feels like a toggle,
// not a disappear-and-reappear (user complaint 2026-08-02).
if (d.kind === "resolving") { r.hidden = false; return; }
r.hidden = false;
// Two chips (toggle) when this host is a collision candidate — could be
// BCDN OR ICANN. Active one is coloured; inactive is greyed + clickable.
const isCand = !!d.tld && (d.kind === "ok" || d.kind === "web") && d.bcnrNativeTld === false;
const active = d.kind === "ok" ? "bcdn" : d.kind === "web" ? "icann" : null;
if (isCand) {
const bcdn = `<span class="reg bcdn ${active === "bcdn" ? "active" : "switch"}" data-c="bcdn" title="${active === "bcdn" ? "Serving from BCDN" : "Switch to BCDN"}">BCDN</span>`;
const icann = `<span class="reg icann ${active === "icann" ? "active" : "switch"}" data-c="icann" title="${active === "icann" ? "Serving from ICANN" : "Switch to ICANN"}">ICANN</span>`;
r.innerHTML = `<span class="tog">${bcdn}${icann}</span>`;
for (const chip of r.querySelectorAll(".reg.switch")) {
chip.onclick = () => T.collisionSwitch({ choice: chip.dataset.c, remember: "no" });
}
} else if (d.kind === "web") {
r.innerHTML = `<span class="reg single icann active">ICANN</span>`;
} else {
r.innerHTML = `<span class="reg single bcdn active">BCDN</span>`;
}
}
// ---- Tor ----
let torShown = false;
$("tor").onclick = () => { T.toggleTor(); if (!torShown) { $("tordisc").hidden = false; torShown = true; syncHeight(); } };
$("tordismiss").onclick = (e) => { e.preventDefault(); $("tordisc").hidden = true; syncHeight(); };
// ---- passive BCNR offer ----
T.onBcnrOffer((d) => {
const bar = $("bcnrbar");
if (d && d.host) { $("bcnrhost").textContent = d.host; $("bcnrreg").textContent = "BCDN"; bar.hidden = false; }
else bar.hidden = true;
syncHeight();
});
$("bcnropen").onclick = () => { $("bcnrbar").hidden = true; syncHeight(); T.switchToBcnr(); };
$("bcnrdismiss").onclick = (e) => { e.preventDefault(); $("bcnrbar").hidden = true; syncHeight(); };
T.onTor((d) => {
const b = $("tor"); b.className = "tor " + (d.state === "on" ? "on" : d.state === "connecting" ? "connecting" : "");
b.textContent = d.state === "on" ? "🧅 Tor: On" : d.state === "connecting" ? "🧅 connecting…" : "🧅 Tor: Off";
});
// Address-picker pick: the picker fires "address-pick" which routes to
// navigateTab, but the tabs event's focus-guard would leave the typed
// query in place if the URL input still had DOM focus. Force the full
// picked URL into the bar and drop focus so subsequent tabs events
// paint the loaded URL cleanly.
T.onAddressPicked && T.onAddressPicked((url) => {
try { $("url").blur(); } catch (e) {}
$("url").value = String(url || "");
});
// ---- tabs ----
T.onTabs((d) => {
$("back").disabled = !d.canBack; $("fwd").disabled = !d.canForward;
// loading indicator: progress bar + reload↔stop button
$("loadbar").classList.toggle("on", !!d.loading);
const rb = $("reload");
if (d.loading) { rb.innerHTML = STOP_SVG; rb.title = "Stop"; rb.onclick = () => T.stop(); }
else { rb.innerHTML = RELOAD_SVG; rb.title = "Reload (Shift-click: hard reload)"; rb.onclick = (ev) => T.reload(ev.shiftKey); }
if (document.activeElement !== $("url")) $("url").value = d.url || "";
current.url = d.url || "";
const active = d.tabs.find((t) => t.active);
current.title = active ? active.title : "";
updateStar();
const box = $("tabs");
box.innerHTML = d.tabs.map((t) => {
// Loading spinner takes the icon slot while a page is loading, then
// hands it back to the favicon once page-favicon-updated fires.
const icon = t.loading
? '<span class="spin"></span>'
: (t.favicon ? `<img class="fav" src="${String(t.favicon).replace(/"/g,"&quot;")}" onerror="this.remove()">` : '');
return `<div class="tab ${t.active ? "active" : ""}" data-id="${t.id}" draggable="true">${icon}<span class="t">${(t.title||"New Tab").replace(/</g,"&lt;")}</span><span class="x" data-close="${t.id}">✕</span></div>`;
}).join("") +
`<span class="newtab" id="newtab">+</span>`;
box.querySelectorAll(".tab").forEach((el) => el.onclick = (e) => {
if (e.target.dataset.close) T.closeTab(Number(e.target.dataset.close));
else T.switchTab(Number(el.dataset.id));
});
// Drag-reorder — HTML5 drag events. Drop-side chosen by whether the pointer
// is on the left or right half of the target tab (matches Chrome UX).
let dragId = null;
box.querySelectorAll(".tab").forEach((row) => {
row.addEventListener("dragstart", (e) => {
dragId = Number(row.dataset.id);
try { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", String(dragId)); } catch {}
row.classList.add("dragging");
});
row.addEventListener("dragend", () => {
row.classList.remove("dragging");
box.querySelectorAll(".tab").forEach((r) => r.classList.remove("dropbefore", "dropafter"));
dragId = null;
});
row.addEventListener("dragover", (e) => {
if (dragId == null || Number(row.dataset.id) === dragId) return;
e.preventDefault(); e.dataTransfer.dropEffect = "move";
const r = row.getBoundingClientRect();
const before = (e.clientX - r.left) < r.width / 2;
row.classList.toggle("dropbefore", before);
row.classList.toggle("dropafter", !before);
});
row.addEventListener("dragleave", () => row.classList.remove("dropbefore", "dropafter"));
row.addEventListener("drop", (e) => {
e.preventDefault();
const targetId = Number(row.dataset.id);
if (dragId == null || dragId === targetId) return;
const r = row.getBoundingClientRect();
const before = (e.clientX - r.left) < r.width / 2;
T.moveTab(dragId, targetId, before ? "before" : "after");
});
});
$("newtab").onclick = () => T.newTab();
});
// ---- provenance ----
T.onNav((d) => {
setBadge(d); setReg(d);
if (document.activeElement !== $("url")) {
if (d.kind === "home") $("url").value = "";
else if (d.host && !$("url").value) $("url").value = d.host;
}
// Favorites bar shows only on the new-tab / home page (hides once a page loads).
$("bookmarks").hidden = !(!d || d.kind === "home");
syncHeight();
});
syncHeight();
</script>
</body>
</html>