Passwords: save and fill logins inside iframes too

Sign-in widgets and embedded checkouts often put the login form in an
iframe, and the password hooks only ran in a tab's top frame, so those
logins were never offered for saving or filling.

- Web tabs now run preloads in iframes (nodeIntegrationInSubFrames; pages
  still get no Node). Only the password hooks act there: the home-page
  bridge, window.bcnr, add-on page scripts and window.theseusId return early
  outside the top frame, exactly as before.
- A login in a frame belongs to the frame's own site, taken from that
  frame's committed URL. The save prompt says "login.example (in a frame on
  shop.example)", the fill offer names both, and the fill goes into that
  exact frame only while it is still that tab's and still on that site.
- The "did the login go through" check runs against the frame.

Verified with a shop page embedding a cross-site login frame: save offer,
fill offer and fill all target the frame; the outer page gets nothing;
top-frame logins behave as before.
This commit is contained in:
Local Dev 2026-10-05 20:34:17 +02:00
parent b4362f2e54
commit ed441b4e9d
6 changed files with 134 additions and 59 deletions

View file

@ -15,6 +15,12 @@
// ordinary preload idiom keep working. // ordinary preload idiom keep working.
const { contextBridge, ipcRenderer, webFrame } = require("electron"); const { contextBridge, ipcRenderer, webFrame } = require("electron");
// Web tabs run preloads in iframes too (nodeIntegrationInSubFrames, for the
// password hooks). Add-on page scripts (wallet providers, consent rules)
// stay top-frame only, as they always were: main decides them from the
// tab's URL, not the frame's, and must never put them into third-party frames.
if (window.top !== window) return;
let injections = []; let injections = [];
try { injections = ipcRenderer.sendSync("addon-inject-scripts", location.href) || []; } try { injections = ipcRenderer.sendSync("addon-inject-scripts", location.href) || []; }
catch (e) { console.warn("[theseus] add-on inject query failed:", e?.message || e); } catch (e) { console.warn("[theseus] add-on inject query failed:", e?.message || e); }

View file

@ -11,6 +11,11 @@
// API surface. // API surface.
const { contextBridge, ipcRenderer } = require("electron"); const { contextBridge, ipcRenderer } = require("electron");
// Web tabs run preloads in iframes too (nodeIntegrationInSubFrames, for the
// password hooks in home-preload.js). This one stays top-frame only, as
// it always was.
if (window.top !== window) return;
// Every method returns a Promise; a name that fails to resolve or isn't // Every method returns a Promise; a name that fails to resolve or isn't
// registered comes back as `null` (not an error) so page code can treat // registered comes back as `null` (not an error) so page code can treat
// "no such name" as data, not an exception. `getBcnrTlds` always returns // "no such name" as data, not an exception. `getBcnrTlds` always returns

View file

@ -5,6 +5,10 @@
// origin-mismatched call, so a third-party page can inspect the API's // origin-mismatched call, so a third-party page can inspect the API's
// SHAPE but can't invoke it against local user data. // SHAPE but can't invoke it against local user data.
const { contextBridge, ipcRenderer } = require("electron"); 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 // 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. // pinned. Main ignores this unless a panel is open and the tab is active.
window.addEventListener("pointerdown", () => { ipcRenderer.send("tab-pointerdown"); }, true); window.addEventListener("pointerdown", () => { ipcRenderer.send("tab-pointerdown"); }, true);
@ -31,11 +35,13 @@ contextBridge.exposeInMainWorld("errorpage", {
registerOnSirius: (host) => ipcRenderer.invoke("error-register", host), registerOnSirius: (host) => ipcRenderer.invoke("error-register", host),
openExternal: (url) => ipcRenderer.invoke("error-open-external", url), openExternal: (url) => ipcRenderer.invoke("error-open-external", url),
}); });
}
// ---- Password manager hooks ------------------------------------------------- // ---- Password manager hooks -------------------------------------------------
// Runs in this preload's isolated world on every web page in a tab; nothing // Runs in this preload's isolated world in every frame of a web tab (a login
// is exposed to the page. Two reports go to main, which takes the site from // form inside an iframe counts too); nothing is exposed to the page. Two
// the tab's committed URL, never from here: // 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-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 // pw-capture a form carrying a password was sent -> main may offer to save it
(() => { (() => {

157
main.js
View file

@ -4,7 +4,7 @@
// h, Sia s3, direct ip, redirect u). Tabs, nav controls, a search box, a home // h, Sia s3, direct ip, redirect u). Tabs, nav controls, a search box, a home
// page, and optional Tor onion routing. No system daemon; the app is the trust // page, and optional Tor onion routing. No system daemon; the app is the trust
// boundary. // boundary.
const { app, BrowserWindow, WebContentsView, ipcMain, protocol, session, Menu, clipboard, nativeTheme, shell, dialog, net, utilityProcess, safeStorage } = require("electron"); const { app, BrowserWindow, WebContentsView, ipcMain, protocol, session, Menu, clipboard, nativeTheme, shell, dialog, net, utilityProcess, safeStorage, webFrameMain } = require("electron");
const path = require("path"); const path = require("path");
const url = require("url"); const url = require("url");
const http = require("http"); const http = require("http");
@ -3634,7 +3634,8 @@ function showPwFill(show, matches, extra = {}) {
win.contentView.removeChildView(pwFillPop); win.contentView.removeChildView(pwFillPop);
win.contentView.addChildView(pwFillPop); win.contentView.addChildView(pwFillPop);
pwFillPop.setVisible(true); pwfVisible = true; pwFillPop.setVisible(true); pwfVisible = true;
pwFillPop.webContents.send("pw-matches", { matches: matches || [], locked: !!extra.locked, host: extra.host || "" }); pwFillPop.webContents.send("pw-matches", { matches: matches || [], locked: !!extra.locked, host: extra.host || "",
framed: !!extra.framed, topHost: extra.topHost || "" });
} else { cancelOverlayShow(pwFillPop); pwFillPop.setVisible(false); pwfVisible = false; } } else { cancelOverlayShow(pwFillPop); pwFillPop.setVisible(false); pwfVisible = false; }
} }
// Compute credential matches for a host. Exact hostname match in phase-1; // Compute credential matches for a host. Exact hostname match in phase-1;
@ -3683,11 +3684,16 @@ function emitPwAvailability() {
// password field + tries to fill the adjacent/associated username field. // password field + tries to fill the adjacent/associated username field.
// Kept intentionally small — the whole autofill affordance is opt-in // Kept intentionally small — the whole autofill affordance is opt-in
// (user clicks the chip; nothing runs on page load). // (user clicks the chip; nothing runs on page load).
async function pwFillIntoActiveTab(entry) { // frameRef: the iframe the login was offered in (null = the tab's own page).
async function pwFillIntoActiveTab(entry, frameRef = null) {
const t = activeTab(); if (!t) return false; const t = activeTab(); if (!t) return false;
// Re-check at fill time: the page may have navigated since the picker opened. // Re-check at fill time: the page (or frame) may have navigated since the
if (!entry.domain || liveHost(t) !== entry.domain) return { ok: false, why: "origin-changed" }; // picker opened. A frame must still be that tab's, still on that site.
const wc = t.view.webContents; let target = t.view.webContents;
if (frameRef) {
target = pwFrameFromRef(frameRef, t, entry.domain);
if (!target) return { ok: false, why: "origin-changed" };
} else if (!entry.domain || liveHost(t) !== entry.domain) return { ok: false, why: "origin-changed" };
const script = `(() => { const script = `(() => {
const visible = (el) => { const r = el.getBoundingClientRect(); return r.width > 4 && r.height > 4; }; const visible = (el) => { const r = el.getBoundingClientRect(); return r.width > 4 && r.height > 4; };
const pwds = [...document.querySelectorAll('input[type=password]:not([disabled])')].filter(visible); const pwds = [...document.querySelectorAll('input[type=password]:not([disabled])')].filter(visible);
@ -3712,8 +3718,7 @@ async function pwFillIntoActiveTab(entry) {
return { ok: true, filledUsername: !!user }; return { ok: true, filledUsername: !!user };
})()`; })()`;
try { try {
const res = await wc.executeJavaScript(script, true); return await target.executeJavaScript(script, true);
return res;
} catch (e) { console.error("pw fill failed:", e?.message); return { ok: false, why: "exec-error" }; } } catch (e) { console.error("pw fill failed:", e?.message); return { ok: false, why: "exec-error" }; }
} }
function showAddressPicker(show, suggestions) { function showAddressPicker(show, suggestions) {
@ -4201,7 +4206,12 @@ function createTab(initial, opts = {}) {
const preloadPath = opts.settings ? path.join(__dirname, "settings-preload.js") const preloadPath = opts.settings ? path.join(__dirname, "settings-preload.js")
: opts.addonFile ? path.join(__dirname, "addon-tab-preload.js") : opts.addonFile ? path.join(__dirname, "addon-tab-preload.js")
: path.join(__dirname, "home-preload.js"); : path.join(__dirname, "home-preload.js");
const view = new WebContentsView({ webPreferences: { preload: preloadPath } }); // Web tabs also run preloads inside iframes, so the password manager sees
// logins in embedded sign-in forms. Pages still get no Node access
// (nodeIntegration stays off); every other preload returns early outside
// the top frame, as before.
const webTab = !opts.settings && !opts.addonFile;
const view = new WebContentsView({ webPreferences: { preload: preloadPath, ...(webTab ? { nodeIntegrationInSubFrames: true } : {}) } });
// Explicit solid background: transparent (Electron default) makes the tab // Explicit solid background: transparent (Electron default) makes the tab
// view flash to whatever's underneath (which can be the just-hidden tab or // view flash to whatever's underneath (which can be the just-hidden tab or
// black) between setVisible(true) and the first paint on tab switch. A // black) between setVisible(true) and the first paint on tab switch. A
@ -7905,6 +7915,7 @@ ipcMain.handle("toggle-pw-fill", async (_e, rect) => {
const matches = pwMatchesForHost(host); const matches = pwMatchesForHost(host);
if (!matches.length) return showPwFill(false); if (!matches.length) return showPwFill(false);
if (rect) pwfPos = { x: Math.round(rect.x), y: Math.round(rect.y) }; if (rect) pwfPos = { x: Math.round(rect.x), y: Math.round(rect.y) };
pwfOffer = null; // the chip fills the tab's own page
showPwFill(true, matches); showPwFill(true, matches);
}); });
ipcMain.handle("close-pw-fill", () => showPwFill(false)); ipcMain.handle("close-pw-fill", () => showPwFill(false));
@ -7921,7 +7932,10 @@ ipcMain.handle("pw-fill-pick", async (e, id) => {
if (!pwFillPop || e.sender !== pwFillPop.webContents) return { ok: false, err: "picker only" }; if (!pwFillPop || e.sender !== pwFillPop.webContents) return { ok: false, err: "picker only" };
showPwFill(false); showPwFill(false);
const t = activeTab(); const t = activeTab();
const host = t ? liveHost(t) : ""; // The offer came from a login field (possibly in an iframe), or from the
// toolbar chip (the tab's own page).
const offer = pwfOffer && t && pwfOffer.tabId === t.id ? pwfOffer : null;
const host = offer ? offer.host : t ? liveHost(t) : "";
let justUnlocked = false; // a PIN / password typed for this very fill also answers the per-login check let justUnlocked = false; // a PIN / password typed for this very fill also answers the per-login check
if (id === "__unlock") { if (id === "__unlock") {
// Offered on a locked vault: unlock, then fill at once when there is one // Offered on a locked vault: unlock, then fill at once when there is one
@ -7931,7 +7945,7 @@ ipcMain.handle("pw-fill-pick", async (e, id) => {
justUnlocked = !u.already; justUnlocked = !u.already;
const matches = pwMatchesForHost(host); const matches = pwMatchesForHost(host);
if (matches.length === 1) id = matches[0].id; if (matches.length === 1) id = matches[0].id;
else { if (matches.length) showPwFill(true, matches, { host }); return { ok: true, shown: matches.length }; } else { if (matches.length) showPwFill(true, matches, { host, framed: !!(offer && offer.ref), topHost: offer && offer.topHost }); return { ok: true, shown: matches.length }; }
} }
if (!vaultState) return { ok: false, err: "locked" }; if (!vaultState) return { ok: false, err: "locked" };
try { try {
@ -7944,31 +7958,51 @@ ipcMain.handle("pw-fill-pick", async (e, id) => {
if (!c.ok) return { ok: false, err: "cancelled" }; if (!c.ok) return { ok: false, err: "cancelled" };
} }
const password = await v.resolvePassword(vaultState, id); const password = await v.resolvePassword(vaultState, id);
return await pwFillIntoActiveTab({ domain: entry.domain, username: entry.username, password }); return await pwFillIntoActiveTab({ domain: entry.domain, username: entry.username, password }, offer && offer.ref);
} catch (e) { return { ok: false, err: e?.message || String(e) }; } } catch (e) { return { ok: false, err: e?.message || String(e) }; }
}); });
// ---- Password manager: offer saved logins, offer to save new ones ---------- // ---- Password manager: offer saved logins, offer to save new ones ----------
// home-preload.js runs in the top frame of every web tab, in its isolated // home-preload.js runs in every frame of every web tab (iframes included:
// sign-in widgets and embedded checkouts often live in one), in its isolated
// world, and reports two things: a login field got focus ("pw-form"), and a // world, and reports two things: a login field got focus ("pw-form"), and a
// form carrying a password was sent ("pw-capture"). It exposes nothing to // form carrying a password was sent ("pw-capture"). It exposes nothing to
// the page. The host is always taken from the tab's committed URL, never // the page. The site is always taken from the committed URL of the frame
// from the message. // that sent the report, never from the message: a login inside an iframe
function webTabForEvent(e) { // from login.example belongs to login.example, whatever page embeds it.
function frameHost(frame) {
try {
const u = new URL(frame.url);
return /^(https?|bns):$/.test(u.protocol) ? u.hostname.toLowerCase() : "";
} catch { return ""; }
}
function pwSource(e) {
const t = tabForSender(e.sender); const t = tabForSender(e.sender);
if (!t || t.settings || t.addonId) return null; if (!t || t.settings || t.addonId) return null;
if (e.senderFrame !== e.sender.mainFrame) return null; const frame = e.senderFrame;
return t; if (!frame || frame.detached) return null;
const host = frameHost(frame);
if (!host) return null;
const isMain = frame === e.sender.mainFrame;
return { t, frame, host, isMain, topHost: liveHost(t), ref: isMain ? null : { processId: frame.processId, routingId: frame.routingId } };
} }
let pwfOfferTab = null; // An iframe the user was offered a login in, if it still exists, still
// belongs to that tab, and still shows that site.
function pwFrameFromRef(ref, t, host) {
let f = null;
try { f = webFrameMain.fromId(ref.processId, ref.routingId); } catch { return null; }
if (!f || f.detached || !t || f.top !== t.view.webContents.mainFrame) return null;
return host && frameHost(f) !== host ? null : f;
}
let pwfOffer = null; // { tabId, host, ref (null = the tab's own page), topHost }
const pwfNavHooked = new WeakSet(); const pwfNavHooked = new WeakSet();
ipcMain.on("pw-form", (e, rect) => { ipcMain.on("pw-form", (e, rect) => {
try { try {
if (!settings.pwOfferFill) return; if (!settings.pwOfferFill) return;
const t = webTabForEvent(e); const src = pwSource(e);
if (!t || t.id !== activeId) return; if (!src || src.t.id !== activeId) return;
const host = liveHost(t); const { t, host } = src;
if (!host || !fs.existsSync(vaultFile())) return; if (!fs.existsSync(vaultFile())) return;
let matches = [], locked = false; let matches = [], locked = false;
if (vaultState) { if (vaultState) {
matches = pwMatchesForHost(host); matches = pwMatchesForHost(host);
@ -7980,36 +8014,42 @@ ipcMain.on("pw-form", (e, rect) => {
locked = true; locked = true;
} }
const b = t.view.getBounds(); const b = t.view.getBounds();
const z = t.view.webContents.getZoomFactor() || 1; if (src.isMain) {
const r = rect && typeof rect === "object" ? rect : {}; const z = t.view.webContents.getZoomFactor() || 1;
const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0); const r = rect && typeof rect === "object" ? rect : {};
const x = Math.round(b.x + num(r.x) * z); const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
const y = Math.round(b.y + (num(r.y) + num(r.h)) * z + 4); const x = Math.round(b.x + num(r.x) * z);
pwfPos = { x, y: Math.max(b.y, Math.min(y, b.y + b.height - 90)) }; const y = Math.round(b.y + (num(r.y) + num(r.h)) * z + 4);
pwfOfferTab = t.id; pwfPos = { x, y: Math.max(b.y, Math.min(y, b.y + b.height - 90)) };
} else {
// A field inside an iframe: its position is only known inside that
// frame, so the offer sits at the top of the page, naming the site.
pwfPos = { x: Math.round(b.x + (b.width - PWF_W) / 2), y: b.y + 10 };
}
pwfOffer = { tabId: t.id, host, ref: src.ref, topHost: src.topHost };
const wc = t.view.webContents; const wc = t.view.webContents;
if (!pwfNavHooked.has(wc)) { if (!pwfNavHooked.has(wc)) {
pwfNavHooked.add(wc); pwfNavHooked.add(wc);
wc.on("did-start-navigation", (_ev, _url, inPage, isMain) => { wc.on("did-start-navigation", (_ev, _url, inPage, isMain) => {
if (isMain && !inPage && pwfVisible && pwfOfferTab === t.id) showPwFill(false); if (isMain && !inPage && pwfVisible && pwfOffer && pwfOffer.tabId === t.id) showPwFill(false);
}); });
} }
showPwFill(true, matches, { locked, host }); showPwFill(true, matches, { locked, host, framed: !src.isMain, topHost: src.topHost });
} catch (err) { console.warn("pw-form:", err?.message); } } catch (err) { console.warn("pw-form:", err?.message); }
}); });
ipcMain.on("pw-form-dismiss", (e) => { ipcMain.on("pw-form-dismiss", (e) => {
const t = webTabForEvent(e); const src = pwSource(e);
if (t && pwfVisible && pwfOfferTab === t.id) showPwFill(false); if (src && pwfVisible && pwfOffer && pwfOffer.tabId === src.t.id) showPwFill(false);
}); });
const pwCaptureSeen = new Map(); // tabId -> { key, at }: one offer per login, however many events report it const pwCaptureSeen = new Map(); // tabId -> { key, at }: one offer per login, however many events report it
ipcMain.on("pw-capture", (e, data) => { ipcMain.on("pw-capture", (e, data) => {
try { try {
if (!settings.pwOfferSave) return; if (!settings.pwOfferSave) return;
const t = webTabForEvent(e); const src = pwSource(e);
if (!t) return; if (!src) return;
const host = liveHost(t); const { t, host } = src;
if (!host || (settings.pwNeverSave || []).includes(host)) return; if ((settings.pwNeverSave || []).includes(host)) return;
const username = String((data && data.username) || "").trim().slice(0, 256); const username = String((data && data.username) || "").trim().slice(0, 256);
const password = String((data && data.password) || ""); const password = String((data && data.password) || "");
if (!password || password.length > 1024) return; if (!password || password.length > 1024) return;
@ -8019,39 +8059,48 @@ ipcMain.on("pw-capture", (e, data) => {
pwCaptureSeen.set(t.id, { key, at: Date.now() }); pwCaptureSeen.set(t.id, { key, at: Date.now() });
// A beat later, so a login that navigates away shows the offer on the // A beat later, so a login that navigates away shows the offer on the
// page it lands on rather than flashing over the form. // page it lands on rather than flashing over the form.
setTimeout(() => offerSavePassword(t.id, host, username, password).catch((err) => console.warn("pw save offer:", err?.message)), 900); setTimeout(() => offerSavePassword(t.id, host, username, password, { ref: src.ref, topHost: src.topHost })
.catch((err) => console.warn("pw save offer:", err?.message)), 900);
} catch (err) { console.warn("pw-capture:", err?.message); } } catch (err) { console.warn("pw-capture:", err?.message); }
}); });
// True once the login looks done: the page moved to another site, or no // True once the login looks done: the page (or the iframe the form was in)
// password field is left on it. A password field still showing after a few // moved to another site or went away, or no password field is left on it. A
// seconds (filled, or emptied by a "wrong password" page) means the login // password field still showing after a few seconds (filled, or emptied by a
// did not go through, and a wrong password is not worth saving. // "wrong password" page) means the login did not go through, and a wrong
async function pwLoginSettled(tabId, host) { // password is not worth saving.
async function pwLoginSettled(tabId, host, ref) {
const CHECK = "[...document.querySelectorAll('input[type=password]')].some((e) => e.offsetWidth > 0 && e.offsetHeight > 0)";
for (let i = 0; i < 8; i++) { for (let i = 0; i < 8; i++) {
const t = tabs.find((x) => x.id === tabId); const t = tabs.find((x) => x.id === tabId);
if (!t) return false; if (!t) return false;
if (liveHost(t) !== host) return true;
let pwField = false; let pwField = false;
try { if (ref) {
pwField = await t.view.webContents.executeJavaScriptInIsolatedWorld(1009, [{ const f = pwFrameFromRef(ref, t, host);
code: "[...document.querySelectorAll('input[type=password]')].some((e) => e.offsetWidth > 0 && e.offsetHeight > 0)", if (!f) return true; // the frame navigated away or was removed
}]); try { pwField = await f.executeJavaScript(CHECK); } catch { return true; }
} catch { return true; } // navigating: the page is going away } else {
if (liveHost(t) !== host) return true;
try {
pwField = await t.view.webContents.executeJavaScriptInIsolatedWorld(1009, [{ code: CHECK }]);
} catch { return true; } // navigating: the page is going away
}
if (!pwField) return true; if (!pwField) return true;
await new Promise((r) => setTimeout(r, 500)); await new Promise((r) => setTimeout(r, 500));
} }
return false; return false;
} }
async function offerSavePassword(tabId, host, username, password) { async function offerSavePassword(tabId, host, username, password, { ref = null, topHost = "" } = {}) {
if (!tabs.some((x) => x.id === tabId)) return; if (!tabs.some((x) => x.id === tabId)) return;
if (!(await pwLoginSettled(tabId, host))) return; if (!(await pwLoginSettled(tabId, host, ref))) return;
// A login in an iframe is saved for the iframe's site; say so.
const where = ref && topHost && topHost !== host ? `${host} (in a frame on ${topHost})` : host;
if (!fs.existsSync(vaultFile())) { if (!fs.existsSync(vaultFile())) {
const pick = await showApprovalModal({ const pick = await showApprovalModal({
from: "Theseus Vault", from: "Theseus Vault",
title: "Save your passwords in Theseus?", title: "Save your passwords in Theseus?",
body: "Theseus can remember this login and fill it next time. Set up the Theseus Vault first: it is encrypted with a master password only you know, and nothing leaves this computer.", body: "Theseus can remember this login and fill it next time. Set up the Theseus Vault first: it is encrypted with a master password only you know, and nothing leaves this computer.",
origin: host, origin: where,
actions: [{ id: "setup", label: "Set up the vault", primary: true }, { id: "never", label: "Never for this site" }, { id: "cancel", label: "Not now" }], actions: [{ id: "setup", label: "Set up the vault", primary: true }, { id: "never", label: "Never for this site" }, { id: "cancel", label: "Not now" }],
}, null, tabId); }, null, tabId);
if (pick === "setup") openSettingsTab("passwords"); if (pick === "setup") openSettingsTab("passwords");
@ -8071,7 +8120,7 @@ async function offerSavePassword(tabId, host, username, password) {
const pick = await showApprovalModal({ const pick = await showApprovalModal({
from: "Theseus Vault", from: "Theseus Vault",
title: update ? "Update the saved password?" : "Save this password?", title: update ? "Update the saved password?" : "Save this password?",
origin: host, origin: where,
rows: [ rows: [
{ label: "Username", value: username || "(none)" }, { label: "Username", value: username || "(none)" },
{ label: "Password", value: "•".repeat(Math.min(12, password.length)) }, { label: "Password", value: "•".repeat(Math.min(12, password.length)) },

View file

@ -30,7 +30,14 @@
window.pwfill.onMatches((data) => { window.pwfill.onMatches((data) => {
const list = $("list"); const list = $("list");
const matches = data.matches || []; const matches = data.matches || [];
document.querySelector(".hdr").textContent = data.locked ? "Theseus Vault" : "Fill password for this site"; // A login form inside an iframe belongs to the iframe's site: name it,
// and the page it sits in, so the user knows where the password goes.
const framed = data.framed && data.topHost && data.topHost !== data.host;
const hdr = document.querySelector(".hdr");
hdr.textContent = framed
? `Login for ${data.host} · in a frame on ${data.topHost}`
: data.locked ? "Theseus Vault" : "Fill password for this site";
hdr.style.textTransform = framed ? "none" : ""; // host names stay as written
if (data.locked) { if (data.locked) {
// The vault is locked, but it has a login for this site. // The vault is locked, but it has a login for this site.
list.innerHTML = `<div class="item" data-id="__unlock"><span class="ic">🔒</span><span class="txt"><div class="u">Sign in with a saved login</div><div class="d">Unlock your vault to fill ${esc(data.host)}</div></span></div>`; list.innerHTML = `<div class="item" data-id="__unlock"><span class="ic">🔒</span><span class="txt"><div class="u">Sign in with a saved login</div><div class="d">Unlock your vault to fill ${esc(data.host)}</div></span></div>`;

View file

@ -11,7 +11,9 @@
// origin-list-unavailable, bad-request, busy, not-top-frame, error. // origin-list-unavailable, bad-request, busy, not-top-frame, error.
const { contextBridge, ipcRenderer } = require("electron"); const { contextBridge, ipcRenderer } = require("electron");
if (/^(https?|bns):$/.test(location.protocol)) { // Top frame only (main refuses iframes anyway): web tabs run preloads in
// iframes too, for the password hooks.
if (window.top === window && /^(https?|bns):$/.test(location.protocol)) {
// Errors lose custom properties crossing the bridge, so the code rides in // Errors lose custom properties crossing the bridge, so the code rides in
// the message as "[code] text" and is copied back onto the Error here, in // the message as "[code] text" and is copied back onto the Error here, in
// the page's world. // the page's world.