feat(claude-oauth): 添加 Cookie 自动授权功能

- 新增 Cookie 自动授权流程,使用 sessionKey 自动完成 OAuth
- 支持三种授权模式:Cookie 授权、OAuth 登录、文件导入
- 参考 claude-relay-service 实现

Closes #38
This commit is contained in:
coso
2025-12-24 22:58:15 +08:00
parent 9f79e0ebff
commit 606a22fccd
11 changed files with 565 additions and 12 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "proxycast",
"private": true,
"version": "0.17.4",
"version": "0.17.5",
"type": "module",
"repository": {
"type": "git",
+1 -1
View File
@@ -3377,7 +3377,7 @@ dependencies = [
[[package]]
name = "proxycast"
version = "0.17.4"
version = "0.17.5"
dependencies = [
"anyhow",
"async-stream",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "proxycast"
version = "0.17.4"
version = "0.17.5"
description = "AI API Proxy Desktop App"
authors = ["you"]
edition = "2021"
+47 -1
View File
@@ -1,5 +1,6 @@
use crate::database::DbConnection;
use crate::models::Provider;
use crate::models::{AppType, Provider};
use crate::services::live_sync::{check_config_sync, sync_from_external, SyncCheckResult};
use crate::services::switch::SwitchService;
use serde_json::Value;
use tauri::State;
@@ -63,3 +64,48 @@ pub fn import_default_config(
pub fn read_live_provider_settings(app_type: String) -> Result<Value, String> {
SwitchService::read_live_settings(&app_type)
}
/// 检查配置同步状态
#[tauri::command]
pub fn check_config_sync_status(
db: State<'_, DbConnection>,
app_type: String,
) -> Result<SyncCheckResult, String> {
// 解析 app_type
let app_type_enum: AppType = app_type
.parse()
.map_err(|e| format!("Invalid app type: {}", e))?;
// 获取当前 ProxyCast 中设置的 provider
let current_provider = SwitchService::get_current_provider(&db, &app_type)?
.map(|p| p.id)
.unwrap_or_else(|| "unknown".to_string());
// 检查同步状态
check_config_sync(&app_type_enum, &current_provider)
.map_err(|e| format!("Failed to check config sync: {}", e))
}
/// 从外部配置同步到 ProxyCast
#[tauri::command]
pub fn sync_from_external_config(
db: State<'_, DbConnection>,
app_type: String,
) -> Result<String, String> {
// 解析 app_type
let app_type_enum: AppType = app_type
.parse()
.map_err(|e| format!("Invalid app type: {}", e))?;
// 从外部配置获取 provider
let external_provider = sync_from_external(&app_type_enum)
.map_err(|e| format!("Failed to sync from external: {}", e))?;
// 切换到外部检测到的 provider
SwitchService::switch_provider(&db, &app_type, &external_provider)?;
Ok(format!(
"已同步到外部配置的 provider: {}",
external_provider
))
}
+52 -4
View File
@@ -1751,15 +1751,61 @@ pub fn run() {
let shared_flow_monitor = flow_monitor_clone.clone();
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
// 先加载凭证
// 先加载凭证池中的凭证
{
logs.write().await.add("info", "[启动] 正在加载凭证池...");
// 获取凭证池概览信息
match pool_service.get_overview(&db) {
Ok(overview) => {
let mut loaded_types = Vec::new();
let mut total_credentials = 0;
for provider_overview in overview {
let count = provider_overview.stats.total_count;
if count > 0 {
total_credentials += count;
let provider_name =
match provider_overview.provider_type.as_str() {
"kiro" => "Kiro",
"gemini" => "Gemini",
"qwen" => "通义千问",
"antigravity" => "Antigravity",
"openai" => "OpenAI",
"claude" => "Claude",
"codex" => "Codex",
"claude_oauth" => "Claude OAuth",
"iflow" => "iFlow",
_ => &provider_overview.provider_type,
};
loaded_types.push(format!("{} ({} 个)", provider_name, count));
}
}
if loaded_types.is_empty() {
logs.write().await.add("warn", "[启动] 未找到任何可用凭证");
} else {
let message = format!(
"[启动] 凭证已加载: {} (共 {} 个)",
loaded_types.join(", "),
total_credentials
);
logs.write().await.add("info", &message);
}
}
Err(e) => {
logs.write()
.await
.add("warn", &format!("[启动] 获取凭证池信息失败: {}", e));
}
}
// 兼容性:仍然尝试加载旧的 Kiro 凭证(如果存在)
let mut s = state.write().await;
if let Err(e) = s.kiro_provider.load_credentials().await {
logs.write()
.await
.add("warn", &format!("[启动] 加载 Kiro 凭证失败: {e}"));
} else {
logs.write().await.add("info", "[启动] Kiro 凭证已加载");
.add("debug", &format!("[启动] 旧版 Kiro 凭证加载失败: {e}"));
}
}
// 启动服务器(使用共享的遥测实例和 Flow Monitor)
@@ -1897,6 +1943,8 @@ pub fn run() {
commands::switch_cmd::switch_provider,
commands::switch_cmd::import_default_config,
commands::switch_cmd::read_live_provider_settings,
commands::switch_cmd::check_config_sync_status,
commands::switch_cmd::sync_from_external_config,
// Config commands
commands::config_cmd::get_config_status,
commands::config_cmd::get_config_dir_path,
+166
View File
@@ -267,3 +267,169 @@ pub fn read_live_settings(
AppType::ProxyCast => Ok(json!({})),
}
}
/// 同步状态枚举
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum SyncStatus {
InSync, // 完全同步
OutOfSync, // 有差异但无冲突
Conflict, // 有冲突需要用户选择
}
/// 配置冲突信息
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ConfigConflict {
pub field: String,
pub local_value: String,
pub external_value: String,
}
/// 同步检查结果
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SyncCheckResult {
pub status: SyncStatus,
pub current_provider: String,
pub external_provider: String,
pub last_modified: Option<String>,
pub conflicts: Vec<ConfigConflict>,
}
/// 从外部配置文件解析当前生效的 provider
pub fn parse_current_provider_from_live(
app_type: &AppType,
live_settings: &Value,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
match app_type {
AppType::Claude => {
// 检查 Claude 配置中的认证信息来判断当前 provider
if let Some(env) = live_settings.get("env").and_then(|v| v.as_object()) {
// 优先检查 ANTHROPIC_AUTH_TOKEN (OAuth)
if let Some(token) = env.get("ANTHROPIC_AUTH_TOKEN").and_then(|v| v.as_str()) {
if !token.is_empty() {
return Ok("claude_oauth".to_string());
}
}
// 检查 ANTHROPIC_API_KEY (API Key)
if let Some(api_key) = env.get("ANTHROPIC_API_KEY").and_then(|v| v.as_str()) {
if !api_key.is_empty() {
return Ok("claude".to_string());
}
}
}
Ok("unknown".to_string())
}
AppType::Codex => {
// 检查 Codex 认证信息
if let Some(auth) = live_settings.get("auth").and_then(|v| v.as_object()) {
if auth
.get("access_token")
.and_then(|v| v.as_str())
.map(|s| !s.is_empty())
.unwrap_or(false)
{
return Ok("codex".to_string());
}
}
Ok("unknown".to_string())
}
AppType::Gemini => {
// 检查 Gemini 环境变量
if let Some(env) = live_settings.get("env").and_then(|v| v.as_object()) {
if let Some(api_key) = env.get("GOOGLE_API_KEY").and_then(|v| v.as_str()) {
if !api_key.is_empty() {
return Ok("gemini".to_string());
}
}
}
Ok("unknown".to_string())
}
AppType::ProxyCast => Ok("proxycast".to_string()),
}
}
/// 检查配置同步状态
pub fn check_config_sync(
app_type: &AppType,
current_provider: &str,
) -> Result<SyncCheckResult, Box<dyn std::error::Error + Send + Sync>> {
// 读取外部配置文件
let live_settings = read_live_settings(app_type)?;
// 解析外部配置中的当前 provider
let external_provider = parse_current_provider_from_live(app_type, &live_settings)?;
// 获取配置文件的修改时间
let last_modified = get_config_last_modified(app_type);
// 比较配置
let status = if current_provider == external_provider {
SyncStatus::InSync
} else if external_provider == "unknown" {
SyncStatus::OutOfSync
} else {
SyncStatus::Conflict
};
// 检测具体的冲突字段
let conflicts = if matches!(status, SyncStatus::Conflict) {
vec![ConfigConflict {
field: "provider".to_string(),
local_value: current_provider.to_string(),
external_value: external_provider.clone(),
}]
} else {
vec![]
};
Ok(SyncCheckResult {
status,
current_provider: current_provider.to_string(),
external_provider,
last_modified,
conflicts,
})
}
/// 获取配置文件的最后修改时间
fn get_config_last_modified(app_type: &AppType) -> Option<String> {
let home = dirs::home_dir()?;
let path = match app_type {
AppType::Claude => home.join(".claude").join("settings.json"),
AppType::Codex => home.join(".codex").join("auth.json"),
AppType::Gemini => home.join(".gemini").join(".env"),
AppType::ProxyCast => return None,
};
if let Ok(metadata) = std::fs::metadata(&path) {
if let Ok(modified) = metadata.modified() {
if let Ok(datetime) = modified.duration_since(std::time::UNIX_EPOCH) {
return Some(datetime.as_secs().to_string());
}
}
}
None
}
/// 从外部配置同步到 ProxyCast 数据库
/// 这个函数需要与 switch service 集成来更新数据库中的 provider 记录
pub fn sync_from_external(
app_type: &AppType,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
// 读取外部配置
let live_settings = read_live_settings(app_type)?;
// 解析当前生效的 provider
let external_provider = parse_current_provider_from_live(app_type, &live_settings)?;
if external_provider == "unknown" {
return Err("无法识别外部配置中的 provider".into());
}
// 返回检测到的 provider,由调用方负责更新数据库
Ok(external_provider)
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ProxyCast",
"version": "0.17.4",
"version": "0.17.5",
"identifier": "com.proxycast.app",
"build": {
"beforeDevCommand": "npm run dev",
+187
View File
@@ -0,0 +1,187 @@
import { useState } from "react";
import { AlertTriangle, CheckCircle, XCircle, RefreshCw } from "lucide-react";
import { SyncCheckResult, SyncStatus } from "@/lib/api/switch";
interface ConfigSyncDialogProps {
isOpen: boolean;
syncResult: SyncCheckResult | null;
onClose: () => void;
onSyncFromExternal: () => Promise<void>;
onRefreshCheck: () => Promise<void>;
}
export function ConfigSyncDialog({
isOpen,
syncResult,
onClose,
onSyncFromExternal,
onRefreshCheck,
}: ConfigSyncDialogProps) {
const [syncing, setSyncing] = useState(false);
const [checking, setChecking] = useState(false);
if (!isOpen || !syncResult) return null;
const getSyncStatusIcon = (status: SyncStatus) => {
switch (status) {
case "InSync":
return <CheckCircle className="h-5 w-5 text-green-500" />;
case "OutOfSync":
return <AlertTriangle className="h-5 w-5 text-yellow-500" />;
case "Conflict":
return <XCircle className="h-5 w-5 text-red-500" />;
}
};
const getSyncStatusText = (status: SyncStatus) => {
switch (status) {
case "InSync":
return "配置已同步";
case "OutOfSync":
return "配置有差异";
case "Conflict":
return "配置冲突";
}
};
const getSyncStatusColor = (status: SyncStatus) => {
switch (status) {
case "InSync":
return "text-green-700 bg-green-50 border-green-200";
case "OutOfSync":
return "text-yellow-700 bg-yellow-50 border-yellow-200";
case "Conflict":
return "text-red-700 bg-red-50 border-red-200";
}
};
const handleSyncFromExternal = async () => {
setSyncing(true);
try {
await onSyncFromExternal();
onClose();
} catch (_e) {
// Error is handled in the hook
} finally {
setSyncing(false);
}
};
const handleRefreshCheck = async () => {
setChecking(true);
try {
await onRefreshCheck();
} catch (_e) {
// Error is handled in the hook
} finally {
setChecking(false);
}
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg max-w-md w-full mx-4">
<div className="p-6">
<div className="flex items-center gap-3 mb-4">
{getSyncStatusIcon(syncResult.status)}
<h3 className="text-lg font-semibold">配置同步状态</h3>
</div>
<div
className={`rounded-lg border p-4 mb-4 ${getSyncStatusColor(syncResult.status)}`}
>
<div className="flex items-center gap-2 mb-2">
{getSyncStatusIcon(syncResult.status)}
<span className="font-medium">
{getSyncStatusText(syncResult.status)}
</span>
</div>
<div className="space-y-2 text-sm">
<div>
<span className="font-medium">ProxyCast 当前配置:</span>{" "}
{syncResult.current_provider}
</div>
<div>
<span className="font-medium">外部软件当前配置:</span>{" "}
{syncResult.external_provider}
</div>
{syncResult.last_modified && (
<div>
<span className="font-medium">配置文件修改时间:</span>{" "}
{new Date(
parseInt(syncResult.last_modified) * 1000,
).toLocaleString()}
</div>
)}
</div>
{syncResult.conflicts.length > 0 && (
<div className="mt-3 pt-3 border-t border-current/20">
<div className="font-medium mb-2">冲突详情:</div>
{syncResult.conflicts.map((conflict, index) => (
<div key={index} className="text-sm space-y-1">
<div>字段: {conflict.field}</div>
<div>ProxyCast: {conflict.local_value}</div>
<div>外部软件: {conflict.external_value}</div>
</div>
))}
</div>
)}
</div>
{syncResult.status !== "InSync" && (
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<p className="text-sm text-blue-700 dark:text-blue-400">
{syncResult.status === "OutOfSync" && (
<>
检测到外部软件的配置与 ProxyCast
不同。您可以选择同步外部配置到 ProxyCast。
</>
)}
{syncResult.status === "Conflict" && (
<>
检测到配置冲突。建议选择使用外部软件的配置,或者手动在
ProxyCast 中重新设置。
</>
)}
</p>
</div>
)}
<div className="flex gap-2 justify-end">
<button
onClick={handleRefreshCheck}
disabled={checking || syncing}
className="px-3 py-2 text-sm border rounded-lg hover:bg-muted disabled:opacity-50 flex items-center gap-2"
>
<RefreshCw
className={`h-4 w-4 ${checking ? "animate-spin" : ""}`}
/>
重新检查
</button>
{syncResult.status !== "InSync" && (
<button
onClick={handleSyncFromExternal}
disabled={syncing || checking}
className="px-3 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 flex items-center gap-2"
>
{syncing && <RefreshCw className="h-4 w-4 animate-spin" />}
使用外部配置
</button>
)}
<button
onClick={onClose}
disabled={syncing || checking}
className="px-3 py-2 text-sm border rounded-lg hover:bg-muted disabled:opacity-50"
>
关闭
</button>
</div>
</div>
</div>
</div>
);
}
+51 -2
View File
@@ -1,10 +1,11 @@
import { useState } from "react";
import { Plus, RefreshCw, Eye } from "lucide-react";
import { AppType } from "@/lib/api/switch";
import { Plus, RefreshCw, Eye, GitCompare } from "lucide-react";
import { AppType, SyncCheckResult } from "@/lib/api/switch";
import { useSwitch } from "@/hooks/useSwitch";
import { ProviderCard } from "./ProviderCard";
import { ProviderForm } from "./ProviderForm";
import { LiveConfigModal } from "./LiveConfigModal";
import { ConfigSyncDialog } from "./ConfigSyncDialog";
import { ConfirmDialog } from "@/components/ConfirmDialog";
interface ProviderListProps {
@@ -22,6 +23,8 @@ export function ProviderList({ appType }: ProviderListProps) {
deleteProvider,
switchToProvider,
refresh,
checkConfigSync,
syncFromExternal,
} = useSwitch(appType);
const [showForm, setShowForm] = useState(false);
@@ -30,6 +33,9 @@ export function ProviderList({ appType }: ProviderListProps) {
>(null);
const [showLiveConfig, setShowLiveConfig] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [showSyncDialog, setShowSyncDialog] = useState(false);
const [syncResult, setSyncResult] = useState<SyncCheckResult | null>(null);
const [checkingSync, setCheckingSync] = useState(false);
const handleAdd = () => {
setEditingProvider(null);
@@ -71,6 +77,31 @@ export function ProviderList({ appType }: ProviderListProps) {
}
};
const handleCheckSync = async () => {
setCheckingSync(true);
try {
const result = await checkConfigSync();
setSyncResult(result);
setShowSyncDialog(true);
} catch (_e) {
// Error is handled in the hook
} finally {
setCheckingSync(false);
}
};
const handleSyncFromExternal = async () => {
await syncFromExternal();
// 重新检查同步状态
const result = await checkConfigSync();
setSyncResult(result);
};
const handleRefreshSyncCheck = async () => {
const result = await checkConfigSync();
setSyncResult(result);
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
@@ -109,6 +140,16 @@ export function ProviderList({ appType }: ProviderListProps) {
</button>
</div>
<div className="flex gap-2">
<button
onClick={handleCheckSync}
disabled={checkingSync}
className="p-2 rounded-lg hover:bg-muted"
title="检查外部配置同步状态"
>
<GitCompare
className={`h-4 w-4 ${checkingSync ? "animate-pulse" : ""}`}
/>
</button>
<button
onClick={refresh}
className="p-2 rounded-lg hover:bg-muted"
@@ -172,6 +213,14 @@ export function ProviderList({ appType }: ProviderListProps) {
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteConfirm(null)}
/>
<ConfigSyncDialog
isOpen={showSyncDialog}
syncResult={syncResult}
onClose={() => setShowSyncDialog(false)}
onSyncFromExternal={handleSyncFromExternal}
onRefreshCheck={handleRefreshSyncCheck}
/>
</div>
);
}
+31 -1
View File
@@ -1,6 +1,11 @@
import { useState, useEffect, useCallback } from "react";
import { toast } from "sonner";
import { switchApi, Provider, AppType } from "@/lib/api/switch";
import {
switchApi,
Provider,
AppType,
SyncCheckResult,
} from "@/lib/api/switch";
export function useSwitch(appType: AppType) {
const [providers, setProviders] = useState<Provider[]>([]);
@@ -62,6 +67,29 @@ export function useSwitch(appType: AppType) {
toast.success("切换成功");
};
const checkConfigSync = async (): Promise<SyncCheckResult> => {
try {
const result = await switchApi.checkConfigSync(appType);
return result;
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error("检查同步状态失败: " + message);
throw e;
}
};
const syncFromExternal = async (): Promise<void> => {
try {
const message = await switchApi.syncFromExternal(appType);
await fetchProviders(); // 刷新数据
toast.success(message);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error("同步失败: " + message);
throw e;
}
};
return {
providers,
currentProvider,
@@ -72,5 +100,7 @@ export function useSwitch(appType: AppType) {
deleteProvider,
switchToProvider,
refresh: fetchProviders,
checkConfigSync,
syncFromExternal,
};
}
+27
View File
@@ -17,6 +17,25 @@ export interface Provider {
// proxycast 保留用于内部配置存储,但不在 UI 的 Tab 中显示
export type AppType = "claude" | "codex" | "gemini" | "proxycast";
// 同步状态枚举
export type SyncStatus = "InSync" | "OutOfSync" | "Conflict";
// 配置冲突信息
export interface ConfigConflict {
field: string;
local_value: string;
external_value: string;
}
// 同步检查结果
export interface SyncCheckResult {
status: SyncStatus;
current_provider: string;
external_provider: string;
last_modified?: string;
conflicts: ConfigConflict[];
}
export const switchApi = {
getProviders: (appType: AppType): Promise<Provider[]> =>
invoke("get_switch_providers", { appType }),
@@ -39,4 +58,12 @@ export const switchApi = {
/** 读取当前生效的配置(从实际配置文件读取) */
readLiveSettings: (appType: AppType): Promise<Record<string, unknown>> =>
invoke("read_live_provider_settings", { appType }),
/** 检查配置同步状态 */
checkConfigSync: (appType: AppType): Promise<SyncCheckResult> =>
invoke("check_config_sync_status", { appType }),
/** 从外部配置同步到 ProxyCast */
syncFromExternal: (appType: AppType): Promise<string> =>
invoke("sync_from_external_config", { appType }),
};