theseus/home-preload.js
Theseus Dev 9c29331e5b Theseus 0.3.131: Mail promoted to Sidebar quicklink, Nostr signer ships, panels free RAM when idle, Game.X in SM cards
Silent Mode Mail is no longer an extension. The 0.1.0 panel was a black
screen (iframe to mail.silentmode.st which sends X-Frame-Options: DENY;
the browser rendered the block page and still fired the iframe's load
event, so the 5-second "did it embed?" watchdog saw it as loaded and the
fallback never swapped in). The whole extension existed to put one "Open
Mail" button in a panel — that is exactly what a sidebar quicklink does,
without taking a panel slot or an Extensions row. So:

  - settings.quickLinks default list gains silentmail at the top
    (Silent Mode Mail → https://mail.silentmode.st/).
  - migrateAegisRename grew a mail retirement block: any existing
    extensions/mail/ folder on the user's box moves to
    addons-backups/mail-migrated-<stamp>/ on first boot of this
    Theseus, so the old extension vanishes from Settings instead of
    sitting there with a broken panel.
  - "mail" added to the first-party reserved-ids list so no community
    add-on can claim the id after retirement.

Nostr NIP-07 signer finally ships. The bundled-addons/nostr/ folder was
sitting untracked in the main checkout, so the detached-HEAD build
worktree 0.3.130 used never saw it (same reason build/, tor/, snapshots/
have to be hand-copied into the worktree). This build committed the
folder and copied it in. Puts window.nostr on https + localhost pages;
identity is Hermes's key (one npub for DMs and public notes). Per-origin
read/sign grants; kinds 0/3/5/10002 re-prompt every time with no
remember option.

Sidebar panel apps free their render tree after idle. closeLeftPanel
used to keep the loaded page warm forever — great for fast reopen, bad
for RAM when the user docks a heavy panel (Hermes, Pithos, Aegis) once
and never comes back. A 2-minute idle timer now blanks the panel to
about:blank after close; reopen within the window still reuses the
loaded page (cancelLeftPanelIdleUnload on open), reopen after the
window reloads fresh. LEFT_PANEL_IDLE_MS = 2 * 60 * 1000. Right-side
sidebar view (webapp tabs — Telegram, X, WhatsApp, AI chat) is longer-
lived by design and untouched here.

Startpage layout:
  - Game.X takes SilentMode.X's slot in the Silent Mode cards (stub
    description + a built-in gamepad brand mark until game.x goes
    live). SilentMode.X moves to the quick-links strip as the 5th
    slot. Still TODO: push the swap into the remote home-cards.json
    for existing profiles.
  - Light-theme palette flip under body.light. The @media
    (prefers-color-scheme: light) block only triggered on OS-level
    light preference; picking the "light" background preset left --ink
    white against a white page, so hero titles, card titles, slot
    labels and the ".bch" extension read as pale ghosts. Added a full
    body.light { --ink; --mut; --card; --line; ... } block plus
    overrides for the hero + card translucent panels (dark-glass →
    light-glass on light theme) and the ctx-menu / modal. Dark theme
    untouched.
2026-10-30 00:38:08 +02:00

116 lines
6.4 KiB
JavaScript

// Preload for tab webContents that host the built-in home page. All non-
// settings tabs get this preload since we don't know in advance whether a
// tab will land on home.html; the corresponding IPC handlers in main.js
// validate the sender's URL is our own home.html file:// and reject any
// origin-mismatched call, so a third-party page can inspect the API's
// SHAPE but can't invoke it against local user data.
const { contextBridge, ipcRenderer } = require("electron");
// Web tabs run this preload in iframes too (nodeIntegrationInSubFrames), so
// the password hooks below see logins inside frames. Everything else here
// stays top-frame only, as it always was.
if (window.top === window) {
// A click in the page closes the left panel (web app / add-on) unless it is
// pinned. Main ignores this unless a panel is open and the tab is active.
window.addEventListener("pointerdown", () => { ipcRenderer.send("tab-pointerdown"); }, true);
contextBridge.exposeInMainWorld("home", {
getCards: () => ipcRenderer.invoke("home-cards-get"),
setCards: (cards) => ipcRenderer.invoke("home-cards-set", cards),
resetCards: () => ipcRenderer.invoke("home-cards-reset"),
navigate: (url) => ipcRenderer.invoke("navigate", url),
// Current default search engine — used to populate the search box
// placeholder with the actual engine name (e.g. "Search the web with
// Startpage") instead of hardcoding DuckDuckGo.
getEngines: () => ipcRenderer.invoke("search-engines"),
// Main pushes an updated list here after a successful remote refresh
// (only when the user has not customised locally). home.html re-renders.
onCards: (cb) => ipcRenderer.on("home-cards", (_e, list) => cb(list)),
// Current Theseus version — the startpage renders a small
// "Theseus v0.3.X — What's new" link beside the hero that points at
// the release notes. IPC is the same one Settings uses; no network.
getVersion: () => ipcRenderer.invoke("app-version"),
});
// Same preload also serves the branded error page (error.html). Handlers in
// main.js sender-check for error.html so a random page seeing the API shape
// can't drive navigation.
contextBridge.exposeInMainWorld("errorpage", {
retry: (url) => ipcRenderer.invoke("error-retry", url),
goHome: () => ipcRenderer.invoke("error-home"),
searchFor: (text) => ipcRenderer.invoke("error-search", text),
registerOnSirius: (host) => ipcRenderer.invoke("error-register", host),
openExternal: (url) => ipcRenderer.invoke("error-open-external", url),
});
}
// ---- Password manager hooks -------------------------------------------------
// Runs in this preload's isolated world in every frame of a web tab (a login
// form inside an iframe counts too); nothing is exposed to the page. Two
// reports go to main, which takes the site from the committed URL of the
// frame that sent them, never from here:
// pw-form a login field got focus -> main may offer saved logins under it
// pw-capture a form carrying a password was sent -> main may offer to save it
(() => {
if (!/^(https?|bns):$/.test(location.protocol)) return;
const visible = (el) => {
const r = el.getBoundingClientRect();
if (r.width < 4 || r.height < 4) return false;
const cs = getComputedStyle(el);
return cs.visibility !== "hidden" && cs.display !== "none";
};
const TEXTY = /^(text|email|tel|)$/i;
const passwords = (scope) => [...(scope || document).querySelectorAll("input[type=password]")].filter((el) => !el.disabled && visible(el));
// The username is the closest text-like field before the password in the
// same form (or page), which is how almost every login form is laid out.
function usernameFor(pw) {
const scope = pw.form || document;
const inputs = [...scope.querySelectorAll("input")].filter((el) => !el.disabled && visible(el));
const at = inputs.indexOf(pw);
for (let i = at - 1; i >= 0; i--) if (TEXTY.test(inputs[i].type || "text")) return inputs[i];
return null;
}
function capture() {
try {
const pws = passwords().filter((el) => el.value);
if (!pws.length) return;
// Sign-up: password + confirmation (same value). Change-password:
// current, new[, confirm] -> the new one is second.
const signup = pws.length >= 2 || /new-password/i.test(pws[0].autocomplete || "");
const pw = pws.length >= 3 ? pws[1] : pws.length === 2 && pws[0].value !== pws[1].value ? pws[1] : pws[0];
const user = usernameFor(pws[0]);
ipcRenderer.send("pw-capture", { username: user ? user.value : "", password: pw.value, signup });
} catch {}
}
document.addEventListener("submit", capture, true);
// Script-driven logins never fire submit: catch the button press and Enter.
document.addEventListener("click", (e) => {
const b = e.target instanceof Element ? e.target.closest("button, input[type=submit], input[type=button], [role=button]") : null;
if (b && passwords().some((el) => el.value)) capture();
}, true);
document.addEventListener("keydown", (e) => {
if (e.key === "Enter" && e.target instanceof HTMLInputElement && (e.target.type === "password" || TEXTY.test(e.target.type))) {
if (passwords().some((el) => el.value)) capture();
}
}, true);
// A focused username or password field of a login form (one password
// field, empty) asks main to show saved logins under it.
function loginField(el) {
if (!(el instanceof HTMLInputElement) || el.disabled || el.readOnly) return null;
if (el.type === "password") return passwords(el.form || document).length === 1 ? el : null;
if (!TEXTY.test(el.type || "text")) return null;
const pws = passwords(el.form || document);
return pws.length === 1 && usernameFor(pws[0]) === el ? el : null;
}
function offer(el) {
if (!el || el.value) return;
const r = el.getBoundingClientRect();
ipcRenderer.send("pw-form", { x: r.left, y: r.top, h: r.height });
}
document.addEventListener("focusin", (e) => offer(loginField(e.target)), true);
// Typing means the user is not taking the offer.
document.addEventListener("input", (e) => { if (loginField(e.target)) ipcRenderer.send("pw-form-dismiss"); }, true);
document.addEventListener("keydown", (e) => { if (e.key === "Escape") ipcRenderer.send("pw-form-dismiss"); }, true);
// An autofocused field is already focused when this runs.
const early = () => offer(loginField(document.activeElement));
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", early, { once: true });
else early();
})();