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