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

src/pages/accounts.rs (61.9K)

   1 use crate::app::{AppAction, PageContent, SectionContextExt, section_divider, section_kv_row};
   2 use cce_ui::layout::{PageLayoutBuilder, LayoutStrategy, RenderTarget};
   3 use cce_ui::widget::ScrollRegion;
   4 use cce_ui::widget::{TextBox, WidgetHost};
   5 
   6 /// Secret Service entries are keyed by (service, address) — the same pair
   7 /// cce-mail resolves passwords through. `KEYRING_SERVICE_LEGACY` is the
   8 /// pre-rename name (the app was `cce-email`); it is only ever deleted here,
   9 /// never written, since cce-mail adopts those entries on its next start.
  10 const KEYRING_SERVICE: &str = "cce-mail";
  11 const KEYRING_SERVICE_LEGACY: &str = "cce-email";
  12 
  13 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
  14 pub struct AccountInfo {
  15     pub email: String,
  16     pub imap: String,
  17     pub smtp: String,
  18     pub is_default: bool,
  19     pub password: String,
  20     #[serde(default)]
  21     pub is_oauth: bool,
  22     #[serde(default)]
  23     pub access_token: Option<String>,
  24     #[serde(default)]
  25     pub refresh_token: Option<String>,
  26     #[serde(default)]
  27     pub token_expiry: Option<u64>,
  28     #[serde(default)]
  29     pub client_id: Option<String>,
  30     #[serde(default)]
  31     pub client_secret: Option<String>,
  32 }
  33 
  34 /// Where an account's password actually lives — the fact the page could not
  35 /// show when the 2026-08-29 keyring migration stranded every entry in the
  36 /// retired KeePassXC vault: accounts.json looked perfectly healthy while
  37 /// cce-mail ran cache-only for two days. Probed off the main thread by
  38 /// [`fetch_accounts`]; never derived in the render path, where a wedged
  39 /// Secret Service would freeze the page.
  40 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  41 pub enum KeyringStatus {
  42     /// The Secret Service answered with a password for this address.
  43     InKeyring,
  44     /// No keyring entry, but accounts.json still holds a plaintext password
  45     /// (the pre-migration fallback; cce-mail adopts it on its next start).
  46     OnDiskPlaintext,
  47     /// Nowhere: the keyring has no entry and the file field is blank.
  48     /// Mail cannot sign in — the stranded-vault failure mode.
  49     Missing,
  50 }
  51 
  52 /// The status for one account given whether the keyring answered. `None`
  53 /// for accounts the question does not apply to (OAuth signs in with
  54 /// refreshed tokens; the mock account never touches the keyring).
  55 pub fn status_from(acc: &AccountInfo, keyring_has_entry: bool) -> Option<KeyringStatus> {
  56     if acc.is_oauth || acc.password == "mock_password" || acc.email == "[email protected]" {
  57         return None;
  58     }
  59     Some(if keyring_has_entry {
  60         KeyringStatus::InKeyring
  61     } else if !acc.password.is_empty() {
  62         KeyringStatus::OnDiskPlaintext
  63     } else {
  64         KeyringStatus::Missing
  65     })
  66 }
  67 
  68 /// What the accounts watcher delivers: the file contents plus, for each
  69 /// password account, where its credential actually lives.
  70 #[derive(Debug, Clone)]
  71 pub struct AccountsSnapshot {
  72     pub accounts: Vec<AccountInfo>,
  73     pub keyring: Vec<(String, KeyringStatus)>,
  74 }
  75 
  76 #[derive(Debug, Clone, Default)]
  77 pub struct AccountsState {
  78     pub loaded: bool,
  79     pub accounts: Vec<AccountInfo>,
  80     pub selected_idx: Option<usize>,
  81     pub adding_new: bool,
  82     pub email_box: cce_ui::widget::Adapted<TextBox>,
  83     pub password_box: cce_ui::widget::Adapted<TextBox>,
  84     pub imap_box: cce_ui::widget::Adapted<TextBox>,
  85     pub smtp_box: cce_ui::widget::Adapted<TextBox>,
  86     pub status_msg: Option<String>,
  87     pub status_msg_timer: f32,
  88     pub oauth_listener_running: bool,
  89     /// The account being edited, keyed by address rather than row index: the
  90     /// background refresh replaces `accounts` wholesale, and an index would
  91     /// quietly re-point the open form at a different account.
  92     pub editing_email: Option<String>,
  93     /// Per-account OAuth credentials — the copy in `accounts.json` that
  94     /// cce-mail actually refreshes with, not the global template.
  95     pub oauth_client_id_box: cce_ui::widget::Adapted<TextBox>,
  96     pub oauth_client_secret_box: cce_ui::widget::Adapted<TextBox>,
  97     /// Per-address keyring status from the last snapshot, plus optimistic
  98     /// updates from Save/Delete (the 3s watcher pass corrects them).
  99     pub keyring: std::collections::HashMap<String, KeyringStatus>,
 100     /// The account rows scroll independently of the page. Rows stay plain
 101     /// `PageContent` buttons (network's list, not services'), so they dispatch
 102     /// through `page_buttons` and this page still needs no dispatch-root
 103     /// bookkeeping — the clip rect is what keeps a scrolled-out row from
 104     /// drawing, and `renderer.rs` clamps each button to its emission-time clip.
 105     pub list: ScrollRegion,
 106 }
 107 
 108 impl AccountsState {
 109     pub fn default_mock() -> Self {
 110         let mut state = Self::default();
 111         state.email_box = TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true).with_label("Email Address");
 112         state.password_box = {
 113             let mut tb = TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true).with_label("Password / App Password");
 114             tb.is_password = true;
 115             tb
 116         };
 117         state.imap_box = TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true).with_label("IMAP Server");
 118         state.smtp_box = TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true).with_label("SMTP Server");
 119         state.oauth_client_id_box = TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true).with_label("Google Client ID");
 120         state.oauth_client_secret_box = {
 121             let mut tb = TextBox::new(String::new()).with_multiline(false).with_draw_bg_border(true).with_label("Google Client Secret");
 122             tb.is_password = true;
 123             tb
 124         };
 125         state.list = ScrollRegion::new(cce_ui::layout::spinbox_height(), LIST_GAP);
 126         state
 127     }
 128 }
 129 
 130 #[derive(Debug, Clone)]
 131 pub enum AccountsMessage {
 132     Refreshed(AccountsSnapshot),
 133     SelectAccount(usize),
 134     AddAccountStart,
 135     AddAccountCancel,
 136     AddAccountSave,
 137     DeleteAccount(usize),
 138     MakeDefault(usize),
 139     StatusMessage(String),
 140     GoogleLoginInit,
 141     GoogleLoginSuccess(AccountInfo),
 142     /// The browser flow ended — successfully, in error, or by timing out. Sent
 143     /// from `run_google_login` on every exit path so the port-36137 listener is
 144     /// never believed to be alive after its task is gone.
 145     GoogleLoginFinished,
 146     ICloudLoginHelp,
 147     EditAccountStart(usize),
 148     EditAccountSave,
 149     EditAccountCancel,
 150 }
 151 
 152 pub fn get_accounts_path() -> std::path::PathBuf {
 153     let p = cce_ui::config::cce_config_dir();
 154     if !p.exists() {
 155         let _ = std::fs::create_dir_all(&p);
 156         #[cfg(unix)]
 157         {
 158             use std::os::unix::fs::PermissionsExt;
 159             if let Ok(metadata) = std::fs::metadata(&p) {
 160                 let mut perms = metadata.permissions();
 161                 perms.set_mode(0o700);
 162                 let _ = std::fs::set_permissions(&p, perms);
 163             }
 164         }
 165     }
 166     p.join("accounts.json")
 167 }
 168 
 169 pub fn load_accounts() -> Vec<AccountInfo> {
 170     let path = get_accounts_path();
 171     if path.exists() {
 172         if let Ok(content) = std::fs::read_to_string(&path) {
 173             if let Ok(accounts) = serde_json::from_str(&content) {
 174                 return accounts;
 175             }
 176         }
 177     }
 178     vec![
 179         AccountInfo {
 180             email: "[email protected]".to_string(),
 181             imap: "imap.cce-ui.org:993".to_string(),
 182             smtp: "smtp.cce-ui.org:465".to_string(),
 183             is_default: true,
 184             password: "mock_password".to_string(),
 185             is_oauth: false,
 186             access_token: None,
 187             refresh_token: None,
 188             token_expiry: None,
 189             client_id: None,
 190             client_secret: None,
 191         },
 192     ]
 193 }
 194 
 195 pub fn save_accounts(accounts: &[AccountInfo]) {
 196     let path = get_accounts_path();
 197     if let Ok(content) = serde_json::to_string_pretty(accounts) {
 198         let _ = std::fs::write(&path, content);
 199         #[cfg(unix)]
 200         {
 201             use std::os::unix::fs::PermissionsExt;
 202             if let Ok(metadata) = std::fs::metadata(&path) {
 203                 let mut perms = metadata.permissions();
 204                 perms.set_mode(0o600);
 205                 let _ = std::fs::set_permissions(&path, perms);
 206             }
 207         }
 208     }
 209 }
 210 
 211 pub async fn fetch_accounts() -> AccountsSnapshot {
 212     let accounts = load_accounts();
 213     // Secret Service lookups are synchronous DBus; keep them off the async
 214     // workers (a wedged provider used to block for 12s at a time).
 215     let probe = accounts.clone();
 216     let keyring = tokio::task::spawn_blocking(move || {
 217         probe
 218             .iter()
 219             .filter_map(|acc| {
 220                 let has_entry = keyring::Entry::new(KEYRING_SERVICE, &acc.email)
 221                     .and_then(|e| e.get_password())
 222                     .is_ok();
 223                 status_from(acc, has_entry).map(|s| (acc.email.clone(), s))
 224             })
 225             .collect()
 226     })
 227     .await
 228     .unwrap_or_default();
 229     AccountsSnapshot { accounts, keyring }
 230 }
 231 
 232 fn generate_pkce() -> (String, String) {
 233     use ring::rand::SecureRandom;
 234     use base64::Engine;
 235     let rand = ring::rand::SystemRandom::new();
 236     let mut bytes = [0u8; 32];
 237     rand.fill(&mut bytes).unwrap();
 238     let verifier = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
 239     
 240     let hash = ring::digest::digest(&ring::digest::SHA256, verifier.as_bytes());
 241     let challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hash.as_ref());
 242     
 243     (verifier, challenge)
 244 }
 245 
 246 
 247 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
 248 pub struct GoogleClientConfig {
 249     pub client_id: String,
 250     pub client_secret: String,
 251 }
 252 
 253 fn write_google_client_config(p: &std::path::Path, config: &GoogleClientConfig) -> std::io::Result<()> {
 254     if let Some(parent) = p.parent() {
 255         let _ = std::fs::create_dir_all(parent);
 256     }
 257     if let Ok(content) = serde_json::to_string_pretty(config) {
 258         std::fs::write(p, content)?;
 259         #[cfg(unix)]
 260         {
 261             use std::os::unix::fs::PermissionsExt;
 262             if let Ok(metadata) = std::fs::metadata(p) {
 263                 let mut perms = metadata.permissions();
 264                 perms.set_mode(0o600);
 265                 let _ = std::fs::set_permissions(p, perms);
 266             }
 267         }
 268     }
 269     Ok(())
 270 }
 271 
 272 /// The Google OAuth client this desktop uses. There is no built-in default:
 273 /// the ID and secret used to be compiled in as constants, which put a live
 274 /// client secret into a public repository. They now come only from
 275 /// google_client.json, written by the Accounts page when the user pastes
 276 /// their own client's values. An empty config means "not set up yet"; the
 277 /// page shows the boxes to fill in.
 278 pub fn load_google_client_config() -> GoogleClientConfig {
 279     let p = cce_ui::config::cce_config_dir().join("google_client.json");
 280     if let Ok(content) = std::fs::read_to_string(&p) {
 281         if let Ok(config) = serde_json::from_str::<GoogleClientConfig>(&content) {
 282             return config;
 283         }
 284     }
 285     let empty = GoogleClientConfig { client_id: String::new(), client_secret: String::new() };
 286     let _ = write_google_client_config(&p, &empty);
 287     empty
 288 }
 289 
 290 /// How long the loopback listener waits for the browser redirect before giving
 291 /// up. Without a bound, abandoning the consent screen would hold port 36137 —
 292 /// and `oauth_listener_running` with it — for the life of the process.
 293 const OAUTH_WAIT: std::time::Duration = std::time::Duration::from_secs(300);
 294 
 295 pub async fn run_google_login(sender: calloop::channel::Sender<AppAction>) {
 296     google_login_flow(&sender).await;
 297     // The listener is dropped by now, so the button is live again whether the
 298     // flow succeeded, failed to bind, or timed out.
 299     let _ = sender.send(AppAction::Accounts(AccountsMessage::GoogleLoginFinished));
 300 }
 301 
 302 async fn google_login_flow(sender: &calloop::channel::Sender<AppAction>) {
 303     let client_config = load_google_client_config();
 304     let listener = match tokio::net::TcpListener::bind("127.0.0.1:36137").await {
 305         Ok(l) => l,
 306         Err(e) => {
 307             let _ = sender.send(AppAction::Accounts(AccountsMessage::StatusMessage(format!("Failed to bind port 36137: {}", e))));
 308             return;
 309         }
 310     };
 311     
 312     let _ = sender.send(AppAction::Accounts(AccountsMessage::StatusMessage("Waiting for browser login...".to_string())));
 313     
 314     let (verifier, challenge) = generate_pkce();
 315     
 316     // Mail scopes: these accounts feed cce-mail's IMAP/SMTP (XOAUTH2 needs
 317     // https://mail.google.com/). The old request asked for cloud-platform/
 318     // cclog/aicode scopes — tokens Gmail rejects with AUTHENTICATIONFAILED.
 319     // tasks, calendar.readonly and calendar.events feed cce-list-sync and
 320     // cce-calendar-sync, which read the tokens this flow stores in
 321     // accounts.json (events is the write half of the calendar mirror).
 322     let auth_url = format!(
 323         "https://accounts.google.com/o/oauth2/v2/auth?client_id={}&redirect_uri=http%3A%2F%2Flocalhost%3A36137%2Fauth%2Fcallback&response_type=code&scope=https%3A%2F%2Fmail.google.com%2F+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Ftasks+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar.readonly+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar.events&access_type=offline&prompt=consent&code_challenge={}&code_challenge_method=S256",
 324         client_config.client_id,
 325         challenge
 326     );
 327     let mut cmd = std::process::Command::new("xdg-open");
 328     cmd.arg(&auth_url);
 329     let _ = cce_ui::process::spawn_detached(cmd);
 330 
 331     let accepted = match tokio::time::timeout(OAUTH_WAIT, listener.accept()).await {
 332         Ok(res) => res,
 333         Err(_) => {
 334             let _ = sender.send(AppAction::Accounts(AccountsMessage::StatusMessage(
 335                 "Google sign-in timed out — start the sign-in again to retry.".to_string(),
 336             )));
 337             return;
 338         }
 339     };
 340 
 341     if let Ok((mut stream, _)) = accepted {
 342         use tokio::io::{AsyncReadExt, AsyncWriteExt};
 343         let mut buffer = [0; 1024];
 344         if let Ok(n) = stream.read(&mut buffer).await {
 345             let req_str = String::from_utf8_lossy(&buffer[..n]);
 346             if let Some(code_idx) = req_str.find("code=") {
 347                 let rest = &req_str[code_idx + 5..];
 348                 let end_idx = rest.find(|c: char| c == ' ' || c == '&' || c == '\r' || c == '\n').unwrap_or(rest.len());
 349                 let code = rest[..end_idx].to_string();
 350                 
 351                 let _ = sender.send(AppAction::Accounts(AccountsMessage::StatusMessage("Exchanging code for token...".to_string())));
 352                 
 353                 // Perform token exchange
 354                 exchange_code_for_tokens(code, verifier, sender.clone()).await;
 355                 
 356                 let response = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n\
 357                                 <html><head><style>body { font-family: sans-serif; background-color: #08080c; color: #fff; text-align: center; padding-top: 50px; }</style></head><body><h2>Clear System Settings Authentication Successful!</h2><p>You can close this tab and return to the application.</p></body></html>";
 358                 let _ = stream.write_all(response.as_bytes()).await;
 359                 let _ = stream.flush().await;
 360             } else {
 361                 let _ = sender.send(AppAction::Accounts(AccountsMessage::StatusMessage("OAuth Error: No code received".to_string())));
 362                 let response = "HTTP/1.1 400 Bad Request\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n\
 363                                 <html><head><style>body { font-family: sans-serif; background-color: #08080c; color: #ff6060; text-align: center; padding-top: 50px; }</style></head><body><h2>Clear System Settings Authentication Failed</h2><p>No authorization code was found.</p></body></html>";
 364                 let _ = stream.write_all(response.as_bytes()).await;
 365                 let _ = stream.flush().await;
 366             }
 367         }
 368     }
 369 }
 370 
 371 pub async fn exchange_code_for_tokens(code: String, verifier: String, sender: calloop::channel::Sender<AppAction>) {
 372     let client_config = load_google_client_config();
 373     let client = reqwest::Client::new();
 374     let mut params = vec![
 375         ("code", code.as_str()),
 376         ("client_id", client_config.client_id.as_str()),
 377         ("redirect_uri", "http://localhost:36137/auth/callback"),
 378         ("grant_type", "authorization_code"),
 379         ("code_verifier", verifier.as_str()),
 380     ];
 381     if !client_config.client_secret.is_empty() {
 382         params.push(("client_secret", client_config.client_secret.as_str()));
 383     }
 384 
 385     
 386     match client.post("https://oauth2.googleapis.com/token")
 387         .form(&params)
 388         .send()
 389         .await 
 390     {
 391         Ok(resp) => {
 392             if resp.status().is_success() {
 393                 if let Ok(json) = resp.json::<serde_json::Value>().await {
 394                     let access_token = json.get("access_token").and_then(|v| v.as_str()).unwrap_or("").to_string();
 395                     let refresh_token = json.get("refresh_token").and_then(|v| v.as_str()).unwrap_or("").to_string();
 396                     let expires_in = json.get("expires_in").and_then(|v| v.as_u64()).unwrap_or(3600);
 397                     
 398                     let now = std::time::SystemTime::now()
 399                         .duration_since(std::time::UNIX_EPOCH)
 400                         .unwrap_or_default()
 401                         .as_secs();
 402                     let expiry = now + expires_in;
 403  
 404                     // Request user profile info to get the email address
 405                     if let Ok(email_resp) = client.get("https://www.googleapis.com/oauth2/v2/userinfo")
 406                         .bearer_auth(&access_token)
 407                         .send()
 408                         .await 
 409                     {
 410                         if let Ok(email_json) = email_resp.json::<serde_json::Value>().await {
 411                             if let Some(email) = email_json.get("email").and_then(|v| v.as_str()) {
 412                                 let new_acc = AccountInfo {
 413                                     email: email.to_string(),
 414                                     imap: "imap.gmail.com:993".to_string(),
 415                                     smtp: "smtp.gmail.com:465".to_string(),
 416                                     is_default: false,
 417                                     password: String::new(),
 418                                     is_oauth: true,
 419                                     access_token: Some(access_token),
 420                                     refresh_token: Some(refresh_token.clone()),
 421                                     token_expiry: Some(expiry),
 422                                     client_id: Some(client_config.client_id.clone()),
 423                                     client_secret: Some(client_config.client_secret.clone()),
 424                                 };
 425                                 
 426                                 let _ = sender.send(AppAction::Accounts(AccountsMessage::GoogleLoginSuccess(new_acc)));
 427                                 return;
 428                             }
 429                         }
 430                     }
 431                 }
 432                 let _ = sender.send(AppAction::Accounts(AccountsMessage::StatusMessage("Failed to parse Google profile".to_string())));
 433             } else {
 434                 let err_text = resp.text().await.unwrap_or_default();
 435                 let _ = sender.send(AppAction::Accounts(AccountsMessage::StatusMessage(format!("Token exchange failed: {}", err_text))));
 436             }
 437         }
 438         Err(e) => {
 439             let _ = sender.send(AppAction::Accounts(AccountsMessage::StatusMessage(format!("Token request failed: {}", e))));
 440         }
 441     }
 442 }
 443 
 444 const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
 445 
 446 // The calm palette: neutral chrome, one green primary, quiet red danger, and
 447 // the accent tint marking both the selected row and an active mode button.
 448 const BTN_NEUTRAL: ([f32; 4], [f32; 4]) = ([0.15, 0.15, 0.20, 1.0], [0.22, 0.22, 0.28, 1.0]);
 449 const BTN_PRIMARY: ([f32; 4], [f32; 4]) = ([0.13, 0.18, 0.14, 1.0], [0.25, 0.30, 0.26, 1.0]);
 450 const BTN_DANGER: ([f32; 4], [f32; 4]) = ([0.25, 0.14, 0.14, 1.0], [0.40, 0.20, 0.20, 1.0]);
 451 const ACCENT_BG: [f32; 4] = [0.20, 0.40, 0.65, 0.35];
 452 const TEXT_BTN: [f32; 4] = [0.90, 0.90, 0.95, 1.0];
 453 const TEXT_DANGER: [f32; 4] = [0.95, 0.55, 0.55, 1.0];
 454 
 455 /// Gap between account rows. style: deliberate — list rows pack tighter
 456 /// than the pane gap, like every list on this app's pages (the inset from
 457 /// the region's edges is the well margin, `section_margin()`).
 458 const LIST_GAP: f32 = 4.0;
 459 /// Rows shown before the region starts scrolling. The list sits ABOVE the
 460 /// actions and the edit form, so it cannot fill the page the way services'
 461 /// does; it grows with the account count up to here and scrolls past it,
 462 /// rather than reserving a fixed well that is mostly empty on the
 463 /// one-or-two-account host this page usually runs on.
 464 const LIST_MAX_ROWS: usize = 8;
 465 
 466 pub fn view(state: &mut AccountsState, cx: f32, cy: f32, cw: f32, ch: f32, sec_focused: &[bool], layout: &mut dyn LayoutStrategy, ctx: &mut cce_ui::context::UiContext) -> PageContent {
 467     let mut final_pc = PageContent::new();
 468     let sec_w = 320.0f32;
 469     let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
 470 
 471     let widget_h = cce_ui::layout::spinbox_height();
 472     let btn_h = 26.0;
 473     let m = crate::app::section_margin();
 474     let gap = cce_ui::layout::plate_gap();
 475 
 476     builder.add_section_spanned(&mut final_pc, "", 1, sec_focused.first().copied().unwrap_or(false), |sec| {
 477         if !state.loaded {
 478             sec.text("Loading online accounts...", 12.0, 0.0, 12.0, TEXT_DIM);
 479             return;
 480         }
 481         let item_w = sec.cw - 2.0 * (sec.padding() + m);
 482         let rx = sec.left;
 483 
 484         // ── Account list: a scroll region, selection tinted, default marked ──
 485         // Laid out on the section context BEFORE the vstack, the way network's
 486         // wifi list is. A VStack derives each row's y from
 487         // `max(grid.max_height(), content_y)`, so a region that advances only
 488         // `content_y` is invisible to the grid half of that and every following
 489         // row lands back on top of the list.
 490         if !state.accounts.is_empty() {
 491             // Row height comes from the region, not from spinbox_height():
 492             // ScrollRegion floors item_height at the list font's line box, and
 493             // drawing at a different height than it virtualizes on would drift
 494             // the rows out from under their own hit boxes.
 495             let item_h = state.list.item_height;
 496             let list_x = sec.left + m;
 497             let list_y = sec.ay();
 498             let list_w = sec.cw - 2.0 * m;
 499             let rows_shown = state.accounts.len().min(LIST_MAX_ROWS);
 500             let list_h = rows_shown as f32 * (item_h + LIST_GAP) + 8.0;
 501 
 502             // Dissolved List (Phase 6v): scroll state + frame prims are app-owned.
 503             state.list.set_rect(list_x, list_y, list_w, list_h);
 504             state.list.update_bounds(state.accounts.len(), list_y, list_h);
 505             state.list.push_prims(sec.pc);
 506 
 507             let btn_w = list_w - 2.0 * m;
 508             sec.pc.push_clip_rect(list_x, list_y, list_w, list_h);
 509             for (idx, acc) in state.accounts.iter().enumerate() {
 510                 // Same predicate the region virtualizes on — a row scrolled out
 511                 // of the box is not emitted at all.
 512                 let Some(draw_y) = state.list.get_item_draw_y(idx, 4.0) else {
 513                     continue;
 514                 };
 515                 let mut label = if acc.is_default {
 516                     format!("{}   \u{2022} default", acc.email)
 517                 } else {
 518                     acc.email.clone()
 519                 };
 520                 // The stranded-vault tell, visible without selecting the row.
 521                 if state.keyring.get(&acc.email) == Some(&KeyringStatus::Missing) {
 522                     label.push_str("   \u{2022} no password");
 523                 }
 524                 let is_selected = state.selected_idx == Some(idx) && !state.adding_new;
 525                 let (bg, hover) = if is_selected {
 526                     (ACCENT_BG, [0.22, 0.44, 0.70, 0.45])
 527                 } else {
 528                     ([1.0, 1.0, 1.0, 0.04], [1.0, 1.0, 1.0, 0.10])
 529                 };
 530                 sec.pc.button_left(
 531                     &label,
 532                     list_x + m,
 533                     draw_y,
 534                     btn_w,
 535                     item_h,
 536                     bg,
 537                     hover,
 538                     TEXT_BTN,
 539                     AppAction::Accounts(AccountsMessage::SelectAccount(idx)),
 540                 );
 541             }
 542             sec.pc.pop_clip_rect();
 543             // Reserve the region's height through `spacing`, NOT `content_y +=`:
 544             // SectionContext keeps a parallel per-column Grid, and its own
 545             // `spacing` recomputes `content_y = grid.max_height()`. A manual
 546             // `content_y` bump that leaves the grid untouched is therefore
 547             // discarded by the next spacing call, and every following row lands
 548             // back on top of the list. (Network's list gets away with the bare
 549             // `content_y +=` only because nothing follows it in that section.)
 550             sec.spacing(list_h + LIST_GAP);
 551         }
 552 
 553         let mut stack = sec.vstack(gap);
 554         if state.accounts.is_empty() {
 555             stack.context.text("No accounts configured.", 12.0, 0.0, 12.0, TEXT_DIM);
 556         }
 557 
 558         stack.context.spacing(6.0);
 559 
 560         // ── Global actions ──
 561         // Adding is the only one left. Google sign-in lives inside the form
 562         // (next to Save) because adding an account is a single intent, and the
 563         // Google API client id/secret is file-backed config edited in
 564         // ~/.config/cce/google_client.json — set once or never, so it follows
 565         // input.kdl's precedent of having no settings UI at all.
 566         let add_bg = if state.adding_new { (ACCENT_BG, ACCENT_BG) } else { BTN_PRIMARY };
 567         // A login in flight is an active mode too — tint whichever button could
 568         // have started it, so the "already waiting on the browser" reply is not
 569         // the only clue.
 570         let login_bg = if state.oauth_listener_running { (ACCENT_BG, ACCENT_BG) } else { BTN_NEUTRAL };
 571         let narrow = item_w < 520.0;
 572 
 573         // Add, Edit, Delete in one row of squares. Edit and Delete used to live
 574         // down in the selected-account zone; they belong next to Add because
 575         // all three act on the account LIST, while everything below the divider
 576         // is about one account's fields.
 577         //
 578         // Icon faces, so each is a square the height of a button rather than a
 579         // share of the section width — the row is drawn as one full-width cell
 580         // with the buttons placed inside it, since equal columns would stretch
 581         // a 26px glyph across a third of the section.
 582         //
 583         // Edit and Delete need a selection, so they appear only with one. They
 584         // are OMITTED rather than dimmed: an icon's only disabled state is
 585         // opacity, and a faint square that still takes the click reads as a
 586         // control that ignored you. `narrow` still governs the fallback width —
 587         // without an icon set these go back to being word buttons.
 588         let sel = state.selected_idx.filter(|&i| i < state.accounts.len());
 589         let icons_ok = cce_ui::upload_icon("plus", 32).is_some();
 590         let (sq, bgap) = if icons_ok { (btn_h, 8.0) } else if narrow { (86.0, 6.0) } else { (110.0, 8.0) };
 591         stack.add_row(1, gap, btn_h, |c, _, x, _w| {
 592             // ONE y for the whole row. `c.ay()` reads the section's running
 593             // content_y, and each button emitted advances it past its own
 594             // bottom — normally right, because `add_row` resets content_y
 595             // between COLUMNS. Three buttons inside a single column get no such
 596             // reset, so re-reading `ay()` per button walked them diagonally
 597             // down the page, one button-height at a time.
 598             let y = c.ay();
 599             c.button_icon("plus", "Add Account", x, y, sq, btn_h,
 600                 add_bg.0, add_bg.1, TEXT_BTN, 1.0,
 601                 AppAction::Accounts(AccountsMessage::AddAccountStart));
 602             if let Some(i) = sel {
 603                 c.button_icon("pencil", "Edit", x + sq + bgap, y, sq, btn_h,
 604                     BTN_NEUTRAL.0, BTN_NEUTRAL.1, TEXT_BTN, 1.0,
 605                     AppAction::Accounts(AccountsMessage::EditAccountStart(i)));
 606                 c.button_icon("trash", "Delete", x + 2.0 * (sq + bgap), y, sq, btn_h,
 607                     BTN_DANGER.0, BTN_DANGER.1, TEXT_DANGER, 1.0,
 608                     AppAction::Accounts(AccountsMessage::DeleteAccount(i)));
 609             }
 610         });
 611 
 612         section_divider(stack.context);
 613 
 614         // ── Context zone: add form / edit form / selected details ──
 615         if state.adding_new {
 616             stack.context.text("Add New Account", 12.0, 0.0, 14.0, [0.35, 0.65, 0.90, 1.0]);
 617             stack.context.text("Gmail signs in with Google below; iCloud requires an App Password.", 12.0, 0.0, 11.0, TEXT_DIM);
 618 
 619             state.email_box.set_row_rect(rx + m, item_w);
 620             stack.add_widget(&mut state.email_box, item_w, widget_h, ctx);
 621             state.password_box.set_row_rect(rx + m, item_w);
 622             stack.add_widget(&mut state.password_box, item_w, widget_h, ctx);
 623             state.imap_box.set_row_rect(rx + m, item_w);
 624             stack.add_widget(&mut state.imap_box, item_w, widget_h, ctx);
 625             state.smtp_box.set_row_rect(rx + m, item_w);
 626             stack.add_widget(&mut state.smtp_box, item_w, widget_h, ctx);
 627 
 628             stack.context.spacing(4.0);
 629             stack.add_row(4, gap, btn_h, |c, i, x, w| {
 630                 let (label, colors, text_col, action) = match i {
 631                     0 => ("Save", BTN_PRIMARY, TEXT_BTN, AccountsMessage::AddAccountSave),
 632                     1 => ("Cancel", BTN_NEUTRAL, TEXT_BTN, AccountsMessage::AddAccountCancel),
 633                     // Four buttons in one row is the tightest cell on the page —
 634                     // the full labels clip below ~440px of section width, so they
 635                     // ride the same `narrow` switch the header row uses.
 636                     2 => (if narrow { "Google" } else { "Login (Google)" }, login_bg, TEXT_BTN, AccountsMessage::GoogleLoginInit),
 637                     _ => (if narrow { "iCloud" } else { "Login (iCloud)" }, BTN_NEUTRAL, TEXT_BTN, AccountsMessage::ICloudLoginHelp),
 638                 };
 639                 c.button(label, x, c.ay(), w, btn_h, colors.0, colors.1, text_col, AppAction::Accounts(action));
 640             });
 641         } else if let Some(acc) = state
 642             .editing_email
 643             .as_ref()
 644             .and_then(|e| state.accounts.iter().find(|a| a.email == *e))
 645             .cloned()
 646         {
 647             stack.context.text("Edit Account", 12.0, 0.0, 14.0, [0.35, 0.65, 0.90, 1.0]);
 648             section_kv_row(stack.context, "Email", &acc.email, TEXT_BTN);
 649             stack.context.text("The address identifies the account \u{2014} delete and re-add to change it.", 12.0, 0.0, 11.0, TEXT_DIM);
 650 
 651             // An OAuth account has no password to edit; a password one has no
 652             // client credentials. Neither ever shows the other's fields.
 653             if acc.is_oauth {
 654                 state.imap_box.set_row_rect(rx + m, item_w);
 655                 stack.add_widget(&mut state.imap_box, item_w, widget_h, ctx);
 656                 state.smtp_box.set_row_rect(rx + m, item_w);
 657                 stack.add_widget(&mut state.smtp_box, item_w, widget_h, ctx);
 658 
 659                 stack.context.spacing(4.0);
 660                 stack.context.text("Credentials this account refreshes tokens with, taking effect", 12.0, 0.0, 11.0, TEXT_DIM);
 661                 stack.context.text("on the next refresh \u{2014} Re-login to re-issue the tokens now.", 12.0, 0.0, 11.0, TEXT_DIM);
 662                 state.oauth_client_id_box.set_row_rect(rx + m, item_w);
 663                 stack.add_widget(&mut state.oauth_client_id_box, item_w, widget_h, ctx);
 664                 state.oauth_client_secret_box.set_row_rect(rx + m, item_w);
 665                 stack.add_widget(&mut state.oauth_client_secret_box, item_w, widget_h, ctx);
 666             } else {
 667                 state.password_box.set_row_rect(rx + m, item_w);
 668                 stack.add_widget(&mut state.password_box, item_w, widget_h, ctx);
 669                 state.imap_box.set_row_rect(rx + m, item_w);
 670                 stack.add_widget(&mut state.imap_box, item_w, widget_h, ctx);
 671                 state.smtp_box.set_row_rect(rx + m, item_w);
 672                 stack.add_widget(&mut state.smtp_box, item_w, widget_h, ctx);
 673             }
 674 
 675             stack.context.spacing(4.0);
 676             stack.add_row(2, gap, btn_h, |c, i, x, w| {
 677                 let (label, colors, action) = match i {
 678                     0 => ("Save", BTN_PRIMARY, AccountsMessage::EditAccountSave),
 679                     _ => ("Cancel", BTN_NEUTRAL, AccountsMessage::EditAccountCancel),
 680                 };
 681                 c.button(label, x, c.ay(), w, btn_h, colors.0, colors.1, TEXT_BTN, AppAction::Accounts(action));
 682             });
 683         } else if let Some(selected_idx) = state.selected_idx {
 684             if selected_idx < state.accounts.len() {
 685                 let acc = state.accounts[selected_idx].clone();
 686 
 687                 section_kv_row(stack.context, "Email", &acc.email, TEXT_BTN);
 688                 let auth_type = if acc.is_oauth { "OAuth2 (Google)" } else { "Password" };
 689                 section_kv_row(stack.context, "Authentication", auth_type, TEXT_BTN);
 690                 // Where the password actually lives — the row that would have
 691                 // shown the 08-29 vault stranding at a glance. Only password
 692                 // accounts carry it; the probe skips OAuth and mock.
 693                 if let Some(status) = state.keyring.get(&acc.email) {
 694                     let (text, color) = match status {
 695                         KeyringStatus::InKeyring => ("in keyring", TEXT_BTN),
 696                         KeyringStatus::OnDiskPlaintext => {
 697                             ("on disk (plaintext) \u{2014} migrates to keyring", [0.90, 0.75, 0.40, 1.0])
 698                         }
 699                         KeyringStatus::Missing => {
 700                             ("MISSING \u{2014} mail cannot sign in; Edit to set it", TEXT_DANGER)
 701                         }
 702                     };
 703                     section_kv_row(stack.context, "Password", text, color);
 704                 }
 705                 section_kv_row(stack.context, "IMAP", &acc.imap, TEXT_BTN);
 706                 section_kv_row(stack.context, "SMTP", &acc.smtp, TEXT_BTN);
 707 
 708                 stack.context.spacing(6.0);
 709 
 710                 // What is left of the per-account actions once Edit and Delete
 711                 // moved up beside Add. The row is sized to what is actually
 712                 // there — a fixed count left ragged gaps whenever an account
 713                 // was default or password — and with only these two left it can
 714                 // now be EMPTY, for a default password account, so it is
 715                 // skipped rather than drawn as a bare gap.
 716                 let mut actions: Vec<(&str, ([f32; 4], [f32; 4]), [f32; 4], AccountsMessage)> = Vec::new();
 717                 if !acc.is_default {
 718                     actions.push(("Make Default", BTN_NEUTRAL, TEXT_BTN, AccountsMessage::MakeDefault(selected_idx)));
 719                 }
 720                 if acc.is_oauth {
 721                     let relogin = if narrow { "Re-login" } else { "Re-login (Browser)" };
 722                     actions.push((relogin, login_bg, TEXT_BTN, AccountsMessage::GoogleLoginInit));
 723                 }
 724                 if !actions.is_empty() {
 725                     stack.add_row(actions.len(), gap, btn_h, |c, i, x, w| {
 726                         if let Some((label, colors, text_col, action)) = actions.get(i).cloned() {
 727                             c.button(label, x, c.ay(), w, btn_h, colors.0, colors.1, text_col, AppAction::Accounts(action));
 728                         }
 729                     });
 730                 }
 731             }
 732         } else {
 733             stack.context.text("Select an account to view details, or add one.", 12.0, 0.0, 12.0, TEXT_DIM);
 734         }
 735 
 736         if let Some(ref msg) = state.status_msg {
 737             stack.context.spacing(6.0);
 738             stack.context.text(msg, 12.0, 0.0, 12.0, [0.56, 0.83, 0.56, 1.0]);
 739         }
 740     });
 741     final_pc
 742 }
 743 
 744 /// A TextBox's live contents: the in-progress edit buffer while the box is
 745 /// still focused, the committed text otherwise. Reading `.text` alone drops
 746 /// whatever was typed into the last-focused field (its buffer only commits on
 747 /// FocusOut), which made Save fail with "All fields must be filled!" unless
 748 /// the user happened to click elsewhere first.
 749 fn live_text(tb: &cce_ui::widget::Adapted<TextBox>) -> String {
 750     if tb.editing {
 751         tb.edit_buffer.trim().to_string()
 752     } else {
 753         tb.text.trim().to_string()
 754     }
 755 }
 756 
 757 /// Seed a box with a value. Both halves, for the same reason `live_text` reads
 758 /// both: `text` is what paints, `edit_buffer` is what a focused box reads back.
 759 fn fill_box(tb: &mut cce_ui::widget::Adapted<TextBox>, value: &str) {
 760     tb.text = value.to_string();
 761     tb.edit_buffer = value.to_string();
 762 }
 763 
 764 pub fn update(state: &mut AccountsState, msg: AccountsMessage) {
 765     match msg {
 766         AccountsMessage::Refreshed(snap) => {
 767             state.loaded = true;
 768             state.accounts = snap.accounts;
 769             state.keyring = snap.keyring.into_iter().collect();
 770             // An account deleted out from under an open edit form leaves it
 771             // editing nothing; close it rather than render a blank zone.
 772             if let Some(ref e) = state.editing_email {
 773                 if !state.accounts.iter().any(|a| a.email == *e) {
 774                     state.editing_email = None;
 775                     state.password_box.placeholder = None;
 776                 }
 777             }
 778             if state.selected_idx.is_none() && !state.accounts.is_empty() {
 779                 state.selected_idx = Some(0);
 780             } else if let Some(idx) = state.selected_idx {
 781                 if idx >= state.accounts.len() {
 782                     state.selected_idx = if state.accounts.is_empty() { None } else { Some(0) };
 783                 }
 784             }
 785         }
 786         AccountsMessage::SelectAccount(idx) => {
 787             state.selected_idx = Some(idx);
 788             state.adding_new = false;
 789             state.editing_email = None;
 790         }
 791         AccountsMessage::AddAccountStart => {
 792             state.adding_new = true;
 793             state.editing_email = None;
 794             fill_box(&mut state.email_box, "");
 795             fill_box(&mut state.password_box, "");
 796             fill_box(&mut state.imap_box, "");
 797             fill_box(&mut state.smtp_box, "");
 798             // Adding needs a real password; only editing may leave it blank.
 799             state.password_box.placeholder = None;
 800         }
 801         AccountsMessage::AddAccountCancel => {
 802             state.adding_new = false;
 803             state.selected_idx = if state.accounts.is_empty() { None } else { Some(0) };
 804         }
 805         AccountsMessage::AddAccountSave => {
 806             let email = live_text(&state.email_box);
 807             let password = live_text(&state.password_box);
 808             let imap = live_text(&state.imap_box);
 809             let smtp = live_text(&state.smtp_box);
 810 
 811             if email.is_empty() || password.is_empty() || imap.is_empty() || smtp.is_empty() {
 812                 state.status_msg = Some("All fields must be filled!".to_string());
 813                 return;
 814             }
 815 
 816             // The password goes to the Secret Service under the SAME entry
 817             // cce-mail resolves (service "cce-mail", account = address) and
 818             // the on-disk field stays blank; plaintext-on-disk only as the
 819             // fallback when no keyring answers (cce-mail migrates it later).
 820             let mut stored_password = password.clone();
 821             let mut in_keyring = false;
 822             if password != "mock_password" {
 823                 if let Ok(entry) = keyring::Entry::new(KEYRING_SERVICE, &email) {
 824                     if entry.set_password(&password).is_ok() {
 825                         stored_password = String::new();
 826                         in_keyring = true;
 827                     }
 828                 }
 829             }
 830 
 831             let new_acc = AccountInfo {
 832                 email: email.clone(),
 833                 imap,
 834                 smtp,
 835                 is_default: state.accounts.is_empty(),
 836                 password: stored_password,
 837                 is_oauth: false,
 838                 access_token: None,
 839                 refresh_token: None,
 840                 token_expiry: None,
 841                 client_id: None,
 842                 client_secret: None,
 843             };
 844 
 845             if let Some(pos) = state.accounts.iter().position(|a| a.email == email) {
 846                 state.accounts[pos] = new_acc;
 847             } else {
 848                 state.accounts.push(new_acc);
 849             }
 850 
 851             save_accounts(&state.accounts);
 852             state.adding_new = false;
 853             state.selected_idx = state.accounts.iter().position(|a| a.email == email);
 854             // Optimistic: the watcher's next probe confirms it.
 855             state.keyring.insert(
 856                 email,
 857                 if in_keyring { KeyringStatus::InKeyring } else { KeyringStatus::OnDiskPlaintext },
 858             );
 859             state.status_msg = Some(if in_keyring {
 860                 "Account saved (password in keyring)".to_string()
 861             } else {
 862                 "Account saved (keyring unavailable — password stored in file)".to_string()
 863             });
 864         }
 865         AccountsMessage::DeleteAccount(idx) => {
 866             if idx < state.accounts.len() {
 867                 let deleted = state.accounts.remove(idx);
 868                 state.keyring.remove(&deleted.email);
 869                 if deleted.is_default && !state.accounts.is_empty() {
 870                     state.accounts[0].is_default = true;
 871                 }
 872                 save_accounts(&state.accounts);
 873                 // Drop the keyring password and cce-mail's cached mail too.
 874                 // The pre-rename service is cleared as well, so an account
 875                 // deleted before cce-mail ever adopted it leaves nothing behind.
 876                 for service in [KEYRING_SERVICE, KEYRING_SERVICE_LEGACY] {
 877                     if let Ok(entry) = keyring::Entry::new(service, &deleted.email) {
 878                         let _ = entry.delete_credential();
 879                     }
 880                 }
 881                 let safe_email = deleted.email.replace('@', "_").replace('.', "_");
 882                 let cache = cce_ui::config::cce_config_dir().join(format!("emails_{}.json", safe_email));
 883                 let _ = std::fs::remove_file(cache);
 884                 state.selected_idx = if state.accounts.is_empty() { None } else { Some(0) };
 885                 state.status_msg = Some("Account deleted successfully!".to_string());
 886             }
 887         }
 888         AccountsMessage::MakeDefault(idx) => {
 889             if idx < state.accounts.len() {
 890                 for (i, acc) in state.accounts.iter_mut().enumerate() {
 891                     acc.is_default = i == idx;
 892                 }
 893                 save_accounts(&state.accounts);
 894                 state.status_msg = Some("Default account updated!".to_string());
 895             }
 896         }
 897         AccountsMessage::StatusMessage(msg) => {
 898             state.status_msg = Some(msg);
 899         }
 900         AccountsMessage::GoogleLoginInit => {
 901             state.oauth_listener_running = true;
 902             state.status_msg = Some("Starting Google Sign-In...".to_string());
 903         }
 904         AccountsMessage::GoogleLoginFinished => {
 905             state.oauth_listener_running = false;
 906         }
 907         AccountsMessage::GoogleLoginSuccess(mut new_acc) => {
 908             let email = new_acc.email.clone();
 909             if let Some(pos) = state.accounts.iter().position(|a| a.email == email) {
 910                 // A re-login refreshes credentials; it must not silently
 911                 // un-default the account it replaces.
 912                 new_acc.is_default = state.accounts[pos].is_default;
 913                 state.accounts[pos] = new_acc;
 914             } else {
 915                 state.accounts.push(new_acc);
 916             }
 917             save_accounts(&state.accounts);
 918             state.adding_new = false;
 919             state.selected_idx = state.accounts.iter().position(|a| a.email == email);
 920             state.status_msg = Some("Google account authenticated!".to_string());
 921         }
 922         AccountsMessage::EditAccountStart(idx) => {
 923             let Some(acc) = state.accounts.get(idx).cloned() else { return };
 924             state.editing_email = Some(acc.email.clone());
 925             state.adding_new = false;
 926             state.selected_idx = Some(idx);
 927 
 928             fill_box(&mut state.imap_box, &acc.imap);
 929             fill_box(&mut state.smtp_box, &acc.smtp);
 930             if acc.is_oauth {
 931                 // Show what this account actually authenticates with: its own
 932                 // pinned copy, or the global template it would fall back to.
 933                 // Only read the template when something is missing — loading it
 934                 // writes the file when absent, which a full account never needs.
 935                 let (id, secret) = match (acc.client_id.clone(), acc.client_secret.clone()) {
 936                     (Some(id), Some(secret)) => (id, secret),
 937                     (id, secret) => {
 938                         let fallback = load_google_client_config();
 939                         (id.unwrap_or(fallback.client_id), secret.unwrap_or(fallback.client_secret))
 940                     }
 941                 };
 942                 fill_box(&mut state.oauth_client_id_box, &id);
 943                 fill_box(&mut state.oauth_client_secret_box, &secret);
 944             } else {
 945                 // The password lives in the keyring. Never read a secret back
 946                 // just to prefill a field — blank means "keep what is stored".
 947                 fill_box(&mut state.password_box, "");
 948                 state.password_box.set_placeholder("unchanged \u{2014} type to replace");
 949             }
 950         }
 951         AccountsMessage::EditAccountSave => {
 952             let Some(email) = state.editing_email.clone() else { return };
 953             let Some(idx) = state.accounts.iter().position(|a| a.email == email) else {
 954                 state.editing_email = None;
 955                 state.status_msg = Some("That account no longer exists.".to_string());
 956                 return;
 957             };
 958 
 959             // Validate everything BEFORE touching state.accounts: a mid-way
 960             // bail would otherwise leave memory disagreeing with the file.
 961             let imap = live_text(&state.imap_box);
 962             let smtp = live_text(&state.smtp_box);
 963             if imap.is_empty() || smtp.is_empty() {
 964                 state.status_msg = Some("IMAP and SMTP must be filled!".to_string());
 965                 return;
 966             }
 967             let is_oauth = state.accounts[idx].is_oauth;
 968             let creds = if is_oauth {
 969                 let id = live_text(&state.oauth_client_id_box);
 970                 let secret = live_text(&state.oauth_client_secret_box);
 971                 if id.is_empty() || secret.is_empty() {
 972                     state.status_msg = Some("Both Client ID and Client Secret are required!".to_string());
 973                     return;
 974                 }
 975                 Some((id, secret))
 976             } else {
 977                 None
 978             };
 979             let password = if is_oauth { String::new() } else { live_text(&state.password_box) };
 980 
 981             let mut msg = "Account updated".to_string();
 982             if !password.is_empty() {
 983                 // Same Secret Service entry cce-mail resolves; the on-disk
 984                 // field stays blank whenever the keyring accepted it.
 985                 let mut stored = password.clone();
 986                 if let Ok(entry) = keyring::Entry::new(KEYRING_SERVICE, &email) {
 987                     if entry.set_password(&password).is_ok() {
 988                         stored = String::new();
 989                         msg = "Account updated (password in keyring)".to_string();
 990                         state.keyring.insert(email.clone(), KeyringStatus::InKeyring);
 991                     } else {
 992                         state.keyring.insert(email.clone(), KeyringStatus::OnDiskPlaintext);
 993                     }
 994                 }
 995                 state.accounts[idx].password = stored;
 996             }
 997             state.accounts[idx].imap = imap;
 998             state.accounts[idx].smtp = smtp;
 999             if let Some((id, secret)) = creds {
1000                 state.accounts[idx].client_id = Some(id);
1001                 state.accounts[idx].client_secret = Some(secret);
1002             }
1003 
1004             save_accounts(&state.accounts);
1005             state.editing_email = None;
1006             state.password_box.placeholder = None;
1007             state.status_msg = Some(msg);
1008         }
1009         AccountsMessage::EditAccountCancel => {
1010             state.editing_email = None;
1011             state.password_box.placeholder = None;
1012         }
1013         AccountsMessage::ICloudLoginHelp => {
1014             let mut cmd = std::process::Command::new("xdg-open");
1015             cmd.arg("https://appleid.apple.com/");
1016             let _ = cce_ui::process::spawn_detached(cmd);
1017             state.status_msg = Some("Generate iCloud App Password...".to_string());
1018         }
1019     }
1020 }
1021 
1022 impl AccountsState {
1023     /// The list is only laid out (and its rect refreshed) when this holds — gate
1024     /// the region's input on it so a stale rect can't eat events on the loading
1025     /// screen or the empty-state text. Network's `wifi_list_visible` precedent.
1026     fn list_visible(&self) -> bool {
1027         self.loaded && !self.accounts.is_empty()
1028     }
1029 }
1030 
1031 impl crate::pages::AppPage for AccountsState {
1032     // Sections: [the one well] — the group depends on the mode.
1033     fn section_widgets(&mut self) -> Vec<Vec<cce_ui::widget::WidgetId>> {
1034         // Must mirror the view's field order exactly — this is what ctrl-nav
1035         // walks, and the edit form shows a different set per auth type.
1036         let editing_oauth = self
1037             .editing_email
1038             .as_ref()
1039             .and_then(|e| self.accounts.iter().find(|a| a.email == *e))
1040             .map(|a| a.is_oauth);
1041         let modify: Vec<cce_ui::widget::WidgetId> = if self.adding_new {
1042             vec![
1043                 self.email_box.id(),
1044                 self.password_box.id(),
1045                 self.imap_box.id(),
1046                 self.smtp_box.id(),
1047             ]
1048         } else {
1049             match editing_oauth {
1050                 Some(true) => vec![
1051                     self.imap_box.id(),
1052                     self.smtp_box.id(),
1053                     self.oauth_client_id_box.id(),
1054                     self.oauth_client_secret_box.id(),
1055                 ],
1056                 Some(false) => vec![
1057                     self.password_box.id(),
1058                     self.imap_box.id(),
1059                     self.smtp_box.id(),
1060                 ],
1061                 None => Vec::new(),
1062             }
1063         };
1064         vec![modify]
1065     }
1066 
1067     fn view(
1068         &mut self,
1069         cx: f32,
1070         cy: f32,
1071         cw: f32,
1072         ch: f32,
1073         _root_focused: bool,
1074         sec_focused: &[bool],
1075         layout: &mut dyn cce_ui::layout::LayoutStrategy,
1076         ctx: &mut cce_ui::context::UiContext,
1077     ) -> crate::app::PageContent {
1078         view(self, cx, cy, cw, ch, sec_focused, layout, ctx)
1079     }
1080 
1081     fn propagate_widget_changes(&mut self, _actions: &mut Vec<crate::app::AppAction>) {
1082         if self.adding_new && self.email_box.take_change() {
1083             let email_val = self.email_box.text.trim().to_lowercase();
1084             if email_val.ends_with("@gmail.com") {
1085                 self.imap_box.text = "imap.gmail.com:993".to_string();
1086                 self.imap_box.edit_buffer = "imap.gmail.com:993".to_string();
1087                 self.smtp_box.text = "smtp.gmail.com:465".to_string();
1088                 self.smtp_box.edit_buffer = "smtp.gmail.com:465".to_string();
1089             } else if email_val.ends_with("@icloud.com") {
1090                 self.imap_box.text = "imap.mail.me.com:993".to_string();
1091                 self.imap_box.edit_buffer = "imap.mail.me.com:993".to_string();
1092                 self.smtp_box.text = "smtp.mail.me.com:587".to_string();
1093                 self.smtp_box.edit_buffer = "smtp.mail.me.com:587".to_string();
1094             } else if email_val.ends_with("@outlook.com") || email_val.ends_with("@hotmail.com") {
1095                 self.imap_box.text = "outlook.office365.com:993".to_string();
1096                 self.imap_box.edit_buffer = "outlook.office365.com:993".to_string();
1097                 self.smtp_box.text = "smtp.office365.com:587".to_string();
1098                 self.smtp_box.edit_buffer = "smtp.office365.com:587".to_string();
1099             }
1100         }
1101     }
1102 
1103     // The dissolved list's own input, all gated on the region actually having
1104     // been laid out this frame. Row CLICKS are not here: the rows are
1105     // PageContent buttons, so their AppAction still travels the page_buttons
1106     // path — these hooks only carry the region's hover, drag and scrolling.
1107     fn handle_pointer_move(
1108         &mut self,
1109         lx: f32,
1110         ly: f32,
1111         _actions: &mut Vec<crate::app::AppAction>,
1112         _ctx: &mut cce_ui::context::UiContext,
1113     ) -> bool {
1114         self.list_visible() && self.list.cursor_moved(lx, ly)
1115     }
1116 
1117     fn handle_pointer_down(&mut self, lx: f32, ly: f32, _ctx: &mut cce_ui::context::UiContext) -> bool {
1118         self.list_visible() && self.list.press(lx, ly)
1119     }
1120 
1121     fn handle_pointer_up(&mut self, _ctx: &mut cce_ui::context::UiContext) -> bool {
1122         self.list.release()
1123     }
1124 
1125     fn handle_mouse_wheel(&mut self, delta: &cce_ui::widget::MouseScrollDelta, lx: f32, ly: f32) -> bool {
1126         self.list_visible() && self.list.wheel(delta, lx, ly)
1127     }
1128 
1129     fn handle_key_input(&mut self, event: &cce_ui::widget::KeyEvent) -> bool {
1130         self.list_visible() && self.list.keyboard(event)
1131     }
1132 
1133     fn tick(&mut self, dt: f32) -> bool {
1134         self.list.tick(dt)
1135     }
1136 }
1137 
1138 #[cfg(test)]
1139 mod tests {
1140     use super::*;
1141     use cce_ui::layout::AdaptiveGrid;
1142 
1143     #[test]
1144     fn test_accounts_page_view() {
1145         let mut state = AccountsState::default_mock();
1146         state.loaded = true;
1147         let mut layout = AdaptiveGrid::new(260.0, 20.0);
1148         let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, &[false], &mut layout, &mut cce_ui::context::UiContext::new());
1149         println!("PC BUTTONS COUNT: {}", pc.buttons.len());
1150         for (i, (btn, _, _)) in pc.buttons.iter().enumerate() {
1151             let base = btn.base();
1152             println!(
1153                 "Button {}: label={:?}, x={}, y={}, w={}, h={}, bg={:?}, hover_bg={:?}, label_color={:?}",
1154                 i, base.label, base.x, base.y, base.w, base.h, btn.bg, btn.hover_bg, btn.label_color
1155             );
1156         }
1157         assert!(!pc.buttons.is_empty(), "Accounts page should have buttons");
1158     }
1159 
1160     #[test]
1161     fn list_reserves_its_height_so_later_rows_clear_it() {
1162         // The scroll region advances the section by hand. SectionContext keeps a
1163         // parallel per-column Grid and its `spacing` recomputes
1164         // `content_y = grid.max_height()`, so reserving the height by bumping
1165         // `content_y` alone is silently discarded and every following row draws
1166         // back on top of the list. Caught live: "Add Account" and the detail
1167         // rows were painted over the account rows.
1168         let mut state = AccountsState::default_mock();
1169         state.loaded = true;
1170         state.accounts = (0..12).map(|i| acct(&format!("a{i}@example.org"), false)).collect();
1171         let mut layout = AdaptiveGrid::new(260.0, 20.0);
1172         let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, &[false], &mut layout, &mut cce_ui::context::UiContext::new());
1173 
1174         let list_bottom = state.list.y + state.list.h;
1175         let add = pc
1176             .buttons
1177             .iter()
1178             .map(|(b, _, _)| b.base())
1179             .find(|b| b.label.as_deref() == Some("Add Account"))
1180             .expect("Add Account button is painted");
1181         assert!(
1182             add.y >= list_bottom,
1183             "Add Account (y={}) must clear the list (bottom={})",
1184             add.y,
1185             list_bottom
1186         );
1187     }
1188 
1189     #[test]
1190     fn list_emits_only_the_rows_the_region_virtualizes_on() {
1191         // Row buttons are emitted under the same `get_item_draw_y` predicate the
1192         // region scrolls by, so a list longer than the cap paints the visible
1193         // window rather than all of its rows.
1194         let mut state = AccountsState::default_mock();
1195         state.loaded = true;
1196         state.accounts = (0..40).map(|i| acct(&format!("a{i}@example.org"), false)).collect();
1197         let mut layout = AdaptiveGrid::new(260.0, 20.0);
1198         let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, &[false], &mut layout, &mut cce_ui::context::UiContext::new());
1199 
1200         let rows = pc
1201             .buttons
1202             .iter()
1203             .filter(|(b, _, _)| b.base().label.as_deref().is_some_and(|l| l.starts_with("a")))
1204             .count();
1205         assert!(
1206             rows > 0 && rows <= LIST_MAX_ROWS + 2,
1207             "expected at most the visible window of rows, got {rows} of 40"
1208         );
1209     }
1210 
1211     fn acct(email: &str, is_oauth: bool) -> AccountInfo {
1212         AccountInfo {
1213             email: email.to_string(),
1214             imap: "imap.example.org:993".to_string(),
1215             smtp: "smtp.example.org:465".to_string(),
1216             is_default: false,
1217             password: String::new(),
1218             is_oauth,
1219             access_token: None,
1220             refresh_token: None,
1221             token_expiry: None,
1222             client_id: is_oauth.then(|| "pinned-id".to_string()),
1223             client_secret: is_oauth.then(|| "pinned-secret".to_string()),
1224         }
1225     }
1226 
1227     #[test]
1228     fn keyring_status_maps_every_account_kind() {
1229         let pw = acct("[email protected]", false);
1230         // The probe's answer decides between the two clean states.
1231         assert_eq!(status_from(&pw, true), Some(KeyringStatus::InKeyring));
1232         assert_eq!(status_from(&pw, false), Some(KeyringStatus::Missing));
1233         // A plaintext file field is the pre-migration fallback, not missing.
1234         let mut on_disk = acct("[email protected]", false);
1235         on_disk.password = "hunter2".to_string();
1236         assert_eq!(status_from(&on_disk, false), Some(KeyringStatus::OnDiskPlaintext));
1237         // ...unless the keyring also has it, which reads as migrated.
1238         assert_eq!(status_from(&on_disk, true), Some(KeyringStatus::InKeyring));
1239         // OAuth and the mock account get no indicator at all.
1240         assert_eq!(status_from(&acct("[email protected]", true), false), None);
1241         let mut mock = acct("[email protected]", false);
1242         mock.password = "mock_password".to_string();
1243         assert_eq!(status_from(&mock, false), None);
1244     }
1245 
1246     /// These assertions deliberately stop short of EditAccountSave's success
1247     /// path: it calls save_accounts, which writes the real accounts.json under
1248     /// XDG_CONFIG_HOME. Only the early-return paths are exercised here.
1249     #[test]
1250     fn edit_prefills_the_account_but_never_the_password() {
1251         let mut state = AccountsState::default_mock();
1252         state.accounts = vec![acct("[email protected]", false)];
1253 
1254         update(&mut state, AccountsMessage::EditAccountStart(0));
1255 
1256         assert_eq!(state.editing_email.as_deref(), Some("[email protected]"));
1257         assert_eq!(state.imap_box.text, "imap.example.org:993");
1258         assert_eq!(state.smtp_box.text, "smtp.example.org:465");
1259         // The secret is in the keyring; a blank box plus a placeholder is how
1260         // "keep the stored one" is expressed.
1261         assert!(state.password_box.text.is_empty());
1262         assert!(state.password_box.placeholder.is_some());
1263     }
1264 
1265     #[test]
1266     fn editing_an_oauth_account_shows_its_pinned_credentials() {
1267         let mut state = AccountsState::default_mock();
1268         state.accounts = vec![acct("[email protected]", true)];
1269 
1270         update(&mut state, AccountsMessage::EditAccountStart(0));
1271 
1272         assert_eq!(state.oauth_client_id_box.text, "pinned-id");
1273         assert_eq!(state.oauth_client_secret_box.text, "pinned-secret");
1274         assert!(state.oauth_client_secret_box.is_password, "the secret stays masked");
1275     }
1276 
1277     /// The form keys on the address, so a background refresh that reorders the
1278     /// list cannot silently re-point it at a different account. Proven via a
1279     /// validation bounce: reaching the IMAP check at all means the lookup found
1280     /// the right row after the reorder.
1281     #[test]
1282     fn edit_follows_the_account_across_a_reorder() {
1283         let mut state = AccountsState::default_mock();
1284         state.accounts = vec![acct("[email protected]", false), acct("[email protected]", false)];
1285 
1286         update(&mut state, AccountsMessage::EditAccountStart(1));
1287         assert_eq!(state.editing_email.as_deref(), Some("[email protected]"));
1288 
1289         update(
1290             &mut state,
1291             AccountsMessage::Refreshed(AccountsSnapshot { accounts: vec![acct("[email protected]", false), acct("[email protected]", false)], keyring: Vec::new() }),
1292         );
1293         assert_eq!(state.editing_email.as_deref(), Some("[email protected]"), "the refresh keeps the form open");
1294 
1295         fill_box(&mut state.imap_box, "");
1296         update(&mut state, AccountsMessage::EditAccountSave);
1297         assert_eq!(state.status_msg.as_deref(), Some("IMAP and SMTP must be filled!"));
1298         assert!(state.editing_email.is_some(), "a failed save keeps the form open");
1299     }
1300 
1301     #[test]
1302     fn a_vanished_account_closes_the_edit_form() {
1303         let mut state = AccountsState::default_mock();
1304         state.accounts = vec![acct("[email protected]", false)];
1305         update(&mut state, AccountsMessage::EditAccountStart(0));
1306 
1307         update(&mut state, AccountsMessage::Refreshed(AccountsSnapshot { accounts: vec![acct("[email protected]", false)], keyring: Vec::new() }));
1308 
1309         assert!(state.editing_email.is_none());
1310         assert!(state.password_box.placeholder.is_none(), "the placeholder does not leak into the add form");
1311     }
1312 
1313     /// The listener flag has to come back down on EVERY exit path, not just the
1314     /// happy one — a stuck `true` would disable the button for the life of the
1315     /// process, which is worse than the double-bind it prevents.
1316     #[test]
1317     fn oauth_listener_flag_tracks_the_flow() {
1318         let mut state = AccountsState::default();
1319         assert!(!state.oauth_listener_running);
1320 
1321         update(&mut state, AccountsMessage::GoogleLoginInit);
1322         assert!(state.oauth_listener_running, "starting a login marks the port busy");
1323 
1324         update(&mut state, AccountsMessage::GoogleLoginFinished);
1325         assert!(!state.oauth_listener_running, "a finished flow frees the button");
1326     }
1327 }
1328