Theseus: in-page translator (LibreTranslate client)

The Website-language setting only tells servers what the user prefers via
Accept-Language — many static sites (including names on BCDN) serve one
language and ignore it, so e.g. hello.bch loads in English for every user,
in every language. This adds a translator that converts the page's visible
text in place, so a Lithuanian user reads hello.bch in Lithuanian without
asking the server for anything.

The URL-bar grows a translate chip next to the website-language globe. The
chip lights up when the page's declared `<html lang>` differs from the
user's preferred language. Click it once to translate in place; click again
to revert — originals are kept in a renderer-local state slot and swapped
back without a reload. Right-click opens the chip menu (change target /
translator settings).

The engine lives behind a swappable adapter in main — this ships with the
LibreTranslate backend (POST /translate with {q, source, target, format}).
The endpoint defaults to the LibreTranslate public tier but is settable in
Settings › General › Translate pages, so a user with a self-hosted
LibreTranslate (or Silent Mode's own translate.silentmode.st once it is
up) swaps it there without a code change. On-device Bergamot (the WASM
engine Firefox Translations uses) will plug into the same adapter in a
later release — same contract (array of texts in, array of translations
out), the chip and revert path are already engine-agnostic.

The fetch goes through session.defaultSession.fetch so Tor and add-on
proxy rules apply uniformly, chunks the batch at ~3.8 KB per POST so a
large page spreads across several requests, times each one out at 45 s,
and reports a failure to the chip's tooltip so a dead endpoint reads as
such and not as a silent no-op. The injected walker skips SCRIPT / STYLE
/ CODE / PRE / NOSCRIPT / TEXTAREA and contentEditable subtrees, keeps a
reference to each text node and the original text, and reverts by
restoring from that pair.
This commit is contained in:
Silent Mode 2026-10-03 15:01:40 +02:00
parent 8b0cc00290
commit f3c8998ecc
4 changed files with 325 additions and 4 deletions

View file

@ -513,6 +513,14 @@
<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="6"/><path d="M2 8 H14"/><path d="M8 2 C5 5 5 11 8 14 C11 11 11 5 8 2"/></svg>
<span class="lcode" id="langCode">AUTO</span>
</button>
<!-- Translate chip: lights up when the page's declared language
differs from the user's preferred one. Click translates in
place; click again reverts. Right-click opens the chip menu
(change target, translator settings). -->
<button class="langchip" id="trBtn" title="Translate this page" hidden>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2 4 H8 M5 2 V4 M3 4 C3 7 5 10 8 10 M8 10 C5 10 3 11 2 13 M3 10 C6 10 8 7 8 4"/><path d="M8.5 14 L11 7 L13.5 14 M9.3 12 H12.7"/></svg>
<span class="lcode" id="trCode">EN</span>
</button>
<button class="star" id="star" title="Save this page">☆</button>
</div>
<div class="bardrag" id="bardrag" title="Drag to shift the address / search bar ratio"></div>
@ -760,6 +768,58 @@
.then(renderLang).catch(() => {});
T.onSettingsUpdate && T.onSettingsUpdate(renderLang);
// ---- page-translate chip ----
// Shows when settings.translateAutoOffer is on and the loaded page's
// declared language (<html lang>) differs from the user's target. Click
// toggles between translated and original. Right-click (or long-press)
// opens the chip menu.
const trBtn = $("trBtn"), trCode = $("trCode");
let trSettingsCached = null, trStateCached = null;
function trTargetBase() {
const s = trSettingsCached || {};
const tag = s.languageMode === "manual" && s.languageValue ? s.languageValue : systemLocaleCached;
return String(tag || "en").split("-")[0].toLowerCase();
}
function renderTr() {
if (!trBtn) return;
const s = trSettingsCached || {};
const st = trStateCached || {};
const target = trTargetBase();
const page = String(st.pageLang || "").toLowerCase();
const translated = !!st.translated;
// Hide when auto-offer is off AND we aren't already translated, OR when
// the page is already in the user's target language.
const shouldShow = translated || (s.translateAutoOffer !== false && page && page !== target);
trBtn.hidden = !shouldShow;
if (!shouldShow) return;
trCode.textContent = (target || "??").toUpperCase().slice(0, 2);
if (translated) {
trBtn.classList.add("on");
trBtn.title = `Translated to ${langName(target)}. Click to show the original.`;
} else if (st.error) {
trBtn.classList.remove("on");
trBtn.title = `Translation failed: ${st.error}. Right-click for settings.`;
} else {
trBtn.classList.remove("on");
trBtn.title = `Translate this page from ${langName(page) || page} to ${langName(target)}`;
}
}
if (trBtn) {
trBtn.onclick = () => {
if (trStateCached?.translated) T.pageTranslateRevert && T.pageTranslateRevert();
else T.pageTranslate && T.pageTranslate();
};
trBtn.addEventListener("contextmenu", (e) => {
e.preventDefault();
const r = trBtn.getBoundingClientRect();
T.pageTranslateMenu && T.pageTranslateMenu({ x: Math.round(r.left), y: Math.round(r.bottom + 4) });
});
}
T.onPageTranslateState && T.onPageTranslateState((d) => { trStateCached = d; renderTr(); });
T.onSettingsUpdate && T.onSettingsUpdate((s) => { trSettingsCached = s; renderTr(); });
T.getSettings().then((s) => { trSettingsCached = s; renderTr(); }).catch(() => {});
if (T.pageTranslateState) T.pageTranslateState().then((d) => { trStateCached = d; renderTr(); }).catch(() => {});
// ---- extension dock ----
// One toolbar button per registered addon sidebar-panel, plus a static
// "coming soon" placeholder for Aegis (the built-in BCH wallet, in

236
main.js
View file

@ -516,6 +516,23 @@ const SETTINGS_DEFAULTS = {
// Global Privacy Control: the Sec-GPC header + navigator.globalPrivacyControl,
// a legally meaningful "do not sell or share" signal in several jurisdictions.
gpc: true,
// Page translator. Converts a page's visible text to the user's own
// language — Accept-Language only asks the server for a translated body
// (and many static sites, including BCNR names, serve only one). The
// engine is swappable; v0.3.70 ships a LibreTranslate client that talks
// to any API-compatible endpoint. On-device Bergamot/WASM is a follow-up
// (same contract: a function that takes an array of strings and a target
// tag, returns an array of translations).
translateBackend: "libretranslate", // libretranslate | (future) bergamot
translateEndpoint: "https://libretranslate.com/translate",
translateApiKey: "",
// Light up the URL-bar translate chip when the loaded page's language
// differs from the user's preferred one. Clicking the chip translates
// the page in place; clicking it again reverts.
translateAutoOffer: true,
// Hosts the user never wants auto-offered translation on — their own
// webmail, docs apps, anything they prefer in its original language.
translateExcludedHosts: [],
// Sidebar width in px. Adjusted by dragging the grip on the panel's left
// edge; persisted across launches. Clamped to [200, 800] on load.
sidebarWidth: 340,
@ -1383,9 +1400,9 @@ function closeOnClickAway(view, isOpen, hide) {
// Only a focus move inside an active window is a click elsewhere in
// Theseus. A popup shown while another app is in front gets focus and
// blur together, and must not close the moment it opens; switching to
// another app is handled by the window's own blur (closePopupsOnWindowBlur).
// The show functions only move focus into a popup while the window is
// active: focusing it from the background makes Windows touch the window,
// another app is handled by the window's own blur (closePopupsOnWindowBlur).
// The show functions only move focus into a popup while the window is
// active: focusing it from the background makes Windows touch the window,
// whose blur would close the popup it just opened.
if (!isOpen() || !win || win.isDestroyed() || !win.isFocused()) return;
clickAwayClosedAt.set(view, Date.now());
@ -3364,6 +3381,7 @@ function setActive(id) {
syncJsDialogVisibility();
notifyTabChange();
emitTabs();
if (t) emitTranslateState(t);
}
function emitTabs() {
const t = activeTab();
@ -3654,6 +3672,24 @@ function createTab(initial, opts = {}) {
});
wc.on("did-navigate", () => { if (tab.id === activeId) { notifyTabChange(); emitPwAvailability(); } });
wc.on("did-navigate-in-page", () => { if (tab.id === activeId) notifyTabChange(); });
// Translator state is per-document: a new navigation drops any "translated"
// flag and clears the cached page language. The chip then re-decides on
// the next pageLang read whether to light up for this new page.
wc.on("did-start-navigation", (_e, _url, _ihr, isMainFrame) => {
if (!isMainFrame) return;
tab._tr = null; tab.pageLang = "";
if (tab.id === activeId) emitTranslateState(tab);
});
// After the page has committed, read <html lang> once so the chip knows
// what language the server actually served (which may not match whatever
// we asked for via Accept-Language).
wc.on("did-finish-load", async () => {
try {
const lang = await wc.executeJavaScript(`document.documentElement.lang || ""`);
tab.pageLang = String(lang || "").toLowerCase().split("-")[0];
} catch { tab.pageLang = ""; }
if (tab.id === activeId) emitTranslateState(tab);
});
// Ctrl+wheel / pinch: Chromium only reports the intent on Windows and
// Linux, the zoom itself is up to us.
wc.on("zoom-changed", (_e, dir) => zoomStep(tab, dir === "in" ? 1 : -1));
@ -4493,6 +4529,200 @@ function languageNameFor(tag) {
try { return new Intl.DisplayNames(["en"], { type: "language" }).of(base) || tag; }
catch { return tag; }
}
// ---- page translator -------------------------------------------------------
// The user's own language, as a BCP-47 base tag ("en", "lt", "pt"): the
// preferred-language setting if they picked one; otherwise their OS locale.
// Base-only, because the translator cares about language, not region — a
// user in "en-GB" wants English, not British-English-but-not-American.
function translationTargetBase() {
const tag = settings.languageMode === "manual" && settings.languageValue
? settings.languageValue
: app.getLocale() || "en-US";
return String(tag).split("-")[0].toLowerCase() || "en";
}
// LibreTranslate POST /translate. `q` may be a single string or an array; the
// response's `translatedText` is a string or array to match. One POST per
// call — the caller chunks when the batch would exceed the request budget.
async function translatorLibreTranslate(texts, from, to) {
const url = settings.translateEndpoint || SETTINGS_DEFAULTS.translateEndpoint;
const body = {
q: texts,
source: from || "auto",
target: to,
format: "text",
};
if (settings.translateApiKey) body.api_key = settings.translateApiKey;
// session.defaultSession.fetch goes through Electron's own network stack,
// so Tor (session proxy) and any add-on proxy settings apply the same way
// they do for a tab's fetch; Node's global fetch would bypass both.
const sfetch = (session.defaultSession.fetch || fetch).bind(session.defaultSession);
const r = await sfetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(45000),
});
if (!r.ok) {
let detail = ""; try { detail = (await r.text()).slice(0, 300); } catch {}
throw new Error(`translator HTTP ${r.status}${detail ? ": " + detail : ""}`);
}
const data = await r.json();
if (Array.isArray(data.translatedText)) return data.translatedText;
if (typeof data.translatedText === "string") return [data.translatedText];
// Some LibreTranslate deployments return a bare array of {translatedText}.
if (Array.isArray(data)) return data.map((d) => d?.translatedText ?? "");
throw new Error("translator: unexpected response shape");
}
// Chunk a texts array so each POST stays under a reasonable size — LibreTranslate
// instances vary (3-5 KB is a safe shared floor), and a monolithic 50-page POST
// is also slower to recover from an upstream drop than four 12-page POSTs.
const TRANSLATE_CHUNK_CHARS = 3800;
function chunkTexts(texts) {
const chunks = [[]]; let charsInLast = 0;
for (const t of texts) {
const len = t.length + 2;
if (charsInLast + len > TRANSLATE_CHUNK_CHARS && chunks[chunks.length - 1].length > 0) {
chunks.push([]); charsInLast = 0;
}
chunks[chunks.length - 1].push(t);
charsInLast += len;
}
return chunks.filter((c) => c.length > 0);
}
async function translateAll(texts, from, to) {
if (!Array.isArray(texts) || texts.length === 0) return [];
const chunks = chunkTexts(texts);
const out = [];
for (const chunk of chunks) {
const got = await translatorLibreTranslate(chunk, from, to);
for (const s of got) out.push(s);
}
return out;
}
// Injected into the target page's renderer. Walks the body for visible text
// nodes, filters out script/style/code/pre and blank runs, saves originals
// on a window-scoped slot so revert can put them back without reloading, and
// returns the texts + the page's declared language. The slots never leak
// across pages (did-navigate drops the renderer context).
const PAGE_TRANSLATE_EXTRACT = `(() => {
const SKIP = new Set(["SCRIPT","STYLE","NOSCRIPT","CODE","PRE","TEXTAREA"]);
const nodes = [], texts = [];
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
let n; while ((n = walker.nextNode())) {
const s = n.nodeValue; if (!s || !s.trim()) continue;
let p = n.parentElement, skip = false;
while (p && p !== document.body) {
if (SKIP.has(p.tagName) || p.isContentEditable) { skip = true; break; }
p = p.parentElement;
}
if (skip) continue;
nodes.push(n); texts.push(s);
}
window.__theseusTranslate = { nodes, originals: texts.slice(), translated: null };
return { sourceLang: (document.documentElement.lang || "").toLowerCase(), texts };
})()`;
// Second-phase apply. Takes a translated-strings array (same order as the
// extract output), substitutes each node's nodeValue, and remembers the
// translated set so a second call to this script with "revert" can restore.
function pageTranslateApplyScript(translated) {
const payload = JSON.stringify(translated);
return `(() => {
const state = window.__theseusTranslate; if (!state) return 0;
const arr = ${payload};
for (let i = 0; i < state.nodes.length && i < arr.length; i++) {
if (arr[i] != null) state.nodes[i].nodeValue = arr[i];
}
state.translated = arr.slice();
return state.nodes.length;
})()`;
}
const PAGE_TRANSLATE_REVERT = `(() => {
const state = window.__theseusTranslate; if (!state) return 0;
for (let i = 0; i < state.nodes.length && i < state.originals.length; i++) {
state.nodes[i].nodeValue = state.originals[i];
}
state.translated = null;
return state.nodes.length;
})()`;
// A tab tracks its own translator state so chip + context menu + revert know
// what to show. { translated: false, source: "", target: "", error?: "" } —
// mutated in-place and broadcast via emitTranslateState so chrome can react.
function tabTranslateState(t) {
if (!t._tr) t._tr = { translated: false, source: "", target: "", error: "" };
return t._tr;
}
function emitTranslateState(t) {
if (!t || t.id !== activeId) return;
try { chrome?.webContents.send("page-translate-state", { ...tabTranslateState(t), pageLang: t.pageLang || "" }); } catch {}
}
async function translateActiveTab(target) {
const t = activeTab(); if (!t) return { ok: false, error: "no tab" };
if (t.settings || t.addonId || t.pending) return { ok: false, error: "not a web page" };
const st = tabTranslateState(t);
const to = (String(target || translationTargetBase()).split("-")[0] || "en").toLowerCase();
const wc = t.view.webContents;
try {
const extracted = await wc.executeJavaScript(PAGE_TRANSLATE_EXTRACT);
const texts = extracted?.texts || [];
if (!texts.length) { st.error = "nothing to translate"; emitTranslateState(t); return { ok: false, error: st.error }; }
const from = extracted.sourceLang || "auto";
const translated = await translateAll(texts, from, to);
await wc.executeJavaScript(pageTranslateApplyScript(translated));
st.translated = true; st.source = from; st.target = to; st.error = "";
emitTranslateState(t);
return { ok: true, source: from, target: to, count: translated.length };
} catch (e) {
st.translated = false; st.error = e?.message || String(e);
emitTranslateState(t);
console.warn("[translate] failed:", st.error);
return { ok: false, error: st.error };
}
}
async function revertActiveTab() {
const t = activeTab(); if (!t) return { ok: false };
if (t.settings || t.addonId || t.pending) return { ok: false };
const st = tabTranslateState(t);
try { await t.view.webContents.executeJavaScript(PAGE_TRANSLATE_REVERT); } catch (e) { console.warn("[translate] revert failed:", e?.message); }
st.translated = false; st.error = "";
emitTranslateState(t);
return { ok: true };
}
ipcMain.handle("page-translate", (e, target) => {
if (chrome && e.sender !== chrome.webContents) throw new Error("page-translate: untrusted sender");
return translateActiveTab(target);
});
ipcMain.handle("page-translate-revert", (e) => {
if (chrome && e.sender !== chrome.webContents) throw new Error("page-translate-revert: untrusted sender");
return revertActiveTab();
});
ipcMain.handle("page-translate-state", (e) => {
if (chrome && e.sender !== chrome.webContents) throw new Error("page-translate-state: untrusted sender");
const t = activeTab(); if (!t) return null;
return { ...tabTranslateState(t), pageLang: t.pageLang || "" };
});
// Dropdown off the URL-bar translate chip. "Translate to <target>" is the
// primary action; "Revert" shows while the page is translated; "More
// languages…" opens Settings › General so the user can pick a different
// target or edit the backend.
ipcMain.handle("page-translate-menu-popup", (e, rect) => {
if (chrome && e.sender !== chrome.webContents) throw new Error("page-translate-menu-popup: untrusted sender");
const t = activeTab(); const st = t ? tabTranslateState(t) : null;
const target = translationTargetBase();
const template = [
st?.translated
? { label: `Revert to ${languageNameFor(st.source || "auto")}`, click: () => revertActiveTab() }
: { label: `Translate this page to ${languageNameFor(target)}`, click: () => translateActiveTab() },
{ type: "separator" },
{ label: "Change target language…", click: () => { try { openSettingsTab("general"); } catch {} } },
{ label: "Translator settings…", click: () => { try { openSettingsTab("general"); } catch {} } },
];
const popup = Menu.buildFromTemplate(template);
const chromeBounds = chrome ? chrome.getBounds() : { x: 0, y: 0 };
const x = Math.max(0, Math.round(chromeBounds.x + (rect?.x || 0)));
const y = Math.max(0, Math.round(chromeBounds.y + (rect?.y || 0)));
popup.popup({ window: win, x, y });
return true;
});
ipcMain.handle("system-locale", () => app.getLocale() || "en-US");
ipcMain.handle("website-language-menu-popup", (e, rect) => {
if (chrome && e.sender !== chrome.webContents) throw new Error("website-language-menu-popup: untrusted sender");

View file

@ -40,6 +40,14 @@ contextBridge.exposeInMainWorld("theseus", {
onSettingsUpdate: (cb) => ipcRenderer.on("settings-update", (_e, d) => cb(d)),
systemLocale: () => ipcRenderer.invoke("system-locale"),
websiteLanguageMenu: (rect) => ipcRenderer.invoke("website-language-menu-popup", rect),
// Page translator chip: click translates to the user's language, click
// again reverts. The chip shows the target code; its "on" state is driven
// by the translated flag arriving via onPageTranslateState.
pageTranslate: (target) => ipcRenderer.invoke("page-translate", target),
pageTranslateRevert: () => ipcRenderer.invoke("page-translate-revert"),
pageTranslateState: () => ipcRenderer.invoke("page-translate-state"),
pageTranslateMenu: (rect) => ipcRenderer.invoke("page-translate-menu-popup", rect),
onPageTranslateState: (cb) => ipcRenderer.on("page-translate-state", (_e, d) => cb(d)),
// Find-in-page. main.js fires 'find-open' on Ctrl+F; chrome renderer
// owns the bar UI and drives findInPage / stopFindInPage via these
// wrappers. Match count / active ordinal comes back through onFindResult.

View file

@ -278,6 +278,20 @@
<input id="webLangOther" type="text" placeholder="BCP-47, e.g. cs-CZ" hidden style="width:150px">
</div>
</div>
<h2 class="sub">Translate pages</h2>
<p class="subd">Translate a page's visible text in place to your preferred language. The <b>Accept-Language</b> row above only asks the server for a translated body — many static sites (including names on BCDN) serve only one language, and translation turns them readable without a reload.</p>
<div class="row">
<div class="txt"><div class="t">Offer to translate</div><div class="d">Light up a translate chip in the address bar when the page's declared language is different from the one you picked above.</div></div>
<label class="sw"><input type="checkbox" id="translateAutoOffer"><span class="track"><span class="knob"></span></span></label>
</div>
<div class="row">
<div class="txt"><div class="t">Translation engine</div><div class="d">LibreTranslate-compatible endpoint. The public free tier is rate-limited; point this at a self-hosted LibreTranslate for unlimited use and privacy. On-device Bergamot (WASM) is coming in a later release.</div></div>
<input id="translateEndpoint" type="text" placeholder="https://libretranslate.com/translate" style="min-width:280px;background:#1b2330;color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:13px">
</div>
<div class="row">
<div class="txt"><div class="t">API key (optional)</div><div class="d">Some LibreTranslate instances need an API key for the paid tier or to raise the rate limit. Leave blank for the public endpoint.</div></div>
<input id="translateApiKey" type="text" placeholder="(none)" style="min-width:200px;background:#1b2330;color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:13px">
</div>
<h2 class="sub">Startup</h2>
<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.</div></div>
@ -1024,13 +1038,22 @@
})();
const TOGGLES = ["restoreSession", "backgroundThrottle", "freezeBackgroundTabs", "blockCamera", "blockMicrophone", "hideMediaDevices", "gpc",
"clearCookiesOnQuit", "clearCacheOnQuit", "clearStorageOnQuit", "clearHistoryOnQuit",
"quickLinksShow"];
"quickLinksShow", "translateAutoOffer"];
// Text inputs that round-trip through settings-set on change. Trimmed; a
// cleared field writes an empty string, which main re-defaults from
// SETTINGS_DEFAULTS on next launch.
const TEXT_FIELDS = ["translateEndpoint", "translateApiKey"];
C.get().then((s) => {
for (const k of TOGGLES) {
const el = document.getElementById(k); if (!el) continue;
el.checked = !!s[k];
el.addEventListener("change", () => C.set(k, el.checked));
}
for (const k of TEXT_FIELDS) {
const el = document.getElementById(k); if (!el) continue;
el.value = String(s[k] ?? "");
el.addEventListener("change", () => C.set(k, el.value.trim()));
}
// ---- Quick-links list editor (General) --------------------------------
// Add/remove rows; each change writes the whole settings.quickLinks array.
// The strip view and the main window layout react through settings-set.