feat(theseus): page dialogs drawn by Theseus instead of Chromium's stock boxes

alert / confirm / prompt from a page came up as bare OS message boxes
titled "theseus-navigator" (the package name), with no hint of who was
asking and nothing of the browser's styling — the PDF Editor's "Delete
signature?" was the reported case.

The session preload replaces the page's three functions with wrappers
that hand the call to the isolated world through a DOM event, which
asks main synchronously and writes the answer back; pages see Chrome's
return values (confirm → boolean, prompt → string or null) and no new
global. Main answers from a sheet hanging under the toolbar, in the
same surface as add-on approvals, that names who is asking: the site's
host, the add-on's name for an add-on page or panel (identified by its
path under the profile's extensions directory), or Theseus for its own
pages. The sheet belongs to the tab that asked — hidden while another
tab is in front, back when its tab returns — and a closing tab or
window answers "cancel" so no renderer stays blocked. Windows without
the chrome (installed apps, plain windows) get a native box with a
proper title, and app.name now reads "Theseus Navigator" for whatever
else still shows one.
This commit is contained in:
Local Dev 2026-09-27 17:53:21 +02:00
parent ce066bb4ca
commit a75707d0ed
5 changed files with 290 additions and 1 deletions

View file

@ -52,3 +52,57 @@ try {
}); });
} }
} catch {} } 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 {}

8
js-dialog-preload.js Normal file
View file

@ -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)),
});

86
js-dialog.html Normal file
View file

@ -0,0 +1,86 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Dialog</title>
<style>
/* Same surface tokens as approval.html so page dialogs and add-on approvals
read as one family. */
:root { color-scheme: light dark;
--surface:#1c222c; --surface2:#0f1621; --line:rgba(255,255,255,.12);
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d; }
@media (prefers-color-scheme: light) {
:root { --surface:#ffffff; --surface2:#f1f4fa; --line:rgba(0,0,0,.12);
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5; --acid:#0AC18E; }
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: transparent; }
body { font: 13px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; color: var(--ink); }
.promptmask { position: fixed; inset: 0; background: rgba(0,0,0,.35);
display: grid; place-items: start center; padding-top: 0; }
/* A sheet hanging from the toolbar, the way Chrome anchors page dialogs:
the page stays visible and dimmed behind it. */
.promptbox { background: var(--surface); border: 1px solid var(--line); border-top: none;
border-radius: 0 0 12px 12px; padding: 14px 18px 14px;
width: min(480px, calc(100vw - 32px)); max-height: calc(100vh - 24px); overflow: auto;
box-shadow: 0 20px 60px #000b; animation: drop .12s ease-out; }
@keyframes drop { from { transform: translateY(-8px); opacity: 0; } to { transform: none; opacity: 1; } }
.who { display: flex; align-items: center; gap: 7px; color: var(--dim); font-size: 11.5px; margin-bottom: 8px; min-width: 0; }
.who .src { color: var(--mut); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.who .src.site { font: 600 12px/1.3 ui-monospace, "Cascadia Code", Consolas, monospace; }
.body { white-space: pre-wrap; overflow-wrap: anywhere; color: var(--ink); font-size: 13.5px; margin: 0 0 12px; max-height: 40vh; overflow: auto; }
.body:empty { display: none; }
input.pin { display: block; width: 100%; margin: 0 0 12px; padding: 8px 10px; border-radius: 7px;
background: var(--surface2); color: var(--ink); border: 1px solid var(--line); font: inherit; font-size: 13px; }
input.pin:focus { outline: none; border-color: rgb(from var(--acid) r g b / .6); }
.pact { display: flex; gap: 6px; justify-content: flex-end; }
.pbtn { padding: 7px 16px; border-radius: 7px; border: 1px solid var(--line); background: var(--surface2);
color: var(--ink); cursor: pointer; font: inherit; font-size: 12.5px; min-width: 76px; }
.pbtn:hover { border-color: rgb(from var(--acid) r g b / .35); }
.pbtn.primary { background: var(--acid); color: #0b0e14; border-color: transparent; font-weight: 650; }
.pbtn:focus-visible { outline: 2px solid rgb(from var(--acid) r g b / .6); outline-offset: 1px; }
</style>
</head>
<body>
<script>
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;" })[c]);
let current = null;
function finish(ok) {
if (!current) return;
const { reqId, kind } = current;
const input = document.getElementById("pin");
const value = kind === "prompt" ? (ok ? (input ? input.value : "") : null) : null;
current = null;
document.body.innerHTML = "";
window.jsdialog.answer(reqId, !!ok, value);
}
window.jsdialog.onShow((req) => {
current = req;
const who = req.who || { label: "This page", kind: "site" };
const icon = who.kind === "addon" ? "🧩" : who.kind === "app" ? "⛓" : "🌐";
const verb = req.kind === "prompt" ? "asks" : "says";
document.body.innerHTML =
`<div class="promptmask"><div class="promptbox" role="${req.kind === "alert" ? "alertdialog" : "dialog"}" aria-modal="true">
<div class="who"><span>${icon}</span><span class="src ${esc(who.kind)}" title="${esc(who.label)}">${esc(who.label)}</span><span>${verb}</span></div>
<div class="body">${esc(req.message)}</div>
${req.kind === "prompt" ? `<input class="pin" id="pin" type="text" value="${esc(req.def ?? "")}" spellcheck="false">` : ""}
<div class="pact">
${req.kind === "alert" ? "" : `<button class="pbtn" type="button" id="cancel">Cancel</button>`}
<button class="pbtn primary" type="button" id="ok">OK</button>
</div>
</div></div>`;
document.getElementById("ok").addEventListener("click", () => finish(true));
const c = document.getElementById("cancel"); if (c) c.addEventListener("click", () => finish(false));
const mask = document.querySelector(".promptmask");
mask.addEventListener("mousedown", (e) => { if (e.target === mask) finish(req.kind === "alert"); });
const input = document.getElementById("pin");
setTimeout(() => { if (input) { input.focus(); input.select(); } else document.getElementById("ok").focus(); }, 0);
});
document.addEventListener("keydown", (e) => {
if (!current) return;
if (e.key === "Escape") { e.preventDefault(); finish(current.kind === "alert"); }
else if (e.key === "Enter" && !(e.target && e.target.id === "cancel")) { e.preventDefault(); finish(true); }
});
</script>
</body>
</html>

141
main.js
View file

@ -69,6 +69,10 @@ if (process.env.THESEUS_USER_DATA) {
try { app.setPath("userData", relocateProfile(app.getPath("appData"))); } try { app.setPath("userData", relocateProfile(app.getPath("appData"))); }
catch (e) { console.warn("profile relocation failed:", e?.message); } 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) { function relocateProfile(appData) {
const newDir = path.join(appData, "Theseus"); const newDir = path.join(appData, "Theseus");
// Two possible previous locations: "Theseus Navigator" (what the code // 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 // Approval overlay sits exactly over the tab area — the page underneath
// keeps running; only pointer input is intercepted. // keeps running; only pointer input is intercepted.
if (approvalPop) approvalPop.setBounds({ x: 0, y: CHROME_H, width: tabW, height: bodyH }); 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(); positionPopover();
positionEnginePicker(); positionEnginePicker();
positionDownloads(); positionDownloads();
@ -2849,6 +2854,7 @@ function setActive(id) {
if (t && !t.addonId && !t.settings) lastCapturableTabId = t.id; if (t && !t.addonId && !t.settings) lastCapturableTabId = t.id;
if (t?.prov) pushNav(t.prov); if (t?.prov) pushNav(t.prov);
chrome.webContents.send("bcnr-offer", t?.bcnrOffer ? { host: t.bcnrOffer.host, tld: t.bcnrOffer.tld, registry: REGISTRY } : null); chrome.webContents.send("bcnr-offer", t?.bcnrOffer ? { host: t.bcnrOffer.host, tld: t.bcnrOffer.tld, registry: REGISTRY } : null);
syncJsDialogVisibility();
emitTabs(); emitTabs();
} }
function emitTabs() { function emitTabs() {
@ -3339,6 +3345,7 @@ function closeTab(id) {
const i = tabs.findIndex((t) => t.id === id); const i = tabs.findIndex((t) => t.id === id);
if (i < 0) return; if (i < 0) return;
const [t] = tabs.splice(i, 1); const [t] = tabs.splice(i, 1);
dismissJsDialogFor(id);
win.contentView.removeChildView(t.view); win.contentView.removeChildView(t.view);
t.view.webContents.destroy?.(); t.view.webContents.destroy?.();
if (tabs.length === 0) { createTab(); return; } if (tabs.length === 0) { createTab(); return; }
@ -3446,6 +3453,13 @@ function createWindow() {
styleScrollbars(approvalPop.webContents); styleScrollbars(approvalPop.webContents);
deferOverlayLoad(approvalPop, "approval.html"); deferOverlayLoad(approvalPop, "approval.html");
approvalPop.setVisible(false); 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. // 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 // dom-ready, NOT did-finish-load: the load event also waits for every
// subresource, and the bookmarks bar pulls its favicons over bns:// — // subresource, and the bookmarks bar pulls its favicons over bns:// —
@ -3465,7 +3479,8 @@ function createWindow() {
win.on("close", () => { saveSession(); sessionSavedAtClose = true; }); win.on("close", () => { saveSession(); sessionSavedAtClose = true; });
win.on("closed", () => { win.on("closed", () => {
win = null; chrome = null; popover = null; enginePicker = null; downloadsPop = null; 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; linkStatusVisible = false;
}); });
layout(); layout();
@ -4364,6 +4379,130 @@ function injectionsForSender(e, href) {
ipcMain.on("addon-inject-scripts", (e, href) => { e.returnValue = 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 // 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. // 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 <profile>/extensions/<id>/…; 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; let approvalPop = null;
const approvalQueue = []; const approvalQueue = [];
let approvalCurrent = null; // { reqId, resolve } let approvalCurrent = null; // { reqId, resolve }

View file

@ -83,6 +83,8 @@
"bcnr-preload.js", "bcnr-preload.js",
"bcnr-origin.js", "bcnr-origin.js",
"webapps.js", "webapps.js",
"js-dialog.html",
"js-dialog-preload.js",
"lib/**/*", "lib/**/*",
"package.json", "package.json",
"node_modules/**/*", "node_modules/**/*",