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:
+124
-53
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user