git.lucas.co / cce-system-interface
system settings
git clone https://git.lucas.co/cce-system-interface.git

commitdc36db8d5a9ce14e9308f92fdc033da7ba6e75ea
parent062bb945aa
authorLucas Galante <[email protected]>
date2026-08-31 12:17
accounts: show where each password actually lives

A per-account keyring status, probed alongside the 3s accounts watcher
(never in the render path — a wedged Secret Service used to block 12s at a
time) and delivered in the new AccountsSnapshot: 'in keyring' when the
Secret Service answers for the address, an amber 'on disk (plaintext) —
migrates to keyring' for the pre-migration fallback, and a red 'MISSING —
mail cannot sign in' when neither holds it, with a '• no password' marker
on the account's list row so the broken state is visible without selecting
anything. OAuth and mock accounts carry no indicator — the question does
not apply. Save/Delete update the map optimistically; the next watcher
pass confirms.

This is the row that would have shown the 2026-08-29 keyring migration
stranding at a glance: every IMAP password left behind in the retired
KeePassXC vault while accounts.json looked perfectly healthy, and cce-mail
ran cache-only for two days before the HTML mail view exposed it.

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/main.rs           |   2 +-
 src/pages/accounts.rs | 126 ++++++++++++++++++++++++++++++++++++++++++++++----
 src/watchers.rs       |   2 +-
 3 files changed, 120 insertions(+), 10 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index 30f9bb3..7fcc973 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -68,7 +68,7 @@ struct SystemInterface {
     rx_services: std::sync::mpsc::Receiver<Vec<pages::services::ServiceInfo>>,
     rx_default_apps: std::sync::mpsc::Receiver<pages::default_apps::DefaultAppsInfo>,
     rx_timers: std::sync::mpsc::Receiver<Vec<pages::timers::TimerInfo>>,
-    rx_accounts: std::sync::mpsc::Receiver<Vec<pages::accounts::AccountInfo>>,
+    rx_accounts: std::sync::mpsc::Receiver<pages::accounts::AccountsSnapshot>,
     tx_backup: std::sync::mpsc::Sender<pages::storage::StorageMessage>,
     rx_backup: std::sync::mpsc::Receiver<pages::storage::StorageMessage>,
     rx_packages: std::sync::mpsc::Receiver<pages::packages::PackagesState>,
diff --git a/src/pages/accounts.rs b/src/pages/accounts.rs
index 417690c..cc15d9b 100644
--- a/src/pages/accounts.rs
+++ b/src/pages/accounts.rs
@@ -31,6 +31,48 @@ pub struct AccountInfo {
     pub client_secret: Option<String>,
 }
 
+/// Where an account's password actually lives — the fact the page could not
+/// show when the 2026-08-29 keyring migration stranded every entry in the
+/// retired KeePassXC vault: accounts.json looked perfectly healthy while
+/// cce-mail ran cache-only for two days. Probed off the main thread by
+/// [`fetch_accounts`]; never derived in the render path, where a wedged
+/// Secret Service would freeze the page.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum KeyringStatus {
+    /// The Secret Service answered with a password for this address.
+    InKeyring,
+    /// No keyring entry, but accounts.json still holds a plaintext password
+    /// (the pre-migration fallback; cce-mail adopts it on its next start).
+    OnDiskPlaintext,
+    /// Nowhere: the keyring has no entry and the file field is blank.
+    /// Mail cannot sign in — the stranded-vault failure mode.
+    Missing,
+}
+
+/// The status for one account given whether the keyring answered. `None`
+/// for accounts the question does not apply to (OAuth signs in with
+/// refreshed tokens; the mock account never touches the keyring).
+pub fn status_from(acc: &AccountInfo, keyring_has_entry: bool) -> Option<KeyringStatus> {
+    if acc.is_oauth || acc.password == "mock_password" || acc.email == "[email protected]" {
+        return None;
+    }
+    Some(if keyring_has_entry {
+        KeyringStatus::InKeyring
+    } else if !acc.password.is_empty() {
+        KeyringStatus::OnDiskPlaintext
+    } else {
+        KeyringStatus::Missing
+    })
+}
+
+/// What the accounts watcher delivers: the file contents plus, for each
+/// password account, where its credential actually lives.
+#[derive(Debug, Clone)]
+pub struct AccountsSnapshot {
+    pub accounts: Vec<AccountInfo>,
+    pub keyring: Vec<(String, KeyringStatus)>,
+}
+
 #[derive(Debug, Clone, Default)]
 pub struct AccountsState {
     pub loaded: bool,
@@ -52,6 +94,9 @@ pub struct AccountsState {
     /// cce-mail actually refreshes with, not the global template.
     pub oauth_client_id_box: cce_ui::widget::Adapted<TextBox>,
     pub oauth_client_secret_box: cce_ui::widget::Adapted<TextBox>,
+    /// Per-address keyring status from the last snapshot, plus optimistic
+    /// updates from Save/Delete (the 3s watcher pass corrects them).
+    pub keyring: std::collections::HashMap<String, KeyringStatus>,
     /// The account rows scroll independently of the page. Rows stay plain
     /// `PageContent` buttons (network's list, not services'), so they dispatch
     /// through `page_buttons` and this page still needs no dispatch-root
@@ -84,7 +129,7 @@ impl AccountsState {
 
 #[derive(Debug, Clone)]
 pub enum AccountsMessage {
-    Refreshed(Vec<AccountInfo>),
+    Refreshed(AccountsSnapshot),
     SelectAccount(usize),
     AddAccountStart,
     AddAccountCancel,
@@ -163,8 +208,25 @@ pub fn save_accounts(accounts: &[AccountInfo]) {
     }
 }
 
-pub async fn fetch_accounts() -> Vec<AccountInfo> {
-    load_accounts()
+pub async fn fetch_accounts() -> AccountsSnapshot {
+    let accounts = load_accounts();
+    // Secret Service lookups are synchronous DBus; keep them off the async
+    // workers (a wedged provider used to block for 12s at a time).
+    let probe = accounts.clone();
+    let keyring = tokio::task::spawn_blocking(move || {
+        probe
+            .iter()
+            .filter_map(|acc| {
+                let has_entry = keyring::Entry::new(KEYRING_SERVICE, &acc.email)
+                    .and_then(|e| e.get_password())
+                    .is_ok();
+                status_from(acc, has_entry).map(|s| (acc.email.clone(), s))
+            })
+            .collect()
+    })
+    .await
+    .unwrap_or_default();
+    AccountsSnapshot { accounts, keyring }
 }
 
 const GOOGLE_CLIENT_ID: &str = "REDACTED.apps.googleusercontent.com";
@@ -453,11 +515,15 @@ pub fn view(state: &mut AccountsState, cx: f32, cy: f32, cw: f32, ch: f32, sec_f
                 let Some(draw_y) = state.list.get_item_draw_y(idx, 4.0) else {
                     continue;
                 };
-                let label = if acc.is_default {
+                let mut label = if acc.is_default {
                     format!("{}   \u{2022} default", acc.email)
                 } else {
                     acc.email.clone()
                 };
+                // The stranded-vault tell, visible without selecting the row.
+                if state.keyring.get(&acc.email) == Some(&KeyringStatus::Missing) {
+                    label.push_str("   \u{2022} no password");
+                }
                 let is_selected = state.selected_idx == Some(idx) && !state.adding_new;
                 let (bg, hover) = if is_selected {
                     (ACCENT_BG, [0.22, 0.44, 0.70, 0.45])
@@ -624,6 +690,21 @@ pub fn view(state: &mut AccountsState, cx: f32, cy: f32, cw: f32, ch: f32, sec_f
                 section_kv_row(stack.context, "Email", &acc.email, TEXT_BTN);
                 let auth_type = if acc.is_oauth { "OAuth2 (Google)" } else { "Password" };
                 section_kv_row(stack.context, "Authentication", auth_type, TEXT_BTN);
+                // Where the password actually lives — the row that would have
+                // shown the 08-29 vault stranding at a glance. Only password
+                // accounts carry it; the probe skips OAuth and mock.
+                if let Some(status) = state.keyring.get(&acc.email) {
+                    let (text, color) = match status {
+                        KeyringStatus::InKeyring => ("in keyring", TEXT_BTN),
+                        KeyringStatus::OnDiskPlaintext => {
+                            ("on disk (plaintext) \u{2014} migrates to keyring", [0.90, 0.75, 0.40, 1.0])
+                        }
+                        KeyringStatus::Missing => {
+                            ("MISSING \u{2014} mail cannot sign in; Edit to set it", TEXT_DANGER)
+                        }
+                    };
+                    section_kv_row(stack.context, "Password", text, color);
+                }
                 section_kv_row(stack.context, "IMAP", &acc.imap, TEXT_BTN);
                 section_kv_row(stack.context, "SMTP", &acc.smtp, TEXT_BTN);
 
@@ -685,9 +766,10 @@ fn fill_box(tb: &mut cce_ui::widget::Adapted<TextBox>, value: &str) {
 
 pub fn update(state: &mut AccountsState, msg: AccountsMessage) {
     match msg {
-        AccountsMessage::Refreshed(accs) => {
+        AccountsMessage::Refreshed(snap) => {
             state.loaded = true;
-            state.accounts = accs;
+            state.accounts = snap.accounts;
+            state.keyring = snap.keyring.into_iter().collect();
             // An account deleted out from under an open edit form leaves it
             // editing nothing; close it rather than render a blank zone.
             if let Some(ref e) = state.editing_email {
@@ -772,6 +854,11 @@ pub fn update(state: &mut AccountsState, msg: AccountsMessage) {
             save_accounts(&state.accounts);
             state.adding_new = false;
             state.selected_idx = state.accounts.iter().position(|a| a.email == email);
+            // Optimistic: the watcher's next probe confirms it.
+            state.keyring.insert(
+                email,
+                if in_keyring { KeyringStatus::InKeyring } else { KeyringStatus::OnDiskPlaintext },
+            );
             state.status_msg = Some(if in_keyring {
                 "Account saved (password in keyring)".to_string()
             } else {
@@ -781,6 +868,7 @@ pub fn update(state: &mut AccountsState, msg: AccountsMessage) {
         AccountsMessage::DeleteAccount(idx) => {
             if idx < state.accounts.len() {
                 let deleted = state.accounts.remove(idx);
+                state.keyring.remove(&deleted.email);
                 if deleted.is_default && !state.accounts.is_empty() {
                     state.accounts[0].is_default = true;
                 }
@@ -899,6 +987,9 @@ pub fn update(state: &mut AccountsState, msg: AccountsMessage) {
                     if entry.set_password(&password).is_ok() {
                         stored = String::new();
                         msg = "Account updated (password in keyring)".to_string();
+                        state.keyring.insert(email.clone(), KeyringStatus::InKeyring);
+                    } else {
+                        state.keyring.insert(email.clone(), KeyringStatus::OnDiskPlaintext);
                     }
                 }
                 state.accounts[idx].password = stored;
@@ -1129,6 +1220,25 @@ mod tests {
         }
     }
 
+    #[test]
+    fn keyring_status_maps_every_account_kind() {
+        let pw = acct("[email protected]", false);
+        // The probe's answer decides between the two clean states.
+        assert_eq!(status_from(&pw, true), Some(KeyringStatus::InKeyring));
+        assert_eq!(status_from(&pw, false), Some(KeyringStatus::Missing));
+        // A plaintext file field is the pre-migration fallback, not missing.
+        let mut on_disk = acct("[email protected]", false);
+        on_disk.password = "hunter2".to_string();
+        assert_eq!(status_from(&on_disk, false), Some(KeyringStatus::OnDiskPlaintext));
+        // ...unless the keyring also has it, which reads as migrated.
+        assert_eq!(status_from(&on_disk, true), Some(KeyringStatus::InKeyring));
+        // OAuth and the mock account get no indicator at all.
+        assert_eq!(status_from(&acct("[email protected]", true), false), None);
+        let mut mock = acct("[email protected]", false);
+        mock.password = "mock_password".to_string();
+        assert_eq!(status_from(&mock, false), None);
+    }
+
     /// These assertions deliberately stop short of EditAccountSave's success
     /// path: it calls save_accounts, which writes the real accounts.json under
     /// XDG_CONFIG_HOME. Only the early-return paths are exercised here.
@@ -1174,7 +1284,7 @@ mod tests {
 
         update(
             &mut state,
-            AccountsMessage::Refreshed(vec![acct("[email protected]", false), acct("[email protected]", false)]),
+            AccountsMessage::Refreshed(AccountsSnapshot { accounts: vec![acct("[email protected]", false), acct("[email protected]", false)], keyring: Vec::new() }),
         );
         assert_eq!(state.editing_email.as_deref(), Some("[email protected]"), "the refresh keeps the form open");
 
@@ -1190,7 +1300,7 @@ mod tests {
         state.accounts = vec![acct("[email protected]", false)];
         update(&mut state, AccountsMessage::EditAccountStart(0));
 
-        update(&mut state, AccountsMessage::Refreshed(vec![acct("[email protected]", false)]));
+        update(&mut state, AccountsMessage::Refreshed(AccountsSnapshot { accounts: vec![acct("[email protected]", false)], keyring: Vec::new() }));
 
         assert!(state.editing_email.is_none());
         assert!(state.password_box.placeholder.is_none(), "the placeholder does not leak into the add form");
diff --git a/src/watchers.rs b/src/watchers.rs
index b62dc5a..fa1bcce 100644
--- a/src/watchers.rs
+++ b/src/watchers.rs
@@ -16,7 +16,7 @@ pub struct Watchers {
     pub rx_services: Receiver<Vec<services::ServiceInfo>>,
     pub rx_default_apps: Receiver<default_apps::DefaultAppsInfo>,
     pub rx_timers: Receiver<Vec<timers::TimerInfo>>,
-    pub rx_accounts: Receiver<Vec<accounts::AccountInfo>>,
+    pub rx_accounts: Receiver<accounts::AccountsSnapshot>,
     pub rx_packages: Receiver<packages::PackagesState>,
 }