# 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 ~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/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