theseus/sidebar-preload.js
Local Dev ffeda26345 feat(theseus/addons): vault-derive, page-inject and approval-modal capabilities
Three opt-in capabilities for add-ons, plus the plumbing they need:

- vault-derive: api.vault.derive("<id>/<path>") resolves once the password
  vault is unlocked with a 32-byte HKDF child of the vault root under
  "silentmode/addons/<path>". Path must start with the add-on id.
- page-inject: manifest "page-inject" {preload, origins}; a session-wide
  preload asks main (sync, against the committed URL) which add-on bridges
  apply and runs them in the isolated world with a scoped `theseus` object.
- approval-modal: api.approvalModal({title, body, origin, rows, actions,
  checkbox}) shows a consent overlay over the tab area (approval.html);
  resolves to the picked action id, "cancel", or "<id>+<checkbox>".
- api.onMessage/emit + window.silentmode.invoke/on for panel <-> activate()
  messaging; page bridges use addon-page-msg, gated by tab + origin match.
- api.require so add-ons can share Theseus's dependency tree.
2026-09-06 02:33:26 +02:00

109 lines
5.2 KiB
JavaScript

// Preload shared by every add-on sidebar panel. Exposes a small, safe
// surface to the add-on's HTML. Main-side handlers derive the add-on
// identity from the sender's URL (the panel is always loaded from
// somewhere inside <userData>/addons/<id>/), so a random page that
// happens to see the API shape can't touch another add-on's storage.
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("silentmode", {
storage: {
get: (key, fallback = null) => ipcRenderer.invoke("addon-storage-get", key, fallback),
set: (key, value) => ipcRenderer.invoke("addon-storage-set", key, value),
all: () => ipcRenderer.invoke("addon-storage-all"),
},
// Ask main which panel is currently visible — panels may want to
// suspend expensive work when hidden.
onVisibility: (cb) => ipcRenderer.on("sidebar-visibility", (_e, visible) => cb(!!visible)),
// Call into the add-on's activate() context: resolves with whatever the
// matching api.onMessage handler returned (or rejects with its error).
invoke: (msg, payload) => ipcRenderer.invoke("addon-msg", String(msg), payload),
// Events the add-on pushes via api.emit while this panel is open.
on: (msg, cb) => ipcRenderer.on("addon-event", (_e, name, payload) => { if (name === msg) cb(payload); }),
});
// -- Panel picker strip -----------------------------------------------------
// Injected at the top of every panel document so the user can switch between
// registered extension panels without a settings round-trip. Only appears
// when 2+ panels exist. Main pushes the panel list here via IPC on the
// initial sidebar-state event.
async function installPickerStrip() {
let state;
try { state = await ipcRenderer.invoke("sidebar-state"); } catch { return; }
const panels = (state && state.panels) || [];
if (panels.length < 2) return;
const strip = document.createElement("div");
strip.setAttribute("aria-label", "Extension panels");
strip.style.cssText = [
"position:fixed", "top:0", "left:5px", "right:0", "height:32px",
"display:flex", "align-items:stretch", "gap:1px",
"background:rgba(11,14,20,.85)", "backdrop-filter:blur(8px)",
"border-bottom:1px solid rgba(255,255,255,.10)",
"z-index:2147483646", "user-select:none",
"font:12px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif",
].join(";");
const active = state.active;
for (const p of panels) {
const tab = document.createElement("button");
tab.type = "button";
const isActive = p.panelId === active;
tab.style.cssText = [
"flex:1", "min-width:0", "padding:0 10px", "border:0",
"background:" + (isActive ? "rgba(214,255,61,.10)" : "transparent"),
"color:" + (isActive ? "#d6ff3d" : "#8b98a9"),
"cursor:pointer",
"display:flex", "align-items:center", "gap:6px", "justify-content:center",
"overflow:hidden", "text-overflow:ellipsis", "white-space:nowrap",
"font:inherit",
].join(";");
tab.innerHTML = '<span>' + (p.icon || "🧩") + '</span><span>' + p.title.replace(/</g, "&lt;") + '</span>';
tab.addEventListener("click", () => ipcRenderer.invoke("sidebar-open", p.panelId));
strip.appendChild(tab);
}
document.body.appendChild(strip);
// Push the panel body down so the 32-px strip doesn't overlap the content.
// box-sizing:border-box keeps 100%-tall panels from overflowing when we
// add the padding.
const style = document.createElement("style");
style.textContent = "html,body{box-sizing:border-box}body{padding-top:33px!important}";
document.head.appendChild(style);
}
// Sidebar resize grip. Injected into every panel automatically so panel
// authors don't have to reinvent it. A thin strip along the LEFT edge
// (the boundary between the tab area and the sidebar) accepts mousedown
// and streams drag deltas to main until mouseup. Main clamps the width
// to [200, 800] and persists it in settings.sidebarWidth.
window.addEventListener("DOMContentLoaded", () => {
installPickerStrip();
const grip = document.createElement("div");
grip.setAttribute("aria-label", "Resize sidebar");
grip.style.cssText = [
"position:fixed", "left:0", "top:0", "bottom:0",
"width:5px", "cursor:col-resize", "z-index:2147483647",
"background:transparent",
].join(";");
// A faint highlight on hover so the affordance is visible.
grip.addEventListener("mouseenter", () => { grip.style.background = "rgba(214,255,61,.20)"; });
grip.addEventListener("mouseleave", () => { if (!dragging) grip.style.background = "transparent"; });
document.body.appendChild(grip);
let dragging = false;
grip.addEventListener("mousedown", (e) => {
if (e.button !== 0) return;
e.preventDefault();
dragging = true;
document.body.style.userSelect = "none";
grip.style.background = "rgba(214,255,61,.35)";
});
window.addEventListener("mousemove", (e) => {
if (!dragging) return;
// Moving cursor LEFT = grow sidebar width. movementX is negative left.
if (e.movementX !== 0) ipcRenderer.invoke("sidebar-drag", -e.movementX);
});
const stop = () => {
if (!dragging) return;
dragging = false;
document.body.style.userSelect = "";
grip.style.background = "transparent";
};
window.addEventListener("mouseup", stop);
window.addEventListener("mouseleave", stop);
});