feat(theseus): Theseus's own prompts use the dialog sheet too

Install-this-app, remove-app, the extension install flow (install,
already installed, installed, not found, failed) and the add-on restart
question were still bare OS message boxes titled "theseus-navigator"
after page dialogs moved to the sheet. askSheet() is a drop-in for
dialog.showMessageBox with the same options and result: title as the
headline, detail under it, the caller's buttons with the default first,
an optional checkbox, and the app's or extension's icon when there is
one. Tone follows the box type (error, warning) or the wording. The
native box stays as the fallback when the browser window is not there,
and for the two synchronous cases (beforeunload, app windows).
This commit is contained in:
Local Dev 2026-09-27 20:13:06 +02:00
parent 45b318fc98
commit 70934a7553
4 changed files with 218 additions and 131 deletions

View file

@ -4,5 +4,6 @@
const { contextBridge, ipcRenderer } = require("electron"); const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("jsdialog", { contextBridge.exposeInMainWorld("jsdialog", {
onShow: (cb) => ipcRenderer.on("jsdialog-show", (_e, req) => cb(req)), 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)), // value: a string for prompt(); { response, checkboxChecked } for one of Theseus's own prompts.
answer: (reqId, ok, value) => ipcRenderer.invoke("jsdialog-answer", reqId, !!ok, value == null ? null : (typeof value === "object" ? value : String(value))),
}); });

View file

@ -1,121 +1,170 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>Dialog</title> <title>Dialog</title>
<style> <style>
/* Same surface tokens as approval.html so page dialogs and add-on approvals /* Same surface tokens as approval.html so page dialogs and add-on approvals
read as one family; tones follow the notification-card pattern (icon in a read as one family; tones follow the notification-card pattern (icon in a
tinted circle, tinted primary action, quiet dismiss). */ tinted circle, tinted primary action, quiet dismiss). */
:root { color-scheme: light dark; :root { color-scheme: light dark;
--surface:#1c222c; --surface2:#0f1621; --line:rgba(255,255,255,.12); --surface:#1c222c; --surface2:#0f1621; --line:rgba(255,255,255,.12);
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d; --ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d;
--ok:#3ddc84; --warn:#f5a524; --danger:#f6768a; --info:#8b98a9; } --ok:#3ddc84; --warn:#f5a524; --danger:#f6768a; --info:#8b98a9; }
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
:root { --surface:#ffffff; --surface2:#f1f4fa; --line:rgba(0,0,0,.12); :root { --surface:#ffffff; --surface2:#f1f4fa; --line:rgba(0,0,0,.12);
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5; --acid:#0AC18E; --ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5; --acid:#0AC18E;
--ok:#16a34a; --warn:#d97706; --danger:#dc2626; --info:#5c6577; } --ok:#16a34a; --warn:#d97706; --danger:#dc2626; --info:#5c6577; }
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: transparent; } 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); } 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); .promptmask { position: fixed; inset: 0; background: rgba(0,0,0,.35);
display: grid; place-items: start center; padding-top: 14px; } display: grid; place-items: start center; padding-top: 14px; }
.card { --tone: var(--info); .card { --tone: var(--info);
position: relative; background: var(--surface); border: 1px solid var(--line); border-radius: 14px; position: relative; background: var(--surface); border: 1px solid var(--line); border-radius: 14px;
padding: 16px 16px 14px 16px; width: min(440px, calc(100vw - 32px)); max-height: calc(100vh - 28px); overflow: auto; padding: 16px 16px 14px 16px; width: min(440px, calc(100vw - 32px)); max-height: calc(100vh - 28px); overflow: auto;
box-shadow: 0 18px 50px #000b; animation: drop .14s ease-out; } box-shadow: 0 18px 50px #000b; animation: drop .14s ease-out; }
.card.ok { --tone: var(--ok); } .card.warn { --tone: var(--warn); } .card.danger { --tone: var(--danger); } .card.ok { --tone: var(--ok); } .card.warn { --tone: var(--warn); } .card.danger { --tone: var(--danger); }
@keyframes drop { from { transform: translateY(-8px); opacity: 0; } to { transform: none; opacity: 1; } } @keyframes drop { from { transform: translateY(-8px); opacity: 0; } to { transform: none; opacity: 1; } }
.row { display: flex; gap: 12px; align-items: flex-start; } .row { display: flex; gap: 12px; align-items: flex-start; }
.icon { flex: none; width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; .icon { flex: none; width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center;
background: rgb(from var(--tone) r g b / .16); color: var(--tone); margin-top: 1px; } background: rgb(from var(--tone) r g b / .16); color: var(--tone); margin-top: 1px; }
.icon svg { width: 17px; height: 17px; } .icon svg { width: 17px; height: 17px; }
.text { flex: 1; min-width: 0; padding-right: 22px; } .text { flex: 1; min-width: 0; padding-right: 22px; }
.who { color: var(--dim); font-size: 11.5px; margin: 0 0 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .who { color: var(--dim); font-size: 11.5px; margin: 0 0 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.who .src { color: var(--mut); font-weight: 600; } .who .src { color: var(--mut); font-weight: 600; }
.who .src.site { font: 600 11.5px/1.3 ui-monospace, "Cascadia Code", Consolas, monospace; } .who .src.site { font: 600 11.5px/1.3 ui-monospace, "Cascadia Code", Consolas, monospace; }
.body { white-space: pre-wrap; overflow-wrap: anywhere; color: var(--ink); font-size: 13.5px; font-weight: 500; max-height: 40vh; overflow: auto; } .body { white-space: pre-wrap; overflow-wrap: anywhere; color: var(--ink); font-size: 13.5px; font-weight: 500; max-height: 40vh; overflow: auto; }
.close { position: absolute; top: 10px; right: 10px; width: 26px; height: 26px; border: none; border-radius: 7px; .close { position: absolute; top: 10px; right: 10px; width: 26px; height: 26px; border: none; border-radius: 7px;
background: transparent; color: var(--dim); cursor: pointer; font-size: 15px; line-height: 1; } background: transparent; color: var(--dim); cursor: pointer; font-size: 15px; line-height: 1; }
.close:hover { background: var(--surface2); color: var(--ink); } .close:hover { background: var(--surface2); color: var(--ink); }
input.pin { display: block; width: 100%; margin: 10px 0 0; padding: 8px 10px; border-radius: 8px; input.pin { display: block; width: 100%; margin: 10px 0 0; padding: 8px 10px; border-radius: 8px;
background: var(--surface2); color: var(--ink); border: 1px solid var(--line); font: inherit; font-size: 13px; } 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(--tone) r g b / .7); } input.pin:focus { outline: none; border-color: rgb(from var(--tone) r g b / .7); }
.pact { display: flex; gap: 8px; align-items: center; margin: 12px 0 0 42px; } .pact { display: flex; gap: 8px; align-items: center; margin: 12px 0 0 42px; }
.pbtn { padding: 7px 16px; border-radius: 8px; border: 1px solid transparent; cursor: pointer; font: inherit; font-size: 12.5px; font-weight: 600; min-width: 84px; } .pbtn { padding: 7px 16px; border-radius: 8px; border: 1px solid transparent; cursor: pointer; font: inherit; font-size: 12.5px; font-weight: 600; min-width: 84px; }
.pbtn.primary { background: rgb(from var(--tone) r g b / .18); color: var(--tone); border-color: rgb(from var(--tone) r g b / .35); } .pbtn.primary { background: rgb(from var(--tone) r g b / .18); color: var(--tone); border-color: rgb(from var(--tone) r g b / .35); }
.pbtn.primary:hover { background: rgb(from var(--tone) r g b / .28); } .pbtn.primary:hover { background: rgb(from var(--tone) r g b / .28); }
.card.info .pbtn.primary { background: var(--acid); color: #0b0e14; border-color: transparent; } .card.info .pbtn.primary { background: var(--acid); color: #0b0e14; border-color: transparent; }
.card.info .pbtn.primary:hover { filter: brightness(1.05); } .card.info .pbtn.primary:hover { filter: brightness(1.05); }
.pbtn.quiet { background: transparent; color: var(--mut); } .pbtn.quiet { background: transparent; color: var(--mut); }
.pbtn.quiet:hover { background: var(--surface2); color: var(--ink); } .pbtn.quiet:hover { background: var(--surface2); color: var(--ink); }
.pbtn:focus-visible { outline: 2px solid rgb(from var(--tone) r g b / .6); outline-offset: 1px; } .pbtn:focus-visible { outline: 2px solid rgb(from var(--tone) r g b / .6); outline-offset: 1px; }
</style> /* Theseus's own prompts: an app/extension icon in place of the tone glyph,
</head> a headline, the explanation under it, an optional checkbox. */
<body> .icon img.appicon { width: 30px; height: 30px; border-radius: 8px; object-fit: cover; display: block; }
<script> .icon.plain { background: transparent; }
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;" })[c]); .title { font-size: 14px; font-weight: 650; color: var(--ink); margin: 0 0 2px; }
// The page only gives us a sentence; read its tone from the words so a .detail { white-space: pre-wrap; overflow-wrap: anywhere; color: var(--mut); font-size: 12.5px; line-height: 1.45; margin-top: 4px; max-height: 40vh; overflow: auto; }
// "Delete…?" looks destructive and a "Saved." looks like good news. label.chk { display: flex; align-items: center; gap: 8px; margin-top: 10px; color: var(--mut); font-size: 12.5px; cursor: pointer; }
function toneFor(msg) { label.chk input { accent-color: var(--tone); }
const m = String(msg || "").toLowerCase(); </style>
if (/\b(error|fail|failed|failure|incorrect|invalid|wrong|cannot|can't|couldn't|unable|denied|delete|remove|discard|permanently|undone|lost|reject)\b/.test(m)) return "danger"; </head>
if (/\b(warning|caution|required|please provide|must|expire|expiring|expired|unsaved|leave|overwrite|replace)\b/.test(m)) return "warn"; <body>
if (/\b(success|successfully|saved|completed|complete|done|created|copied|sent|installed|updated|ready)\b/.test(m)) return "ok"; <script>
return "info"; const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;" })[c]);
} // The page only gives us a sentence; read its tone from the words so a
const ICONS = { // "Delete…?" looks destructive and a "Saved." looks like good news.
ok: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 8.5 L6.5 11.5 L12.5 5"/></svg>', function toneFor(msg) {
warn: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2.2 L14.3 13.3 H1.7 Z"/><path d="M8 6.2 V9.6"/><circle cx="8" cy="11.8" r=".6" fill="currentColor"/></svg>', const m = String(msg || "").toLowerCase();
danger: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="8" cy="8" r="6.3"/><path d="M8 4.6 V8.8"/><circle cx="8" cy="11.3" r=".6" fill="currentColor"/></svg>', if (/\b(error|fail|failed|failure|incorrect|invalid|wrong|cannot|can't|couldn't|unable|denied|delete|remove|discard|permanently|undone|lost|reject)\b/.test(m)) return "danger";
info: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="8" cy="8" r="6.3"/><path d="M8 7.2 V11.4"/><circle cx="8" cy="4.9" r=".6" fill="currentColor"/></svg>', if (/\b(warning|caution|required|please provide|must|expire|expiring|expired|unsaved|leave|overwrite|replace)\b/.test(m)) return "warn";
}; if (/\b(success|successfully|saved|completed|complete|done|created|copied|sent|installed|updated|ready)\b/.test(m)) return "ok";
let current = null; return "info";
function finish(ok) { }
if (!current) return; const ICONS = {
const { reqId, kind } = current; ok: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 8.5 L6.5 11.5 L12.5 5"/></svg>',
const input = document.getElementById("pin"); warn: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2.2 L14.3 13.3 H1.7 Z"/><path d="M8 6.2 V9.6"/><circle cx="8" cy="11.8" r=".6" fill="currentColor"/></svg>',
const value = kind === "prompt" ? (ok ? (input ? input.value : "") : null) : null; danger: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="8" cy="8" r="6.3"/><path d="M8 4.6 V8.8"/><circle cx="8" cy="11.3" r=".6" fill="currentColor"/></svg>',
current = null; info: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="8" cy="8" r="6.3"/><path d="M8 7.2 V11.4"/><circle cx="8" cy="4.9" r=".6" fill="currentColor"/></svg>',
document.body.innerHTML = ""; };
window.jsdialog.answer(reqId, !!ok, value); let current = null;
} function finish(ok) {
window.jsdialog.onShow((req) => { if (!current) return;
current = req; const { reqId, kind } = current;
const who = req.who || { label: "This page", kind: "site" }; const input = document.getElementById("pin");
const verb = req.kind === "prompt" ? "asks" : "says"; const value = kind === "prompt" ? (ok ? (input ? input.value : "") : null) : null;
const tone = toneFor(req.message); current = null;
const okLabel = req.kind === "alert" ? "OK" : tone === "danger" && /\?\s*$/.test(String(req.message || "")) ? "Yes" : "OK"; document.body.innerHTML = "";
document.body.innerHTML = window.jsdialog.answer(reqId, !!ok, value);
`<div class="promptmask"><div class="card ${tone}" role="${req.kind === "alert" ? "alertdialog" : "dialog"}" aria-modal="true"> }
<button class="close" id="close" type="button" title="${req.kind === "alert" ? "OK" : "Cancel"}" aria-label="Close">✕</button> // One of Theseus's own prompts: title + explanation, the caller's buttons
<div class="row"> // (default first), optional checkbox, an icon when the caller has one.
<div class="icon">${ICONS[tone]}</div> function finishApp(response) {
<div class="text"> if (!current) return;
<div class="who"><span class="src ${esc(who.kind)}" title="${esc(who.label)}">${esc(who.label)}</span> ${verb}</div> const { reqId, cancelId } = current;
<div class="body">${esc(req.message)}</div> const chk = document.getElementById("chk");
${req.kind === "prompt" ? `<input class="pin" id="pin" type="text" value="${esc(req.def ?? "")}" spellcheck="false">` : ""} current = null;
</div> document.body.innerHTML = "";
</div> window.jsdialog.answer(reqId, response !== cancelId, { response, checkboxChecked: !!(chk && chk.checked) });
<div class="pact"> }
<button class="pbtn primary" type="button" id="ok">${okLabel}</button> function showApp(req) {
${req.kind === "alert" ? "" : `<button class="pbtn quiet" type="button" id="cancel">Cancel</button>`} const tone = req.type === "error" ? "danger" : req.type === "warning" ? "warn" : toneFor(req.title + " " + req.message);
</div> const order = [req.defaultId, ...req.buttons.map((_, i) => i).filter((i) => i !== req.defaultId)];
</div></div>`; document.body.innerHTML =
document.getElementById("ok").addEventListener("click", () => finish(true)); `<div class="promptmask"><div class="card ${tone}" role="dialog" aria-modal="true">
const c = document.getElementById("cancel"); if (c) c.addEventListener("click", () => finish(false)); <button class="close" id="close" type="button" title="${esc(req.buttons[req.cancelId] || "Close")}" aria-label="Close">✕</button>
document.getElementById("close").addEventListener("click", () => finish(req.kind === "alert")); <div class="row">
const mask = document.querySelector(".promptmask"); <div class="icon${req.iconUrl ? " plain" : ""}">${req.iconUrl ? `<img class="appicon" src="${esc(req.iconUrl)}" alt="">` : ICONS[tone]}</div>
mask.addEventListener("mousedown", (e) => { if (e.target === mask) finish(req.kind === "alert"); }); <div class="text">
const input = document.getElementById("pin"); <div class="who"><span class="src app">Theseus</span> ${req.buttons.length > 1 ? "asks" : "says"}</div>
setTimeout(() => { if (input) { input.focus(); input.select(); } else document.getElementById("ok").focus(); }, 0); ${req.title && req.title !== req.message ? `<div class="title">${esc(req.message)}</div>` : `<div class="body">${esc(req.message)}</div>`}
}); ${req.detail ? `<div class="detail">${esc(req.detail)}</div>` : ""}
document.addEventListener("keydown", (e) => { ${req.checkbox ? `<label class="chk"><input type="checkbox" id="chk"${req.checkbox.checked ? " checked" : ""}> ${esc(req.checkbox.label)}</label>` : ""}
if (!current) return; </div>
if (e.key === "Escape") { e.preventDefault(); finish(current.kind === "alert"); } </div>
else if (e.key === "Enter" && !(e.target && (e.target.id === "cancel" || e.target.id === "close"))) { e.preventDefault(); finish(true); } <div class="pact">
}); ${order.map((i) => `<button class="pbtn ${i === req.defaultId ? "primary" : "quiet"}" type="button" data-i="${i}">${esc(req.buttons[i])}</button>`).join("")}
</script> </div>
</body> </div></div>`;
</html> document.querySelectorAll("button[data-i]").forEach((b) => b.addEventListener("click", () => finishApp(Number(b.dataset.i))));
document.getElementById("close").addEventListener("click", () => finishApp(req.cancelId));
const mask = document.querySelector(".promptmask");
mask.addEventListener("mousedown", (e) => { if (e.target === mask) finishApp(req.cancelId); });
setTimeout(() => { const b = document.querySelector("button.primary"); b && b.focus(); }, 0);
}
window.jsdialog.onShow((req) => {
current = req;
if (req.kind === "app") return showApp(req);
const who = req.who || { label: "This page", kind: "site" };
const verb = req.kind === "prompt" ? "asks" : "says";
const tone = toneFor(req.message);
const okLabel = req.kind === "alert" ? "OK" : tone === "danger" && /\?\s*$/.test(String(req.message || "")) ? "Yes" : "OK";
document.body.innerHTML =
`<div class="promptmask"><div class="card ${tone}" role="${req.kind === "alert" ? "alertdialog" : "dialog"}" aria-modal="true">
<button class="close" id="close" type="button" title="${req.kind === "alert" ? "OK" : "Cancel"}" aria-label="Close">✕</button>
<div class="row">
<div class="icon">${ICONS[tone]}</div>
<div class="text">
<div class="who"><span class="src ${esc(who.kind)}" title="${esc(who.label)}">${esc(who.label)}</span> ${verb}</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>
</div>
<div class="pact">
<button class="pbtn primary" type="button" id="ok">${okLabel}</button>
${req.kind === "alert" ? "" : `<button class="pbtn quiet" type="button" id="cancel">Cancel</button>`}
</div>
</div></div>`;
document.getElementById("ok").addEventListener("click", () => finish(true));
const c = document.getElementById("cancel"); if (c) c.addEventListener("click", () => finish(false));
document.getElementById("close").addEventListener("click", () => finish(req.kind === "alert"));
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 (current.kind === "app") {
if (e.key === "Escape") { e.preventDefault(); finishApp(current.cancelId); }
else if (e.key === "Enter" && !(e.target && e.target.matches("button.quiet, #close"))) { e.preventDefault(); finishApp(current.defaultId); }
return;
}
if (e.key === "Escape") { e.preventDefault(); finish(current.kind === "alert"); }
else if (e.key === "Enter" && !(e.target && (e.target.id === "cancel" || e.target.id === "close"))) { e.preventDefault(); finish(true); }
});
</script>
</body>
</html>

49
main.js
View file

@ -2163,8 +2163,7 @@ function initAddons() {
restartApp: async (addonName) => { restartApp: async (addonName) => {
const who = String(addonName || "An add-on").slice(0, 60); const who = String(addonName || "An add-on").slice(0, 60);
console.log(`[restart] ${who} asked to relaunch Theseus`); console.log(`[restart] ${who} asked to relaunch Theseus`);
const parent = win && !win.isDestroyed() ? win : undefined; const { response } = await askSheet({
const { response } = await dialog.showMessageBox(parent, {
type: "question", title: "Restart Theseus?", type: "question", title: "Restart Theseus?",
message: `${who} wants to restart Theseus.`, message: `${who} wants to restart Theseus.`,
detail: "Usually to finish installing its own update. Open tabs are restored after the restart. If you choose Later, the update still applies the next time Theseus starts.", detail: "Usually to finish installing its own update. Open tabs are restored after the restart. If you choose Later, the update still applies the next time Theseus starts.",
@ -4191,12 +4190,12 @@ async function installExtensionWithConsent(id, requester) {
try { card = (await fetchCommunityCatalog()).find((e) => e.id === id) || null; } catch {} try { card = (await fetchCommunityCatalog()).find((e) => e.id === id) || null; } catch {}
const parent = win && !win.isDestroyed() ? win : undefined; const parent = win && !win.isDestroyed() ? win : undefined;
if (!card) { if (!card) {
await dialog.showMessageBox(parent, { type: "warning", title: "Extension not found", message: `"${id}" is not in the community catalog.`, buttons: ["OK"] }); await askSheet({ type: "warning", title: "Extension not found", message: `"${id}" is not in the community catalog.`, buttons: ["OK"] });
return { ok: false, error: "not in the catalog" }; return { ok: false, error: "not in the catalog" };
} }
const installed = addonHost ? addonHost.snapshot().installed.find((a) => a.id === id) : null; const installed = addonHost ? addonHost.snapshot().installed.find((a) => a.id === id) : null;
if (installed && addonUpdater.cmpVer(card.latest, installed.version) <= 0) { if (installed && addonUpdater.cmpVer(card.latest, installed.version) <= 0) {
const { response: r0 } = await dialog.showMessageBox(parent, { const { response: r0 } = await askSheet({
type: "info", title: "Already installed", type: "info", title: "Already installed",
message: `${card.name || id} ${installed.version} is already installed.`, message: `${card.name || id} ${installed.version} is already installed.`,
detail: "Newer signed versions arrive through the regular update check. Manage it under Settings › Extensions.", detail: "Newer signed versions arrive through the regular update check. Manage it under Settings › Extensions.",
@ -4206,7 +4205,7 @@ async function installExtensionWithConsent(id, requester) {
return { ok: true, version: installed.version, publisher: installed.publisher || card.publisher, alreadyInstalled: true }; return { ok: true, version: installed.version, publisher: installed.publisher || card.publisher, alreadyInstalled: true };
} }
const from = requester ? `Requested by ${requester}.\n\n` : ""; const from = requester ? `Requested by ${requester}.\n\n` : "";
const { response } = await dialog.showMessageBox(parent, { const { response } = await askSheet({
type: "question", title: "Install extension", type: "question", title: "Install extension",
message: installed message: installed
? `Update ${card.name || id} ${installed.version} → ${card.latest}?` ? `Update ${card.name || id} ${installed.version} → ${card.latest}?`
@ -4218,7 +4217,7 @@ async function installExtensionWithConsent(id, requester) {
if (response !== 0) return { ok: false, error: "cancelled" }; if (response !== 0) return { ok: false, error: "cancelled" };
const r = await installCommunityById(id); const r = await installCommunityById(id);
if (r.ok) { if (r.ok) {
const { response: after } = await dialog.showMessageBox(parent, { const { response: after } = await askSheet({
type: "info", title: "Extension installed", type: "info", title: "Extension installed",
message: `${card.name || id} ${r.version} is installed and active.`, message: `${card.name || id} ${r.version} is installed and active.`,
detail: `Signed by ${r.publisher || card.publisher}. Manage it under Settings › Extensions.`, detail: `Signed by ${r.publisher || card.publisher}. Manage it under Settings › Extensions.`,
@ -4226,7 +4225,7 @@ async function installExtensionWithConsent(id, requester) {
}); });
if (after === 1) openSettingsTab("addons"); if (after === 1) openSettingsTab("addons");
} else { } else {
await dialog.showMessageBox(parent, { type: "error", title: "Install failed", message: `${card.name || id} was not installed.`, detail: r.error || "unknown error", buttons: ["OK"] }); await askSheet({ type: "error", title: "Install failed", message: `${card.name || id} was not installed.`, detail: r.error || "unknown error", buttons: ["OK"] });
} }
return r; return r;
} finally { installPromptOpen = false; } } finally { installPromptOpen = false; }
@ -4456,6 +4455,14 @@ function jsDialogWho(sender, tab) {
} }
function jsDialogReply(item, ok, value) { function jsDialogReply(item, ok, value) {
const { kind } = item.req; const { kind } = item.req;
if (item.resolve) { // one of Theseus's own prompts (askSheet)
const v = value && typeof value === "object" ? value : {};
const buttons = item.req.buttons || [];
let response = ok ? Number(v.response) : item.req.cancelId;
if (!Number.isInteger(response) || response < 0 || response >= buttons.length) response = item.req.cancelId;
item.resolve({ response, checkboxChecked: !!v.checkboxChecked });
return;
}
const out = kind === "confirm" ? (ok ? "1" : "0") : kind === "prompt" ? (ok ? String(value ?? "") : null) : ""; const out = kind === "confirm" ? (ok ? "1" : "0") : kind === "prompt" ? (ok ? String(value ?? "") : null) : "";
try { item.e.returnValue = { handled: true, value: out }; } catch {} try { item.e.returnValue = { handled: true, value: out }; } catch {}
} }
@ -4504,6 +4511,33 @@ function finishJsDialog(ok, value) {
try { const t = cur.tabId != null ? tabById(cur.tabId) : null; if (t && t.id === activeId) t.view.webContents.focus(); } catch {} try { const t = cur.tabId != null ? tabById(cur.tabId) : null; if (t && t.id === activeId) t.view.webContents.focus(); } catch {}
pumpJsDialog(); pumpJsDialog();
} }
// Theseus's own prompts — install this app / extension, remove, restart —
// in the same sheet as page dialogs. Same option shape as
// dialog.showMessageBox (title, message, detail, buttons, defaultId,
// cancelId, checkboxLabel, checkboxChecked, icon, type), same result; the
// native box remains the fallback when the browser window is not there.
function askSheet(opts) {
const o = opts || {};
const buttons = Array.isArray(o.buttons) && o.buttons.length ? o.buttons.map(String) : ["OK"];
const defaultId = Number.isInteger(o.defaultId) && o.defaultId >= 0 && o.defaultId < buttons.length ? o.defaultId : 0;
const cancelId = Number.isInteger(o.cancelId) && o.cancelId >= 0 && o.cancelId < buttons.length ? o.cancelId : (buttons.length > 1 ? buttons.length - 1 : 0);
if (!jsDialogPop || !winAlive()) {
const parent = win && !win.isDestroyed() ? win : undefined;
return dialog.showMessageBox(parent, { ...o, noLink: true }).catch(() => ({ response: cancelId, checkboxChecked: false }));
}
let iconUrl = null;
try { if (o.icon && typeof o.icon.toDataURL === "function" && !o.icon.isEmpty()) iconUrl = o.icon.toDataURL(); } catch {}
const req = {
reqId: ++jsDialogSeq, kind: "app", who: { label: "Theseus", kind: "app" },
title: String(o.title || ""), message: String(o.message || ""), detail: String(o.detail || ""),
buttons, defaultId, cancelId, type: String(o.type || "none"), iconUrl,
checkbox: o.checkboxLabel ? { label: String(o.checkboxLabel), checked: !!o.checkboxChecked } : null,
};
return new Promise((resolve) => {
jsDialogQueue.push({ resolve, tabId: null, req });
pumpJsDialog();
});
}
// A tab closing (or the window going away) must not leave its renderer // A tab closing (or the window going away) must not leave its renderer
// blocked inside a sendSync that nobody will answer. // blocked inside a sendSync that nobody will answer.
function dismissJsDialogFor(tabId) { function dismissJsDialogFor(tabId) {
@ -6361,6 +6395,7 @@ if (!process.env.THESEUS_NO_AUTOSTART && !app.requestSingleInstanceLock()) {
initAddons(); initAddons();
webapps.init({ webapps.init({
parentWindow: () => (win && !win.isDestroyed() ? win : undefined), parentWindow: () => (win && !win.isDestroyed() ? win : undefined),
ask: askSheet,
openInTab: openInMainTab, openInTab: openInMainTab,
targetUrlFor, isBnsHost, targetUrlFor, isBnsHost,
prepareContents: (wc) => { styleScrollbars(wc); try { wc.setWebRTCIPHandlingPolicy(webrtcPolicy()); } catch {} try { wc.setBackgroundThrottling(settings.backgroundThrottle); } catch {} applyFingerprint(wc); }, prepareContents: (wc) => { styleScrollbars(wc); try { wc.setWebRTCIPHandlingPolicy(webrtcPolicy()); } catch {} try { wc.setBackgroundThrottling(settings.backgroundThrottle); } catch {} applyFingerprint(wc); },

View file

@ -269,7 +269,8 @@ async function install(desc, ctx = {}) {
const parent = deps.parentWindow ? deps.parentWindow() : undefined; const parent = deps.parentWindow ? deps.parentWindow() : undefined;
let icon; // best effort preview in the dialog let icon; // best effort preview in the dialog
try { const raw = await fetchBytes(desc.iconUrl || ""); const i = nativeImage.createFromBuffer(raw); if (!i.isEmpty()) icon = i.resize({ width: 64, height: 64 }); } catch {} try { const raw = await fetchBytes(desc.iconUrl || ""); const i = nativeImage.createFromBuffer(raw); if (!i.isEmpty()) icon = i.resize({ width: 64, height: 64 }); } catch {}
const { response, checkboxChecked } = await dialog.showMessageBox(parent, { const ask = (o) => (deps.ask ? deps.ask(o) : dialog.showMessageBox(parent, o));
const { response, checkboxChecked } = await ask({
type: "question", title: "Install app", icon, type: "question", title: "Install app", icon,
message: `Install ${desc.name}?`, message: `Install ${desc.name}?`,
detail: `${desc.host}\n\nIt opens in its own window and gets a Start Menu entry. It keeps running inside Theseus, with your names, add-ons and settings.`, detail: `${desc.host}\n\nIt opens in its own window and gets a Start Menu entry. It keeps running inside Theseus, with your names, add-ons and settings.`,
@ -302,7 +303,8 @@ async function uninstall(key, ask = true) {
const entry = find(key); if (!entry) return false; const entry = find(key); if (!entry) return false;
if (ask) { if (ask) {
const parent = deps.parentWindow ? deps.parentWindow() : undefined; const parent = deps.parentWindow ? deps.parentWindow() : undefined;
const { response } = await dialog.showMessageBox(parent, { const ask = (o) => (deps.ask ? deps.ask(o) : dialog.showMessageBox(parent, o));
const { response } = await ask({
type: "question", title: "Remove app", message: `Remove ${entry.name} from Theseus?`, type: "question", title: "Remove app", message: `Remove ${entry.name} from Theseus?`,
detail: "Its window and shortcuts go away. The site itself and your data on it are untouched.", detail: "Its window and shortcuts go away. The site itself and your data on it are untouched.",
buttons: ["Remove", "Cancel"], defaultId: 0, cancelId: 1, noLink: true, buttons: ["Remove", "Cancel"], defaultId: 0, cancelId: 1, noLink: true,