theseus/home-preload.js
Theseus Dev f76ca4f9f4 Theseus 0.3.132: layout revert, Nostr per-site identities, Mail as sidebar quicklink, connector token stays persistent
Startpage layout reverted to the pre-0.3.131 shape. 0.3.131's full-page-
height sidebar was a mistake — slots in a short strip under a hero at
the top, cards and slots sharing one row of panels at the same height,
is what matches the design. .page/.main-col/.sidebar structure dropped,
hero back as a body sibling of .homepage, slots-list back to repeat(3,
72px) with minSlots=9. Two of the user's own tweaks baked into the
defaults: .section.chromeless on the left column so slot icons float
on the page background instead of sitting in a boxed panel, and
defaultState().iconSize = "large" (60 px tiles).

Game.X in the SM cards (replaces SilentMode.X in its slot, stub
description + a built-in gamepad brand mark until game.x goes live).
SilentMode.X moved to the quick-links strip as the 5th slot. moveItem
now strips the pinned-slot anchor when a q item moves to sm — a link
dragged onto the cards used to flip back on the next render because
its slot number was still set.

Nostr NIP-07 signer grows per-site identities (0.1.0 → 0.1.1). First
approval modal picks between the Hermes identity (one npub for DMs
and public notes, the one published as the np record on your BNS
name) and a site-specific HKDF-derived npub (unique per origin, so
Nostr clients can't cross-correlate your activity). The chooser shows
both npubs so the user sees what each button reveals. Grants without
a stored profileId (migrated from 0.3.131) default to the Hermes
identity so existing approvals keep behaving the same.
lib/hermes.js:nostrKeyFromRootForOrigin HKDFs with info
silentmode/nostr-origin/v1/<origin> over the existing 32-byte
messengerRoot. main.js _nostrAPI methods take {profileId, origin};
signer cache per profile.

Silent Mode Mail retired as an extension — now a Sidebar quicklink
(id: silentmail). Its 0.1.0 panel iframed 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 s "did it embed?"
watchdog saw it as loaded and the panel stayed black. The whole
extension was just a landing page with an Open Mail button — exactly
what a sidebar quicklink does, without taking a panel slot or an
Extensions row. migrateAegisRename auto-retires any existing
extensions/mail/ to addons-backups/mail-migrated-<stamp>/ on first
boot of this Theseus; "mail" added to the first-party reserved-ids
list so a community add-on can't claim the id.

Claude connector 0.2.1 → 0.2.2: the bearer token no longer rotates on
every server stop. The old stop() generated a fresh token every time
Theseus quit, the panel toggled off, or a 0.3.X install replaced the
app — the Connect button wrote the new token to ~/.claude.json but
any already-running Claude Code or Claude Desktop process held the
previous token in memory and only re-reads config on launch, so /mcp
reconnect kept sending the stale bearer and the server rejected it
forever. Infinite loop: install Theseus upgrade, click Connect, MCP
still 401, close Claude, open Claude, install another upgrade a day
later, repeat. The explicit Revoke token button still rotates for
users who want fresh keys; everything else is persistent.

Sidebar panel apps blank to about:blank after 2 min of being closed to
free their render tree (iframes, media, listeners). Fast reopen within
the window still reuses the loaded page.

Light theme palette flip under body.light — the full set of
--ink / --mut / --card / --line tokens flip plus hero + card
translucent panels switch to light-glass, so hero titles, SM card
text, slot labels and the ".bch" tld read correctly on the light
background preset instead of as pale ghosts.
2026-10-30 03:45:32 +02:00

112 lines
6.2 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)),
});
// 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();
})();