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

commitd6f6bab9e803b1167f3c813a0dbd4ab3ad5611b0
parent69669d62a3
authorLucas Galante <[email protected]>
date2026-08-14 11:42
fix: one Google sign-in at a time; the listener flag is wired up

`oauth_listener_running` was declared but never read or written, so nothing
guarded a second press of Sign in with Google. The second flow could only
fail to bind the fixed port 36137 and surfaced as "Failed to bind port
36137" — reading as a broken app rather than "you already have a login
waiting in the browser".

GoogleLoginInit now raises the flag and the spawn site checks it. Clearing
it is the harder half: the flow has several exits, so `run_google_login`
became a wrapper that sends the new GoogleLoginFinished on every one of
them. A stuck `true` would disable the button for the life of the process,
which is worse than the double-bind it prevents — hence also the 300s
timeout on accept(), without which abandoning the consent screen held both
the port and the flag forever.

The button tints while a login is in flight, matching how Add and Google
API already mark an active mode.

Verified live against the running DE with a fake xdg-open on PATH (no
browser, no Google contact): first press binds 36137 and tints; second
press is refused with one xdg-open call total; completing the flow frees
the port and a retry rebinds; with the timeout shortened to 3s, expiry
releases the port and restores the neutral button.

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

 src/main.rs           | 22 +++++++++++++++++-----
 src/pages/accounts.rs | 52 +++++++++++++++++++++++++++++++++++++++++++++++++--
 2 files changed, 67 insertions(+), 7 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index 8b0957e..f24ca46 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -623,11 +623,23 @@ impl SystemInterface {
             }
             AppAction::Accounts(m) => match m {
                 pages::accounts::AccountsMessage::GoogleLoginInit => {
-                    pages::accounts::update(&mut self.app.accounts, m.clone());
-                    let sender = self.sender.clone();
-                    tokio::spawn(async move {
-                        pages::accounts::run_google_login(sender).await;
-                    });
+                    // One listener at a time: port 36137 is fixed, so a second
+                    // flow could only fail to bind and report it as a broken app
+                    // rather than "you already have a login in the browser".
+                    if self.app.accounts.oauth_listener_running {
+                        pages::accounts::update(
+                            &mut self.app.accounts,
+                            pages::accounts::AccountsMessage::StatusMessage(
+                                "A Google sign-in is already waiting on the browser.".to_string(),
+                            ),
+                        );
+                    } else {
+                        pages::accounts::update(&mut self.app.accounts, m.clone());
+                        let sender = self.sender.clone();
+                        tokio::spawn(async move {
+                            pages::accounts::run_google_login(sender).await;
+                        });
+                    }
                 }
                 _ => pages::accounts::update(&mut self.app.accounts, m.clone()),
             },
diff --git a/src/pages/accounts.rs b/src/pages/accounts.rs
index b2557b0..2287304 100644
--- a/src/pages/accounts.rs
+++ b/src/pages/accounts.rs
@@ -76,6 +76,10 @@ pub enum AccountsMessage {
     StatusMessage(String),
     GoogleLoginInit,
     GoogleLoginSuccess(AccountInfo),
+    /// The browser flow ended — successfully, in error, or by timing out. Sent
+    /// from `run_google_login` on every exit path so the port-36137 listener is
+    /// never believed to be alive after its task is gone.
+    GoogleLoginFinished,
     ICloudLoginHelp,
     EditOAuthCredsStart,
     EditOAuthCredsSave,
@@ -212,7 +216,19 @@ pub fn load_google_client_config() -> GoogleClientConfig {
     default_config
 }
 
+/// How long the loopback listener waits for the browser redirect before giving
+/// up. Without a bound, abandoning the consent screen would hold port 36137 —
+/// and `oauth_listener_running` with it — for the life of the process.
+const OAUTH_WAIT: std::time::Duration = std::time::Duration::from_secs(300);
+
 pub async fn run_google_login(sender: calloop::channel::Sender<AppAction>) {
+    google_login_flow(&sender).await;
+    // The listener is dropped by now, so the button is live again whether the
+    // flow succeeded, failed to bind, or timed out.
+    let _ = sender.send(AppAction::Accounts(AccountsMessage::GoogleLoginFinished));
+}
+
+async fn google_login_flow(sender: &calloop::channel::Sender<AppAction>) {
     let client_config = load_google_client_config();
     let listener = match tokio::net::TcpListener::bind("127.0.0.1:36137").await {
         Ok(l) => l,
@@ -238,7 +254,17 @@ pub async fn run_google_login(sender: calloop::channel::Sender<AppAction>) {
     cmd.arg(&auth_url);
     let _ = cce_ui::process::spawn_detached(cmd);
 
-    if let Ok((mut stream, _)) = listener.accept().await {
+    let accepted = match tokio::time::timeout(OAUTH_WAIT, listener.accept()).await {
+        Ok(res) => res,
+        Err(_) => {
+            let _ = sender.send(AppAction::Accounts(AccountsMessage::StatusMessage(
+                "Google sign-in timed out — press Sign in with Google to retry.".to_string(),
+            )));
+            return;
+        }
+    };
+
+    if let Ok((mut stream, _)) = accepted {
         use tokio::io::{AsyncReadExt, AsyncWriteExt};
         let mut buffer = [0; 1024];
         if let Ok(n) = stream.read(&mut buffer).await {
@@ -407,11 +433,14 @@ pub fn view(state: &mut AccountsState, cx: f32, cy: f32, cw: f32, ch: f32, sec_f
         // ── Global actions: one compact row ──
         let add_bg = if state.adding_new { (ACCENT_BG, ACCENT_BG) } else { BTN_PRIMARY };
         let oauth_bg = if state.editing_oauth_creds { (ACCENT_BG, ACCENT_BG) } else { BTN_NEUTRAL };
+        // A login in flight is an active mode too — tint it like the others so
+        // the "already waiting on the browser" reply isn't the only clue.
+        let login_bg = if state.oauth_listener_running { (ACCENT_BG, ACCENT_BG) } else { BTN_NEUTRAL };
         let narrow = item_w < 520.0;
         stack.add_row(3, 8.0, btn_h, |c, i, x, w| {
             let (label, colors, action) = match i {
                 0 => (if narrow { "Add" } else { "Add Account" }, add_bg, AccountsMessage::AddAccountStart),
-                1 => (if narrow { "Google Login" } else { "Sign in with Google" }, BTN_NEUTRAL, AccountsMessage::GoogleLoginInit),
+                1 => (if narrow { "Google Login" } else { "Sign in with Google" }, login_bg, AccountsMessage::GoogleLoginInit),
                 _ => (if narrow { "Google API" } else { "Google API Settings" }, oauth_bg, AccountsMessage::EditOAuthCredsStart),
             };
             c.button(label, x, c.ay(), w, btn_h, colors.0, colors.1, TEXT_BTN, AppAction::Accounts(action));
@@ -634,8 +663,12 @@ pub fn update(state: &mut AccountsState, msg: AccountsMessage) {
             state.status_msg = Some(msg);
         }
         AccountsMessage::GoogleLoginInit => {
+            state.oauth_listener_running = true;
             state.status_msg = Some("Starting Google Sign-In...".to_string());
         }
+        AccountsMessage::GoogleLoginFinished => {
+            state.oauth_listener_running = false;
+        }
         AccountsMessage::GoogleLoginSuccess(new_acc) => {
             let email = new_acc.email.clone();
             if let Some(pos) = state.accounts.iter().position(|a| a.email == email) {
@@ -778,5 +811,20 @@ mod tests {
         }
         assert!(!pc.buttons.is_empty(), "Accounts page should have buttons");
     }
+
+    /// The listener flag has to come back down on EVERY exit path, not just the
+    /// happy one — a stuck `true` would disable the button for the life of the
+    /// process, which is worse than the double-bind it prevents.
+    #[test]
+    fn oauth_listener_flag_tracks_the_flow() {
+        let mut state = AccountsState::default();
+        assert!(!state.oauth_listener_running);
+
+        update(&mut state, AccountsMessage::GoogleLoginInit);
+        assert!(state.oauth_listener_running, "starting a login marks the port busy");
+
+        update(&mut state, AccountsMessage::GoogleLoginFinished);
+        assert!(!state.oauth_listener_running, "a finished flow frees the button");
+    }
 }