mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-06-16 13:34:04 +08:00
8ccfbd36d6
* refactor(toolsearch): replace binary patch with ENABLE_TOOL_SEARCH env var toggle
- Remove toolsearch_patch.rs binary patching mechanism (~590 lines)
- Delete `toolsearch_patch.rs` and `commands/toolsearch.rs`
- Remove auto-patch startup logic and command registration from lib.rs
- Remove `tool_search_bypass` field from settings.rs
- Remove frontend settings ToggleRow, useSettings hook sync logic, and API methods
- Clean up zh/en/ja i18n keys (notifications + settings)
- Add ENABLE_TOOL_SEARCH toggle to Claude provider form
- Add checkbox in CommonConfigEditor.tsx (alongside teammates toggle)
- When enabled, writes `"env": { "ENABLE_TOOL_SEARCH": "true" }`
- When disabled, removes the key; takes effect on provider switch
- Add zh/en/ja i18n key: `claudeConfig.enableToolSearch`
Claude Code 2.1.76+ natively supports this env var, eliminating the need for binary patching.
* feat(claude): add effortLevel high toggle to provider form
- Add "high-effort thinking" checkbox to Claude provider config form
- When checked, writes `"effortLevel": "high"`; when unchecked, removes the field
- Add zh/en/ja i18n translations
* refactor(claude): remove deprecated alwaysThinking toggle
- Claude Code now enables extended thinking by default; alwaysThinkingEnabled is a no-op
- Thinking control is now handled via effortLevel (added in prior commit)
- Remove state, switch case, and checkbox UI from CommonConfigEditor
- Clean up alwaysThinking i18n keys across zh/en/ja locales
* feat(opencode): add setCacheKey: true to all provider presets
- Add setCacheKey: true to options in all 33 regular presets
- Add setCacheKey: true to OPENCODE_DEFAULT_CONFIG for custom providers
- Exclude 2 OMO presets (Oh My OpenCode / Slim) which have their own config mechanism
Closes #1523
* fix(codex): resolve 1M context window toggle causing MCP editor flicker
- Add localValueRef to short-circuit duplicate CodeMirror updateListener callbacks,
breaking the React state → CodeMirror → stale onChange → React state feedback loop
- Use localValueRef.current in handleContextWindowToggle and handleCompactLimitChange
to avoid stale closure reads
- Change compact limit input from type="number" to type="text" with inputMode="numeric"
to remove unnecessary spinner buttons
* feat(codex): add 1M context window toggle utilities and i18n keys
- Add extractCodexTopLevelInt, setCodexTopLevelInt, removeCodexTopLevelField
TOML helpers in providerConfigUtils.ts
- Add i18n keys for contextWindow1M, autoCompactLimit in zh/en/ja locales
* feat(claude): collapse model mapping fields by default
- Wrap 5 model mapping inputs in a Collapsible, collapsed by default
- Auto-expand when any model value is present (including preset-filled)
- Show hint text when collapsed explaining most users need no config
- Add zh/en/ja i18n keys for toggle label and collapsed hint
- Use variant={null} to avoid ghost button hover style clash in dark mode
* feat(claude): merge advanced fields into single collapsible section
- Merge API format, auth field, and model mapping into a unified "Advanced Options" collapsible
- Extend smart-expand logic to detect non-default values across all advanced fields
- Preserve model mapping sub-header and hint with a separator line
- Update zh/en/ja i18n keys (advancedOptionsToggle, advancedOptionsHint, modelMappingLabel, modelMappingHint)
* feat(copilot): add GitHub Copilot reverse proxy support
Add GitHub Copilot as a Claude provider variant with OAuth device code
authentication and Anthropic ↔ OpenAI format transformation.
Backend:
- Add CopilotAuthManager for GitHub OAuth device code flow
- Implement Copilot token auto-refresh (60s before expiry)
- Persist GitHub token to ~/.cc-switch/copilot_auth.json
- Add ProviderType::GitHubCopilot and AuthStrategy::GitHubCopilot
- Modify forwarder to use /chat/completions for Copilot
- Add Copilot-specific headers (Editor-Version, Editor-Plugin-Version)
Frontend:
- Add CopilotAuthSection component for OAuth UI
- Add useCopilotAuth hook for OAuth state management
- Auto-copy user code to clipboard and open browser
- Use 8-second polling interval to avoid GitHub rate limits
- Skip API Key validation for Copilot providers
- Add GitHub Copilot preset with claude-sonnet-4 model
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
* fix(copilot): remove is_expired() calls from tests
Remove references to deleted is_expired() method in test code.
Only is_expiring_soon() is needed for token refresh logic.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
* feat(copilot): add real-time model listing from Copilot API
- Add fetch_models() to CopilotAuthManager calling GET /models endpoint
- Add copilot_get_models Tauri command
- Add copilotGetModels() frontend API wrapper
- Modify ClaudeFormFields to show model dropdown for Copilot providers
- Fetches available models on component mount when isCopilotPreset
- Groups models by vendor (Anthropic, OpenAI, Google, etc.)
- Input + dropdown button combo allows both manual entry and selection
- Non-Copilot providers keep original plain Input behavior
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(copilot): add usage query integration
- Add Copilot usage API integration (fetch_usage method)
- Add copilot_get_usage Tauri command
- Add GitHub Copilot template in usage query modal
- Unify naming: copilot → github_copilot
- Add constants management (TEMPLATE_TYPES, PROVIDER_TYPES)
- Improve error handling with detailed error messages
- Add database migration (v5 → v6) for template type update
- Add i18n translations (zh, en, ja)
- Improve type safety with TemplateType
- Apply code formatting (cargo fmt, prettier)
* 修复github 登录和注销问题 ,模型选择问题
* feat(copilot): add multi-account support for GitHub Copilot
- Add multi-account storage structure with v1 to v2 migration
- Add per-account token caching and auto-refresh
- Add new Tauri commands for account management
- Integrate account selection in Proxy forwarder
- Add account selection UI in CopilotAuthSection
- Save githubAccountId to ProviderMeta
- Add i18n translations for multi-account features (zh/en/ja)
* 修复用量查询Reset字段出现多余字符
* refactor(auth-binding): introduce generic provider auth binding primitives
- add shared authBinding types in Rust and TypeScript while keeping githubAccountId as a compatibility field\n- resolve Copilot token, models, and usage through provider-bound account lookup instead of only the implicit default account\n- fix the Unix build regression in settings.rs by restoring std::io::Write for write_all()\n- remove the accidental .github ignore entry and drop leftover Copilot form debug logs\n- keep the first migration step non-breaking by writing both authBinding and the legacy githubAccountId field from the form
* refactor(auth-service): add managed auth command surface and explicit default account state
- introduce generic managed auth commands and frontend auth API wrappers for provider-scoped login, status, account listing, removal, logout, and default-account selection\n- store an explicit Copilot default_account_id instead of relying on HashMap iteration order, and use it consistently for fallback token/model/usage resolution\n- sort managed accounts deterministically and surface default-account state to the UI\n- refactor the Copilot form hook to wrap a generic useManagedAuth implementation while preserving the existing component contract\n- add default-account controls to the Copilot auth section and extend Copilot auth status serialization/tests for the new state
* feat(auth-center): add a dedicated settings entrypoint for managed OAuth accounts
- add an Auth Center tab to Settings so managed OAuth accounts are no longer hidden inside individual provider forms\n- introduce a first AuthCenterPanel that hosts GitHub Copilot account management as the initial managed auth provider\n- keep the provider form experience intact while establishing a global account-management surface for future providers such as OpenAI\n- validate that the new settings tab works cleanly with the generic managed auth hook and existing Copilot account controls
* feat(add-provider): expose managed OAuth sources alongside universal providers
- add an OAuth tab to the Add Provider flow so managed auth sources sit beside app-specific and universal providers\n- reuse the new Auth Center panel inside the dialog, keeping account management discoverable during provider creation\n- make the dialog footer adapt to the OAuth tab so account setup does not pretend to create a provider directly\n- align the add-provider UX with the new architecture where OAuth accounts are global assets and providers bind to them later
* fix(auth-reliability): harden managed auth persistence and refresh behavior
- replace direct Copilot auth store writes with private temp-file writes and atomic rename semantics, and document the local token storage limitation\n- add per-account refresh locks plus a double-check path so concurrent requests do not stampede GitHub token refresh\n- surface legacy migration failures through auth status, expose them in the UI, and add translated copy for the new account-state labels\n- stop writing the legacy githubAccountId field from the provider form while keeping compatibility reads in place\n- add logout error recovery and Copilot model-load toasts so auth failures are no longer silently swallowed
* refactor(copilot-detection): prefer provider type before URL fallbacks
- update forwarder endpoint rewriting to treat providerType as the primary GitHub Copilot signal\n- keep githubcopilot.com string matching only as a compatibility fallback for older provider records without providerType\n- reduce one more path where Copilot behavior depended purely on URL heuristics
* fix(copilot-auth): add cancel button to error state in CopilotAuthSection
- 错误状态下仅有"重试"按钮,用户无法退出(如不可恢复的 403 未订阅错误)
- 新增"取消"按钮,复用已有的 cancelAuth 逻辑重置为 idle 状态
* 修复打包后github账号头像显示异常
* 修复github copilot 来源的模型测试报错
* feat(copilot-preset): add default model presets for GitHub Copilot
- 补充 Copilot 预设的默认模型配置,用户选完预设即可直接使用
- ANTHROPIC_MODEL: claude-opus-4.6
- ANTHROPIC_DEFAULT_HAIKU_MODEL: claude-haiku-4.5
- ANTHROPIC_DEFAULT_SONNET_MODEL: claude-sonnet-4.6
- ANTHROPIC_DEFAULT_OPUS_MODEL: claude-opus-4.6
---------
Co-authored-by: Jason <farion1231@gmail.com>
Co-authored-by: 周梦泽 <mengze.zhou@dafeng-tech.com>
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
711 lines
23 KiB
Rust
711 lines
23 KiB
Rust
use serde::{Deserialize, Serialize};
|
||
use std::fs;
|
||
use std::io::Write;
|
||
use std::path::PathBuf;
|
||
use std::sync::{OnceLock, RwLock};
|
||
|
||
use crate::app_config::AppType;
|
||
use crate::error::AppError;
|
||
use crate::services::skill::SyncMethod;
|
||
|
||
/// 自定义端点配置(历史兼容,实际存储在 provider.meta.custom_endpoints)
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct CustomEndpoint {
|
||
pub url: String,
|
||
pub added_at: i64,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub last_used: Option<i64>,
|
||
}
|
||
|
||
fn default_true() -> bool {
|
||
true
|
||
}
|
||
|
||
/// 主页面显示的应用配置
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct VisibleApps {
|
||
#[serde(default = "default_true")]
|
||
pub claude: bool,
|
||
#[serde(default = "default_true")]
|
||
pub codex: bool,
|
||
#[serde(default = "default_true")]
|
||
pub gemini: bool,
|
||
#[serde(default = "default_true")]
|
||
pub opencode: bool,
|
||
#[serde(default = "default_true")]
|
||
pub openclaw: bool,
|
||
}
|
||
|
||
impl Default for VisibleApps {
|
||
fn default() -> Self {
|
||
Self {
|
||
claude: true,
|
||
codex: true,
|
||
gemini: true,
|
||
opencode: true,
|
||
openclaw: true,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl VisibleApps {
|
||
/// Check if the specified app is visible
|
||
pub fn is_visible(&self, app: &AppType) -> bool {
|
||
match app {
|
||
AppType::Claude => self.claude,
|
||
AppType::Codex => self.codex,
|
||
AppType::Gemini => self.gemini,
|
||
AppType::OpenCode => self.opencode,
|
||
AppType::OpenClaw => self.openclaw,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// WebDAV 同步状态(持久化同步进度信息)
|
||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct WebDavSyncStatus {
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub last_sync_at: Option<i64>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub last_error: Option<String>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub last_error_source: Option<String>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub last_remote_etag: Option<String>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub last_local_manifest_hash: Option<String>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub last_remote_manifest_hash: Option<String>,
|
||
}
|
||
|
||
fn default_remote_root() -> String {
|
||
"cc-switch-sync".to_string()
|
||
}
|
||
fn default_profile() -> String {
|
||
"default".to_string()
|
||
}
|
||
|
||
/// WebDAV 同步设置
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct WebDavSyncSettings {
|
||
#[serde(default)]
|
||
pub enabled: bool,
|
||
#[serde(default)]
|
||
pub auto_sync: bool,
|
||
#[serde(default)]
|
||
pub base_url: String,
|
||
#[serde(default)]
|
||
pub username: String,
|
||
#[serde(default)]
|
||
pub password: String,
|
||
#[serde(default = "default_remote_root")]
|
||
pub remote_root: String,
|
||
#[serde(default = "default_profile")]
|
||
pub profile: String,
|
||
#[serde(default)]
|
||
pub status: WebDavSyncStatus,
|
||
}
|
||
|
||
impl Default for WebDavSyncSettings {
|
||
fn default() -> Self {
|
||
Self {
|
||
enabled: false,
|
||
auto_sync: false,
|
||
base_url: String::new(),
|
||
username: String::new(),
|
||
password: String::new(),
|
||
remote_root: default_remote_root(),
|
||
profile: default_profile(),
|
||
status: WebDavSyncStatus::default(),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl WebDavSyncSettings {
|
||
pub fn validate(&self) -> Result<(), crate::error::AppError> {
|
||
if self.base_url.trim().is_empty() {
|
||
return Err(crate::error::AppError::localized(
|
||
"webdav.base_url.required",
|
||
"WebDAV 地址不能为空",
|
||
"WebDAV URL is required.",
|
||
));
|
||
}
|
||
if self.username.trim().is_empty() {
|
||
return Err(crate::error::AppError::localized(
|
||
"webdav.username.required",
|
||
"WebDAV 用户名不能为空",
|
||
"WebDAV username is required.",
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub fn normalize(&mut self) {
|
||
self.base_url = self.base_url.trim().to_string();
|
||
self.username = self.username.trim().to_string();
|
||
self.remote_root = self.remote_root.trim().to_string();
|
||
self.profile = self.profile.trim().to_string();
|
||
if self.remote_root.is_empty() {
|
||
self.remote_root = default_remote_root();
|
||
}
|
||
if self.profile.is_empty() {
|
||
self.profile = default_profile();
|
||
}
|
||
}
|
||
|
||
/// Returns true if all credential fields are blank (no config to persist).
|
||
fn is_empty(&self) -> bool {
|
||
self.base_url.is_empty() && self.username.is_empty() && self.password.is_empty()
|
||
}
|
||
}
|
||
|
||
/// 应用设置结构
|
||
///
|
||
/// 存储设备级别设置,保存在本地 `~/.cc-switch/settings.json`,不随数据库同步。
|
||
/// 这确保了云同步场景下多设备可以独立运作。
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct AppSettings {
|
||
// ===== 设备级 UI 设置 =====
|
||
#[serde(default = "default_show_in_tray")]
|
||
pub show_in_tray: bool,
|
||
#[serde(default = "default_minimize_to_tray_on_close")]
|
||
pub minimize_to_tray_on_close: bool,
|
||
/// 是否启用 Claude 插件联动
|
||
#[serde(default)]
|
||
pub enable_claude_plugin_integration: bool,
|
||
/// 是否跳过 Claude Code 初次安装确认
|
||
#[serde(default)]
|
||
pub skip_claude_onboarding: bool,
|
||
/// 是否开机自启
|
||
#[serde(default)]
|
||
pub launch_on_startup: bool,
|
||
/// 静默启动(程序启动时不显示主窗口,仅托盘运行)
|
||
#[serde(default)]
|
||
pub silent_startup: bool,
|
||
/// 是否在主页面启用本地代理功能(默认关闭)
|
||
#[serde(default)]
|
||
pub enable_local_proxy: bool,
|
||
/// User has confirmed the local proxy first-run notice
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub proxy_confirmed: Option<bool>,
|
||
/// User has confirmed the usage query first-run notice
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub usage_confirmed: Option<bool>,
|
||
/// User has confirmed the stream check first-run notice
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub stream_check_confirmed: Option<bool>,
|
||
/// Whether to show the failover toggle independently on the main page
|
||
#[serde(default)]
|
||
pub enable_failover_toggle: bool,
|
||
/// User has confirmed the failover toggle first-run notice
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub failover_confirmed: Option<bool>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub language: Option<String>,
|
||
|
||
// ===== 主页面显示的应用 =====
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub visible_apps: Option<VisibleApps>,
|
||
|
||
// ===== 设备级目录覆盖 =====
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub claude_config_dir: Option<String>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub codex_config_dir: Option<String>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub gemini_config_dir: Option<String>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub opencode_config_dir: Option<String>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub openclaw_config_dir: Option<String>,
|
||
|
||
// ===== 当前供应商 ID(设备级)=====
|
||
/// 当前 Claude 供应商 ID(本地存储,优先于数据库 is_current)
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub current_provider_claude: Option<String>,
|
||
/// 当前 Codex 供应商 ID(本地存储,优先于数据库 is_current)
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub current_provider_codex: Option<String>,
|
||
/// 当前 Gemini 供应商 ID(本地存储,优先于数据库 is_current)
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub current_provider_gemini: Option<String>,
|
||
/// 当前 OpenCode 供应商 ID(本地存储,对 OpenCode 可能无意义,但保持结构一致)
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub current_provider_opencode: Option<String>,
|
||
/// 当前 OpenClaw 供应商 ID(本地存储,对 OpenClaw 可能无意义,但保持结构一致)
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub current_provider_openclaw: Option<String>,
|
||
|
||
// ===== Skill 同步设置 =====
|
||
/// Skill 同步方式:auto(默认,优先 symlink)、symlink、copy
|
||
#[serde(default)]
|
||
pub skill_sync_method: SyncMethod,
|
||
|
||
// ===== WebDAV 同步设置 =====
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub webdav_sync: Option<WebDavSyncSettings>,
|
||
|
||
// ===== WebDAV 备份设置(旧版,保留向后兼容)=====
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub webdav_backup: Option<serde_json::Value>,
|
||
|
||
// ===== 备份策略设置 =====
|
||
/// Auto-backup interval in hours (default 24, 0 = disabled)
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub backup_interval_hours: Option<u32>,
|
||
/// Maximum number of backup files to retain (default 10)
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub backup_retain_count: Option<u32>,
|
||
|
||
// ===== 终端设置 =====
|
||
/// 首选终端应用(可选,默认使用系统默认终端)
|
||
/// - macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty"
|
||
/// - Windows: "cmd" | "powershell" | "wt" (Windows Terminal)
|
||
/// - Linux: "gnome-terminal" | "konsole" | "xfce4-terminal" | "alacritty" | "kitty" | "ghostty"
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub preferred_terminal: Option<String>,
|
||
}
|
||
|
||
fn default_show_in_tray() -> bool {
|
||
true
|
||
}
|
||
|
||
fn default_minimize_to_tray_on_close() -> bool {
|
||
true
|
||
}
|
||
|
||
impl Default for AppSettings {
|
||
fn default() -> Self {
|
||
Self {
|
||
show_in_tray: true,
|
||
minimize_to_tray_on_close: true,
|
||
enable_claude_plugin_integration: false,
|
||
skip_claude_onboarding: false,
|
||
launch_on_startup: false,
|
||
silent_startup: false,
|
||
enable_local_proxy: false,
|
||
proxy_confirmed: None,
|
||
usage_confirmed: None,
|
||
stream_check_confirmed: None,
|
||
enable_failover_toggle: false,
|
||
failover_confirmed: None,
|
||
language: None,
|
||
visible_apps: None,
|
||
claude_config_dir: None,
|
||
codex_config_dir: None,
|
||
gemini_config_dir: None,
|
||
opencode_config_dir: None,
|
||
openclaw_config_dir: None,
|
||
current_provider_claude: None,
|
||
current_provider_codex: None,
|
||
current_provider_gemini: None,
|
||
current_provider_opencode: None,
|
||
current_provider_openclaw: None,
|
||
skill_sync_method: SyncMethod::default(),
|
||
webdav_sync: None,
|
||
webdav_backup: None,
|
||
backup_interval_hours: None,
|
||
backup_retain_count: None,
|
||
preferred_terminal: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl AppSettings {
|
||
fn settings_path() -> Option<PathBuf> {
|
||
// settings.json 保留用于旧版本迁移和无数据库场景
|
||
Some(
|
||
crate::config::get_home_dir()
|
||
.join(".cc-switch")
|
||
.join("settings.json"),
|
||
)
|
||
}
|
||
|
||
fn normalize_paths(&mut self) {
|
||
self.claude_config_dir = self
|
||
.claude_config_dir
|
||
.as_ref()
|
||
.map(|s| s.trim())
|
||
.filter(|s| !s.is_empty())
|
||
.map(|s| s.to_string());
|
||
|
||
self.codex_config_dir = self
|
||
.codex_config_dir
|
||
.as_ref()
|
||
.map(|s| s.trim())
|
||
.filter(|s| !s.is_empty())
|
||
.map(|s| s.to_string());
|
||
|
||
self.gemini_config_dir = self
|
||
.gemini_config_dir
|
||
.as_ref()
|
||
.map(|s| s.trim())
|
||
.filter(|s| !s.is_empty())
|
||
.map(|s| s.to_string());
|
||
|
||
self.opencode_config_dir = self
|
||
.opencode_config_dir
|
||
.as_ref()
|
||
.map(|s| s.trim())
|
||
.filter(|s| !s.is_empty())
|
||
.map(|s| s.to_string());
|
||
|
||
self.openclaw_config_dir = self
|
||
.openclaw_config_dir
|
||
.as_ref()
|
||
.map(|s| s.trim())
|
||
.filter(|s| !s.is_empty())
|
||
.map(|s| s.to_string());
|
||
|
||
self.language = self
|
||
.language
|
||
.as_ref()
|
||
.map(|s| s.trim())
|
||
.filter(|s| matches!(*s, "en" | "zh" | "ja"))
|
||
.map(|s| s.to_string());
|
||
|
||
if let Some(sync) = &mut self.webdav_sync {
|
||
sync.normalize();
|
||
if sync.is_empty() {
|
||
self.webdav_sync = None;
|
||
}
|
||
}
|
||
}
|
||
|
||
fn load_from_file() -> Self {
|
||
let Some(path) = Self::settings_path() else {
|
||
return Self::default();
|
||
};
|
||
if let Ok(content) = fs::read_to_string(&path) {
|
||
match serde_json::from_str::<AppSettings>(&content) {
|
||
Ok(mut settings) => {
|
||
settings.normalize_paths();
|
||
settings
|
||
}
|
||
Err(err) => {
|
||
log::warn!(
|
||
"解析设置文件失败,将使用默认设置。路径: {}, 错误: {}",
|
||
path.display(),
|
||
err
|
||
);
|
||
Self::default()
|
||
}
|
||
}
|
||
} else {
|
||
Self::default()
|
||
}
|
||
}
|
||
}
|
||
|
||
fn save_settings_file(settings: &AppSettings) -> Result<(), AppError> {
|
||
let mut normalized = settings.clone();
|
||
normalized.normalize_paths();
|
||
let Some(path) = AppSettings::settings_path() else {
|
||
return Err(AppError::Config("无法获取用户主目录".to_string()));
|
||
};
|
||
|
||
if let Some(parent) = path.parent() {
|
||
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||
}
|
||
|
||
let json = serde_json::to_string_pretty(&normalized)
|
||
.map_err(|e| AppError::JsonSerialize { source: e })?;
|
||
#[cfg(unix)]
|
||
{
|
||
use std::fs::OpenOptions;
|
||
use std::os::unix::fs::OpenOptionsExt;
|
||
|
||
let mut file = OpenOptions::new()
|
||
.create(true)
|
||
.write(true)
|
||
.truncate(true)
|
||
.mode(0o600)
|
||
.open(&path)
|
||
.map_err(|e| AppError::io(&path, e))?;
|
||
file.write_all(json.as_bytes())
|
||
.map_err(|e| AppError::io(&path, e))?;
|
||
}
|
||
|
||
#[cfg(not(unix))]
|
||
{
|
||
fs::write(&path, json).map_err(|e| AppError::io(&path, e))?;
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
static SETTINGS_STORE: OnceLock<RwLock<AppSettings>> = OnceLock::new();
|
||
|
||
fn settings_store() -> &'static RwLock<AppSettings> {
|
||
SETTINGS_STORE.get_or_init(|| RwLock::new(AppSettings::load_from_file()))
|
||
}
|
||
|
||
fn resolve_override_path(raw: &str) -> PathBuf {
|
||
if raw == "~" {
|
||
if let Some(home) = dirs::home_dir() {
|
||
return home;
|
||
}
|
||
} else if let Some(stripped) = raw.strip_prefix("~/") {
|
||
if let Some(home) = dirs::home_dir() {
|
||
return home.join(stripped);
|
||
}
|
||
} else if let Some(stripped) = raw.strip_prefix("~\\") {
|
||
if let Some(home) = dirs::home_dir() {
|
||
return home.join(stripped);
|
||
}
|
||
}
|
||
|
||
PathBuf::from(raw)
|
||
}
|
||
|
||
pub fn get_settings() -> AppSettings {
|
||
settings_store()
|
||
.read()
|
||
.unwrap_or_else(|e| {
|
||
log::warn!("设置锁已毒化,使用恢复值: {e}");
|
||
e.into_inner()
|
||
})
|
||
.clone()
|
||
}
|
||
|
||
pub fn get_settings_for_frontend() -> AppSettings {
|
||
let mut settings = get_settings();
|
||
if let Some(sync) = &mut settings.webdav_sync {
|
||
sync.password.clear();
|
||
}
|
||
settings.webdav_backup = None;
|
||
settings
|
||
}
|
||
|
||
pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
|
||
new_settings.normalize_paths();
|
||
save_settings_file(&new_settings)?;
|
||
|
||
let mut guard = settings_store().write().unwrap_or_else(|e| {
|
||
log::warn!("设置锁已毒化,使用恢复值: {e}");
|
||
e.into_inner()
|
||
});
|
||
*guard = new_settings;
|
||
Ok(())
|
||
}
|
||
|
||
fn mutate_settings<F>(mutator: F) -> Result<(), AppError>
|
||
where
|
||
F: FnOnce(&mut AppSettings),
|
||
{
|
||
let mut guard = settings_store().write().unwrap_or_else(|e| {
|
||
log::warn!("设置锁已毒化,使用恢复值: {e}");
|
||
e.into_inner()
|
||
});
|
||
let mut next = guard.clone();
|
||
mutator(&mut next);
|
||
next.normalize_paths();
|
||
save_settings_file(&next)?;
|
||
*guard = next;
|
||
Ok(())
|
||
}
|
||
|
||
/// 从文件重新加载设置到内存缓存
|
||
/// 用于导入配置等场景,确保内存缓存与文件同步
|
||
pub fn reload_settings() -> Result<(), AppError> {
|
||
let fresh_settings = AppSettings::load_from_file();
|
||
let mut guard = settings_store().write().unwrap_or_else(|e| {
|
||
log::warn!("设置锁已毒化,使用恢复值: {e}");
|
||
e.into_inner()
|
||
});
|
||
*guard = fresh_settings;
|
||
Ok(())
|
||
}
|
||
|
||
pub fn get_claude_override_dir() -> Option<PathBuf> {
|
||
let settings = settings_store().read().ok()?;
|
||
settings
|
||
.claude_config_dir
|
||
.as_ref()
|
||
.map(|p| resolve_override_path(p))
|
||
}
|
||
|
||
pub fn get_codex_override_dir() -> Option<PathBuf> {
|
||
let settings = settings_store().read().ok()?;
|
||
settings
|
||
.codex_config_dir
|
||
.as_ref()
|
||
.map(|p| resolve_override_path(p))
|
||
}
|
||
|
||
pub fn get_gemini_override_dir() -> Option<PathBuf> {
|
||
let settings = settings_store().read().ok()?;
|
||
settings
|
||
.gemini_config_dir
|
||
.as_ref()
|
||
.map(|p| resolve_override_path(p))
|
||
}
|
||
|
||
pub fn get_opencode_override_dir() -> Option<PathBuf> {
|
||
let settings = settings_store().read().ok()?;
|
||
settings
|
||
.opencode_config_dir
|
||
.as_ref()
|
||
.map(|p| resolve_override_path(p))
|
||
}
|
||
|
||
pub fn get_openclaw_override_dir() -> Option<PathBuf> {
|
||
let settings = settings_store().read().ok()?;
|
||
settings
|
||
.openclaw_config_dir
|
||
.as_ref()
|
||
.map(|p| resolve_override_path(p))
|
||
}
|
||
|
||
// ===== 当前供应商管理函数 =====
|
||
|
||
/// 获取指定应用类型的当前供应商 ID(从本地 settings 读取)
|
||
///
|
||
/// 这是设备级别的设置,不随数据库同步。
|
||
/// 如果本地没有设置,调用者应该 fallback 到数据库的 `is_current` 字段。
|
||
pub fn get_current_provider(app_type: &AppType) -> Option<String> {
|
||
let settings = settings_store().read().ok()?;
|
||
match app_type {
|
||
AppType::Claude => settings.current_provider_claude.clone(),
|
||
AppType::Codex => settings.current_provider_codex.clone(),
|
||
AppType::Gemini => settings.current_provider_gemini.clone(),
|
||
AppType::OpenCode => settings.current_provider_opencode.clone(),
|
||
AppType::OpenClaw => settings.current_provider_openclaw.clone(),
|
||
}
|
||
}
|
||
|
||
/// 设置指定应用类型的当前供应商 ID(保存到本地 settings)
|
||
///
|
||
/// 这是设备级别的设置,不随数据库同步。
|
||
/// 传入 `None` 会清除当前供应商设置。
|
||
pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(), AppError> {
|
||
let mut settings = get_settings();
|
||
|
||
match app_type {
|
||
AppType::Claude => settings.current_provider_claude = id.map(|s| s.to_string()),
|
||
AppType::Codex => settings.current_provider_codex = id.map(|s| s.to_string()),
|
||
AppType::Gemini => settings.current_provider_gemini = id.map(|s| s.to_string()),
|
||
AppType::OpenCode => settings.current_provider_opencode = id.map(|s| s.to_string()),
|
||
AppType::OpenClaw => settings.current_provider_openclaw = id.map(|s| s.to_string()),
|
||
}
|
||
|
||
update_settings(settings)
|
||
}
|
||
|
||
/// 获取有效的当前供应商 ID(验证存在性)
|
||
///
|
||
/// 逻辑:
|
||
/// 1. 从本地 settings 读取当前供应商 ID
|
||
/// 2. 验证该 ID 在数据库中存在
|
||
/// 3. 如果不存在则清理本地 settings,fallback 到数据库的 is_current
|
||
///
|
||
/// 这确保了返回的 ID 一定是有效的(在数据库中存在)。
|
||
/// 多设备云同步场景下,配置导入后本地 ID 可能失效,此函数会自动修复。
|
||
pub fn get_effective_current_provider(
|
||
db: &crate::database::Database,
|
||
app_type: &AppType,
|
||
) -> Result<Option<String>, AppError> {
|
||
// 1. 从本地 settings 读取
|
||
if let Some(local_id) = get_current_provider(app_type) {
|
||
// 2. 验证该 ID 在数据库中存在
|
||
let providers = db.get_all_providers(app_type.as_str())?;
|
||
if providers.contains_key(&local_id) {
|
||
// 存在,直接返回
|
||
return Ok(Some(local_id));
|
||
}
|
||
|
||
// 3. 不存在,清理本地 settings
|
||
log::warn!(
|
||
"本地 settings 中的供应商 {} ({}) 在数据库中不存在,将清理并 fallback 到数据库",
|
||
local_id,
|
||
app_type.as_str()
|
||
);
|
||
let _ = set_current_provider(app_type, None);
|
||
}
|
||
|
||
// Fallback 到数据库的 is_current
|
||
db.get_current_provider(app_type.as_str())
|
||
}
|
||
|
||
// ===== Skill 同步方式管理函数 =====
|
||
|
||
/// 获取 Skill 同步方式配置
|
||
pub fn get_skill_sync_method() -> SyncMethod {
|
||
settings_store()
|
||
.read()
|
||
.unwrap_or_else(|e| {
|
||
log::warn!("设置锁已毒化,使用恢复值: {e}");
|
||
e.into_inner()
|
||
})
|
||
.skill_sync_method
|
||
}
|
||
|
||
// ===== 备份策略管理函数 =====
|
||
|
||
/// Get the effective auto-backup interval in hours (default 24)
|
||
pub fn effective_backup_interval_hours() -> u32 {
|
||
settings_store()
|
||
.read()
|
||
.unwrap_or_else(|e| {
|
||
log::warn!("设置锁已毒化,使用恢复值: {e}");
|
||
e.into_inner()
|
||
})
|
||
.backup_interval_hours
|
||
.unwrap_or(24)
|
||
}
|
||
|
||
/// Get the effective backup retain count (default 10, minimum 1)
|
||
pub fn effective_backup_retain_count() -> usize {
|
||
settings_store()
|
||
.read()
|
||
.unwrap_or_else(|e| {
|
||
log::warn!("设置锁已毒化,使用恢复值: {e}");
|
||
e.into_inner()
|
||
})
|
||
.backup_retain_count
|
||
.map(|n| (n as usize).max(1))
|
||
.unwrap_or(10)
|
||
}
|
||
|
||
// ===== 终端设置管理函数 =====
|
||
|
||
/// 获取首选终端应用
|
||
pub fn get_preferred_terminal() -> Option<String> {
|
||
settings_store()
|
||
.read()
|
||
.unwrap_or_else(|e| {
|
||
log::warn!("设置锁已毒化,使用恢复值: {e}");
|
||
e.into_inner()
|
||
})
|
||
.preferred_terminal
|
||
.clone()
|
||
}
|
||
|
||
// ===== WebDAV 同步设置管理函数 =====
|
||
|
||
/// 获取 WebDAV 同步设置
|
||
pub fn get_webdav_sync_settings() -> Option<WebDavSyncSettings> {
|
||
settings_store().read().ok()?.webdav_sync.clone()
|
||
}
|
||
|
||
/// 保存 WebDAV 同步设置
|
||
pub fn set_webdav_sync_settings(settings: Option<WebDavSyncSettings>) -> Result<(), AppError> {
|
||
mutate_settings(|current| {
|
||
current.webdav_sync = settings;
|
||
})
|
||
}
|
||
|
||
/// 仅更新 WebDAV 同步状态,避免覆写 credentials/root/profile 等字段
|
||
pub fn update_webdav_sync_status(status: WebDavSyncStatus) -> Result<(), AppError> {
|
||
mutate_settings(|current| {
|
||
if let Some(sync) = current.webdav_sync.as_mut() {
|
||
sync.status = status;
|
||
}
|
||
})
|
||
}
|