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
@@ -0,0 +1,445 @@
# XHR Password Capture Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Capture credentials from XHR-driven login flows (Google, Microsoft, Okta…) that never fire a DOM `submit` event, so "Save this password?" appears for the majority of real-world sites.
**Architecture:** Two-phase capture-then-offer in `inject.js`. Phase 1 snapshots username + password into page-local memory on every `input` event in a password field. Phase 2 fires when a plausible login-succeeded signal arrives — password field removed from DOM (MutationObserver), or SPA navigates via the History API — and only offers if the field is now absent (field still present means failed login). The existing `submit` listener stays for traditional forms. A `filling` flag prevents the capture from re-triggering when our own fill command writes into the fields.
**Tech Stack:** JavaScript (inject.js, runs in every app's WKWebView). No Rust or frontend changes required — the existing `savepw` sentinel and `webviews.rs` handler already do the right thing once the sentinel fires.
**Spec:** No separate spec doc — requirements are in the task description. Re-stated in Global Constraints below.
## Global Constraints
- Must NOT fire the sentinel from `beforeunload` or `pagehide` — WKWebView's navigation is already in flight and `location.href` assignment is unreliable there.
- The captured value must stay in page memory only until the offer moment; never serialise it, never log it, never send it to the shell.
- Offer at most once per captured credential; clear `pwCapture` immediately after sending the sentinel.
- Do not offer if the password field is still in the DOM when the trigger fires (failed login heuristic).
- Do not capture during programmatic fill (`__workAppFill`) — that would re-offer a credential just retrieved from the Keychain.
- Keep the existing `submit` listener for traditional forms; it must not double-fire with the new mutation/URL triggers.
- App version stays 0.0.1; no new Rust changes.
---
### Task 1: Rewrite the password section of inject.js
**Files:**
- Modify: `src-tauri/src/inject.js` — replace the passwords section (lines ~492565)
**Interfaces:**
- Consumes: `send(kind, data)` — already defined in inject.js; sends a sentinel URL
- Consumes: `lastRightClicked` — already in scope; used by `__workAppFill`
- Produces: `window.__workAppFill(account, password)` — unchanged signature, now sets/clears `filling`
- [ ] **Step 1: Replace the passwords section in inject.js**
Find and replace everything between `/* -------------------------------------------------------- passwords */` and the end of the `submit` listener (just before `/* ---------------------------------------------------------- the icon */`).
The new passwords section:
```javascript
/* -------------------------------------------------------- passwords */
/* Capture-then-offer: works for XHR-driven logins that never fire a DOM
submit — Google, Microsoft, and most of the modern web.
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 error form is still showing); clear and wait for
the next attempt. If it is gone, the page has moved on: offer to save.
Three signals trigger phase 2:
A. Password field disappears from the DOM (MutationObserver).
B. SPA navigates via pushState / replaceState / popstate.
C. Traditional form submit — send immediately, before the page navigates.
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 = '';
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;
}
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.
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: field disappears from DOM — the cleanest signal that the
// login flow moved past the password step.
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 changes URL via the History API. 100 ms grace period
// covers the race where the URL changes fractionally before the React/Vue
// component tree removes the form. pwCapture is cleared immediately so
// Trigger A cannot double-fire during the 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 gone.
}, 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) {
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 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;
}
};
// Trigger C: traditional form submit. Sends immediately so the sentinel
// reaches Rust before the page navigates. 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 {
pwSnapshot();
if (pwCapture) {
var c = pwCapture;
pwCapture = null;
send('savepw', { u: c.user, p: c.pass, h: c.host });
}
} catch (err) {}
}, true);
```
- [ ] **Step 2: Verify the existing test in webviews.rs still passes**
The webviews test `the_script_carries_this_apps_own_configuration` checks that inject.js is present in the built script. No logic there that would break, but confirm:
```bash
cargo test --manifest-path src-tauri/Cargo.toml 2>&1 | grep "test result"
```
Expected: `test result: ok. 26 passed; 0 failed`
---
### Task 2: Build a local test page and verify all scenarios
**Files:**
- Create (temporary, not committed): `/tmp/workapp-login-test/index.html`
**What this tests:**
1. XHR login, DOM removal → offer APPEARS
2. Failed XHR login (field stays) → offer does NOT appear
3. XHR login, URL change via pushState only (no DOM removal) → offer APPEARS after 100 ms
4. Traditional form submit → offer APPEARS (regression)
- [ ] **Step 1: Write the test page**
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Login Test</title>
<style>
body { font-family: -apple-system, sans-serif; max-width: 380px;
margin: 80px auto; padding: 0 20px; }
input, button { display: block; width: 100%; margin: 8px 0;
padding: 10px; box-sizing: border-box; font-size: 14px; }
button { cursor: pointer; background: #0ea5e9; color: white;
border: none; border-radius: 6px; }
button.sec { background: #64748b; }
.err { color: #dc2626; margin: 8px 0; font-size: 13px; }
.ok { color: #16a34a; margin: 8px 0; font-size: 13px; }
label { display: flex; align-items: center; gap: 8px;
font-size: 13px; margin: 4px 0; }
fieldset { border: 1px solid #e2e8f0; border-radius: 8px;
padding: 12px; margin-bottom: 16px; }
legend { font-size: 12px; font-weight: 600; color: #475569; }
</style>
</head>
<body>
<h2>Work App — login trigger test</h2>
<fieldset>
<legend>Scenario controls</legend>
<label><input type="checkbox" id="succeed" checked> Simulate successful login</label>
<label><input type="checkbox" id="removeDom" checked> Remove form from DOM on success</label>
<label><input type="checkbox" id="pushUrl"> Change URL via pushState on success</label>
</fieldset>
<div id="loginSection">
<form id="loginForm" onsubmit="return false">
<input type="email" id="email" placeholder="Email" autocomplete="username">
<input type="password" id="password" placeholder="Password"
autocomplete="current-password">
<button type="button" onclick="doXhr()">Login via XHR (no submit event)</button>
<button type="submit" class="sec" onclick="doFormSubmit(event)">
Login via form submit (classic)
</button>
</form>
<div id="msg"></div>
</div>
<div id="dashboard" style="display:none">
<div class="ok">✓ Logged in — form removed from DOM.</div>
<div class="ok" id="urlNote"></div>
<button onclick="location.reload()">Reload to reset</button>
</div>
<script>
function doXhr() {
var succeed = document.getElementById('succeed').checked;
var rmDom = document.getElementById('removeDom').checked;
var pushUrl = document.getElementById('pushUrl').checked;
var msg = document.getElementById('msg');
msg.className = '';
msg.textContent = '';
if (!succeed) {
msg.className = 'err';
msg.textContent = 'Invalid credentials. Try again.';
return; // field stays — no offer should fire
}
// Success path
if (rmDom) {
document.getElementById('loginSection').remove();
document.getElementById('dashboard').style.display = '';
}
if (pushUrl) {
history.pushState({}, '', '/dashboard');
document.getElementById('urlNote').textContent =
'✓ URL changed to /dashboard via pushState.';
}
}
function doFormSubmit(e) {
// Let the submit event bubble so inject.js sees it,
// then prevent default so we don't actually navigate.
// inject.js's listener fires first (capture phase).
}
</script>
</body>
</html>
```
- [ ] **Step 2: Serve the test page**
```bash
mkdir -p /tmp/workapp-login-test
# (write the file above to /tmp/workapp-login-test/index.html)
python3 -m http.server 9753 --directory /tmp/workapp-login-test &
```
- [ ] **Step 3: Add the test app to Work's config on M1**
The M1 config is the generic seed (Gmail, Drive, Calendar). Add a test entry:
```bash
CONF="$HOME/Library/Application Support/com.vincent.workapp/apps.json"
# Read current, add test app, write back
python3 - <<'PY'
import json, uuid, pathlib
p = pathlib.Path.home() / "Library/Application Support/com.vincent.workapp/apps.json"
cfg = json.loads(p.read_text())
test_app = {
"id": "test-login-local",
"name": "Login Test",
"url": "http://localhost:9753",
"scope": ["localhost"],
"groupId": None,
"userAgent": None,
"hidden": [],
"icon": None,
"savedAccount": None,
"savedHost": None,
"zoom": 1.0,
"order": 99
}
cfg["apps"].append(test_app)
p.write_text(json.dumps(cfg, indent=2))
print("added Login Test app")
PY
```
- [ ] **Step 4: Build and launch**
```bash
npm run ship
```
(ship quits and relaunches the app, which picks up the new config)
- [ ] **Step 5: Test scenario 1 — XHR success, DOM removal**
In the Work app, click "Login Test". Check all three boxes: ✓ Succeed, ✓ Remove DOM, ☐ pushState.
Enter any email and password, click **"Login via XHR"**.
Expected: "Save this password?" dialog appears.
- [ ] **Step 6: Test scenario 2 — Failed XHR login**
Reload the page (Back button or reload). Uncheck "Simulate successful login". Enter credentials, click XHR button.
Expected: error message appears, **no** "Save this password?" dialog.
- [ ] **Step 7: Test scenario 3 — pushState only (no DOM removal)**
Reload. Check Succeed, uncheck Remove DOM, check pushState. Enter credentials, click XHR button.
Expected: "Save this password?" dialog appears within ~100 ms (brief delay for the grace period).
- [ ] **Step 8: Test scenario 4 — traditional form submit (regression)**
Reload. Click **"Login via form submit"** with credentials typed.
Expected: "Save this password?" dialog appears.
- [ ] **Step 9: Confirm no double-offer**
In scenario 1 (DOM removal + pushState both active): check both Remove DOM and pushState. Click XHR.
Expected: exactly **one** "Save this password?" dialog, not two.
- [ ] **Step 10: Remove the test app from config**
```bash
python3 - <<'PY'
import json, pathlib
p = pathlib.Path.home() / "Library/Application Support/com.vincent.workapp/apps.json"
cfg = json.loads(p.read_text())
cfg["apps"] = [a for a in cfg["apps"] if a["id"] != "test-login-local"]
p.write_text(json.dumps(cfg, indent=2))
print("removed Login Test app")
PY
```
Kill the test server: `kill %1` (or `pkill -f "http.server 9753"`)
---
### Task 3: Commit and ship the final build
- [ ] **Step 1: Confirm 26 tests still pass**
```bash
cargo test --manifest-path src-tauri/Cargo.toml 2>&1 | grep "test result"
```
Expected: `test result: ok. 26 passed; 0 failed`
- [ ] **Step 2: Build the release**
```bash
npm run ship
```
- [ ] **Step 3: Commit**
```bash
git add src-tauri/src/inject.js
git commit -m "Capture passwords from XHR-driven logins, not only form submit
Google, Microsoft and most modern auth flows never fire a DOM submit
event. The previous listener was therefore deaf to most of the app list.
Two-phase approach: capture credentials on every 'input' event in a
password field (page-local memory only, nothing leaves the page); offer
to save when a plausible login-succeeded signal fires:
A. Password field disappears from the DOM (MutationObserver) — the
cleanest signal; React/Vue unmount the form before or alongside the
navigation.
B. SPA navigates via pushState/replaceState/popstate — 100 ms grace
period handles the race where the URL changes before the form is
removed.
C. Traditional form submit — unchanged behaviour, send immediately so
the sentinel reaches Rust before the page navigates.
A filling flag prevents the input-capture from re-firing when our own
fill command writes into the fields. The existing failed-login heuristic
(field still in DOM → don't offer) applies to all three triggers."
```
- [ ] **Step 4: Tell Vincent the build is ready to pull**
+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);