diff --git a/bcnr-preload.js b/bcnr-preload.js index 9e307fbc..699b1257 100644 --- a/bcnr-preload.js +++ b/bcnr-preload.js @@ -52,3 +52,57 @@ try { }); } } catch {} + +// ---- page dialogs (alert / confirm / prompt) ---- +// Chromium's stock boxes are bare OS message boxes titled with the package +// name. The main world's alert/confirm/prompt are replaced with wrappers that +// hand the call to this isolated world through a DOM event (synchronous, no +// global left on window for pages to fingerprint), which asks main over +// sendSync and writes the answer back on the element. If nothing answers, +// the wrapper falls through to the original function. Theseus's own views +// share the session and get the same wrappers; main decides by sender. +try { + const { webFrame } = require("electron"); + document.addEventListener("theseus:jsdialog", (e) => { + const el = e.target; + if (!el || typeof el.getAttribute !== "function" || el.localName !== "theseus-dialog") return; + let r = null; + try { + r = ipcRenderer.sendSync("js-dialog", { + kind: el.getAttribute("data-kind"), + message: el.getAttribute("data-message") || "", + def: el.hasAttribute("data-default") ? el.getAttribute("data-default") : null, + }); + } catch { r = null; } + if (r && typeof r === "object" && r.handled) { + el.setAttribute("data-handled", r.value == null ? "null" : "1"); + el.setAttribute("data-result", r.value == null ? "" : String(r.value)); + } + }, true); + webFrame.executeJavaScript([ + "(() => {", + " const natives = { alert: window.alert, confirm: window.confirm, prompt: window.prompt };", + " const ask = (kind, message, def) => {", + " const root = document.documentElement; if (!root) return null;", + " const el = document.createElement('theseus-dialog'); el.hidden = true;", + " el.setAttribute('data-kind', kind); el.setAttribute('data-message', message);", + " if (def != null) el.setAttribute('data-default', def);", + " root.appendChild(el);", + " try { el.dispatchEvent(new Event('theseus:jsdialog', { bubbles: true })); } finally { el.remove(); }", + " if (!el.hasAttribute('data-handled')) return null;", + " return { value: el.getAttribute('data-handled') === 'null' ? null : el.getAttribute('data-result') };", + " };", + " const wrap = (kind) => function (message, def) {", + " const msg = arguments.length ? String(message) : '';", + " const r = ask(kind, msg, kind === 'prompt' ? (def == null ? '' : String(def)) : null);", + " if (!r) return natives[kind].apply(window, arguments);", + " if (kind === 'alert') return undefined;", + " if (kind === 'confirm') return r.value === '1';", + " return r.value;", + " };", + " for (const k of ['alert', 'confirm', 'prompt']) {", + " try { const f = wrap(k); Object.defineProperty(f, 'name', { value: k }); f.toString = () => 'function ' + k + '() { [native code] }'; window[k] = f; } catch (e) {}", + " }", + "})();", + ].join("\n")).catch(() => {}); +} catch {} diff --git a/js-dialog-preload.js b/js-dialog-preload.js new file mode 100644 index 00000000..f69a44a1 --- /dev/null +++ b/js-dialog-preload.js @@ -0,0 +1,8 @@ +// Preload for the page-dialog overlay (js-dialog.html): the Theseus-drawn +// replacement for a page's alert / confirm / prompt. Main pushes one request +// at a time via `jsdialog-show`; the page answers with `jsdialog-answer`. +const { contextBridge, ipcRenderer } = require("electron"); +contextBridge.exposeInMainWorld("jsdialog", { + onShow: (cb) => ipcRenderer.on("jsdialog-show", (_e, req) => cb(req)), + answer: (reqId, ok, value) => ipcRenderer.invoke("jsdialog-answer", reqId, !!ok, value == null ? null : String(value)), +}); diff --git a/js-dialog.html b/js-dialog.html new file mode 100644 index 00000000..c85d6dd2 --- /dev/null +++ b/js-dialog.html @@ -0,0 +1,86 @@ + + + + +Dialog + + + + + + diff --git a/main.js b/main.js index 75464c9e..c7b574bf 100644 --- a/main.js +++ b/main.js @@ -69,6 +69,10 @@ if (process.env.THESEUS_USER_DATA) { try { app.setPath("userData", relocateProfile(app.getPath("appData"))); } catch (e) { console.warn("profile relocation failed:", e?.message); } } +// Native boxes (and anything else that reads app.name) say "Theseus Navigator", +// not the package name. After the userData paths above: the default profile +// location derives from the name, and those are set explicitly already. +try { app.setName("Theseus Navigator"); } catch {} function relocateProfile(appData) { const newDir = path.join(appData, "Theseus"); // Two possible previous locations: "Theseus Navigator" (what the code @@ -2468,6 +2472,7 @@ function layout() { // Approval overlay sits exactly over the tab area — the page underneath // keeps running; only pointer input is intercepted. if (approvalPop) approvalPop.setBounds({ x: 0, y: CHROME_H, width: tabW, height: bodyH }); + if (jsDialogPop) jsDialogPop.setBounds({ x: 0, y: CHROME_H, width: tabW, height: bodyH }); positionPopover(); positionEnginePicker(); positionDownloads(); @@ -2849,6 +2854,7 @@ function setActive(id) { if (t && !t.addonId && !t.settings) lastCapturableTabId = t.id; if (t?.prov) pushNav(t.prov); chrome.webContents.send("bcnr-offer", t?.bcnrOffer ? { host: t.bcnrOffer.host, tld: t.bcnrOffer.tld, registry: REGISTRY } : null); + syncJsDialogVisibility(); emitTabs(); } function emitTabs() { @@ -3339,6 +3345,7 @@ function closeTab(id) { const i = tabs.findIndex((t) => t.id === id); if (i < 0) return; const [t] = tabs.splice(i, 1); + dismissJsDialogFor(id); win.contentView.removeChildView(t.view); t.view.webContents.destroy?.(); if (tabs.length === 0) { createTab(); return; } @@ -3446,6 +3453,13 @@ function createWindow() { styleScrollbars(approvalPop.webContents); deferOverlayLoad(approvalPop, "approval.html"); approvalPop.setVisible(false); + // Page dialogs (alert / confirm / prompt) — see the js-dialog block. + jsDialogPop = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "js-dialog-preload.js") } }); + try { jsDialogPop.setBackgroundColor("#00000000"); } catch {} + win.contentView.addChildView(jsDialogPop); + styleScrollbars(jsDialogPop.webContents); + deferOverlayLoad(jsDialogPop, "js-dialog.html"); + jsDialogPop.setVisible(false); // Everything that isn't needed to paint the toolbar waits for chrome.html. // dom-ready, NOT did-finish-load: the load event also waits for every // subresource, and the bookmarks bar pulls its favicons over bns:// — @@ -3465,7 +3479,8 @@ function createWindow() { win.on("close", () => { saveSession(); sessionSavedAtClose = true; }); win.on("closed", () => { win = null; chrome = null; popover = null; enginePicker = null; downloadsPop = null; - addressPicker = null; pwFillPop = null; linkStatus = null; sidebar = null; approvalPop = null; + dismissJsDialogFor(null); + addressPicker = null; pwFillPop = null; linkStatus = null; sidebar = null; approvalPop = null; jsDialogPop = null; linkStatusVisible = false; }); layout(); @@ -4364,6 +4379,130 @@ function injectionsForSender(e, href) { ipcMain.on("addon-inject-scripts", (e, href) => { e.returnValue = injectionsForSender(e, href); }); // Approval overlay. One request at a time; later callers queue behind the // visible one so two dapps can't race each other for the same click. +// ---- page dialogs: alert / confirm / prompt ---- +// Chromium's stock JavaScript dialogs are bare OS message boxes titled with +// the package name ("theseus-navigator"). The session preload reroutes the +// page's calls here, synchronously (sendSync), and the answer comes from a +// Theseus-drawn sheet under the toolbar that names who is asking — the +// site's host, or the add-on's name for add-on pages — like Chrome's +// "example.com says". Windows without the chrome (app windows, plain +// windows) fall back to a native box with a proper title. +let jsDialogPop = null; +const jsDialogQueue = []; +let jsDialogCurrent = null; // { e, req, tabId } +let jsDialogSeq = 0; +function jsDialogWho(sender, tab) { + let u = ""; try { u = sender.getURL() || ""; } catch {} + // Add-on pages — a full-tab page, a sidebar panel, a background page — all + // load from /extensions//…; the id is the first segment after + // that directory. (A tab's addonId flag would also stay set after the tab + // navigated elsewhere, so the URL is the reliable source.) + if (/^file:/i.test(u)) { + try { + const base = url.pathToFileURL(addonsUserDir()).href.replace(/\/?$/, "/"); + if (u.startsWith(base)) { + const id = decodeURIComponent(u.slice(base.length).split(/[/?#]/)[0]); + const a = addonHost && addonHost.getInstalled().find((x) => x.manifest && x.manifest.id === id); + return { label: a && a.manifest.name ? String(a.manifest.name) : id, kind: "addon" }; + } + } catch {} + } + if (tab && (tab.settings || (tab.prov && tab.prov.kind === "home"))) return { label: "Theseus", kind: "app" }; + // file: outside a tab is one of Theseus's own views; inside a tab it is a local file the user opened. + if (/^file:/i.test(u)) return tab ? { label: "This page", kind: "site" } : { label: "Theseus", kind: "app" }; + try { const p = new URL(u.replace(/^bns:\/\//i, "https://")); if (p.host) return { label: p.host, kind: "site" }; } catch {} + return { label: "This page", kind: "site" }; +} +function jsDialogReply(item, ok, value) { + const { kind } = item.req; + const out = kind === "confirm" ? (ok ? "1" : "0") : kind === "prompt" ? (ok ? String(value ?? "") : null) : ""; + try { item.e.returnValue = { handled: true, value: out }; } catch {} +} +function pumpJsDialog() { + if (jsDialogCurrent || !jsDialogQueue.length) return; + if (!jsDialogPop || !winAlive()) { for (const it of jsDialogQueue.splice(0)) jsDialogReply(it, false, null); return; } + const next = jsDialogQueue.shift(); + if (next.tabId != null && !tabById(next.tabId)) { jsDialogReply(next, false, null); return pumpJsDialog(); } + jsDialogCurrent = next; + // The dialog belongs to the tab that asked: bring that tab forward so + // the sheet never appears over an unrelated page. (A panel's dialog has + // no tab and shows over whatever is current.) + if (next.tabId != null && next.tabId !== activeId) { try { setActive(next.tabId); } catch {} } + overlayReady(jsDialogPop).then(() => { + if (jsDialogCurrent !== next) return; + try { + jsDialogPop.webContents.send("jsdialog-show", next.req); + jsDialogPop.setVisible(true); + try { win.contentView.removeChildView(jsDialogPop); win.contentView.addChildView(jsDialogPop); } catch {} + layout(); + jsDialogPop.webContents.focus(); + } catch (err) { + jsDialogCurrent = null; + jsDialogReply(next, false, null); + console.warn("page dialog show failed:", err?.message); + pumpJsDialog(); + } + }); +} +// The sheet belongs to one tab: hidden while another tab is in front, back +// (and above any tab added since) when its tab returns. A panel's sheet has +// no tab and stays. +function syncJsDialogVisibility() { + if (!jsDialogPop || !jsDialogCurrent || !winAlive()) return; + const show = jsDialogCurrent.tabId == null || jsDialogCurrent.tabId === activeId; + try { + jsDialogPop.setVisible(show); + if (show) { win.contentView.removeChildView(jsDialogPop); win.contentView.addChildView(jsDialogPop); jsDialogPop.webContents.focus(); } + } catch {} +} +function finishJsDialog(ok, value) { + const cur = jsDialogCurrent; if (!cur) return; + jsDialogCurrent = null; + try { if (jsDialogPop) jsDialogPop.setVisible(false); } catch {} + jsDialogReply(cur, ok, value); + try { const t = cur.tabId != null ? tabById(cur.tabId) : null; if (t && t.id === activeId) t.view.webContents.focus(); } catch {} + pumpJsDialog(); +} +// A tab closing (or the window going away) must not leave its renderer +// blocked inside a sendSync that nobody will answer. +function dismissJsDialogFor(tabId) { + for (let i = jsDialogQueue.length - 1; i >= 0; i--) if (tabId == null || jsDialogQueue[i].tabId === tabId) jsDialogReply(jsDialogQueue.splice(i, 1)[0], false, null); + if (jsDialogCurrent && (tabId == null || jsDialogCurrent.tabId === tabId)) finishJsDialog(false, null); +} +ipcMain.on("js-dialog", (e, raw) => { + const kind = ["alert", "confirm", "prompt"].includes(raw && raw.kind) ? raw.kind : "alert"; + const message = String((raw && raw.message) ?? "").slice(0, 4000); + const def = raw && raw.def != null ? String(raw.def).slice(0, 2000) : null; + const tab = tabs.find((t) => t.view?.webContents === e.sender); + const who = jsDialogWho(e.sender, tab); + // Anything living in the browser window — a tab, a sidebar panel, an + // add-on's page — gets the sheet. Only another window (an installed app's + // window, a plain window) gets a native box: the sheet is drawn in win. + let owner = null; try { owner = BrowserWindow.fromWebContents(e.sender); } catch {} + const inMain = winAlive() && (!!tab || !owner || owner === win); + if (!inMain || !jsDialogPop) { + if (kind === "prompt") { e.returnValue = { handled: false }; return; } + let parent; try { parent = BrowserWindow.fromWebContents(e.sender) || undefined; } catch {} + let r = 0; + try { + r = dialog.showMessageBoxSync(parent, { + type: kind === "confirm" ? "question" : "info", title: "Theseus Navigator", + message: `${who.label} says`, detail: message, + buttons: kind === "confirm" ? ["OK", "Cancel"] : ["OK"], defaultId: 0, cancelId: 1, noLink: true, + }); + } catch { r = 1; } + e.returnValue = { handled: true, value: kind === "confirm" ? (r === 0 ? "1" : "0") : "" }; + return; + } + jsDialogQueue.push({ e, tabId: tab ? tab.id : null, req: { reqId: ++jsDialogSeq, kind, message, def, who } }); + pumpJsDialog(); +}); +ipcMain.handle("jsdialog-answer", (e, reqId, ok, value) => { + if (!jsDialogPop || e.sender !== jsDialogPop.webContents) return false; + if (!jsDialogCurrent || jsDialogCurrent.req.reqId !== reqId) return false; + finishJsDialog(!!ok, value); + return true; +}); let approvalPop = null; const approvalQueue = []; let approvalCurrent = null; // { reqId, resolve } diff --git a/package.json b/package.json index 3b084245..9cb9bc5a 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,8 @@ "bcnr-preload.js", "bcnr-origin.js", "webapps.js", + "js-dialog.html", + "js-dialog-preload.js", "lib/**/*", "package.json", "node_modules/**/*",