diff --git a/src/cli/contacts.rs b/src/cli/contacts.rs index c32ee3d..f2b78c2 100644 --- a/src/cli/contacts.rs +++ b/src/cli/contacts.rs @@ -1,6 +1,6 @@ use anyhow::Result; use crate::ipc::Request; -use super::super::cli::transport; +use super::transport; pub fn cmd_contacts(query: Option, limit: usize, json: bool) -> Result<()> { let req = Request::Contacts { query, limit }; diff --git a/src/cli/daemon_cmd.rs b/src/cli/daemon_cmd.rs index 44e6660..9444e9f 100644 --- a/src/cli/daemon_cmd.rs +++ b/src/cli/daemon_cmd.rs @@ -37,8 +37,17 @@ fn cmd_stop() -> Result<()> { #[cfg(unix)] { - unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM); } - println!("已停止 wx-daemon (PID {})", pid); + let ret = unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) }; + if ret != 0 { + let errno = unsafe { *libc::__error() }; + if errno == libc::ESRCH { + println!("wx-daemon (PID {}) 已不在运行,清理残留文件", pid); + } else { + anyhow::bail!("发送 SIGTERM 失败 (errno {})", errno); + } + } else { + println!("已停止 wx-daemon (PID {})", pid); + } } #[cfg(windows)] diff --git a/src/cli/export.rs b/src/cli/export.rs index 5490dd7..d5ab7f6 100644 --- a/src/cli/export.rs +++ b/src/cli/export.rs @@ -1,6 +1,6 @@ use anyhow::Result; use crate::ipc::Request; -use super::super::cli::transport; +use super::transport; use super::history::{parse_time, parse_time_end}; pub fn cmd_export( diff --git a/src/cli/history.rs b/src/cli/history.rs index 3929a76..1ccf058 100644 --- a/src/cli/history.rs +++ b/src/cli/history.rs @@ -1,6 +1,6 @@ use anyhow::Result; use crate::ipc::Request; -use super::super::cli::transport; +use super::transport; pub fn cmd_history( chat: String, diff --git a/src/cli/init.rs b/src/cli/init.rs index b80b1ec..28775e0 100644 --- a/src/cli/init.rs +++ b/src/cli/init.rs @@ -75,6 +75,11 @@ pub fn cmd_init(force: bool) -> Result<()> { cfg.entry("keys_file".into()).or_insert_with(|| json!("all_keys.json")); cfg.entry("decrypted_dir".into()).or_insert_with(|| json!("decrypted")); + // 确保父目录存在(如 ~/.wx-cli/) + if let Some(parent) = config_path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("创建目录失败: {}", parent.display()))?; + } std::fs::write(&config_path, serde_json::to_string_pretty(&cfg)?) .context("写入 config.json 失败")?; println!("配置已保存: {}", config_path.display()); @@ -84,13 +89,21 @@ pub fn cmd_init(force: bool) -> Result<()> { } fn find_or_create_config_path() -> std::path::PathBuf { - // 优先使用可执行文件同目录 - if let Ok(exe) = std::env::current_exe() { - if let Some(dir) = exe.parent() { - return dir.join("config.json"); + // 如果当前工作目录或可执行文件目录已有 config.json,沿用它(支持便携模式) + if let Ok(cwd) = std::env::current_dir() { + let p = cwd.join("config.json"); + if p.exists() { + return p; } } - std::env::current_dir() - .unwrap_or_default() - .join("config.json") + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + let p = dir.join("config.json"); + if p.exists() { + return p; + } + } + } + // 默认写入 ~/.wx-cli/config.json(与 load_config 的最终查找路径保持一致) + config::cli_dir().join("config.json") } diff --git a/src/cli/search.rs b/src/cli/search.rs index 59955a8..6f79d3d 100644 --- a/src/cli/search.rs +++ b/src/cli/search.rs @@ -1,6 +1,6 @@ use anyhow::Result; use crate::ipc::Request; -use super::super::cli::transport; +use super::transport; use super::history::{parse_time, parse_time_end}; pub fn cmd_search( diff --git a/src/cli/sessions.rs b/src/cli/sessions.rs index 8721ad3..cfedf79 100644 --- a/src/cli/sessions.rs +++ b/src/cli/sessions.rs @@ -1,6 +1,6 @@ use anyhow::Result; use crate::ipc::Request; -use super::super::cli::transport; +use super::transport; pub fn cmd_sessions(limit: usize, json: bool) -> Result<()> { let resp = transport::send(Request::Sessions { limit })?; diff --git a/src/cli/transport.rs b/src/cli/transport.rs index 7865bbb..e0c4d05 100644 --- a/src/cli/transport.rs +++ b/src/cli/transport.rs @@ -67,11 +67,23 @@ fn start_daemon() -> Result<()> { #[cfg(unix)] { use std::os::unix::process::CommandExt; + // 日志文件:~/.wx-cli/daemon.log + let log_path = config::log_path(); + // 确保父目录存在 + if let Some(parent) = log_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let (stdout_stdio, stderr_stdio) = std::fs::OpenOptions::new() + .create(true).append(true) + .open(&log_path) + .and_then(|f| f.try_clone().map(|g| (f, g))) + .map(|(f, g)| (std::process::Stdio::from(f), std::process::Stdio::from(g))) + .unwrap_or_else(|_| (std::process::Stdio::null(), std::process::Stdio::null())); let mut cmd = std::process::Command::new(&exe); cmd.env("WX_DAEMON_MODE", "1") .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()); + .stdout(stdout_stdio) + .stderr(stderr_stdio); // SAFETY: setsid() 在 fork 后的子进程中调用,使 daemon 脱离控制终端 unsafe { cmd.pre_exec(|| { libc::setsid(); Ok(()) }); } let _ = cmd.spawn().context("无法启动 daemon 进程")?; @@ -79,8 +91,15 @@ fn start_daemon() -> Result<()> { #[cfg(windows)] { + let log_file = std::fs::OpenOptions::new() + .create(true).append(true) + .open(config::log_path()) + .ok() + .map(std::process::Stdio::from) + .unwrap_or_else(std::process::Stdio::null); let _ = std::process::Command::new(&exe) .env("WX_DAEMON_MODE", "1") + .stdout(log_file) .creation_flags(0x00000008) // DETACHED_PROCESS .spawn() .context("无法启动 daemon 进程")?; diff --git a/src/cli/watch.rs b/src/cli/watch.rs index ed54fa1..73c15d1 100644 --- a/src/cli/watch.rs +++ b/src/cli/watch.rs @@ -2,7 +2,7 @@ use anyhow::Result; use std::io::BufRead; use crate::ipc::Request; -use super::super::cli::transport; +use super::transport; pub fn cmd_watch(chat: Option, json: bool) -> Result<()> { transport::ensure_daemon()?; diff --git a/src/config.rs b/src/config.rs index 1fb59b6..55a03ca 100644 --- a/src/config.rs +++ b/src/config.rs @@ -55,21 +55,6 @@ pub fn load_config() -> Result { }) } -/// 保存配置到文件 -pub fn save_config(config: &Config) -> Result<()> { - let config_path = find_config_file().unwrap_or_else(|_| { - std::env::current_exe() - .unwrap_or_default() - .parent() - .unwrap_or(Path::new(".")) - .join("config.json") - }); - let content = serde_json::to_string_pretty(config)?; - std::fs::write(&config_path, content) - .with_context(|| format!("写入 config.json 失败: {}", config_path.display()))?; - Ok(()) -} - fn find_config_file() -> Result { // 1. 优先查找可执行文件同目录 if let Ok(exe) = std::env::current_exe() { diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs index 1c09f4d..0d07469 100644 --- a/src/crypto/mod.rs +++ b/src/crypto/mod.rs @@ -4,6 +4,7 @@ use anyhow::{bail, Result}; use aes::Aes256; use cbc::Decryptor; use cbc::cipher::{BlockDecryptMut, KeyIvInit}; +use std::io::{Read, Write}; use std::path::Path; pub const PAGE_SZ: usize = 4096; @@ -73,34 +74,34 @@ fn aes_cbc_decrypt(key: &[u8; 32], iv: &[u8; 16], data: &[u8]) -> Result Ok(buf) } -/// 完整解密一个 SQLCipher 数据库文件 +/// 完整解密一个 SQLCipher 数据库文件(流式,逐页读写避免全量载入内存) /// /// 读取 `db_path`,按 PAGE_SZ 分页解密,写入 `out_path` pub fn full_decrypt(db_path: &Path, out_path: &Path, enc_key: &[u8; 32]) -> Result<()> { - let data = std::fs::read(db_path)?; - if data.is_empty() { - bail!("数据库文件为空: {}", db_path.display()); - } - if let Some(parent) = out_path.parent() { std::fs::create_dir_all(parent)?; } - let total_pages = (data.len() + PAGE_SZ - 1) / PAGE_SZ; - let mut out = Vec::with_capacity(data.len()); - - for pgno in 1..=total_pages { - let offset = (pgno - 1) * PAGE_SZ; - let end = std::cmp::min(offset + PAGE_SZ, data.len()); - let mut page = data[offset..end].to_vec(); - // 不足一页则补零 - if page.len() < PAGE_SZ { - page.resize(PAGE_SZ, 0); - } - let dec = decrypt_page(enc_key, &page, pgno as u32)?; - out.extend_from_slice(&dec); + let mut input = std::fs::File::open(db_path)?; + let file_size = input.metadata()?.len() as usize; + if file_size == 0 { + bail!("数据库文件为空: {}", db_path.display()); + } + + let mut output = std::fs::File::create(out_path)?; + let total_pages = (file_size + PAGE_SZ - 1) / PAGE_SZ; + let mut page_buf = vec![0u8; PAGE_SZ]; + + for pgno in 1..=total_pages { + let n = input.read(&mut page_buf)?; + if n == 0 { break; } + // 不足一页则补零 + if n < PAGE_SZ { + page_buf[n..].fill(0); + } + let dec = decrypt_page(enc_key, &page_buf, pgno as u32)?; + output.write_all(&dec)?; } - std::fs::write(out_path, &out)?; Ok(()) } diff --git a/src/daemon/cache.rs b/src/daemon/cache.rs index 4b742f4..229ddfc 100644 --- a/src/daemon/cache.rs +++ b/src/daemon/cache.rs @@ -80,11 +80,10 @@ impl DbCache { continue; } let db_path = self.db_dir.join(rel_key.replace('\\', std::path::MAIN_SEPARATOR_STR).replace('/', std::path::MAIN_SEPARATOR_STR)); - let wal_path_str = format!("{}-wal", db_path.display()); - let wal_path = Path::new(&wal_path_str); + let wal_path = wal_path_for(&db_path); let db_mt = mtime_nanos(&db_path); - let wal_mt = if wal_path.exists() { mtime_nanos(wal_path) } else { 0 }; + let wal_mt = if wal_path.exists() { mtime_nanos(&wal_path) } else { 0 }; if db_mt == entry.db_mt && wal_mt == entry.wal_mt { inner.insert(rel_key.clone(), CacheEntry { @@ -135,8 +134,7 @@ impl DbCache { return Ok(None); } - let wal_path_str = format!("{}-wal", db_path.display()); - let wal_path = Path::new(&wal_path_str).to_path_buf(); + let wal_path = wal_path_for(&db_path); let db_mt = mtime_nanos(&db_path); let wal_mt = if wal_path.exists() { mtime_nanos(&wal_path) } else { 0 }; @@ -195,13 +193,20 @@ impl DbCache { } } -fn mtime_nanos(path: &Path) -> u64 { +pub(super) fn mtime_nanos(path: &Path) -> u64 { std::fs::metadata(path) .and_then(|m| m.modified()) .map(|t| t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos() as u64) .unwrap_or(0) } +/// `foo/bar.db` → `foo/bar.db-wal`(用 OsString 拼接,避免 display() 的 UTF-8 问题) +fn wal_path_for(db_path: &Path) -> PathBuf { + let mut name = db_path.file_name().unwrap_or_default().to_os_string(); + name.push("-wal"); + db_path.with_file_name(name) +} + fn hex_to_32bytes(s: &str) -> Result<[u8; 32]> { if s.len() != 64 { anyhow::bail!("密钥 hex 长度应为 64,实际为 {}", s.len()); diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index 2c24e87..1ca44d2 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -208,12 +208,7 @@ async fn run_watcher( } } -fn mtime_nanos(path: &std::path::Path) -> u64 { - std::fs::metadata(path) - .and_then(|m| m.modified()) - .map(|t| t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos() as u64) - .unwrap_or(0) -} +use cache::mtime_nanos; fn decompress_or_str(data: &[u8]) -> String { if data.is_empty() { return String::new(); } diff --git a/src/daemon/query.rs b/src/daemon/query.rs index 659ccca..0904a85 100644 --- a/src/daemon/query.rs +++ b/src/daemon/query.rs @@ -4,9 +4,16 @@ use regex::Regex; use rusqlite::Connection; use serde_json::{json, Value}; use std::collections::HashMap; +use std::sync::OnceLock; use super::cache::DbCache; +/// 静态编译的 Msg 表名正则,避免在热路径中重复编译 +fn msg_table_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"^Msg_[0-9a-f]{32}$").unwrap()) +} + /// 联系人名称缓存 #[derive(Clone)] pub struct Names { @@ -141,7 +148,7 @@ pub async fn q_history( let tables = find_msg_tables(db, names, &username).await?; if tables.is_empty() { - return Ok(json!({ "error": format!("找不到 {} 的消息记录", display) })); + anyhow::bail!("找不到 {} 的消息记录", display); } let mut all_msgs: Vec = Vec::new(); @@ -218,7 +225,7 @@ pub async fn q_search( .filter_map(|r| r.ok()) .collect(); - let re = Regex::new(r"^Msg_[0-9a-f]{32}$").unwrap(); + let re = msg_table_re(); let mut result = Vec::new(); for tname in table_names { if !re.is_match(&tname) { @@ -483,8 +490,10 @@ fn search_in_table( limit: usize, ) -> Result> { let id2u = load_id2u(conn); - let mut clauses = vec!["message_content LIKE ?".to_string()]; - let mut params: Vec> = vec![Box::new(format!("%{}%", keyword))]; + // 转义 LIKE 通配符,使用 '\' 作为 ESCAPE 字符 + let escaped_kw = keyword.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_"); + let mut clauses = vec!["message_content LIKE ? ESCAPE '\\'".to_string()]; + let mut params: Vec> = vec![Box::new(format!("%{}%", escaped_kw))]; if let Some(s) = since { clauses.push("create_time >= ?".into()); params.push(Box::new(s)); diff --git a/src/ipc.rs b/src/ipc.rs index c94e4aa..130ed22 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -41,12 +41,6 @@ pub enum Request { Watch, } -impl Request { - pub fn to_json_line(&self) -> anyhow::Result { - let s = serde_json::to_string(self)?; - Ok(s + "\n") - } -} /// daemon 的响应 #[derive(Debug, Clone, Serialize, Deserialize)]