mirror of https://github.com/jackwener/wx-cli.git
Merge branch 'jackwener:main' into main
commit
1d8c184c3c
33
README.md
33
README.md
|
|
@ -8,7 +8,7 @@
|
|||
[](#安装)
|
||||
[](https://www.rust-lang.org)
|
||||
|
||||
会话 · 聊天记录 · 搜索 · 联系人 · 群成员 · 收藏 · 统计 · 导出
|
||||
会话 · 聊天记录 · 搜索 · 联系人 · 群成员 · 群昵称 · 收藏 · 统计 · 导出
|
||||
|
||||
</div>
|
||||
|
||||
|
|
@ -100,10 +100,16 @@ cargo build --release
|
|||
# 1. 签名(只需做一次,WeChat 更新后重做)
|
||||
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
|
||||
|
||||
# 3. 初始化
|
||||
# 4. 初始化
|
||||
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 --force --deep --sign - /Applications/WeChat.app
|
||||
> ```
|
||||
>
|
||||
> 重签名后 macOS 的 TCC 隐私授权按新 code signature 重新校验,旧记录会失效。如果跳过 `tccutil reset`,微信截图/视频通话/麦克风等权限可能"看起来已开启但实际拒绝"。详见 [macOS 权限与签名指南](docs/macos-permission-guide.md#五重签名后微信权限-silent-失效)。
|
||||
|
||||
**Linux**
|
||||
|
||||
|
|
@ -158,6 +166,17 @@ wx search "会议" --in "工作群" --since 2026-01-01
|
|||
|
||||
会话/消息输出里都带 `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)
|
||||
|
||||
三个独立命令,区分"通知"和"帖子":
|
||||
|
|
@ -187,6 +206,14 @@ wx contacts --query "李" # 按名字搜索
|
|||
wx members "AI交流群" # 群成员列表
|
||||
```
|
||||
|
||||
`wx members --json` 返回的成员字段包括:
|
||||
|
||||
- `username`:微信内部 username
|
||||
- `display`:用于展示的名称,优先使用群昵称
|
||||
- `contact_display`:联系人备注或微信昵称
|
||||
- `group_nickname`:群昵称;本地没有记录时为空字符串
|
||||
- `is_owner`:是否群主
|
||||
|
||||
### 收藏 & 统计
|
||||
|
||||
```bash
|
||||
|
|
|
|||
45
SKILL.md
45
SKILL.md
|
|
@ -11,6 +11,7 @@ description: "wx-cli — 从本地微信数据库查询聊天记录、联系人
|
|||
- 微信消息历史
|
||||
- 微信联系人
|
||||
- 微信群成员
|
||||
- 微信群昵称 / 群名片
|
||||
- 微信收藏
|
||||
- wechat history / messages / contacts
|
||||
- wx-cli
|
||||
|
|
@ -65,14 +66,33 @@ codesign --remove-signature "/Applications/WeChat.app/Contents/Frameworks/vlc_pl
|
|||
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
|
||||
killall WeChat && open /Applications/WeChat.app
|
||||
# 等待微信完全登录后再继续
|
||||
```
|
||||
|
||||
**第三步:初始化**
|
||||
之后微信触发权限请求时按 GUI 提示重新允许即可。在 macOS 26 上,把 WeChat 加进 **隐私与安全 → 录屏与系统录音** 的上半区,**不要**只勾下半区的"仅系统录音"——后者不能授予截图权限。
|
||||
|
||||
**第四步:初始化**
|
||||
|
||||
```bash
|
||||
sudo wx init
|
||||
|
|
@ -137,6 +157,17 @@ wx search "会议" --in "工作群" --since 2026-01-01
|
|||
|
||||
`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
|
||||
|
|
@ -148,6 +179,16 @@ wx contacts --query "李"
|
|||
wx members "AI交流群"
|
||||
```
|
||||
|
||||
`wx members --json` 每个成员包含:
|
||||
|
||||
- `username`:微信内部 username
|
||||
- `display`:推荐展示名,优先使用群昵称
|
||||
- `contact_display`:联系人备注或微信昵称
|
||||
- `group_nickname`:群昵称;没有记录时为空字符串
|
||||
- `is_owner`:是否群主
|
||||
|
||||
Agent 展示群成员时优先用 `display`。需要区分群昵称和联系人名时,再读取 `group_nickname` 与 `contact_display`。
|
||||
|
||||
### 朋友圈(SNS)
|
||||
|
||||
三个命令,作用各不同:
|
||||
|
|
|
|||
|
|
@ -196,3 +196,79 @@ open /Applications/WeChat.app
|
|||
| "SIP 阻止了调试微信" | ❌ SIP 只保护系统进程,微信不受 SIP 保护 |
|
||||
| "加了 sshd 到 FDA 就行" | ❌ 还需要加 `sshd-keygen-wrapper`,且要重连 SSH |
|
||||
| "微信开着也能重签名" | ❌ 运行中的 binary/dylib 被占用,codesign 会失败 |
|
||||
|
||||
---
|
||||
|
||||
## 五、重签名后微信权限 silent 失效
|
||||
|
||||
### 现象
|
||||
|
||||
完成 ad-hoc 重签名后,微信任意以下功能都可能"看起来已授权但实际被拒绝":
|
||||
|
||||
- 截图 / 屏幕共享(`ScreenCapture`)
|
||||
- 视频通话 / 扫码(`Camera`)
|
||||
- 语音消息 / 通话(`Microphone`)
|
||||
- 自动化、第三方输入法(`AppleEvents`)
|
||||
- 同步通讯录(`AddressBook`)
|
||||
- 文件发送 / 接收(`SystemPolicyDocumentsFolder` / `Downloads` / `Desktop`)
|
||||
|
||||
System Settings 里通常仍看到"微信.app"开关是 ON,但运行时权限校验失败。微信会反复弹"需要开启 X 权限"。
|
||||
|
||||
### 根因(第一性原理)
|
||||
|
||||
macOS TCC(Transparency, 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 弹窗的"允许"重新授权一次,之后正常工作。
|
||||
|
|
|
|||
|
|
@ -118,6 +118,10 @@ pub fn cmd_init(force: bool) -> Result<()> {
|
|||
std::fs::write(&config_path, serde_json::to_string_pretty(&cfg)?)
|
||||
.context("写入 config.json 失败")?;
|
||||
println!("配置已保存: {}", config_path.display());
|
||||
|
||||
// init 之后必须停掉旧 daemon(它用的是旧 config),下次调用会自动重启
|
||||
let _ = crate::cli::transport::stop_daemon();
|
||||
|
||||
println!("初始化完成,可以使用 wx sessions / wx history 等命令了");
|
||||
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -124,6 +124,31 @@ pub fn ensure_daemon() -> Result<()> {
|
|||
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/` 可写,给出比"超时"更明确的错误。
|
||||
///
|
||||
/// 典型坑:旧版本 `sudo wx init` 把目录留成 root 属主,非 root 的 daemon
|
||||
|
|
|
|||
109
src/config.rs
109
src/config.rs
|
|
@ -71,7 +71,8 @@ fn find_config_file() -> Result<PathBuf> {
|
|||
return Ok(cwd);
|
||||
}
|
||||
// 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");
|
||||
if p.exists() {
|
||||
return Ok(p);
|
||||
|
|
@ -87,9 +88,44 @@ fn find_config_file() -> Result<PathBuf> {
|
|||
}
|
||||
|
||||
pub fn cli_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
||||
.join(".wx-cli")
|
||||
cli_home_dir().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 {
|
||||
|
|
@ -154,17 +190,7 @@ pub fn auto_detect_db_dir() -> Option<PathBuf> {
|
|||
|
||||
#[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 home = sudo_user_home_dir().or_else(dirs::home_dir)?;
|
||||
|
||||
let base = home.join("Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files");
|
||||
if !base.exists() {
|
||||
|
|
@ -190,9 +216,7 @@ fn detect_db_dir_impl() -> Option<PathBuf> {
|
|||
#[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 sudo_home = sudo_user_home_dir();
|
||||
|
||||
let mut candidates: Vec<PathBuf> = Vec::new();
|
||||
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| {
|
||||
std::fs::metadata(p)
|
||||
.and_then(|m| m.modified())
|
||||
.unwrap_or(std::time::SystemTime::UNIX_EPOCH)
|
||||
// 排序:取 db_storage 目录下所有 .db 文件的最新 mtime,而非目录自身的 mtime
|
||||
// 这样当收到新消息时(只有 .db 文件被更新),能正确识别最新目录
|
||||
latest_db_mtime(p).unwrap_or(std::time::SystemTime::UNIX_EPOCH)
|
||||
});
|
||||
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")]
|
||||
fn detect_db_dir_impl() -> Option<PathBuf> {
|
||||
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> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1239
src/daemon/query.rs
1239
src/daemon/query.rs
File diff suppressed because it is too large
Load Diff
|
|
@ -231,5 +231,8 @@ async fn dispatch(
|
|||
Err(e) => Response::err(e.to_string()),
|
||||
}
|
||||
}
|
||||
ReloadConfig => {
|
||||
Response::ok(serde_json::json!({ "reloading": true }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,6 +114,8 @@ pub enum Request {
|
|||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
user: Option<String>,
|
||||
},
|
||||
/// 重新加载配置和密钥(init --force 后 daemon 不会自动重读)
|
||||
ReloadConfig,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
/// 通过 /proc/<pid>/maps 枚举内存区域,
|
||||
/// 通过 /proc/<pid>/mem 读取内存内容,
|
||||
/// 搜索 x'<64hex><32hex>' 格式的 SQLCipher 密钥
|
||||
use anyhow::{bail, Context, Result};
|
||||
use anyhow::{Context, Result};
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::path::Path;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue