mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-27 16:26:16 +08:00
1586451862
* feat(failover): add auto-failover master switch with proxy integration
- Add persistent auto_failover_enabled setting in database
- Add get/set_auto_failover_enabled commands
- Provider router respects master switch state
- Proxy shutdown automatically disables failover
- Enabling failover auto-starts proxy server
- Optimistic updates for failover queue toggle
* feat(proxy): persist proxy takeover state across app restarts
- Add proxy_takeover_{app_type} settings for per-app state tracking
- Restore proxy takeover state automatically on app startup
- Preserve state on normal exit, clear on manual stop
- Add stop_with_restore_keep_state method for graceful shutdown
* fix(proxy): set takeover state for all apps in start_with_takeover
* fix(windows): hide console window when checking CLI versions
Add CREATE_NO_WINDOW flag to prevent command prompt from flashing
when detecting claude/codex/gemini CLI versions on Windows.
* refactor(failover): make auto-failover toggle per-app independent
- Change setting key from 'auto_failover_enabled' to 'auto_failover_enabled_{app_type}'
- Update provider_router to check per-app failover setting
- When failover disabled, use current provider only; when enabled, use queue order
- Add unit tests for failover enabled/disabled behavior
* feat(failover): auto-switch to higher priority provider on recovery
- After circuit breaker reset, check if recovered provider has higher priority
- Automatically switch back if queue_order is lower (higher priority)
- Stream health check now resets circuit breaker on success/degraded
* chore: remove unused start_proxy_with_takeover command
- Remove command registration from lib.rs
- Add comment clarifying failover queue is preserved on proxy stop
* feat(ui): integrate failover controls into provider cards
- Add failover toggle button to provider card actions
- Show priority badge (P1, P2, ...) for queued providers
- Highlight active provider with green border in failover mode
- Sync drag-drop order with failover queue
- Move per-app failover toggle to FailoverQueueManager
- Simplify SettingsPage failover section
* test(providers): add mocks for failover hooks in ProviderList tests
* refactor(failover): merge failover_queue table into providers
- Add in_failover_queue field to providers table
- Remove standalone failover_queue table and related indexes
- Simplify queue ordering by reusing sort_index field
- Remove reorder_failover_queue and set_failover_item_enabled commands
- Update frontend to use simplified FailoverQueueItem type
* fix(database): ensure in_failover_queue column exists for v2 databases
Add column check in create_tables to handle existing v2 databases
that were created before the failover queue refactor.
* fix(ui): differentiate active provider border color by proxy mode
- Use green border/gradient when proxy takeover is active
- Use blue border/gradient in normal mode (no proxy)
- Improves visual distinction between proxy and non-proxy states
* fix(database): clear provider health record when removing from failover queue
When a provider is removed from the failover queue, its health monitoring
is no longer needed. This change ensures the health record is also deleted
from the database to prevent stale data.
* fix(failover): improve cache cleanup for provider health and circuit breaker
- Use removeQueries instead of invalidateQueries when stopping proxy to
completely clear health and circuit breaker caches
- Clear provider health and circuit breaker caches when removing from
failover queue
- Refresh failover queue after drag-sort since queue order depends on
sort_index
- Only show health badge when provider is in failover queue
* style: apply prettier formatting to App.tsx and ProviderList.tsx
* fix(proxy): handle missing health records and clear health on proxy stop
- Return default healthy state when provider health record not found
- Add clear_provider_health_for_app to clear health for specific app
- Clear app health records when stopping proxy takeover
* fix(proxy): track actual provider used in forwarding for accurate logging
Introduce ForwardResult and ForwardError structs to return the actual
provider that handled the request. This ensures usage statistics and
error logs reflect the correct provider after failover.
956 lines
39 KiB
Rust
956 lines
39 KiB
Rust
mod app_config;
|
||
mod app_store;
|
||
mod auto_launch;
|
||
mod claude_mcp;
|
||
mod claude_plugin;
|
||
mod codex_config;
|
||
mod commands;
|
||
mod config;
|
||
mod database;
|
||
mod deeplink;
|
||
mod error;
|
||
mod gemini_config;
|
||
mod gemini_mcp;
|
||
mod init_status;
|
||
mod mcp;
|
||
mod prompt;
|
||
mod prompt_files;
|
||
mod provider;
|
||
mod provider_defaults;
|
||
mod proxy;
|
||
mod services;
|
||
mod settings;
|
||
mod store;
|
||
mod tray;
|
||
mod usage_script;
|
||
|
||
pub use app_config::{AppType, McpApps, McpServer, MultiAppConfig};
|
||
pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic};
|
||
pub use commands::*;
|
||
pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};
|
||
pub use database::Database;
|
||
pub use deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest};
|
||
pub use error::AppError;
|
||
pub use mcp::{
|
||
import_from_claude, import_from_codex, import_from_gemini, remove_server_from_claude,
|
||
remove_server_from_codex, remove_server_from_gemini, sync_enabled_to_claude,
|
||
sync_enabled_to_codex, sync_enabled_to_gemini, sync_single_server_to_claude,
|
||
sync_single_server_to_codex, sync_single_server_to_gemini,
|
||
};
|
||
pub use provider::{Provider, ProviderMeta};
|
||
pub use services::{
|
||
ConfigService, EndpointLatency, McpService, PromptService, ProviderService, ProxyService,
|
||
SkillService, SpeedtestService,
|
||
};
|
||
pub use settings::{update_settings, AppSettings};
|
||
pub use store::AppState;
|
||
use tauri_plugin_deep_link::DeepLinkExt;
|
||
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
|
||
|
||
use std::sync::Arc;
|
||
#[cfg(target_os = "macos")]
|
||
use tauri::image::Image;
|
||
use tauri::tray::{TrayIconBuilder, TrayIconEvent};
|
||
use tauri::RunEvent;
|
||
use tauri::{Emitter, Manager};
|
||
|
||
/// 统一处理 ccswitch:// 深链接 URL
|
||
///
|
||
/// - 解析 URL
|
||
/// - 向前端发射 `deeplink-import` / `deeplink-error` 事件
|
||
/// - 可选:在成功时聚焦主窗口
|
||
fn handle_deeplink_url(
|
||
app: &tauri::AppHandle,
|
||
url_str: &str,
|
||
focus_main_window: bool,
|
||
source: &str,
|
||
) -> bool {
|
||
if !url_str.starts_with("ccswitch://") {
|
||
return false;
|
||
}
|
||
|
||
log::info!("✓ Deep link URL detected from {source}: {url_str}");
|
||
|
||
match crate::deeplink::parse_deeplink_url(url_str) {
|
||
Ok(request) => {
|
||
log::info!(
|
||
"✓ Successfully parsed deep link: resource={}, app={:?}, name={:?}",
|
||
request.resource,
|
||
request.app,
|
||
request.name
|
||
);
|
||
|
||
if let Err(e) = app.emit("deeplink-import", &request) {
|
||
log::error!("✗ Failed to emit deeplink-import event: {e}");
|
||
} else {
|
||
log::info!("✓ Emitted deeplink-import event to frontend");
|
||
}
|
||
|
||
if focus_main_window {
|
||
if let Some(window) = app.get_webview_window("main") {
|
||
let _ = window.unminimize();
|
||
let _ = window.show();
|
||
let _ = window.set_focus();
|
||
log::info!("✓ Window shown and focused");
|
||
}
|
||
}
|
||
}
|
||
Err(e) => {
|
||
log::error!("✗ Failed to parse deep link URL: {e}");
|
||
|
||
if let Err(emit_err) = app.emit(
|
||
"deeplink-error",
|
||
serde_json::json!({
|
||
"url": url_str,
|
||
"error": e.to_string()
|
||
}),
|
||
) {
|
||
log::error!("✗ Failed to emit deeplink-error event: {emit_err}");
|
||
}
|
||
}
|
||
}
|
||
|
||
true
|
||
}
|
||
|
||
/// 更新托盘菜单的Tauri命令
|
||
#[tauri::command]
|
||
async fn update_tray_menu(
|
||
app: tauri::AppHandle,
|
||
state: tauri::State<'_, AppState>,
|
||
) -> Result<bool, String> {
|
||
match tray::create_tray_menu(&app, state.inner()) {
|
||
Ok(new_menu) => {
|
||
if let Some(tray) = app.tray_by_id("main") {
|
||
tray.set_menu(Some(new_menu))
|
||
.map_err(|e| format!("更新托盘菜单失败: {e}"))?;
|
||
return Ok(true);
|
||
}
|
||
Ok(false)
|
||
}
|
||
Err(err) => {
|
||
log::error!("创建托盘菜单失败: {err}");
|
||
Ok(false)
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(target_os = "macos")]
|
||
fn macos_tray_icon() -> Option<Image<'static>> {
|
||
const ICON_BYTES: &[u8] = include_bytes!("../icons/tray/macos/statusbar_template_3x.png");
|
||
|
||
match Image::from_bytes(ICON_BYTES) {
|
||
Ok(icon) => Some(icon),
|
||
Err(err) => {
|
||
log::warn!("Failed to load macOS tray icon: {err}");
|
||
None
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||
pub fn run() {
|
||
let mut builder = tauri::Builder::default();
|
||
|
||
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
|
||
{
|
||
builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||
log::info!("=== Single Instance Callback Triggered ===");
|
||
log::info!("Args count: {}", args.len());
|
||
for (i, arg) in args.iter().enumerate() {
|
||
log::info!(" arg[{i}]: {arg}");
|
||
}
|
||
|
||
// Check for deep link URL in args (mainly for Windows/Linux command line)
|
||
let mut found_deeplink = false;
|
||
for arg in &args {
|
||
if handle_deeplink_url(app, arg, false, "single_instance args") {
|
||
found_deeplink = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if !found_deeplink {
|
||
log::info!("ℹ No deep link URL found in args (this is expected on macOS when launched via system)");
|
||
}
|
||
|
||
// Show and focus window regardless
|
||
if let Some(window) = app.get_webview_window("main") {
|
||
let _ = window.unminimize();
|
||
let _ = window.show();
|
||
let _ = window.set_focus();
|
||
}
|
||
}));
|
||
}
|
||
|
||
let builder = builder
|
||
// 注册 deep-link 插件(处理 macOS AppleEvent 和其他平台的深链接)
|
||
.plugin(tauri_plugin_deep_link::init())
|
||
// 拦截窗口关闭:根据设置决定是否最小化到托盘
|
||
.on_window_event(|window, event| {
|
||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||
let settings = crate::settings::get_settings();
|
||
|
||
if settings.minimize_to_tray_on_close {
|
||
api.prevent_close();
|
||
let _ = window.hide();
|
||
#[cfg(target_os = "windows")]
|
||
{
|
||
let _ = window.set_skip_taskbar(true);
|
||
}
|
||
#[cfg(target_os = "macos")]
|
||
{
|
||
tray::apply_tray_policy(window.app_handle(), false);
|
||
}
|
||
} else {
|
||
window.app_handle().exit(0);
|
||
}
|
||
}
|
||
})
|
||
.plugin(tauri_plugin_process::init())
|
||
.plugin(tauri_plugin_dialog::init())
|
||
.plugin(tauri_plugin_opener::init())
|
||
.plugin(tauri_plugin_store::Builder::new().build())
|
||
.setup(|app| {
|
||
// 注册 Updater 插件(桌面端)
|
||
#[cfg(desktop)]
|
||
{
|
||
if let Err(e) = app
|
||
.handle()
|
||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||
{
|
||
// 若配置不完整(如缺少 pubkey),跳过 Updater 而不中断应用
|
||
log::warn!("初始化 Updater 插件失败,已跳过:{e}");
|
||
}
|
||
}
|
||
// 初始化日志
|
||
if cfg!(debug_assertions) {
|
||
app.handle().plugin(
|
||
tauri_plugin_log::Builder::default()
|
||
.level(log::LevelFilter::Info)
|
||
.build(),
|
||
)?;
|
||
}
|
||
|
||
// 预先刷新 Store 覆盖配置,确保 AppState 初始化时可读取到最新路径
|
||
app_store::refresh_app_config_dir_override(app.handle());
|
||
|
||
// 初始化数据库
|
||
let app_config_dir = crate::config::get_app_config_dir();
|
||
let db_path = app_config_dir.join("cc-switch.db");
|
||
let json_path = app_config_dir.join("config.json");
|
||
|
||
// 检查是否需要从 config.json 迁移到 SQLite
|
||
let has_json = json_path.exists();
|
||
let has_db = db_path.exists();
|
||
|
||
// 如果需要迁移,先验证 config.json 是否可以加载(在创建数据库之前)
|
||
// 这样如果加载失败用户选择退出,数据库文件还没被创建,下次可以正常重试
|
||
let migration_config = if !has_db && has_json {
|
||
log::info!("检测到旧版配置文件,验证配置文件...");
|
||
|
||
// 循环:支持用户重试加载配置文件
|
||
loop {
|
||
match crate::app_config::MultiAppConfig::load() {
|
||
Ok(config) => {
|
||
log::info!("✓ 配置文件加载成功");
|
||
break Some(config);
|
||
}
|
||
Err(e) => {
|
||
log::error!("加载旧配置文件失败: {e}");
|
||
// 弹出系统对话框让用户选择
|
||
if !show_migration_error_dialog(app.handle(), &e.to_string()) {
|
||
// 用户选择退出(此时数据库还没创建,下次启动可以重试)
|
||
log::info!("用户选择退出程序");
|
||
std::process::exit(1);
|
||
}
|
||
// 用户选择重试,继续循环
|
||
log::info!("用户选择重试加载配置文件");
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
None
|
||
};
|
||
|
||
// 现在创建数据库
|
||
let db = match crate::database::Database::init() {
|
||
Ok(db) => Arc::new(db),
|
||
Err(e) => {
|
||
log::error!("Failed to init database: {e}");
|
||
return Err(Box::new(e));
|
||
}
|
||
};
|
||
|
||
// 如果有预加载的配置,执行迁移
|
||
if let Some(config) = migration_config {
|
||
log::info!("开始执行数据迁移...");
|
||
|
||
match db.migrate_from_json(&config) {
|
||
Ok(_) => {
|
||
log::info!("✓ 配置迁移成功");
|
||
// 标记迁移成功,供前端显示 Toast
|
||
crate::init_status::set_migration_success();
|
||
// 归档旧配置文件(重命名而非删除,便于用户恢复)
|
||
let archive_path = json_path.with_extension("json.migrated");
|
||
if let Err(e) = std::fs::rename(&json_path, &archive_path) {
|
||
log::warn!("归档旧配置文件失败: {e}");
|
||
} else {
|
||
log::info!("✓ 旧配置已归档为 config.json.migrated");
|
||
}
|
||
}
|
||
Err(e) => {
|
||
// 配置加载成功但迁移失败的情况极少(磁盘满等),仅记录日志
|
||
log::error!("配置迁移失败: {e},将从现有配置导入");
|
||
}
|
||
}
|
||
}
|
||
|
||
let app_state = AppState::new(db);
|
||
|
||
// 设置 AppHandle 用于代理故障转移时的 UI 更新
|
||
app_state.proxy_service.set_app_handle(app.handle().clone());
|
||
|
||
// ============================================================
|
||
// 按表独立判断的导入逻辑(各类数据独立检查,互不影响)
|
||
// ============================================================
|
||
|
||
// 1. 初始化默认 Skills 仓库(已有内置检查:表非空则跳过)
|
||
match app_state.db.init_default_skill_repos() {
|
||
Ok(count) if count > 0 => {
|
||
log::info!("✓ Initialized {count} default skill repositories");
|
||
}
|
||
Ok(_) => {} // 表非空,静默跳过
|
||
Err(e) => log::warn!("✗ Failed to initialize default skill repos: {e}"),
|
||
}
|
||
|
||
// 2. 导入供应商配置(已有内置检查:该应用已有供应商则跳过)
|
||
for app in [
|
||
crate::app_config::AppType::Claude,
|
||
crate::app_config::AppType::Codex,
|
||
crate::app_config::AppType::Gemini,
|
||
] {
|
||
match crate::services::provider::ProviderService::import_default_config(
|
||
&app_state,
|
||
app.clone(),
|
||
) {
|
||
Ok(true) => {
|
||
log::info!("✓ Imported default provider for {}", app.as_str());
|
||
}
|
||
Ok(false) => {} // 已有供应商,静默跳过
|
||
Err(e) => {
|
||
log::debug!(
|
||
"○ No default provider to import for {}: {}",
|
||
app.as_str(),
|
||
e
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. 导入 MCP 服务器配置(表空时触发)
|
||
if app_state.db.is_mcp_table_empty().unwrap_or(false) {
|
||
log::info!("MCP table empty, importing from live configurations...");
|
||
|
||
match crate::services::mcp::McpService::import_from_claude(&app_state) {
|
||
Ok(count) if count > 0 => {
|
||
log::info!("✓ Imported {count} MCP server(s) from Claude");
|
||
}
|
||
Ok(_) => log::debug!("○ No Claude MCP servers found to import"),
|
||
Err(e) => log::warn!("✗ Failed to import Claude MCP: {e}"),
|
||
}
|
||
|
||
match crate::services::mcp::McpService::import_from_codex(&app_state) {
|
||
Ok(count) if count > 0 => {
|
||
log::info!("✓ Imported {count} MCP server(s) from Codex");
|
||
}
|
||
Ok(_) => log::debug!("○ No Codex MCP servers found to import"),
|
||
Err(e) => log::warn!("✗ Failed to import Codex MCP: {e}"),
|
||
}
|
||
|
||
match crate::services::mcp::McpService::import_from_gemini(&app_state) {
|
||
Ok(count) if count > 0 => {
|
||
log::info!("✓ Imported {count} MCP server(s) from Gemini");
|
||
}
|
||
Ok(_) => log::debug!("○ No Gemini MCP servers found to import"),
|
||
Err(e) => log::warn!("✗ Failed to import Gemini MCP: {e}"),
|
||
}
|
||
}
|
||
|
||
// 4. 导入提示词文件(表空时触发)
|
||
if app_state.db.is_prompts_table_empty().unwrap_or(false) {
|
||
log::info!("Prompts table empty, importing from live configurations...");
|
||
|
||
for app in [
|
||
crate::app_config::AppType::Claude,
|
||
crate::app_config::AppType::Codex,
|
||
crate::app_config::AppType::Gemini,
|
||
] {
|
||
match crate::services::prompt::PromptService::import_from_file_on_first_launch(
|
||
&app_state,
|
||
app.clone(),
|
||
) {
|
||
Ok(count) if count > 0 => {
|
||
log::info!("✓ Imported {count} prompt(s) for {}", app.as_str());
|
||
}
|
||
Ok(_) => log::debug!("○ No prompt file found for {}", app.as_str()),
|
||
Err(e) => log::warn!("✗ Failed to import prompt for {}: {e}", app.as_str()),
|
||
}
|
||
}
|
||
}
|
||
|
||
// 迁移旧的 app_config_dir 配置到 Store
|
||
if let Err(e) = app_store::migrate_app_config_dir_from_settings(app.handle()) {
|
||
log::warn!("迁移 app_config_dir 失败: {e}");
|
||
}
|
||
|
||
// 启动阶段不再无条件保存,避免意外覆盖用户配置。
|
||
|
||
// 注册 deep-link URL 处理器(使用正确的 DeepLinkExt API)
|
||
log::info!("=== Registering deep-link URL handler ===");
|
||
|
||
// Linux 和 Windows 调试模式需要显式注册
|
||
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
|
||
{
|
||
#[cfg(target_os = "linux")]
|
||
{
|
||
// Use Tauri's path API to get correct path (includes app identifier)
|
||
// tauri-plugin-deep-link writes to: ~/.local/share/com.ccswitch.desktop/applications/cc-switch-handler.desktop
|
||
// Only register if .desktop file doesn't exist to avoid overwriting user customizations
|
||
let should_register = app
|
||
.path()
|
||
.data_dir()
|
||
.map(|d| !d.join("applications/cc-switch-handler.desktop").exists())
|
||
.unwrap_or(true);
|
||
|
||
if should_register {
|
||
if let Err(e) = app.deep_link().register_all() {
|
||
log::error!("✗ Failed to register deep link schemes: {}", e);
|
||
} else {
|
||
log::info!("✓ Deep link schemes registered (Linux)");
|
||
}
|
||
} else {
|
||
log::info!("⊘ Deep link handler already exists, skipping registration");
|
||
}
|
||
}
|
||
|
||
#[cfg(all(debug_assertions, windows))]
|
||
{
|
||
if let Err(e) = app.deep_link().register_all() {
|
||
log::error!("✗ Failed to register deep link schemes: {}", e);
|
||
} else {
|
||
log::info!("✓ Deep link schemes registered (Windows debug)");
|
||
}
|
||
}
|
||
}
|
||
|
||
// 注册 URL 处理回调(所有平台通用)
|
||
app.deep_link().on_open_url({
|
||
let app_handle = app.handle().clone();
|
||
move |event| {
|
||
log::info!("=== Deep Link Event Received (on_open_url) ===");
|
||
let urls = event.urls();
|
||
log::info!("Received {} URL(s)", urls.len());
|
||
|
||
for (i, url) in urls.iter().enumerate() {
|
||
let url_str = url.as_str();
|
||
log::info!(" URL[{i}]: {url_str}");
|
||
|
||
if handle_deeplink_url(&app_handle, url_str, true, "on_open_url") {
|
||
break; // Process only first ccswitch:// URL
|
||
}
|
||
}
|
||
}
|
||
});
|
||
log::info!("✓ Deep-link URL handler registered");
|
||
|
||
// 创建动态托盘菜单
|
||
let menu = tray::create_tray_menu(app.handle(), &app_state)?;
|
||
|
||
// 构建托盘
|
||
let mut tray_builder = TrayIconBuilder::with_id("main")
|
||
.on_tray_icon_event(|_tray, event| match event {
|
||
// 左键点击已通过 show_menu_on_left_click(true) 打开菜单,这里不再额外处理
|
||
TrayIconEvent::Click { .. } => {}
|
||
_ => log::debug!("unhandled event {event:?}"),
|
||
})
|
||
.menu(&menu)
|
||
.on_menu_event(|app, event| {
|
||
tray::handle_tray_menu_event(app, &event.id.0);
|
||
})
|
||
.show_menu_on_left_click(true);
|
||
|
||
// 使用平台对应的托盘图标(macOS 使用模板图标适配深浅色)
|
||
#[cfg(target_os = "macos")]
|
||
{
|
||
if let Some(icon) = macos_tray_icon() {
|
||
tray_builder = tray_builder.icon(icon).icon_as_template(true);
|
||
} else if let Some(icon) = app.default_window_icon() {
|
||
log::warn!("Falling back to default window icon for tray");
|
||
tray_builder = tray_builder.icon(icon.clone());
|
||
} else {
|
||
log::warn!("Failed to load macOS tray icon for tray");
|
||
}
|
||
}
|
||
|
||
#[cfg(not(target_os = "macos"))]
|
||
{
|
||
if let Some(icon) = app.default_window_icon() {
|
||
tray_builder = tray_builder.icon(icon.clone());
|
||
} else {
|
||
log::warn!("Failed to get default window icon for tray");
|
||
}
|
||
}
|
||
|
||
let _tray = tray_builder.build(app)?;
|
||
// 将同一个实例注入到全局状态,避免重复创建导致的不一致
|
||
app.manage(app_state);
|
||
|
||
// 初始化 SkillService
|
||
match SkillService::new() {
|
||
Ok(skill_service) => {
|
||
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
|
||
}
|
||
Err(e) => {
|
||
log::warn!("初始化 SkillService 失败: {e}");
|
||
}
|
||
}
|
||
|
||
// 异常退出恢复 + 代理状态自动恢复
|
||
let app_handle = app.handle().clone();
|
||
tauri::async_runtime::spawn(async move {
|
||
let state = app_handle.state::<AppState>();
|
||
|
||
// 检查是否有 Live 备份(表示上次异常退出时可能处于接管状态)
|
||
let has_backups = match state.db.has_any_live_backup().await {
|
||
Ok(v) => v,
|
||
Err(e) => {
|
||
log::error!("检查 Live 备份失败: {e}");
|
||
false
|
||
}
|
||
};
|
||
// 检查 Live 配置是否仍处于被接管状态(包含占位符)
|
||
let live_taken_over = state.proxy_service.detect_takeover_in_live_configs();
|
||
|
||
if has_backups || live_taken_over {
|
||
log::warn!("检测到上次异常退出(存在接管残留),正在恢复 Live 配置...");
|
||
if let Err(e) = state.proxy_service.recover_from_crash().await {
|
||
log::error!("恢复 Live 配置失败: {e}");
|
||
} else {
|
||
log::info!("Live 配置已恢复");
|
||
}
|
||
}
|
||
|
||
// 检查 settings 表中的代理状态,自动恢复代理服务
|
||
restore_proxy_state_on_startup(&state).await;
|
||
});
|
||
|
||
Ok(())
|
||
})
|
||
.invoke_handler(tauri::generate_handler![
|
||
commands::get_providers,
|
||
commands::get_current_provider,
|
||
commands::add_provider,
|
||
commands::update_provider,
|
||
commands::delete_provider,
|
||
commands::switch_provider,
|
||
commands::import_default_config,
|
||
commands::get_claude_config_status,
|
||
commands::get_config_status,
|
||
commands::get_claude_code_config_path,
|
||
commands::get_config_dir,
|
||
commands::open_config_folder,
|
||
commands::pick_directory,
|
||
commands::open_external,
|
||
commands::get_init_error,
|
||
commands::get_migration_result,
|
||
commands::get_app_config_path,
|
||
commands::open_app_config_folder,
|
||
commands::get_claude_common_config_snippet,
|
||
commands::set_claude_common_config_snippet,
|
||
commands::get_common_config_snippet,
|
||
commands::set_common_config_snippet,
|
||
commands::read_live_provider_settings,
|
||
commands::get_settings,
|
||
commands::save_settings,
|
||
commands::restart_app,
|
||
commands::check_for_updates,
|
||
commands::is_portable_mode,
|
||
commands::get_claude_plugin_status,
|
||
commands::read_claude_plugin_config,
|
||
commands::apply_claude_plugin_config,
|
||
commands::is_claude_plugin_applied,
|
||
commands::apply_claude_onboarding_skip,
|
||
commands::clear_claude_onboarding_skip,
|
||
// Claude MCP management
|
||
commands::get_claude_mcp_status,
|
||
commands::read_claude_mcp_config,
|
||
commands::upsert_claude_mcp_server,
|
||
commands::delete_claude_mcp_server,
|
||
commands::validate_mcp_command,
|
||
// usage query
|
||
commands::queryProviderUsage,
|
||
commands::testUsageScript,
|
||
// New MCP via config.json (SSOT)
|
||
commands::get_mcp_config,
|
||
commands::upsert_mcp_server_in_config,
|
||
commands::delete_mcp_server_in_config,
|
||
commands::set_mcp_enabled,
|
||
// v3.7.0: Unified MCP management
|
||
commands::get_mcp_servers,
|
||
commands::upsert_mcp_server,
|
||
commands::delete_mcp_server,
|
||
commands::toggle_mcp_app,
|
||
// Prompt management
|
||
commands::get_prompts,
|
||
commands::upsert_prompt,
|
||
commands::delete_prompt,
|
||
commands::enable_prompt,
|
||
commands::import_prompt_from_file,
|
||
commands::get_current_prompt_file_content,
|
||
// ours: endpoint speed test + custom endpoint management
|
||
commands::test_api_endpoints,
|
||
commands::get_custom_endpoints,
|
||
commands::add_custom_endpoint,
|
||
commands::remove_custom_endpoint,
|
||
commands::update_endpoint_last_used,
|
||
// app_config_dir override via Store
|
||
commands::get_app_config_dir_override,
|
||
commands::set_app_config_dir_override,
|
||
// provider sort order management
|
||
commands::update_providers_sort_order,
|
||
// theirs: config import/export and dialogs
|
||
commands::export_config_to_file,
|
||
commands::import_config_from_file,
|
||
commands::save_file_dialog,
|
||
commands::open_file_dialog,
|
||
commands::sync_current_providers_live,
|
||
// Deep link import
|
||
commands::parse_deeplink,
|
||
commands::merge_deeplink_config,
|
||
commands::import_from_deeplink,
|
||
commands::import_from_deeplink_unified,
|
||
update_tray_menu,
|
||
// Environment variable management
|
||
commands::check_env_conflicts,
|
||
commands::delete_env_vars,
|
||
commands::restore_env_backup,
|
||
// Skill management
|
||
commands::get_skills,
|
||
commands::get_skills_for_app,
|
||
commands::install_skill,
|
||
commands::install_skill_for_app,
|
||
commands::uninstall_skill,
|
||
commands::uninstall_skill_for_app,
|
||
commands::get_skill_repos,
|
||
commands::add_skill_repo,
|
||
commands::remove_skill_repo,
|
||
// Auto launch
|
||
commands::set_auto_launch,
|
||
commands::get_auto_launch_status,
|
||
// Proxy server management
|
||
commands::start_proxy_server,
|
||
commands::stop_proxy_with_restore,
|
||
commands::get_proxy_takeover_status,
|
||
commands::set_proxy_takeover_for_app,
|
||
commands::get_proxy_status,
|
||
commands::get_proxy_config,
|
||
commands::update_proxy_config,
|
||
commands::is_proxy_running,
|
||
commands::is_live_takeover_active,
|
||
commands::switch_proxy_provider,
|
||
// Proxy failover commands
|
||
commands::get_provider_health,
|
||
commands::reset_circuit_breaker,
|
||
commands::get_circuit_breaker_config,
|
||
commands::update_circuit_breaker_config,
|
||
commands::get_circuit_breaker_stats,
|
||
// Failover queue management
|
||
commands::get_failover_queue,
|
||
commands::get_available_providers_for_failover,
|
||
commands::add_to_failover_queue,
|
||
commands::remove_from_failover_queue,
|
||
commands::get_auto_failover_enabled,
|
||
commands::set_auto_failover_enabled,
|
||
// Usage statistics
|
||
commands::get_usage_summary,
|
||
commands::get_usage_trends,
|
||
commands::get_provider_stats,
|
||
commands::get_model_stats,
|
||
commands::get_request_logs,
|
||
commands::get_request_detail,
|
||
commands::get_model_pricing,
|
||
commands::update_model_pricing,
|
||
commands::delete_model_pricing,
|
||
commands::check_provider_limits,
|
||
// Stream health check
|
||
commands::stream_check_provider,
|
||
commands::stream_check_all_providers,
|
||
commands::get_stream_check_config,
|
||
commands::save_stream_check_config,
|
||
commands::get_tool_versions,
|
||
]);
|
||
|
||
let app = builder
|
||
.build(tauri::generate_context!())
|
||
.expect("error while running tauri application");
|
||
|
||
app.run(|app_handle, event| {
|
||
// 处理退出请求(所有平台)
|
||
if let RunEvent::ExitRequested { api, .. } = &event {
|
||
log::info!("收到退出请求,开始清理...");
|
||
// 阻止立即退出,执行清理
|
||
api.prevent_exit();
|
||
|
||
let app_handle = app_handle.clone();
|
||
tauri::async_runtime::spawn(async move {
|
||
cleanup_before_exit(&app_handle).await;
|
||
log::info!("清理完成,退出应用");
|
||
|
||
// 短暂等待确保所有 I/O 操作(如数据库写入)刷新到磁盘
|
||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||
|
||
// 使用 std::process::exit 避免再次触发 ExitRequested
|
||
std::process::exit(0);
|
||
});
|
||
return;
|
||
}
|
||
|
||
#[cfg(target_os = "macos")]
|
||
{
|
||
match event {
|
||
// macOS 在 Dock 图标被点击并重新激活应用时会触发 Reopen 事件,这里手动恢复主窗口
|
||
RunEvent::Reopen { .. } => {
|
||
if let Some(window) = app_handle.get_webview_window("main") {
|
||
#[cfg(target_os = "windows")]
|
||
{
|
||
let _ = window.set_skip_taskbar(false);
|
||
}
|
||
let _ = window.unminimize();
|
||
let _ = window.show();
|
||
let _ = window.set_focus();
|
||
tray::apply_tray_policy(app_handle, true);
|
||
}
|
||
}
|
||
// 处理通过自定义 URL 协议触发的打开事件(例如 ccswitch://...)
|
||
RunEvent::Opened { urls } => {
|
||
if let Some(url) = urls.first() {
|
||
let url_str = url.to_string();
|
||
log::info!("RunEvent::Opened with URL: {url_str}");
|
||
|
||
if url_str.starts_with("ccswitch://") {
|
||
// 解析并广播深链接事件,复用与 single_instance 相同的逻辑
|
||
match crate::deeplink::parse_deeplink_url(&url_str) {
|
||
Ok(request) => {
|
||
log::info!(
|
||
"Successfully parsed deep link from RunEvent::Opened: resource={}, app={:?}",
|
||
request.resource,
|
||
request.app
|
||
);
|
||
|
||
if let Err(e) =
|
||
app_handle.emit("deeplink-import", &request)
|
||
{
|
||
log::error!(
|
||
"Failed to emit deep link event from RunEvent::Opened: {e}"
|
||
);
|
||
}
|
||
}
|
||
Err(e) => {
|
||
log::error!(
|
||
"Failed to parse deep link URL from RunEvent::Opened: {e}"
|
||
);
|
||
|
||
if let Err(emit_err) = app_handle.emit(
|
||
"deeplink-error",
|
||
serde_json::json!({
|
||
"url": url_str,
|
||
"error": e.to_string()
|
||
}),
|
||
) {
|
||
log::error!(
|
||
"Failed to emit deep link error event from RunEvent::Opened: {emit_err}"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 确保主窗口可见
|
||
if let Some(window) = app_handle.get_webview_window("main") {
|
||
let _ = window.unminimize();
|
||
let _ = window.show();
|
||
let _ = window.set_focus();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
#[cfg(not(target_os = "macos"))]
|
||
{
|
||
let _ = (app_handle, event);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ============================================================
|
||
// 应用退出清理
|
||
// ============================================================
|
||
|
||
/// 应用退出前的清理工作
|
||
///
|
||
/// 在应用退出前检查代理服务器状态,如果正在运行则停止代理并恢复 Live 配置。
|
||
/// 确保 Claude Code/Codex/Gemini 的配置不会处于损坏状态。
|
||
/// 使用 stop_with_restore_keep_state 保留 settings 表中的代理状态,下次启动时自动恢复。
|
||
pub async fn cleanup_before_exit(app_handle: &tauri::AppHandle) {
|
||
if let Some(state) = app_handle.try_state::<store::AppState>() {
|
||
let proxy_service = &state.proxy_service;
|
||
|
||
// 退出时也需要兜底:代理可能已崩溃/未运行,但 Live 接管残留仍在(占位符/备份)。
|
||
let has_backups = match state.db.has_any_live_backup().await {
|
||
Ok(v) => v,
|
||
Err(e) => {
|
||
log::error!("退出时检查 Live 备份失败: {e}");
|
||
false
|
||
}
|
||
};
|
||
let live_taken_over = proxy_service.detect_takeover_in_live_configs();
|
||
let needs_restore = has_backups || live_taken_over;
|
||
|
||
if needs_restore {
|
||
log::info!("检测到接管残留,开始恢复 Live 配置(保留代理状态)...");
|
||
// 使用 keep_state 版本,保留 settings 表中的代理状态
|
||
if let Err(e) = proxy_service.stop_with_restore_keep_state().await {
|
||
log::error!("退出时恢复 Live 配置失败: {e}");
|
||
} else {
|
||
log::info!("已恢复 Live 配置(代理状态已保留,下次启动将自动恢复)");
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 非接管模式:代理在运行则仅停止代理
|
||
if proxy_service.is_running().await {
|
||
log::info!("检测到代理服务器正在运行,开始停止...");
|
||
if let Err(e) = proxy_service.stop().await {
|
||
log::error!("退出时停止代理失败: {e}");
|
||
}
|
||
log::info!("代理服务器清理完成");
|
||
}
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 启动时恢复代理状态
|
||
// ============================================================
|
||
|
||
/// 启动时根据 settings 表中的代理状态自动恢复代理服务
|
||
///
|
||
/// 检查 `proxy_takeover_claude`、`proxy_takeover_codex`、`proxy_takeover_gemini` 的值,
|
||
/// 如果有任一应用的状态为 `true`,则自动启动代理服务并接管对应应用的 Live 配置。
|
||
async fn restore_proxy_state_on_startup(state: &store::AppState) {
|
||
// 收集需要恢复接管的应用列表
|
||
let apps_to_restore: Vec<&str> = ["claude", "codex", "gemini"]
|
||
.iter()
|
||
.filter(|app_type| {
|
||
state
|
||
.db
|
||
.get_proxy_takeover_enabled(app_type)
|
||
.unwrap_or(false)
|
||
})
|
||
.copied()
|
||
.collect();
|
||
|
||
if apps_to_restore.is_empty() {
|
||
log::debug!("启动时无需恢复代理状态");
|
||
return;
|
||
}
|
||
|
||
log::info!("检测到上次代理状态需要恢复,应用列表: {apps_to_restore:?}");
|
||
|
||
// 逐个恢复接管状态
|
||
for app_type in apps_to_restore {
|
||
match state
|
||
.proxy_service
|
||
.set_takeover_for_app(app_type, true)
|
||
.await
|
||
{
|
||
Ok(()) => {
|
||
log::info!("✓ 已恢复 {app_type} 的代理接管状态");
|
||
}
|
||
Err(e) => {
|
||
log::error!("✗ 恢复 {app_type} 的代理接管状态失败: {e}");
|
||
// 失败时清除该应用的状态,避免下次启动再次尝试
|
||
if let Err(clear_err) = state.db.set_proxy_takeover_enabled(app_type, false) {
|
||
log::error!("清除 {app_type} 代理状态失败: {clear_err}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 迁移错误对话框辅助函数
|
||
// ============================================================
|
||
|
||
/// 检测是否为中文环境
|
||
fn is_chinese_locale() -> bool {
|
||
std::env::var("LANG")
|
||
.or_else(|_| std::env::var("LC_ALL"))
|
||
.or_else(|_| std::env::var("LC_MESSAGES"))
|
||
.map(|lang| lang.starts_with("zh"))
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
/// 显示迁移错误对话框
|
||
/// 返回 true 表示用户选择重试,false 表示用户选择退出
|
||
fn show_migration_error_dialog(app: &tauri::AppHandle, error: &str) -> bool {
|
||
let title = if is_chinese_locale() {
|
||
"配置迁移失败"
|
||
} else {
|
||
"Migration Failed"
|
||
};
|
||
|
||
let message = if is_chinese_locale() {
|
||
format!(
|
||
"从旧版本迁移配置时发生错误:\n\n{error}\n\n\
|
||
您的数据尚未丢失,旧配置文件仍然保留。\n\
|
||
建议回退到旧版本 CC Switch 以保护数据。\n\n\
|
||
点击「重试」重新尝试迁移\n\
|
||
点击「退出」关闭程序(可回退版本后重新打开)"
|
||
)
|
||
} else {
|
||
format!(
|
||
"An error occurred while migrating configuration:\n\n{error}\n\n\
|
||
Your data is NOT lost - the old config file is still preserved.\n\
|
||
Consider rolling back to an older CC Switch version.\n\n\
|
||
Click 'Retry' to attempt migration again\n\
|
||
Click 'Exit' to close the program"
|
||
)
|
||
};
|
||
|
||
let retry_text = if is_chinese_locale() {
|
||
"重试"
|
||
} else {
|
||
"Retry"
|
||
};
|
||
let exit_text = if is_chinese_locale() {
|
||
"退出"
|
||
} else {
|
||
"Exit"
|
||
};
|
||
|
||
// 使用 blocking_show 同步等待用户响应
|
||
// OkCancelCustom: 第一个按钮(重试)返回 true,第二个按钮(退出)返回 false
|
||
app.dialog()
|
||
.message(&message)
|
||
.title(title)
|
||
.kind(MessageDialogKind::Error)
|
||
.buttons(MessageDialogButtons::OkCancelCustom(
|
||
retry_text.to_string(),
|
||
exit_text.to_string(),
|
||
))
|
||
.blocking_show()
|
||
}
|