Compare commits
11 commits
41eb0acec9
...
f3a8d883e2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3a8d883e2 | ||
|
|
1916d6adfe | ||
|
|
1f394bb524 | ||
|
|
b1b393f02f | ||
|
|
94cfb98770 | ||
|
|
de1a5e18d4 | ||
|
|
1d4e85f1d2 | ||
|
|
fb3fa84d15 | ||
|
|
ffc4d4d79e | ||
|
|
aea158ab05 | ||
|
|
1a31ac0393 |
14 changed files with 688 additions and 171 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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() };
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
12
chrome.html
12
chrome.html
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
|
|
|
|||
53
error.html
53
error.html
|
|
@ -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(() => {});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
162
main.js
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
502
settings.html
502
settings.html
|
|
@ -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 & 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 › 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 › 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 & 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 & 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) => ({ "&":"&","<":"<",">":">","\"":""","'":"'" })[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("");
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue