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.

+
+
+

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) => '
' + escapeHtml(s.name) + ' → ' + escapeHtml(s.version) + '
').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. loadAddons();