Compare commits

...

11 commits

Author SHA1 Message Date
Local Dev
f3a8d883e2 Theseus 0.3.125: Claude connector gets a simultaneous-session cap
New row in the connector panel: Simultaneous sessions. Three radios —
1 only / Multi / No limit — and a number input next to Multi for the
exact cap (clamped 1-100). Default stays at no limit, so existing users
see no behaviour change until they pick a value.

On the server side, initialize counts live sessions first (a GC pass over
expired ones runs so an abandoned tab doesn't permanently hold a slot)
and refuses any that would push past the cap with 429 and a message the
client surfaces to the user ("Theseus is at its simultaneous-session
cap (<N>). Disconnect a client, or raise the limit..."). Lowering the
cap below the live count is non-retroactive: no one gets kicked, but the
next connect fails until one goes away.

Settings surface widened: publicState() carries sessionLimit, and a new
set-session-limit message writes it (persisted in the add-on's storage
alongside token / port / allowEvaluate).
2026-10-27 22:30:23 +02:00
Local Dev
1916d6adfe Theseus 0.3.124 (cont'd): Shield panel gets its own close button
Shield is a right-side add-on panel (no `side` in the manifest). Unlike
left panels, right-side ones get no shared chrome from Theseus — the
panel-header with Maximize/Close only draws over left panels. So the
Shield panel had no visible close button and the user had to re-click
the Shield icon in the dock to toggle it off.

Added a × button in the add-on's own header that calls
window.silentmode.close() (sidebar-preload's close IPC). Minimal CSS
with a hover tint that matches the panel-header's own close.
2026-10-27 22:02:23 +02:00
Local Dev
1f394bb524 Theseus 0.3.124 (cont'd): the General row is Theseus ID, not Theseus Shield
The subagent's first pass took the Settings brief's parenthetical note
("previously Theseus ID") to mean "this row sits where Theseus ID used
to live", and wired the row to the Shield (adblocker) add-on with its
on/off toggle. The intent was simpler: just put Theseus ID back as a
regular chevron row in General. Dropped the Shield toggle/opener wiring
and reset the row to label and link Theseus ID, matching the Theseus ID
chevron that used to live below Updates (now consolidated).
2026-10-27 21:59:07 +02:00
Local Dev
b1b393f02f Theseus 0.3.124 (cont'd): Ariadne nested under System; Theseus logo toggles the Settings tab
Ariadne's Thread drops out of the Settings top-level nav. It is reached
from the System section's chevron now; its page gets a System › crumb
back. Aegis stays top-level (user-facing wallet, used often enough to
earn its own slot).

The Theseus logo in the toolbar was open-only — the second click did
nothing. It now toggles: if Settings is already the active tab, the
click closes it (and switches to whatever tab Theseus falls back to).
Deep links that open a specific section (openSettings("ai-engines")
from a picker, say) still always open — toggling is the intent of the
logo click specifically, not of every open-settings call.
2026-10-27 21:57:32 +02:00
Local Dev
94cfb98770 Theseus 0.3.124 (cont'd): sidebar auto-close on click now fires in Settings and add-on tabs too
The overlay sidebar closes when the page is clicked — but only for web
tabs, because only home-preload reported the pointerdown. Settings and
full-tab add-on pages use their own preloads, so clicking inside them
left the overlay panel sitting on top. Added the same pointerdown
reporter to settings-preload and addon-tab-preload. Push-mode behaviour
is unchanged (main gates on sidebarPanelMode).
2026-10-27 21:55:45 +02:00
Local Dev
de1a5e18d4 Theseus 0.3.124: Open-with label trimmed; file assocs auto-on; Settings sidebar gets System (holds Names & Certificates)
Open-with menu label — Windows was showing the per-extension ProgID's
default value, which was set to "Theseus HTML Document". That looked
verbose next to the real brand name, and the per-ext label never changed
regardless of which extension the user right-clicked. Shortened to just
"Theseus Navigator". The ApplicationDescription under Capabilities is
dropped too — Default Apps uses it as a tooltip and the sentence it held
was noise.

File associations are now auto-registered on first launch. The toggle in
Settings is dropped: the entries live under HKCU with no admin, cause no
harm by being present, and the common case (right-click an .html on your
desktop, see Theseus in the Open-with menu) is strictly better than the
previous "toggle it on first" step. Two per-profile flags remember the
rollout (fileAssocsAutoApplied) and the one-time 0.3.124 label rewrite
(fileAssocsLabelFixed_1); a user who removes the entries by hand stays
opted out.

Settings sidebar — the Plug-ins nav entry is renamed System and now holds
a chevron to Names & Certificates (which is no longer a separate top-
level nav entry). The underlying section id stays "plugins" so the
plugins-ariadne / plugins-aegis crumbs and any existing deep links keep
working. Names & Certificates picks up a System › crumb at the top.
2026-10-27 21:50:06 +02:00
Local Dev
1d4e85f1d2 Theseus 0.3.123 (cont'd): Shield + Cookie Pop-ups now apply to sidebar AI panels
Shield (adblocker) already ran in the sidebar — it hooks the default
session's webRequest, so any WebContentsView on that session got the same
filter as a tab. Cookie Pop-ups (consent) didn't: the add-on-inject
preload asks main which scripts to run, and the gate required the sender
to be a web tab. Sidebar panels aren't in `tabs`, so sites loaded in the
sparkle-opened AI panel (and quick-link apps like WhatsApp, Telegram)
still showed every consent banner.

Fix is in injectionsForSender (and addon-page-msg for the inject's
round-trip back): also accept quickPanel.webContents and leftPanel's
sender as a "page" for the purpose of page-inject add-ons. The scripts
still see only the URL — nothing panel-specific — so no new attack
surface is opened; everything on the user's enabled page-inject list
runs on the same origins there as it does in a tab.
2026-10-27 21:05:05 +02:00
Local Dev
fb3fa84d15 Theseus 0.3.123 (cont'd): catalog visible by default, "+ Show more" at the bottom
The curated catalog was hidden behind the "+ Add search engine" button at
the top-right of the header, which buried the common case (pick one of
the five curated options) behind an extra click. Flipped the layout so
the curated catalog paints inline right below the Enabled list, and the
"+ Show more" button lives at the bottom — opens the Discover-more bank
and the custom-URL form. Same change on the AI engines page.
2026-10-27 20:58:48 +02:00
Local Dev
ffc4d4d79e Theseus 0.3.123: error page's Did-you-mean row gains a Search chip
The "Did you mean" row on name-unreachable / name-not-registered used to
show only URL suggestions (BCNR warm-index matches + ICANN TLD swaps).
It now also carries a trailing "🔎 Search '<host>'" chip that defers to
the user's default search engine, so a DNS failure where none of the URL
alternatives look right still gives a one-click recovery path.

When no URL alternative surfaces at all — just the search chip is enough
on its own — the row's label softens from "Did you mean" to "Not sure
what you were after?" so a bare search chip reads naturally.
2026-10-27 20:55:11 +02:00
Local Dev
aea158ab05 Theseus 0.3.122 followups: AI engines page, Pick-AI edit button, Pithos panel, Did-you-mean on DNS failures, LLM picker UI polish, real-brand icon preference
Shipped on top of the 0.3.122 Settings restructure commit.

AI engines settings page. New top-level nav entry "AI engines" with its own
Default picker, Enabled list, catalog, discover filter and + Add AI engine
form. The Search page is now LLM-free — ENGINE_KINDS is `["search"]`, the
Search catalog filters out kind="llm" entries, and the Search custom-URL
form only adds kind="search" engines. add-engine IPC now accepts a kind:
"llm" field (sets defaultLlmId instead of searchEngine for LLM customs).

Pick-AI picker. Borders removed, corners rounded (16px), heavier shadow;
"Pick AI engines…" footer row dropped. Edit button (pencil icon) added to
the header; opens the new AI engines settings page via `llm-picker-settings`,
which now routes to the "ai-engines" slug (openSettingsTab falls through to
"search" for legacy profiles that lack the section).

Icon priority flipped. allEngines() now prefers a fetched favicon (the
site's own brand mark, cached per profile) over the bundled generic tile.
Bundled tiles still carry the first paint while the fetch runs, and the
emit after fetch swaps in the real brand. Offline profiles see the tile
indefinitely — same reliability as before.

Pithos sidebar panel. bundled-addons/pithos/addon.json declares a `panels`
block so sidebarLeftPanels() returns Pithos's panel BEFORE the add-on's
on-demand activation. Previously, the Sidebar settings' Theseus native
apps card was missing Pithos: it only appeared after first launch, when
the add-on's own registerSidebarPanel call ran. Hermes already did this.

Did-you-mean on DNS failures. The error page's "Did you mean" row now
merges BCNR warm-index matches with ICANN alternative-TLD swaps + history
base-name matches. For my.mynymbox.com the suggestions are my.mynymbox.io,
mynymbox.io, my.mynymbox.net, mynymbox.net — the subdomain-strip is
interleaved so a mistake like typing my.mynymbox.com when the real site
is client.mynymbox.io still surfaces mynymbox.io near the top. Both paths
are local-only; no network, no leak of what the user is browsing.

Sidebar settings tightened. "How panels open" collapsed to one row with
two side-by-side option cards; "Reset panel widths" and "AI engine in the
sidebar" are one-liners now.
2026-10-27 20:50:40 +02:00
Local Dev
1a31ac0393 Theseus 0.3.122: Settings restructure — General first-rows, top-level Ariadne & Aegis, Sidebar native-apps card
Settings page UX restructure.

- Nav bar: reordered to General, Language, Search, Passwords, Performance,
  Privacy, Names & Certificates, Plug-ins, Ariadne's Thread, Aegis,
  Extensions, Sidebar, Apps. Dropped the top-level Theseus ID entry;
  Theseus ID is reachable from General now. Added two new top-level
  entries — Ariadne's Thread and Aegis — each a small landing section
  that chevrons into the existing plug-in sub-page (Ariadne) or opens the
  wallet panel via openPanel("aegis:main") (Aegis).

- General section: first four rows are now Make-Theseus-default, Theseus
  Shield (chevron + on/off toggle), Startup (restore tabs), Updates
  (version + Check button), followed by a Theseus ID chevron row.
  Appearance / Developer tools / Registries / About stay below in the
  same order. Removed the "Default handling" sub-head; the default-
  browser and file-associations rows moved to the top. The Shield row
  uses listAddons() + setAddonEnabled("blocker", on) for state; the
  chevron falls back to openPanel("blocker:main").

- Names & Certificates: dropped the always-on BNS row at the top.

- Plug-ins main page: Ariadne's Thread and Aegis rows removed — they
  moved to top-level. The page now links into the new landing sections.
  Hidden placeholders keep the DOM ids (ariadneStatusMain, aegisCheckMain
  …) so the shared refresh code keeps working. Sub-pages (plugins-
  ariadne, plugins-aegis) untouched.

- Extensions list: Hermes and Pithos hidden from the list (they live in
  the Sidebar card now). docx-editor and pdf-editor sort last within
  each group so smaller utilities stay near the top.

- Toolbar add-on strip (chrome.html): same last-sort rule for docx-editor
  and pdf-editor, applied to the rank() ordering so unknown keys without
  a saved dockOrder keep registration order and the editors land past
  them.

- Sidebar section: added two cards at the top. "Theseus native apps"
  (Hermes + Pithos) and "Third-party apps" (the sidebar quick-links /
  Web apps). Native apps appear first. The Extension panels section
  still lists everything else that opens from the strip; Hermes and
  Pithos are filtered out of it so they don't appear twice.

- Version bumped to 0.3.122. No new IPCs — reuses listAddons,
  setAddonEnabled, openPanel, sidebarLeftPanels, addonInvoke.
2026-10-27 18:04:49 +02:00
14 changed files with 688 additions and 171 deletions

View file

@ -5,6 +5,10 @@
// identity from the sender's file:// URL, so a page hosted anywhere else
// gets nothing back from these handlers.
const { contextBridge, ipcRenderer } = require("electron");
// Clicks in an add-on tab close an overlay sidebar panel the same way clicks
// in a web tab or Settings do (see home-preload / settings-preload). Push
// mode is unaffected — main gates on sidebarPanelMode.
window.addEventListener("pointerdown", () => { ipcRenderer.send("tab-pointerdown"); }, true);
contextBridge.exposeInMainWorld("silentmode", {
storage: {
get: (key, fallback = null) => ipcRenderer.invoke("addon-storage-get", key, fallback),

View file

@ -17,6 +17,10 @@ body { background: var(--bg); color: var(--ink); font: 13px/1.5 system-ui, -appl
header { display: flex; align-items: center; gap: 8px; padding: 12px 14px; border-bottom: 1px solid var(--line); background: var(--panel); }
header h1 { margin: 0; font-size: 14px; font-weight: 600; }
header .pill { margin-left: auto; }
header .close { width: 24px; height: 24px; border: 0; border-radius: 6px; background: transparent; color: var(--mut); cursor: pointer; padding: 0; display: inline-grid; place-items: center; }
header .close:hover { background: rgba(255,255,255,.08); color: var(--ink); }
header .close svg { width: 14px; height: 14px; display: block; }
@media (prefers-color-scheme: light) { header .close:hover { background: rgba(0,0,0,.06); } }
.pill { display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px; border-radius: 999px;
font-size: 11px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase;
background: rgba(139,152,169,.14); color: var(--mut); }
@ -52,7 +56,7 @@ h2 { margin: 0 0 8px; font-size: 11px; font-weight: 600; letter-spacing: .06em;
</style>
</head>
<body>
<header><span>🛡</span><h1>Shield</h1><span class="pill" id="pill"><span class="dot"></span><span id="pillText">…</span></span></header>
<header><span>🛡</span><h1>Shield</h1><span class="pill" id="pill"><span class="dot"></span><span id="pillText">…</span></span><button class="close" id="close" title="Close"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg></button></header>
<div class="body">
<div class="card site" id="siteCard">
<div class="lbl" id="siteLbl">Open a site to see what Shield stops there.</div>
@ -116,6 +120,10 @@ h2 { margin: 0 0 8px; font-size: 11px; font-weight: 600; letter-spacing: .06em;
}
$("enabled").onclick = () => window.silentmode.invoke("set-enabled", { enabled: !(state && state.enabled) }).then(render);
$("toSettings").onclick = (e) => { e.preventDefault(); window.silentmode.invoke("open-settings"); };
// Close the Shield panel. Right-side add-on panels have no Theseus-drawn
// chrome (unlike left panels, which get the shared panel-header with a
// close button), so the panel ships its own.
$("close").onclick = () => { try { window.silentmode.close(); } catch (e) {} };
$("refresh").onclick = () => { $("refresh").disabled = true; window.silentmode.invoke("refresh-lists").then((r) => { if (r && !r.ok) $("updated").textContent = "Update failed: " + (r.error || "unknown"); }); };
window.silentmode.on("state", render);
window.silentmode.invoke("state").then(render);

View file

@ -49,6 +49,10 @@ module.exports = {
get allowEvaluate() { return api.storage.get("allowEvaluate", false) === true; },
get allowedOrigins() { const v = api.storage.get("allowedOrigins", []); return Array.isArray(v) ? v.map(String) : []; },
get uploadDirs() { const v = api.storage.get("uploadDirs", []); return Array.isArray(v) ? v.map(String) : []; },
// Simultaneous-session cap. 0 = unlimited (default). Values are clamped
// between 1 and 100 everywhere a UI writes them, so a saved 1 or 5 means
// the user picked that limit on purpose.
get sessionLimit() { const v = Number(api.storage.get("sessionLimit", 0)); return Number.isFinite(v) && v > 0 ? Math.floor(v) : 0; },
};
if (!S.token) api.storage.set("token", newToken());
@ -120,6 +124,7 @@ module.exports = {
allowEvaluate: S.allowEvaluate,
allowedOrigins: S.allowedOrigins,
uploadDirs: S.uploadDirs,
sessionLimit: S.sessionLimit,
token: S.token,
commands: url ? commandsFor(url, S.token) : null,
apps: appStatus,
@ -193,6 +198,7 @@ module.exports = {
if (!port) throw new Error("no free local port");
const s = new McpServer({
getToken: () => S.token,
getSessionLimit: () => S.sessionLimit,
tools: { list: () => controller.definitions(), call: (name, args, session) => controller.call(name, args, session) },
serverInfo: { name: "theseus", title: "Theseus Navigator", version: readVersion(api.folder) },
instructions: INSTRUCTIONS,
@ -252,6 +258,19 @@ module.exports = {
if (on) await start(); else await stop("turned off in the panel");
return publicState();
});
// Simultaneous-session cap. 0 = unlimited; any positive value is a hard
// ceiling checked at initialize time. Lowering below the current live
// count does NOT retroactively kick anyone — the next connect will be
// refused until an existing session goes away.
api.onMessage("set-session-limit", async ({ limit } = {}) => {
let n = Number(limit);
if (!Number.isFinite(n) || n < 0) n = 0;
if (n > 100) n = 100;
api.storage.set("sessionLimit", Math.floor(n));
log.add({ tool: "connector", args: n > 0 ? `session cap set to ${Math.floor(n)}` : "session cap cleared (no limit)", ok: true });
pushStatus();
return publicState();
});
api.onMessage("disconnect", async () => {
if (server) server.dropAllSessions();
await controller.releaseAll();

View file

@ -27,8 +27,12 @@ function tokenMatches(given, expected) {
class McpServer {
// tools: { list(): [toolDef], call(name, args, session) => Promise<result> }
constructor({ getToken, tools, serverInfo, instructions, onStatus, log }) {
constructor({ getToken, getSessionLimit, tools, serverInfo, instructions, onStatus, log }) {
this.getToken = getToken;
// 0 = no cap. Any positive value is a hard ceiling on live sessions —
// an initialize past the ceiling is refused with 429 so the client can
// tell the user something concrete.
this.getSessionLimit = typeof getSessionLimit === "function" ? getSessionLimit : () => 0;
this.tools = tools;
this.serverInfo = serverInfo || { name: "theseus", version: "0.0.0" };
this.instructions = instructions || "";
@ -179,6 +183,15 @@ class McpServer {
let session = sid ? this.sessions.get(sid) : null;
const headers = {};
if (init) {
// Enforce the user's simultaneous-session cap. Only live sessions
// count: a GC pass over expired ones runs first so an abandoned
// tab doesn't permanently hold a slot.
this._expire();
const limit = Math.max(0, Number(this.getSessionLimit()) || 0);
if (limit > 0 && this.sessions.size >= limit) {
this.log(`refused connect: at session cap (${limit})`);
return this._json(res, 429, { jsonrpc: "2.0", id: init.id ?? null, error: { code: -32020, message: `Theseus is at its simultaneous-session cap (${limit}). Disconnect a client, or raise the limit in the Claude connector panel.` } }, { "retry-after": "30" });
}
const id = crypto.randomBytes(16).toString("hex");
const ci = (init.params && init.params.clientInfo) || {};
session = { id, client: String(ci.name || "MCP client").slice(0, 60), clientVersion: String(ci.version || ""), protocolVersion: null, created: Date.now(), lastSeen: Date.now(), streams: new Set() };

View file

@ -76,6 +76,14 @@
<section>
<div class="row"><div class="grow"><b>Accept connections</b><div class="note" id="where">Off</div></div><label class="sw"><input type="checkbox" id="accept"><span></span></label></div>
<div class="row" style="margin-top:8px;align-items:flex-start"><div class="grow"><b>Simultaneous sessions</b><div class="note">A new client's <code>initialize</code> past this limit is refused with 429. Live sessions only — abandoned ones time out.</div></div>
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
<label><input type="radio" name="smode" value="1"> 1 only</label>
<label><input type="radio" name="smode" value="multi"> Multi</label>
<label><input type="radio" name="smode" value="0"> No limit</label>
<input type="number" id="sNum" min="1" max="100" step="1" style="width:60px" title="Multi: enter the cap (1-100)" hidden>
</div>
</div>
<div id="clients" class="note"></div>
<div class="row" style="margin-top:8px"><button class="b" id="disconnect">Disconnect clients</button><button class="b warn" id="revoke" title="Issue a new token. Every client must be set up again with the new command.">Revoke token</button></div>
</section>
@ -156,6 +164,14 @@
: '<li class="empty">None. Uploads are refused.</li>';
$("evaluate").checked = !!st.allowEvaluate;
$("evalWarn").hidden = !st.allowEvaluate;
// Session-cap radios: 1-only / Multi / No-limit. Multi reveals a number
// input for the exact cap. 0 (unlimited) shows "No limit" selected.
const lim = Number(st.sessionLimit) || 0;
const smode = lim === 1 ? "1" : lim === 0 ? "0" : "multi";
for (const r of document.querySelectorAll('input[name="smode"]')) r.checked = (r.value === smode);
const num = $("sNum");
num.hidden = (smode !== "multi");
num.value = smode === "multi" ? String(lim) : (smode === "1" ? "" : "");
}
function entryHtml(e) {
@ -169,6 +185,25 @@
}
$("accept").onchange = async (e) => render(await S.invoke("set-accept", { on: e.target.checked }));
// Session cap: the three radios + the number input share one setter.
for (const r of document.querySelectorAll('input[name="smode"]')) {
r.addEventListener("change", async () => {
const v = r.value;
$("sNum").hidden = (v !== "multi");
if (v === "0") render(await S.invoke("set-session-limit", { limit: 0 }));
else if (v === "1") render(await S.invoke("set-session-limit", { limit: 1 }));
else { // multi
const n = Math.max(2, Math.min(100, Number($("sNum").value) || 5));
$("sNum").value = String(n);
render(await S.invoke("set-session-limit", { limit: n }));
}
});
}
$("sNum").addEventListener("change", async () => {
const n = Math.max(2, Math.min(100, Number($("sNum").value) || 5));
$("sNum").value = String(n);
render(await S.invoke("set-session-limit", { limit: n }));
});
$("evaluate").onchange = async (e) => render(await S.invoke("set-evaluate", { on: e.target.checked }));
$("disconnect").onclick = async () => render(await S.invoke("disconnect"));
// Two clicks: every client has to be set up again afterwards.

View file

@ -7,6 +7,15 @@
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM2RkYzk3Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMWU4ZjZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIyIDZoMjB2NWMwIDItMiAzLTIgNSA4IDMgMTQgMTEgMTQgMjEgMCAxMi05IDIxLTIyIDIxUzEwIDQ5IDEwIDM3YzAtMTAgNi0xOCAxNC0yMSAwLTItMi0zLTItNXoiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMTQgMzBoMzZNMTMgNDBoMzgiIHN0cm9rZT0iIzBiMWYxOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9Ii41NSIvPjwvc3ZnPgo=",
"main": "index.js",
"activation": "on-demand",
"panels": [
{
"id": "main",
"title": "Pithos",
"page": "ui/index.html",
"side": "left",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM2RkYzk3Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMWU4ZjZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIyIDZoMjB2NWMwIDItMiAzLTIgNSA4IDMgMTQgMTEgMTQgMjEgMCAxMi05IDIxLTIyIDIxUzEwIDQ5IDEwIDM3YzAtMTAgNi0xOCAxNC0yMSAwLTItMi0zLTItNXoiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMTQgMzBoMzZNMTMgNDBoMzgiIHN0cm9rZT0iIzBiMWYxOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9Ii41NSIvPjwvc3ZnPgo="
}
],
"capabilities": [
"sidebar-panel",
"vault-derive",

View file

@ -905,11 +905,19 @@
lastButtons.push({ kind: "menu", key: "m:" + m.addonId, addonId: m.addonId, icon: String(m.icon || "•"), title: m.title || m.addonId, items: m.items || [] });
}
// User prefs: drag-reorder order and buttons hidden via the right-click
// menu. Unknown keys keep registration order after the ordered ones.
// menu. Unknown keys keep registration order after the ordered ones; the
// document editors sort LAST so smaller utilities (Notepad, Screenshot,
// Translate…) stay near the start of the strip.
if (d.dock) lastDock = d.dock;
const order = Array.isArray(lastDock.order) ? lastDock.order : [];
const hidden = new Set(Array.isArray(lastDock.hidden) ? lastDock.hidden : []);
const rank = (k) => { const i = order.indexOf(k); return i < 0 ? order.length : i; };
const LAST_ADDON_IDS = new Set(["docx-editor", "pdf-editor"]);
const addonIdOf = (k) => k.startsWith("p:") ? k.slice(2).split(":")[0] : k.slice(2);
const rank = (k) => {
const i = order.indexOf(k);
if (i >= 0) return i;
return LAST_ADDON_IDS.has(addonIdOf(k)) ? order.length + 1 : order.length;
};
lastButtons = lastButtons.filter((b) => !hidden.has(b.key)).map((b, i) => ({ ...b, i })).sort((a, b) => rank(a.key) - rank(b.key) || a.i - b.i);
lastPanels = lastPanels.filter((p) => !hidden.has("p:" + p.panelId));
lastMenus = lastMenus.filter((m) => !hidden.has("m:" + m.addonId));

View file

@ -9,4 +9,7 @@ contextBridge.exposeInMainWorld("errorpage", {
registerOnSirius: (host) => ipcRenderer.invoke("error-register", host),
openExternal: (url) => ipcRenderer.invoke("error-open-external", url),
bnsSimilar: (host) => ipcRenderer.invoke("error-bns-similar", host),
// ICANN "did you mean?" — alternative-TLD swaps + history matches for a
// host whose DNS failed. See icannAlternatives() in main.js.
icannAlternatives: (host) => ipcRenderer.invoke("error-icann-alternatives", host),
});

View file

@ -47,6 +47,8 @@
background: var(--panel); border: 1px solid var(--line); color: var(--acid);
font-family: ui-monospace, monospace; font-size: 12.5px; text-decoration: none; cursor: pointer; }
.suggest a:hover { border-color: rgb(from var(--acid) r g b / .55); filter: brightness(1.05); }
.suggest a.search { font-family: inherit; color: var(--ink); }
.suggest a.search:hover { color: var(--acid); }
.actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 1.3rem; }
.btn { font-family: inherit; font-size: 13.5px; padding: 9px 16px; border-radius: 9px;
border: 1px solid var(--line); background: var(--panel2); color: var(--ink);
@ -270,16 +272,32 @@
actionsBox.appendChild(b);
}
// "Did you mean" — for BCNR-name failures, ask main for near-matches in
// the warm shared index (Levenshtein ≤ 2, same TLD). No network — the
// index is local, so this works offline too. Clicking a suggestion
// retries the load at that host, preserving the original path if any.
if ((kind === "name-not-registered" || kind === "name-unreachable") && host && window.errorpage?.bnsSimilar) {
window.errorpage.bnsSimilar(host).then((matches) => {
if (!Array.isArray(matches) || !matches.length) return;
// "Did you mean" — two sources, merged into one row, plus a search chip:
// • BCNR warm shared index (Levenshtein ≤ 2, same TLD) — for a BCDN name
// typo like "games.x" → "game.x". Local index; works offline.
// • ICANN alternative-TLD swaps + history base-name matches — a DNS
// failure on my.mynymbox.com surfaces my.mynymbox.io, .net, .org, …,
// and any related host the user has visited. Local too.
// • A trailing "Search '<host>'" chip that defers to the default
// search engine when none of the URL alternatives looks right.
// Clicking a URL chip retries at that host (keeps the original path);
// clicking the search chip opens a search for the host text.
if ((kind === "name-not-registered" || kind === "name-unreachable") && host) {
let origPath = "/";
try { const u = new URL(url); origPath = u.pathname + u.search + u.hash; } catch {}
const sources = [
window.errorpage?.bnsSimilar ? window.errorpage.bnsSimilar(host) : Promise.resolve([]),
window.errorpage?.icannAlternatives ? window.errorpage.icannAlternatives(host) : Promise.resolve([]),
];
Promise.all(sources).then(([bns, icann]) => {
const seen = new Set();
const matches = [];
for (const name of [...(bns || []), ...(icann || [])]) {
if (!name || seen.has(name)) continue;
seen.add(name); matches.push(name);
if (matches.length >= 4) break;
}
const row = $("suggestRow");
let origPath = "/";
try { const u = new URL(url); origPath = u.pathname + u.search + u.hash; } catch {}
for (const name of matches) {
const a = el("a", null, name);
a.href = "#";
@ -289,6 +307,23 @@
});
row.appendChild(a);
}
// Search chip. Shown whenever we have a host, even when no URL match
// surfaced — "search for it on your default engine" is the universal
// fallback for a DNS miss.
const s = el("a", "search", "🔎 Search '" + host + "'");
s.href = "#";
s.title = "Open your default search engine with this as the query";
s.addEventListener("click", (ev) => {
ev.preventDefault();
window.errorpage.searchFor(host);
});
row.appendChild(s);
// Replace the row's "Did you mean" label with a softer one when the
// only chip is the search fallback — nothing to actually suggest.
if (!matches.length) {
const lbl = $("suggest").querySelector(".lbl");
if (lbl) lbl.textContent = "Not sure what you were after?";
}
$("suggest").hidden = false;
}).catch(() => {});
}

View file

@ -19,16 +19,21 @@
}
.pop {
display: flex; flex-direction: column;
background: var(--bg); border: 1px solid var(--line); border-radius: 12px;
box-shadow: 0 12px 40px rgba(0,0,0,.5), 0 2px 8px rgba(0,0,0,.4);
background: var(--bg); border-radius: 16px;
box-shadow: 0 16px 48px rgba(0,0,0,.55), 0 4px 12px rgba(0,0,0,.4);
overflow: hidden; width: 240px;
}
.hdr { padding: 10px 14px 6px; font-size: 11px; font-weight: 600;
.hdr { padding: 10px 14px 4px; font-size: 11px; font-weight: 600;
color: var(--mut); text-transform: uppercase; letter-spacing: .5px;
border-bottom: 1px solid var(--line); }
display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.hdr .edit { background: transparent; border: 0; padding: 2px; margin: -2px;
color: var(--mut); cursor: pointer; border-radius: 6px;
display: inline-grid; place-items: center; }
.hdr .edit:hover { color: var(--ink); background: var(--card-hi); }
.hdr .edit svg { width: 14px; height: 14px; display: block; }
.list { display: flex; flex-direction: column; padding: 4px; }
.row { display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border-radius: 8px; cursor: pointer; position: relative;
padding: 8px 10px; border-radius: 10px; cursor: pointer; position: relative;
transition: background .08s; }
.row:hover { background: var(--card-hi); }
.row.cur { background: var(--acid-tint); }
@ -41,7 +46,7 @@
.name { flex: 1; font-weight: 600; font-size: 13px; color: var(--ink); }
.check { flex: none; width: 14px; height: 14px; color: var(--acid); opacity: 0; }
.row.cur .check { opacity: 1; }
.footer { border-top: 1px solid var(--line); padding: 4px; }
.footer { padding: 4px; }
.footer .row { color: var(--mut); font-weight: 500; }
.footer .ico { background: transparent; }
.footer .ico svg { width: 18px; height: 18px; color: var(--mut); }
@ -50,14 +55,8 @@
</head>
<body>
<div class="pop" id="pop">
<div class="hdr">AI engine</div>
<div class="hdr"><span>AI engine</span><button class="edit" id="editBtn" title="Edit AI engines" aria-label="Edit AI engines"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button></div>
<div class="list" id="list"></div>
<div class="footer">
<div class="row" id="settingsRow">
<div class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></div>
<div class="name">Pick AI engines…</div>
</div>
</div>
</div>
<script>
const api = window.llmPicker;
@ -85,8 +84,10 @@
list.appendChild(row);
}
}
document.getElementById("settingsRow").addEventListener("click", () => api.settings());
document.addEventListener("keydown", (e) => { if (e.key === "Escape") api.close(); });
// Edit button → opens Settings on the dedicated AI engines page (handled
// in main via the picker's own `settings` IPC).
document.getElementById("editBtn").addEventListener("click", () => api.settings());
// Close-on-click-away is driven from main (see closeOnClickAway registration);
// relying on a client-side window.blur here closes the picker the moment it
// opens if Electron's focus flickers between the strip view and the pop,

162
main.js
View file

@ -310,13 +310,17 @@ function isInstalled(id) {
return (settings.installedEngines || DEFAULT_ENABLED).includes(id);
}
function allEngines() {
// Built-in engines without a bundled icon fall back to the same
// fetch-and-cache path custom engines use — pulls the site's own
// /favicon.ico once and remembers it per profile, so the picker shows
// the real brand mark instead of the sym glyph. The emoji sym renders
// first and the real icon replaces it on the next emit after fetch.
const list = Object.entries(SEARCH_ENGINES).map(([id, e]) =>
({ id, name: e.name, sym: e.sym, favicon: bundledIcon(id) || (e.fav ? customFavicon("https://" + e.fav + "/") : null), kind: e.kind || "search", tier: e.tier || "catalog", builtin: true, installed: isInstalled(id), enabled: isEnabled(id) && !e.frozen, frozen: e.frozen || null }));
// Icon preference, cheapest first: a cached fetched favicon (the site's own
// brand mark, pulled once at /favicon.ico and kept per profile) → the
// bundled generic tile → the sym emoji. The fetch is triggered by
// customFavicon when the cache misses; the first paint shows the bundled
// tile, the next emit after fetch swaps in the real mark. For engines with
// no bundled file (today: Phind, frozen) the emoji carries the first paint.
const list = Object.entries(SEARCH_ENGINES).map(([id, e]) => {
const fetched = e.fav ? customFavicon("https://" + e.fav + "/") : null;
const favicon = fetched || bundledIcon(id);
return { id, name: e.name, sym: e.sym, favicon, kind: e.kind || "search", tier: e.tier || "catalog", builtin: true, installed: isInstalled(id), enabled: isEnabled(id) && !e.frozen, frozen: e.frozen || null };
});
for (const c of settings.customEngines || [])
list.push({ id: c.id, name: c.name, sym: c.sym || "🔍", favicon: customFavicon(c.url), kind: c.kind || "search", tier: "custom", builtin: false, installed: true, enabled: isEnabled(c.id) });
// Apply the user's custom order; ids not in engineOrder keep their natural order (stable sort).
@ -7642,8 +7646,14 @@ ipcMain.on("wiz-link", (e, uri) => {
});
ipcMain.handle("addon-page-msg", async (e, addonId, msg, payload) => {
// Sidebar panels (quickPanel, leftPanel) count as pages for inject
// round-trips too — see injectionsForSender for the same gate.
const tab = tabForSender(e.sender);
if (!tab || !addonHost) throw new Error("not a page");
const isPanel = !tab && (
(quickPanel && e.sender === quickPanel.webContents) ||
(leftPanel && e.sender === leftPanel.webContents)
);
if ((!tab && !isPanel) || !addonHost) throw new Error("not a page");
// Only the tab's top-level document speaks for its origin. The inject
// preload runs in the main frame alone, so a message from any other frame
// did not come through it — and it would be credited with the top-level
@ -7668,14 +7678,23 @@ ipcMain.handle("addon-page-msg", async (e, addonId, msg, payload) => {
// Assigning event.returnValue sends the reply at once, so it is set exactly
// once at the end.
function injectionsForSender(e, href) {
// Normally the sender must be a web tab. Sidebar panels (the sparkle-opened
// AI chat, the quick-link apps like Telegram/WhatsApp) are not in `tabs`
// but DO load third-party origins, so cookie-consent rules, page-inject
// wallets and friends should run there too — otherwise the AI panel in
// the strip shows every consent banner the matching tab wouldn't.
const tab = tabForSender(e.sender);
if (!tab || !addonHost) return [];
const isPanel = !tab && (
(quickPanel && e.sender === quickPanel.webContents) ||
(leftPanel && e.sender === leftPanel.webContents)
);
if ((!tab && !isPanel) || !addonHost) return [];
const url = e.sender.getURL();
if (!url || url.startsWith("file:")) return [];
if (href && href !== url) console.log(`[addons] inject: preload href ${href} ≠ committed ${url}`);
const origin = pageOriginOf(url);
const list = addonHost.injectionsFor(url).map((x) => ({ ...x, origin }));
if (list.length) console.log(`[addons] inject ${list.map((x) => x.id).join(",")} into ${origin}`);
if (list.length) console.log(`[addons] inject ${list.map((x) => x.id).join(",")} into ${origin}${isPanel ? " (sidebar panel)" : ""}`);
return list;
}
ipcMain.on("addon-inject-scripts", (e, href) => { e.returnValue = injectionsForSender(e, href); });
@ -8068,6 +8087,66 @@ function levenshtein(a, b) {
}
return prev[n];
}
// "Did you mean?" for ICANN hosts that failed DNS. Two sources, in order:
// 1. Hostnames the user has visited whose registrable base matches
// (mynymbox.com → suggests my.mynymbox.io if that is in history).
// 2. Common TLD swaps for the registrable base — a canned list of the
// TLDs most frequently confused with each other (.com ↔ .io ↔ .net
// ↔ .org ↔ .co ↔ .app ↔ .dev ↔ .ai).
// Returns up to 4 full hostnames the user can click to retry. No network;
// no suggestion that leaks what the user is browsing beyond their own box.
const COMMON_TLD_SWAPS = ["com", "io", "net", "org", "co", "app", "dev", "ai", "me"];
function icannAlternatives(host) {
const h = String(host || "").trim().toLowerCase();
if (!h || !isLikelyIcannHost(h)) return [];
const tld = tldOf(h);
if (!tld) return [];
// Full base = everything before ".<tld>" ("my.mynymbox" for my.mynymbox.com).
// Apex base = just the registrable name ("mynymbox"), used to also suggest
// the apex at a different TLD — subdomain typos (my.mynymbox.com actually
// being client.mynymbox.io) are common enough that this doubles the hit rate.
const fullBase = h.slice(0, -(tld.length + 1));
if (!fullBase) return [];
const parts = fullBase.split(".");
const apexBase = parts[parts.length - 1];
const hasSub = parts.length > 1 && apexBase.length >= 2;
const seen = new Set([h]);
const out = [];
const push = (cand) => { if (cand && !seen.has(cand)) { seen.add(cand); out.push(cand); } };
// History hits first — a hostname the user has actually visited is a
// stronger signal than a canned TLD swap. Match either the full base
// (my.mynymbox.*) or the apex base (*.mynymbox.* / mynymbox.*).
for (const entry of history || []) {
if (out.length >= 4) break;
try {
const u = new URL(entry.url);
const cand = u.hostname.toLowerCase();
if (!isLikelyIcannHost(cand)) continue;
const candTld = tldOf(cand);
if (!candTld) continue;
const candFull = cand.slice(0, -(candTld.length + 1));
const candApex = candFull.split(".").pop();
if (candFull === fullBase || candApex === apexBase) push(cand);
} catch {}
}
// TLD swaps. When the host has a subdomain, interleave the full form
// (my.mynymbox.io) with the stripped-subdomain form (mynymbox.io) so
// the top suggestions cover BOTH the "wrong-TLD" and the "wrong-TLD-
// and-wrong-subdomain" cases — mynymbox.com's real site is actually
// client.mynymbox.io, which neither variant alone catches.
for (const alt of COMMON_TLD_SWAPS) {
if (out.length >= 4) break;
if (alt === tld) continue;
push(fullBase + "." + alt);
if (out.length >= 4) break;
if (hasSub) push(apexBase + "." + alt);
}
return out;
}
ipcMain.handle("error-icann-alternatives", (e, host) => {
if (!isErrorPageSender(e.sender)) return [];
return icannAlternatives(host);
});
ipcMain.handle("error-bns-similar", (e, host) => {
if (!isErrorPageSender(e.sender)) return [];
const h = String(host || "").trim().toLowerCase();
@ -8258,7 +8337,20 @@ function openSettingsTab(section) {
}
createTab(null, { settings: true, settingsSection: slug });
}
ipcMain.handle("open-settings", (_e, section) => openSettingsTab(section));
// The toolbar's Theseus logo toggles the Settings tab: if Settings is already
// the active tab, clicking the logo closes it (switching to the previously
// active tab, or the first remaining one). Called without a section, so a
// deep-link call like openSettingsTab("ai-engines") always opens rather than
// toggling — the toggle is intent-of-the-logo-click, not of a nav route.
function toggleSettingsTab() {
const ex = tabs.find((t) => t.settings);
if (ex && isActiveId(ex.id)) { closeTab(ex.id); return; }
openSettingsTab();
}
ipcMain.handle("open-settings", (_e, section) => {
if (section) openSettingsTab(section);
else toggleSettingsTab();
});
// Settings › Performance drives Shield and Cookie Pop-ups through their
// add-ons' own message handlers; only the Settings tab may call this.
const isSettingsSender = (e) => tabs.some((t) => t.settings && t.view?.webContents === e.sender);
@ -9151,11 +9243,14 @@ ipcMain.handle("add-engine", async (_e, eng) => {
// installs it in both the settings list AND the toolbar dropdown.
if (eng && eng.name && eng.url && String(eng.url).includes("%s")) {
const id = "custom-" + Date.now().toString(36);
const kind = eng.kind === "llm" ? "llm" : "search";
settings.customEngines = [...(settings.customEngines || []),
{ id, name: String(eng.name).slice(0, 40), sym: String(eng.sym || "🔍").slice(0, 4), url: String(eng.url).slice(0, 400) }];
{ id, name: String(eng.name).slice(0, 40), sym: String(eng.sym || (kind === "llm" ? "✨" : "🔍")).slice(0, 4), url: String(eng.url).slice(0, 400), kind }];
// Custom engines are auto-installed and enabled.
settings.enabledEngines = [...new Set([...(settings.enabledEngines || DEFAULT_ENABLED), id])];
settings.searchEngine = id; // select the one just added
// LLM customs do NOT become the default search engine — only web-search customs do.
if (kind === "search") settings.searchEngine = id;
else settings.defaultLlmId = id;
saveSettings(); emitEngines();
// Settings pulls the list once on return, so give the icon fetch a moment
// to land; offline or slow, the row shows the emoji and the toolbar
@ -9993,7 +10088,10 @@ ipcMain.handle("llm-picker-pick", (e, id) => {
ipcMain.handle("llm-picker-settings", (e) => {
if (!llmPickerPop || e.sender !== llmPickerPop.webContents) return false;
hideLlmPicker();
openSettingsTab("search");
// Opens the dedicated AI engines page added in 0.3.122 — kept separate from
// the Search page so the two lists do not share a scroll or a Default
// picker. Legacy profiles that lack this section fall through to "search".
openSettingsTab("ai-engines");
return true;
});
ipcMain.handle("llm-picker-close", (e) => {
@ -12244,7 +12342,12 @@ async function setFileAssociations(on) {
const classes = `HKCU\\Software\\Classes`;
const caps = `HKCU\\Software\\Theseus Navigator\\Capabilities`;
if (on) {
await regAdd(`${classes}\\${WIN_PROGID}`, "", "Theseus HTML Document");
// The ProgID's default value is the friendly name Windows shows in the
// Open-with menu AND in the file-type column of Explorer. Keep it short
// — "Theseus Navigator" — not a long "Theseus HTML Document" per
// extension. ApplicationDescription under Capabilities is also dropped;
// Default Apps picks it up as a tooltip and the long sentence was noisy.
await regAdd(`${classes}\\${WIN_PROGID}`, "", "Theseus Navigator");
await regAdd(`${classes}\\${WIN_PROGID}\\DefaultIcon`, "", `${exe},0`);
await regAdd(`${classes}\\${WIN_PROGID}\\shell\\open\\command`, "", openCmd);
for (const ext of FILE_ASSOC_EXTS) {
@ -12254,7 +12357,6 @@ async function setFileAssociations(on) {
// Windows Settings → Default Apps for each listed extension.
await regAdd(caps, "ApplicationName", "Theseus Navigator");
await regAdd(caps, "ApplicationIcon", `${exe},0`);
await regAdd(caps, "ApplicationDescription", "A browser that follows the thread.");
for (const ext of FILE_ASSOC_EXTS) await regAdd(`${caps}\\FileAssociations`, ext, WIN_PROGID);
for (const p of DEFAULT_BROWSER_PROTOS) await regAdd(`${caps}\\URLAssociations`, p, WIN_PROGID);
await regAdd(`HKCU\\Software\\RegisteredApplications`, "Theseus Navigator", "Software\\Theseus Navigator\\Capabilities");
@ -12291,6 +12393,30 @@ ipcMain.handle("default-browser-state", () => ({
}));
ipcMain.handle("default-browser-set", (_e, on) => setAsDefaultBrowser(!!on));
ipcMain.handle("file-assocs-set", (_e, on) => setFileAssociations(!!on));
// Auto-register the HKCU "Open with Theseus" entries on first launch so
// double-clicking an .html / .pdf / .svg / .xhtml file (or picking it from
// an Open-with menu) just works, with no toggle to flip in Settings. Two
// per-profile flags:
// fileAssocsAutoApplied — first-run rollout. Once set, never auto-apply
// again, so a user who removes the association
// by hand (registry or Default Apps) stays opted
// out across restarts.
// fileAssocsLabelFixed_1 — one-time re-write for 0.3.124+: the old progid
// default was "Theseus HTML Document" which looked
// verbose next to the per-extension entry in the
// Open-with menu. Rewriting it to "Theseus
// Navigator" is idempotent and only fires once.
function autoApplyFileAssociations() {
if (process.platform !== "win32") return;
const needFirstRun = !settings.fileAssocsAutoApplied;
const needLabelFix = settings.fileAssocsAutoApplied && !settings.fileAssocsLabelFixed_1 && hasFileAssociations();
if (!needFirstRun && !needLabelFix) return;
setFileAssociations(true).then(() => {
if (needFirstRun) settings.fileAssocsAutoApplied = true;
settings.fileAssocsLabelFixed_1 = true;
try { saveSettings(); } catch {}
}).catch((e) => console.warn("auto file assocs:", e?.message));
}
ipcMain.handle("open-system-defaults", () => openSystemDefaultApps());
if (!process.env.THESEUS_NO_AUTOSTART && !app.requestSingleInstanceLock()) {
// Another Theseus owns this profile; it got our argv via second-instance.
@ -12370,6 +12496,10 @@ if (!process.env.THESEUS_NO_AUTOSTART && !app.requestSingleInstanceLock()) {
// "restart to apply" chip instead of finding out in Settings.
// boot-time poll now runs from onChromeReady; interval poll (4 h) is fine here.
setInterval(() => pollAddonUpdates("interval"), 4 * 60 * 60 * 1000);
// Register the Windows Open-with entries on first run — no toggle, no
// UAC, no user step. Deferred past the toolbar paint so the one-shot
// reg.exe fan-out never gets in the way of launch.
setTimeout(() => { try { autoApplyFileAssociations(); } catch {} }, 5_000);
// Launched from an installed app's shortcut: just that app's window; the
// browser window comes up on the next plain launch (second-instance).
const appUrl = webapps.appUrlFromArgv(process.argv);

View file

@ -1,6 +1,6 @@
{
"name": "theseus-navigator",
"version": "0.3.121",
"version": "0.3.125",
"license": "MPL-2.0",
"description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.",
"author": "Silent Mode",

View file

@ -1,4 +1,8 @@
const { contextBridge, ipcRenderer } = require("electron");
// A click inside the Settings tab closes an open sidebar panel (overlay mode)
// — same signal home-preload sends in a web tab. Without this, clicking the
// Settings icon with an overlay panel open did nothing to the panel.
window.addEventListener("pointerdown", () => { ipcRenderer.send("tab-pointerdown"); }, true);
contextBridge.exposeInMainWorld("cfg", {
get: () => ipcRenderer.invoke("settings-get"),
set: (key, value) => ipcRenderer.invoke("settings-set", key, value),

View file

@ -313,16 +313,16 @@
<nav class="side">
<div class="brand">⛓ Theseus</div>
<a data-sec="general" class="active">General</a>
<a data-sec="sidebar">Sidebar</a>
<a data-sec="language">Language</a>
<a data-sec="search">Search</a>
<a data-sec="ai-engines">AI engines</a>
<a data-sec="passwords">Passwords</a>
<a data-sec="theseusid">Theseus ID</a>
<a data-sec="performance">Performance</a>
<a data-sec="privacy">Privacy</a>
<a data-sec="names">Names &amp; Certificates</a>
<a data-sec="plugins">Plug-ins</a>
<a data-sec="plugins">System</a>
<a data-sec="aegis">Aegis</a>
<a data-sec="addons">Extensions</a>
<a data-sec="sidebar">Sidebar</a>
<a data-sec="apps">Apps</a>
</nav>
<div class="content">
@ -330,20 +330,32 @@
<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>
<!-- Default-browser row — moved to the top of General. Hidden until the
platform-check (Windows only, today) reveals it. -->
<div class="row" id="defaultBrowserRow" hidden>
<div class="txt">
<div class="t">Make Theseus the default browser</div>
<div class="d">Register Theseus for <code>http</code> and <code>https</code> links. Windows still asks you to confirm the switch in <b>Settings &rsaquo; Default apps</b>; the button opens that page.</div>
<div class="acts"><button class="btn small ghost" id="openSysDefaults">Open Default Apps…</button></div>
</div>
<label class="sw"><input type="checkbox" id="defaultBrowser"><span class="track"><span class="knob"></span></span></label>
</div>
<!-- Theseus ID — chevron to the ID sub-page. No toggle: identity is
not an on/off, it is managed on its own page. -->
<div class="row link" data-go="theseusid">
<div class="txt"><div class="t">Theseus ID</div><div class="d">Your Silent Mode account, kept in your Theseus Vault.</div></div>
<span class="chev">›</span>
</div>
<!-- Startup — the restoreSession toggle lives here as a one-line row. -->
<div class="row">
<div class="txt"><div class="t">Open previous windows and tabs</div><div class="d">Restore the tabs from your last session when Theseus starts, even when history is cleared on quit.</div></div>
<div class="txt"><div class="t">Startup</div><div class="d">Restore the tabs from your last session when Theseus starts.</div></div>
<label class="sw"><input type="checkbox" id="restoreSession"><span class="track"><span class="knob"></span></span></label>
</div>
<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>
<!-- Updates — version + Check button, compressed to one line. The init
in initUpdatesPanel() still writes to #updStatus / #updCheck. -->
<div class="row">
<div class="txt"><div class="t">Updates</div><div class="d" id="updStatus">Loading…</div></div>
<div class="ctl"><button id="updCheck" class="btn">Check for updates</button></div>
</div>
<h2 class="sub">Appearance</h2>
<p class="subd">Choose a theme for the browser. <b>System</b> follows your operating system's light/dark setting.</p>
@ -400,22 +412,6 @@
<div id="blkSummary" class="pmuted" style="font-size:12.5px"></div>
<div><button id="resetBlocklist" class="btn">Forget "continue anyway" choices</button></div>
</div>
<h2 class="sub" id="defHandlingHead" hidden>Default handling</h2>
<div class="row" id="defaultBrowserRow" hidden>
<div class="txt">
<div class="t">Make Theseus the default browser</div>
<div class="d">Register Theseus for <code>http</code> and <code>https</code> links. Windows still asks you to confirm the switch in <b>Settings &rsaquo; Default apps</b>; the button opens that page.</div>
<div class="acts"><button class="btn small ghost" id="openSysDefaults">Open Default Apps…</button></div>
</div>
<label class="sw"><input type="checkbox" id="defaultBrowser"><span class="track"><span class="knob"></span></span></label>
</div>
<div class="row" id="fileAssocsRow" hidden>
<div class="txt">
<div class="t">Show "Open with Theseus" in file menus</div>
<div class="d">Right-click an <code>.html</code>, <code>.htm</code>, <code>.pdf</code>, <code>.svg</code> or <code>.xhtml</code> file and Theseus appears in the Open-with menu. Theseus opens it as a local-file tab.</div>
</div>
<label class="sw"><input type="checkbox" id="fileAssocs"><span class="track"><span class="knob"></span></span></label>
</div>
<h2 class="sub">About</h2>
<div class="row">
<div class="txt"><div class="t">Theseus Navigator is free software</div>
@ -481,19 +477,12 @@
these toggles, no further UI churn. Each row's "Not wired yet" caption
goes away as its adapter lights up. -->
<section id="names" hidden>
<div class="crumb"><a data-go="plugins">System</a> ›</div>
<h1>Names &amp; Certificates</h1>
<p class="lede">Decide which name systems Theseus can resolve, and which certificate roots it will trust. BNS (<i>.x</i>, <i>.bch</i>, <i>.sia</i>, …) is always on; everything else is off by default.</p>
<h2 class="sub" style="border-top:0;padding-top:0;margin-top:0">Name resolvers</h2>
<div class="row">
<div class="txt">
<div class="t">Bitcoin Cash Name System (BNS)</div>
<div class="d"><i>.x</i>, <i>.bch</i>, <i>.sia</i>, <i>.coin</i>, <i>.dgb</i>, and the rest of the seeded TLDs. Powered by <a data-go="plugins/ariadne" style="color:var(--acid-text);cursor:pointer">Ariadne's Thread</a>.</div>
</div>
<label class="sw" title="BNS is built in and always on"><input type="checkbox" checked disabled><span class="track"><span class="knob"></span></span></label>
</div>
<div class="row">
<div class="txt">
<div class="t">Ethereum Name Service (ENS)</div>
@ -597,40 +586,78 @@
</p>
</section>
<!-- "System" (section id stays "plugins" so existing deep links and the
plugins-ariadne / plugins-aegis sub-page crumbs keep working — the
nav just reads System in the sidebar now). -->
<section id="plugins" hidden>
<h1>Plug-ins</h1>
<p class="lede">Silent Mode components that live alongside Theseus — each with its own on/off, updates and settings page.</p>
<h1>System</h1>
<p class="lede">The Silent Mode components that live alongside Theseus and the name systems the browser can resolve.</p>
<div class="row" style="justify-content:flex-end;gap:8px"><a class="btn" style="text-decoration:none;display:inline-flex;align-items:center" href="https://theseus.x/plug-ins/" target="_blank" rel="noopener" title="Current signed versions, hashes and how plug-ins update">About plug-ins on theseus.x ↗</a></div>
<!-- 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 class="row link" data-go="ariadne">
<div class="txt"><div class="t">Ariadne's Thread</div><div class="d">System-wide BCDN resolver.</div></div>
<span class="chev">›</span>
</div>
<div class="row link" data-go="aegis">
<div class="txt"><div class="t">Aegis</div><div class="d">Built-in multi-chain wallet.</div></div>
<span class="chev">›</span>
</div>
<div class="row link" data-go="names">
<div class="txt"><div class="t">Names &amp; Certificates</div><div class="d">Which name systems Theseus can resolve, and which certificate roots it trusts.</div></div>
<span class="chev">›</span>
</div>
<!-- Hidden hooks: the plug-in init code wires several controls below by
id (status, toggles, install/update/apply buttons). Keep them in the
DOM so the sub-pages (plugins-ariadne / plugins-aegis) still work;
moving the plug-ins to top-level nav entries did not change their
sub-pages. These are offscreen placeholders that mirror the sub-page
controls, read by the shared refresh functions. -->
<div hidden aria-hidden="true" id="pluginsMainHooks">
<span id="ariadneStatusMain"></span>
<button id="ariadneInstallMain"></button>
<button id="ariadneUpdateMain"></button>
<button id="ariadneCheckMain"></button>
<span id="ariadneSwWrap"><input type="checkbox" id="ariadneToggleMain"></span>
<span id="aegisStatusMain"></span>
<button id="aegisCheckMain"></button>
<button id="aegisApplyMain"></button>
</div>
</section>
<!-- 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>
<!-- Ariadne's Thread landing — no longer a top-level nav entry. The
System section's chevron row navigates here; the plugins-ariadne
sub-page's own crumb then goes back to System. -->
<section id="ariadne" hidden>
<div class="crumb"><a data-go="plugins">System</a> ›</div>
<h1>Ariadne's Thread</h1>
<p class="lede">A local daemon that resolves BCDN names for <b>every browser on this machine</b>, not just Theseus. Theseus has its own built-in resolver either way; this plug-in is for Chrome, Edge, Firefox and the rest.</p>
<div class="row link" data-go="plugins/ariadne">
<div class="txt">
<div class="t">Open Ariadne's Thread settings</div>
<div class="d">Install, update, uninstall; collision policy, sources and the live status report.</div>
</div>
<div class="plug-ctl">
<button id="aegisCheckMain" class="btn small">Check for updates</button>
<button id="aegisApplyMain" class="btn small" hidden>Apply update</button>
<span class="chev">›</span>
</div>
</section>
<!-- Top-level landing section for Aegis. The chevron opens the Aegis
panel in the sidebar (openPanel), falling back to its sub-page under
Plug-ins if the panel isn't registered. -->
<section id="aegis" hidden>
<h1>Aegis</h1>
<p class="lede">Multi-chain wallet (BCH, BTC, TRX, ETH, SOL, SC, DGB) that ships with Theseus and updates over-the-air.</p>
<div class="row link" id="aegisOpenRow">
<div class="txt">
<div class="t">Open the Aegis wallet</div>
<div class="d">Opens Aegis in the sidebar panel beside the page.</div>
</div>
<span class="chev">›</span>
</div>
<div class="row link" data-go="plugins/aegis">
<div class="txt">
<div class="t">Aegis plug-in settings</div>
<div class="d">Version, updates and Apply update now.</div>
</div>
<span class="chev">›</span>
</div>
</section>
@ -724,21 +751,21 @@
<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 class="txt">
<div class="t">Additional search engines</div>
<div class="d">These appear in the toolbar dropdown. Drag to reorder. Toggle off to move an engine back to the catalog.</div>
</div>
<div id="engineList"></div>
<!-- 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 -->
<!-- Add from catalog is visible by default; the wider bank + the
custom-URL form live behind "+ Show more", which sits at the
bottom of the catalog so the common case (pick one from the
curated list) is one click. -->
<div class="ehdr">Add from catalog</div>
<div id="catalogList"></div>
<div style="display:flex;justify-content:flex-start;margin-top:6px">
<button id="engAddBtn" class="btn" type="button">+ Show more</button>
</div>
<div id="engineCatalog" class="engcat" hidden>
<div class="ehdr">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"
@ -755,6 +782,47 @@
</div>
</div>
</section>
<!-- AI ENGINES — mirrors the Search layout but shows only kind="llm"
engines, so picking an AI launcher and picking a web-search engine
live in separate pages. The default-engine picker here sets the
defaultLlmId (the sidebar strip's Pick AI target), not the toolbar's
search engine. Renderers share engIcon + rowFor; see renderAiEngines
below for the paint. -->
<section id="ai-engines" hidden>
<h1>AI engines</h1>
<p class="lede">Which AI launchers show up in the sidebar's Pick-AI dropdown, and which one opens when you click the sparkle button.</p>
<div class="row">
<div class="txt"><div class="t">Default AI engine</div><div class="d">Opens when you click the sparkle button in the sidebar strip.</div></div>
<div class="ctl"><div class="esel" id="defaultLlm" tabindex="0" role="combobox" aria-haspopup="listbox" aria-expanded="false" title="Default AI engine"><span class="eic"></span><span class="enm"></span><span class="caret">▾</span></div></div>
</div>
<div class="row" style="flex-direction:column;align-items:stretch;gap:10px">
<div class="txt">
<div class="t">Enabled AI engines</div>
<div class="d">These appear in the sidebar strip's Pick-AI dropdown. Drag to reorder. Toggle off to move an engine back to the catalog.</div>
</div>
<div id="llmEngineList"></div>
<div class="ehdr">Add from catalog</div>
<div id="llmCatalogList"></div>
<div style="display:flex;justify-content:flex-start;margin-top:6px">
<button id="llmAddBtn" class="btn" type="button">+ Show more</button>
</div>
<div id="llmEngineCatalog" class="engcat" hidden>
<div class="ehdr" style="display:flex;align-items:center;justify-content:space-between;gap:8px">
<span>Discover more AI engines</span>
<input id="llmEngineFilter" type="search" placeholder="Filter by name…" autocomplete="off"
style="background:#1b2330;color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:4px 8px;font-size:12px;min-width:auto;width:170px">
</div>
<div id="llmExtraList"></div>
<div class="ehdr">Or add a custom URL</div>
<div class="addeng">
<input id="llmEngSym" placeholder="✨" style="max-width:52px;text-align:center;flex:none">
<input id="llmEngName" placeholder="Name (e.g. My Chat)">
<input id="llmEngUrl" placeholder="https://example.com/chat?q=%s">
<button id="llmEngAdd" class="btn">Add</button>
</div>
</div>
</div>
</section>
<!-- PASSWORDS -->
<section id="passwords" hidden>
<h1>Passwords</h1>
@ -1110,27 +1178,42 @@
<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>
<h2 class="sub">How panels open</h2>
<div class="row" style="flex-direction:column;align-items:stretch;gap:10px">
<div style="display:flex;flex-direction:column;gap:6px">
<label class="polrow"><input type="radio" name="sidebarPanelMode" value="overlay"><span><b>Over the page</b> <span class="pmuted">— the panel floats over the left of the page, which keeps its width. A click in the page closes it.</span></span></label>
<label class="polrow"><input type="radio" name="sidebarPanelMode" value="push"><span><b>Push the page aside</b> <span class="pmuted">— the page narrows to make room, and the panel stays open until you close it.</span></span></label>
<!-- How panels open — compact one-row header + two side-by-side option
cards (click a card to select). Radios stay in the DOM so the init
code keeps working; the card is really a label wrapping the radio. -->
<div class="row" style="flex-direction:column;align-items:stretch;gap:8px">
<div class="txt"><div class="t">How panels open</div><div class="d">Choose how the sidebar panel sits with the page.</div></div>
<div class="sb-choice">
<label class="sb-opt"><input type="radio" name="sidebarPanelMode" value="overlay"><div><b>Over the page</b><div class="pmuted">Panel floats; click page to close.</div></div></label>
<label class="sb-opt"><input type="radio" name="sidebarPanelMode" value="push"><div><b>Push the page aside</b><div class="pmuted">Page narrows; panel stays open.</div></div></label>
</div>
</div>
<style>
.sb-choice { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.sb-opt { display: flex; gap: 10px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; cursor: pointer; background: var(--panel); align-items: flex-start; }
.sb-opt:hover { background: var(--card-hi, rgba(255,255,255,.04)); }
.sb-opt input { margin-top: 2px; }
.sb-opt:has(input:checked) { border-color: var(--acid, #d6ff3d); background: rgba(214,255,61,.08); }
@media (max-width: 540px) { .sb-choice { grid-template-columns: 1fr; } }
</style>
<div class="row">
<div class="txt"><div class="t">Reset panel widths</div><div class="d">Blanks every saved width so each app opens at its default.</div></div>
<div class="ctl"><button class="btn small ghost" id="sidebarResetWidths">Reset</button></div>
</div>
<div class="row">
<div class="txt">
<div class="t">Reset panel widths</div>
<div class="d">Each web app and extension panel remembers how wide you dragged it. This blanks every saved width — the next time an app opens it uses its default again.</div>
<div class="acts"><button class="btn small ghost" id="sidebarResetWidths">Reset widths</button></div>
</div>
<div class="txt"><div class="t">AI engine in the sidebar</div><div class="d">Sparkle button opens your default AI; <a data-go="search" style="color:var(--acid-text);cursor:pointer">Search</a> picks it.</div></div>
</div>
<div class="row">
<div class="txt">
<div class="t">AI engine in the sidebar</div>
<div class="d">The sparkle button at the bottom of the strip opens your default AI engine (right-click it to pick from any you've enabled, or set up more under <a data-go="search" style="color:var(--acid-text);cursor:pointer">Search</a>).</div>
</div>
<!-- Card A: Theseus native apps (Hermes, Pithos) — bundled system apps.
Rendered from sidebarLeftPanels(); filtered to the native-app ids. -->
<h2 class="sub">Theseus native apps</h2>
<p class="subd">System apps bundled with Theseus. They open from the sidebar strip.</p>
<div class="row" style="flex-direction:column;align-items:stretch;gap:6px">
<div id="sbNativeApps" style="display:flex;flex-direction:column;gap:6px"></div>
</div>
<h2 class="sub">Web apps</h2>
<!-- Card B: Third-party apps — the sidebar quick-links (Proton,
Telegram, WhatsApp, YouTube, X, …). Same qlList the Web apps
section used to drive, with the add-input below. -->
<h2 class="sub">Third-party apps</h2>
<p class="subd">Sites that open in the panel. Switch one off to take it off the strip; it stays here, still signed in, for switching back on. A site you installed as an app can be added from Apps.</p>
<div class="row" style="flex-direction:column;align-items:stretch;gap:8px">
<div id="qlList" style="display:flex;flex-direction:column;gap:6px"></div>
@ -1141,7 +1224,7 @@
</div>
</div>
<h2 class="sub">Extension panels</h2>
<p class="subd">Extensions that open from the strip. Switch one off to take it off the strip; the extension keeps working.</p>
<p class="subd">Extensions that open from the strip. Switch one off to take it off the strip; the extension keeps working. The native apps above live under their own card.</p>
<div class="row" style="flex-direction:column;align-items:stretch;gap:6px">
<div id="sbPanels" style="display:flex;flex-direction:column;gap:6px"></div>
</div>
@ -1240,7 +1323,7 @@
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.";
updStatus.textContent = v ? `Theseus current version v${v}.` : "Version unavailable — try Check for updates.";
}).catch((e) => {
updStatus.textContent = "Could not read version: " + (e?.message || e) + " — try Check for updates.";
});
@ -1271,18 +1354,14 @@
// (today: everything other than Windows).
(async () => {
const row = document.getElementById("defaultBrowserRow");
const assocRow = document.getElementById("fileAssocsRow");
const head = document.getElementById("defHandlingHead");
const defBrowser = document.getElementById("defaultBrowser");
const assoc = document.getElementById("fileAssocs");
const openSys = document.getElementById("openSysDefaults");
async function refresh() {
try {
const d = await cfg.defaultBrowserState();
if (!d.supported) return;
head.hidden = false; row.hidden = false; assocRow.hidden = false;
row.hidden = false;
defBrowser.checked = !!d.isDefault;
assoc.checked = !!d.hasAssocs;
} catch {}
}
defBrowser.addEventListener("change", async () => {
@ -1292,15 +1371,23 @@
if (defBrowser.checked) await cfg.openSystemDefaults();
setTimeout(refresh, 1000);
});
assoc.addEventListener("change", async () => {
assoc.disabled = true;
try { await cfg.setFileAssocs(assoc.checked); }
finally { assoc.disabled = false; }
setTimeout(refresh, 600);
});
openSys.addEventListener("click", () => cfg.openSystemDefaults());
refresh();
})();
// Aegis landing-section chevron — opens Aegis's own sidebar panel. Falls
// back to the plug-in sub-page if the panel isn't registered.
(function () {
const row = document.getElementById("aegisOpenRow");
if (!row) return;
row.addEventListener("click", async (e) => {
// The row's own data-go would route to Settings, not the Aegis panel.
e.preventDefault(); e.stopPropagation();
if (C && typeof C.openPanel === "function") {
try { await C.openPanel("aegis:main"); return; } catch {}
}
try { showSection("plugins/aegis"); } catch {}
});
})();
// sidebar navigation
// "naming" is intentionally absent: the Registries section was folded
// into General in 0.3.21. Leaving the id in this list threw a
@ -1672,26 +1759,47 @@
if (C.onSettingsUpdate) C.onSettingsUpdate((next) => { cur = next; render(next.quickLinks || []); });
})();
// ---- Extension panels on the strip (Sidebar) ---------------------------
// Two cards: native apps (Hermes + Pithos) are rendered into #sbNativeApps,
// every other left panel into #sbPanels. The native-id test matches a
// panel.id of the form "p:<addon>:<panel>" (what sidebar-left-panels
// returns), so hermes:main / pithos:main land in the native card.
(function () {
const nativeBox = document.getElementById("sbNativeApps");
const box = document.getElementById("sbPanels");
if (!box || !C.sidebarLeftPanels) return;
if ((!nativeBox && !box) || !C.sidebarLeftPanels) return;
const esc = (v) => String(v || "").replace(/[&<>"']/g, (c) => ({ "&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;" })[c]);
const NATIVE_IDS = new Set(["hermes", "pithos"]);
const addonOf = (pid) => String(pid || "").replace(/^p:/, "").split(":")[0];
const isNative = (p) => NATIVE_IDS.has(addonOf(p.id));
let cur = s;
function rowHtml(p, widths, B) {
const icon = /^data:image\//.test(p.icon) ? `<img src="${esc(p.icon)}" alt="" style="width:20px;height:20px">` : esc(p.icon || (p.title || "?").slice(0, 1));
return `<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:8px 12px">
<div style="width:28px;height:28px;border-radius:6px;background:rgba(255,255,255,.06);display:grid;place-items:center;flex:none">${icon}</div>
<div style="flex:1;min-width:140px;${p.hidden ? "opacity:.5" : ""}"><div style="font-weight:600">${esc(p.title)}</div><div style="font-size:12px;color:var(--mut)">${p.hidden ? "Off the strip" : "On the strip"}${widths[p.id] ? " · " + Number(widths[p.id]) + " px" : ""}</div></div>
${B ? B.widthBtnHtml(widths[p.id], p.id) : ""}
${B ? B.onStripHtml(!p.hidden, p.id) : ""}</div>`;
}
async function render() {
let panels = []; try { panels = (await C.sidebarLeftPanels()) || []; } catch {}
const widths = cur.leftPanelWidths || {};
const B = window.__sbButtons;
if (!panels.length) { box.innerHTML = `<div class="pmuted" style="font-size:12.5px">No extension opens from the strip.</div>`; return; }
box.innerHTML = panels.map((p) => {
const icon = /^data:image\//.test(p.icon) ? `<img src="${esc(p.icon)}" alt="" style="width:20px;height:20px">` : esc(p.icon || (p.title || "?").slice(0, 1));
return `<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:8px 12px">
<div style="width:28px;height:28px;border-radius:6px;background:rgba(255,255,255,.06);display:grid;place-items:center;flex:none">${icon}</div>
<div style="flex:1;min-width:140px;${p.hidden ? "opacity:.5" : ""}"><div style="font-weight:600">${esc(p.title)}</div><div style="font-size:12px;color:var(--mut)">${p.hidden ? "Off the strip" : "On the strip"}${widths[p.id] ? " · " + Number(widths[p.id]) + " px" : ""}</div></div>
${B ? B.widthBtnHtml(widths[p.id], p.id) : ""}
${B ? B.onStripHtml(!p.hidden, p.id) : ""}</div>`;
}).join("");
if (B) B.wireOnStrip(box);
if (window.__sbWireWidth) window.__sbWireWidth(box);
const native = panels.filter(isNative);
const rest = panels.filter((p) => !isNative(p));
if (nativeBox) {
nativeBox.innerHTML = native.length
? native.map((p) => rowHtml(p, widths, B)).join("")
: `<div class="pmuted" style="font-size:12.5px">Hermes and Pithos are not available.</div>`;
if (B) B.wireOnStrip(nativeBox);
if (window.__sbWireWidth) window.__sbWireWidth(nativeBox);
}
if (box) {
box.innerHTML = rest.length
? rest.map((p) => rowHtml(p, widths, B)).join("")
: `<div class="pmuted" style="font-size:12.5px">No extension opens from the strip.</div>`;
if (B) B.wireOnStrip(box);
if (window.__sbWireWidth) window.__sbWireWidth(box);
}
}
render();
document.addEventListener("section", (e) => { if (String(e.detail) === "sidebar") render(); });
@ -1852,9 +1960,10 @@
? `<img class="ei" src="${esc(e.favicon)}" onerror="this.replaceWith(Object.assign(document.createElement('span'),{className:'es',textContent:'${sym}'}))">`
: `<span class="es">${sym}</span>`;
};
// The Search page only shows kind=search. LLMs live on their own page
// (section #ai-engines), painted below by renderAiEngines.
const ENGINE_KINDS = [
{ key: "search", label: "Search engines" },
{ 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.
@ -1900,7 +2009,9 @@
`<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);
// Search catalog + extras — kind=search only; LLMs live under the
// AI engines page's own catalog, below.
const uninstalled = d.engines.filter((e) => e.builtin && !e.installed && (e.kind || "search") === "search");
const catalogOff = uninstalled.filter((e) => (e.tier || "catalog") === "catalog");
const extraOff = uninstalled.filter((e) => e.tier === "extra");
if (cat) {
@ -1926,6 +2037,11 @@
}
// Toggle: pure on/off in the enabled set — the row stays visible either way.
list.querySelectorAll('input[type="checkbox"]').forEach((cb) => cb.onchange = () => C.setEngineEnabled(cb.dataset.id, cb.checked).then(renderEngines));
// Also paint the AI engines page (kind=llm). Kept intentionally
// light-weight: same row/cat markup, no drag, no right-click menu, no
// custom URL form — those live on the Search page and are rare for AI
// launchers. The Default AI picker is a plain <select>-style button.
renderLlmEngines(d);
// Right-click any row → context menu with "Remove from list" (moves a
// built-in back to the catalog; deletes a custom entirely).
list.querySelectorAll(".eng").forEach((row) => {
@ -2030,6 +2146,118 @@
sel.onkeydown = (ev) => { if (selMenu) return; if (ev.key === "Enter" || ev.key === " " || ev.key === "ArrowDown" || ev.key === "ArrowUp") { ev.preventDefault(); openDefaultEngineMenu(); } };
// The toolbar picker changes the same setting; keep the control in step.
if (C.onSettingsUpdate) C.onSettingsUpdate((next) => { if (next && typeof next.searchEngine === "string" && next.searchEngine !== selCurrent) { selCurrent = next.searchEngine; paintDefaultEngine(); } });
// ---- AI engines page (section #ai-engines) ------------------------------
// Mirrors the Search page's shape but limited to kind=llm. The Default
// AI engine button writes settings.defaultLlmId (same key the sidebar
// strip reads); toggles reuse C.setEngineEnabled. The LLM catalog shows
// installable AI launchers; "Discover more" is wired for symmetry even
// though no LLM is on the "extra" tier today.
const defLlmSel = document.getElementById("defaultLlm");
let defLlmId = s.defaultLlmId || null;
function paintDefaultLlm(enabledLlms) {
if (!defLlmSel) return;
const cur = enabledLlms.find((e) => e.id === defLlmId) || enabledLlms[0];
defLlmSel.querySelector(".eic").innerHTML = cur ? engIcon(cur) : "";
defLlmSel.querySelector(".enm").textContent = cur ? cur.name : "No AI engines enabled";
}
function renderLlmEngines(d) {
const installed = d.engines.filter((e) => e.installed && (e.kind || "search") === "llm");
const enabledLlms = d.engines.filter((e) => e.enabled && (e.kind || "search") === "llm");
paintDefaultLlm(enabledLlms);
const list = document.getElementById("llmEngineList");
if (!list) return;
list.innerHTML = installed.length
? installed.map(rowFor).join("")
: `<div class="cempty2">No AI engines in your list. Click + Add AI engine below.</div>`;
list.querySelectorAll('input[type="checkbox"]').forEach((cb) => cb.onchange = () => C.setEngineEnabled(cb.dataset.id, cb.checked).then(renderEngines));
list.querySelectorAll(".eng").forEach((row) => {
row.addEventListener("contextmenu", (ev) => { ev.preventDefault(); openEngineMenu(row, ev.clientX, ev.clientY); });
});
const cat = document.getElementById("llmCatalogList");
const extra = document.getElementById("llmExtraList");
const filterInput = document.getElementById("llmEngineFilter");
const uninstalled = d.engines.filter((e) => e.builtin && !e.installed && (e.kind || "search") === "llm");
const catalogOff = uninstalled.filter((e) => (e.tier || "catalog") === "catalog");
const extraOff = uninstalled.filter((e) => e.tier === "extra");
if (cat) {
cat.innerHTML = catalogOff.length
? catalogOff.map(catRow).join("")
: `<div class="cempty2">All AI engines are already in your list.</div>`;
cat.querySelectorAll(".add").forEach((b) => b.onclick = () => C.setEngineEnabled(b.dataset.add, true).then(renderEngines));
}
if (extra) {
const paint = (q) => {
const filt = String(q || "").trim().toLowerCase();
const shown = filt ? extraOff.filter((e) => e.name.toLowerCase().includes(filt)) : extraOff;
extra.innerHTML = shown.length
? shown.map(catRow).join("")
: `<div class="cempty2">${filt ? "No AI engines match that filter." : "No extra AI engines yet."}</div>`;
extra.querySelectorAll(".add").forEach((b) => b.onclick = () => C.setEngineEnabled(b.dataset.add, true).then(renderEngines));
};
paint(filterInput ? filterInput.value : "");
if (filterInput && !filterInput.dataset.wired) {
filterInput.dataset.wired = "1";
filterInput.addEventListener("input", () => paint(filterInput.value));
}
}
}
// Default AI engine dropdown — mirrors the Search engine picker's menu.
let llmMenu = null;
function closeDefaultLlmMenu() { if (llmMenu) { llmMenu.remove(); llmMenu = null; defLlmSel?.classList.remove("open"); defLlmSel?.setAttribute("aria-expanded", "false"); } }
function openDefaultLlmMenu() {
if (!defLlmSel) return;
closeEngineMenu(); closeDefaultEngineMenu(); closeDefaultLlmMenu();
const enabledLlms = selEngines.filter((e) => (e.kind || "search") === "llm");
const m = document.createElement("div");
m.className = "ctxmenu eselmenu"; m.setAttribute("role", "listbox");
m.innerHTML = enabledLlms.map((e) => `<div class="mi${e.id === defLlmId ? " cur" : ""}" role="option" data-id="${esc(e.id)}"><span class="eic">${engIcon(e)}</span><span class="enm">${esc(e.name)}</span>${e.id === defLlmId ? '<span class="chk">✓</span>' : ""}</div>`).join("")
|| `<div class="cempty2" style="padding:10px 12px">Turn an AI engine on below first.</div>`;
document.body.appendChild(m);
const r = defLlmSel.getBoundingClientRect(), mr = m.getBoundingClientRect();
const vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
m.style.minWidth = r.width + "px";
m.style.left = Math.max(6, Math.min(r.left, vw - mr.width - 6)) + "px";
m.style.top = (r.bottom + 4 + mr.height > vh - 6 ? Math.max(6, r.top - 4 - mr.height) : r.bottom + 4) + "px";
const items = [...m.querySelectorAll(".mi")];
const choose = (id) => { closeDefaultLlmMenu(); if (id && id !== defLlmId) { defLlmId = id; C.set("defaultLlmId", id); paintDefaultLlm(enabledLlms); } defLlmSel.focus(); };
items.forEach((i) => { i.onclick = () => choose(i.dataset.id); });
const off = (ev) => { if (!m.contains(ev.target) && ev.target !== defLlmSel && !defLlmSel.contains(ev.target)) closeDefaultLlmMenu(); };
const key = (ev) => { if (ev.key === "Escape") { ev.preventDefault(); closeDefaultLlmMenu(); defLlmSel.focus(); } };
const cleanup = () => { document.removeEventListener("mousedown", off); document.removeEventListener("keydown", key, true); };
document.addEventListener("mousedown", off);
document.addEventListener("keydown", key, true);
const origRemove = m.remove.bind(m); m.remove = () => { cleanup(); origRemove(); };
llmMenu = m; defLlmSel.classList.add("open"); defLlmSel.setAttribute("aria-expanded", "true");
}
if (defLlmSel) {
defLlmSel.onclick = () => (llmMenu ? closeDefaultLlmMenu() : openDefaultLlmMenu());
defLlmSel.onkeydown = (ev) => { if (llmMenu) return; if (ev.key === "Enter" || ev.key === " " || ev.key === "ArrowDown" || ev.key === "ArrowUp") { ev.preventDefault(); openDefaultLlmMenu(); } };
}
// LLM custom-URL add form — same shape as the Search page's engAdd.
const llmAddBtn = document.getElementById("llmAddBtn");
const llmCatBox = document.getElementById("llmEngineCatalog");
if (llmAddBtn && llmCatBox) {
llmAddBtn.onclick = () => {
const open = llmCatBox.hidden;
llmCatBox.hidden = !open;
llmAddBtn.textContent = open ? "− Show less" : "+ Show more";
};
}
const llmEngAdd = document.getElementById("llmEngAdd");
if (llmEngAdd) {
llmEngAdd.onclick = () => {
const name = document.getElementById("llmEngName").value.trim();
const url = document.getElementById("llmEngUrl").value.trim();
const sym = document.getElementById("llmEngSym").value.trim();
if (!name || !url.includes("%s")) { alert("Enter a name and a URL containing %s (where the prompt goes)."); return; }
C.addEngine({ name, url, sym, kind: "llm" }).then((d) => {
document.getElementById("llmEngName").value = ""; document.getElementById("llmEngUrl").value = ""; document.getElementById("llmEngSym").value = "";
renderEngines(d);
});
};
}
if (C.onSettingsUpdate) C.onSettingsUpdate((next) => { if (next && typeof next.defaultLlmId !== "undefined" && next.defaultLlmId !== defLlmId) { defLlmId = next.defaultLlmId; C.engines().then((d) => paintDefaultLlm(d.engines.filter((e) => e.enabled && (e.kind || "search") === "llm"))); } });
// appearance (theme) — three visual cards: system | light | dark. Any
// unrecognised saved value falls back to "system" (follow the OS).
const th = document.getElementById("theme");
@ -2060,14 +2288,16 @@
renderEngines(d);
});
};
// "+ Add search engine" toggles the catalog panel below the enabled list.
// "+ Show more" opens the Discover-more engines list and the custom-URL
// form. The curated catalog above it is always visible — the common case
// (pick a curated engine) is one click.
const catBtn = document.getElementById("engAddBtn");
const catBox = document.getElementById("engineCatalog");
if (catBtn && catBox) {
catBtn.onclick = () => {
const open = catBox.hidden;
catBox.hidden = !open;
catBtn.textContent = open ? "− Hide catalog" : "+ Add search engine";
catBtn.textContent = open ? "− Show less" : "+ Show more";
};
}
C.engines().then(renderEngines);
@ -3048,10 +3278,26 @@
"session-proxy": "Can route the browser's traffic through a proxy it controls.",
"tab-control": "Can open, read and operate your web tabs for a program on this computer that you connect to it, on sites you approve one by one.",
};
// Extensions list ordering rules:
// HIDDEN_IDS — Hermes and Pithos are Theseus native apps; they appear in
// the Sidebar settings card instead of here.
// LAST_IDS — docx-editor and pdf-editor sort last within each group so
// the smaller utilities (Notepad, Screenshot, Translate, …)
// stay near the top of the list.
const ADDONS_HIDDEN_IDS = new Set(["hermes", "pithos"]);
const ADDONS_LAST_IDS = ["docx-editor", "pdf-editor"];
function addonSortLast(a, b) {
const ai = ADDONS_LAST_IDS.indexOf(a.id);
const bi = ADDONS_LAST_IDS.indexOf(b.id);
const ar = ai < 0 ? -1 : ai;
const br = bi < 0 ? -1 : bi;
return ar - br;
}
function renderAddons(snap) {
lastAddonSnap = snap;
// Plug-ins (Aegis, category "plugin") have their own cards under Plug-ins.
const items = ((snap && snap.installed) || []).filter((a) => a.category !== "plugin");
const items = ((snap && snap.installed) || [])
.filter((a) => a.category !== "plugin" && !ADDONS_HIDDEN_IDS.has(a.id));
if (!items.length) {
addonsList.innerHTML = '<div class="d" style="color:var(--dim)">No extensions installed. Install one from theseus.x/extensions, or drop a folder into the extensions directory.</div>';
return;
@ -3071,8 +3317,10 @@
+ '<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);
// Stable sort inside each bucket keeps registration order; the compare
// only moves ADDONS_LAST_IDS to the end of their bucket.
const enabled = items.filter((a) => !a.error && a.enabled).sort(addonSortLast);
const disabled = items.filter((a) => !a.error && !a.enabled).sort(addonSortLast);
const failed = items.filter((a) => a.error);
let html = "";
if (enabled.length) html += '<div class="xgrp">Enabled</div>' + enabled.map(rowFor).join("");