Skip to main content

cc_switch_lib/
settings.rs

1use crate::app_config::AppType;
2use crate::config::{get_app_config_dir, home_dir};
3use crate::error::AppError;
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::fs;
7use std::path::PathBuf;
8use std::sync::{OnceLock, RwLock};
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
11#[serde(rename_all = "camelCase")]
12pub struct VisibleApps {
13    #[serde(default = "default_visible_app_claude")]
14    pub claude: bool,
15    #[serde(default = "default_visible_app_codex")]
16    pub codex: bool,
17    #[serde(default = "default_visible_app_gemini")]
18    pub gemini: bool,
19    #[serde(default = "default_visible_app_opencode")]
20    pub opencode: bool,
21    #[serde(default = "default_visible_app_openclaw")]
22    pub openclaw: bool,
23    #[serde(default = "default_visible_app_hermes")]
24    pub hermes: bool,
25}
26
27fn default_visible_app_claude() -> bool {
28    true
29}
30
31fn default_visible_app_codex() -> bool {
32    true
33}
34
35fn default_visible_app_gemini() -> bool {
36    false
37}
38
39fn default_visible_app_opencode() -> bool {
40    true
41}
42
43fn default_visible_app_openclaw() -> bool {
44    true
45}
46
47fn default_visible_app_hermes() -> bool {
48    true
49}
50
51pub fn default_visible_apps() -> VisibleApps {
52    VisibleApps {
53        claude: true,
54        codex: true,
55        gemini: false,
56        opencode: true,
57        openclaw: true,
58        hermes: true,
59    }
60}
61
62impl Default for VisibleApps {
63    fn default() -> Self {
64        default_visible_apps()
65    }
66}
67
68impl VisibleApps {
69    pub fn ordered_enabled(&self) -> Vec<AppType> {
70        app_order()
71            .into_iter()
72            .filter(|app_type| self.is_enabled_for(app_type))
73            .collect()
74    }
75
76    pub fn is_enabled_for(&self, app_type: &AppType) -> bool {
77        match app_type {
78            AppType::Claude => self.claude,
79            AppType::Codex => self.codex,
80            AppType::Gemini => self.gemini,
81            AppType::OpenCode => self.opencode,
82            AppType::OpenClaw => self.openclaw,
83            AppType::Hermes => self.hermes,
84        }
85    }
86
87    pub fn set_enabled_for(&mut self, app_type: &AppType, enabled: bool) {
88        match app_type {
89            AppType::Claude => self.claude = enabled,
90            AppType::Codex => self.codex = enabled,
91            AppType::Gemini => self.gemini = enabled,
92            AppType::OpenCode => self.opencode = enabled,
93            AppType::OpenClaw => self.openclaw = enabled,
94            AppType::Hermes => self.hermes = enabled,
95        }
96    }
97
98    pub fn normalize(&mut self) {
99        if self.ordered_enabled().is_empty() {
100            *self = default_visible_apps();
101        }
102    }
103
104    pub fn validate(&self) -> Result<(), AppError> {
105        if self.ordered_enabled().is_empty() {
106            return Err(AppError::InvalidInput(
107                "At least one app must remain visible".to_string(),
108            ));
109        }
110
111        Ok(())
112    }
113}
114
115fn app_order() -> [AppType; 6] {
116    [
117        AppType::Claude,
118        AppType::Codex,
119        AppType::Gemini,
120        AppType::OpenCode,
121        AppType::OpenClaw,
122        AppType::Hermes,
123    ]
124}
125
126pub fn next_visible_app(
127    visible: &VisibleApps,
128    current: &AppType,
129    direction: i8,
130) -> Option<AppType> {
131    let ordered = app_order();
132    if ordered
133        .iter()
134        .all(|app_type| !visible.is_enabled_for(app_type))
135    {
136        return None;
137    }
138
139    let current_index = ordered.iter().position(|app_type| app_type == current)?;
140    let step = if direction < 0 { -1 } else { 1 };
141    let len = ordered.len() as isize;
142
143    for offset in 1..=ordered.len() {
144        let index = (current_index as isize + step * offset as isize).rem_euclid(len) as usize;
145        let candidate = &ordered[index];
146        if visible.is_enabled_for(candidate) {
147            return Some(candidate.clone());
148        }
149    }
150
151    None
152}
153
154/// 自定义端点配置
155#[derive(Debug, Clone, Serialize, Deserialize)]
156#[serde(rename_all = "camelCase")]
157pub struct CustomEndpoint {
158    pub url: String,
159    pub added_at: i64,
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub last_used: Option<i64>,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize, Default)]
165#[serde(rename_all = "camelCase")]
166pub struct SecurityAuthSettings {
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub selected_type: Option<String>,
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize, Default)]
172#[serde(rename_all = "camelCase")]
173pub struct SecuritySettings {
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub auth: Option<SecurityAuthSettings>,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
179#[serde(rename_all = "camelCase")]
180pub struct WebDavSyncStatus {
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub last_sync_at: Option<i64>,
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub last_error: Option<String>,
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub last_error_source: Option<String>,
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub last_remote_etag: Option<String>,
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub last_local_manifest_hash: Option<String>,
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub last_remote_manifest_hash: Option<String>,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
196#[serde(rename_all = "camelCase")]
197pub struct WebDavSyncSettings {
198    #[serde(default)]
199    pub enabled: bool,
200    #[serde(default)]
201    pub base_url: String,
202    #[serde(default = "default_webdav_remote_root")]
203    pub remote_root: String,
204    #[serde(default = "default_webdav_profile")]
205    pub profile: String,
206    #[serde(default)]
207    pub username: String,
208    #[serde(default)]
209    pub password: String,
210    #[serde(default)]
211    pub auto_sync: bool,
212    #[serde(default)]
213    pub status: WebDavSyncStatus,
214}
215
216fn default_webdav_remote_root() -> String {
217    "cc-switch-sync".to_string()
218}
219
220fn default_webdav_profile() -> String {
221    "default".to_string()
222}
223
224const JIANGUOYUN_WEBDAV_BASE_URL: &str = "https://dav.jianguoyun.com/dav";
225
226impl Default for WebDavSyncSettings {
227    fn default() -> Self {
228        Self {
229            enabled: false,
230            base_url: String::new(),
231            remote_root: default_webdav_remote_root(),
232            profile: default_webdav_profile(),
233            username: String::new(),
234            password: String::new(),
235            auto_sync: false,
236            status: WebDavSyncStatus::default(),
237        }
238    }
239}
240
241impl WebDavSyncSettings {
242    pub fn jianguoyun_preset(username: &str, password: &str) -> Self {
243        let mut settings = Self {
244            enabled: true,
245            base_url: JIANGUOYUN_WEBDAV_BASE_URL.to_string(),
246            remote_root: default_webdav_remote_root(),
247            profile: default_webdav_profile(),
248            username: username.to_string(),
249            password: password.to_string(),
250            ..Self::default()
251        };
252        settings.normalize();
253        settings
254    }
255
256    pub fn normalize(&mut self) {
257        self.base_url = self.base_url.trim().trim_end_matches('/').to_string();
258        self.remote_root = sanitize_path_segment(&self.remote_root);
259        self.profile = sanitize_path_segment(&self.profile);
260        self.username = self.username.trim().to_string();
261        self.password = self.password.trim().to_string();
262    }
263
264    pub fn validate(&self) -> Result<(), AppError> {
265        if !self.enabled && self.base_url.is_empty() {
266            return Ok(());
267        }
268        if self.base_url.is_empty() {
269            return Err(AppError::InvalidInput(
270                "WebDAV base_url 不能为空".to_string(),
271            ));
272        }
273        crate::services::webdav::parse_base_url(&self.base_url)?;
274        if self.remote_root.is_empty() || self.profile.is_empty() {
275            return Err(AppError::InvalidInput(
276                "WebDAV remote_root/profile 不能为空".to_string(),
277            ));
278        }
279        if self.remote_root.contains("..") || self.profile.contains("..") {
280            return Err(AppError::InvalidInput(
281                "WebDAV remote_root/profile 不能包含 '..'".to_string(),
282            ));
283        }
284        Ok(())
285    }
286}
287
288fn sanitize_path_segment(raw: &str) -> String {
289    raw.trim()
290        .trim_matches('/')
291        .split('/')
292        .filter(|s| !s.is_empty())
293        .collect::<Vec<_>>()
294        .join("/")
295}
296
297/// 应用设置结构,允许覆盖默认配置目录
298#[derive(Debug, Clone, Serialize, Deserialize)]
299#[serde(rename_all = "camelCase")]
300pub struct AppSettings {
301    #[serde(default = "default_show_in_tray")]
302    pub show_in_tray: bool,
303    #[serde(default = "default_minimize_to_tray_on_close")]
304    pub minimize_to_tray_on_close: bool,
305    /// 是否启用 Claude 插件联动
306    #[serde(default)]
307    pub enable_claude_plugin_integration: bool,
308    /// 是否跳过 Claude Code 初次安装确认
309    #[serde(default)]
310    pub skip_claude_onboarding: bool,
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub claude_config_dir: Option<String>,
313    #[serde(default, skip_serializing_if = "Option::is_none")]
314    pub codex_config_dir: Option<String>,
315    #[serde(default, skip_serializing_if = "Option::is_none")]
316    pub gemini_config_dir: Option<String>,
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub opencode_config_dir: Option<String>,
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub openclaw_config_dir: Option<String>,
321    #[serde(default, skip_serializing_if = "Option::is_none")]
322    pub hermes_config_dir: Option<String>,
323    #[serde(default, skip_serializing_if = "Option::is_none")]
324    pub current_provider_claude: Option<String>,
325    #[serde(default, skip_serializing_if = "Option::is_none")]
326    pub current_provider_codex: Option<String>,
327    #[serde(default, skip_serializing_if = "Option::is_none")]
328    pub current_provider_gemini: Option<String>,
329    #[serde(default, skip_serializing_if = "Option::is_none")]
330    pub current_provider_opencode: Option<String>,
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub current_provider_openclaw: Option<String>,
333    #[serde(default, skip_serializing_if = "Option::is_none")]
334    pub current_provider_hermes: Option<String>,
335    #[serde(default = "default_visible_apps")]
336    pub visible_apps: VisibleApps,
337    #[serde(default, skip_serializing_if = "Option::is_none")]
338    pub language: Option<String>,
339    /// 是否开机自启
340    #[serde(default)]
341    pub launch_on_startup: bool,
342    /// Skills 同步方式(auto|symlink|copy)
343    #[serde(default)]
344    pub skill_sync_method: crate::services::skill::SyncMethod,
345    #[serde(default, skip_serializing_if = "Option::is_none")]
346    pub security: Option<SecuritySettings>,
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub webdav_sync: Option<WebDavSyncSettings>,
349    #[serde(default, skip_serializing_if = "Option::is_none")]
350    pub backup_retain_count: Option<u32>,
351    /// Claude 自定义端点列表
352    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
353    pub custom_endpoints_claude: HashMap<String, CustomEndpoint>,
354    /// Codex 自定义端点列表
355    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
356    pub custom_endpoints_codex: HashMap<String, CustomEndpoint>,
357}
358
359fn default_show_in_tray() -> bool {
360    true
361}
362
363fn default_minimize_to_tray_on_close() -> bool {
364    true
365}
366
367impl Default for AppSettings {
368    fn default() -> Self {
369        Self {
370            show_in_tray: true,
371            minimize_to_tray_on_close: true,
372            enable_claude_plugin_integration: false,
373            skip_claude_onboarding: false,
374            claude_config_dir: None,
375            codex_config_dir: None,
376            gemini_config_dir: None,
377            opencode_config_dir: None,
378            openclaw_config_dir: None,
379            hermes_config_dir: None,
380            current_provider_claude: None,
381            current_provider_codex: None,
382            current_provider_gemini: None,
383            current_provider_opencode: None,
384            current_provider_openclaw: None,
385            current_provider_hermes: None,
386            visible_apps: default_visible_apps(),
387            language: None,
388            launch_on_startup: false,
389            skill_sync_method: crate::services::skill::SyncMethod::default(),
390            security: None,
391            webdav_sync: None,
392            backup_retain_count: None,
393            custom_endpoints_claude: HashMap::new(),
394            custom_endpoints_codex: HashMap::new(),
395        }
396    }
397}
398
399impl AppSettings {
400    fn settings_path() -> PathBuf {
401        // settings.json 可以跟随 CC_SWITCH_CONFIG_DIR,且不会形成循环依赖:
402        // 路径仅依赖进程环境变量和 HOME,不依赖已持久化的 settings 内容。
403        get_app_config_dir().join("settings.json")
404    }
405
406    fn normalize_common(&mut self) {
407        self.claude_config_dir = self
408            .claude_config_dir
409            .as_ref()
410            .map(|s| s.trim())
411            .filter(|s| !s.is_empty())
412            .map(|s| s.to_string());
413
414        self.codex_config_dir = self
415            .codex_config_dir
416            .as_ref()
417            .map(|s| s.trim())
418            .filter(|s| !s.is_empty())
419            .map(|s| s.to_string());
420
421        self.gemini_config_dir = self
422            .gemini_config_dir
423            .as_ref()
424            .map(|s| s.trim())
425            .filter(|s| !s.is_empty())
426            .map(|s| s.to_string());
427
428        self.opencode_config_dir = self
429            .opencode_config_dir
430            .as_ref()
431            .map(|s| s.trim())
432            .filter(|s| !s.is_empty())
433            .map(|s| s.to_string());
434
435        self.openclaw_config_dir = self
436            .openclaw_config_dir
437            .as_ref()
438            .map(|s| s.trim())
439            .filter(|s| !s.is_empty())
440            .map(|s| s.to_string());
441
442        self.hermes_config_dir = self
443            .hermes_config_dir
444            .as_ref()
445            .map(|s| s.trim())
446            .filter(|s| !s.is_empty())
447            .map(|s| s.to_string());
448
449        self.language = self
450            .language
451            .as_ref()
452            .map(|s| s.trim())
453            .filter(|s| matches!(*s, "en" | "zh"))
454            .map(|s| s.to_string());
455
456        if let Some(webdav) = self.webdav_sync.as_mut() {
457            webdav.normalize();
458        }
459    }
460
461    fn normalize_loaded(&mut self) {
462        self.normalize_common();
463        self.visible_apps.normalize();
464    }
465
466    fn validate(&self) -> Result<(), AppError> {
467        self.visible_apps.validate()
468    }
469
470    pub fn load() -> Self {
471        let path = Self::settings_path();
472        if let Ok(content) = fs::read_to_string(&path) {
473            match serde_json::from_str::<AppSettings>(&content) {
474                Ok(mut settings) => {
475                    settings.normalize_loaded();
476                    settings
477                }
478                Err(err) => {
479                    log::warn!(
480                        "解析设置文件失败,将使用默认设置。路径: {}, 错误: {}",
481                        path.display(),
482                        err
483                    );
484                    Self::default()
485                }
486            }
487        } else {
488            Self::default()
489        }
490    }
491
492    pub fn save(&self) -> Result<(), AppError> {
493        let mut normalized = self.clone();
494        normalized.normalize_common();
495        normalized.validate()?;
496        let path = Self::settings_path();
497
498        if let Some(parent) = path.parent() {
499            fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
500        }
501
502        let json = serde_json::to_string_pretty(&normalized)
503            .map_err(|e| AppError::JsonSerialize { source: e })?;
504        fs::write(&path, json).map_err(|e| AppError::io(&path, e))?;
505        Ok(())
506    }
507}
508
509fn settings_store() -> &'static RwLock<AppSettings> {
510    static STORE: OnceLock<RwLock<AppSettings>> = OnceLock::new();
511    STORE.get_or_init(|| RwLock::new(AppSettings::load()))
512}
513
514pub fn reload_settings() -> Result<(), AppError> {
515    let fresh_settings = AppSettings::load();
516    let mut guard = settings_store().write().expect("写入设置锁失败");
517    *guard = fresh_settings;
518    Ok(())
519}
520
521#[cfg(test)]
522pub(crate) fn reload_test_settings() {
523    let mut guard = settings_store().write().expect("写入设置锁失败");
524    *guard = AppSettings::load();
525}
526
527fn resolve_override_path(raw: &str) -> PathBuf {
528    if raw == "~" {
529        if let Some(home) = home_dir() {
530            return home;
531        }
532    } else if let Some(stripped) = raw.strip_prefix("~/") {
533        if let Some(home) = home_dir() {
534            return home.join(stripped);
535        }
536    } else if let Some(stripped) = raw.strip_prefix("~\\") {
537        if let Some(home) = home_dir() {
538            return home.join(stripped);
539        }
540    }
541
542    PathBuf::from(raw)
543}
544
545pub fn get_settings() -> AppSettings {
546    settings_store().read().expect("读取设置锁失败").clone()
547}
548
549pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
550    new_settings.normalize_common();
551    new_settings.validate()?;
552    new_settings.save()?;
553
554    let mut guard = settings_store().write().expect("写入设置锁失败");
555    *guard = new_settings;
556    Ok(())
557}
558
559pub fn ensure_security_auth_selected_type(selected_type: &str) -> Result<(), AppError> {
560    let mut settings = get_settings();
561    let current = settings
562        .security
563        .as_ref()
564        .and_then(|sec| sec.auth.as_ref())
565        .and_then(|auth| auth.selected_type.as_deref());
566
567    if current == Some(selected_type) {
568        return Ok(());
569    }
570
571    let mut security = settings.security.unwrap_or_default();
572    let mut auth = security.auth.unwrap_or_default();
573    auth.selected_type = Some(selected_type.to_string());
574    security.auth = Some(auth);
575    settings.security = Some(security);
576
577    update_settings(settings)
578}
579
580pub fn get_claude_override_dir() -> Option<PathBuf> {
581    let settings = settings_store().read().ok()?;
582    settings
583        .claude_config_dir
584        .as_ref()
585        .map(|p| resolve_override_path(p))
586}
587
588pub fn get_codex_override_dir() -> Option<PathBuf> {
589    let settings = settings_store().read().ok()?;
590    settings
591        .codex_config_dir
592        .as_ref()
593        .map(|p| resolve_override_path(p))
594}
595
596pub fn get_gemini_override_dir() -> Option<PathBuf> {
597    let settings = settings_store().read().ok()?;
598    settings
599        .gemini_config_dir
600        .as_ref()
601        .map(|p| resolve_override_path(p))
602}
603
604pub fn get_opencode_override_dir() -> Option<PathBuf> {
605    let settings = settings_store().read().ok()?;
606    settings
607        .opencode_config_dir
608        .as_ref()
609        .map(|p| resolve_override_path(p))
610}
611
612pub fn get_openclaw_override_dir() -> Option<PathBuf> {
613    let settings = settings_store().read().ok()?;
614    settings
615        .openclaw_config_dir
616        .as_ref()
617        .map(|p| resolve_override_path(p))
618}
619
620pub fn get_hermes_override_dir() -> Option<PathBuf> {
621    let settings = settings_store().read().ok()?;
622    settings
623        .hermes_config_dir
624        .as_ref()
625        .map(|p| resolve_override_path(p))
626}
627
628pub fn get_current_provider(app_type: &AppType) -> Option<String> {
629    let settings = settings_store().read().ok()?;
630    match app_type {
631        AppType::Claude => settings.current_provider_claude.clone(),
632        AppType::Codex => settings.current_provider_codex.clone(),
633        AppType::Gemini => settings.current_provider_gemini.clone(),
634        AppType::OpenCode => settings.current_provider_opencode.clone(),
635        AppType::OpenClaw => settings.current_provider_openclaw.clone(),
636        AppType::Hermes => settings.current_provider_hermes.clone(),
637    }
638}
639
640pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(), AppError> {
641    let mut settings = get_settings();
642
643    match app_type {
644        AppType::Claude => settings.current_provider_claude = id.map(|value| value.to_string()),
645        AppType::Codex => settings.current_provider_codex = id.map(|value| value.to_string()),
646        AppType::Gemini => settings.current_provider_gemini = id.map(|value| value.to_string()),
647        AppType::OpenCode => settings.current_provider_opencode = id.map(|value| value.to_string()),
648        AppType::OpenClaw => settings.current_provider_openclaw = id.map(|value| value.to_string()),
649        AppType::Hermes => settings.current_provider_hermes = id.map(|value| value.to_string()),
650    }
651
652    update_settings(settings)
653}
654
655pub fn get_visible_apps() -> VisibleApps {
656    settings_store()
657        .read()
658        .map(|settings| settings.visible_apps.clone())
659        .unwrap_or_else(|_| default_visible_apps())
660}
661
662pub fn set_visible_apps(visible_apps: VisibleApps) -> Result<(), AppError> {
663    visible_apps.validate()?;
664
665    let mut settings = get_settings();
666    settings.visible_apps = visible_apps;
667    update_settings(settings)
668}
669
670pub fn get_effective_current_provider(
671    db: &crate::database::Database,
672    app_type: &AppType,
673) -> Result<Option<String>, AppError> {
674    if let Some(local_id) = get_current_provider(app_type) {
675        let providers = db.get_all_providers(app_type.as_str())?;
676        if providers.contains_key(&local_id) {
677            return Ok(Some(local_id));
678        }
679
680        log::warn!(
681            "本地 settings 中的供应商 {} ({}) 在数据库中不存在,将清理并 fallback 到数据库",
682            local_id,
683            app_type.as_str()
684        );
685        let _ = set_current_provider(app_type, None);
686    }
687
688    db.get_current_provider(app_type.as_str())
689}
690
691pub fn get_skill_sync_method() -> crate::services::skill::SyncMethod {
692    settings_store()
693        .read()
694        .map(|s| s.skill_sync_method)
695        .unwrap_or_default()
696}
697
698pub fn effective_backup_retain_count() -> usize {
699    settings_store()
700        .read()
701        .map(|settings| {
702            settings
703                .backup_retain_count
704                .map(|count| usize::try_from(count).unwrap_or(usize::MAX).max(1))
705                .unwrap_or(10)
706        })
707        .unwrap_or(10)
708}
709
710pub fn set_skill_sync_method(method: crate::services::skill::SyncMethod) -> Result<(), AppError> {
711    let mut settings = get_settings();
712    settings.skill_sync_method = method;
713    update_settings(settings)
714}
715
716pub fn get_webdav_sync_settings() -> Option<WebDavSyncSettings> {
717    settings_store()
718        .read()
719        .ok()
720        .and_then(|s| s.webdav_sync.clone())
721}
722
723pub fn set_webdav_sync_settings(webdav_sync: Option<WebDavSyncSettings>) -> Result<(), AppError> {
724    let mut settings = get_settings();
725    settings.webdav_sync = match webdav_sync {
726        Some(mut cfg) => {
727            cfg.normalize();
728            cfg.validate()?;
729            Some(cfg)
730        }
731        None => None,
732    };
733    update_settings(settings)
734}
735
736pub fn update_webdav_sync_status(status: WebDavSyncStatus) -> Result<(), AppError> {
737    let mut settings = get_settings();
738    if let Some(ref mut webdav) = settings.webdav_sync {
739        webdav.status = status;
740    }
741    update_settings(settings)
742}
743
744pub fn webdav_jianguoyun_preset(username: &str, password: &str) -> WebDavSyncSettings {
745    WebDavSyncSettings::jianguoyun_preset(username, password)
746}
747
748pub fn get_skip_claude_onboarding() -> bool {
749    settings_store()
750        .read()
751        .map(|s| s.skip_claude_onboarding)
752        .unwrap_or(false)
753}
754
755pub fn get_enable_claude_plugin_integration() -> bool {
756    settings_store()
757        .read()
758        .map(|s| s.enable_claude_plugin_integration)
759        .unwrap_or(false)
760}
761
762pub fn set_enable_claude_plugin_integration(enabled: bool) -> Result<(), AppError> {
763    let mut settings = get_settings();
764    settings.enable_claude_plugin_integration = enabled;
765    update_settings(settings)
766}
767
768pub fn set_skip_claude_onboarding(enabled: bool) -> Result<(), AppError> {
769    if enabled {
770        crate::claude_mcp::set_has_completed_onboarding()?;
771    } else {
772        crate::claude_mcp::clear_has_completed_onboarding()?;
773    }
774
775    let mut settings = get_settings();
776    settings.skip_claude_onboarding = enabled;
777    update_settings(settings)
778}