feat(theseus/addons): CDP capture + editor Discard + manual update controls

Three tied-together fixes:

1) captureTab moves from WebContents.capturePage() to CDP
   Page.captureScreenshot for every mode (visible / full / region).
   Blank-screenshot symptom: after a toolbar-menu selection, the OS
   popup teardown left the tab view marked occluded for a few frames
   on some Windows setups, so capturePage() snapshotted a
   stale/transparent frame at the correct dimensions — no 0x0, no
   retry hit. CDP forces a fresh composite regardless of occlusion
   state (same path the "Full page" mode was already using) and
   returns a base64 PNG directly; PNG dimensions come out of the
   IHDR chunk (bytes 16-24). Attach only when nothing else has, and
   detach after only if WE attached, so an open DevTools stays
   attached.

2) Editor gets a Discard button. Toolbar picks up an "×" glyph next
   to Save/Copy that closes the editor tab and drops the working
   screenshot. Top-level Escape now falls through the same path
   after unwinding an in-flight text placement or crop rectangle. A
   new "addon-tab-close" IPC lets an add-on's own tab close itself
   (main matches the sender's webContents id against the tab list,
   so a page can only close its own tab); window.silentmode.closeTab()
   exposes it from addon-tab-preload.js.

3) Manual update controls in Settings > Extensions. New "Check for
   updates" button at the top of the Extensions surface calls the
   same signed-update polling the boot timer runs; the result is
   surfaced inline ("All extensions are up to date" / "N updates
   staged; restart Theseus to apply"). A "Pending updates" box
   below lists what's in <userData>/addons-updates-staged/ so the
   user knows what will be promoted on next restart.

Toolbar-menu popup settle bumped from 120 ms to 250 ms with an
explicit win.focus() in the popup close callback — the previous
window wasn't enough on slower Windows setups. CDP capture no longer
depends on this delay anyway, but the settle still helps any add-on
that does DOM work in its click handler before capture.

Screenshot add-on bumped 0.2.2 → 0.2.3 (Discard button; capture
fixes come from the host, not the add-on).
This commit is contained in:
Local Dev 2026-09-08 02:27:36 +02:00
parent f91a1f6988
commit 638aa4d326
7 changed files with 183 additions and 59 deletions

View file

@ -13,4 +13,8 @@ contextBridge.exposeInMainWorld("silentmode", {
}, },
invoke: (msg, payload) => ipcRenderer.invoke("addon-msg", String(msg), payload), invoke: (msg, payload) => ipcRenderer.invoke("addon-msg", String(msg), payload),
on: (msg, cb) => ipcRenderer.on("addon-event", (_e, name, payload) => { if (name === msg) cb(payload); }), on: (msg, cb) => ipcRenderer.on("addon-event", (_e, name, payload) => { if (name === msg) cb(payload); }),
// Close the tab this page lives in. Used by editor "Discard" and by
// Escape. Main derives the tab from the sender's webContents id so an
// add-on page can only close its own tab, never anyone else's.
closeTab: () => ipcRenderer.invoke("addon-tab-close"),
}); });

View file

@ -1,7 +1,7 @@
{ {
"id": "screenshot", "id": "screenshot",
"name": "Screenshot", "name": "Screenshot",
"version": "0.2.2", "version": "0.2.3",
"description": "Capture the current tab — visible viewport, entire scrollable page, or a rectangle you draw. Pick a mode from a toolbar dropdown; the capture opens in a full-tab editor (crop, annotate, redact, save).", "description": "Capture the current tab — visible viewport, entire scrollable page, or a rectangle you draw. Pick a mode from a toolbar dropdown; the capture opens in a full-tab editor (crop, annotate, redact, save).",
"author": "Silent Mode", "author": "Silent Mode",
"icon": "📸", "icon": "📸",

View file

@ -91,6 +91,10 @@
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v9M4 7l4 4 4-4M2 14h12"/></svg> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v9M4 7l4 4 4-4M2 14h12"/></svg>
<span>Save</span> <span>Save</span>
</button> </button>
<button class="tool wide" id="discard" title="Discard and close this tab (Esc)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg>
<span>Discard</span>
</button>
</div> </div>
<div class="board" id="board"> <div class="board" id="board">

View file

@ -407,11 +407,13 @@ draw.addEventListener("pointerup", (ev) => {
} }
}); });
// Escape cancels an in-flight crop selection or a text placement. // Escape unwinds progressively: in-flight text placement → in-flight
// crop rectangle → whole editor (drops the screenshot and closes the tab).
window.addEventListener("keydown", (ev) => { window.addEventListener("keydown", (ev) => {
if (ev.key === "Escape") { if (ev.key === "Escape") {
if (state.textInput) { cancelText(); ev.preventDefault(); return; } if (state.textInput) { cancelText(); ev.preventDefault(); return; }
if (state.tool === "crop") { state.cropRect = null; clearOverlay(); applyCropBtn.hidden = true; cancelCropBtn.hidden = true; return; } if (state.tool === "crop" && state.cropRect) { state.cropRect = null; clearOverlay(); applyCropBtn.hidden = true; cancelCropBtn.hidden = true; return; }
discard(); ev.preventDefault(); return;
} }
// Undo / redo shortcuts. // Undo / redo shortcuts.
const meta = ev.ctrlKey || ev.metaKey; const meta = ev.ctrlKey || ev.metaKey;
@ -555,5 +557,26 @@ async function copy() {
$("save").addEventListener("click", save); $("save").addEventListener("click", save);
$("copy").addEventListener("click", copy); $("copy").addEventListener("click", copy);
$("discard").addEventListener("click", discard);
// Drop the working screenshot and close the editor tab. Used by the
// Discard button and the top-level Escape shortcut. window.close() on a
// tab opened via api.openTab lands as the tab's close request; Theseus's
// tab manager honors it just like a normal cross-origin window.close().
async function discard() {
// Preferred path: ask main to close this tab. window.close() on a page
// Chromium didn't open via script is a no-op by default, and we don't
// want the editor stuck if the user has changed nothing to save.
try {
if (window.silentmode?.closeTab) {
const ok = await window.silentmode.closeTab();
if (ok) return;
}
} catch { /* fall through */ }
// Fallbacks: window.close() (works when the tab was opened via JS), then
// a hard navigation so at minimum the stale screenshot is gone.
try { window.close(); } catch {}
location.replace("about:blank");
}
init(); init();

160
main.js
View file

@ -1482,50 +1482,52 @@ function initAddons() {
const mode = String(opts?.mode || "visible"); const mode = String(opts?.mode || "visible");
const format = opts?.format === "jpeg" ? "jpeg" : "png"; const format = opts?.format === "jpeg" ? "jpeg" : "png";
const quality = Math.max(1, Math.min(100, Number(opts?.quality) || 90)); const quality = Math.max(1, Math.min(100, Number(opts?.quality) || 90));
const encode = (img) => format === "jpeg" // CDP-based capture. Page.captureScreenshot forces a fresh composite
? `data:image/jpeg;base64,${img.toJPEG(quality).toString("base64")}` // regardless of occlusion state, so it doesn't blank out when the tab
: img.toDataURL(); // view is marked hidden (which happened right after a native menu
// capturePage() intermittently returns a 0x0 image on Windows — usually // popup closed — the compositor stays throttled for a few frames and
// right after a navigation, when the view hasn't painted a frame yet. // WebContents.capturePage() would snapshot a stale/transparent frame
// Retry up to a few times with a short delay; without this the caller // at the correct dimensions, which no size-based retry could catch).
// sees a "data:image/png;base64," with no payload and treats it as a // Reads PNG width/height from the IHDR chunk so we don't need a
// failure ("blank screenshot"). // NativeImage roundtrip.
async function captureNonEmpty(rect) { function pngDims(b64) {
for (let i = 0; i < 6; i++) { const buf = Buffer.from(b64, "base64");
const img = rect ? await wc.capturePage(rect) : await wc.capturePage(); return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
const s = img.getSize(); }
if (s.width > 0 && s.height > 0) return img; async function cdpCapture({ full = false, rect = null } = {}) {
console.log(`[addons] [${addonId}] captureTab attempt ${i+1} returned 0x0, retrying`); const wasAttached = wc.debugger.isAttached();
await new Promise((r) => setTimeout(r, 150)); if (!wasAttached) {
try { wc.debugger.attach("1.3"); }
catch (e) {
if (!/already attached/i.test(String(e?.message))) throw e;
}
}
try {
const p = { format: format === "jpeg" ? "jpeg" : "png" };
if (format === "jpeg") p.quality = quality;
if (rect) p.clip = { x: rect.x, y: rect.y, width: rect.width, height: rect.height, scale: 1 };
if (full) p.captureBeyondViewport = true;
const { data } = await wc.debugger.sendCommand("Page.captureScreenshot", p);
const dataUrl = `data:image/${p.format};base64,${data}`;
const dims = p.format === "png" ? pngDims(data) : (rect ? { width: rect.width, height: rect.height } : null);
return { dataUrl, ...(dims || {}) };
} finally {
// Only detach if WE attached; leave a pre-existing DevTools/other
// consumer's attachment alone.
if (!wasAttached) { try { wc.debugger.detach(); } catch {} }
} }
throw new Error("capturePage returned 0x0 after 6 attempts — the tab may not be visible");
} }
if (mode === "visible") { if (mode === "visible") {
const img = await captureNonEmpty(); const r = await cdpCapture();
const s = img.getSize(); console.log(`[addons] [${addonId}] captureTab visible ${r.width}x${r.height}`);
console.log(`[addons] [${addonId}] captureTab visible ${s.width}x${s.height}`); return { dataUrl: r.dataUrl, width: r.width, height: r.height, host, format };
return { dataUrl: encode(img), width: s.width, height: s.height, host, format };
} }
if (mode === "full") { if (mode === "full") {
const dims = await wc.executeJavaScript( // Page.captureScreenshot with captureBeyondViewport does the whole
`({w: Math.max(document.documentElement.scrollWidth, document.body ? document.body.scrollWidth : 0),` // scrollable page in one shot, no setBounds gymnastics needed.
+ ` h: Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0),` const r = await cdpCapture({ full: true });
+ ` dpr: window.devicePixelRatio || 1})`, true); console.log(`[addons] [${addonId}] captureTab full ${r.width}x${r.height}`);
const fullW = Math.max(1, Math.min(16384, Math.floor(dims.w))); return { dataUrl: r.dataUrl, width: r.width, height: r.height, host, format };
const fullH = Math.max(1, Math.min(32768, Math.floor(dims.h)));
const prevBounds = t.view.getBounds();
try {
t.view.setBounds({ x: prevBounds.x, y: prevBounds.y, width: fullW, height: fullH });
// Let layout+paint catch up before capture. One requestAnimationFrame
// isn't enough for lazy-loaded content; a short delay gets most pages.
await new Promise((r) => setTimeout(r, 300));
const img = await captureNonEmpty();
const s = img.getSize();
console.log(`[addons] [${addonId}] captureTab full ${s.width}x${s.height} (page ${fullW}x${fullH})`);
return { dataUrl: encode(img), width: s.width, height: s.height, host, format };
} finally {
try { layout(); } catch {}
}
} }
if (mode === "region") { if (mode === "region") {
const src = String(opts?.overlaySource || ""); const src = String(opts?.overlaySource || "");
@ -1533,21 +1535,21 @@ function initAddons() {
// Overlay script runs in the target tab's world. It's expected to // Overlay script runs in the target tab's world. It's expected to
// resolve (as the executeJavaScript result) with {x,y,w,h} in CSS // resolve (as the executeJavaScript result) with {x,y,w,h} in CSS
// pixels, or null when the user hits Escape / right-clicks. // pixels, or null when the user hits Escape / right-clicks.
const rect = await wc.executeJavaScript(src, true); const rectRaw = await wc.executeJavaScript(src, true);
if (!rect || typeof rect !== "object") { if (!rectRaw || typeof rectRaw !== "object") {
console.log(`[addons] [${addonId}] captureTab region cancelled`); console.log(`[addons] [${addonId}] captureTab region cancelled`);
return { dataUrl: "", width: 0, height: 0, host, format, cancelled: true }; return { dataUrl: "", width: 0, height: 0, host, format, cancelled: true };
} }
const r = { const rect = {
x: Math.max(0, Math.floor(rect.x)), x: Math.max(0, Math.floor(rectRaw.x)),
y: Math.max(0, Math.floor(rect.y)), y: Math.max(0, Math.floor(rectRaw.y)),
width: Math.max(1, Math.floor(rect.w)), width: Math.max(1, Math.floor(rectRaw.w)),
height: Math.max(1, Math.floor(rect.h)), height: Math.max(1, Math.floor(rectRaw.h)),
}; };
const img = await captureNonEmpty(r); const r = await cdpCapture({ rect });
const s = img.getSize(); const s = { width: r.width || rect.width, height: r.height || rect.height };
console.log(`[addons] [${addonId}] captureTab region ${s.width}x${s.height} @ ${r.x},${r.y}`); console.log(`[addons] [${addonId}] captureTab region ${s.width}x${s.height} @ ${rect.x},${rect.y}`);
return { dataUrl: encode(img), width: s.width, height: s.height, host, format }; return { dataUrl: r.dataUrl, width: s.width, height: s.height, host, format };
} }
throw new Error(`unknown capture mode: ${mode}`); throw new Error(`unknown capture mode: ${mode}`);
}, },
@ -2590,18 +2592,37 @@ ipcMain.handle("toolbar-menu-popup", async (e, addonId, rect) => {
popup.popup({ window: win, x, y, callback: () => { popup.popup({ window: win, x, y, callback: () => {
try { chrome?.webContents.send("toolbar-menu-closed"); } catch {} try { chrome?.webContents.send("toolbar-menu-closed"); } catch {}
if (!picked) return; // user hit Escape or clicked outside if (!picked) return; // user hit Escape or clicked outside
// Small settle before the handler runs. Electron fires this callback // Explicitly return foreground to the parent window; on some Windows
// as the popup closes, but Windows takes a few frames to restore // configurations Electron's popup teardown alone leaves the app in a
// foreground state to the parent window; without the delay the // "not-quite-foreground" state until the next OS message pump tick.
// compositor is still throttled when captureTab hits capturePage(). try { win?.focus(); } catch {}
// Settle before the handler runs. Windows takes several frames to
// restore foreground and un-throttle the compositor; the previous
// 120 ms was too short on slower / higher-latency setups and led to
// blank frames. 250 ms is what the "full page" mode already uses.
// captureTab itself no longer relies on capturePage anyway (it goes
// through CDP Page.captureScreenshot, which forces a fresh composite),
// but the delay still helps addons that do their own DOM work in the
// click handler before capture.
const iid = picked; const iid = picked;
setTimeout(() => { setTimeout(() => {
addonHost.dispatch(id, "menu-select", { id: iid }, { from: "toolbar-menu" }) addonHost.dispatch(id, "menu-select", { id: iid }, { from: "toolbar-menu" })
.catch((err) => console.warn(`[addons] menu-select ${id}.${iid} failed:`, err?.message || err)); .catch((err) => console.warn(`[addons] menu-select ${id}.${iid} failed:`, err?.message || err));
}, 120); }, 250);
}}); }});
return true; return true;
}); });
// Close the tab a full-tab add-on page lives in. Sender identifies the
// webContents; we match it against our tab list and close that tab only.
// A page hosted elsewhere (or a spoofed sender not in the tab set) gets
// nothing.
ipcMain.handle("addon-tab-close", (e) => {
const senderId = e.sender.id;
const tab = tabs.find((t) => t.view?.webContents?.id === senderId);
if (!tab) return false;
closeTab(tab.id);
return true;
});
// Read-side of Settings' Add-ons tab. // Read-side of Settings' Add-ons tab.
ipcMain.handle("addons-list", () => addonHost ? addonHost.snapshot() : { installed: [], sidebarPanels: [] }); ipcMain.handle("addons-list", () => addonHost ? addonHost.snapshot() : { installed: [], sidebarPanels: [] });
// Toggle an add-on's enabled state. Discovery re-runs so newly-enabled // Toggle an add-on's enabled state. Discovery re-runs so newly-enabled
@ -2636,6 +2657,35 @@ ipcMain.handle("addons-reveal", (_e, folder) => {
ipcMain.handle("addons-open-dir", () => { ipcMain.handle("addons-open-dir", () => {
try { shell.openPath(addonsUserDir()); return true; } catch { return false; } try { shell.openPath(addonsUserDir()); return true; } catch { return false; }
}); });
// Manual "Check for updates" from Settings > Extensions. Runs the same
// checkAndStageUpdates the boot timer runs; returns a snapshot of the
// staged dir so the UI can render "Update to <ver> — restart to apply".
ipcMain.handle("addons-check-updates", async () => {
const stagedDir = addonsStagedDir();
try {
await addonUpdater.checkAndStageUpdates({
addonsDir: addonsUserDir(),
stagedDir,
pubkeysHex: ADDON_UPDATE_PUBKEYS,
logger: (...a) => console.log("[addons]", ...a),
});
} catch (e) { console.warn("[addons] check-updates failed:", e?.message || e); }
return listStagedAddons(stagedDir);
});
ipcMain.handle("addons-list-staged", () => listStagedAddons(addonsStagedDir()));
function listStagedAddons(stagedDir) {
const out = [];
let entries = [];
try { entries = fs.readdirSync(stagedDir, { withFileTypes: true }); } catch { return out; }
for (const de of entries) {
if (!de.isDirectory()) continue;
try {
const m = JSON.parse(fs.readFileSync(path.join(stagedDir, de.name, "addon.json"), "utf8"));
if (m?.id && m?.version) out.push({ id: m.id, name: m.name || m.id, version: m.version, folder: path.join(stagedDir, de.name) });
} catch {}
}
return out;
}
ipcMain.handle("addons-reload", () => { ipcMain.handle("addons-reload", () => {
if (!addonHost) return false; if (!addonHost) return false;
addonHost.discoverAndActivate(); addonHost.discoverAndActivate();

View file

@ -45,4 +45,6 @@ contextBridge.exposeInMainWorld("cfg", {
revealAddon: (folder) => ipcRenderer.invoke("addons-reveal", folder), revealAddon: (folder) => ipcRenderer.invoke("addons-reveal", folder),
openAddonsDir: () => ipcRenderer.invoke("addons-open-dir"), openAddonsDir: () => ipcRenderer.invoke("addons-open-dir"),
reloadAddons: () => ipcRenderer.invoke("addons-reload"), reloadAddons: () => ipcRenderer.invoke("addons-reload"),
checkAddonUpdates: () => ipcRenderer.invoke("addons-check-updates"),
listStagedAddonUpdates: () => ipcRenderer.invoke("addons-list-staged"),
}); });

View file

@ -547,9 +547,16 @@
Bundled reference extensions (like the Notepad) are copied there on first run — you can edit or remove them Bundled reference extensions (like the Notepad) are copied there on first run — you can edit or remove them
without losing anything the browser needs.</p> without losing anything the browser needs.</p>
<div class="row" style="justify-content:flex-end;gap:8px"> <div class="row" style="justify-content:flex-end;gap:8px">
<button id="addonsCheckUpdates" class="btn" type="button">Check for updates</button>
<button id="addonsReload" class="btn" type="button">Reload</button> <button id="addonsReload" class="btn" type="button">Reload</button>
<button id="addonsOpenDir" class="btn" type="button">Open extensions folder</button> <button id="addonsOpenDir" class="btn" type="button">Open extensions folder</button>
</div> </div>
<div id="addonsUpdStatus" class="pmuted" style="font-size:12.5px;margin-top:6px;text-align:right"></div>
<div id="addonsStagedBox" hidden style="margin-top:1rem;padding:12px 14px;border:1px solid var(--acid);border-radius:10px;background:rgba(214,255,61,.06)">
<div style="font-weight:600;margin-bottom:6px">Pending updates</div>
<div id="addonsStaged" style="font-size:13px"></div>
<div style="color:var(--dim);font-size:12px;margin-top:8px">These apply on the next Theseus restart.</div>
</div>
<h2 class="sub" style="border-top:0;padding-top:0;margin-top:1.5rem">Installed</h2> <h2 class="sub" style="border-top:0;padding-top:0;margin-top:1.5rem">Installed</h2>
<div id="addonsList"><div class="d" style="color:var(--dim)">Loading…</div></div> <div id="addonsList"><div class="d" style="color:var(--dim)">Loading…</div></div>
<div class="note">Extensions run with full app access — treat installing one like installing an unsigned executable. <div class="note">Extensions run with full app access — treat installing one like installing an unsigned executable.
@ -1136,7 +1143,41 @@
await C.reloadAddons(); loadAddons(); await C.reloadAddons(); loadAddons();
}); });
document.getElementById("addonsOpenDir").addEventListener("click", () => C.openAddonsDir()); document.getElementById("addonsOpenDir").addEventListener("click", () => C.openAddonsDir());
document.querySelector('.side a[data-sec="addons"]').addEventListener("click", loadAddons); document.querySelector('.side a[data-sec="addons"]').addEventListener("click", () => { loadAddons(); loadStagedAddonUpdates(); });
// Manual "Check for updates" for extensions — runs the same signed-update
// polling the boot timer runs, and surfaces what's staged for the next
// launch. Staged folders live in <userData>/addons-updates-staged/ and
// promoteStagedUpdates() applies them on next init.
async function loadStagedAddonUpdates() {
let staged = [];
try { staged = await C.listStagedAddonUpdates(); } catch { staged = []; }
const box = document.getElementById("addonsStagedBox");
const list = document.getElementById("addonsStaged");
if (!staged || !staged.length) { box.hidden = true; return; }
list.innerHTML = staged.map((s) => '<div>' + escapeHtml(s.name) + ' → <b>' + escapeHtml(s.version) + '</b></div>').join("");
box.hidden = false;
}
document.getElementById("addonsCheckUpdates").addEventListener("click", async () => {
const btn = document.getElementById("addonsCheckUpdates");
const status = document.getElementById("addonsUpdStatus");
btn.disabled = true; const orig = btn.textContent; btn.textContent = "Checking…";
status.textContent = "";
try {
const staged = await C.checkAddonUpdates();
if (!staged || !staged.length) {
status.textContent = "All extensions are up to date.";
} else {
status.textContent = staged.length + " update" + (staged.length > 1 ? "s" : "") + " staged; restart Theseus to apply.";
}
await loadStagedAddonUpdates();
} catch (e) {
status.textContent = "Check failed: " + (e?.message || e);
} finally {
btn.disabled = false; btn.textContent = orig;
}
});
loadStagedAddonUpdates();
// Populate on first paint so the tab is ready when the user clicks in. // Populate on first paint so the tab is ready when the user clicks in.
loadAddons(); loadAddons();
</script> </script>