Files
cc-switch/src-tauri/src/lib.rs
SaladDay 20f62bf4f8 feat(webdav): follow-up 补齐自动同步与大文件防护 (#1043)
* feat(webdav): add robust auto sync with failure feedback

(cherry picked from commit bb6760124a62a964b36902c004e173534910728f)

* fix(webdav): enforce bounded download and extraction size

(cherry picked from commit 7777d6ec2b9bba07c8bbba9b04fe3ea6b15e0e79)

* fix(webdav): only show auto-sync callout for auto-source errors

* refactor(webdav): remove services->commands auto-sync dependency
2026-02-15 20:58:17 +08:00

1372 lines
58 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 openclaw_config;
mod opencode_config;
mod panic_hook;
mod prompt;
mod prompt_files;
mod provider;
mod provider_defaults;
mod proxy;
mod services;
mod session_manager;
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::open_provider_terminal;
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};
fn redact_url_for_log(url_str: &str) -> String {
match url::Url::parse(url_str) {
Ok(url) => {
let mut output = format!("{}://", url.scheme());
if let Some(host) = url.host_str() {
output.push_str(host);
}
output.push_str(url.path());
let mut keys: Vec<String> = url.query_pairs().map(|(k, _)| k.to_string()).collect();
keys.sort();
keys.dedup();
if !keys.is_empty() {
output.push_str("?[keys:");
output.push_str(&keys.join(","));
output.push(']');
}
output
}
Err(_) => {
let base = url_str.split('#').next().unwrap_or(url_str);
match base.split_once('?') {
Some((prefix, _)) => format!("{prefix}?[redacted]"),
None => base.to_string(),
}
}
}
}
/// 统一处理 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;
}
let redacted_url = redact_url_for_log(url_str);
log::info!("✓ Deep link URL detected from {source}: {redacted_url}");
log::debug!("Deep link URL (raw) 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() {
// 设置 panic hook在应用崩溃时记录日志到 <app_config_dir>/crash.log默认 ~/.cc-switch/crash.log
panic_hook::setup_panic_hook();
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::debug!("Args count: {}", args.len());
for (i, arg) in args.iter().enumerate() {
log::debug!(" arg[{i}]: {}", redact_url_for_log(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| {
// 预先刷新 Store 覆盖配置,确保后续路径读取正确(日志/数据库等)
app_store::refresh_app_config_dir_override(app.handle());
panic_hook::init_app_config_dir(crate::config::get_app_config_dir());
// 注册 Updater 插件(桌面端)
#[cfg(desktop)]
{
if let Err(e) = app
.handle()
.plugin(tauri_plugin_updater::Builder::new().build())
{
// 若配置不完整(如缺少 pubkey跳过 Updater 而不中断应用
log::warn!("初始化 Updater 插件失败,已跳过:{e}");
}
}
// 初始化日志(单文件输出到 <app_config_dir>/logs/cc-switch.log
{
use tauri_plugin_log::{RotationStrategy, Target, TargetKind, TimezoneStrategy};
let log_dir = panic_hook::get_log_dir();
// 确保日志目录存在
if let Err(e) = std::fs::create_dir_all(&log_dir) {
eprintln!("创建日志目录失败: {e}");
}
// 启动时删除旧日志文件,实现单文件覆盖效果
let log_file_path = log_dir.join("cc-switch.log");
let _ = std::fs::remove_file(&log_file_path);
app.handle().plugin(
tauri_plugin_log::Builder::default()
// 初始化为 Trace允许后续通过 log::set_max_level() 动态调整级别
.level(log::LevelFilter::Trace)
.targets([
Target::new(TargetKind::Stdout),
Target::new(TargetKind::Folder {
path: log_dir,
file_name: Some("cc-switch".into()),
}),
])
// 单文件模式:启动时删除旧文件,达到大小时轮转
// 注意KeepSome(n) 内部会做 n-2 运算n=1 会导致 usize 下溢
// KeepSome(2) 是最小安全值,表示不保留轮转文件
.rotation_strategy(RotationStrategy::KeepSome(2))
// 单文件大小限制 1GB
.max_file_size(1024 * 1024 * 1024)
.timezone_strategy(TimezoneStrategy::UseLocal)
.build(),
)?;
}
// 初始化数据库
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
};
// 现在创建数据库(包含 Schema 迁移)
//
// 说明:从 v3.8.* 升级的用户通常会走到这里的 SQLite schema 迁移,
// 若迁移失败(数据库损坏/权限不足/user_version 过新等),需要给用户明确提示,
// 否则表现可能只是“应用打不开/闪退”。
let db = loop {
match crate::database::Database::init() {
Ok(db) => break Arc::new(db),
Err(e) => {
log::error!("Failed to init database: {e}");
if !show_database_init_error_dialog(app.handle(), &db_path, &e.to_string())
{
log::info!("用户选择退出程序");
std::process::exit(1);
}
log::info!("用户选择重试初始化数据库");
}
}
};
// 如果有预加载的配置,执行迁移
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}"),
}
// 1.1. Skills 统一管理迁移:当数据库迁移到 v3 结构后,自动从各应用目录导入到 SSOT
// 触发条件由 schema 迁移设置 settings.skills_ssot_migration_pending = true 控制。
match app_state.db.get_setting("skills_ssot_migration_pending") {
Ok(Some(flag)) if flag == "true" || flag == "1" => {
// 安全保护:如果用户已经有 v3 结构的 Skills 数据,就不要自动清空重建。
let has_existing = app_state
.db
.get_all_installed_skills()
.map(|skills| !skills.is_empty())
.unwrap_or(false);
if has_existing {
log::info!(
"Detected skills_ssot_migration_pending but skills table not empty; skipping auto import."
);
let _ = app_state
.db
.set_setting("skills_ssot_migration_pending", "false");
} else {
match crate::services::skill::migrate_skills_to_ssot(&app_state.db) {
Ok(count) => {
log::info!("✓ Auto imported {count} skill(s) into SSOT");
if count > 0 {
crate::init_status::set_skills_migration_result(count);
}
let _ = app_state
.db
.set_setting("skills_ssot_migration_pending", "false");
}
Err(e) => {
log::warn!("✗ Failed to auto import legacy skills to SSOT: {e}");
crate::init_status::set_skills_migration_error(e.to_string());
// 保留 pending 标志,方便下次启动重试
}
}
}
}
Ok(_) => {} // 未开启迁移标志,静默跳过
Err(e) => log::warn!("✗ Failed to read skills migration flag: {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());
// 首次运行:自动提取通用配置片段(仅当通用配置为空时)
if app_state
.db
.get_config_snippet(app.as_str())
.ok()
.flatten()
.is_none()
{
match crate::services::provider::ProviderService::extract_common_config_snippet(&app_state, app.clone()) {
Ok(snippet) if !snippet.is_empty() && snippet != "{}" => {
if let Err(e) = app_state.db.set_config_snippet(app.as_str(), Some(snippet)) {
log::warn!("✗ Failed to save common config snippet for {}: {e}", app.as_str());
} else {
log::info!("✓ Extracted common config snippet for {}", app.as_str());
}
}
Ok(_) => log::debug!("○ No common config to extract for {}", app.as_str()),
Err(e) => log::debug!("○ Failed to extract common config for {}: {e}", app.as_str()),
}
}
}
Ok(false) => {} // 已有供应商,静默跳过
Err(e) => {
log::debug!(
"○ No default provider to import for {}: {}",
app.as_str(),
e
);
}
}
}
// 2.1 OpenCode 供应商导入(累加式模式,需特殊处理)
// OpenCode 与其他应用不同:配置文件中可同时存在多个供应商
// 需要遍历 provider 字段下的每个供应商并导入
match crate::services::provider::import_opencode_providers_from_live(&app_state) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} OpenCode provider(s) from live config");
}
Ok(_) => log::debug!("○ No OpenCode providers found to import"),
Err(e) => log::warn!("○ Failed to import OpenCode providers: {e}"),
}
// 2.2 OMO 配置导入(当数据库中无 OMO provider 时,从本地文件导入)
{
let has_omo = app_state
.db
.get_all_providers("opencode")
.map(|providers| providers.values().any(|p| p.category.as_deref() == Some("omo")))
.unwrap_or(false);
if !has_omo {
match crate::services::OmoService::import_from_local(&app_state) {
Ok(provider) => {
log::info!("✓ Imported OMO config from local as provider '{}'", provider.name);
}
Err(AppError::OmoConfigNotFound) => {
log::debug!("○ No OMO config to import");
}
Err(e) => {
log::warn!("✗ Failed to import OMO config from local: {e}");
}
}
}
}
// 2.3 OpenClaw 供应商导入(累加式模式,需特殊处理)
// OpenClaw 与 OpenCode 类似:配置文件中可同时存在多个供应商
// 需要遍历 models.providers 字段下的每个供应商并导入
match crate::services::provider::import_openclaw_providers_from_live(&app_state) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} OpenClaw provider(s) from live config");
}
Ok(_) => log::debug!("○ No OpenClaw providers found to import"),
Err(e) => log::warn!("○ Failed to import OpenClaw providers: {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}"),
}
match crate::services::mcp::McpService::import_from_opencode(&app_state) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} MCP server(s) from OpenCode");
}
Ok(_) => log::debug!("○ No OpenCode MCP servers found to import"),
Err(e) => log::warn!("✗ Failed to import OpenCode 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,
crate::app_config::AppType::OpenCode,
crate::app_config::AppType::OpenClaw,
] {
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::debug!(" URL[{i}]: {}", redact_url_for_log(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)?;
crate::services::webdav_auto_sync::start_worker(
app_state.db.clone(),
app.handle().clone(),
);
// 将同一个实例注入到全局状态,避免重复创建导致的不一致
app.manage(app_state);
// 从数据库加载日志配置并应用
{
let db = &app.state::<AppState>().db;
if let Ok(log_config) = db.get_log_config() {
log::set_max_level(log_config.to_level_filter());
log::info!(
"已加载日志配置: enabled={}, level={}",
log_config.enabled,
log_config.level
);
}
}
// 初始化 SkillService
let skill_service = SkillService::new();
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
// 初始化全局出站代理 HTTP 客户端
{
let db = &app.state::<AppState>().db;
let proxy_url = db.get_global_proxy_url().ok().flatten();
if let Err(e) = crate::proxy::http_client::init(proxy_url.as_deref()) {
log::error!(
"[GlobalProxy] [GP-005] Failed to initialize with saved config: {e}"
);
// 清除无效的代理配置
if proxy_url.is_some() {
log::warn!(
"[GlobalProxy] [GP-006] Clearing invalid proxy config from database"
);
if let Err(clear_err) = db.set_global_proxy_url(None) {
log::error!(
"[GlobalProxy] [GP-007] Failed to clear invalid config: {clear_err}"
);
}
}
// 使用直连模式重新初始化
if let Err(fallback_err) = crate::proxy::http_client::init(None) {
log::error!(
"[GlobalProxy] [GP-008] Failed to initialize direct connection: {fallback_err}"
);
}
}
}
// 异常退出恢复 + 代理状态自动恢复
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;
});
// Linux: 禁用 WebKitGTK 硬件加速,防止 EGL 初始化失败导致白屏
#[cfg(target_os = "linux")]
{
if let Some(window) = app.get_webview_window("main") {
let _ = window.with_webview(|webview| {
use webkit2gtk::{WebViewExt, SettingsExt, HardwareAccelerationPolicy};
let wk_webview = webview.inner();
if let Some(settings) = WebViewExt::settings(&wk_webview) {
SettingsExt::set_hardware_acceleration_policy(&settings, HardwareAccelerationPolicy::Never);
log::info!("已禁用 WebKitGTK 硬件加速");
}
});
}
}
// 静默启动:根据设置决定是否显示主窗口
let settings = crate::settings::get_settings();
if let Some(window) = app.get_webview_window("main") {
if settings.silent_startup {
// 静默启动模式:保持窗口隐藏
let _ = window.hide();
#[cfg(target_os = "windows")]
let _ = window.set_skip_taskbar(true);
#[cfg(target_os = "macos")]
tray::apply_tray_policy(app.handle(), false);
log::info!("静默启动模式:主窗口已隐藏");
} else {
// 正常启动模式:显示窗口
let _ = window.show();
log::info!("正常启动模式:主窗口已显示");
}
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::get_providers,
commands::get_current_provider,
commands::add_provider,
commands::update_provider,
commands::delete_provider,
commands::remove_provider_from_live_config,
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_skills_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::extract_common_config_snippet,
commands::read_live_provider_settings,
commands::get_settings,
commands::save_settings,
commands::get_rectifier_config,
commands::set_rectifier_config,
commands::get_log_config,
commands::set_log_config,
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,
// Unified MCP management
commands::get_mcp_servers,
commands::upsert_mcp_server,
commands::delete_mcp_server,
commands::toggle_mcp_app,
commands::import_mcp_from_apps,
// 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::webdav_test_connection,
commands::webdav_sync_upload,
commands::webdav_sync_download,
commands::webdav_sync_save_settings,
commands::webdav_sync_fetch_remote_info,
commands::save_file_dialog,
commands::open_file_dialog,
commands::open_zip_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 (v3.10.0+ unified)
commands::get_installed_skills,
commands::install_skill_unified,
commands::uninstall_skill_unified,
commands::toggle_skill_app,
commands::scan_unmanaged_skills,
commands::import_skills_from_apps,
commands::discover_available_skills,
// Skill management (legacy API compatibility)
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,
commands::install_skills_from_zip,
// 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,
// Global & Per-App Config
commands::get_global_proxy_config,
commands::update_global_proxy_config,
commands::get_proxy_config_for_app,
commands::update_proxy_config_for_app,
commands::get_default_cost_multiplier,
commands::set_default_cost_multiplier,
commands::get_pricing_model_source,
commands::set_pricing_model_source,
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,
// Session manager
commands::list_sessions,
commands::get_session_messages,
commands::launch_session_terminal,
commands::get_tool_versions,
// Provider terminal
commands::open_provider_terminal,
// Universal Provider management
commands::get_universal_providers,
commands::get_universal_provider,
commands::upsert_universal_provider,
commands::delete_universal_provider,
commands::sync_universal_provider,
// OpenCode specific
commands::import_opencode_providers_from_live,
commands::get_opencode_live_provider_ids,
// OpenClaw specific
commands::import_openclaw_providers_from_live,
commands::get_openclaw_live_provider_ids,
commands::get_openclaw_default_model,
commands::set_openclaw_default_model,
commands::get_openclaw_model_catalog,
commands::set_openclaw_model_catalog,
commands::get_openclaw_agents_defaults,
commands::set_openclaw_agents_defaults,
commands::get_openclaw_env,
commands::set_openclaw_env,
commands::get_openclaw_tools,
commands::set_openclaw_tools,
// Global upstream proxy
commands::get_global_proxy_url,
commands::set_global_proxy_url,
commands::test_proxy_url,
commands::get_upstream_proxy_status,
commands::scan_local_proxies,
// Window theme control
commands::set_window_theme,
commands::read_omo_local_file,
commands::get_current_omo_provider_id,
commands::get_omo_provider_count,
commands::disable_current_omo,
// Workspace files (OpenClaw)
commands::read_workspace_file,
commands::write_workspace_file,
]);
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!("代理服务器清理完成");
}
}
}
// ============================================================
// 启动时恢复代理状态
// ============================================================
/// 启动时根据 proxy_config 表中的代理状态自动恢复代理服务
///
/// 检查 `proxy_config.enabled` 字段,如果有任一应用的状态为 `true`
/// 则自动启动代理服务并接管对应应用的 Live 配置。
async fn restore_proxy_state_on_startup(state: &store::AppState) {
// 收集需要恢复接管的应用列表(从 proxy_config.enabled 读取)
let mut apps_to_restore = Vec::new();
for app_type in ["claude", "codex", "gemini"] {
if let Ok(config) = state.db.get_proxy_config_for_app(app_type).await {
if config.enabled {
apps_to_restore.push(app_type);
}
}
}
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
.proxy_service
.set_takeover_for_app(app_type, false)
.await
{
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()
}
/// 显示数据库初始化/Schema 迁移失败对话框
/// 返回 true 表示用户选择重试false 表示用户选择退出
fn show_database_init_error_dialog(
app: &tauri::AppHandle,
db_path: &std::path::Path,
error: &str,
) -> bool {
let title = if is_chinese_locale() {
"数据库初始化失败"
} else {
"Database Initialization Failed"
};
let message = if is_chinese_locale() {
format!(
"初始化数据库或迁移数据库结构时发生错误:\n\n{error}\n\n\
数据库文件路径:\n{db}\n\n\
您的数据尚未丢失,应用不会自动删除数据库文件。\n\
常见原因包括:数据库版本过新、文件损坏、权限不足、磁盘空间不足等。\n\n\
建议:\n\
1) 先备份整个配置目录(包含 cc-switch.db\n\
2) 如果提示“数据库版本过新”,请升级到更新版本\n\
3) 如果刚升级出现异常,可回退旧版本导出/备份后再升级\n\n\
点击「重试」重新尝试初始化\n\
点击「退出」关闭程序",
db = db_path.display()
)
} else {
format!(
"An error occurred while initializing or migrating the database:\n\n{error}\n\n\
Database file path:\n{db}\n\n\
Your data is NOT lost - the app will not delete the database automatically.\n\
Common causes include: newer database version, corrupted file, permission issues, or low disk space.\n\n\
Suggestions:\n\
1) Back up the entire config directory (including cc-switch.db)\n\
2) If you see “database version is newer”, please upgrade CC Switch\n\
3) If this happened right after upgrading, consider rolling back to export/backup then upgrade again\n\n\
Click 'Retry' to attempt initialization again\n\
Click 'Exit' to close the program",
db = db_path.display()
)
};
let retry_text = if is_chinese_locale() {
"重试"
} else {
"Retry"
};
let exit_text = if is_chinese_locale() {
"退出"
} else {
"Exit"
};
app.dialog()
.message(&message)
.title(title)
.kind(MessageDialogKind::Error)
.buttons(MessageDialogButtons::OkCancelCustom(
retry_text.to_string(),
exit_text.to_string(),
))
.blocking_show()
}