diff --git a/package.json b/package.json index 42144b204..f82ee3b93 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "proxycast", "private": true, - "version": "0.14.10", + "version": "0.15.0", "type": "module", "repository": { "type": "git", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e2c3e4c2a..41cd5cc60 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3367,7 +3367,7 @@ dependencies = [ [[package]] name = "proxycast" -version = "0.14.10" +version = "0.15.0" dependencies = [ "anyhow", "async-stream", @@ -3382,6 +3382,7 @@ dependencies = [ "indexmap 2.12.1", "md5", "notify", + "once_cell", "open", "parking_lot", "proptest", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 89c18974f..75ba45cec 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "proxycast" -version = "0.14.10" +version = "0.15.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" @@ -55,6 +55,7 @@ sha2 = "0.10" serde_urlencoded = "0.7" open = "5" url = "2" +once_cell = "1" [dev-dependencies] proptest = "1" diff --git a/src-tauri/src/commands/provider_pool_cmd.rs b/src-tauri/src/commands/provider_pool_cmd.rs index e28e6a1c4..dc86cf4aa 100644 --- a/src-tauri/src/commands/provider_pool_cmd.rs +++ b/src-tauri/src/commands/provider_pool_cmd.rs @@ -1250,6 +1250,414 @@ pub async fn start_antigravity_oauth_login( Ok(credential) } +/// Codex OAuth 授权 URL 响应 +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct CodexAuthUrlResponse { + pub auth_url: String, +} + +/// 获取 Codex OAuth 授权 URL 并等待回调(不自动打开浏览器) +/// +/// 启动服务器后通过事件发送授权 URL,然后等待回调 +/// 成功后返回凭证 +#[tauri::command] +pub async fn get_codex_auth_url_and_wait( + app: tauri::AppHandle, + db: State<'_, DbConnection>, + pool_service: State<'_, ProviderPoolServiceState>, + name: Option, +) -> Result { + use crate::providers::codex; + + tracing::info!("[Codex OAuth] 启动服务器并获取授权 URL"); + + // 启动服务器并获取授权 URL + let (auth_url, wait_future) = codex::start_codex_oauth_server_and_get_url() + .await + .map_err(|e| format!("启动 OAuth 服务器失败: {}", e))?; + + tracing::info!("[Codex OAuth] 授权 URL: {}", auth_url); + + // 通过事件发送授权 URL 给前端 + let _ = app.emit( + "codex-auth-url", + CodexAuthUrlResponse { + auth_url: auth_url.clone(), + }, + ); + + // 等待回调 + let result = wait_future.await.map_err(|e| e.to_string())?; + + tracing::info!( + "[Codex OAuth] 登录成功,凭证保存到: {}", + result.creds_file_path + ); + + // 添加到凭证池 + let credential = pool_service.0.add_credential( + &db, + "codex", + CredentialData::CodexOAuth { + creds_file_path: result.creds_file_path, + }, + name, + Some(true), + None, + )?; + + tracing::info!("[Codex OAuth] 凭证已添加到凭证池: {}", credential.uuid); + + Ok(credential) +} + +/// 启动 Codex OAuth 登录流程 +/// +/// 打开浏览器让用户登录 OpenAI 账号,获取 Codex 凭证 +#[tauri::command] +pub async fn start_codex_oauth_login( + db: State<'_, DbConnection>, + pool_service: State<'_, ProviderPoolServiceState>, + name: Option, +) -> Result { + use crate::providers::codex; + + tracing::info!("[Codex OAuth] 开始 OAuth 登录流程"); + + // 启动 OAuth 登录 + let result = codex::start_codex_oauth_login() + .await + .map_err(|e| format!("Codex OAuth 登录失败: {}", e))?; + + tracing::info!( + "[Codex OAuth] 登录成功,凭证保存到: {}", + result.creds_file_path + ); + + // 添加到凭证池 + let credential = pool_service.0.add_credential( + &db, + "codex", + CredentialData::CodexOAuth { + creds_file_path: result.creds_file_path, + }, + name, + Some(true), + None, + )?; + + tracing::info!("[Codex OAuth] 凭证已添加到凭证池: {}", credential.uuid); + + Ok(credential) +} + +/// Claude OAuth 授权 URL 响应 +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ClaudeOAuthAuthUrlResponse { + pub auth_url: String, +} + +/// 获取 Claude OAuth 授权 URL 并等待回调(不自动打开浏览器) +/// +/// 启动服务器后通过事件发送授权 URL,然后等待回调 +/// 成功后返回凭证 +#[tauri::command] +pub async fn get_claude_oauth_auth_url_and_wait( + app: tauri::AppHandle, + db: State<'_, DbConnection>, + pool_service: State<'_, ProviderPoolServiceState>, + name: Option, +) -> Result { + use crate::providers::claude_oauth; + + tracing::info!("[Claude OAuth] 启动服务器并获取授权 URL"); + + // 启动服务器并获取授权 URL + let (auth_url, wait_future) = claude_oauth::start_claude_oauth_server_and_get_url() + .await + .map_err(|e| format!("启动 OAuth 服务器失败: {}", e))?; + + tracing::info!("[Claude OAuth] 授权 URL: {}", auth_url); + + // 通过事件发送授权 URL 给前端 + let _ = app.emit( + "claude-oauth-auth-url", + ClaudeOAuthAuthUrlResponse { + auth_url: auth_url.clone(), + }, + ); + + // 等待回调 + let result = wait_future.await.map_err(|e| e.to_string())?; + + tracing::info!( + "[Claude OAuth] 登录成功,凭证保存到: {}", + result.creds_file_path + ); + + // 添加到凭证池 + let credential = pool_service.0.add_credential( + &db, + "claude_oauth", + CredentialData::ClaudeOAuth { + creds_file_path: result.creds_file_path, + }, + name, + Some(true), + None, + )?; + + tracing::info!("[Claude OAuth] 凭证已添加到凭证池: {}", credential.uuid); + + Ok(credential) +} + +/// 启动 Claude OAuth 登录流程 +/// +/// 打开浏览器让用户登录 Claude 账号,获取凭证 +#[tauri::command] +pub async fn start_claude_oauth_login( + db: State<'_, DbConnection>, + pool_service: State<'_, ProviderPoolServiceState>, + name: Option, +) -> Result { + use crate::providers::claude_oauth; + + tracing::info!("[Claude OAuth] 开始 OAuth 登录流程"); + + // 启动 OAuth 登录 + let result = claude_oauth::start_claude_oauth_login() + .await + .map_err(|e| format!("Claude OAuth 登录失败: {}", e))?; + + tracing::info!( + "[Claude OAuth] 登录成功,凭证保存到: {}", + result.creds_file_path + ); + + // 添加到凭证池 + let credential = pool_service.0.add_credential( + &db, + "claude_oauth", + CredentialData::ClaudeOAuth { + creds_file_path: result.creds_file_path, + }, + name, + Some(true), + None, + )?; + + tracing::info!("[Claude OAuth] 凭证已添加到凭证池: {}", credential.uuid); + + Ok(credential) +} + +/// Qwen Device Code 响应 +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct QwenDeviceCodeResponse { + pub user_code: String, + pub verification_uri: String, + pub verification_uri_complete: Option, + pub expires_in: i64, +} + +/// 获取 Qwen Device Code 并等待用户授权 +/// +/// 启动 Device Code Flow 后通过事件发送设备码信息,然后轮询等待授权 +/// 成功后返回凭证 +#[tauri::command] +pub async fn get_qwen_device_code_and_wait( + app: tauri::AppHandle, + db: State<'_, DbConnection>, + pool_service: State<'_, ProviderPoolServiceState>, + name: Option, +) -> Result { + use crate::providers::qwen; + + tracing::info!("[Qwen] 启动 Device Code Flow"); + + // 启动 Device Code Flow 并获取设备码信息 + let (device_response, wait_future) = qwen::start_qwen_device_code_and_get_info() + .await + .map_err(|e| format!("启动 Device Code Flow 失败: {}", e))?; + + tracing::info!( + "[Qwen] Device Code: user_code={}, verification_uri={}", + device_response.user_code, + device_response.verification_uri + ); + + // 通过事件发送设备码信息给前端 + let _ = app.emit( + "qwen-device-code", + QwenDeviceCodeResponse { + user_code: device_response.user_code.clone(), + verification_uri: device_response.verification_uri.clone(), + verification_uri_complete: device_response.verification_uri_complete.clone(), + expires_in: device_response.expires_in, + }, + ); + + // 等待用户授权 + let result = wait_future.await.map_err(|e| e.to_string())?; + + tracing::info!("[Qwen] 登录成功,凭证保存到: {}", result.creds_file_path); + + // 添加到凭证池 + let credential = pool_service.0.add_credential( + &db, + "qwen", + CredentialData::QwenOAuth { + creds_file_path: result.creds_file_path, + }, + name, + Some(true), + None, + )?; + + tracing::info!("[Qwen] 凭证已添加到凭证池: {}", credential.uuid); + + Ok(credential) +} + +/// 启动 Qwen Device Code Flow 登录流程 +/// +/// 自动打开浏览器让用户完成授权 +#[tauri::command] +pub async fn start_qwen_device_code_login( + db: State<'_, DbConnection>, + pool_service: State<'_, ProviderPoolServiceState>, + name: Option, +) -> Result { + use crate::providers::qwen; + + tracing::info!("[Qwen] 开始 Device Code Flow 登录流程"); + + // 启动 Device Code Flow 登录 + let result = qwen::start_qwen_device_code_login() + .await + .map_err(|e| format!("Qwen Device Code Flow 登录失败: {}", e))?; + + tracing::info!("[Qwen] 登录成功,凭证保存到: {}", result.creds_file_path); + + // 添加到凭证池 + let credential = pool_service.0.add_credential( + &db, + "qwen", + CredentialData::QwenOAuth { + creds_file_path: result.creds_file_path, + }, + name, + Some(true), + None, + )?; + + tracing::info!("[Qwen] 凭证已添加到凭证池: {}", credential.uuid); + + Ok(credential) +} + +/// iFlow OAuth 授权 URL 响应 +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct IFlowAuthUrlResponse { + pub auth_url: String, +} + +/// 获取 iFlow OAuth 授权 URL 并等待回调(不自动打开浏览器) +/// +/// 启动服务器后通过事件发送授权 URL,然后等待回调 +/// 成功后返回凭证 +#[tauri::command] +pub async fn get_iflow_auth_url_and_wait( + app: tauri::AppHandle, + db: State<'_, DbConnection>, + pool_service: State<'_, ProviderPoolServiceState>, + name: Option, +) -> Result { + use crate::providers::iflow; + + tracing::info!("[iFlow OAuth] 启动服务器并获取授权 URL"); + + // 启动服务器并获取授权 URL + let (auth_url, wait_future) = iflow::start_iflow_oauth_server_and_get_url() + .await + .map_err(|e| format!("启动 OAuth 服务器失败: {}", e))?; + + tracing::info!("[iFlow OAuth] 授权 URL: {}", auth_url); + + // 通过事件发送授权 URL 给前端 + let _ = app.emit( + "iflow-auth-url", + IFlowAuthUrlResponse { + auth_url: auth_url.clone(), + }, + ); + + // 等待回调 + let result = wait_future.await.map_err(|e| e.to_string())?; + + tracing::info!( + "[iFlow OAuth] 登录成功,凭证保存到: {}", + result.creds_file_path + ); + + // 添加到凭证池 + let credential = pool_service.0.add_credential( + &db, + "iflow", + CredentialData::IFlowOAuth { + creds_file_path: result.creds_file_path, + }, + name, + Some(true), + None, + )?; + + tracing::info!("[iFlow OAuth] 凭证已添加到凭证池: {}", credential.uuid); + + Ok(credential) +} + +/// 启动 iFlow OAuth 登录流程 +/// +/// 打开浏览器让用户登录 iFlow 账号,获取凭证 +#[tauri::command] +pub async fn start_iflow_oauth_login( + db: State<'_, DbConnection>, + pool_service: State<'_, ProviderPoolServiceState>, + name: Option, +) -> Result { + use crate::providers::iflow; + + tracing::info!("[iFlow OAuth] 开始 OAuth 登录流程"); + + // 启动 OAuth 登录 + let result = iflow::start_iflow_oauth_login() + .await + .map_err(|e| format!("iFlow OAuth 登录失败: {}", e))?; + + tracing::info!( + "[iFlow OAuth] 登录成功,凭证保存到: {}", + result.creds_file_path + ); + + // 添加到凭证池 + let credential = pool_service.0.add_credential( + &db, + "iflow", + CredentialData::IFlowOAuth { + creds_file_path: result.creds_file_path, + }, + name, + Some(true), + None, + )?; + + tracing::info!("[iFlow OAuth] 凭证已添加到凭证池: {}", credential.uuid); + + Ok(credential) +} + /// 获取 Kiro 凭证的 Machine ID 指纹信息 /// /// 返回凭证的唯一设备指纹,用于在 UI 中展示 @@ -1329,3 +1737,170 @@ pub async fn get_kiro_credential_fingerprint( auth_method, }) } + +/// Gemini OAuth 授权 URL 响应 +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct GeminiAuthUrlResponse { + pub auth_url: String, + pub session_id: String, +} + +use once_cell::sync::Lazy; +/// Gemini OAuth 会话存储(用于存储 code_verifier) +use std::collections::HashMap; +use tokio::sync::RwLock; + +static GEMINI_OAUTH_SESSIONS: Lazy< + RwLock>, +> = Lazy::new(|| RwLock::new(HashMap::new())); + +/// 获取 Gemini OAuth 授权 URL(不等待回调) +/// +/// 生成授权 URL 和 session_id,通过事件发送给前端 +/// 用户需要手动复制授权码回来,然后调用 exchange_gemini_code +#[tauri::command] +pub async fn get_gemini_auth_url_and_wait( + app: tauri::AppHandle, + _db: State<'_, DbConnection>, + _pool_service: State<'_, ProviderPoolServiceState>, + _name: Option, +) -> Result { + use crate::providers::gemini; + + tracing::info!("[Gemini OAuth] 生成授权 URL"); + + // 生成授权 URL 和会话信息 + let (auth_url, session) = gemini::generate_gemini_auth_url_with_session(); + let session_id = session.session_id.clone(); + + tracing::info!("[Gemini OAuth] 授权 URL: {}", auth_url); + tracing::info!("[Gemini OAuth] Session ID: {}", session_id); + + // 存储会话信息(用于后续交换 token) + { + let mut sessions = GEMINI_OAUTH_SESSIONS.write().await; + sessions.insert(session_id.clone(), session); + + // 清理过期的会话(超过 10 分钟) + let now = chrono::Utc::now().timestamp(); + sessions.retain(|_, s| now - s.created_at < 600); + } + + // 通过事件发送授权 URL 给前端 + let _ = app.emit( + "gemini-auth-url", + GeminiAuthUrlResponse { + auth_url: auth_url.clone(), + session_id: session_id.clone(), + }, + ); + + // 返回错误,让前端知道需要用户手动输入授权码 + // 这不是真正的错误,只是流程需要用户交互 + Err(format!("AUTH_URL:{}", auth_url)) +} + +/// 用 Gemini 授权码交换 Token 并添加凭证 +#[tauri::command] +pub async fn exchange_gemini_code( + db: State<'_, DbConnection>, + pool_service: State<'_, ProviderPoolServiceState>, + code: String, + session_id: Option, + name: Option, +) -> Result { + use crate::providers::gemini; + + tracing::info!("[Gemini OAuth] 开始交换授权码"); + + // 获取 code_verifier + let code_verifier = if let Some(ref sid) = session_id { + let sessions = GEMINI_OAUTH_SESSIONS.read().await; + sessions + .get(sid) + .map(|s| s.code_verifier.clone()) + .ok_or_else(|| "会话已过期,请重新获取授权 URL".to_string())? + } else { + // 如果没有 session_id,尝试使用最近的会话 + let sessions = GEMINI_OAUTH_SESSIONS.read().await; + sessions + .values() + .max_by_key(|s| s.created_at) + .map(|s| s.code_verifier.clone()) + .ok_or_else(|| "没有可用的会话,请先获取授权 URL".to_string())? + }; + + // 交换 token 并创建凭证 + let result = gemini::exchange_gemini_code_and_create_credentials(&code, &code_verifier) + .await + .map_err(|e| format!("交换授权码失败: {}", e))?; + + tracing::info!( + "[Gemini OAuth] 登录成功,凭证保存到: {}", + result.creds_file_path + ); + + // 清理使用过的会话 + if let Some(ref sid) = session_id { + let mut sessions = GEMINI_OAUTH_SESSIONS.write().await; + sessions.remove(sid); + } + + // 添加到凭证池 + let credential = pool_service.0.add_credential( + &db, + "gemini", + CredentialData::GeminiOAuth { + creds_file_path: result.creds_file_path, + project_id: None, // 项目 ID 会在健康检查时自动获取 + }, + name, + Some(true), + None, + )?; + + tracing::info!("[Gemini OAuth] 凭证已添加到凭证池: {}", credential.uuid); + + Ok(credential) +} + +/// 启动 Gemini OAuth 登录流程 +/// +/// 打开浏览器让用户登录 Google 账号,获取 Gemini 凭证 +#[tauri::command] +pub async fn start_gemini_oauth_login( + db: State<'_, DbConnection>, + pool_service: State<'_, ProviderPoolServiceState>, + name: Option, +) -> Result { + use crate::providers::gemini; + + tracing::info!("[Gemini OAuth] 开始 OAuth 登录流程"); + + // 启动 OAuth 登录 + let result = gemini::start_gemini_oauth_login() + .await + .map_err(|e| format!("Gemini OAuth 登录失败: {}", e))?; + + tracing::info!( + "[Gemini OAuth] 登录成功,凭证保存到: {}", + result.creds_file_path + ); + + // 添加到凭证池 + let credential = pool_service.0.add_credential( + &db, + "gemini", + CredentialData::GeminiOAuth { + creds_file_path: result.creds_file_path, + project_id: None, + }, + name, + Some(true), + None, + )?; + + tracing::info!("[Gemini OAuth] 凭证已添加到凭证池: {}", credential.uuid); + + Ok(credential) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 115ee6cbe..b8047f2f6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1683,6 +1683,17 @@ pub fn run() { commands::provider_pool_cmd::migrate_private_config_to_pool, commands::provider_pool_cmd::start_antigravity_oauth_login, commands::provider_pool_cmd::get_antigravity_auth_url_and_wait, + commands::provider_pool_cmd::get_codex_auth_url_and_wait, + commands::provider_pool_cmd::start_codex_oauth_login, + commands::provider_pool_cmd::get_claude_oauth_auth_url_and_wait, + commands::provider_pool_cmd::start_claude_oauth_login, + commands::provider_pool_cmd::get_qwen_device_code_and_wait, + commands::provider_pool_cmd::start_qwen_device_code_login, + commands::provider_pool_cmd::get_iflow_auth_url_and_wait, + commands::provider_pool_cmd::start_iflow_oauth_login, + commands::provider_pool_cmd::get_gemini_auth_url_and_wait, + commands::provider_pool_cmd::start_gemini_oauth_login, + commands::provider_pool_cmd::exchange_gemini_code, commands::provider_pool_cmd::get_kiro_credential_fingerprint, // Route commands commands::route_cmd::get_available_routes, diff --git a/src-tauri/src/providers/claude_oauth.rs b/src-tauri/src/providers/claude_oauth.rs index 8ce364f55..b00434bce 100644 --- a/src-tauri/src/providers/claude_oauth.rs +++ b/src-tauri/src/providers/claude_oauth.rs @@ -347,3 +347,366 @@ impl ClaudeOAuthProvider { format!("http://localhost:{}/callback", self.callback_port) } } + +// ============================================================================ +// OAuth 登录功能 +// ============================================================================ + +use std::sync::Arc; +use tokio::sync::oneshot; +use uuid::Uuid; + +/// OAuth 登录成功后的凭证信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClaudeOAuthResult { + pub credentials: ClaudeOAuthCredentials, + pub creds_file_path: String, +} + +/// 生成 Claude OAuth 授权 URL +pub fn generate_claude_auth_url(port: u16, state: &str, code_challenge: &str) -> String { + let redirect_uri = format!("http://localhost:{}/oauth-callback", port); + + let params = [ + ("client_id", CLAUDE_CLIENT_ID), + ("response_type", "code"), + ("redirect_uri", redirect_uri.as_str()), + ("scope", "user:inference user:profile"), + ("state", state), + ("code_challenge", code_challenge), + ("code_challenge_method", "S256"), + ]; + + let query = params + .iter() + .map(|(k, v)| format!("{}={}", k, urlencoding::encode(v))) + .collect::>() + .join("&"); + + format!("{}?{}", CLAUDE_AUTH_URL, query) +} + +/// 用授权码交换 Token +pub async fn exchange_claude_code_for_token( + client: &Client, + code: &str, + code_verifier: &str, + redirect_uri: &str, +) -> Result> { + let body = serde_json::json!({ + "grant_type": "authorization_code", + "client_id": CLAUDE_CLIENT_ID, + "code": code, + "redirect_uri": redirect_uri, + "code_verifier": code_verifier + }); + + let resp = client + .post(CLAUDE_TOKEN_URL) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .json(&body) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!("Token 交换失败: {} - {}", status, body).into()); + } + + let data: serde_json::Value = resp.json().await?; + Ok(data) +} + +/// OAuth 成功页面 HTML +const CLAUDE_OAUTH_SUCCESS_HTML: &str = r#" + + + + 授权成功 + + + +
+

✓ 授权成功

+

Claude 账号已添加到 ProxyCast

+ +

可以关闭此页面

+
+ +"#; + +/// OAuth 失败页面 HTML +const CLAUDE_OAUTH_ERROR_HTML: &str = r#" + + + + 授权失败 + + + +
+

✗ 授权失败

+

ERROR_PLACEHOLDER

+

请关闭此页面后重试

+
+ +"#; + +/// 启动 OAuth 服务器并返回授权 URL(不打开浏览器) +pub async fn start_claude_oauth_server_and_get_url() -> Result< + ( + String, + impl std::future::Future>>, + ), + Box, +> { + use axum::{extract::Query, response::Html, routing::get, Router}; + use std::collections::HashMap; + use tokio::net::TcpListener; + + let client = Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build()?; + + // 生成 PKCE codes + let pkce_codes = PKCECodes::generate()?; + let code_verifier = pkce_codes.code_verifier.clone(); + let code_challenge = pkce_codes.code_challenge.clone(); + + // 生成随机 state + let state = Uuid::new_v4().to_string(); + let state_clone = state.clone(); + + // 创建 channel 用于接收回调结果 + let (tx, rx) = oneshot::channel::>(); + let tx = Arc::new(tokio::sync::Mutex::new(Some(tx))); + + // 绑定到随机端口 + let listener = TcpListener::bind("127.0.0.1:0").await?; + let port = listener.local_addr()?.port(); + + let redirect_uri = format!("http://localhost:{}/oauth-callback", port); + let redirect_uri_clone = redirect_uri.clone(); + + // 生成授权 URL + let auth_url = generate_claude_auth_url(port, &state, &code_challenge); + + tracing::info!( + "[Claude OAuth] 服务器启动在端口 {}, 授权 URL: {}", + port, + auth_url + ); + + // 构建路由 + let app = Router::new().route( + "/oauth-callback", + get(move |Query(params): Query>| { + let tx = tx.clone(); + let client = client.clone(); + let state_expected = state_clone.clone(); + let redirect_uri = redirect_uri_clone.clone(); + let code_verifier = code_verifier.clone(); + + async move { + let code = params.get("code"); + let returned_state = params.get("state"); + let error = params.get("error"); + + // 检查错误 + if let Some(err) = error { + let html = CLAUDE_OAUTH_ERROR_HTML.replace("ERROR_PLACEHOLDER", err); + if let Some(sender) = tx.lock().await.take() { + let _ = sender.send(Err(format!("OAuth 错误: {}", err))); + } + return Html(html); + } + + // 检查 state + if returned_state.map(|s| s.as_str()) != Some(&state_expected) { + let html = + CLAUDE_OAUTH_ERROR_HTML.replace("ERROR_PLACEHOLDER", "State 验证失败"); + if let Some(sender) = tx.lock().await.take() { + let _ = sender.send(Err("State 验证失败".to_string())); + } + return Html(html); + } + + // 检查 code + let code = match code { + Some(c) => c, + None => { + let html = + CLAUDE_OAUTH_ERROR_HTML.replace("ERROR_PLACEHOLDER", "未收到授权码"); + if let Some(sender) = tx.lock().await.take() { + let _ = sender.send(Err("未收到授权码".to_string())); + } + return Html(html); + } + }; + + // 交换 Token + let token_result = + exchange_claude_code_for_token(&client, code, &code_verifier, &redirect_uri) + .await; + let token_data = match token_result { + Ok(data) => data, + Err(e) => { + let html = + CLAUDE_OAUTH_ERROR_HTML.replace("ERROR_PLACEHOLDER", &e.to_string()); + if let Some(sender) = tx.lock().await.take() { + let _ = sender.send(Err(e.to_string())); + } + return Html(html); + } + }; + + let access_token = token_data["access_token"].as_str().unwrap_or_default(); + let refresh_token = token_data["refresh_token"].as_str().map(|s| s.to_string()); + let expires_in = token_data["expires_in"].as_i64(); + + // 从响应中提取用户邮箱 + let email = token_data["account"]["email_address"] + .as_str() + .map(|s| s.to_string()); + + // 构建凭证 + let now = chrono::Utc::now(); + let credentials = ClaudeOAuthCredentials { + access_token: Some(access_token.to_string()), + refresh_token, + email: email.clone(), + expire: expires_in.map(|e| (now + chrono::Duration::seconds(e)).to_rfc3339()), + last_refresh: Some(now.to_rfc3339()), + cred_type: "claude_oauth".to_string(), + }; + + // 保存凭证到应用数据目录 + let creds_dir = dirs::data_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join("proxycast") + .join("credentials") + .join("claude_oauth"); + + if let Err(e) = std::fs::create_dir_all(&creds_dir) { + let html = CLAUDE_OAUTH_ERROR_HTML + .replace("ERROR_PLACEHOLDER", &format!("创建目录失败: {}", e)); + if let Some(sender) = tx.lock().await.take() { + let _ = sender.send(Err(format!("创建目录失败: {}", e))); + } + return Html(html); + } + + // 生成唯一文件名 + let uuid = Uuid::new_v4().to_string(); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let filename = format!("claude_oauth_{}_{}.json", &uuid[..8], timestamp); + let creds_file_path = creds_dir.join(&filename); + + // 保存凭证 + let creds_json = match serde_json::to_string_pretty(&credentials) { + Ok(json) => json, + Err(e) => { + let html = CLAUDE_OAUTH_ERROR_HTML + .replace("ERROR_PLACEHOLDER", &format!("序列化凭证失败: {}", e)); + if let Some(sender) = tx.lock().await.take() { + let _ = sender.send(Err(format!("序列化凭证失败: {}", e))); + } + return Html(html); + } + }; + + if let Err(e) = std::fs::write(&creds_file_path, &creds_json) { + let html = CLAUDE_OAUTH_ERROR_HTML + .replace("ERROR_PLACEHOLDER", &format!("保存凭证失败: {}", e)); + if let Some(sender) = tx.lock().await.take() { + let _ = sender.send(Err(format!("保存凭证失败: {}", e))); + } + return Html(html); + } + + tracing::info!("[Claude OAuth] 凭证已保存到: {:?}", creds_file_path); + + // 发送成功结果 + let result = ClaudeOAuthResult { + credentials, + creds_file_path: creds_file_path.to_string_lossy().to_string(), + }; + + if let Some(sender) = tx.lock().await.take() { + let _ = sender.send(Ok(result)); + } + + // 返回成功页面 + let html = CLAUDE_OAUTH_SUCCESS_HTML.replace( + "EMAIL_PLACEHOLDER", + &email.unwrap_or_else(|| "未知邮箱".to_string()), + ); + Html(html) + } + }), + ); + + // 启动服务器 + let server = axum::serve(listener, app); + + // 创建等待 future + let wait_future = async move { + // 设置超时(5 分钟) + let timeout = tokio::time::timeout(std::time::Duration::from_secs(300), async { + // 启动服务器(在后台运行) + tokio::spawn(async move { + if let Err(e) = server.await { + tracing::error!("[Claude OAuth] 服务器错误: {}", e); + } + }); + + // 等待回调结果 + match rx.await { + Ok(result) => result.map_err(|e| { + Box::new(std::io::Error::new(std::io::ErrorKind::Other, e)) + as Box + }), + Err(_) => Err("OAuth 回调通道关闭".into()), + } + }); + + match timeout.await { + Ok(result) => result, + Err(_) => Err("OAuth 登录超时(5分钟)".into()), + } + }; + + Ok((auth_url, wait_future)) +} + +/// 启动 Claude OAuth 登录流程(自动打开浏览器) +pub async fn start_claude_oauth_login() -> Result> { + let (auth_url, wait_future) = start_claude_oauth_server_and_get_url().await?; + + tracing::info!("[Claude OAuth] 打开浏览器进行授权: {}", auth_url); + + // 打开浏览器 + if let Err(e) = open::that(&auth_url) { + tracing::warn!("[Claude OAuth] 无法打开浏览器: {}. 请手动打开 URL.", e); + } + + // 等待回调 + wait_future.await +} diff --git a/src-tauri/src/providers/codex.rs b/src-tauri/src/providers/codex.rs index 9873d0bfb..b4c6baf85 100644 --- a/src-tauri/src/providers/codex.rs +++ b/src-tauri/src/providers/codex.rs @@ -1441,3 +1441,402 @@ mod tests { assert_eq!(result["top_p"], 0.9); } } + +// ============================================================================ +// OAuth 登录功能(参考 Antigravity 实现) +// ============================================================================ + +use std::sync::Arc; +use tokio::sync::oneshot; +use uuid::Uuid; + +/// OAuth 登录成功后的凭证信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CodexOAuthResult { + pub credentials: CodexCredentials, + pub creds_file_path: String, +} + +/// OpenAI OAuth 固定回调端口(必须与 client_id 注册的回调地址一致) +const OPENAI_OAUTH_CALLBACK_PORT: u16 = 1455; + +/// OpenAI OAuth 固定回调路径(必须与 client_id 注册的回调地址一致) +const OPENAI_OAUTH_CALLBACK_PATH: &str = "/auth/callback"; + +/// 生成 OAuth 授权 URL(用于外部浏览器登录) +/// +/// 注意:OpenAI OAuth 要求 redirect_uri 必须是预先注册的固定地址 +/// Codex CLI 的 client_id 只注册了 http://localhost:1455/auth/callback +pub fn generate_codex_auth_url(state: &str, code_challenge: &str) -> String { + let redirect_uri = format!( + "http://localhost:{}{}", + OPENAI_OAUTH_CALLBACK_PORT, OPENAI_OAUTH_CALLBACK_PATH + ); + + let params = [ + ("client_id", OPENAI_CLIENT_ID), + ("response_type", "code"), + ("redirect_uri", redirect_uri.as_str()), + ("scope", "openid email profile offline_access"), + ("state", state), + ("code_challenge", code_challenge), + ("code_challenge_method", "S256"), + ("prompt", "login"), + ("id_token_add_organizations", "true"), + ("codex_cli_simplified_flow", "true"), + ]; + + let query = params + .iter() + .map(|(k, v)| format!("{}={}", k, urlencoding::encode(v))) + .collect::>() + .join("&"); + + format!("{}?{}", OPENAI_AUTH_URL, query) +} + +/// 用授权码交换 Token +pub async fn exchange_codex_code_for_token( + client: &Client, + code: &str, + code_verifier: &str, + redirect_uri: &str, +) -> Result> { + let params = [ + ("grant_type", "authorization_code"), + ("client_id", OPENAI_CLIENT_ID), + ("code", code), + ("redirect_uri", redirect_uri), + ("code_verifier", code_verifier), + ]; + + let resp = client + .post(OPENAI_TOKEN_URL) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Accept", "application/json") + .form(¶ms) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!("Token 交换失败: {} - {}", status, body).into()); + } + + let data: serde_json::Value = resp.json().await?; + Ok(data) +} + +/// OAuth 成功页面 HTML +const CODEX_OAUTH_SUCCESS_HTML: &str = r#" + + + + 授权成功 + + + +
+

✓ 授权成功

+

Codex 账号已添加到 ProxyCast

+ +

可以关闭此页面

+
+ +"#; + +/// OAuth 失败页面 HTML +const CODEX_OAUTH_ERROR_HTML: &str = r#" + + + + 授权失败 + + + +
+

✗ 授权失败

+

ERROR_PLACEHOLDER

+

请关闭此页面后重试

+
+ +"#; + +/// 启动 OAuth 服务器并返回授权 URL(不打开浏览器) +/// 服务器会在后台等待回调,成功后返回凭证 +/// +/// 注意:OpenAI OAuth 要求使用固定的回调地址 http://localhost:1455/auth/callback +pub async fn start_codex_oauth_server_and_get_url() -> Result< + ( + String, + impl std::future::Future>>, + ), + Box, +> { + use axum::{extract::Query, response::Html, routing::get, Router}; + use std::collections::HashMap; + use tokio::net::TcpListener; + + let client = Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build()?; + + // 生成 PKCE codes + let pkce_codes = PKCECodes::generate()?; + let code_verifier = pkce_codes.code_verifier.clone(); + let code_challenge = pkce_codes.code_challenge.clone(); + + // 生成随机 state + let state = Uuid::new_v4().to_string(); + let state_clone = state.clone(); + + // 创建 channel 用于接收回调结果 + let (tx, rx) = oneshot::channel::>(); + let tx = Arc::new(tokio::sync::Mutex::new(Some(tx))); + + // 使用固定端口 1455(OpenAI OAuth 要求) + let port = OPENAI_OAUTH_CALLBACK_PORT; + let listener = TcpListener::bind(format!("127.0.0.1:{}", port)).await.map_err(|e| { + if e.kind() == std::io::ErrorKind::AddrInUse { + format!( + "端口 {} 已被占用。OpenAI OAuth 要求使用固定端口 1455,请关闭占用该端口的应用后重试。", + port + ) + } else { + format!("绑定端口 {} 失败: {}", port, e) + } + })?; + + let redirect_uri = format!( + "http://localhost:{}{}", + OPENAI_OAUTH_CALLBACK_PORT, OPENAI_OAUTH_CALLBACK_PATH + ); + let redirect_uri_clone = redirect_uri.clone(); + + // 生成授权 URL(不再传入 port 参数) + let auth_url = generate_codex_auth_url(&state, &code_challenge); + + tracing::info!( + "[Codex OAuth] 服务器启动在端口 {}, 授权 URL: {}", + port, + auth_url + ); + + // 构建路由(使用固定的回调路径 /auth/callback) + let app = Router::new().route( + OPENAI_OAUTH_CALLBACK_PATH, + get(move |Query(params): Query>| { + let tx = tx.clone(); + let client = client.clone(); + let state_expected = state_clone.clone(); + let redirect_uri = redirect_uri_clone.clone(); + let code_verifier = code_verifier.clone(); + + async move { + let code = params.get("code"); + let returned_state = params.get("state"); + let error = params.get("error"); + + // 检查错误 + if let Some(err) = error { + let html = CODEX_OAUTH_ERROR_HTML.replace("ERROR_PLACEHOLDER", err); + if let Some(sender) = tx.lock().await.take() { + let _ = sender.send(Err(format!("OAuth 错误: {}", err))); + } + return Html(html); + } + + // 检查 state + if returned_state.map(|s| s.as_str()) != Some(&state_expected) { + let html = + CODEX_OAUTH_ERROR_HTML.replace("ERROR_PLACEHOLDER", "State 验证失败"); + if let Some(sender) = tx.lock().await.take() { + let _ = sender.send(Err("State 验证失败".to_string())); + } + return Html(html); + } + + // 检查 code + let code = match code { + Some(c) => c, + None => { + let html = + CODEX_OAUTH_ERROR_HTML.replace("ERROR_PLACEHOLDER", "未收到授权码"); + if let Some(sender) = tx.lock().await.take() { + let _ = sender.send(Err("未收到授权码".to_string())); + } + return Html(html); + } + }; + + // 交换 Token + let token_result = + exchange_codex_code_for_token(&client, code, &code_verifier, &redirect_uri) + .await; + let token_data = match token_result { + Ok(data) => data, + Err(e) => { + let html = + CODEX_OAUTH_ERROR_HTML.replace("ERROR_PLACEHOLDER", &e.to_string()); + if let Some(sender) = tx.lock().await.take() { + let _ = sender.send(Err(e.to_string())); + } + return Html(html); + } + }; + + let access_token = token_data["access_token"].as_str().unwrap_or_default(); + let refresh_token = token_data["refresh_token"].as_str().map(|s| s.to_string()); + let id_token = token_data["id_token"].as_str().map(|s| s.to_string()); + let expires_in = token_data["expires_in"].as_i64(); + + // 解析 ID Token 获取用户信息 + let (account_id, email) = if let Some(ref id_token) = id_token { + parse_jwt_claims(id_token) + } else { + (None, None) + }; + + // 构建凭证 + let now = chrono::Utc::now(); + let credentials = CodexCredentials { + id_token, + access_token: Some(access_token.to_string()), + refresh_token, + account_id, + last_refresh: Some(now.to_rfc3339()), + email: email.clone(), + r#type: "codex".to_string(), + expires_at: expires_in + .map(|e| (now + chrono::Duration::seconds(e)).to_rfc3339()), + }; + + // 保存凭证到应用数据目录 + let creds_dir = dirs::data_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("proxycast") + .join("credentials") + .join("codex"); + + if let Err(e) = std::fs::create_dir_all(&creds_dir) { + let html = CODEX_OAUTH_ERROR_HTML + .replace("ERROR_PLACEHOLDER", &format!("创建目录失败: {}", e)); + if let Some(sender) = tx.lock().await.take() { + let _ = sender.send(Err(format!("创建目录失败: {}", e))); + } + return Html(html); + } + + // 生成唯一文件名 + let uuid = Uuid::new_v4().to_string(); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let filename = format!("codex_{}_{}.json", &uuid[..8], timestamp); + let creds_file_path = creds_dir.join(&filename); + + // 保存凭证 + let creds_json = match serde_json::to_string_pretty(&credentials) { + Ok(json) => json, + Err(e) => { + let html = CODEX_OAUTH_ERROR_HTML + .replace("ERROR_PLACEHOLDER", &format!("序列化凭证失败: {}", e)); + if let Some(sender) = tx.lock().await.take() { + let _ = sender.send(Err(format!("序列化凭证失败: {}", e))); + } + return Html(html); + } + }; + + if let Err(e) = std::fs::write(&creds_file_path, &creds_json) { + let html = CODEX_OAUTH_ERROR_HTML + .replace("ERROR_PLACEHOLDER", &format!("保存凭证失败: {}", e)); + if let Some(sender) = tx.lock().await.take() { + let _ = sender.send(Err(format!("保存凭证失败: {}", e))); + } + return Html(html); + } + + tracing::info!("[Codex OAuth] 凭证已保存到: {:?}", creds_file_path); + + // 发送成功结果 + let result = CodexOAuthResult { + credentials, + creds_file_path: creds_file_path.to_string_lossy().to_string(), + }; + + if let Some(sender) = tx.lock().await.take() { + let _ = sender.send(Ok(result)); + } + + // 返回成功页面 + let html = CODEX_OAUTH_SUCCESS_HTML.replace( + "EMAIL_PLACEHOLDER", + &email.unwrap_or_else(|| "未知邮箱".to_string()), + ); + Html(html) + } + }), + ); + + // 启动服务器 + let server = axum::serve(listener, app); + + // 创建等待 future + let wait_future = async move { + // 设置超时(5 分钟) + let timeout = tokio::time::timeout(std::time::Duration::from_secs(300), async { + // 启动服务器(在后台运行) + tokio::spawn(async move { + if let Err(e) = server.await { + tracing::error!("[Codex OAuth] 服务器错误: {}", e); + } + }); + + // 等待回调结果 + match rx.await { + Ok(result) => result.map_err(|e| { + Box::new(std::io::Error::new(std::io::ErrorKind::Other, e)) + as Box + }), + Err(_) => Err("OAuth 回调通道关闭".into()), + } + }); + + match timeout.await { + Ok(result) => result, + Err(_) => Err("OAuth 登录超时(5分钟)".into()), + } + }; + + Ok((auth_url, wait_future)) +} + +/// 启动 Codex OAuth 登录流程(自动打开浏览器) +pub async fn start_codex_oauth_login() -> Result> { + let (auth_url, wait_future) = start_codex_oauth_server_and_get_url().await?; + + tracing::info!("[Codex OAuth] 打开浏览器进行授权: {}", auth_url); + + // 打开浏览器 + if let Err(e) = open::that(&auth_url) { + tracing::warn!("[Codex OAuth] 无法打开浏览器: {}. 请手动打开 URL.", e); + } + + // 等待回调 + wait_future.await +} diff --git a/src-tauri/src/providers/gemini.rs b/src-tauri/src/providers/gemini.rs index 52892842f..8155d4716 100644 --- a/src-tauri/src/providers/gemini.rs +++ b/src-tauri/src/providers/gemini.rs @@ -747,3 +747,609 @@ mod gemini_api_key_tests { let _ = provider; } } + +// ============================================================================ +// Gemini OAuth 登录功能 +// ============================================================================ + +use std::sync::Arc; +use tokio::sync::oneshot; +use uuid::Uuid; + +// Gemini CLI OAuth 配置 - 与 claude-relay-service 对齐 +pub const GEMINI_OAUTH_CLIENT_ID: &str = + "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"; +pub const GEMINI_OAUTH_CLIENT_SECRET: &str = "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"; +pub const GEMINI_OAUTH_SCOPES: &[&str] = &["https://www.googleapis.com/auth/cloud-platform"]; +pub const GEMINI_OAUTH_REDIRECT_URI: &str = "https://codeassist.google.com/authcode"; + +/// OAuth 登录成功后的凭证信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GeminiOAuthResult { + pub credentials: GeminiCredentials, + pub creds_file_path: String, +} + +/// 生成 PKCE code_verifier 和 code_challenge +fn generate_pkce() -> (String, String) { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; + use sha2::{Digest, Sha256}; + + // 生成 43-128 字符的随机字符串作为 code_verifier + let code_verifier: String = (0..64) + .map(|_| { + let idx = rand::random::() % 66; + let chars = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; + chars[idx as usize] as char + }) + .collect(); + + // 计算 code_challenge = BASE64URL(SHA256(code_verifier)) + let mut hasher = Sha256::new(); + hasher.update(code_verifier.as_bytes()); + let hash = hasher.finalize(); + let code_challenge = URL_SAFE_NO_PAD.encode(hash); + + (code_verifier, code_challenge) +} + +/// 生成 OAuth 授权 URL(使用 PKCE) +pub fn generate_gemini_auth_url(state: &str, code_challenge: &str) -> String { + let scopes = GEMINI_OAUTH_SCOPES.join(" "); + + let params = [ + ("access_type", "offline"), + ("client_id", GEMINI_OAUTH_CLIENT_ID), + ("code_challenge", code_challenge), + ("code_challenge_method", "S256"), + ("prompt", "select_account"), + ("redirect_uri", GEMINI_OAUTH_REDIRECT_URI), + ("response_type", "code"), + ("scope", &scopes), + ("state", state), + ]; + + let query = params + .iter() + .map(|(k, v)| format!("{}={}", k, urlencoding::encode(v))) + .collect::>() + .join("&"); + + format!("https://accounts.google.com/o/oauth2/v2/auth?{}", query) +} + +/// Gemini OAuth 会话信息(用于存储 PKCE code_verifier) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GeminiOAuthSession { + pub session_id: String, + pub code_verifier: String, + pub state: String, + pub created_at: i64, +} + +/// 生成 Gemini OAuth 授权 URL 和会话信息 +/// +/// 返回 (auth_url, session) 元组 +/// - auth_url: 用户需要在浏览器中打开的授权 URL +/// - session: 包含 code_verifier 的会话信息,用于后续交换 token +pub fn generate_gemini_auth_url_with_session() -> (String, GeminiOAuthSession) { + let (code_verifier, code_challenge) = generate_pkce(); + let state = Uuid::new_v4().to_string(); + let session_id = Uuid::new_v4().to_string(); + + let auth_url = generate_gemini_auth_url(&state, &code_challenge); + + let session = GeminiOAuthSession { + session_id, + code_verifier, + state, + created_at: chrono::Utc::now().timestamp(), + }; + + (auth_url, session) +} + +/// 用授权码交换 Token 并创建凭证 +/// +/// 完整流程: +/// 1. 用 code + code_verifier 交换 tokens +/// 2. 获取用户邮箱 +/// 3. 获取项目 ID +/// 4. 保存凭证到文件 +pub async fn exchange_gemini_code_and_create_credentials( + code: &str, + code_verifier: &str, +) -> Result> { + let client = Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build()?; + + tracing::info!("[Gemini OAuth] 正在用授权码交换 Token..."); + + // 交换 Token + let token_data = exchange_gemini_code_for_token(&client, code, code_verifier).await?; + + let access_token = token_data["access_token"] + .as_str() + .ok_or("响应中没有 access_token")? + .to_string(); + let refresh_token = token_data["refresh_token"].as_str().map(|s| s.to_string()); + let expires_in = token_data["expires_in"].as_i64(); + + tracing::info!("[Gemini OAuth] Token 交换成功"); + + // 获取用户邮箱 + let email = fetch_gemini_user_email(&client, &access_token) + .await + .ok() + .flatten(); + + tracing::info!("[Gemini OAuth] 用户邮箱: {:?}", email); + + // 获取项目 ID + let _project_id = fetch_gemini_project_id(&client, &access_token) + .await + .ok() + .flatten(); + + // 构建凭证 + let now = chrono::Utc::now(); + let expires_at = expires_in.map(|secs| now + chrono::Duration::seconds(secs)); + + let credentials = GeminiCredentials { + access_token: Some(access_token), + refresh_token, + token_type: Some("Bearer".to_string()), + expiry_date: expires_at.map(|t| t.timestamp_millis()), + expire: expires_at.map(|t| t.to_rfc3339()), + scope: Some(GEMINI_OAUTH_SCOPES.join(" ")), + email, + last_refresh: Some(now.to_rfc3339()), + cred_type: "gemini".to_string(), + token: None, + }; + + // 保存凭证到文件 + let file_path = save_gemini_credentials_to_file(&credentials).await?; + + tracing::info!("[Gemini OAuth] 凭证已保存到: {}", file_path); + + Ok(GeminiOAuthResult { + credentials, + creds_file_path: file_path, + }) +} + +/// 用授权码交换 Token(使用 PKCE) +pub async fn exchange_gemini_code_for_token( + client: &Client, + code: &str, + code_verifier: &str, +) -> Result> { + let params = [ + ("code", code), + ("client_id", GEMINI_OAUTH_CLIENT_ID), + ("client_secret", GEMINI_OAUTH_CLIENT_SECRET), + ("code_verifier", code_verifier), + ("redirect_uri", GEMINI_OAUTH_REDIRECT_URI), + ("grant_type", "authorization_code"), + ]; + + let resp = client.post(GEMINI_TOKEN_URL).form(¶ms).send().await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!("Token 交换失败: {} - {}", status, body).into()); + } + + let data: serde_json::Value = resp.json().await?; + Ok(data) +} + +/// 获取用户邮箱 +pub async fn fetch_gemini_user_email( + client: &Client, + access_token: &str, +) -> Result, Box> { + let resp = client + .get("https://www.googleapis.com/oauth2/v2/userinfo") + .header("Authorization", format!("Bearer {}", access_token)) + .send() + .await?; + + if resp.status().is_success() { + let data: serde_json::Value = resp.json().await?; + Ok(data["email"].as_str().map(|s| s.to_string())) + } else { + Ok(None) + } +} + +/// 获取项目 ID(通过 loadCodeAssist 接口) +pub async fn fetch_gemini_project_id( + client: &Client, + access_token: &str, +) -> Result, Box> { + tracing::info!("[Gemini OAuth] 正在获取 projectId..."); + + let resp = client + .post(format!( + "{}/{CODE_ASSIST_API_VERSION}:loadCodeAssist", + CODE_ASSIST_ENDPOINT + )) + .header("Authorization", format!("Bearer {}", access_token)) + .header("Content-Type", "application/json") + .json(&serde_json::json!({ + "cloudaicompanionProject": "", + "metadata": { + "ideType": "IDE_UNSPECIFIED", + "platform": "PLATFORM_UNSPECIFIED", + "pluginType": "GEMINI", + "duetProject": "" + } + })) + .send() + .await?; + + let status = resp.status(); + tracing::info!("[Gemini OAuth] loadCodeAssist 响应状态: {}", status); + + if status.is_success() { + let data: serde_json::Value = resp.json().await?; + if let Some(project) = data["cloudaicompanionProject"].as_str() { + if !project.is_empty() { + tracing::info!("[Gemini OAuth] 获取到 projectId: {}", project); + return Ok(Some(project.to_string())); + } + } + tracing::info!("[Gemini OAuth] cloudaicompanionProject 为空"); + Ok(None) + } else { + let body = resp.text().await.unwrap_or_default(); + tracing::warn!( + "[Gemini OAuth] loadCodeAssist 请求失败: {} - {}", + status, + body + ); + Ok(None) + } +} + +/// OAuth 成功页面 HTML +const GEMINI_OAUTH_SUCCESS_HTML: &str = r#" + + + + 授权成功 + + + +
+

✓ 授权成功

+

Gemini 账号已添加到 ProxyCast

+ +

可以关闭此页面

+
+ +"#; + +/// OAuth 失败页面 HTML +const GEMINI_OAUTH_ERROR_HTML: &str = r#" + + + + 授权失败 + + + +
+

✗ 授权失败

+

ERROR_PLACEHOLDER

+

请关闭此页面后重试

+
+ +"#; + +/// 保存 Gemini 凭证到文件 +async fn save_gemini_credentials_to_file( + credentials: &GeminiCredentials, +) -> Result> { + // 生成唯一文件名 + let uuid = Uuid::new_v4().to_string(); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let filename = format!("gemini_{}_{}_gemini.json", &uuid[..8], timestamp); + + // 获取凭证存储目录 + let credentials_dir = dirs::data_dir() + .ok_or_else(|| "无法获取应用数据目录")? + .join("proxycast") + .join("credentials"); + + // 确保目录存在 + tokio::fs::create_dir_all(&credentials_dir).await?; + + let file_path = credentials_dir.join(&filename); + + // 写入凭证 + let content = serde_json::to_string_pretty(credentials)?; + tokio::fs::write(&file_path, content).await?; + + Ok(file_path.to_string_lossy().to_string()) +} + +/// 启动 OAuth 服务器并返回授权 URL(不打开浏览器) +/// 服务器会在后台等待回调,成功后返回凭证 +pub async fn start_gemini_oauth_server_and_get_url() -> Result< + ( + String, + impl std::future::Future< + Output = Result>, + >, + ), + Box, +> { + use axum::{extract::Query, response::Html, routing::get, Router}; + use std::collections::HashMap; + use tokio::net::TcpListener; + + let client = Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build()?; + + // 生成 PKCE + let (code_verifier, code_challenge) = generate_pkce(); + + // 生成随机 state + let state = Uuid::new_v4().to_string(); + let state_clone = state.clone(); + let code_verifier_clone = code_verifier.clone(); + + // 创建 channel 用于接收回调结果 + let (tx, rx) = oneshot::channel::>(); + let tx = Arc::new(tokio::sync::Mutex::new(Some(tx))); + + // 尝试绑定到多个端口 + let ports_to_try = [11451, 11452, 11453, 11454, 11455, 0]; + let mut listener = None; + let mut bound_port = 0; + + for port in ports_to_try { + match TcpListener::bind(format!("127.0.0.1:{}", port)).await { + Ok(l) => { + bound_port = l.local_addr()?.port(); + listener = Some(l); + tracing::info!("[Gemini OAuth] 成功绑定到端口 {}", bound_port); + break; + } + Err(e) => { + tracing::warn!("[Gemini OAuth] 端口 {} 绑定失败: {}", port, e); + continue; + } + } + } + + let listener = listener.ok_or("无法绑定到任何可用端口")?; + + // 生成授权 URL + let auth_url = generate_gemini_auth_url(&state, &code_challenge); + + tracing::info!( + "[Gemini OAuth] 服务器启动在端口 {}, 授权 URL: {}", + bound_port, + auth_url + ); + + // 构建路由 + let app = Router::new().route( + "/oauth-callback", + get(move |Query(params): Query>| { + let tx = tx.clone(); + let client = client.clone(); + let state_expected = state_clone.clone(); + let code_verifier = code_verifier_clone.clone(); + + async move { + let code = params.get("code"); + let returned_state = params.get("state"); + let error = params.get("error"); + + // 检查错误 + if let Some(err) = error { + let error_desc = params + .get("error_description") + .map(|s| s.as_str()) + .unwrap_or("未知错误"); + let error_msg = format!("{}: {}", err, error_desc); + tracing::error!("[Gemini OAuth] 授权失败: {}", error_msg); + + if let Some(tx) = tx.lock().await.take() { + let _ = tx.send(Err(error_msg.clone())); + } + + let html = GEMINI_OAUTH_ERROR_HTML.replace("ERROR_PLACEHOLDER", &error_msg); + return Html(html); + } + + // 验证 state + if returned_state.map(|s| s.as_str()) != Some(&state_expected) { + let error_msg = "State 验证失败"; + tracing::error!("[Gemini OAuth] {}", error_msg); + + if let Some(tx) = tx.lock().await.take() { + let _ = tx.send(Err(error_msg.to_string())); + } + + let html = GEMINI_OAUTH_ERROR_HTML.replace("ERROR_PLACEHOLDER", error_msg); + return Html(html); + } + + // 获取授权码 + let code = match code { + Some(c) => c, + None => { + let error_msg = "未收到授权码"; + tracing::error!("[Gemini OAuth] {}", error_msg); + + if let Some(tx) = tx.lock().await.take() { + let _ = tx.send(Err(error_msg.to_string())); + } + + let html = GEMINI_OAUTH_ERROR_HTML.replace("ERROR_PLACEHOLDER", error_msg); + return Html(html); + } + }; + + tracing::info!("[Gemini OAuth] 收到授权码,正在交换 Token..."); + + // 交换 Token + let token_result = + exchange_gemini_code_for_token(&client, code, &code_verifier).await; + + match token_result { + Ok(token_data) => { + let access_token = token_data["access_token"] + .as_str() + .unwrap_or("") + .to_string(); + let refresh_token = + token_data["refresh_token"].as_str().map(|s| s.to_string()); + let expires_in = token_data["expires_in"].as_i64(); + + // 获取用户邮箱 + let email = fetch_gemini_user_email(&client, &access_token) + .await + .ok() + .flatten(); + + // 获取项目 ID + let project_id = fetch_gemini_project_id(&client, &access_token) + .await + .ok() + .flatten(); + + // 构建凭证 + let now = chrono::Utc::now(); + let expires_at = + expires_in.map(|secs| now + chrono::Duration::seconds(secs)); + + let credentials = GeminiCredentials { + access_token: Some(access_token), + refresh_token, + token_type: Some("Bearer".to_string()), + expiry_date: expires_at.map(|t| t.timestamp_millis()), + expire: expires_at.map(|t| t.to_rfc3339()), + scope: Some(GEMINI_OAUTH_SCOPES.join(" ")), + email: email.clone(), + last_refresh: Some(now.to_rfc3339()), + cred_type: "gemini".to_string(), + token: None, + }; + + // 保存凭证到文件 + match save_gemini_credentials_to_file(&credentials).await { + Ok(file_path) => { + tracing::info!("[Gemini OAuth] 凭证已保存到: {}", file_path); + + let result = GeminiOAuthResult { + credentials: credentials.clone(), + creds_file_path: file_path, + }; + + if let Some(tx) = tx.lock().await.take() { + let _ = tx.send(Ok(result)); + } + + let email_display = email.unwrap_or_else(|| "未知邮箱".to_string()); + let project_display = project_id + .map(|p| format!("

Project ID: {}

", p)) + .unwrap_or_default(); + let html = GEMINI_OAUTH_SUCCESS_HTML + .replace("EMAIL_PLACEHOLDER", &email_display) + .replace( + "\n", + &format!("{}\n", project_display), + ); + Html(html) + } + Err(e) => { + let error_msg = format!("保存凭证失败: {}", e); + tracing::error!("[Gemini OAuth] {}", error_msg); + + if let Some(tx) = tx.lock().await.take() { + let _ = tx.send(Err(error_msg.clone())); + } + + let html = GEMINI_OAUTH_ERROR_HTML + .replace("ERROR_PLACEHOLDER", &error_msg); + Html(html) + } + } + } + Err(e) => { + let error_msg = format!("Token 交换失败: {}", e); + tracing::error!("[Gemini OAuth] {}", error_msg); + + if let Some(tx) = tx.lock().await.take() { + let _ = tx.send(Err(error_msg.clone())); + } + + let html = GEMINI_OAUTH_ERROR_HTML.replace("ERROR_PLACEHOLDER", &error_msg); + Html(html) + } + } + } + }), + ); + + // 启动服务器 + let server_future = async move { + axum::serve(listener, app) + .await + .map_err(|e| format!("服务器错误: {}", e)) + }; + + // 启动服务器任务 + tokio::spawn(server_future); + + // 返回授权 URL 和等待结果的 Future + let wait_future = async move { + match rx.await { + Ok(result) => result.map_err(|e| e.into()), + Err(_) => Err("OAuth 回调通道关闭".into()), + } + }; + + Ok((auth_url, wait_future)) +} + +/// 启动 Gemini OAuth 登录流程(自动打开浏览器) +pub async fn start_gemini_oauth_login( +) -> Result> { + let (auth_url, wait_future) = start_gemini_oauth_server_and_get_url().await?; + + // 打开浏览器 + tracing::info!("[Gemini OAuth] 正在打开浏览器..."); + if let Err(e) = open::that(&auth_url) { + tracing::warn!("[Gemini OAuth] 无法自动打开浏览器: {}", e); + } + + // 等待回调 + wait_future.await +} diff --git a/src-tauri/src/providers/iflow.rs b/src-tauri/src/providers/iflow.rs index c75f254be..7d654d91a 100644 --- a/src-tauri/src/providers/iflow.rs +++ b/src-tauri/src/providers/iflow.rs @@ -1575,3 +1575,431 @@ mod tests { assert!(provider.are_cookies_expired()); } } + +// ============================================================================ +// OAuth 登录功能(与 CLIProxyAPI 对齐) +// ============================================================================ + +use std::sync::Arc; +use tokio::sync::oneshot; +use uuid::Uuid; + +/// iFlow OAuth 登录结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IFlowOAuthResult { + pub credentials: IFlowCredentials, + pub creds_file_path: String, +} + +/// OAuth 成功页面 HTML +const IFLOW_OAUTH_SUCCESS_HTML: &str = r#" + + + + 授权成功 + + + +
+

✓ 授权成功

+

iFlow 账号已添加到 ProxyCast

+ +

可以关闭此页面

+
+ +"#; + +/// OAuth 失败页面 HTML +const IFLOW_OAUTH_ERROR_HTML: &str = r#" + + + + 授权失败 + + + +
+

✗ 授权失败

+

ERROR_PLACEHOLDER

+

请关闭此页面后重试

+
+ +"#; + +/// 生成 iFlow OAuth 授权 URL +pub fn generate_iflow_auth_url(port: u16, state: &str) -> String { + let redirect_uri = format!("http://localhost:{}/oauth2callback", port); + + let params = [ + ("loginMethod", "phone"), + ("type", "phone"), + ("redirect", redirect_uri.as_str()), + ("state", state), + ("client_id", IFLOW_CLIENT_ID), + ]; + + let query = params + .iter() + .map(|(k, v)| format!("{}={}", k, urlencoding::encode(v))) + .collect::>() + .join("&"); + + format!("{}?{}", IFLOW_AUTH_URL, query) +} + +/// 用授权码交换 Token +pub async fn exchange_iflow_code_for_token( + client: &Client, + code: &str, + redirect_uri: &str, +) -> Result> { + // 构建 Basic Auth 头 + let basic_auth = BASE64_STANDARD.encode(format!("{}:{}", IFLOW_CLIENT_ID, IFLOW_CLIENT_SECRET)); + + let params = [ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", redirect_uri), + ("client_id", IFLOW_CLIENT_ID), + ("client_secret", IFLOW_CLIENT_SECRET), + ]; + + let resp = client + .post(IFLOW_TOKEN_URL) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Accept", "application/json") + .header("Authorization", format!("Basic {}", basic_auth)) + .form(¶ms) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!("Token 交换失败: {} - {}", status, body).into()); + } + + let data: serde_json::Value = resp.json().await?; + + let access_token = data["access_token"] + .as_str() + .ok_or("响应中没有 access_token")? + .to_string(); + let refresh_token = data["refresh_token"].as_str().map(|s| s.to_string()); + let token_type = data["token_type"].as_str().map(|s| s.to_string()); + let scope = data["scope"].as_str().map(|s| s.to_string()); + let expires_in = data["expires_in"].as_i64().unwrap_or(3600); + + let expires_at = chrono::Utc::now() + chrono::Duration::seconds(expires_in); + + // 获取用户信息和 API Key + let user_info_url = format!( + "{}?accessToken={}", + IFLOW_USER_INFO_URL, + urlencoding::encode(&access_token) + ); + + let user_resp = client + .get(&user_info_url) + .header("Accept", "application/json") + .send() + .await; + + let (api_key, email) = if let Ok(resp) = user_resp { + if resp.status().is_success() { + if let Ok(user_data) = resp.json::().await { + if user_data["success"].as_bool().unwrap_or(false) { + let api_key = user_data["data"]["apiKey"].as_str().map(|s| s.to_string()); + let email = user_data["data"]["email"] + .as_str() + .or_else(|| user_data["data"]["phone"].as_str()) + .map(|s| s.to_string()); + (api_key, email) + } else { + (None, None) + } + } else { + (None, None) + } + } else { + (None, None) + } + } else { + (None, None) + }; + + let credentials = IFlowCredentials { + auth_type: "oauth".to_string(), + access_token: Some(access_token), + refresh_token, + expire: Some(expires_at.to_rfc3339()), + expires_at: Some(expires_at.to_rfc3339()), + cookies: None, + cookie_expires_at: None, + email, + user_id: None, + last_refresh: Some(chrono::Utc::now().to_rfc3339()), + api_key, + token_type, + scope, + cred_type: "iflow".to_string(), + }; + + Ok(credentials) +} + +/// 启动 OAuth 服务器并返回授权 URL(不打开浏览器) +pub async fn start_iflow_oauth_server_and_get_url() -> Result< + ( + String, + impl std::future::Future>>, + ), + Box, +> { + use axum::{extract::Query, response::Html, routing::get, Router}; + use std::collections::HashMap; + use tokio::net::TcpListener; + + let client = Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build()?; + + // 生成随机 state + let state = Uuid::new_v4().to_string(); + let state_clone = state.clone(); + + // 创建 channel 用于接收回调结果 + let (tx, rx) = oneshot::channel::>(); + let tx = Arc::new(tokio::sync::Mutex::new(Some(tx))); + + // 尝试绑定到端口,如果默认端口被占用则尝试其他端口 + let ports_to_try = [DEFAULT_CALLBACK_PORT, 11452, 11453, 11454, 11455, 0]; // 0 表示让系统分配 + let mut listener = None; + let mut last_error = None; + + for port in ports_to_try { + match TcpListener::bind(format!("127.0.0.1:{}", port)).await { + Ok(l) => { + listener = Some(l); + break; + } + Err(e) => { + last_error = Some(e); + continue; + } + } + } + + let listener = listener.ok_or_else(|| { + format!( + "启动 OAuth 服务器失败: {}", + last_error + .map(|e| e.to_string()) + .unwrap_or_else(|| "无法绑定端口".to_string()) + ) + })?; + let port = listener.local_addr()?.port(); + + let redirect_uri = format!("http://localhost:{}/oauth2callback", port); + let redirect_uri_clone = redirect_uri.clone(); + + // 生成授权 URL + let auth_url = generate_iflow_auth_url(port, &state); + + tracing::info!( + "[iFlow OAuth] 服务器启动在端口 {}, 授权 URL: {}", + port, + auth_url + ); + + // 构建路由 + let app = Router::new().route( + "/oauth2callback", + get(move |Query(params): Query>| { + let tx = tx.clone(); + let client = client.clone(); + let state_expected = state_clone.clone(); + let redirect_uri = redirect_uri_clone.clone(); + + async move { + let code = params.get("code"); + let returned_state = params.get("state"); + let error = params.get("error"); + + // 检查错误 + if let Some(err) = error { + let html = IFLOW_OAUTH_ERROR_HTML.replace("ERROR_PLACEHOLDER", err); + if let Some(sender) = tx.lock().await.take() { + let _ = sender.send(Err(format!("OAuth 错误: {}", err))); + } + return Html(html); + } + + // 检查 state + if returned_state.map(|s| s.as_str()) != Some(&state_expected) { + let html = + IFLOW_OAUTH_ERROR_HTML.replace("ERROR_PLACEHOLDER", "State 验证失败"); + if let Some(sender) = tx.lock().await.take() { + let _ = sender.send(Err("State 验证失败".to_string())); + } + return Html(html); + } + + // 检查 code + let code = match code { + Some(c) => c, + None => { + let html = + IFLOW_OAUTH_ERROR_HTML.replace("ERROR_PLACEHOLDER", "未收到授权码"); + if let Some(sender) = tx.lock().await.take() { + let _ = sender.send(Err("未收到授权码".to_string())); + } + return Html(html); + } + }; + + // 交换 Token + let token_result = + exchange_iflow_code_for_token(&client, code, &redirect_uri).await; + let credentials = match token_result { + Ok(creds) => creds, + Err(e) => { + let html = + IFLOW_OAUTH_ERROR_HTML.replace("ERROR_PLACEHOLDER", &e.to_string()); + if let Some(sender) = tx.lock().await.take() { + let _ = sender.send(Err(e.to_string())); + } + return Html(html); + } + }; + + let email = credentials.email.clone(); + + // 保存凭证到应用数据目录 + let creds_dir = dirs::data_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join("proxycast") + .join("credentials") + .join("iflow"); + + if let Err(e) = std::fs::create_dir_all(&creds_dir) { + let html = IFLOW_OAUTH_ERROR_HTML + .replace("ERROR_PLACEHOLDER", &format!("创建目录失败: {}", e)); + if let Some(sender) = tx.lock().await.take() { + let _ = sender.send(Err(format!("创建目录失败: {}", e))); + } + return Html(html); + } + + // 生成唯一文件名 + let uuid = Uuid::new_v4().to_string(); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let filename = format!("iflow_{}_{}.json", &uuid[..8], timestamp); + let creds_file_path = creds_dir.join(&filename); + + // 保存凭证 + let creds_json = match serde_json::to_string_pretty(&credentials) { + Ok(json) => json, + Err(e) => { + let html = IFLOW_OAUTH_ERROR_HTML + .replace("ERROR_PLACEHOLDER", &format!("序列化凭证失败: {}", e)); + if let Some(sender) = tx.lock().await.take() { + let _ = sender.send(Err(format!("序列化凭证失败: {}", e))); + } + return Html(html); + } + }; + + if let Err(e) = std::fs::write(&creds_file_path, &creds_json) { + let html = IFLOW_OAUTH_ERROR_HTML + .replace("ERROR_PLACEHOLDER", &format!("保存凭证失败: {}", e)); + if let Some(sender) = tx.lock().await.take() { + let _ = sender.send(Err(format!("保存凭证失败: {}", e))); + } + return Html(html); + } + + tracing::info!("[iFlow OAuth] 凭证已保存到: {:?}", creds_file_path); + + // 发送成功结果 + let result = IFlowOAuthResult { + credentials, + creds_file_path: creds_file_path.to_string_lossy().to_string(), + }; + + if let Some(sender) = tx.lock().await.take() { + let _ = sender.send(Ok(result)); + } + + // 返回成功页面 + let html = IFLOW_OAUTH_SUCCESS_HTML.replace( + "EMAIL_PLACEHOLDER", + &email.unwrap_or_else(|| "未知用户".to_string()), + ); + Html(html) + } + }), + ); + + // 启动服务器 + let server = axum::serve(listener, app); + + // 创建等待 future + let wait_future = async move { + // 设置超时(5 分钟) + let timeout = tokio::time::timeout(std::time::Duration::from_secs(300), async { + // 启动服务器(在后台运行) + tokio::spawn(async move { + if let Err(e) = server.await { + tracing::error!("[iFlow OAuth] 服务器错误: {}", e); + } + }); + + // 等待回调结果 + match rx.await { + Ok(result) => result.map_err(|e| { + Box::new(std::io::Error::new(std::io::ErrorKind::Other, e)) + as Box + }), + Err(_) => Err("OAuth 回调通道关闭".into()), + } + }); + + match timeout.await { + Ok(result) => result, + Err(_) => Err("OAuth 登录超时(5分钟)".into()), + } + }; + + Ok((auth_url, wait_future)) +} + +/// 启动 iFlow OAuth 登录流程(自动打开浏览器) +pub async fn start_iflow_oauth_login() -> Result> { + let (auth_url, wait_future) = start_iflow_oauth_server_and_get_url().await?; + + tracing::info!("[iFlow OAuth] 打开浏览器进行授权: {}", auth_url); + + // 打开浏览器 + if let Err(e) = open::that(&auth_url) { + tracing::warn!("[iFlow OAuth] 无法打开浏览器: {}. 请手动打开 URL.", e); + } + + // 等待回调 + wait_future.await +} diff --git a/src-tauri/src/providers/qwen.rs b/src-tauri/src/providers/qwen.rs index 403c4161e..117a14f9e 100644 --- a/src-tauri/src/providers/qwen.rs +++ b/src-tauri/src/providers/qwen.rs @@ -316,3 +316,340 @@ impl QwenProvider { Ok(resp) } } + +// ============================================================================ +// Device Code Flow 登录功能(与 CLIProxyAPI 对齐) +// ============================================================================ + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use rand::RngCore; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +// Device Code Flow 端点 +const QWEN_DEVICE_CODE_URL: &str = "https://chat.qwen.ai/api/v1/oauth2/device/code"; +const QWEN_OAUTH_SCOPE: &str = "openid profile email model.completion"; +const QWEN_DEVICE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code"; + +/// Device Code Flow 响应 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeviceCodeResponse { + /// 设备码(用于轮询) + #[serde(alias = "deviceCode")] + pub device_code: String, + /// 用户码(用户在浏览器中输入) + #[serde(alias = "userCode")] + pub user_code: String, + /// 验证 URL + #[serde(alias = "verificationUri")] + pub verification_uri: String, + /// 完整验证 URL(包含 user_code) + #[serde(default, alias = "verificationUriComplete")] + pub verification_uri_complete: Option, + /// 过期时间(秒) + #[serde(alias = "expiresIn")] + pub expires_in: i64, + /// 轮询间隔(秒),默认 5 秒 + #[serde(default = "default_interval")] + pub interval: i64, +} + +fn default_interval() -> i64 { + 5 +} + +/// Qwen OAuth 登录结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QwenOAuthResult { + pub credentials: QwenCredentials, + pub creds_file_path: String, +} + +/// PKCE 代码生成 +fn generate_pkce_pair() -> Result<(String, String), Box> { + let mut bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut bytes); + let code_verifier = URL_SAFE_NO_PAD.encode(bytes); + + let mut hasher = Sha256::new(); + hasher.update(code_verifier.as_bytes()); + let hash = hasher.finalize(); + let code_challenge = URL_SAFE_NO_PAD.encode(hash); + + Ok((code_verifier, code_challenge)) +} + +/// 发起 Device Code Flow +pub async fn initiate_device_flow( + client: &Client, +) -> Result<(DeviceCodeResponse, String), Box> { + let (code_verifier, code_challenge) = generate_pkce_pair()?; + + let params = [ + ("client_id", QWEN_CLIENT_ID), + ("scope", QWEN_OAUTH_SCOPE), + ("code_challenge", code_challenge.as_str()), + ("code_challenge_method", "S256"), + ]; + + let resp = client + .post(QWEN_DEVICE_CODE_URL) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Accept", "application/json") + .form(¶ms) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!("Device code 请求失败: {} - {}", status, body).into()); + } + + // 先获取响应体文本,以便在解析失败时提供详细错误信息 + let body = resp.text().await?; + tracing::debug!("[QWEN] Device Code 响应: {}", body); + + let device_response: DeviceCodeResponse = serde_json::from_str(&body) + .map_err(|e| format!("解析 Device Code 响应失败: {} - 响应内容: {}", e, body))?; + + if device_response.device_code.is_empty() { + return Err("Device code 响应中没有 device_code".into()); + } + + tracing::info!( + "[QWEN] Device Code Flow 已启动,user_code: {}, verification_uri: {}", + device_response.user_code, + device_response.verification_uri + ); + + Ok((device_response, code_verifier)) +} + +/// 轮询 Token 端点 +pub async fn poll_for_token( + client: &Client, + device_code: &str, + code_verifier: &str, + interval: u64, + max_attempts: u32, +) -> Result> { + let poll_interval = std::time::Duration::from_secs(interval.max(5)); + + for attempt in 0..max_attempts { + if attempt > 0 { + tokio::time::sleep(poll_interval).await; + } + + tracing::debug!("[QWEN] 轮询 Token,第 {} 次尝试", attempt + 1); + + let params = [ + ("grant_type", QWEN_DEVICE_GRANT_TYPE), + ("client_id", QWEN_CLIENT_ID), + ("device_code", device_code), + ("code_verifier", code_verifier), + ]; + + let resp = client + .post(QWEN_TOKEN_URL) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Accept", "application/json") + .form(¶ms) + .send() + .await; + + let resp = match resp { + Ok(r) => r, + Err(e) => { + tracing::warn!("[QWEN] 轮询请求失败: {}", e); + continue; + } + }; + + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + + if status.is_success() { + // 成功获取 Token + let data: serde_json::Value = serde_json::from_str(&body)?; + + let access_token = data["access_token"] + .as_str() + .ok_or("响应中没有 access_token")? + .to_string(); + let refresh_token = data["refresh_token"].as_str().map(|s| s.to_string()); + let token_type = data["token_type"].as_str().map(|s| s.to_string()); + let resource_url = data["resource_url"].as_str().map(|s| s.to_string()); + let expires_in = data["expires_in"].as_i64().unwrap_or(3600); + + let expires_at = chrono::Utc::now() + chrono::Duration::seconds(expires_in); + + let credentials = QwenCredentials { + access_token: Some(access_token), + refresh_token, + token_type, + resource_url, + expiry_date: Some(expires_at.timestamp_millis()), + expire: Some(expires_at.to_rfc3339()), + last_refresh: Some(chrono::Utc::now().to_rfc3339()), + cred_type: "qwen".to_string(), + }; + + tracing::info!("[QWEN] Token 获取成功"); + return Ok(credentials); + } + + // 解析错误响应 + if let Ok(error_data) = serde_json::from_str::(&body) { + let error_type = error_data["error"].as_str().unwrap_or(""); + + match error_type { + "authorization_pending" => { + // 用户尚未完成授权,继续轮询 + tracing::debug!("[QWEN] 等待用户授权..."); + continue; + } + "slow_down" => { + // 轮询太频繁,增加间隔 + tracing::debug!("[QWEN] 服务器要求降低轮询频率"); + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + continue; + } + "expired_token" => { + return Err("Device code 已过期,请重新开始授权流程".into()); + } + "access_denied" => { + return Err("用户拒绝了授权请求".into()); + } + _ => { + let error_desc = error_data["error_description"] + .as_str() + .unwrap_or("未知错误"); + return Err(format!("Token 获取失败: {} - {}", error_type, error_desc).into()); + } + } + } + + // 其他错误 + if status.as_u16() != 400 { + return Err(format!("Token 请求失败: {} - {}", status, body).into()); + } + } + + Err("授权超时,请重新开始授权流程".into()) +} + +/// 启动 Qwen Device Code Flow 登录 +pub async fn start_qwen_device_code_login() -> Result> +{ + let client = Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build()?; + + // 发起 Device Code Flow + let (device_response, code_verifier) = initiate_device_flow(&client).await?; + + // 打开浏览器 + let verification_url = device_response + .verification_uri_complete + .as_ref() + .unwrap_or(&device_response.verification_uri); + + tracing::info!("[QWEN] 打开浏览器进行授权: {}", verification_url); + + if let Err(e) = open::that(verification_url) { + tracing::warn!("[QWEN] 无法打开浏览器: {}. 请手动打开 URL.", e); + } + + // 轮询 Token + let credentials = poll_for_token( + &client, + &device_response.device_code, + &code_verifier, + device_response.interval as u64, + 60, // 最多轮询 60 次(约 5 分钟) + ) + .await?; + + // 保存凭证到应用数据目录 + let creds_dir = dirs::data_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join("proxycast") + .join("credentials") + .join("qwen"); + + std::fs::create_dir_all(&creds_dir)?; + + let uuid = Uuid::new_v4().to_string(); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let filename = format!("qwen_{}_{}.json", &uuid[..8], timestamp); + let creds_file_path = creds_dir.join(&filename); + + let creds_json = serde_json::to_string_pretty(&credentials)?; + std::fs::write(&creds_file_path, &creds_json)?; + + tracing::info!("[QWEN] 凭证已保存到: {:?}", creds_file_path); + + Ok(QwenOAuthResult { + credentials, + creds_file_path: creds_file_path.to_string_lossy().to_string(), + }) +} + +/// 启动 Qwen Device Code Flow 并返回设备码信息(不自动打开浏览器) +pub async fn start_qwen_device_code_and_get_info() -> Result< + ( + DeviceCodeResponse, + impl std::future::Future>>, + ), + Box, +> { + let client = Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build()?; + + // 发起 Device Code Flow + let (device_response, code_verifier) = initiate_device_flow(&client).await?; + + let device_code = device_response.device_code.clone(); + let interval = device_response.interval as u64; + + // 创建等待 future + let wait_future = async move { + // 轮询 Token + let credentials = + poll_for_token(&client, &device_code, &code_verifier, interval, 60).await?; + + // 保存凭证到应用数据目录 + let creds_dir = dirs::data_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join("proxycast") + .join("credentials") + .join("qwen"); + + std::fs::create_dir_all(&creds_dir)?; + + let uuid = Uuid::new_v4().to_string(); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let filename = format!("qwen_{}_{}.json", &uuid[..8], timestamp); + let creds_file_path = creds_dir.join(&filename); + + let creds_json = serde_json::to_string_pretty(&credentials)?; + std::fs::write(&creds_file_path, &creds_json)?; + + tracing::info!("[QWEN] 凭证已保存到: {:?}", creds_file_path); + + Ok(QwenOAuthResult { + credentials, + creds_file_path: creds_file_path.to_string_lossy().to_string(), + }) + }; + + Ok((device_response, wait_future)) +} diff --git a/src-tauri/src/services/provider_pool_service.rs b/src-tauri/src/services/provider_pool_service.rs index 745a004df..465758d9d 100644 --- a/src-tauri/src/services/provider_pool_service.rs +++ b/src-tauri/src/services/provider_pool_service.rs @@ -300,6 +300,8 @@ impl ProviderPoolService { } /// 执行单个凭证的健康检查 + /// + /// 如果遇到 401 错误,会自动尝试刷新 token 后重试 pub async fn check_credential_health( &self, db: &DbConnection, @@ -335,6 +337,71 @@ impl ProviderPoolService { }) } Err(e) => { + // 如果是 401 错误,尝试刷新 token 后重试 + if e.contains("401") || e.contains("Unauthorized") { + tracing::info!("[健康检查] 检测到 401 错误,尝试刷新 token: {}", uuid); + + // 尝试刷新 token + match self.refresh_credential_token(db, uuid).await { + Ok(_) => { + tracing::info!("[健康检查] Token 刷新成功,重新检查健康状态"); + + // 重新获取凭证(token 已更新) + let updated_cred = { + let conn = db.lock().map_err(|e| e.to_string())?; + ProviderPoolDao::get_by_uuid(&conn, uuid) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Credential not found: {}", uuid))? + }; + + // 重新执行健康检查 + let retry_start = std::time::Instant::now(); + let retry_result = self + .perform_health_check(&updated_cred.credential, &check_model) + .await; + let retry_duration_ms = retry_start.elapsed().as_millis() as u64; + + match retry_result { + Ok(_) => { + self.mark_healthy(db, uuid, Some(&check_model))?; + return Ok(HealthCheckResult { + uuid: uuid.to_string(), + success: true, + model: Some(check_model), + message: Some( + "Health check passed after token refresh".to_string(), + ), + duration_ms: duration_ms + retry_duration_ms, + }); + } + Err(retry_e) => { + tracing::warn!("[健康检查] Token 刷新后仍然失败: {}", retry_e); + self.mark_unhealthy(db, uuid, Some(&retry_e))?; + return Ok(HealthCheckResult { + uuid: uuid.to_string(), + success: false, + model: Some(check_model), + message: Some(retry_e), + duration_ms: duration_ms + retry_duration_ms, + }); + } + } + } + Err(refresh_err) => { + tracing::warn!("[健康检查] Token 刷新失败: {}", refresh_err); + // Token 刷新失败,返回原始错误 + self.mark_unhealthy(db, uuid, Some(&e))?; + return Ok(HealthCheckResult { + uuid: uuid.to_string(), + success: false, + model: Some(check_model), + message: Some(format!("{} (Token 刷新失败: {})", e, refresh_err)), + duration_ms, + }); + } + } + } + self.mark_unhealthy(db, uuid, Some(&e))?; Ok(HealthCheckResult { uuid: uuid.to_string(), @@ -550,11 +617,13 @@ impl ProviderPoolService { } // Gemini OAuth 健康检查 + // 使用 cloudcode-pa.googleapis.com API(与 Gemini CLI 兼容) + // 使用 loadCodeAssist 接口进行健康检查,这是最简单可靠的方式 async fn check_gemini_health( &self, creds_path: &str, _project_id: Option<&str>, - model: &str, + _model: &str, ) -> Result<(), String> { let creds_content = std::fs::read_to_string(creds_path).map_err(|e| format!("读取凭证文件失败: {}", e))?; @@ -565,24 +634,25 @@ impl ProviderPoolService { .as_str() .ok_or_else(|| "凭证中缺少 access_token".to_string())?; - let url = format!( - "https://generativelanguage.googleapis.com/v1beta/models/{}:generateContent", - model - ); + // 使用 loadCodeAssist 接口进行健康检查 + // 这个接口用于获取项目信息,是最简单可靠的健康检查方式 + let url = "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist"; let request_body = serde_json::json!({ - "contents": [{ - "parts": [{"text": "Say OK"}] - }], - "generationConfig": { - "maxOutputTokens": 10 + "cloudaicompanionProject": "", + "metadata": { + "ideType": "IDE_UNSPECIFIED", + "platform": "PLATFORM_UNSPECIFIED", + "pluginType": "GEMINI", + "duetProject": "" } }); let response = self .client - .post(&url) + .post(url) .bearer_auth(access_token) + .header("Content-Type", "application/json") .json(&request_body) .timeout(self.health_check_timeout) .send() @@ -592,7 +662,9 @@ impl ProviderPoolService { if response.status().is_success() { Ok(()) } else { - Err(format!("HTTP {}", response.status())) + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + Err(format!("HTTP {} - {}", status, body)) } } @@ -607,16 +679,31 @@ impl ProviderPoolService { .as_str() .ok_or_else(|| "凭证中缺少 access_token".to_string())?; + // 获取 base_url,优先使用 resource_url,否则使用默认值 + let base_url = if let Some(resource_url) = creds["resource_url"].as_str() { + if resource_url.starts_with("http") { + format!("{}/v1", resource_url.trim_end_matches('/')) + } else { + format!("https://{}/v1", resource_url) + } + } else { + "https://portal.qwen.ai/v1".to_string() + }; + let request_body = serde_json::json!({ "model": model, "messages": [{"role": "user", "content": "Say OK"}], "max_tokens": 10 }); + let url = format!("{}/chat/completions", base_url); + let response = self .client - .post("https://chat.qwen.ai/api/v1/chat/completions") + .post(&url) .bearer_auth(access_token) + .header("Content-Type", "application/json") + .header("Accept", "application/json") .json(&request_body) .timeout(self.health_check_timeout) .send() diff --git a/src/components/provider-pool/AddCredentialModal.tsx b/src/components/provider-pool/AddCredentialModal.tsx index b262a8509..d5c35f187 100644 --- a/src/components/provider-pool/AddCredentialModal.tsx +++ b/src/components/provider-pool/AddCredentialModal.tsx @@ -1,8 +1,19 @@ -import { useState, useEffect } from "react"; -import { X, Key, FolderOpen, LogIn, Copy, Check, Loader2 } from "lucide-react"; +/** + * 添加凭证模态框 + * 根据 Provider 类型显示不同的表单 + */ + +import { useState } from "react"; +import { X, Key, FolderOpen } from "lucide-react"; import { open } from "@tauri-apps/plugin-dialog"; -import { listen } from "@tauri-apps/api/event"; import { providerPoolApi, PoolProviderType } from "@/lib/api/providerPool"; +import { AntigravityForm } from "./credential-forms/AntigravityForm"; +import { CodexForm } from "./credential-forms/CodexForm"; +import { ClaudeOAuthForm } from "./credential-forms/ClaudeOAuthForm"; +import { QwenForm } from "./credential-forms/QwenForm"; +import { IFlowForm } from "./credential-forms/IFlowForm"; +import { GeminiForm } from "./credential-forms/GeminiForm"; +import { defaultCredsPath, providerLabels } from "./credential-forms/types"; interface AddCredentialModalProps { providerType: PoolProviderType; @@ -10,17 +21,6 @@ interface AddCredentialModalProps { onSuccess: () => void; } -// Default credential paths -const defaultCredsPath: Record = { - kiro: "~/.aws/sso/cache/kiro-auth-token.json", - gemini: "~/.gemini/oauth_creds.json", - qwen: "~/.qwen/oauth_creds.json", - antigravity: "", - codex: "~/.codex/oauth.json", - claude_oauth: "~/.claude/oauth.json", - iflow: "~/.iflow/oauth_creds.json", -}; - export function AddCredentialModal({ providerType, onClose, @@ -30,42 +30,19 @@ export function AddCredentialModal({ const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - // OAuth fields - initialize with default path + // OAuth 字段 const [credsFilePath, setCredsFilePath] = useState( defaultCredsPath[providerType] || "", ); const [projectId, setProjectId] = useState(""); - // API Key fields + // API Key 字段 const [apiKey, setApiKey] = useState(""); const [baseUrl, setBaseUrl] = useState(""); - // Antigravity 添加方式: "login" | "file" - const [antigravityMode, setAntigravityMode] = useState<"login" | "file">( - "login", - ); - - const isOAuth = [ - "kiro", - "gemini", - "qwen", - "antigravity", - "codex", - "claude_oauth", - "iflow", - ].includes(providerType); - - const providerLabels: Record = { - kiro: "Kiro (AWS)", - gemini: "Gemini (Google)", - qwen: "Qwen (阿里)", - openai: "OpenAI", - claude: "Claude (Anthropic)", - antigravity: "Antigravity (Gemini 3 Pro)", - codex: "Codex (OpenAI OAuth)", - claude_oauth: "Claude OAuth", - iflow: "iFlow", - }; + // 判断是否为 OAuth 类型(不包括有特殊表单的 antigravity、codex、claude_oauth、qwen、iflow、gemini) + const isSimpleOAuth = ["kiro"].includes(providerType); + const isApiKey = ["openai", "claude"].includes(providerType); const handleSelectFile = async () => { try { @@ -81,100 +58,94 @@ export function AddCredentialModal({ } }; - // Antigravity OAuth 登录状态 - const [antigravityAuthUrl, setAntigravityAuthUrl] = useState( - null, - ); - const [urlCopied, setUrlCopied] = useState(false); - const [waitingForCallback, setWaitingForCallback] = useState(false); + // Antigravity 表单 + const antigravityForm = AntigravityForm({ + name, + credsFilePath, + setCredsFilePath, + projectId, + setProjectId, + onSelectFile: handleSelectFile, + loading, + setLoading, + setError, + onSuccess, + }); - // 监听后端发送的授权 URL 事件 - useEffect(() => { - let unlisten: (() => void) | undefined; + // Codex 表单 + const codexForm = CodexForm({ + name, + credsFilePath, + setCredsFilePath, + onSelectFile: handleSelectFile, + loading, + setLoading, + setError, + onSuccess, + }); - const setupListener = async () => { - unlisten = await listen<{ auth_url: string }>( - "antigravity-auth-url", - (event) => { - setAntigravityAuthUrl(event.payload.auth_url); - }, - ); - }; + // Claude OAuth 表单 + const claudeOAuthForm = ClaudeOAuthForm({ + name, + credsFilePath, + setCredsFilePath, + onSelectFile: handleSelectFile, + loading, + setLoading, + setError, + onSuccess, + }); - setupListener(); + // Qwen 表单 + const qwenForm = QwenForm({ + name, + credsFilePath, + setCredsFilePath, + onSelectFile: handleSelectFile, + loading, + setLoading, + setError, + onSuccess, + }); - return () => { - if (unlisten) { - unlisten(); - } - }; - }, []); + // iFlow 表单 + const iflowForm = IFlowForm({ + name, + credsFilePath, + setCredsFilePath, + onSelectFile: handleSelectFile, + loading, + setLoading, + setError, + onSuccess, + }); - const handleAntigravityLogin = async () => { - setLoading(true); - setError(null); - setAntigravityAuthUrl(null); - - try { - const trimmedName = name.trim() || undefined; - await providerPoolApi.startAntigravityOAuthLogin(trimmedName, false); - onSuccess(); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - } finally { - setLoading(false); - } - }; - - // 获取授权 URL 并启动服务器等待回调 - const handleGetAntigravityAuthUrl = async () => { - setLoading(true); - setError(null); - setAntigravityAuthUrl(null); - setUrlCopied(false); - setWaitingForCallback(true); - - try { - const trimmedName = name.trim() || undefined; - // 调用后端:启动服务器并等待回调 - // 授权 URL 会通过事件发送 - await providerPoolApi.getAntigravityAuthUrlAndWait(trimmedName, false); - // 如果成功返回,说明授权完成 - onSuccess(); - } catch (e) { - const errorMsg = e instanceof Error ? e.message : String(e); - setError(errorMsg); - setWaitingForCallback(false); - } finally { - setLoading(false); - } - }; - - // 复制授权 URL - const handleCopyAuthUrl = () => { - if (antigravityAuthUrl) { - navigator.clipboard.writeText(antigravityAuthUrl); - setUrlCopied(true); - setTimeout(() => setUrlCopied(false), 2000); - } - }; + // Gemini 表单 + const geminiForm = GeminiForm({ + name, + credsFilePath, + setCredsFilePath, + projectId, + setProjectId, + onSelectFile: handleSelectFile, + loading, + setLoading, + setError, + onSuccess, + }); + // 简单 OAuth 和 API Key 的提交处理 const handleSubmit = async () => { - // Antigravity 登录模式单独处理 - if (providerType === "antigravity" && antigravityMode === "login") { - await handleAntigravityLogin(); - return; - } - setLoading(true); setError(null); try { const trimmedName = name.trim() || undefined; - if (isOAuth) { + if (isSimpleOAuth) { if (!credsFilePath) { setError("请选择凭证文件"); + setLoading(false); return; } @@ -189,29 +160,11 @@ export function AddCredentialModal({ trimmedName, ); break; - case "qwen": - await providerPoolApi.addQwenOAuth(credsFilePath, trimmedName); - break; - case "antigravity": - await providerPoolApi.addAntigravityOAuth( - credsFilePath, - projectId.trim() || undefined, - trimmedName, - ); - break; - case "codex": - await providerPoolApi.addCodexOAuth(credsFilePath, trimmedName); - break; - case "claude_oauth": - await providerPoolApi.addClaudeOAuth(credsFilePath, trimmedName); - break; - case "iflow": - await providerPoolApi.addIFlowOAuth(credsFilePath, trimmedName); - break; } - } else { + } else if (isApiKey) { if (!apiKey) { setError("请输入 API Key"); + setLoading(false); return; } @@ -241,138 +194,283 @@ export function AddCredentialModal({ } }; - // Antigravity 特殊渲染 - const renderAntigravityContent = () => ( + // 渲染简单 OAuth 表单 + const renderSimpleOAuthForm = () => ( <> - {/* 模式选择 */} -
- - +
+ +
+ setCredsFilePath(e.target.value)} + placeholder="输入凭证文件的完整路径..." + className="flex-1 rounded-lg border bg-background px-3 py-2 text-sm" + /> + +
+

+ {providerType === "kiro" && + "默认路径: ~/.aws/sso/cache/kiro-auth-token.json"} + {providerType === "gemini" && "默认路径: ~/.gemini/oauth_creds.json"} +

- {antigravityMode === "login" ? ( -
-
-

- 点击下方按钮获取授权 - URL,然后复制到浏览器(支持指纹浏览器)完成登录。 -

-

- 授权成功后,凭证将自动保存并添加到凭证池。 -

-
- - {/* 授权 URL 显示区域 */} - {antigravityAuthUrl && ( -
-
-
- 授权 URL - -
-

- {antigravityAuthUrl.slice(0, 100)}... -

-
- - {waitingForCallback && ( -
-
- -

- 请复制上方 URL 到浏览器完成登录,正在等待授权回调... -

-
-
- )} -
- )} + {providerType === "gemini" && ( +
+ + setProjectId(e.target.value)} + placeholder="Google Cloud Project ID..." + className="w-full rounded-lg border bg-background px-3 py-2 text-sm" + />
- ) : ( - <> - {/* 文件选择 */} -
- -
- setCredsFilePath(e.target.value)} - placeholder="选择 accounts.json 或 oauth_creds.json..." - className="flex-1 rounded-lg border bg-background px-3 py-2 text-sm" - /> - -
-

- 支持 antigravity2api-nodejs 的 data/accounts.json 格式 -

-
- - {/* Project ID */} -
- - setProjectId(e.target.value)} - placeholder="Google Cloud Project ID..." - className="w-full rounded-lg border bg-background px-3 py-2 text-sm" - /> -
- )} ); + // 渲染 API Key 表单 + const renderApiKeyForm = () => ( + <> +
+ +
+ + setApiKey(e.target.value)} + placeholder="sk-..." + className="w-full rounded-lg border bg-background pl-10 pr-3 py-2 text-sm" + /> +
+
+ +
+ + setBaseUrl(e.target.value)} + placeholder={ + providerType === "openai" + ? "https://api.openai.com/v1" + : "https://api.anthropic.com/v1" + } + className="w-full rounded-lg border bg-background px-3 py-2 text-sm" + /> +

+ 留空使用默认 URL,或输入自定义代理地址 +

+
+ + ); + + // 渲染底部按钮 + const renderFooterButton = () => { + // Antigravity 登录模式 + if (providerType === "antigravity" && antigravityForm.mode === "login") { + if (!antigravityForm.authUrl) { + return ( + + ); + } + return null; + } + + // Antigravity 文件模式 + if (providerType === "antigravity" && antigravityForm.mode === "file") { + return ( + + ); + } + + // Codex 登录模式 + if (providerType === "codex" && codexForm.mode === "login") { + if (!codexForm.authUrl) { + return ( + + ); + } + return null; + } + + // Codex 文件模式 + if (providerType === "codex" && codexForm.mode === "file") { + return ( + + ); + } + + // Claude OAuth 登录模式 + if (providerType === "claude_oauth" && claudeOAuthForm.mode === "login") { + if (!claudeOAuthForm.authUrl) { + return ( + + ); + } + return null; + } + + // Claude OAuth 文件模式 + if (providerType === "claude_oauth" && claudeOAuthForm.mode === "file") { + return ( + + ); + } + + // Qwen 登录模式 + if (providerType === "qwen" && qwenForm.mode === "login") { + if (!qwenForm.deviceCode) { + return ( + + ); + } + return null; + } + + // Qwen 文件模式 + if (providerType === "qwen" && qwenForm.mode === "file") { + return ( + + ); + } + + // iFlow 登录模式 + if (providerType === "iflow" && iflowForm.mode === "login") { + if (!iflowForm.authUrl) { + return ( + + ); + } + return null; + } + + // iFlow 文件模式 + if (providerType === "iflow" && iflowForm.mode === "file") { + return ( + + ); + } + + // Gemini 登录模式 + if (providerType === "gemini" && geminiForm.mode === "login") { + if (!geminiForm.authUrl) { + return ( + + ); + } + return null; + } + + // Gemini 文件模式 + if (providerType === "gemini" && geminiForm.mode === "file") { + return ( + + ); + } + + // 其他类型 + return ( + + ); + }; + return (
@@ -388,7 +486,7 @@ export function AddCredentialModal({ {/* Content */}
- {/* Name field */} + {/* 名称字段 */}
- {providerType === "antigravity" ? ( - renderAntigravityContent() - ) : isOAuth ? ( - <> - {/* Credential File */} -
- -
- setCredsFilePath(e.target.value)} - placeholder="输入凭证文件的完整路径..." - className="flex-1 rounded-lg border bg-background px-3 py-2 text-sm" - /> - -
-

- {providerType === "kiro" && - "默认路径: ~/.aws/sso/cache/kiro-auth-token.json"} - {providerType === "gemini" && - "默认路径: ~/.gemini/oauth_creds.json"} - {providerType === "qwen" && - "默认路径: ~/.qwen/oauth_creds.json"} - {providerType === "codex" && "默认路径: ~/.codex/oauth.json"} - {providerType === "claude_oauth" && - "默认路径: ~/.claude/oauth.json"} - {providerType === "iflow" && - "默认路径: ~/.iflow/oauth_creds.json"} -

-
+ {/* 根据类型渲染不同表单 */} + {providerType === "antigravity" && antigravityForm.render()} + {providerType === "codex" && codexForm.render()} + {providerType === "claude_oauth" && claudeOAuthForm.render()} + {providerType === "qwen" && qwenForm.render()} + {providerType === "iflow" && iflowForm.render()} + {providerType === "gemini" && geminiForm.render()} + {isSimpleOAuth && renderSimpleOAuthForm()} + {isApiKey && renderApiKeyForm()} - {/* Gemini specific: Project ID */} - {providerType === "gemini" && ( -
- - setProjectId(e.target.value)} - placeholder="Google Cloud Project ID..." - className="w-full rounded-lg border bg-background px-3 py-2 text-sm" - /> -
- )} - - ) : ( - <> - {/* API Key */} -
- -
- - setApiKey(e.target.value)} - placeholder="sk-..." - className="w-full rounded-lg border bg-background pl-10 pr-3 py-2 text-sm" - /> -
-
- - {/* Base URL */} -
- - setBaseUrl(e.target.value)} - placeholder={ - providerType === "openai" - ? "https://api.openai.com/v1" - : "https://api.anthropic.com/v1" - } - className="w-full rounded-lg border bg-background px-3 py-2 text-sm" - /> -

- 留空使用默认 URL,或输入自定义代理地址 -

-
- - )} - - {/* Error */} + {/* 错误提示 */} {error && (
{error} @@ -517,26 +526,7 @@ export function AddCredentialModal({ > 取消 - {providerType === "antigravity" && antigravityMode === "login" ? ( - // Antigravity 登录模式:显示获取授权 URL 按钮 - !antigravityAuthUrl && ( - - ) - ) : ( - - )} + {renderFooterButton()}
diff --git a/src/components/provider-pool/credential-forms/AntigravityForm.tsx b/src/components/provider-pool/credential-forms/AntigravityForm.tsx new file mode 100644 index 000000000..51bd01e09 --- /dev/null +++ b/src/components/provider-pool/credential-forms/AntigravityForm.tsx @@ -0,0 +1,155 @@ +/** + * Antigravity 凭证添加表单 + * 支持 Google OAuth 登录和文件导入两种模式 + */ + +import { useState, useEffect } from "react"; +import { listen } from "@tauri-apps/api/event"; +import { providerPoolApi } from "@/lib/api/providerPool"; +import { ModeSelector } from "./ModeSelector"; +import { FileImportForm } from "./FileImportForm"; +import { OAuthUrlDisplay } from "./OAuthUrlDisplay"; + +interface AntigravityFormProps { + name: string; + credsFilePath: string; + setCredsFilePath: (path: string) => void; + projectId: string; + setProjectId: (id: string) => void; + onSelectFile: () => void; + loading: boolean; + setLoading: (loading: boolean) => void; + setError: (error: string | null) => void; + onSuccess: () => void; +} + +export function AntigravityForm({ + name, + credsFilePath, + setCredsFilePath, + projectId, + setProjectId, + onSelectFile, + loading: _loading, + setLoading, + setError, + onSuccess, +}: AntigravityFormProps) { + const [mode, setMode] = useState<"login" | "file">("login"); + const [authUrl, setAuthUrl] = useState(null); + const [waitingForCallback, setWaitingForCallback] = useState(false); + + // 监听后端发送的授权 URL 事件 + useEffect(() => { + let unlisten: (() => void) | undefined; + + const setupListener = async () => { + unlisten = await listen<{ auth_url: string }>( + "antigravity-auth-url", + (event) => { + setAuthUrl(event.payload.auth_url); + }, + ); + }; + + setupListener(); + + return () => { + if (unlisten) unlisten(); + }; + }, []); + + // 获取授权 URL 并启动服务器等待回调 + const handleGetAuthUrl = async () => { + setLoading(true); + setError(null); + setAuthUrl(null); + setWaitingForCallback(true); + + try { + const trimmedName = name.trim() || undefined; + await providerPoolApi.getAntigravityAuthUrlAndWait(trimmedName, false); + onSuccess(); + } catch (e) { + const errorMsg = e instanceof Error ? e.message : String(e); + setError(errorMsg); + setWaitingForCallback(false); + } finally { + setLoading(false); + } + }; + + // 文件导入提交 + const handleFileSubmit = async () => { + if (!credsFilePath) { + setError("请选择凭证文件"); + return; + } + + setLoading(true); + setError(null); + + try { + const trimmedName = name.trim() || undefined; + await providerPoolApi.addAntigravityOAuth( + credsFilePath, + projectId.trim() || undefined, + trimmedName, + ); + onSuccess(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }; + + return { + mode, + authUrl, + waitingForCallback, + handleGetAuthUrl, + handleFileSubmit, + render: () => ( + <> + + + {mode === "login" ? ( +
+
+

+ 点击下方按钮获取授权 + URL,然后复制到浏览器(支持指纹浏览器)完成登录。 +

+

+ 授权成功后,凭证将自动保存并添加到凭证池。 +

+
+ + +
+ ) : ( + + )} + + ), + }; +} diff --git a/src/components/provider-pool/credential-forms/ClaudeOAuthForm.tsx b/src/components/provider-pool/credential-forms/ClaudeOAuthForm.tsx new file mode 100644 index 000000000..7e3dc9f77 --- /dev/null +++ b/src/components/provider-pool/credential-forms/ClaudeOAuthForm.tsx @@ -0,0 +1,144 @@ +/** + * Claude OAuth 凭证添加表单 + * 支持 Claude OAuth 登录和文件导入两种模式 + */ + +import { useState, useEffect } from "react"; +import { listen } from "@tauri-apps/api/event"; +import { providerPoolApi } from "@/lib/api/providerPool"; +import { ModeSelector } from "./ModeSelector"; +import { FileImportForm } from "./FileImportForm"; +import { OAuthUrlDisplay } from "./OAuthUrlDisplay"; + +interface ClaudeOAuthFormProps { + name: string; + credsFilePath: string; + setCredsFilePath: (path: string) => void; + onSelectFile: () => void; + loading: boolean; + setLoading: (loading: boolean) => void; + setError: (error: string | null) => void; + onSuccess: () => void; +} + +export function ClaudeOAuthForm({ + name, + credsFilePath, + setCredsFilePath, + onSelectFile, + loading: _loading, + setLoading, + setError, + onSuccess, +}: ClaudeOAuthFormProps) { + const [mode, setMode] = useState<"login" | "file">("login"); + const [authUrl, setAuthUrl] = useState(null); + const [waitingForCallback, setWaitingForCallback] = useState(false); + + // 监听后端发送的授权 URL 事件 + useEffect(() => { + let unlisten: (() => void) | undefined; + + const setupListener = async () => { + unlisten = await listen<{ auth_url: string }>( + "claude-oauth-auth-url", + (event) => { + setAuthUrl(event.payload.auth_url); + }, + ); + }; + + setupListener(); + + return () => { + if (unlisten) unlisten(); + }; + }, []); + + // 获取授权 URL 并启动服务器等待回调 + const handleGetAuthUrl = async () => { + setLoading(true); + setError(null); + setAuthUrl(null); + setWaitingForCallback(true); + + try { + const trimmedName = name.trim() || undefined; + await providerPoolApi.getClaudeOAuthAuthUrlAndWait(trimmedName); + onSuccess(); + } catch (e) { + const errorMsg = e instanceof Error ? e.message : String(e); + setError(errorMsg); + setWaitingForCallback(false); + } finally { + setLoading(false); + } + }; + + // 文件导入提交 + const handleFileSubmit = async () => { + if (!credsFilePath) { + setError("请选择凭证文件"); + return; + } + + setLoading(true); + setError(null); + + try { + const trimmedName = name.trim() || undefined; + await providerPoolApi.addClaudeOAuth(credsFilePath, trimmedName); + onSuccess(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }; + + return { + mode, + authUrl, + waitingForCallback, + handleGetAuthUrl, + handleFileSubmit, + render: () => ( + <> + + + {mode === "login" ? ( +
+
+

+ 点击下方按钮获取授权 URL,然后复制到浏览器(支持指纹浏览器)完成 + Claude 登录。 +

+

+ 授权成功后,凭证将自动保存并添加到凭证池。 +

+
+ + +
+ ) : ( + + )} + + ), + }; +} diff --git a/src/components/provider-pool/credential-forms/CodexForm.tsx b/src/components/provider-pool/credential-forms/CodexForm.tsx new file mode 100644 index 000000000..94f3c1846 --- /dev/null +++ b/src/components/provider-pool/credential-forms/CodexForm.tsx @@ -0,0 +1,144 @@ +/** + * Codex 凭证添加表单 + * 支持 OpenAI OAuth 登录和文件导入两种模式 + */ + +import { useState, useEffect } from "react"; +import { listen } from "@tauri-apps/api/event"; +import { providerPoolApi } from "@/lib/api/providerPool"; +import { ModeSelector } from "./ModeSelector"; +import { FileImportForm } from "./FileImportForm"; +import { OAuthUrlDisplay } from "./OAuthUrlDisplay"; + +interface CodexFormProps { + name: string; + credsFilePath: string; + setCredsFilePath: (path: string) => void; + onSelectFile: () => void; + loading: boolean; + setLoading: (loading: boolean) => void; + setError: (error: string | null) => void; + onSuccess: () => void; +} + +export function CodexForm({ + name, + credsFilePath, + setCredsFilePath, + onSelectFile, + loading: _loading, + setLoading, + setError, + onSuccess, +}: CodexFormProps) { + const [mode, setMode] = useState<"login" | "file">("login"); + const [authUrl, setAuthUrl] = useState(null); + const [waitingForCallback, setWaitingForCallback] = useState(false); + + // 监听后端发送的授权 URL 事件 + useEffect(() => { + let unlisten: (() => void) | undefined; + + const setupListener = async () => { + unlisten = await listen<{ auth_url: string }>( + "codex-auth-url", + (event) => { + setAuthUrl(event.payload.auth_url); + }, + ); + }; + + setupListener(); + + return () => { + if (unlisten) unlisten(); + }; + }, []); + + // 获取授权 URL 并启动服务器等待回调 + const handleGetAuthUrl = async () => { + setLoading(true); + setError(null); + setAuthUrl(null); + setWaitingForCallback(true); + + try { + const trimmedName = name.trim() || undefined; + await providerPoolApi.getCodexAuthUrlAndWait(trimmedName); + onSuccess(); + } catch (e) { + const errorMsg = e instanceof Error ? e.message : String(e); + setError(errorMsg); + setWaitingForCallback(false); + } finally { + setLoading(false); + } + }; + + // 文件导入提交 + const handleFileSubmit = async () => { + if (!credsFilePath) { + setError("请选择凭证文件"); + return; + } + + setLoading(true); + setError(null); + + try { + const trimmedName = name.trim() || undefined; + await providerPoolApi.addCodexOAuth(credsFilePath, trimmedName); + onSuccess(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }; + + return { + mode, + authUrl, + waitingForCallback, + handleGetAuthUrl, + handleFileSubmit, + render: () => ( + <> + + + {mode === "login" ? ( +
+
+

+ 点击下方按钮获取授权 URL,然后复制到浏览器(支持指纹浏览器)完成 + OpenAI 登录。 +

+

+ 授权成功后,凭证将自动保存并添加到凭证池。 +

+
+ + +
+ ) : ( + + )} + + ), + }; +} diff --git a/src/components/provider-pool/credential-forms/FileImportForm.tsx b/src/components/provider-pool/credential-forms/FileImportForm.tsx new file mode 100644 index 000000000..a67f3cda3 --- /dev/null +++ b/src/components/provider-pool/credential-forms/FileImportForm.tsx @@ -0,0 +1,70 @@ +/** + * 文件导入表单组件 + */ + +import { FolderOpen } from "lucide-react"; + +interface FileImportFormProps { + credsFilePath: string; + setCredsFilePath: (path: string) => void; + onSelectFile: () => void; + placeholder?: string; + hint?: string; + projectId?: string; + setProjectId?: (id: string) => void; + showProjectId?: boolean; +} + +export function FileImportForm({ + credsFilePath, + setCredsFilePath, + onSelectFile, + placeholder = "选择凭证文件...", + hint, + projectId, + setProjectId, + showProjectId = false, +}: FileImportFormProps) { + return ( + <> +
+ +
+ setCredsFilePath(e.target.value)} + placeholder={placeholder} + className="flex-1 rounded-lg border bg-background px-3 py-2 text-sm" + /> + +
+ {hint &&

{hint}

} +
+ + {showProjectId && setProjectId && ( +
+ + setProjectId(e.target.value)} + placeholder="Google Cloud Project ID..." + className="w-full rounded-lg border bg-background px-3 py-2 text-sm" + /> +
+ )} + + ); +} diff --git a/src/components/provider-pool/credential-forms/GeminiForm.tsx b/src/components/provider-pool/credential-forms/GeminiForm.tsx new file mode 100644 index 000000000..ed9ba2d8b --- /dev/null +++ b/src/components/provider-pool/credential-forms/GeminiForm.tsx @@ -0,0 +1,270 @@ +/** + * Gemini 凭证添加表单 + * 支持 Google OAuth 登录和文件导入两种模式 + * + * Gemini OAuth 流程: + * 1. 生成授权 URL(包含 PKCE) + * 2. 用户在浏览器中打开 URL 并授权 + * 3. 浏览器跳转到 codeassist.google.com/authcode 显示 code + * 4. 用户复制 code 回应用 + * 5. 应用用 code 交换 tokens + */ + +import { useState, useEffect } from "react"; +import { listen } from "@tauri-apps/api/event"; +import { providerPoolApi } from "@/lib/api/providerPool"; +import { ModeSelector } from "./ModeSelector"; +import { FileImportForm } from "./FileImportForm"; +import { Copy, Check, Loader2 } from "lucide-react"; + +interface GeminiFormProps { + name: string; + credsFilePath: string; + setCredsFilePath: (path: string) => void; + projectId: string; + setProjectId: (id: string) => void; + onSelectFile: () => void; + loading: boolean; + setLoading: (loading: boolean) => void; + setError: (error: string | null) => void; + onSuccess: () => void; +} + +export function GeminiForm({ + name, + credsFilePath, + setCredsFilePath, + projectId, + setProjectId, + onSelectFile, + loading: _loading, + setLoading, + setError, + onSuccess, +}: GeminiFormProps) { + const [mode, setMode] = useState<"login" | "file">("login"); + const [authUrl, setAuthUrl] = useState(null); + const [sessionId, setSessionId] = useState(null); + const [authCode, setAuthCode] = useState(""); + const [copied, setCopied] = useState(false); + const [exchanging, setExchanging] = useState(false); + + // 监听后端发送的授权 URL 事件 + useEffect(() => { + let unlisten: (() => void) | undefined; + + const setupListener = async () => { + unlisten = await listen<{ auth_url: string; session_id: string }>( + "gemini-auth-url", + (event) => { + console.log("[Gemini OAuth] 收到授权 URL 事件:", event.payload); + setAuthUrl(event.payload.auth_url); + setSessionId(event.payload.session_id); + }, + ); + }; + + setupListener(); + + return () => { + if (unlisten) unlisten(); + }; + }, []); + + // 获取授权 URL + const handleGetAuthUrl = async () => { + setLoading(true); + setError(null); + setAuthUrl(null); + setSessionId(null); + setAuthCode(""); + + try { + // 调用后端生成授权 URL + await providerPoolApi.getGeminiAuthUrlAndWait(name.trim() || undefined); + // URL 会通过事件返回 + } catch (e) { + const errorMsg = e instanceof Error ? e.message : String(e); + // 检查是否包含 AUTH_URL 前缀(后端返回的授权 URL) + if (errorMsg.includes("AUTH_URL:")) { + const urlMatch = errorMsg.match(/AUTH_URL:(.+?)(?:\s|$)/); + if (urlMatch) { + setAuthUrl(urlMatch[1]); + } + } else { + setError(errorMsg); + } + } finally { + setLoading(false); + } + }; + + // 用 code 交换 token + const handleExchangeCode = async () => { + if (!authCode.trim()) { + setError("请输入授权码"); + return; + } + + setExchanging(true); + setError(null); + + try { + const trimmedName = name.trim() || undefined; + await providerPoolApi.exchangeGeminiCode( + authCode.trim(), + sessionId || undefined, + trimmedName, + ); + onSuccess(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setExchanging(false); + } + }; + + // 复制 URL + const handleCopyUrl = async () => { + if (authUrl) { + await navigator.clipboard.writeText(authUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + // 文件导入提交 + const handleFileSubmit = async () => { + if (!credsFilePath) { + setError("请选择凭证文件"); + return; + } + + setLoading(true); + setError(null); + + try { + const trimmedName = name.trim() || undefined; + await providerPoolApi.addGeminiOAuth( + credsFilePath, + projectId.trim() || undefined, + trimmedName, + ); + onSuccess(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }; + + return { + mode, + authUrl, + sessionId, + authCode, + exchanging, + handleGetAuthUrl, + handleExchangeCode, + handleFileSubmit, + render: () => ( + <> + + + {mode === "login" ? ( +
+
+

+ 点击下方按钮获取授权 URL,然后复制到浏览器完成 Google 登录。 +

+

+ 授权成功后,复制页面显示的授权码粘贴到下方输入框。 +

+
+ + {/* 授权 URL 显示 */} + {authUrl && ( +
+
+ 授权 URL + +
+
+

+ {authUrl.length > 100 + ? `${authUrl.slice(0, 100)}...` + : authUrl} +

+
+ + {/* 授权码输入 */} +
+ + setAuthCode(e.target.value)} + placeholder="粘贴浏览器页面显示的授权码..." + className="w-full rounded-lg border bg-background px-3 py-2 text-sm" + /> +

+ 在浏览器中完成授权后,复制页面显示的授权码 +

+
+ + {/* 提交按钮 */} + +
+ )} +
+ ) : ( + + )} + + ), + }; +} diff --git a/src/components/provider-pool/credential-forms/IFlowForm.tsx b/src/components/provider-pool/credential-forms/IFlowForm.tsx new file mode 100644 index 000000000..a13c11483 --- /dev/null +++ b/src/components/provider-pool/credential-forms/IFlowForm.tsx @@ -0,0 +1,144 @@ +/** + * iFlow 凭证添加表单 + * 支持 OAuth 登录和文件导入两种模式 + */ + +import { useState, useEffect } from "react"; +import { listen } from "@tauri-apps/api/event"; +import { providerPoolApi } from "@/lib/api/providerPool"; +import { ModeSelector } from "./ModeSelector"; +import { FileImportForm } from "./FileImportForm"; +import { OAuthUrlDisplay } from "./OAuthUrlDisplay"; + +interface IFlowFormProps { + name: string; + credsFilePath: string; + setCredsFilePath: (path: string) => void; + onSelectFile: () => void; + loading: boolean; + setLoading: (loading: boolean) => void; + setError: (error: string | null) => void; + onSuccess: () => void; +} + +export function IFlowForm({ + name, + credsFilePath, + setCredsFilePath, + onSelectFile, + loading: _loading, + setLoading, + setError, + onSuccess, +}: IFlowFormProps) { + const [mode, setMode] = useState<"login" | "file">("login"); + const [authUrl, setAuthUrl] = useState(null); + const [waitingForCallback, setWaitingForCallback] = useState(false); + + // 监听后端发送的授权 URL 事件 + useEffect(() => { + let unlisten: (() => void) | undefined; + + const setupListener = async () => { + unlisten = await listen<{ auth_url: string }>( + "iflow-auth-url", + (event) => { + setAuthUrl(event.payload.auth_url); + }, + ); + }; + + setupListener(); + + return () => { + if (unlisten) unlisten(); + }; + }, []); + + // 获取授权 URL 并启动服务器等待回调 + const handleGetAuthUrl = async () => { + setLoading(true); + setError(null); + setAuthUrl(null); + setWaitingForCallback(true); + + try { + const trimmedName = name.trim() || undefined; + await providerPoolApi.getIFlowAuthUrlAndWait(trimmedName); + onSuccess(); + } catch (e) { + const errorMsg = e instanceof Error ? e.message : String(e); + setError(errorMsg); + setWaitingForCallback(false); + } finally { + setLoading(false); + } + }; + + // 文件导入提交 + const handleFileSubmit = async () => { + if (!credsFilePath) { + setError("请选择凭证文件"); + return; + } + + setLoading(true); + setError(null); + + try { + const trimmedName = name.trim() || undefined; + await providerPoolApi.addIFlowOAuth(credsFilePath, trimmedName); + onSuccess(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }; + + return { + mode, + authUrl, + waitingForCallback, + handleGetAuthUrl, + handleFileSubmit, + render: () => ( + <> + + + {mode === "login" ? ( +
+
+

+ 点击下方按钮获取授权 URL,然后复制到浏览器(支持指纹浏览器)完成 + iFlow 登录。 +

+

+ 授权成功后,凭证将自动保存并添加到凭证池。 +

+
+ + +
+ ) : ( + + )} + + ), + }; +} diff --git a/src/components/provider-pool/credential-forms/ModeSelector.tsx b/src/components/provider-pool/credential-forms/ModeSelector.tsx new file mode 100644 index 000000000..67839199e --- /dev/null +++ b/src/components/provider-pool/credential-forms/ModeSelector.tsx @@ -0,0 +1,48 @@ +/** + * 登录/文件导入模式选择器 + */ + +import { LogIn, FolderOpen } from "lucide-react"; + +interface ModeSelectorProps { + mode: "login" | "file"; + setMode: (mode: "login" | "file") => void; + loginLabel?: string; + fileLabel?: string; +} + +export function ModeSelector({ + mode, + setMode, + loginLabel = "登录", + fileLabel = "导入文件", +}: ModeSelectorProps) { + return ( +
+ + +
+ ); +} diff --git a/src/components/provider-pool/credential-forms/OAuthUrlDisplay.tsx b/src/components/provider-pool/credential-forms/OAuthUrlDisplay.tsx new file mode 100644 index 000000000..dff245565 --- /dev/null +++ b/src/components/provider-pool/credential-forms/OAuthUrlDisplay.tsx @@ -0,0 +1,72 @@ +/** + * OAuth 授权 URL 显示组件 + * 用于显示授权 URL 和等待回调状态 + */ + +import { useState } from "react"; +import { Copy, Check, Loader2 } from "lucide-react"; + +interface OAuthUrlDisplayProps { + authUrl: string | null; + waitingForCallback: boolean; + colorScheme?: "blue" | "green" | "purple" | "amber"; +} + +export function OAuthUrlDisplay({ + authUrl, + waitingForCallback, + colorScheme: _colorScheme = "blue", +}: OAuthUrlDisplayProps) { + const [urlCopied, setUrlCopied] = useState(false); + + const handleCopyUrl = () => { + if (authUrl) { + navigator.clipboard.writeText(authUrl); + setUrlCopied(true); + setTimeout(() => setUrlCopied(false), 2000); + } + }; + + if (!authUrl) return null; + + return ( +
+
+
+ 授权 URL + +
+

+ {authUrl.slice(0, 100)}... +

+
+ + {waitingForCallback && ( +
+
+ +

+ 请复制上方 URL 到浏览器完成登录,正在等待授权回调... +

+
+
+ )} +
+ ); +} diff --git a/src/components/provider-pool/credential-forms/QwenForm.tsx b/src/components/provider-pool/credential-forms/QwenForm.tsx new file mode 100644 index 000000000..d032a49d5 --- /dev/null +++ b/src/components/provider-pool/credential-forms/QwenForm.tsx @@ -0,0 +1,215 @@ +/** + * Qwen 凭证添加表单 + * 支持 Device Code Flow 登录和文件导入两种模式 + */ + +import { useState, useEffect } from "react"; +import { listen } from "@tauri-apps/api/event"; +import { open } from "@tauri-apps/plugin-shell"; +import { providerPoolApi } from "@/lib/api/providerPool"; +import { ModeSelector } from "./ModeSelector"; +import { FileImportForm } from "./FileImportForm"; +import { Copy, Check, ExternalLink } from "lucide-react"; + +interface QwenFormProps { + name: string; + credsFilePath: string; + setCredsFilePath: (path: string) => void; + onSelectFile: () => void; + loading: boolean; + setLoading: (loading: boolean) => void; + setError: (error: string | null) => void; + onSuccess: () => void; +} + +interface DeviceCodeInfo { + user_code: string; + verification_uri: string; + verification_uri_complete?: string; + expires_in: number; +} + +export function QwenForm({ + name, + credsFilePath, + setCredsFilePath, + onSelectFile, + loading: _loading, + setLoading, + setError, + onSuccess, +}: QwenFormProps) { + const [mode, setMode] = useState<"login" | "file">("login"); + const [deviceCode, setDeviceCode] = useState(null); + const [waitingForAuth, setWaitingForAuth] = useState(false); + const [copied, setCopied] = useState(false); + + // 监听后端发送的设备码事件 + useEffect(() => { + let unlisten: (() => void) | undefined; + + const setupListener = async () => { + unlisten = await listen("qwen-device-code", (event) => { + setDeviceCode(event.payload); + }); + }; + + setupListener(); + + return () => { + if (unlisten) unlisten(); + }; + }, []); + + // 复制用户码 + const handleCopyCode = async () => { + if (deviceCode?.user_code) { + await navigator.clipboard.writeText(deviceCode.user_code); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + // 打开验证链接 + const handleOpenVerificationUrl = async () => { + if (deviceCode) { + const url = + deviceCode.verification_uri_complete || deviceCode.verification_uri; + try { + await open(url); + } catch (e) { + console.error("Failed to open URL:", e); + // 如果 Tauri shell 失败,尝试使用 window.open 作为后备 + window.open(url, "_blank"); + } + } + }; + + // 获取设备码并启动轮询 + const handleGetDeviceCode = async () => { + setLoading(true); + setError(null); + setDeviceCode(null); + setWaitingForAuth(true); + + try { + const trimmedName = name.trim() || undefined; + await providerPoolApi.getQwenDeviceCodeAndWait(trimmedName); + onSuccess(); + } catch (e) { + const errorMsg = e instanceof Error ? e.message : String(e); + setError(errorMsg); + setWaitingForAuth(false); + } finally { + setLoading(false); + } + }; + + // 文件导入提交 + const handleFileSubmit = async () => { + if (!credsFilePath) { + setError("请选择凭证文件"); + return; + } + + setLoading(true); + setError(null); + + try { + const trimmedName = name.trim() || undefined; + await providerPoolApi.addQwenOAuth(credsFilePath, trimmedName); + onSuccess(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }; + + return { + mode, + deviceCode, + waitingForAuth, + handleGetDeviceCode, + handleFileSubmit, + render: () => ( + <> + + + {mode === "login" ? ( +
+
+

+ 点击下方按钮获取设备码,然后在浏览器中完成 Qwen 登录授权。 +

+

+ 授权成功后,凭证将自动保存并添加到凭证池。 +

+
+ + {deviceCode && ( +
+ {/* 用户码显示 */} +
+

+ 请在浏览器中输入以下验证码: +

+
+ + {deviceCode.user_code} + + +
+
+ + {/* 验证链接 */} + + + {waitingForAuth && ( +
+
+ 等待授权中... +
+ )} +
+ )} +
+ ) : ( + + )} + + ), + }; +} diff --git a/src/components/provider-pool/credential-forms/index.ts b/src/components/provider-pool/credential-forms/index.ts new file mode 100644 index 000000000..867fd7c52 --- /dev/null +++ b/src/components/provider-pool/credential-forms/index.ts @@ -0,0 +1,14 @@ +/** + * 凭证表单组件导出 + */ + +export * from "./types"; +export * from "./ModeSelector"; +export * from "./OAuthUrlDisplay"; +export * from "./FileImportForm"; +export * from "./AntigravityForm"; +export * from "./CodexForm"; +export * from "./ClaudeOAuthForm"; +export * from "./QwenForm"; +export * from "./IFlowForm"; +export * from "./GeminiForm"; diff --git a/src/components/provider-pool/credential-forms/types.ts b/src/components/provider-pool/credential-forms/types.ts new file mode 100644 index 000000000..cad5e3290 --- /dev/null +++ b/src/components/provider-pool/credential-forms/types.ts @@ -0,0 +1,59 @@ +/** + * 凭证表单共享类型定义 + */ + +import { PoolProviderType } from "@/lib/api/providerPool"; + +/** OAuth 登录表单的通用 Props */ +export interface OAuthLoginFormProps { + name: string; + loading: boolean; + error: string | null; + onSuccess: () => void; + setLoading: (loading: boolean) => void; + setError: (error: string | null) => void; +} + +/** 文件导入表单的通用 Props */ +export interface FileImportFormProps { + credsFilePath: string; + setCredsFilePath: (path: string) => void; + projectId?: string; + setProjectId?: (id: string) => void; + onSelectFile: () => void; + defaultPathHint?: string; + fileHint?: string; +} + +/** API Key 表单的通用 Props */ +export interface ApiKeyFormProps { + apiKey: string; + setApiKey: (key: string) => void; + baseUrl: string; + setBaseUrl: (url: string) => void; + providerType: PoolProviderType; +} + +/** 默认凭证文件路径 */ +export const defaultCredsPath: Record = { + kiro: "~/.aws/sso/cache/kiro-auth-token.json", + gemini: "~/.gemini/oauth_creds.json", + qwen: "~/.qwen/oauth_creds.json", + antigravity: "", + codex: "~/.codex/auth.json", + claude_oauth: "~/.claude/oauth.json", + iflow: "~/.iflow/oauth_creds.json", +}; + +/** Provider 显示名称 */ +export const providerLabels: Record = { + kiro: "Kiro (AWS)", + gemini: "Gemini (Google)", + qwen: "Qwen (阿里)", + openai: "OpenAI", + claude: "Claude (Anthropic)", + antigravity: "Antigravity (Gemini 3 Pro)", + codex: "Codex (OpenAI OAuth)", + claude_oauth: "Claude OAuth", + iflow: "iFlow", +}; diff --git a/src/lib/api/providerPool.ts b/src/lib/api/providerPool.ts index 312347949..ae4894b00 100644 --- a/src/lib/api/providerPool.ts +++ b/src/lib/api/providerPool.ts @@ -383,6 +383,72 @@ export const providerPoolApi = { }); }, + // Codex OAuth 登录(打开浏览器授权) + async startCodexOAuthLogin(name?: string): Promise { + return invoke("start_codex_oauth_login", { name }); + }, + + // 获取 Codex OAuth 授权 URL 并等待回调(不自动打开浏览器) + // 服务器会在后台等待回调,成功后返回凭证 + async getCodexAuthUrlAndWait(name?: string): Promise { + return invoke("get_codex_auth_url_and_wait", { name }); + }, + + // Claude OAuth 登录(打开浏览器授权) + async startClaudeOAuthLogin(name?: string): Promise { + return invoke("start_claude_oauth_login", { name }); + }, + + // 获取 Claude OAuth 授权 URL 并等待回调(不自动打开浏览器) + // 服务器会在后台等待回调,成功后返回凭证 + async getClaudeOAuthAuthUrlAndWait( + name?: string, + ): Promise { + return invoke("get_claude_oauth_auth_url_and_wait", { name }); + }, + + // Qwen Device Code Flow 登录(打开浏览器授权) + async startQwenDeviceCodeLogin(name?: string): Promise { + return invoke("start_qwen_device_code_login", { name }); + }, + + // 获取 Qwen Device Code 并等待用户授权(不自动打开浏览器) + // 服务器会在后台轮询等待授权,成功后返回凭证 + async getQwenDeviceCodeAndWait(name?: string): Promise { + return invoke("get_qwen_device_code_and_wait", { name }); + }, + + // iFlow OAuth 登录(打开浏览器授权) + async startIFlowOAuthLogin(name?: string): Promise { + return invoke("start_iflow_oauth_login", { name }); + }, + + // 获取 iFlow OAuth 授权 URL 并等待回调(不自动打开浏览器) + // 服务器会在后台等待回调,成功后返回凭证 + async getIFlowAuthUrlAndWait(name?: string): Promise { + return invoke("get_iflow_auth_url_and_wait", { name }); + }, + + // Gemini OAuth 登录(打开浏览器授权) + async startGeminiOAuthLogin(name?: string): Promise { + return invoke("start_gemini_oauth_login", { name }); + }, + + // 获取 Gemini OAuth 授权 URL 并等待回调(不自动打开浏览器) + // 服务器会在后台等待回调,成功后返回凭证 + async getGeminiAuthUrlAndWait(name?: string): Promise { + return invoke("get_gemini_auth_url_and_wait", { name }); + }, + + // 用 Gemini 授权码交换 token + async exchangeGeminiCode( + code: string, + sessionId?: string, + name?: string, + ): Promise { + return invoke("exchange_gemini_code", { code, sessionId, name }); + }, + // OAuth token management async refreshCredentialToken(uuid: string): Promise { return invoke("refresh_pool_credential_token", { uuid });