theseus/home-preload.js
Theseus Dev b4cea7b83f Theseus 0.3.130: startpage sidebar fills the panel, searchbar stops auto-growing, Store Claude Desktop detected
Startpage sidebar is now a real left column that starts at the Theseus
title's top edge and runs the full page height. The slot grid is 3
columns stretched to the full sidebar width, with enough rows to cover
the sidebar vertically (count derived from the main column's height on
each render, and re-derived on viewport resize). Empty cells sit as
semi-transparent dashed outlines in the exact position a filled tile
would occupy, so the grid reads as one uniform surface; any empty cell
doubles as the "add link" surface and clicking it opens the Add-link
modal with that cell pre-pinned, so new links land exactly where the
click did instead of being pushed to the end. The dedicated "+" button
is gone — the pin-location is the click target. A "Theseus v0.3.X —
What's new" link appears under the search tip, pointing at
silentmode.st/releases; version comes from a new getVersion exposed on
window.home over the existing app-version IPC.

Chrome toolbar: 0.3.128's "give the searchbox more room while typing"
rule is removed — focusing the search field was pulling the drag handle
with it, which made the URL / search ratio impossible to set by hand.
The URL bar now caps at 75% of the pair's shared budget via a hard CSS
max-width (so a wide urlBarWidthPx stored under the old 80% ceiling
clamps on load without the user having to re-drag), and the drag floor
matches at 25 % so the search field always keeps at least a quarter of
the pair.

Claude connector: bumped bundled-addons/claude-connector from 0.2.0 to
0.2.1. The clients.cjs Store-install detection and the panel-access
work shipped in 0.3.129's bundled 0.2.0, but seedBundledAddons only
overwrites the installed copy when the bundle is strictly newer, so
every user with an existing 0.2.0 kept the stale pre-fix copy from
%APPDATA%/Theseus/extensions/claude-connector/. The version bump
surfaces the Store-install detection ("Claude Desktop not installed on
this computer" was false on every Microsoft Store install) and the
add-on-panel approvals split in the connector panel, both of which
were already in-tree but gated behind the version check.
2026-10-29 14:46:29 +02:00

116 lines
6.5 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 — exposed so the startpage can show a tiny
// "What's new in Theseus 0.3.X" link next to the search box. The IPC is
// the same one Settings uses; it reads app.getVersion() with 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();
})();