The PIN could only be six digits and was set from three bare inputs; the unlock prompt sat at the top of the page; and the password manager only filled when you found the key chip, never offered to save, and "Clear cookies on quit" signed you out of every site, including the ones whose login the vault already holds. - PINs are 6 to 8 digits. The PIN record stores its length so pads draw the right number of dots and submit on the last digit; a PIN of the wrong length is refused without a strike, so an older Aegis pad cannot burn the count against an 8-digit PIN. - Settings sets a PIN in steps: master password, choose the PIN on a pad (6/7/8), repeat it, done. The locked vault opens Theseus's own prompt, which is now centred, with the PIN pad or the master password field. - After a sign-in or sign-up form is sent and the page moves on, Theseus offers to save (or update) the login, with an optional "ask for my PIN or password before filling it". Focusing a login form offers the saved logins under it; on a locked vault it offers to unlock first. A failed login (the password field still showing) gets no offer. - "Keep sign-ins for sites in your vault" (on): the quit clear spares the cookies and site storage of sites with a saved login. Their hostnames are kept sealed with the OS keystore so the list is readable at quit while the vault is locked. Verified end to end on a scratch profile: signed in, restarted, still signed in; another site's cookie was cleared.
103 lines
5.7 KiB
JavaScript
103 lines
5.7 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");
|
|
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 on every web page in a tab; nothing
|
|
// is exposed to the page. Two reports go to main, which takes the site from
|
|
// the tab's committed URL, 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();
|
|
})();
|