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.
17 KiB
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
beforeunloadorpagehide— WKWebView's navigation is already in flight andlocation.hrefassignment 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
pwCaptureimmediately 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
submitlistener 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 ~492–565)
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/clearsfilling -
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:
/* -------------------------------------------------------- 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:
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:
- XHR login, DOM removal → offer APPEARS
- Failed XHR login (field stays) → offer does NOT appear
- XHR login, URL change via pushState only (no DOM removal) → offer APPEARS after 100 ms
- Traditional form submit → offer APPEARS (regression)
- Step 1: Write the test page
<!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
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:
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
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
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
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
npm run ship
- Step 3: Commit
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