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:
parent
f91a1f6988
commit
638aa4d326
7 changed files with 183 additions and 59 deletions
|
|
@ -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"),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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": "📸",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<span>Save</span>
|
||||
</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 class="board" id="board">
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
160
main.js
160
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 <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", () => {
|
||||
if (!addonHost) return false;
|
||||
addonHost.discoverAndActivate();
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.</p>
|
||||
<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="addonsOpenDir" class="btn" type="button">Open extensions folder</button>
|
||||
</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>
|
||||
<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.
|
||||
|
|
@ -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 <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.
|
||||
loadAddons();
|
||||
</script>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue