diff --git a/settings-ui/index.html b/settings-ui/index.html index c7e1914..394caa7 100644 --- a/settings-ui/index.html +++ b/settings-ui/index.html @@ -14,6 +14,10 @@

Accounts

+
diff --git a/settings-ui/main.js b/settings-ui/main.js index 1732e88..b9e383d 100644 --- a/settings-ui/main.js +++ b/settings-ui/main.js @@ -53,10 +53,18 @@ async function save() { function renderAccounts(accounts) { const list = document.getElementById("accounts-list"); + const startup = document.getElementById("startup_account"); list.textContent = ""; + startup.textContent = ""; const canRemove = accounts.length > 1; for (const a of accounts) { + const option = document.createElement("option"); + option.value = a.id; + option.textContent = a.name; + option.selected = a.is_default; + startup.appendChild(option); + const row = document.createElement("div"); row.className = "account-row"; @@ -164,6 +172,15 @@ window.addEventListener("DOMContentLoaded", () => { wireLock(); document.getElementById("save").addEventListener("click", save); document.getElementById("add_account").addEventListener("click", addAccount); + document.getElementById("startup_account").addEventListener("change", async (event) => { + try { + await invoke("set_default_account", { id: event.target.value }); + await loadAccounts(); + } catch (e) { + await Dlg.alert(String(e), { title: "Could not change startup account" }); + await loadAccounts(); + } + }); document.getElementById("new_account_name").addEventListener("keydown", (e) => { if (e.key === "Enter") addAccount(); }); diff --git a/src-tauri/src/accounts.rs b/src-tauri/src/accounts.rs index 0a5527a..30e8c0a 100644 --- a/src-tauri/src/accounts.rs +++ b/src-tauri/src/accounts.rs @@ -40,6 +40,8 @@ pub struct Account { pub struct AccountsFile { pub accounts: Vec, pub next_seq: u32, + /// Preferred startup account; independent of the historical `default` profile id. + pub startup_account: Option, } impl Default for AccountsFile { @@ -52,6 +54,7 @@ impl Default for AccountsFile { store_uuid: None, }], next_seq: 1, + startup_account: None, } } } @@ -90,7 +93,27 @@ pub fn remove(f: &mut AccountsFile, id: &str) -> Result { let Some(pos) = f.accounts.iter().position(|a| a.id == id) else { return Err(format!("unknown account: {id}")); }; - Ok(f.accounts.remove(pos)) + let removed = f.accounts.remove(pos); + if f.startup_account.as_deref() == Some(id) { + f.startup_account = None; + } + Ok(removed) +} + +/// Older files and a removed preference fall back to the first account in UI order. +pub fn startup_account(f: &AccountsFile) -> Option<&Account> { + f.startup_account + .as_deref() + .and_then(|id| f.accounts.iter().find(|a| a.id == id)) + .or_else(|| f.accounts.iter().min_by_key(|a| a.order)) +} + +pub fn set_startup_account(f: &mut AccountsFile, id: &str) -> Result<(), String> { + if !f.accounts.iter().any(|a| a.id == id) { + return Err(format!("unknown account: {id}")); + } + f.startup_account = Some(id.to_owned()); + Ok(()) } /// Rename an account by id. Errors if the id is unknown. @@ -215,6 +238,53 @@ pub fn gen_store_uuid() -> [u8; 16] { mod tests { use super::*; + #[test] + fn startup_preference_survives_roundtrip_without_changing_profile_identity() { + let mut f = AccountsFile::default(); + let second = add(&mut f, "Work"); + set_startup_account(&mut f, &second.id).unwrap(); + let restored: AccountsFile = + serde_json::from_str(&serde_json::to_string(&f).unwrap()).unwrap(); + assert_eq!(startup_account(&restored), Some(&second)); + assert_eq!(restored.accounts[0].id, "default"); + assert_eq!(restored.accounts[0].store_uuid, None); + } + + #[test] + fn missing_or_stale_startup_preference_uses_ui_order() { + let mut f = AccountsFile::default(); + add(&mut f, "Work"); + f.accounts.reverse(); + assert_eq!(startup_account(&f).unwrap().id, "default"); + f.startup_account = Some("removed-id".into()); + assert_eq!(startup_account(&f).unwrap().id, "default"); + } + + #[test] + fn removing_preferred_account_selects_remaining_account() { + let mut f = AccountsFile::default(); + let second = add(&mut f, "Work"); + set_startup_account(&mut f, &second.id).unwrap(); + remove(&mut f, &second.id).unwrap(); + assert_eq!(startup_account(&f).unwrap().id, "default"); + assert_eq!(f.startup_account, None); + } + + #[test] + fn startup_selection_rejects_unknown_id_without_mutating_state() { + let mut f = AccountsFile::default(); + let before = f.clone(); + assert!(set_startup_account(&mut f, "missing").is_err()); + assert_eq!(f, before); + } + + #[test] + fn no_startup_account_when_list_is_empty() { + let mut f = AccountsFile::default(); + f.accounts.clear(); + assert!(startup_account(&f).is_none()); + } + #[test] fn default_file_has_single_default_account() { let f = AccountsFile::default(); diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 63915a9..3feb9e6 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -168,6 +168,7 @@ pub struct AccountView { pub order: u32, pub unread: u32, pub open: bool, + pub is_default: bool, } fn account_views(app: &tauri::AppHandle) -> Vec { @@ -185,12 +186,29 @@ fn account_views(app: &tauri::AppHandle) -> Vec { open: app .get_webview_window(&accounts::window_label(&a.id)) .is_some(), + is_default: accounts::startup_account(&f).is_some_and(|startup| startup.id == a.id), }) .collect(); views.sort_by_key(|v| v.order); views } +#[tauri::command] +pub fn set_default_account( + window: tauri::Window, + app: tauri::AppHandle, + id: String, +) -> Result<(), String> { + if is_remote(&window) { + return Err("forbidden".into()); + } + lock::require_unlocked(&app)?; + let _guard = accounts::lock_mutations(); + let mut f = accounts::load(&app); + accounts::set_startup_account(&mut f, &id)?; + accounts::save(&app, &f).map_err(|e| e.to_string()) +} + #[tauri::command] pub fn list_accounts( window: tauri::Window, @@ -236,12 +254,14 @@ pub async fn add_account( } crate::tray::rebuild_menu(&app); + let is_default = accounts::startup_account(&f).is_some_and(|startup| startup.id == acct.id); Ok(AccountView { id: acct.id, name: acct.name, order: acct.order, unread: 0, open: true, + is_default, }) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index acf1a55..f79d690 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -121,6 +121,7 @@ pub fn run() { commands::set_settings, commands::open_settings, commands::list_accounts, + commands::set_default_account, commands::add_account, commands::remove_account, commands::rename_account, @@ -175,9 +176,21 @@ pub fn run() { handle.manage(lock::LockState::new(!lock_on_launch)); let open_hidden = start_hidden || lock_on_launch; + let startup_label = + accounts::startup_account(&f).map(|account| accounts::window_label(&account.id)); + if let Some(label) = &startup_label { + *handle.state::().lock().unwrap() = label.clone(); + } + // Open every account window so each one receives messages/notifications. - for a in &f.accounts { - window::open_account_window(handle, a, open_hidden)?; + // On a normal launch the preferred account is the visible one; the rest + // stay connected in the background. A minimized or locked launch keeps + // every account hidden. + for account in &f.accounts { + let is_startup = startup_label + .as_deref() + .is_some_and(|label| label == accounts::window_label(&account.id)); + window::open_account_window(handle, account, open_hidden || !is_startup)?; } tray::setup(handle)?; diff --git a/src-tauri/src/lock.rs b/src-tauri/src/lock.rs index 221665a..9830e13 100644 --- a/src-tauri/src/lock.rs +++ b/src-tauri/src/lock.rs @@ -99,6 +99,20 @@ pub fn unlock(app: &AppHandle) { /// capture of the lock screen; `prevent_close` while locked stops the X from /// relaunching into an unlocked state. pub fn show_lock_window(app: &AppHandle) { + let app = app.clone(); + tauri::async_runtime::spawn_blocking(move || { + static CREATE: Mutex<()> = Mutex::new(()); + let _guard = CREATE.lock().unwrap_or_else(|e| e.into_inner()); + if !is_unlocked(&app) { + show_lock_window_inner(&app); + } + }); +} + +fn show_lock_window_inner(app: &AppHandle) { + if is_unlocked(app) { + return; + } if let Some(w) = app.get_webview_window("lock") { let _ = w.show(); let _ = w.set_focus(); @@ -116,6 +130,12 @@ pub fn show_lock_window(app: &AppHandle) { .build(); if let Ok(win) = built { + // Unlock can race a queued window creation; never leave a stale lock + // screen on top after authentication has already succeeded. + if is_unlocked(app) { + let _ = win.destroy(); + return; + } let app_handle = app.clone(); win.on_window_event(move |event| { if let tauri::WindowEvent::CloseRequested { api, .. } = event { diff --git a/src-tauri/src/window.rs b/src-tauri/src/window.rs index f69fe9f..981eca7 100644 --- a/src-tauri/src/window.rs +++ b/src-tauri/src/window.rs @@ -1226,16 +1226,41 @@ pub fn toggle_active(app: &AppHandle) { /// Opens (or focuses) the local settings window. pub fn open_settings_window(app: &AppHandle) { + // Tray/menu callbacks can deadlock WebView2 when they construct a window + // synchronously. Serialize construction on a blocking worker instead. + let app = app.clone(); + tauri::async_runtime::spawn_blocking(move || { + static CREATE: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _guard = CREATE.lock().unwrap_or_else(|e| e.into_inner()); + open_settings_window_inner(&app); + }); +} + +fn open_settings_window_inner(app: &AppHandle) { + if !crate::lock::is_unlocked(app) { + crate::lock::show_lock_window(app); + return; + } if let Some(w) = app.get_webview_window("settings") { let _ = w.show(); let _ = w.set_focus(); return; } - let _ = WebviewWindowBuilder::new(app, "settings", WebviewUrl::App("index.html".into())) + let built = WebviewWindowBuilder::new(app, "settings", WebviewUrl::App("index.html".into())) .title("whatRust — Settings") .inner_size(440.0, 680.0) .resizable(false) .build(); + if let Ok(window) = built { + // A lock request can win while the settings webview is queued. + if crate::lock::is_unlocked(app) { + let _ = window.show(); + let _ = window.set_focus(); + } else { + let _ = window.destroy(); + crate::lock::show_lock_window(app); + } + } } #[cfg(test)]