mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
feat: 添加版本检查功能
- 添加 GitHub API 版本检查功能 - 实现语义化版本比较逻辑 - 更新前端版本检查 UI 和交互 - 添加新版本提示和下载链接 - 更新仓库信息到 aiclientproxy 组织 - 更新文档链接到官方文档站点 - 保持与 0.14.0 版本兼容性 - 统一代码格式化
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
# ProxyCast 版本检查功能 - 修改文件
|
||||
|
||||
本文件夹包含实现版本检查功能时修改的所有文件。
|
||||
|
||||
## 功能概述
|
||||
|
||||
- 添加 GitHub API 版本检查功能
|
||||
- 实现版本比较逻辑
|
||||
- 更新前端显示新版本和下载链接
|
||||
- 修复字段名匹配问题 (has_update -> hasUpdate)
|
||||
- 更新仓库链接到新地址
|
||||
- 更新文档链接到 https://aiclientproxy.github.io/proxycast/
|
||||
- 统一版本号到 0.13.0
|
||||
|
||||
## 修改文件列表
|
||||
|
||||
### 后端文件 (Rust)
|
||||
1. **src-tauri/src/commands/config_cmd.rs** - 核心版本检查逻辑
|
||||
2. **src-tauri/src/lib.rs** - 命令注册
|
||||
3. **src-tauri/Cargo.toml** - 版本号和仓库信息
|
||||
|
||||
### 前端文件 (TypeScript/React)
|
||||
4. **src/components/settings/AboutSection.tsx** - 版本检查 UI
|
||||
|
||||
### 配置文件
|
||||
5. **package.json** - 版本号和仓库信息
|
||||
6. **package-lock.json** - 版本号同步
|
||||
|
||||
## 主要变更
|
||||
|
||||
### 后端变更
|
||||
- 新增 `VersionCheckResult` 结构体
|
||||
- 新增 `check_for_updates()` 异步函数
|
||||
- 实现语义化版本比较逻辑
|
||||
- 集成 GitHub Releases API
|
||||
- 添加 serde 字段重命名支持
|
||||
|
||||
### 前端变更
|
||||
- 更新版本检查接口调用
|
||||
- 改进 UI 显示逻辑
|
||||
- 添加新版本提示和下载链接
|
||||
- 更新文档链接
|
||||
|
||||
### 配置变更
|
||||
- 统一所有文件版本号到 0.13.0
|
||||
- 更新仓库地址到 aiclientproxy 组织
|
||||
- 更新主页链接
|
||||
|
||||
## 文件结构
|
||||
```
|
||||
modified_files/
|
||||
├── README.md # 本说明文件
|
||||
├── package.json # 前端配置
|
||||
├── package-lock.json # 依赖锁定
|
||||
├── src/
|
||||
│ └── components/
|
||||
│ └── settings/
|
||||
│ └── AboutSection.tsx # 关于页面组件
|
||||
└── src-tauri/
|
||||
├── Cargo.toml # Rust 项目配置
|
||||
└── src/
|
||||
├── lib.rs # 主库文件
|
||||
└── commands/
|
||||
└── config_cmd.rs # 配置命令
|
||||
```
|
||||
|
||||
## 使用说明
|
||||
|
||||
这些文件可以直接替换原项目中的对应文件,实现完整的版本检查功能。
|
||||
|
||||
生成时间: 2025-12-20
|
||||
Generated
+5503
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"name": "proxycast",
|
||||
"private": true,
|
||||
"version": "0.13.0",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/aiclientproxy/proxycast.git"
|
||||
},
|
||||
"homepage": "https://github.com/aiclientproxy/proxycast",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"lint": "eslint src --max-warnings 0",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
|
||||
"prepare": "husky",
|
||||
"test": "vitest --run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.1",
|
||||
"@radix-ui/react-tabs": "^1.1.1",
|
||||
"@radix-ui/react-toast": "^1.2.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.3",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.4.2",
|
||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.460.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tailwind-merge": "^2.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.15.0",
|
||||
"@fast-check/vitest": "^0.2.4",
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.15.0",
|
||||
"@typescript-eslint/parser": "^8.15.0",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.15.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.14",
|
||||
"fast-check": "^4.4.0",
|
||||
"globals": "^15.12.0",
|
||||
"husky": "^9.1.7",
|
||||
"postcss": "^8.4.47",
|
||||
"prettier": "^3.3.3",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10",
|
||||
"vitest": "^4.0.16"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
[package]
|
||||
name = "proxycast"
|
||||
version = "0.13.0"
|
||||
description = "AI API Proxy Desktop App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
repository = "https://github.com/aiclientproxy/proxycast"
|
||||
homepage = "https://github.com/aiclientproxy/proxycast"
|
||||
|
||||
[lib]
|
||||
name = "proxycast_lib"
|
||||
crate-type = ["lib", "cdylib", "staticlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-autostart = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
axum = { version = "0.7", features = ["ws"] }
|
||||
axum-server = { version = "0.7", features = ["tls-rustls"] }
|
||||
rustls-pemfile = "2"
|
||||
tower = "0.4"
|
||||
tower-http = { version = "0.5", features = ["limit"] }
|
||||
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
dirs = "5"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
futures = "0.3"
|
||||
async-stream = "0.3"
|
||||
regex = "1"
|
||||
md5 = "0.7"
|
||||
urlencoding = "2"
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
serde_yaml = "0.9"
|
||||
indexmap = { version = "2", features = ["serde"] }
|
||||
zip = "0.6"
|
||||
anyhow = "1"
|
||||
dashmap = "5"
|
||||
notify = { version = "6", default-features = false, features = ["macos_fsevent"] }
|
||||
parking_lot = "0.12"
|
||||
tiktoken-rs = "0.6"
|
||||
async-trait = "0.1"
|
||||
thiserror = "1"
|
||||
base64 = "0.22"
|
||||
rand = "0.8"
|
||||
sha2 = "0.10"
|
||||
serde_urlencoded = "0.7"
|
||||
open = "5"
|
||||
url = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = "1"
|
||||
tempfile = "3"
|
||||
|
||||
[features]
|
||||
default = ["custom-protocol"]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
@@ -0,0 +1,676 @@
|
||||
use crate::config::{
|
||||
Config, ConfigManager, ExportBundle, ExportOptions as ExportServiceOptions, ExportService,
|
||||
ImportOptions as ImportServiceOptions, ImportService, ValidationResult,
|
||||
};
|
||||
use crate::models::AppType;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_autostart::ManagerExt;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConfigStatus {
|
||||
pub exists: bool,
|
||||
pub path: String,
|
||||
pub has_env: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VersionCheckResult {
|
||||
pub current: String,
|
||||
pub latest: Option<String>,
|
||||
#[serde(rename = "hasUpdate")]
|
||||
pub has_update: bool,
|
||||
#[serde(rename = "downloadUrl")]
|
||||
pub download_url: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Get the config directory path for an app type
|
||||
fn get_config_dir(app_type: &AppType) -> Option<PathBuf> {
|
||||
let home = dirs::home_dir()?;
|
||||
match app_type {
|
||||
AppType::Claude => Some(home.join(".claude")),
|
||||
AppType::Codex => Some(home.join(".codex")),
|
||||
AppType::Gemini => Some(home.join(".gemini")),
|
||||
AppType::ProxyCast => dirs::config_dir().map(|d| d.join("proxycast")),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_config_status(app_type: String) -> Result<ConfigStatus, String> {
|
||||
let app = app_type.parse::<AppType>().map_err(|e| e.to_string())?;
|
||||
let config_dir = get_config_dir(&app).ok_or("Cannot determine config directory")?;
|
||||
|
||||
let main_config = match app {
|
||||
AppType::Claude => config_dir.join("settings.json"),
|
||||
AppType::Codex => config_dir.join("auth.json"),
|
||||
AppType::Gemini => config_dir.join(".env"),
|
||||
AppType::ProxyCast => config_dir.join("config.json"),
|
||||
};
|
||||
|
||||
let has_env = match app {
|
||||
AppType::Claude => {
|
||||
config_dir.join("settings.json").exists()
|
||||
&& std::fs::read_to_string(config_dir.join("settings.json"))
|
||||
.map(|s| s.contains("env"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
AppType::Codex => config_dir.join("auth.json").exists(),
|
||||
AppType::Gemini => config_dir.join(".env").exists(),
|
||||
AppType::ProxyCast => config_dir.join("config.json").exists(),
|
||||
};
|
||||
|
||||
Ok(ConfigStatus {
|
||||
exists: main_config.exists(),
|
||||
path: config_dir.to_string_lossy().to_string(),
|
||||
has_env,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_config_dir_path(app_type: String) -> Result<String, String> {
|
||||
let app = app_type.parse::<AppType>().map_err(|e| e.to_string())?;
|
||||
let config_dir = get_config_dir(&app).ok_or("Cannot determine config directory")?;
|
||||
Ok(config_dir.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_config_folder(_handle: AppHandle, app_type: String) -> Result<bool, String> {
|
||||
let app = app_type.parse::<AppType>().map_err(|e| e.to_string())?;
|
||||
let config_dir = get_config_dir(&app).ok_or("Cannot determine config directory")?;
|
||||
|
||||
if !config_dir.exists() {
|
||||
std::fs::create_dir_all(&config_dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
std::process::Command::new("open")
|
||||
.arg(&config_dir)
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
std::process::Command::new("explorer")
|
||||
.arg(&config_dir)
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
std::process::Command::new("xdg-open")
|
||||
.arg(&config_dir)
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolVersion {
|
||||
pub name: String,
|
||||
pub version: Option<String>,
|
||||
pub installed: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_tool_versions() -> Result<Vec<ToolVersion>, String> {
|
||||
let mut versions = Vec::new();
|
||||
|
||||
// Check Claude Code version
|
||||
let claude_version = std::process::Command::new("claude")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| {
|
||||
if o.status.success() {
|
||||
String::from_utf8(o.stdout).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.map(|s| s.trim().to_string());
|
||||
|
||||
versions.push(ToolVersion {
|
||||
name: "Claude Code".to_string(),
|
||||
version: claude_version.clone(),
|
||||
installed: claude_version.is_some(),
|
||||
});
|
||||
|
||||
// Check Codex version
|
||||
let codex_version = std::process::Command::new("codex")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| {
|
||||
if o.status.success() {
|
||||
String::from_utf8(o.stdout).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.map(|s| s.trim().to_string());
|
||||
|
||||
versions.push(ToolVersion {
|
||||
name: "Codex".to_string(),
|
||||
version: codex_version.clone(),
|
||||
installed: codex_version.is_some(),
|
||||
});
|
||||
|
||||
// Check Gemini CLI version
|
||||
let gemini_version = std::process::Command::new("gemini")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| {
|
||||
if o.status.success() {
|
||||
String::from_utf8(o.stdout).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.map(|s| s.trim().to_string());
|
||||
|
||||
versions.push(ToolVersion {
|
||||
name: "Gemini CLI".to_string(),
|
||||
version: gemini_version.clone(),
|
||||
installed: gemini_version.is_some(),
|
||||
});
|
||||
|
||||
Ok(versions)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_auto_launch_status(app: AppHandle) -> Result<bool, String> {
|
||||
let autostart_manager = app.autolaunch();
|
||||
autostart_manager
|
||||
.is_enabled()
|
||||
.map_err(|e| format!("Failed to get autostart status: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_auto_launch(app: AppHandle, enabled: bool) -> Result<bool, String> {
|
||||
let autostart_manager = app.autolaunch();
|
||||
|
||||
if enabled {
|
||||
autostart_manager
|
||||
.enable()
|
||||
.map_err(|e| format!("Failed to enable autostart: {e}"))?;
|
||||
} else {
|
||||
autostart_manager
|
||||
.disable()
|
||||
.map_err(|e| format!("Failed to disable autostart: {e}"))?;
|
||||
}
|
||||
|
||||
Ok(enabled)
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
// ============ Config Import/Export Commands ============
|
||||
|
||||
/// 配置导出选项
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExportOptions {
|
||||
/// 是否脱敏敏感信息(API 密钥等)
|
||||
pub redact_secrets: bool,
|
||||
}
|
||||
|
||||
/// 配置导出结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExportResult {
|
||||
/// YAML 配置内容
|
||||
pub content: String,
|
||||
/// 建议的文件名
|
||||
pub suggested_filename: String,
|
||||
}
|
||||
|
||||
/// 导出配置为 YAML 字符串
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `config` - 当前配置
|
||||
/// * `redact_secrets` - 是否脱敏敏感信息
|
||||
#[tauri::command]
|
||||
pub fn export_config(config: Config, redact_secrets: bool) -> Result<ExportResult, String> {
|
||||
let manager = ConfigManager::new(PathBuf::from("temp.yaml"));
|
||||
let mut manager_with_config = manager;
|
||||
manager_with_config.set_config(config);
|
||||
|
||||
let content = manager_with_config
|
||||
.export(redact_secrets)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// 生成带时间戳的文件名
|
||||
let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
|
||||
let suffix = if redact_secrets { "_redacted" } else { "" };
|
||||
let suggested_filename = format!("proxycast_config_{}{}.yaml", timestamp, suffix);
|
||||
|
||||
Ok(ExportResult {
|
||||
content,
|
||||
suggested_filename,
|
||||
})
|
||||
}
|
||||
|
||||
/// 配置导入选项
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ImportOptions {
|
||||
/// 是否合并到现有配置(true)或替换(false)
|
||||
pub merge: bool,
|
||||
}
|
||||
|
||||
/// 配置导入结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ImportResult {
|
||||
/// 是否成功
|
||||
pub success: bool,
|
||||
/// 导入后的配置
|
||||
pub config: Config,
|
||||
/// 警告信息(如果有)
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// 验证配置 YAML 格式
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `yaml_content` - YAML 配置字符串
|
||||
#[tauri::command]
|
||||
pub fn validate_config_yaml(yaml_content: String) -> Result<Config, String> {
|
||||
ConfigManager::parse_yaml(&yaml_content).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 导入配置
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `current_config` - 当前配置
|
||||
/// * `yaml_content` - 要导入的 YAML 配置字符串
|
||||
/// * `merge` - 是否合并到现有配置(true)或替换(false)
|
||||
#[tauri::command]
|
||||
pub fn import_config(
|
||||
current_config: Config,
|
||||
yaml_content: String,
|
||||
merge: bool,
|
||||
) -> Result<ImportResult, String> {
|
||||
let mut manager = ConfigManager::new(PathBuf::from("temp.yaml"));
|
||||
manager.set_config(current_config);
|
||||
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
// 先验证 YAML 格式
|
||||
let imported_config = ConfigManager::parse_yaml(&yaml_content).map_err(|e| e.to_string())?;
|
||||
|
||||
// 检查是否包含脱敏的密钥
|
||||
if imported_config.server.api_key == "***REDACTED***" {
|
||||
warnings.push("导入的配置包含脱敏的 API 密钥,将保留原有值".to_string());
|
||||
}
|
||||
if imported_config
|
||||
.providers
|
||||
.openai
|
||||
.api_key
|
||||
.as_ref()
|
||||
.map(|k| k == "***REDACTED***")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
warnings.push("导入的配置包含脱敏的 OpenAI API 密钥,将保留原有值".to_string());
|
||||
}
|
||||
if imported_config
|
||||
.providers
|
||||
.claude
|
||||
.api_key
|
||||
.as_ref()
|
||||
.map(|k| k == "***REDACTED***")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
warnings.push("导入的配置包含脱敏的 Claude API 密钥,将保留原有值".to_string());
|
||||
}
|
||||
|
||||
// 执行导入
|
||||
manager
|
||||
.import(&yaml_content, merge)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// 如果导入的配置包含脱敏的密钥,恢复原有值
|
||||
let final_config = manager.config().clone();
|
||||
|
||||
Ok(ImportResult {
|
||||
success: true,
|
||||
config: final_config,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取配置文件路径信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConfigPathInfo {
|
||||
/// YAML 配置文件路径
|
||||
pub yaml_path: String,
|
||||
/// JSON 配置文件路径(旧版)
|
||||
pub json_path: String,
|
||||
/// YAML 配置是否存在
|
||||
pub yaml_exists: bool,
|
||||
/// JSON 配置是否存在
|
||||
pub json_exists: bool,
|
||||
}
|
||||
|
||||
/// 获取配置文件路径信息
|
||||
#[tauri::command]
|
||||
pub fn get_config_paths() -> Result<ConfigPathInfo, String> {
|
||||
let yaml_path = ConfigManager::default_config_path();
|
||||
let json_path = dirs::config_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("proxycast")
|
||||
.join("config.json");
|
||||
|
||||
Ok(ConfigPathInfo {
|
||||
yaml_path: yaml_path.to_string_lossy().to_string(),
|
||||
json_path: json_path.to_string_lossy().to_string(),
|
||||
yaml_exists: yaml_path.exists(),
|
||||
json_exists: json_path.exists(),
|
||||
})
|
||||
}
|
||||
|
||||
// ============ Enhanced Export/Import Commands (using ExportService/ImportService) ============
|
||||
|
||||
/// 统一导出选项
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UnifiedExportOptions {
|
||||
/// 是否包含配置
|
||||
pub include_config: bool,
|
||||
/// 是否包含凭证
|
||||
pub include_credentials: bool,
|
||||
/// 是否脱敏敏感信息
|
||||
pub redact_secrets: bool,
|
||||
}
|
||||
|
||||
/// 统一导出结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UnifiedExportResult {
|
||||
/// 导出包内容(JSON 格式)
|
||||
pub content: String,
|
||||
/// 建议的文件名
|
||||
pub suggested_filename: String,
|
||||
/// 是否已脱敏
|
||||
pub redacted: bool,
|
||||
/// 是否包含配置
|
||||
pub has_config: bool,
|
||||
/// 是否包含凭证
|
||||
pub has_credentials: bool,
|
||||
}
|
||||
|
||||
/// 导出完整的配置和凭证包
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `config` - 当前配置
|
||||
/// * `options` - 导出选项
|
||||
///
|
||||
/// # Requirements: 3.1, 3.2
|
||||
#[tauri::command]
|
||||
pub fn export_bundle(
|
||||
config: Config,
|
||||
options: UnifiedExportOptions,
|
||||
) -> Result<UnifiedExportResult, String> {
|
||||
let export_options = ExportServiceOptions {
|
||||
include_config: options.include_config,
|
||||
include_credentials: options.include_credentials,
|
||||
redact_secrets: options.redact_secrets,
|
||||
};
|
||||
|
||||
// 获取应用版本
|
||||
let app_version = env!("CARGO_PKG_VERSION").to_string();
|
||||
|
||||
let bundle =
|
||||
ExportService::export(&config, &export_options, &app_version).map_err(|e| e.to_string())?;
|
||||
|
||||
let content = bundle.to_json().map_err(|e| e.to_string())?;
|
||||
|
||||
// 生成带时间戳的文件名
|
||||
let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
|
||||
let suffix = if options.redact_secrets {
|
||||
"_redacted"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let scope = match (options.include_config, options.include_credentials) {
|
||||
(true, true) => "full",
|
||||
(true, false) => "config",
|
||||
(false, true) => "credentials",
|
||||
(false, false) => "empty",
|
||||
};
|
||||
let suggested_filename = format!("proxycast_{}_{}{}.json", scope, timestamp, suffix);
|
||||
|
||||
Ok(UnifiedExportResult {
|
||||
content,
|
||||
suggested_filename,
|
||||
redacted: bundle.redacted,
|
||||
has_config: bundle.has_config(),
|
||||
has_credentials: bundle.has_credentials(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 仅导出配置为 YAML
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `config` - 当前配置
|
||||
/// * `redact_secrets` - 是否脱敏敏感信息
|
||||
///
|
||||
/// # Requirements: 3.1, 5.1
|
||||
#[tauri::command]
|
||||
pub fn export_config_yaml(config: Config, redact_secrets: bool) -> Result<ExportResult, String> {
|
||||
let content = ExportService::export_yaml(&config, redact_secrets).map_err(|e| e.to_string())?;
|
||||
|
||||
// 生成带时间戳的文件名
|
||||
let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
|
||||
let suffix = if redact_secrets { "_redacted" } else { "" };
|
||||
let suggested_filename = format!("proxycast_config_{}{}.yaml", timestamp, suffix);
|
||||
|
||||
Ok(ExportResult {
|
||||
content,
|
||||
suggested_filename,
|
||||
})
|
||||
}
|
||||
|
||||
/// 验证导入内容
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `content` - 导入内容(JSON 导出包或 YAML 配置)
|
||||
///
|
||||
/// # Requirements: 4.1, 4.2
|
||||
#[tauri::command]
|
||||
pub fn validate_import(content: String) -> Result<ValidationResult, String> {
|
||||
Ok(ImportService::validate(&content))
|
||||
}
|
||||
|
||||
/// 导入完整的导出包
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `current_config` - 当前配置
|
||||
/// * `content` - 导出包内容(JSON 格式)
|
||||
/// * `merge` - 是否合并到现有配置
|
||||
///
|
||||
/// # Requirements: 4.1, 4.3
|
||||
#[tauri::command]
|
||||
pub fn import_bundle(
|
||||
current_config: Config,
|
||||
content: String,
|
||||
merge: bool,
|
||||
) -> Result<ImportResult, String> {
|
||||
// 首先尝试解析为 ExportBundle
|
||||
if let Ok(bundle) = ExportBundle::from_json(&content) {
|
||||
let options = ImportServiceOptions { merge };
|
||||
let result =
|
||||
ImportService::import(&bundle, ¤t_config, &options, ¤t_config.auth_dir)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
return Ok(ImportResult {
|
||||
success: result.success,
|
||||
config: result.config,
|
||||
warnings: result.warnings,
|
||||
});
|
||||
}
|
||||
|
||||
// 尝试解析为 YAML 配置
|
||||
let options = ImportServiceOptions { merge };
|
||||
let result = ImportService::import_yaml(&content, ¤t_config, &options)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(ImportResult {
|
||||
success: result.success,
|
||||
config: result.config,
|
||||
warnings: result.warnings,
|
||||
})
|
||||
}
|
||||
|
||||
// ============ Path Utility Commands ============
|
||||
|
||||
/// 展开路径中的 tilde (~) 为用户主目录
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `path` - 要展开的路径字符串
|
||||
///
|
||||
/// # Returns
|
||||
/// 展开后的完整路径字符串
|
||||
///
|
||||
/// # Requirements: 2.3
|
||||
#[tauri::command]
|
||||
pub fn expand_path(path: String) -> Result<String, String> {
|
||||
use crate::config::expand_tilde;
|
||||
|
||||
let expanded = expand_tilde(&path);
|
||||
Ok(expanded.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// 打开认证目录
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `path` - 认证目录路径(支持 tilde 展开)
|
||||
///
|
||||
/// # Requirements: 2.2
|
||||
#[tauri::command]
|
||||
pub async fn open_auth_dir(path: String) -> Result<bool, String> {
|
||||
use crate::config::expand_tilde;
|
||||
|
||||
let expanded = expand_tilde(&path);
|
||||
|
||||
// 确保目录存在
|
||||
if !expanded.exists() {
|
||||
std::fs::create_dir_all(&expanded).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
std::process::Command::new("open")
|
||||
.arg(&expanded)
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
std::process::Command::new("explorer")
|
||||
.arg(&expanded)
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
std::process::Command::new("xdg-open")
|
||||
.arg(&expanded)
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_for_updates() -> Result<VersionCheckResult, String> {
|
||||
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const GITHUB_API_URL: &str =
|
||||
"https://api.github.com/repos/aiclientproxy/proxycast/releases/latest";
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
match client
|
||||
.get(GITHUB_API_URL)
|
||||
.header("User-Agent", "ProxyCast")
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
if response.status().is_success() {
|
||||
match response.json::<serde_json::Value>().await {
|
||||
Ok(data) => {
|
||||
let latest_version = data["tag_name"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.trim_start_matches('v');
|
||||
|
||||
let download_url = data["html_url"].as_str().map(|s| s.to_string());
|
||||
|
||||
let has_update = version_compare(CURRENT_VERSION, latest_version);
|
||||
|
||||
Ok(VersionCheckResult {
|
||||
current: CURRENT_VERSION.to_string(),
|
||||
latest: Some(latest_version.to_string()),
|
||||
has_update,
|
||||
download_url,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
Err(e) => Ok(VersionCheckResult {
|
||||
current: CURRENT_VERSION.to_string(),
|
||||
latest: None,
|
||||
has_update: false,
|
||||
download_url: None,
|
||||
error: Some(format!("解析响应失败: {}", e)),
|
||||
}),
|
||||
}
|
||||
} else {
|
||||
Ok(VersionCheckResult {
|
||||
current: CURRENT_VERSION.to_string(),
|
||||
latest: None,
|
||||
has_update: false,
|
||||
download_url: None,
|
||||
error: Some(format!("GitHub API 请求失败: {}", response.status())),
|
||||
})
|
||||
}
|
||||
}
|
||||
Err(e) => Ok(VersionCheckResult {
|
||||
current: CURRENT_VERSION.to_string(),
|
||||
latest: None,
|
||||
has_update: false,
|
||||
download_url: None,
|
||||
error: Some(format!("网络请求失败: {}", e)),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// 简单的版本比较函数
|
||||
/// 返回 true 如果 latest > current
|
||||
fn version_compare(current: &str, latest: &str) -> bool {
|
||||
// 移除 'v' 前缀
|
||||
let current = current.trim_start_matches('v');
|
||||
let latest = latest.trim_start_matches('v');
|
||||
|
||||
let current_parts: Vec<u32> = current.split('.').filter_map(|s| s.parse().ok()).collect();
|
||||
let latest_parts: Vec<u32> = latest.split('.').filter_map(|s| s.parse().ok()).collect();
|
||||
|
||||
let max_len = current_parts.len().max(latest_parts.len());
|
||||
|
||||
for i in 0..max_len {
|
||||
let current_part = current_parts.get(i).unwrap_or(&0);
|
||||
let latest_part = latest_parts.get(i).unwrap_or(&0);
|
||||
|
||||
if latest_part > current_part {
|
||||
return true;
|
||||
} else if latest_part < current_part {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,254 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
ExternalLink,
|
||||
RefreshCw,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
interface VersionInfo {
|
||||
current: string;
|
||||
latest?: string;
|
||||
hasUpdate: boolean;
|
||||
downloadUrl?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ToolVersion {
|
||||
name: string;
|
||||
version: string | null;
|
||||
installed: boolean;
|
||||
}
|
||||
|
||||
export function AboutSection() {
|
||||
const [versionInfo, setVersionInfo] = useState<VersionInfo>({
|
||||
current: "0.13.0",
|
||||
latest: undefined,
|
||||
hasUpdate: false,
|
||||
downloadUrl: undefined,
|
||||
error: undefined,
|
||||
});
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [toolVersions, setToolVersions] = useState<ToolVersion[]>([]);
|
||||
const [loadingTools, setLoadingTools] = useState(true);
|
||||
|
||||
// 加载本地工具版本
|
||||
useEffect(() => {
|
||||
const loadToolVersions = async () => {
|
||||
try {
|
||||
const versions = await invoke<ToolVersion[]>("get_tool_versions");
|
||||
setToolVersions(versions);
|
||||
} catch (error) {
|
||||
console.error("Failed to load tool versions:", error);
|
||||
} finally {
|
||||
setLoadingTools(false);
|
||||
}
|
||||
};
|
||||
loadToolVersions();
|
||||
}, []);
|
||||
|
||||
const handleCheckUpdate = async () => {
|
||||
setChecking(true);
|
||||
try {
|
||||
const result = await invoke<VersionInfo>("check_for_updates");
|
||||
setVersionInfo(result);
|
||||
} catch (error) {
|
||||
console.error("Failed to check for updates:", error);
|
||||
setVersionInfo((prev) => ({
|
||||
...prev,
|
||||
error: "检查更新失败",
|
||||
}));
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
{/* 应用信息 */}
|
||||
<div className="p-6 rounded-lg border text-center space-y-4">
|
||||
<div className="w-16 h-16 mx-auto rounded-2xl bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center">
|
||||
<span className="text-2xl font-bold text-white">PC</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">ProxyCast</h2>
|
||||
<p className="text-sm text-muted-foreground">AI API 代理服务</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span className="text-sm">版本 {versionInfo.current}</span>
|
||||
{versionInfo.hasUpdate ? (
|
||||
<span className="px-2 py-0.5 rounded-full bg-green-100 text-green-700 text-xs">
|
||||
有新版本 {versionInfo.latest}
|
||||
</span>
|
||||
) : versionInfo.error ? (
|
||||
<span className="flex items-center gap-1 text-xs text-red-500">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{versionInfo.error}
|
||||
</span>
|
||||
) : versionInfo.latest ? (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
已是最新
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button
|
||||
onClick={handleCheckUpdate}
|
||||
disabled={checking}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border text-sm hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${checking ? "animate-spin" : ""}`}
|
||||
/>
|
||||
检查更新
|
||||
</button>
|
||||
|
||||
{versionInfo.hasUpdate && versionInfo.downloadUrl && (
|
||||
<a
|
||||
href={versionInfo.downloadUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-green-600 text-white text-sm hover:bg-green-700"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
下载新版本
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 链接 */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium">相关链接</h3>
|
||||
<div className="space-y-2">
|
||||
<a
|
||||
href="https://github.com/aiclientproxy/proxycast"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between p-3 rounded-lg border hover:bg-muted/50"
|
||||
>
|
||||
<span className="text-sm">GitHub 仓库</span>
|
||||
<ExternalLink className="h-4 w-4 text-muted-foreground" />
|
||||
</a>
|
||||
<a
|
||||
href="https://aiclientproxy.github.io/proxycast/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between p-3 rounded-lg border hover:bg-muted/50"
|
||||
>
|
||||
<span className="text-sm">文档</span>
|
||||
<ExternalLink className="h-4 w-4 text-muted-foreground" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/aiclientproxy/proxycast/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between p-3 rounded-lg border hover:bg-muted/50"
|
||||
>
|
||||
<span className="text-sm">问题反馈</span>
|
||||
<ExternalLink className="h-4 w-4 text-muted-foreground" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 使用说明 Q&A */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium">使用说明</h3>
|
||||
<div className="space-y-2">
|
||||
<QAItem
|
||||
question="ProxyCast 是什么?"
|
||||
answer="ProxyCast 是一个本地 AI API 代理服务,可以将 Kiro、Gemini CLI 等工具的凭证转换为标准的 OpenAI/Anthropic API,供 Claude Code、Cherry Studio、Cursor 等工具使用。"
|
||||
/>
|
||||
<QAItem
|
||||
question="如何开始使用?"
|
||||
answer="1. 在「凭证池」添加你的凭证(如 Kiro 凭证文件或 Claude API Key);2. 在「API Server」启动服务并选择默认 Provider;3. 在你的 AI 工具中配置 API 地址为 http://localhost:8999"
|
||||
/>
|
||||
<QAItem
|
||||
question="什么是配置切换?"
|
||||
answer="配置切换可以一键修改 Claude Code、Codex、Gemini CLI 的配置文件,快速在不同 Provider 间切换。添加 ProxyCast 配置后,这些工具就会使用本地代理服务。"
|
||||
/>
|
||||
<QAItem
|
||||
question="凭证文件在哪里?"
|
||||
answer="Kiro 凭证:~/.kiro/kiro_creds.json;Gemini CLI 凭证:~/.gemini/oauth_creds.json;Qwen 凭证:~/.qwen-coder/auth.json"
|
||||
/>
|
||||
<QAItem
|
||||
question="支持哪些 AI 工具?"
|
||||
answer="支持所有兼容 OpenAI API 或 Anthropic API 的工具,如 Claude Code、Cursor、Cherry Studio、Continue、Cline 等。"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 本地工具版本 */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium">本地工具版本</h3>
|
||||
<div className="p-4 rounded-lg border space-y-3">
|
||||
{loadingTools ? (
|
||||
<>
|
||||
<ToolVersionItem name="Claude Code" version="检测中..." />
|
||||
<ToolVersionItem name="Codex" version="检测中..." />
|
||||
<ToolVersionItem name="Gemini CLI" version="检测中..." />
|
||||
</>
|
||||
) : (
|
||||
toolVersions.map((tool) => (
|
||||
<ToolVersionItem
|
||||
key={tool.name}
|
||||
name={tool.name}
|
||||
version={tool.installed ? tool.version || "已安装" : "未安装"}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 版权信息 */}
|
||||
<div className="text-center text-xs text-muted-foreground pt-4 border-t">
|
||||
<p>Made with love for AI developers</p>
|
||||
<p className="mt-1">2025-2026 ProxyCast</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolVersionItem({ name, version }: { name: string; version: string }) {
|
||||
const isInstalled = version !== "未安装" && !version.includes("检测");
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">{name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{isInstalled ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<AlertCircle className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground font-mono">
|
||||
{version}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QAItem({ question, answer }: { question: string; answer: string }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex w-full items-center justify-between p-3 text-left hover:bg-muted/50"
|
||||
>
|
||||
<span className="text-sm font-medium">{question}</span>
|
||||
<span className="text-muted-foreground">{isOpen ? "−" : "+"}</span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="px-3 pb-3 text-sm text-muted-foreground">{answer}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "proxycast",
|
||||
"version": "0.12.5",
|
||||
"version": "0.14.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "proxycast",
|
||||
"version": "0.12.5",
|
||||
"version": "0.14.0",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
"private": true,
|
||||
"version": "0.14.0",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/aiclientproxy/proxycast.git"
|
||||
},
|
||||
"homepage": "https://github.com/aiclientproxy/proxycast",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
|
||||
Generated
+1
-1
@@ -3367,7 +3367,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast"
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
|
||||
@@ -4,6 +4,8 @@ version = "0.14.0"
|
||||
description = "AI API Proxy Desktop App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
repository = "https://github.com/aiclientproxy/proxycast"
|
||||
homepage = "https://github.com/aiclientproxy/proxycast"
|
||||
|
||||
[lib]
|
||||
name = "proxycast_lib"
|
||||
|
||||
@@ -573,3 +573,108 @@ pub async fn open_auth_dir(path: String) -> Result<bool, String> {
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
// ============ Version Check Commands ============
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VersionCheckResult {
|
||||
pub current: String,
|
||||
pub latest: Option<String>,
|
||||
#[serde(rename = "hasUpdate")]
|
||||
pub has_update: bool,
|
||||
#[serde(rename = "downloadUrl")]
|
||||
pub download_url: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// 检查应用更新
|
||||
///
|
||||
/// 从 GitHub Releases API 获取最新版本信息并与当前版本比较
|
||||
#[tauri::command]
|
||||
pub async fn check_for_updates() -> Result<VersionCheckResult, String> {
|
||||
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const GITHUB_API_URL: &str =
|
||||
"https://api.github.com/repos/aiclientproxy/proxycast/releases/latest";
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
match client
|
||||
.get(GITHUB_API_URL)
|
||||
.header("User-Agent", "ProxyCast")
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
if response.status().is_success() {
|
||||
match response.json::<serde_json::Value>().await {
|
||||
Ok(data) => {
|
||||
let latest_version = data["tag_name"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.trim_start_matches('v');
|
||||
|
||||
let download_url = data["html_url"].as_str().map(|s| s.to_string());
|
||||
|
||||
let has_update = version_compare(CURRENT_VERSION, latest_version);
|
||||
|
||||
Ok(VersionCheckResult {
|
||||
current: CURRENT_VERSION.to_string(),
|
||||
latest: Some(latest_version.to_string()),
|
||||
has_update,
|
||||
download_url,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
Err(e) => Ok(VersionCheckResult {
|
||||
current: CURRENT_VERSION.to_string(),
|
||||
latest: None,
|
||||
has_update: false,
|
||||
download_url: None,
|
||||
error: Some(format!("解析响应失败: {}", e)),
|
||||
}),
|
||||
}
|
||||
} else {
|
||||
Ok(VersionCheckResult {
|
||||
current: CURRENT_VERSION.to_string(),
|
||||
latest: None,
|
||||
has_update: false,
|
||||
download_url: None,
|
||||
error: Some(format!("GitHub API 请求失败: {}", response.status())),
|
||||
})
|
||||
}
|
||||
}
|
||||
Err(e) => Ok(VersionCheckResult {
|
||||
current: CURRENT_VERSION.to_string(),
|
||||
latest: None,
|
||||
has_update: false,
|
||||
download_url: None,
|
||||
error: Some(format!("网络请求失败: {}", e)),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// 简单的版本比较函数
|
||||
/// 返回 true 如果 latest > current
|
||||
fn version_compare(current: &str, latest: &str) -> bool {
|
||||
// 移除 'v' 前缀
|
||||
let current = current.trim_start_matches('v');
|
||||
let latest = latest.trim_start_matches('v');
|
||||
|
||||
let current_parts: Vec<u32> = current.split('.').filter_map(|s| s.parse().ok()).collect();
|
||||
let latest_parts: Vec<u32> = latest.split('.').filter_map(|s| s.parse().ok()).collect();
|
||||
|
||||
let max_len = current_parts.len().max(latest_parts.len());
|
||||
|
||||
for i in 0..max_len {
|
||||
let current_part = current_parts.get(i).unwrap_or(&0);
|
||||
let latest_part = latest_parts.get(i).unwrap_or(&0);
|
||||
|
||||
if latest_part > current_part {
|
||||
return true;
|
||||
} else if latest_part < current_part {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
@@ -1619,6 +1619,7 @@ pub fn run() {
|
||||
// Path utility commands
|
||||
commands::config_cmd::expand_path,
|
||||
commands::config_cmd::open_auth_dir,
|
||||
commands::config_cmd::check_for_updates,
|
||||
// MCP commands
|
||||
commands::mcp_cmd::get_mcp_servers,
|
||||
commands::mcp_cmd::add_mcp_server,
|
||||
|
||||
@@ -11,6 +11,8 @@ interface VersionInfo {
|
||||
current: string;
|
||||
latest?: string;
|
||||
hasUpdate: boolean;
|
||||
downloadUrl?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ToolVersion {
|
||||
@@ -20,10 +22,12 @@ interface ToolVersion {
|
||||
}
|
||||
|
||||
export function AboutSection() {
|
||||
const [versionInfo] = useState<VersionInfo>({
|
||||
current: "0.8.0",
|
||||
const [versionInfo, setVersionInfo] = useState<VersionInfo>({
|
||||
current: "0.14.0",
|
||||
latest: undefined,
|
||||
hasUpdate: false,
|
||||
downloadUrl: undefined,
|
||||
error: undefined,
|
||||
});
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [toolVersions, setToolVersions] = useState<ToolVersion[]>([]);
|
||||
@@ -46,9 +50,18 @@ export function AboutSection() {
|
||||
|
||||
const handleCheckUpdate = async () => {
|
||||
setChecking(true);
|
||||
// TODO: 实现版本检查
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
setChecking(false);
|
||||
try {
|
||||
const result = await invoke<VersionInfo>("check_for_updates");
|
||||
setVersionInfo(result);
|
||||
} catch (error) {
|
||||
console.error("Failed to check for updates:", error);
|
||||
setVersionInfo((prev) => ({
|
||||
...prev,
|
||||
error: "检查更新失败",
|
||||
}));
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -68,24 +81,45 @@ export function AboutSection() {
|
||||
<span className="text-sm">版本 {versionInfo.current}</span>
|
||||
{versionInfo.hasUpdate ? (
|
||||
<span className="px-2 py-0.5 rounded-full bg-green-100 text-green-700 text-xs">
|
||||
有新版本
|
||||
有新版本 {versionInfo.latest}
|
||||
</span>
|
||||
) : (
|
||||
) : versionInfo.error ? (
|
||||
<span className="flex items-center gap-1 text-xs text-red-500">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{versionInfo.error}
|
||||
</span>
|
||||
) : versionInfo.latest ? (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
已是最新
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCheckUpdate}
|
||||
disabled={checking}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border text-sm hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${checking ? "animate-spin" : ""}`} />
|
||||
检查更新
|
||||
</button>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button
|
||||
onClick={handleCheckUpdate}
|
||||
disabled={checking}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border text-sm hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${checking ? "animate-spin" : ""}`}
|
||||
/>
|
||||
检查更新
|
||||
</button>
|
||||
|
||||
{versionInfo.hasUpdate && versionInfo.downloadUrl && (
|
||||
<a
|
||||
href={versionInfo.downloadUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-green-600 text-white text-sm hover:bg-green-700"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
下载新版本
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 链接 */}
|
||||
@@ -102,7 +136,7 @@ export function AboutSection() {
|
||||
<ExternalLink className="h-4 w-4 text-muted-foreground" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/aiclientproxy/proxycast/wiki"
|
||||
href="https://aiclientproxy.github.io/proxycast/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between p-3 rounded-lg border hover:bg-muted/50"
|
||||
|
||||
Reference in New Issue
Block a user