diff --git a/src-tauri/src/bin/codex_monitor_daemon.rs b/src-tauri/src/bin/codex_monitor_daemon.rs index a659483ba..bcb148ac3 100644 --- a/src-tauri/src/bin/codex_monitor_daemon.rs +++ b/src-tauri/src/bin/codex_monitor_daemon.rs @@ -23,6 +23,7 @@ mod utils; #[path = "../types.rs"] mod types; +use base64::Engine; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; use std::collections::HashMap; @@ -31,17 +32,23 @@ use std::fs::File; use std::io::Read; use std::net::SocketAddr; use std::path::PathBuf; +use std::process::Stdio; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; use ignore::WalkBuilder; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; use tokio::net::{TcpListener, TcpStream}; use tokio::process::Command; -use tokio::sync::{broadcast, mpsc, Mutex}; +use tokio::sync::{broadcast, mpsc, oneshot, Mutex}; +use tokio::time::timeout; use uuid::Uuid; use utils::{git_env_path, resolve_git_binary}; -use backend::app_server::{spawn_workspace_session, WorkspaceSession}; +use backend::app_server::{ + build_codex_command_with_bin, spawn_workspace_session, WorkspaceSession, +}; use backend::events::{AppServerEvent, EventSink, TerminalOutput}; use storage::{read_settings, read_workspaces, write_settings, write_workspaces}; use types::{ @@ -103,6 +110,7 @@ struct DaemonState { settings_path: PathBuf, app_settings: Mutex, event_sink: DaemonEventSink, + codex_login_cancels: Mutex>>, } #[derive(Serialize, Deserialize)] @@ -125,6 +133,7 @@ impl DaemonState { settings_path, app_settings: Mutex::new(app_settings), event_sink, + codex_login_cancels: Mutex::new(HashMap::new()), } } @@ -1302,6 +1311,173 @@ impl DaemonState { .await } + async fn account_read(&self, workspace_id: String) -> Result { + let response = match self.get_session(&workspace_id).await { + Ok(session) => session.send_request("account/read", Value::Null).await.ok(), + Err(_) => None, + }; + let codex_home = self.resolve_codex_home_for_workspace(&workspace_id).await.ok(); + let fallback = read_auth_account(codex_home); + Ok(build_account_response(response, fallback)) + } + + async fn codex_login(&self, workspace_id: String) -> Result { + let (entry, parent_entry, settings) = { + let workspaces = self.workspaces.lock().await; + let entry = workspaces + .get(&workspace_id) + .ok_or("workspace not found")? + .clone(); + let parent_entry = entry + .parent_id + .as_ref() + .and_then(|parent_id| workspaces.get(parent_id)) + .cloned(); + let settings = self.app_settings.lock().await.clone(); + (entry, parent_entry, settings) + }; + + let codex_bin = entry + .codex_bin + .clone() + .filter(|value| !value.trim().is_empty()) + .or(settings.codex_bin.clone()); + let codex_args = + codex_args::resolve_workspace_codex_args(&entry, parent_entry.as_ref(), Some(&settings)); + let codex_home = codex_home::resolve_workspace_codex_home(&entry, parent_entry.as_ref()) + .or_else(codex_home::resolve_default_codex_home); + + let mut command = build_codex_command_with_bin(codex_bin); + if let Some(ref codex_home) = codex_home { + command.env("CODEX_HOME", codex_home); + } + codex_args::apply_codex_args(&mut command, codex_args.as_deref())?; + command.arg("login"); + command.stdout(Stdio::piped()); + command.stderr(Stdio::piped()); + + let mut child = command.spawn().map_err(|error| error.to_string())?; + let (cancel_tx, cancel_rx) = oneshot::channel::<()>(); + { + let mut cancels = self.codex_login_cancels.lock().await; + if let Some(existing) = cancels.remove(&workspace_id) { + let _ = existing.send(()); + } + cancels.insert(workspace_id.clone(), cancel_tx); + } + let pid = child.id(); + let canceled = Arc::new(AtomicBool::new(false)); + let canceled_for_task = Arc::clone(&canceled); + let cancel_task = tokio::spawn(async move { + if cancel_rx.await.is_ok() { + canceled_for_task.store(true, Ordering::Relaxed); + if let Some(pid) = pid { + #[cfg(not(target_os = "windows"))] + unsafe { + libc::kill(pid as i32, libc::SIGKILL); + } + #[cfg(target_os = "windows")] + { + let _ = Command::new("taskkill") + .args(["/PID", &pid.to_string(), "/T", "/F"]) + .status() + .await; + } + } + } + }); + let stdout_pipe = child.stdout.take(); + let stderr_pipe = child.stderr.take(); + + let stdout_task = tokio::spawn(async move { + let mut buffer = Vec::new(); + if let Some(mut stdout) = stdout_pipe { + let _ = stdout.read_to_end(&mut buffer).await; + } + buffer + }); + let stderr_task = tokio::spawn(async move { + let mut buffer = Vec::new(); + if let Some(mut stderr) = stderr_pipe { + let _ = stderr.read_to_end(&mut buffer).await; + } + buffer + }); + + let status = match timeout(Duration::from_secs(120), child.wait()).await { + Ok(result) => result.map_err(|error| error.to_string())?, + Err(_) => { + let _ = child.kill().await; + let _ = child.wait().await; + cancel_task.abort(); + { + let mut cancels = self.codex_login_cancels.lock().await; + cancels.remove(&workspace_id); + } + return Err("Codex login timed out.".to_string()); + } + }; + + cancel_task.abort(); + { + let mut cancels = self.codex_login_cancels.lock().await; + cancels.remove(&workspace_id); + } + + if canceled.load(Ordering::Relaxed) { + return Err("Codex login canceled.".to_string()); + } + + let stdout_bytes = match stdout_task.await { + Ok(bytes) => bytes, + Err(_) => Vec::new(), + }; + let stderr_bytes = match stderr_task.await { + Ok(bytes) => bytes, + Err(_) => Vec::new(), + }; + + let stdout = String::from_utf8_lossy(&stdout_bytes); + let stderr = String::from_utf8_lossy(&stderr_bytes); + let detail = if stderr.trim().is_empty() { + stdout.trim() + } else { + stderr.trim() + }; + let combined = if stdout.trim().is_empty() { + stderr.trim().to_string() + } else if stderr.trim().is_empty() { + stdout.trim().to_string() + } else { + format!("{}\n{}", stdout.trim(), stderr.trim()) + }; + let limited = combined.chars().take(4000).collect::(); + + if !status.success() { + return Err(if detail.is_empty() { + "Codex login failed.".to_string() + } else { + format!("Codex login failed: {detail}") + }); + } + + Ok(json!({ "output": limited })) + } + + async fn codex_login_cancel(&self, workspace_id: String) -> Result { + let cancel_tx = { + let mut cancels = self.codex_login_cancels.lock().await; + cancels.remove(&workspace_id) + }; + let canceled = if let Some(tx) = cancel_tx { + let _ = tx.send(()); + true + } else { + false + }; + Ok(json!({ "canceled": canceled })) + } + async fn skills_list(&self, workspace_id: String) -> Result { let session = self.get_session(&workspace_id).await?; let params = json!({ @@ -1394,6 +1570,213 @@ fn normalize_git_path(path: &str) -> String { path.replace('\\', "/") } +struct AuthAccount { + email: Option, + plan_type: Option, +} + +fn build_account_response(response: Option, fallback: Option) -> Value { + let mut account = response + .as_ref() + .and_then(extract_account_map) + .unwrap_or_default(); + if let Some(fallback) = fallback { + let account_type = account + .get("type") + .and_then(|value| value.as_str()) + .map(|value| value.to_ascii_lowercase()); + let allow_fallback = account.is_empty() + || matches!(account_type.as_deref(), None | Some("chatgpt") | Some("unknown")); + if allow_fallback { + if !account.contains_key("email") { + if let Some(email) = fallback.email { + account.insert("email".to_string(), Value::String(email)); + } + } + if !account.contains_key("planType") { + if let Some(plan) = fallback.plan_type { + account.insert("planType".to_string(), Value::String(plan)); + } + } + if !account.contains_key("type") { + account.insert("type".to_string(), Value::String("chatgpt".to_string())); + } + } + } + + let account_value = if account.is_empty() { + Value::Null + } else { + Value::Object(account) + }; + let mut result = Map::new(); + result.insert("account".to_string(), account_value); + if let Some(requires_openai_auth) = response + .as_ref() + .and_then(extract_requires_openai_auth) + { + result.insert( + "requiresOpenaiAuth".to_string(), + Value::Bool(requires_openai_auth), + ); + } + Value::Object(result) +} + +fn extract_account_map(value: &Value) -> Option> { + let account = value + .get("account") + .or_else(|| value.get("result").and_then(|result| result.get("account"))) + .and_then(|value| value.as_object().cloned()); + if account.is_some() { + return account; + } + let root = value.as_object()?; + if root.contains_key("email") || root.contains_key("planType") || root.contains_key("type") { + return Some(root.clone()); + } + None +} + +fn extract_requires_openai_auth(value: &Value) -> Option { + value + .get("requiresOpenaiAuth") + .or_else(|| value.get("requires_openai_auth")) + .or_else(|| { + value + .get("result") + .and_then(|result| result.get("requiresOpenaiAuth")) + }) + .or_else(|| { + value + .get("result") + .and_then(|result| result.get("requires_openai_auth")) + }) + .and_then(|value| value.as_bool()) +} + +fn read_auth_account(codex_home: Option) -> Option { + let codex_home = codex_home?; + let auth_path = codex_home.join("auth.json"); + let data = std::fs::read(auth_path).ok()?; + let auth_value: Value = serde_json::from_slice(&data).ok()?; + let tokens = auth_value.get("tokens")?; + let id_token = tokens + .get("idToken") + .or_else(|| tokens.get("id_token")) + .and_then(|value| value.as_str())?; + let payload = decode_jwt_payload(id_token)?; + + let auth_dict = payload + .get("https://api.openai.com/auth") + .and_then(|value| value.as_object()); + let profile_dict = payload + .get("https://api.openai.com/profile") + .and_then(|value| value.as_object()); + let plan = normalize_string( + auth_dict + .and_then(|dict| dict.get("chatgpt_plan_type")) + .or_else(|| payload.get("chatgpt_plan_type")), + ); + let email = normalize_string( + payload + .get("email") + .or_else(|| profile_dict.and_then(|dict| dict.get("email"))), + ); + + if email.is_none() && plan.is_none() { + return None; + } + + Some(AuthAccount { + email, + plan_type: plan, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn fallback_account() -> AuthAccount { + AuthAccount { + email: Some("chatgpt@example.com".to_string()), + plan_type: Some("plus".to_string()), + } + } + + fn result_account_map(value: &Value) -> Map { + value + .get("account") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default() + } + + #[test] + fn build_account_response_does_not_fallback_for_apikey() { + let response = Some(json!({ + "account": { + "type": "apikey" + } + })); + let result = build_account_response(response, Some(fallback_account())); + let account = result_account_map(&result); + + assert_eq!(account.get("type").and_then(Value::as_str), Some("apikey")); + assert!(!account.contains_key("email")); + assert!(!account.contains_key("planType")); + } + + #[test] + fn build_account_response_falls_back_when_account_missing() { + let result = build_account_response(None, Some(fallback_account())); + let account = result_account_map(&result); + + assert_eq!( + account.get("email").and_then(Value::as_str), + Some("chatgpt@example.com"), + ); + assert_eq!(account.get("planType").and_then(Value::as_str), Some("plus")); + assert_eq!(account.get("type").and_then(Value::as_str), Some("chatgpt")); + } + + #[test] + fn build_account_response_allows_fallback_for_chatgpt_type() { + let response = Some(json!({ + "account": { + "type": "chatgpt" + } + })); + let result = build_account_response(response, Some(fallback_account())); + let account = result_account_map(&result); + + assert_eq!(account.get("type").and_then(Value::as_str), Some("chatgpt")); + assert_eq!( + account.get("email").and_then(Value::as_str), + Some("chatgpt@example.com"), + ); + assert_eq!(account.get("planType").and_then(Value::as_str), Some("plus")); + } +} + +fn decode_jwt_payload(token: &str) -> Option { + let payload = token.split('.').nth(1)?; + let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload.as_bytes()) + .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload.as_bytes())) + .ok()?; + serde_json::from_slice(&decoded).ok() +} + +fn normalize_string(value: Option<&Value>) -> Option { + value + .and_then(|value| value.as_str()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + fn list_workspace_files_inner(root: &PathBuf, max_files: usize) -> Vec { let mut results = Vec::new(); let walker = WalkBuilder::new(root) @@ -2158,6 +2541,18 @@ async fn handle_rpc_request( let workspace_id = parse_string(¶ms, "workspaceId")?; state.account_rate_limits(workspace_id).await } + "account_read" => { + let workspace_id = parse_string(¶ms, "workspaceId")?; + state.account_read(workspace_id).await + } + "codex_login" => { + let workspace_id = parse_string(¶ms, "workspaceId")?; + state.codex_login(workspace_id).await + } + "codex_login_cancel" => { + let workspace_id = parse_string(¶ms, "workspaceId")?; + state.codex_login_cancel(workspace_id).await + } "skills_list" => { let workspace_id = parse_string(¶ms, "workspaceId")?; state.skills_list(workspace_id).await diff --git a/src-tauri/src/codex.rs b/src-tauri/src/codex.rs index 6faebd0ca..d7c8614e6 100644 --- a/src-tauri/src/codex.rs +++ b/src-tauri/src/codex.rs @@ -1,12 +1,17 @@ +use base64::Engine; use serde_json::{json, Map, Value}; +use std::fs; use std::io::ErrorKind; use std::path::PathBuf; +use std::process::Stdio; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; use tauri::{AppHandle, State}; +use tokio::io::AsyncReadExt; use tokio::process::Command; -use tokio::sync::mpsc; +use tokio::sync::{mpsc, oneshot}; use tokio::time::timeout; pub(crate) use crate::backend::app_server::WorkspaceSession; @@ -14,7 +19,7 @@ use crate::backend::app_server::{ build_codex_command_with_bin, build_codex_path_env, check_codex_installation, spawn_workspace_session as spawn_workspace_session_inner, }; -use crate::codex_args::apply_codex_args; +use crate::codex_args::{apply_codex_args, resolve_workspace_codex_args}; use crate::codex_config; use crate::codex_home::{resolve_default_codex_home, resolve_workspace_codex_home}; use crate::event_sink::TauriEventSink; @@ -503,6 +508,238 @@ pub(crate) async fn account_rate_limits( .await } +#[tauri::command] +pub(crate) async fn account_read( + workspace_id: String, + state: State<'_, AppState>, + app: AppHandle, +) -> Result { + if remote_backend::is_remote_mode(&*state).await { + return remote_backend::call_remote( + &*state, + app, + "account_read", + json!({ "workspaceId": workspace_id }), + ) + .await; + } + + let session = { + let sessions = state.sessions.lock().await; + sessions.get(&workspace_id).cloned() + }; + let response = if let Some(session) = session { + session.send_request("account/read", Value::Null).await.ok() + } else { + None + }; + + let (entry, parent_entry) = { + let workspaces = state.workspaces.lock().await; + let entry = workspaces + .get(&workspace_id) + .ok_or("workspace not found")? + .clone(); + let parent_entry = entry + .parent_id + .as_ref() + .and_then(|parent_id| workspaces.get(parent_id)) + .cloned(); + (entry, parent_entry) + }; + let codex_home = resolve_workspace_codex_home(&entry, parent_entry.as_ref()) + .or_else(resolve_default_codex_home); + let fallback = read_auth_account(codex_home); + + Ok(build_account_response(response, fallback)) +} + +#[tauri::command] +pub(crate) async fn codex_login( + workspace_id: String, + state: State<'_, AppState>, + app: AppHandle, +) -> Result { + if remote_backend::is_remote_mode(&*state).await { + return remote_backend::call_remote( + &*state, + app, + "codex_login", + json!({ "workspaceId": workspace_id }), + ) + .await; + } + + let (entry, parent_entry, settings) = { + let workspaces = state.workspaces.lock().await; + let entry = workspaces + .get(&workspace_id) + .ok_or("workspace not found")? + .clone(); + let parent_entry = entry + .parent_id + .as_ref() + .and_then(|parent_id| workspaces.get(parent_id)) + .cloned(); + let settings = state.app_settings.lock().await.clone(); + (entry, parent_entry, settings) + }; + + let codex_bin = entry + .codex_bin + .clone() + .filter(|value| !value.trim().is_empty()) + .or(settings.codex_bin.clone()); + let codex_args = resolve_workspace_codex_args(&entry, parent_entry.as_ref(), Some(&settings)); + let codex_home = resolve_workspace_codex_home(&entry, parent_entry.as_ref()) + .or_else(resolve_default_codex_home); + + let mut command = build_codex_command_with_bin(codex_bin); + if let Some(ref codex_home) = codex_home { + command.env("CODEX_HOME", codex_home); + } + apply_codex_args(&mut command, codex_args.as_deref())?; + command.arg("login"); + command.stdout(Stdio::piped()); + command.stderr(Stdio::piped()); + + let mut child = command.spawn().map_err(|error| error.to_string())?; + let (cancel_tx, cancel_rx) = oneshot::channel::<()>(); + { + let mut cancels = state.codex_login_cancels.lock().await; + if let Some(existing) = cancels.remove(&workspace_id) { + let _ = existing.send(()); + } + cancels.insert(workspace_id.clone(), cancel_tx); + } + let pid = child.id(); + let canceled = Arc::new(AtomicBool::new(false)); + let canceled_for_task = Arc::clone(&canceled); + let cancel_task = tokio::spawn(async move { + if cancel_rx.await.is_ok() { + canceled_for_task.store(true, Ordering::Relaxed); + if let Some(pid) = pid { + #[cfg(not(target_os = "windows"))] + unsafe { + libc::kill(pid as i32, libc::SIGKILL); + } + #[cfg(target_os = "windows")] + { + let _ = Command::new("taskkill") + .args(["/PID", &pid.to_string(), "/T", "/F"]) + .status() + .await; + } + } + } + }); + let stdout_pipe = child.stdout.take(); + let stderr_pipe = child.stderr.take(); + + let stdout_task = tokio::spawn(async move { + let mut buffer = Vec::new(); + if let Some(mut stdout) = stdout_pipe { + let _ = stdout.read_to_end(&mut buffer).await; + } + buffer + }); + let stderr_task = tokio::spawn(async move { + let mut buffer = Vec::new(); + if let Some(mut stderr) = stderr_pipe { + let _ = stderr.read_to_end(&mut buffer).await; + } + buffer + }); + + let status = match timeout(Duration::from_secs(120), child.wait()).await { + Ok(result) => result.map_err(|error| error.to_string())?, + Err(_) => { + let _ = child.kill().await; + let _ = child.wait().await; + cancel_task.abort(); + { + let mut cancels = state.codex_login_cancels.lock().await; + cancels.remove(&workspace_id); + } + return Err("Codex login timed out.".to_string()); + } + }; + + cancel_task.abort(); + { + let mut cancels = state.codex_login_cancels.lock().await; + cancels.remove(&workspace_id); + } + + if canceled.load(Ordering::Relaxed) { + return Err("Codex login canceled.".to_string()); + } + + let stdout_bytes = match stdout_task.await { + Ok(bytes) => bytes, + Err(_) => Vec::new(), + }; + let stderr_bytes = match stderr_task.await { + Ok(bytes) => bytes, + Err(_) => Vec::new(), + }; + + let stdout = String::from_utf8_lossy(&stdout_bytes); + let stderr = String::from_utf8_lossy(&stderr_bytes); + let detail = if stderr.trim().is_empty() { + stdout.trim() + } else { + stderr.trim() + }; + let combined = if stdout.trim().is_empty() { + stderr.trim().to_string() + } else if stderr.trim().is_empty() { + stdout.trim().to_string() + } else { + format!("{}\n{}", stdout.trim(), stderr.trim()) + }; + let limited = combined.chars().take(4000).collect::(); + + if !status.success() { + return Err(if detail.is_empty() { + "Codex login failed.".to_string() + } else { + format!("Codex login failed: {detail}") + }); + } + + Ok(json!({ "output": limited })) +} + +#[tauri::command] +pub(crate) async fn codex_login_cancel( + workspace_id: String, + state: State<'_, AppState>, + app: AppHandle, +) -> Result { + if remote_backend::is_remote_mode(&*state).await { + return remote_backend::call_remote( + &*state, + app, + "codex_login_cancel", + json!({ "workspaceId": workspace_id }), + ) + .await; + } + + let cancel_tx = { + let mut cancels = state.codex_login_cancels.lock().await; + cancels.remove(&workspace_id) + }; + let canceled = if let Some(tx) = cancel_tx { + let _ = tx.send(()); + true + } else { + false + }; + Ok(json!({ "canceled": canceled })) +} + #[tauri::command] pub(crate) async fn skills_list( workspace_id: String, @@ -1048,3 +1285,210 @@ fn sanitize_run_worktree_name(value: &str) -> String { } format!("feat/{}", cleaned.trim_start_matches('/')) } + +struct AuthAccount { + email: Option, + plan_type: Option, +} + +fn build_account_response(response: Option, fallback: Option) -> Value { + let mut account = response + .as_ref() + .and_then(extract_account_map) + .unwrap_or_default(); + if let Some(fallback) = fallback { + let account_type = account + .get("type") + .and_then(|value| value.as_str()) + .map(|value| value.to_ascii_lowercase()); + let allow_fallback = account.is_empty() + || matches!(account_type.as_deref(), None | Some("chatgpt") | Some("unknown")); + if allow_fallback { + if !account.contains_key("email") { + if let Some(email) = fallback.email { + account.insert("email".to_string(), Value::String(email)); + } + } + if !account.contains_key("planType") { + if let Some(plan) = fallback.plan_type { + account.insert("planType".to_string(), Value::String(plan)); + } + } + if !account.contains_key("type") { + account.insert("type".to_string(), Value::String("chatgpt".to_string())); + } + } + } + + let account_value = if account.is_empty() { + Value::Null + } else { + Value::Object(account) + }; + let mut result = Map::new(); + result.insert("account".to_string(), account_value); + if let Some(requires_openai_auth) = response + .as_ref() + .and_then(extract_requires_openai_auth) + { + result.insert( + "requiresOpenaiAuth".to_string(), + Value::Bool(requires_openai_auth), + ); + } + Value::Object(result) +} + +fn extract_account_map(value: &Value) -> Option> { + let account = value + .get("account") + .or_else(|| value.get("result").and_then(|result| result.get("account"))) + .and_then(|value| value.as_object().cloned()); + if account.is_some() { + return account; + } + let root = value.as_object()?; + if root.contains_key("email") || root.contains_key("planType") || root.contains_key("type") { + return Some(root.clone()); + } + None +} + +fn extract_requires_openai_auth(value: &Value) -> Option { + value + .get("requiresOpenaiAuth") + .or_else(|| value.get("requires_openai_auth")) + .or_else(|| { + value + .get("result") + .and_then(|result| result.get("requiresOpenaiAuth")) + }) + .or_else(|| { + value + .get("result") + .and_then(|result| result.get("requires_openai_auth")) + }) + .and_then(|value| value.as_bool()) +} + +fn read_auth_account(codex_home: Option) -> Option { + let codex_home = codex_home?; + let auth_path = codex_home.join("auth.json"); + let data = fs::read(auth_path).ok()?; + let auth_value: Value = serde_json::from_slice(&data).ok()?; + let tokens = auth_value.get("tokens")?; + let id_token = tokens + .get("idToken") + .or_else(|| tokens.get("id_token")) + .and_then(|value| value.as_str())?; + let payload = decode_jwt_payload(id_token)?; + + let auth_dict = payload + .get("https://api.openai.com/auth") + .and_then(|value| value.as_object()); + let profile_dict = payload + .get("https://api.openai.com/profile") + .and_then(|value| value.as_object()); + let plan = normalize_string( + auth_dict + .and_then(|dict| dict.get("chatgpt_plan_type")) + .or_else(|| payload.get("chatgpt_plan_type")), + ); + let email = normalize_string( + payload + .get("email") + .or_else(|| profile_dict.and_then(|dict| dict.get("email"))), + ); + + if email.is_none() && plan.is_none() { + return None; + } + + Some(AuthAccount { + email, + plan_type: plan, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn fallback_account() -> AuthAccount { + AuthAccount { + email: Some("chatgpt@example.com".to_string()), + plan_type: Some("plus".to_string()), + } + } + + fn result_account_map(value: &Value) -> Map { + value + .get("account") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default() + } + + #[test] + fn build_account_response_does_not_fallback_for_apikey() { + let response = Some(json!({ + "account": { + "type": "apikey" + } + })); + let result = build_account_response(response, Some(fallback_account())); + let account = result_account_map(&result); + + assert_eq!(account.get("type").and_then(Value::as_str), Some("apikey")); + assert!(!account.contains_key("email")); + assert!(!account.contains_key("planType")); + } + + #[test] + fn build_account_response_falls_back_when_account_missing() { + let result = build_account_response(None, Some(fallback_account())); + let account = result_account_map(&result); + + assert_eq!( + account.get("email").and_then(Value::as_str), + Some("chatgpt@example.com"), + ); + assert_eq!(account.get("planType").and_then(Value::as_str), Some("plus")); + assert_eq!(account.get("type").and_then(Value::as_str), Some("chatgpt")); + } + + #[test] + fn build_account_response_allows_fallback_for_chatgpt_type() { + let response = Some(json!({ + "account": { + "type": "chatgpt" + } + })); + let result = build_account_response(response, Some(fallback_account())); + let account = result_account_map(&result); + + assert_eq!(account.get("type").and_then(Value::as_str), Some("chatgpt")); + assert_eq!( + account.get("email").and_then(Value::as_str), + Some("chatgpt@example.com"), + ); + assert_eq!(account.get("planType").and_then(Value::as_str), Some("plus")); + } +} + +fn decode_jwt_payload(token: &str) -> Option { + let payload = token.split('.').nth(1)?; + let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload.as_bytes()) + .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload.as_bytes())) + .ok()?; + serde_json::from_slice(&decoded).ok() +} + +fn normalize_string(value: Option<&Value>) -> Option { + value + .and_then(|value| value.as_str()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f7df80679..6c3dd1606 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -143,6 +143,9 @@ pub fn run() { git::create_git_branch, codex::model_list, codex::account_rate_limits, + codex::account_read, + codex::codex_login, + codex::codex_login_cancel, codex::skills_list, prompts::prompts_list, prompts::prompts_create, diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index ffad46019..86e481023 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use std::sync::Arc; use tauri::{AppHandle, Manager}; use tokio::sync::Mutex; +use tokio::sync::oneshot; use crate::dictation::DictationState; use crate::storage::{read_settings, read_workspaces}; @@ -18,6 +19,7 @@ pub(crate) struct AppState { pub(crate) settings_path: PathBuf, pub(crate) app_settings: Mutex, pub(crate) dictation: Mutex, + pub(crate) codex_login_cancels: Mutex>>, } impl AppState { @@ -39,6 +41,7 @@ impl AppState { settings_path, app_settings: Mutex::new(app_settings), dictation: Mutex::new(DictationState::default()), + codex_login_cancels: Mutex::new(HashMap::new()), } } } diff --git a/src/App.tsx b/src/App.tsx index 3ff7510b7..2ff6d50e2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -104,6 +104,7 @@ import type { import { OPEN_APP_STORAGE_KEY } from "./features/app/constants"; import { useOpenAppIcons } from "./features/app/hooks/useOpenAppIcons"; import { useCodeCssVars } from "./features/app/hooks/useCodeCssVars"; +import { useAccountSwitching } from "./features/app/hooks/useAccountSwitching"; const AboutView = lazy(() => import("./features/about/components/AboutView").then((module) => ({ @@ -596,6 +597,7 @@ function MainApp() { threadListCursorByWorkspace, tokenUsageByThread, rateLimitsByWorkspace, + accountByWorkspace, planByThread, lastAgentMessageByThread, interruptTurn, @@ -616,6 +618,8 @@ function MainApp() { handleApprovalDecision, handleApprovalRemember, handleUserInputSubmit, + refreshAccountInfo, + refreshAccountRateLimits, } = useThreads({ activeWorkspace, onWorkspaceConnected: markWorkspaceConnected, @@ -628,6 +632,18 @@ function MainApp() { customPrompts: prompts, onMessageActivity: queueGitStatusRefresh }); + const { + activeAccount, + accountSwitching, + handleSwitchAccount, + handleCancelSwitchAccount, + } = useAccountSwitching({ + activeWorkspaceId, + accountByWorkspace, + refreshAccountInfo, + refreshAccountRateLimits, + alertError, + }); const activeThreadIdRef = useRef(activeThreadId ?? null); const { getThreadRows } = useThreadRows(threadParentById); useEffect(() => { @@ -1532,6 +1548,10 @@ function MainApp() { activeItems, activeRateLimits, usageShowRemaining: appSettings.usageShowRemaining, + accountInfo: activeAccount, + onSwitchAccount: handleSwitchAccount, + onCancelSwitchAccount: handleCancelSwitchAccount, + accountSwitching, codeBlockCopyUseModifier: appSettings.composerCodeBlockCopyUseModifier, openAppTargets: appSettings.openAppTargets, openAppIconById, diff --git a/src/features/app/components/Sidebar.test.tsx b/src/features/app/components/Sidebar.test.tsx index 11ad59f46..08d9658c5 100644 --- a/src/features/app/components/Sidebar.test.tsx +++ b/src/features/app/components/Sidebar.test.tsx @@ -20,6 +20,10 @@ const baseProps = { activeThreadId: null, accountRateLimits: null, usageShowRemaining: false, + accountInfo: null, + onSwitchAccount: vi.fn(), + onCancelSwitchAccount: vi.fn(), + accountSwitching: false, onOpenSettings: vi.fn(), onOpenDebug: vi.fn(), showDebugButton: false, diff --git a/src/features/app/components/Sidebar.tsx b/src/features/app/components/Sidebar.tsx index 69eda490e..a0901847d 100644 --- a/src/features/app/components/Sidebar.tsx +++ b/src/features/app/components/Sidebar.tsx @@ -1,4 +1,9 @@ -import type { RateLimitSnapshot, ThreadSummary, WorkspaceInfo } from "../../../types"; +import type { + AccountSnapshot, + RateLimitSnapshot, + ThreadSummary, + WorkspaceInfo, +} from "../../../types"; import { createPortal } from "react-dom"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { RefObject } from "react"; @@ -48,6 +53,10 @@ type SidebarProps = { activeThreadId: string | null; accountRateLimits: RateLimitSnapshot | null; usageShowRemaining: boolean; + accountInfo: AccountSnapshot | null; + onSwitchAccount: () => void; + onCancelSwitchAccount: () => void; + accountSwitching: boolean; onOpenSettings: () => void; onOpenDebug: () => void; showDebugButton: boolean; @@ -95,6 +104,10 @@ export function Sidebar({ activeThreadId, accountRateLimits, usageShowRemaining, + accountInfo, + onSwitchAccount, + onCancelSwitchAccount, + accountSwitching, onOpenSettings, onOpenDebug, showDebugButton, @@ -207,6 +220,17 @@ export function Sidebar({ [normalizedQuery], ); + const accountEmail = accountInfo?.email?.trim() ?? ""; + const accountButtonLabel = accountEmail + ? accountEmail + : accountInfo?.type === "apikey" + ? "API key" + : "Sign in to Codex"; + const accountActionLabel = accountEmail ? "Switch account" : "Sign in"; + const showAccountSwitcher = Boolean(activeWorkspaceId); + const accountSwitchDisabled = accountSwitching || !activeWorkspaceId; + const accountCancelDisabled = !accountSwitching || !activeWorkspaceId; + const pinnedThreadRows = (() => { type ThreadRow = { thread: ThreadSummary; depth: number }; const groups: Array<{ @@ -614,6 +638,14 @@ export function Sidebar({ onOpenSettings={onOpenSettings} onOpenDebug={onOpenDebug} showDebugButton={showDebugButton} + showAccountSwitcher={showAccountSwitcher} + accountLabel={accountButtonLabel} + accountActionLabel={accountActionLabel} + accountDisabled={accountSwitchDisabled} + accountSwitching={accountSwitching} + accountCancelDisabled={accountCancelDisabled} + onSwitchAccount={onSwitchAccount} + onCancelSwitchAccount={onCancelSwitchAccount} /> ); diff --git a/src/features/app/components/SidebarCornerActions.tsx b/src/features/app/components/SidebarCornerActions.tsx index 9680604ba..f670c71d0 100644 --- a/src/features/app/components/SidebarCornerActions.tsx +++ b/src/features/app/components/SidebarCornerActions.tsx @@ -1,19 +1,111 @@ import ScrollText from "lucide-react/dist/esm/icons/scroll-text"; import Settings from "lucide-react/dist/esm/icons/settings"; +import User from "lucide-react/dist/esm/icons/user"; +import X from "lucide-react/dist/esm/icons/x"; +import { useEffect, useRef, useState } from "react"; type SidebarCornerActionsProps = { onOpenSettings: () => void; onOpenDebug: () => void; showDebugButton: boolean; + showAccountSwitcher: boolean; + accountLabel: string; + accountActionLabel: string; + accountDisabled: boolean; + accountSwitching: boolean; + accountCancelDisabled: boolean; + onSwitchAccount: () => void; + onCancelSwitchAccount: () => void; }; export function SidebarCornerActions({ onOpenSettings, onOpenDebug, showDebugButton, + showAccountSwitcher, + accountLabel, + accountActionLabel, + accountDisabled, + accountSwitching, + accountCancelDisabled, + onSwitchAccount, + onCancelSwitchAccount, }: SidebarCornerActionsProps) { + const [accountMenuOpen, setAccountMenuOpen] = useState(false); + const accountMenuRef = useRef(null); + + useEffect(() => { + if (!accountMenuOpen) { + return; + } + const handleClick = (event: MouseEvent) => { + const target = event.target as Node; + if (accountMenuRef.current?.contains(target)) { + return; + } + setAccountMenuOpen(false); + }; + window.addEventListener("mousedown", handleClick); + return () => { + window.removeEventListener("mousedown", handleClick); + }; + }, [accountMenuOpen]); + + useEffect(() => { + if (!showAccountSwitcher) { + setAccountMenuOpen(false); + } + }, [showAccountSwitcher]); + return (
+ {showAccountSwitcher && ( +
+ + {accountMenuOpen && ( +
+
Account
+
{accountLabel}
+
+ + {accountSwitching && ( + + )} +
+
+ )} +
+ )}