Make the save-password dialog actually visible

Nothing had ever been saved: every app carried savedAccount=None and the
Keychain held no Work entries at all. So right-clicking "Fill saved
password" had nothing to recall — it was never a fill bug. The offer was
firing correctly on login; its dialog just could not be seen.

Two things hid it.

The stage was parked only for settingsOpen, so the password offer left
the apps where they were — and a native webview paints over anything the
shell draws. Every shell dialog needs the park, not just Settings.

Parking alone was still not enough. show_only and set_stage each move
every webview back to the stage, and the staggered bootstrap calls
show_only once per app as it builds them, so the park was undone within
milliseconds — the dialog appeared and vanished in a flash. Nothing
recorded that the apps were *meant* to be away, so every layout pass
raced to put them back.

AppState now carries that intent and placement() applies it, so a pass
that runs while a dialog is up leaves the apps parked instead of
returning them to the stage.

Verified end to end against a local login form: the offer appears and
stays on an XHR login that fires no submit event, the credential reaches
the Keychain, right-click fill puts it back, and a failed login is
correctly not offered for saving.
This commit is contained in:
2026-09-03 20:30:58 +02:00
parent 229dd557cc
commit 56e161911e
3 changed files with 37 additions and 5 deletions
+10
View File
@@ -48,6 +48,13 @@ pub struct AppState {
pub unread: Mutex<std::collections::HashMap<String, u32>>,
/// Size of the last dialog backdrop still, or why there wasn't one.
pub last_snapshot: Mutex<String>,
/// A dialog is up and the apps are parked off screen.
///
/// Repositioning is not a one-off: webviews are re-laid-out whenever the
/// stage moves and once per app as the staggered bootstrap builds them.
/// Without somewhere to record that they are meant to be away, the next
/// such pass puts them straight back over the dialog.
pub parked: Mutex<bool>,
}
impl AppState {
@@ -105,6 +112,7 @@ pub fn build_state(handle: &AppHandle) -> Result<AppState, String> {
last_notification: Mutex::new(String::new()),
unread: Mutex::new(std::collections::HashMap::new()),
last_snapshot: Mutex::new(String::new()),
parked: Mutex::new(false),
})
}
@@ -253,6 +261,7 @@ pub async fn stage_snapshot(app: AppHandle) -> Option<String> {
#[tauri::command]
pub fn hide_stage(app: AppHandle, state: State<'_, AppState>) {
let stage = *state.stage.lock().unwrap();
*state.parked.lock().unwrap() = true;
webviews::hide_all(&app, &state.cfg(), stage);
}
@@ -261,6 +270,7 @@ pub fn hide_stage(app: AppHandle, state: State<'_, AppState>) {
pub fn show_stage(app: AppHandle, state: State<'_, AppState>) {
let cfg = state.cfg();
let stage = *state.stage.lock().unwrap();
*state.parked.lock().unwrap() = false;
let active = state.active.lock().unwrap().clone();
webviews::show_only(&app, active.as_deref(), &cfg, stage);
}
+17 -2
View File
@@ -944,6 +944,19 @@ fn set_page_visibility(handle: &AppHandle, app_id: &str, visible: bool) {
));
}
/// Where the apps belong right now.
///
/// While a dialog is up they belong off screen, and every layout pass has to
/// agree — otherwise the pass that runs next puts them back over the dialog.
fn placement(handle: &AppHandle, stage: (f64, f64, f64, f64)) -> (f64, f64) {
let parked = *handle
.state::<crate::commands::AppState>()
.parked
.lock()
.unwrap();
(stage.0, if parked { stage.1 + PARKED_OFFSET } else { stage.1 })
}
/// Brings one app to the front. Every other app stays live behind it.
pub fn show_only(
handle: &AppHandle,
@@ -951,9 +964,10 @@ pub fn show_only(
cfg: &Config,
stage: (f64, f64, f64, f64),
) {
let (x, y) = placement(handle, stage);
for app in &cfg.apps {
let Some(wv) = handle.get_webview(&label_for(&app.id)) else { continue };
let _ = wv.set_position(LogicalPosition::new(stage.0, stage.1));
let _ = wv.set_position(LogicalPosition::new(x, y));
let _ = wv.set_size(LogicalSize::new(stage.2, stage.3));
let _ = wv.set_zoom(app.zoom);
let _ = wv.show();
@@ -977,9 +991,10 @@ pub fn set_stage(
radius: f64,
) {
let cfg = handle.state::<crate::commands::AppState>().cfg();
let (x, y) = placement(handle, stage);
for app in &cfg.apps {
let Some(wv) = handle.get_webview(&label_for(&app.id)) else { continue };
let _ = wv.set_position(LogicalPosition::new(stage.0, stage.1));
let _ = wv.set_position(LogicalPosition::new(x, y));
let _ = wv.set_size(LogicalSize::new(stage.2, stage.3));
set_corner_radius(handle, &app.id, radius);
}
+10 -3
View File
@@ -189,12 +189,19 @@ export default function App() {
/* A native view paints over anything the shell draws, so a dialog needs the
app moved out of the way rather than merely a higher z-index — and once it
is moved there is nothing left behind the dialog to look at. A still taken
on the way out, blurred, puts the background back. */
on the way out, blurred, puts the background back.
This applies to EVERY shell dialog, not just settings. The save-password
offer once missed it and rendered behind the app's own view, where it was
both invisible and unclickable — so nothing was ever saved, and filling
had nothing to recall. */
const dialogUp = settingsOpen || offer !== null;
useEffect(() => {
if (!config) return;
let cancelled = false;
if (settingsOpen) {
if (dialogUp) {
void (async () => {
const shot = await api.stageSnapshot().catch(() => null);
if (cancelled) return;
@@ -208,7 +215,7 @@ export default function App() {
return () => {
cancelled = true;
};
}, [settingsOpen, config]);
}, [dialogUp, config]);
const toggleCollapse = () => {
if (!config) return;