v0.15.0: Gemini OAuth 授权码流程 + 健康检查自动刷新 Token

主要更新:
- 修复 Gemini OAuth 登录流程,改用授权码模式(用户手动复制授权码)
- 修复 Gemini 健康检查 API 端点(使用 cloudcode-pa.googleapis.com)
- 健康检查遇到 401 错误时自动尝试刷新 Token 后重试
- 添加 once_cell 依赖用于存储 OAuth 会话
- 新增 exchange_gemini_code 命令用于交换授权码
This commit is contained in:
coso
2025-12-21 01:53:54 +08:00
parent 919519d8b8
commit 2af7093b07
24 changed files with 4600 additions and 401 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "proxycast",
"private": true,
"version": "0.14.10",
"version": "0.15.0",
"type": "module",
"repository": {
"type": "git",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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"
+575
View File
@@ -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<String>,
) -> Result<ProviderCredential, String> {
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<String>,
) -> Result<ProviderCredential, String> {
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<String>,
) -> Result<ProviderCredential, String> {
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<String>,
) -> Result<ProviderCredential, String> {
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<String>,
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<String>,
) -> Result<ProviderCredential, String> {
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<String>,
) -> Result<ProviderCredential, String> {
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<String>,
) -> Result<ProviderCredential, String> {
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<String>,
) -> Result<ProviderCredential, String> {
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<HashMap<String, crate::providers::gemini::GeminiOAuthSession>>,
> = 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<String>,
) -> Result<ProviderCredential, String> {
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<String>,
name: Option<String>,
) -> Result<ProviderCredential, String> {
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<String>,
) -> Result<ProviderCredential, String> {
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)
}
+11
View File
@@ -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,
+363
View File
@@ -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::<Vec<_>>()
.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<serde_json::Value, Box<dyn Error + Send + Sync>> {
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#"<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>授权成功</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: linear-gradient(135deg, #d97706 0%, #b45309 100%); }
.container { text-align: center; background: white; padding: 40px 60px; border-radius: 16px; box-shadow: 0 10px 40px rgba(0,0,0,0.2); }
h1 { color: #d97706; margin-bottom: 16px; }
p { color: #666; margin-bottom: 8px; }
.email { color: #333; font-weight: 500; }
</style>
</head>
<body>
<div class="container">
<h1>✓ 授权成功</h1>
<p>Claude 账号已添加到 ProxyCast</p>
<p class="email">EMAIL_PLACEHOLDER</p>
<p style="margin-top: 20px; color: #999;">可以关闭此页面</p>
</div>
</body>
</html>"#;
/// OAuth 失败页面 HTML
const CLAUDE_OAUTH_ERROR_HTML: &str = r#"<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>授权失败</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); }
.container { text-align: center; background: white; padding: 40px 60px; border-radius: 16px; box-shadow: 0 10px 40px rgba(0,0,0,0.2); }
h1 { color: #ef4444; margin-bottom: 16px; }
p { color: #666; }
.error { color: #ef4444; font-size: 14px; margin-top: 16px; }
</style>
</head>
<body>
<div class="container">
<h1>✗ 授权失败</h1>
<p>ERROR_PLACEHOLDER</p>
<p style="margin-top: 20px; color: #999;">请关闭此页面后重试</p>
</div>
</body>
</html>"#;
/// 启动 OAuth 服务器并返回授权 URL(不打开浏览器)
pub async fn start_claude_oauth_server_and_get_url() -> Result<
(
String,
impl std::future::Future<Output = Result<ClaudeOAuthResult, Box<dyn Error + Send + Sync>>>,
),
Box<dyn Error + Send + Sync>,
> {
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::<Result<ClaudeOAuthResult, String>>();
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<HashMap<String, String>>| {
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<dyn Error + Send + Sync>
}),
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<ClaudeOAuthResult, Box<dyn Error + Send + Sync>> {
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
}
+399
View File
@@ -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::<Vec<_>>()
.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<serde_json::Value, Box<dyn Error + Send + Sync>> {
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(&params)
.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#"<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>授权成功</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: linear-gradient(135deg, #10a37f 0%, #1a7f64 100%); }
.container { text-align: center; background: white; padding: 40px 60px; border-radius: 16px; box-shadow: 0 10px 40px rgba(0,0,0,0.2); }
h1 { color: #10a37f; margin-bottom: 16px; }
p { color: #666; margin-bottom: 8px; }
.email { color: #333; font-weight: 500; }
</style>
</head>
<body>
<div class="container">
<h1>✓ 授权成功</h1>
<p>Codex 账号已添加到 ProxyCast</p>
<p class="email">EMAIL_PLACEHOLDER</p>
<p style="margin-top: 20px; color: #999;">可以关闭此页面</p>
</div>
</body>
</html>"#;
/// OAuth 失败页面 HTML
const CODEX_OAUTH_ERROR_HTML: &str = r#"<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>授权失败</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); }
.container { text-align: center; background: white; padding: 40px 60px; border-radius: 16px; box-shadow: 0 10px 40px rgba(0,0,0,0.2); }
h1 { color: #ef4444; margin-bottom: 16px; }
p { color: #666; }
.error { color: #ef4444; font-size: 14px; margin-top: 16px; }
</style>
</head>
<body>
<div class="container">
<h1>✗ 授权失败</h1>
<p>ERROR_PLACEHOLDER</p>
<p style="margin-top: 20px; color: #999;">请关闭此页面后重试</p>
</div>
</body>
</html>"#;
/// 启动 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<Output = Result<CodexOAuthResult, Box<dyn Error + Send + Sync>>>,
),
Box<dyn Error + Send + Sync>,
> {
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::<Result<CodexOAuthResult, String>>();
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<HashMap<String, String>>| {
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<dyn Error + Send + Sync>
}),
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<CodexOAuthResult, Box<dyn Error + Send + Sync>> {
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
}
+606
View File
@@ -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::<u8>() % 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::<Vec<_>>()
.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<GeminiOAuthResult, Box<dyn std::error::Error + Send + Sync>> {
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<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
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(&params).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<Option<String>, Box<dyn std::error::Error + Send + Sync>> {
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<Option<String>, Box<dyn std::error::Error + Send + Sync>> {
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#"<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>授权成功</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: linear-gradient(135deg, #4285f4 0%, #34a853 100%); }
.container { text-align: center; background: white; padding: 40px 60px; border-radius: 16px; box-shadow: 0 10px 40px rgba(0,0,0,0.2); }
h1 { color: #22c55e; margin-bottom: 16px; }
p { color: #666; margin-bottom: 8px; }
.email { color: #333; font-weight: 500; }
</style>
</head>
<body>
<div class="container">
<h1>✓ 授权成功</h1>
<p>Gemini 账号已添加到 ProxyCast</p>
<p class="email">EMAIL_PLACEHOLDER</p>
<p style="margin-top: 20px; color: #999;">可以关闭此页面</p>
</div>
</body>
</html>"#;
/// OAuth 失败页面 HTML
const GEMINI_OAUTH_ERROR_HTML: &str = r#"<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>授权失败</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: linear-gradient(135deg, #4285f4 0%, #34a853 100%); }
.container { text-align: center; background: white; padding: 40px 60px; border-radius: 16px; box-shadow: 0 10px 40px rgba(0,0,0,0.2); }
h1 { color: #ef4444; margin-bottom: 16px; }
p { color: #666; }
.error { color: #ef4444; font-size: 14px; margin-top: 16px; }
</style>
</head>
<body>
<div class="container">
<h1>✗ 授权失败</h1>
<p>ERROR_PLACEHOLDER</p>
<p style="margin-top: 20px; color: #999;">请关闭此页面后重试</p>
</div>
</body>
</html>"#;
/// 保存 Gemini 凭证到文件
async fn save_gemini_credentials_to_file(
credentials: &GeminiCredentials,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
// 生成唯一文件名
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<GeminiOAuthResult, Box<dyn std::error::Error + Send + Sync>>,
>,
),
Box<dyn std::error::Error + Send + Sync>,
> {
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::<Result<GeminiOAuthResult, String>>();
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<HashMap<String, String>>| {
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!("<p>Project ID: {}</p>", p))
.unwrap_or_default();
let html = GEMINI_OAUTH_SUCCESS_HTML
.replace("EMAIL_PLACEHOLDER", &email_display)
.replace(
"</div>\n</body>",
&format!("{}</div>\n</body>", 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<GeminiOAuthResult, Box<dyn std::error::Error + Send + Sync>> {
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
}
+428
View File
@@ -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#"<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>授权成功</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
.container { text-align: center; background: white; padding: 40px 60px; border-radius: 16px; box-shadow: 0 10px 40px rgba(0,0,0,0.2); }
h1 { color: #667eea; margin-bottom: 16px; }
p { color: #666; margin-bottom: 8px; }
.email { color: #333; font-weight: 500; }
</style>
</head>
<body>
<div class="container">
<h1>✓ 授权成功</h1>
<p>iFlow 账号已添加到 ProxyCast</p>
<p class="email">EMAIL_PLACEHOLDER</p>
<p style="margin-top: 20px; color: #999;">可以关闭此页面</p>
</div>
</body>
</html>"#;
/// OAuth 失败页面 HTML
const IFLOW_OAUTH_ERROR_HTML: &str = r#"<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>授权失败</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); }
.container { text-align: center; background: white; padding: 40px 60px; border-radius: 16px; box-shadow: 0 10px 40px rgba(0,0,0,0.2); }
h1 { color: #ef4444; margin-bottom: 16px; }
p { color: #666; }
.error { color: #ef4444; font-size: 14px; margin-top: 16px; }
</style>
</head>
<body>
<div class="container">
<h1>✗ 授权失败</h1>
<p>ERROR_PLACEHOLDER</p>
<p style="margin-top: 20px; color: #999;">请关闭此页面后重试</p>
</div>
</body>
</html>"#;
/// 生成 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::<Vec<_>>()
.join("&");
format!("{}?{}", IFLOW_AUTH_URL, query)
}
/// 用授权码交换 Token
pub async fn exchange_iflow_code_for_token(
client: &Client,
code: &str,
redirect_uri: &str,
) -> Result<IFlowCredentials, Box<dyn Error + Send + Sync>> {
// 构建 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(&params)
.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::<serde_json::Value>().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<Output = Result<IFlowOAuthResult, Box<dyn Error + Send + Sync>>>,
),
Box<dyn Error + Send + Sync>,
> {
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::<Result<IFlowOAuthResult, String>>();
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<HashMap<String, String>>| {
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<dyn Error + Send + Sync>
}),
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<IFlowOAuthResult, Box<dyn Error + Send + Sync>> {
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
}
+337
View File
@@ -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<String>,
/// 过期时间(秒)
#[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<dyn Error + Send + Sync>> {
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<dyn Error + Send + Sync>> {
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(&params)
.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<QwenCredentials, Box<dyn Error + Send + Sync>> {
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(&params)
.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::<serde_json::Value>(&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<QwenOAuthResult, Box<dyn Error + Send + Sync>>
{
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<Output = Result<QwenOAuthResult, Box<dyn Error + Send + Sync>>>,
),
Box<dyn Error + Send + Sync>,
> {
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))
}
+100 -13
View File
@@ -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()
@@ -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<string, string> = {
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<string | null>(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<PoolProviderType, string> = {
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<string | null>(
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 = () => (
<>
{/* 模式选择 */}
<div className="flex gap-2 mb-4">
<button
type="button"
onClick={() => setAntigravityMode("login")}
className={`flex-1 rounded-lg border px-3 py-2 text-sm ${
antigravityMode === "login"
? "border-primary bg-primary/10 text-primary"
: "hover:bg-muted"
}`}
>
<LogIn className="inline h-4 w-4 mr-1" />
Google 登录
</button>
<button
type="button"
onClick={() => setAntigravityMode("file")}
className={`flex-1 rounded-lg border px-3 py-2 text-sm ${
antigravityMode === "file"
? "border-primary bg-primary/10 text-primary"
: "hover:bg-muted"
}`}
>
<FolderOpen className="inline h-4 w-4 mr-1" />
导入文件
</button>
<div>
<label className="mb-1 block text-sm font-medium">
凭证文件路径 <span className="text-red-500">*</span>
</label>
<div className="flex gap-2">
<input
type="text"
value={credsFilePath}
onChange={(e) => setCredsFilePath(e.target.value)}
placeholder="输入凭证文件的完整路径..."
className="flex-1 rounded-lg border bg-background px-3 py-2 text-sm"
/>
<button
type="button"
onClick={handleSelectFile}
className="flex items-center gap-1 rounded-lg border px-3 py-2 text-sm hover:bg-muted"
>
<FolderOpen className="h-4 w-4" />
浏览
</button>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{providerType === "kiro" &&
"默认路径: ~/.aws/sso/cache/kiro-auth-token.json"}
{providerType === "gemini" && "默认路径: ~/.gemini/oauth_creds.json"}
</p>
</div>
{antigravityMode === "login" ? (
<div className="space-y-4">
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-950/30">
<p className="text-sm text-blue-700 dark:text-blue-300">
点击下方按钮获取授权
URL,然后复制到浏览器(支持指纹浏览器)完成登录。
</p>
<p className="mt-2 text-xs text-blue-600 dark:text-blue-400">
授权成功后,凭证将自动保存并添加到凭证池。
</p>
</div>
{/* 授权 URL 显示区域 */}
{antigravityAuthUrl && (
<div className="space-y-3">
<div className="rounded-lg border bg-muted/50 p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">授权 URL</span>
<button
type="button"
onClick={handleCopyAuthUrl}
className="flex items-center gap-1 rounded px-2 py-1 text-xs hover:bg-muted"
>
{urlCopied ? (
<>
<Check className="h-3 w-3 text-green-500" />
<span className="text-green-500">已复制</span>
</>
) : (
<>
<Copy className="h-3 w-3" />
<span>复制</span>
</>
)}
</button>
</div>
<p className="text-xs text-muted-foreground break-all font-mono">
{antigravityAuthUrl.slice(0, 100)}...
</p>
</div>
{waitingForCallback && (
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-3 dark:border-yellow-800 dark:bg-yellow-950/30">
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-yellow-600" />
<p className="text-sm text-yellow-700 dark:text-yellow-300">
请复制上方 URL 到浏览器完成登录,正在等待授权回调...
</p>
</div>
</div>
)}
</div>
)}
{providerType === "gemini" && (
<div>
<label className="mb-1 block text-sm font-medium">
Project ID (可选)
</label>
<input
type="text"
value={projectId}
onChange={(e) => setProjectId(e.target.value)}
placeholder="Google Cloud Project ID..."
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
/>
</div>
) : (
<>
{/* 文件选择 */}
<div>
<label className="mb-1 block text-sm font-medium">
凭证文件路径 <span className="text-red-500">*</span>
</label>
<div className="flex gap-2">
<input
type="text"
value={credsFilePath}
onChange={(e) => setCredsFilePath(e.target.value)}
placeholder="选择 accounts.json 或 oauth_creds.json..."
className="flex-1 rounded-lg border bg-background px-3 py-2 text-sm"
/>
<button
type="button"
onClick={handleSelectFile}
className="flex items-center gap-1 rounded-lg border px-3 py-2 text-sm hover:bg-muted"
>
<FolderOpen className="h-4 w-4" />
浏览
</button>
</div>
<p className="mt-1 text-xs text-muted-foreground">
支持 antigravity2api-nodejs 的 data/accounts.json 格式
</p>
</div>
{/* Project ID */}
<div>
<label className="mb-1 block text-sm font-medium">
Project ID (可选)
</label>
<input
type="text"
value={projectId}
onChange={(e) => setProjectId(e.target.value)}
placeholder="Google Cloud Project ID..."
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
/>
</div>
</>
)}
</>
);
// 渲染 API Key 表单
const renderApiKeyForm = () => (
<>
<div>
<label className="mb-1 block text-sm font-medium">
API Key <span className="text-red-500">*</span>
</label>
<div className="relative">
<Key className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="sk-..."
className="w-full rounded-lg border bg-background pl-10 pr-3 py-2 text-sm"
/>
</div>
</div>
<div>
<label className="mb-1 block text-sm font-medium">
Base URL (可选)
</label>
<input
type="text"
value={baseUrl}
onChange={(e) => 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"
/>
<p className="mt-1 text-xs text-muted-foreground">
留空使用默认 URL,或输入自定义代理地址
</p>
</div>
</>
);
// 渲染底部按钮
const renderFooterButton = () => {
// Antigravity 登录模式
if (providerType === "antigravity" && antigravityForm.mode === "login") {
if (!antigravityForm.authUrl) {
return (
<button
onClick={antigravityForm.handleGetAuthUrl}
disabled={loading}
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{loading ? "获取中..." : "获取授权 URL"}
</button>
);
}
return null;
}
// Antigravity 文件模式
if (providerType === "antigravity" && antigravityForm.mode === "file") {
return (
<button
onClick={antigravityForm.handleFileSubmit}
disabled={loading}
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{loading ? "添加中..." : "添加凭证"}
</button>
);
}
// Codex 登录模式
if (providerType === "codex" && codexForm.mode === "login") {
if (!codexForm.authUrl) {
return (
<button
onClick={codexForm.handleGetAuthUrl}
disabled={loading}
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{loading ? "获取中..." : "获取授权 URL"}
</button>
);
}
return null;
}
// Codex 文件模式
if (providerType === "codex" && codexForm.mode === "file") {
return (
<button
onClick={codexForm.handleFileSubmit}
disabled={loading}
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{loading ? "添加中..." : "添加凭证"}
</button>
);
}
// Claude OAuth 登录模式
if (providerType === "claude_oauth" && claudeOAuthForm.mode === "login") {
if (!claudeOAuthForm.authUrl) {
return (
<button
onClick={claudeOAuthForm.handleGetAuthUrl}
disabled={loading}
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{loading ? "获取中..." : "获取授权 URL"}
</button>
);
}
return null;
}
// Claude OAuth 文件模式
if (providerType === "claude_oauth" && claudeOAuthForm.mode === "file") {
return (
<button
onClick={claudeOAuthForm.handleFileSubmit}
disabled={loading}
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{loading ? "添加中..." : "添加凭证"}
</button>
);
}
// Qwen 登录模式
if (providerType === "qwen" && qwenForm.mode === "login") {
if (!qwenForm.deviceCode) {
return (
<button
onClick={qwenForm.handleGetDeviceCode}
disabled={loading}
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{loading ? "获取中..." : "获取设备码"}
</button>
);
}
return null;
}
// Qwen 文件模式
if (providerType === "qwen" && qwenForm.mode === "file") {
return (
<button
onClick={qwenForm.handleFileSubmit}
disabled={loading}
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{loading ? "添加中..." : "添加凭证"}
</button>
);
}
// iFlow 登录模式
if (providerType === "iflow" && iflowForm.mode === "login") {
if (!iflowForm.authUrl) {
return (
<button
onClick={iflowForm.handleGetAuthUrl}
disabled={loading}
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{loading ? "获取中..." : "获取授权 URL"}
</button>
);
}
return null;
}
// iFlow 文件模式
if (providerType === "iflow" && iflowForm.mode === "file") {
return (
<button
onClick={iflowForm.handleFileSubmit}
disabled={loading}
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{loading ? "添加中..." : "添加凭证"}
</button>
);
}
// Gemini 登录模式
if (providerType === "gemini" && geminiForm.mode === "login") {
if (!geminiForm.authUrl) {
return (
<button
onClick={geminiForm.handleGetAuthUrl}
disabled={loading}
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{loading ? "获取中..." : "获取授权 URL"}
</button>
);
}
return null;
}
// Gemini 文件模式
if (providerType === "gemini" && geminiForm.mode === "file") {
return (
<button
onClick={geminiForm.handleFileSubmit}
disabled={loading}
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{loading ? "添加中..." : "添加凭证"}
</button>
);
}
// 其他类型
return (
<button
onClick={handleSubmit}
disabled={loading}
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{loading ? "添加中..." : "添加凭证"}
</button>
);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-lg bg-background p-6 shadow-xl">
@@ -388,7 +486,7 @@ export function AddCredentialModal({
{/* Content */}
<div className="mt-4 space-y-4">
{/* Name field */}
{/* 名称字段 */}
<div>
<label className="mb-1 block text-sm font-medium">
名称 (可选)
@@ -402,106 +500,17 @@ export function AddCredentialModal({
/>
</div>
{providerType === "antigravity" ? (
renderAntigravityContent()
) : isOAuth ? (
<>
{/* Credential File */}
<div>
<label className="mb-1 block text-sm font-medium">
凭证文件路径 <span className="text-red-500">*</span>
</label>
<div className="flex gap-2">
<input
type="text"
value={credsFilePath}
onChange={(e) => setCredsFilePath(e.target.value)}
placeholder="输入凭证文件的完整路径..."
className="flex-1 rounded-lg border bg-background px-3 py-2 text-sm"
/>
<button
type="button"
onClick={handleSelectFile}
className="flex items-center gap-1 rounded-lg border px-3 py-2 text-sm hover:bg-muted"
>
<FolderOpen className="h-4 w-4" />
浏览
</button>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{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"}
</p>
</div>
{/* 根据类型渲染不同表单 */}
{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" && (
<div>
<label className="mb-1 block text-sm font-medium">
Project ID (可选)
</label>
<input
type="text"
value={projectId}
onChange={(e) => setProjectId(e.target.value)}
placeholder="Google Cloud Project ID..."
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
/>
</div>
)}
</>
) : (
<>
{/* API Key */}
<div>
<label className="mb-1 block text-sm font-medium">
API Key <span className="text-red-500">*</span>
</label>
<div className="relative">
<Key className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="sk-..."
className="w-full rounded-lg border bg-background pl-10 pr-3 py-2 text-sm"
/>
</div>
</div>
{/* Base URL */}
<div>
<label className="mb-1 block text-sm font-medium">
Base URL (可选)
</label>
<input
type="text"
value={baseUrl}
onChange={(e) => 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"
/>
<p className="mt-1 text-xs text-muted-foreground">
留空使用默认 URL,或输入自定义代理地址
</p>
</div>
</>
)}
{/* Error */}
{/* 错误提示 */}
{error && (
<div className="rounded-lg border border-red-500 bg-red-50 p-3 text-sm text-red-700 dark:bg-red-950/30">
{error}
@@ -517,26 +526,7 @@ export function AddCredentialModal({
>
取消
</button>
{providerType === "antigravity" && antigravityMode === "login" ? (
// Antigravity 登录模式:显示获取授权 URL 按钮
!antigravityAuthUrl && (
<button
onClick={handleGetAntigravityAuthUrl}
disabled={loading}
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{loading ? "获取中..." : "获取授权 URL"}
</button>
)
) : (
<button
onClick={handleSubmit}
disabled={loading}
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{loading ? "添加中..." : "添加凭证"}
</button>
)}
{renderFooterButton()}
</div>
</div>
</div>
@@ -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<string | null>(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: () => (
<>
<ModeSelector
mode={mode}
setMode={setMode}
loginLabel="Google 登录"
fileLabel="导入文件"
/>
{mode === "login" ? (
<div className="space-y-4">
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-950/30">
<p className="text-sm text-blue-700 dark:text-blue-300">
点击下方按钮获取授权
URL,然后复制到浏览器(支持指纹浏览器)完成登录。
</p>
<p className="mt-2 text-xs text-blue-600 dark:text-blue-400">
授权成功后,凭证将自动保存并添加到凭证池。
</p>
</div>
<OAuthUrlDisplay
authUrl={authUrl}
waitingForCallback={waitingForCallback}
colorScheme="blue"
/>
</div>
) : (
<FileImportForm
credsFilePath={credsFilePath}
setCredsFilePath={setCredsFilePath}
onSelectFile={onSelectFile}
placeholder="选择 accounts.json 或 oauth_creds.json..."
hint="支持 antigravity2api-nodejs 的 data/accounts.json 格式"
projectId={projectId}
setProjectId={setProjectId}
showProjectId
/>
)}
</>
),
};
}
@@ -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<string | null>(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: () => (
<>
<ModeSelector
mode={mode}
setMode={setMode}
loginLabel="Claude 登录"
fileLabel="导入文件"
/>
{mode === "login" ? (
<div className="space-y-4">
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950/30">
<p className="text-sm text-amber-700 dark:text-amber-300">
点击下方按钮获取授权 URL,然后复制到浏览器(支持指纹浏览器)完成
Claude 登录。
</p>
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">
授权成功后,凭证将自动保存并添加到凭证池。
</p>
</div>
<OAuthUrlDisplay
authUrl={authUrl}
waitingForCallback={waitingForCallback}
colorScheme="amber"
/>
</div>
) : (
<FileImportForm
credsFilePath={credsFilePath}
setCredsFilePath={setCredsFilePath}
onSelectFile={onSelectFile}
placeholder="选择 oauth.json 或 oauth_creds.json..."
hint="默认路径: ~/.claude/oauth.json 或 Claude CLI 的凭证文件"
/>
)}
</>
),
};
}
@@ -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<string | null>(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: () => (
<>
<ModeSelector
mode={mode}
setMode={setMode}
loginLabel="OpenAI 登录"
fileLabel="导入文件"
/>
{mode === "login" ? (
<div className="space-y-4">
<div className="rounded-lg border border-green-200 bg-green-50 p-4 dark:border-green-800 dark:bg-green-950/30">
<p className="text-sm text-green-700 dark:text-green-300">
点击下方按钮获取授权 URL,然后复制到浏览器(支持指纹浏览器)完成
OpenAI 登录。
</p>
<p className="mt-2 text-xs text-green-600 dark:text-green-400">
授权成功后,凭证将自动保存并添加到凭证池。
</p>
</div>
<OAuthUrlDisplay
authUrl={authUrl}
waitingForCallback={waitingForCallback}
colorScheme="green"
/>
</div>
) : (
<FileImportForm
credsFilePath={credsFilePath}
setCredsFilePath={setCredsFilePath}
onSelectFile={onSelectFile}
placeholder="选择 auth.json 或 oauth.json..."
hint="默认路径: ~/.codex/auth.json 或 Codex CLI 的凭证文件"
/>
)}
</>
),
};
}
@@ -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 (
<>
<div>
<label className="mb-1 block text-sm font-medium">
凭证文件路径 <span className="text-red-500">*</span>
</label>
<div className="flex gap-2">
<input
type="text"
value={credsFilePath}
onChange={(e) => setCredsFilePath(e.target.value)}
placeholder={placeholder}
className="flex-1 rounded-lg border bg-background px-3 py-2 text-sm"
/>
<button
type="button"
onClick={onSelectFile}
className="flex items-center gap-1 rounded-lg border px-3 py-2 text-sm hover:bg-muted"
>
<FolderOpen className="h-4 w-4" />
浏览
</button>
</div>
{hint && <p className="mt-1 text-xs text-muted-foreground">{hint}</p>}
</div>
{showProjectId && setProjectId && (
<div>
<label className="mb-1 block text-sm font-medium">
Project ID (可选)
</label>
<input
type="text"
value={projectId || ""}
onChange={(e) => setProjectId(e.target.value)}
placeholder="Google Cloud Project ID..."
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
/>
</div>
)}
</>
);
}
@@ -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<string | null>(null);
const [sessionId, setSessionId] = useState<string | null>(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: () => (
<>
<ModeSelector
mode={mode}
setMode={setMode}
loginLabel="Google 登录"
fileLabel="导入文件"
/>
{mode === "login" ? (
<div className="space-y-4">
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-950/30">
<p className="text-sm text-blue-700 dark:text-blue-300">
点击下方按钮获取授权 URL,然后复制到浏览器完成 Google 登录。
</p>
<p className="mt-2 text-xs text-blue-600 dark:text-blue-400">
授权成功后,复制页面显示的授权码粘贴到下方输入框。
</p>
</div>
{/* 授权 URL 显示 */}
{authUrl && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">授权 URL</span>
<button
onClick={handleCopyUrl}
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-blue-600 hover:bg-blue-100 dark:text-blue-400 dark:hover:bg-blue-900/30"
>
{copied ? (
<>
<Check className="h-3 w-3" />
已复制
</>
) : (
<>
<Copy className="h-3 w-3" />
复制
</>
)}
</button>
</div>
<div className="rounded-lg border bg-muted/50 p-3">
<p className="break-all text-xs text-muted-foreground">
{authUrl.length > 100
? `${authUrl.slice(0, 100)}...`
: authUrl}
</p>
</div>
{/* 授权码输入 */}
<div className="space-y-2">
<label className="text-sm font-medium">
授权码 <span className="text-red-500">*</span>
</label>
<input
type="text"
value={authCode}
onChange={(e) => setAuthCode(e.target.value)}
placeholder="粘贴浏览器页面显示的授权码..."
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
/>
<p className="text-xs text-muted-foreground">
在浏览器中完成授权后,复制页面显示的授权码
</p>
</div>
{/* 提交按钮 */}
<button
onClick={handleExchangeCode}
disabled={exchanging || !authCode.trim()}
className="w-full rounded-lg bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
>
{exchanging ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
验证中...
</span>
) : (
"验证授权码"
)}
</button>
</div>
)}
</div>
) : (
<FileImportForm
credsFilePath={credsFilePath}
setCredsFilePath={setCredsFilePath}
onSelectFile={onSelectFile}
placeholder="选择 oauth_creds.json..."
hint="默认路径: ~/.gemini/oauth_creds.json"
projectId={projectId}
setProjectId={setProjectId}
showProjectId
/>
)}
</>
),
};
}
@@ -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<string | null>(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: () => (
<>
<ModeSelector
mode={mode}
setMode={setMode}
loginLabel="iFlow 登录"
fileLabel="导入文件"
/>
{mode === "login" ? (
<div className="space-y-4">
<div className="rounded-lg border border-purple-200 bg-purple-50 p-4 dark:border-purple-800 dark:bg-purple-950/30">
<p className="text-sm text-purple-700 dark:text-purple-300">
点击下方按钮获取授权 URL,然后复制到浏览器(支持指纹浏览器)完成
iFlow 登录。
</p>
<p className="mt-2 text-xs text-purple-600 dark:text-purple-400">
授权成功后,凭证将自动保存并添加到凭证池。
</p>
</div>
<OAuthUrlDisplay
authUrl={authUrl}
waitingForCallback={waitingForCallback}
colorScheme="purple"
/>
</div>
) : (
<FileImportForm
credsFilePath={credsFilePath}
setCredsFilePath={setCredsFilePath}
onSelectFile={onSelectFile}
placeholder="选择 auth.json 或 oauth_creds.json..."
hint="默认路径: ~/.iflow/auth.json"
/>
)}
</>
),
};
}
@@ -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 (
<div className="flex gap-2 mb-4">
<button
type="button"
onClick={() => setMode("login")}
className={`flex-1 rounded-lg border px-3 py-2 text-sm ${
mode === "login"
? "border-primary bg-primary/10 text-primary"
: "hover:bg-muted"
}`}
>
<LogIn className="inline h-4 w-4 mr-1" />
{loginLabel}
</button>
<button
type="button"
onClick={() => setMode("file")}
className={`flex-1 rounded-lg border px-3 py-2 text-sm ${
mode === "file"
? "border-primary bg-primary/10 text-primary"
: "hover:bg-muted"
}`}
>
<FolderOpen className="inline h-4 w-4 mr-1" />
{fileLabel}
</button>
</div>
);
}
@@ -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 (
<div className="space-y-3">
<div className="rounded-lg border bg-muted/50 p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">授权 URL</span>
<button
type="button"
onClick={handleCopyUrl}
className="flex items-center gap-1 rounded px-2 py-1 text-xs hover:bg-muted"
>
{urlCopied ? (
<>
<Check className="h-3 w-3 text-green-500" />
<span className="text-green-500">已复制</span>
</>
) : (
<>
<Copy className="h-3 w-3" />
<span>复制</span>
</>
)}
</button>
</div>
<p className="text-xs text-muted-foreground break-all font-mono">
{authUrl.slice(0, 100)}...
</p>
</div>
{waitingForCallback && (
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-3 dark:border-yellow-800 dark:bg-yellow-950/30">
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-yellow-600" />
<p className="text-sm text-yellow-700 dark:text-yellow-300">
请复制上方 URL 到浏览器完成登录,正在等待授权回调...
</p>
</div>
</div>
)}
</div>
);
}
@@ -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<DeviceCodeInfo | null>(null);
const [waitingForAuth, setWaitingForAuth] = useState(false);
const [copied, setCopied] = useState(false);
// 监听后端发送的设备码事件
useEffect(() => {
let unlisten: (() => void) | undefined;
const setupListener = async () => {
unlisten = await listen<DeviceCodeInfo>("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: () => (
<>
<ModeSelector
mode={mode}
setMode={setMode}
loginLabel="Qwen 登录"
fileLabel="导入文件"
/>
{mode === "login" ? (
<div className="space-y-4">
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-950/30">
<p className="text-sm text-blue-700 dark:text-blue-300">
点击下方按钮获取设备码,然后在浏览器中完成 Qwen 登录授权。
</p>
<p className="mt-2 text-xs text-blue-600 dark:text-blue-400">
授权成功后,凭证将自动保存并添加到凭证池。
</p>
</div>
{deviceCode && (
<div className="space-y-3">
{/* 用户码显示 */}
<div className="rounded-lg border border-blue-300 bg-blue-100 p-4 dark:border-blue-700 dark:bg-blue-900/50">
<p className="mb-2 text-sm font-medium text-blue-800 dark:text-blue-200">
请在浏览器中输入以下验证码:
</p>
<div className="flex items-center justify-between">
<code className="text-2xl font-bold tracking-wider text-blue-900 dark:text-blue-100">
{deviceCode.user_code}
</code>
<button
onClick={handleCopyCode}
className="flex items-center gap-1 rounded-lg border border-blue-300 bg-white px-3 py-1.5 text-sm text-blue-700 hover:bg-blue-50 dark:border-blue-600 dark:bg-blue-800 dark:text-blue-200 dark:hover:bg-blue-700"
>
{copied ? (
<>
<Check className="h-4 w-4" />
已复制
</>
) : (
<>
<Copy className="h-4 w-4" />
复制
</>
)}
</button>
</div>
</div>
{/* 验证链接 */}
<button
onClick={handleOpenVerificationUrl}
className="flex w-full items-center justify-center gap-2 rounded-lg border border-blue-300 bg-white px-4 py-2 text-sm text-blue-700 hover:bg-blue-50 dark:border-blue-600 dark:bg-blue-800 dark:text-blue-200 dark:hover:bg-blue-700"
>
<ExternalLink className="h-4 w-4" />
打开验证页面
</button>
{waitingForAuth && (
<div className="flex items-center justify-center gap-2 text-sm text-blue-600 dark:text-blue-400">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
等待授权中...
</div>
)}
</div>
)}
</div>
) : (
<FileImportForm
credsFilePath={credsFilePath}
setCredsFilePath={setCredsFilePath}
onSelectFile={onSelectFile}
placeholder="选择 oauth_creds.json..."
hint="默认路径: ~/.qwen/oauth_creds.json"
/>
)}
</>
),
};
}
@@ -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";
@@ -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<string, string> = {
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<PoolProviderType, string> = {
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",
};
+66
View File
@@ -383,6 +383,72 @@ export const providerPoolApi = {
});
},
// Codex OAuth 登录(打开浏览器授权)
async startCodexOAuthLogin(name?: string): Promise<ProviderCredential> {
return invoke("start_codex_oauth_login", { name });
},
// 获取 Codex OAuth 授权 URL 并等待回调(不自动打开浏览器)
// 服务器会在后台等待回调,成功后返回凭证
async getCodexAuthUrlAndWait(name?: string): Promise<ProviderCredential> {
return invoke("get_codex_auth_url_and_wait", { name });
},
// Claude OAuth 登录(打开浏览器授权)
async startClaudeOAuthLogin(name?: string): Promise<ProviderCredential> {
return invoke("start_claude_oauth_login", { name });
},
// 获取 Claude OAuth 授权 URL 并等待回调(不自动打开浏览器)
// 服务器会在后台等待回调,成功后返回凭证
async getClaudeOAuthAuthUrlAndWait(
name?: string,
): Promise<ProviderCredential> {
return invoke("get_claude_oauth_auth_url_and_wait", { name });
},
// Qwen Device Code Flow 登录(打开浏览器授权)
async startQwenDeviceCodeLogin(name?: string): Promise<ProviderCredential> {
return invoke("start_qwen_device_code_login", { name });
},
// 获取 Qwen Device Code 并等待用户授权(不自动打开浏览器)
// 服务器会在后台轮询等待授权,成功后返回凭证
async getQwenDeviceCodeAndWait(name?: string): Promise<ProviderCredential> {
return invoke("get_qwen_device_code_and_wait", { name });
},
// iFlow OAuth 登录(打开浏览器授权)
async startIFlowOAuthLogin(name?: string): Promise<ProviderCredential> {
return invoke("start_iflow_oauth_login", { name });
},
// 获取 iFlow OAuth 授权 URL 并等待回调(不自动打开浏览器)
// 服务器会在后台等待回调,成功后返回凭证
async getIFlowAuthUrlAndWait(name?: string): Promise<ProviderCredential> {
return invoke("get_iflow_auth_url_and_wait", { name });
},
// Gemini OAuth 登录(打开浏览器授权)
async startGeminiOAuthLogin(name?: string): Promise<ProviderCredential> {
return invoke("start_gemini_oauth_login", { name });
},
// 获取 Gemini OAuth 授权 URL 并等待回调(不自动打开浏览器)
// 服务器会在后台等待回调,成功后返回凭证
async getGeminiAuthUrlAndWait(name?: string): Promise<ProviderCredential> {
return invoke("get_gemini_auth_url_and_wait", { name });
},
// 用 Gemini 授权码交换 token
async exchangeGeminiCode(
code: string,
sessionId?: string,
name?: string,
): Promise<ProviderCredential> {
return invoke("exchange_gemini_code", { code, sessionId, name });
},
// OAuth token management
async refreshCredentialToken(uuid: string): Promise<string> {
return invoke("refresh_pool_credential_token", { uuid });