mirror of https://github.com/jackwener/wx-cli.git
feat: Rust 完整重写 wx-cli(单一二进制,支持 macOS/Linux/Windows)
实现所有核心模块: - src/crypto/: SQLCipher 4 页解密 + WAL 应用(AES-256-CBC) - src/scanner/: 三平台内存扫描(macOS Mach VM / Linux /proc/mem / Windows ReadProcessMemory) - src/daemon/: tokio 异步 daemon,Unix socket IPC,mtime-aware DB 缓存,WAL 监听推送 - src/cli/: clap CLI,自动启动 daemon,完整命令实现 - src/config.rs: 跨平台配置加载,兼容 Python 版 config.json 格式 - src/ipc.rs: 换行符分隔 JSON 协议,与 Python 版兼容 - .github/workflows/release.yml: 四平台自动构建发布 cargo build --release 验证通过,生成 4.8MB macOS arm64 单一二进制pull/1/head
parent
0d5ac82349
commit
d475f6219b
|
|
@ -0,0 +1,70 @@
|
|||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
name: wx-macos-arm64
|
||||
- os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
name: wx-macos-x86_64
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
name: wx-linux-x86_64
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
name: wx-windows-x86_64.exe
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Cache cargo registry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: ${{ runner.os }}-cargo-
|
||||
|
||||
- name: Build release
|
||||
run: cargo build --release --target ${{ matrix.target }}
|
||||
|
||||
- name: Rename binary (Unix)
|
||||
if: matrix.os != 'windows-latest'
|
||||
run: |
|
||||
cp target/${{ matrix.target }}/release/wx ${{ matrix.name }}
|
||||
|
||||
- name: Rename binary (Windows)
|
||||
if: matrix.os == 'windows-latest'
|
||||
run: |
|
||||
copy target\${{ matrix.target }}\release\wx.exe ${{ matrix.name }}
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.name }}
|
||||
path: ${{ matrix.name }}
|
||||
|
||||
- name: Upload to GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
files: ${{ matrix.name }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,68 @@
|
|||
[package]
|
||||
name = "wx"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "wx"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
# CLI
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
|
||||
# 异步
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
# 序列化
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "=1.0.140"
|
||||
|
||||
# SQLite
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
|
||||
# 加密
|
||||
aes = "0.8"
|
||||
cbc = { version = "0.1", features = ["alloc"] }
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
pbkdf2 = "0.12"
|
||||
|
||||
# 解压
|
||||
zstd = "0.13"
|
||||
|
||||
# IPC (Unix socket + Windows named pipe 统一)
|
||||
interprocess = { version = "2", features = ["tokio"] }
|
||||
|
||||
# 错误处理
|
||||
anyhow = "1"
|
||||
|
||||
# 时间
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# 跨平台路径
|
||||
dirs = "5"
|
||||
|
||||
# MD5 (联系人表名 Msg_<md5>)
|
||||
md5 = "0.7"
|
||||
|
||||
# 正则表达式
|
||||
regex = "1"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows = { version = "0.58", features = [
|
||||
"Win32_System_Diagnostics_Debug",
|
||||
"Win32_System_Diagnostics_ToolHelp",
|
||||
"Win32_System_Threading",
|
||||
"Win32_Foundation",
|
||||
"Win32_System_Memory",
|
||||
] }
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
use anyhow::Result;
|
||||
use crate::ipc::Request;
|
||||
use super::super::cli::transport;
|
||||
|
||||
pub fn cmd_contacts(query: Option<String>, limit: usize, json: bool) -> Result<()> {
|
||||
let req = Request::Contacts { query, limit };
|
||||
let resp = transport::send(req)?;
|
||||
|
||||
let contacts = resp.data.get("contacts")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let total = resp.data["total"].as_i64().unwrap_or(contacts.len() as i64);
|
||||
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&contacts)?);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("共 {} 个联系人(显示 {} 个):\n", total, contacts.len());
|
||||
for c in &contacts {
|
||||
let display = c["display"].as_str().unwrap_or("");
|
||||
let username = c["username"].as_str().unwrap_or("");
|
||||
println!(" {:<20} {}", display, username);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
use anyhow::Result;
|
||||
use crate::config;
|
||||
use crate::cli::DaemonCommands;
|
||||
use crate::cli::transport;
|
||||
|
||||
pub fn cmd_daemon(cmd: DaemonCommands) -> Result<()> {
|
||||
match cmd {
|
||||
DaemonCommands::Status => cmd_status(),
|
||||
DaemonCommands::Stop => cmd_stop(),
|
||||
DaemonCommands::Logs { follow, lines } => cmd_logs(follow, lines),
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_status() -> Result<()> {
|
||||
if transport::is_alive() {
|
||||
let pid_path = config::pid_path();
|
||||
let pid = std::fs::read_to_string(&pid_path)
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|_| "?".into());
|
||||
println!("wx-daemon 运行中 (PID {})", pid);
|
||||
} else {
|
||||
println!("wx-daemon 未运行");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_stop() -> Result<()> {
|
||||
let pid_path = config::pid_path();
|
||||
if !pid_path.exists() {
|
||||
println!("daemon 未运行");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let pid_str = std::fs::read_to_string(&pid_path)?;
|
||||
let pid: u32 = pid_str.trim().parse()
|
||||
.map_err(|_| anyhow::anyhow!("PID 文件格式错误"))?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM); }
|
||||
println!("已停止 wx-daemon (PID {})", pid);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
std::process::Command::new("taskkill")
|
||||
.args(["/PID", &pid.to_string(), "/F"])
|
||||
.output()?;
|
||||
println!("已停止 wx-daemon (PID {})", pid);
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_file(config::sock_path());
|
||||
let _ = std::fs::remove_file(&pid_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_logs(follow: bool, lines: usize) -> Result<()> {
|
||||
let log_path = config::log_path();
|
||||
if !log_path.exists() {
|
||||
println!("暂无日志");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if follow {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::process::Command::new("tail")
|
||||
.args([&format!("-{}", lines), "-f", &log_path.to_string_lossy()])
|
||||
.status()?;
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
let mut file = std::fs::File::open(&log_path)?;
|
||||
let len = file.seek(SeekFrom::End(0))?;
|
||||
let start = len.saturating_sub((lines as u64) * 200);
|
||||
file.seek(SeekFrom::Start(start))?;
|
||||
let mut content = String::new();
|
||||
file.read_to_string(&mut content)?;
|
||||
let all_lines: Vec<&str> = content.lines().collect();
|
||||
let show = &all_lines[all_lines.len().saturating_sub(lines)..];
|
||||
for line in show { println!("{}", line); }
|
||||
loop {
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
let mut buf = String::new();
|
||||
file.read_to_string(&mut buf)?;
|
||||
if !buf.is_empty() { print!("{}", buf); }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let content = std::fs::read_to_string(&log_path)?;
|
||||
let all_lines: Vec<&str> = content.lines().collect();
|
||||
let show = &all_lines[all_lines.len().saturating_sub(lines)..];
|
||||
for line in show { println!("{}", line); }
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
use anyhow::Result;
|
||||
use crate::ipc::Request;
|
||||
use super::super::cli::transport;
|
||||
use super::history::{parse_time, parse_time_end};
|
||||
|
||||
pub fn cmd_export(
|
||||
chat: String,
|
||||
since: Option<String>,
|
||||
until: Option<String>,
|
||||
limit: usize,
|
||||
format: String,
|
||||
output: Option<String>,
|
||||
) -> Result<()> {
|
||||
let since_ts = since.as_deref().map(parse_time).transpose()?;
|
||||
let until_ts = until.as_deref().map(parse_time_end).transpose()?;
|
||||
|
||||
let req = Request::History {
|
||||
chat,
|
||||
limit,
|
||||
offset: 0,
|
||||
since: since_ts,
|
||||
until: until_ts,
|
||||
};
|
||||
|
||||
let resp = transport::send(req)?;
|
||||
let messages = resp.data["messages"].as_array().cloned().unwrap_or_default();
|
||||
let chat_name = resp.data["chat"].as_str().unwrap_or("").to_string();
|
||||
let is_group = resp.data["is_group"].as_bool().unwrap_or(false);
|
||||
let count = messages.len();
|
||||
|
||||
let text = match format.as_str() {
|
||||
"json" => serde_json::to_string_pretty(&resp.data)?,
|
||||
"txt" => {
|
||||
let group_str = if is_group { "[群]" } else { "" };
|
||||
let mut lines = vec![format!("=== {}{} ({} 条) ===\n", chat_name, group_str, count)];
|
||||
for m in &messages {
|
||||
let time = m["time"].as_str().unwrap_or("");
|
||||
let sender = m["sender"].as_str().unwrap_or("");
|
||||
let content = m["content"].as_str().unwrap_or("");
|
||||
let sender_str = if !sender.is_empty() { format!("{}: ", sender) } else { String::new() };
|
||||
lines.push(format!("[{}] {}{}", time, sender_str, content));
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
_ => {
|
||||
// markdown (default)
|
||||
let group_str = if is_group { "(群聊)" } else { "" };
|
||||
let mut lines = vec![
|
||||
format!("# {}{}", chat_name, group_str),
|
||||
format!("\n> 导出 {} 条消息\n", count),
|
||||
];
|
||||
for m in &messages {
|
||||
let time = m["time"].as_str().unwrap_or("");
|
||||
let sender = m["sender"].as_str().unwrap_or("");
|
||||
let content = m["content"].as_str().unwrap_or("").replace('\n', "\n> ");
|
||||
let sender_md = if !sender.is_empty() { format!("**{}**: ", sender) } else { String::new() };
|
||||
lines.push(format!("### {}\n\n{}{}\n", time, sender_md, content));
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
};
|
||||
|
||||
match output {
|
||||
Some(path) => {
|
||||
std::fs::write(&path, &text)?;
|
||||
println!("已导出 {} 条消息到 {}", count, path);
|
||||
}
|
||||
None => println!("{}", text),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
use anyhow::Result;
|
||||
use crate::ipc::Request;
|
||||
use super::super::cli::transport;
|
||||
|
||||
pub fn cmd_history(
|
||||
chat: String,
|
||||
limit: usize,
|
||||
offset: usize,
|
||||
since: Option<String>,
|
||||
until: Option<String>,
|
||||
json: bool,
|
||||
) -> Result<()> {
|
||||
let since_ts = since.as_deref().map(parse_time).transpose()?;
|
||||
let until_ts = until.as_deref().map(|s| parse_time_end(s)).transpose()?;
|
||||
|
||||
let req = Request::History {
|
||||
chat,
|
||||
limit,
|
||||
offset,
|
||||
since: since_ts,
|
||||
until: until_ts,
|
||||
};
|
||||
|
||||
let resp = transport::send(req)?;
|
||||
|
||||
if json {
|
||||
let msgs = resp.data.get("messages").cloned().unwrap_or(serde_json::Value::Array(vec![]));
|
||||
println!("{}", serde_json::to_string_pretty(&msgs)?);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let chat_name = resp.data["chat"].as_str().unwrap_or("");
|
||||
let is_group = resp.data["is_group"].as_bool().unwrap_or(false);
|
||||
let count = resp.data["count"].as_i64().unwrap_or(0);
|
||||
let group_str = if is_group { " [群]" } else { "" };
|
||||
println!("=== {}{} ({} 条) ===\n", chat_name, group_str, count);
|
||||
|
||||
if let Some(msgs) = resp.data["messages"].as_array() {
|
||||
for m in msgs {
|
||||
let time = m["time"].as_str().unwrap_or("");
|
||||
let sender = m["sender"].as_str().unwrap_or("");
|
||||
let content = m["content"].as_str().unwrap_or("");
|
||||
|
||||
let sender_str = if !sender.is_empty() {
|
||||
format!("\x1b[33m{}\x1b[0m: ", sender)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
println!("\x1b[90m[{}]\x1b[0m {}{}", time, sender_str, content);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn parse_time(s: &str) -> Result<i64> {
|
||||
for fmt in &["%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"] {
|
||||
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, fmt) {
|
||||
return Ok(dt.and_utc().timestamp());
|
||||
}
|
||||
// 尝试仅日期格式
|
||||
if let Ok(d) = chrono::NaiveDate::parse_from_str(s, fmt) {
|
||||
let dt = d.and_hms_opt(0, 0, 0).unwrap();
|
||||
return Ok(dt.and_utc().timestamp());
|
||||
}
|
||||
}
|
||||
anyhow::bail!("无法解析时间 '{}',支持 YYYY-MM-DD / YYYY-MM-DD HH:MM / YYYY-MM-DD HH:MM:SS", s)
|
||||
}
|
||||
|
||||
pub fn parse_time_end(s: &str) -> Result<i64> {
|
||||
// 对于仅日期格式,结束时间为当天 23:59:59
|
||||
if s.len() == 10 {
|
||||
if let Ok(d) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
|
||||
let dt = d.and_hms_opt(23, 59, 59).unwrap();
|
||||
return Ok(dt.and_utc().timestamp());
|
||||
}
|
||||
}
|
||||
parse_time(s)
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
use anyhow::{Context, Result};
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::config;
|
||||
use crate::scanner;
|
||||
|
||||
pub fn cmd_init(force: bool) -> Result<()> {
|
||||
// 查找 config.json
|
||||
let config_path = find_or_create_config_path();
|
||||
|
||||
// 检查是否已初始化
|
||||
if !force && config_path.exists() {
|
||||
if let Ok(content) = std::fs::read_to_string(&config_path) {
|
||||
if let Ok(cfg) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
let db_dir = cfg.get("db_dir").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let keys_file = cfg.get("keys_file").and_then(|v| v.as_str()).unwrap_or("all_keys.json");
|
||||
let keys_path = if std::path::Path::new(keys_file).is_absolute() {
|
||||
std::path::PathBuf::from(keys_file)
|
||||
} else {
|
||||
config_path.parent().unwrap_or(std::path::Path::new("."))
|
||||
.join(keys_file)
|
||||
};
|
||||
if !db_dir.is_empty() && !db_dir.contains("your_wxid")
|
||||
&& std::path::Path::new(db_dir).exists()
|
||||
&& keys_path.exists()
|
||||
{
|
||||
println!("已初始化,数据目录: {}", db_dir);
|
||||
println!("如需重新扫描密钥,使用 --force");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: 检测 db_dir
|
||||
println!("检测微信数据目录...");
|
||||
let db_dir = config::auto_detect_db_dir()
|
||||
.context("未能自动检测到微信数据目录\n请手动编辑 config.json 中的 db_dir 字段")?;
|
||||
println!("找到数据目录: {}", db_dir.display());
|
||||
|
||||
// Step 2: 扫描密钥(需要 root/sudo)
|
||||
println!("扫描加密密钥(需要 root 权限)...");
|
||||
let entries = scanner::scan_keys(&db_dir)?;
|
||||
|
||||
// Step 3: 保存 all_keys.json
|
||||
let keys_file_path = config_path.parent()
|
||||
.unwrap_or(std::path::Path::new("."))
|
||||
.join("all_keys.json");
|
||||
|
||||
let mut keys_json = serde_json::Map::new();
|
||||
for entry in &entries {
|
||||
keys_json.insert(entry.db_name.clone(), json!({
|
||||
"enc_key": entry.enc_key,
|
||||
}));
|
||||
}
|
||||
std::fs::write(&keys_file_path, serde_json::to_string_pretty(&keys_json)?)
|
||||
.context("写入 all_keys.json 失败")?;
|
||||
println!("成功提取 {} 个数据库密钥", entries.len());
|
||||
println!("密钥已保存: {}", keys_file_path.display());
|
||||
|
||||
// Step 4: 保存 config.json
|
||||
let mut cfg = HashMap::new();
|
||||
// 读取已有配置
|
||||
if config_path.exists() {
|
||||
if let Ok(c) = std::fs::read_to_string(&config_path) {
|
||||
if let Ok(v) = serde_json::from_str::<HashMap<String, serde_json::Value>>(&c) {
|
||||
for (k, val) in v {
|
||||
cfg.insert(k, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cfg.insert("db_dir".into(), json!(db_dir.to_string_lossy()));
|
||||
cfg.entry("keys_file".into()).or_insert_with(|| json!("all_keys.json"));
|
||||
cfg.entry("decrypted_dir".into()).or_insert_with(|| json!("decrypted"));
|
||||
|
||||
std::fs::write(&config_path, serde_json::to_string_pretty(&cfg)?)
|
||||
.context("写入 config.json 失败")?;
|
||||
println!("配置已保存: {}", config_path.display());
|
||||
println!("初始化完成,可以使用 wx sessions / wx history 等命令了");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
std::env::current_dir()
|
||||
.unwrap_or_default()
|
||||
.join("config.json")
|
||||
}
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
mod init;
|
||||
pub mod sessions;
|
||||
pub mod history;
|
||||
pub mod search;
|
||||
pub mod contacts;
|
||||
pub mod export;
|
||||
pub mod watch;
|
||||
pub mod daemon_cmd;
|
||||
pub mod transport;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
/// wx — 微信本地数据 CLI
|
||||
#[derive(Parser)]
|
||||
#[command(name = "wx", version = "0.1.0", about = "wx — 微信本地数据 CLI")]
|
||||
pub struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// 初始化:检测数据目录并扫描加密密钥
|
||||
Init {
|
||||
/// 强制重新扫描(覆盖已有配置)
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
},
|
||||
/// 列出最近会话
|
||||
Sessions {
|
||||
/// 会话数量
|
||||
#[arg(short = 'n', long, default_value = "20")]
|
||||
limit: usize,
|
||||
/// 输出原始 JSON
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// 查看聊天记录
|
||||
History {
|
||||
/// 聊天对象名称(支持模糊匹配)
|
||||
chat: String,
|
||||
/// 消息数量
|
||||
#[arg(short = 'n', long, default_value = "50")]
|
||||
limit: usize,
|
||||
/// 分页偏移
|
||||
#[arg(long, default_value = "0")]
|
||||
offset: usize,
|
||||
/// 起始时间 YYYY-MM-DD
|
||||
#[arg(long)]
|
||||
since: Option<String>,
|
||||
/// 结束时间 YYYY-MM-DD
|
||||
#[arg(long)]
|
||||
until: Option<String>,
|
||||
/// 输出原始 JSON
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// 搜索消息
|
||||
Search {
|
||||
/// 搜索关键词
|
||||
keyword: String,
|
||||
/// 限定聊天(可多次指定)
|
||||
#[arg(long = "in", value_name = "CHAT")]
|
||||
chats: Vec<String>,
|
||||
/// 结果数量
|
||||
#[arg(short = 'n', long, default_value = "20")]
|
||||
limit: usize,
|
||||
/// 起始时间 YYYY-MM-DD
|
||||
#[arg(long)]
|
||||
since: Option<String>,
|
||||
/// 结束时间 YYYY-MM-DD
|
||||
#[arg(long)]
|
||||
until: Option<String>,
|
||||
/// 输出原始 JSON
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// 查看联系人
|
||||
Contacts {
|
||||
/// 按名字过滤
|
||||
#[arg(short = 'q', long)]
|
||||
query: Option<String>,
|
||||
/// 显示数量
|
||||
#[arg(short = 'n', long, default_value = "50")]
|
||||
limit: usize,
|
||||
/// 输出原始 JSON
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// 导出聊天记录到文件
|
||||
Export {
|
||||
/// 聊天对象名称
|
||||
chat: String,
|
||||
/// 起始时间 YYYY-MM-DD
|
||||
#[arg(long)]
|
||||
since: Option<String>,
|
||||
/// 结束时间 YYYY-MM-DD
|
||||
#[arg(long)]
|
||||
until: Option<String>,
|
||||
/// 最多导出条数
|
||||
#[arg(short = 'n', long, default_value = "500")]
|
||||
limit: usize,
|
||||
/// 输出格式 [markdown|txt|json]
|
||||
#[arg(short = 'f', long, default_value = "markdown", value_parser = ["markdown", "txt", "json"])]
|
||||
format: String,
|
||||
/// 输出文件(默认 stdout)
|
||||
#[arg(short = 'o', long)]
|
||||
output: Option<String>,
|
||||
},
|
||||
/// 实时监听新消息(Ctrl+C 退出)
|
||||
Watch {
|
||||
/// 只显示指定聊天的消息
|
||||
#[arg(long)]
|
||||
chat: Option<String>,
|
||||
/// 输出 JSON lines
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// 管理 wx-daemon
|
||||
Daemon {
|
||||
#[command(subcommand)]
|
||||
cmd: DaemonCommands,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum DaemonCommands {
|
||||
/// 查看 daemon 运行状态
|
||||
Status,
|
||||
/// 停止 daemon
|
||||
Stop,
|
||||
/// 查看 daemon 日志
|
||||
Logs {
|
||||
/// 持续输出(tail -f)
|
||||
#[arg(short = 'f', long)]
|
||||
follow: bool,
|
||||
/// 显示最近 N 行
|
||||
#[arg(short = 'n', long, default_value = "50")]
|
||||
lines: usize,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
let cli = Cli::parse();
|
||||
if let Err(e) = dispatch(cli) {
|
||||
eprintln!("错误: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch(cli: Cli) -> Result<()> {
|
||||
match cli.command {
|
||||
Commands::Init { force } => init::cmd_init(force),
|
||||
Commands::Sessions { limit, json } => sessions::cmd_sessions(limit, json),
|
||||
Commands::History { chat, limit, offset, since, until, json } => {
|
||||
history::cmd_history(chat, limit, offset, since, until, json)
|
||||
}
|
||||
Commands::Search { keyword, chats, limit, since, until, json } => {
|
||||
search::cmd_search(keyword, chats, limit, since, until, json)
|
||||
}
|
||||
Commands::Contacts { query, limit, json } => contacts::cmd_contacts(query, limit, json),
|
||||
Commands::Export { chat, since, until, limit, format, output } => {
|
||||
export::cmd_export(chat, since, until, limit, format, output)
|
||||
}
|
||||
Commands::Watch { chat, json } => watch::cmd_watch(chat, json),
|
||||
Commands::Daemon { cmd } => daemon_cmd::cmd_daemon(cmd),
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
use anyhow::Result;
|
||||
use crate::ipc::Request;
|
||||
use super::super::cli::transport;
|
||||
use super::history::{parse_time, parse_time_end};
|
||||
|
||||
pub fn cmd_search(
|
||||
keyword: String,
|
||||
chats: Vec<String>,
|
||||
limit: usize,
|
||||
since: Option<String>,
|
||||
until: Option<String>,
|
||||
json: bool,
|
||||
) -> Result<()> {
|
||||
let since_ts = since.as_deref().map(parse_time).transpose()?;
|
||||
let until_ts = until.as_deref().map(parse_time_end).transpose()?;
|
||||
|
||||
let chats_opt = if chats.is_empty() { None } else { Some(chats) };
|
||||
|
||||
let req = Request::Search {
|
||||
keyword: keyword.clone(),
|
||||
chats: chats_opt,
|
||||
limit,
|
||||
since: since_ts,
|
||||
until: until_ts,
|
||||
};
|
||||
|
||||
let resp = transport::send(req)?;
|
||||
let results = resp.data.get("results")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let count = resp.data["count"].as_i64().unwrap_or(results.len() as i64);
|
||||
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&results)?);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("搜索 \"{}\",找到 {} 条:\n", keyword, count);
|
||||
for r in &results {
|
||||
let time = r["time"].as_str().unwrap_or("");
|
||||
let chat = r["chat"].as_str().unwrap_or("");
|
||||
let sender = r["sender"].as_str().unwrap_or("");
|
||||
let content = r["content"].as_str().unwrap_or("");
|
||||
|
||||
let chat_str = if !chat.is_empty() {
|
||||
format!("\x1b[36m[{}]\x1b[0m ", chat)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let sender_str = if !sender.is_empty() {
|
||||
format!("\x1b[33m{}\x1b[0m: ", sender)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
println!("\x1b[90m[{}]\x1b[0m {}{}{}", time, chat_str, sender_str, content);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
use anyhow::Result;
|
||||
use crate::ipc::Request;
|
||||
use super::super::cli::transport;
|
||||
|
||||
pub fn cmd_sessions(limit: usize, json: bool) -> Result<()> {
|
||||
let resp = transport::send(Request::Sessions { limit })?;
|
||||
let data = resp.data.get("sessions")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&data)?);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for s in &data {
|
||||
let time = s["time"].as_str().unwrap_or("");
|
||||
let chat = s["chat"].as_str().unwrap_or("");
|
||||
let is_group = s["is_group"].as_bool().unwrap_or(false);
|
||||
let unread = s["unread"].as_i64().unwrap_or(0);
|
||||
let msg_type = s["last_msg_type"].as_str().unwrap_or("");
|
||||
let sender = s["last_sender"].as_str().unwrap_or("");
|
||||
let summary = s["summary"].as_str().unwrap_or("");
|
||||
|
||||
let unread_str = if unread > 0 {
|
||||
format!(" \x1b[31m({}未读)\x1b[0m", unread)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let group_str = if is_group { " [群]" } else { "" };
|
||||
let sender_str = if !sender.is_empty() {
|
||||
format!("{}: ", sender)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
println!("\x1b[90m[{}]\x1b[0m \x1b[1m{}\x1b[0m{}{}", time, chat, group_str, unread_str);
|
||||
println!(" {}: {}{}", msg_type, sender_str, summary);
|
||||
println!();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
use anyhow::{bail, Context, Result};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::config;
|
||||
use crate::ipc::{Request, Response};
|
||||
|
||||
const STARTUP_TIMEOUT_SECS: u64 = 15;
|
||||
|
||||
/// 检查 daemon 是否存活
|
||||
pub fn is_alive() -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::net::UnixStream;
|
||||
let sock_path = config::sock_path();
|
||||
if !sock_path.exists() {
|
||||
return false;
|
||||
}
|
||||
let mut stream = match UnixStream::connect(&sock_path) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return false,
|
||||
};
|
||||
stream.set_read_timeout(Some(Duration::from_secs(2))).ok();
|
||||
stream.set_write_timeout(Some(Duration::from_secs(2))).ok();
|
||||
|
||||
let req = serde_json::json!({"cmd": "ping"});
|
||||
if write!(stream, "{}\n", req).is_err() {
|
||||
return false;
|
||||
}
|
||||
let mut line = String::new();
|
||||
let mut reader = BufReader::new(&stream);
|
||||
if reader.read_line(&mut line).is_err() {
|
||||
return false;
|
||||
}
|
||||
serde_json::from_str::<serde_json::Value>(&line)
|
||||
.ok()
|
||||
.and_then(|v| v.get("pong").and_then(|p| p.as_bool()))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// 通过 named pipe 检测
|
||||
let pipe_path = r"\\.\pipe\wechat-cli-daemon";
|
||||
use std::fs::OpenOptions;
|
||||
OpenOptions::new().read(true).write(true).open(pipe_path).is_ok()
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// 确保 daemon 运行,必要时自动启动
|
||||
pub fn ensure_daemon() -> Result<()> {
|
||||
if is_alive() {
|
||||
return Ok(());
|
||||
}
|
||||
eprintln!("启动 wx-daemon...");
|
||||
start_daemon()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 启动 daemon 进程(自身二进制,设置 WX_DAEMON_MODE=1)
|
||||
fn start_daemon() -> Result<()> {
|
||||
let exe = std::env::current_exe().context("无法获取当前可执行文件路径")?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let _ = std::process::Command::new(&exe)
|
||||
.env("WX_DAEMON_MODE", "1")
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.context("无法启动 daemon 进程")?;
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = std::process::Command::new(&exe)
|
||||
.env("WX_DAEMON_MODE", "1")
|
||||
.creation_flags(0x00000008) // DETACHED_PROCESS
|
||||
.spawn()
|
||||
.context("无法启动 daemon 进程")?;
|
||||
}
|
||||
|
||||
// 等待 daemon 就绪(最多 STARTUP_TIMEOUT_SECS 秒)
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(STARTUP_TIMEOUT_SECS);
|
||||
while std::time::Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
if is_alive() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
bail!(
|
||||
"wx-daemon 启动超时(>{}s)\n请查看日志: {}",
|
||||
STARTUP_TIMEOUT_SECS,
|
||||
config::log_path().display()
|
||||
)
|
||||
}
|
||||
|
||||
/// 向 daemon 发送请求并返回响应
|
||||
pub fn send(req: Request) -> Result<Response> {
|
||||
ensure_daemon()?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
send_unix(req)
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
send_windows(req)
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
bail!("不支持当前平台")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn send_unix(req: Request) -> Result<Response> {
|
||||
use std::os::unix::net::UnixStream;
|
||||
let sock_path = config::sock_path();
|
||||
let mut stream = UnixStream::connect(&sock_path)
|
||||
.context("连接 daemon socket 失败")?;
|
||||
stream.set_read_timeout(Some(Duration::from_secs(30))).ok();
|
||||
stream.set_write_timeout(Some(Duration::from_secs(30))).ok();
|
||||
|
||||
let req_str = serde_json::to_string(&req)? + "\n";
|
||||
stream.write_all(req_str.as_bytes())?;
|
||||
|
||||
let mut line = String::new();
|
||||
let mut reader = BufReader::new(&stream);
|
||||
reader.read_line(&mut line)?;
|
||||
|
||||
let resp: Response = serde_json::from_str(&line)
|
||||
.context("解析 daemon 响应失败")?;
|
||||
|
||||
if !resp.ok {
|
||||
bail!("{}", resp.error.as_deref().unwrap_or("未知错误"));
|
||||
}
|
||||
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn send_windows(req: Request) -> Result<Response> {
|
||||
use std::fs::OpenOptions;
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
|
||||
let pipe_path = r"\\.\pipe\wechat-cli-daemon";
|
||||
let mut pipe = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(pipe_path)
|
||||
.context("连接 daemon named pipe 失败")?;
|
||||
|
||||
let req_str = serde_json::to_string(&req)? + "\n";
|
||||
pipe.write_all(req_str.as_bytes())?;
|
||||
|
||||
let mut line = String::new();
|
||||
let mut reader = BufReader::new(pipe);
|
||||
reader.read_line(&mut line)?;
|
||||
|
||||
let resp: Response = serde_json::from_str(&line)
|
||||
.context("解析 daemon 响应失败")?;
|
||||
|
||||
if !resp.ok {
|
||||
bail!("{}", resp.error.as_deref().unwrap_or("未知错误"));
|
||||
}
|
||||
|
||||
Ok(resp)
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
use anyhow::Result;
|
||||
use std::io::BufRead;
|
||||
|
||||
use crate::ipc::Request;
|
||||
use super::super::cli::transport;
|
||||
|
||||
pub fn cmd_watch(chat: Option<String>, json: bool) -> Result<()> {
|
||||
transport::ensure_daemon()?;
|
||||
|
||||
let sock_path = crate::config::sock_path();
|
||||
|
||||
// 连接 socket
|
||||
#[cfg(unix)]
|
||||
let mut stream = {
|
||||
use std::os::unix::net::UnixStream;
|
||||
UnixStream::connect(&sock_path)?
|
||||
};
|
||||
|
||||
// 发送 watch 请求
|
||||
let req_line = serde_json::to_string(&Request::Watch)? + "\n";
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::io::Write;
|
||||
stream.write_all(req_line.as_bytes())?;
|
||||
}
|
||||
|
||||
if !json {
|
||||
eprintln!("监听中(Ctrl+C 退出)...\n");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let reader = std::io::BufReader::new(stream.try_clone()?);
|
||||
for line_result in reader.lines() {
|
||||
let line = match line_result {
|
||||
Ok(l) => l,
|
||||
Err(_) => break,
|
||||
};
|
||||
let line = line.trim().to_string();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let event: serde_json::Value = match serde_json::from_str(&line) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let evt = event["event"].as_str().unwrap_or("");
|
||||
if evt == "connected" || evt == "heartbeat" {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 过滤指定聊天
|
||||
if let Some(ref filter_chat) = chat {
|
||||
let event_chat = event["chat"].as_str().unwrap_or("");
|
||||
let event_user = event["username"].as_str().unwrap_or("");
|
||||
if event_chat != filter_chat && event_user != filter_chat {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if json {
|
||||
println!("{}", line);
|
||||
continue;
|
||||
}
|
||||
|
||||
let time_s = event["time"].as_str().unwrap_or("");
|
||||
let chat_s = event["chat"].as_str().unwrap_or("");
|
||||
let is_group = event["is_group"].as_bool().unwrap_or(false);
|
||||
let sender = event["sender"].as_str().unwrap_or("");
|
||||
let content = event["content"].as_str().unwrap_or("");
|
||||
|
||||
let chat_part = if is_group {
|
||||
format!("\x1b[36m[{}]\x1b[0m ", chat_s)
|
||||
} else {
|
||||
format!("\x1b[1m{}\x1b[0m ", chat_s)
|
||||
};
|
||||
let sender_part = if !sender.is_empty() {
|
||||
format!("\x1b[33m{}\x1b[0m: ", sender)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
println!("\x1b[90m[{}]\x1b[0m {}{}{}", time_s, chat_part, sender_part, content);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -0,0 +1,274 @@
|
|||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
pub db_dir: PathBuf,
|
||||
pub keys_file: PathBuf,
|
||||
pub decrypted_dir: PathBuf,
|
||||
#[serde(default)]
|
||||
pub wechat_process: String,
|
||||
}
|
||||
|
||||
/// 从 <exe_dir>/config.json 或 $HOME/.wechat-cli/config.json 加载配置
|
||||
pub fn load_config() -> Result<Config> {
|
||||
let config_path = find_config_file()?;
|
||||
let content = std::fs::read_to_string(&config_path)
|
||||
.with_context(|| format!("读取 config.json 失败: {}", config_path.display()))?;
|
||||
let raw: serde_json::Value = serde_json::from_str(&content)
|
||||
.with_context(|| "config.json 格式错误")?;
|
||||
|
||||
let db_dir = raw.get("db_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(default_db_dir);
|
||||
|
||||
let base_dir = config_path.parent().unwrap_or(Path::new("."));
|
||||
|
||||
let keys_file = raw.get("keys_file")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| {
|
||||
let p = PathBuf::from(s);
|
||||
if p.is_absolute() { p } else { base_dir.join(p) }
|
||||
})
|
||||
.unwrap_or_else(|| base_dir.join("all_keys.json"));
|
||||
|
||||
let decrypted_dir = raw.get("decrypted_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| {
|
||||
let p = PathBuf::from(s);
|
||||
if p.is_absolute() { p } else { base_dir.join(p) }
|
||||
})
|
||||
.unwrap_or_else(|| base_dir.join("decrypted"));
|
||||
|
||||
let wechat_process = raw.get("wechat_process")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(default_wechat_process())
|
||||
.to_string();
|
||||
|
||||
Ok(Config {
|
||||
db_dir,
|
||||
keys_file,
|
||||
decrypted_dir,
|
||||
wechat_process,
|
||||
})
|
||||
}
|
||||
|
||||
/// 保存配置到文件
|
||||
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() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
let p = dir.join("config.json");
|
||||
if p.exists() {
|
||||
return Ok(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2. 当前工作目录
|
||||
let cwd = std::env::current_dir().unwrap_or_default().join("config.json");
|
||||
if cwd.exists() {
|
||||
return Ok(cwd);
|
||||
}
|
||||
// 3. ~/.wechat-cli/config.json
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let p = home.join(".wechat-cli").join("config.json");
|
||||
if p.exists() {
|
||||
return Ok(p);
|
||||
}
|
||||
}
|
||||
// 返回默认路径(可能不存在,调用方负责处理)
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
return Ok(dir.join("config.json"));
|
||||
}
|
||||
}
|
||||
Ok(PathBuf::from("config.json"))
|
||||
}
|
||||
|
||||
pub fn cli_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
||||
.join(".wechat-cli")
|
||||
}
|
||||
|
||||
pub fn sock_path() -> PathBuf {
|
||||
cli_dir().join("daemon.sock")
|
||||
}
|
||||
|
||||
pub fn pid_path() -> PathBuf {
|
||||
cli_dir().join("daemon.pid")
|
||||
}
|
||||
|
||||
pub fn log_path() -> PathBuf {
|
||||
cli_dir().join("daemon.log")
|
||||
}
|
||||
|
||||
pub fn cache_dir() -> PathBuf {
|
||||
cli_dir().join("cache")
|
||||
}
|
||||
|
||||
pub fn mtime_file() -> PathBuf {
|
||||
cache_dir().join("_mtimes.json")
|
||||
}
|
||||
|
||||
fn default_db_dir() -> PathBuf {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
dirs::home_dir()
|
||||
.unwrap_or_default()
|
||||
.join("Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files")
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
dirs::home_dir()
|
||||
.unwrap_or_default()
|
||||
.join("Documents/xwechat_files")
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
PathBuf::from(std::env::var("APPDATA").unwrap_or_default())
|
||||
.join("Tencent/xwechat")
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
{
|
||||
PathBuf::from(".")
|
||||
}
|
||||
}
|
||||
|
||||
fn default_wechat_process() -> &'static str {
|
||||
#[cfg(target_os = "macos")]
|
||||
{ "WeChat" }
|
||||
#[cfg(target_os = "linux")]
|
||||
{ "wechat" }
|
||||
#[cfg(target_os = "windows")]
|
||||
{ "Weixin.exe" }
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
{ "WeChat" }
|
||||
}
|
||||
|
||||
/// 自动检测微信 db_storage 目录
|
||||
pub fn auto_detect_db_dir() -> Option<PathBuf> {
|
||||
detect_db_dir_impl()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn detect_db_dir_impl() -> Option<PathBuf> {
|
||||
let home = dirs::home_dir()?;
|
||||
// 支持 sudo 环境
|
||||
let home = if let Ok(sudo_user) = std::env::var("SUDO_USER") {
|
||||
if !sudo_user.is_empty() {
|
||||
PathBuf::from("/Users").join(&sudo_user)
|
||||
} else {
|
||||
home
|
||||
}
|
||||
} else {
|
||||
home
|
||||
};
|
||||
|
||||
let base = home.join("Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files");
|
||||
if !base.exists() {
|
||||
return None;
|
||||
}
|
||||
let mut candidates: Vec<PathBuf> = Vec::new();
|
||||
if let Ok(entries) = std::fs::read_dir(&base) {
|
||||
for entry in entries.flatten() {
|
||||
let storage = entry.path().join("db_storage");
|
||||
if storage.is_dir() {
|
||||
candidates.push(storage);
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates.sort_by_key(|p| {
|
||||
std::fs::metadata(p)
|
||||
.and_then(|m| m.modified())
|
||||
.unwrap_or(std::time::SystemTime::UNIX_EPOCH)
|
||||
});
|
||||
candidates.into_iter().next_back()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn detect_db_dir_impl() -> Option<PathBuf> {
|
||||
let home = dirs::home_dir()?;
|
||||
let sudo_home = std::env::var("SUDO_USER").ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|u| PathBuf::from("/home").join(u));
|
||||
|
||||
let mut candidates: Vec<PathBuf> = Vec::new();
|
||||
for base_home in [Some(home.clone()), sudo_home].into_iter().flatten() {
|
||||
let xwechat = base_home.join("Documents/xwechat_files");
|
||||
if xwechat.exists() {
|
||||
if let Ok(entries) = std::fs::read_dir(&xwechat) {
|
||||
for entry in entries.flatten() {
|
||||
let storage = entry.path().join("db_storage");
|
||||
if storage.is_dir() {
|
||||
candidates.push(storage);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let old = base_home.join(".local/share/weixin/data/db_storage");
|
||||
if old.is_dir() {
|
||||
candidates.push(old);
|
||||
}
|
||||
}
|
||||
candidates.sort_by_key(|p| {
|
||||
std::fs::metadata(p)
|
||||
.and_then(|m| m.modified())
|
||||
.unwrap_or(std::time::SystemTime::UNIX_EPOCH)
|
||||
});
|
||||
candidates.into_iter().next_back()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn detect_db_dir_impl() -> Option<PathBuf> {
|
||||
let appdata = std::env::var("APPDATA").ok()?;
|
||||
let config_dir = PathBuf::from(&appdata).join("Tencent/xwechat/config");
|
||||
if !config_dir.exists() {
|
||||
return None;
|
||||
}
|
||||
let mut candidates: Vec<PathBuf> = Vec::new();
|
||||
if let Ok(entries) = std::fs::read_dir(&config_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().map(|e| e == "ini").unwrap_or(false) {
|
||||
if let Ok(content) = std::fs::read_to_string(&path) {
|
||||
let data_root = content.trim().to_string();
|
||||
if PathBuf::from(&data_root).is_dir() {
|
||||
let pattern = PathBuf::from(&data_root)
|
||||
.join("xwechat_files");
|
||||
if let Ok(entries2) = std::fs::read_dir(&pattern) {
|
||||
for entry2 in entries2.flatten() {
|
||||
let storage = entry2.path().join("db_storage");
|
||||
if storage.is_dir() {
|
||||
candidates.push(storage);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates.into_iter().next()
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
fn detect_db_dir_impl() -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
pub mod wal;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use aes::Aes256;
|
||||
use cbc::Decryptor;
|
||||
use cbc::cipher::{BlockDecryptMut, KeyIvInit};
|
||||
use std::path::Path;
|
||||
|
||||
pub const PAGE_SZ: usize = 4096;
|
||||
pub const SALT_SZ: usize = 16;
|
||||
pub const RESERVE_SZ: usize = 80; // IV(16) + HMAC(64)
|
||||
|
||||
/// SQLite 文件头魔数(16字节)
|
||||
pub const SQLITE_HDR: &[u8] = b"SQLite format 3\x00";
|
||||
|
||||
type Aes256CbcDec = Decryptor<Aes256>;
|
||||
|
||||
/// 解密单个 SQLCipher 4 页
|
||||
///
|
||||
/// - `enc_key`: 32字节 AES 密钥
|
||||
/// - `page_data`: 原始加密页面数据(PAGE_SZ 字节)
|
||||
/// - `pgno`: 页码(从1开始)
|
||||
///
|
||||
/// 返回解密后的完整页面(PAGE_SZ 字节)
|
||||
pub fn decrypt_page(enc_key: &[u8; 32], page_data: &[u8], pgno: u32) -> Result<Vec<u8>> {
|
||||
if page_data.len() < PAGE_SZ {
|
||||
bail!("页面数据不足 {} 字节", PAGE_SZ);
|
||||
}
|
||||
|
||||
// IV 位于页面末尾 RESERVE_SZ 区域的前16字节
|
||||
let iv_offset = PAGE_SZ - RESERVE_SZ;
|
||||
let iv: &[u8; 16] = page_data[iv_offset..iv_offset + 16]
|
||||
.try_into()
|
||||
.expect("IV 长度固定为 16");
|
||||
|
||||
let mut result = vec![0u8; PAGE_SZ];
|
||||
|
||||
if pgno == 1 {
|
||||
// 第一页:跳过 salt(16字节),解密 [SALT_SZ..PAGE_SZ-RESERVE_SZ]
|
||||
let enc = &page_data[SALT_SZ..PAGE_SZ - RESERVE_SZ];
|
||||
let dec = aes_cbc_decrypt(enc_key, iv, enc)?;
|
||||
// 写入 SQLite 文件头
|
||||
result[..16].copy_from_slice(SQLITE_HDR);
|
||||
// 写入解密数据(从第16字节开始)
|
||||
result[16..PAGE_SZ - RESERVE_SZ].copy_from_slice(&dec);
|
||||
// 末尾 RESERVE_SZ 字节补零
|
||||
// (已经是零,无需显式操作)
|
||||
} else {
|
||||
// 其他页:解密 [0..PAGE_SZ-RESERVE_SZ]
|
||||
let enc = &page_data[..PAGE_SZ - RESERVE_SZ];
|
||||
let dec = aes_cbc_decrypt(enc_key, iv, enc)?;
|
||||
result[..PAGE_SZ - RESERVE_SZ].copy_from_slice(&dec);
|
||||
// 末尾 RESERVE_SZ 字节补零
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// AES-256-CBC 解密(不去除 padding,SQLCipher 不使用 PKCS#7 padding)
|
||||
fn aes_cbc_decrypt(key: &[u8; 32], iv: &[u8; 16], data: &[u8]) -> Result<Vec<u8>> {
|
||||
if data.is_empty() || data.len() % 16 != 0 {
|
||||
bail!("密文长度不是 AES 块大小的倍数: {}", data.len());
|
||||
}
|
||||
let mut buf = data.to_vec();
|
||||
// 使用 raw 模式不处理 padding
|
||||
Aes256CbcDec::new(key.into(), iv.into())
|
||||
.decrypt_blocks_mut(unsafe {
|
||||
std::slice::from_raw_parts_mut(
|
||||
buf.as_mut_ptr() as *mut aes::cipher::Block<Aes256>,
|
||||
buf.len() / 16,
|
||||
)
|
||||
});
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// 完整解密一个 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);
|
||||
}
|
||||
|
||||
std::fs::write(out_path, &out)?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
use anyhow::Result;
|
||||
use std::io::{SeekFrom, Seek, Write};
|
||||
use std::path::Path;
|
||||
|
||||
use super::{decrypt_page, PAGE_SZ};
|
||||
|
||||
pub const WAL_HDR_SZ: usize = 32;
|
||||
pub const WAL_FRAME_HDR: usize = 24;
|
||||
|
||||
/// 将 WAL 文件中的变更应用到已解密的数据库文件
|
||||
///
|
||||
/// WAL 格式(SQLite 标准,SQLCipher 4 的 WAL 帧也被加密):
|
||||
/// - WAL header (32 bytes): magic(4) + format(4) + page_sz(4) + ckpt_seq(4) + salt1(4) + salt2(4) + cksum1(4) + cksum2(4)
|
||||
/// - 每帧:frame_header(24 bytes) + page_data(PAGE_SZ bytes)
|
||||
/// - frame_header: pgno(4) + commit_pgcnt(4) + salt1(4) + salt2(4) + cksum1(4) + cksum2(4)
|
||||
pub fn apply_wal(wal_path: &Path, out_path: &Path, enc_key: &[u8; 32]) -> Result<()> {
|
||||
if !wal_path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let wal_data = std::fs::read(wal_path)?;
|
||||
if wal_data.len() <= WAL_HDR_SZ {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 读取 WAL 头中的 salt1 / salt2
|
||||
let s1 = u32::from_be_bytes(wal_data[16..20].try_into().unwrap());
|
||||
let s2 = u32::from_be_bytes(wal_data[20..24].try_into().unwrap());
|
||||
|
||||
let frame_size = WAL_FRAME_HDR + PAGE_SZ;
|
||||
let frame_area = &wal_data[WAL_HDR_SZ..];
|
||||
|
||||
// 打开输出文件做随机写
|
||||
let mut db_file = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(out_path)?;
|
||||
|
||||
let mut pos = 0usize;
|
||||
while pos + frame_size <= frame_area.len() {
|
||||
let fh = &frame_area[pos..pos + WAL_FRAME_HDR];
|
||||
let page_data = &frame_area[pos + WAL_FRAME_HDR..pos + frame_size];
|
||||
|
||||
let pgno = u32::from_be_bytes(fh[0..4].try_into().unwrap());
|
||||
let fs1 = u32::from_be_bytes(fh[8..12].try_into().unwrap());
|
||||
let fs2 = u32::from_be_bytes(fh[12..16].try_into().unwrap());
|
||||
|
||||
pos += frame_size;
|
||||
|
||||
// 跳过无效页码
|
||||
if pgno == 0 || pgno > 1_000_000 {
|
||||
continue;
|
||||
}
|
||||
// salt 不匹配的帧属于已检查点或旧事务
|
||||
if fs1 != s1 || fs2 != s2 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut page_buf = page_data.to_vec();
|
||||
if page_buf.len() < PAGE_SZ {
|
||||
page_buf.resize(PAGE_SZ, 0);
|
||||
}
|
||||
|
||||
let dec = decrypt_page(enc_key, &page_buf, pgno)?;
|
||||
let file_offset = (pgno as u64 - 1) * PAGE_SZ as u64;
|
||||
db_file.seek(SeekFrom::Start(file_offset))?;
|
||||
db_file.write_all(&dec)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::config;
|
||||
use crate::crypto;
|
||||
use crate::crypto::wal;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct MtimeEntry {
|
||||
db_mt: f64,
|
||||
wal_mt: f64,
|
||||
path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct CacheEntry {
|
||||
db_mtime: f64,
|
||||
wal_mtime: f64,
|
||||
decrypted_path: PathBuf,
|
||||
}
|
||||
|
||||
/// 解密后数据库的 mtime-aware 缓存
|
||||
///
|
||||
/// 当数据库文件(.db)或 WAL 文件(.db-wal)的 mtime 发生变化时,
|
||||
/// 自动重新解密并更新缓存。跨进程重启可通过持久化 mtime 文件复用已解密的 DB。
|
||||
pub struct DbCache {
|
||||
db_dir: PathBuf,
|
||||
cache_dir: PathBuf,
|
||||
all_keys: HashMap<String, String>, // rel_key -> enc_key(hex)
|
||||
inner: Arc<Mutex<HashMap<String, CacheEntry>>>,
|
||||
}
|
||||
|
||||
impl DbCache {
|
||||
pub async fn new(
|
||||
db_dir: PathBuf,
|
||||
all_keys: HashMap<String, String>,
|
||||
) -> Result<Self> {
|
||||
let cache_dir = config::cache_dir();
|
||||
tokio::fs::create_dir_all(&cache_dir).await?;
|
||||
|
||||
let inner: HashMap<String, CacheEntry> = HashMap::new();
|
||||
let cache = DbCache {
|
||||
db_dir,
|
||||
cache_dir,
|
||||
all_keys,
|
||||
inner: Arc::new(Mutex::new(inner)),
|
||||
};
|
||||
|
||||
cache.load_persistent().await;
|
||||
Ok(cache)
|
||||
}
|
||||
|
||||
fn cache_file_path(&self, rel_key: &str) -> PathBuf {
|
||||
let hash = format!("{:x}", md5::compute(rel_key.as_bytes()));
|
||||
let short = &hash[..12];
|
||||
self.cache_dir.join(format!("{}.db", short))
|
||||
}
|
||||
|
||||
/// 从持久化文件加载 mtime 记录,复用未过期的解密文件
|
||||
async fn load_persistent(&self) {
|
||||
let mtime_file = config::mtime_file();
|
||||
let content = match tokio::fs::read_to_string(&mtime_file).await {
|
||||
Ok(c) => c,
|
||||
Err(_) => return,
|
||||
};
|
||||
let saved: HashMap<String, MtimeEntry> = match serde_json::from_str(&content) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let mut inner = self.inner.lock().await;
|
||||
let mut reused = 0usize;
|
||||
for (rel_key, entry) in &saved {
|
||||
let dec_path = PathBuf::from(&entry.path);
|
||||
if !dec_path.exists() {
|
||||
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 db_mt = mtime_f64(&db_path);
|
||||
let wal_mt = if wal_path.exists() { mtime_f64(wal_path) } else { 0.0 };
|
||||
|
||||
if (db_mt - entry.db_mt).abs() < 0.001 && (wal_mt - entry.wal_mt).abs() < 0.001 {
|
||||
inner.insert(rel_key.clone(), CacheEntry {
|
||||
db_mtime: db_mt,
|
||||
wal_mtime: wal_mt,
|
||||
decrypted_path: dec_path,
|
||||
});
|
||||
reused += 1;
|
||||
}
|
||||
}
|
||||
if reused > 0 {
|
||||
eprintln!("[cache] 复用 {} 个已解密 DB", reused);
|
||||
}
|
||||
}
|
||||
|
||||
/// 持久化 mtime 记录
|
||||
async fn save_persistent(&self) {
|
||||
let mtime_file = config::mtime_file();
|
||||
let inner = self.inner.lock().await;
|
||||
let data: HashMap<String, MtimeEntry> = inner.iter().map(|(k, v)| {
|
||||
(k.clone(), MtimeEntry {
|
||||
db_mt: v.db_mtime,
|
||||
wal_mt: v.wal_mtime,
|
||||
path: v.decrypted_path.to_string_lossy().into_owned(),
|
||||
})
|
||||
}).collect();
|
||||
drop(inner);
|
||||
|
||||
if let Ok(json) = serde_json::to_string_pretty(&data) {
|
||||
let _ = tokio::fs::write(&mtime_file, json).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取解密后的数据库路径
|
||||
///
|
||||
/// 如果 mtime 未变,直接返回缓存路径;否则重新解密
|
||||
pub async fn get(&self, rel_key: &str) -> Result<Option<PathBuf>> {
|
||||
let enc_key_hex = match self.all_keys.get(rel_key) {
|
||||
Some(k) => k.clone(),
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let db_path = self.db_dir.join(
|
||||
rel_key.replace('\\', std::path::MAIN_SEPARATOR_STR)
|
||||
.replace('/', std::path::MAIN_SEPARATOR_STR)
|
||||
);
|
||||
if !db_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let wal_path_str = format!("{}-wal", db_path.display());
|
||||
let wal_path = Path::new(&wal_path_str).to_path_buf();
|
||||
|
||||
let db_mt = mtime_f64(&db_path);
|
||||
let wal_mt = if wal_path.exists() { mtime_f64(&wal_path) } else { 0.0 };
|
||||
|
||||
// 检查缓存
|
||||
{
|
||||
let inner = self.inner.lock().await;
|
||||
if let Some(entry) = inner.get(rel_key) {
|
||||
if (entry.db_mtime - db_mt).abs() < 0.001
|
||||
&& (entry.wal_mtime - wal_mt).abs() < 0.001
|
||||
&& entry.decrypted_path.exists()
|
||||
{
|
||||
return Ok(Some(entry.decrypted_path.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 需要重新解密
|
||||
let out_path = self.cache_file_path(rel_key);
|
||||
let enc_key_bytes = hex_to_32bytes(&enc_key_hex)
|
||||
.with_context(|| format!("密钥格式错误: {}", rel_key))?;
|
||||
|
||||
let t0 = std::time::Instant::now();
|
||||
let db_path2 = db_path.clone();
|
||||
let out_path2 = out_path.clone();
|
||||
let key_copy = enc_key_bytes;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
crypto::full_decrypt(&db_path2, &out_path2, &key_copy)
|
||||
}).await??;
|
||||
|
||||
// 应用 WAL
|
||||
if wal_path.exists() {
|
||||
let out_path3 = out_path.clone();
|
||||
let wal_path3 = wal_path.clone();
|
||||
let key_copy2 = enc_key_bytes;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
wal::apply_wal(&wal_path3, &out_path3, &key_copy2)
|
||||
}).await??;
|
||||
}
|
||||
|
||||
let elapsed_ms = t0.elapsed().as_millis();
|
||||
eprintln!("[cache] 解密 {} ({}ms)", rel_key, elapsed_ms);
|
||||
|
||||
// 更新内存缓存
|
||||
{
|
||||
let mut inner = self.inner.lock().await;
|
||||
inner.insert(rel_key.to_string(), CacheEntry {
|
||||
db_mtime: db_mt,
|
||||
wal_mtime: wal_mt,
|
||||
decrypted_path: out_path.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
self.save_persistent().await;
|
||||
Ok(Some(out_path))
|
||||
}
|
||||
}
|
||||
|
||||
fn mtime_f64(path: &Path) -> f64 {
|
||||
std::fs::metadata(path)
|
||||
.and_then(|m| m.modified())
|
||||
.map(|t| t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs_f64())
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn hex_to_32bytes(s: &str) -> Result<[u8; 32]> {
|
||||
if s.len() != 64 {
|
||||
anyhow::bail!("密钥 hex 长度应为 64,实际为 {}", s.len());
|
||||
}
|
||||
let mut out = [0u8; 32];
|
||||
for i in 0..32 {
|
||||
out[i] = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16)
|
||||
.with_context(|| format!("非法 hex 字符 at {}", i * 2))?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
|
@ -0,0 +1,280 @@
|
|||
pub mod cache;
|
||||
pub mod query;
|
||||
pub mod watcher;
|
||||
pub mod server;
|
||||
|
||||
use anyhow::Result;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::config;
|
||||
|
||||
/// daemon 入口
|
||||
///
|
||||
/// 当 WX_DAEMON_MODE 环境变量设置时,main() 调用此函数
|
||||
pub fn run() {
|
||||
let rt = tokio::runtime::Runtime::new().expect("无法创建 tokio runtime");
|
||||
if let Err(e) = rt.block_on(async_run()) {
|
||||
eprintln!("[daemon] 启动失败: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async fn async_run() -> Result<()> {
|
||||
// 确保工作目录存在
|
||||
let cli_dir = config::cli_dir();
|
||||
tokio::fs::create_dir_all(&cli_dir).await?;
|
||||
tokio::fs::create_dir_all(config::cache_dir()).await?;
|
||||
|
||||
// 写 PID 文件
|
||||
let pid = std::process::id();
|
||||
tokio::fs::write(config::pid_path(), pid.to_string()).await?;
|
||||
|
||||
// 注册 SIGTERM / SIGINT 处理
|
||||
setup_signal_handler().await;
|
||||
|
||||
eprintln!("[daemon] wx-daemon 启动 (PID {})", pid);
|
||||
|
||||
// 加载配置
|
||||
let cfg = config::load_config()?;
|
||||
eprintln!("[daemon] DB_DIR: {}", cfg.db_dir.display());
|
||||
|
||||
// 加载密钥
|
||||
let keys_content = tokio::fs::read_to_string(&cfg.keys_file).await
|
||||
.map_err(|e| anyhow::anyhow!("读取密钥文件 {:?} 失败: {}", cfg.keys_file, e))?;
|
||||
let keys_raw: serde_json::Value = serde_json::from_str(&keys_content)?;
|
||||
let all_keys = extract_keys(&keys_raw);
|
||||
eprintln!("[daemon] 密钥数量: {}", all_keys.len());
|
||||
|
||||
// 初始化 DbCache
|
||||
let db = Arc::new(cache::DbCache::new(cfg.db_dir.clone(), all_keys.clone()).await?);
|
||||
|
||||
// 收集消息 DB 列表
|
||||
let msg_db_keys: Vec<String> = all_keys.keys()
|
||||
.filter(|k| {
|
||||
let k = k.replace('\\', "/");
|
||||
k.contains("message/message_") && k.ends_with(".db")
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
// 预热:加载联系人 + 解密 session.db
|
||||
eprintln!("[daemon] 预热...");
|
||||
let names_raw = query::load_names(&*db).await.unwrap_or_else(|e| {
|
||||
eprintln!("[daemon] 加载联系人失败: {}", e);
|
||||
query::Names {
|
||||
map: HashMap::new(),
|
||||
md5_to_uname: HashMap::new(),
|
||||
msg_db_keys: Vec::new(),
|
||||
}
|
||||
});
|
||||
let mut names = names_raw;
|
||||
names.msg_db_keys = msg_db_keys;
|
||||
|
||||
let _ = db.get("session/session.db").await;
|
||||
eprintln!("[daemon] 预热完成,联系人 {} 个", names.map.len());
|
||||
|
||||
let names_arc = Arc::new(std::sync::RwLock::new(names));
|
||||
|
||||
// 启动 WAL watcher
|
||||
let (watch_tx, _) = broadcast::channel::<crate::ipc::WatchEvent>(500);
|
||||
let session_wal = cfg.db_dir.join("session").join("session.db-wal");
|
||||
|
||||
// SAFETY: 我们确保 db 和 names_arc 在 daemon 生命周期内有效
|
||||
// 使用 Arc 传递引用避免 'static 问题
|
||||
let db_arc = Arc::clone(&db);
|
||||
let names_arc2 = Arc::clone(&names_arc);
|
||||
let tx_clone = watch_tx.clone();
|
||||
let session_wal2 = session_wal.clone();
|
||||
tokio::spawn(async move {
|
||||
run_watcher(db_arc, names_arc2, tx_clone, session_wal2).await;
|
||||
});
|
||||
|
||||
// 启动 IPC server(阻塞)
|
||||
server::serve(Arc::clone(&db), Arc::clone(&names_arc), watch_tx).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_watcher(
|
||||
db: Arc<cache::DbCache>,
|
||||
names: Arc<std::sync::RwLock<query::Names>>,
|
||||
tx: broadcast::Sender<crate::ipc::WatchEvent>,
|
||||
session_wal: PathBuf,
|
||||
) {
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
use crate::ipc::WatchEvent;
|
||||
|
||||
let mut last_mtime = 0.0f64;
|
||||
let mut last_ts: HashMap<String, i64> = HashMap::new();
|
||||
let mut initialized = false;
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
if tx.receiver_count() == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let wal_mtime = match mtime_f64(&session_wal) {
|
||||
Some(m) => m,
|
||||
None => continue,
|
||||
};
|
||||
if (wal_mtime - last_mtime).abs() < 0.001 {
|
||||
continue;
|
||||
}
|
||||
last_mtime = wal_mtime;
|
||||
|
||||
let path = match db.get("session/session.db").await {
|
||||
Ok(Some(p)) => p,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let path2 = path.clone();
|
||||
let rows: Vec<(String, Vec<u8>, i64, i64, String)> = match tokio::task::spawn_blocking(move || {
|
||||
let conn = rusqlite::Connection::open(&path2)?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT username, summary, last_timestamp, last_msg_type, last_msg_sender
|
||||
FROM SessionTable WHERE last_timestamp > 0
|
||||
ORDER BY last_timestamp DESC LIMIT 50"
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, Vec<u8>>(1).unwrap_or_default(),
|
||||
row.get::<_, i64>(2)?,
|
||||
row.get::<_, i64>(3).unwrap_or(0),
|
||||
row.get::<_, String>(4).unwrap_or_default(),
|
||||
))
|
||||
})?.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok::<_, anyhow::Error>(rows)
|
||||
}).await {
|
||||
Ok(Ok(r)) => r,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let names_guard = match names.read() {
|
||||
Ok(g) => g,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
for (username, summary_bytes, ts, msg_type, sender) in &rows {
|
||||
if !initialized {
|
||||
last_ts.insert(username.clone(), *ts);
|
||||
continue;
|
||||
}
|
||||
let prev_ts = last_ts.get(username).copied().unwrap_or(0);
|
||||
if *ts <= prev_ts {
|
||||
continue;
|
||||
}
|
||||
last_ts.insert(username.clone(), *ts);
|
||||
|
||||
let display = names_guard.display(username);
|
||||
let is_group = username.contains("@chatroom");
|
||||
let summary = decompress_or_str(summary_bytes);
|
||||
let summary = if summary.contains(":\n") {
|
||||
summary.splitn(2, ":\n").nth(1).unwrap_or(&summary).to_string()
|
||||
} else {
|
||||
summary
|
||||
};
|
||||
let sender_display = if !sender.is_empty() {
|
||||
names_guard.map.get(sender).cloned().unwrap_or_else(|| sender.clone())
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let event = WatchEvent {
|
||||
event: "message".into(),
|
||||
time: Some(fmt_hhmm(*ts)),
|
||||
chat: Some(display),
|
||||
username: Some(username.clone()),
|
||||
is_group: Some(is_group),
|
||||
sender: Some(sender_display),
|
||||
content: Some(summary),
|
||||
msg_type: Some(query::fmt_type(*msg_type)),
|
||||
timestamp: Some(*ts),
|
||||
};
|
||||
let _ = tx.send(event);
|
||||
}
|
||||
|
||||
if !initialized {
|
||||
initialized = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mtime_f64(path: &std::path::Path) -> Option<f64> {
|
||||
std::fs::metadata(path).ok()?
|
||||
.modified().ok()
|
||||
.map(|t| t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs_f64())
|
||||
}
|
||||
|
||||
fn decompress_or_str(data: &[u8]) -> String {
|
||||
if data.is_empty() { return String::new(); }
|
||||
if let Ok(dec) = zstd::decode_all(data) {
|
||||
if let Ok(s) = String::from_utf8(dec) { return s; }
|
||||
}
|
||||
String::from_utf8_lossy(data).into_owned()
|
||||
}
|
||||
|
||||
fn fmt_hhmm(ts: i64) -> String {
|
||||
use chrono::{Local, TimeZone};
|
||||
Local.timestamp_opt(ts, 0)
|
||||
.single()
|
||||
.map(|dt| dt.format("%H:%M").to_string())
|
||||
.unwrap_or_else(|| ts.to_string())
|
||||
}
|
||||
|
||||
/// 从 all_keys.json 提取 rel_key -> enc_key 映射
|
||||
///
|
||||
/// 兼容两种格式:
|
||||
/// - `{ "rel/path.db": { "enc_key": "hex" } }`(Python 版原生格式)
|
||||
/// - `{ "rel/path.db": "hex" }`(简化格式)
|
||||
fn extract_keys(json: &serde_json::Value) -> HashMap<String, String> {
|
||||
let mut result = HashMap::new();
|
||||
if let Some(obj) = json.as_object() {
|
||||
for (k, v) in obj {
|
||||
if k.starts_with('_') { continue; }
|
||||
let enc_key = if let Some(s) = v.as_str() {
|
||||
s.to_string()
|
||||
} else if let Some(obj2) = v.as_object() {
|
||||
obj2.get("enc_key")
|
||||
.and_then(|e| e.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
if !enc_key.is_empty() {
|
||||
// 统一路径分隔符
|
||||
let rel = k.replace('\\', "/");
|
||||
result.insert(rel, enc_key);
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// 设置信号处理(Unix: SIGTERM/SIGINT)
|
||||
async fn setup_signal_handler() {
|
||||
#[cfg(unix)]
|
||||
tokio::spawn(async move {
|
||||
use tokio::signal::unix::{signal, SignalKind};
|
||||
let mut term = signal(SignalKind::terminate()).expect("无法监听 SIGTERM");
|
||||
let mut int = signal(SignalKind::interrupt()).expect("无法监听 SIGINT");
|
||||
tokio::select! {
|
||||
_ = term.recv() => {},
|
||||
_ = int.recv() => {},
|
||||
}
|
||||
cleanup_and_exit();
|
||||
});
|
||||
}
|
||||
|
||||
fn cleanup_and_exit() {
|
||||
let _ = std::fs::remove_file(config::sock_path());
|
||||
let _ = std::fs::remove_file(config::pid_path());
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
|
@ -0,0 +1,676 @@
|
|||
use anyhow::{Context, Result};
|
||||
use chrono::{Local, TimeZone};
|
||||
use regex::Regex;
|
||||
use rusqlite::Connection;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::cache::DbCache;
|
||||
|
||||
/// 联系人名称缓存
|
||||
#[derive(Clone)]
|
||||
pub struct Names {
|
||||
/// username -> display_name
|
||||
pub map: HashMap<String, String>,
|
||||
/// md5(username) -> username(用于从 Msg_<md5> 表名反推联系人)
|
||||
pub md5_to_uname: HashMap<String, String>,
|
||||
/// 消息 DB 的相对路径列表(message/message_N.db)
|
||||
pub msg_db_keys: Vec<String>,
|
||||
}
|
||||
|
||||
impl Names {
|
||||
pub fn display(&self, username: &str) -> String {
|
||||
self.map.get(username).cloned().unwrap_or_else(|| username.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载联系人缓存(从 contact/contact.db)
|
||||
pub async fn load_names(db: &DbCache) -> Result<Names> {
|
||||
let path = db.get("contact/contact.db").await?;
|
||||
let mut map = HashMap::new();
|
||||
if let Some(p) = path {
|
||||
let p2 = p.clone();
|
||||
let rows: Vec<(String, String, String)> = tokio::task::spawn_blocking(move || {
|
||||
let conn = Connection::open(&p2).context("打开 contact.db 失败")?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT username, nick_name, remark FROM contact"
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1).unwrap_or_default(),
|
||||
row.get::<_, String>(2).unwrap_or_default(),
|
||||
))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok::<_, anyhow::Error>(rows)
|
||||
}).await??;
|
||||
|
||||
for (uname, nick, remark) in rows {
|
||||
let display = if !remark.is_empty() { remark }
|
||||
else if !nick.is_empty() { nick }
|
||||
else { uname.clone() };
|
||||
map.insert(uname, display);
|
||||
}
|
||||
}
|
||||
|
||||
let md5_to_uname: HashMap<String, String> = map.keys()
|
||||
.map(|u| (format!("{:x}", md5::compute(u.as_bytes())), u.clone()))
|
||||
.collect();
|
||||
|
||||
Ok(Names { map, md5_to_uname, msg_db_keys: Vec::new() })
|
||||
}
|
||||
|
||||
/// 查询最近会话列表
|
||||
pub async fn q_sessions(db: &DbCache, names: &Names, limit: usize) -> Result<Value> {
|
||||
let path = db.get("session/session.db").await?
|
||||
.context("无法解密 session.db")?;
|
||||
|
||||
let path2 = path.clone();
|
||||
let limit_val = limit;
|
||||
let rows: Vec<(String, i64, Vec<u8>, i64, i64, String, String)> = tokio::task::spawn_blocking(move || {
|
||||
let conn = Connection::open(&path2)?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT username, unread_count, summary, last_timestamp,
|
||||
last_msg_type, last_msg_sender, last_sender_display_name
|
||||
FROM SessionTable
|
||||
WHERE last_timestamp > 0
|
||||
ORDER BY last_timestamp DESC LIMIT ?"
|
||||
)?;
|
||||
let rows = stmt.query_map([limit_val as i64], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, i64>(1).unwrap_or(0),
|
||||
row.get::<_, Vec<u8>>(2).unwrap_or_default(),
|
||||
row.get::<_, i64>(3).unwrap_or(0),
|
||||
row.get::<_, i64>(4).unwrap_or(0),
|
||||
row.get::<_, String>(5).unwrap_or_default(),
|
||||
row.get::<_, String>(6).unwrap_or_default(),
|
||||
))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok::<_, anyhow::Error>(rows)
|
||||
}).await??;
|
||||
|
||||
let mut results = Vec::new();
|
||||
for (username, unread, summary_bytes, ts, msg_type, sender, sender_name) in rows {
|
||||
let display = names.display(&username);
|
||||
let is_group = username.contains("@chatroom");
|
||||
|
||||
// 尝试 zstd 解压 summary
|
||||
let summary = decompress_or_str(&summary_bytes);
|
||||
let summary = strip_group_prefix(&summary);
|
||||
|
||||
let sender_display = if is_group && !sender.is_empty() {
|
||||
names.map.get(&sender).cloned().unwrap_or_else(|| {
|
||||
if !sender_name.is_empty() { sender_name.clone() } else { sender.clone() }
|
||||
})
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
results.push(json!({
|
||||
"chat": display,
|
||||
"username": username,
|
||||
"is_group": is_group,
|
||||
"unread": unread,
|
||||
"last_msg_type": fmt_type(msg_type),
|
||||
"last_sender": sender_display,
|
||||
"summary": summary,
|
||||
"timestamp": ts,
|
||||
"time": fmt_time(ts, "%m-%d %H:%M"),
|
||||
}));
|
||||
}
|
||||
Ok(json!({ "sessions": results }))
|
||||
}
|
||||
|
||||
/// 查询聊天记录
|
||||
pub async fn q_history(
|
||||
db: &DbCache,
|
||||
names: &Names,
|
||||
chat: &str,
|
||||
limit: usize,
|
||||
offset: usize,
|
||||
since: Option<i64>,
|
||||
until: Option<i64>,
|
||||
) -> Result<Value> {
|
||||
let username = resolve_username(chat, names)
|
||||
.with_context(|| format!("找不到联系人: {}", chat))?;
|
||||
let display = names.display(&username);
|
||||
let is_group = username.contains("@chatroom");
|
||||
|
||||
let tables = find_msg_tables(db, names, &username).await?;
|
||||
if tables.is_empty() {
|
||||
return Ok(json!({ "error": format!("找不到 {} 的消息记录", display) }));
|
||||
}
|
||||
|
||||
let mut all_msgs: Vec<Value> = Vec::new();
|
||||
for (db_path, table_name) in &tables {
|
||||
let path = db_path.clone();
|
||||
let tname = table_name.clone();
|
||||
let uname = username.clone();
|
||||
let is_group2 = is_group;
|
||||
let names_map = names.map.clone();
|
||||
let since2 = since;
|
||||
let until2 = until;
|
||||
let limit2 = limit;
|
||||
let offset2 = offset;
|
||||
|
||||
let msgs: Vec<Value> = tokio::task::spawn_blocking(move || {
|
||||
query_messages(&path, &tname, &uname, is_group2, &names_map, since2, until2, limit2 + offset2, 0)
|
||||
}).await??;
|
||||
|
||||
all_msgs.extend(msgs);
|
||||
}
|
||||
|
||||
all_msgs.sort_by_key(|m| std::cmp::Reverse(m["timestamp"].as_i64().unwrap_or(0)));
|
||||
let paged: Vec<Value> = all_msgs.into_iter().skip(offset).take(limit).collect();
|
||||
let mut paged = paged;
|
||||
paged.sort_by_key(|m| m["timestamp"].as_i64().unwrap_or(0));
|
||||
|
||||
Ok(json!({
|
||||
"chat": display,
|
||||
"username": username,
|
||||
"is_group": is_group,
|
||||
"count": paged.len(),
|
||||
"messages": paged,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 搜索消息
|
||||
pub async fn q_search(
|
||||
db: &DbCache,
|
||||
names: &Names,
|
||||
keyword: &str,
|
||||
chats: Option<Vec<String>>,
|
||||
limit: usize,
|
||||
since: Option<i64>,
|
||||
until: Option<i64>,
|
||||
) -> Result<Value> {
|
||||
let mut targets: Vec<(String, String, String, String)> = Vec::new(); // (path, table, display, uname)
|
||||
|
||||
if let Some(chat_names) = chats {
|
||||
for chat_name in &chat_names {
|
||||
if let Some(uname) = resolve_username(chat_name, names) {
|
||||
let tables = find_msg_tables(db, names, &uname).await?;
|
||||
for (p, t) in tables {
|
||||
targets.push((p.to_string_lossy().into_owned(), t, names.display(&uname), uname.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 全局搜索:遍历所有消息 DB
|
||||
for rel_key in &names.msg_db_keys {
|
||||
let path = match db.get(rel_key).await? {
|
||||
Some(p) => p,
|
||||
None => continue,
|
||||
};
|
||||
let path2 = path.clone();
|
||||
let md5_lookup = names.md5_to_uname.clone();
|
||||
let names_map = names.map.clone();
|
||||
|
||||
let table_targets: Vec<(String, String, String, String)> = tokio::task::spawn_blocking(move || {
|
||||
let conn = Connection::open(&path2)?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'"
|
||||
)?;
|
||||
let table_names: Vec<String> = stmt.query_map([], |row| row.get(0))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
let re = Regex::new(r"^Msg_[0-9a-f]{32}$").unwrap();
|
||||
let mut result = Vec::new();
|
||||
for tname in table_names {
|
||||
if !re.is_match(&tname) {
|
||||
continue;
|
||||
}
|
||||
let hash = &tname[4..];
|
||||
let uname = md5_lookup.get(hash).cloned().unwrap_or_default();
|
||||
let display = if uname.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
names_map.get(&uname).cloned().unwrap_or_else(|| uname.clone())
|
||||
};
|
||||
result.push((
|
||||
path2.to_string_lossy().into_owned(),
|
||||
tname,
|
||||
display,
|
||||
uname,
|
||||
));
|
||||
}
|
||||
Ok::<_, anyhow::Error>(result)
|
||||
}).await??;
|
||||
|
||||
targets.extend(table_targets);
|
||||
}
|
||||
}
|
||||
|
||||
// 按 db_path 分组
|
||||
let mut by_path: HashMap<String, Vec<(String, String, String)>> = HashMap::new();
|
||||
for (p, t, d, u) in targets {
|
||||
by_path.entry(p).or_default().push((t, d, u));
|
||||
}
|
||||
|
||||
let mut results: Vec<Value> = Vec::new();
|
||||
let kw = keyword.to_string();
|
||||
for (db_path, table_list) in by_path {
|
||||
let kw2 = kw.clone();
|
||||
let since2 = since;
|
||||
let until2 = until;
|
||||
let limit2 = limit * 3;
|
||||
|
||||
let found: Vec<Value> = tokio::task::spawn_blocking(move || {
|
||||
let conn = Connection::open(&db_path)?;
|
||||
let mut all = Vec::new();
|
||||
for (tname, display, uname) in &table_list {
|
||||
let is_group = uname.contains("@chatroom");
|
||||
let rows = search_in_table(&conn, tname, &uname, is_group,
|
||||
&HashMap::new(), &kw2, since2, until2, limit2)?;
|
||||
for mut row in rows {
|
||||
if row.get("chat").map(|v| v.as_str().unwrap_or("")).unwrap_or("").is_empty() {
|
||||
if let Some(obj) = row.as_object_mut() {
|
||||
obj.insert("chat".into(), serde_json::Value::String(
|
||||
if display.is_empty() { tname.clone() } else { display.clone() }
|
||||
));
|
||||
}
|
||||
}
|
||||
all.push(row);
|
||||
}
|
||||
}
|
||||
Ok::<_, anyhow::Error>(all)
|
||||
}).await??;
|
||||
|
||||
results.extend(found);
|
||||
}
|
||||
|
||||
results.sort_by_key(|r| std::cmp::Reverse(r["timestamp"].as_i64().unwrap_or(0)));
|
||||
let paged: Vec<Value> = results.into_iter().take(limit).collect();
|
||||
Ok(json!({ "keyword": keyword, "count": paged.len(), "results": paged }))
|
||||
}
|
||||
|
||||
/// 查询联系人
|
||||
pub async fn q_contacts(names: &Names, query: Option<&str>, limit: usize) -> Result<Value> {
|
||||
let mut contacts: Vec<Value> = names.map.iter()
|
||||
.filter(|(u, _)| !u.starts_with("gh_") && !u.starts_with("biz_"))
|
||||
.map(|(u, d)| json!({ "username": u, "display": d }))
|
||||
.collect();
|
||||
|
||||
if let Some(q) = query {
|
||||
let low = q.to_lowercase();
|
||||
contacts.retain(|c| {
|
||||
c["display"].as_str().map(|s| s.to_lowercase().contains(&low)).unwrap_or(false)
|
||||
|| c["username"].as_str().map(|s| s.to_lowercase().contains(&low)).unwrap_or(false)
|
||||
});
|
||||
}
|
||||
|
||||
contacts.sort_by(|a, b| {
|
||||
a["display"].as_str().unwrap_or("").cmp(b["display"].as_str().unwrap_or(""))
|
||||
});
|
||||
|
||||
let total = contacts.len();
|
||||
contacts.truncate(limit);
|
||||
Ok(json!({ "contacts": contacts, "total": total }))
|
||||
}
|
||||
|
||||
// ─── 内部辅助函数 ────────────────────────────────────────────────────────────
|
||||
|
||||
fn resolve_username(chat_name: &str, names: &Names) -> Option<String> {
|
||||
if names.map.contains_key(chat_name)
|
||||
|| chat_name.contains("@chatroom")
|
||||
|| chat_name.starts_with("wxid_")
|
||||
{
|
||||
return Some(chat_name.to_string());
|
||||
}
|
||||
let low = chat_name.to_lowercase();
|
||||
// 精确匹配显示名
|
||||
for (uname, display) in &names.map {
|
||||
if low == display.to_lowercase() {
|
||||
return Some(uname.clone());
|
||||
}
|
||||
}
|
||||
// 模糊匹配
|
||||
for (uname, display) in &names.map {
|
||||
if display.to_lowercase().contains(&low) {
|
||||
return Some(uname.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn find_msg_tables(
|
||||
db: &DbCache,
|
||||
names: &Names,
|
||||
username: &str,
|
||||
) -> Result<Vec<(std::path::PathBuf, String)>> {
|
||||
let table_name = format!("Msg_{:x}", md5::compute(username.as_bytes()));
|
||||
let re = Regex::new(r"^Msg_[0-9a-f]{32}$").unwrap();
|
||||
if !re.is_match(&table_name) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut results = Vec::new();
|
||||
for rel_key in &names.msg_db_keys {
|
||||
let path = match db.get(rel_key).await? {
|
||||
Some(p) => p,
|
||||
None => continue,
|
||||
};
|
||||
let tname = table_name.clone();
|
||||
let path2 = path.clone();
|
||||
let exists: Option<i64> = tokio::task::spawn_blocking(move || {
|
||||
let conn = Connection::open(&path2)?;
|
||||
// 检查表是否存在
|
||||
let table_exists: Option<i64> = conn.query_row(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
|
||||
[&tname],
|
||||
|row| row.get(0),
|
||||
).ok().flatten();
|
||||
if table_exists.is_none() {
|
||||
return Ok::<_, anyhow::Error>(None);
|
||||
}
|
||||
let max_ts: Option<i64> = conn.query_row(
|
||||
&format!("SELECT MAX(create_time) FROM [{}]", tname),
|
||||
[],
|
||||
|row| row.get(0),
|
||||
).ok().flatten();
|
||||
Ok(max_ts)
|
||||
}).await??;
|
||||
|
||||
if exists.is_some() {
|
||||
results.push((path.clone(), table_name.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
// 按最大时间戳排序(最新的优先)
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
fn query_messages(
|
||||
db_path: &std::path::Path,
|
||||
table: &str,
|
||||
chat_username: &str,
|
||||
is_group: bool,
|
||||
names_map: &HashMap<String, String>,
|
||||
since: Option<i64>,
|
||||
until: Option<i64>,
|
||||
limit: usize,
|
||||
offset: usize,
|
||||
) -> Result<Vec<Value>> {
|
||||
let conn = Connection::open(db_path)?;
|
||||
let id2u = load_id2u(&conn);
|
||||
|
||||
let mut clauses = Vec::new();
|
||||
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
|
||||
if let Some(s) = since {
|
||||
clauses.push("create_time >= ?");
|
||||
params.push(Box::new(s));
|
||||
}
|
||||
if let Some(u) = until {
|
||||
clauses.push("create_time <= ?");
|
||||
params.push(Box::new(u));
|
||||
}
|
||||
let where_clause = if clauses.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("WHERE {}", clauses.join(" AND "))
|
||||
};
|
||||
|
||||
let sql = format!(
|
||||
"SELECT local_id, local_type, create_time, real_sender_id,
|
||||
message_content, WCDB_CT_message_content
|
||||
FROM [{}] {} ORDER BY create_time DESC LIMIT ? OFFSET ?",
|
||||
table, where_clause
|
||||
);
|
||||
|
||||
params.push(Box::new(limit as i64));
|
||||
params.push(Box::new(offset as i64));
|
||||
|
||||
let params_ref: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(params_ref.as_slice(), |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, i64>(1)?,
|
||||
row.get::<_, i64>(2)?,
|
||||
row.get::<_, i64>(3)?,
|
||||
row.get::<_, Vec<u8>>(4).unwrap_or_default(),
|
||||
row.get::<_, i64>(5).unwrap_or(0),
|
||||
))
|
||||
})?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut result = Vec::new();
|
||||
for (local_id, local_type, ts, real_sender_id, content_bytes, ct) in rows {
|
||||
let content = decompress_message(&content_bytes, ct);
|
||||
let sender = sender_label(real_sender_id, &content, is_group, chat_username, &id2u, names_map);
|
||||
let text = fmt_content(local_id, local_type, &content, is_group);
|
||||
|
||||
result.push(json!({
|
||||
"timestamp": ts,
|
||||
"time": fmt_time(ts, "%Y-%m-%d %H:%M"),
|
||||
"sender": sender,
|
||||
"content": text,
|
||||
"type": fmt_type(local_type),
|
||||
"local_id": local_id,
|
||||
}));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn search_in_table(
|
||||
conn: &Connection,
|
||||
table: &str,
|
||||
chat_username: &str,
|
||||
is_group: bool,
|
||||
names_map: &HashMap<String, String>,
|
||||
keyword: &str,
|
||||
since: Option<i64>,
|
||||
until: Option<i64>,
|
||||
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))];
|
||||
if let Some(s) = since {
|
||||
clauses.push("create_time >= ?".into());
|
||||
params.push(Box::new(s));
|
||||
}
|
||||
if let Some(u) = until {
|
||||
clauses.push("create_time <= ?".into());
|
||||
params.push(Box::new(u));
|
||||
}
|
||||
let where_clause = format!("WHERE {}", clauses.join(" AND "));
|
||||
let sql = format!(
|
||||
"SELECT local_id, local_type, create_time, real_sender_id,
|
||||
message_content, WCDB_CT_message_content
|
||||
FROM [{}] {} ORDER BY create_time DESC LIMIT ?",
|
||||
table, where_clause
|
||||
);
|
||||
params.push(Box::new(limit as i64));
|
||||
|
||||
let params_ref: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(params_ref.as_slice(), |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, i64>(1)?,
|
||||
row.get::<_, i64>(2)?,
|
||||
row.get::<_, i64>(3)?,
|
||||
row.get::<_, Vec<u8>>(4).unwrap_or_default(),
|
||||
row.get::<_, i64>(5).unwrap_or(0),
|
||||
))
|
||||
})?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut result = Vec::new();
|
||||
for (local_id, local_type, ts, real_sender_id, content_bytes, ct) in rows {
|
||||
let content = decompress_message(&content_bytes, ct);
|
||||
let sender = sender_label(real_sender_id, &content, is_group, chat_username, &id2u, names_map);
|
||||
let text = fmt_content(local_id, local_type, &content, is_group);
|
||||
|
||||
result.push(json!({
|
||||
"timestamp": ts,
|
||||
"time": fmt_time(ts, "%Y-%m-%d %H:%M"),
|
||||
"chat": "",
|
||||
"sender": sender,
|
||||
"content": text,
|
||||
"type": fmt_type(local_type),
|
||||
}));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn load_id2u(conn: &Connection) -> HashMap<i64, String> {
|
||||
let mut map = HashMap::new();
|
||||
if let Ok(mut stmt) = conn.prepare("SELECT rowid, user_name FROM Name2Id") {
|
||||
let _ = stmt.query_map([], |row| {
|
||||
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
|
||||
}).map(|rows| {
|
||||
for r in rows.flatten() {
|
||||
map.insert(r.0, r.1);
|
||||
}
|
||||
});
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
fn sender_label(
|
||||
real_sender_id: i64,
|
||||
content: &str,
|
||||
is_group: bool,
|
||||
chat_username: &str,
|
||||
id2u: &HashMap<i64, String>,
|
||||
names: &HashMap<String, String>,
|
||||
) -> String {
|
||||
let sender_uname = id2u.get(&real_sender_id).cloned().unwrap_or_default();
|
||||
if is_group {
|
||||
if !sender_uname.is_empty() && sender_uname != chat_username {
|
||||
return names.get(&sender_uname).cloned().unwrap_or(sender_uname);
|
||||
}
|
||||
if content.contains(":\n") {
|
||||
let raw = content.splitn(2, ":\n").next().unwrap_or("");
|
||||
return names.get(raw).cloned().unwrap_or_else(|| raw.to_string());
|
||||
}
|
||||
return String::new();
|
||||
}
|
||||
if !sender_uname.is_empty() && sender_uname != chat_username {
|
||||
return names.get(&sender_uname).cloned().unwrap_or(sender_uname);
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
fn decompress_message(data: &[u8], ct: i64) -> String {
|
||||
if ct == 4 && !data.is_empty() {
|
||||
// zstd 压缩
|
||||
if let Ok(dec) = zstd::decode_all(data) {
|
||||
return String::from_utf8_lossy(&dec).into_owned();
|
||||
}
|
||||
}
|
||||
String::from_utf8_lossy(data).into_owned()
|
||||
}
|
||||
|
||||
fn decompress_or_str(data: &[u8]) -> String {
|
||||
if data.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
// 尝试 zstd 解压
|
||||
if let Ok(dec) = zstd::decode_all(data) {
|
||||
if let Ok(s) = String::from_utf8(dec) {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
String::from_utf8_lossy(data).into_owned()
|
||||
}
|
||||
|
||||
fn strip_group_prefix(s: &str) -> String {
|
||||
if s.contains(":\n") {
|
||||
s.splitn(2, ":\n").nth(1).unwrap_or(s).to_string()
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fmt_type(t: i64) -> String {
|
||||
let base = (t as u64 & 0xFFFFFFFF) as i64;
|
||||
match base {
|
||||
1 => "文本".into(),
|
||||
3 => "图片".into(),
|
||||
34 => "语音".into(),
|
||||
42 => "名片".into(),
|
||||
43 => "视频".into(),
|
||||
47 => "表情".into(),
|
||||
48 => "位置".into(),
|
||||
49 => "链接/文件".into(),
|
||||
50 => "通话".into(),
|
||||
10000 => "系统".into(),
|
||||
10002 => "撤回".into(),
|
||||
_ => format!("type={}", base),
|
||||
}
|
||||
}
|
||||
|
||||
fn fmt_content(local_id: i64, local_type: i64, content: &str, is_group: bool) -> String {
|
||||
let base = (local_type as u64 & 0xFFFFFFFF) as i64;
|
||||
match base {
|
||||
3 => return format!("[图片] local_id={}", local_id),
|
||||
47 => return "[表情]".into(),
|
||||
50 => return "[通话]".into(),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let text = if is_group && content.contains(":\n") {
|
||||
content.splitn(2, ":\n").nth(1).unwrap_or(content)
|
||||
} else {
|
||||
content
|
||||
};
|
||||
|
||||
if base == 49 && text.contains("<appmsg") {
|
||||
if let Some(parsed) = parse_appmsg(text) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
text.to_string()
|
||||
}
|
||||
|
||||
fn parse_appmsg(text: &str) -> Option<String> {
|
||||
// 简单 XML 解析,避免引入重量级 XML 库(或直接用 minidom)
|
||||
// 这里用基本字符串搜索实现
|
||||
let title = extract_xml_text(text, "title")?;
|
||||
let atype = extract_xml_text(text, "type").unwrap_or_default();
|
||||
match atype.as_str() {
|
||||
"6" => Some(if !title.is_empty() { format!("[文件] {}", title) } else { "[文件]".into() }),
|
||||
"57" => {
|
||||
let ref_content = extract_xml_text(text, "content")
|
||||
.map(|s| {
|
||||
let s: String = s.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if s.len() > 80 { format!("{}...", &s[..80]) } else { s }
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let quote = if !title.is_empty() { format!("[引用] {}", title) } else { "[引用]".into() };
|
||||
if !ref_content.is_empty() {
|
||||
Some(format!("{}\n \u{21b3} {}", quote, ref_content))
|
||||
} else {
|
||||
Some(quote)
|
||||
}
|
||||
}
|
||||
"33" | "36" | "44" => Some(if !title.is_empty() { format!("[小程序] {}", title) } else { "[小程序]".into() }),
|
||||
_ => Some(if !title.is_empty() { format!("[链接] {}", title) } else { "[链接/文件]".into() }),
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_xml_text(xml: &str, tag: &str) -> Option<String> {
|
||||
let open = format!("<{}>", tag);
|
||||
let close = format!("</{}>", tag);
|
||||
let start = xml.find(&open)?;
|
||||
let content_start = start + open.len();
|
||||
let end = xml[content_start..].find(&close)?;
|
||||
Some(xml[content_start..content_start + end].trim().to_string())
|
||||
}
|
||||
|
||||
fn fmt_time(ts: i64, fmt: &str) -> String {
|
||||
Local.timestamp_opt(ts, 0)
|
||||
.single()
|
||||
.map(|dt| dt.format(fmt).to_string())
|
||||
.unwrap_or_else(|| ts.to_string())
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,219 @@
|
|||
use anyhow::Result;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::ipc::{Request, Response, WatchEvent};
|
||||
use super::cache::DbCache;
|
||||
use super::query::Names;
|
||||
|
||||
/// 启动 IPC server(Unix socket / Windows named pipe)
|
||||
pub async fn serve(
|
||||
db: Arc<DbCache>,
|
||||
names: Arc<std::sync::RwLock<Names>>,
|
||||
watch_tx: broadcast::Sender<WatchEvent>,
|
||||
) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
serve_unix(db, names, watch_tx).await?;
|
||||
#[cfg(windows)]
|
||||
serve_windows(db, names, watch_tx).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn serve_unix(
|
||||
db: Arc<DbCache>,
|
||||
names: Arc<std::sync::RwLock<Names>>,
|
||||
watch_tx: broadcast::Sender<WatchEvent>,
|
||||
) -> Result<()> {
|
||||
use tokio::net::UnixListener;
|
||||
let sock_path = crate::config::sock_path();
|
||||
|
||||
// 删除旧 socket 文件
|
||||
if sock_path.exists() {
|
||||
let _ = tokio::fs::remove_file(&sock_path).await;
|
||||
}
|
||||
|
||||
let listener = UnixListener::bind(&sock_path)?;
|
||||
// 设置权限 0600
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&sock_path, std::fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
|
||||
eprintln!("[server] 监听 {}", sock_path.display());
|
||||
|
||||
loop {
|
||||
let (stream, _) = listener.accept().await?;
|
||||
let db2 = Arc::clone(&db);
|
||||
let names2 = Arc::clone(&names);
|
||||
let tx2 = watch_tx.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_connection_unix(stream, db2, names2, tx2).await {
|
||||
eprintln!("[server] 连接处理错误: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn handle_connection_unix(
|
||||
stream: tokio::net::UnixStream,
|
||||
db: Arc<DbCache>,
|
||||
names: Arc<std::sync::RwLock<Names>>,
|
||||
watch_tx: broadcast::Sender<WatchEvent>,
|
||||
) -> Result<()> {
|
||||
let (reader, mut writer) = stream.into_split();
|
||||
let mut lines = BufReader::new(reader).lines();
|
||||
|
||||
let line = match lines.next_line().await? {
|
||||
Some(l) => l,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
// 解析请求
|
||||
let req: Request = match serde_json::from_str(&line) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
let resp = Response::err(format!("JSON 解析错误: {}", e));
|
||||
writer.write_all(resp.to_json_line()?.as_bytes()).await?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
match req {
|
||||
Request::Watch => {
|
||||
// 流式模式:持续推送事件
|
||||
let mut rx = watch_tx.subscribe();
|
||||
let connected = WatchEvent::connected();
|
||||
writer.write_all(connected.to_json_line()?.as_bytes()).await?;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
event = rx.recv() => {
|
||||
match event {
|
||||
Ok(e) => {
|
||||
if writer.write_all(e.to_json_line()?.as_bytes()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
_ = tokio::time::sleep(Duration::from_secs(30)) => {
|
||||
// 心跳
|
||||
let hb = WatchEvent::heartbeat();
|
||||
if writer.write_all(hb.to_json_line()?.as_bytes()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
other => {
|
||||
let resp = dispatch(other, &db, &names).await;
|
||||
writer.write_all(resp.to_json_line()?.as_bytes()).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
async fn serve_windows(
|
||||
db: Arc<DbCache>,
|
||||
names: Arc<std::sync::RwLock<Names>>,
|
||||
watch_tx: broadcast::Sender<WatchEvent>,
|
||||
) -> Result<()> {
|
||||
use interprocess::local_socket::{
|
||||
tokio::prelude::*, GenericNamespaced, ListenerOptions,
|
||||
};
|
||||
|
||||
let pipe_name = r"\\.\pipe\wechat-cli-daemon";
|
||||
let name = pipe_name.to_ns_name::<GenericNamespaced>()?;
|
||||
let opts = ListenerOptions::new().name(name);
|
||||
let listener = opts.create_tokio()?;
|
||||
|
||||
eprintln!("[server] 监听 {}", pipe_name);
|
||||
|
||||
loop {
|
||||
let conn = listener.accept().await?;
|
||||
let db2 = Arc::clone(&db);
|
||||
let names2 = Arc::clone(&names);
|
||||
let tx2 = watch_tx.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_connection_generic(conn, db2, names2, tx2).await {
|
||||
eprintln!("[server] 连接处理错误: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn dispatch(
|
||||
req: Request,
|
||||
db: &DbCache,
|
||||
names: &std::sync::RwLock<Names>,
|
||||
) -> Response {
|
||||
use crate::ipc::Request::*;
|
||||
use super::query;
|
||||
|
||||
match req {
|
||||
Ping => Response::ok(serde_json::json!({ "pong": true })),
|
||||
Sessions { limit } => {
|
||||
// 在 await 前获取并复制所需数据,避免 RwLockGuard 跨 await
|
||||
let names_snapshot = match clone_names(names) {
|
||||
Ok(n) => n,
|
||||
Err(e) => return Response::err(e),
|
||||
};
|
||||
match query::q_sessions(db, &names_snapshot, limit).await {
|
||||
Ok(v) => Response::ok(v),
|
||||
Err(e) => Response::err(e.to_string()),
|
||||
}
|
||||
}
|
||||
History { chat, limit, offset, since, until } => {
|
||||
let names_snapshot = match clone_names(names) {
|
||||
Ok(n) => n,
|
||||
Err(e) => return Response::err(e),
|
||||
};
|
||||
match query::q_history(db, &names_snapshot, &chat, limit, offset, since, until).await {
|
||||
Ok(v) => Response::ok(v),
|
||||
Err(e) => Response::err(e.to_string()),
|
||||
}
|
||||
}
|
||||
Search { keyword, chats, limit, since, until } => {
|
||||
let names_snapshot = match clone_names(names) {
|
||||
Ok(n) => n,
|
||||
Err(e) => return Response::err(e),
|
||||
};
|
||||
match query::q_search(db, &names_snapshot, &keyword, chats, limit, since, until).await {
|
||||
Ok(v) => Response::ok(v),
|
||||
Err(e) => Response::err(e.to_string()),
|
||||
}
|
||||
}
|
||||
Contacts { query, limit } => {
|
||||
let names_snapshot = match clone_names(names) {
|
||||
Ok(n) => n,
|
||||
Err(e) => return Response::err(e),
|
||||
};
|
||||
match query::q_contacts(&names_snapshot, query.as_deref(), limit).await {
|
||||
Ok(v) => Response::ok(v),
|
||||
Err(e) => Response::err(e.to_string()),
|
||||
}
|
||||
}
|
||||
Watch => Response::err("Watch 命令不应通过 dispatch 处理"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 克隆 Names 以避免 RwLockGuard 跨 await
|
||||
fn clone_names(names: &std::sync::RwLock<Names>) -> Result<Names, String> {
|
||||
let guard = names.read().map_err(|_| "内部错误: names lock poisoned".to_string())?;
|
||||
Ok(Names {
|
||||
map: guard.map.clone(),
|
||||
md5_to_uname: guard.md5_to_uname.clone(),
|
||||
msg_db_keys: guard.msg_db_keys.clone(),
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use super::cache::DbCache;
|
||||
use super::query::{fmt_type, Names};
|
||||
use crate::ipc::WatchEvent;
|
||||
|
||||
/// 启动 WAL 变化监听 task
|
||||
///
|
||||
/// 每 500ms 检测 session.db-wal 的 mtime,有变化时重新读 session.db,
|
||||
/// 找到 timestamp 更新的行,broadcast 到所有 watch 客户端
|
||||
#[allow(dead_code)]
|
||||
pub async fn start_watcher(
|
||||
db: &'static DbCache,
|
||||
names_ref: &'static std::sync::RwLock<Names>,
|
||||
tx: broadcast::Sender<WatchEvent>,
|
||||
session_wal_path: PathBuf,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let mut last_mtime = 0.0f64;
|
||||
let mut last_ts: HashMap<String, i64> = HashMap::new();
|
||||
let mut initialized = false;
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
// 如果没有订阅者,跳过
|
||||
if tx.receiver_count() == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let wal_mtime = match mtime_f64(&session_wal_path) {
|
||||
Some(m) => m,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
if (wal_mtime - last_mtime).abs() < 0.001 {
|
||||
continue;
|
||||
}
|
||||
last_mtime = wal_mtime;
|
||||
|
||||
// 重新解密 session.db
|
||||
let path = match db.get("session/session.db").await {
|
||||
Ok(Some(p)) => p,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let path2 = path.clone();
|
||||
let rows: Vec<(String, Vec<u8>, i64, i64, String)> = match tokio::task::spawn_blocking(move || {
|
||||
let conn = rusqlite::Connection::open(&path2)?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT username, summary, last_timestamp, last_msg_type, last_msg_sender
|
||||
FROM SessionTable WHERE last_timestamp > 0
|
||||
ORDER BY last_timestamp DESC LIMIT 50"
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, Vec<u8>>(1).unwrap_or_default(),
|
||||
row.get::<_, i64>(2)?,
|
||||
row.get::<_, i64>(3).unwrap_or(0),
|
||||
row.get::<_, String>(4).unwrap_or_default(),
|
||||
))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok::<_, anyhow::Error>(rows)
|
||||
}).await {
|
||||
Ok(Ok(r)) => r,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let names_guard = names_ref.read().expect("names lock poisoned");
|
||||
|
||||
for (username, summary_bytes, ts, msg_type, sender) in &rows {
|
||||
if !initialized {
|
||||
last_ts.insert(username.clone(), *ts);
|
||||
continue;
|
||||
}
|
||||
|
||||
let prev_ts = last_ts.get(username).copied().unwrap_or(0);
|
||||
if *ts <= prev_ts {
|
||||
continue;
|
||||
}
|
||||
last_ts.insert(username.clone(), *ts);
|
||||
|
||||
let display = names_guard.display(username);
|
||||
let is_group = username.contains("@chatroom");
|
||||
|
||||
let summary = decompress_or_str(summary_bytes);
|
||||
let summary = if summary.contains(":\n") {
|
||||
summary.splitn(2, ":\n").nth(1).unwrap_or(&summary).to_string()
|
||||
} else {
|
||||
summary
|
||||
};
|
||||
|
||||
let sender_display = if !sender.is_empty() {
|
||||
names_guard.map.get(sender).cloned().unwrap_or_else(|| sender.clone())
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let event = WatchEvent {
|
||||
event: "message".into(),
|
||||
time: Some(fmt_time_hhmm(*ts)),
|
||||
chat: Some(display),
|
||||
username: Some(username.clone()),
|
||||
is_group: Some(is_group),
|
||||
sender: Some(sender_display),
|
||||
content: Some(summary),
|
||||
msg_type: Some(fmt_type(*msg_type)),
|
||||
timestamp: Some(*ts),
|
||||
};
|
||||
|
||||
let _ = tx.send(event);
|
||||
}
|
||||
|
||||
if !initialized {
|
||||
initialized = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn mtime_f64(path: &std::path::Path) -> Option<f64> {
|
||||
std::fs::metadata(path)
|
||||
.and_then(|m| m.modified())
|
||||
.ok()
|
||||
.map(|t| t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs_f64())
|
||||
}
|
||||
|
||||
fn decompress_or_str(data: &[u8]) -> String {
|
||||
if data.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
if let Ok(dec) = zstd::decode_all(data) {
|
||||
if let Ok(s) = String::from_utf8(dec) {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
String::from_utf8_lossy(data).into_owned()
|
||||
}
|
||||
|
||||
fn fmt_time_hhmm(ts: i64) -> String {
|
||||
use chrono::{Local, TimeZone};
|
||||
Local.timestamp_opt(ts, 0)
|
||||
.single()
|
||||
.map(|dt| dt.format("%H:%M").to_string())
|
||||
.unwrap_or_else(|| ts.to_string())
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// CLI 向 daemon 发送的请求(换行符分隔 JSON,与 Python 版兼容)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "cmd", rename_all = "snake_case")]
|
||||
pub enum Request {
|
||||
Ping,
|
||||
Sessions {
|
||||
#[serde(default = "default_limit_20")]
|
||||
limit: usize,
|
||||
},
|
||||
History {
|
||||
chat: String,
|
||||
#[serde(default = "default_limit_50")]
|
||||
limit: usize,
|
||||
#[serde(default)]
|
||||
offset: usize,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
since: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
until: Option<i64>,
|
||||
},
|
||||
Search {
|
||||
keyword: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
chats: Option<Vec<String>>,
|
||||
#[serde(default = "default_limit_20")]
|
||||
limit: usize,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
since: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
until: Option<i64>,
|
||||
},
|
||||
Contacts {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
query: Option<String>,
|
||||
#[serde(default = "default_limit_50")]
|
||||
limit: usize,
|
||||
},
|
||||
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)]
|
||||
pub struct Response {
|
||||
pub ok: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
#[serde(flatten)]
|
||||
pub data: Value,
|
||||
}
|
||||
|
||||
impl Response {
|
||||
pub fn ok(data: Value) -> Self {
|
||||
Self { ok: true, error: None, data }
|
||||
}
|
||||
|
||||
pub fn err(msg: impl Into<String>) -> Self {
|
||||
Self { ok: false, error: Some(msg.into()), data: Value::Null }
|
||||
}
|
||||
|
||||
pub fn to_json_line(&self) -> anyhow::Result<String> {
|
||||
let s = serde_json::to_string(self)?;
|
||||
Ok(s + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
fn default_limit_20() -> usize { 20 }
|
||||
fn default_limit_50() -> usize { 50 }
|
||||
|
||||
/// Watch 事件(daemon -> CLI 流式推送)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WatchEvent {
|
||||
pub event: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub time: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub chat: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub username: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub is_group: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sender: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<String>,
|
||||
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
|
||||
pub msg_type: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub timestamp: Option<i64>,
|
||||
}
|
||||
|
||||
impl WatchEvent {
|
||||
pub fn connected() -> Self {
|
||||
Self {
|
||||
event: "connected".into(),
|
||||
time: None, chat: None, username: None, is_group: None,
|
||||
sender: None, content: None, msg_type: None, timestamp: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn heartbeat() -> Self {
|
||||
Self {
|
||||
event: "heartbeat".into(),
|
||||
time: None, chat: None, username: None, is_group: None,
|
||||
sender: None, content: None, msg_type: None, timestamp: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_json_line(&self) -> anyhow::Result<String> {
|
||||
let s = serde_json::to_string(self)?;
|
||||
Ok(s + "\n")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
mod config;
|
||||
mod ipc;
|
||||
mod crypto;
|
||||
mod scanner;
|
||||
mod daemon;
|
||||
mod cli;
|
||||
|
||||
fn main() {
|
||||
if std::env::var("WX_DAEMON_MODE").is_ok() {
|
||||
daemon::run();
|
||||
} else {
|
||||
cli::run();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
/// Linux WeChat 进程内存密钥扫描器
|
||||
///
|
||||
/// 通过 /proc/<pid>/maps 枚举内存区域,
|
||||
/// 通过 /proc/<pid>/mem 读取内存内容,
|
||||
/// 搜索 x'<64hex><32hex>' 格式的 SQLCipher 密钥
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::path::Path;
|
||||
|
||||
use super::{collect_db_salts, KeyEntry};
|
||||
|
||||
const HEX_PATTERN_LEN: usize = 96;
|
||||
const CHUNK_SIZE: usize = 2 * 1024 * 1024;
|
||||
|
||||
/// 查找 WeChat 进程 PID
|
||||
fn find_wechat_pid() -> Option<u32> {
|
||||
let proc_dir = std::fs::read_dir("/proc").ok()?;
|
||||
for entry in proc_dir.flatten() {
|
||||
let name = entry.file_name();
|
||||
let name_str = name.to_string_lossy();
|
||||
// 只处理数字目录(PID)
|
||||
if !name_str.chars().all(|c| c.is_ascii_digit()) {
|
||||
continue;
|
||||
}
|
||||
let comm_path = format!("/proc/{}/comm", name_str);
|
||||
if let Ok(comm) = std::fs::read_to_string(&comm_path) {
|
||||
let comm = comm.trim().to_lowercase();
|
||||
if comm == "wechat" || comm == "weixin" {
|
||||
if let Ok(pid) = name_str.parse::<u32>() {
|
||||
return Some(pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 解析 /proc/<pid>/maps 文件,返回可读的内存区域 (start, end)
|
||||
fn parse_maps(pid: u32) -> Result<Vec<(u64, u64)>> {
|
||||
let maps_path = format!("/proc/{}/maps", pid);
|
||||
let content = std::fs::read_to_string(&maps_path)
|
||||
.with_context(|| format!("读取 {} 失败", maps_path))?;
|
||||
|
||||
let mut regions = Vec::new();
|
||||
for line in content.lines() {
|
||||
// 格式: start-end perms offset dev inode pathname
|
||||
let parts: Vec<&str> = line.splitn(2, ' ').collect();
|
||||
if parts.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let perms = parts[1].trim_start();
|
||||
// 只选取 r 和 w 权限的区域
|
||||
if !perms.starts_with("rw") {
|
||||
continue;
|
||||
}
|
||||
let addr_parts: Vec<&str> = parts[0].splitn(2, '-').collect();
|
||||
if addr_parts.len() != 2 {
|
||||
continue;
|
||||
}
|
||||
if let (Ok(start), Ok(end)) = (
|
||||
u64::from_str_radix(addr_parts[0], 16),
|
||||
u64::from_str_radix(addr_parts[1], 16),
|
||||
) {
|
||||
regions.push((start, end));
|
||||
}
|
||||
}
|
||||
Ok(regions)
|
||||
}
|
||||
|
||||
pub fn scan_keys(db_dir: &Path) -> Result<Vec<KeyEntry>> {
|
||||
let pid = find_wechat_pid()
|
||||
.context("找不到 WeChat 进程,请确认 WeChat 正在运行")?;
|
||||
eprintln!("WeChat PID: {}", pid);
|
||||
|
||||
let db_salts = collect_db_salts(db_dir);
|
||||
eprintln!("找到 {} 个加密数据库", db_salts.len());
|
||||
|
||||
eprintln!("扫描进程内存...");
|
||||
let regions = parse_maps(pid)?;
|
||||
eprintln!("找到 {} 个可读写内存区域", regions.len());
|
||||
|
||||
let mem_path = format!("/proc/{}/mem", pid);
|
||||
let mut mem_file = std::fs::File::open(&mem_path)
|
||||
.with_context(|| format!("打开 {} 失败,请以 root 权限运行", mem_path))?;
|
||||
|
||||
let mut raw_keys: Vec<(String, String)> = Vec::new();
|
||||
for (start, end) in ®ions {
|
||||
scan_region(&mut mem_file, *start, *end, &mut raw_keys);
|
||||
}
|
||||
// 去重
|
||||
raw_keys.dedup_by(|a, b| a.0 == b.0 && a.1 == b.1);
|
||||
eprintln!("找到 {} 个候选密钥", raw_keys.len());
|
||||
|
||||
let mut entries = Vec::new();
|
||||
for (key_hex, salt_hex) in &raw_keys {
|
||||
for (db_salt, db_name) in &db_salts {
|
||||
if salt_hex == db_salt {
|
||||
entries.push(KeyEntry {
|
||||
db_name: db_name.clone(),
|
||||
enc_key: key_hex.clone(),
|
||||
salt: salt_hex.clone(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("匹配到 {}/{} 个密钥", entries.len(), raw_keys.len());
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn scan_region(
|
||||
mem: &mut std::fs::File,
|
||||
start: u64,
|
||||
end: u64,
|
||||
results: &mut Vec<(String, String)>,
|
||||
) {
|
||||
let total_len = (end - start) as usize;
|
||||
let overlap = HEX_PATTERN_LEN + 3;
|
||||
let mut offset = 0usize;
|
||||
|
||||
loop {
|
||||
if offset >= total_len {
|
||||
break;
|
||||
}
|
||||
let chunk_size = std::cmp::min(CHUNK_SIZE, total_len - offset);
|
||||
let addr = start + offset as u64;
|
||||
|
||||
if mem.seek(SeekFrom::Start(addr)).is_err() {
|
||||
break;
|
||||
}
|
||||
let mut buf = vec![0u8; chunk_size];
|
||||
match mem.read(&mut buf) {
|
||||
Ok(n) if n > 0 => {
|
||||
buf.truncate(n);
|
||||
search_pattern(&buf, results);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if chunk_size > overlap {
|
||||
offset += chunk_size - overlap;
|
||||
} else {
|
||||
offset += chunk_size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn is_hex_char(c: u8) -> bool {
|
||||
c.is_ascii_hexdigit()
|
||||
}
|
||||
|
||||
fn search_pattern(buf: &[u8], results: &mut Vec<(String, String)>) {
|
||||
let total = HEX_PATTERN_LEN + 3;
|
||||
if buf.len() < total {
|
||||
return;
|
||||
}
|
||||
let mut i = 0;
|
||||
while i + total <= buf.len() {
|
||||
if buf[i] != b'x' || buf[i + 1] != b'\'' {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let hex_start = i + 2;
|
||||
let all_hex = buf[hex_start..hex_start + HEX_PATTERN_LEN]
|
||||
.iter()
|
||||
.all(|&c| is_hex_char(c));
|
||||
if !all_hex {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if buf[hex_start + HEX_PATTERN_LEN] != b'\'' {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let key_hex = String::from_utf8_lossy(&buf[hex_start..hex_start + 64])
|
||||
.to_lowercase();
|
||||
let salt_hex = String::from_utf8_lossy(&buf[hex_start + 64..hex_start + 96])
|
||||
.to_lowercase();
|
||||
let is_dup = results.iter().any(|(k, s)| k == &key_hex && s == &salt_hex);
|
||||
if !is_dup {
|
||||
results.push((key_hex, salt_hex));
|
||||
}
|
||||
i += total;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,296 @@
|
|||
/// macOS WeChat 进程内存密钥扫描器
|
||||
///
|
||||
/// 翻译自 find_all_keys_macos.c,使用 Mach VM API:
|
||||
/// - task_for_pid: 获取目标进程的 task port(需要 root 权限)
|
||||
/// - mach_vm_region: 枚举内存区域
|
||||
/// - mach_vm_read: 读取内存块
|
||||
///
|
||||
/// 注意:
|
||||
/// 1. 需要以 root (sudo) 运行
|
||||
/// 2. WeChat 需要进行 ad-hoc 签名
|
||||
/// 3. 在内存中搜索 x'<64hex><32hex>' 格式的 SQLCipher 密钥
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::path::Path;
|
||||
|
||||
use super::{collect_db_salts, KeyEntry};
|
||||
|
||||
// Mach 相关常量
|
||||
const KERN_SUCCESS: i32 = 0;
|
||||
const VM_PROT_READ: i32 = 1;
|
||||
const VM_PROT_WRITE: i32 = 2;
|
||||
const VM_REGION_BASIC_INFO_64: i32 = 9;
|
||||
const CHUNK_SIZE: usize = 2 * 1024 * 1024; // 2MB
|
||||
const HEX_PATTERN_LEN: usize = 96; // 64(key) + 32(salt)
|
||||
|
||||
// vm_region_basic_info_64 结构体
|
||||
#[repr(C)]
|
||||
struct VmRegionBasicInfo64 {
|
||||
protection: i32,
|
||||
max_protection: i32,
|
||||
inheritance: u32,
|
||||
shared: u32,
|
||||
reserved: u32,
|
||||
_offset: u64,
|
||||
behavior: i32,
|
||||
user_wired_count: u16,
|
||||
}
|
||||
|
||||
// Mach FFI 声明
|
||||
#[allow(non_camel_case_types)]
|
||||
type kern_return_t = i32;
|
||||
#[allow(non_camel_case_types)]
|
||||
type mach_port_t = u32;
|
||||
#[allow(non_camel_case_types)]
|
||||
type mach_vm_address_t = u64;
|
||||
#[allow(non_camel_case_types)]
|
||||
type mach_vm_size_t = u64;
|
||||
#[allow(non_camel_case_types)]
|
||||
type mach_msg_type_number_t = u32;
|
||||
#[allow(non_camel_case_types)]
|
||||
type vm_offset_t = usize;
|
||||
#[allow(non_camel_case_types, dead_code)]
|
||||
type vm_prot_t = i32;
|
||||
|
||||
extern "C" {
|
||||
fn mach_task_self() -> mach_port_t;
|
||||
fn task_for_pid(host: mach_port_t, pid: libc::pid_t, task: *mut mach_port_t) -> kern_return_t;
|
||||
fn mach_vm_region(
|
||||
task: mach_port_t,
|
||||
address: *mut mach_vm_address_t,
|
||||
size: *mut mach_vm_size_t,
|
||||
flavor: i32,
|
||||
info: *mut VmRegionBasicInfo64,
|
||||
info_count: *mut mach_msg_type_number_t,
|
||||
obj_name: *mut mach_port_t,
|
||||
) -> kern_return_t;
|
||||
fn mach_vm_read(
|
||||
task: mach_port_t,
|
||||
addr: mach_vm_address_t,
|
||||
size: mach_vm_size_t,
|
||||
data: *mut vm_offset_t,
|
||||
data_cnt: *mut mach_msg_type_number_t,
|
||||
) -> kern_return_t;
|
||||
fn mach_vm_deallocate(
|
||||
task: mach_port_t,
|
||||
addr: mach_vm_address_t,
|
||||
size: mach_vm_size_t,
|
||||
) -> kern_return_t;
|
||||
}
|
||||
|
||||
/// 查找 WeChat 进程的 PID
|
||||
fn find_wechat_pid() -> Option<libc::pid_t> {
|
||||
// 使用 pgrep -x WeChat 查找(与 C 版本一致)
|
||||
let output = std::process::Command::new("pgrep")
|
||||
.args(["-x", "WeChat"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let s = String::from_utf8_lossy(&output.stdout);
|
||||
s.trim().parse().ok()
|
||||
}
|
||||
|
||||
/// 判断字节是否是 ASCII 十六进制字符
|
||||
#[inline]
|
||||
fn is_hex_char(c: u8) -> bool {
|
||||
c.is_ascii_hexdigit()
|
||||
}
|
||||
|
||||
pub fn scan_keys(db_dir: &Path) -> Result<Vec<KeyEntry>> {
|
||||
// 1. 查找 WeChat PID
|
||||
let pid = find_wechat_pid()
|
||||
.context("找不到 WeChat 进程,请确认 WeChat 正在运行")?;
|
||||
eprintln!("WeChat PID: {}", pid);
|
||||
|
||||
// 2. 获取 task port
|
||||
// SAFETY: task_for_pid 是标准 Mach API,参数合法
|
||||
let task = unsafe {
|
||||
let mut task: mach_port_t = 0;
|
||||
let kr = task_for_pid(mach_task_self(), pid, &mut task);
|
||||
if kr != KERN_SUCCESS {
|
||||
bail!(
|
||||
"task_for_pid 失败 (kr={})\n请确认:(1) 以 root 运行 (2) WeChat 已 ad-hoc 签名",
|
||||
kr
|
||||
);
|
||||
}
|
||||
task
|
||||
};
|
||||
eprintln!("Got task port: {}", task);
|
||||
|
||||
// 3. 收集数据库 salt 映射
|
||||
eprintln!("扫描数据库文件...");
|
||||
let db_salts = collect_db_salts(db_dir);
|
||||
eprintln!("找到 {} 个加密数据库", db_salts.len());
|
||||
|
||||
// 4. 扫描进程内存
|
||||
eprintln!("扫描进程内存寻找密钥...");
|
||||
let raw_keys = scan_memory(task)?;
|
||||
eprintln!("找到 {} 个候选密钥", raw_keys.len());
|
||||
|
||||
// 5. 将密钥与数据库 salt 匹配
|
||||
let mut entries = Vec::new();
|
||||
for (key_hex, salt_hex) in &raw_keys {
|
||||
for (db_salt, db_name) in &db_salts {
|
||||
if salt_hex == db_salt {
|
||||
entries.push(KeyEntry {
|
||||
db_name: db_name.clone(),
|
||||
enc_key: key_hex.clone(),
|
||||
salt: salt_hex.clone(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("匹配到 {}/{} 个密钥", entries.len(), raw_keys.len());
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// 扫描进程内存,返回 (key_hex, salt_hex) 列表
|
||||
fn scan_memory(task: mach_port_t) -> Result<Vec<(String, String)>> {
|
||||
let mut results: Vec<(String, String)> = Vec::new();
|
||||
let mut addr: mach_vm_address_t = 0;
|
||||
|
||||
// VM_REGION_BASIC_INFO_COUNT_64 = sizeof(vm_region_basic_info_64) / sizeof(int32_t)
|
||||
let info_count_expected: mach_msg_type_number_t =
|
||||
(std::mem::size_of::<VmRegionBasicInfo64>() / 4) as u32;
|
||||
|
||||
loop {
|
||||
let mut size: mach_vm_size_t = 0;
|
||||
let mut info = VmRegionBasicInfo64 {
|
||||
protection: 0, max_protection: 0, inheritance: 0,
|
||||
shared: 0, reserved: 0, _offset: 0, behavior: 0, user_wired_count: 0,
|
||||
};
|
||||
let mut info_count: mach_msg_type_number_t = info_count_expected;
|
||||
let mut obj_name: mach_port_t = 0;
|
||||
|
||||
// SAFETY: mach_vm_region 枚举虚拟内存区域,所有参数合法
|
||||
let kr = unsafe {
|
||||
mach_vm_region(
|
||||
task,
|
||||
&mut addr,
|
||||
&mut size,
|
||||
VM_REGION_BASIC_INFO_64,
|
||||
&mut info,
|
||||
&mut info_count,
|
||||
&mut obj_name,
|
||||
)
|
||||
};
|
||||
|
||||
if kr != KERN_SUCCESS {
|
||||
break;
|
||||
}
|
||||
if size == 0 {
|
||||
addr = addr.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 只扫描可读可写区域(密钥通常存在于堆内存)
|
||||
if (info.protection & (VM_PROT_READ | VM_PROT_WRITE)) == (VM_PROT_READ | VM_PROT_WRITE) {
|
||||
scan_region(task, addr, size, &mut results);
|
||||
}
|
||||
|
||||
addr = addr.saturating_add(size);
|
||||
}
|
||||
|
||||
// 去重
|
||||
results.dedup_by(|a, b| a.0 == b.0 && a.1 == b.1);
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// 扫描单个内存区域,按 CHUNK_SIZE 分块读取
|
||||
fn scan_region(
|
||||
task: mach_port_t,
|
||||
addr: mach_vm_address_t,
|
||||
size: mach_vm_size_t,
|
||||
results: &mut Vec<(String, String)>,
|
||||
) {
|
||||
let end = addr + size;
|
||||
let mut ca = addr;
|
||||
|
||||
while ca < end {
|
||||
let cs = std::cmp::min(end - ca, CHUNK_SIZE as u64);
|
||||
|
||||
let mut data: vm_offset_t = 0;
|
||||
let mut dc: mach_msg_type_number_t = 0;
|
||||
|
||||
// SAFETY: mach_vm_read 读取目标进程内存到内核缓冲区,
|
||||
// 返回的 data 指针指向通过 vm_allocate 分配的内存,
|
||||
// 必须用 mach_vm_deallocate 释放
|
||||
let kr = unsafe {
|
||||
mach_vm_read(task, ca, cs, &mut data, &mut dc)
|
||||
};
|
||||
|
||||
if kr == KERN_SUCCESS {
|
||||
// SAFETY: data 是 mach_vm_read 返回的有效指针,dc 是字节数
|
||||
let buf: &[u8] = unsafe {
|
||||
std::slice::from_raw_parts(data as *const u8, dc as usize)
|
||||
};
|
||||
|
||||
search_pattern(buf, results);
|
||||
|
||||
// SAFETY: 释放 mach_vm_read 分配的内核内存
|
||||
unsafe {
|
||||
mach_vm_deallocate(mach_task_self(), data as u64, dc as u64);
|
||||
}
|
||||
}
|
||||
|
||||
// 保留 (HEX_PATTERN_LEN + 3) 字节重叠以处理跨块边界的模式
|
||||
let overlap = HEX_PATTERN_LEN + 3;
|
||||
if cs as usize > overlap {
|
||||
ca += cs - overlap as u64;
|
||||
} else {
|
||||
ca += cs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 在缓冲区中搜索 x'<96个十六进制字符>' 模式
|
||||
///
|
||||
/// 格式:x'<64hex(key)><32hex(salt)>'(总计 99 字节)
|
||||
fn search_pattern(buf: &[u8], results: &mut Vec<(String, String)>) {
|
||||
let total = HEX_PATTERN_LEN + 3; // x' + 96 hex + '
|
||||
if buf.len() < total {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut i = 0;
|
||||
while i + total <= buf.len() {
|
||||
if buf[i] != b'x' || buf[i + 1] != b'\'' {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 验证后续 96 字节都是十六进制字符
|
||||
let hex_start = i + 2;
|
||||
let all_hex = buf[hex_start..hex_start + HEX_PATTERN_LEN]
|
||||
.iter()
|
||||
.all(|&c| is_hex_char(c));
|
||||
|
||||
if !all_hex {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 验证结尾的单引号
|
||||
if buf[hex_start + HEX_PATTERN_LEN] != b'\'' {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 提取 key_hex 和 salt_hex,统一转小写
|
||||
let key_hex = String::from_utf8_lossy(&buf[hex_start..hex_start + 64])
|
||||
.to_lowercase();
|
||||
let salt_hex = String::from_utf8_lossy(&buf[hex_start + 64..hex_start + 96])
|
||||
.to_lowercase();
|
||||
|
||||
// 去重检查
|
||||
let is_dup = results.iter().any(|(k, s)| k == &key_hex && s == &salt_hex);
|
||||
if !is_dup {
|
||||
results.push((key_hex, salt_hex));
|
||||
}
|
||||
|
||||
i += total;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows;
|
||||
|
||||
/// 扫描到的一条密钥记录
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KeyEntry {
|
||||
/// 相对路径,如 "message/message_0.db"
|
||||
pub db_name: String,
|
||||
/// 32字节 AES 密钥(hex)
|
||||
pub enc_key: String,
|
||||
/// 16字节 salt(hex,来自数据库文件头)
|
||||
pub salt: String,
|
||||
}
|
||||
|
||||
/// 从进程内存中扫描所有 SQLCipher 密钥
|
||||
///
|
||||
/// 需要以 root/Administrator 权限运行
|
||||
pub fn scan_keys(db_dir: &Path) -> Result<Vec<KeyEntry>> {
|
||||
#[cfg(target_os = "macos")]
|
||||
return macos::scan_keys(db_dir);
|
||||
#[cfg(target_os = "linux")]
|
||||
return linux::scan_keys(db_dir);
|
||||
#[cfg(target_os = "windows")]
|
||||
return windows::scan_keys(db_dir);
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
{
|
||||
anyhow::bail!("当前平台不支持自动密钥扫描")
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取 DB 文件前 16 字节作为 salt(hex),如果是明文 SQLite 则返回 None
|
||||
pub fn read_db_salt(path: &Path) -> Option<String> {
|
||||
let mut buf = [0u8; 16];
|
||||
let mut f = std::fs::File::open(path).ok()?;
|
||||
use std::io::Read;
|
||||
f.read_exact(&mut buf).ok()?;
|
||||
// 明文 SQLite:头部是 "SQLite format 3"
|
||||
if &buf[..15] == b"SQLite format 3" {
|
||||
return None;
|
||||
}
|
||||
Some(hex::encode(&buf))
|
||||
}
|
||||
|
||||
/// 遍历 db_dir,收集所有 .db 文件的 salt -> 相对路径 映射
|
||||
pub fn collect_db_salts(db_dir: &Path) -> Vec<(String, String)> {
|
||||
let mut result = Vec::new();
|
||||
collect_recursive(db_dir, db_dir, &mut result);
|
||||
result
|
||||
}
|
||||
|
||||
fn collect_recursive(base: &Path, dir: &Path, out: &mut Vec<(String, String)>) {
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return,
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect_recursive(base, &path, out);
|
||||
} else if path.extension().map(|e| e == "db").unwrap_or(false) {
|
||||
if let Some(salt) = read_db_salt(&path) {
|
||||
if let Ok(rel) = path.strip_prefix(base) {
|
||||
let rel_str = rel.to_string_lossy().replace('\\', "/");
|
||||
out.push((salt, rel_str));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hex encoding helper (avoid adding hex crate by implementing inline)
|
||||
mod hex {
|
||||
pub fn encode(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
/// Windows WeChat 进程内存密钥扫描器
|
||||
///
|
||||
/// 使用 Windows API:
|
||||
/// - CreateToolhelp32Snapshot + Process32Next: 枚举进程找 Weixin.exe
|
||||
/// - OpenProcess: 获取进程句柄(需要 PROCESS_VM_READ | PROCESS_QUERY_INFORMATION)
|
||||
/// - VirtualQueryEx: 枚举内存区域
|
||||
/// - ReadProcessMemory: 读取内存内容
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::path::Path;
|
||||
use windows::Win32::Foundation::{CloseHandle, HANDLE};
|
||||
use windows::Win32::System::Diagnostics::ToolHelp::{
|
||||
CreateToolhelp32Snapshot, Process32First, Process32Next, PROCESSENTRY32, TH32CS_SNAPPROCESS,
|
||||
};
|
||||
use windows::Win32::System::Memory::{
|
||||
VirtualQueryEx, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_READWRITE,
|
||||
};
|
||||
use windows::Win32::System::Threading::{
|
||||
OpenProcess, PROCESS_QUERY_INFORMATION, PROCESS_VM_READ,
|
||||
};
|
||||
use windows::Win32::System::Diagnostics::Debug::ReadProcessMemory;
|
||||
|
||||
use super::{collect_db_salts, KeyEntry};
|
||||
|
||||
const HEX_PATTERN_LEN: usize = 96;
|
||||
const CHUNK_SIZE: usize = 2 * 1024 * 1024;
|
||||
|
||||
/// 查找 Weixin.exe 进程 PID
|
||||
fn find_wechat_pid() -> Option<u32> {
|
||||
// SAFETY: CreateToolhelp32Snapshot 标准 Windows API
|
||||
let snap = unsafe {
|
||||
CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0).ok()?
|
||||
};
|
||||
|
||||
let mut entry = PROCESSENTRY32 {
|
||||
dwSize: std::mem::size_of::<PROCESSENTRY32>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// SAFETY: Process32First/Process32Next 标准快照遍历
|
||||
unsafe {
|
||||
if Process32First(snap, &mut entry).is_err() {
|
||||
let _ = CloseHandle(snap);
|
||||
return None;
|
||||
}
|
||||
loop {
|
||||
let name = std::ffi::CStr::from_ptr(entry.szExeFile.as_ptr() as *const i8)
|
||||
.to_string_lossy();
|
||||
if name.eq_ignore_ascii_case("Weixin.exe") {
|
||||
let pid = entry.th32ProcessID;
|
||||
let _ = CloseHandle(snap);
|
||||
return Some(pid);
|
||||
}
|
||||
if Process32Next(snap, &mut entry).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = CloseHandle(snap);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn scan_keys(db_dir: &Path) -> Result<Vec<KeyEntry>> {
|
||||
let pid = find_wechat_pid()
|
||||
.context("找不到 Weixin.exe 进程,请确认微信正在运行")?;
|
||||
eprintln!("WeChat PID: {}", pid);
|
||||
|
||||
// SAFETY: OpenProcess 请求读取权限
|
||||
let process = unsafe {
|
||||
OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, false, pid)
|
||||
.context("OpenProcess 失败,请以管理员权限运行")?
|
||||
};
|
||||
|
||||
let db_salts = collect_db_salts(db_dir);
|
||||
eprintln!("找到 {} 个加密数据库", db_salts.len());
|
||||
|
||||
eprintln!("扫描进程内存...");
|
||||
let raw_keys = scan_memory(process)?;
|
||||
eprintln!("找到 {} 个候选密钥", raw_keys.len());
|
||||
|
||||
// SAFETY: 关闭进程句柄
|
||||
unsafe { let _ = CloseHandle(process); }
|
||||
|
||||
let mut entries = Vec::new();
|
||||
for (key_hex, salt_hex) in &raw_keys {
|
||||
for (db_salt, db_name) in &db_salts {
|
||||
if salt_hex == db_salt {
|
||||
entries.push(KeyEntry {
|
||||
db_name: db_name.clone(),
|
||||
enc_key: key_hex.clone(),
|
||||
salt: salt_hex.clone(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!("匹配到 {}/{} 个密钥", entries.len(), raw_keys.len());
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn scan_memory(process: HANDLE) -> Result<Vec<(String, String)>> {
|
||||
let mut results: Vec<(String, String)> = Vec::new();
|
||||
let mut addr: usize = 0;
|
||||
|
||||
loop {
|
||||
let mut mbi = MEMORY_BASIC_INFORMATION::default();
|
||||
// SAFETY: VirtualQueryEx 枚举进程内存区域
|
||||
let ret = unsafe {
|
||||
VirtualQueryEx(
|
||||
process,
|
||||
Some(addr as *const _),
|
||||
&mut mbi,
|
||||
std::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
|
||||
)
|
||||
};
|
||||
if ret == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
let region_size = mbi.RegionSize;
|
||||
let base = mbi.BaseAddress as usize;
|
||||
|
||||
// 只扫描已提交的可读写页面
|
||||
if mbi.State == MEM_COMMIT && mbi.Protect == PAGE_READWRITE {
|
||||
scan_region(process, base, region_size, &mut results);
|
||||
}
|
||||
|
||||
addr = base.saturating_add(region_size);
|
||||
if addr == 0 {
|
||||
break; // overflow
|
||||
}
|
||||
}
|
||||
|
||||
results.dedup_by(|a, b| a.0 == b.0 && a.1 == b.1);
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
fn scan_region(
|
||||
process: HANDLE,
|
||||
base: usize,
|
||||
size: usize,
|
||||
results: &mut Vec<(String, String)>,
|
||||
) {
|
||||
let overlap = HEX_PATTERN_LEN + 3;
|
||||
let mut offset = 0usize;
|
||||
|
||||
loop {
|
||||
if offset >= size {
|
||||
break;
|
||||
}
|
||||
let chunk_size = std::cmp::min(CHUNK_SIZE, size - offset);
|
||||
let addr = base + offset;
|
||||
let mut buf = vec![0u8; chunk_size];
|
||||
let mut bytes_read: usize = 0;
|
||||
|
||||
// SAFETY: ReadProcessMemory 读取目标进程内存
|
||||
let ok = unsafe {
|
||||
ReadProcessMemory(
|
||||
process,
|
||||
addr as *const _,
|
||||
buf.as_mut_ptr() as *mut _,
|
||||
chunk_size,
|
||||
Some(&mut bytes_read),
|
||||
).is_ok()
|
||||
};
|
||||
|
||||
if ok && bytes_read > 0 {
|
||||
buf.truncate(bytes_read);
|
||||
search_pattern(&buf, results);
|
||||
}
|
||||
|
||||
if chunk_size > overlap {
|
||||
offset += chunk_size - overlap;
|
||||
} else {
|
||||
offset += chunk_size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn is_hex_char(c: u8) -> bool {
|
||||
c.is_ascii_hexdigit()
|
||||
}
|
||||
|
||||
fn search_pattern(buf: &[u8], results: &mut Vec<(String, String)>) {
|
||||
let total = HEX_PATTERN_LEN + 3;
|
||||
if buf.len() < total {
|
||||
return;
|
||||
}
|
||||
let mut i = 0;
|
||||
while i + total <= buf.len() {
|
||||
if buf[i] != b'x' || buf[i + 1] != b'\'' {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let hex_start = i + 2;
|
||||
let all_hex = buf[hex_start..hex_start + HEX_PATTERN_LEN]
|
||||
.iter()
|
||||
.all(|&c| is_hex_char(c));
|
||||
if !all_hex {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if buf[hex_start + HEX_PATTERN_LEN] != b'\'' {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let key_hex = String::from_utf8_lossy(&buf[hex_start..hex_start + 64])
|
||||
.to_lowercase();
|
||||
let salt_hex = String::from_utf8_lossy(&buf[hex_start + 64..hex_start + 96])
|
||||
.to_lowercase();
|
||||
let is_dup = results.iter().any(|(k, s)| k == &key_hex && s == &salt_hex);
|
||||
if !is_dup {
|
||||
results.push((key_hex, salt_hex));
|
||||
}
|
||||
i += total;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue