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.
This commit is contained in:
@@ -494,8 +494,11 @@ impl MultiAppConfig {
|
|||||||
// 创建提示词对象
|
// 创建提示词对象
|
||||||
let timestamp = std::time::SystemTime::now()
|
let timestamp = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.unwrap()
|
.map(|d| d.as_secs() as i64)
|
||||||
.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 id = format!("auto-imported-{timestamp}");
|
||||||
let prompt = crate::prompt::Prompt {
|
let prompt = crate::prompt::Prompt {
|
||||||
|
|||||||
@@ -6,9 +6,25 @@ use crate::provider::{Provider, ProviderMeta};
|
|||||||
use crate::services::skill::{SkillRepo, SkillState};
|
use crate::services::skill::{SkillRepo, SkillState};
|
||||||
use indexmap::IndexMap;
|
use indexmap::IndexMap;
|
||||||
use rusqlite::{params, Connection, Result};
|
use rusqlite::{params, Connection, Result};
|
||||||
|
use serde::Serialize;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
/// 安全地序列化 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)))?
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Database {
|
pub struct Database {
|
||||||
// 使用 Mutex 包装 Connection 以支持在多线程环境(如 Tauri State)中共享
|
// 使用 Mutex 包装 Connection 以支持在多线程环境(如 Tauri State)中共享
|
||||||
// rusqlite::Connection 本身不是 Sync 的
|
// rusqlite::Connection 本身不是 Sync 的
|
||||||
@@ -40,7 +56,7 @@ impl Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn create_tables(&self) -> Result<(), AppError> {
|
fn create_tables(&self) -> Result<(), AppError> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
// 1. Providers 表
|
// 1. Providers 表
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -152,7 +168,7 @@ impl Database {
|
|||||||
|
|
||||||
/// 从 MultiAppConfig 迁移数据
|
/// 从 MultiAppConfig 迁移数据
|
||||||
pub fn migrate_from_json(&self, config: &MultiAppConfig) -> Result<(), AppError> {
|
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
|
let tx = conn
|
||||||
.transaction()
|
.transaction()
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
@@ -178,7 +194,7 @@ impl Database {
|
|||||||
id,
|
id,
|
||||||
app_type,
|
app_type,
|
||||||
provider.name,
|
provider.name,
|
||||||
serde_json::to_string(&provider.settings_config).unwrap(),
|
to_json_string(&provider.settings_config)?,
|
||||||
provider.website_url,
|
provider.website_url,
|
||||||
provider.category,
|
provider.category,
|
||||||
provider.created_at,
|
provider.created_at,
|
||||||
@@ -186,7 +202,7 @@ impl Database {
|
|||||||
provider.notes,
|
provider.notes,
|
||||||
provider.icon,
|
provider.icon,
|
||||||
provider.icon_color,
|
provider.icon_color,
|
||||||
serde_json::to_string(&meta_clone).unwrap(), // 不含 endpoints 的 meta
|
to_json_string(&meta_clone)?, // 不含 endpoints 的 meta
|
||||||
is_current,
|
is_current,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -215,11 +231,11 @@ impl Database {
|
|||||||
params![
|
params![
|
||||||
id,
|
id,
|
||||||
server.name,
|
server.name,
|
||||||
serde_json::to_string(&server.server).unwrap(),
|
to_json_string(&server.server)?,
|
||||||
server.description,
|
server.description,
|
||||||
server.homepage,
|
server.homepage,
|
||||||
server.docs,
|
server.docs,
|
||||||
serde_json::to_string(&server.tags).unwrap(),
|
to_json_string(&server.tags)?,
|
||||||
server.apps.claude,
|
server.apps.claude,
|
||||||
server.apps.codex,
|
server.apps.codex,
|
||||||
server.apps.gemini,
|
server.apps.gemini,
|
||||||
@@ -303,13 +319,42 @@ impl Database {
|
|||||||
Ok(())
|
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 ---
|
// --- Providers DAO ---
|
||||||
|
|
||||||
pub fn get_all_providers(
|
pub fn get_all_providers(
|
||||||
&self,
|
&self,
|
||||||
app_type: &str,
|
app_type: &str,
|
||||||
) -> Result<IndexMap<String, Provider>, AppError> {
|
) -> Result<IndexMap<String, Provider>, AppError> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = lock_conn!(self.conn);
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta
|
"SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta
|
||||||
FROM providers WHERE app_type = ?1
|
FROM providers WHERE app_type = ?1
|
||||||
@@ -396,7 +441,7 @@ impl Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_current_provider(&self, app_type: &str) -> Result<Option<String>, AppError> {
|
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
|
let mut stmt = conn
|
||||||
.prepare("SELECT id FROM providers WHERE app_type = ?1 AND is_current = 1 LIMIT 1")
|
.prepare("SELECT id FROM providers WHERE app_type = ?1 AND is_current = 1 LIMIT 1")
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
@@ -415,7 +460,7 @@ impl Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn save_provider(&self, app_type: &str, provider: &Provider) -> Result<(), AppError> {
|
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
|
let tx = conn
|
||||||
.transaction()
|
.transaction()
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
@@ -477,7 +522,7 @@ impl Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> {
|
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(
|
conn.execute(
|
||||||
"DELETE FROM providers WHERE id = ?1 AND app_type = ?2",
|
"DELETE FROM providers WHERE id = ?1 AND app_type = ?2",
|
||||||
params![id, app_type],
|
params![id, app_type],
|
||||||
@@ -487,7 +532,7 @@ impl Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_current_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> {
|
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
|
let tx = conn
|
||||||
.transaction()
|
.transaction()
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
@@ -516,7 +561,7 @@ impl Database {
|
|||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
url: &str,
|
url: &str,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = lock_conn!(self.conn);
|
||||||
let added_at = chrono::Utc::now().timestamp_millis();
|
let added_at = chrono::Utc::now().timestamp_millis();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO provider_endpoints (provider_id, app_type, url, added_at) VALUES (?1, ?2, ?3, ?4)",
|
"INSERT INTO provider_endpoints (provider_id, app_type, url, added_at) VALUES (?1, ?2, ?3, ?4)",
|
||||||
@@ -531,7 +576,7 @@ impl Database {
|
|||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
url: &str,
|
url: &str,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = lock_conn!(self.conn);
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"DELETE FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2 AND url = ?3",
|
"DELETE FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2 AND url = ?3",
|
||||||
params![provider_id, app_type, url],
|
params![provider_id, app_type, url],
|
||||||
@@ -543,7 +588,7 @@ impl Database {
|
|||||||
// --- MCP Servers DAO ---
|
// --- MCP Servers DAO ---
|
||||||
|
|
||||||
pub fn get_all_mcp_servers(&self) -> Result<IndexMap<String, McpServer>, AppError> {
|
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(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini
|
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini
|
||||||
FROM mcp_servers
|
FROM mcp_servers
|
||||||
@@ -595,7 +640,7 @@ impl Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn save_mcp_server(&self, server: &McpServer) -> Result<(), AppError> {
|
pub fn save_mcp_server(&self, server: &McpServer) -> Result<(), AppError> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = lock_conn!(self.conn);
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO mcp_servers (
|
"INSERT OR REPLACE INTO mcp_servers (
|
||||||
id, name, server_config, description, homepage, docs, tags,
|
id, name, server_config, description, homepage, docs, tags,
|
||||||
@@ -619,7 +664,7 @@ impl Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_mcp_server(&self, id: &str) -> Result<(), AppError> {
|
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])
|
conn.execute("DELETE FROM mcp_servers WHERE id = ?1", params![id])
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -628,7 +673,7 @@ impl Database {
|
|||||||
// --- Prompts DAO ---
|
// --- Prompts DAO ---
|
||||||
|
|
||||||
pub fn get_prompts(&self, app_type: &str) -> Result<IndexMap<String, Prompt>, AppError> {
|
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
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT id, name, content, description, enabled, created_at, updated_at
|
"SELECT id, name, content, description, enabled, created_at, updated_at
|
||||||
@@ -671,7 +716,7 @@ impl Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn save_prompt(&self, app_type: &str, prompt: &Prompt) -> Result<(), AppError> {
|
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(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO prompts (
|
"INSERT OR REPLACE INTO prompts (
|
||||||
id, app_type, name, content, description, enabled, created_at, updated_at
|
id, app_type, name, content, description, enabled, created_at, updated_at
|
||||||
@@ -692,7 +737,7 @@ impl Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_prompt(&self, app_type: &str, id: &str) -> Result<(), AppError> {
|
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(
|
conn.execute(
|
||||||
"DELETE FROM prompts WHERE id = ?1 AND app_type = ?2",
|
"DELETE FROM prompts WHERE id = ?1 AND app_type = ?2",
|
||||||
params![id, app_type],
|
params![id, app_type],
|
||||||
@@ -704,7 +749,7 @@ impl Database {
|
|||||||
// --- Skills DAO ---
|
// --- Skills DAO ---
|
||||||
|
|
||||||
pub fn get_skills(&self) -> Result<IndexMap<String, SkillState>, AppError> {
|
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
|
let mut stmt = conn
|
||||||
.prepare("SELECT key, installed, installed_at FROM skills ORDER BY key ASC")
|
.prepare("SELECT key, installed, installed_at FROM skills ORDER BY key ASC")
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
@@ -737,7 +782,7 @@ impl Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_skill_state(&self, key: &str, state: &SkillState) -> Result<(), AppError> {
|
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(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO skills (key, installed, installed_at) VALUES (?1, ?2, ?3)",
|
"INSERT OR REPLACE INTO skills (key, installed, installed_at) VALUES (?1, ?2, ?3)",
|
||||||
params![key, state.installed, state.installed_at.timestamp()],
|
params![key, state.installed, state.installed_at.timestamp()],
|
||||||
@@ -747,7 +792,7 @@ impl Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_skill_repos(&self) -> Result<Vec<SkillRepo>, AppError> {
|
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
|
let mut stmt = conn
|
||||||
.prepare("SELECT owner, name, branch, enabled, skills_path FROM skill_repos ORDER BY owner ASC, name ASC")
|
.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()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
@@ -772,7 +817,7 @@ impl Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn save_skill_repo(&self, repo: &SkillRepo) -> Result<(), AppError> {
|
pub fn save_skill_repo(&self, repo: &SkillRepo) -> Result<(), AppError> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = lock_conn!(self.conn);
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled, skills_path) VALUES (?1, ?2, ?3, ?4, ?5)",
|
"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],
|
params![repo.owner, repo.name, repo.branch, repo.enabled, repo.skills_path],
|
||||||
@@ -781,7 +826,7 @@ impl Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_skill_repo(&self, owner: &str, name: &str) -> Result<(), AppError> {
|
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(
|
conn.execute(
|
||||||
"DELETE FROM skill_repos WHERE owner = ?1 AND name = ?2",
|
"DELETE FROM skill_repos WHERE owner = ?1 AND name = ?2",
|
||||||
params![owner, name],
|
params![owner, name],
|
||||||
@@ -790,10 +835,31 @@ impl Database {
|
|||||||
Ok(())
|
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 ---
|
// --- Settings DAO ---
|
||||||
|
|
||||||
pub fn get_setting(&self, key: &str) -> Result<Option<String>, AppError> {
|
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
|
let mut stmt = conn
|
||||||
.prepare("SELECT value FROM settings WHERE key = ?1")
|
.prepare("SELECT value FROM settings WHERE key = ?1")
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
@@ -812,7 +878,7 @@ impl Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_setting(&self, key: &str, value: &str) -> Result<(), AppError> {
|
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(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
||||||
params![key, value],
|
params![key, value],
|
||||||
@@ -837,7 +903,7 @@ impl Database {
|
|||||||
self.set_setting(&key, &value)
|
self.set_setting(&key, &value)
|
||||||
} else {
|
} else {
|
||||||
// Delete if None
|
// 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])
|
conn.execute("DELETE FROM settings WHERE key = ?1", params![key])
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -495,24 +495,31 @@ pub fn run() {
|
|||||||
use objc2::runtime::AnyObject;
|
use objc2::runtime::AnyObject;
|
||||||
use objc2_app_kit::NSColor;
|
use objc2_app_kit::NSColor;
|
||||||
|
|
||||||
let ns_window_ptr = window.ns_window().unwrap();
|
match window.ns_window() {
|
||||||
let ns_window: Retained<AnyObject> =
|
Ok(ns_window_ptr) => {
|
||||||
unsafe { Retained::retain(ns_window_ptr as *mut AnyObject).unwrap() };
|
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
|
unsafe {
|
||||||
// #3498db = RGB(52, 152, 219)
|
use objc2::msg_send;
|
||||||
let bg_color = unsafe {
|
let _: () = msg_send![&*ns_window, setBackgroundColor: &*bg_color];
|
||||||
NSColor::colorWithRed_green_blue_alpha(
|
}
|
||||||
52.0 / 255.0, // R: 52
|
} else {
|
||||||
152.0 / 255.0, // G: 152
|
log::warn!("Failed to retain NSWindow reference");
|
||||||
219.0 / 255.0, // B: 219
|
}
|
||||||
1.0, // Alpha: 1.0
|
}
|
||||||
)
|
Err(e) => log::warn!("Failed to get NSWindow pointer: {e}"),
|
||||||
};
|
|
||||||
|
|
||||||
unsafe {
|
|
||||||
use objc2::msg_send;
|
|
||||||
let _: () = msg_send![&*ns_window, setBackgroundColor: &*bg_color];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -565,6 +572,115 @@ pub fn run() {
|
|||||||
|
|
||||||
let app_state = AppState::new(db);
|
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
|
// 迁移旧的 app_config_dir 配置到 Store
|
||||||
if let Err(e) = app_store::migrate_app_config_dir_from_settings(app.handle()) {
|
if let Err(e) = app_store::migrate_app_config_dir_from_settings(app.handle()) {
|
||||||
log::warn!("迁移 app_config_dir 失败: {e}");
|
log::warn!("迁移 app_config_dir 失败: {e}");
|
||||||
@@ -622,7 +738,11 @@ pub fn run() {
|
|||||||
.show_menu_on_left_click(true);
|
.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)?;
|
let _tray = tray_builder.build(app)?;
|
||||||
// 将同一个实例注入到全局状态,避免重复创建导致的不一致
|
// 将同一个实例注入到全局状态,避免重复创建导致的不一致
|
||||||
|
|||||||
@@ -348,10 +348,7 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 确保新结构存在
|
// 确保新结构存在
|
||||||
if config.mcp.servers.is_none() {
|
let servers = config.mcp.servers.get_or_insert_with(HashMap::new);
|
||||||
config.mcp.servers = Some(HashMap::new());
|
|
||||||
}
|
|
||||||
let servers = config.mcp.servers.as_mut().unwrap();
|
|
||||||
|
|
||||||
let mut changed = 0;
|
let mut changed = 0;
|
||||||
let mut errors = Vec::new();
|
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}")))?;
|
.map_err(|e| AppError::McpValidation(format!("解析 ~/.codex/config.toml 失败: {e}")))?;
|
||||||
|
|
||||||
// 确保新结构存在
|
// 确保新结构存在
|
||||||
if config.mcp.servers.is_none() {
|
let servers = config.mcp.servers.get_or_insert_with(HashMap::new);
|
||||||
config.mcp.servers = Some(HashMap::new());
|
|
||||||
}
|
|
||||||
let servers = config.mcp.servers.as_mut().unwrap();
|
|
||||||
|
|
||||||
let mut changed_total = 0usize;
|
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() {
|
let servers = config.mcp.servers.get_or_insert_with(HashMap::new);
|
||||||
config.mcp.servers = Some(HashMap::new());
|
|
||||||
}
|
|
||||||
let servers = config.mcp.servers.as_mut().unwrap();
|
|
||||||
|
|
||||||
let mut changed = 0;
|
let mut changed = 0;
|
||||||
let mut errors = Vec::new();
|
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 {
|
for item in arr {
|
||||||
match item {
|
match item {
|
||||||
Value::String(s) => toml_arr.push(s.as_str()),
|
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_i64() => {
|
||||||
Value::Number(n) if n.is_f64() => toml_arr.push(n.as_f64().unwrap()),
|
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),
|
Value::Bool(b) => toml_arr.push(*b),
|
||||||
_ => {
|
_ => {
|
||||||
all_same_type = false;
|
all_same_type = false;
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ use crate::prompt::Prompt;
|
|||||||
use crate::prompt_files::prompt_file_path;
|
use crate::prompt_files::prompt_file_path;
|
||||||
use crate::store::AppState;
|
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;
|
pub struct PromptService;
|
||||||
|
|
||||||
impl PromptService {
|
impl PromptService {
|
||||||
@@ -64,10 +72,7 @@ impl PromptService {
|
|||||||
.find(|(_, p)| p.enabled)
|
.find(|(_, p)| p.enabled)
|
||||||
.map(|(id, p)| (id.clone(), p))
|
.map(|(id, p)| (id.clone(), p))
|
||||||
{
|
{
|
||||||
let timestamp = std::time::SystemTime::now()
|
let timestamp = get_unix_timestamp()?;
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_secs() as i64;
|
|
||||||
enabled_prompt.content = live_content.clone();
|
enabled_prompt.content = live_content.clone();
|
||||||
enabled_prompt.updated_at = Some(timestamp);
|
enabled_prompt.updated_at = Some(timestamp);
|
||||||
log::info!("回填 live 提示词内容到已启用项: {enabled_id}");
|
log::info!("回填 live 提示词内容到已启用项: {enabled_id}");
|
||||||
@@ -135,10 +140,7 @@ impl PromptService {
|
|||||||
|
|
||||||
let content =
|
let content =
|
||||||
std::fs::read_to_string(&file_path).map_err(|e| AppError::io(&file_path, e))?;
|
std::fs::read_to_string(&file_path).map_err(|e| AppError::io(&file_path, e))?;
|
||||||
let timestamp = std::time::SystemTime::now()
|
let timestamp = get_unix_timestamp()?;
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_secs() as i64;
|
|
||||||
|
|
||||||
let id = format!("imported-{timestamp}");
|
let id = format!("imported-{timestamp}");
|
||||||
let prompt = Prompt {
|
let prompt = Prompt {
|
||||||
@@ -167,4 +169,56 @@ impl PromptService {
|
|||||||
std::fs::read_to_string(&file_path).map_err(|e| AppError::io(&file_path, e))?;
|
std::fs::read_to_string(&file_path).map_err(|e| AppError::io(&file_path, e))?;
|
||||||
Ok(Some(content))
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -424,9 +424,9 @@ impl ProviderService {
|
|||||||
/// 归一化 Claude 模型键:读旧键(ANTHROPIC_SMALL_FAST_MODEL),写新键(DEFAULT_*), 并删除旧键
|
/// 归一化 Claude 模型键:读旧键(ANTHROPIC_SMALL_FAST_MODEL),写新键(DEFAULT_*), 并删除旧键
|
||||||
fn normalize_claude_models_in_value(settings: &mut Value) -> bool {
|
fn normalize_claude_models_in_value(settings: &mut Value) -> bool {
|
||||||
let mut changed = false;
|
let mut changed = false;
|
||||||
let env = match settings.get_mut("env") {
|
let env = match settings.get_mut("env").and_then(|v| v.as_object_mut()) {
|
||||||
Some(v) if v.is_object() => v.as_object_mut().unwrap(),
|
Some(obj) => obj,
|
||||||
_ => return changed,
|
None => return changed,
|
||||||
};
|
};
|
||||||
|
|
||||||
let model = env
|
let model = env
|
||||||
|
|||||||
@@ -231,7 +231,12 @@ impl SkillService {
|
|||||||
// 解析技能元数据
|
// 解析技能元数据
|
||||||
match self.parse_skill_metadata(&skill_md) {
|
match self.parse_skill_metadata(&skill_md) {
|
||||||
Ok(meta) => {
|
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)
|
// 构建 README URL(考虑 skillsPath)
|
||||||
let readme_path = if let Some(ref skills_path) = repo.skills_path {
|
let readme_path = if let Some(ref skills_path) = repo.skills_path {
|
||||||
@@ -305,7 +310,12 @@ impl SkillService {
|
|||||||
continue;
|
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;
|
let mut found = false;
|
||||||
|
|||||||
Reference in New Issue
Block a user