diff --git a/docs/superpowers/plans/2026-09-03-xhr-password-capture.md b/docs/superpowers/plans/2026-09-03-xhr-password-capture.md
new file mode 100644
index 0000000..65b9bb5
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-03-xhr-password-capture.md
@@ -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 ~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
+
+
+
+
+ Login Test
+
+
+
+
Work App — login trigger test
+
+
+
+
+
+
+
+
+
+
✓ Logged in — form removed from DOM.
+
+
+
+
+
+
+
+```
+
+- [ ] **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**
diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs
index 54f3833..f560542 100644
--- a/src-tauri/src/commands.rs
+++ b/src-tauri/src/commands.rs
@@ -333,6 +333,7 @@ pub fn add_app(
hidden: Vec::new(),
icon: None,
saved_account: None,
+ saved_host: None,
zoom: 1.0,
order,
};
@@ -947,6 +948,7 @@ pub fn save_password(state: State<'_, AppState>) -> Result {
let mut cfg = state.config.lock().unwrap();
if let Some(a) = cfg.apps.iter_mut().find(|a| a.id == app_id) {
a.saved_account = Some(account);
+ a.saved_host = Some(host.clone());
}
}
state.persist()?;
@@ -977,7 +979,17 @@ pub fn fill_password_for(
.saved_account
.clone()
.ok_or("no password saved for this app")?;
- let host = config::default_scope(&target.url).ok_or("this app has no host")?;
+ // Prefer the hostname that was live when the credential was saved
+ // (`location.hostname` at submit time). For services with a separate auth
+ // domain (Google, Microsoft, Okta…) that differs from the app's configured
+ // URL, so looking up by the configured URL's host always finds nothing.
+ // Fall back to deriving from the URL for entries saved before this field
+ // was added.
+ let host = target
+ .saved_host
+ .clone()
+ .or_else(|| config::default_scope(&target.url))
+ .ok_or("this app has no host")?;
#[cfg(target_os = "macos")]
let password = {
diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs
index f34b28c..085183c 100644
--- a/src-tauri/src/config.rs
+++ b/src-tauri/src/config.rs
@@ -41,6 +41,12 @@ pub struct App {
/// the Keychain — but without it the Keychain cannot be asked for the item.
#[serde(default)]
pub saved_account: Option,
+ /// The hostname the credential was saved under — `location.hostname` at
+ /// submit time, which is often different from the app's configured URL
+ /// (e.g. `accounts.google.com` vs `mail.google.com`). Stored so the fill
+ /// path can look up the same key the save path wrote.
+ #[serde(default)]
+ pub saved_host: Option,
/// Page zoom, remembered per app: a dense ERP and a mail client do not
/// want the same size.
#[serde(default = "default_zoom")]
@@ -221,6 +227,7 @@ pub fn seed() -> Config {
hidden: Vec::new(),
icon: None,
saved_account: None,
+ saved_host: None,
zoom: 1.0,
order,
};
diff --git a/src-tauri/src/inject.js b/src-tauri/src/inject.js
index 78e5551..4e668a3 100644
--- a/src-tauri/src/inject.js
+++ b/src-tauri/src/inject.js
@@ -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);
diff --git a/src-tauri/src/webviews.rs b/src-tauri/src/webviews.rs
index a3cd8cf..063f1a4 100644
--- a/src-tauri/src/webviews.rs
+++ b/src-tauri/src/webviews.rs
@@ -295,7 +295,7 @@ fn handle_sentinel(
"fillpw" => {
let state = handle.state::();
if let Err(e) = crate::commands::fill_password_for(&handle, &from, &state) {
- eprintln!("could not fill a password: {e}");
+ let _ = handle.emit("fill-error", e);
}
}
@@ -1084,6 +1084,7 @@ mod tests {
hidden: vec![".ad".into()],
icon: None,
saved_account: None,
+ saved_host: None,
zoom: 1.0,
order: 0,
};
diff --git a/src/App.tsx b/src/App.tsx
index 23150ce..5ce0291 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -32,6 +32,7 @@ export default function App() {
const [downloads, setDownloads] = useState([]);
const [offer, setOffer] = useState(null);
const [saved, setSaved] = useState(null);
+ const [fillError, setFillError] = useState(null);
const stageRef = useRef(null);
const booted = useRef(false);
@@ -147,6 +148,11 @@ export default function App() {
}),
// A login was submitted. The password is held in Rust; this only asks.
listen("password-offer", (e) => setOffer(e.payload)),
+ // Fill failed — show the reason rather than doing nothing silently.
+ listen("fill-error", (e) => {
+ setFillError(e.payload);
+ setTimeout(() => setFillError(null), 5000);
+ }),
listen<{ id: number; name: string; path: string }>("download-started", (e) =>
setDownloads((d) => [
@@ -322,6 +328,21 @@ export default function App() {
)}
+ {fillError && (
+ /* Positioned within the nav column (left of the stage), so it is
+ always above native views. The stage starts at x = collapsed ? rail
+ : PANEL; this toast stays well within that. */
+