Merge branch 'jackwener:main' into main

pull/26/head
shadow 2026-05-14 15:53:06 +08:00 committed by GitHub
commit 1d8c184c3c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 1445 additions and 93 deletions

View File

@ -8,7 +8,7 @@
[![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-lightgrey.svg)](#安装) [![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-lightgrey.svg)](#安装)
[![Rust](https://img.shields.io/badge/built%20with-Rust-orange.svg)](https://www.rust-lang.org) [![Rust](https://img.shields.io/badge/built%20with-Rust-orange.svg)](https://www.rust-lang.org)
会话 · 聊天记录 · 搜索 · 联系人 · 群成员 · 收藏 · 统计 · 导出 会话 · 聊天记录 · 搜索 · 联系人 · 群成员 · 群昵称 · 收藏 · 统计 · 导出
</div> </div>
@ -100,10 +100,16 @@ cargo build --release
# 1. 签名只需做一次WeChat 更新后重做) # 1. 签名只需做一次WeChat 更新后重做)
codesign --force --deep --sign - /Applications/WeChat.app codesign --force --deep --sign - /Applications/WeChat.app
# 2. 重启微信,等待完全登录 # 2. 清理旧 TCC 授权记录(重签名后必做,否则微信截图/通话权限可能 silent 失效)
for s in ScreenCapture Camera Microphone AppleEvents AddressBook \
SystemPolicyDocumentsFolder SystemPolicyDownloadsFolder SystemPolicyDesktopFolder; do
tccutil reset "$s" com.tencent.xinWeChat
done
# 3. 重启微信,等待完全登录
killall WeChat && open /Applications/WeChat.app killall WeChat && open /Applications/WeChat.app
# 3. 初始化 # 4. 初始化
sudo wx init sudo wx init
``` ```
@ -112,6 +118,8 @@ sudo wx init
> codesign --remove-signature "/Applications/WeChat.app/Contents/Frameworks/vlc_plugins/librtp_mpeg4_plugin.dylib" > codesign --remove-signature "/Applications/WeChat.app/Contents/Frameworks/vlc_plugins/librtp_mpeg4_plugin.dylib"
> codesign --force --deep --sign - /Applications/WeChat.app > codesign --force --deep --sign - /Applications/WeChat.app
> ``` > ```
>
> 重签名后 macOS 的 TCC 隐私授权按新 code signature 重新校验,旧记录会失效。如果跳过 `tccutil reset`,微信截图/视频通话/麦克风等权限可能"看起来已开启但实际拒绝"。详见 [macOS 权限与签名指南](docs/macos-permission-guide.md#五重签名后微信权限-silent-失效)。
**Linux** **Linux**
@ -158,6 +166,17 @@ wx search "会议" --in "工作群" --since 2026-01-01
会话/消息输出里都带 `chat_type` 字段,取值为 `private` / `group` / `official_account` / `folded`。`official_account` 涵盖公众号、订阅号、服务号及 `mphelper` / `qqsafe` 等系统通知;`folded` 对应微信里的"订阅号折叠"和"折叠群聊"两个聚合入口。 会话/消息输出里都带 `chat_type` 字段,取值为 `private` / `group` / `official_account` / `folded`。`official_account` 涵盖公众号、订阅号、服务号及 `mphelper` / `qqsafe` 等系统通知;`folded` 对应微信里的"订阅号折叠"和"折叠群聊"两个聚合入口。
群聊里的 `last_sender`、`sender` 和 `stats``top_senders` 会优先使用群昵称(群名片)。如果本地数据库里没有对应群昵称,则回退到联系人备注、微信昵称或 username。
引用消息会在 `history` / `search` / `new-messages` 输出中显示当前回复和被引用原文:
```text
[引用] 当前回复
↳ 发送者: 被引用内容
```
`--type link` / `--type file` 会包含微信 appmsg 里的链接、文件、合并聊天记录和引用消息等变体;搜索时也会匹配解压后可见的引用原文。
### 朋友圈SNS ### 朋友圈SNS
三个独立命令,区分"通知"和"帖子" 三个独立命令,区分"通知"和"帖子"
@ -187,6 +206,14 @@ wx contacts --query "李" # 按名字搜索
wx members "AI交流群" # 群成员列表 wx members "AI交流群" # 群成员列表
``` ```
`wx members --json` 返回的成员字段包括:
- `username`:微信内部 username
- `display`:用于展示的名称,优先使用群昵称
- `contact_display`:联系人备注或微信昵称
- `group_nickname`:群昵称;本地没有记录时为空字符串
- `is_owner`:是否群主
### 收藏 & 统计 ### 收藏 & 统计
```bash ```bash

View File

@ -11,6 +11,7 @@ description: "wx-cli — 从本地微信数据库查询聊天记录、联系人
- 微信消息历史 - 微信消息历史
- 微信联系人 - 微信联系人
- 微信群成员 - 微信群成员
- 微信群昵称 / 群名片
- 微信收藏 - 微信收藏
- wechat history / messages / contacts - wechat history / messages / contacts
- wx-cli - wx-cli
@ -65,14 +66,33 @@ codesign --remove-signature "/Applications/WeChat.app/Contents/Frameworks/vlc_pl
codesign --force --deep --sign - /Applications/WeChat.app codesign --force --deep --sign - /Applications/WeChat.app
``` ```
**第二步:重启 WeChat** **第二步:清理 WeChat 在 macOS TCC 隐私数据库里的旧授权记录**(重签名后必做)
macOS TCC 按 `bundle id + csreq` 联合校验权限csreq 编码自代码签名。重签名后旧 csreq 和新签名不再匹配,旧授权记录会 silent 失效System Settings 仍把开关画成"已允许",运行时实际拒绝)。把 WeChat 在 TCC 里的旧记录抹掉,让 macOS 在下次微信请求权限时按新签名重新生成 csreq
```bash
tccutil reset ScreenCapture com.tencent.xinWeChat # 截图 / 屏幕共享
tccutil reset Camera com.tencent.xinWeChat # 视频通话 / 扫码
tccutil reset Microphone com.tencent.xinWeChat # 语音消息 / 通话
tccutil reset AppleEvents com.tencent.xinWeChat # 自动化 / 输入法
tccutil reset AddressBook com.tencent.xinWeChat # 通讯录
tccutil reset SystemPolicyDocumentsFolder com.tencent.xinWeChat
tccutil reset SystemPolicyDownloadsFolder com.tencent.xinWeChat
tccutil reset SystemPolicyDesktopFolder com.tencent.xinWeChat
```
`tccutil` 对没有授权过的 service 会报 "No such bundle identifier",是 no-op不影响其他 service 的 reset。
**第三步:重启 WeChat**
```bash ```bash
killall WeChat && open /Applications/WeChat.app killall WeChat && open /Applications/WeChat.app
# 等待微信完全登录后再继续 # 等待微信完全登录后再继续
``` ```
**第三步:初始化** 之后微信触发权限请求时按 GUI 提示重新允许即可。在 macOS 26 上,把 WeChat 加进 **隐私与安全 → 录屏与系统录音** 的上半区,**不要**只勾下半区的"仅系统录音"——后者不能授予截图权限。
**第四步:初始化**
```bash ```bash
sudo wx init sudo wx init
@ -137,6 +157,17 @@ wx search "会议" --in "工作群" --since 2026-01-01
`wx unread --filter` 支持 `private` / `group` / `official` / `folded` / `all`,逗号分隔多选。默认 `all` `wx unread --filter` 支持 `private` / `group` / `official` / `folded` / `all`,逗号分隔多选。默认 `all`
群聊消息里的 `last_sender`、`sender` 和 `stats.top_senders` 会优先显示群昵称(群名片)。如果本地数据库没有群昵称,再回退到联系人备注、微信昵称或 username。
引用消息appmsg `type=57`)在 `history` / `search` / `new-messages` 输出里会展开为两行:第一行是当前回复,第二行以 `↳` 开头显示被引用原文,例如:
```text
[引用] 当前回复
↳ 发送者: 被引用内容
```
`--type link` / `--type file` 会覆盖微信 appmsg 的链接、文件、合并聊天记录和引用消息等变体;`search --type link` 也会匹配解压并格式化后的引用原文。
### 联系人与群组 ### 联系人与群组
```bash ```bash
@ -148,6 +179,16 @@ wx contacts --query "李"
wx members "AI交流群" wx members "AI交流群"
``` ```
`wx members --json` 每个成员包含:
- `username`:微信内部 username
- `display`:推荐展示名,优先使用群昵称
- `contact_display`:联系人备注或微信昵称
- `group_nickname`:群昵称;没有记录时为空字符串
- `is_owner`:是否群主
Agent 展示群成员时优先用 `display`。需要区分群昵称和联系人名时,再读取 `group_nickname``contact_display`
### 朋友圈SNS ### 朋友圈SNS
三个命令,作用各不同: 三个命令,作用各不同:

View File

@ -196,3 +196,79 @@ open /Applications/WeChat.app
| "SIP 阻止了调试微信" | ❌ SIP 只保护系统进程,微信不受 SIP 保护 | | "SIP 阻止了调试微信" | ❌ SIP 只保护系统进程,微信不受 SIP 保护 |
| "加了 sshd 到 FDA 就行" | ❌ 还需要加 `sshd-keygen-wrapper`,且要重连 SSH | | "加了 sshd 到 FDA 就行" | ❌ 还需要加 `sshd-keygen-wrapper`,且要重连 SSH |
| "微信开着也能重签名" | ❌ 运行中的 binary/dylib 被占用codesign 会失败 | | "微信开着也能重签名" | ❌ 运行中的 binary/dylib 被占用codesign 会失败 |
---
## 五、重签名后微信权限 silent 失效
### 现象
完成 ad-hoc 重签名后,微信任意以下功能都可能"看起来已授权但实际被拒绝"
- 截图 / 屏幕共享(`ScreenCapture`
- 视频通话 / 扫码(`Camera`
- 语音消息 / 通话(`Microphone`
- 自动化、第三方输入法(`AppleEvents`
- 同步通讯录(`AddressBook`
- 文件发送 / 接收(`SystemPolicyDocumentsFolder` / `Downloads` / `Desktop`
System Settings 里通常仍看到"微信.app"开关是 ON但运行时权限校验失败。微信会反复弹"需要开启 X 权限"。
### 根因(第一性原理)
macOS TCCTransparency, Consent, and Control**bundle id + csreq** 联合校验权限。`csreq`code requirement是从 app 的 code signature 推导出的二进制 blob存在 `/Library/Application Support/com.apple.TCC/TCC.db``access` 表里,每条 ~160 字节。
`codesign --force --deep --sign -` 把 WeChat 从官方签名换成 ad-hoc 签名(甚至 ad-hoc → ad-hoc 重签也会变),新进程的 csreq 跟旧记录里那条对不上 —— tccd 拒绝。
System Settings UI 只按 client 显示开关、不重算 csreq所以视觉上是"已授权",运行时实际拒绝。这是 silent drift。
### 修复步骤
把 WeChat 在 TCC 里的旧记录全部抹掉,让 macOS 在下次微信请求权限时按新签名重新生成 csreq
```bash
for s in ScreenCapture Camera Microphone AppleEvents AddressBook \
SystemPolicyDocumentsFolder SystemPolicyDownloadsFolder SystemPolicyDesktopFolder; do
tccutil reset "$s" com.tencent.xinWeChat
done
```
`tccutil` 对没有授权过的 service 会报 "No such bundle identifier",这是 no-op不影响其他 service 的 reset。
之后退出并重新打开微信,按 GUI 提示重新允许:
```bash
killall WeChat
open /Applications/WeChat.app
```
> 这一步**应当由用户/agent 手动执行**,不在 `wx init` 里自动跑——TCC 重置会让用户的现有授权失效,需要由人决定时机。
#### macOS 26 的 UI 拆分
在 macOS 26 上,**隐私与安全 → 录屏与系统录音** 显示为两块,容易踩坑:
| 区域 | 作用 |
|------|------|
| **录屏与系统录音**(上半区) | 录制屏幕内容 + 系统音频;微信截图、屏幕共享需要这一项 |
| **仅系统录音**(下半区) | 只录系统音频;只打开这一项**不能**修复微信截图 |
把 WeChat 加进上半区;只勾下半区的"仅系统录音"无效。
### 验证
确认 WeChat 当前是 ad-hoc 签名(这是修复前提):
```bash
codesign -dv --verbose=4 /Applications/WeChat.app 2>&1 | grep -E "Signature|flags|TeamIdentifier"
```
期望看到:
```text
flags=0x2(adhoc)
Signature=adhoc
TeamIdentifier=not set
```
最直接的功能验证:在微信里使用截图、视频通话、麦克风等功能,按 GUI 弹窗的"允许"重新授权一次,之后正常工作。

View File

@ -118,6 +118,10 @@ pub fn cmd_init(force: bool) -> Result<()> {
std::fs::write(&config_path, serde_json::to_string_pretty(&cfg)?) std::fs::write(&config_path, serde_json::to_string_pretty(&cfg)?)
.context("写入 config.json 失败")?; .context("写入 config.json 失败")?;
println!("配置已保存: {}", config_path.display()); println!("配置已保存: {}", config_path.display());
// init 之后必须停掉旧 daemon它用的是旧 config下次调用会自动重启
let _ = crate::cli::transport::stop_daemon();
println!("初始化完成,可以使用 wx sessions / wx history 等命令了"); println!("初始化完成,可以使用 wx sessions / wx history 等命令了");
Ok(()) Ok(())

View File

@ -124,6 +124,31 @@ pub fn ensure_daemon() -> Result<()> {
Ok(()) Ok(())
} }
/// 停止 daemon如果正在运行
pub fn stop_daemon() -> Result<()> {
let pid_path = config::pid_path();
if let Ok(pid_str) = std::fs::read_to_string(&pid_path) {
if let Ok(pid) = pid_str.trim().parse::<u32>() {
#[cfg(unix)]
{
let _ = std::process::Command::new("kill")
.arg("-TERM")
.arg(pid.to_string())
.spawn();
}
#[cfg(windows)]
{
let _ = std::process::Command::new("taskkill")
.args(["/F", "/PID", &pid.to_string()])
.spawn();
}
}
}
let _ = std::fs::remove_file(config::sock_path());
let _ = std::fs::remove_file(&pid_path);
Ok(())
}
/// 启动 daemon 前检查 `~/.wx-cli/` 可写,给出比"超时"更明确的错误。 /// 启动 daemon 前检查 `~/.wx-cli/` 可写,给出比"超时"更明确的错误。
/// ///
/// 典型坑:旧版本 `sudo wx init` 把目录留成 root 属主,非 root 的 daemon /// 典型坑:旧版本 `sudo wx init` 把目录留成 root 属主,非 root 的 daemon

View File

@ -71,7 +71,8 @@ fn find_config_file() -> Result<PathBuf> {
return Ok(cwd); return Ok(cwd);
} }
// 3. ~/.wx-cli/config.json // 3. ~/.wx-cli/config.json
if let Some(home) = dirs::home_dir() { let home = cli_home_dir();
if home != PathBuf::from("/tmp") {
let p = home.join(".wx-cli").join("config.json"); let p = home.join(".wx-cli").join("config.json");
if p.exists() { if p.exists() {
return Ok(p); return Ok(p);
@ -87,9 +88,44 @@ fn find_config_file() -> Result<PathBuf> {
} }
pub fn cli_dir() -> PathBuf { pub fn cli_dir() -> PathBuf {
dirs::home_dir() cli_home_dir().join(".wx-cli")
.unwrap_or_else(|| PathBuf::from("/tmp")) }
.join(".wx-cli")
fn cli_home_dir() -> PathBuf {
resolve_cli_home(
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/tmp")),
sudo_user_home_dir(),
)
}
fn resolve_cli_home(default_home: PathBuf, sudo_home: Option<PathBuf>) -> PathBuf {
sudo_home.unwrap_or(default_home)
}
#[cfg(unix)]
fn sudo_user_home_dir() -> Option<PathBuf> {
use std::ffi::{CStr, CString};
let sudo_user = std::env::var("SUDO_USER").ok()?;
let sudo_user = sudo_user.trim();
if sudo_user.is_empty() {
return None;
}
let c_user = CString::new(sudo_user).ok()?;
unsafe {
let pwd = libc::getpwnam(c_user.as_ptr());
if pwd.is_null() || (*pwd).pw_dir.is_null() {
return None;
}
let dir = CStr::from_ptr((*pwd).pw_dir).to_str().ok()?;
Some(PathBuf::from(dir))
}
}
#[cfg(not(unix))]
fn sudo_user_home_dir() -> Option<PathBuf> {
None
} }
pub fn sock_path() -> PathBuf { pub fn sock_path() -> PathBuf {
@ -154,17 +190,7 @@ pub fn auto_detect_db_dir() -> Option<PathBuf> {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
fn detect_db_dir_impl() -> Option<PathBuf> { fn detect_db_dir_impl() -> Option<PathBuf> {
let home = dirs::home_dir()?; let home = sudo_user_home_dir().or_else(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"); let base = home.join("Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files");
if !base.exists() { if !base.exists() {
@ -190,9 +216,7 @@ fn detect_db_dir_impl() -> Option<PathBuf> {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn detect_db_dir_impl() -> Option<PathBuf> { fn detect_db_dir_impl() -> Option<PathBuf> {
let home = dirs::home_dir()?; let home = dirs::home_dir()?;
let sudo_home = std::env::var("SUDO_USER").ok() let sudo_home = sudo_user_home_dir();
.filter(|s| !s.is_empty())
.map(|u| PathBuf::from("/home").join(u));
let mut candidates: Vec<PathBuf> = Vec::new(); let mut candidates: Vec<PathBuf> = Vec::new();
for base_home in [Some(home.clone()), sudo_home].into_iter().flatten() { for base_home in [Some(home.clone()), sudo_home].into_iter().flatten() {
@ -213,13 +237,32 @@ fn detect_db_dir_impl() -> Option<PathBuf> {
} }
} }
candidates.sort_by_key(|p| { candidates.sort_by_key(|p| {
std::fs::metadata(p) // 排序:取 db_storage 目录下所有 .db 文件的最新 mtime而非目录自身的 mtime
.and_then(|m| m.modified()) // 这样当收到新消息时(只有 .db 文件被更新),能正确识别最新目录
.unwrap_or(std::time::SystemTime::UNIX_EPOCH) latest_db_mtime(p).unwrap_or(std::time::SystemTime::UNIX_EPOCH)
}); });
candidates.into_iter().next_back() candidates.into_iter().next_back()
} }
/// 递归查找 db_storage 目录下所有 .db 文件的最新 mtime
fn latest_db_mtime(dir: &Path) -> Option<std::time::SystemTime> {
let mut latest = None;
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
let mtime = if path.is_dir() {
latest_db_mtime(&path).unwrap_or(std::time::SystemTime::UNIX_EPOCH)
} else if path.extension().and_then(|s| s.to_str()) == Some("db") {
entry.metadata().and_then(|m| m.modified()).unwrap_or(std::time::SystemTime::UNIX_EPOCH)
} else {
continue;
};
latest = Some(latest.map_or(mtime, |cur| if mtime > cur { mtime } else { cur }));
}
}
latest
}
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
fn detect_db_dir_impl() -> Option<PathBuf> { fn detect_db_dir_impl() -> Option<PathBuf> {
let appdata = std::env::var("APPDATA").ok()?; let appdata = std::env::var("APPDATA").ok()?;
@ -257,3 +300,27 @@ fn detect_db_dir_impl() -> Option<PathBuf> {
fn detect_db_dir_impl() -> Option<PathBuf> { fn detect_db_dir_impl() -> Option<PathBuf> {
None None
} }
#[cfg(test)]
mod tests {
use super::resolve_cli_home;
use std::path::PathBuf;
#[test]
fn resolve_cli_home_prefers_sudo_home_when_present() {
let home = resolve_cli_home(
PathBuf::from("/root"),
Some(PathBuf::from("/Users/alice")),
);
assert_eq!(home, PathBuf::from("/Users/alice"));
}
#[test]
fn resolve_cli_home_falls_back_to_default_home() {
let home = resolve_cli_home(
PathBuf::from("/root"),
None,
);
assert_eq!(home, PathBuf::from("/root"));
}
}

File diff suppressed because it is too large Load Diff

View File

@ -231,5 +231,8 @@ async fn dispatch(
Err(e) => Response::err(e.to_string()), Err(e) => Response::err(e.to_string()),
} }
} }
ReloadConfig => {
Response::ok(serde_json::json!({ "reloading": true }))
}
} }
} }

View File

@ -114,6 +114,8 @@ pub enum Request {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
user: Option<String>, user: Option<String>,
}, },
/// 重新加载配置和密钥init --force 后 daemon 不会自动重读)
ReloadConfig,
} }

View File

@ -3,7 +3,7 @@
/// 通过 /proc/<pid>/maps 枚举内存区域, /// 通过 /proc/<pid>/maps 枚举内存区域,
/// 通过 /proc/<pid>/mem 读取内存内容, /// 通过 /proc/<pid>/mem 读取内存内容,
/// 搜索 x'<64hex><32hex>' 格式的 SQLCipher 密钥 /// 搜索 x'<64hex><32hex>' 格式的 SQLCipher 密钥
use anyhow::{bail, Context, Result}; use anyhow::{Context, Result};
use std::io::{Read, Seek, SeekFrom}; use std::io::{Read, Seek, SeekFrom};
use std::path::Path; use std::path::Path;