11 Commits

Author SHA1 Message Date
YoVinchen
09a87c97b8 fix(backend): remove unnecessary dereference in backup operation
- Simplify Backup::new call by removing redundant dereference
- MutexGuard already implements DerefMut
2025-11-24 00:49:24 +08:00
YoVinchen
d31531f88b feat(i18n): update translations for SQL backup feature
- Update Chinese translations for SQL import/export
- Update English translations for SQL import/export
- Change terminology from 'config file' to 'SQL backup'
- Update error messages and UI hints
2025-11-24 00:47:48 +08:00
YoVinchen
ae21754d50 feat(frontend): update import/export UI for SQL backup
- Change default export filename from .json to .sql
- Update file format to timestamp format (YYYYMMDD_HHMMSS)
- Update error messages to reference SQL files
- Align with backend SQL export/import implementation
2025-11-24 00:46:58 +08:00
YoVinchen
7c1f13e4f3 refactor(backend): migrate settings storage to database
- Add bind_db function to initialize database-backed settings
- Implement load_initial_settings with database fallback
- Replace direct file save with settings store management
- Add SETTINGS_DB static for database binding
- Maintain backward compatibility with file-based settings
- Keep settings.json for legacy migration support
2025-11-24 00:46:32 +08:00
YoVinchen
fd25c9949f refactor(backend): migrate import/export to use SQL backup
- Reimplement export_config_to_file to use database.export_sql
- Reimplement import_config_from_file to use database.import_sql
- Add sync_current_from_db to sync live configs after import
- Add settings database binding on app initialization
- Remove deprecated JSON-based config import logic
2025-11-24 00:46:00 +08:00
YoVinchen
6443dc897d feat(backend): add database SQL export/import with backup
- Enable rusqlite backup feature for SQL dump support
- Implement export_sql to generate SQLite-compatible SQL dumps
- Implement import_sql with automatic backup before import
- Add snapshot_to_memory to avoid long-held database locks
- Add backup rotation to retain latest 10 backups
- Support atomic import with rollback on failure
2025-11-24 00:45:34 +08:00
YoVinchen
7aa381cbb7 feat(deeplink): display all four Claude model fields in import dialog
- Show haiku/sonnet/opus/multiModel fields conditionally for Claude
- Maintain single model field display for Codex and Gemini
- Add i18n translations for new model field labels (zh/en)
2025-11-23 20:35:28 +08:00
YoVinchen
1de3f1b7f8 docs(frontend): add code comments and improve formatting
- Add explanatory comment in EditProviderDialog about config assembly
- Improve import formatting in SkillsPage for better readability
2025-11-23 16:30:30 +08:00
YoVinchen
edc71efe4c fix(skills): auto-sync locally installed skills to database
Add automatic synchronization in get_skills command:
- Detect locally installed skills in ~/.claude/skills/
- Auto-add to database if not already tracked
- Ensures existing skills are recognized on first launch

This fixes the issue where user's existing skills were not
imported into the database on initial application run.
2025-11-23 16:25:55 +08:00
YoVinchen
3faf22f1c9 feat(init): implement automatic data import on first launch
Add comprehensive first-launch data import system:

Database layer:
- Add is_empty_for_first_import() to detect empty database
- Add init_default_skill_repos() to initialize 3 default skill repositories

Services layer:
- Implement McpService::import_from_claude/codex/gemini()
  to import MCP servers from existing config files
- Implement PromptService::import_from_file_on_first_launch()
  to import prompt files (CLAUDE.md, AGENTS.md, GEMINI.md)

Startup flow (lib.rs):
- Check if database is empty on startup
- Import existing configurations if detected:
  1. Initialize default skill repositories
  2. Import provider configurations from live settings
  3. Import MCP servers from config files
  4. Import prompt files
- All imports are fault-tolerant and logged

This ensures seamless migration from file-based configs to database.
2025-11-23 16:24:54 +08:00
YoVinchen
0cb8b30f15 refactor(backend): replace unsafe unwrap calls with proper error handling
- Add to_json_string helper for safe JSON serialization
- Add lock_conn macro for safe Mutex locking
- Replace 41 unwrap() calls with proper error handling:
  - database.rs: JSON serialization and Mutex operations (31 fixes)
  - lib.rs: macOS NSWindow and tray icon handling (3 fixes)
  - services/provider.rs: Claude model normalization (1 fix)
  - services/prompt.rs: timestamp generation (3 fixes)
  - services/skill.rs: directory name extraction (2 fixes)
  - mcp.rs: HashMap initialization and type conversions (5 fixes)
  - app_config.rs: timestamp fallback (1 fix)

This improves application stability and prevents potential panics.
2025-11-23 16:23:18 +08:00
18 changed files with 965 additions and 191 deletions

View File

@@ -51,7 +51,7 @@ url = "2.5"
auto-launch = "0.5"
once_cell = "1.21.3"
base64 = "0.22"
rusqlite = { version = "0.31", features = ["bundled"] }
rusqlite = { version = "0.31", features = ["bundled", "backup"] }
indexmap = { version = "2", features = ["serde"] }
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]

View File

@@ -494,8 +494,11 @@ impl MultiAppConfig {
// 创建提示词对象
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
.map(|d| d.as_secs() as i64)
.unwrap_or_else(|_| {
log::warn!("Failed to get system time, using 0 as timestamp");
0
});
let id = format!("auto-imported-{timestamp}");
let prompt = crate::prompt::Prompt {

View File

@@ -6,20 +6,22 @@ use tauri::State;
use tauri_plugin_dialog::DialogExt;
use crate::error::AppError;
use crate::services::ConfigService;
use crate::services::provider::ProviderService;
use crate::store::AppState;
/// 导出配置文件
/// 导出数据库为 SQL 备份
#[tauri::command]
pub async fn export_config_to_file(
#[allow(non_snake_case)] filePath: String,
state: State<'_, AppState>,
) -> Result<Value, String> {
let db = state.db.clone();
tauri::async_runtime::spawn_blocking(move || {
let target_path = PathBuf::from(&filePath);
ConfigService::export_config_to_path(&target_path)?;
db.export_sql(&target_path)?;
Ok::<_, AppError>(json!({
"success": true,
"message": "Configuration exported successfully",
"message": "SQL exported successfully",
"filePath": filePath
}))
})
@@ -28,65 +30,49 @@ pub async fn export_config_to_file(
.map_err(|e: AppError| e.to_string())
}
/// 从文件导入配置
/// TODO: 需要重构以使用数据库而不是 JSON 配置
/// 从 SQL 备份导入数据库
#[tauri::command]
pub async fn import_config_from_file(
#[allow(non_snake_case)] _filePath: String,
_state: State<'_, AppState>,
#[allow(non_snake_case)] filePath: String,
state: State<'_, AppState>,
) -> Result<Value, String> {
// TODO: 实现基于数据库的导入逻辑
// 当前暂时禁用此功能
Err("配置导入功能正在重构中,暂时不可用".to_string())
/* 旧的实现,需要重构:
let (new_config, backup_id) = tauri::async_runtime::spawn_blocking(move || {
let db = state.db.clone();
let db_for_state = db.clone();
tauri::async_runtime::spawn_blocking(move || {
let path_buf = PathBuf::from(&filePath);
ConfigService::load_config_for_import(&path_buf)
let backup_id = db.import_sql(&path_buf)?;
// 导入后同步当前供应商到各自的 live 配置
let app_state = AppState::new(db_for_state);
if let Err(err) = ProviderService::sync_current_from_db(&app_state) {
log::warn!("导入后同步 live 配置失败: {err}");
}
Ok::<_, AppError>(json!({
"success": true,
"message": "SQL imported successfully",
"backupId": backup_id
}))
})
.await
.map_err(|e| format!("导入配置失败: {e}"))?
.map_err(|e: AppError| e.to_string())?;
{
let mut guard = state
.config
.write()
.map_err(|e| AppError::from(e).to_string())?;
*guard = new_config;
}
Ok(json!({
"success": true,
"message": "Configuration imported successfully",
"backupId": backup_id
}))
*/
.map_err(|e: AppError| e.to_string())
}
/// 同步当前供应商配置到对应的 live 文件
/// TODO: 需要重构以使用数据库而不是 JSON 配置
#[tauri::command]
pub async fn sync_current_providers_live(_state: State<'_, AppState>) -> Result<Value, String> {
// TODO: 实现基于数据库的同步逻辑
// 当前暂时禁用此功能
Err("配置同步功能正在重构中,暂时不可用".to_string())
/* 旧的实现,需要重构:
{
let mut config_state = state
.config
.write()
.map_err(|e| AppError::from(e).to_string())?;
ConfigService::sync_current_providers_to_live(&mut config_state)
.map_err(|e| e.to_string())?;
}
Ok(json!({
"success": true,
"message": "Live configuration synchronized"
}))
*/
pub async fn sync_current_providers_live(state: State<'_, AppState>) -> Result<Value, String> {
let db = state.db.clone();
tauri::async_runtime::spawn_blocking(move || {
let app_state = AppState::new(db);
ProviderService::sync_current_from_db(&app_state)?;
Ok::<_, AppError>(json!({
"success": true,
"message": "Live configuration synchronized"
}))
})
.await
.map_err(|e| format!("同步当前供应商失败: {e}"))?
.map_err(|e: AppError| e.to_string())
}
/// 保存文件对话框
@@ -98,7 +84,7 @@ pub async fn save_file_dialog<R: tauri::Runtime>(
let dialog = app.dialog();
let result = dialog
.file()
.add_filter("JSON", &["json"])
.add_filter("SQL", &["sql"])
.set_file_name(&defaultName)
.blocking_save_file();
@@ -113,7 +99,7 @@ pub async fn open_file_dialog<R: tauri::Runtime>(
let dialog = app.dialog();
let result = dialog
.file()
.add_filter("JSON", &["json"])
.add_filter("SQL", &["sql"])
.blocking_pick_file();
Ok(result.map(|p| p.to_string()))

View File

@@ -15,11 +15,32 @@ pub async fn get_skills(
) -> Result<Vec<Skill>, String> {
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
service
let skills = service
.0
.list_skills(repos)
.await
.map_err(|e| e.to_string())
.map_err(|e| e.to_string())?;
// 自动同步本地已安装的 skills 到数据库
// 这样用户在首次运行时,已有的 skills 会被自动记录
let existing_states = app_state.db.get_skills().unwrap_or_default();
for skill in &skills {
if skill.installed && !existing_states.contains_key(&skill.directory) {
// 本地有该 skill但数据库中没有记录自动添加
if let Err(e) = app_state.db.update_skill_state(
&skill.directory,
&SkillState {
installed: true,
installed_at: Utc::now(),
},
) {
log::warn!("同步本地 skill {} 状态到数据库失败: {}", skill.directory, e);
}
}
}
Ok(skills)
}
#[tauri::command]

View File

@@ -4,10 +4,34 @@ use crate::error::AppError;
use crate::prompt::Prompt;
use crate::provider::{Provider, ProviderMeta};
use crate::services::skill::{SkillRepo, SkillState};
use chrono::Utc;
use indexmap::IndexMap;
use rusqlite::{params, Connection, Result};
use rusqlite::backup::Backup;
use rusqlite::types::ValueRef;
use rusqlite::{params, Connection};
use serde::Serialize;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use tempfile::NamedTempFile;
/// 安全地序列化 JSON避免 unwrap panic
fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
serde_json::to_string(value)
.map_err(|e| AppError::Config(format!("JSON serialization failed: {e}")))
}
/// 安全地获取 Mutex 锁,避免 unwrap panic
macro_rules! lock_conn {
($mutex:expr) => {
$mutex
.lock()
.map_err(|e| AppError::Database(format!("Mutex lock failed: {}", e)))?
};
}
const DB_BACKUP_RETAIN: usize = 10;
pub struct Database {
// 使用 Mutex 包装 Connection 以支持在多线程环境(如 Tauri State中共享
@@ -40,8 +64,11 @@ impl Database {
}
fn create_tables(&self) -> Result<(), AppError> {
let conn = self.conn.lock().unwrap();
let conn = lock_conn!(self.conn);
Self::create_tables_on_conn(&conn)
}
fn create_tables_on_conn(conn: &Connection) -> Result<(), AppError> {
// 1. Providers 表
conn.execute(
"CREATE TABLE IF NOT EXISTS providers (
@@ -150,9 +177,311 @@ impl Database {
Ok(())
}
/// 创建内存快照以避免长时间持有数据库锁
fn snapshot_to_memory(&self) -> Result<Connection, AppError> {
let conn = lock_conn!(self.conn);
let mut snapshot =
Connection::open_in_memory().map_err(|e| AppError::Database(e.to_string()))?;
{
let backup =
Backup::new(&conn, &mut snapshot).map_err(|e| AppError::Database(e.to_string()))?;
backup
.step(-1)
.map_err(|e| AppError::Database(e.to_string()))?;
}
Ok(snapshot)
}
/// 导出为 SQLite 兼容的 SQL 文本
pub fn export_sql(&self, target_path: &Path) -> Result<(), AppError> {
let snapshot = self.snapshot_to_memory()?;
let dump = Self::dump_sql(&snapshot)?;
if let Some(parent) = target_path.parent() {
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
crate::config::atomic_write(target_path, dump.as_bytes())
}
/// 从 SQL 文件导入,返回生成的备份 ID若无备份则为空字符串
pub fn import_sql(&self, source_path: &Path) -> Result<String, AppError> {
if !source_path.exists() {
return Err(AppError::InvalidInput(format!(
"SQL 文件不存在: {}",
source_path.display()
)));
}
let sql_raw = fs::read_to_string(source_path).map_err(|e| AppError::io(source_path, e))?;
let sql_content = Self::sanitize_import_sql(&sql_raw);
// 导入前备份现有数据库
let backup_path = self.backup_database_file()?;
// 在临时数据库执行导入,确保失败不会污染主库
let temp_file = NamedTempFile::new().map_err(|e| AppError::IoContext {
context: "创建临时数据库文件失败".to_string(),
source: e,
})?;
let temp_path = temp_file.path().to_path_buf();
let temp_conn =
Connection::open(&temp_path).map_err(|e| AppError::Database(e.to_string()))?;
temp_conn
.execute_batch(&sql_content)
.map_err(|e| AppError::Database(format!("执行 SQL 导入失败: {e}")))?;
// 补齐缺失表/索引并进行基础校验
Self::create_tables_on_conn(&temp_conn)?;
Self::validate_basic_state(&temp_conn)?;
// 使用 Backup 将临时库原子写回主库
{
let mut main_conn = lock_conn!(self.conn);
let backup = Backup::new(&temp_conn, &mut main_conn)
.map_err(|e| AppError::Database(e.to_string()))?;
backup
.step(-1)
.map_err(|e| AppError::Database(e.to_string()))?;
}
let backup_id = backup_path
.and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string()))
.unwrap_or_default();
Ok(backup_id)
}
/// 移除 SQLite 保留对象相关语句(如 sqlite_sequence避免导入报错
fn sanitize_import_sql(sql: &str) -> String {
let mut cleaned = String::new();
let lower_keyword = "sqlite_sequence";
for stmt in sql.split(';') {
let trimmed = stmt.trim();
if trimmed.is_empty() {
continue;
}
if trimmed.to_ascii_lowercase().contains(lower_keyword) {
continue;
}
cleaned.push_str(trimmed);
cleaned.push_str(";\n");
}
cleaned
}
/// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 None
fn backup_database_file(&self) -> Result<Option<PathBuf>, AppError> {
let db_path = get_app_config_dir().join("cc-switch.db");
if !db_path.exists() {
return Ok(None);
}
let backup_dir = db_path
.parent()
.ok_or_else(|| AppError::Config("无效的数据库路径".to_string()))?
.join("backups");
fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?;
let backup_id = format!("db_backup_{}", Utc::now().format("%Y%m%d_%H%M%S"));
let backup_path = backup_dir.join(format!("{backup_id}.db"));
{
let conn = lock_conn!(self.conn);
let mut dest_conn =
Connection::open(&backup_path).map_err(|e| AppError::Database(e.to_string()))?;
let backup = Backup::new(&conn, &mut dest_conn)
.map_err(|e| AppError::Database(e.to_string()))?;
backup
.step(-1)
.map_err(|e| AppError::Database(e.to_string()))?;
}
Self::cleanup_db_backups(&backup_dir)?;
Ok(Some(backup_path))
}
fn cleanup_db_backups(dir: &Path) -> Result<(), AppError> {
let entries = match fs::read_dir(dir) {
Ok(iter) => iter
.filter_map(|entry| entry.ok())
.filter(|entry| {
entry
.path()
.extension()
.map(|ext| ext == "db")
.unwrap_or(false)
})
.collect::<Vec<_>>(),
Err(_) => return Ok(()),
};
if entries.len() <= DB_BACKUP_RETAIN {
return Ok(());
}
let remove_count = entries.len().saturating_sub(DB_BACKUP_RETAIN);
let mut sorted = entries;
sorted.sort_by_key(|entry| entry.metadata().and_then(|m| m.modified()).ok());
for entry in sorted.into_iter().take(remove_count) {
if let Err(err) = fs::remove_file(entry.path()) {
log::warn!("删除旧数据库备份失败 {}: {}", entry.path().display(), err);
}
}
Ok(())
}
fn validate_basic_state(conn: &Connection) -> Result<(), AppError> {
let provider_count: i64 = conn
.query_row("SELECT COUNT(*) FROM providers", [], |row| row.get(0))
.map_err(|e| AppError::Database(e.to_string()))?;
let mcp_count: i64 = conn
.query_row("SELECT COUNT(*) FROM mcp_servers", [], |row| row.get(0))
.map_err(|e| AppError::Database(e.to_string()))?;
if provider_count == 0 && mcp_count == 0 {
return Err(AppError::Config(
"导入的 SQL 未包含有效的供应商或 MCP 数据".to_string(),
));
}
Ok(())
}
fn dump_sql(conn: &Connection) -> Result<String, AppError> {
let mut output = String::new();
let timestamp = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
let user_version: i64 = conn
.query_row("PRAGMA user_version;", [], |row| row.get(0))
.unwrap_or(0);
output.push_str(&format!(
"-- CC Switch SQLite 导出\n-- 生成时间: {timestamp}\n-- user_version: {user_version}\n"
));
output.push_str("PRAGMA foreign_keys=OFF;\n");
output.push_str(&format!("PRAGMA user_version={user_version};\n"));
output.push_str("BEGIN TRANSACTION;\n");
// 导出 schema
let mut stmt = conn
.prepare(
"SELECT type, name, tbl_name, sql
FROM sqlite_master
WHERE sql NOT NULL AND type IN ('table','index','trigger','view')
ORDER BY type='table' DESC, name",
)
.map_err(|e| AppError::Database(e.to_string()))?;
let mut tables = Vec::new();
let mut rows = stmt
.query([])
.map_err(|e| AppError::Database(e.to_string()))?;
while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
let obj_type: String = row.get(0).map_err(|e| AppError::Database(e.to_string()))?;
let name: String = row.get(1).map_err(|e| AppError::Database(e.to_string()))?;
let sql: String = row.get(3).map_err(|e| AppError::Database(e.to_string()))?;
// 跳过 SQLite 内部对象(如 sqlite_sequence
if name.starts_with("sqlite_") {
continue;
}
output.push_str(&sql);
output.push_str(";\n");
if obj_type == "table" && !name.starts_with("sqlite_") {
tables.push(name);
}
}
// 导出数据
for table in tables {
let columns = Self::get_table_columns(conn, &table)?;
if columns.is_empty() {
continue;
}
let mut stmt = conn
.prepare(&format!("SELECT * FROM \"{table}\""))
.map_err(|e| AppError::Database(e.to_string()))?;
let mut rows = stmt
.query([])
.map_err(|e| AppError::Database(e.to_string()))?;
while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
let mut values = Vec::with_capacity(columns.len());
for idx in 0..columns.len() {
let value = row
.get_ref(idx)
.map_err(|e| AppError::Database(e.to_string()))?;
values.push(Self::format_sql_value(value)?);
}
let cols = columns
.iter()
.map(|c| format!("\"{c}\""))
.collect::<Vec<_>>()
.join(", ");
output.push_str(&format!(
"INSERT INTO \"{table}\" ({cols}) VALUES ({});\n",
values.join(", ")
));
}
}
output.push_str("COMMIT;\nPRAGMA foreign_keys=ON;\n");
Ok(output)
}
fn get_table_columns(conn: &Connection, table: &str) -> Result<Vec<String>, AppError> {
let mut stmt = conn
.prepare(&format!("PRAGMA table_info(\"{table}\")"))
.map_err(|e| AppError::Database(e.to_string()))?;
let iter = stmt
.query_map([], |row| row.get::<_, String>(1))
.map_err(|e| AppError::Database(e.to_string()))?;
let mut columns = Vec::new();
for col in iter {
columns.push(col.map_err(|e| AppError::Database(e.to_string()))?);
}
Ok(columns)
}
fn format_sql_value(value: ValueRef<'_>) -> Result<String, AppError> {
match value {
ValueRef::Null => Ok("NULL".to_string()),
ValueRef::Integer(i) => Ok(i.to_string()),
ValueRef::Real(f) => Ok(f.to_string()),
ValueRef::Text(t) => {
let text = std::str::from_utf8(t)
.map_err(|e| AppError::Database(format!("文本字段不是有效的 UTF-8: {e}")))?;
let escaped = text.replace('\'', "''");
Ok(format!("'{escaped}'"))
}
ValueRef::Blob(bytes) => {
let mut s = String::from("X'");
for b in bytes {
use std::fmt::Write;
let _ = write!(&mut s, "{b:02X}");
}
s.push('\'');
Ok(s)
}
}
}
/// 从 MultiAppConfig 迁移数据
pub fn migrate_from_json(&self, config: &MultiAppConfig) -> Result<(), AppError> {
let mut conn = self.conn.lock().unwrap();
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -178,7 +507,7 @@ impl Database {
id,
app_type,
provider.name,
serde_json::to_string(&provider.settings_config).unwrap(),
to_json_string(&provider.settings_config)?,
provider.website_url,
provider.category,
provider.created_at,
@@ -186,7 +515,7 @@ impl Database {
provider.notes,
provider.icon,
provider.icon_color,
serde_json::to_string(&meta_clone).unwrap(), // 不含 endpoints 的 meta
to_json_string(&meta_clone)?, // 不含 endpoints 的 meta
is_current,
],
)
@@ -215,11 +544,11 @@ impl Database {
params![
id,
server.name,
serde_json::to_string(&server.server).unwrap(),
to_json_string(&server.server)?,
server.description,
server.homepage,
server.docs,
serde_json::to_string(&server.tags).unwrap(),
to_json_string(&server.tags)?,
server.apps.claude,
server.apps.codex,
server.apps.gemini,
@@ -303,13 +632,42 @@ impl Database {
Ok(())
}
/// 检查数据库是否为空(需要首次导入)
/// 通过检查是否有任何 MCP 服务器、提示词、Skills 仓库或供应商来判断
pub fn is_empty_for_first_import(&self) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
// 检查是否有 MCP 服务器
let mcp_count: i64 = conn
.query_row("SELECT COUNT(*) FROM mcp_servers", [], |row| row.get(0))
.map_err(|e| AppError::Database(e.to_string()))?;
// 检查是否有提示词
let prompt_count: i64 = conn
.query_row("SELECT COUNT(*) FROM prompts", [], |row| row.get(0))
.map_err(|e| AppError::Database(e.to_string()))?;
// 检查是否有 Skills 仓库
let skill_repo_count: i64 = conn
.query_row("SELECT COUNT(*) FROM skill_repos", [], |row| row.get(0))
.map_err(|e| AppError::Database(e.to_string()))?;
// 检查是否有供应商
let provider_count: i64 = conn
.query_row("SELECT COUNT(*) FROM providers", [], |row| row.get(0))
.map_err(|e| AppError::Database(e.to_string()))?;
// 如果四者都为 0说明是空数据库
Ok(mcp_count == 0 && prompt_count == 0 && skill_repo_count == 0 && provider_count == 0)
}
// --- Providers DAO ---
pub fn get_all_providers(
&self,
app_type: &str,
) -> Result<IndexMap<String, Provider>, AppError> {
let conn = self.conn.lock().unwrap();
let conn = lock_conn!(self.conn);
let mut stmt = conn.prepare(
"SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta
FROM providers WHERE app_type = ?1
@@ -396,7 +754,7 @@ impl Database {
}
pub fn get_current_provider(&self, app_type: &str) -> Result<Option<String>, AppError> {
let conn = self.conn.lock().unwrap();
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare("SELECT id FROM providers WHERE app_type = ?1 AND is_current = 1 LIMIT 1")
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -415,7 +773,7 @@ impl Database {
}
pub fn save_provider(&self, app_type: &str, provider: &Provider) -> Result<(), AppError> {
let mut conn = self.conn.lock().unwrap();
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -477,7 +835,7 @@ impl Database {
}
pub fn delete_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> {
let conn = self.conn.lock().unwrap();
let conn = lock_conn!(self.conn);
conn.execute(
"DELETE FROM providers WHERE id = ?1 AND app_type = ?2",
params![id, app_type],
@@ -487,7 +845,7 @@ impl Database {
}
pub fn set_current_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> {
let mut conn = self.conn.lock().unwrap();
let mut conn = lock_conn!(self.conn);
let tx = conn
.transaction()
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -516,7 +874,7 @@ impl Database {
provider_id: &str,
url: &str,
) -> Result<(), AppError> {
let conn = self.conn.lock().unwrap();
let conn = lock_conn!(self.conn);
let added_at = chrono::Utc::now().timestamp_millis();
conn.execute(
"INSERT INTO provider_endpoints (provider_id, app_type, url, added_at) VALUES (?1, ?2, ?3, ?4)",
@@ -531,7 +889,7 @@ impl Database {
provider_id: &str,
url: &str,
) -> Result<(), AppError> {
let conn = self.conn.lock().unwrap();
let conn = lock_conn!(self.conn);
conn.execute(
"DELETE FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2 AND url = ?3",
params![provider_id, app_type, url],
@@ -543,7 +901,7 @@ impl Database {
// --- MCP Servers DAO ---
pub fn get_all_mcp_servers(&self) -> Result<IndexMap<String, McpServer>, AppError> {
let conn = self.conn.lock().unwrap();
let conn = lock_conn!(self.conn);
let mut stmt = conn.prepare(
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini
FROM mcp_servers
@@ -595,7 +953,7 @@ impl Database {
}
pub fn save_mcp_server(&self, server: &McpServer) -> Result<(), AppError> {
let conn = self.conn.lock().unwrap();
let conn = lock_conn!(self.conn);
conn.execute(
"INSERT OR REPLACE INTO mcp_servers (
id, name, server_config, description, homepage, docs, tags,
@@ -619,7 +977,7 @@ impl Database {
}
pub fn delete_mcp_server(&self, id: &str) -> Result<(), AppError> {
let conn = self.conn.lock().unwrap();
let conn = lock_conn!(self.conn);
conn.execute("DELETE FROM mcp_servers WHERE id = ?1", params![id])
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
@@ -628,7 +986,7 @@ impl Database {
// --- Prompts DAO ---
pub fn get_prompts(&self, app_type: &str) -> Result<IndexMap<String, Prompt>, AppError> {
let conn = self.conn.lock().unwrap();
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare(
"SELECT id, name, content, description, enabled, created_at, updated_at
@@ -671,7 +1029,7 @@ impl Database {
}
pub fn save_prompt(&self, app_type: &str, prompt: &Prompt) -> Result<(), AppError> {
let conn = self.conn.lock().unwrap();
let conn = lock_conn!(self.conn);
conn.execute(
"INSERT OR REPLACE INTO prompts (
id, app_type, name, content, description, enabled, created_at, updated_at
@@ -692,7 +1050,7 @@ impl Database {
}
pub fn delete_prompt(&self, app_type: &str, id: &str) -> Result<(), AppError> {
let conn = self.conn.lock().unwrap();
let conn = lock_conn!(self.conn);
conn.execute(
"DELETE FROM prompts WHERE id = ?1 AND app_type = ?2",
params![id, app_type],
@@ -704,7 +1062,7 @@ impl Database {
// --- Skills DAO ---
pub fn get_skills(&self) -> Result<IndexMap<String, SkillState>, AppError> {
let conn = self.conn.lock().unwrap();
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare("SELECT key, installed, installed_at FROM skills ORDER BY key ASC")
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -737,7 +1095,7 @@ impl Database {
}
pub fn update_skill_state(&self, key: &str, state: &SkillState) -> Result<(), AppError> {
let conn = self.conn.lock().unwrap();
let conn = lock_conn!(self.conn);
conn.execute(
"INSERT OR REPLACE INTO skills (key, installed, installed_at) VALUES (?1, ?2, ?3)",
params![key, state.installed, state.installed_at.timestamp()],
@@ -747,7 +1105,7 @@ impl Database {
}
pub fn get_skill_repos(&self) -> Result<Vec<SkillRepo>, AppError> {
let conn = self.conn.lock().unwrap();
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare("SELECT owner, name, branch, enabled, skills_path FROM skill_repos ORDER BY owner ASC, name ASC")
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -772,7 +1130,7 @@ impl Database {
}
pub fn save_skill_repo(&self, repo: &SkillRepo) -> Result<(), AppError> {
let conn = self.conn.lock().unwrap();
let conn = lock_conn!(self.conn);
conn.execute(
"INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled, skills_path) VALUES (?1, ?2, ?3, ?4, ?5)",
params![repo.owner, repo.name, repo.branch, repo.enabled, repo.skills_path],
@@ -781,7 +1139,7 @@ impl Database {
}
pub fn delete_skill_repo(&self, owner: &str, name: &str) -> Result<(), AppError> {
let conn = self.conn.lock().unwrap();
let conn = lock_conn!(self.conn);
conn.execute(
"DELETE FROM skill_repos WHERE owner = ?1 AND name = ?2",
params![owner, name],
@@ -790,10 +1148,31 @@ impl Database {
Ok(())
}
/// 初始化默认的 Skill 仓库(首次启动时调用)
pub fn init_default_skill_repos(&self) -> Result<usize, AppError> {
// 检查是否已有仓库
let existing = self.get_skill_repos()?;
if !existing.is_empty() {
return Ok(0);
}
// 获取默认仓库列表
let default_store = crate::services::skill::SkillStore::default();
let mut count = 0;
for repo in &default_store.repos {
self.save_skill_repo(repo)?;
count += 1;
}
log::info!("初始化默认 Skill 仓库完成,共 {count} 个");
Ok(count)
}
// --- Settings DAO ---
pub fn get_setting(&self, key: &str) -> Result<Option<String>, AppError> {
let conn = self.conn.lock().unwrap();
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare("SELECT value FROM settings WHERE key = ?1")
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -812,7 +1191,7 @@ impl Database {
}
pub fn set_setting(&self, key: &str, value: &str) -> Result<(), AppError> {
let conn = self.conn.lock().unwrap();
let conn = lock_conn!(self.conn);
conn.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
params![key, value],
@@ -837,7 +1216,7 @@ impl Database {
self.set_setting(&key, &value)
} else {
// Delete if None
let conn = self.conn.lock().unwrap();
let conn = lock_conn!(self.conn);
conn.execute("DELETE FROM settings WHERE key = ?1", params![key])
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())

View File

@@ -495,24 +495,31 @@ pub fn run() {
use objc2::runtime::AnyObject;
use objc2_app_kit::NSColor;
let ns_window_ptr = window.ns_window().unwrap();
let ns_window: Retained<AnyObject> =
unsafe { Retained::retain(ns_window_ptr as *mut AnyObject).unwrap() };
match window.ns_window() {
Ok(ns_window_ptr) => {
if let Some(ns_window) =
unsafe { Retained::retain(ns_window_ptr as *mut AnyObject) }
{
// 使用与主界面 banner 相同的蓝色 #3498db
// #3498db = RGB(52, 152, 219)
let bg_color = unsafe {
NSColor::colorWithRed_green_blue_alpha(
52.0 / 255.0, // R: 52
152.0 / 255.0, // G: 152
219.0 / 255.0, // B: 219
1.0, // Alpha: 1.0
)
};
// 使用与主界面 banner 相同的蓝色 #3498db
// #3498db = RGB(52, 152, 219)
let bg_color = unsafe {
NSColor::colorWithRed_green_blue_alpha(
52.0 / 255.0, // R: 52
152.0 / 255.0, // G: 152
219.0 / 255.0, // B: 219
1.0, // Alpha: 1.0
)
};
unsafe {
use objc2::msg_send;
let _: () = msg_send![&*ns_window, setBackgroundColor: &*bg_color];
unsafe {
use objc2::msg_send;
let _: () = msg_send![&*ns_window, setBackgroundColor: &*bg_color];
}
} else {
log::warn!("Failed to retain NSWindow reference");
}
}
Err(e) => log::warn!("Failed to get NSWindow pointer: {e}"),
}
}
}
@@ -563,8 +570,118 @@ pub fn run() {
}
}
crate::settings::bind_db(db.clone());
let app_state = AppState::new(db);
// 检查是否需要首次导入(数据库为空)
let need_first_import = app_state
.db
.is_empty_for_first_import()
.unwrap_or_else(|e| {
log::warn!("Failed to check if database is empty: {e}");
false
});
if need_first_import {
// 数据库为空,尝试从用户现有的配置文件导入数据并初始化默认配置
log::info!(
"Empty database detected, importing existing configurations and initializing defaults..."
);
// 1. 初始化默认 Skills 仓库3个
match app_state.db.init_default_skill_repos() {
Ok(count) if count > 0 => {
log::info!("✓ Initialized {count} default skill repositories");
}
Ok(_) => log::debug!("No default skill repositories to initialize"),
Err(e) => log::warn!("✗ Failed to initialize default skill repos: {e}"),
}
// 2. 导入供应商配置(从 live 配置文件)
for app in [
crate::app_config::AppType::Claude,
crate::app_config::AppType::Codex,
crate::app_config::AppType::Gemini,
] {
match crate::services::provider::ProviderService::import_default_config(
&app_state,
app.clone(),
) {
Ok(_) => {
log::info!("✓ Imported default provider for {}", app.as_str());
}
Err(e) => {
log::debug!(
"○ No default provider to import for {}: {}",
app.as_str(),
e
);
}
}
}
// 3. 导入 MCP 服务器配置
match crate::services::mcp::McpService::import_from_claude(&app_state) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} MCP server(s) from Claude");
}
Ok(_) => log::debug!("○ No Claude MCP servers found to import"),
Err(e) => log::warn!("✗ Failed to import Claude MCP: {e}"),
}
match crate::services::mcp::McpService::import_from_codex(&app_state) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} MCP server(s) from Codex");
}
Ok(_) => log::debug!("○ No Codex MCP servers found to import"),
Err(e) => log::warn!("✗ Failed to import Codex MCP: {e}"),
}
match crate::services::mcp::McpService::import_from_gemini(&app_state) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} MCP server(s) from Gemini");
}
Ok(_) => log::debug!("○ No Gemini MCP servers found to import"),
Err(e) => log::warn!("✗ Failed to import Gemini MCP: {e}"),
}
// 4. 导入提示词文件
match crate::services::prompt::PromptService::import_from_file_on_first_launch(
&app_state,
crate::app_config::AppType::Claude,
) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} prompt(s) from Claude");
}
Ok(_) => log::debug!("○ No Claude prompt file found to import"),
Err(e) => log::warn!("✗ Failed to import Claude prompt: {e}"),
}
match crate::services::prompt::PromptService::import_from_file_on_first_launch(
&app_state,
crate::app_config::AppType::Codex,
) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} prompt(s) from Codex");
}
Ok(_) => log::debug!("○ No Codex prompt file found to import"),
Err(e) => log::warn!("✗ Failed to import Codex prompt: {e}"),
}
match crate::services::prompt::PromptService::import_from_file_on_first_launch(
&app_state,
crate::app_config::AppType::Gemini,
) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} prompt(s) from Gemini");
}
Ok(_) => log::debug!("○ No Gemini prompt file found to import"),
Err(e) => log::warn!("✗ Failed to import Gemini prompt: {e}"),
}
log::info!("First-time import completed");
}
// 迁移旧的 app_config_dir 配置到 Store
if let Err(e) = app_store::migrate_app_config_dir_from_settings(app.handle()) {
log::warn!("迁移 app_config_dir 失败: {e}");
@@ -622,7 +739,11 @@ pub fn run() {
.show_menu_on_left_click(true);
// 统一使用应用默认图标;待托盘模板图标就绪后再启用
tray_builder = tray_builder.icon(app.default_window_icon().unwrap().clone());
if let Some(icon) = app.default_window_icon() {
tray_builder = tray_builder.icon(icon.clone());
} else {
log::warn!("Failed to get default window icon for tray");
}
let _tray = tray_builder.build(app)?;
// 将同一个实例注入到全局状态,避免重复创建导致的不一致

View File

@@ -348,10 +348,7 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError
};
// 确保新结构存在
if config.mcp.servers.is_none() {
config.mcp.servers = Some(HashMap::new());
}
let servers = config.mcp.servers.as_mut().unwrap();
let servers = config.mcp.servers.get_or_insert_with(HashMap::new);
let mut changed = 0;
let mut errors = Vec::new();
@@ -421,10 +418,7 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
.map_err(|e| AppError::McpValidation(format!("解析 ~/.codex/config.toml 失败: {e}")))?;
// 确保新结构存在
if config.mcp.servers.is_none() {
config.mcp.servers = Some(HashMap::new());
}
let servers = config.mcp.servers.as_mut().unwrap();
let servers = config.mcp.servers.get_or_insert_with(HashMap::new);
let mut changed_total = 0usize;
@@ -724,10 +718,7 @@ pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result<usize, AppError
};
// 确保新结构存在
if config.mcp.servers.is_none() {
config.mcp.servers = Some(HashMap::new());
}
let servers = config.mcp.servers.as_mut().unwrap();
let servers = config.mcp.servers.get_or_insert_with(HashMap::new);
let mut changed = 0;
let mut errors = Vec::new();
@@ -852,8 +843,22 @@ fn json_value_to_toml_item(value: &Value, field_name: &str) -> Option<toml_edit:
for item in arr {
match item {
Value::String(s) => toml_arr.push(s.as_str()),
Value::Number(n) if n.is_i64() => toml_arr.push(n.as_i64().unwrap()),
Value::Number(n) if n.is_f64() => toml_arr.push(n.as_f64().unwrap()),
Value::Number(n) if n.is_i64() => {
if let Some(i) = n.as_i64() {
toml_arr.push(i);
} else {
all_same_type = false;
break;
}
}
Value::Number(n) if n.is_f64() => {
if let Some(f) = n.as_f64() {
toml_arr.push(f);
} else {
all_same_type = false;
break;
}
}
Value::Bool(b) => toml_arr.push(*b),
_ => {
all_same_type = false;

View File

@@ -180,21 +180,68 @@ impl McpService {
}
/// 从 Claude 导入 MCPv3.7.0 已更新为统一结构)
pub fn import_from_claude(_state: &AppState) -> Result<usize, AppError> {
// TODO: Implement import logic using database
// For now, return 0 as a placeholder
Ok(0)
pub fn import_from_claude(state: &AppState) -> Result<usize, AppError> {
// 创建临时 MultiAppConfig 用于导入
let mut temp_config = crate::app_config::MultiAppConfig::default();
// 调用原有的导入逻辑(从 mcp.rs
let count = crate::mcp::import_from_claude(&mut temp_config)?;
// 如果有导入的服务器,保存到数据库
if count > 0 {
if let Some(servers) = &temp_config.mcp.servers {
for server in servers.values() {
state.db.save_mcp_server(server)?;
// 同步到 Claude live 配置
Self::sync_server_to_apps(state, server)?;
}
}
}
Ok(count)
}
/// 从 Codex 导入 MCPv3.7.0 已更新为统一结构)
pub fn import_from_codex(_state: &AppState) -> Result<usize, AppError> {
// TODO: Implement import logic using database
Ok(0)
pub fn import_from_codex(state: &AppState) -> Result<usize, AppError> {
// 创建临时 MultiAppConfig 用于导入
let mut temp_config = crate::app_config::MultiAppConfig::default();
// 调用原有的导入逻辑(从 mcp.rs
let count = crate::mcp::import_from_codex(&mut temp_config)?;
// 如果有导入的服务器,保存到数据库
if count > 0 {
if let Some(servers) = &temp_config.mcp.servers {
for server in servers.values() {
state.db.save_mcp_server(server)?;
// 同步到 Codex live 配置
Self::sync_server_to_apps(state, server)?;
}
}
}
Ok(count)
}
/// 从 Gemini 导入 MCPv3.7.0 已更新为统一结构)
pub fn import_from_gemini(_state: &AppState) -> Result<usize, AppError> {
// TODO: Implement import logic using database
Ok(0)
pub fn import_from_gemini(state: &AppState) -> Result<usize, AppError> {
// 创建临时 MultiAppConfig 用于导入
let mut temp_config = crate::app_config::MultiAppConfig::default();
// 调用原有的导入逻辑(从 mcp.rs
let count = crate::mcp::import_from_gemini(&mut temp_config)?;
// 如果有导入的服务器,保存到数据库
if count > 0 {
if let Some(servers) = &temp_config.mcp.servers {
for server in servers.values() {
state.db.save_mcp_server(server)?;
// 同步到 Gemini live 配置
Self::sync_server_to_apps(state, server)?;
}
}
}
Ok(count)
}
}

View File

@@ -7,6 +7,14 @@ use crate::prompt::Prompt;
use crate::prompt_files::prompt_file_path;
use crate::store::AppState;
/// 安全地获取当前 Unix 时间戳
fn get_unix_timestamp() -> Result<i64, AppError> {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.map_err(|e| AppError::Message(format!("Failed to get system time: {e}")))
}
pub struct PromptService;
impl PromptService {
@@ -64,10 +72,7 @@ impl PromptService {
.find(|(_, p)| p.enabled)
.map(|(id, p)| (id.clone(), p))
{
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let timestamp = get_unix_timestamp()?;
enabled_prompt.content = live_content.clone();
enabled_prompt.updated_at = Some(timestamp);
log::info!("回填 live 提示词内容到已启用项: {enabled_id}");
@@ -135,10 +140,7 @@ impl PromptService {
let content =
std::fs::read_to_string(&file_path).map_err(|e| AppError::io(&file_path, e))?;
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let timestamp = get_unix_timestamp()?;
let id = format!("imported-{timestamp}");
let prompt = Prompt {
@@ -167,4 +169,56 @@ impl PromptService {
std::fs::read_to_string(&file_path).map_err(|e| AppError::io(&file_path, e))?;
Ok(Some(content))
}
/// 首次启动时从现有提示词文件自动导入(如果存在)
/// 返回导入的数量
pub fn import_from_file_on_first_launch(
state: &AppState,
app: AppType,
) -> Result<usize, AppError> {
let file_path = prompt_file_path(&app)?;
// 检查文件是否存在
if !file_path.exists() {
return Ok(0);
}
// 读取文件内容
let content = match std::fs::read_to_string(&file_path) {
Ok(c) => c,
Err(e) => {
log::warn!("读取提示词文件失败: {file_path:?}, 错误: {e}");
return Ok(0);
}
};
// 检查内容是否为空
if content.trim().is_empty() {
return Ok(0);
}
log::info!("发现提示词文件,自动导入: {file_path:?}");
// 创建提示词对象
let timestamp = get_unix_timestamp()?;
let id = format!("auto-imported-{timestamp}");
let prompt = Prompt {
id: id.clone(),
name: format!(
"Auto-imported Prompt {}",
chrono::Local::now().format("%Y-%m-%d %H:%M")
),
content,
description: Some("Automatically imported on first launch".to_string()),
enabled: true, // 首次导入时自动启用
created_at: Some(timestamp),
updated_at: Some(timestamp),
};
// 保存到数据库
state.db.save_prompt(app.as_str(), &prompt)?;
log::info!("自动导入完成: {}", app.as_str());
Ok(1)
}
}

View File

@@ -12,6 +12,7 @@ use crate::config::{
};
use crate::error::AppError;
use crate::provider::{Provider, UsageData, UsageResult};
use crate::services::mcp::McpService;
use crate::settings::{self, CustomEndpoint};
use crate::store::AppState;
use crate::usage_script;
@@ -424,9 +425,9 @@ impl ProviderService {
/// 归一化 Claude 模型键:读旧键(ANTHROPIC_SMALL_FAST_MODEL),写新键(DEFAULT_*), 并删除旧键
fn normalize_claude_models_in_value(settings: &mut Value) -> bool {
let mut changed = false;
let env = match settings.get_mut("env") {
Some(v) if v.is_object() => v.as_object_mut().unwrap(),
_ => return changed,
let env = match settings.get_mut("env").and_then(|v| v.as_object_mut()) {
Some(obj) => obj,
None => return changed,
};
let model = env
@@ -550,6 +551,30 @@ impl ProviderService {
Ok(())
}
/// 将数据库中的当前供应商同步到对应 live 配置
pub fn sync_current_from_db(state: &AppState) -> Result<(), AppError> {
for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
let current_id = match state.db.get_current_provider(app_type.as_str())? {
Some(id) => id,
None => continue,
};
let providers = state.db.get_all_providers(app_type.as_str())?;
if let Some(provider) = providers.get(&current_id) {
Self::write_live_snapshot(&app_type, provider)?;
} else {
log::warn!(
"无法同步 live 配置: 当前供应商 {} ({}) 未找到",
current_id,
app_type.as_str()
);
}
}
// MCP 同步
McpService::sync_all_enabled(state)?;
Ok(())
}
/// 列出指定应用下的所有供应商
pub fn list(
state: &AppState,

View File

@@ -231,7 +231,12 @@ impl SkillService {
// 解析技能元数据
match self.parse_skill_metadata(&skill_md) {
Ok(meta) => {
let directory = path.file_name().unwrap().to_string_lossy().to_string();
// 安全地获取目录名
let Some(dir_name) = path.file_name() else {
log::warn!("Failed to get directory name from path: {path:?}");
continue;
};
let directory = dir_name.to_string_lossy().to_string();
// 构建 README URL考虑 skillsPath
let readme_path = if let Some(ref skills_path) = repo.skills_path {
@@ -305,7 +310,12 @@ impl SkillService {
continue;
}
let directory = path.file_name().unwrap().to_string_lossy().to_string();
// 安全地获取目录名
let Some(dir_name) = path.file_name() else {
log::warn!("Failed to get directory name from path: {path:?}");
continue;
};
let directory = dir_name.to_string_lossy().to_string();
// 更新已安装状态
let mut found = false;

View File

@@ -2,8 +2,9 @@ use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::{OnceLock, RwLock};
use std::sync::{Arc, OnceLock, RwLock};
use crate::database::Database;
use crate::error::AppError;
/// 自定义端点配置
@@ -90,8 +91,7 @@ impl Default for AppSettings {
impl AppSettings {
fn settings_path() -> PathBuf {
// settings.json 必须使用固定路径,不能被 app_config_dir 覆盖
// 否则会造成循环依赖:读取 settings 需要知道路径,但路径在 settings 中
// settings.json 保留用于旧版本迁移和无数据库场景
dirs::home_dir()
.expect("无法获取用户主目录")
.join(".cc-switch")
@@ -128,7 +128,7 @@ impl AppSettings {
.map(|s| s.to_string());
}
pub fn load() -> Self {
fn load_from_file() -> Self {
let path = Self::settings_path();
if let Ok(content) = fs::read_to_string(&path) {
match serde_json::from_str::<AppSettings>(&content) {
@@ -149,26 +149,80 @@ impl AppSettings {
Self::default()
}
}
}
pub fn save(&self) -> Result<(), AppError> {
let mut normalized = self.clone();
normalized.normalize_paths();
let path = Self::settings_path();
fn save_settings_file(settings: &AppSettings) -> Result<(), AppError> {
let mut normalized = settings.clone();
normalized.normalize_paths();
let path = AppSettings::settings_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
let json = serde_json::to_string_pretty(&normalized)
.map_err(|e| AppError::JsonSerialize { source: e })?;
fs::write(&path, json).map_err(|e| AppError::io(&path, e))?;
Ok(())
let json = serde_json::to_string_pretty(&normalized)
.map_err(|e| AppError::JsonSerialize { source: e })?;
fs::write(&path, json).map_err(|e| AppError::io(&path, e))?;
Ok(())
}
static SETTINGS_STORE: OnceLock<RwLock<AppSettings>> = OnceLock::new();
fn settings_store() -> &'static RwLock<AppSettings> {
SETTINGS_STORE.get_or_init(|| RwLock::new(load_initial_settings()))
}
static SETTINGS_DB: OnceLock<Arc<Database>> = OnceLock::new();
const APP_SETTINGS_KEY: &str = "app_settings";
pub fn bind_db(db: Arc<Database>) {
if SETTINGS_DB.set(db).is_err() {
return;
}
if let Some(store) = SETTINGS_STORE.get() {
let mut guard = store.write().expect("写入设置锁失败");
*guard = load_initial_settings();
}
}
fn settings_store() -> &'static RwLock<AppSettings> {
static STORE: OnceLock<RwLock<AppSettings>> = OnceLock::new();
STORE.get_or_init(|| RwLock::new(AppSettings::load()))
fn load_initial_settings() -> AppSettings {
if let Some(db) = SETTINGS_DB.get() {
if let Some(from_db) = load_from_db(db.as_ref()) {
return from_db;
}
// 从文件迁移一次并写入数据库
let file_settings = AppSettings::load_from_file();
if let Err(e) = save_to_db(db.as_ref(), &file_settings) {
log::warn!("迁移设置到数据库失败,将继续使用内存副本: {e}");
}
return file_settings;
}
AppSettings::load_from_file()
}
fn load_from_db(db: &Database) -> Option<AppSettings> {
let raw = db.get_setting(APP_SETTINGS_KEY).ok()??;
match serde_json::from_str::<AppSettings>(&raw) {
Ok(mut settings) => {
settings.normalize_paths();
Some(settings)
}
Err(err) => {
log::warn!("解析数据库中 app_settings 失败: {err}");
None
}
}
}
fn save_to_db(db: &Database, settings: &AppSettings) -> Result<(), AppError> {
let mut normalized = settings.clone();
normalized.normalize_paths();
let json =
serde_json::to_string(&normalized).map_err(|e| AppError::JsonSerialize { source: e })?;
db.set_setting(APP_SETTINGS_KEY, &json)
}
fn resolve_override_path(raw: &str) -> PathBuf {
@@ -195,7 +249,11 @@ pub fn get_settings() -> AppSettings {
pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
new_settings.normalize_paths();
new_settings.save()?;
if let Some(db) = SETTINGS_DB.get() {
save_to_db(db, &new_settings)?;
} else {
save_settings_file(&new_settings)?;
}
let mut guard = settings_store().write().expect("写入设置锁失败");
*guard = new_settings;

View File

@@ -254,16 +254,65 @@ export function DeepLinkImportDialog() {
</div>
</div>
{/* Model (if present) */}
{request.model && (
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.model")}
</div>
<div className="col-span-2 text-sm font-mono">
{request.model}
</div>
</div>
{/* Model Fields - 根据应用类型显示不同的模型字段 */}
{request.app === "claude" ? (
<>
{/* Claude 四种模型字段 */}
{request.haikuModel && (
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.haikuModel")}
</div>
<div className="col-span-2 text-sm font-mono">
{request.haikuModel}
</div>
</div>
)}
{request.sonnetModel && (
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.sonnetModel")}
</div>
<div className="col-span-2 text-sm font-mono">
{request.sonnetModel}
</div>
</div>
)}
{request.opusModel && (
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.opusModel")}
</div>
<div className="col-span-2 text-sm font-mono">
{request.opusModel}
</div>
</div>
)}
{request.model && (
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.multiModel")}
</div>
<div className="col-span-2 text-sm font-mono">
{request.model}
</div>
</div>
)}
</>
) : (
<>
{/* Codex 和 Gemini 使用通用 model 字段 */}
{request.model && (
<div className="grid grid-cols-3 items-center gap-4">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.model")}
</div>
<div className="col-span-2 text-sm font-mono">
{request.model}
</div>
</div>
)}
</>
)}
{/* Notes (if present) */}

View File

@@ -78,6 +78,8 @@ export function EditProviderDialog({
async (values: ProviderFormValues) => {
if (!provider) return;
// 注意values.settingsConfig 已经是最终的配置字符串
// ProviderForm 已经为不同的 app 类型Claude/Codex/Gemini正确组装了配置
const parsedConfig = JSON.parse(values.settingsConfig) as Record<
string,
unknown

View File

@@ -1,4 +1,10 @@
import { useState, useEffect, useMemo, forwardRef, useImperativeHandle } from "react";
import {
useState,
useEffect,
useMemo,
forwardRef,
useImperativeHandle,
} from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";

View File

@@ -78,7 +78,7 @@ export function useImportExport(
if (!selectedFile) {
toast.error(
t("settings.selectFileFailed", {
defaultValue: "请选择有效的配置文件",
defaultValue: "请选择有效的 SQL 备份文件",
}),
);
return;
@@ -97,7 +97,7 @@ export function useImportExport(
const message =
result.message ||
t("settings.configCorrupted", {
defaultValue: "配置文件已损坏或格式不正确",
defaultValue: "SQL 文件已损坏或格式不正确",
});
setErrorMessage(message);
toast.error(message);
@@ -150,14 +150,14 @@ export function useImportExport(
const exportConfig = useCallback(async () => {
try {
const defaultName = `cc-switch-config-${
new Date().toISOString().split("T")[0]
}.json`;
const now = new Date();
const stamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}_${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}${String(now.getSeconds()).padStart(2, "0")}`;
const defaultName = `cc-switch-export-${stamp}.sql`;
const destination = await settingsApi.saveFileDialog(defaultName);
if (!destination) {
toast.error(
t("settings.selectFileFailed", {
defaultValue: "选择保存位置失败",
defaultValue: "选择 SQL 备份保存路径",
}),
);
return;

View File

@@ -145,10 +145,10 @@
"themeLight": "Light",
"themeDark": "Dark",
"themeSystem": "System",
"importExport": "Import/Export Config",
"importExportHint": "Import or export CC Switch configuration for backup or migration.",
"exportConfig": "Export Config to File",
"selectConfigFile": "Select Config File",
"importExport": "SQL Import/Export",
"importExportHint": "Import or export database SQL backups for migration or restore.",
"exportConfig": "Export SQL Backup",
"selectConfigFile": "Select SQL File",
"noFileSelected": "No configuration file selected.",
"import": "Import",
"importing": "Importing...",
@@ -159,8 +159,8 @@
"importPartialHint": "Please manually reselect the provider to refresh the live configuration.",
"configExported": "Config exported to:",
"exportFailed": "Export failed",
"selectFileFailed": "Failed to select file",
"configCorrupted": "Config file may be corrupted or invalid",
"selectFileFailed": "Please choose a valid SQL backup file",
"configCorrupted": "SQL file may be corrupted or invalid",
"backupId": "Backup ID",
"autoReload": "Data will refresh automatically in 2 seconds...",
"languageOptionChinese": "中文",
@@ -749,6 +749,10 @@
"endpoint": "API Endpoint",
"apiKey": "API Key",
"model": "Model",
"haikuModel": "Haiku Model",
"sonnetModel": "Sonnet Model",
"opusModel": "Opus Model",
"multiModel": "Multi-Modal Model",
"notes": "Notes",
"import": "Import",
"importing": "Importing...",

View File

@@ -145,10 +145,10 @@
"themeLight": "浅色",
"themeDark": "深色",
"themeSystem": "跟随系统",
"importExport": "导入导出配置",
"importExportHint": "导入导出 CC Switch 配置,便于备份或迁移。",
"exportConfig": "导出配置到文件",
"selectConfigFile": "选择配置文件",
"importExport": "SQL 导入导出",
"importExportHint": "导入/导出数据库 SQL 备份,便于备份或迁移。",
"exportConfig": "导出 SQL 备份",
"selectConfigFile": "选择 SQL 文件",
"noFileSelected": "尚未选择配置文件。",
"import": "导入",
"importing": "导入中...",
@@ -159,8 +159,8 @@
"importPartialHint": "请手动重新选择一次供应商以刷新对应配置。",
"configExported": "配置已导出到:",
"exportFailed": "导出失败",
"selectFileFailed": "选择文件失败",
"configCorrupted": "配置文件可能已损坏或格式不正确",
"selectFileFailed": "选择有效的 SQL 备份文件",
"configCorrupted": "SQL 文件可能已损坏或格式不正确",
"backupId": "备份ID",
"autoReload": "数据将在2秒后自动刷新...",
"languageOptionChinese": "中文",
@@ -749,6 +749,10 @@
"endpoint": "API 端点",
"apiKey": "API 密钥",
"model": "模型",
"haikuModel": "Haiku 模型",
"sonnetModel": "Sonnet 模型",
"opusModel": "Opus 模型",
"multiModel": "多模态模型",
"notes": "备注",
"import": "导入",
"importing": "导入中...",