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/**/*",