├── src-tauri ├── build.rs ├── icons │ ├── 32x32.png │ ├── 64x64.png │ ├── icon.icns │ ├── icon.ico │ ├── icon.png │ ├── 128x128.png │ ├── 128x128@2x.png │ ├── StoreLogo.png │ ├── Square30x30Logo.png │ ├── Square44x44Logo.png │ ├── Square71x71Logo.png │ ├── Square89x89Logo.png │ ├── Square107x107Logo.png │ ├── Square142x142Logo.png │ ├── Square150x150Logo.png │ ├── Square284x284Logo.png │ ├── Square310x310Logo.png │ ├── ios │ │ ├── AppIcon-512@2x.png │ │ ├── AppIcon-20x20@1x.png │ │ ├── AppIcon-20x20@2x.png │ │ ├── AppIcon-20x20@3x.png │ │ ├── AppIcon-29x29@1x.png │ │ ├── AppIcon-29x29@2x.png │ │ ├── AppIcon-29x29@3x.png │ │ ├── AppIcon-40x40@1x.png │ │ ├── AppIcon-40x40@2x.png │ │ ├── AppIcon-40x40@3x.png │ │ ├── AppIcon-60x60@2x.png │ │ ├── AppIcon-60x60@3x.png │ │ ├── AppIcon-76x76@1x.png │ │ ├── AppIcon-76x76@2x.png │ │ ├── AppIcon-20x20@2x-1.png │ │ ├── AppIcon-29x29@2x-1.png │ │ ├── AppIcon-40x40@2x-1.png │ │ └── AppIcon-83.5x83.5@2x.png │ ├── tray │ │ ├── tray-icon-xl.png │ │ ├── tray-icon-hq-16.png │ │ ├── tray-icon-large.png │ │ ├── tray-icon-clean-16.png │ │ └── tray-icon-large-32.png │ └── android │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_round.png │ │ └── ic_launcher_foreground.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_round.png │ │ └── ic_launcher_foreground.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_round.png │ │ └── ic_launcher_foreground.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_round.png │ │ └── ic_launcher_foreground.png │ │ └── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_round.png │ │ └── ic_launcher_foreground.png ├── .gitignore ├── src │ ├── main.rs │ ├── error.rs │ ├── performance_tests.rs │ ├── claude_detector.rs │ ├── types.rs │ ├── validation.rs │ ├── i18n_service.rs │ ├── settings_service.rs │ ├── lib.rs │ └── app.rs ├── capabilities │ └── default.json ├── Cargo.toml └── tauri.conf.json ├── images ├── edit.png ├── cccs.jpeg ├── settings.png └── traymenu.png ├── .gitignore ├── package.json ├── .agent.md ├── doc ├── settings.md └── cccs.md ├── .vscode └── extensions.json ├── LICENSE ├── vite.config.js ├── public └── vite.svg ├── .github └── workflows │ └── build.yml ├── .kiro └── specs │ ├── enhanced-settings-page │ ├── requirements.md │ ├── tasks.md │ └── design.md │ └── claude-config-switcher │ ├── requirements.md │ ├── tasks.md │ └── design.md ├── README.CN.md ├── README.md ├── index_old.html ├── CLAUDE.md └── index.html /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /images/edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/images/edit.png -------------------------------------------------------------------------------- /images/cccs.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/images/cccs.jpeg -------------------------------------------------------------------------------- /images/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/images/settings.png -------------------------------------------------------------------------------- /images/traymenu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/images/traymenu.png -------------------------------------------------------------------------------- /src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /src-tauri/icons/64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/64x64.png -------------------------------------------------------------------------------- /src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | /gen/schemas 5 | 6 | -------------------------------------------------------------------------------- /src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/ios/AppIcon-512@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/tray/tray-icon-xl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/tray/tray-icon-xl.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-20x20@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/ios/AppIcon-20x20@1x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-20x20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/ios/AppIcon-20x20@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-20x20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/ios/AppIcon-20x20@3x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-29x29@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/ios/AppIcon-29x29@1x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-29x29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/ios/AppIcon-29x29@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-29x29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/ios/AppIcon-29x29@3x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-40x40@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/ios/AppIcon-40x40@1x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-40x40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/ios/AppIcon-40x40@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-40x40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/ios/AppIcon-40x40@3x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-60x60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/ios/AppIcon-60x60@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-60x60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/ios/AppIcon-60x60@3x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-76x76@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/ios/AppIcon-76x76@1x.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-76x76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/ios/AppIcon-76x76@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/tray/tray-icon-hq-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/tray/tray-icon-hq-16.png -------------------------------------------------------------------------------- /src-tauri/icons/tray/tray-icon-large.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/tray/tray-icon-large.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-20x20@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/ios/AppIcon-20x20@2x-1.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-29x29@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/ios/AppIcon-29x29@2x-1.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-40x40@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/ios/AppIcon-40x40@2x-1.png -------------------------------------------------------------------------------- /src-tauri/icons/tray/tray-icon-clean-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/tray/tray-icon-clean-16.png -------------------------------------------------------------------------------- /src-tauri/icons/tray/tray-icon-large-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/tray/tray-icon-large-32.png -------------------------------------------------------------------------------- /src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/breakstring/cccs/HEAD/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | // Prevents additional console window on Windows in release, DO NOT REMOVE!! 2 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 3 | 4 | fn main() { 5 | app_lib::run(); 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | .claude/settings.local.json 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cccs", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview", 10 | "tauri": "tauri", 11 | "tauri:dev": "tauri dev", 12 | "tauri:build": "tauri build" 13 | }, 14 | "devDependencies": { 15 | "@tauri-apps/cli": "^2.7.1", 16 | "vite": "^7.0.4" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.agent.md: -------------------------------------------------------------------------------- 1 | # Workspace notes 2 | 3 | - 始终使用中文和用户沟通 4 | 5 | ## 项目文件结构 (重要!) 6 | 7 | 根据 CLAUDE.md 的说明,这是一个标准的 Tauri+Vite 项目: 8 | 9 | ### 前端文件位置 (需要修改的文件): 10 | - `index.html` - HTML结构和布局 (在根目录) 11 | - `src/main.js` - JavaScript功能和逻辑 (ES6模块) 12 | - `src/style.css` - 所有样式修改 13 | 14 | ### 后端文件位置: 15 | - `src-tauri/src/` - Rust源代码 16 | - `src-tauri/tauri.conf.json` - Tauri配置 17 | 18 | ### 重要提醒: 19 | - 前端入口是 `src/main.js` (不是 `src/main.ts`) 20 | - 样式文件是 `src/style.css` 21 | - HTML文件在根目录的 `index.html` 22 | - 静态资源在 `public/` 目录 23 | -------------------------------------------------------------------------------- /doc/settings.md: -------------------------------------------------------------------------------- 1 | # 增强设置页面 2 | 更改设置页面布局,增强其功能。 3 | 4 | # 方式 5 | - 更改布局为左侧导航列表,右侧为设置的相关内容操作界面 6 | - 左侧的导航列表自上往下分别排列:Current,以及各个 Profile。 7 | - 默认选中 Current,右侧主要区域通过一个多行的文本编辑区域显示当前的 settings.json 的内容。 8 | - 点击不同的 Profile,右侧加载显示不同的 Profile 对应的设置文件的内容,用户同样可以编辑。 9 | - 编辑区域下方有两个按钮,一个是“保存”,一个是“另存为...” 10 | - 点击保存,保存当前修改的文件(例如默认的 settings.json,或者 xxx.settings.json) 11 | - 点击另存为,弹出提示框让用户输入一个新的“Profile”名字,用户输入后确认然后保存。这个提示框里要提示用户 Profile名字不能有特殊字符,以及它最后保存的名字。 12 | - 不管点击“保存”还是点击“另存为...”的按钮里的保存动作,都需要校验内容是否为合规的 JSON 格式(其他内容校验先不用做,代码你预留这样的机制,回头我们再细说其他的校验逻辑)。当然,保存成功或者失败要有一些 UI 上的提示变化。 13 | - 左侧导航列表的最底端(即整个页面的下对齐那里)放置一个 About,点击它的话右侧主要内容区域显示现在的设置页面的内容。 14 | 15 | -------------------------------------------------------------------------------- /src-tauri/capabilities/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../gen/schemas/desktop-schema.json", 3 | "identifier": "default", 4 | "description": "enables the default permissions", 5 | "windows": [ 6 | "main", 7 | "settings" 8 | ], 9 | "permissions": [ 10 | "core:default", 11 | "core:window:default", 12 | "core:window:allow-close", 13 | "core:window:allow-minimize", 14 | "core:window:allow-maximize", 15 | "core:window:allow-set-size", 16 | "core:window:allow-inner-size", 17 | "core:webview:default", 18 | "fs:default", 19 | "dialog:default", 20 | "shell:default", 21 | "shell:allow-open" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "rust-lang.rust-analyzer", 4 | "tamasfe.even-better-toml", 5 | "serayuzgur.crates", 6 | "vadimcn.vscode-lldb", 7 | "ms-vscode.vscode-typescript-next", 8 | "esbenp.prettier-vscode", 9 | "dbaeumer.vscode-eslint", 10 | "bradlc.vscode-tailwindcss", 11 | "formulahendry.auto-rename-tag", 12 | "ms-vscode.vscode-json", 13 | "yzhang.markdown-all-in-one", 14 | "shd101wyy.markdown-preview-enhanced", 15 | "ms-vscode.vscode-css-peek", 16 | "christian-kohler.path-intellisense", 17 | "ms-vscode.vscode-typescript-next", 18 | "ms-vscode.vscode-js-debug", 19 | "usernamehw.errorlens", 20 | "gruntfuggly.todo-tree", 21 | "streetsidesoftware.code-spell-checker" 22 | ] 23 | } -------------------------------------------------------------------------------- /src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cccs" 3 | version = "1.0.8" 4 | description = "Claude Code Configuration Switcher" 5 | authors = ["KZAILab"] 6 | license = "MIT" 7 | repository = "https://github.com/breakstring/cccs" 8 | edition = "2021" 9 | rust-version = "1.77.2" 10 | 11 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 12 | 13 | [lib] 14 | name = "app_lib" 15 | crate-type = ["staticlib", "cdylib", "rlib"] 16 | 17 | [build-dependencies] 18 | tauri-build = { version = "2.3.1", features = [] } 19 | 20 | [dependencies] 21 | serde_json = "1.0" 22 | serde = { version = "1.0", features = ["derive"] } 23 | log = "0.4" 24 | tokio = { version = "1.0", features = ["time", "rt-multi-thread"] } 25 | crc32fast = "1.4" 26 | thiserror = "1.0" 27 | dirs = "5.0" 28 | image = "0.24" 29 | 30 | # Tauri dependencies 31 | tauri = { version = "2.7.0", features = ["tray-icon"] } 32 | tauri-plugin-log = "2" 33 | tauri-plugin-fs = "2" 34 | tauri-plugin-dialog = "2" 35 | tauri-plugin-shell = "2" 36 | 37 | [dev-dependencies] 38 | tempfile = "3.8" 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Kenn Zhang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src-tauri/src/error.rs: -------------------------------------------------------------------------------- 1 | // CCCS Error handling 2 | use thiserror::Error; 3 | 4 | #[derive(Error, Debug)] 5 | pub enum AppError { 6 | #[error("Claude Code installation not found")] 7 | ClaudeNotFound, 8 | 9 | #[error("Configuration file error: {0}")] 10 | ConfigError(String), 11 | 12 | #[error("Tray operation failed: {0}")] 13 | TrayError(String), 14 | 15 | #[error("File system error: {0}")] 16 | FileSystemError(String), 17 | 18 | #[error("Permission denied: {0}")] 19 | PermissionError(String), 20 | 21 | #[error("Settings error: {0}")] 22 | SettingsError(String), 23 | 24 | #[error("Monitor service error: {0}")] 25 | MonitorError(String), 26 | 27 | #[error("I18n error: {0}")] 28 | I18nError(String), 29 | 30 | #[error("IO error: {0}")] 31 | IoError(#[from] std::io::Error), 32 | 33 | #[error("JSON error: {0}")] 34 | JsonError(#[from] serde_json::Error), 35 | 36 | #[error("Tauri error: {0}")] 37 | TauriError(#[from] tauri::Error), 38 | } 39 | 40 | // Convenience type alias 41 | pub type AppResult = Result; -------------------------------------------------------------------------------- /src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", 3 | "productName": "CCCS", 4 | "version": "1.0.8", 5 | "identifier": "com.kzailab.cccs", 6 | "build": { 7 | "frontendDist": "../dist", 8 | "devUrl": "http://localhost:5173", 9 | "beforeDevCommand": "npm run dev", 10 | "beforeBuildCommand": "npm run build" 11 | }, 12 | "app": { 13 | "windows": [ 14 | { 15 | "title": "CCCS - Claude Code Configuration Switcher", 16 | "width": 700, 17 | "height": 800, 18 | "minWidth": 500, 19 | "minHeight": 600, 20 | "resizable": true, 21 | "fullscreen": false, 22 | "visible": false 23 | } 24 | ], 25 | "security": { 26 | "csp": null 27 | }, 28 | "withGlobalTauri": true 29 | }, 30 | "plugins": { 31 | "shell": { 32 | "open": true 33 | } 34 | }, 35 | "bundle": { 36 | "active": true, 37 | "targets": "all", 38 | "icon": [ 39 | "icons/32x32.png", 40 | "icons/128x128.png", 41 | "icons/128x128@2x.png", 42 | "icons/icon.icns", 43 | "icons/icon.ico" 44 | ] 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | 3 | const host = process.env.TAURI_DEV_HOST; 4 | 5 | export default defineConfig({ 6 | // prevent vite from obscuring rust errors 7 | clearScreen: false, 8 | server: { 9 | // make sure this port matches the devUrl port in tauri.conf.json file 10 | port: 5173, 11 | // Tauri expects a fixed port, fail if that port is not available 12 | strictPort: true, 13 | // if the host Tauri is expecting is set, use it 14 | host: host || false, 15 | hmr: host 16 | ? { 17 | protocol: 'ws', 18 | host, 19 | port: 1421, 20 | } 21 | : undefined, 22 | watch: { 23 | // tell vite to ignore watching `src-tauri` 24 | ignored: ['**/src-tauri/**'], 25 | }, 26 | }, 27 | // Env variables starting with the item of `envPrefix` will be exposed in tauri's source code through `import.meta.env`. 28 | envPrefix: ['VITE_', 'TAURI_ENV_*'], 29 | build: { 30 | // Tauri uses Chromium on Windows and WebKit on macOS and Linux 31 | target: 32 | process.env.TAURI_ENV_PLATFORM == 'windows' 33 | ? 'chrome105' 34 | : 'safari13', 35 | // don't minify for debug builds 36 | minify: !process.env.TAURI_ENV_DEBUG ? 'esbuild' : false, 37 | // produce sourcemaps for debug builds 38 | sourcemap: !!process.env.TAURI_ENV_DEBUG, 39 | }, 40 | }); -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | workflow_dispatch: 8 | 9 | jobs: 10 | build: 11 | strategy: 12 | matrix: 13 | platform: [macos-latest, ubuntu-latest, windows-latest] 14 | 15 | runs-on: ${{ matrix.platform }} 16 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v4 20 | 21 | - name: Install dependencies (ubuntu only) 22 | if: matrix.platform == 'ubuntu-latest' 23 | run: | 24 | sudo apt-get update 25 | sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf 26 | 27 | - name: Rust setup 28 | uses: dtolnay/rust-toolchain@stable 29 | 30 | - name: Rust cache 31 | uses: swatinem/rust-cache@v2 32 | with: 33 | workspaces: './src-tauri -> target' 34 | 35 | - name: Node.js setup 36 | uses: actions/setup-node@v4 37 | with: 38 | node-version: lts/* 39 | cache: npm 40 | 41 | - name: Install frontend dependencies 42 | run: npm install 43 | 44 | - name: Build the app 45 | uses: tauri-apps/tauri-action@v0 46 | env: 47 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 48 | with: 49 | tagName: ${{ github.ref_name }} 50 | releaseName: 'CCCS ${{ github.ref_name }}' 51 | releaseBody: 'See the assets to download and install this version.' 52 | releaseDraft: false # 改为 false,自动发布 53 | prerelease: false -------------------------------------------------------------------------------- /doc/cccs.md: -------------------------------------------------------------------------------- 1 | # 目标 2 | 基于 Tauri, Vite, TS ,Rust 等技术栈开发一个跨平台的桌面应用程序,应用程序的名字为 CCCS,即“Claude Code Configuration Switcher”的含义。 3 | 4 | # CCCS 的作用 5 | 帮助用户方便快捷的切换 Claude Code 的配置文件。 6 | 7 | # CCCS 的使用方式 8 | - 用户启动应用的时候,应该首先检测系统中当前用户所在的主目录下是否存在 ".claude 这个目录", 如果没有那么应该询问用户是否当前没有安装 "Claude Code",用户要么在这个环节退出程序,要么在这个界面上有一个本地的文件选择器输入框,让用户可以选择当前的 Claude Code 的安装目录。 9 | - 例如,假设在 Mac 系统上,当前用户名叫 abc,那么应该存在一个“/Users/abc/.claude”的目录。这个目录也是后面我们用来切换配置的目录。 10 | - 检查该目录下是否存在 settings.json 文件,以及其他和该配置文件名类似但是多了一些“profilename.”前缀的文件,例如 11 | - settings.json ,这个文件必须存在,如果不存在,应该提示用户至少先运行一次 Claude Code ,并直接退出程序。 12 | - 可能存在的: abc.settings.json, hahaha.settings.json ,这种就是我们的一些其他 profile 的配置文件,例如这两个文件对应我们的 Profile就是 abc和 hahaha 13 | - 前述检查通过(即,存在 Claude Code 的目录以及一个默认的 settings.json 配置文件),则程序自动最小化到系统托盘图标。 14 | - 用户右键点击该应用的图标,下拉出菜单。菜单上依次有选项 15 | - 依次多个 Profile,例如上述的 abc 和 hahaha,分别各占一个菜单项。 16 | - 分割线 17 | - 设置 18 | - 退出 19 | 20 | ## 配置内容检测 21 | - 我们的应用应该定时的刷新循环检查一下默认的 settings.json 和各个 profile 的配置。 22 | - 当默认的 settings.json 和某一个 profile 里面的内容一致时,该 profile 的菜单项的右侧应该显示一个绿色对勾的 emoji 图标(✅),否则默认不显示任何图标。 23 | 24 | ## 弹出菜单上的点击 25 | 26 | ### profile 类 27 | - 当点击某一个 profile 的菜单项时,如果本身这个 profile 的内容和默认的配置的内容一致,则不做任何动作。 28 | - 否则,该 profile 最右侧的 emoji 先显示一个“❕”,然后将该 profile 的内容复制到默认的 settings.json 里面,然后将图标改为(✅)。 29 | - 当然,这时候如果有其他的 profile 菜单项本身是绿色对勾,那么需要判断是否把那个对勾去掉。例如,那个 profile 和当前的内容一样(有可能有两个 profile 内容一致),那就有可能出现了两个绿色对勾的 profile 了,这是允许的。如果别的 profile 都和默认的设置不一致,那么别的那些 profile 后面应该没有图标才对。 30 | 31 | 32 | ### 设置 33 | 打开一个设置页面,上半部分你可以先根据这个应用的说明简单的写几句介绍性文字。 34 | 然后下方有个"关闭"按钮就行 35 | 36 | ### 退出 37 | 点击它,则退出当前程序。 38 | 39 | 40 | # 其他注意事项 41 | - 所有的 UI 上的文字要考虑中文和英文(以及其他语言的可能性,请采用适当的多语言处理方式来兼容,当前可以不用设置语言,通过检测系统当前 UI的语言来设定程序的语言就好。如不一致,默认用英文) 42 | - 检测匹配 Profile 和 settings.json 的内容一致性的时机,你需要帮我考虑是定时刷新检测好?还是弹出菜单时检测?还是说鼠标移动到我们的程序的托盘图标上的时候就可以检测? -------------------------------------------------------------------------------- /.kiro/specs/enhanced-settings-page/requirements.md: -------------------------------------------------------------------------------- 1 | # 需求文档 2 | 3 | ## 简介 4 | 5 | 增强设置页面功能,将现有的设置页面重新设计为具有左侧导航和右侧内容区域的布局。新的设计将支持多个Profile的管理,允许用户查看、编辑和保存不同的设置配置,并提供更好的用户体验和功能组织。 6 | 7 | ## 需求 8 | 9 | ### 需求 1:左侧导航布局 10 | 11 | **用户故事:** 作为用户,我希望在设置页面的左侧看到一个导航列表,这样我可以轻松地在不同的设置部分之间切换。 12 | 13 | #### 验收标准 14 | 15 | 1. 当用户打开设置页面时,系统应显示左侧导航列表和右侧内容区域的布局 16 | 2. 当系统加载设置页面时,左侧导航列表应自上而下显示 "Current" 以及所有可用的 Profile 17 | 3. 当用户首次打开页面时,系统应默认选中 "Current" 项 18 | 4. 当菜单项处于选中状态时,系统应提供明显的视觉区分(如背景色变化、边框高亮等)以增强用户体验 19 | 20 | ### 需求 2:Current设置管理 21 | 22 | **用户故事:** 作为用户,我希望能够查看和编辑当前的 settings.json 文件内容,这样我可以修改当前的应用设置。 23 | 24 | #### 验收标准 25 | 26 | 1. 当用户选择 "Current" 导航项时,系统应在右侧显示当前 settings.json 文件的内容 27 | 2. 当系统显示设置内容时,应使用多行文本编辑区域展示 JSON 内容 28 | 3. 当用户修改文本内容时,系统应允许实时编辑 29 | 4. 当显示 Current 设置时,右侧编辑区域顶部应明确标示 "Current" 以防止用户操作错误 30 | 31 | ### 需求 3:Profile设置管理 32 | 33 | **用户故事:** 作为用户,我希望能够查看和编辑不同的 Profile 设置文件,这样我可以管理多个设置配置。 34 | 35 | #### 验收标准 36 | 37 | 1. 当用户点击任意 Profile 导航项时,系统应在右侧加载并显示对应的设置文件内容 38 | 2. 当系统显示 Profile 内容时,应使用多行文本编辑区域展示 JSON 内容 39 | 3. 当用户修改 Profile 内容时,系统应允许实时编辑 40 | 4. 当显示特定 Profile 设置时,右侧编辑区域顶部应明确标示当前 Profile 的名称以防止用户操作错误 41 | 42 | ### 需求 4:保存功能 43 | 44 | **用户故事:** 作为用户,我希望能够保存我对设置文件的修改,这样我的更改可以持久化。 45 | 46 | #### 验收标准 47 | 48 | 1. 当用户点击"保存"按钮时,系统应将当前编辑的内容保存到对应的文件(settings.json 或 xxx.settings.json) 49 | 2. 当系统执行保存操作前,应验证内容是否为合规的 JSON 格式 50 | 3. 如果 JSON 格式无效,那么系统应显示错误提示并阻止保存 51 | 4. 当保存成功时,系统应显示成功的 UI 提示 52 | 5. 当保存失败时,系统应显示失败的 UI 提示 53 | 54 | ### 需求 5:另存为功能 55 | 56 | **用户故事:** 作为用户,我希望能够将当前编辑的设置另存为新的 Profile,这样我可以创建和管理多个设置配置。 57 | 58 | #### 验收标准 59 | 60 | 1. 当用户点击"另存为..."按钮时,系统应弹出提示框让用户输入新的 Profile 名字 61 | 2. 当显示提示框时,系统应提示用户 Profile 名字不能包含特殊字符 62 | 3. 当显示提示框时,系统应显示最终保存的文件名格式预览 63 | 4. 当用户输入 Profile 名字并确认时,系统应验证内容是否为合规的 JSON 格式 64 | 5. 如果 JSON 格式无效,那么系统应显示错误提示并阻止保存 65 | 6. 当另存为成功时,系统应显示成功的 UI 提示并更新左侧导航列表 66 | 7. 当另存为失败时,系统应显示失败的 UI 提示 67 | 68 | ### 需求 6:About页面集成 69 | 70 | **用户故事:** 作为用户,我希望能够访问关于信息,这样我可以了解应用的相关信息。 71 | 72 | #### 验收标准 73 | 74 | 1. 当系统显示左侧导航列表时,应在最底端(页面下对齐位置)放置一个 "About" 项 75 | 2. 当用户点击 "About" 项时,系统应在右侧内容区域显示原有设置页面的内容 76 | 77 | ### 需求 7:JSON内容验证框架 78 | 79 | **用户故事:** 作为开发者,我希望系统具有可扩展的内容验证机制,这样未来可以方便地添加更多的验证逻辑。 80 | 81 | #### 验收标准 82 | 83 | 1. 当系统设计验证机制时,应预留扩展点以支持未来添加其他验证逻辑 84 | 2. 当前阶段仅需实现 JSON 格式验证,但架构应支持添加更多验证规则 -------------------------------------------------------------------------------- /.kiro/specs/claude-config-switcher/requirements.md: -------------------------------------------------------------------------------- 1 | # 需求文档 2 | 3 | ## 介绍 4 | 5 | CCCS(Claude Code Configuration Switcher)是一个基于Tauri、Vite、TypeScript和Rust技术栈开发的跨平台桌面应用程序。该应用程序帮助用户方便快捷地切换Claude Code的配置文件,通过系统托盘图标提供配置文件管理功能。 6 | 7 | ## 需求 8 | 9 | ### 需求 1:应用启动时检测Claude Code安装 10 | 11 | **用户故事:** 作为用户,我希望应用启动时能自动检测Claude Code的安装状态,以便确保应用能正常工作 12 | 13 | #### 验收标准 14 | 15 | 1. 当应用启动时,系统应该检查当前用户主目录下是否存在".claude"目录 16 | 2. 如果".claude"目录不存在,系统应该询问用户是否未安装Claude Code 17 | 3. 如果用户确认未安装,系统应该提供文件选择器让用户选择Claude Code安装目录 18 | 4. 如果用户选择退出,系统应该正常关闭应用程序 19 | 20 | ### 需求 2:配置文件验证 21 | 22 | **用户故事:** 作为用户,我希望应用能验证Claude Code配置文件的存在,以确保配置切换功能可用 23 | 24 | #### 验收标准 25 | 26 | 1. 当".claude"目录存在时,系统应该检查该目录下是否存在settings.json文件 27 | 2. 如果settings.json文件不存在,系统应该提示用户至少先运行一次Claude Code 28 | 3. 如果settings.json文件不存在,系统应该直接退出程序 29 | 4. 系统应该扫描目录中所有带"profilename."前缀的settings.json文件 30 | 31 | ### 需求 3:系统托盘集成 32 | 33 | **用户故事:** 作为用户,我希望应用能最小化到系统托盘,以便不占用桌面空间但仍可快速访问 34 | 35 | #### 验收标准 36 | 37 | 1. 当配置文件验证通过后,应用应该自动最小化到系统托盘 38 | 2. 系统托盘中应该显示应用图标 39 | 3. 右键点击托盘图标时,应该弹出上下文菜单 40 | 4. 应用应该在系统托盘中保持运行状态 41 | 42 | ### 需求 4:配置文件菜单显示 43 | 44 | **用户故事:** 作为用户,我希望能在托盘菜单中看到所有可用的配置文件,以便选择要切换到的配置 45 | 46 | #### 验收标准 47 | 48 | 1. 托盘菜单应该显示所有检测到的profile配置文件 49 | 2. 菜单应该包含分割线分隔不同类型的菜单项 50 | 3. 菜单应该包含"设置"选项 51 | 4. 菜单应该包含"退出"选项 52 | 5. 菜单项应该按逻辑顺序排列(profiles、分割线、设置、退出) 53 | 54 | ### 需求 5:配置文件状态检测 55 | 56 | **用户故事:** 作为用户,我希望能看到哪个配置文件当前处于活动状态,以便了解当前使用的配置 57 | 58 | #### 验收标准 59 | 60 | 1. 系统应该定时检查默认settings.json与各profile配置的内容一致性 61 | 2. 当profile配置与默认settings.json内容一致时,该profile菜单项右侧应该显示绿色对勾(✅) 62 | 3. 当profile配置与默认settings.json内容不一致时,该profile菜单项不应该显示任何图标 63 | 4. 系统应该支持多个profile同时显示绿色对勾(当它们内容一致时) 64 | 65 | ### 需求 6:配置文件切换功能 66 | 67 | **用户故事:** 作为用户,我希望能点击profile菜单项来切换到该配置,以便快速应用不同的Claude Code设置 68 | 69 | #### 验收标准 70 | 71 | 1. 当点击已激活的profile时(显示绿色对勾),系统不应该执行任何操作 72 | 2. 当点击未激活的profile时,系统应该先显示警告图标(❕) 73 | 3. 系统应该将选中profile的内容复制到默认settings.json文件 74 | 4. 复制完成后,系统应该将该profile的图标更改为绿色对勾(✅) 75 | 5. 系统应该更新其他profile的状态图标,移除不再匹配的绿色对勾 76 | 77 | ### 需求 7:设置页面 78 | 79 | **用户故事:** 作为用户,我希望能访问设置页面了解应用信息,以便更好地使用应用 80 | 81 | #### 验收标准 82 | 83 | 1. 点击"设置"菜单项时,系统应该打开设置页面 84 | 2. 设置页面上半部分应该显示应用的介绍性文字 85 | 3. 设置页面应该包含"关闭"按钮 86 | 4. 点击"关闭"按钮时,设置页面应该关闭 87 | 88 | ### 需求 8:应用退出功能 89 | 90 | **用户故事:** 作为用户,我希望能正常退出应用程序,以便完全关闭应用 91 | 92 | #### 验收标准 93 | 94 | 1. 点击"退出"菜单项时,应用程序应该完全关闭 95 | 2. 系统托盘图标应该消失 96 | 3. 所有应用相关的进程应该正常终止 97 | 98 | ### 需求 9:多语言支持 99 | 100 | **用户故事:** 作为用户,我希望应用能根据我的系统语言显示相应的界面文字,以便更好地理解和使用应用 101 | 102 | #### 验收标准 103 | 104 | 1. 应用应该检测系统当前的UI语言设置 105 | 2. 应用应该支持中文和英文界面 106 | 3. 当系统语言为中文时,应用界面应该显示中文文字 107 | 4. 当系统语言为英文或其他不支持的语言时,应用界面应该默认显示英文文字 108 | 5. 所有用户可见的文字都应该支持多语言切换 -------------------------------------------------------------------------------- /src-tauri/src/performance_tests.rs: -------------------------------------------------------------------------------- 1 | // Performance tests for CCCS components 2 | // This module is only available in test builds 3 | 4 | #[cfg(test)] 5 | pub mod tests { 6 | use crate::{AppResult, PerformanceTestConfig}; 7 | use std::time::{Duration, Instant}; 8 | use std::path::PathBuf; 9 | use std::fs; 10 | use tempfile::TempDir; 11 | 12 | pub struct PerformanceTestSuite { 13 | temp_dir: TempDir, 14 | test_files: Vec, 15 | } 16 | 17 | impl PerformanceTestSuite { 18 | pub fn new() -> AppResult { 19 | let temp_dir = TempDir::new() 20 | .map_err(|e| crate::AppError::FileSystemError(format!("Failed to create temp directory: {}", e)))?; 21 | 22 | Ok(Self { 23 | temp_dir, 24 | test_files: Vec::new(), 25 | }) 26 | } 27 | 28 | pub fn setup_test_files(&mut self, config: &PerformanceTestConfig) -> AppResult<()> { 29 | let test_content = "x".repeat(config.file_size_bytes); 30 | 31 | for i in 0..config.file_count { 32 | let file_path = self.temp_dir.path().join(format!("test_{}.settings.json", i)); 33 | let json_content = format!(r#"{{"test_file": {}, "content": "{}"}}"#, i, test_content); 34 | 35 | fs::write(&file_path, json_content) 36 | .map_err(|e| crate::AppError::FileSystemError(format!("Failed to create test file: {}", e)))?; 37 | 38 | self.test_files.push(file_path); 39 | } 40 | 41 | Ok(()) 42 | } 43 | 44 | pub fn get_test_dir(&self) -> &std::path::Path { 45 | self.temp_dir.path() 46 | } 47 | 48 | pub fn get_test_files(&self) -> &[PathBuf] { 49 | &self.test_files 50 | } 51 | } 52 | 53 | #[derive(Debug, Clone)] 54 | pub struct PerformanceTestResult { 55 | pub test_name: String, 56 | pub total_duration: Duration, 57 | pub operations_count: u64, 58 | pub success: bool, 59 | pub error_message: Option, 60 | } 61 | 62 | impl PerformanceTestResult { 63 | pub fn print_summary(&self) { 64 | println!("=== {} ===", self.test_name); 65 | println!("Success: {}", self.success); 66 | if let Some(ref error) = self.error_message { 67 | println!("Error: {}", error); 68 | } 69 | println!("Total Duration: {:?}", self.total_duration); 70 | println!("Operations: {}", self.operations_count); 71 | println!(); 72 | } 73 | } 74 | } -------------------------------------------------------------------------------- /.kiro/specs/enhanced-settings-page/tasks.md: -------------------------------------------------------------------------------- 1 | # 实现计划 2 | 3 | - [ ] 1. 扩展后端Tauri API支持Profile管理 4 | - 在ConfigService中添加获取所有Profile信息的方法 5 | - 实现读取指定Profile内容的API 6 | - 创建保存Profile内容的API方法 7 | - 添加创建新Profile的功能 8 | - 实现Profile名称验证逻辑 9 | - 为所有新增方法编写单元测试 10 | - _需求: 1.2, 2.1, 3.1, 4.1, 5.1_ 11 | 12 | - [ ] 2. 实现JSON内容验证框架 13 | - 创建基础的JSON格式验证器 14 | - 设计可扩展的验证接口以支持未来扩展 15 | - 实现验证错误详细信息的数据结构 16 | - 为验证框架编写单元测试覆盖各种JSON错误场景 17 | - _需求: 4.2, 4.3, 5.4, 5.5, 7.1, 7.2_ 18 | 19 | - [ ] 3. 重构设置页面HTML结构为左右分栏布局 20 | - 修改settings.html创建左侧导航容器和右侧内容容器 21 | - 添加Profile标题显示区域到右侧内容区顶部 22 | - 创建保存和另存为按钮的UI结构 23 | - 添加模态对话框的HTML结构用于另存为功能 24 | - _需求: 1.1, 2.4, 3.4, 5.1_ 25 | 26 | - [ ] 4. 实现左侧导航组件的核心功能 27 | - [ ] 4.1 创建NavigationPanel类管理导航状态和渲染 28 | - 实现Profile列表的动态加载和渲染 29 | - 创建导航项点击事件处理逻辑 30 | - 实现Current和Profile项的区分显示 31 | - _需求: 1.2, 1.3_ 32 | 33 | - [ ] 4.2 添加导航项选中状态的视觉效果 34 | - 实现选中项的明显视觉区分(背景色、边框等) 35 | - 创建选中状态切换的动画效果 36 | - 确保选中状态在页面刷新后保持 37 | - _需求: 1.4_ 38 | 39 | - [ ] 5. 开发右侧内容编辑区域的功能 40 | - [ ] 5.1 创建ContentEditor类管理编辑状态 41 | - 实现Profile内容的加载和显示逻辑 42 | - 创建文本编辑区域的变化监听 43 | - 实现编辑状态跟踪(是否有未保存更改) 44 | - _需求: 2.1, 2.2, 2.3, 3.1, 3.2, 3.3_ 45 | 46 | - [ ] 5.2 实现Profile标题显示组件 47 | - 创建ProfileHeader类显示当前编辑的Profile名称 48 | - 在Current和具体Profile名称之间正确切换显示 49 | - 确保标题显示清晰防止用户操作错误 50 | - _需求: 2.4, 3.4_ 51 | 52 | - [ ] 6. 实现保存功能的完整流程 53 | - 集成JSON验证到保存流程中 54 | - 创建保存成功和失败的UI提示机制 55 | - 实现保存按钮的点击处理逻辑 56 | - 添加保存过程的加载状态显示 57 | - 为保存功能编写集成测试 58 | - _需求: 4.1, 4.2, 4.3, 4.4, 4.5_ 59 | 60 | - [ ] 7. 开发另存为功能的模态对话框 61 | - [ ] 7.1 创建SaveAsModal类管理对话框状态 62 | - 实现模态对话框的显示和隐藏逻辑 63 | - 创建Profile名称输入和验证界面 64 | - 添加特殊字符限制的实时验证提示 65 | - _需求: 5.1, 5.2_ 66 | 67 | - [ ] 7.2 实现另存为功能的核心逻辑 68 | - 集成Profile名称验证和JSON内容验证 69 | - 创建新Profile后更新左侧导航列表 70 | - 实现另存为成功和失败的UI反馈 71 | - 添加文件名格式预览功能 72 | - _需求: 5.3, 5.4, 5.5, 5.6, 5.7_ 73 | 74 | - [ ] 8. 集成About页面到新的导航结构中 75 | - 在左侧导航底部添加About项目 76 | - 实现About项的点击处理显示原设置页面内容 77 | - 确保About项在页面底部正确对齐 78 | - _需求: 6.1, 6.2_ 79 | 80 | - [ ] 9. 扩展国际化支持新的界面元素 81 | - 为所有新增的UI文本添加国际化键值 82 | - 更新中英文翻译文件包含新的界面文本 83 | - 确保新组件正确使用国际化系统 84 | - 测试语言切换对新界面元素的影响 85 | 86 | - [ ] 10. 更新CSS样式实现响应式左右分栏布局 87 | - 创建左侧导航面板的样式和选中状态效果 88 | - 设计右侧编辑区域的布局和Profile标题样式 89 | - 实现模态对话框的样式和动画效果 90 | - 确保在不同屏幕尺寸下的响应式适配 91 | - 保持与现有设置页面风格的一致性 92 | 93 | - [ ] 11. 编写端到端集成测试验证完整工作流 94 | - 测试从Current到Profile的切换流程 95 | - 验证编辑、保存、另存为的完整功能链路 96 | - 测试错误场景的处理(无效JSON、文件权限等) 97 | - 确保所有UI交互按预期工作 98 | 99 | - [ ] 12. 优化性能和用户体验细节 100 | - 实现Profile内容的懒加载优化 101 | - 添加文件操作的加载指示器 102 | - 优化大JSON文件的编辑性能 103 | - 实现未保存更改时的切换确认提示 -------------------------------------------------------------------------------- /.kiro/specs/claude-config-switcher/tasks.md: -------------------------------------------------------------------------------- 1 | # CCCS 实现计划 2 | 3 | 基于需求和设计文档,将CCCS功能转换为一系列可执行的编码任务。每个任务都是增量式的,可独立测试,确保没有复杂度的大跳跃。 4 | 5 | ## 实现任务 6 | 7 | - [ ] 1. 建立项目基础结构和核心依赖 8 | - 配置Tauri 2.0项目基础结构,包含必要的Cargo.toml依赖和权限配置 9 | - 添加文件系统、托盘图标、国际化等插件依赖 10 | - 创建基本的main.rs入口点和lib.rs模块结构 11 | - _需求: 1.1, 2.1, 3.1_ 12 | 13 | - [ ] 2. 实现Claude Code安装检测核心逻辑 14 | - 创建`claude_detector.rs`模块,实现检测用户主目录下`.claude`目录的功能 15 | - 编写检测默认settings.json文件存在性的函数 16 | - 实现文件选择器对话框功能,让用户手动选择Claude目录 17 | - 编写单元测试验证检测逻辑的正确性 18 | - _需求: 1.1, 1.2, 1.3, 1.4, 2.1, 2.2, 2.3_ 19 | 20 | - [ ] 3. 开发配置文件扫描和管理模块 21 | - 创建`config_service.rs`,实现Profile结构体和配置文件扫描功能 22 | - 实现扫描`.claude`目录中所有`*.settings.json`文件的函数 23 | - 创建配置文件内容读取和CRC32校验功能 24 | - 编写配置文件比较逻辑,识别哪个profile与默认配置一致 25 | - 编写单元测试覆盖配置扫描和比较逻辑 26 | - _需求: 2.4, 5.1, 5.2, 5.3, 5.4_ 27 | 28 | - [ ] 4. 实现配置文件切换操作 29 | - 在`config_service.rs`中添加配置文件复制和切换功能 30 | - 实现将选定profile内容复制到默认settings.json的操作 31 | - 添加切换操作的原子性保证和错误恢复机制 32 | - 创建切换状态的验证逻辑 33 | - 编写集成测试验证完整的配置切换流程 34 | - _需求: 6.1, 6.2, 6.3, 6.4, 6.5_ 35 | 36 | - [ ] 5. 构建系统托盘图标和基础菜单 37 | - 创建`tray_service.rs`模块,使用Tauri Tray API创建系统托盘图标 38 | - 实现基础托盘菜单结构(profiles、分割线、设置、退出) 39 | - 添加托盘图标点击事件处理器框架 40 | - 实现基本的菜单项点击事件分发逻辑 41 | - 编写测试验证托盘菜单创建和事件处理 42 | - _需求: 3.1, 3.2, 3.3, 4.1, 4.2, 4.3, 4.4, 4.5_ 43 | 44 | - [ ] 6. 实现动态托盘菜单生成和状态显示 45 | - 在`tray_service.rs`中添加根据扫描到的profiles动态生成菜单项的功能 46 | - 实现菜单项状态图标逻辑(✅绿色对勾显示活跃配置) 47 | - 添加菜单更新机制,当配置状态变化时刷新菜单显示 48 | - 实现profile菜单项点击时的配置切换集成 49 | - 编写测试验证动态菜单生成和状态更新 50 | - _需求: 4.1, 5.2, 5.3, 6.1, 6.4, 6.5_ 51 | 52 | - [ ] 7. 开发文件监控服务 53 | - 创建`monitor_service.rs`,实现定时监控特定配置文件的服务 54 | - 实现FileMetadata结构体,跟踪文件修改时间和校验和 55 | - 添加高效的文件变化检测逻辑(先检查修改时间,再检查内容) 56 | - 实现监控间隔的可配置性和定时器管理 57 | - 编写单元测试验证监控逻辑和性能优化 58 | - _需求: 5.1, 5.2, 5.3, 5.4_ 59 | 60 | - [ ] 8. 实现用户设置管理和持久化 61 | - 创建`settings_service.rs`和UserSettings结构体 62 | - 实现用户设置的JSON序列化和文件持久化 63 | - 添加设置验证逻辑(监控间隔1-60分钟范围检查) 64 | - 实现设置变更时的监控服务更新机制 65 | - 编写测试验证设置存储和加载功能 66 | - _需求: 7.1, 7.2, 7.3, 7.4_ 67 | 68 | - [ ] 9. 构建设置页面前端界面 69 | - 创建`settings.html`和相关CSS,实现设置页面布局 70 | - 添加监控间隔数字输入框和上下箭头控制 71 | - 实现复选框控制自动监控和通知选项 72 | - 添加语言选择下拉菜单和当前状态信息显示 73 | - 创建设置页面的TypeScript事件处理逻辑 74 | - _需求: 7.1, 7.2, 9.1, 9.2, 9.3, 9.4_ 75 | 76 | - [ ] 10. 实现设置页面后端集成 77 | - 在`settings_service.rs`中添加Tauri命令处理设置页面请求 78 | - 实现设置窗口创建、显示和焦点管理 79 | - 添加前后端通信逻辑,处理设置变更事件 80 | - 实现设置变更后立即应用到监控服务的功能 81 | - 编写集成测试验证设置页面的完整交互流程 82 | - _需求: 7.1, 7.2, 7.3, 7.4_ 83 | 84 | - [ ] 11. 开发多语言支持系统 85 | - 创建`i18n_service.rs`,实现系统语言检测功能 86 | - 添加中英文文本资源文件和加载逻辑 87 | - 实现文本键值获取函数和缺失翻译的回退机制 88 | - 集成多语言支持到托盘菜单和设置页面 89 | - 编写测试验证语言检测和文本资源加载 90 | - _需求: 9.1, 9.2, 9.3, 9.4, 9.5_ 91 | 92 | - [ ] 12. 实现应用程序生命周期管理 93 | - 创建主应用程序结构体,集成所有服务模块 94 | - 实现应用程序启动流程,包括依赖初始化和错误处理 95 | - 添加优雅的应用程序关闭机制,清理资源和停止定时器 96 | - 实现错误恢复机制和异常状态处理 97 | - 编写集成测试验证完整的应用程序生命周期 98 | - _需求: 1.1, 1.4, 8.1, 8.2, 8.3_ 99 | 100 | - [ ] 13. 集成所有组件并实现端到端功能 101 | - 将所有服务模块整合到主应用程序中 102 | - 实现组件间的事件通信和状态同步 103 | - 添加跨组件的错误处理和状态一致性保证 104 | - 确保监控服务变更能正确更新托盘菜单状态 105 | - 编写端到端测试覆盖主要用户场景和错误情况 106 | - _需求: 覆盖所有需求的集成验证_ 107 | 108 | - [ ] 14. 实现权限配置和安全设置 109 | - 配置Tauri权限系统,限制文件系统访问范围 110 | - 添加配置文件路径验证,防止目录遍历攻击 111 | - 实现安全的错误消息处理,避免敏感信息泄露 112 | - 配置应用程序以最小权限原则运行 113 | - 编写安全测试验证权限限制和安全措施 114 | - _需求: 所有涉及文件操作的需求的安全实现_ 115 | 116 | - [ ] 15. 优化性能和添加错误处理 117 | - 优化配置文件扫描性能,实现智能缓存机制 118 | - 添加全面的错误处理和用户友好的错误消息 119 | - 实现配置文件操作的原子性和数据完整性保证 120 | - 优化内存使用和监控服务的资源消耗 121 | - 编写性能测试和压力测试验证优化效果 122 | - _需求: 所有功能需求的性能和稳定性保证_ -------------------------------------------------------------------------------- /README.CN.md: -------------------------------------------------------------------------------- 1 | # CCCS - Claude Code Configuration Switcher 2 | 3 | [English](./README.md) | 中文文档 4 | 5 | 一个轻量级桌面应用程序,用于快速切换 Claude Code 配置文件。 6 | 7 | ## 功能特性 8 | 9 | - **快速配置切换**:通过系统托盘单击即可切换不同的 Claude Code 配置 10 | - **智能状态指示器**:可视化显示配置文件状态: 11 | - ✅ **完全匹配** - 配置与当前设置完全一致 12 | - 🔄 **部分匹配** - 除 model 字段外完全一致(Claude Code 自动更新) 13 | - ❌ **错误** - 读取或解析配置文件失败 14 | - **无图标** - 配置与当前设置不同 15 | - **自动检测**:自动检测 Claude Code 安装和配置文件 16 | - **实时监控**:监控配置变化并相应更新状态 17 | - **多语言支持**:支持中英文界面 18 | - **系统托盘集成**:后台运行,资源占用最小 19 | 20 | ## 安装 21 | 22 | ### 系统要求 23 | 24 | - 系统中必须已安装 Claude Code 25 | - 支持 macOS、Windows 或 Linux 26 | 27 | ### 下载 28 | 29 | 目前请下载源代码自行编译,预构建的二进制文件将在未来版本中提供。 30 | 31 | ```bash 32 | # 克隆仓库 33 | git clone https://github.com/breakstring/cccs.git 34 | cd cccs 35 | 36 | # 安装依赖 37 | npm install 38 | 39 | # 生产环境构建 40 | npm run tauri build 41 | ``` 42 | 43 | ## 使用说明 44 | 45 | ### 系统托盘菜单 46 | 47 | ![托盘菜单](./images/traymenu.png) 48 | 49 | *CCCS 系统托盘菜单显示不同的配置文件状态* 50 | 51 | ### 配置文件格式 52 | 53 | CCCS 自动扫描 Claude Code 目录中的配置文件(macOS/Linux 为 `~/.claude/`,Windows 为 `%USERPROFILE%\.claude\`)。 54 | 55 | **配置文件命名规范:** 56 | - 配置文件必须遵循格式:`{配置名称}.settings.json` 57 | - 示例: 58 | - `工作.settings.json` 59 | - `个人.settings.json` 60 | - `开发环境.settings.json` 61 | 62 | **文件位置:** 63 | - macOS/Linux:`~/.claude/` 64 | - Windows:`%USERPROFILE%\.claude\` 65 | 66 | **重要说明:** 67 | - 主要的 `settings.json` 文件是当前激活的配置 68 | - 配置文件应包含有效的 JSON 配置数据 69 | - CCCS 在比较配置时会智能忽略 `model` 字段,因为 Claude Code 会自动更新此字段 70 | 71 | ### 快速开始 72 | 73 | 1. **启动 CCCS**:应用程序将出现在系统托盘中 74 | 2. **创建配置文件**:复制当前的 `~/.claude/settings.json` 创建配置文件(如 `工作.settings.json`) 75 | 3. **切换配置**:右键点击托盘图标并选择所需配置 76 | 4. **监控状态**:将鼠标悬停在托盘图标上以刷新配置状态 77 | 78 | ### 配置编辑器 79 | 80 | ![配置编辑器](./images/edit.png) 81 | 82 | *CCCS 配置编辑器用于修改配置文件设置* 83 | 84 | CCCS 包含内置的配置编辑器,允许您直接在应用程序内修改配置文件设置: 85 | 86 | - **JSON 编辑器**:以 JSON 格式编辑配置文件,支持语法高亮 87 | - **实时验证**:自动验证 JSON 语法和结构 88 | - **配置文件管理**:创建、编辑和保存配置文件 89 | - **安全编辑**:所有更改在应用前都会进行验证 90 | 91 | ### 设置 92 | 93 | ![设置页面](./images/settings.png) 94 | 95 | *CCCS 设置页面及配置文件状态说明* 96 | 97 | 右键点击托盘图标并选择"设置"来访问配置: 98 | 99 | - **语言设置**:在中英文之间切换 100 | - **状态图标说明**:了解配置文件状态指示器的含义 101 | 102 | ## 关于此项目 103 | 104 | 本项目同时作为使用 Claude Code 进行 **Vibe Coding** 的示例展示。我们提供了原始提示词和使用 Kiro 的 SPECS 方法论开发过程中的产出,供参考: 105 | 106 | - **原始提示词**:位于 `./doc/` - 包含初始项目需求和开发提示词 107 | - **SPECS 开发过程**:位于 `./kiro/claude-config-switcher/` - 展示使用 Kiro 结构化方法的完整开发工作流程 108 | 109 | 如果您对 AI 辅助开发工作流程感兴趣,或想了解这个项目如何使用 Claude Code 从零开始构建,欢迎探索这些资源。 110 | 111 | ## 开发 112 | 113 | ### 开发环境要求 114 | 115 | - Node.js(v16 或更高版本) 116 | - Rust(最新稳定版) 117 | - Tauri CLI 118 | 119 | **注意**:本项目目前仅在 macOS 上进行了测试,因为开发者手头只有一台 Mac 笔记本。虽然 Tauri 框架理论上支持 Linux 和 Windows,但这些平台的用户可以自行摸索尝试兼容性。 120 | 121 | ### 从源码构建 122 | 123 | ```bash 124 | # 克隆仓库 125 | git clone https://github.com/breakstring/cccs.git 126 | cd cccs 127 | 128 | # 安装依赖 129 | npm install 130 | 131 | # 开发模式运行 132 | npm run tauri dev 133 | 134 | # 生产环境构建 135 | npm run tauri build 136 | ``` 137 | 138 | ### 项目结构 139 | 140 | ``` 141 | cccs/ 142 | ├── src/ # 前端(TypeScript/Vite) 143 | ├── src-tauri/ # 后端(Rust/Tauri) 144 | ├── public/ # 静态资源 145 | └── dist/ # 构建后的前端资源 146 | ``` 147 | 148 | ## 贡献 149 | 150 | 1. Fork 本仓库 151 | 2. 创建功能分支 152 | 3. 提交你的更改 153 | 4. 推送到分支 154 | 5. 创建 Pull Request 155 | 156 | ## 许可证 157 | 158 | 本项目基于 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情。 159 | 160 | ## 开发路线图 161 | 162 | - [x] 配置文件修改器 163 | - [x] 修改默认图标 164 | - [ ] 配置文件的 JSON Schema 校验 165 | 166 | ## 支持 167 | 168 | 如果您遇到任何问题或有疑问: 169 | 170 | 1. 查看 [Issues](https://github.com/breakstring/cccs/issues) 页面 171 | 2. 如果您的问题尚未报告,请创建新的 issue 172 | 3. 提供关于您的系统和问题的详细信息 173 | 174 | --- 175 | 176 | 由 [KZAI Lab](https://github.com/breakstring) 用 ❤️ 制作 -------------------------------------------------------------------------------- /src-tauri/src/claude_detector.rs: -------------------------------------------------------------------------------- 1 | // Claude Code installation detection 2 | use crate::{AppError, AppResult}; 3 | use std::path::{Path, PathBuf}; 4 | use tauri::AppHandle; 5 | 6 | pub struct ClaudeDetector; 7 | 8 | impl ClaudeDetector { 9 | /// Detect Claude Code installation in the user's home directory 10 | pub fn detect_claude_installation() -> AppResult { 11 | let home_dir = dirs::home_dir() 12 | .ok_or_else(|| AppError::ConfigError("Unable to find home directory".to_string()))?; 13 | 14 | let claude_dir = home_dir.join(".claude"); 15 | 16 | if claude_dir.exists() && claude_dir.is_dir() { 17 | log::info!("Found Claude directory at: {:?}", claude_dir); 18 | Ok(claude_dir) 19 | } else { 20 | log::warn!("Claude directory not found at: {:?}", claude_dir); 21 | Err(AppError::ClaudeNotFound) 22 | } 23 | } 24 | 25 | /// Validate that the default settings.json file exists 26 | pub fn validate_default_config(claude_dir: &Path) -> AppResult<()> { 27 | let settings_file = claude_dir.join("settings.json"); 28 | 29 | if settings_file.exists() && settings_file.is_file() { 30 | log::info!("Found default settings.json at: {:?}", settings_file); 31 | Ok(()) 32 | } else { 33 | log::error!("Default settings.json not found at: {:?}", settings_file); 34 | Err(AppError::ConfigError( 35 | "Default settings.json file not found. Please run Claude Code at least once.".to_string() 36 | )) 37 | } 38 | } 39 | 40 | /// Show file picker dialog for manual Claude directory selection 41 | pub async fn show_directory_picker(app: &AppHandle) -> AppResult> { 42 | use tauri_plugin_dialog::{DialogExt, MessageDialogKind}; 43 | 44 | // First show a confirmation dialog 45 | let choice = app.dialog() 46 | .message("Claude Code installation not found. Would you like to manually select the Claude directory?") 47 | .kind(MessageDialogKind::Info) 48 | .blocking_show(); 49 | 50 | if choice { 51 | // Show directory picker 52 | if let Some(folder_path) = app.dialog() 53 | .file() 54 | .set_title("Select Claude Code Directory") 55 | .blocking_pick_folder() 56 | { 57 | let claude_path = folder_path.as_path().unwrap(); 58 | log::info!("User selected Claude directory: {:?}", claude_path); 59 | 60 | // Validate the selected directory 61 | Self::validate_default_config(claude_path)?; 62 | Ok(Some(claude_path.to_path_buf())) 63 | } else { 64 | log::info!("User cancelled directory selection"); 65 | Ok(None) 66 | } 67 | } else { 68 | log::info!("User chose not to select directory manually"); 69 | Ok(None) 70 | } 71 | } 72 | } 73 | 74 | #[cfg(test)] 75 | mod tests { 76 | use super::*; 77 | use std::fs; 78 | use tempfile::TempDir; 79 | 80 | #[test] 81 | fn test_validate_default_config_success() { 82 | let temp_dir = TempDir::new().unwrap(); 83 | let settings_file = temp_dir.path().join("settings.json"); 84 | fs::write(&settings_file, "{}").unwrap(); 85 | 86 | let result = ClaudeDetector::validate_default_config(temp_dir.path()); 87 | assert!(result.is_ok()); 88 | } 89 | 90 | #[test] 91 | fn test_validate_default_config_missing_file() { 92 | let temp_dir = TempDir::new().unwrap(); 93 | 94 | let result = ClaudeDetector::validate_default_config(temp_dir.path()); 95 | assert!(result.is_err()); 96 | 97 | if let Err(AppError::ConfigError(msg)) = result { 98 | assert!(msg.contains("settings.json file not found")); 99 | } else { 100 | panic!("Expected ConfigError"); 101 | } 102 | } 103 | } -------------------------------------------------------------------------------- /src-tauri/src/types.rs: -------------------------------------------------------------------------------- 1 | // CCCS Types definitions 2 | use serde::{Deserialize, Serialize}; 3 | use std::path::PathBuf; 4 | use std::time::SystemTime; 5 | 6 | #[derive(Debug, Clone, Serialize, Deserialize)] 7 | pub struct Profile { 8 | pub name: String, 9 | pub path: PathBuf, 10 | pub content: String, 11 | pub is_active: bool, 12 | } 13 | 14 | #[derive(Debug, Clone, Serialize, Deserialize)] 15 | pub struct ProfileInfo { 16 | pub id: String, 17 | pub display_name: String, 18 | pub file_path: String, 19 | pub is_default: bool, 20 | pub last_modified: SystemTime, 21 | pub file_size: u64, 22 | } 23 | 24 | #[derive(Debug, Clone, Serialize, Deserialize)] 25 | pub struct ValidationResult { 26 | pub is_valid: bool, 27 | pub errors: Vec, 28 | } 29 | 30 | #[derive(Debug, Clone, Serialize, Deserialize)] 31 | pub struct ValidationError { 32 | pub line: usize, 33 | pub column: usize, 34 | pub message: String, 35 | pub error_type: String, 36 | } 37 | 38 | #[derive(Debug, Clone)] 39 | pub struct FileMetadata { 40 | pub modified_time: SystemTime, 41 | pub checksum: u32, 42 | pub size: u64, 43 | } 44 | 45 | #[derive(Debug, Clone, Serialize, Deserialize)] 46 | pub struct UserSettings { 47 | pub monitor_interval_minutes: u64, 48 | pub auto_start_monitoring: bool, 49 | pub language: Option, 50 | pub show_notifications: bool, 51 | #[serde(default = "UserSettings::get_default_ignored_fields")] 52 | pub ignored_fields: Vec, // 配置比较时要忽略的字段列表 53 | } 54 | 55 | impl Default for UserSettings { 56 | fn default() -> Self { 57 | Self { 58 | monitor_interval_minutes: 5, 59 | auto_start_monitoring: true, 60 | language: None, 61 | show_notifications: true, 62 | ignored_fields: Self::get_default_ignored_fields(), 63 | } 64 | } 65 | } 66 | 67 | impl UserSettings { 68 | /// 获取默认的忽略字段列表 69 | pub fn get_default_ignored_fields() -> Vec { 70 | vec![ 71 | "model".to_string(), 72 | "feedbackSurveyState".to_string(), 73 | ] 74 | } 75 | 76 | /// 验证忽略字段列表的有效性 77 | pub fn validate_ignored_fields(fields: &[String]) -> Result<(), String> { 78 | for field in fields { 79 | let field = field.trim(); 80 | if field.is_empty() { 81 | return Err("字段名不能为空".to_string()); 82 | } 83 | 84 | // 检查字段名是否包含无效字符 85 | if field.contains(|c: char| c.is_whitespace() || "{}[]\"'\\".contains(c)) { 86 | return Err(format!("字段名 '{}' 包含无效字符", field)); 87 | } 88 | 89 | // 检查字段名长度 90 | if field.len() > 100 { 91 | return Err(format!("字段名 '{}' 过长(最大100字符)", field)); 92 | } 93 | } 94 | 95 | Ok(()) 96 | } 97 | 98 | /// 清理和标准化忽略字段列表 99 | pub fn normalize_ignored_fields(fields: Vec) -> Vec { 100 | fields 101 | .into_iter() 102 | .map(|field| field.trim().to_string()) 103 | .filter(|field| !field.is_empty()) 104 | .collect::>() // 去重 105 | .into_iter() 106 | .collect() 107 | } 108 | } 109 | 110 | #[derive(Debug, PartialEq)] 111 | pub enum ProfileStatus { 112 | FullMatch, // 完全匹配 ✅ 113 | PartialMatch, // 部分匹配(忽略model字段后匹配)🔄 114 | NoMatch, // 不匹配 ❌ 115 | Error(String), // 错误状态 116 | } 117 | 118 | #[derive(Debug)] 119 | pub struct ConfigFileChange { 120 | pub file_path: PathBuf, 121 | pub change_type: ChangeType, 122 | } 123 | 124 | #[derive(Debug)] 125 | pub enum ChangeType { 126 | Modified, 127 | Created, 128 | Deleted, 129 | } 130 | 131 | // Performance monitoring statistics 132 | #[derive(Debug, Clone, Serialize, Deserialize)] 133 | pub struct MonitoringStats { 134 | pub monitored_files_count: usize, 135 | pub cached_metadata_count: usize, 136 | pub current_error_count: u32, 137 | pub is_running: bool, 138 | pub interval_minutes: u64, 139 | pub cache_size_limit: usize, 140 | pub max_scan_errors: u32, 141 | } 142 | 143 | // Performance test configuration 144 | #[derive(Debug, Clone)] 145 | pub struct PerformanceTestConfig { 146 | pub test_duration_seconds: u64, 147 | pub file_count: usize, 148 | pub file_size_bytes: usize, 149 | pub modification_frequency_seconds: u64, 150 | } 151 | 152 | impl Default for PerformanceTestConfig { 153 | fn default() -> Self { 154 | Self { 155 | test_duration_seconds: 60, 156 | file_count: 10, 157 | file_size_bytes: 1024, 158 | modification_frequency_seconds: 5, 159 | } 160 | } 161 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CCCS - Claude Code Configuration Switcher 2 | 3 | [中文文档](./README.CN.md) | English 4 | 5 | A lightweight desktop application for quickly switching Claude Code configuration files. 6 | 7 | ## Features 8 | 9 | - **Quick Profile Switching**: Switch between different Claude Code configurations with a single click from the system tray 10 | - **Smart Status Indicators**: Visual indicators showing profile status: 11 | - ✅ **Full Match** - Configuration fully matches current settings 12 | - 🔄 **Partial Match** - Identical except model field (auto-updated by Claude Code) 13 | - ❌ **Error** - Failed to read or parse configuration file 14 | - **No Icon** - Configuration differs from current settings 15 | - **Automatic Detection**: Automatically detects Claude Code installation and configuration files 16 | - **Real-time Monitoring**: Monitors configuration changes and updates status accordingly 17 | - **Multi-language Support**: Supports English and Chinese interfaces 18 | - **System Tray Integration**: Runs in the background with minimal resource usage 19 | 20 | ## Installation 21 | 22 | ### Prerequisites 23 | 24 | - Claude Code must be installed on your system 25 | - macOS, Windows, or Linux 26 | 27 | ### Download 28 | 29 | Currently, please download the source code and build it yourself. Pre-built binaries will be available in future releases. 30 | 31 | ```bash 32 | # Clone the repository 33 | git clone https://github.com/breakstring/cccs.git 34 | cd cccs 35 | 36 | # Install dependencies 37 | npm install 38 | 39 | # Build for production 40 | npm run tauri build 41 | ``` 42 | 43 | ## Usage 44 | 45 | ### System Tray Menu 46 | 47 | ![Tray Menu](./images/traymenu.png) 48 | 49 | *CCCS system tray menu showing different profile statuses* 50 | 51 | ### Profile File Format 52 | 53 | CCCS automatically scans for configuration files in your Claude Code directory (`~/.claude/` on macOS/Linux, `%USERPROFILE%\.claude\` on Windows). 54 | 55 | **Profile File Naming Convention:** 56 | - Profile files must follow the pattern: `{ProfileName}.settings.json` 57 | - Examples: 58 | - `Work.settings.json` 59 | - `Personal.settings.json` 60 | - `Development.settings.json` 61 | 62 | **File Location:** 63 | - macOS/Linux: `~/.claude/` 64 | - Windows: `%USERPROFILE%\.claude\` 65 | 66 | **Important Notes:** 67 | - The main `settings.json` file is your current active configuration 68 | - Profile files should contain valid JSON configuration data 69 | - CCCS intelligently ignores the `model` field when comparing configurations, as Claude Code updates this automatically 70 | 71 | ### Getting Started 72 | 73 | 1. **Launch CCCS**: The application will appear in your system tray 74 | 2. **Create Profiles**: Copy your current `~/.claude/settings.json` to create profile files (e.g., `Work.settings.json`) 75 | 3. **Switch Profiles**: Right-click the tray icon and select your desired profile 76 | 4. **Monitor Status**: Hover over the tray icon to refresh profile status 77 | 78 | ### Configuration Editor 79 | 80 | ![Configuration Editor](./images/edit.png) 81 | 82 | *CCCS configuration editor for modifying profile settings* 83 | 84 | CCCS includes a built-in configuration editor that allows you to modify profile settings directly within the application: 85 | 86 | - **JSON Editor**: Edit configuration files in JSON format with syntax highlighting 87 | - **Real-time Validation**: Automatic validation of JSON syntax and structure 88 | - **Profile Management**: Create, edit, and save configuration profiles 89 | - **Safe Editing**: All changes are validated before being applied 90 | 91 | ### Settings 92 | 93 | ![Settings Page](./images/settings.png) 94 | 95 | *CCCS settings page with profile status explanations* 96 | 97 | Access settings by right-clicking the tray icon and selecting "Settings": 98 | 99 | - **Language**: Choose between English and Chinese 100 | - **Status Icons Guide**: Reference for understanding profile status indicators 101 | 102 | ## About This Project 103 | 104 | This project serves as a demonstration of **Vibe Coding** using Claude Code. We've included the original prompts and development artifacts produced using Kiro's SPECS methodology for reference: 105 | 106 | - **Original Prompts**: Located in `./doc/` - Contains the initial project requirements and development prompts 107 | - **SPECS Development Process**: Located in `./kiro/claude-config-switcher/` - Shows the complete development workflow using Kiro's structured approach 108 | 109 | If you're interested in learning about AI-assisted development workflows or want to see how this project was built from scratch using Claude Code, feel free to explore these resources. 110 | 111 | ## Development 112 | 113 | ### Prerequisites 114 | 115 | - Node.js (v16 or later) 116 | - Rust (latest stable) 117 | - Tauri CLI 118 | 119 | **Note**: This project has only been tested on macOS as the developer currently has access to only a Mac laptop. While Tauri framework theoretically supports Linux and Windows, users on these platforms are welcome to explore and test the compatibility themselves. 120 | 121 | ### Building from Source 122 | 123 | ```bash 124 | # Clone the repository 125 | git clone https://github.com/breakstring/cccs.git 126 | cd cccs 127 | 128 | # Install dependencies 129 | npm install 130 | 131 | # Run in development mode 132 | npm run tauri dev 133 | 134 | # Build for production 135 | npm run tauri build 136 | ``` 137 | 138 | ### Project Structure 139 | 140 | ``` 141 | cccs/ 142 | ├── src/ # Frontend (TypeScript/Vite) 143 | ├── src-tauri/ # Backend (Rust/Tauri) 144 | ├── public/ # Static assets 145 | └── dist/ # Built frontend assets 146 | ``` 147 | 148 | ## Contributing 149 | 150 | 1. Fork the repository 151 | 2. Create a feature branch 152 | 3. Commit your changes 153 | 4. Push to the branch 154 | 5. Create a Pull Request 155 | 156 | ## License 157 | 158 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 159 | 160 | ## Roadmap 161 | 162 | - [x] Profile Configuration Editor 163 | - [x] Custom Icon Support 164 | - [ ] JSON Schema Validation for Configuration Files 165 | 166 | ## Support 167 | 168 | If you encounter any issues or have questions: 169 | 170 | 1. Check the [Issues](https://github.com/breakstring/cccs/issues) page 171 | 2. Create a new issue if your problem isn't already reported 172 | 3. Provide detailed information about your system and the issue 173 | 174 | --- 175 | 176 | Made with ❤️ by [KZAI Lab](https://github.com/breakstring) -------------------------------------------------------------------------------- /.kiro/specs/enhanced-settings-page/design.md: -------------------------------------------------------------------------------- 1 | # 设计文档 2 | 3 | ## 概述 4 | 5 | 基于需求文档,我们将把现有的静态设置页面重新设计为一个动态的、支持多Profile管理的界面。新的设计采用左右分栏布局,左侧为导航菜单,右侧为内容编辑区域。该设计将充分利用现有的Tauri架构和文件系统API,提供直观的JSON编辑体验和强大的Profile管理功能。 6 | 7 | ## 架构 8 | 9 | ### 整体架构图 10 | 11 | ```mermaid 12 | graph TB 13 | A[Settings Page UI] --> B[Navigation Component] 14 | A --> C[Content Editor Component] 15 | A --> D[Modal Dialog Component] 16 | 17 | B --> E[Profile List Manager] 18 | C --> F[JSON Editor] 19 | C --> G[Profile Header] 20 | 21 | E --> H[Tauri File API] 22 | F --> I[JSON Validator] 23 | F --> H 24 | 25 | H --> J[File System] 26 | I --> K[Validation Framework] 27 | 28 | style A fill:#e1f5fe 29 | style B fill:#f3e5f5 30 | style C fill:#f3e5f5 31 | style E fill:#fff3e0 32 | style F fill:#fff3e0 33 | ``` 34 | 35 | ### 技术栈集成 36 | 37 | - **前端**: 继续使用纯HTML/CSS/JavaScript,与现有设置页面保持一致 38 | - **后端**: 利用现有的Tauri Rust服务,扩展文件操作功能 39 | - **文件操作**: 基于现有的 `ConfigService` 和 `SettingsService` 40 | - **国际化**: 扩展现有的i18n系统支持新的界面元素 41 | 42 | ## 组件和接口 43 | 44 | ### 前端组件结构 45 | 46 | #### 1. NavigationPanel 组件 47 | ```typescript 48 | interface NavigationItem { 49 | id: string; 50 | displayName: string; 51 | filePath: string; 52 | isActive: boolean; 53 | isDefault: boolean; // 用于标识Current 54 | } 55 | 56 | class NavigationPanel { 57 | private items: NavigationItem[]; 58 | private selectedItem: string | null; 59 | 60 | // 渲染导航列表 61 | render(): void 62 | 63 | // 处理导航项点击 64 | handleItemClick(itemId: string): void 65 | 66 | // 更新选中状态的视觉效果 67 | updateActiveState(itemId: string): void 68 | 69 | // 刷新Profile列表 70 | refreshProfileList(): Promise 71 | } 72 | ``` 73 | 74 | #### 2. ContentEditor 组件 75 | ```typescript 76 | interface EditorState { 77 | currentProfile: string | null; 78 | content: string; 79 | isModified: boolean; 80 | lastSaved: Date | null; 81 | } 82 | 83 | class ContentEditor { 84 | private state: EditorState; 85 | private textArea: HTMLTextAreaElement; 86 | private validator: JSONValidator; 87 | 88 | // 加载Profile内容 89 | loadProfile(profileId: string): Promise 90 | 91 | // 保存当前内容 92 | save(): Promise 93 | 94 | // 另存为新Profile 95 | saveAs(profileName: string): Promise 96 | 97 | // 内容变化处理 98 | handleContentChange(): void 99 | 100 | // 渲染编辑器界面 101 | render(): void 102 | } 103 | ``` 104 | 105 | #### 3. ProfileHeader 组件 106 | ```typescript 107 | class ProfileHeader { 108 | private currentProfile: string | null; 109 | 110 | // 更新标题显示 111 | updateTitle(profileName: string): void 112 | 113 | // 渲染头部区域 114 | render(): void 115 | } 116 | ``` 117 | 118 | #### 4. SaveAsModal 组件 119 | ```typescript 120 | interface SaveAsModalOptions { 121 | currentContent: string; 122 | onSave: (profileName: string) => Promise; 123 | onCancel: () => void; 124 | } 125 | 126 | class SaveAsModal { 127 | private options: SaveAsModalOptions; 128 | 129 | // 显示对话框 130 | show(): void 131 | 132 | // 隐藏对话框 133 | hide(): void 134 | 135 | // 验证Profile名称 136 | validateProfileName(name: string): ValidationResult 137 | 138 | // 处理保存操作 139 | handleSave(): Promise 140 | } 141 | ``` 142 | 143 | ### 后端API扩展 144 | 145 | 基于现有的Tauri command结构,需要扩展以下API: 146 | 147 | ```rust 148 | // 扩展现有的配置服务 149 | impl ConfigService { 150 | // 获取所有Profile信息(包括Current) 151 | pub async fn get_all_profiles(&self) -> AppResult> 152 | 153 | // 读取指定Profile的内容 154 | pub async fn read_profile_content(&self, profile_path: &str) -> AppResult 155 | 156 | // 保存Profile内容 157 | pub async fn save_profile_content(&self, profile_path: &str, content: &str) -> AppResult<()> 158 | 159 | // 创建新Profile 160 | pub async fn create_profile(&self, profile_name: &str, content: &str) -> AppResult 161 | 162 | // 验证Profile名称 163 | pub fn validate_profile_name(&self, name: &str) -> ValidationResult 164 | } 165 | 166 | // Tauri命令定义 167 | #[tauri::command] 168 | pub async fn get_profiles_list() -> Result, String> 169 | 170 | #[tauri::command] 171 | pub async fn load_profile_content(profile_path: String) -> Result 172 | 173 | #[tauri::command] 174 | pub async fn save_profile(profile_path: String, content: String) -> Result<(), String> 175 | 176 | #[tauri::command] 177 | pub async fn create_new_profile(profile_name: String, content: String) -> Result 178 | 179 | #[tauri::command] 180 | pub async fn validate_json_content(content: String) -> Result 181 | ``` 182 | 183 | ## 数据模型 184 | 185 | ### ProfileInfo 数据结构 186 | ```typescript 187 | interface ProfileInfo { 188 | id: string; // 唯一标识符 189 | displayName: string; // 显示名称 190 | filePath: string; // 文件路径 191 | isDefault: boolean; // 是否为Current (settings.json) 192 | lastModified: Date; // 最后修改时间 193 | fileSize: number; // 文件大小 194 | } 195 | ``` 196 | 197 | ### ValidationResult 数据结构 198 | ```typescript 199 | interface ValidationResult { 200 | isValid: boolean; 201 | errors: ValidationError[]; 202 | } 203 | 204 | interface ValidationError { 205 | line: number; 206 | column: number; 207 | message: string; 208 | type: 'syntax' | 'semantic'; 209 | } 210 | ``` 211 | 212 | ### UIState 数据结构 213 | ```typescript 214 | interface UIState { 215 | selectedProfile: string | null; 216 | profiles: ProfileInfo[]; 217 | isLoading: boolean; 218 | hasUnsavedChanges: boolean; 219 | showSaveAsModal: boolean; 220 | lastError: string | null; 221 | } 222 | ``` 223 | 224 | ## 错误处理 225 | 226 | ### 错误分类和处理策略 227 | 228 | #### 1. 文件系统错误 229 | - **读取失败**: 显示友好错误信息,提供重试选项 230 | - **写入失败**: 保存失败时保留用户输入,提供另存为选项 231 | - **权限错误**: 明确提示权限问题,建议解决方案 232 | 233 | #### 2. JSON验证错误 234 | - **语法错误**: 高亮错误位置,提供详细错误信息 235 | - **格式错误**: 友好的错误描述,建议修正方案 236 | 237 | #### 3. Profile管理错误 238 | - **重名检测**: 实时验证Profile名称唯一性 239 | - **特殊字符**: 清晰提示允许的字符范围 240 | - **文件路径**: 处理文件路径相关的边界情况 241 | 242 | ### 错误处理工作流 243 | 244 | ```mermaid 245 | graph TD 246 | A[用户操作] --> B{操作类型} 247 | B -->|保存| C[JSON验证] 248 | B -->|另存为| D[名称验证] 249 | B -->|加载| E[文件读取] 250 | 251 | C --> F{验证通过?} 252 | F -->|是| G[执行保存] 253 | F -->|否| H[显示验证错误] 254 | 255 | D --> I{名称有效?} 256 | I -->|是| J[执行创建] 257 | I -->|否| K[显示名称错误] 258 | 259 | E --> L{读取成功?} 260 | L -->|是| M[更新UI] 261 | L -->|否| N[显示读取错误] 262 | 263 | G --> O[成功提示] 264 | H --> P[保持编辑状态] 265 | J --> Q[更新Profile列表] 266 | K --> R[保持对话框] 267 | M --> S[切换Profile] 268 | N --> T[保持当前状态] 269 | ``` 270 | 271 | ## 测试策略 272 | 273 | ### 单元测试覆盖 274 | 275 | #### 前端组件测试 276 | 1. **NavigationPanel** 277 | - Profile列表渲染正确性 278 | - 选中状态切换逻辑 279 | - 事件处理正确性 280 | 281 | 2. **ContentEditor** 282 | - 内容加载和保存流程 283 | - JSON验证集成 284 | - 修改状态跟踪 285 | 286 | 3. **SaveAsModal** 287 | - 名称验证逻辑 288 | - 用户交互流程 289 | - 错误处理机制 290 | 291 | #### 后端API测试 292 | 1. **文件操作** 293 | - Profile读写功能 294 | - 错误场景处理 295 | - 文件系统边界情况 296 | 297 | 2. **验证框架** 298 | - JSON格式验证 299 | - Profile名称验证 300 | - 扩展验证规则支持 301 | 302 | ### 集成测试场景 303 | 304 | #### 端到端工作流测试 305 | 1. **基本Profile管理流程** 306 | - 加载Current设置 307 | - 编辑并保存修改 308 | - 创建新Profile 309 | - 在Profile间切换 310 | 311 | 2. **错误场景测试** 312 | - 无效JSON处理 313 | - 文件权限问题 314 | - 网络/文件系统异常 315 | 316 | 3. **边界条件测试** 317 | - 空Profile列表 318 | - 大文件处理 319 | - 特殊字符处理 320 | - 并发操作场景 321 | 322 | ### 性能测试考虑 323 | 324 | #### 关键性能指标 325 | 1. **UI响应性** 326 | - Profile切换延迟 < 200ms 327 | - 文本编辑响应延迟 < 50ms 328 | - 保存操作完成时间 < 1s 329 | 330 | 2. **内存使用** 331 | - 大JSON文件加载优化 332 | - 组件内存泄漏防护 333 | - 缓存策略有效性 334 | 335 | 3. **文件操作效率** 336 | - 批量Profile加载优化 337 | - 文件监控实现 338 | - 缓存失效策略 339 | 340 | ## 实现注意事项 341 | 342 | ### 用户体验优化 343 | 344 | #### 1. 视觉设计原则 345 | - **选中状态明显区分**: 使用背景色、边框或阴影增强选中反馈 346 | - **Profile标识清晰**: 右侧编辑器顶部使用明显的标识区分当前编辑的Profile 347 | - **状态反馈及时**: 保存、加载等操作提供即时的视觉反馈 348 | 349 | #### 2. 交互体验考虑 350 | - **防误操作**: 未保存更改时切换Profile需要确认 351 | - **键盘支持**: 支持常用快捷键(Ctrl+S保存等) 352 | - **加载状态**: 文件操作期间显示加载指示器 353 | 354 | ### 扩展性设计 355 | 356 | #### 1. 验证框架扩展 357 | - 使用策略模式支持多种验证规则 358 | - 预留验证插件接口 359 | - 支持异步验证流程 360 | 361 | #### 2. UI组件模块化 362 | - 组件间低耦合设计 363 | - 事件驱动的通信机制 364 | - 便于未来功能扩展 365 | 366 | ### 技术实现细节 367 | 368 | #### 1. 文件监控 369 | - 实现文件变更监听,自动刷新Profile列表 370 | - 处理外部修改冲突检测 371 | 372 | #### 2. 国际化集成 373 | - 扩展现有i18n系统支持新界面元素 374 | - 保持与现有设置页面的一致性 375 | 376 | #### 3. 性能优化 377 | - 实现Profile内容的懒加载 378 | - 使用虚拟滚动处理大量Profile 379 | - 优化JSON编辑器的渲染性能 -------------------------------------------------------------------------------- /index_old.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | CCCS - Settings 7 | 8 | 9 |
10 | 11 | 33 | 34 | 35 |
36 | 37 |
38 |

Current

39 |
40 | 41 | 42 |
43 | 44 |
45 |
46 |
Configuration Editor
47 |
Edit your configuration in JSON format
48 |
49 | 50 | 51 | 52 |
53 | 57 | 61 |
62 |
63 | 64 | 65 | 132 |
133 |
134 |
135 | 136 | 137 | 163 | 164 | 165 | 169 | 170 | 171 |
172 | 173 | 174 | 175 | 176 | -------------------------------------------------------------------------------- /src-tauri/src/validation.rs: -------------------------------------------------------------------------------- 1 | // JSON validation framework with extensible validation rules 2 | use crate::{AppResult, AppError, ValidationResult, ValidationError}; 3 | use serde_json; 4 | use std::collections::HashMap; 5 | 6 | pub trait Validator { 7 | fn validate(&self, content: &str) -> AppResult; 8 | fn get_validator_name(&self) -> &'static str; 9 | } 10 | 11 | pub struct JsonValidator { 12 | rules: Vec>, 13 | } 14 | 15 | pub trait ValidationRule: Send + Sync { 16 | fn validate(&self, json_value: &serde_json::Value) -> Vec; 17 | fn get_rule_name(&self) -> &'static str; 18 | } 19 | 20 | // JSON format validator 21 | pub struct JsonFormatValidator; 22 | 23 | impl Validator for JsonFormatValidator { 24 | fn validate(&self, content: &str) -> AppResult { 25 | let mut errors = Vec::new(); 26 | 27 | // Try to parse as JSON 28 | match serde_json::from_str::(content) { 29 | Ok(_) => { 30 | // JSON is valid 31 | } 32 | Err(e) => { 33 | errors.push(ValidationError { 34 | line: e.line(), 35 | column: e.column(), 36 | message: e.to_string(), 37 | error_type: "syntax".to_string(), 38 | }); 39 | } 40 | } 41 | 42 | Ok(ValidationResult { 43 | is_valid: errors.is_empty(), 44 | errors, 45 | }) 46 | } 47 | 48 | fn get_validator_name(&self) -> &'static str { 49 | "json_format" 50 | } 51 | } 52 | 53 | // Rule: Must be a JSON object 54 | pub struct ObjectRule; 55 | 56 | impl ValidationRule for ObjectRule { 57 | fn validate(&self, json_value: &serde_json::Value) -> Vec { 58 | let mut errors = Vec::new(); 59 | 60 | if !json_value.is_object() { 61 | errors.push(ValidationError { 62 | line: 1, 63 | column: 1, 64 | message: "Configuration must be a JSON object".to_string(), 65 | error_type: "semantic".to_string(), 66 | }); 67 | } 68 | 69 | errors 70 | } 71 | 72 | fn get_rule_name(&self) -> &'static str { 73 | "object_rule" 74 | } 75 | } 76 | 77 | // Rule: Check for required fields (extensible) 78 | pub struct RequiredFieldsRule { 79 | required_fields: Vec, 80 | } 81 | 82 | impl RequiredFieldsRule { 83 | pub fn new(fields: Vec) -> Self { 84 | Self { 85 | required_fields: fields, 86 | } 87 | } 88 | } 89 | 90 | impl ValidationRule for RequiredFieldsRule { 91 | fn validate(&self, json_value: &serde_json::Value) -> Vec { 92 | let mut errors = Vec::new(); 93 | 94 | if let Some(obj) = json_value.as_object() { 95 | for field in &self.required_fields { 96 | if !obj.contains_key(field) { 97 | errors.push(ValidationError { 98 | line: 1, 99 | column: 1, 100 | message: format!("Required field '{}' is missing", field), 101 | error_type: "semantic".to_string(), 102 | }); 103 | } 104 | } 105 | } 106 | 107 | errors 108 | } 109 | 110 | fn get_rule_name(&self) -> &'static str { 111 | "required_fields" 112 | } 113 | } 114 | 115 | // Rule: Check field types (extensible) 116 | pub struct FieldTypeRule { 117 | field_types: HashMap, 118 | } 119 | 120 | #[derive(Clone)] 121 | pub enum FieldType { 122 | String, 123 | Number, 124 | Boolean, 125 | Array, 126 | Object, 127 | } 128 | 129 | impl FieldTypeRule { 130 | pub fn new() -> Self { 131 | Self { 132 | field_types: HashMap::new(), 133 | } 134 | } 135 | 136 | pub fn add_field_type(mut self, field_name: String, field_type: FieldType) -> Self { 137 | self.field_types.insert(field_name, field_type); 138 | self 139 | } 140 | } 141 | 142 | impl ValidationRule for FieldTypeRule { 143 | fn validate(&self, json_value: &serde_json::Value) -> Vec { 144 | let mut errors = Vec::new(); 145 | 146 | if let Some(obj) = json_value.as_object() { 147 | for (field_name, expected_type) in &self.field_types { 148 | if let Some(field_value) = obj.get(field_name) { 149 | let type_matches = match expected_type { 150 | FieldType::String => field_value.is_string(), 151 | FieldType::Number => field_value.is_number(), 152 | FieldType::Boolean => field_value.is_boolean(), 153 | FieldType::Array => field_value.is_array(), 154 | FieldType::Object => field_value.is_object(), 155 | }; 156 | 157 | if !type_matches { 158 | errors.push(ValidationError { 159 | line: 1, 160 | column: 1, 161 | message: format!("Field '{}' has incorrect type", field_name), 162 | error_type: "semantic".to_string(), 163 | }); 164 | } 165 | } 166 | } 167 | } 168 | 169 | errors 170 | } 171 | 172 | fn get_rule_name(&self) -> &'static str { 173 | "field_type" 174 | } 175 | } 176 | 177 | impl JsonValidator { 178 | pub fn new() -> Self { 179 | Self { 180 | rules: Vec::new(), 181 | } 182 | } 183 | 184 | pub fn with_basic_rules() -> Self { 185 | let mut validator = Self::new(); 186 | validator.add_rule(Box::new(ObjectRule)); 187 | validator 188 | } 189 | 190 | pub fn add_rule(&mut self, rule: Box) { 191 | self.rules.push(rule); 192 | } 193 | 194 | pub fn validate(&self, content: &str) -> AppResult { 195 | let mut all_errors = Vec::new(); 196 | 197 | // First validate JSON format 198 | let format_validator = JsonFormatValidator; 199 | let format_result = format_validator.validate(content)?; 200 | all_errors.extend(format_result.errors); 201 | 202 | // If JSON format is invalid, don't run semantic rules 203 | if !format_result.is_valid { 204 | return Ok(ValidationResult { 205 | is_valid: false, 206 | errors: all_errors, 207 | }); 208 | } 209 | 210 | // Parse JSON for semantic validation 211 | let json_value = serde_json::from_str::(content) 212 | .map_err(|e| AppError::ConfigError(format!("Failed to parse JSON: {}", e)))?; 213 | 214 | // Run all semantic rules 215 | for rule in &self.rules { 216 | let rule_errors = rule.validate(&json_value); 217 | all_errors.extend(rule_errors); 218 | } 219 | 220 | Ok(ValidationResult { 221 | is_valid: all_errors.is_empty(), 222 | errors: all_errors, 223 | }) 224 | } 225 | 226 | pub fn get_rule_names(&self) -> Vec<&'static str> { 227 | self.rules.iter().map(|rule| rule.get_rule_name()).collect() 228 | } 229 | } 230 | 231 | impl Default for JsonValidator { 232 | fn default() -> Self { 233 | Self::with_basic_rules() 234 | } 235 | } 236 | 237 | #[cfg(test)] 238 | mod tests { 239 | use super::*; 240 | 241 | #[test] 242 | fn test_json_format_validator_valid() { 243 | let validator = JsonFormatValidator; 244 | let result = validator.validate(r#"{"key": "value"}"#).unwrap(); 245 | assert!(result.is_valid); 246 | assert!(result.errors.is_empty()); 247 | } 248 | 249 | #[test] 250 | fn test_json_format_validator_invalid() { 251 | let validator = JsonFormatValidator; 252 | let result = validator.validate(r#"{"key": "value""#).unwrap(); 253 | assert!(!result.is_valid); 254 | assert!(!result.errors.is_empty()); 255 | assert_eq!(result.errors[0].error_type, "syntax"); 256 | } 257 | 258 | #[test] 259 | fn test_object_rule_valid() { 260 | let rule = ObjectRule; 261 | let json = serde_json::json!({"key": "value"}); 262 | let errors = rule.validate(&json); 263 | assert!(errors.is_empty()); 264 | } 265 | 266 | #[test] 267 | fn test_object_rule_invalid() { 268 | let rule = ObjectRule; 269 | let json = serde_json::json!("not an object"); 270 | let errors = rule.validate(&json); 271 | assert!(!errors.is_empty()); 272 | assert_eq!(errors[0].error_type, "semantic"); 273 | } 274 | 275 | #[test] 276 | fn test_required_fields_rule() { 277 | let rule = RequiredFieldsRule::new(vec!["name".to_string(), "version".to_string()]); 278 | 279 | // Valid case 280 | let json = serde_json::json!({"name": "test", "version": "1.0"}); 281 | let errors = rule.validate(&json); 282 | assert!(errors.is_empty()); 283 | 284 | // Missing field case 285 | let json = serde_json::json!({"name": "test"}); 286 | let errors = rule.validate(&json); 287 | assert_eq!(errors.len(), 1); 288 | assert!(errors[0].message.contains("version")); 289 | } 290 | 291 | #[test] 292 | fn test_field_type_rule() { 293 | let rule = FieldTypeRule::new() 294 | .add_field_type("name".to_string(), FieldType::String) 295 | .add_field_type("count".to_string(), FieldType::Number); 296 | 297 | // Valid case 298 | let json = serde_json::json!({"name": "test", "count": 42}); 299 | let errors = rule.validate(&json); 300 | assert!(errors.is_empty()); 301 | 302 | // Invalid type case 303 | let json = serde_json::json!({"name": 123, "count": "not a number"}); 304 | let errors = rule.validate(&json); 305 | assert_eq!(errors.len(), 2); 306 | } 307 | 308 | #[test] 309 | fn test_json_validator_with_rules() { 310 | let mut validator = JsonValidator::new(); 311 | validator.add_rule(Box::new(ObjectRule)); 312 | validator.add_rule(Box::new(RequiredFieldsRule::new(vec!["theme".to_string()]))); 313 | 314 | // Valid case 315 | let result = validator.validate(r#"{"theme": "dark", "lang": "en"}"#).unwrap(); 316 | assert!(result.is_valid); 317 | 318 | // Invalid case - missing required field 319 | let result = validator.validate(r#"{"lang": "en"}"#).unwrap(); 320 | assert!(!result.is_valid); 321 | assert!(!result.errors.is_empty()); 322 | } 323 | 324 | #[test] 325 | fn test_json_validator_syntax_error() { 326 | let validator = JsonValidator::with_basic_rules(); 327 | let result = validator.validate(r#"{"invalid": json"#).unwrap(); 328 | assert!(!result.is_valid); 329 | assert!(!result.errors.is_empty()); 330 | assert_eq!(result.errors[0].error_type, "syntax"); 331 | } 332 | } -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | This is a Tauri application with a TypeScript/Vite frontend. The project combines a Rust backend (Tauri) with a web frontend built using Vite and TypeScript. 8 | 9 | **CCCS (Claude Code Configuration Switcher)** is a system tray application that allows users to quickly switch between different Claude Code configuration profiles. 10 | 11 | ## Architecture 12 | 13 | ### Frontend (src/) 14 | - **Entry point**: `src/main.ts` - Sets up the basic HTML structure and initializes the counter component 15 | - **Components**: `src/counter.ts` - Simple counter functionality with click handlers 16 | - **Styling**: `src/style.css` - Application styles 17 | - **Build tool**: Vite with TypeScript support 18 | 19 | ### Backend (src-tauri/) 20 | - **Entry point**: `src-tauri/src/main.rs` - Launches the Tauri application 21 | - **Core logic**: `src-tauri/src/lib.rs` - Contains the main Tauri application setup with logging configuration 22 | - **Build configuration**: `src-tauri/Cargo.toml` - Rust dependencies and build settings 23 | - **Tauri configuration**: `src-tauri/tauri.conf.json` - App window settings, build commands, and bundle configuration 24 | 25 | ## Tauri Backend Architecture 26 | 27 | ### Core Modules 28 | - **`app.rs`** - Main application state and initialization 29 | - **`claude_detector.rs`** - Detects Claude Code installation and configuration files 30 | - **`config_service.rs`** - Handles profile management and configuration file operations 31 | - **`tray_service.rs`** - System tray icon and menu management 32 | - **`monitor_service.rs`** - File monitoring for configuration changes 33 | - **`settings_service.rs`** - Application settings management 34 | - **`i18n_service.rs`** - Internationalization support (Chinese/English) 35 | - **`validation.rs`** - JSON configuration validation framework 36 | - **`types.rs`** - Common data structures and types 37 | - **`error.rs`** - Error handling definitions 38 | 39 | ### Tauri Commands (API) 40 | Available commands that can be called from frontend JavaScript: 41 | 42 | #### Profile Management 43 | - **`get_profiles_info()`** - Get summary information about profiles 44 | - **`get_profiles_list()`** - Get detailed list of all profiles 45 | - **`load_profile_content(profile_id: String)`** - Load content of a specific profile 46 | - **`save_profile(profile_id: String, content: String)`** - Save changes to a profile 47 | - **`create_new_profile(profile_name: String, content: String)`** - Create a new profile 48 | - **`validate_json_content(content: String)`** - Validate JSON configuration 49 | 50 | #### Field Exclusion Settings (Dynamic Ignored Fields) 51 | - **`get_ignored_fields()`** - Get current list of ignored fields for profile comparison 52 | - **`update_ignored_fields(fields: Vec)`** - Update the ignored fields list 53 | - **`get_default_ignored_fields()`** - Get default ignored fields (model, feedbackSurveyState) 54 | - **`reset_ignored_fields_to_default()`** - Reset ignored fields to default values 55 | 56 | #### Window Management 57 | - **`close_settings_window()`** - Close the settings window 58 | 59 | ### Permissions Configuration 60 | **Important**: `src-tauri/capabilities/default.json` defines Tauri permissions: 61 | - Window operations: close, minimize, maximize, set-size, inner-size 62 | - File system access for configuration files 63 | - Dialog access for save/load operations 64 | 65 | ## Development Commands 66 | 67 | ### Frontend Development 68 | - `npm run dev` - Start development server (runs Vite dev server on localhost:5173) 69 | - `npm run build` - Build frontend for production (TypeScript compilation + Vite build) 70 | - `npm run preview` - Preview production build 71 | 72 | ### Tauri Development 73 | - `npm run tauri:dev` or `cargo tauri dev` - Run full application in development mode 74 | - `npm run tauri:build` or `cargo tauri build` - Build production application 75 | - Development is handled through the Tauri configuration in `tauri.conf.json` 76 | - Frontend dev server runs on `http://localhost:5173` 77 | - Build process: `npm run build` creates the `dist` directory for Tauri 78 | 79 | ### Debugging and Testing 80 | 81 | #### Recommended Debugging Workflow (TabbyMCP + tmux) 82 | 83 | **IMPORTANT**: 使用以下推荐的调试流程避免阻塞主工作流程: 84 | 85 | ##### 1. 创建专用的 tmux 调试会话 86 | ```bash 87 | # 创建新的 tmux 会话用于调试 88 | tmux new-session -d -s cccs-debug 89 | ``` 90 | 91 | ##### 2. 在 tmux 会话中运行应用程序 92 | ```bash 93 | # 切换到项目目录 94 | tmux send-keys -t cccs-debug 'cd /Users/kenn/Projects/cccs' Enter 95 | 96 | # 启动应用程序(开发模式) 97 | tmux send-keys -t cccs-debug 'npm run tauri:dev' Enter 98 | ``` 99 | 100 | ##### 3. 查看应用程序日志 101 | ```bash 102 | # 捕获 tmux 会话的屏幕内容查看日志 103 | tmux capture-pane -t cccs-debug -p 104 | 105 | # 如果需要查看更多历史输出 106 | tmux capture-pane -t cccs-debug -S -1000 -p 107 | ``` 108 | 109 | ##### 4. 在会话中执行其他调试命令 110 | ```bash 111 | # 向 tmux 会话发送任意命令 112 | tmux send-keys -t cccs-debug 'echo "Debug message"' Enter 113 | 114 | # 停止应用程序(如果需要) 115 | tmux send-keys -t cccs-debug 'C-c' 116 | 117 | # 重新启动应用程序 118 | tmux send-keys -t cccs-debug 'npm run tauri:dev' Enter 119 | ``` 120 | 121 | ##### 5. 清理调试会话 122 | ```bash 123 | # 结束调试会话 124 | tmux kill-session -t cccs-debug 125 | ``` 126 | 127 | ##### TabbyMCP 工具使用 128 | - **获取终端会话列表**: `mcp__tabbymcp__get_ssh_session_list()` 129 | - **执行命令**: `mcp__tabbymcp__exec_command({tabId, command, commandExplanation})` 130 | - **查看终端缓冲区**: `mcp__tabbymcp__get_terminal_buffer({tabId, startLine, endLine})` 131 | 132 | ##### 调试优势 133 | 1. **非阻塞**:不会阻塞主要的工作流程 134 | 2. **持久化**:可以随时查看应用程序状态和日志 135 | 3. **灵活性**:可以在不中断应用的情况下执行其他命令 136 | 4. **隔离性**:调试环境与主工作环境分离 137 | 138 | ##### 注意事项 139 | - 使用 `tmux send-keys` 而不是直接 `tmux attach`,避免阻塞 140 | - 定期使用 `tmux capture-pane` 查看最新日志 141 | - 调试完成后记得清理 tmux 会话 142 | 143 | ### VS Code Development 144 | **Launch configurations available (F5)**: 145 | - **"Launch CCCS App"** - Run the full Tauri application 146 | - **"Debug Frontend"** - Run only the Vite dev server 147 | - **"Launch Full Stack"** - Run both frontend and backend simultaneously 148 | - **"Attach to Chrome"** - Attach debugger to Chrome for frontend debugging 149 | 150 | ## Key Configuration Files 151 | 152 | - `package.json` - Frontend dependencies and npm scripts 153 | - `tsconfig.json` - TypeScript configuration with strict settings 154 | - `src-tauri/tauri.conf.json` - Tauri app configuration including window settings and build commands 155 | - `src-tauri/Cargo.toml` - Rust dependencies and library configuration 156 | - `src-tauri/capabilities/default.json` - Tauri permissions configuration 157 | 158 | ## Settings Page File Structure (UPDATED - 2025-08-03) 159 | 160 | **IMPORTANT**: 项目已成功重构为标准Tauri+Vite结构! 161 | 162 | ### 标准Tauri+Vite项目结构 (CURRENT): 163 | ``` 164 | project-root/ 165 | ├── index.html ← Vite入口点 (在根目录) 166 | ├── package.json ← 前端依赖 167 | ├── vite.config.js ← Vite配置 168 | ├── src/ ← 前端源代码目录 169 | │ ├── main.js ← 前端入口JavaScript (ES6模块) 170 | │ ├── style.css ← 样式文件 171 | │ └── ... ← 其他前端源码 172 | ├── public/ ← 静态资源目录 (构建时复制到输出根目录) 173 | │ ├── vite.svg ← 静态资源 174 | │ └── ... ← 其他静态文件 175 | ├── src-tauri/ ← Tauri后端目录 176 | │ ├── src/ ← Rust源代码 177 | │ ├── Cargo.toml ← Rust配置 178 | │ └── tauri.conf.json ← Tauri配置 179 | └── dist/ ← 构建输出目录 (生成) 180 | ├── index.html ← 构建后的HTML 181 | ├── assets/ ← 打包后的JS/CSS 182 | └── ... ← public/目录的静态文件 183 | ``` 184 | 185 | ### 重构完成的改进: 186 | 1. ✅ **符合Vite最佳实践** - 标准的Vite项目结构 187 | 2. ✅ **ES6模块化** - 使用import/export语法 188 | 3. ✅ **清晰的职责分离** - 源码与静态资源明确分离 189 | 4. ✅ **简化的构建流程** - 标准npm scripts 190 | 5. ✅ **去除冗余配置** - 移除TypeScript依赖 191 | 192 | ### 当前的正确工作流程: 193 | - **开发模式**: `npm run dev` (启动Vite开发服务器) 194 | - **Tauri开发**: `npm run tauri:dev` (启动完整应用) 195 | - **生产构建**: `npm run build` (构建前端) 196 | - **Tauri打包**: `npm run tauri:build` (打包应用) 197 | 198 | ### 文件路径机制 (CURRENT): 199 | - **开发模式**: Vite serves static files from `public/` and sources from `src/` 200 | - `src/main.js` → `http://localhost:5173/src/main.js` (ES6模块) 201 | - `src/style.css` → 通过import自动加载 202 | - `public/vite.svg` → `http://localhost:5173/vite.svg` 203 | 204 | - **生产模式**: Files are bundled into `dist/` directory 205 | - `src/main.js` + `src/style.css` → `dist/assets/index-[hash].js` 206 | - `public/vite.svg` → `dist/vite.svg` 207 | 208 | ### ALWAYS Edit These Files (Updated Structure): 209 | - `index.html` - HTML结构和布局 210 | - `src/style.css` - 所有样式修改 211 | - `src/main.js` - JavaScript功能和逻辑 212 | 213 | ### 构建和测试: 214 | - **开发测试**: `npm run dev` 确保开发服务器正常 215 | - **构建测试**: `npm run build` 确保生产构建成功 216 | - **应用测试**: `npm run tauri:dev` 测试完整应用 217 | 218 | ### 备份文件位置: 219 | - 原始文件备份在 `public_backup_[timestamp]/` 和 `index_backup_[timestamp].html` 220 | - 如需回滚可参考备份文件 221 | 222 | ## Application Features 223 | 224 | ### System Tray Integration 225 | - Always runs in system tray 226 | - Menu shows all available profiles with status indicators 227 | - Quick profile switching directly from tray menu 228 | - Settings window accessible from tray menu 229 | 230 | ### Profile Management 231 | - Automatically detects Claude Code directory (`~/.claude`) 232 | - Scans for profile files (`*.settings.json`) 233 | - Shows profile status: ✅ Full match, 🔄 Partial match, ❌ Error 234 | - Create, edit, and save profiles through GUI 235 | 236 | ### Dynamic Field Exclusion (NEW FEATURE) 237 | - **Configurable Ignored Fields**: Users can customize which fields to ignore during profile comparison 238 | - **Default Fields**: Automatically ignores `model` and `feedbackSurveyState` (fields auto-updated by Claude Code) 239 | - **GUI Management**: Add, remove, and reset ignored fields through Settings interface 240 | - **Real-time Updates**: Profile status icons update immediately when ignored fields change 241 | - **Backward Compatibility**: Existing configurations automatically upgrade to support the new feature 242 | 243 | ### Settings Interface 244 | - **Left Panel**: Navigation between profiles and Settings section 245 | - **Right Panel**: 246 | - Profile view: JSON editor with syntax highlighting and validation 247 | - Settings view: Three-section layout with field exclusion management 248 | - **Internationalization**: Chinese and English language support 249 | - **Responsive Design**: Works on different screen sizes 250 | 251 | ### File Monitoring 252 | - Automatically monitors configuration file changes 253 | - Updates tray menu when files are modified externally 254 | - Real-time status updates 255 | 256 | ## Testing and Debugging 257 | 258 | ### Debug Mode Features 259 | - Extensive logging throughout the application 260 | - Performance testing module (`performance_tests.rs`) available in debug builds 261 | - Console debugging in settings window (F12) 262 | 263 | ### Common Issues 264 | 1. **Permission Errors**: Check `src-tauri/capabilities/default.json` for required permissions 265 | 2. **File Path Issues**: Ensure using absolute paths (`/settings.css`) not relative (`settings.css`) 266 | 3. **Build Issues**: Run `npm run build` after changes to `public/` directory 267 | 4. **Tauri API Not Available**: Check browser console for Tauri initialization errors 268 | 269 | ## Project Structure Notes 270 | 271 | - Frontend assets are served from the `public/` directory 272 | - Tauri icons are stored in `src-tauri/icons/` with multiple formats for different platforms 273 | - The app uses a hybrid architecture where the frontend is built with Vite and bundled into the Tauri application 274 | - No test framework is currently configured 275 | - Application logs are available through Tauri's logging system -------------------------------------------------------------------------------- /.kiro/specs/claude-config-switcher/design.md: -------------------------------------------------------------------------------- 1 | # CCCS 设计文档 2 | 3 | ## 概述 4 | 5 | CCCS(Claude Code Configuration Switcher)是一个基于Tauri 2.0的跨平台桌面应用程序,专门用于管理和切换Claude Code的配置文件。应用程序通过系统托盘界面提供简洁的配置管理功能,支持多语言界面和实时配置监控。 6 | 7 | ### 核心功能 8 | - 自动检测Claude Code安装 9 | - 配置文件扫描和验证 10 | - 系统托盘集成 11 | - 实时配置状态监控 12 | - 配置文件快速切换 13 | - 多语言支持 14 | 15 | ## 架构 16 | 17 | ### 技术栈 18 | - **后端**: Rust (Tauri 2.0) 19 | - **前端**: TypeScript + Vite 20 | - **UI框架**: 原生HTML/CSS/JS 21 | - **文件系统**: Tauri FS Plugin 22 | - **系统托盘**: Tauri Tray API 23 | - **国际化**: 基于系统locale的静态文本 24 | 25 | ### 架构模式 26 | 27 | ```mermaid 28 | graph TB 29 | A[主进程 - Tauri Runtime] --> B[系统托盘服务] 30 | A --> C[配置管理服务] 31 | A --> D[文件监控服务] 32 | A --> E[国际化服务] 33 | 34 | B --> F[托盘菜单生成器] 35 | B --> G[事件处理器] 36 | 37 | C --> H[配置文件扫描器] 38 | C --> I[配置文件比较器] 39 | C --> J[配置文件操作器] 40 | 41 | D --> K[定时器服务] 42 | D --> L[文件变化检测] 43 | 44 | E --> M[系统语言检测] 45 | E --> N[文本资源管理] 46 | 47 | F --> O[动态菜单项] 48 | G --> P[点击事件处理] 49 | H --> Q[Profile发现] 50 | I --> R[内容比较] 51 | J --> S[文件复制操作] 52 | ``` 53 | 54 | ## 组件和接口 55 | 56 | ### 1. 主应用程序 (App) 57 | 58 | **职责**: 应用程序入口点和生命周期管理 59 | 60 | **接口**: 61 | ```rust 62 | struct App { 63 | config_service: ConfigService, 64 | tray_service: TrayService, 65 | monitor_service: MonitorService, 66 | i18n_service: I18nService, 67 | settings_service: SettingsService, 68 | } 69 | 70 | impl App { 71 | fn new() -> Result; 72 | fn initialize(&mut self) -> Result<(), AppError>; 73 | fn run(&self) -> Result<(), AppError>; 74 | fn shutdown(&self) -> Result<(), AppError>; 75 | fn update_monitor_interval(&mut self, minutes: u64) -> Result<(), AppError>; 76 | } 77 | ``` 78 | 79 | ### 2. 配置服务 (ConfigService) 80 | 81 | **职责**: 处理Claude Code配置文件的发现、验证、比较和操作 82 | 83 | **接口**: 84 | ```rust 85 | struct ConfigService { 86 | claude_dir: PathBuf, 87 | profiles: Vec, 88 | } 89 | 90 | #[derive(Debug, Clone)] 91 | struct Profile { 92 | name: String, 93 | path: PathBuf, 94 | content: String, 95 | is_active: bool, 96 | } 97 | 98 | impl ConfigService { 99 | fn detect_claude_installation() -> Result; 100 | fn scan_profiles(&mut self) -> Result, ConfigError>; 101 | fn validate_default_config() -> Result<(), ConfigError>; 102 | fn compare_profiles(&self) -> Vec; 103 | fn switch_profile(&mut self, profile_name: &str) -> Result<(), ConfigError>; 104 | fn get_profile_status(&self, profile_name: &str) -> ProfileStatus; 105 | } 106 | 107 | enum ProfileStatus { 108 | Active, 109 | Inactive, 110 | Error(String), 111 | } 112 | ``` 113 | 114 | ### 3. 系统托盘服务 (TrayService) 115 | 116 | **职责**: 管理系统托盘图标、菜单生成和事件处理 117 | 118 | **接口**: 119 | ```rust 120 | struct TrayService { 121 | tray_icon: Option, 122 | menu_builder: MenuBuilder, 123 | event_handler: TrayEventHandler, 124 | } 125 | 126 | impl TrayService { 127 | fn create_tray(&mut self, app: &AppHandle) -> Result<(), TrayError>; 128 | fn update_menu(&mut self, profiles: &[Profile]) -> Result<(), TrayError>; 129 | fn handle_menu_click(&self, event_id: &str) -> Result<(), TrayError>; 130 | fn show_settings_window(&self) -> Result<(), TrayError>; 131 | } 132 | ``` 133 | 134 | ### 4. 监控服务 (MonitorService) 135 | 136 | **职责**: 定时监控特定配置文件变化,更新托盘菜单状态 137 | 138 | **监控策略**: 只监控关键配置文件,避免扫描整个.claude目录 139 | - `settings.json` (默认配置文件) 140 | - `*.settings.json` (各个profile配置文件) 141 | 142 | **接口**: 143 | ```rust 144 | struct MonitorService { 145 | timer: Option, 146 | monitored_files: Vec, 147 | file_metadata: HashMap, 148 | monitor_interval_minutes: u64, 149 | } 150 | 151 | #[derive(Debug, Clone)] 152 | struct FileMetadata { 153 | modified_time: SystemTime, 154 | checksum: u32, // 使用CRC32快速校验 155 | size: u64, 156 | } 157 | 158 | impl MonitorService { 159 | fn new(interval_minutes: u64) -> Self; 160 | fn set_monitor_interval(&mut self, minutes: u64) -> Result<(), MonitorError>; 161 | fn add_file_to_monitor(&mut self, path: PathBuf); 162 | fn start_monitoring(&mut self, callback: Arc) -> Result<(), MonitorError>; 163 | fn stop_monitoring(&mut self); 164 | fn force_scan(&self) -> Result, MonitorError>; 165 | fn efficient_file_check(&self, path: &Path) -> Result; 166 | } 167 | 168 | #[derive(Debug)] 169 | struct ConfigFileChange { 170 | file_path: PathBuf, 171 | change_type: ChangeType, 172 | } 173 | 174 | enum ChangeType { 175 | Modified, 176 | Created, 177 | Deleted, 178 | } 179 | ``` 180 | 181 | ### 5. 国际化服务 (I18nService) 182 | 183 | **职责**: 管理多语言文本资源和系统语言检测 184 | 185 | **接口**: 186 | ```rust 187 | struct I18nService { 188 | current_locale: String, 189 | text_resources: HashMap>, 190 | } 191 | 192 | impl I18nService { 193 | fn detect_system_locale() -> String; 194 | fn load_text_resources(&mut self) -> Result<(), I18nError>; 195 | fn get_text(&self, key: &str) -> String; 196 | fn get_supported_locales() -> Vec; 197 | } 198 | ``` 199 | 200 | ### 6. 设置服务 (SettingsService) 201 | 202 | **职责**: 管理用户配置的持久化存储和设置界面 203 | 204 | **接口**: 205 | ```rust 206 | struct SettingsService { 207 | settings_file_path: PathBuf, 208 | current_settings: UserSettings, 209 | } 210 | 211 | impl SettingsService { 212 | fn new() -> Result; 213 | fn load_settings(&mut self) -> Result; 214 | fn save_settings(&self, settings: &UserSettings) -> Result<(), SettingsError>; 215 | fn validate_monitor_interval(minutes: u64) -> Result<(), SettingsError>; 216 | fn open_settings_window(&self, app: &AppHandle) -> Result<(), SettingsError>; 217 | } 218 | ``` 219 | 220 | ## 数据模型 221 | 222 | ### 配置文件模型 223 | 224 | ```rust 225 | #[derive(Debug, Clone, Serialize, Deserialize)] 226 | struct ClaudeConfig { 227 | // Claude Code配置的典型结构 228 | settings: serde_json::Value, 229 | } 230 | 231 | #[derive(Debug, Clone)] 232 | struct ProfileMetadata { 233 | name: String, 234 | path: PathBuf, 235 | last_modified: SystemTime, 236 | size: u64, 237 | checksum: String, 238 | } 239 | ``` 240 | 241 | ### 用户设置模型 242 | 243 | ```rust 244 | #[derive(Debug, Clone, Serialize, Deserialize)] 245 | struct UserSettings { 246 | monitor_interval_minutes: u64, // 1-60分钟 247 | auto_start_monitoring: bool, 248 | language: Option, // None表示跟随系统 249 | show_notifications: bool, 250 | } 251 | 252 | impl Default for UserSettings { 253 | fn default() -> Self { 254 | Self { 255 | monitor_interval_minutes: 5, // 默认5分钟 256 | auto_start_monitoring: true, 257 | language: None, 258 | show_notifications: true, 259 | } 260 | } 261 | } 262 | ``` 263 | 264 | ### 应用程序状态 265 | 266 | ```rust 267 | #[derive(Debug)] 268 | struct AppState { 269 | is_initialized: bool, 270 | claude_dir: Option, 271 | profiles: Vec, 272 | current_locale: String, 273 | monitoring_enabled: bool, 274 | user_settings: UserSettings, 275 | } 276 | ``` 277 | 278 | ### 错误模型 279 | 280 | ```rust 281 | #[derive(Debug, thiserror::Error)] 282 | enum AppError { 283 | #[error("Claude Code installation not found")] 284 | ClaudeNotFound, 285 | #[error("Configuration file error: {0}")] 286 | ConfigError(String), 287 | #[error("Tray operation failed: {0}")] 288 | TrayError(String), 289 | #[error("File system error: {0}")] 290 | FileSystemError(String), 291 | #[error("Permission denied: {0}")] 292 | PermissionError(String), 293 | } 294 | ``` 295 | 296 | ## 错误处理 297 | 298 | ### 错误处理策略 299 | 300 | 1. **启动时错误**: 301 | - Claude Code未安装 → 显示文件选择对话框 302 | - 配置文件不存在 → 显示错误消息并退出 303 | - 权限不足 → 显示权限请求提示 304 | 305 | 2. **运行时错误**: 306 | - 配置文件读取失败 → 在托盘菜单显示错误状态 307 | - 文件写入失败 → 显示错误通知 308 | - 监控服务异常 → 自动重启监控 309 | 310 | 3. **用户交互错误**: 311 | - 无效的配置文件选择 → 显示警告消息 312 | - 网络连接问题 → 优雅降级到本地功能 313 | 314 | ### 错误恢复机制 315 | 316 | ```rust 317 | impl ErrorRecovery { 318 | fn handle_config_error(&self, error: ConfigError) -> RecoveryAction { 319 | match error { 320 | ConfigError::FileNotFound => RecoveryAction::PromptUser, 321 | ConfigError::PermissionDenied => RecoveryAction::ShowPermissionDialog, 322 | ConfigError::InvalidFormat => RecoveryAction::ShowErrorAndContinue, 323 | _ => RecoveryAction::LogAndContinue, 324 | } 325 | } 326 | 327 | fn auto_retry_monitoring(&self, max_retries: u32) -> bool { 328 | // 自动重试监控服务 329 | } 330 | } 331 | ``` 332 | 333 | ## 用户界面设计 334 | 335 | ### 设置页面设计 336 | 337 | 设置页面是一个独立的窗口,包含应用程序的配置选项和信息。 338 | 339 | #### 页面布局 340 | 341 | ``` 342 | ┌─────────────────────────────────────────┐ 343 | │ CCCS - 设置 │ 344 | ├─────────────────────────────────────────┤ 345 | │ │ 346 | │ 应用程序信息 │ 347 | │ ───────────────────── │ 348 | │ CCCS (Claude Code Configuration │ 349 | │ Switcher) 是一个用于快速切换 │ 350 | │ Claude Code 配置文件的工具。 │ 351 | │ │ 352 | │ 监控设置 │ 353 | │ ───────────────────── │ 354 | │ 配置文件监控间隔: │ 355 | │ [ 5 ] 分钟 ▲▼ │ 356 | │ (有效范围: 1-60 分钟) │ 357 | │ │ 358 | │ □ 启动时自动开始监控 │ 359 | │ □ 显示配置切换通知 │ 360 | │ │ 361 | │ 语言设置 │ 362 | │ ───────────────────── │ 363 | │ 界面语言: [跟随系统 ▼] │ 364 | │ 中文 │ 365 | │ English │ 366 | │ │ 367 | │ 当前状态 │ 368 | │ ───────────────────── │ 369 | │ Claude Code 目录: ~/.claude │ 370 | │ 发现的配置文件: 3 个 │ 371 | │ 监控状态: 运行中 │ 372 | │ │ 373 | │ [关闭] │ 374 | └─────────────────────────────────────────┘ 375 | ``` 376 | 377 | #### 界面元素详细说明 378 | 379 | 1. **监控间隔设置**: 380 | - 数字输入框,支持1-60分钟 381 | - 上下箭头按钮用于快速调整 382 | - 实时验证输入有效性 383 | - 更改后立即生效 384 | 385 | 2. **开关选项**: 386 | - 复选框控制自动监控和通知 387 | - 状态保存到用户配置文件 388 | 389 | 3. **语言选择**: 390 | - 下拉菜单支持系统自动、中文、英文 391 | - 切换后立即应用到界面 392 | 393 | 4. **状态信息**: 394 | - 只读显示当前系统状态 395 | - 实时更新监控状态 396 | 397 | #### 设置页面交互逻辑 398 | 399 | ```rust 400 | #[derive(Debug)] 401 | struct SettingsWindow { 402 | app_handle: AppHandle, 403 | current_settings: UserSettings, 404 | window_handle: Option, 405 | } 406 | 407 | impl SettingsWindow { 408 | fn open(&mut self) -> Result<(), SettingsError> { 409 | if self.window_handle.is_some() { 410 | // 如果窗口已存在,则聚焦到前台 411 | self.focus_existing_window() 412 | } else { 413 | // 创建新的设置窗口 414 | self.create_settings_window() 415 | } 416 | } 417 | 418 | fn create_settings_window(&mut self) -> Result<(), SettingsError> { 419 | let window = WebviewWindowBuilder::new( 420 | &self.app_handle, 421 | "settings", 422 | WebviewUrl::App("settings.html".into()) 423 | ) 424 | .title("CCCS - 设置") 425 | .inner_size(450.0, 600.0) 426 | .resizable(false) 427 | .center() 428 | .build()?; 429 | 430 | self.window_handle = Some(window); 431 | Ok(()) 432 | } 433 | 434 | fn handle_monitor_interval_change(&mut self, minutes: u64) -> Result<(), SettingsError> { 435 | // 验证输入范围 436 | if !(1..=60).contains(&minutes) { 437 | return Err(SettingsError::InvalidInterval(minutes)); 438 | } 439 | 440 | // 更新设置 441 | self.current_settings.monitor_interval_minutes = minutes; 442 | 443 | // 保存到文件 444 | self.save_settings()?; 445 | 446 | // 通知主应用更新监控间隔 447 | self.app_handle.emit("settings_changed", &self.current_settings)?; 448 | 449 | Ok(()) 450 | } 451 | } 452 | ``` 453 | 454 | ## 测试策略 455 | 456 | ### 单元测试 457 | 458 | 1. **配置服务测试**: 459 | - 配置文件扫描功能 460 | - 配置文件比较逻辑 461 | - 配置切换操作 462 | 463 | 2. **文件操作测试**: 464 | - 文件读取/写入操作 465 | - 权限处理 466 | - 错误场景模拟 467 | 468 | 3. **国际化测试**: 469 | - 语言检测功能 470 | - 文本资源加载 471 | - 缺失翻译处理 472 | 473 | ### 集成测试 474 | 475 | 1. **系统托盘集成**: 476 | - 托盘图标创建 477 | - 菜单更新机制 478 | - 事件处理流程 479 | 480 | 2. **端到端测试**: 481 | - 完整的配置切换流程 482 | - 错误处理场景 483 | - 多语言界面切换 484 | 485 | ### 性能测试 486 | 487 | 1. **文件监控性能**: 488 | - 大量配置文件扫描 489 | - 监控频率优化 490 | - 内存使用情况 491 | 492 | 2. **启动性能**: 493 | - 应用启动时间 494 | - 托盘图标显示延迟 495 | 496 | ## 技术决策和理由 497 | 498 | ### 1. 使用Tauri 2.0 499 | - **优势**: 跨平台、小体积、安全性、原生性能 500 | - **权衡**: 需要Rust知识,生态系统相对较小 501 | 502 | ### 2. 精确文件监控 vs 目录监控 503 | - **选择**: 精确文件监控(只监控特定配置文件) 504 | - **理由**: 505 | - 避免监控整个`.claude`目录带来的性能问题 506 | - Claude Code运行时会频繁写入日志、缓存等文件 507 | - 只关注真正重要的配置文件:`settings.json`和`*.settings.json` 508 | - 减少无关文件变化事件的干扰 509 | 510 | ### 3. 定时监控 vs 文件系统事件 511 | - **选择**: 定时监控(用户可配置间隔) 512 | - **理由**: 513 | - 简单可靠,避免文件系统事件的复杂性和平台差异 514 | - 降低系统资源消耗 515 | - 更好的跨平台兼容性 516 | - 用户可根据需要调整监控频率(1-60分钟) 517 | 518 | ### 4. 性能优化策略 519 | - **选择**: 分层检查(修改时间 + 内容校验) 520 | - **理由**: 521 | - 先检查文件修改时间,快速过滤未变化的文件 522 | - 只有修改时间变化时才读取文件内容 523 | - 使用CRC32快速校验和算法而非SHA 524 | - 最小化磁盘I/O操作 525 | 526 | ### 5. 静态国际化 vs 动态加载 527 | - **选择**: 静态编译时国际化 528 | - **理由**: 529 | - 减少运行时开销 530 | - 简化部署 531 | - 足够满足当前需求(中英文) 532 | 533 | ### 6. 内存缓存 vs 实时读取 534 | - **选择**: 混合方案 535 | - **理由**: 536 | - 配置文件内容缓存以提高比较性能 537 | - 文件元数据实时读取以确保准确性 538 | 539 | ### 7. 单一托盘图标 vs 状态指示 540 | - **选择**: 单一图标 + 菜单项状态 541 | - **理由**: 542 | - 保持界面简洁 543 | - 符合系统托盘最佳实践 544 | - 避免过多的视觉干扰 545 | 546 | ## 安全考虑 547 | 548 | ### 文件系统权限 549 | - 限制只访问Claude Code配置目录 550 | - 验证文件路径防止目录遍历攻击 551 | - 使用Tauri的权限系统限制文件操作范围 552 | 553 | ### 配置文件验证 554 | - 验证JSON格式的有效性 555 | - 检查文件大小限制 556 | - 防止恶意配置文件注入 557 | 558 | ### 进程安全 559 | - 最小权限原则运行 560 | - 安全的错误消息处理 561 | - 防止敏感信息泄露 562 | 563 | 这个设计文档为CCCS应用程序提供了完整的技术蓝图,涵盖了从架构设计到实现细节的各个方面,确保应用程序能够安全、高效地满足所有需求。 -------------------------------------------------------------------------------- /src-tauri/src/i18n_service.rs: -------------------------------------------------------------------------------- 1 | // Internationalization service for CCCS 2 | use crate::{AppError, AppResult}; 3 | use std::collections::HashMap; 4 | 5 | pub struct I18nService { 6 | current_locale: String, 7 | text_resources: HashMap>, 8 | } 9 | 10 | impl I18nService { 11 | pub fn new() -> Self { 12 | let current_locale = Self::detect_system_locale(); 13 | let mut service = Self { 14 | current_locale: current_locale.clone(), 15 | text_resources: HashMap::new(), 16 | }; 17 | 18 | if let Err(e) = service.load_text_resources() { 19 | log::warn!("Failed to load text resources: {}", e); 20 | } 21 | 22 | service 23 | } 24 | 25 | /// Detect system locale 26 | pub fn detect_system_locale() -> String { 27 | log::info!("Detecting system locale..."); 28 | 29 | // Try to get system locale from environment variables 30 | if let Ok(locale) = std::env::var("LANG") { 31 | log::info!("LANG env var: {}", locale); 32 | if locale.starts_with("zh") { 33 | log::info!("Detected Chinese locale from LANG"); 34 | return "zh".to_string(); 35 | } 36 | } 37 | 38 | // Try LC_ALL 39 | if let Ok(locale) = std::env::var("LC_ALL") { 40 | log::info!("LC_ALL env var: {}", locale); 41 | if locale.starts_with("zh") { 42 | log::info!("Detected Chinese locale from LC_ALL"); 43 | return "zh".to_string(); 44 | } 45 | } 46 | 47 | // Platform-specific detection 48 | #[cfg(target_os = "macos")] 49 | { 50 | if let Some(locale) = Self::get_macos_locale() { 51 | log::info!("macOS locale: {}", locale); 52 | if locale.starts_with("zh") { 53 | log::info!("Detected Chinese locale from macOS"); 54 | return "zh".to_string(); 55 | } 56 | } 57 | } 58 | 59 | #[cfg(target_os = "windows")] 60 | { 61 | if let Some(locale) = Self::get_windows_locale() { 62 | log::info!("Windows locale: {}", locale); 63 | if locale.starts_with("zh") { 64 | log::info!("Detected Chinese locale from Windows"); 65 | return "zh".to_string(); 66 | } 67 | } 68 | } 69 | 70 | // Default to English 71 | log::info!("No Chinese locale detected, defaulting to English"); 72 | "en".to_string() 73 | } 74 | 75 | #[cfg(target_os = "macos")] 76 | fn get_macos_locale() -> Option { 77 | use std::process::Command; 78 | 79 | if let Ok(output) = Command::new("defaults") 80 | .args(&["read", "-g", "AppleLocale"]) 81 | .output() 82 | { 83 | if let Ok(locale) = String::from_utf8(output.stdout) { 84 | return Some(locale.trim().to_string()); 85 | } 86 | } 87 | None 88 | } 89 | 90 | #[cfg(target_os = "windows")] 91 | fn get_windows_locale() -> Option { 92 | // This would require Windows API calls 93 | // For now, return None and fall back to environment variables 94 | None 95 | } 96 | 97 | /// Load text resources for all supported languages 98 | pub fn load_text_resources(&mut self) -> AppResult<()> { 99 | self.text_resources.clear(); 100 | 101 | // English resources 102 | let mut en_resources = HashMap::new(); 103 | en_resources.insert("app_name".to_string(), "CCCS".to_string()); 104 | en_resources.insert("app_description".to_string(), "Claude Code Configuration Switcher".to_string()); 105 | en_resources.insert("settings".to_string(), "Settings".to_string()); 106 | en_resources.insert("exit".to_string(), "Exit".to_string()); 107 | en_resources.insert("profile".to_string(), "Profile".to_string()); 108 | en_resources.insert("active".to_string(), "Active".to_string()); 109 | en_resources.insert("inactive".to_string(), "Inactive".to_string()); 110 | en_resources.insert("switch_profile".to_string(), "Switch to profile: {}".to_string()); 111 | en_resources.insert("switching_profile".to_string(), "Switching profile...".to_string()); 112 | en_resources.insert("profile_switched".to_string(), "Profile switched successfully".to_string()); 113 | en_resources.insert("switch_failed".to_string(), "Failed to switch profile".to_string()); 114 | en_resources.insert("claude_not_found".to_string(), "Claude Code installation not found".to_string()); 115 | en_resources.insert("settings_not_found".to_string(), "settings.json not found. Please run Claude Code at least once.".to_string()); 116 | en_resources.insert("monitor_interval".to_string(), "Monitor interval: {} minutes".to_string()); 117 | en_resources.insert("monitoring_started".to_string(), "File monitoring started".to_string()); 118 | en_resources.insert("monitoring_stopped".to_string(), "File monitoring stopped".to_string()); 119 | en_resources.insert("config_changed".to_string(), "Configuration file changed".to_string()); 120 | en_resources.insert("error".to_string(), "Error".to_string()); 121 | en_resources.insert("warning".to_string(), "Warning".to_string()); 122 | en_resources.insert("info".to_string(), "Information".to_string()); 123 | en_resources.insert("ok".to_string(), "OK".to_string()); 124 | en_resources.insert("cancel".to_string(), "Cancel".to_string()); 125 | en_resources.insert("close".to_string(), "Close".to_string()); 126 | en_resources.insert("save".to_string(), "Save".to_string()); 127 | en_resources.insert("loading".to_string(), "Loading...".to_string()); 128 | en_resources.insert("saving".to_string(), "Saving...".to_string()); 129 | 130 | // Chinese resources 131 | let mut zh_resources = HashMap::new(); 132 | zh_resources.insert("app_name".to_string(), "CCCS".to_string()); 133 | zh_resources.insert("app_description".to_string(), "Claude Code 配置切换器".to_string()); 134 | zh_resources.insert("settings".to_string(), "设置".to_string()); 135 | zh_resources.insert("exit".to_string(), "退出".to_string()); 136 | zh_resources.insert("profile".to_string(), "配置".to_string()); 137 | zh_resources.insert("active".to_string(), "激活".to_string()); 138 | zh_resources.insert("inactive".to_string(), "未激活".to_string()); 139 | zh_resources.insert("switch_profile".to_string(), "切换到配置: {}".to_string()); 140 | zh_resources.insert("switching_profile".to_string(), "正在切换配置...".to_string()); 141 | zh_resources.insert("profile_switched".to_string(), "配置切换成功".to_string()); 142 | zh_resources.insert("switch_failed".to_string(), "配置切换失败".to_string()); 143 | zh_resources.insert("claude_not_found".to_string(), "未找到 Claude Code 安装".to_string()); 144 | zh_resources.insert("settings_not_found".to_string(), "未找到 settings.json 文件。请至少运行一次 Claude Code。".to_string()); 145 | zh_resources.insert("monitor_interval".to_string(), "监控间隔: {} 分钟".to_string()); 146 | zh_resources.insert("monitoring_started".to_string(), "文件监控已启动".to_string()); 147 | zh_resources.insert("monitoring_stopped".to_string(), "文件监控已停止".to_string()); 148 | zh_resources.insert("config_changed".to_string(), "配置文件已更改".to_string()); 149 | zh_resources.insert("error".to_string(), "错误".to_string()); 150 | zh_resources.insert("warning".to_string(), "警告".to_string()); 151 | zh_resources.insert("info".to_string(), "信息".to_string()); 152 | zh_resources.insert("ok".to_string(), "确定".to_string()); 153 | zh_resources.insert("cancel".to_string(), "取消".to_string()); 154 | zh_resources.insert("close".to_string(), "关闭".to_string()); 155 | zh_resources.insert("save".to_string(), "保存".to_string()); 156 | zh_resources.insert("loading".to_string(), "加载中...".to_string()); 157 | zh_resources.insert("saving".to_string(), "保存中...".to_string()); 158 | 159 | self.text_resources.insert("en".to_string(), en_resources); 160 | self.text_resources.insert("zh".to_string(), zh_resources); 161 | 162 | log::info!("Loaded text resources for {} languages", self.text_resources.len()); 163 | Ok(()) 164 | } 165 | 166 | /// Get text for a specific key 167 | pub fn get_text(&self, key: &str) -> String { 168 | self.get_text_with_args(key, &[]) 169 | } 170 | 171 | /// Get text for a specific key with arguments for formatting 172 | pub fn get_text_with_args(&self, key: &str, args: &[&str]) -> String { 173 | let resources = self.text_resources.get(&self.current_locale) 174 | .or_else(|| self.text_resources.get("en")) 175 | .expect("English resources should always be available"); 176 | 177 | if let Some(template) = resources.get(key) { 178 | // Simple string formatting - replace {} with arguments in order 179 | let mut result = template.clone(); 180 | for arg in args { 181 | if let Some(pos) = result.find("{}") { 182 | result.replace_range(pos..pos+2, arg); 183 | } else { 184 | break; 185 | } 186 | } 187 | result 188 | } else { 189 | log::warn!("Missing translation for key '{}' in locale '{}'", key, self.current_locale); 190 | key.to_string() 191 | } 192 | } 193 | 194 | /// Get all supported locales 195 | pub fn get_supported_locales() -> Vec { 196 | vec!["en".to_string(), "zh".to_string()] 197 | } 198 | 199 | /// Set the current locale 200 | pub fn set_locale(&mut self, locale: &str) -> AppResult<()> { 201 | if !Self::get_supported_locales().contains(&locale.to_string()) { 202 | return Err(AppError::I18nError( 203 | format!("Unsupported locale: {}", locale) 204 | )); 205 | } 206 | 207 | self.current_locale = locale.to_string(); 208 | log::info!("Locale changed to: {}", locale); 209 | Ok(()) 210 | } 211 | 212 | /// Get current locale 213 | pub fn get_current_locale(&self) -> &str { 214 | &self.current_locale 215 | } 216 | 217 | /// Check if a locale is supported 218 | pub fn is_locale_supported(locale: &str) -> bool { 219 | Self::get_supported_locales().contains(&locale.to_string()) 220 | } 221 | 222 | /// Get localized profile menu text 223 | pub fn get_profile_menu_text(&self, profile_name: &str, is_active: bool) -> String { 224 | if is_active { 225 | format!("✅ {}", profile_name) 226 | } else { 227 | format!("  {}", profile_name) // 全角空格 + 两个普通空格 228 | } 229 | } 230 | 231 | /// Get localized tray tooltip 232 | pub fn get_tray_tooltip(&self, profile_count: usize, active_profile: Option<&str>) -> String { 233 | let base_tooltip = if profile_count == 0 { 234 | self.get_text("app_description") 235 | } else if let Some(active) = active_profile { 236 | format!("{} - {}: {}", 237 | self.get_text("app_description"), 238 | self.get_text("active"), 239 | active 240 | ) 241 | } else { 242 | format!("{} - {} {}", 243 | self.get_text("app_description"), 244 | profile_count, 245 | if profile_count == 1 { 246 | self.get_text("profile") 247 | } else { 248 | format!("{}s", self.get_text("profile")) 249 | } 250 | ) 251 | }; 252 | 253 | base_tooltip 254 | } 255 | } 256 | 257 | // Tauri commands for i18n 258 | #[tauri::command] 259 | pub async fn get_current_locale(state: tauri::State<'_, std::sync::Mutex>) -> Result { 260 | let service = state.lock().map_err(|e| format!("Failed to lock i18n service: {}", e))?; 261 | Ok(service.get_current_locale().to_string()) 262 | } 263 | 264 | #[tauri::command] 265 | pub async fn set_locale( 266 | locale: String, 267 | state: tauri::State<'_, std::sync::Mutex>, 268 | ) -> Result<(), String> { 269 | let mut service = state.lock().map_err(|e| format!("Failed to lock i18n service: {}", e))?; 270 | service.set_locale(&locale).map_err(|e| e.to_string()) 271 | } 272 | 273 | #[tauri::command] 274 | pub async fn get_text( 275 | key: String, 276 | args: Option>, 277 | state: tauri::State<'_, std::sync::Mutex>, 278 | ) -> Result { 279 | let service = state.lock().map_err(|e| format!("Failed to lock i18n service: {}", e))?; 280 | 281 | if let Some(args) = args { 282 | let args_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); 283 | Ok(service.get_text_with_args(&key, &args_refs)) 284 | } else { 285 | Ok(service.get_text(&key)) 286 | } 287 | } 288 | 289 | #[tauri::command] 290 | pub async fn get_supported_locales() -> Result, String> { 291 | Ok(I18nService::get_supported_locales()) 292 | } 293 | 294 | #[cfg(test)] 295 | mod tests { 296 | use super::*; 297 | 298 | #[test] 299 | fn test_i18n_service_creation() { 300 | let service = I18nService::new(); 301 | assert!(!service.current_locale.is_empty()); 302 | assert!(!service.text_resources.is_empty()); 303 | } 304 | 305 | #[test] 306 | fn test_get_text() { 307 | let service = I18nService::new(); 308 | 309 | // Test basic text retrieval 310 | let text = service.get_text("app_name"); 311 | assert_eq!(text, "CCCS"); 312 | 313 | // Test missing key 314 | let missing = service.get_text("non_existent_key"); 315 | assert_eq!(missing, "non_existent_key"); 316 | } 317 | 318 | #[test] 319 | fn test_get_text_with_args() { 320 | let service = I18nService::new(); 321 | 322 | // Test text with formatting 323 | let text = service.get_text_with_args("switch_profile", &["test"]); 324 | assert!(text.contains("test")); 325 | } 326 | 327 | #[test] 328 | fn test_set_locale() { 329 | let mut service = I18nService::new(); 330 | 331 | // Test valid locale 332 | assert!(service.set_locale("zh").is_ok()); 333 | assert_eq!(service.get_current_locale(), "zh"); 334 | 335 | // Test invalid locale 336 | assert!(service.set_locale("invalid").is_err()); 337 | } 338 | 339 | #[test] 340 | fn test_locale_specific_text() { 341 | let mut service = I18nService::new(); 342 | 343 | // Test English 344 | service.set_locale("en").unwrap(); 345 | assert_eq!(service.get_text("settings"), "Settings"); 346 | 347 | // Test Chinese 348 | service.set_locale("zh").unwrap(); 349 | assert_eq!(service.get_text("settings"), "设置"); 350 | } 351 | 352 | #[test] 353 | fn test_get_supported_locales() { 354 | let locales = I18nService::get_supported_locales(); 355 | assert!(locales.contains(&"en".to_string())); 356 | assert!(locales.contains(&"zh".to_string())); 357 | } 358 | 359 | #[test] 360 | fn test_profile_menu_text() { 361 | let service = I18nService::new(); 362 | 363 | let active_text = service.get_profile_menu_text("test", true); 364 | assert_eq!(active_text, "✅ test"); 365 | 366 | let inactive_text = service.get_profile_menu_text("test", false); 367 | assert_eq!(inactive_text, "  test"); 368 | } 369 | 370 | #[test] 371 | fn test_tray_tooltip() { 372 | let mut service = I18nService::new(); 373 | service.set_locale("en").unwrap(); 374 | 375 | // Test with no profiles 376 | let tooltip = service.get_tray_tooltip(0, None); 377 | assert!(tooltip.contains("Claude Code Configuration Switcher")); 378 | 379 | // Test with active profile 380 | let tooltip = service.get_tray_tooltip(2, Some("work")); 381 | assert!(tooltip.contains("Active")); 382 | assert!(tooltip.contains("work")); 383 | } 384 | } -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | CCCS Settings 7 | 8 | 9 |
10 | 11 | 36 | 37 | 38 |
39 | 40 |
41 |

Current

42 |
43 | 44 | 45 |
46 | 47 |
48 |
49 |
50 |

Configuration Editor

51 |

Edit your configuration in JSON format

52 |
53 | 54 |
55 | 56 |
57 |
58 | 59 | 79 |
80 | 81 | 82 | 232 |
233 |
234 |
235 | 236 | 237 | 263 | 264 | 265 | 269 | 270 | 271 |
272 | 273 | 274 | 275 | 276 | -------------------------------------------------------------------------------- /src-tauri/src/settings_service.rs: -------------------------------------------------------------------------------- 1 | // Settings service for user configuration management 2 | use crate::{AppError, AppResult, UserSettings}; 3 | use std::path::{Path, PathBuf}; 4 | use std::fs; 5 | 6 | pub struct SettingsService { 7 | settings_file_path: PathBuf, 8 | current_settings: UserSettings, 9 | } 10 | 11 | impl SettingsService { 12 | /// Create a new settings service 13 | pub fn new() -> AppResult { 14 | let settings_dir = Self::get_settings_directory()?; 15 | 16 | // Ensure settings directory exists 17 | if !settings_dir.exists() { 18 | fs::create_dir_all(&settings_dir) 19 | .map_err(|e| AppError::SettingsError(format!("Failed to create settings directory: {}", e)))?; 20 | } 21 | 22 | let settings_file_path = settings_dir.join("cccs_settings.json"); 23 | 24 | let mut service = Self { 25 | settings_file_path, 26 | current_settings: UserSettings::default(), 27 | }; 28 | 29 | // Load existing settings or create default 30 | service.load_or_create_settings()?; 31 | Ok(service) 32 | } 33 | 34 | /// Create a settings service with default values (fallback) 35 | pub fn with_defaults() -> Self { 36 | let settings_file_path = dirs::config_dir() 37 | .unwrap_or_else(|| std::env::temp_dir()) 38 | .join("cccs") 39 | .join("settings.json"); 40 | 41 | Self { 42 | settings_file_path, 43 | current_settings: UserSettings::default(), 44 | } 45 | } 46 | 47 | /// Get the platform-specific settings directory 48 | fn get_settings_directory() -> AppResult { 49 | dirs::config_dir() 50 | .map(|dir| dir.join("cccs")) 51 | .ok_or_else(|| AppError::SettingsError("Failed to get config directory".to_string())) 52 | } 53 | 54 | /// Load settings from file or create default settings 55 | fn load_or_create_settings(&mut self) -> AppResult<()> { 56 | if self.settings_file_path.exists() { 57 | self.load_settings()?; 58 | } else { 59 | self.save_settings(&UserSettings::default())?; 60 | } 61 | Ok(()) 62 | } 63 | 64 | /// Load settings from the settings file 65 | pub fn load_settings(&mut self) -> AppResult { 66 | log::info!("Loading settings from: {:?}", self.settings_file_path); 67 | 68 | let content = fs::read_to_string(&self.settings_file_path) 69 | .map_err(|e| AppError::SettingsError(format!("Failed to read settings file: {}", e)))?; 70 | 71 | let settings: UserSettings = serde_json::from_str(&content) 72 | .map_err(|e| AppError::SettingsError(format!("Failed to parse settings JSON: {}", e)))?; 73 | 74 | // Validate settings 75 | Self::validate_settings(&settings)?; 76 | 77 | self.current_settings = settings.clone(); 78 | log::info!("Settings loaded successfully"); 79 | 80 | Ok(settings) 81 | } 82 | 83 | /// Save settings to the settings file 84 | pub fn save_settings(&self, settings: &UserSettings) -> AppResult<()> { 85 | log::info!("Saving settings to: {:?}", self.settings_file_path); 86 | 87 | // Validate settings before saving 88 | Self::validate_settings(settings)?; 89 | 90 | let content = serde_json::to_string_pretty(settings) 91 | .map_err(|e| AppError::SettingsError(format!("Failed to serialize settings: {}", e)))?; 92 | 93 | // Write to temporary file first for atomic operation 94 | let temp_path = self.settings_file_path.with_extension("json.tmp"); 95 | fs::write(&temp_path, content) 96 | .map_err(|e| AppError::SettingsError(format!("Failed to write temporary settings file: {}", e)))?; 97 | 98 | // Atomic move 99 | fs::rename(&temp_path, &self.settings_file_path) 100 | .map_err(|e| AppError::SettingsError(format!("Failed to save settings file: {}", e)))?; 101 | 102 | log::info!("Settings saved successfully"); 103 | Ok(()) 104 | } 105 | 106 | /// Validate settings values 107 | fn validate_settings(settings: &UserSettings) -> AppResult<()> { 108 | Self::validate_monitor_interval(settings.monitor_interval_minutes)?; 109 | 110 | // Validate language if specified 111 | if let Some(ref language) = settings.language { 112 | if !Self::is_supported_language(language) { 113 | return Err(AppError::SettingsError( 114 | format!("Unsupported language: {}", language) 115 | )); 116 | } 117 | } 118 | 119 | // Validate ignored fields 120 | crate::UserSettings::validate_ignored_fields(&settings.ignored_fields) 121 | .map_err(|e| AppError::SettingsError(format!("Invalid ignored fields in settings: {}", e)))?; 122 | 123 | Ok(()) 124 | } 125 | 126 | /// Validate monitor interval (1-60 minutes) 127 | pub fn validate_monitor_interval(minutes: u64) -> AppResult<()> { 128 | if !(1..=60).contains(&minutes) { 129 | return Err(AppError::SettingsError( 130 | format!("Invalid monitor interval: {} minutes. Must be between 1 and 60.", minutes) 131 | )); 132 | } 133 | Ok(()) 134 | } 135 | 136 | /// Check if a language is supported 137 | fn is_supported_language(language: &str) -> bool { 138 | matches!(language, "en" | "zh" | "zh-CN" | "zh-TW") 139 | } 140 | 141 | /// Update current settings and save 142 | pub fn update_settings(&mut self, new_settings: UserSettings) -> AppResult<()> { 143 | log::info!("Updating settings"); 144 | 145 | self.save_settings(&new_settings)?; 146 | self.current_settings = new_settings; 147 | 148 | Ok(()) 149 | } 150 | 151 | /// Update monitor interval 152 | pub fn update_monitor_interval(&mut self, minutes: u64) -> AppResult<()> { 153 | Self::validate_monitor_interval(minutes)?; 154 | 155 | self.current_settings.monitor_interval_minutes = minutes; 156 | self.save_settings(&self.current_settings)?; 157 | 158 | log::info!("Monitor interval updated to {} minutes", minutes); 159 | Ok(()) 160 | } 161 | 162 | /// Update auto start monitoring setting 163 | pub fn update_auto_start_monitoring(&mut self, enabled: bool) -> AppResult<()> { 164 | self.current_settings.auto_start_monitoring = enabled; 165 | self.save_settings(&self.current_settings)?; 166 | 167 | log::info!("Auto start monitoring set to: {}", enabled); 168 | Ok(()) 169 | } 170 | 171 | /// Update language setting 172 | pub fn update_language(&mut self, language: Option) -> AppResult<()> { 173 | if let Some(ref lang) = language { 174 | if !Self::is_supported_language(lang) { 175 | return Err(AppError::SettingsError( 176 | format!("Unsupported language: {}", lang) 177 | )); 178 | } 179 | } 180 | 181 | self.current_settings.language = language; 182 | self.save_settings(&self.current_settings)?; 183 | 184 | log::info!("Language setting updated"); 185 | Ok(()) 186 | } 187 | 188 | /// Update notifications setting 189 | pub fn update_show_notifications(&mut self, enabled: bool) -> AppResult<()> { 190 | self.current_settings.show_notifications = enabled; 191 | self.save_settings(&self.current_settings)?; 192 | 193 | log::info!("Show notifications set to: {}", enabled); 194 | Ok(()) 195 | } 196 | 197 | /// Get current settings 198 | pub fn get_current_settings(&self) -> &UserSettings { 199 | &self.current_settings 200 | } 201 | 202 | /// Get ignored fields list 203 | pub fn get_ignored_fields(&self) -> &[String] { 204 | &self.current_settings.ignored_fields 205 | } 206 | 207 | /// Update ignored fields list 208 | pub fn update_ignored_fields(&mut self, fields: Vec) -> AppResult<()> { 209 | // 验证字段列表 210 | crate::UserSettings::validate_ignored_fields(&fields) 211 | .map_err(|e| AppError::SettingsError(format!("Invalid ignored fields: {}", e)))?; 212 | 213 | // 标准化字段列表 214 | let normalized_fields = crate::UserSettings::normalize_ignored_fields(fields); 215 | 216 | log::info!("Updating ignored fields: {:?}", normalized_fields); 217 | 218 | // 更新设置 219 | self.current_settings.ignored_fields = normalized_fields; 220 | 221 | // 保存到文件 222 | self.save_settings(&self.current_settings)?; 223 | 224 | log::info!("Ignored fields updated successfully"); 225 | Ok(()) 226 | } 227 | 228 | /// Get default ignored fields 229 | pub fn get_default_ignored_fields() -> Vec { 230 | crate::UserSettings::get_default_ignored_fields() 231 | } 232 | 233 | /// Reset ignored fields to default 234 | pub fn reset_ignored_fields_to_default(&mut self) -> AppResult<()> { 235 | let default_fields = Self::get_default_ignored_fields(); 236 | log::info!("Resetting ignored fields to default: {:?}", default_fields); 237 | 238 | self.current_settings.ignored_fields = default_fields; 239 | self.save_settings(&self.current_settings)?; 240 | 241 | log::info!("Ignored fields reset to default successfully"); 242 | Ok(()) 243 | } 244 | 245 | /// Get settings file path 246 | pub fn get_settings_file_path(&self) -> &Path { 247 | &self.settings_file_path 248 | } 249 | 250 | /// Reset settings to default 251 | pub fn reset_to_defaults(&mut self) -> AppResult<()> { 252 | log::info!("Resetting settings to defaults"); 253 | 254 | let default_settings = UserSettings::default(); 255 | self.save_settings(&default_settings)?; 256 | self.current_settings = default_settings; 257 | 258 | Ok(()) 259 | } 260 | 261 | /// Export settings to a specified file 262 | pub fn export_settings(&self, export_path: &Path) -> AppResult<()> { 263 | log::info!("Exporting settings to: {:?}", export_path); 264 | 265 | let content = serde_json::to_string_pretty(&self.current_settings) 266 | .map_err(|e| AppError::SettingsError(format!("Failed to serialize settings for export: {}", e)))?; 267 | 268 | fs::write(export_path, content) 269 | .map_err(|e| AppError::SettingsError(format!("Failed to export settings: {}", e)))?; 270 | 271 | log::info!("Settings exported successfully"); 272 | Ok(()) 273 | } 274 | 275 | /// Import settings from a specified file 276 | pub fn import_settings(&mut self, import_path: &Path) -> AppResult<()> { 277 | log::info!("Importing settings from: {:?}", import_path); 278 | 279 | let content = fs::read_to_string(import_path) 280 | .map_err(|e| AppError::SettingsError(format!("Failed to read import file: {}", e)))?; 281 | 282 | let imported_settings: UserSettings = serde_json::from_str(&content) 283 | .map_err(|e| AppError::SettingsError(format!("Failed to parse imported settings: {}", e)))?; 284 | 285 | // Validate imported settings 286 | Self::validate_settings(&imported_settings)?; 287 | 288 | self.update_settings(imported_settings)?; 289 | 290 | log::info!("Settings imported successfully"); 291 | Ok(()) 292 | } 293 | 294 | /// Create backup of current settings 295 | pub fn create_backup(&self) -> AppResult { 296 | let timestamp = std::time::SystemTime::now() 297 | .duration_since(std::time::UNIX_EPOCH) 298 | .map_err(|e| AppError::SettingsError(format!("Failed to get timestamp: {}", e)))? 299 | .as_secs(); 300 | 301 | let backup_filename = format!("cccs_settings_backup_{}.json", timestamp); 302 | let backup_path = self.settings_file_path 303 | .parent() 304 | .unwrap() 305 | .join(backup_filename); 306 | 307 | self.export_settings(&backup_path)?; 308 | 309 | log::info!("Settings backup created: {:?}", backup_path); 310 | Ok(backup_path) 311 | } 312 | } 313 | 314 | // Tauri commands for settings management 315 | #[tauri::command] 316 | pub async fn get_settings(state: tauri::State<'_, std::sync::Mutex>) -> Result { 317 | let service = state.lock().map_err(|e| format!("Failed to lock settings service: {}", e))?; 318 | Ok(service.get_current_settings().clone()) 319 | } 320 | 321 | #[tauri::command] 322 | pub async fn update_monitor_interval( 323 | minutes: u64, 324 | state: tauri::State<'_, std::sync::Mutex>, 325 | ) -> Result<(), String> { 326 | let mut service = state.lock().map_err(|e| format!("Failed to lock settings service: {}", e))?; 327 | service.update_monitor_interval(minutes) 328 | .map_err(|e| e.to_string()) 329 | } 330 | 331 | #[tauri::command] 332 | pub async fn update_auto_start_monitoring( 333 | enabled: bool, 334 | state: tauri::State<'_, std::sync::Mutex>, 335 | ) -> Result<(), String> { 336 | let mut service = state.lock().map_err(|e| format!("Failed to lock settings service: {}", e))?; 337 | service.update_auto_start_monitoring(enabled) 338 | .map_err(|e| e.to_string()) 339 | } 340 | 341 | #[tauri::command] 342 | pub async fn update_language( 343 | language: Option, 344 | state: tauri::State<'_, std::sync::Mutex>, 345 | ) -> Result<(), String> { 346 | let mut service = state.lock().map_err(|e| format!("Failed to lock settings service: {}", e))?; 347 | service.update_language(language) 348 | .map_err(|e| e.to_string()) 349 | } 350 | 351 | #[tauri::command] 352 | pub async fn update_show_notifications( 353 | enabled: bool, 354 | state: tauri::State<'_, std::sync::Mutex>, 355 | ) -> Result<(), String> { 356 | let mut service = state.lock().map_err(|e| format!("Failed to lock settings service: {}", e))?; 357 | service.update_show_notifications(enabled) 358 | .map_err(|e| e.to_string()) 359 | } 360 | 361 | #[tauri::command] 362 | pub async fn reset_settings_to_defaults( 363 | state: tauri::State<'_, std::sync::Mutex>, 364 | ) -> Result<(), String> { 365 | let mut service = state.lock().map_err(|e| format!("Failed to lock settings service: {}", e))?; 366 | service.reset_to_defaults() 367 | .map_err(|e| e.to_string()) 368 | } 369 | 370 | #[tauri::command] 371 | pub async fn get_ignored_fields( 372 | state: tauri::State<'_, std::sync::Mutex>, 373 | ) -> Result, String> { 374 | let service = state.lock().map_err(|e| format!("Failed to lock settings service: {}", e))?; 375 | Ok(service.get_ignored_fields().to_vec()) 376 | } 377 | 378 | #[tauri::command] 379 | pub async fn update_ignored_fields( 380 | fields: Vec, 381 | state: tauri::State<'_, std::sync::Mutex>, 382 | ) -> Result<(), String> { 383 | let mut service = state.lock().map_err(|e| format!("Failed to lock settings service: {}", e))?; 384 | service.update_ignored_fields(fields) 385 | .map_err(|e| e.to_string()) 386 | } 387 | 388 | #[tauri::command] 389 | pub async fn get_default_ignored_fields() -> Result, String> { 390 | Ok(SettingsService::get_default_ignored_fields()) 391 | } 392 | 393 | #[tauri::command] 394 | pub async fn reset_ignored_fields_to_default( 395 | state: tauri::State<'_, std::sync::Mutex>, 396 | ) -> Result<(), String> { 397 | let mut service = state.lock().map_err(|e| format!("Failed to lock settings service: {}", e))?; 398 | service.reset_ignored_fields_to_default() 399 | .map_err(|e| e.to_string()) 400 | } 401 | 402 | #[cfg(test)] 403 | mod tests { 404 | use super::*; 405 | use tempfile::TempDir; 406 | use std::env; 407 | 408 | fn create_test_settings_service() -> (SettingsService, TempDir) { 409 | let temp_dir = TempDir::new().unwrap(); 410 | 411 | // Override the config directory for testing 412 | env::set_var("HOME", temp_dir.path()); 413 | 414 | let service = SettingsService::new().unwrap(); 415 | (service, temp_dir) 416 | } 417 | 418 | #[test] 419 | fn test_settings_service_creation() { 420 | let (_service, _temp_dir) = create_test_settings_service(); 421 | // If we get here without panic, the service was created successfully 422 | } 423 | 424 | #[test] 425 | fn test_validate_monitor_interval() { 426 | assert!(SettingsService::validate_monitor_interval(1).is_ok()); 427 | assert!(SettingsService::validate_monitor_interval(30).is_ok()); 428 | assert!(SettingsService::validate_monitor_interval(60).is_ok()); 429 | 430 | assert!(SettingsService::validate_monitor_interval(0).is_err()); 431 | assert!(SettingsService::validate_monitor_interval(61).is_err()); 432 | } 433 | 434 | #[test] 435 | fn test_is_supported_language() { 436 | assert!(SettingsService::is_supported_language("en")); 437 | assert!(SettingsService::is_supported_language("zh")); 438 | assert!(SettingsService::is_supported_language("zh-CN")); 439 | assert!(SettingsService::is_supported_language("zh-TW")); 440 | 441 | assert!(!SettingsService::is_supported_language("fr")); 442 | assert!(!SettingsService::is_supported_language("invalid")); 443 | } 444 | 445 | #[test] 446 | fn test_update_monitor_interval() { 447 | let (mut service, _temp_dir) = create_test_settings_service(); 448 | 449 | assert!(service.update_monitor_interval(15).is_ok()); 450 | assert_eq!(service.get_current_settings().monitor_interval_minutes, 15); 451 | 452 | assert!(service.update_monitor_interval(0).is_err()); 453 | // Value should remain unchanged after error 454 | assert_eq!(service.get_current_settings().monitor_interval_minutes, 15); 455 | } 456 | 457 | #[test] 458 | fn test_update_language() { 459 | let (mut service, _temp_dir) = create_test_settings_service(); 460 | 461 | assert!(service.update_language(Some("zh".to_string())).is_ok()); 462 | assert_eq!(service.get_current_settings().language, Some("zh".to_string())); 463 | 464 | assert!(service.update_language(None).is_ok()); 465 | assert_eq!(service.get_current_settings().language, None); 466 | 467 | assert!(service.update_language(Some("invalid".to_string())).is_err()); 468 | } 469 | 470 | #[test] 471 | fn test_settings_persistence() { 472 | let temp_dir = TempDir::new().unwrap(); 473 | env::set_var("HOME", temp_dir.path()); 474 | 475 | // Create service and update settings 476 | { 477 | let mut service = SettingsService::new().unwrap(); 478 | service.update_monitor_interval(25).unwrap(); 479 | service.update_auto_start_monitoring(false).unwrap(); 480 | } 481 | 482 | // Create new service and verify settings persisted 483 | { 484 | let service = SettingsService::new().unwrap(); 485 | let settings = service.get_current_settings(); 486 | assert_eq!(settings.monitor_interval_minutes, 25); 487 | assert!(!settings.auto_start_monitoring); 488 | } 489 | } 490 | 491 | #[test] 492 | fn test_export_import_settings() { 493 | let (service, temp_dir) = create_test_settings_service(); 494 | 495 | let export_path = temp_dir.path().join("exported_settings.json"); 496 | 497 | // Export settings 498 | assert!(service.export_settings(&export_path).is_ok()); 499 | assert!(export_path.exists()); 500 | 501 | // Create new service and import 502 | let (mut new_service, _) = create_test_settings_service(); 503 | new_service.update_monitor_interval(30).unwrap(); // Change to different value 504 | 505 | assert!(new_service.import_settings(&export_path).is_ok()); 506 | 507 | // Settings should match original 508 | assert_eq!( 509 | new_service.get_current_settings().monitor_interval_minutes, 510 | service.get_current_settings().monitor_interval_minutes 511 | ); 512 | } 513 | 514 | #[test] 515 | fn test_create_backup() { 516 | let (service, _temp_dir) = create_test_settings_service(); 517 | 518 | let backup_path = service.create_backup().unwrap(); 519 | assert!(backup_path.exists()); 520 | assert!(backup_path.file_name().unwrap().to_str().unwrap().starts_with("cccs_settings_backup_")); 521 | } 522 | } -------------------------------------------------------------------------------- /src-tauri/src/lib.rs: -------------------------------------------------------------------------------- 1 | // CCCS - Claude Code Configuration Switcher 2 | // Core modules 3 | mod app; 4 | mod claude_detector; 5 | mod config_service; 6 | mod error; 7 | mod i18n_service; 8 | mod monitor_service; 9 | mod settings_service; 10 | mod tray_service; 11 | mod types; 12 | mod validation; 13 | 14 | // Performance testing module (only in debug builds) 15 | #[cfg(debug_assertions)] 16 | pub mod performance_tests; 17 | 18 | // Re-exports for public API 19 | pub use error::AppError; 20 | pub use types::*; 21 | 22 | pub type AppResult = Result; 23 | 24 | use app::App; 25 | use serde::Serialize; 26 | use std::sync::{Arc, Mutex}; 27 | use tauri::{AppHandle, Manager, Emitter}; 28 | 29 | #[derive(Serialize)] 30 | struct ProfilesInfo { 31 | claude_directory: String, 32 | profiles_count: usize, 33 | monitor_status: String, 34 | } 35 | 36 | #[tauri::command] 37 | async fn get_profiles_info( 38 | app_state: tauri::State<'_, Arc>>, 39 | ) -> Result { 40 | log::info!("get_profiles_info called"); 41 | 42 | let app = match app_state.try_lock() { 43 | Ok(guard) => guard, 44 | Err(e) => { 45 | log::error!("Failed to lock app state: {}", e); 46 | return Err("Failed to access application state".to_string()); 47 | } 48 | }; 49 | 50 | let config_service = app.get_config_service(); 51 | let config = match config_service.try_lock() { 52 | Ok(guard) => guard, 53 | Err(e) => { 54 | log::error!("Failed to lock config service: {}", e); 55 | return Err("Failed to access configuration service".to_string()); 56 | } 57 | }; 58 | 59 | let profiles = config.get_profiles(); 60 | let claude_dir = config.get_claude_dir(); 61 | 62 | log::info!( 63 | "Returning profiles info: {} profiles found in {}", 64 | profiles.len(), 65 | claude_dir.display() 66 | ); 67 | 68 | Ok(ProfilesInfo { 69 | claude_directory: claude_dir.to_string_lossy().to_string(), 70 | profiles_count: profiles.len(), 71 | monitor_status: "inactive".to_string(), 72 | }) 73 | } 74 | 75 | #[tauri::command] 76 | async fn get_profiles_list( 77 | app_state: tauri::State<'_, Arc>>, 78 | ) -> Result, String> { 79 | log::info!("get_profiles_list called"); 80 | 81 | let app = match app_state.try_lock() { 82 | Ok(guard) => guard, 83 | Err(e) => { 84 | log::error!("Failed to lock app state: {}", e); 85 | return Err("Failed to access application state".to_string()); 86 | } 87 | }; 88 | 89 | let config_service = app.get_config_service(); 90 | let mut config = match config_service.try_lock() { 91 | Ok(guard) => guard, 92 | Err(e) => { 93 | log::error!("Failed to lock config service: {}", e); 94 | return Err("Failed to access configuration service".to_string()); 95 | } 96 | }; 97 | 98 | match config.get_all_profiles_info() { 99 | Ok(profiles) => { 100 | log::info!("Returning {} profiles", profiles.len()); 101 | Ok(profiles) 102 | } 103 | Err(e) => { 104 | log::error!("Failed to get profiles list: {}", e); 105 | Err(e.to_string()) 106 | } 107 | } 108 | } 109 | 110 | #[tauri::command] 111 | async fn get_profile_status( 112 | profile_id: String, 113 | app_state: tauri::State<'_, Arc>>, 114 | settings_state: tauri::State<'_, std::sync::Mutex>, 115 | ) -> Result { 116 | log::debug!("get_profile_status called for profile: {}", profile_id); 117 | 118 | if profile_id == "current" { 119 | return Ok("".to_string()); // Current profile doesn't have status icon 120 | } 121 | 122 | let app = match app_state.try_lock() { 123 | Ok(guard) => guard, 124 | Err(e) => { 125 | log::error!("Failed to lock app state: {}", e); 126 | return Err("Failed to access application state".to_string()); 127 | } 128 | }; 129 | 130 | let config_service = app.get_config_service(); 131 | let mut config = match config_service.try_lock() { 132 | Ok(guard) => guard, 133 | Err(e) => { 134 | log::error!("Failed to lock config service: {}", e); 135 | return Err("Failed to access configuration service".to_string()); 136 | } 137 | }; 138 | 139 | // Get ignored fields from settings 140 | let ignored_fields = match settings_state.lock() { 141 | Ok(settings) => settings.get_ignored_fields().to_vec(), 142 | Err(e) => { 143 | log::warn!("Failed to get ignored fields from settings: {}, using defaults", e); 144 | crate::UserSettings::get_default_ignored_fields() 145 | } 146 | }; 147 | 148 | match config.read_profile_content(&profile_id) { 149 | Ok(content) => { 150 | let status = config.get_detailed_profile_status_with_ignored_fields(&content, Some(&ignored_fields)); 151 | let icon = match status { 152 | crate::ProfileStatus::FullMatch => "✅", 153 | crate::ProfileStatus::PartialMatch => "🔄", 154 | crate::ProfileStatus::Error(_) => "❌", 155 | crate::ProfileStatus::NoMatch => "", 156 | }; 157 | Ok(icon.to_string()) 158 | } 159 | Err(e) => { 160 | log::error!("Failed to read profile content: {}", e); 161 | Ok("❌".to_string()) 162 | } 163 | } 164 | } 165 | 166 | #[tauri::command] 167 | async fn load_profile_content( 168 | profile_id: String, 169 | app_state: tauri::State<'_, Arc>>, 170 | ) -> Result { 171 | log::info!("load_profile_content called for profile: {}", profile_id); 172 | 173 | // Use blocking lock for load_profile_content to avoid race conditions 174 | // This is critical for the Current profile loading issue 175 | let app = app_state.lock().unwrap_or_else(|poisoned| { 176 | log::warn!("App state lock was poisoned, recovering"); 177 | poisoned.into_inner() 178 | }); 179 | 180 | let config_service = app.get_config_service(); 181 | let mut config = config_service.lock().unwrap_or_else(|poisoned| { 182 | log::warn!("Config service lock was poisoned, recovering"); 183 | poisoned.into_inner() 184 | }); 185 | 186 | match config.read_profile_content(&profile_id) { 187 | Ok(content) => { 188 | log::info!("Successfully loaded content for profile: {}", profile_id); 189 | Ok(content) 190 | } 191 | Err(e) => { 192 | log::error!("Failed to load profile content: {}", e); 193 | Err(e.to_string()) 194 | } 195 | } 196 | } 197 | 198 | #[tauri::command] 199 | async fn save_profile( 200 | profile_id: String, 201 | content: String, 202 | app_state: tauri::State<'_, Arc>>, 203 | ) -> Result<(), String> { 204 | log::info!("save_profile called for profile: {}", profile_id); 205 | 206 | let app = match app_state.try_lock() { 207 | Ok(guard) => guard, 208 | Err(e) => { 209 | log::error!("Failed to lock app state: {}", e); 210 | return Err("Failed to access application state".to_string()); 211 | } 212 | }; 213 | 214 | let config_service = app.get_config_service(); 215 | let mut config = match config_service.try_lock() { 216 | Ok(guard) => guard, 217 | Err(e) => { 218 | log::error!("Failed to lock config service: {}", e); 219 | return Err("Failed to access configuration service".to_string()); 220 | } 221 | }; 222 | 223 | match config.save_profile_content(&profile_id, &content) { 224 | Ok(()) => { 225 | log::info!("Successfully saved profile: {}", profile_id); 226 | Ok(()) 227 | } 228 | Err(e) => { 229 | log::error!("Failed to save profile: {}", e); 230 | Err(e.to_string()) 231 | } 232 | } 233 | } 234 | 235 | #[tauri::command] 236 | async fn create_new_profile( 237 | profile_name: String, 238 | content: String, 239 | app_state: tauri::State<'_, Arc>>, 240 | ) -> Result { 241 | log::info!("create_new_profile called for profile: {}", profile_name); 242 | 243 | let app = match app_state.try_lock() { 244 | Ok(guard) => guard, 245 | Err(e) => { 246 | log::error!("Failed to lock app state: {}", e); 247 | return Err("Failed to access application state".to_string()); 248 | } 249 | }; 250 | 251 | let config_service = app.get_config_service(); 252 | let mut config = match config_service.try_lock() { 253 | Ok(guard) => guard, 254 | Err(e) => { 255 | log::error!("Failed to lock config service: {}", e); 256 | return Err("Failed to access configuration service".to_string()); 257 | } 258 | }; 259 | 260 | let result = config.create_profile(&profile_name, &content); 261 | drop(config); // 释放锁,避免死锁 262 | 263 | match result { 264 | Ok(path) => { 265 | log::info!("Successfully created profile: {} at {}", profile_name, path); 266 | 267 | // Update tray menu to show the new profile 268 | if let Err(e) = app.update_tray_menu() { 269 | log::warn!("Failed to update tray menu after creating profile: {}", e); 270 | // Don't fail the operation since profile creation was successful 271 | } 272 | 273 | Ok(path) 274 | } 275 | Err(e) => { 276 | log::error!("Failed to create profile: {}", e); 277 | Err(e.to_string()) 278 | } 279 | } 280 | } 281 | 282 | #[tauri::command] 283 | async fn delete_profile( 284 | profile_id: String, 285 | app_state: tauri::State<'_, Arc>>, 286 | ) -> Result<(), String> { 287 | log::info!("delete_profile called for profile: {}", profile_id); 288 | 289 | let app = match app_state.try_lock() { 290 | Ok(guard) => guard, 291 | Err(e) => { 292 | log::error!("Failed to lock app state: {}", e); 293 | return Err("Failed to access application state".to_string()); 294 | } 295 | }; 296 | 297 | let config_service = app.get_config_service(); 298 | let mut config = match config_service.try_lock() { 299 | Ok(guard) => guard, 300 | Err(e) => { 301 | log::error!("Failed to lock config service: {}", e); 302 | return Err("Failed to access configuration service".to_string()); 303 | } 304 | }; 305 | 306 | match config.delete_profile(&profile_id) { 307 | Ok(()) => { 308 | log::info!("Successfully deleted profile: {}", profile_id); 309 | Ok(()) 310 | } 311 | Err(e) => { 312 | log::error!("Failed to delete profile: {}", e); 313 | Err(e.to_string()) 314 | } 315 | } 316 | } 317 | 318 | #[tauri::command] 319 | async fn validate_json_content( 320 | content: String, 321 | app_state: tauri::State<'_, Arc>>, 322 | ) -> Result { 323 | log::debug!("validate_json_content called"); 324 | 325 | let app = match app_state.try_lock() { 326 | Ok(guard) => guard, 327 | Err(e) => { 328 | log::error!("Failed to lock app state: {}", e); 329 | return Err("Failed to access application state".to_string()); 330 | } 331 | }; 332 | 333 | let config_service = app.get_config_service(); 334 | let config = match config_service.try_lock() { 335 | Ok(guard) => guard, 336 | Err(e) => { 337 | log::error!("Failed to lock config service: {}", e); 338 | return Err("Failed to access configuration service".to_string()); 339 | } 340 | }; 341 | 342 | match config.validate_json_content(&content) { 343 | Ok(result) => { 344 | log::debug!("JSON validation result: valid={}", result.is_valid); 345 | Ok(result) 346 | } 347 | Err(e) => { 348 | log::error!("Failed to validate JSON content: {}", e); 349 | Err(e.to_string()) 350 | } 351 | } 352 | } 353 | 354 | #[tauri::command] 355 | async fn apply_profile( 356 | profile_id: String, 357 | content: String, 358 | app_state: tauri::State<'_, Arc>>, 359 | ) -> Result<(), String> { 360 | log::info!("apply_profile called for profile: {} with content length: {}", profile_id, content.len()); 361 | 362 | let app = match app_state.try_lock() { 363 | Ok(guard) => guard, 364 | Err(e) => { 365 | log::error!("Failed to lock app state: {}", e); 366 | return Err("Failed to access application state".to_string()); 367 | } 368 | }; 369 | 370 | let config_service = app.get_config_service(); 371 | let mut config = match config_service.try_lock() { 372 | Ok(guard) => guard, 373 | Err(e) => { 374 | log::error!("Failed to lock config service: {}", e); 375 | return Err("Failed to access configuration service".to_string()); 376 | } 377 | }; 378 | 379 | // Apply the profile content directly (this will copy the provided content to default settings.json) 380 | let result = config.apply_profile_content(&content); 381 | drop(config); // 释放锁,避免死锁 382 | 383 | match result { 384 | Ok(()) => { 385 | log::info!("Successfully applied profile content from: {}", profile_id); 386 | 387 | // Update tray menu to reflect the new active profile status 388 | if let Err(e) = app.update_tray_menu() { 389 | log::warn!("Failed to update tray menu after applying profile: {}", e); 390 | // Don't fail the operation since profile application was successful 391 | } 392 | 393 | Ok(()) 394 | } 395 | Err(e) => { 396 | log::error!("Failed to apply profile content from '{}': {}", profile_id, e); 397 | Err(format!("Failed to apply profile: {}", e)) 398 | } 399 | } 400 | } 401 | 402 | #[tauri::command] 403 | async fn close_settings_window(app_handle: tauri::AppHandle) -> Result<(), String> { 404 | if let Some(window) = app_handle.get_webview_window("settings") { 405 | window.close().map_err(|e| e.to_string())?; 406 | } 407 | Ok(()) 408 | } 409 | 410 | #[tauri::command] 411 | async fn exit_application(app_handle: tauri::AppHandle) -> Result<(), String> { 412 | log::info!("Exit application command called"); 413 | 414 | // Emit cleanup event before exit 415 | let _ = app_handle.emit("app_exit_requested", ()); 416 | 417 | // In development mode, try to also terminate the dev server 418 | #[cfg(debug_assertions)] 419 | { 420 | log::info!("Development mode detected, attempting to clean up dev server"); 421 | // Note: In development, the parent process (npm) should handle cleanup 422 | // when the Tauri process exits. If this doesn't work consistently, 423 | // users can use Ctrl+C in the terminal to stop both processes. 424 | } 425 | 426 | // Exit the entire application 427 | app_handle.exit(0); 428 | Ok(()) 429 | } 430 | 431 | #[cfg_attr(mobile, tauri::mobile_entry_point)] 432 | pub fn run() { 433 | tauri::Builder::default() 434 | .plugin(tauri_plugin_log::Builder::default().build()) 435 | .plugin(tauri_plugin_fs::init()) 436 | .plugin(tauri_plugin_dialog::init()) 437 | .plugin(tauri_plugin_shell::init()) 438 | .setup(setup_app) 439 | .invoke_handler(tauri::generate_handler![ 440 | settings_service::get_settings, 441 | settings_service::update_monitor_interval, 442 | settings_service::update_auto_start_monitoring, 443 | settings_service::update_language, 444 | settings_service::update_show_notifications, 445 | settings_service::reset_settings_to_defaults, 446 | settings_service::get_ignored_fields, 447 | settings_service::update_ignored_fields, 448 | settings_service::get_default_ignored_fields, 449 | settings_service::reset_ignored_fields_to_default, 450 | i18n_service::get_current_locale, 451 | i18n_service::set_locale, 452 | i18n_service::get_text, 453 | i18n_service::get_supported_locales, 454 | get_profiles_info, 455 | get_profiles_list, 456 | get_profile_status, 457 | load_profile_content, 458 | save_profile, 459 | apply_profile, 460 | create_new_profile, 461 | delete_profile, 462 | validate_json_content, 463 | close_settings_window, 464 | exit_application, 465 | ]) 466 | .run(tauri::generate_context!()) 467 | .expect("error while running tauri application"); 468 | } 469 | 470 | fn setup_app(app: &mut tauri::App) -> Result<(), Box> { 471 | // Initialize logging 472 | if cfg!(debug_assertions) { 473 | log::info!("CCCS application starting in development mode"); 474 | } 475 | 476 | // Hide dock icon on macOS to make this a pure tray application 477 | #[cfg(target_os = "macos")] 478 | app.set_activation_policy(tauri::ActivationPolicy::Accessory); 479 | 480 | let app_handle = app.handle().clone(); 481 | 482 | // Initialize basic services with error handling 483 | match initialize_services(app) { 484 | Ok(()) => { 485 | log::info!("Services initialized successfully"); 486 | } 487 | Err(e) => { 488 | log::error!("Failed to initialize services: {}", e); 489 | // Continue anyway - the app can still function without some services 490 | } 491 | } 492 | 493 | // Initialize CCCS app and store it in Tauri state 494 | // This needs to be done synchronously to ensure proper state management 495 | match initialize_cccs_app(app_handle.clone()) { 496 | Ok(cccs_app) => { 497 | log::info!("CCCS app created successfully"); 498 | // Store the app instance 499 | app.manage(Arc::new(Mutex::new(cccs_app))); 500 | 501 | // Defer actual initialization to avoid blocking startup 502 | let app_handle_clone = app_handle.clone(); 503 | std::thread::spawn(move || { 504 | // Add delay to ensure UI is ready 505 | std::thread::sleep(std::time::Duration::from_millis(2000)); 506 | 507 | log::info!("Starting delayed CCCS initialization"); 508 | 509 | // Get the app instance and initialize it 510 | if let Some(app_state) = app_handle_clone.try_state::>>() { 511 | let rt = tokio::runtime::Runtime::new().unwrap(); 512 | rt.block_on(async { 513 | let mut app = app_state.lock().unwrap(); 514 | match app.initialize().await { 515 | Ok(()) => { 516 | log::info!("CCCS application initialized successfully"); 517 | } 518 | Err(e) => { 519 | log::error!("Failed to initialize CCCS app: {}", e); 520 | } 521 | } 522 | }); 523 | } 524 | }); 525 | } 526 | Err(e) => { 527 | log::error!("Failed to create CCCS app: {}", e); 528 | // App will continue to run in basic mode 529 | } 530 | } 531 | 532 | log::info!("CCCS setup completed"); 533 | Ok(()) 534 | } 535 | 536 | /// Initialize Tauri state services 537 | fn initialize_services(app: &mut tauri::App) -> Result<(), Box> { 538 | // Initialize settings service 539 | match settings_service::SettingsService::new() { 540 | Ok(settings_service) => { 541 | app.manage(std::sync::Mutex::new(settings_service)); 542 | log::info!("Settings service initialized"); 543 | } 544 | Err(e) => { 545 | log::error!("Failed to initialize settings service: {}", e); 546 | // Use default settings service 547 | let default_settings = settings_service::SettingsService::with_defaults(); 548 | app.manage(std::sync::Mutex::new(default_settings)); 549 | } 550 | } 551 | 552 | // Initialize i18n service 553 | let i18n_service = i18n_service::I18nService::new(); 554 | app.manage(std::sync::Mutex::new(i18n_service)); 555 | log::info!("I18n service initialized"); 556 | 557 | // Note: Config service will be initialized as part of the App instance 558 | 559 | Ok(()) 560 | } 561 | 562 | /// Create the main CCCS application 563 | fn initialize_cccs_app(app_handle: AppHandle) -> Result> { 564 | log::info!("Creating CCCS application instance"); 565 | 566 | // Create the main app with error handling 567 | let cccs_app = match app::App::new(app_handle.clone()) { 568 | Ok(app) => { 569 | log::info!("App instance created successfully"); 570 | app 571 | } 572 | Err(e) => { 573 | log::error!("Failed to create App instance: {}", e); 574 | return Err(e.into()); 575 | } 576 | }; 577 | 578 | Ok(cccs_app) 579 | } 580 | -------------------------------------------------------------------------------- /src-tauri/src/app.rs: -------------------------------------------------------------------------------- 1 | // Application lifecycle management for CCCS 2 | use crate::{ 3 | claude_detector::ClaudeDetector, config_service::ConfigService, i18n_service::I18nService, 4 | monitor_service::MonitorService, settings_service::SettingsService, tray_service::TrayService, 5 | AppError, AppResult, 6 | }; 7 | use std::sync::{Arc, Mutex}; 8 | use tauri::{AppHandle, Emitter, Listener, Manager}; 9 | 10 | pub struct App { 11 | config_service: Arc>, 12 | tray_service: Arc>, 13 | monitor_service: Arc>, 14 | settings_service: Arc>, 15 | i18n_service: Arc>, 16 | app_handle: AppHandle, 17 | is_initialized: bool, 18 | } 19 | 20 | impl App { 21 | /// Create a new application instance 22 | pub fn new(app_handle: AppHandle) -> AppResult { 23 | log::info!("Creating new CCCS application instance"); 24 | 25 | let settings_service = Arc::new(Mutex::new(SettingsService::new()?)); 26 | let i18n_service = Arc::new(Mutex::new(I18nService::new())); 27 | 28 | // Get settings for monitor interval 29 | let monitor_interval = { 30 | let settings = settings_service.lock().unwrap(); 31 | settings.get_current_settings().monitor_interval_minutes 32 | }; 33 | 34 | // These will be initialized later during the initialization process 35 | let config_service = Arc::new(Mutex::new(ConfigService::new(std::env::temp_dir()))); // Placeholder 36 | let tray_service = Arc::new(Mutex::new(TrayService::new(app_handle.clone()))); 37 | let monitor_service = Arc::new(Mutex::new(MonitorService::new(monitor_interval))); 38 | 39 | Ok(Self { 40 | config_service, 41 | tray_service, 42 | monitor_service, 43 | settings_service, 44 | i18n_service, 45 | app_handle, 46 | is_initialized: false, 47 | }) 48 | } 49 | 50 | /// Initialize the application 51 | pub async fn initialize(&mut self) -> AppResult<()> { 52 | log::info!("Initializing CCCS application"); 53 | 54 | if self.is_initialized { 55 | log::warn!("Application already initialized"); 56 | return Ok(()); 57 | } 58 | 59 | // Step 1: Detect Claude Code installation 60 | let claude_dir = match ClaudeDetector::detect_claude_installation() { 61 | Ok(dir) => dir, 62 | Err(AppError::ClaudeNotFound) => { 63 | log::info!("Claude Code not found, showing directory picker"); 64 | 65 | match ClaudeDetector::show_directory_picker(&self.app_handle).await? { 66 | Some(dir) => dir, 67 | None => { 68 | log::info!("User cancelled directory selection, exiting"); 69 | self.app_handle.exit(0); 70 | return Ok(()); 71 | } 72 | } 73 | } 74 | Err(e) => return Err(e), 75 | }; 76 | 77 | // Step 2: Validate default configuration 78 | ClaudeDetector::validate_default_config(&claude_dir)?; 79 | 80 | // Step 3: Initialize configuration service with real Claude directory 81 | { 82 | let mut config_service = self.config_service.lock().unwrap(); 83 | *config_service = ConfigService::new(claude_dir.clone()); 84 | 85 | // Scan for profiles 86 | config_service.scan_profiles()?; 87 | } 88 | 89 | // Step 4: Setup file monitoring 90 | self.setup_monitoring().await?; 91 | 92 | // Step 5: Create system tray 93 | self.setup_tray().await?; 94 | 95 | // Step 6: Setup event listeners 96 | self.setup_event_listeners().await?; 97 | 98 | self.is_initialized = true; 99 | log::info!("CCCS application initialized successfully"); 100 | 101 | Ok(()) 102 | } 103 | 104 | /// Setup file monitoring 105 | async fn setup_monitoring(&self) -> AppResult<()> { 106 | log::info!("Setting up file monitoring"); 107 | 108 | let config_service = Arc::clone(&self.config_service); 109 | let tray_service = Arc::clone(&self.tray_service); 110 | let app_handle = self.app_handle.clone(); 111 | 112 | let mut monitor_service = self.monitor_service.lock().unwrap(); 113 | 114 | // Add files to monitor 115 | let monitored_files = { 116 | let config = config_service.lock().unwrap(); 117 | config.get_monitored_files() 118 | }; 119 | 120 | for file in monitored_files { 121 | monitor_service.add_file_to_monitor(file); 122 | } 123 | 124 | // Start monitoring if auto-start is enabled 125 | let should_auto_start = { 126 | let settings = self.settings_service.lock().unwrap(); 127 | settings.get_current_settings().auto_start_monitoring 128 | }; 129 | 130 | if should_auto_start { 131 | let callback = move |changes: Vec| { 132 | log::info!("File changes detected: {} files changed", changes.len()); 133 | 134 | // Update configuration service 135 | if let Ok(mut config) = config_service.lock() { 136 | if let Err(e) = config.refresh_profile_status() { 137 | log::error!("Failed to refresh profile status: {}", e); 138 | } 139 | 140 | // Update tray menu with detailed status 141 | if let Ok(mut tray) = tray_service.lock() { 142 | let profiles = config.get_profiles(); 143 | let statuses = config.compare_profiles(); 144 | if let Err(e) = tray.update_menu_with_detailed_status(profiles, &statuses) { 145 | log::error!("Failed to update tray menu: {}", e); 146 | } 147 | } 148 | } 149 | 150 | // Emit event to notify frontend 151 | let _ = app_handle.emit("profiles_changed", ()); 152 | }; 153 | 154 | monitor_service.start_monitoring(callback)?; 155 | } 156 | 157 | Ok(()) 158 | } 159 | 160 | /// Setup system tray 161 | async fn setup_tray(&self) -> AppResult<()> { 162 | log::info!("Setting up system tray"); 163 | 164 | let mut tray_service = self.tray_service.lock().unwrap(); 165 | tray_service.create_tray()?; 166 | 167 | // Update tray menu with initial profiles and detailed status 168 | let (profiles, statuses) = { 169 | let config = self.config_service.lock().unwrap(); 170 | let profiles = config.get_profiles().to_vec(); 171 | let statuses = config.compare_profiles(); 172 | (profiles, statuses) 173 | }; 174 | 175 | tray_service.update_menu_with_detailed_status(&profiles, &statuses)?; 176 | 177 | // Set tooltip 178 | let tooltip = { 179 | let i18n = self.i18n_service.lock().unwrap(); 180 | let active_profile = profiles 181 | .iter() 182 | .enumerate() 183 | .find(|(i, _)| matches!(statuses[*i], crate::ProfileStatus::FullMatch)) 184 | .map(|(_, p)| p.name.as_str()); 185 | i18n.get_tray_tooltip(profiles.len(), active_profile) 186 | }; 187 | tray_service.set_tooltip(&tooltip)?; 188 | 189 | Ok(()) 190 | } 191 | 192 | /// Setup event listeners 193 | async fn setup_event_listeners(&self) -> AppResult<()> { 194 | log::info!("Setting up event listeners"); 195 | 196 | let config_service = Arc::clone(&self.config_service); 197 | let tray_service = Arc::clone(&self.tray_service); 198 | let i18n_service = Arc::clone(&self.i18n_service); 199 | 200 | // Listen for profile switch requests from tray 201 | let config_service_clone = Arc::clone(&config_service); 202 | let tray_service_clone = Arc::clone(&tray_service); 203 | let i18n_service_clone = Arc::clone(&i18n_service); 204 | let _app_handle_for_switch = self.app_handle.clone(); 205 | 206 | self.app_handle 207 | .listen("profile_switch_requested", move |event| { 208 | // Parse payload manually since as_str() is unstable 209 | if let Ok(profile_name) = serde_json::from_str::(event.payload()) { 210 | log::info!("Profile switch requested: {}", profile_name); 211 | 212 | // Show switching status 213 | if let Ok(mut tray) = tray_service_clone.lock() { 214 | let _ = tray.update_profile_status(&profile_name, "❕"); 215 | } 216 | 217 | // Perform switch 218 | let result = { 219 | let mut config = config_service_clone.lock().unwrap(); 220 | config.switch_profile(&profile_name) 221 | }; 222 | 223 | match result { 224 | Ok(()) => { 225 | log::info!("Profile switched successfully: {}", profile_name); 226 | 227 | // Update tray menu with detailed status 228 | if let (Ok(config), Ok(mut tray)) = 229 | (config_service_clone.lock(), tray_service_clone.lock()) 230 | { 231 | let profiles = config.get_profiles(); 232 | let statuses = config.compare_profiles(); 233 | let _ = tray.update_menu_with_detailed_status(profiles, &statuses); 234 | 235 | // Update tooltip 236 | if let Ok(i18n) = i18n_service_clone.lock() { 237 | let active_profile = profiles 238 | .iter() 239 | .enumerate() 240 | .find(|(i, _)| { 241 | matches!(statuses[*i], crate::ProfileStatus::FullMatch) 242 | }) 243 | .map(|(_, p)| p.name.as_str()); 244 | let tooltip = 245 | i18n.get_tray_tooltip(profiles.len(), active_profile); 246 | let _ = tray.set_tooltip(&tooltip); 247 | } 248 | } 249 | } 250 | Err(e) => { 251 | log::error!("Failed to switch profile {}: {}", profile_name, e); 252 | 253 | // Reset status on error 254 | if let Ok(mut tray) = tray_service_clone.lock() { 255 | let _ = tray.update_profile_status(&profile_name, ""); 256 | } 257 | } 258 | } 259 | } 260 | }); 261 | 262 | // Listen for settings menu clicks 263 | let app_handle_clone = self.app_handle.clone(); 264 | self.app_handle.listen("menu_settings_clicked", move |_| { 265 | log::info!("Settings menu clicked"); 266 | 267 | // Simply emit event to trigger settings window creation (no async needed) 268 | let _ = app_handle_clone.emit("open_settings_window", ()); 269 | }); 270 | 271 | // Listen for open settings window requests 272 | let app_handle_clone2 = self.app_handle.clone(); 273 | self.app_handle.listen("open_settings_window", move |_| { 274 | log::info!("Opening settings window"); 275 | 276 | // Check if settings window already exists and is visible 277 | if let Some(window) = app_handle_clone2.get_webview_window("settings") { 278 | log::info!("Settings window already exists, checking visibility and focusing it"); 279 | // Try to show and focus the window - if it was closed, this will make it visible again 280 | match window.show() { 281 | Ok(_) => { 282 | log::info!("Successfully showed existing settings window"); 283 | let _ = window.set_focus(); 284 | let _ = window.unminimize(); // In case it was minimized 285 | return; 286 | } 287 | Err(e) => { 288 | log::warn!("Failed to show existing settings window: {}, creating new one", e); 289 | // Continue to create a new window 290 | } 291 | } 292 | } 293 | 294 | // Calculate adaptive window size based on screen resolution with improved cross-platform logic 295 | let (window_width, window_height, min_width, min_height) = { 296 | // Try to get primary monitor size for adaptive sizing 297 | if let Ok(monitors) = app_handle_clone2.primary_monitor() { 298 | if let Some(monitor) = monitors { 299 | let screen_size = monitor.size(); 300 | log::info!("Primary monitor size: {}x{}", screen_size.width, screen_size.height); 301 | 302 | // Improved adaptive algorithm: 303 | // - Use absolute sizing with reasonable bounds 304 | // - Consider different screen densities and platforms 305 | let (adaptive_width, adaptive_height) = if screen_size.width >= 1920 { 306 | // High resolution displays (1920x1080+, 4K, Retina) 307 | if screen_size.height >= 1440 { 308 | // 4K or ultrawide displays 309 | (1000.0, 900.0) 310 | } else { 311 | // 1920x1080, 2560x1440 312 | (900.0, 800.0) 313 | } 314 | } else if screen_size.width >= 1366 { 315 | // Medium resolution (1366x768, 1440x900) 316 | (800.0, 750.0) 317 | } else { 318 | // Low resolution or small screens 319 | (750.0, 700.0) 320 | }; 321 | 322 | // Set conservative minimum sizes to ensure UI elements fit properly 323 | let min_w = 500.0; 324 | let min_h = 600.0; 325 | 326 | log::info!("Calculated adaptive window size: {}x{} (min: {}x{}) for screen {}x{}", 327 | adaptive_width, adaptive_height, min_w, min_h, screen_size.width, screen_size.height); 328 | 329 | (adaptive_width, adaptive_height, min_w, min_h) 330 | } else { 331 | log::warn!("No primary monitor found, using default sizes"); 332 | (900.0, 800.0, 500.0, 600.0) // Conservative defaults 333 | } 334 | } else { 335 | log::warn!("Failed to get monitor info, using default sizes"); 336 | (900.0, 800.0, 500.0, 600.0) // Conservative defaults 337 | } 338 | }; 339 | 340 | // Create settings window 341 | match tauri::WebviewWindowBuilder::new( 342 | &app_handle_clone2, 343 | "settings", 344 | tauri::WebviewUrl::App("settings.html".into()), 345 | ) 346 | .title("CCCS Settings") 347 | .inner_size(window_width, window_height) 348 | .min_inner_size(min_width, min_height) 349 | .center() 350 | .resizable(true) 351 | .visible(false) // Start hidden to prevent flashing 352 | .on_page_load(|window, _payload| { 353 | // Inject initialization script after page loads 354 | log::info!("Settings page loaded, injecting init script"); 355 | let init_script = r#" 356 | console.log('Page load hook: Checking Tauri API...'); 357 | if (window.__TAURI__) { 358 | console.log('Tauri API is available!'); 359 | } else { 360 | console.error('Tauri API is NOT available in page load hook'); 361 | } 362 | "#; 363 | if let Err(e) = window.eval(init_script) { 364 | log::error!("Failed to inject init script: {}", e); 365 | } 366 | 367 | // Show window after a fixed delay to allow for content loading 368 | let window_clone = window.clone(); 369 | std::thread::spawn(move || { 370 | std::thread::sleep(std::time::Duration::from_millis(300)); // Increased delay 371 | let _ = window_clone.show(); 372 | let _ = window_clone.set_focus(); 373 | }); 374 | }) 375 | .build() 376 | { 377 | Ok(_window) => { 378 | log::info!("Settings window created successfully"); 379 | // Don't call show() here - it will be called after page load 380 | } 381 | Err(e) => { 382 | log::error!("Failed to create settings window: {}", e); 383 | } 384 | } 385 | }); 386 | 387 | // Listen for tray icon hover events 388 | let config_service = Arc::clone(&self.config_service); 389 | let tray_service = Arc::clone(&self.tray_service); 390 | let _app_handle_for_hover = self.app_handle.clone(); 391 | self.app_handle.listen("tray_icon_hover", move |_| { 392 | log::info!("Tray icon hover detected, refreshing profiles"); 393 | 394 | // Refresh profiles synchronously 395 | if let Ok(mut config) = config_service.lock() { 396 | if let Err(e) = config.scan_profiles() { 397 | log::error!("Failed to scan profiles on hover: {}", e); 398 | return; 399 | } 400 | 401 | // Update tray menu with fresh profiles and detailed status 402 | if let Ok(mut tray) = tray_service.lock() { 403 | let profiles = config.get_profiles(); 404 | let statuses = config.compare_profiles(); 405 | let _ = tray.update_menu_with_detailed_status(profiles, &statuses); 406 | } 407 | } 408 | }); 409 | 410 | // Listen for app exit requests 411 | let monitor_service = Arc::clone(&self.monitor_service); 412 | self.app_handle.listen("app_exit_requested", move |_| { 413 | log::info!("Application exit requested"); 414 | 415 | // Stop monitoring 416 | if let Ok(mut monitor) = monitor_service.lock() { 417 | monitor.stop_monitoring(); 418 | } 419 | 420 | log::info!("Application cleanup completed"); 421 | }); 422 | 423 | Ok(()) 424 | } 425 | 426 | /// Update monitor interval 427 | #[allow(dead_code)] 428 | pub async fn update_monitor_interval(&self, minutes: u64) -> AppResult<()> { 429 | log::info!("Updating monitor interval to {} minutes", minutes); 430 | 431 | let mut monitor_service = self.monitor_service.lock().unwrap(); 432 | monitor_service.set_monitor_interval(minutes)?; 433 | 434 | Ok(()) 435 | } 436 | 437 | /// Shutdown the application gracefully 438 | #[allow(dead_code)] 439 | pub async fn shutdown(&self) -> AppResult<()> { 440 | log::info!("Shutting down CCCS application"); 441 | 442 | // Stop monitoring 443 | { 444 | let mut monitor_service = self.monitor_service.lock().unwrap(); 445 | monitor_service.stop_monitoring(); 446 | } 447 | 448 | // Clean up resources 449 | log::info!("Application shutdown completed"); 450 | 451 | Ok(()) 452 | } 453 | 454 | /// Check if application is initialized 455 | #[allow(dead_code)] 456 | pub fn is_initialized(&self) -> bool { 457 | self.is_initialized 458 | } 459 | 460 | /// Get reference to config service 461 | pub fn get_config_service(&self) -> Arc> { 462 | Arc::clone(&self.config_service) 463 | } 464 | 465 | /// Get reference to settings service for testing 466 | #[cfg(test)] 467 | pub fn get_settings_service(&self) -> Arc> { 468 | Arc::clone(&self.settings_service) 469 | } 470 | 471 | /// Update tray menu with current profile status 472 | pub fn update_tray_menu(&self) -> AppResult<()> { 473 | log::info!("Updating tray menu with current profile status"); 474 | 475 | let config_service = Arc::clone(&self.config_service); 476 | let tray_service = Arc::clone(&self.tray_service); 477 | 478 | if let Ok(config) = config_service.lock() { 479 | if let Ok(mut tray) = tray_service.lock() { 480 | let profiles = config.get_profiles(); 481 | let statuses = config.compare_profiles(); 482 | if let Err(e) = tray.update_menu_with_detailed_status(profiles, &statuses) { 483 | log::error!("Failed to update tray menu: {}", e); 484 | return Err(AppError::from(e)); 485 | } 486 | 487 | // Emit event to notify frontend 488 | let _ = self.app_handle.emit("profiles_changed", ()); 489 | } 490 | } 491 | 492 | Ok(()) 493 | } 494 | } 495 | 496 | #[cfg(test)] 497 | mod tests { 498 | use super::*; 499 | 500 | #[test] 501 | fn test_app_structure() { 502 | // Simple compilation test for the App structure 503 | assert!(true); 504 | } 505 | } 506 | --------------------------------------------------------------------------------