diff --git a/chrome.html b/chrome.html index adcef4bc..b047e474 100644 --- a/chrome.html +++ b/chrome.html @@ -513,6 +513,14 @@ AUTO + +
@@ -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 () 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 diff --git a/main.js b/main.js index 10d33dd3..838411b9 100644 --- a/main.js +++ b/main.js @@ -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 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 " 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"); diff --git a/preload.js b/preload.js index 8b3c3f43..9a9885c0 100644 --- a/preload.js +++ b/preload.js @@ -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. diff --git a/settings.html b/settings.html index 83a0f42c..6764bfaf 100644 --- a/settings.html +++ b/settings.html @@ -278,6 +278,20 @@ +

Translate pages

+

Translate a page's visible text in place to your preferred language. The Accept-Language 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.

+
+
Offer to translate
Light up a translate chip in the address bar when the page's declared language is different from the one you picked above.
+ +
+
+
Translation engine
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.
+ +
+
+
API key (optional)
Some LibreTranslate instances need an API key for the paid tier or to raise the rate limit. Leave blank for the public endpoint.
+ +

Startup

Open previous windows and tabs
Restore the tabs from your last session when Theseus starts.
@@ -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.