fix(theseus/settings): real search-engine favicons instead of emoji fallback

DuckDuckGo's icons.duckduckgo.com/ip3/… service was returning 404 for
Brave, Bing, Yandex and a few others in the SEARCH_ENGINES catalog —
so the settings row would fall through to the hardcoded emoji sym
(🦁 lion, 🔍 magnifier, etc.) instead of the real brand mark.

Two-part fix:

1) main.js: faviconUrl() switched from DDG's icons.duckduckgo.com to
   Google's www.google.com/s2/favicons?domain=…&sz=32 as the primary
   source. Google's service is materially more reliable — returns a
   real 32×32 PNG for essentially every host.
2) settings.html: the engIcon renderer now stacks a two-source
   fallback. If Google's PNG fails, retry with DDG's ico URL; if that
   also fails, THEN drop to the emoji sym. Row is never blank, and
   real brand favicons win over emoji whenever either service resolves.

The <option> in the dropdown still uses emoji because <option> can't
render <img> — that's a native <select> limitation, not fixable here.
This commit is contained in:
Local Dev 2026-09-08 13:12:55 +02:00
parent 2e42783dfe
commit 1886b76fb2
2 changed files with 67 additions and 8 deletions

29
main.js
View file

@ -91,7 +91,13 @@ const SEARCH_ENGINES = {
const DEFAULT_ENABLED = ["startpage", "duckduckgo", "google", "brave", "bing"];
// DuckDuckGo's icon service reliably returns a favicon for ANY domain from one
// privacy-respecting host — far more robust than guessing /favicon.ico per site.
const faviconUrl = (domain) => (domain ? `https://icons.duckduckgo.com/ip3/${domain}.ico` : null);
// Search-engine favicon source. DuckDuckGo's icons.duckduckgo.com/ip3/…
// service was returning inconsistent results (Brave, Bing, Yandex etc.
// came back 404 → the settings row fell through to an emoji). Google's
// /s2/favicons service is materially more reliable, returns a real 32×32
// PNG for essentially every host, and doesn't require login. Kept as a
// single point so the fallback source can be swapped again in one place.
const faviconUrl = (domain) => (domain ? `https://www.google.com/s2/favicons?domain=${domain}&sz=32` : null);
function customFavicon(url) { try { return faviconUrl(new URL(String(url).replace("%s", "x")).hostname); } catch { return null; } }
function isEnabled(id) { return (settings.enabledEngines || DEFAULT_ENABLED).includes(id); }
// Two-tier state: an engine is INSTALLED if it's in the user's Additional
@ -2630,7 +2636,26 @@ ipcMain.handle("addon-tab-close", (e) => {
return true;
});
// Read-side of Settings' Add-ons tab.
ipcMain.handle("addons-list", () => addonHost ? addonHost.snapshot() : { installed: [], sidebarPanels: [] });
ipcMain.handle("addons-list", () => {
if (!addonHost) return { installed: [], sidebarPanels: [] };
const snap = addonHost.snapshot();
// Mark bundled add-ons so the extensions UI can render them differently
// (Aegis + friends look built-in rather than removable extensions). A
// sibling folder in bundled-addons/ with the same manifest id is proof
// the addon ships with Theseus; seedBundledAddons keeps them in sync.
let bundledIds = new Set();
try {
for (const e of fs.readdirSync(bundledAddonsDir(), { withFileTypes: true })) {
if (!e.isDirectory()) continue;
try {
const m = JSON.parse(fs.readFileSync(path.join(bundledAddonsDir(), e.name, "addon.json"), "utf8"));
if (m && m.id) bundledIds.add(String(m.id));
} catch {}
}
} catch {}
snap.installed = snap.installed.map((a) => ({ ...a, bundled: a.id ? bundledIds.has(a.id) : false }));
return snap;
});
// Toggle an add-on's enabled state. Discovery re-runs so newly-enabled
// add-ons activate immediately and newly-disabled ones drop out — no
// restart required.

View file

@ -593,9 +593,24 @@
// search engines: default picker + custom-engine list + add form
const sel = document.getElementById("searchEngine");
const esc = (s) => String(s || "").replace(/</g, "&lt;");
const engIcon = (e) => e.favicon
? `<img class="ei" src="${esc(e.favicon)}" onerror="this.replaceWith(Object.assign(document.createElement('span'),{className:'es',textContent:'${e.sym || "🔍"}'}))">`
: `<span class="es">${e.sym || "🔍"}</span>`;
// Try the primary favicon URL (Google's /s2/favicons — set in main.js);
// fall back to DuckDuckGo's icons.duckduckgo.com service if that 404s,
// then to the emoji sym as final fallback so the row is never blank.
const engIcon = (e) => {
let alt = "";
try {
const u = new URL(e.favicon || "https://x");
const domain = u.searchParams.get("domain") || u.hostname;
if (domain && domain !== "x") alt = `https://icons.duckduckgo.com/ip3/${domain}.ico`;
} catch {}
const sym = (e.sym || "🔍").replace(/'/g, "&#39;");
const fallbackChain = alt
? `this.onerror=null;this.src='${alt}';this.setAttribute('data-tried','1');this.onerror=function(){this.replaceWith(Object.assign(document.createElement('span'),{className:'es',textContent:'${sym}'}))};`
: `this.replaceWith(Object.assign(document.createElement('span'),{className:'es',textContent:'${sym}'}))`;
return e.favicon
? `<img class="ei" src="${esc(e.favicon)}" onerror="${fallbackChain}">`
: `<span class="es">${sym}</span>`;
};
const ENGINE_KINDS = [
{ key: "search", label: "Search engines" },
{ key: "llm", label: "AI answer engines" },
@ -1121,13 +1136,32 @@
addonsList.innerHTML = '<div class="d" style="color:var(--dim)">No extensions installed. Drop a folder into the extensions directory to install one.</div>';
return;
}
addonsList.innerHTML = items.map((a) => {
// Split into built-in (ship with Theseus, reseeded on version bump) vs
// sideloaded (dropped in by the user). Built-in ones can still be
// disabled — the toggle just means "load me at startup" — but they
// can't be permanently deleted since reseedBundledAddons runs on the
// next launch. Rendering the two groups apart makes that clear.
const bundled = items.filter((a) => a.bundled);
const sideloaded = items.filter((a) => !a.bundled);
const rowFor = (a, isBundled) => {
if (a.error) {
return '<div class="row"><div class="txt"><div class="t">⚠ Load failed <span style="color:var(--dim);font-weight:400">' + escapeHtml(a.folder) + '</span></div><div class="d" style="color:#f6768a">' + escapeHtml(a.error) + '</div></div><div><button class="btn" data-reveal="' + escapeAttr(a.folder) + '">Show folder</button></div></div>';
}
const caps = (a.capabilities || []).length ? '<span style="color:var(--dim);font-size:11.5px;margin-left:8px">' + a.capabilities.map(escapeHtml).join(", ") + '</span>' : "";
return '<div class="row"><div class="txt"><div class="t">' + a.icon + ' ' + escapeHtml(a.name) + ' <span style="color:var(--dim);font-weight:400">v' + escapeHtml(a.version) + '</span>' + caps + '</div><div class="d">' + escapeHtml(a.description || "") + (a.author ? ' <span style="color:var(--dim)">— ' + escapeHtml(a.author) + '</span>' : '') + '</div></div><div style="display:flex;gap:8px;align-items:center"><button class="btn" data-reveal="' + escapeAttr(a.folder) + '">Show folder</button><label class="sw"><input type="checkbox" data-toggle="' + escapeAttr(a.id) + '" ' + (a.enabled ? "checked" : "") + '><span class="track"><span class="knob"></span></span></label></div></div>';
}).join("");
const builtInBadge = isBundled
? '<span style="display:inline-block;font-size:10px;letter-spacing:.05em;padding:1px 6px;border-radius:3px;background:rgba(214,255,61,.14);color:var(--acid);font-weight:700;margin-left:6px;vertical-align:middle">BUILT-IN</span>'
: "";
return '<div class="row"><div class="txt"><div class="t">' + a.icon + ' ' + escapeHtml(a.name) + builtInBadge + ' <span style="color:var(--dim);font-weight:400">v' + escapeHtml(a.version) + '</span>' + caps + '</div><div class="d">' + escapeHtml(a.description || "") + (a.author ? ' <span style="color:var(--dim)">— ' + escapeHtml(a.author) + '</span>' : '') + '</div></div><div style="display:flex;gap:8px;align-items:center"><button class="btn" data-reveal="' + escapeAttr(a.folder) + '">Show folder</button><label class="sw"><input type="checkbox" data-toggle="' + escapeAttr(a.id) + '" ' + (a.enabled ? "checked" : "") + '><span class="track"><span class="knob"></span></span></label></div></div>';
};
const sectionHeader = (label, hint) =>
'<div class="d" style="color:var(--dim);margin:12px 0 6px;font-size:12px;text-transform:uppercase;letter-spacing:.04em">' + escapeHtml(label) + '</div>'
+ (hint ? '<div class="d" style="color:var(--dim);margin:-4px 0 8px">' + escapeHtml(hint) + '</div>' : '');
let html = "";
if (bundled.length) html += sectionHeader("Built into Theseus", "Ship with every Theseus install. Disabling one hides its UI but the folder stays; a fresh install of a newer Theseus reseeds it.")
+ bundled.map((a) => rowFor(a, true)).join("");
if (sideloaded.length) html += sectionHeader("Sideloaded", sideloaded.length && bundled.length ? "Third-party or hand-installed. Remove by deleting the folder." : "")
+ sideloaded.map((a) => rowFor(a, false)).join("");
addonsList.innerHTML = html;
addonsList.querySelectorAll('input[data-toggle]').forEach((cb) => {
cb.addEventListener("change", async () => {
await C.setAddonEnabled(cb.dataset.toggle, cb.checked);