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

src/pages/accounts.rs (73K)

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