diff --git a/.claude/settings.local.json b/.claude/settings.local.json index e5eca42a8..f3bab6be5 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -7,7 +7,8 @@ "Bash(cargo test:*)", "Bash(cargo build:*)", "Bash(npm run check:*)", - "Bash(npm run:*)" + "Bash(npm run:*)", + "Bash(tree:*)" ], "deny": [], "ask": [] diff --git a/package.json b/package.json index 4ff45fa2e..920c25313 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "proxycast", "private": true, - "version": "0.4.2", + "version": "0.4.3", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 79ef445d2..f4c151613 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,6 +8,18 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -810,6 +822,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -1332,12 +1356,30 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.4.1" @@ -1865,6 +1907,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -2789,7 +2842,7 @@ dependencies = [ [[package]] name = "proxycast" -version = "0.4.1" +version = "0.4.3" dependencies = [ "async-stream", "axum", @@ -2799,6 +2852,7 @@ dependencies = [ "md5", "regex", "reqwest", + "rusqlite", "serde", "serde_json", "tauri", @@ -2807,6 +2861,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "urlencoding", "uuid", ] @@ -3058,6 +3113,20 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags 2.10.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -4513,6 +4582,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "urlpattern" version = "0.3.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f85661a82..704e3f2f2 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "proxycast" -version = "0.4.2" +version = "0.4.3" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" @@ -29,6 +29,8 @@ futures = "0.3" async-stream = "0.3" regex = "1" md5 = "0.7" +urlencoding = "2" +rusqlite = { version = "0.31", features = ["bundled"] } [features] default = ["custom-protocol"] diff --git a/src-tauri/src/database/dao/mcp.rs b/src-tauri/src/database/dao/mcp.rs new file mode 100644 index 000000000..9e3b37c9d --- /dev/null +++ b/src-tauri/src/database/dao/mcp.rs @@ -0,0 +1,2 @@ +// MCP Servers DAO - 数据访问对象 +// TODO: 实现 mcp_servers 表的 CRUD 操作 diff --git a/src-tauri/src/database/dao/mod.rs b/src-tauri/src/database/dao/mod.rs new file mode 100644 index 000000000..d489b7f38 --- /dev/null +++ b/src-tauri/src/database/dao/mod.rs @@ -0,0 +1,3 @@ +pub mod mcp; +pub mod prompts; +pub mod providers; diff --git a/src-tauri/src/database/dao/prompts.rs b/src-tauri/src/database/dao/prompts.rs new file mode 100644 index 000000000..5ab7dc09e --- /dev/null +++ b/src-tauri/src/database/dao/prompts.rs @@ -0,0 +1,2 @@ +// Prompts DAO - 数据访问对象 +// TODO: 实现 prompts 表的 CRUD 操作 diff --git a/src-tauri/src/database/dao/providers.rs b/src-tauri/src/database/dao/providers.rs new file mode 100644 index 000000000..d82dcfbe9 --- /dev/null +++ b/src-tauri/src/database/dao/providers.rs @@ -0,0 +1,2 @@ +// Providers DAO - 数据访问对象 +// TODO: 实现 providers 表的 CRUD 操作 diff --git a/src-tauri/src/database/migration.rs b/src-tauri/src/database/migration.rs new file mode 100644 index 000000000..18ed6788c --- /dev/null +++ b/src-tauri/src/database/migration.rs @@ -0,0 +1,46 @@ +use rusqlite::Connection; +use serde_json::Value; +use std::path::PathBuf; + +/// 从旧的 JSON 配置迁移数据到 SQLite +pub fn migrate_from_json( + conn: &Connection, +) -> Result<(), Box> { + // 检查是否已经迁移过 + let migrated: bool = conn + .query_row( + "SELECT value FROM settings WHERE key = 'migrated_from_json'", + [], + |row| row.get::<_, String>(0), + ) + .map(|v| v == "true") + .unwrap_or(false); + + if migrated { + return Ok(()); + } + + // 读取旧配置文件 + let home = dirs::home_dir().ok_or("Cannot find home directory")?; + let config_path = home.join(".proxycast").join("config.json"); + + if config_path.exists() { + let content = std::fs::read_to_string(&config_path)?; + let config: Value = serde_json::from_str(&content)?; + + // TODO: 解析旧配置并插入到数据库 + // 这里需要根据实际的旧配置格式来实现 + + // 备份旧配置 + let backup_path = home.join(".proxycast").join("config.json.backup"); + std::fs::copy(&config_path, &backup_path)?; + } + + // 标记迁移完成 + conn.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES ('migrated_from_json', 'true')", + [], + )?; + + Ok(()) +} diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs new file mode 100644 index 000000000..f21195d62 --- /dev/null +++ b/src-tauri/src/database/mod.rs @@ -0,0 +1,28 @@ +pub mod dao; +pub mod migration; +pub mod schema; + +use rusqlite::Connection; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +pub type DbConnection = Arc>; + +/// 获取数据库文件路径 +pub fn get_db_path() -> PathBuf { + let home = dirs::home_dir().expect("Cannot find home directory"); + let db_dir = home.join(".proxycast"); + std::fs::create_dir_all(&db_dir).expect("Cannot create .proxycast directory"); + db_dir.join("proxycast.db") +} + +/// 初始化数据库连接 +pub fn init_database() -> Result { + let db_path = get_db_path(); + let conn = Connection::open(&db_path)?; + + // 创建表结构 + schema::create_tables(&conn)?; + + Ok(Arc::new(Mutex::new(conn))) +} diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs new file mode 100644 index 000000000..9016fc719 --- /dev/null +++ b/src-tauri/src/database/schema.rs @@ -0,0 +1,64 @@ +use rusqlite::Connection; + +pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> { + // Providers 表 + conn.execute( + "CREATE TABLE IF NOT EXISTS providers ( + id TEXT NOT NULL, + app_type TEXT NOT NULL, + name TEXT NOT NULL, + settings_config TEXT NOT NULL, + category TEXT, + icon TEXT, + icon_color TEXT, + notes TEXT, + created_at INTEGER, + sort_index INTEGER, + is_current INTEGER DEFAULT 0, + PRIMARY KEY (id, app_type) + )", + [], + )?; + + // MCP 服务器表 + conn.execute( + "CREATE TABLE IF NOT EXISTS mcp_servers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + server_config TEXT NOT NULL, + description TEXT, + enabled_proxycast INTEGER DEFAULT 0, + enabled_claude INTEGER DEFAULT 0, + enabled_codex INTEGER DEFAULT 0, + enabled_gemini INTEGER DEFAULT 0, + created_at INTEGER + )", + [], + )?; + + // Prompts 表 + conn.execute( + "CREATE TABLE IF NOT EXISTS prompts ( + id TEXT NOT NULL, + app_type TEXT NOT NULL, + name TEXT NOT NULL, + content TEXT NOT NULL, + description TEXT, + is_current INTEGER DEFAULT 0, + created_at INTEGER, + PRIMARY KEY (id, app_type) + )", + [], + )?; + + // 设置表 + conn.execute( + "CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + )", + [], + )?; + + Ok(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d345dcf47..86f8920ab 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ mod config; mod converter; +mod database; mod logger; mod models; mod providers; diff --git a/src-tauri/src/models/app_type.rs b/src-tauri/src/models/app_type.rs new file mode 100644 index 000000000..62ee7f50f --- /dev/null +++ b/src-tauri/src/models/app_type.rs @@ -0,0 +1,41 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AppType { + ProxyCast, + Claude, + Codex, + Gemini, +} + +impl AppType { + pub fn as_str(&self) -> &'static str { + match self { + AppType::ProxyCast => "proxycast", + AppType::Claude => "claude", + AppType::Codex => "codex", + AppType::Gemini => "gemini", + } + } +} + +impl std::str::FromStr for AppType { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "proxycast" => Ok(AppType::ProxyCast), + "claude" => Ok(AppType::Claude), + "codex" => Ok(AppType::Codex), + "gemini" => Ok(AppType::Gemini), + _ => Err(format!("Invalid app type: {}", s)), + } + } +} + +impl std::fmt::Display for AppType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} diff --git a/src-tauri/src/models/mcp_model.rs b/src-tauri/src/models/mcp_model.rs new file mode 100644 index 000000000..7e868d24f --- /dev/null +++ b/src-tauri/src/models/mcp_model.rs @@ -0,0 +1,37 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct McpServer { + pub id: String, + pub name: String, + pub server_config: Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default)] + pub enabled_proxycast: bool, + #[serde(default)] + pub enabled_claude: bool, + #[serde(default)] + pub enabled_codex: bool, + #[serde(default)] + pub enabled_gemini: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub created_at: Option, +} + +impl McpServer { + pub fn new(id: String, name: String, server_config: Value) -> Self { + Self { + id, + name, + server_config, + description: None, + enabled_proxycast: false, + enabled_claude: false, + enabled_codex: false, + enabled_gemini: false, + created_at: Some(chrono::Utc::now().timestamp()), + } + } +} diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 6e288c903..bb2f4160a 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -1,10 +1,18 @@ pub mod anthropic; +pub mod app_type; pub mod codewhisperer; +pub mod mcp_model; pub mod openai; +pub mod prompt_model; +pub mod provider_model; #[allow(unused_imports)] pub use anthropic::*; +pub use app_type::AppType; #[allow(unused_imports)] pub use codewhisperer::*; +pub use mcp_model::McpServer; #[allow(unused_imports)] pub use openai::*; +pub use prompt_model::Prompt; +pub use provider_model::Provider; diff --git a/src-tauri/src/models/prompt_model.rs b/src-tauri/src/models/prompt_model.rs new file mode 100644 index 000000000..72f9e6682 --- /dev/null +++ b/src-tauri/src/models/prompt_model.rs @@ -0,0 +1,29 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Prompt { + pub id: String, + pub app_type: String, + pub name: String, + pub content: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default)] + pub is_current: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub created_at: Option, +} + +impl Prompt { + pub fn new(id: String, app_type: String, name: String, content: String) -> Self { + Self { + id, + app_type, + name, + content, + description: None, + is_current: false, + created_at: Some(chrono::Utc::now().timestamp()), + } + } +} diff --git a/src-tauri/src/models/provider_model.rs b/src-tauri/src/models/provider_model.rs new file mode 100644 index 000000000..8d9eb6984 --- /dev/null +++ b/src-tauri/src/models/provider_model.rs @@ -0,0 +1,42 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Provider { + pub id: String, + pub app_type: String, + pub name: String, + pub settings_config: Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub category: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub icon_color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub notes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub created_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sort_index: Option, + #[serde(default)] + pub is_current: bool, +} + +impl Provider { + pub fn new(id: String, app_type: String, name: String, settings_config: Value) -> Self { + Self { + id, + app_type, + name, + settings_config, + category: None, + icon: None, + icon_color: None, + notes: None, + created_at: Some(chrono::Utc::now().timestamp()), + sort_index: None, + is_current: false, + } + } +} diff --git a/src-tauri/src/providers/kiro.rs b/src-tauri/src/providers/kiro.rs index f3d5309e8..824b802ad 100644 --- a/src-tauri/src/providers/kiro.rs +++ b/src-tauri/src/providers/kiro.rs @@ -102,6 +102,13 @@ impl KiroProvider { if tokio::fs::try_exists(&path).await.unwrap_or(false) { let content = tokio::fs::read_to_string(&path).await?; let creds: KiroCredentials = serde_json::from_str(&content)?; + tracing::info!( + "[KIRO] Main file loaded: has_access={}, has_refresh={}, has_client_id={}, auth_method={:?}", + creds.access_token.is_some(), + creds.refresh_token.is_some(), + creds.client_id.is_some(), + creds.auth_method + ); merge_credentials(&mut merged, &creds); } @@ -114,6 +121,12 @@ impl KiroProvider { { if let Ok(content) = tokio::fs::read_to_string(&file_path).await { if let Ok(creds) = serde_json::from_str::(&content) { + tracing::info!( + "[KIRO] Extra file {:?}: has_client_id={}, has_client_secret={}", + file_path.file_name(), + creds.client_id.is_some(), + creds.client_secret.is_some() + ); merge_credentials(&mut merged, &creds); } } @@ -121,6 +134,15 @@ impl KiroProvider { } } + tracing::info!( + "[KIRO] Final merged: has_access={}, has_refresh={}, has_client_id={}, has_client_secret={}, auth_method={:?}", + merged.access_token.is_some(), + merged.refresh_token.is_some(), + merged.client_id.is_some(), + merged.client_secret.is_some(), + merged.auth_method + ); + self.credentials = merged; Ok(()) } @@ -161,25 +183,43 @@ impl KiroProvider { .to_lowercase(); let refresh_url = self.get_refresh_url(); - let body = if auth_method == "idc" { - serde_json::json!({ - "refreshToken": refresh_token, - "clientId": self.credentials.client_id, - "clientSecret": self.credentials.client_secret, - "grantType": "refresh_token" - }) - } else { - serde_json::json!({ "refreshToken": refresh_token }) - }; + let resp = if auth_method == "idc" { + // AWS OIDC endpoint requires form-urlencoded format + let client_id = self + .credentials + .client_id + .as_ref() + .ok_or("No client_id for IdC refresh")?; + let client_secret = self + .credentials + .client_secret + .as_ref() + .ok_or("No client_secret for IdC refresh")?; - let resp = self - .client - .post(&refresh_url) - .header("Content-Type", "application/json") - .header("Accept", "application/json") - .json(&body) - .send() - .await?; + let form_body = format!( + "grant_type=refresh_token&client_id={}&client_secret={}&refresh_token={}", + urlencoding::encode(client_id), + urlencoding::encode(client_secret), + urlencoding::encode(refresh_token) + ); + + self.client + .post(&refresh_url) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Accept", "application/json") + .body(form_body) + .send() + .await? + } else { + let body = serde_json::json!({ "refreshToken": refresh_token }); + self.client + .post(&refresh_url) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .json(&body) + .send() + .await? + }; if !resp.status().is_success() { let status = resp.status(); @@ -188,13 +228,20 @@ impl KiroProvider { } let data: serde_json::Value = resp.json().await?; + + // AWS OIDC returns snake_case, social endpoint returns camelCase let new_token = data["accessToken"] .as_str() + .or_else(|| data["access_token"].as_str()) .ok_or("No access token in response")?; self.credentials.access_token = Some(new_token.to_string()); - if let Some(rt) = data["refreshToken"].as_str() { + // Handle both camelCase and snake_case response formats + if let Some(rt) = data["refreshToken"] + .as_str() + .or_else(|| data["refresh_token"].as_str()) + { self.credentials.refresh_token = Some(rt.to_string()); } if let Some(arn) = data["profileArn"].as_str() { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index f1d2ac036..a4feb1a8e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ProxyCast", - "version": "0.4.2", + "version": "0.4.3", "identifier": "com.proxycast.app", "build": { "beforeDevCommand": "npm run dev",