system settings
git clone https://git.lucas.co/cce-system-interface.git
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");
+ }
}