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(¶ms)
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 }