mirror of https://github.com/jackwener/wx-cli.git
fix: 深度 review 修复 10 个 bug/问题
Critical & High: - daemon 日志:启动时将 stdout/stderr 重定向到 ~/.wx-cli/daemon.log 而非 /dev/null,使 wx daemon logs 真正可用 - q_history 找不到聊天时改为 bail! 而非 ok:true+error 字段, 避免 CLI 静默返回空输出 - init 写 config.json 默认路径改为 ~/.wx-cli/config.json, 避免写入系统 bin 目录(/usr/local/bin/config.json) - LIKE 通配符:搜索关键词中的 %/_/\ 现在正确转义 - WAL 路径:改用 OsString.push 拼接 "-wal" 后缀, 避免 display() 在非 UTF-8 路径上失效 - cmd_stop:检查 kill() 返回值,ESRCH 时给出明确提示 Performance & Code quality: - full_decrypt:改为流式逐页读写,峰值内存从 2×文件大小降为 O(1) - Regex:msg_table_re() 用 OnceLock 静态编译,避免热路径重复编译 - mtime_nanos:消除 daemon/mod.rs 与 cache.rs 的重复定义 - use super::super::cli::transport → use super::transport - 删除未使用的 save_config、Request::to_json_line 死代码pull/2/head
parent
dfd020a2b9
commit
7f869e7c3b
|
|
@ -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<String>, limit: usize, json: bool) -> Result<()> {
|
||||
let req = Request::Contacts { query, limit };
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 })?;
|
||||
|
|
|
|||
|
|
@ -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 进程")?;
|
||||
|
|
|
|||
|
|
@ -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<String>, json: bool) -> Result<()> {
|
||||
transport::ensure_daemon()?;
|
||||
|
|
|
|||
|
|
@ -55,21 +55,6 @@ pub fn load_config() -> Result<Config> {
|
|||
})
|
||||
}
|
||||
|
||||
/// 保存配置到文件
|
||||
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<PathBuf> {
|
||||
// 1. 优先查找可执行文件同目录
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
|
|
|
|||
|
|
@ -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<Vec<u8>
|
|||
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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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(); }
|
||||
|
|
|
|||
|
|
@ -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<Regex> = 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<Value> = 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<Vec<Value>> {
|
||||
let id2u = load_id2u(conn);
|
||||
let mut clauses = vec!["message_content LIKE ?".to_string()];
|
||||
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = 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<Box<dyn rusqlite::types::ToSql>> = vec![Box::new(format!("%{}%", escaped_kw))];
|
||||
if let Some(s) = since {
|
||||
clauses.push("create_time >= ?".into());
|
||||
params.push(Box::new(s));
|
||||
|
|
|
|||
|
|
@ -41,12 +41,6 @@ pub enum Request {
|
|||
Watch,
|
||||
}
|
||||
|
||||
impl Request {
|
||||
pub fn to_json_line(&self) -> anyhow::Result<String> {
|
||||
let s = serde_json::to_string(self)?;
|
||||
Ok(s + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
/// daemon 的响应
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
|
|||
Loading…
Reference in New Issue