diff --git a/addon-tab-preload.js b/addon-tab-preload.js
index 9057eb4..b8a49b1 100644
--- a/addon-tab-preload.js
+++ b/addon-tab-preload.js
@@ -13,4 +13,8 @@ contextBridge.exposeInMainWorld("silentmode", {
},
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); }),
+ // 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"),
});
diff --git a/bundled-addons/screenshot/addon.json b/bundled-addons/screenshot/addon.json
index 7e2022f..b2eeb70 100644
--- a/bundled-addons/screenshot/addon.json
+++ b/bundled-addons/screenshot/addon.json
@@ -1,7 +1,7 @@
{
"id": "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).",
"author": "Silent Mode",
"icon": "📸",
diff --git a/bundled-addons/screenshot/editor.html b/bundled-addons/screenshot/editor.html
index 91c3029..6b43400 100644
--- a/bundled-addons/screenshot/editor.html
+++ b/bundled-addons/screenshot/editor.html
@@ -91,6 +91,10 @@
Save
+
diff --git a/bundled-addons/screenshot/editor.js b/bundled-addons/screenshot/editor.js
index 867f5e3..54aad05 100644
--- a/bundled-addons/screenshot/editor.js
+++ b/bundled-addons/screenshot/editor.js
@@ -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) => {
if (ev.key === "Escape") {
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.
const meta = ev.ctrlKey || ev.metaKey;
@@ -555,5 +557,26 @@ async function copy() {
$("save").addEventListener("click", save);
$("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();
diff --git a/main.js b/main.js
index 505c4e8..4025124 100644
--- a/main.js
+++ b/main.js
@@ -1482,50 +1482,52 @@ function initAddons() {
const mode = String(opts?.mode || "visible");
const format = opts?.format === "jpeg" ? "jpeg" : "png";
const quality = Math.max(1, Math.min(100, Number(opts?.quality) || 90));
- const encode = (img) => format === "jpeg"
- ? `data:image/jpeg;base64,${img.toJPEG(quality).toString("base64")}`
- : img.toDataURL();
- // capturePage() intermittently returns a 0x0 image on Windows — usually
- // right after a navigation, when the view hasn't painted a frame yet.
- // Retry up to a few times with a short delay; without this the caller
- // sees a "data:image/png;base64," with no payload and treats it as a
- // failure ("blank screenshot").
- async function captureNonEmpty(rect) {
- for (let i = 0; i < 6; i++) {
- const img = rect ? await wc.capturePage(rect) : await wc.capturePage();
- const s = img.getSize();
- if (s.width > 0 && s.height > 0) return img;
- console.log(`[addons] [${addonId}] captureTab attempt ${i+1} returned 0x0, retrying`);
- await new Promise((r) => setTimeout(r, 150));
+ // CDP-based capture. Page.captureScreenshot forces a fresh composite
+ // regardless of occlusion state, so it doesn't blank out when the tab
+ // view is marked hidden (which happened right after a native menu
+ // popup closed — the compositor stays throttled for a few frames and
+ // WebContents.capturePage() would snapshot a stale/transparent frame
+ // at the correct dimensions, which no size-based retry could catch).
+ // Reads PNG width/height from the IHDR chunk so we don't need a
+ // NativeImage roundtrip.
+ function pngDims(b64) {
+ const buf = Buffer.from(b64, "base64");
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
+ }
+ async function cdpCapture({ full = false, rect = null } = {}) {
+ const wasAttached = wc.debugger.isAttached();
+ 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") {
- const img = await captureNonEmpty();
- const s = img.getSize();
- console.log(`[addons] [${addonId}] captureTab visible ${s.width}x${s.height}`);
- return { dataUrl: encode(img), width: s.width, height: s.height, host, format };
+ const r = await cdpCapture();
+ console.log(`[addons] [${addonId}] captureTab visible ${r.width}x${r.height}`);
+ return { dataUrl: r.dataUrl, width: r.width, height: r.height, host, format };
}
if (mode === "full") {
- const dims = await wc.executeJavaScript(
- `({w: Math.max(document.documentElement.scrollWidth, document.body ? document.body.scrollWidth : 0),`
- + ` h: Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0),`
- + ` dpr: window.devicePixelRatio || 1})`, true);
- const fullW = Math.max(1, Math.min(16384, Math.floor(dims.w)));
- 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 {}
- }
+ // Page.captureScreenshot with captureBeyondViewport does the whole
+ // scrollable page in one shot, no setBounds gymnastics needed.
+ const r = await cdpCapture({ full: true });
+ console.log(`[addons] [${addonId}] captureTab full ${r.width}x${r.height}`);
+ return { dataUrl: r.dataUrl, width: r.width, height: r.height, host, format };
}
if (mode === "region") {
const src = String(opts?.overlaySource || "");
@@ -1533,21 +1535,21 @@ function initAddons() {
// 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
// pixels, or null when the user hits Escape / right-clicks.
- const rect = await wc.executeJavaScript(src, true);
- if (!rect || typeof rect !== "object") {
+ const rectRaw = await wc.executeJavaScript(src, true);
+ if (!rectRaw || typeof rectRaw !== "object") {
console.log(`[addons] [${addonId}] captureTab region cancelled`);
return { dataUrl: "", width: 0, height: 0, host, format, cancelled: true };
}
- const r = {
- x: Math.max(0, Math.floor(rect.x)),
- y: Math.max(0, Math.floor(rect.y)),
- width: Math.max(1, Math.floor(rect.w)),
- height: Math.max(1, Math.floor(rect.h)),
+ const rect = {
+ x: Math.max(0, Math.floor(rectRaw.x)),
+ y: Math.max(0, Math.floor(rectRaw.y)),
+ width: Math.max(1, Math.floor(rectRaw.w)),
+ height: Math.max(1, Math.floor(rectRaw.h)),
};
- const img = await captureNonEmpty(r);
- const s = img.getSize();
- console.log(`[addons] [${addonId}] captureTab region ${s.width}x${s.height} @ ${r.x},${r.y}`);
- return { dataUrl: encode(img), width: s.width, height: s.height, host, format };
+ const r = await cdpCapture({ rect });
+ const s = { width: r.width || rect.width, height: r.height || rect.height };
+ console.log(`[addons] [${addonId}] captureTab region ${s.width}x${s.height} @ ${rect.x},${rect.y}`);
+ return { dataUrl: r.dataUrl, width: s.width, height: s.height, host, format };
}
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: () => {
try { chrome?.webContents.send("toolbar-menu-closed"); } catch {}
if (!picked) return; // user hit Escape or clicked outside
- // Small settle before the handler runs. Electron fires this callback
- // as the popup closes, but Windows takes a few frames to restore
- // foreground state to the parent window; without the delay the
- // compositor is still throttled when captureTab hits capturePage().
+ // Explicitly return foreground to the parent window; on some Windows
+ // configurations Electron's popup teardown alone leaves the app in a
+ // "not-quite-foreground" state until the next OS message pump tick.
+ 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;
setTimeout(() => {
addonHost.dispatch(id, "menu-select", { id: iid }, { from: "toolbar-menu" })
.catch((err) => console.warn(`[addons] menu-select ${id}.${iid} failed:`, err?.message || err));
- }, 120);
+ }, 250);
}});
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.
ipcMain.handle("addons-list", () => addonHost ? addonHost.snapshot() : { installed: [], sidebarPanels: [] });
// 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", () => {
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 — 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", () => {
if (!addonHost) return false;
addonHost.discoverAndActivate();
diff --git a/settings-preload.js b/settings-preload.js
index 749de7a..8025ad8 100644
--- a/settings-preload.js
+++ b/settings-preload.js
@@ -45,4 +45,6 @@ contextBridge.exposeInMainWorld("cfg", {
revealAddon: (folder) => ipcRenderer.invoke("addons-reveal", folder),
openAddonsDir: () => ipcRenderer.invoke("addons-open-dir"),
reloadAddons: () => ipcRenderer.invoke("addons-reload"),
+ checkAddonUpdates: () => ipcRenderer.invoke("addons-check-updates"),
+ listStagedAddonUpdates: () => ipcRenderer.invoke("addons-list-staged"),
});
diff --git a/settings.html b/settings.html
index 1b75ab8..64ca2d0 100644
--- a/settings.html
+++ b/settings.html
@@ -547,9 +547,16 @@
Bundled reference extensions (like the Notepad) are copied there on first run — you can edit or remove them
without losing anything the browser needs.
+
+
—
+
+
Pending updates
+
+
These apply on the next Theseus restart.
+
Installed
Loading…
Extensions run with full app access — treat installing one like installing an unsigned executable.
@@ -1136,7 +1143,41 @@
await C.reloadAddons(); loadAddons();
});
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 /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) => '