Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions settings-ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
<main class="prefs">
<section class="group">
<h2 class="group-title">Accounts</h2>
<label class="row">
<span class="row-text"><span class="row-title">Account to open at startup</span></span>
<select id="startup_account" aria-label="Account to open at startup"></select>
</label>
<div id="accounts-list" class="boxed-list"></div>
<div class="add-row">
<input type="text" id="new_account_name" placeholder="New account name" aria-label="New account name" />
Expand Down
17 changes: 17 additions & 0 deletions settings-ui/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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();
});
Expand Down
72 changes: 71 additions & 1 deletion src-tauri/src/accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ pub struct Account {
pub struct AccountsFile {
pub accounts: Vec<Account>,
pub next_seq: u32,
/// Preferred startup account; independent of the historical `default` profile id.
pub startup_account: Option<String>,
}

impl Default for AccountsFile {
Expand All @@ -52,6 +54,7 @@ impl Default for AccountsFile {
store_uuid: None,
}],
next_seq: 1,
startup_account: None,
}
}
}
Expand Down Expand Up @@ -90,7 +93,27 @@ pub fn remove(f: &mut AccountsFile, id: &str) -> Result<Account, String> {
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.
Expand Down Expand Up @@ -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();
Expand Down
20 changes: 20 additions & 0 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AccountView> {
Expand All @@ -185,12 +186,29 @@ fn account_views(app: &tauri::AppHandle) -> Vec<AccountView> {
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,
Expand Down Expand Up @@ -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,
})
}

Expand Down
17 changes: 15 additions & 2 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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::<accounts::ActiveAccount>().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)?;
Expand Down
20 changes: 20 additions & 0 deletions src-tauri/src/lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 {
Expand Down
27 changes: 26 additions & 1 deletion src-tauri/src/window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
Loading