Fix fill-password: wrong host key, and silent failure

Two bugs made 'Fill saved password' do nothing:

1. HOST MISMATCH. Saving used location.hostname at submit time (e.g.
   accounts.google.com); filling derived the host from the app's
   configured URL (mail.google.com). The Keychain lookup always found
   nothing for any service with a separate auth domain. Fixed by storing
   the save-time hostname in App.saved_host and reading it back at fill
   time, falling back to default_scope for entries saved before this.

2. SILENT FAILURE. Every error path in the fillpw sentinel called only
   eprintln!, which is invisible to the user. A feature that fails silently
   is indistinguishable from one that is not wired up. Fixed by emitting a
   fill-error event; the shell shows it as a toast in the nav column, which
   is left of the stage and therefore always above native views.
This commit is contained in:
Vincent Rozenberg
2026-09-03 13:35:26 +02:00
parent d26b1f7b29
commit 647c08c5b8
4 changed files with 43 additions and 2 deletions
+13 -1
View File
@@ -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<String, String> {
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 = {
+7
View File
@@ -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<String>,
/// 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<String>,
/// 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,
};
+2 -1
View File
@@ -295,7 +295,7 @@ fn handle_sentinel(
"fillpw" => {
let state = handle.state::<crate::commands::AppState>();
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,
};
+21
View File
@@ -32,6 +32,7 @@ export default function App() {
const [downloads, setDownloads] = useState<Download[]>([]);
const [offer, setOffer] = useState<PasswordOffer | null>(null);
const [saved, setSaved] = useState<string | null>(null);
const [fillError, setFillError] = useState<string | null>(null);
const stageRef = useRef<HTMLDivElement>(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<PasswordOffer>("password-offer", (e) => setOffer(e.payload)),
// Fill failed — show the reason rather than doing nothing silently.
listen<string>("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() {
</Dialog>
)}
{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. */
<div
role="alert"
style={{ width: collapsed ? rail : 240 }}
className="fixed bottom-4 left-0 z-[80] px-3"
>
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-[12px] leading-snug text-red-700 shadow-md dark:border-red-900 dark:bg-red-950/60 dark:text-red-300">
{fillError}
</div>
</div>
)}
{settingsOpen && (
<Settings
config={config}