Theseus: Firefox-style search picker (favicons, catalog, scan)

- Custom engine dropdown as a floating overlay view (engine-picker.html) that
  renders REAL favicons per engine — a native <select> can't show images.
- Larger catalog (DuckDuckGo/Google/Brave/Bing/Startpage/Yandex/Ecosia/Mojeek/
  SearXNG/Wikipedia/Perplexity); Settings has a favicon checklist to choose
  which appear in the dropdown (enabledEngines), plus manual add/remove.
- OpenSearch "scan": pages advertising a search engine surface an
  "Add <site>'s search" entry in the dropdown.
- Toolbar search box now just a magnifier button that opens the dropdown
  (no per-engine icon in the bar).
This commit is contained in:
Local Dev 2026-07-30 22:55:52 +02:00
parent ad34ad3bbe
commit 0f823ba583
7 changed files with 268 additions and 98 deletions

View file

@ -66,11 +66,10 @@
.searchbox { display: flex; align-items: center; width: 300px; background: var(--surface); border: 1px solid var(--line); .searchbox { display: flex; align-items: center; width: 300px; background: var(--surface); border: 1px solid var(--line);
border-radius: 999px; padding: 0 12px 0 3px; } border-radius: 999px; padding: 0 12px 0 3px; }
.searchbox:focus-within { border-color: #4b7bec; } .searchbox:focus-within { border-color: #4b7bec; }
.engine-sel { appearance: none; -webkit-appearance: none; width: 34px; height: 30px; border: none; cursor: pointer; .engine-btn { width: 34px; height: 30px; border: none; cursor: pointer; border-radius: 999px 0 0 999px; padding: 0;
color: transparent; border-radius: 999px 0 0 999px; background: transparent url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%238b98a9' stroke-width='1.6'%3E%3Ccircle cx='7' cy='7' r='4.2'/%3E%3Cpath d='M10.2 10.2 L14 14'/%3E%3C/svg%3E") no-repeat left 8px center / 14px; }
background: transparent url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%238b98a9' stroke-width='1.6'%3E%3Ccircle cx='7' cy='7' r='4.2'/%3E%3Cpath d='M10.2 10.2 L14 14'/%3E%3C/svg%3E") no-repeat center / 15px; } .engine-btn::after { content: "▾"; position: relative; left: 12px; font-size: 9px; color: var(--dim); }
.engine-sel:hover { background-color: var(--hover); } .engine-btn:hover { background-color: var(--hover); }
.engine-sel option { color: var(--ink); background: var(--surface); }
#search { flex: 1; padding: 7px 4px; } #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 { 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; } .tor.connecting { background: #7a5c14; } .tor.on { background: #6b3fa0; }
@ -117,7 +116,7 @@
<button class="star" id="star" title="Save this page"></button> <button class="star" id="star" title="Save this page"></button>
</div> </div>
<div class="searchbox"> <div class="searchbox">
<select id="engine" class="engine-sel" title="Choose search engine"></select> <button id="engineBtn" class="engine-btn" title="Choose search engine"></button>
<input id="search" placeholder="Search" spellcheck="false"> <input id="search" placeholder="Search" spellcheck="false">
</div> </div>
<button class="tor" id="tor" title="Route traffic through Tor">🧅 Tor: Off</button> <button class="tor" id="tor" title="Route traffic through Tor">🧅 Tor: Off</button>
@ -153,24 +152,18 @@
$("logo").onclick = () => T.openSettings(); $("logo").onclick = () => T.openSettings();
$("secbadge").onclick = () => { const r = $("secbadge").getBoundingClientRect(); T.toggleSiteInfo({ x: Math.round(r.left), y: Math.round(r.bottom + 4) }); }; $("secbadge").onclick = () => { const r = $("secbadge").getBoundingClientRect(); T.toggleSiteInfo({ x: Math.round(r.left), y: Math.round(r.bottom + 4) }); };
// ---- search-engine picker (with an "Add / edit…" entry that opens Settings) ---- // ---- search-engine picker: the magnifier button opens a custom overlay
let engineCurrent = "duckduckgo"; // dropdown (real favicons); here we only track the current engine's name. ----
function fillEngines(d) { function setEnginePlaceholder(d) {
engineCurrent = d.current; const cur = (d.engines || []).find((e) => e.id === d.current);
const sel = $("engine"); $("search").placeholder = cur ? "Search with " + cur.name : "Search";
sel.innerHTML = d.engines.map((e) => `<option value="${e.id}">${(e.sym ? e.sym + " " : "")}${e.name.replace(/</g,"&lt;")}</option>`).join("")
+ `<option disabled>──────────</option><option value="__manage"> Add / edit engines…</option>`;
sel.value = d.current;
const cur = d.engines.find((e) => e.id === d.current);
$("search").placeholder = cur ? "Search with " + (cur.sym ? cur.sym + " " : "") + cur.name : "Search";
} }
$("engine").onchange = () => { $("engineBtn").onclick = () => {
const v = $("engine").value; const r = $("engineBtn").getBoundingClientRect();
if (v === "__manage") { $("engine").value = engineCurrent; T.openSettings(); return; } T.toggleEnginePicker({ x: Math.round(r.left), y: Math.round(r.bottom + 4) });
engineCurrent = v; T.setSearchEngine(v);
}; };
T.getSearchEngines().then(fillEngines); T.getSearchEngines().then(setEnginePlaceholder);
T.onEngines && T.onEngines(fillEngines); T.onEngines && T.onEngines(setEnginePlaceholder);
// ---- favorites bar (new-tab page only) ---- // ---- favorites bar (new-tab page only) ----
let current = { url: "", title: "" }, bookmarks = []; let current = { url: "", title: "" }, bookmarks = [];

9
engine-picker-preload.js Normal file
View file

@ -0,0 +1,9 @@
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("picker", {
onEngines: (cb) => ipcRenderer.on("engines", (_e, d) => cb(d)),
pick: (id) => ipcRenderer.invoke("pick-engine", id),
addDetected: () => ipcRenderer.invoke("add-detected-engine"),
openSettings: () => ipcRenderer.invoke("picker-open-settings"),
close: () => ipcRenderer.invoke("close-engine-picker"),
resize: (h) => ipcRenderer.invoke("ep-resize", h),
});

55
engine-picker.html Normal file
View file

@ -0,0 +1,55 @@
<!doctype html>
<html><head><meta charset="utf-8">
<style>
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
html, body { margin: 0; background: transparent; }
.menu { background: #1c222c; border: 1px solid #ffffff26; border-radius: 10px; box-shadow: 0 12px 34px #000c; overflow: hidden; color: #e7eaf1; }
.hdr { font-size: 11px; letter-spacing: .04em; text-transform: uppercase; color: #8b98a9; padding: 10px 14px 6px; }
.item { display: flex; align-items: center; gap: 10px; padding: 8px 14px; cursor: pointer; font-size: 13px; }
.item:hover { background: #ffffff12; }
.item .ic { width: 18px; height: 18px; flex: none; display: grid; place-items: center; }
.item .ic img { width: 16px; height: 16px; border-radius: 3px; }
.item .ic .em { font-size: 14px; }
.item .nm { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.item .chk { color: #4fd1a5; font-size: 12px; }
.sep { height: 1px; background: #ffffff14; margin: 6px 0; }
.foot .item { color: #b9c2d0; }
@media (prefers-color-scheme: light) {
.menu { background: #ffffff; border-color: rgba(0,0,0,.15); color: #1a1f28; }
.hdr { color: #7b8494; } .item:hover { background: rgba(0,0,0,.05); } .sep { background: rgba(0,0,0,.10); } .foot .item { color: #3c4453; }
}
</style></head>
<body>
<div class="menu">
<div class="hdr">Search with</div>
<div id="list"></div>
<div class="sep"></div>
<div class="foot">
<div class="item" id="detected" hidden><span class="ic"><span class="em"></span></span><span class="nm" id="detName"></span></div>
<div class="item" id="settings"><span class="ic"><span class="em">⚙️</span></span><span class="nm">Search settings…</span></div>
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s || "").replace(/</g, "&lt;");
function report() { requestAnimationFrame(() => { try { window.picker.resize(document.querySelector(".menu").offsetHeight); } catch (e) {} }); }
window.picker.onData ? null : null;
window.picker.onEngines((d) => {
const list = $("list");
list.innerHTML = (d.engines || []).map((e) => {
const ic = e.favicon
? `<img src="${esc(e.favicon)}" onerror="this.replaceWith(Object.assign(document.createElement('span'),{className:'em',textContent:'${(e.sym||'🔍')}'}))">`
: `<span class="em">${e.sym || "🔍"}</span>`;
return `<div class="item" data-id="${esc(e.id)}"><span class="ic">${ic}</span><span class="nm">${esc(e.name)}</span>${e.id === d.current ? '<span class="chk"></span>' : ""}</div>`;
}).join("");
list.querySelectorAll(".item").forEach((el) => el.onclick = () => window.picker.pick(el.dataset.id));
// "Add this site's search" (OpenSearch scan)
const det = $("detected");
if (d.detected && d.detected.name) { det.hidden = false; $("detName").textContent = "Add " + d.detected.name; }
else det.hidden = true;
report();
});
$("detected").onclick = () => window.picker.addDetected();
$("settings").onclick = () => window.picker.openSettings();
</script>
</body></html>

225
main.js
View file

@ -23,20 +23,37 @@ const RESOLVER = app.isPackaged
: path.join(__dirname, "..", "Argus", "src", "lib", "resolver-web.js"); : path.join(__dirname, "..", "Argus", "src", "lib", "resolver-web.js");
// Built-in search engines. Users can also add their own (settings.customEngines, // Built-in search engines. Users can also add their own (settings.customEngines,
// each { id, name, url } where the url contains "%s" for the query). // each { id, name, url } where the url contains "%s" for the query).
// Catalog of built-in engines (users pick which to enable + can add their own).
// fav = the domain to load a real favicon from; sym = emoji fallback.
const SEARCH_ENGINES = { const SEARCH_ENGINES = {
duckduckgo: { name: "DuckDuckGo", sym: "🦆", url: (q) => "https://duckduckgo.com/?q=" + encodeURIComponent(q) }, duckduckgo: { name: "DuckDuckGo", sym: "🦆", fav: "duckduckgo.com", url: (q) => "https://duckduckgo.com/?q=" + encodeURIComponent(q) },
google: { name: "Google", sym: "🔵", url: (q) => "https://www.google.com/search?q=" + encodeURIComponent(q) }, google: { name: "Google", sym: "🔵", fav: "www.google.com", url: (q) => "https://www.google.com/search?q=" + encodeURIComponent(q) },
brave: { name: "Brave", sym: "🦁", url: (q) => "https://search.brave.com/search?q=" + encodeURIComponent(q) }, brave: { name: "Brave", sym: "🦁", fav: "search.brave.com", url: (q) => "https://search.brave.com/search?q=" + encodeURIComponent(q) },
bing: { name: "Bing", sym: "🔎", url: (q) => "https://www.bing.com/search?q=" + encodeURIComponent(q) }, bing: { name: "Bing", sym: "🔎", fav: "www.bing.com", url: (q) => "https://www.bing.com/search?q=" + encodeURIComponent(q) },
startpage: { name: "Startpage", sym: "🛡️", url: (q) => "https://www.startpage.com/sp/search?query=" + encodeURIComponent(q) }, startpage: { name: "Startpage", sym: "🛡️", fav: "www.startpage.com", url: (q) => "https://www.startpage.com/sp/search?query=" + encodeURIComponent(q) },
yandex: { name: "Yandex", sym: "🔴", url: (q) => "https://yandex.com/search/?text=" + encodeURIComponent(q) }, yandex: { name: "Yandex", sym: "🔴", fav: "yandex.com", url: (q) => "https://yandex.com/search/?text=" + encodeURIComponent(q) },
ecosia: { name: "Ecosia", sym: "🌱", fav: "www.ecosia.org", url: (q) => "https://www.ecosia.org/search?q=" + encodeURIComponent(q) },
mojeek: { name: "Mojeek", sym: "🧭", fav: "www.mojeek.com", url: (q) => "https://www.mojeek.com/search?q=" + encodeURIComponent(q) },
searxng: { name: "SearXNG", sym: "🧩", fav: "searx.be", url: (q) => "https://searx.be/search?q=" + encodeURIComponent(q) },
wikipedia: { name: "Wikipedia", sym: "📖", fav: "en.wikipedia.org", url: (q) => "https://en.wikipedia.org/wiki/Special:Search?search=" + encodeURIComponent(q) },
perplexity: { name: "Perplexity", sym: "🧠", fav: "www.perplexity.ai", url: (q) => "https://www.perplexity.ai/search?q=" + encodeURIComponent(q) },
}; };
// Built-in + user-added, as flat metadata for the pickers (sym = a display symbol). // Engines enabled by default (shown in the toolbar dropdown). The rest are in the
// catalog and can be turned on from Settings. Custom + detected engines are always on.
const DEFAULT_ENABLED = ["duckduckgo", "google", "brave", "bing", "startpage"];
const faviconUrl = (domain) => (domain ? `https://${domain}/favicon.ico` : null);
function customFavicon(url) { try { return faviconUrl(new URL(String(url).replace("%s", "x")).hostname); } catch { return null; } }
function isEnabled(id) { return (settings.enabledEngines || DEFAULT_ENABLED).includes(id); }
// Built-in + user-added, as flat metadata for the pickers. Built-in obey
// enabledEngines; custom engines are always enabled.
function allEngines() { function allEngines() {
const list = Object.entries(SEARCH_ENGINES).map(([id, e]) => ({ id, name: e.name, sym: e.sym, builtin: true })); const list = Object.entries(SEARCH_ENGINES).map(([id, e]) =>
for (const c of settings.customEngines || []) list.push({ id: c.id, name: c.name, sym: c.sym || "🔍", builtin: false }); ({ id, name: e.name, sym: e.sym, favicon: faviconUrl(e.fav), builtin: true, enabled: isEnabled(id) }));
for (const c of settings.customEngines || [])
list.push({ id: c.id, name: c.name, sym: c.sym || "🔍", favicon: customFavicon(c.url), builtin: false, enabled: true });
return list; return list;
} }
function enabledEnginesList() { return allEngines().filter((e) => e.enabled); }
function engineUrl(id, q) { function engineUrl(id, q) {
if (SEARCH_ENGINES[id]) return SEARCH_ENGINES[id].url(q); if (SEARCH_ENGINES[id]) return SEARCH_ENGINES[id].url(q);
const c = (settings.customEngines || []).find((e) => e.id === id); const c = (settings.customEngines || []).find((e) => e.id === id);
@ -47,28 +64,37 @@ const SEARCH = (q) => engineUrl(settings.searchEngine, q);
const GATEWAY = "https://navigate.st"; const GATEWAY = "https://navigate.st";
// ---- BNS name detection (multi-TLD) -------------------------------------- // ---- BNS name detection (multi-TLD) --------------------------------------
// The engine (resolver-web.js) resolves any <label>.<tld> from the BCNR beacon. // Theseus is a BNS-native browser: BCNR is the priority registry for EVERY
// Two client-side sets decide how Theseus treats a name: // dotted host, regardless of TLD. The engine (resolver-web.js) resolves any
// NATIVE — BCNR is tried FIRST. If the TLD is also in DUAL (real ICANN), // <label>.<tld> from the BCNR beacon. Flow:
// a BCNR NXDOMAIN or resolver failure falls through to the real // 1. User navigates to `<label>.<tld>` (address bar or link click)
// web instead of erroring. If the TLD is NOT in DUAL, BCNR is // 2. Theseus asks BCNR first
// authoritative and NXDOMAIN shows a clean bns:// error page. // 3. If BCNR has a record — serve it (on-chain h, Sia s3, direct ip, redirect u)
// DUAL — Real ICANN TLDs. Used two ways: // 4. If BCNR NXDOMAINs or is unreachable — fall through to the real web
// (a) present in NATIVE too — unlocks the ICANN fallback above; // (https://<host><path>), so users aren't locked out of the clearnet
// (b) present in DUAL only — Theseus loads the ICANN site // when the chain is down or the name isn't registered.
// normally and surfaces a passive "also on BCNR" switch. // Non-BNS-eligible hosts (bare IPv4/IPv6, localhost, single-label hostnames,
const BNS_NATIVE_TLDS = new Set(["bch", "p2p", "bit", "nav", "x", "sia", "dgb", "nt", "de", "ru"]); // non-http schemes) bypass BCNR and load directly.
const BNS_DUAL_TLDS = new Set(["de", "dev", "ltd", "ru"]); // The former NATIVE/DUAL sets are gone — Theseus doesn't privilege ICANN.
const REGISTRY = "BCNR"; // user-facing registry label (Bitcoin Cash Name Registry) const REGISTRY = "BCNR"; // user-facing registry label (Bitcoin Cash Name Registry)
const tldOf = (host) => { const tldOf = (host) => {
const h = String(host).toLowerCase().replace(/\.$/, ""); const h = String(host).toLowerCase().replace(/\.$/, "");
const dot = h.lastIndexOf("."); const dot = h.lastIndexOf(".");
return dot < 0 ? null : h.slice(dot + 1); return dot < 0 ? null : h.slice(dot + 1);
}; };
const nativeTld = (host) => { const t = tldOf(host); return t && BNS_NATIVE_TLDS.has(t) ? t : null; }; // Any dotted host that isn't an IP or localhost is a BCNR candidate.
const dualTld = (host) => { const t = tldOf(host); return t && BNS_DUAL_TLDS.has(t) ? t : null; }; const isBnsHost = (host) => {
// Kept for will-navigate: only native BNS hosts are intercepted as BCNR up front. if (!host) return false;
const isBnsHost = (host) => nativeTld(host) !== null; const h = String(host).toLowerCase().replace(/\.$/, "");
if (h === "localhost" || h.startsWith("localhost:")) return false;
if (/^\d{1,3}(\.\d{1,3}){3}(:\d+)?$/.test(h)) return false; // IPv4[:port]
if (h.startsWith("[")) return false; // IPv6 literal
const dot = h.lastIndexOf(".");
return dot > 0 && dot < h.length - 1; // has a real TLD
};
// Kept as aliases so external callers (tests, module.exports) don't break.
const nativeTld = (host) => isBnsHost(host) ? tldOf(host) : null;
const dualTld = () => null; // dual-priority mode is gone — no ICANN-first TLDs
const registryOf = (_tld) => REGISTRY; const registryOf = (_tld) => REGISTRY;
// Address-bar heuristic: is this input a URL/hostname, or a search query? Mirrors // Address-bar heuristic: is this input a URL/hostname, or a search query? Mirrors
@ -96,6 +122,7 @@ const SETTINGS_DEFAULTS = {
languageMode: "show", languageValue: "en-US", // locale for manual languageMode: "show", languageValue: "en-US", // locale for manual
locationMode: "hide", locationLat: "40.7128", locationLon: "-74.0060", // coords for manual (default: deny) locationMode: "hide", locationLat: "40.7128", locationLon: "-74.0060", // coords for manual (default: deny)
searchEngine: "duckduckgo",// default search engine (built-in id or a custom id) searchEngine: "duckduckgo",// default search engine (built-in id or a custom id)
enabledEngines: DEFAULT_ENABLED.slice(), // which built-in engines show in the dropdown
customEngines: [], // user-added: [{ id, name, url-with-%s }] customEngines: [], // user-added: [{ id, name, url-with-%s }]
theme: "dark", // dark | light | system — drives prefers-color-scheme in all views theme: "dark", // dark | light | system — drives prefers-color-scheme in all views
}; };
@ -121,7 +148,10 @@ const bookmarksFile = () => path.join(app.getPath("userData"), "bookmarks.json")
function loadBookmarks() { try { if (fs.existsSync(bookmarksFile())) bookmarks = JSON.parse(fs.readFileSync(bookmarksFile(), "utf8")); } catch (e) { console.error("bookmarks load failed:", e.message); } } function loadBookmarks() { try { if (fs.existsSync(bookmarksFile())) bookmarks = JSON.parse(fs.readFileSync(bookmarksFile(), "utf8")); } catch (e) { console.error("bookmarks load failed:", e.message); } }
function saveBookmarks() { try { fs.writeFileSync(bookmarksFile(), JSON.stringify(bookmarks, null, 2)); } catch (e) { console.error("bookmarks save failed:", e.message); } } function saveBookmarks() { try { fs.writeFileSync(bookmarksFile(), JSON.stringify(bookmarks, null, 2)); } catch (e) { console.error("bookmarks save failed:", e.message); } }
function emitBookmarks() { try { chrome?.webContents.send("bookmarks", bookmarks); } catch {} } function emitBookmarks() { try { chrome?.webContents.send("bookmarks", bookmarks); } catch {} }
function emitEngines() { try { chrome?.webContents.send("engines", { engines: allEngines(), current: settings.searchEngine }); } catch {} } function emitEngines() {
try { chrome?.webContents.send("engines", { engines: enabledEnginesList(), current: settings.searchEngine }); } catch {}
if (epVisible) try { enginePicker?.webContents.send("engines", { engines: enabledEnginesList(), current: settings.searchEngine, detected: activeTab()?.detected || null }); } catch {}
}
// WebRTC IP-handling policy — the same control the "WebRTC Network Limiter" // WebRTC IP-handling policy — the same control the "WebRTC Network Limiter"
// Chrome extension provides, done natively (that extension's chrome.privacy API // Chrome extension provides, done natively (that extension's chrome.privacy API
// isn't available in Electron, and this is more reliable). Tor forces the strongest. // isn't available in Electron, and this is more reliable). Tor forces the strongest.
@ -322,6 +352,21 @@ async function contentFetch(url, init = {}) {
const r = await fetch(url, init); const r = await fetch(url, init);
return { status: r.status, contentType: r.headers.get("content-type"), buffer: Buffer.from(await r.arrayBuffer()) }; return { status: r.status, contentType: r.headers.get("content-type"), buffer: Buffer.from(await r.arrayBuffer()) };
} }
// OpenSearch "scan": fetch a page's OpenSearch description and turn its HTML
// search template into our { name, url-with-%s } form.
async function fetchOpenSearch(href) {
try {
const r = await contentFetch(href, {});
const xml = r.buffer.toString("utf8");
const nameM = xml.match(/<ShortName>([^<]+)<\/ShortName>/i);
const urlM = xml.match(/<Url\b[^>]*type=["']text\/html["'][^>]*template=["']([^"']+)["']/i)
|| xml.match(/<Url\b[^>]*template=["']([^"']+)["'][^>]*type=["']text\/html["']/i);
if (!urlM) return null;
const template = urlM[1].replace(/\{searchTerms\??\}/gi, "%s").replace(/\{[^}]*\}/g, ""); // drop other {params}
if (!template.includes("%s") || !/^https?:\/\//i.test(template)) return null;
return { name: (nameM ? nameM[1] : new URL(href).hostname).trim().slice(0, 40), url: template };
} catch { return null; }
}
// ---- electrum server pool: hardcoded seed + on-chain discovery, persisted ---- // ---- electrum server pool: hardcoded seed + on-chain discovery, persisted ----
// Bootstrap from the baked-in seed (with pinned IPs), then refresh from the // Bootstrap from the baked-in seed (with pinned IPs), then refresh from the
@ -435,6 +480,10 @@ let CHROME_H = 84; // grows when an extra bar (Tor notice / BCNR offer) is sh
// never pushes the page down. Positioned under the address-bar badge on demand. // never pushes the page down. Positioned under the address-bar badge on demand.
let popover, popVisible = false, popPos = { x: 8, y: 90 }; let popover, popVisible = false, popPos = { x: 8, y: 90 };
const POP_W = 360; let popH = 210; // popH is updated to fit the popover's content const POP_W = 360; let popH = 210; // popH is updated to fit the popover's content
// Engine-picker: a second floating overlay VIEW (a custom dropdown that shows real
// engine favicons, like Firefox — a native <select> can't render images).
let enginePicker, epVisible = false, epPos = { x: 8, y: 90 };
const EP_W = 250; let epH = 320;
const tabs = []; // { id, view, title, url, prov } const tabs = []; // { id, view, title, url, prov }
let activeId = null, tabSeq = 0; let activeId = null, tabSeq = 0;
const tabById = (id) => tabs.find((t) => t.id === id); const tabById = (id) => tabs.find((t) => t.id === id);
@ -454,6 +503,7 @@ function layout() {
const bodyH = Math.max(0, height - CHROME_H); const bodyH = Math.max(0, height - CHROME_H);
for (const t of tabs) t.view.setBounds({ x: 0, y: CHROME_H, width, height: bodyH }); for (const t of tabs) t.view.setBounds({ x: 0, y: CHROME_H, width, height: bodyH });
positionPopover(); positionPopover();
positionEnginePicker();
} }
function positionPopover() { function positionPopover() {
if (!popover) return; if (!popover) return;
@ -472,9 +522,26 @@ function showPopover(show) {
popover.webContents.send("site-info", activeTab()?.prov || { kind: "home" }); popover.webContents.send("site-info", activeTab()?.prov || { kind: "home" });
} else { popover.setVisible(false); popVisible = false; } } else { popover.setVisible(false); popVisible = false; }
} }
function positionEnginePicker() {
if (!enginePicker) return;
const { width } = win.getContentBounds();
const x = Math.max(6, Math.min(epPos.x, width - EP_W - 6));
enginePicker.setBounds({ x, y: epPos.y, width: EP_W, height: epH });
}
function showEnginePicker(show) {
if (!enginePicker) return;
if (show) {
positionEnginePicker();
win.contentView.removeChildView(enginePicker);
win.contentView.addChildView(enginePicker);
enginePicker.setVisible(true); epVisible = true;
enginePicker.webContents.send("engines", { engines: enabledEnginesList(), current: settings.searchEngine, detected: activeTab()?.detected || null });
} else { enginePicker.setVisible(false); epVisible = false; }
}
function setActive(id) { function setActive(id) {
activeId = id; activeId = id;
if (popVisible) showPopover(false); // don't carry a stale popover across tabs if (popVisible) showPopover(false); // don't carry a stale popover across tabs
if (epVisible) showEnginePicker(false);
for (const t of tabs) t.view.setVisible(t.id === id); for (const t of tabs) t.view.setVisible(t.id === id);
const t = activeTab(); const t = activeTab();
if (t?.prov) pushNav(t.prov); if (t?.prov) pushNav(t.prov);
@ -513,8 +580,20 @@ function createTab(initial, opts = {}) {
wc.on("page-title-updated", (_e, title) => { tab.title = title; emitTabs(); }); wc.on("page-title-updated", (_e, title) => { tab.title = title; emitTabs(); });
wc.on("did-navigate", () => emitTabs()); wc.on("did-navigate", () => emitTabs());
wc.on("did-navigate-in-page", () => emitTabs()); wc.on("did-navigate-in-page", () => emitTabs());
wc.on("did-start-loading", () => setLoading(tab, true)); wc.on("did-start-loading", () => { tab.detected = null; setLoading(tab, true); });
wc.on("did-stop-loading", () => setLoading(tab, false)); wc.on("did-stop-loading", () => setLoading(tab, false));
// OpenSearch scan: note any search engine the page advertises, so the picker
// can offer to add it ("Add <site>'s search").
wc.on("did-finish-load", async () => {
if (tab.settings) return;
try {
const href = await wc.executeJavaScript(
`(()=>{const l=document.querySelector('link[rel="search"][type="application/opensearchdescription+xml"]');return l?new URL(l.getAttribute('href'),location.href).href:null})()`, true);
if (!href) return;
const osd = await fetchOpenSearch(href);
if (osd) { tab.detected = osd; if (tab.id === activeId && epVisible) emitEngines(); }
} catch {}
});
wc.on("will-navigate", (e, u) => { wc.on("will-navigate", (e, u) => {
try { try {
const parsed = new URL(u); const parsed = new URL(u);
@ -581,6 +660,12 @@ function createWindow() {
win.contentView.addChildView(popover); win.contentView.addChildView(popover);
popover.webContents.loadFile("popover.html"); popover.webContents.loadFile("popover.html");
popover.setVisible(false); popover.setVisible(false);
// Floating engine-picker overlay (custom dropdown with real favicons).
enginePicker = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "engine-picker-preload.js") } });
try { enginePicker.setBackgroundColor("#00000000"); } catch {}
win.contentView.addChildView(enginePicker);
enginePicker.webContents.loadFile("engine-picker.html");
enginePicker.setVisible(false);
chrome.webContents.once("did-finish-load", () => { chrome.webContents.once("did-finish-load", () => {
const saved = settings.restoreSession ? loadSession() : []; const saved = settings.restoreSession ? loadSession() : [];
if (saved.length) saved.forEach((u) => createTab(u)); else createTab(); if (saved.length) saved.forEach((u) => createTab(u)); else createTab();
@ -600,48 +685,32 @@ async function navigateTab(id, input) {
const raw = q.replace(/^[a-z]+:\/\//i, ""); const raw = q.replace(/^[a-z]+:\/\//i, "");
const host = raw.split("/")[0].toLowerCase(); const host = raw.split("/")[0].toLowerCase();
const rest = raw.slice(host.length) || "/"; const rest = raw.slice(host.length) || "/";
const native = nativeTld(host);
const dual = dualTld(host);
// Every fresh navigation clears any stale "also on BCNR" offer for this tab.
t.nav = (t.nav || 0) + 1; t.nav = (t.nav || 0) + 1;
const navId = t.nav; // Legacy "also on BCNR" chip state — kept clean; the passive switch is gone
// now that BCNR is priority for every host.
t.bcnrOffer = null; t.bcnrOffer = null;
if (id === activeId) chrome.webContents.send("bcnr-offer", null); if (id === activeId) chrome.webContents.send("bcnr-offer", null);
// Native BCNR TLD (.bch/.p2p/.nav): resolve from the registry outright. // BCNR-first for every dotted host. loadBns falls through to https://<host>
if (native) return loadBns(t, id, host, rest, native); // on NXDOMAIN or resolver failure, so clearnet still works.
if (isBnsHost(host)) return loadBns(t, id, host, rest, tldOf(host));
// Ordinary web — and dual-use ICANN TLDs (.de/.dev/.ltd) — load the REAL site // Non-BNS-eligible input: raw IP, localhost, single-label — load direct.
// immediately. We never hijack or delay the clearnet web.
t.url = q.includes("://") ? q : "https://" + q; t.url = q.includes("://") ? q : "https://" + q;
await t.view.webContents.loadURL(t.url); await t.view.webContents.loadURL(t.url);
t.prov = { host, kind: "web" }; t.prov = { host, kind: "web" };
if (id === activeId) pushNav(t.prov); if (id === activeId) pushNav(t.prov);
emitTabs(); emitTabs();
// Dual-use: in parallel, check whether the name also exists on BCNR. If it
// does, offer a passive switch — the user opts in; nothing is forced.
if (dual) {
resolveHost(host).then((entry) => {
if (entry && tabById(id) === t && t.nav === navId) {
t.bcnrOffer = { host, rest, tld: dual };
if (id === activeId) chrome.webContents.send("bcnr-offer", { host, tld: dual, registry: REGISTRY });
}
}).catch(() => {});
}
} }
// Load a name from BCNR into a tab — used by native TLDs and by an accepted // Load a name from BCNR into a tab. Called for every dotted host — BCNR is
// dual-use switch from the "also on BCNR" bar. // tried first; on NXDOMAIN or resolver failure we always fall through to the
// clearnet (https://<host><rest>) so the user isn't stranded when the chain
// is down or the name isn't registered.
async function loadBns(t, id, host, rest, tld) { async function loadBns(t, id, host, rest, tld) {
const registry = registryOf(tld); const registry = registryOf(tld);
if (id === activeId) pushNav({ host, kind: "resolving", tld, registry }); if (id === activeId) pushNav({ host, kind: "resolving", tld, registry });
// A NATIVE TLD that is ALSO in DUAL is a real ICANN TLD — BCNR is priority,
// but a miss (NXDOMAIN or resolver failure) falls through to the real web so
// users aren't locked out of e.g. bank.de because a name isn't on chain or
// the beacon is unreachable.
const canFallbackToWeb = BNS_DUAL_TLDS.has(tld);
const fallbackToWeb = async (reason) => { const fallbackToWeb = async (reason) => {
t.url = "https://" + host + (rest === "/" ? "" : rest); t.url = "https://" + host + (rest === "/" ? "" : rest);
try { await t.view.webContents.loadURL(t.url); } catch {} try { await t.view.webContents.loadURL(t.url); } catch {}
@ -651,17 +720,9 @@ async function loadBns(t, id, host, rest, tld) {
}; };
let entry; let entry;
try { entry = await resolveHost(host); } try { entry = await resolveHost(host); }
catch (e) { catch { setLoading(t, false); return fallbackToWeb("BCNR unreachable"); }
if (canFallbackToWeb) { setLoading(t, false); return fallbackToWeb("BCNR unreachable"); }
setLoading(t, false); t.prov = { host, kind: "error", error: e.message, tld, registry }; if (id === activeId) pushNav(t.prov); return;
}
t.url = host + (rest === "/" ? "" : rest); t.url = host + (rest === "/" ? "" : rest);
if (!entry) { if (!entry) { setLoading(t, false); return fallbackToWeb("no BCNR record"); }
if (canFallbackToWeb) { setLoading(t, false); return fallbackToWeb("no BCNR record"); }
t.prov = { host, kind: "nxdomain", tld, registry };
await t.view.webContents.loadURL(`bns://${host}/`);
if (id === activeId) pushNav(t.prov); emitTabs(); return;
}
await t.view.webContents.loadURL(`bns://${host}${rest}`); await t.view.webContents.loadURL(`bns://${host}${rest}`);
const src = entry.records.h ? "on-chain (chain)" : entry.records.s3 ? "Sia network" : entry.records.ip ? "direct server" : entry.records.u ? "redirect" : "record"; const src = entry.records.h ? "on-chain (chain)" : entry.records.s3 ? "Sia network" : entry.records.ip ? "direct server" : entry.records.u ? "redirect" : "record";
t.prov = { host, kind: "ok", source: src, category: entry.category, records: Object.keys(entry.records), tld, registry }; t.prov = { host, kind: "ok", source: src, category: entry.category, records: Object.keys(entry.records), tld, registry };
@ -706,10 +767,46 @@ ipcMain.handle("add-engine", (_e, eng) => {
}); });
ipcMain.handle("remove-engine", (_e, id) => { ipcMain.handle("remove-engine", (_e, id) => {
settings.customEngines = (settings.customEngines || []).filter((e) => e.id !== id); settings.customEngines = (settings.customEngines || []).filter((e) => e.id !== id);
if (settings.searchEngine === id) settings.searchEngine = "duckduckgo"; if (settings.searchEngine === id) settings.searchEngine = enabledEnginesList()[0]?.id || "duckduckgo";
saveSettings(); emitEngines(); saveSettings(); emitEngines();
return { engines: allEngines(), current: settings.searchEngine }; return { engines: allEngines(), current: settings.searchEngine };
}); });
// Enable/disable a built-in engine (which show in the toolbar dropdown).
ipcMain.handle("set-engine-enabled", (_e, id, on) => {
if (SEARCH_ENGINES[id]) {
let list = (settings.enabledEngines || DEFAULT_ENABLED).filter((x) => x !== id);
if (on) list.push(id);
settings.enabledEngines = list.length ? list : ["duckduckgo"]; // never empty
if (!enabledEnginesList().some((e) => e.id === settings.searchEngine))
settings.searchEngine = enabledEnginesList()[0]?.id || "duckduckgo";
saveSettings(); emitEngines();
}
return { engines: allEngines(), current: settings.searchEngine };
});
// The custom dropdown overlay (real favicons).
ipcMain.handle("toggle-engine-picker", (_e, rect) => {
if (epVisible) return showEnginePicker(false);
if (rect) epPos = { x: Math.round(rect.x), y: Math.round(rect.y) };
showEnginePicker(true);
});
ipcMain.handle("close-engine-picker", () => showEnginePicker(false));
ipcMain.handle("ep-resize", (_e, h) => { epH = Math.max(80, Math.min(440, Math.round(h) || 320)); if (epVisible) positionEnginePicker(); });
ipcMain.handle("pick-engine", (_e, id) => {
if (enabledEnginesList().some((e) => e.id === id)) { settings.searchEngine = id; saveSettings(); emitEngines(); }
showEnginePicker(false);
});
ipcMain.handle("picker-open-settings", () => { showEnginePicker(false); const ex = tabs.find((t) => t.settings); if (ex) return setActive(ex.id); createTab(null, { settings: true }); });
// OpenSearch "scan": add the search engine the current page advertises.
ipcMain.handle("add-detected-engine", () => {
const d = activeTab()?.detected;
if (d && d.url && d.url.includes("%s")) {
const id = "custom-" + Date.now().toString(36);
settings.customEngines = [...(settings.customEngines || []), { id, name: d.name.slice(0, 40), sym: "🔍", url: d.url.slice(0, 400) }];
settings.searchEngine = id;
saveSettings(); emitEngines();
}
showEnginePicker(false);
});
ipcMain.handle("bookmarks-get", () => bookmarks); ipcMain.handle("bookmarks-get", () => bookmarks);
ipcMain.handle("bookmark-add", (_e, bm) => { ipcMain.handle("bookmark-add", (_e, bm) => {
if (bm && bm.url && !bookmarks.some((b) => b.url === bm.url)) { if (bm && bm.url && !bookmarks.some((b) => b.url === bm.url)) {

View file

@ -19,6 +19,7 @@ contextBridge.exposeInMainWorld("theseus", {
setSearchEngine: (id) => ipcRenderer.invoke("set-search-engine", id), setSearchEngine: (id) => ipcRenderer.invoke("set-search-engine", id),
addEngine: (eng) => ipcRenderer.invoke("add-engine", eng), addEngine: (eng) => ipcRenderer.invoke("add-engine", eng),
removeEngine: (id) => ipcRenderer.invoke("remove-engine", id), removeEngine: (id) => ipcRenderer.invoke("remove-engine", id),
toggleEnginePicker: (rect) => ipcRenderer.invoke("toggle-engine-picker", rect),
onEngines: (cb) => ipcRenderer.on("engines", (_e, d) => cb(d)), onEngines: (cb) => ipcRenderer.on("engines", (_e, d) => cb(d)),
getBookmarks: () => ipcRenderer.invoke("bookmarks-get"), getBookmarks: () => ipcRenderer.invoke("bookmarks-get"),
addBookmark: (bm) => ipcRenderer.invoke("bookmark-add", bm), addBookmark: (bm) => ipcRenderer.invoke("bookmark-add", bm),

View file

@ -5,4 +5,5 @@ contextBridge.exposeInMainWorld("cfg", {
engines: () => ipcRenderer.invoke("search-engines"), engines: () => ipcRenderer.invoke("search-engines"),
addEngine: (eng) => ipcRenderer.invoke("add-engine", eng), addEngine: (eng) => ipcRenderer.invoke("add-engine", eng),
removeEngine: (id) => ipcRenderer.invoke("remove-engine", id), removeEngine: (id) => ipcRenderer.invoke("remove-engine", id),
setEngineEnabled: (id, on) => ipcRenderer.invoke("set-engine-enabled", id, on),
}); });

View file

@ -35,6 +35,13 @@
.btn:hover{background:rgba(214,255,61,.22)} .btn:hover{background:rgba(214,255,61,.22)}
.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{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} .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}
.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 .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} .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} .cempty{color:var(--dim);font-size:12.5px}
@ -54,7 +61,7 @@
select, .ctl input, .addeng input{ background:#f1f3f7; } select, .ctl input, .addeng input{ background:#f1f3f7; }
.track{ background:#c8cfdb; } .track{ background:#c8cfdb; }
.knob{ background:#8a93a2; } .knob{ background:#8a93a2; }
code, .ceng{ background:#eef1f6; } code, .ceng, .eng{ background:#eef1f6; }
} }
</style></head> </style></head>
<body> <body>
@ -76,13 +83,13 @@
<div class="ctl"><select id="theme"><option value="dark">Dark</option><option value="light">Light</option><option value="system">System</option></select></div> <div class="ctl"><select id="theme"><option value="dark">Dark</option><option value="light">Light</option><option value="system">System</option></select></div>
</div> </div>
<div class="row"> <div class="row">
<div class="txt"><div class="t">Search engine</div><div class="d">Used when you type a search into the address bar.</div></div> <div class="txt"><div class="t">Default search engine</div><div class="d">Used when you type a search into the address bar.</div></div>
<div class="ctl"><select id="searchEngine"></select></div> <div class="ctl"><select id="searchEngine"></select></div>
</div> </div>
<div class="row" style="flex-direction:column;align-items:stretch;gap:12px"> <div class="row" style="flex-direction:column;align-items:stretch;gap:10px">
<div class="txt"><div class="t">Add a search engine</div> <div class="txt"><div class="t">Search engines</div>
<div class="d">Name it, and give a URL with <code>%s</code> where the query goes — e.g. <code>https://searx.be/search?q=%s</code></div></div> <div class="d">Tick which appear in the toolbar's search dropdown. Add your own with a URL containing <code>%s</code> — e.g. <code>https://searx.be/search?q=%s</code>. Theseus also offers to add a site's own search when you visit one.</div></div>
<div id="customList"></div> <div id="engineList"></div>
<div class="addeng"> <div class="addeng">
<input id="engSym" placeholder="🔍" style="max-width:52px;text-align:center;flex:none"> <input id="engSym" placeholder="🔍" style="max-width:52px;text-align:center;flex:none">
<input id="engName" placeholder="Name (e.g. SearXNG)"> <input id="engName" placeholder="Name (e.g. SearXNG)">
@ -177,14 +184,21 @@
// search engines: default picker + custom-engine list + add form // search engines: default picker + custom-engine list + add form
const sel = document.getElementById("searchEngine"); const sel = document.getElementById("searchEngine");
const esc = (s) => String(s || "").replace(/</g, "&lt;"); const esc = (s) => String(s || "").replace(/</g, "&lt;");
const engIcon = (e) => e.favicon
? `<img class="ei" src="${esc(e.favicon)}" onerror="this.replaceWith(Object.assign(document.createElement('span'),{className:'es',textContent:'${e.sym || "🔍"}'}))">`
: `<span class="es">${e.sym || "🔍"}</span>`;
function renderEngines(d) { function renderEngines(d) {
sel.innerHTML = d.engines.map((e) => `<option value="${e.id}">${(e.sym ? e.sym + " " : "")}${esc(e.name)}</option>`).join(""); // default engine: pick from the ENABLED engines (those shown in the dropdown)
const enabled = d.engines.filter((e) => e.enabled);
sel.innerHTML = enabled.map((e) => `<option value="${e.id}">${(e.sym ? e.sym + " " : "")}${esc(e.name)}</option>`).join("");
sel.value = d.current; sel.value = d.current;
const custom = d.engines.filter((e) => !e.builtin); // unified checklist: built-in engines get an enable toggle; custom get a remove ✕
const list = document.getElementById("customList"); const list = document.getElementById("engineList");
list.innerHTML = custom.length list.innerHTML = d.engines.map((e) => `<div class="eng"><span class="eic">${engIcon(e)}</span><span class="enm">${esc(e.name)}</span>` +
? custom.map((e) => `<div class="ceng"><span class="cs">${e.sym || "🔍"}</span><span class="cn">${esc(e.name)}</span><span class="cu">${esc(e.id)}</span><button class="cx" data-id="${e.id}" title="Remove"></button></div>`).join("") (e.builtin
: `<div class="cempty">No custom engines yet.</div>`; ? `<label class="sw sm"><input type="checkbox" data-id="${e.id}" ${e.enabled ? "checked" : ""}><span class="track"><span class="knob"></span></span></label>`
: `<button class="cx" data-id="${e.id}" title="Remove"></button>`) + `</div>`).join("");
list.querySelectorAll('input[type="checkbox"]').forEach((cb) => cb.onchange = () => C.setEngineEnabled(cb.dataset.id, cb.checked).then(renderEngines));
list.querySelectorAll(".cx").forEach((b) => b.onclick = () => C.removeEngine(b.dataset.id).then(renderEngines)); list.querySelectorAll(".cx").forEach((b) => b.onclick = () => C.removeEngine(b.dataset.id).then(renderEngines));
} }
sel.onchange = () => C.set("searchEngine", sel.value); sel.onchange = () => C.set("searchEngine", sel.value);