diff --git a/Cargo.lock b/Cargo.lock index a385a02..a5bd2b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -116,6 +116,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "compact_str" version = "0.8.2" @@ -130,6 +140,16 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -469,7 +489,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.4", + "socket2", "tokio", "tower-service", "tracing", @@ -651,6 +671,55 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "js-sys" version = "0.3.102" @@ -748,6 +817,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + [[package]] name = "num-traits" version = "0.2.19" @@ -757,6 +832,31 @@ dependencies = [ "autocfg", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -856,7 +956,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.5.10", + "socket2", "thiserror", "tokio", "tracing", @@ -893,9 +993,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1055,6 +1155,15 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.44" @@ -1115,12 +1224,27 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -1213,6 +1337,22 @@ dependencies = [ "libc", ] +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.12" @@ -1225,16 +1365,6 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "socket2" version = "0.6.4" @@ -1336,6 +1466,7 @@ dependencies = [ "serde_json", "tokio", "url", + "webbrowser", ] [[package]] @@ -1395,7 +1526,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.4", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] @@ -1556,6 +1687,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -1655,6 +1796,22 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webbrowser" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" +dependencies = [ + "core-foundation", + "jni", + "log", + "ndk-context", + "objc2", + "objc2-foundation", + "url", + "web-sys", +] + [[package]] name = "webpki-roots" version = "1.0.8" @@ -1680,6 +1837,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/Cargo.toml b/Cargo.toml index 3295c9c..842cdb0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,3 +14,4 @@ serde_json = "1" chrono = { version = "0.4", features = ["serde"] } dirs = "6" url = "2" +webbrowser = "1" diff --git a/src/app.rs b/src/app.rs index f9a0274..0dc6b07 100644 --- a/src/app.rs +++ b/src/app.rs @@ -29,14 +29,14 @@ pub struct App { pub api_client: Arc, pub needs_auth: bool, pub auth_error: Option, + pub auth_url: String, auth_tx: std_mpsc::Sender, auth_rx: std_mpsc::Receiver, sync_tx: mpsc::Sender, } enum AuthEvent { - DeviceCode(String, String), - Complete, + Ready, Error(String), } @@ -87,6 +87,7 @@ impl App { api_client, needs_auth: !has_token, auth_error: None, + auth_url: String::new(), auth_tx, auth_rx, sync_tx, @@ -96,27 +97,38 @@ impl App { pub fn start_auth_process(&mut self) { let api = self.api_client.clone(); let tx = self.auth_tx.clone(); + self.auth_error = None; + self.auth_url.clear(); std::thread::spawn(move || { let rt = tokio::runtime::Runtime::new().unwrap(); rt.block_on(async move { - match api.authenticate().await { - Ok((url, code)) => { - let _ = tx.send(AuthEvent::DeviceCode(url, code)); + match api.start_auth_flow().await { + Ok((auth_url, _port)) => { + // Try to open browser, but it's OK if it fails + ApiClient::open_browser(&auth_url); + + // Poll until token is ready loop { - tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; - if api.token_file_exists() { - let _ = tx.send(AuthEvent::Complete); + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + if api.token_is_ready().await { + let _ = tx.send(AuthEvent::Ready); break; } } } Err(e) => { let msg = match &e { - crate::infrastructure::api::ApiError::Network(s) => format!("Network: {}", s), - crate::infrastructure::api::ApiError::Auth(s) => format!("Auth: {}", s), - crate::infrastructure::api::ApiError::Api(s) => format!("API: {}", s), + crate::infrastructure::api::ApiError::Network(s) => { + format!("Network: {}", s) + } + crate::infrastructure::api::ApiError::Auth(s) => { + format!("Auth: {}", s) + } + crate::infrastructure::api::ApiError::Api(s) => { + format!("API: {}", s) + } }; let _ = tx.send(AuthEvent::Error(msg)); } @@ -131,21 +143,14 @@ impl App { } while let Ok(event) = self.auth_rx.try_recv() { match event { - AuthEvent::DeviceCode(url, code) => { - self.show_popup = Some(Popup::DeviceAuth { url, code }); - } - AuthEvent::Complete => { + AuthEvent::Ready => { self.needs_auth = false; self.auth_error = None; self.show_popup = None; let _ = self.sync_tx.try_send(SyncCommand::InitialSync); } AuthEvent::Error(msg) => { - self.auth_error = Some(msg.clone()); - self.show_popup = Some(Popup::DeviceAuth { - url: String::new(), - code: String::new(), - }); + self.auth_error = Some(msg); } } } @@ -270,6 +275,17 @@ impl App { KeyCode::Enter => { if url.is_empty() && self.auth_error.is_none() { self.start_auth_process(); + self.show_popup = Some(Popup::DeviceAuth { + url: "starting...".to_string(), + code: String::new(), + }); + } else if self.auth_error.is_some() { + self.auth_error = None; + self.start_auth_process(); + self.show_popup = Some(Popup::DeviceAuth { + url: "starting...".to_string(), + code: String::new(), + }); } } KeyCode::Esc => { diff --git a/src/infrastructure/api.rs b/src/infrastructure/api.rs index 8497988..b37fe4b 100644 --- a/src/infrastructure/api.rs +++ b/src/infrastructure/api.rs @@ -1,3 +1,5 @@ +use std::io::{BufRead, BufReader, Write}; +use std::net::TcpListener; use std::path::PathBuf; use std::sync::Arc; @@ -5,7 +7,8 @@ use chrono::{DateTime, Utc}; use reqwest::Client; use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; -use tokio::time::{sleep, Duration}; + +use url::Url; use crate::domain::models::*; @@ -32,6 +35,8 @@ pub struct ApiClient { token_path: PathBuf, } +const SCOPES: &str = "https://www.googleapis.com/auth/tasks"; + impl ApiClient { pub fn new(client_id: String, client_secret: String) -> Self { let token_path = dirs::config_dir() @@ -49,10 +54,11 @@ impl ApiClient { } pub fn token_file_exists(&self) -> bool { - self.token_path.exists() && std::fs::read_to_string(&self.token_path) - .ok() - .and_then(|s| serde_json::from_str::(&s).ok()) - .is_some() + self.token_path.exists() + && std::fs::read_to_string(&self.token_path) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .is_some() } pub async fn load_token(&self) -> Option { @@ -69,123 +75,65 @@ impl ApiClient { } } - pub async fn authenticate(&self) -> Result<(String, String), ApiError> { + /// Starts the Loopback IP Redirect OAuth flow (RFC 8252). + /// Returns (auth_url, callback_port) so the app can tell the user + /// to open the URL or open it automatically. + pub async fn start_auth_flow(&self) -> Result<(String, u16), ApiError> { if self.client_id.is_empty() { return Err(ApiError::Auth( - "GOOGLE_CLIENT_ID not set. Set both GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET".to_string(), - )); - } - if self.client_secret.is_empty() { - return Err(ApiError::Auth( - "GOOGLE_CLIENT_SECRET not set. Set both GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET".to_string(), + "GOOGLE_CLIENT_ID not set".to_string(), )); } - let params = serde_json::json!({ - "client_id": self.client_id, - "scope": "https://www.googleapis.com/auth/tasks", - }); + // Find a free port + let listener = TcpListener::bind("127.0.0.1:0") + .map_err(|e| ApiError::Network(format!("Failed to bind port: {}", e)))?; + let port = listener.local_addr().unwrap().port(); + let redirect_uri = format!("http://127.0.0.1:{}/", port); - let resp = self - .client - .post("https://oauth2.googleapis.com/device/code") - .json(¶ms) - .send() - .await - .map_err(|e| ApiError::Network(format!("HTTP request failed: {}", e)))?; + // Build Google auth URL + let auth_url = format!( + "https://accounts.google.com/o/oauth2/v2/auth?\ + response_type=code&\ + client_id={}&\ + redirect_uri={}&\ + scope={}&\ + access_type=offline&\ + prompt=consent", + urlencoding(&self.client_id), + urlencoding(&redirect_uri), + urlencoding(SCOPES), + ); - let status = resp.status(); - let data: serde_json::Value = resp - .json() - .await - .map_err(|e| ApiError::Api(format!("Invalid response (status {}): {}", status, e)))?; + // Spawn a thread that accepts one connection and parses the code + let client_id = self.client_id.clone(); + let client_secret = self.client_secret.clone(); + let token = self.token.clone(); + let token_path = self.token_path.clone(); - if !status.is_success() { - let err_code = data["error"].as_str().unwrap_or("unknown_error"); - let err_desc = data["error_description"] - .as_str() - .unwrap_or("no description"); - - let mut msg = format!("OAuth error ({}): {} - {}", status, err_code, err_desc); - - if err_code == "invalid_client" || err_desc.contains("Invalid client") { - msg.push_str( - "\n\nPossible fixes (Google Cloud Console):\n\ - 1. APIs & Services > Library -> Enable 'Google Tasks API'.\n\ - 2. APIs & Services > OAuth consent screen:\n\ - - Set 'Publishing status' to 'Testing'\n\ - - Add 'https://www.googleapis.com/auth/tasks' to Scopes\n\ - - Add your email under 'Test users'\n\ - 3. APIs & Services > Credentials:\n\ - - Create new OAuth 2.0 Client ID of type 'Desktop app'\n\ - - Copy the Client ID and Client Secret exactly (no extra spaces)", - ); - } - - return Err(ApiError::Api(msg)); - } - - let url = data["verification_url"] - .as_str() - .or_else(|| data["verification_uri"].as_str()) - .unwrap_or("https://www.google.com/device") - .to_string(); - let code = data["user_code"] - .as_str() - .unwrap_or("") - .to_string(); - let device_code = data["device_code"].as_str().unwrap_or("").to_string(); - let interval = data["interval"].as_u64().unwrap_or(5); - - tokio::spawn({ - let client = self.client.clone(); - let client_id = self.client_id.clone(); - let client_secret = self.client_secret.clone(); - let device_code = device_code.clone(); - let token = self.token.clone(); - let token_path = self.token_path.clone(); - - async move { - loop { - sleep(Duration::from_secs(interval)).await; - - let poll = serde_json::json!({ - "client_id": client_id, - "client_secret": client_secret, - "device_code": device_code, - "grant_type": "urn:ietf:params:oauth:grant_type:device_code", - }); - - if let Ok(resp) = client - .post("https://oauth2.googleapis.com/token") - .json(&poll) - .send() - .await - { - if let Ok(data) = resp.json::().await { - if let Some(access_token) = data["access_token"].as_str() { - let expires_in = data["expires_in"].as_i64().unwrap_or(3600); - let oauth_token = OAuthToken { - access_token: access_token.to_string(), - refresh_token: data["refresh_token"].as_str().map(|s| s.to_string()), - expires_at: Some(Utc::now() + chrono::Duration::seconds(expires_in)), - }; - - if let Ok(content) = serde_json::to_string_pretty(&oauth_token) { - std::fs::write(&token_path, content).ok(); - } - - let mut t = token.lock().await; - *t = Some(oauth_token); - break; - } - } - } - } + std::thread::spawn(move || { + if let Err(e) = handle_oauth_callback( + listener, + &client_id, + &client_secret, + &token, + &token_path, + ) { + eprintln!("OAuth callback error: {}", e); } }); - Ok((url, code)) + Ok((auth_url, port)) + } + + /// Opens the browser or returns the URL for manual opening + pub fn open_browser(auth_url: &str) -> bool { + webbrowser::open(auth_url).is_ok() + } + + /// Polls the in-memory token to see if auth completed + pub async fn token_is_ready(&self) -> bool { + self.token.lock().await.is_some() } pub async fn refresh_access_token(&self, refresh_token: &str) -> Result<(), ApiError> { @@ -202,12 +150,20 @@ impl ApiClient { .json(¶ms) .send() .await - .map_err(|e| ApiError::Network(e.to_string()))?; + .map_err(|e| ApiError::Network(format!("HTTP request failed: {}", e)))?; + let status = resp.status(); let data: serde_json::Value = resp .json() .await - .map_err(|e| ApiError::Network(e.to_string()))?; + .map_err(|e| ApiError::Api(format!("Invalid response (status {}): {}", status, e)))?; + + if !status.is_success() { + return Err(ApiError::Api(format!( + "Token refresh failed ({}): {:?}", + status, data + ))); + } if let Some(access_token) = data["access_token"].as_str() { let expires_in = data["expires_in"].as_i64().unwrap_or(3600); @@ -243,7 +199,9 @@ impl ApiClient { return Ok(t.access_token.clone()); } } - Err(ApiError::Auth("Token expired and no refresh token".to_string())) + Err(ApiError::Auth( + "Token expired and no refresh token".to_string(), + )) } else if let Some(saved) = self.load_token().await { *token = Some(saved); if let Some(ref t) = *token { @@ -314,7 +272,11 @@ impl ApiClient { .map(|(i, item)| { let due_str = item["due"].as_str().and_then(|s| { chrono::NaiveDateTime::parse_from_str( - &s.replace("T", " ").replace("Z", "").chars().take(16).collect::(), + &s.replace("T", " ") + .replace("Z", "") + .chars() + .take(16) + .collect::(), "%Y-%m-%d %H:%M", ) .ok() @@ -350,7 +312,8 @@ impl ApiClient { body["notes"] = serde_json::Value::String(notes.clone()); } if let Some(due) = task.due { - body["due"] = serde_json::Value::String(due.format("%Y-%m-%dT%H:%M:00.000Z").to_string()); + body["due"] = + serde_json::Value::String(due.format("%Y-%m-%dT%H:%M:00.000Z").to_string()); } if task.status == TaskStatus::Completed { body["status"] = serde_json::Value::String("completed".to_string()); @@ -371,7 +334,10 @@ impl ApiClient { .map_err(|e| ApiError::Network(e.to_string()))?; if !resp.status().is_success() { - return Err(ApiError::Api(format!("Create failed: {}", resp.status()))); + return Err(ApiError::Api(format!( + "Create failed: {}", + resp.status() + ))); } Ok(()) @@ -388,7 +354,8 @@ impl ApiClient { body["notes"] = serde_json::Value::String(notes.clone()); } if let Some(due) = task.due { - body["due"] = serde_json::Value::String(due.format("%Y-%m-%dT%H:%M:00.000Z").to_string()); + body["due"] = + serde_json::Value::String(due.format("%Y-%m-%dT%H:%M:00.000Z").to_string()); } body["status"] = serde_json::Value::String(match task.status { TaskStatus::Completed => "completed".to_string(), @@ -410,7 +377,10 @@ impl ApiClient { .map_err(|e| ApiError::Network(e.to_string()))?; if !resp.status().is_success() { - return Err(ApiError::Api(format!("Update failed: {}", resp.status()))); + return Err(ApiError::Api(format!( + "Update failed: {}", + resp.status() + ))); } Ok(()) @@ -433,7 +403,10 @@ impl ApiClient { .map_err(|e| ApiError::Network(e.to_string()))?; if !resp.status().is_success() { - return Err(ApiError::Api(format!("Delete failed: {}", resp.status()))); + return Err(ApiError::Api(format!( + "Delete failed: {}", + resp.status() + ))); } Ok(()) @@ -475,3 +448,87 @@ impl ApiClient { Ok(()) } } + +fn urlencoding(s: &str) -> String { + s.chars() + .map(|c| match c { + 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => c.to_string(), + _ => format!("%{:02X}", c as u8), + }) + .collect() +} + +fn handle_oauth_callback( + listener: TcpListener, + client_id: &str, + client_secret: &str, + token_storage: &Arc>>, + token_path: &PathBuf, +) -> Result<(), Box> { + let (stream, _) = listener.accept()?; + let mut reader = BufReader::new(&stream); + let mut request_line = String::new(); + reader.read_line(&mut request_line)?; + + // Parse the GET request to extract the code + let code = request_line + .split_whitespace() + .nth(1) + .and_then(|path| { + let parsed = Url::parse(&format!("http://localhost{}", path)).ok()?; + parsed.query_pairs().find(|(k, _)| k == "code")?.1.to_string().into() + }); + + let reply = if let Some(ref _code) = code { + // Send success response to browser + "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n

Authorized!

You can close this tab and return to the terminal.

" + } else { + "HTTP/1.1 400 Bad Request\r\nContent-Type: text/html\r\n\r\n

Authorization failed

No code received.

" + }; + + let mut response = stream.try_clone()?; + response.write_all(reply.as_bytes())?; + response.flush()?; + + if let Some(auth_code) = code { + // Exchange code for token + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(async move { + let client = Client::new(); + let params = serde_json::json!({ + "client_id": client_id, + "client_secret": client_secret, + "code": auth_code, + "redirect_uri": format!("http://127.0.0.1:{}/", listener.local_addr().unwrap().port()), + "grant_type": "authorization_code", + }); + + if let Ok(resp) = client + .post("https://oauth2.googleapis.com/token") + .json(¶ms) + .send() + .await + { + if let Ok(data) = resp.json::().await { + if let Some(access_token) = data["access_token"].as_str() { + let expires_in = data["expires_in"].as_i64().unwrap_or(3600); + let oauth_token = OAuthToken { + access_token: access_token.to_string(), + refresh_token: data["refresh_token"].as_str().map(|s| s.to_string()), + expires_at: Some(Utc::now() + chrono::Duration::seconds(expires_in)), + }; + + if let Ok(content) = serde_json::to_string_pretty(&oauth_token) { + std::fs::write(token_path, content).ok(); + } + + let mut t = token_storage.lock().await; + *t = Some(oauth_token); + } + } + } + }); + } + + Ok(()) +} diff --git a/src/ui/components.rs b/src/ui/components.rs index 7197fd7..4e44e7f 100644 --- a/src/ui/components.rs +++ b/src/ui/components.rs @@ -297,13 +297,15 @@ pub fn render_device_auth_popup( frame: &mut Frame, area: Rect, url: &str, - code: &str, + _code: &str, error: Option<&str>, ) { - let popup_area = centered_rect(75, 11, area); + let popup_area = centered_rect(80, 13, area); let border_color = if error.is_some() { Color::Red + } else if url == "starting..." { + Color::Yellow } else if url.is_empty() { POPUP_BORDER } else { @@ -314,7 +316,7 @@ pub fn render_device_auth_popup( .borders(Borders::ALL) .style(Style::default().bg(POPUP_BG)) .border_style(Style::default().fg(border_color)) - .title(" Google OAuth ") + .title(" Google Tasks - Authorization ") .title_alignment(Alignment::Left); let mut lines = Vec::new(); @@ -322,28 +324,52 @@ pub fn render_device_auth_popup( if let Some(err) = error { lines.push(Line::from("")); lines.push(Line::from(Span::styled( - format!(" ERROR: {} ", err), + " Authorization Error ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), ))); lines.push(Line::from("")); + let wrapped = textwrap(&err, 50); + for line in wrapped { + lines.push(Line::from(Span::styled( + line, + Style::default().fg(Color::White), + ))); + } + lines.push(Line::from("")); lines.push(Line::from(Span::styled( - " Press Enter to retry or Esc to cancel ", + " Press Enter to retry | Esc to cancel ", + Style::default().fg(Color::DarkGray), + ))); + } else if url == "starting..." { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + " Starting authorization... ", + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + ))); + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + " A browser tab will open or you can copy the URL manually. ", Style::default().fg(Color::DarkGray), ))); } else if url.is_empty() { lines.push(Line::from("")); lines.push(Line::from(Span::styled( - " Google Tasks Authorization Required ", + " Google Tasks Authorization ", Style::default().fg(Color::White).add_modifier(Modifier::BOLD), ))); lines.push(Line::from("")); lines.push(Line::from(Span::styled( - " Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET env vars ", + " This app needs access to your Google Tasks. ", + Style::default().fg(Color::White), + ))); + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + " Set env vars GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET ", Style::default().fg(Color::Yellow), ))); lines.push(Line::from("")); lines.push(Line::from(Span::styled( - " Press Enter to start authorization ", + " Press Enter to start ", Style::default().fg(Color::Cyan), ))); lines.push(Line::from(Span::styled( @@ -353,33 +379,65 @@ pub fn render_device_auth_popup( } else { lines.push(Line::from("")); lines.push(Line::from(Span::styled( - " Visit the following URL to authorize: ", - Style::default().fg(Color::White), + " Authorize in your browser: ", + Style::default().fg(Color::White).add_modifier(Modifier::BOLD), ))); lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - url, - Style::default().fg(Color::Cyan).add_modifier(Modifier::UNDERLINED), - ))); + // Show a shortened version of the URL for readability + if url.len() > 70 { + let short_url: String = url.chars().take(67).collect(); + lines.push(Line::from(Span::styled( + format!(" {}", short_url), + Style::default().fg(Color::Cyan), + ))); + lines.push(Line::from(Span::styled( + " ... (full URL in terminal log) ", + Style::default().fg(Color::DarkGray), + ))); + } else { + lines.push(Line::from(Span::styled( + format!(" {}", url), + Style::default().fg(Color::Cyan), + ))); + } lines.push(Line::from("")); lines.push(Line::from(Span::styled( - format!(" Enter code: {} ", code), + " Waiting for browser authorization... ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), ))); - lines.push(Line::from("")); lines.push(Line::from(Span::styled( - " Waiting for authorization... (Esc to cancel) ", + " (Esc to cancel) ", Style::default().fg(Color::DarkGray), ))); } let paragraph = Paragraph::new(Text::from(lines)) .block(block) - .alignment(Alignment::Center); + .alignment(Alignment::Left); frame.render_widget(paragraph, popup_area); } +/// Simple word wrap: splits text at word boundaries to fit max_width chars per line +fn textwrap(text: &str, max_width: usize) -> Vec { + let mut result = Vec::new(); + let mut current = String::new(); + for word in text.split_whitespace() { + if current.len() + word.len() + 1 > max_width && !current.is_empty() { + result.push(current.clone()); + current.clear(); + } + if !current.is_empty() { + current.push(' '); + } + current.push_str(word); + } + if !current.is_empty() { + result.push(current); + } + result +} + fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { let popup_layout = ratatui::layout::Layout::default() .direction(ratatui::layout::Direction::Vertical)