Capture XHR-driven logins and offer to save password

Google, Microsoft, Okta and most modern login pages never fire a DOM
submit event — they POST via fetch/XHR and manipulate the DOM directly.
The old approach (submit listener) missed every one of them.

Three triggers now cover the common outcomes after a successful login:

  A. MutationObserver — password field removed from DOM.  Primary path.
     Most SPAs tear down the login form on success.

  B. History API interception (pushState / replaceState / popstate) —
     URL changes before the DOM settles.  100 ms grace period, then
     checks whether the field is still present before firing.

  C. Traditional form submit — kept for regressions (non-SPA sites).
     Clears pwCapture so Trigger A cannot double-fire.

Failed-login heuristic: if the password field is still in the DOM when a
trigger fires, the credential is not offered.  Avoids false positives on
wrong-password attempts.

`filling` flag prevents the capture loop from running while __workAppFill
is programmatically writing into fields.
This commit is contained in:
Vincent Rozenberg
2026-09-03 13:53:30 +02:00
parent 647c08c5b8
commit 2e0c498fc9
2 changed files with 569 additions and 53 deletions
+124 -53
View File
@@ -491,76 +491,147 @@
/* -------------------------------------------------------- passwords */
/* Offers to remember a login you just typed, the way a browser does.
The value goes straight to Rust and into the macOS Keychain; it is never
written to this app's config, never logged, and never handed to the shell
— the shell is only told which host and which username, which is all it
needs to ask you the question. */
function credentialsIn(form) {
var pw = form.querySelector('input[type="password"]');
if (!pw || !pw.value) return null;
/* Capture-then-offer: works for XHR-driven logins that never fire a DOM
submit — Google, Microsoft, and most of the modern web.
/* The username is whatever text-like field comes before the password —
which is how every login form on the web is built, whatever it calls
its fields. */
var fields = form.querySelectorAll('input');
Two phases:
1. CAPTURE — on every keystroke in a password field, snapshot the
username and password into page-local memory only. Nothing leaves
the page yet.
2. OFFER — when a plausible login-succeeded signal fires, check whether
the password field is still in the DOM. If it is, the login
probably failed (the form is still showing an error); clear and wait
for the next attempt. If it is gone, the page has moved on: offer.
Three signals trigger phase 2:
A. Password field disappears from the DOM (MutationObserver) — the
cleanest signal; React/Vue unmount the form before the navigation.
B. SPA navigates via pushState / replaceState / popstate. 100 ms grace
period covers the race where the URL changes before the form is gone.
C. Traditional form submit — send immediately so the sentinel reaches
Rust before the page navigates away.
The value travels exactly once, cleared right after the sentinel is
queued. It never touches apps.json, the shell, or the logs. */
var pwCapture = null; // { user, pass, host } — page-local only
var filling = false; // true while __workAppFill is writing into fields
function pwSnapshot() {
var pw = document.querySelector('input[type="password"]');
if (!pw || !pw.value) return;
var user = '';
for (var i = 0; i < fields.length; i++) {
if (fields[i] === pw) break;
var t = (fields[i].type || '').toLowerCase();
if (t === 'text' || t === 'email' || t === 'tel') user = fields[i].value || user;
var all = document.querySelectorAll('input');
for (var i = 0; i < all.length; i++) {
if (all[i] === pw) break;
var t = (all[i].type || '').toLowerCase();
if (t === 'text' || t === 'email' || t === 'tel') user = all[i].value || user;
}
return { user: user, pass: pw.value };
pwCapture = { user: user, pass: pw.value, host: location.hostname };
}
// Capture on every keystroke in a password field, but never while our
// own fill command is writing — that would offer to re-save what was
// just retrieved from the Keychain.
document.addEventListener('input', function (e) {
if (filling) return;
var el = e.target;
if (el && el.tagName === 'INPUT' &&
(el.type || '').toLowerCase() === 'password') {
pwSnapshot();
}
}, true);
// Trigger A: password field disappears from the DOM.
try {
new MutationObserver(function () {
if (!pwCapture) return;
if (document.querySelector('input[type="password"]')) return;
var c = pwCapture;
pwCapture = null;
send('savepw', { u: c.user, p: c.pass, h: c.host });
}).observe(document.documentElement, { childList: true, subtree: true });
} catch (e) {}
// Trigger B: SPA navigates via the History API. pwCapture is cleared
// immediately so Trigger A cannot double-fire during the 100 ms wait.
(function () {
function onNav() {
if (!pwCapture) return;
var snap = pwCapture;
pwCapture = null;
setTimeout(function () {
if (!document.querySelector('input[type="password"]')) {
send('savepw', { u: snap.user, p: snap.pass, h: snap.host });
}
// Field still present after 100 ms: failed login; snap already cleared.
}, 100);
}
var origPush = history.pushState;
var origReplace = history.replaceState;
try {
history.pushState = function () { origPush.apply(this, arguments); onNav(); };
history.replaceState = function () { origReplace.apply(this, arguments); onNav(); };
} catch (e) {}
window.addEventListener('popstate', onNav);
})();
/* Called from Rust after you ask for it, never on its own. Fills the form
around the password field you right-clicked, or the first one on the page. */
window.__workAppFill = function (account, password) {
var pw = (lastRightClicked && lastRightClicked.closest
? lastRightClicked.closest('form')
: null);
var form = pw || document.querySelector('form input[type="password"]');
if (form && form.tagName === 'INPUT') form = form.form;
if (!form) form = document;
filling = true;
try {
var pw = (lastRightClicked && lastRightClicked.closest
? lastRightClicked.closest('form')
: null);
var form = pw || document.querySelector('form input[type="password"]');
if (form && form.tagName === 'INPUT') form = form.form;
if (!form) form = document;
var field = form.querySelector('input[type="password"]');
if (!field) return false;
var field = form.querySelector('input[type="password"]');
if (!field) return false;
var user = null;
var all = form.querySelectorAll('input');
for (var i = 0; i < all.length; i++) {
if (all[i] === field) break;
var t = (all[i].type || '').toLowerCase();
if (t === 'text' || t === 'email' || t === 'tel') user = all[i];
var user = null;
var all = form.querySelectorAll('input');
for (var i = 0; i < all.length; i++) {
if (all[i] === field) break;
var t = (all[i].type || '').toLowerCase();
if (t === 'text' || t === 'email' || t === 'tel') user = all[i];
}
function fill(el, value) {
if (!el) return;
/* Assigning .value directly is invisible to React and Angular, which
track their own copy — the site would submit an empty field. This
sets it the way a keystroke would. */
var proto = Object.getPrototypeOf(el);
var setter = Object.getOwnPropertyDescriptor(proto, 'value');
if (setter && setter.set) setter.set.call(el, value);
else el.value = value;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}
if (account) fill(user, account);
fill(field, password);
field.focus();
return true;
} finally {
filling = false;
}
function fill(el, value) {
if (!el) return;
/* Assigning .value directly is invisible to React and Angular, which
track their own copy — the site would submit an empty field. This sets
it the way a keystroke would. */
var proto = Object.getPrototypeOf(el);
var setter = Object.getOwnPropertyDescriptor(proto, 'value');
if (setter && setter.set) setter.set.call(el, value);
else el.value = value;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}
if (account) fill(user, account);
fill(field, password);
field.focus();
return true;
};
// Trigger C: traditional form submit. Clears pwCapture so Trigger A
// does not double-fire when the navigation removes the field.
document.addEventListener('submit', function (e) {
var form = e.target;
if (!form || form.tagName !== 'FORM') return;
try {
var found = credentialsIn(form);
if (found) {
send('savepw', { u: found.user, p: found.pass, h: location.hostname });
pwSnapshot();
if (pwCapture) {
var c = pwCapture;
pwCapture = null;
send('savepw', { u: c.user, p: c.pass, h: c.host });
}
} catch (err) {}
}, true);