diff --git a/Cargo.lock b/Cargo.lock
index 42ad9b6..9237ba4 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -301,6 +301,12 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2db04e74f0a9a93103b50e90b96024c9b2bdca8bce6a632ec71b88736d3d359"
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
[[package]]
name = "errno"
version = "0.3.14"
@@ -377,13 +383,19 @@ dependencies = [
"ahash",
]
+[[package]]
+name = "hashbrown"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
+
[[package]]
name = "hashlink"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
dependencies = [
- "hashbrown",
+ "hashbrown 0.14.5",
]
[[package]]
@@ -425,6 +437,16 @@ dependencies = [
"cc",
]
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.0",
+]
+
[[package]]
name = "inout"
version = "0.1.4"
@@ -771,6 +793,19 @@ dependencies = [
"serde",
]
+[[package]]
+name = "serde_yaml"
+version = "0.9.34+deprecated"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
+dependencies = [
+ "indexmap",
+ "itoa",
+ "ryu",
+ "serde",
+ "unsafe-libyaml",
+]
+
[[package]]
name = "sha2"
version = "0.10.9"
@@ -897,6 +932,12 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+[[package]]
+name = "unsafe-libyaml"
+version = "0.2.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
+
[[package]]
name = "utf8parse"
version = "0.2.2"
@@ -1277,6 +1318,7 @@ dependencies = [
"rusqlite",
"serde",
"serde_json",
+ "serde_yaml",
"sha2",
"tokio",
"windows",
diff --git a/Cargo.toml b/Cargo.toml
index 4a6ab54..e04011a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -3,7 +3,7 @@ name = "wx-cli"
version = "0.1.1"
edition = "2021"
description = "WeChat 4.x (macOS/Linux) local data CLI — decrypt SQLCipher DBs, query chat history, watch new messages"
-license = "MIT"
+license = "Apache-2.0"
repository = "https://github.com/jackwener/wx-cli"
keywords = ["wechat", "sqlcipher", "decrypt", "cli"]
categories = ["command-line-utilities"]
@@ -23,6 +23,7 @@ tokio = { version = "1", features = ["full"] }
# 序列化
serde = { version = "1", features = ["derive"] }
serde_json = "=1.0.140"
+serde_yaml = "0.9"
# SQLite
rusqlite = { version = "0.31", features = ["bundled"] }
diff --git a/LICENSE b/LICENSE
index d75c68f..d645695 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,21 +1,202 @@
-MIT License
-Copyright (c) 2026 jackwener
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
+ 1. Definitions.
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/README.md b/README.md
index bb88ccd..75143ee 100644
--- a/README.md
+++ b/README.md
@@ -4,8 +4,8 @@
**从命令行查询本地微信数据**
-[](LICENSE)
-[](#安装)
+[](LICENSE)
+[](#安装)
[](https://www.rust-lang.org)
会话 · 聊天记录 · 搜索 · 联系人 · 群成员 · 收藏 · 统计 · 导出
@@ -25,31 +25,40 @@
## 安装
+**macOS / Linux**
+
```bash
curl -fsSL https://raw.githubusercontent.com/jackwener/wx-cli/main/install.sh | bash
```
-> 支持:macOS Apple Silicon / Intel,Linux x86_64 / arm64
+**Windows**(PowerShell,以管理员身份运行)
+
+```powershell
+irm https://raw.githubusercontent.com/jackwener/wx-cli/main/install.ps1 | iex
+```
其他安装方式
**手动下载**
-从 [Releases](https://github.com/jackwener/wx-cli/releases) 下载对应平台文件,重命名为 `wx`,`chmod +x` 后放入 PATH:
+从 [Releases](https://github.com/jackwener/wx-cli/releases) 下载对应平台文件:
| 平台 | 文件 |
|------|------|
| macOS Apple Silicon | `wx-macos-arm64` |
| macOS Intel | `wx-macos-x86_64` |
| Linux x86_64 | `wx-linux-x86_64` |
+| Windows x86_64 | `wx-windows-x86_64.exe` |
+
+macOS / Linux:`chmod +x wx && sudo mv wx /usr/local/bin/`
**从源码构建**
```bash
git clone git@github.com:jackwener/wx-cli.git && cd wx-cli
cargo build --release
-# 产物:target/release/wx
+# 产物:target/release/wx(Windows: wx.exe)
```
@@ -58,18 +67,27 @@ cargo build --release
## 快速开始
-微信 4.x(macOS 版)需要先做 ad-hoc 签名才能扫描内存:
+保持微信运行,然后初始化(只需一次):
+
+**macOS**(需要先对微信做 ad-hoc 签名,才能扫描其内存)
```bash
sudo codesign --force --deep --sign - /Applications/WeChat.app
+sudo wx init
```
-保持微信运行,然后初始化(只需一次):
+**Linux**
```bash
sudo wx init
```
+**Windows**(以管理员身份运行 PowerShell)
+
+```powershell
+wx init
+```
+
之后直接用,daemon 会在首次调用时自动启动:
```bash
diff --git a/find_all_keys_macos.c b/find_all_keys_macos.c
deleted file mode 100644
index eb6a9e5..0000000
--- a/find_all_keys_macos.c
+++ /dev/null
@@ -1,319 +0,0 @@
-/*
- * find_all_keys_macos.c - macOS WeChat memory key scanner
- *
- * Scans WeChat process memory for SQLCipher encryption keys in the
- * x'' format used by WeChat 4.x on macOS.
- *
- * Prerequisites:
- * - WeChat must be ad-hoc signed (or SIP disabled)
- * - Must run as root (sudo)
- *
- * Build:
- * cc -O2 -o find_all_keys_macos find_all_keys_macos.c -framework Foundation
- *
- * Usage:
- * sudo ./find_all_keys_macos [pid]
- * If pid is omitted, automatically finds WeChat PID.
- *
- * Output: JSON file at ./all_keys.json (compatible with decrypt_db.py)
- */
-
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-
-#define MAX_KEYS 256
-#define KEY_SIZE 32
-#define SALT_SIZE 16
-#define HEX_PATTERN_LEN 96 /* 64 hex (key) + 32 hex (salt) */
-#define CHUNK_SIZE (2 * 1024 * 1024)
-
-typedef struct {
- char key_hex[65];
- char salt_hex[33];
- char full_pragma[100];
-} key_entry_t;
-
-/* Forward declaration */
-static int read_db_salt(const char *path, char *salt_hex_out);
-
-/* nftw callback state for collecting DB files */
-#define MAX_DBS 256
-static char g_db_salts[MAX_DBS][33];
-static char g_db_names[MAX_DBS][256];
-static int g_db_count = 0;
-static int nftw_collect_db(const char *fpath, const struct stat *sb,
- int typeflag, struct FTW *ftwbuf) {
- (void)sb; (void)ftwbuf;
- if (typeflag != FTW_F) return 0;
- size_t len = strlen(fpath);
- if (len < 3 || strcmp(fpath + len - 3, ".db") != 0) return 0;
- if (g_db_count >= MAX_DBS) return 0;
-
- char salt[33];
- if (read_db_salt(fpath, salt) != 0) return 0;
-
- strcpy(g_db_salts[g_db_count], salt);
- /* Extract relative path from db_storage/ */
- const char *rel = strstr(fpath, "db_storage/");
- if (rel) rel += strlen("db_storage/");
- else {
- rel = strrchr(fpath, '/');
- rel = rel ? rel + 1 : fpath;
- }
- strncpy(g_db_names[g_db_count], rel, 255);
- g_db_names[g_db_count][255] = '\0';
- printf(" %s: salt=%s\n", g_db_names[g_db_count], salt);
- g_db_count++;
- return 0;
-}
-
-static int is_hex_char(unsigned char c) {
- return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
-}
-
-static pid_t find_wechat_pid(void) {
- FILE *fp = popen("pgrep -x WeChat", "r");
- if (!fp) return -1;
- char buf[64];
- pid_t pid = -1;
- if (fgets(buf, sizeof(buf), fp))
- pid = atoi(buf);
- pclose(fp);
- return pid;
-}
-
-/* Read DB salt (first 16 bytes) and return hex string */
-static int read_db_salt(const char *path, char *salt_hex_out) {
- FILE *f = fopen(path, "rb");
- if (!f) return -1;
- unsigned char header[16];
- if (fread(header, 1, 16, f) != 16) { fclose(f); return -1; }
- fclose(f);
- /* Check if unencrypted */
- if (memcmp(header, "SQLite format 3", 15) == 0) return -1;
- for (int i = 0; i < 16; i++)
- sprintf(salt_hex_out + i * 2, "%02x", header[i]);
- salt_hex_out[32] = '\0';
- return 0;
-}
-
-int main(int argc, char *argv[]) {
- pid_t pid;
- if (argc >= 2)
- pid = atoi(argv[1]);
- else
- pid = find_wechat_pid();
-
- if (pid <= 0) {
- fprintf(stderr, "WeChat not running or invalid PID\n");
- return 1;
- }
-
- printf("============================================================\n");
- printf(" macOS WeChat Memory Key Scanner (C version)\n");
- printf("============================================================\n");
- printf("WeChat PID: %d\n", pid);
-
- /* Get task port */
- mach_port_t task;
- kern_return_t kr = task_for_pid(mach_task_self(), pid, &task);
- if (kr != KERN_SUCCESS) {
- fprintf(stderr, "task_for_pid failed: %d\n", kr);
- fprintf(stderr, "Make sure: (1) running as root, (2) WeChat is ad-hoc signed\n");
- return 1;
- }
- printf("Got task port: %u\n", task);
-
- /* Resolve real user's HOME (sudo may change HOME to /var/root) */
- const char *home = getenv("HOME");
- const char *sudo_user = getenv("SUDO_USER");
- if (sudo_user) {
- struct passwd *pw = getpwnam(sudo_user);
- if (pw && pw->pw_dir)
- home = pw->pw_dir;
- }
- if (!home) home = "/root";
- printf("User home: %s\n", home);
-
- /* Collect DB salts by recursively walking db_storage directories.
- * Note: POSIX glob() does not support ** recursive matching on macOS,
- * so we use nftw() to walk the directory tree instead. */
- printf("\nScanning for DB files...\n");
- char db_base_dir[512];
- snprintf(db_base_dir, sizeof(db_base_dir),
- "%s/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files",
- home);
-
- /* Walk each account's db_storage directory */
- DIR *xdir = opendir(db_base_dir);
- if (xdir) {
- struct dirent *ent;
- while ((ent = readdir(xdir)) != NULL) {
- if (ent->d_name[0] == '.') continue;
- char storage_path[768];
- snprintf(storage_path, sizeof(storage_path),
- "%s/%s/db_storage", db_base_dir, ent->d_name);
- struct stat st;
- if (stat(storage_path, &st) == 0 && S_ISDIR(st.st_mode)) {
- nftw(storage_path, nftw_collect_db, 20, FTW_PHYS);
- }
- }
- closedir(xdir);
- }
- printf("Found %d encrypted DBs\n", g_db_count);
-
- /* Scan memory for x' patterns */
- printf("\nScanning memory for keys...\n");
- key_entry_t keys[MAX_KEYS];
- int key_count = 0;
- size_t total_scanned = 0;
- int region_count = 0;
-
- mach_vm_address_t addr = 0;
- while (1) {
- mach_vm_size_t size = 0;
- vm_region_basic_info_data_64_t info;
- mach_msg_type_number_t info_count = VM_REGION_BASIC_INFO_COUNT_64;
- mach_port_t obj_name;
-
- kr = mach_vm_region(task, &addr, &size, VM_REGION_BASIC_INFO_64,
- (vm_region_info_t)&info, &info_count, &obj_name);
- if (kr != KERN_SUCCESS) break;
- if (size == 0) { addr++; continue; } /* guard against infinite loop */
-
- if ((info.protection & (VM_PROT_READ | VM_PROT_WRITE)) ==
- (VM_PROT_READ | VM_PROT_WRITE)) {
- region_count++;
-
- mach_vm_address_t ca = addr;
- while (ca < addr + size) {
- mach_vm_size_t cs = addr + size - ca;
- if (cs > CHUNK_SIZE) cs = CHUNK_SIZE;
-
- vm_offset_t data;
- mach_msg_type_number_t dc;
- kr = mach_vm_read(task, ca, cs, &data, &dc);
- if (kr == KERN_SUCCESS) {
- unsigned char *buf = (unsigned char *)data;
- total_scanned += dc;
-
- for (size_t i = 0; i + HEX_PATTERN_LEN + 3 < dc; i++) {
- if (buf[i] == 'x' && buf[i + 1] == '\'') {
- /* Check if followed by 96 hex chars and closing ' */
- int valid = 1;
- for (int j = 0; j < HEX_PATTERN_LEN; j++) {
- if (!is_hex_char(buf[i + 2 + j])) { valid = 0; break; }
- }
- if (!valid) continue;
- if (buf[i + 2 + HEX_PATTERN_LEN] != '\'') continue;
-
- /* Extract key and salt hex */
- char key_hex[65], salt_hex[33];
- memcpy(key_hex, buf + i + 2, 64);
- key_hex[64] = '\0';
- memcpy(salt_hex, buf + i + 2 + 64, 32);
- salt_hex[32] = '\0';
-
- /* Convert to lowercase for comparison */
- for (int j = 0; key_hex[j]; j++)
- if (key_hex[j] >= 'A' && key_hex[j] <= 'F')
- key_hex[j] += 32;
- for (int j = 0; salt_hex[j]; j++)
- if (salt_hex[j] >= 'A' && salt_hex[j] <= 'F')
- salt_hex[j] += 32;
-
- /* Deduplicate */
- int dup = 0;
- for (int k = 0; k < key_count; k++) {
- if (strcmp(keys[k].key_hex, key_hex) == 0 &&
- strcmp(keys[k].salt_hex, salt_hex) == 0) {
- dup = 1; break;
- }
- }
- if (dup) continue;
-
- if (key_count < MAX_KEYS) {
- strcpy(keys[key_count].key_hex, key_hex);
- strcpy(keys[key_count].salt_hex, salt_hex);
- snprintf(keys[key_count].full_pragma, sizeof(keys[key_count].full_pragma),
- "x'%s%s'", key_hex, salt_hex);
- key_count++;
- }
- }
- }
- mach_vm_deallocate(mach_task_self(), data, dc);
- }
- /* Advance with overlap to catch patterns spanning chunk boundaries.
- * Pattern is x'<96 hex chars>' = 99 bytes total. */
- if (cs > HEX_PATTERN_LEN + 3)
- ca += cs - (HEX_PATTERN_LEN + 3);
- else
- ca += cs;
- }
- }
- addr += size;
- }
-
- printf("\nScan complete: %zuMB scanned, %d regions, %d unique keys\n",
- total_scanned / 1024 / 1024, region_count, key_count);
-
- /* Match keys to DBs */
- printf("\n%-25s %-66s %s\n", "Database", "Key", "Salt");
- printf("%-25s %-66s %s\n",
- "-------------------------",
- "------------------------------------------------------------------",
- "--------------------------------");
-
- int matched = 0;
- for (int i = 0; i < key_count; i++) {
- const char *db = NULL;
- for (int j = 0; j < g_db_count; j++) {
- if (strcmp(keys[i].salt_hex, g_db_salts[j]) == 0) {
- db = g_db_names[j];
- matched++;
- break;
- }
- }
- printf("%-25s %-66s %s\n",
- db ? db : "(unknown)",
- keys[i].key_hex,
- keys[i].salt_hex);
- }
- printf("\nMatched %d/%d keys to known DBs\n", matched, key_count);
-
- /* Save JSON: { "rel/path.db": { "enc_key": "hex" }, ... }
- * Uses forward slashes (native macOS paths, valid JSON without escaping).
- */
- const char *out_path = "all_keys.json";
- FILE *fp = fopen(out_path, "w");
- if (fp) {
- fprintf(fp, "{\n");
- int first = 1;
- for (int i = 0; i < key_count; i++) {
- const char *db = NULL;
- for (int j = 0; j < g_db_count; j++) {
- if (strcmp(keys[i].salt_hex, g_db_salts[j]) == 0) {
- db = g_db_names[j];
- break;
- }
- }
- if (!db) continue;
- fprintf(fp, "%s \"%s\": {\"enc_key\": \"%s\"}",
- first ? "" : ",\n", db, keys[i].key_hex);
- first = 0;
- }
- fprintf(fp, "\n}\n");
- fclose(fp);
- printf("Saved to %s\n", out_path);
- }
-
- return 0;
-}
diff --git a/install.ps1 b/install.ps1
new file mode 100644
index 0000000..d56cecd
--- /dev/null
+++ b/install.ps1
@@ -0,0 +1,50 @@
+# wx-cli Windows installer
+# Run with: irm https://raw.githubusercontent.com/jackwener/wx-cli/main/install.ps1 | iex
+
+$ErrorActionPreference = "Stop"
+
+$Repo = "jackwener/wx-cli"
+$BinName = "wx.exe"
+$Asset = "wx-windows-x86_64.exe"
+$InstallDir = "$env:LOCALAPPDATA\wx-cli"
+
+# ── 获取最新版本 ────────────────────────────────────────────
+Write-Host "正在获取最新版本..."
+$Release = Invoke-RestMethod "https://api.github.com/repos/$Repo/releases/latest"
+$Tag = $Release.tag_name
+
+if (-not $Tag) {
+ Write-Error "获取版本失败,请检查网络或访问 https://github.com/$Repo/releases"
+ exit 1
+}
+
+Write-Host "版本: $Tag"
+
+# ── 下载 ────────────────────────────────────────────────────
+$Url = "https://github.com/$Repo/releases/download/$Tag/$Asset"
+$TmpFile = Join-Path $env:TEMP "wx-cli-download.exe"
+
+Write-Host "下载中: $Url"
+Invoke-WebRequest -Uri $Url -OutFile $TmpFile -UseBasicParsing
+
+# ── 安装 ────────────────────────────────────────────────────
+if (-not (Test-Path $InstallDir)) {
+ New-Item -ItemType Directory -Path $InstallDir | Out-Null
+}
+
+Move-Item -Force $TmpFile (Join-Path $InstallDir $BinName)
+
+# ── 加入 PATH(当前用户) ────────────────────────────────────
+$UserPath = [Environment]::GetEnvironmentVariable("PATH", "User")
+if ($UserPath -notlike "*$InstallDir*") {
+ [Environment]::SetEnvironmentVariable("PATH", "$UserPath;$InstallDir", "User")
+ Write-Host "已将 $InstallDir 加入用户 PATH(重新打开终端生效)"
+}
+
+Write-Host ""
+Write-Host "✓ wx 已安装到 $InstallDir\$BinName"
+Write-Host ""
+Write-Host "快速开始(以管理员身份运行):"
+Write-Host " wx init # 首次初始化(需要微信正在运行)"
+Write-Host " wx sessions # 查看最近会话"
+Write-Host " wx --help # 查看所有命令"
diff --git a/src/cli/contacts.rs b/src/cli/contacts.rs
index f2b78c2..e52a30b 100644
--- a/src/cli/contacts.rs
+++ b/src/cli/contacts.rs
@@ -1,28 +1,12 @@
use anyhow::Result;
use crate::ipc::Request;
use super::transport;
+use super::output::{resolve, print_value};
pub fn cmd_contacts(query: Option, limit: usize, json: bool) -> Result<()> {
- let req = Request::Contacts { query, limit };
- let resp = transport::send(req)?;
-
+ let resp = transport::send(Request::Contacts { query, limit })?;
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(())
+ .unwrap_or(serde_json::Value::Array(vec![]));
+ print_value(&contacts, &resolve(json))
}
diff --git a/src/cli/export.rs b/src/cli/export.rs
index d5ab7f6..85a6989 100644
--- a/src/cli/export.rs
+++ b/src/cli/export.rs
@@ -20,6 +20,7 @@ pub fn cmd_export(
offset: 0,
since: since_ts,
until: until_ts,
+ msg_type: None,
};
let resp = transport::send(req)?;
diff --git a/src/cli/favorites.rs b/src/cli/favorites.rs
new file mode 100644
index 0000000..84db1d6
--- /dev/null
+++ b/src/cli/favorites.rs
@@ -0,0 +1,29 @@
+use anyhow::Result;
+use crate::ipc::Request;
+use super::transport;
+use super::output::{resolve, print_value};
+
+fn parse_fav_type(s: &str) -> Option {
+ match s {
+ "text" => Some(1),
+ "image" => Some(2),
+ "article" => Some(5),
+ "card" => Some(19),
+ "video" => Some(20),
+ _ => None,
+ }
+}
+
+pub fn cmd_favorites(
+ limit: usize,
+ fav_type: Option,
+ query: Option,
+ json: bool,
+) -> Result<()> {
+ let type_val = fav_type.as_deref().and_then(parse_fav_type);
+ let resp = transport::send(Request::Favorites { limit, fav_type: type_val, query })?;
+ let items = resp.data.get("items")
+ .cloned()
+ .unwrap_or(serde_json::Value::Array(vec![]));
+ print_value(&items, &resolve(json))
+}
diff --git a/src/cli/history.rs b/src/cli/history.rs
index 1ccf058..b5fabb7 100644
--- a/src/cli/history.rs
+++ b/src/cli/history.rs
@@ -1,6 +1,7 @@
use anyhow::Result;
use crate::ipc::Request;
use super::transport;
+use super::output::{resolve, print_value};
pub fn cmd_history(
chat: String,
@@ -8,73 +9,65 @@ pub fn cmd_history(
offset: usize,
since: Option,
until: Option,
+ msg_type: Option,
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 until_ts = until.as_deref().map(parse_time_end).transpose()?;
+ let type_val = msg_type.as_deref().and_then(parse_msg_type);
+ let req = Request::History { chat, limit, offset, since: since_ts, until: until_ts, msg_type: type_val };
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(())
+ let msgs = resp.data.get("messages")
+ .cloned()
+ .unwrap_or(serde_json::Value::Array(vec![]));
+ print_value(&msgs, &resolve(json))
}
pub fn parse_time(s: &str) -> Result {
- for fmt in &["%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"] {
+ use chrono::{Local, TimeZone};
+ for fmt in &["%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"] {
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());
+ return Local.from_local_datetime(&dt).single()
+ .map(|d| d.timestamp())
+ .ok_or_else(|| anyhow::anyhow!("本地时间歧义: {}", s));
}
}
+ if let Ok(d) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
+ let dt = d.and_hms_opt(0, 0, 0).unwrap();
+ return Local.from_local_datetime(&dt).single()
+ .map(|d| d.timestamp())
+ .ok_or_else(|| anyhow::anyhow!("本地时间歧义: {}", s));
+ }
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 {
- // 对于仅日期格式,结束时间为当天 23:59:59
+ use chrono::{Local, TimeZone};
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());
+ return Local.from_local_datetime(&dt).single()
+ .map(|d| d.timestamp())
+ .ok_or_else(|| anyhow::anyhow!("本地时间歧义: {}", s));
}
}
parse_time(s)
}
+
+/// 将消息类型字符串转为 local_type 整数,未知类型返回 None
+pub fn parse_msg_type(s: &str) -> Option {
+ match s {
+ "text" => Some(1),
+ "image" => Some(3),
+ "voice" => Some(34),
+ "video" => Some(43),
+ "sticker" => Some(47),
+ "location" => Some(48),
+ "link" | "file" => Some(49),
+ "call" => Some(50),
+ "system" => Some(10000),
+ _ => None,
+ }
+}
diff --git a/src/cli/members.rs b/src/cli/members.rs
new file mode 100644
index 0000000..2579fd1
--- /dev/null
+++ b/src/cli/members.rs
@@ -0,0 +1,12 @@
+use anyhow::Result;
+use crate::ipc::Request;
+use super::transport;
+use super::output::{resolve, print_value};
+
+pub fn cmd_members(chat: String, json: bool) -> Result<()> {
+ let resp = transport::send(Request::Members { chat })?;
+ let members = resp.data.get("members")
+ .cloned()
+ .unwrap_or(serde_json::Value::Array(vec![]));
+ print_value(&members, &resolve(json))
+}
diff --git a/src/cli/mod.rs b/src/cli/mod.rs
index b5f45d3..4d493de 100644
--- a/src/cli/mod.rs
+++ b/src/cli/mod.rs
@@ -4,9 +4,14 @@ pub mod history;
pub mod search;
pub mod contacts;
pub mod export;
-pub mod watch;
pub mod daemon_cmd;
pub mod transport;
+pub mod output;
+pub mod unread;
+pub mod members;
+pub mod new_messages;
+pub mod stats;
+pub mod favorites;
use anyhow::Result;
use clap::{Parser, Subcommand};
@@ -32,7 +37,7 @@ enum Commands {
/// 会话数量
#[arg(short = 'n', long, default_value = "20")]
limit: usize,
- /// 输出原始 JSON
+ /// 输出 JSON(默认 YAML)
#[arg(long)]
json: bool,
},
@@ -52,7 +57,11 @@ enum Commands {
/// 结束时间 YYYY-MM-DD
#[arg(long)]
until: Option,
- /// 输出原始 JSON
+ /// 消息类型过滤 [text|image|voice|video|sticker|location|link|file|call|system]
+ #[arg(long = "type", value_name = "TYPE",
+ value_parser = ["text","image","voice","video","sticker","location","link","file","call","system"])]
+ msg_type: Option,
+ /// 输出 JSON(默认 YAML)
#[arg(long)]
json: bool,
},
@@ -72,7 +81,11 @@ enum Commands {
/// 结束时间 YYYY-MM-DD
#[arg(long)]
until: Option,
- /// 输出原始 JSON
+ /// 消息类型过滤 [text|image|voice|video|sticker|location|link|file|call|system]
+ #[arg(long = "type", value_name = "TYPE",
+ value_parser = ["text","image","voice","video","sticker","location","link","file","call","system"])]
+ msg_type: Option,
+ /// 输出 JSON(默认 YAML)
#[arg(long)]
json: bool,
},
@@ -84,7 +97,7 @@ enum Commands {
/// 显示数量
#[arg(short = 'n', long, default_value = "50")]
limit: usize,
- /// 输出原始 JSON
+ /// 输出 JSON(默认 YAML)
#[arg(long)]
json: bool,
},
@@ -101,19 +114,66 @@ enum Commands {
/// 最多导出条数
#[arg(short = 'n', long, default_value = "500")]
limit: usize,
- /// 输出格式 [markdown|txt|json]
- #[arg(short = 'f', long, default_value = "markdown", value_parser = ["markdown", "txt", "json"])]
+ /// 输出格式 [markdown|txt|json|yaml]
+ #[arg(short = 'f', long, default_value = "markdown", value_parser = ["markdown", "txt", "json", "yaml"])]
format: String,
/// 输出文件(默认 stdout)
#[arg(short = 'o', long)]
output: Option,
},
- /// 实时监听新消息(Ctrl+C 退出)
- Watch {
- /// 只显示指定聊天的消息
+ /// 显示有未读消息的会话
+ Unread {
+ /// 显示数量
+ #[arg(short = 'n', long, default_value = "20")]
+ limit: usize,
+ /// 输出 JSON(默认 YAML)
#[arg(long)]
- chat: Option,
- /// 输出 JSON lines
+ json: bool,
+ },
+ /// 查看群成员
+ Members {
+ /// 群聊名称(支持模糊匹配)
+ chat: String,
+ /// 输出 JSON(默认 YAML)
+ #[arg(long)]
+ json: bool,
+ },
+ /// 获取自上次检查以来的新消息
+ NewMessages {
+ /// 显示数量上限
+ #[arg(short = 'n', long, default_value = "200")]
+ limit: usize,
+ /// 输出 JSON(默认 YAML)
+ #[arg(long)]
+ json: bool,
+ },
+ /// 聊天统计分析
+ Stats {
+ /// 聊天对象名称(支持模糊匹配)
+ chat: String,
+ /// 起始时间 YYYY-MM-DD
+ #[arg(long)]
+ since: Option,
+ /// 结束时间 YYYY-MM-DD
+ #[arg(long)]
+ until: Option,
+ /// 输出 JSON(默认 YAML)
+ #[arg(long)]
+ json: bool,
+ },
+ /// 查看微信收藏内容
+ Favorites {
+ /// 显示数量
+ #[arg(short = 'n', long, default_value = "50")]
+ limit: usize,
+ /// 类型过滤 [text|image|article|card|video]
+ #[arg(long = "type", value_name = "TYPE",
+ value_parser = ["text","image","article","card","video"])]
+ fav_type: Option,
+ /// 内容关键词搜索
+ #[arg(short = 'q', long)]
+ query: Option,
+ /// 输出 JSON(默认 YAML)
#[arg(long)]
json: bool,
},
@@ -153,17 +213,25 @@ 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::History { chat, limit, offset, since, until, msg_type, json } => {
+ history::cmd_history(chat, limit, offset, since, until, msg_type, json)
}
- Commands::Search { keyword, chats, limit, since, until, json } => {
- search::cmd_search(keyword, chats, limit, since, until, json)
+ Commands::Search { keyword, chats, limit, since, until, msg_type, json } => {
+ search::cmd_search(keyword, chats, limit, since, until, msg_type, 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::Unread { limit, json } => unread::cmd_unread(limit, json),
+ Commands::Members { chat, json } => members::cmd_members(chat, json),
+ Commands::NewMessages { limit, json } => new_messages::cmd_new_messages(limit, json),
+ Commands::Stats { chat, since, until, json } => {
+ stats::cmd_stats(chat, since, until, json)
+ }
+ Commands::Favorites { limit, fav_type, query, json } => {
+ favorites::cmd_favorites(limit, fav_type, query, json)
+ }
Commands::Daemon { cmd } => daemon_cmd::cmd_daemon(cmd),
}
}
diff --git a/src/cli/new_messages.rs b/src/cli/new_messages.rs
new file mode 100644
index 0000000..b847210
--- /dev/null
+++ b/src/cli/new_messages.rs
@@ -0,0 +1,58 @@
+use anyhow::Result;
+use std::collections::HashMap;
+use crate::ipc::Request;
+use super::transport;
+use super::output::{resolve, print_value};
+
+fn state_file() -> std::path::PathBuf {
+ dirs::home_dir()
+ .unwrap_or_else(|| std::path::PathBuf::from("."))
+ .join(".wx-cli")
+ .join("last_check.json")
+}
+
+/// 加载上次的 per-session 时间戳快照
+/// 格式:{ "sessions": { "username": timestamp, ... } }
+/// 旧格式(只有 timestamp 字段)直接丢弃,重新全量获取
+fn load_state() -> Option> {
+ let data = std::fs::read_to_string(state_file()).ok()?;
+ let v: serde_json::Value = serde_json::from_str(&data).ok()?;
+ // 旧格式(只有 timestamp 字段)没有 sessions key → 返回 None 触发首次运行逻辑
+ let map: HashMap = v.get("sessions")?
+ .as_object()?
+ .iter()
+ .filter_map(|(k, v)| v.as_i64().map(|ts| (k.clone(), ts)))
+ .collect();
+ // 空 map 也是合法状态(账号无任何会话),返回 Some(empty) 而非 None
+ // 这样不会误触发全量历史拉取
+ Some(map)
+}
+
+fn save_state(new_state: &HashMap) -> Result<()> {
+ let path = state_file();
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent)?;
+ }
+ std::fs::write(&path, serde_json::to_string(&serde_json::json!({ "sessions": new_state }))?)?;
+ Ok(())
+}
+
+pub fn cmd_new_messages(limit: usize, json: bool) -> Result<()> {
+ let state = load_state();
+ let resp = transport::send(Request::NewMessages { state, limit })?;
+
+ // 保存 daemon 返回的 new_state
+ if let Some(obj) = resp.data.get("new_state").and_then(|v| v.as_object()) {
+ let map: HashMap = obj.iter()
+ .filter_map(|(k, v)| v.as_i64().map(|ts| (k.clone(), ts)))
+ .collect();
+ if !map.is_empty() {
+ let _ = save_state(&map);
+ }
+ }
+
+ let messages = resp.data.get("messages")
+ .cloned()
+ .unwrap_or(serde_json::Value::Array(vec![]));
+ print_value(&messages, &resolve(json))
+}
diff --git a/src/cli/output.rs b/src/cli/output.rs
new file mode 100644
index 0000000..33ef78a
--- /dev/null
+++ b/src/cli/output.rs
@@ -0,0 +1,18 @@
+/// 输出格式
+pub enum Fmt {
+ Yaml,
+ Json,
+}
+
+/// 默认 YAML,--json 时输出 JSON
+pub fn resolve(json: bool) -> Fmt {
+ if json { Fmt::Json } else { Fmt::Yaml }
+}
+
+pub fn print_value(value: &serde_json::Value, fmt: &Fmt) -> anyhow::Result<()> {
+ match fmt {
+ Fmt::Json => println!("{}", serde_json::to_string_pretty(value)?),
+ Fmt::Yaml => print!("{}", serde_yaml::to_string(value)?),
+ }
+ Ok(())
+}
diff --git a/src/cli/search.rs b/src/cli/search.rs
index 6f79d3d..e6f3d00 100644
--- a/src/cli/search.rs
+++ b/src/cli/search.rs
@@ -1,7 +1,8 @@
use anyhow::Result;
use crate::ipc::Request;
use super::transport;
-use super::history::{parse_time, parse_time_end};
+use super::history::{parse_time, parse_time_end, parse_msg_type};
+use super::output::{resolve, print_value};
pub fn cmd_search(
keyword: String,
@@ -9,53 +10,26 @@ pub fn cmd_search(
limit: usize,
since: Option,
until: Option,
+ msg_type: Option,
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 type_val = msg_type.as_deref().and_then(parse_msg_type);
let chats_opt = if chats.is_empty() { None } else { Some(chats) };
let req = Request::Search {
- keyword: keyword.clone(),
+ keyword,
chats: chats_opt,
limit,
since: since_ts,
until: until_ts,
+ msg_type: type_val,
};
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(())
+ .unwrap_or(serde_json::Value::Array(vec![]));
+ print_value(&results, &resolve(json))
}
diff --git a/src/cli/sessions.rs b/src/cli/sessions.rs
index cfedf79..9ccadb8 100644
--- a/src/cli/sessions.rs
+++ b/src/cli/sessions.rs
@@ -1,44 +1,12 @@
use anyhow::Result;
use crate::ipc::Request;
use super::transport;
+use super::output::{resolve, print_value};
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(())
+ .unwrap_or(serde_json::Value::Array(vec![]));
+ print_value(&data, &resolve(json))
}
diff --git a/src/cli/stats.rs b/src/cli/stats.rs
new file mode 100644
index 0000000..2e9a293
--- /dev/null
+++ b/src/cli/stats.rs
@@ -0,0 +1,18 @@
+use anyhow::Result;
+use crate::ipc::Request;
+use super::transport;
+use super::history::{parse_time, parse_time_end};
+use super::output::{resolve, print_value};
+
+pub fn cmd_stats(
+ chat: String,
+ since: Option,
+ until: Option,
+ 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 resp = transport::send(Request::Stats { chat, since: since_ts, until: until_ts })?;
+ print_value(&resp.data, &resolve(json))
+}
diff --git a/src/cli/unread.rs b/src/cli/unread.rs
new file mode 100644
index 0000000..595081a
--- /dev/null
+++ b/src/cli/unread.rs
@@ -0,0 +1,12 @@
+use anyhow::Result;
+use crate::ipc::Request;
+use super::transport;
+use super::output::{resolve, print_value};
+
+pub fn cmd_unread(limit: usize, json: bool) -> Result<()> {
+ let resp = transport::send(Request::Unread { limit })?;
+ let data = resp.data.get("sessions")
+ .cloned()
+ .unwrap_or(serde_json::Value::Array(vec![]));
+ print_value(&data, &resolve(json))
+}
diff --git a/src/cli/watch.rs b/src/cli/watch.rs
deleted file mode 100644
index 73c15d1..0000000
--- a/src/cli/watch.rs
+++ /dev/null
@@ -1,94 +0,0 @@
-use anyhow::Result;
-use std::io::BufRead;
-
-use crate::ipc::Request;
-use super::transport;
-
-pub fn cmd_watch(chat: Option, 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(windows)]
- {
- anyhow::bail!("watch 命令在 Windows 上暂不支持,请使用 Unix 系统");
- }
-
- #[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(())
-}
diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs
index 0d07469..e5407b5 100644
--- a/src/crypto/mod.rs
+++ b/src/crypto/mod.rs
@@ -7,6 +7,8 @@ use cbc::cipher::{BlockDecryptMut, KeyIvInit};
use std::io::{Read, Write};
use std::path::Path;
+type Block = aes::cipher::Block;
+
pub const PAGE_SZ: usize = 4096;
pub const SALT_SZ: usize = 16;
pub const RESERVE_SZ: usize = 80; // IV(16) + HMAC(64)
@@ -62,16 +64,13 @@ fn aes_cbc_decrypt(key: &[u8; 32], iv: &[u8; 16], data: &[u8]) -> Result
if data.is_empty() || data.len() % 16 != 0 {
bail!("密文长度不是 AES 块大小的倍数: {}", data.len());
}
- let mut buf = data.to_vec();
- // 使用 raw 模式不处理 padding
+ // 将 &[u8] 复制为 Block 数组,避免 unsafe from_raw_parts_mut
+ let mut blocks: Vec = data.chunks_exact(16)
+ .map(Block::clone_from_slice)
+ .collect();
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,
- buf.len() / 16,
- )
- });
- Ok(buf)
+ .decrypt_blocks_mut(&mut blocks);
+ Ok(blocks.iter().flat_map(|b| b.iter().copied()).collect())
}
/// 完整解密一个 SQLCipher 数据库文件(流式,逐页读写避免全量载入内存)
diff --git a/src/daemon/cache.rs b/src/daemon/cache.rs
index 229ddfc..9801396 100644
--- a/src/daemon/cache.rs
+++ b/src/daemon/cache.rs
@@ -56,8 +56,7 @@ impl DbCache {
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))
+ self.cache_dir.join(format!("{}.db", hash))
}
/// 从持久化文件加载 mtime 记录,复用未过期的解密文件
diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs
index 1ca44d2..63e1fe5 100644
--- a/src/daemon/mod.rs
+++ b/src/daemon/mod.rs
@@ -4,9 +4,7 @@ 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;
@@ -78,154 +76,12 @@ async fn async_run() -> Result<()> {
let names_arc = Arc::new(std::sync::RwLock::new(names));
- // 启动 WAL watcher
- let (watch_tx, _) = broadcast::channel::(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?;
+ server::serve(Arc::clone(&db), Arc::clone(&names_arc)).await?;
Ok(())
}
-async fn run_watcher(
- db: Arc,
- names: Arc>,
- tx: broadcast::Sender,
- session_wal: PathBuf,
-) {
- use std::collections::HashMap;
- use std::time::Duration;
- use crate::ipc::WatchEvent;
-
- let mut last_mtime = 0u64;
- let mut last_ts: HashMap = 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_nanos(&session_wal) {
- 0 => continue,
- m => m,
- };
- if wal_mtime == last_mtime {
- 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, 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>(1)
- .or_else(|_| row.get::<_, String>(1).map(|s| s.into_bytes()))
- .unwrap_or_default(),
- row.get::<_, i64>(2)?,
- row.get::<_, i64>(3).unwrap_or(0),
- row.get::<_, String>(4).unwrap_or_default(),
- ))
- })?.collect::>>()?;
- 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;
- }
- }
-}
-
-use cache::mtime_nanos;
-
-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 映射
///
/// 兼容两种格式:
diff --git a/src/daemon/query.rs b/src/daemon/query.rs
index 30bcc81..da42bba 100644
--- a/src/daemon/query.rs
+++ b/src/daemon/query.rs
@@ -1,5 +1,5 @@
use anyhow::{Context, Result};
-use chrono::{Local, TimeZone};
+use chrono::{Local, TimeZone, Timelike};
use regex::Regex;
use rusqlite::Connection;
use serde_json::{json, Value};
@@ -140,6 +140,7 @@ pub async fn q_history(
offset: usize,
since: Option,
until: Option,
+ msg_type: Option,
) -> Result {
let username = resolve_username(chat, names)
.with_context(|| format!("找不到联系人: {}", chat))?;
@@ -164,7 +165,9 @@ pub async fn q_history(
let offset2 = offset;
let msgs: Vec = tokio::task::spawn_blocking(move || {
- query_messages(&path, &tname, &uname, is_group2, &names_map, since2, until2, limit2 + offset2, 0)
+ // per-DB 软上限:offset + limit 已足够全局分页,避免大群全量加载
+ let per_db_cap = offset2 + limit2;
+ query_messages(&path, &tname, &uname, is_group2, &names_map, since2, until2, msg_type, per_db_cap, 0)
}).await??;
all_msgs.extend(msgs);
@@ -193,6 +196,7 @@ pub async fn q_search(
limit: usize,
since: Option,
until: Option,
+ msg_type: Option,
) -> Result {
let mut targets: Vec<(String, String, String, String)> = Vec::new(); // (path, table, display, uname)
@@ -277,7 +281,7 @@ pub async fn q_search(
for (tname, display, uname) in &table_list {
let is_group = uname.contains("@chatroom");
match search_in_table(&conn, tname, &uname, is_group,
- &names_map2, &kw2, since2, until2, limit2)
+ &names_map2, &kw2, since2, until2, msg_type, limit2)
{
Ok(rows) => {
for mut row in rows {
@@ -343,19 +347,21 @@ fn resolve_username(chat_name: &str, names: &Names) -> Option {
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());
- }
+ // 精确匹配显示名:排序后取第一个,保证确定性
+ let mut exact: Vec<&String> = names.map.iter()
+ .filter(|(_, display)| display.to_lowercase() == low)
+ .map(|(uname, _)| uname)
+ .collect();
+ exact.sort();
+ if let Some(u) = exact.into_iter().next() {
+ return Some(u.clone());
}
- // 模糊匹配
- for (uname, display) in &names.map {
- if display.to_lowercase().contains(&low) {
- return Some(uname.clone());
- }
- }
- None
+ // 模糊匹配:取 display name 最短的(最精确),相同长度取字典序最小
+ let mut candidates: Vec<(&String, &String)> = names.map.iter()
+ .filter(|(_, display)| display.to_lowercase().contains(&low))
+ .collect();
+ candidates.sort_by_key(|(uname, display)| (display.len(), uname.as_str()));
+ candidates.into_iter().next().map(|(uname, _)| uname.clone())
}
async fn find_msg_tables(
@@ -364,8 +370,7 @@ async fn find_msg_tables(
username: &str,
) -> Result> {
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) {
+ if !msg_table_re().is_match(&table_name) {
return Ok(Vec::new());
}
@@ -413,6 +418,7 @@ fn query_messages(
names_map: &HashMap,
since: Option,
until: Option,
+ msg_type: Option,
limit: usize,
offset: usize,
) -> Result> {
@@ -429,6 +435,10 @@ fn query_messages(
clauses.push("create_time <= ?");
params.push(Box::new(u));
}
+ if let Some(t) = msg_type {
+ clauses.push("local_type = ?");
+ params.push(Box::new(t));
+ }
let where_clause = if clauses.is_empty() {
String::new()
} else {
@@ -487,6 +497,7 @@ fn search_in_table(
keyword: &str,
since: Option,
until: Option,
+ msg_type: Option,
limit: usize,
) -> Result> {
let id2u = load_id2u(conn);
@@ -502,6 +513,10 @@ fn search_in_table(
clauses.push("create_time <= ?".into());
params.push(Box::new(u));
}
+ if let Some(t) = msg_type {
+ clauses.push("local_type = ?".into());
+ params.push(Box::new(t));
+ }
let where_clause = format!("WHERE {}", clauses.join(" AND "));
let sql = format!(
"SELECT local_id, local_type, create_time, real_sender_id,
@@ -761,3 +776,639 @@ fn fmt_time(ts: i64, fmt: &str) -> String {
.unwrap_or_else(|| ts.to_string())
}
+// ─── 新增命令查询函数 ──────────────────────────────────────────────────────────
+
+/// 查询有未读消息的会话
+pub async fn q_unread(db: &DbCache, names: &Names, limit: usize) -> Result {
+ let path = db.get("session/session.db").await?
+ .context("无法解密 session.db")?;
+
+ let limit_val = limit;
+ let rows: Vec<(String, i64, Vec, i64, i64, String, String)> = tokio::task::spawn_blocking(move || {
+ let conn = Connection::open(&path)?;
+ 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 unread_count > 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),
+ get_content_bytes(row, 2),
+ 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::>>()?;
+ 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");
+ 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"),
+ }));
+ }
+ let total = results.len();
+ Ok(json!({ "sessions": results, "total": total }))
+}
+
+/// 查询群成员:优先从 contact.db 的 chatroom_member/chat_room 表获取完整列表,
+/// 若表不存在则退化为从消息记录聚合有发言记录的成员
+pub async fn q_members(db: &DbCache, names: &Names, chat: &str) -> Result {
+ let username = resolve_username(chat, names)
+ .with_context(|| format!("找不到联系人: {}", chat))?;
+
+ if !username.contains("@chatroom") {
+ anyhow::bail!("'{}' 不是群聊,无法查看群成员", names.display(&username));
+ }
+
+ let display = names.display(&username);
+ let names_map = names.map.clone();
+
+ // 优先路径:contact.db → chatroom_member + chat_room(完整成员列表)
+ if let Some(contact_p) = db.get("contact/contact.db").await? {
+ let uname2 = username.clone();
+ let display2 = display.clone();
+ let names_map2 = names_map.clone();
+
+ let members_opt: Option> = tokio::task::spawn_blocking(move || {
+ let conn = Connection::open(&contact_p)?;
+
+ let has_table: bool = conn.query_row(
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name='chatroom_member'",
+ [],
+ |_| Ok(true),
+ ).unwrap_or(false);
+
+ if !has_table {
+ return Ok::<_, anyhow::Error>(None);
+ }
+
+ // 从 chat_room 表获取整数 room_id 和群主
+ // WeChat 不同版本列名可能不同:username / chat_room_name / name
+ let (room_id, owner): (i64, String) = [
+ "SELECT id, owner FROM chat_room WHERE username = ?",
+ "SELECT id, owner FROM chat_room WHERE chat_room_name = ?",
+ "SELECT id, owner FROM chat_room WHERE name = ?",
+ ].iter().find_map(|sql| {
+ conn.query_row(sql, [&uname2], |row| {
+ Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1).unwrap_or_default()))
+ }).ok()
+ }).unwrap_or((0, String::new()));
+
+ if room_id == 0 {
+ return Ok::<_, anyhow::Error>(None);
+ }
+
+ let mut stmt = conn.prepare(
+ "SELECT c.username, c.nick_name, c.remark
+ FROM chatroom_member cm
+ LEFT JOIN contact c ON c.id = cm.member_id
+ WHERE cm.room_id = ?"
+ )?;
+ let raw: Vec<(String, String, String)> = stmt.query_map([room_id], |row| {
+ Ok((
+ row.get::<_, String>(0).unwrap_or_default(),
+ row.get::<_, String>(1).unwrap_or_default(),
+ row.get::<_, String>(2).unwrap_or_default(),
+ ))
+ })?
+ .filter_map(|r| r.ok())
+ .filter(|(uid, _, _)| !uid.is_empty())
+ .collect();
+
+ if raw.is_empty() {
+ return Ok(None);
+ }
+
+ let mut members: Vec = raw.iter().map(|(uid, nick, remark)| {
+ let disp = if !remark.is_empty() { remark.clone() }
+ else if !nick.is_empty() { nick.clone() }
+ else { names_map2.get(uid).cloned().unwrap_or_else(|| uid.clone()) };
+ let is_owner = uid == &owner && !owner.is_empty();
+ json!({ "username": uid, "display": disp, "is_owner": is_owner })
+ }).collect();
+
+ // 群主排首位,其余按 display 字典序
+ members.sort_by(|a, b| {
+ let ao = a["is_owner"].as_bool().unwrap_or(false);
+ let bo = b["is_owner"].as_bool().unwrap_or(false);
+ if ao != bo { return bo.cmp(&ao); }
+ a["display"].as_str().unwrap_or("").cmp(b["display"].as_str().unwrap_or(""))
+ });
+
+ let _ = display2; // 不在此 closure 内使用
+ Ok(Some(members))
+ }).await??;
+
+ if let Some(members) = members_opt {
+ return Ok(json!({
+ "chat": display,
+ "username": username,
+ "count": members.len(),
+ "members": members,
+ }));
+ }
+ }
+
+ // 降级路径:从消息记录中聚合发言过的成员
+ let tables = find_msg_tables(db, names, &username).await?;
+ if tables.is_empty() {
+ return Ok(json!({
+ "chat": display,
+ "username": username,
+ "count": 0,
+ "members": [],
+ }));
+ }
+
+ let mut sender_set: std::collections::HashSet = std::collections::HashSet::new();
+ for (db_path, table_name) in &tables {
+ let path = db_path.clone();
+ let tname = table_name.clone();
+ let uname = username.clone();
+
+ let senders: Vec = tokio::task::spawn_blocking(move || {
+ let conn = Connection::open(&path)?;
+ let id2u = load_id2u(&conn);
+ let mut stmt = conn.prepare(&format!(
+ "SELECT DISTINCT real_sender_id FROM [{}] WHERE real_sender_id > 0", tname
+ ))?;
+ let ids: Vec = stmt.query_map([], |row| row.get(0))?
+ .filter_map(|r| r.ok())
+ .collect();
+ let senders: Vec = ids.iter()
+ .filter_map(|id| id2u.get(id))
+ .filter(|u| *u != &uname)
+ .cloned()
+ .collect();
+ Ok::<_, anyhow::Error>(senders)
+ }).await??;
+
+ sender_set.extend(senders);
+ }
+
+ let mut members: Vec = sender_set.iter().map(|u| {
+ json!({
+ "username": u,
+ "display": names_map.get(u).cloned().unwrap_or_else(|| u.clone()),
+ "is_owner": false,
+ })
+ }).collect();
+ members.sort_by(|a, b| {
+ a["display"].as_str().unwrap_or("").cmp(b["display"].as_str().unwrap_or(""))
+ });
+
+ Ok(json!({
+ "chat": display,
+ "username": username,
+ "count": members.len(),
+ "members": members,
+ }))
+}
+
+/// 查询新消息:以 session.db 的 last_timestamp 作为 inbox 索引,
+/// 只查询 last_timestamp > state[username] 的会话,精确且高效
+pub async fn q_new_messages(
+ db: &DbCache,
+ names: &Names,
+ state: Option>,
+ limit: usize,
+) -> Result {
+ // 首次运行(state=None)或未见过的会话,用 24h 前作为起点,
+ // 避免第一次运行时把全量历史消息涌入
+ let fallback_ts = chrono::Utc::now().timestamp() - 86400;
+
+ // 1. 从 session.db 读取所有会话的当前 last_timestamp
+ let session_path = db.get("session/session.db").await?
+ .context("无法解密 session.db")?;
+
+ let all_sessions: Vec<(String, i64)> = tokio::task::spawn_blocking(move || {
+ let conn = Connection::open(&session_path)?;
+ let mut stmt = conn.prepare(
+ "SELECT username, last_timestamp FROM SessionTable WHERE last_timestamp > 0"
+ )?;
+ let rows = stmt.query_map([], |row| {
+ Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1).unwrap_or(0)))
+ })?
+ .collect::>>()?;
+ Ok::<_, anyhow::Error>(rows)
+ }).await??;
+
+ // 2. 记录 session.db 的当前快照(用于构建 new_state 基础)
+ let session_ts_map: HashMap = all_sessions.iter()
+ .map(|(u, ts)| (u.clone(), *ts))
+ .collect();
+
+ // 3. 找出有新消息的会话
+ // 不在 state 中的会话(首次运行或新会话)以 fallback_ts 为基准
+ let changed: Vec<(String, i64)> = all_sessions.into_iter()
+ .filter(|(uname, ts)| {
+ let last_known = state.as_ref()
+ .and_then(|m| m.get(uname))
+ .copied()
+ .unwrap_or(fallback_ts);
+ *ts > last_known
+ })
+ .collect();
+
+ if changed.is_empty() {
+ return Ok(json!({
+ "count": 0,
+ "messages": [],
+ "new_state": session_ts_map,
+ }));
+ }
+
+ // 4. 只查询有新消息的会话的消息表
+ // per_table_limit 取 limit*5 防止单表截断,最终由全局 truncate 收尾
+ let per_table_limit = limit.saturating_mul(5).max(200);
+ let mut all_msgs: Vec = Vec::new();
+
+ for (uname, _) in &changed {
+ let since_ts = state.as_ref()
+ .and_then(|m| m.get(uname))
+ .copied()
+ .unwrap_or(fallback_ts);
+ let tables = find_msg_tables(db, names, uname).await?;
+ if tables.is_empty() { continue; }
+
+ let display = names.display(uname);
+ let is_group = uname.contains("@chatroom");
+
+ for (db_path, table_name) in &tables {
+ let path = db_path.clone();
+ let tname = table_name.clone();
+ let uname2 = uname.clone();
+ let display2 = display.clone();
+ let names_map = names.map.clone();
+ let tname_for_log = tname.clone();
+
+ let msgs: Vec = match tokio::task::spawn_blocking(move || {
+ let conn = Connection::open(&path)?;
+ let id2u = load_id2u(&conn);
+
+ let sql = format!(
+ "SELECT local_id, local_type, create_time, real_sender_id,
+ message_content, WCDB_CT_message_content
+ FROM [{}] WHERE create_time > ? ORDER BY create_time ASC LIMIT ?",
+ tname
+ );
+ let rows: Vec<_> = conn.prepare(&sql)
+ .and_then(|mut stmt| {
+ stmt.query_map(
+ rusqlite::params![since_ts, per_table_limit as i64],
+ |row| Ok((
+ row.get::<_, i64>(0)?,
+ row.get::<_, i64>(1)?,
+ row.get::<_, i64>(2)?,
+ row.get::<_, i64>(3)?,
+ get_content_bytes(row, 4),
+ row.get::<_, i64>(5).unwrap_or(0),
+ )),
+ ).map(|it| it.filter_map(|r| r.ok()).collect())
+ })
+ .unwrap_or_default();
+
+ 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, &uname2, &id2u, &names_map);
+ let text = fmt_content(local_id, local_type, &content, is_group);
+ result.push(json!({
+ "chat": display2,
+ "username": uname2,
+ "is_group": is_group,
+ "timestamp": ts,
+ "time": fmt_time(ts, "%Y-%m-%d %H:%M"),
+ "sender": sender,
+ "content": text,
+ "type": fmt_type(local_type),
+ }));
+ }
+ Ok::<_, anyhow::Error>(result)
+ }).await {
+ Ok(Ok(v)) => v,
+ Ok(Err(e)) => { eprintln!("[new-messages] skip {}: {}", tname_for_log, e); continue; }
+ Err(e) => { eprintln!("[new-messages] task error: {}", e); continue; }
+ };
+
+ all_msgs.extend(msgs);
+ }
+ }
+
+ all_msgs.sort_by_key(|m| m["timestamp"].as_i64().unwrap_or(0));
+ all_msgs.truncate(limit);
+
+ // 5. 重建 new_state,防止全局 limit 截断导致消息永久丢失:
+ // - 未变化的会话:沿用 session.db 的 last_timestamp
+ // - 变化但全被截断(无消息在最终结果中):保留旧 since_ts,下次重试
+ // - 变化且有消息返回:推进到该会话在结果中的最大 timestamp
+ let mut new_state = session_ts_map;
+ // 先把 changed 会话重置回旧 since_ts
+ for (uname, _) in &changed {
+ let old_ts = state.as_ref()
+ .and_then(|m| m.get(uname))
+ .copied()
+ .unwrap_or(fallback_ts);
+ new_state.insert(uname.clone(), old_ts);
+ }
+ // 再根据实际返回的消息向前推进
+ for m in &all_msgs {
+ if let (Some(uname), Some(ts)) = (m["username"].as_str(), m["timestamp"].as_i64()) {
+ let e = new_state.entry(uname.to_string()).or_insert(0);
+ if ts > *e { *e = ts; }
+ }
+ }
+
+ Ok(json!({
+ "count": all_msgs.len(),
+ "messages": all_msgs,
+ "new_state": new_state,
+ }))
+}
+
+/// 查询收藏内容(favorite/favorite.db 的 fav_db_item 表)
+pub async fn q_favorites(
+ db: &DbCache,
+ limit: usize,
+ fav_type: Option,
+ query: Option,
+) -> Result {
+ let path = db.get("favorite/favorite.db").await?
+ .context("找不到 favorite.db,请确认微信数据目录")?;
+
+ let rows: Vec = tokio::task::spawn_blocking(move || {
+ let conn = Connection::open(&path)?;
+
+ let mut clauses: Vec<&'static str> = Vec::new();
+ let mut params: Vec> = Vec::new();
+
+ if let Some(t) = fav_type {
+ clauses.push("type = ?");
+ params.push(Box::new(t));
+ }
+ let like_str: Option = query.map(|q| {
+ let esc = q.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
+ format!("%{}%", esc)
+ });
+ if let Some(ref s) = like_str {
+ clauses.push("content LIKE ? ESCAPE '\\'");
+ params.push(Box::new(s.clone()));
+ }
+
+ let where_clause = if clauses.is_empty() {
+ String::new()
+ } else {
+ format!("WHERE {}", clauses.join(" AND "))
+ };
+ params.push(Box::new(limit as i64));
+
+ let sql = format!(
+ "SELECT local_id, type, update_time, content, fromusr, realchatname
+ FROM fav_db_item {} ORDER BY update_time DESC LIMIT ?",
+ where_clause
+ );
+
+ let params_ref: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
+ let mut stmt = conn.prepare(&sql)?;
+ let rows: Vec = stmt.query_map(params_ref.as_slice(), |row| {
+ Ok((
+ row.get::<_, i64>(0).unwrap_or(0),
+ row.get::<_, i64>(1).unwrap_or(0),
+ row.get::<_, i64>(2).unwrap_or(0),
+ row.get::<_, String>(3).unwrap_or_default(),
+ row.get::<_, String>(4).unwrap_or_default(),
+ row.get::<_, String>(5).unwrap_or_default(),
+ ))
+ })?
+ .filter_map(|r| r.ok())
+ .map(|(local_id, ftype, ts, content, fromusr, chatname)| {
+ let type_str = match ftype {
+ 1 => "文本",
+ 2 => "图片",
+ 5 => "文章",
+ 19 => "名片",
+ 20 => "视频",
+ _ => "其他",
+ };
+ // 安全截断(按 Unicode 字符而非字节)
+ let preview: String = content.chars().take(100).collect();
+ let preview = if content.chars().count() > 100 {
+ format!("{}...", preview)
+ } else {
+ preview
+ };
+ // WeChat 部分版本的 update_time 为毫秒,10位以上判定为毫秒后转秒
+ let ts_secs = if ts > 9_999_999_999 { ts / 1000 } else { ts };
+ json!({
+ "id": local_id,
+ "type": type_str,
+ "type_num": ftype,
+ "time": fmt_time(ts_secs, "%Y-%m-%d %H:%M"),
+ "timestamp": ts_secs,
+ "preview": preview,
+ "from": fromusr,
+ "chat": chatname,
+ })
+ })
+ .collect();
+
+ Ok::<_, anyhow::Error>(rows)
+ }).await??;
+
+ Ok(json!({
+ "count": rows.len(),
+ "items": rows,
+ }))
+}
+
+/// 聊天统计:消息总数、类型分布、发言排行、24小时分布
+pub async fn q_stats(
+ db: &DbCache,
+ names: &Names,
+ chat: &str,
+ since: Option,
+ until: Option,
+) -> Result {
+ 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() {
+ anyhow::bail!("找不到 {} 的消息记录", display);
+ }
+
+ // 跨所有分片 DB 累计统计
+ let mut total: i64 = 0;
+ let mut type_counts: HashMap = HashMap::new();
+ let mut sender_counts: HashMap = HashMap::new();
+ let mut hour_counts = [0i64; 24];
+
+ 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();
+
+ // 用 SQL GROUP BY 在数据库侧聚合,避免把全量消息内容加载进内存
+ let result: (i64, HashMap, HashMap, [i64; 24]) =
+ tokio::task::spawn_blocking(move || {
+ let conn = Connection::open(&path)?;
+ let id2u = load_id2u(&conn);
+
+ let mut clauses = Vec::new();
+ let mut params: Vec> = 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 params_ref: Vec<&dyn rusqlite::types::ToSql> =
+ params.iter().map(|p| p.as_ref()).collect();
+
+ // 1. 总数
+ let count: i64 = conn.query_row(
+ &format!("SELECT COUNT(*) FROM [{}] {}", tname, where_clause),
+ params_ref.as_slice(),
+ |row| row.get(0),
+ ).unwrap_or(0);
+
+ // 2. 类型分布:SQL GROUP BY,不加载消息内容
+ let type_sql = format!(
+ "SELECT (local_type & 0xFFFFFFFF), COUNT(*) FROM [{}] {} GROUP BY (local_type & 0xFFFFFFFF)",
+ tname, where_clause
+ );
+ let mut type_c: HashMap = HashMap::new();
+ if let Ok(mut stmt) = conn.prepare(&type_sql) {
+ let _ = stmt.query_map(params_ref.as_slice(), |row| {
+ Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))
+ }).map(|rows| {
+ for r in rows.flatten() {
+ *type_c.entry(fmt_type(r.0)).or_insert(0) += r.1;
+ }
+ });
+ }
+
+ // 3. 小时分布:只取时间戳,不加载消息内容
+ let hour_sql = format!(
+ "SELECT create_time FROM [{}] {}",
+ tname, where_clause
+ );
+ let mut hour_c = [0i64; 24];
+ if let Ok(mut stmt) = conn.prepare(&hour_sql) {
+ let _ = stmt.query_map(params_ref.as_slice(), |row| row.get::<_, i64>(0))
+ .map(|rows| {
+ for ts in rows.flatten() {
+ if let Some(dt) = Local.timestamp_opt(ts, 0).single() {
+ let h = dt.hour() as usize;
+ if h < 24 { hour_c[h] += 1; }
+ }
+ }
+ });
+ }
+
+ // 4. 发言排行:只取 real_sender_id,不加载消息内容
+ // where_clause 可能已含 WHERE,用 AND 追加而非重复写 WHERE
+ let sender_filter = if where_clause.is_empty() {
+ "WHERE real_sender_id > 0".to_string()
+ } else {
+ format!("{} AND real_sender_id > 0", where_clause)
+ };
+ let sender_sql = format!(
+ "SELECT real_sender_id, COUNT(*) FROM [{}] {} GROUP BY real_sender_id",
+ tname, sender_filter
+ );
+ let mut sender_c: HashMap = HashMap::new();
+ if is_group2 {
+ if let Ok(mut stmt) = conn.prepare(&sender_sql) {
+ let _ = stmt.query_map(params_ref.as_slice(), |row| {
+ Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))
+ }).map(|rows| {
+ for (id, cnt) in rows.flatten() {
+ if let Some(u) = id2u.get(&id) {
+ if u != &uname {
+ let name = names_map.get(u).cloned().unwrap_or_else(|| u.clone());
+ *sender_c.entry(name).or_insert(0) += cnt;
+ }
+ }
+ }
+ });
+ }
+ }
+
+ Ok::<_, anyhow::Error>((count, type_c, sender_c, hour_c))
+ }).await??;
+
+ let (count, type_c, sender_c, hour_c) = result;
+ total += count;
+ for (k, v) in type_c { *type_counts.entry(k).or_insert(0) += v; }
+ for (k, v) in sender_c { *sender_counts.entry(k).or_insert(0) += v; }
+ for i in 0..24 { hour_counts[i] += hour_c[i]; }
+ }
+
+ // 类型分布,按数量降序
+ let mut by_type: Vec = type_counts.iter()
+ .map(|(t, c)| json!({ "type": t, "count": c }))
+ .collect();
+ by_type.sort_by_key(|v| std::cmp::Reverse(v["count"].as_i64().unwrap_or(0)));
+
+ // 发言排行,Top 10
+ let mut top_senders: Vec = sender_counts.iter()
+ .map(|(s, c)| json!({ "sender": s, "count": c }))
+ .collect();
+ top_senders.sort_by_key(|v| std::cmp::Reverse(v["count"].as_i64().unwrap_or(0)));
+ top_senders.truncate(10);
+
+ // 24小时分布
+ let by_hour: Vec = hour_counts.iter().enumerate()
+ .map(|(h, c)| json!({ "hour": h, "count": c }))
+ .collect();
+
+ Ok(json!({
+ "chat": display,
+ "username": username,
+ "is_group": is_group,
+ "total": total,
+ "by_type": by_type,
+ "top_senders": top_senders,
+ "by_hour": by_hour,
+ }))
+}
+
diff --git a/src/daemon/server.rs b/src/daemon/server.rs
index 89d81f0..35eabe1 100644
--- a/src/daemon/server.rs
+++ b/src/daemon/server.rs
@@ -1,10 +1,8 @@
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 crate::ipc::{Request, Response};
use super::cache::DbCache;
use super::query::Names;
@@ -12,12 +10,11 @@ use super::query::Names;
pub async fn serve(
db: Arc,
names: Arc>,
- watch_tx: broadcast::Sender,
) -> Result<()> {
#[cfg(unix)]
- serve_unix(db, names, watch_tx).await?;
+ serve_unix(db, names).await?;
#[cfg(windows)]
- serve_windows(db, names, watch_tx).await?;
+ serve_windows(db, names).await?;
Ok(())
}
@@ -25,7 +22,6 @@ pub async fn serve(
async fn serve_unix(
db: Arc,
names: Arc>,
- watch_tx: broadcast::Sender,
) -> Result<()> {
use tokio::net::UnixListener;
let sock_path = crate::config::sock_path();
@@ -49,10 +45,9 @@ async fn serve_unix(
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 {
+ if let Err(e) = handle_connection_unix(stream, db2, names2).await {
eprintln!("[server] 连接处理错误: {}", e);
}
});
@@ -64,7 +59,6 @@ async fn handle_connection_unix(
stream: tokio::net::UnixStream,
db: Arc,
names: Arc>,
- watch_tx: broadcast::Sender,
) -> Result<()> {
let (reader, mut writer) = stream.into_split();
let mut lines = BufReader::new(reader).lines();
@@ -84,41 +78,8 @@ async fn handle_connection_unix(
}
};
- 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?;
- }
- }
+ let resp = dispatch(req, &db, &names).await;
+ writer.write_all(resp.to_json_line()?.as_bytes()).await?;
Ok(())
}
@@ -126,7 +87,6 @@ async fn handle_connection_unix(
async fn serve_windows(
db: Arc,
names: Arc>,
- watch_tx: broadcast::Sender,
) -> Result<()> {
use interprocess::local_socket::{
tokio::prelude::*, GenericNamespaced, ListenerOptions,
@@ -143,10 +103,9 @@ async fn serve_windows(
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 {
+ if let Err(e) = handle_connection_generic(conn, db2, names2).await {
eprintln!("[server] 连接处理错误: {}", e);
}
});
@@ -164,7 +123,6 @@ async fn dispatch(
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),
@@ -174,22 +132,22 @@ async fn dispatch(
Err(e) => Response::err(e.to_string()),
}
}
- History { chat, limit, offset, since, until } => {
+ History { chat, limit, offset, since, until, msg_type } => {
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 {
+ match query::q_history(db, &names_snapshot, &chat, limit, offset, since, until, msg_type).await {
Ok(v) => Response::ok(v),
Err(e) => Response::err(e.to_string()),
}
}
- Search { keyword, chats, limit, since, until } => {
+ Search { keyword, chats, limit, since, until, msg_type } => {
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 {
+ match query::q_search(db, &names_snapshot, &keyword, chats, limit, since, until, msg_type).await {
Ok(v) => Response::ok(v),
Err(e) => Response::err(e.to_string()),
}
@@ -204,7 +162,52 @@ async fn dispatch(
Err(e) => Response::err(e.to_string()),
}
}
- Watch => Response::err("Watch 命令不应通过 dispatch 处理"),
+ Unread { limit } => {
+ let names_snapshot = match clone_names(names) {
+ Ok(n) => n,
+ Err(e) => return Response::err(e),
+ };
+ match query::q_unread(db, &names_snapshot, limit).await {
+ Ok(v) => Response::ok(v),
+ Err(e) => Response::err(e.to_string()),
+ }
+ }
+ Members { chat } => {
+ let names_snapshot = match clone_names(names) {
+ Ok(n) => n,
+ Err(e) => return Response::err(e),
+ };
+ match query::q_members(db, &names_snapshot, &chat).await {
+ Ok(v) => Response::ok(v),
+ Err(e) => Response::err(e.to_string()),
+ }
+ }
+ NewMessages { state, limit } => {
+ let names_snapshot = match clone_names(names) {
+ Ok(n) => n,
+ Err(e) => return Response::err(e),
+ };
+ match query::q_new_messages(db, &names_snapshot, state, limit).await {
+ Ok(v) => Response::ok(v),
+ Err(e) => Response::err(e.to_string()),
+ }
+ }
+ Favorites { limit, fav_type, query } => {
+ match query::q_favorites(db, limit, fav_type, query).await {
+ Ok(v) => Response::ok(v),
+ Err(e) => Response::err(e.to_string()),
+ }
+ }
+ Stats { chat, since, until } => {
+ let names_snapshot = match clone_names(names) {
+ Ok(n) => n,
+ Err(e) => return Response::err(e),
+ };
+ match query::q_stats(db, &names_snapshot, &chat, since, until).await {
+ Ok(v) => Response::ok(v),
+ Err(e) => Response::err(e.to_string()),
+ }
+ }
}
}
diff --git a/src/ipc.rs b/src/ipc.rs
index 130ed22..cf21993 100644
--- a/src/ipc.rs
+++ b/src/ipc.rs
@@ -1,3 +1,4 @@
+use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -20,6 +21,8 @@ pub enum Request {
since: Option,
#[serde(skip_serializing_if = "Option::is_none")]
until: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ msg_type: Option,
},
Search {
keyword: String,
@@ -31,6 +34,8 @@ pub enum Request {
since: Option,
#[serde(skip_serializing_if = "Option::is_none")]
until: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ msg_type: Option,
},
Contacts {
#[serde(skip_serializing_if = "Option::is_none")]
@@ -38,7 +43,38 @@ pub enum Request {
#[serde(default = "default_limit_50")]
limit: usize,
},
- Watch,
+ Unread {
+ #[serde(default = "default_limit_20")]
+ limit: usize,
+ },
+ Members {
+ chat: String,
+ },
+ NewMessages {
+ /// 上次检查时各会话的 last_timestamp 快照(username -> ts)
+ /// None 表示首次运行,会返回 new_state 供下次使用
+ #[serde(skip_serializing_if = "Option::is_none")]
+ state: Option>,
+ #[serde(default = "default_limit_200")]
+ limit: usize,
+ },
+ Stats {
+ chat: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ since: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ until: Option,
+ },
+ Favorites {
+ #[serde(default = "default_limit_50")]
+ limit: usize,
+ /// 类型过滤:1=文本,2=图片,5=文章,19=名片,20=视频
+ #[serde(skip_serializing_if = "Option::is_none")]
+ fav_type: Option,
+ /// 内容关键词搜索
+ #[serde(skip_serializing_if = "Option::is_none")]
+ query: Option,
+ },
}
@@ -69,48 +105,4 @@ impl Response {
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,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub chat: Option,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub username: Option,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub is_group: Option,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub sender: Option,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub content: Option,
- #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
- pub msg_type: Option,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub timestamp: Option,
-}
-
-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 {
- let s = serde_json::to_string(self)?;
- Ok(s + "\n")
- }
-}
+fn default_limit_200() -> usize { 200 }
diff --git a/src/scanner/macos.rs b/src/scanner/macos.rs
index ec7e988..bc48dfd 100644
--- a/src/scanner/macos.rs
+++ b/src/scanner/macos.rs
@@ -246,7 +246,7 @@ fn scan_region(
/// 在缓冲区中搜索 x'<96个十六进制字符>' 模式
///
/// 格式:x'<64hex(key)><32hex(salt)>'(总计 99 字节)
-fn search_pattern(buf: &[u8], results: &mut Vec<(String, String)>) {
+pub(crate) fn search_pattern(buf: &[u8], results: &mut Vec<(String, String)>) {
let total = HEX_PATTERN_LEN + 3; // x' + 96 hex + '
if buf.len() < total {
return;
@@ -291,3 +291,152 @@ fn search_pattern(buf: &[u8], results: &mut Vec<(String, String)>) {
i += total;
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// 构造一条合法的 x'' 模式字节串
+ fn make_pattern(key: &[u8; 64], salt: &[u8; 32]) -> Vec {
+ let mut v = vec![b'x', b'\''];
+ v.extend_from_slice(key);
+ v.extend_from_slice(salt);
+ v.push(b'\'');
+ v
+ }
+
+ #[test]
+ fn test_is_hex_char_valid() {
+ for c in b'0'..=b'9' { assert!(is_hex_char(c), "digit {}", c as char); }
+ for c in b'a'..=b'f' { assert!(is_hex_char(c), "lower {}", c as char); }
+ for c in b'A'..=b'F' { assert!(is_hex_char(c), "upper {}", c as char); }
+ }
+
+ #[test]
+ fn test_is_hex_char_invalid() {
+ for c in [b'g', b'G', b'x', b'\'', b' ', b'\0', b'z', b'Z'] {
+ assert!(!is_hex_char(c), "expected non-hex: {}", c as char);
+ }
+ }
+
+ #[test]
+ fn test_search_pattern_basic() {
+ let key = [b'a'; 64];
+ let salt = [b'b'; 32];
+ let buf = make_pattern(&key, &salt);
+ let mut results = Vec::new();
+ search_pattern(&buf, &mut results);
+ assert_eq!(results.len(), 1);
+ assert_eq!(results[0].0, "a".repeat(64));
+ assert_eq!(results[0].1, "b".repeat(32));
+ }
+
+ #[test]
+ fn test_search_pattern_uppercase_lowercased() {
+ // 大写十六进制字符应被统一转为小写
+ let key = [b'A'; 64];
+ let salt = [b'B'; 32];
+ let buf = make_pattern(&key, &salt);
+ let mut results = Vec::new();
+ search_pattern(&buf, &mut results);
+ assert_eq!(results.len(), 1);
+ assert_eq!(results[0].0, "a".repeat(64));
+ assert_eq!(results[0].1, "b".repeat(32));
+ }
+
+ #[test]
+ fn test_search_pattern_not_all_hex() {
+ // 96 个十六进制字符中有一个非法字符 → 不匹配
+ let mut buf = vec![b'x', b'\''];
+ buf.extend_from_slice(&[b'a'; 95]);
+ buf.push(b'g'); // 'g' 不是合法十六进制字符
+ buf.push(b'\'');
+ let mut results = Vec::new();
+ search_pattern(&buf, &mut results);
+ assert!(results.is_empty());
+ }
+
+ #[test]
+ fn test_search_pattern_wrong_closing_quote() {
+ // 结尾引号错误 → 不匹配
+ let mut buf = vec![b'x', b'\''];
+ buf.extend_from_slice(&[b'a'; 96]);
+ buf.push(b'"'); // 应为 b'\''
+ let mut results = Vec::new();
+ search_pattern(&buf, &mut results);
+ assert!(results.is_empty());
+ }
+
+ #[test]
+ fn test_search_pattern_dedup() {
+ // 相同模式出现两次 → 只保留一条
+ let key = [b'1'; 64];
+ let salt = [b'2'; 32];
+ let pattern = make_pattern(&key, &salt);
+ let mut buf = pattern.clone();
+ buf.extend_from_slice(&pattern);
+ let mut results = Vec::new();
+ search_pattern(&buf, &mut results);
+ assert_eq!(results.len(), 1);
+ }
+
+ #[test]
+ fn test_search_pattern_multiple_distinct() {
+ // 两个不同的合法模式 → 各自独立捕获
+ let key1 = [b'a'; 64]; let salt1 = [b'b'; 32];
+ let key2 = [b'c'; 64]; let salt2 = [b'd'; 32];
+ let mut buf = make_pattern(&key1, &salt1);
+ buf.extend_from_slice(&make_pattern(&key2, &salt2));
+ let mut results = Vec::new();
+ search_pattern(&buf, &mut results);
+ assert_eq!(results.len(), 2);
+ let keys: Vec<&str> = results.iter().map(|(k, _)| k.as_str()).collect();
+ assert!(keys.contains(&"a".repeat(64).as_str()));
+ assert!(keys.contains(&"c".repeat(64).as_str()));
+ }
+
+ #[test]
+ fn test_search_pattern_embedded_in_garbage() {
+ // 模式夹在垃圾字节中间,仍应找到
+ let mut buf = vec![0xFFu8; 50];
+ let key = [b'e'; 64];
+ let salt = [b'f'; 32];
+ buf.extend_from_slice(&make_pattern(&key, &salt));
+ buf.extend_from_slice(&[0x00u8; 50]);
+ let mut results = Vec::new();
+ search_pattern(&buf, &mut results);
+ assert_eq!(results.len(), 1);
+ }
+
+ #[test]
+ fn test_search_pattern_too_short() {
+ // 缓冲区太小,无法容纳完整模式
+ let buf = [b'x', b'\'', b'a', b'b'];
+ let mut results = Vec::new();
+ search_pattern(&buf, &mut results);
+ assert!(results.is_empty());
+ }
+
+ #[test]
+ fn test_search_pattern_empty_buf() {
+ let mut results = Vec::new();
+ search_pattern(&[], &mut results);
+ assert!(results.is_empty());
+ }
+
+ #[test]
+ fn test_search_pattern_real_hex_mix() {
+ // 合法的混合大小写十六进制(0-9, a-f, A-F)
+ let mut key = [b'0'; 64];
+ for (i, c) in b"0123456789abcdefABCDEF0123456789abcdef0123456789abcdef01234567".iter().enumerate() {
+ if i < 64 { key[i] = *c; }
+ }
+ let salt = [b'9'; 32];
+ let buf = make_pattern(&key, &salt);
+ let mut results = Vec::new();
+ search_pattern(&buf, &mut results);
+ assert_eq!(results.len(), 1);
+ // 结果应全小写
+ assert!(results[0].0.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()));
+ }
+}
diff --git a/src/scanner/mod.rs b/src/scanner/mod.rs
index abed70c..0d76a50 100644
--- a/src/scanner/mod.rs
+++ b/src/scanner/mod.rs
@@ -82,3 +82,161 @@ mod hex {
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::fs;
+
+ /// 创建一个进程唯一的临时目录(测试用),返回路径;测试结束后调用方负责删除
+ fn make_temp_dir(label: &str) -> std::path::PathBuf {
+ let mut p = std::env::temp_dir();
+ // 用 label + thread id 保证同进程内并发测试不冲突
+ p.push(format!("wx-cli-test-{}-{:?}", label, std::thread::current().id()));
+ fs::create_dir_all(&p).unwrap();
+ p
+ }
+
+ // ── read_db_salt ──────────────────────────────────────────────────────────
+
+ #[test]
+ fn test_read_db_salt_plaintext_sqlite() {
+ let dir = make_temp_dir("salt-plain");
+ let path = dir.join("plain.db");
+ // 明文 SQLite 头:前 15 字节是 "SQLite format 3"
+ let mut content = b"SQLite format 3\x00".to_vec();
+ content.extend_from_slice(&[0u8; 100]);
+ fs::write(&path, &content).unwrap();
+
+ assert!(read_db_salt(&path).is_none(), "明文 SQLite 应返回 None");
+ fs::remove_dir_all(&dir).ok();
+ }
+
+ #[test]
+ fn test_read_db_salt_encrypted() {
+ let dir = make_temp_dir("salt-enc");
+ let path = dir.join("enc.db");
+ // 非 SQLite 头 → 视为加密数据库,取前 16 字节作为 salt
+ let header: [u8; 16] = [
+ 0xde, 0xad, 0xbe, 0xef, 0x01, 0x02, 0x03, 0x04,
+ 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c,
+ ];
+ fs::write(&path, &header).unwrap();
+
+ let salt = read_db_salt(&path).expect("加密 DB 应返回 Some");
+ assert_eq!(salt, "deadbeef0102030405060708090a0b0c");
+ fs::remove_dir_all(&dir).ok();
+ }
+
+ #[test]
+ fn test_read_db_salt_too_short() {
+ let dir = make_temp_dir("salt-short");
+ let path = dir.join("short.db");
+ fs::write(&path, b"tooshort").unwrap(); // < 16 bytes
+
+ assert!(read_db_salt(&path).is_none(), "文件太短应返回 None");
+ fs::remove_dir_all(&dir).ok();
+ }
+
+ #[test]
+ fn test_read_db_salt_nonexistent() {
+ assert!(read_db_salt(Path::new("/nonexistent/surely/not/here.db")).is_none());
+ }
+
+ #[test]
+ fn test_read_db_salt_exactly_16_bytes() {
+ let dir = make_temp_dir("salt-16");
+ let path = dir.join("exact.db");
+ let header = [0xabu8; 16];
+ fs::write(&path, &header).unwrap();
+
+ let salt = read_db_salt(&path).unwrap();
+ // 0xab × 16 → "ab" × 16 = 32 chars
+ assert_eq!(salt, "ab".repeat(16));
+ fs::remove_dir_all(&dir).ok();
+ }
+
+ // ── collect_db_salts ──────────────────────────────────────────────────────
+
+ #[test]
+ fn test_collect_db_salts_empty_dir() {
+ let dir = make_temp_dir("collect-empty");
+ let salts = collect_db_salts(&dir);
+ assert!(salts.is_empty());
+ fs::remove_dir_all(&dir).ok();
+ }
+
+ #[test]
+ fn test_collect_db_salts_skips_plaintext_sqlite() {
+ let dir = make_temp_dir("collect-plain");
+ let mut content = b"SQLite format 3\x00".to_vec();
+ content.extend_from_slice(&[0u8; 100]);
+ fs::write(dir.join("plain.db"), &content).unwrap();
+
+ assert!(collect_db_salts(&dir).is_empty(), "明文 SQLite 应被跳过");
+ fs::remove_dir_all(&dir).ok();
+ }
+
+ #[test]
+ fn test_collect_db_salts_finds_encrypted() {
+ let dir = make_temp_dir("collect-enc");
+ let header = [0x11u8; 16];
+ fs::write(dir.join("msg.db"), &header).unwrap();
+
+ let salts = collect_db_salts(&dir);
+ assert_eq!(salts.len(), 1);
+ assert_eq!(salts[0].0, "11".repeat(16)); // 0x11 × 16 → "11" × 16
+ assert_eq!(salts[0].1, "msg.db");
+ fs::remove_dir_all(&dir).ok();
+ }
+
+ #[test]
+ fn test_collect_db_salts_recursive() {
+ let dir = make_temp_dir("collect-rec");
+ let subdir = dir.join("sub");
+ fs::create_dir_all(&subdir).unwrap();
+
+ let header = [0xaau8; 16];
+ fs::write(dir.join("root.db"), &header).unwrap();
+ fs::write(subdir.join("nested.db"), &header).unwrap();
+ fs::write(dir.join("ignored.txt"), b"text file").unwrap();
+
+ let salts = collect_db_salts(&dir);
+ assert_eq!(salts.len(), 2, "应递归找到 2 个加密 .db");
+
+ let names: Vec<&str> = salts.iter().map(|(_, n)| n.as_str()).collect();
+ assert!(names.contains(&"root.db"));
+ assert!(names.contains(&"sub/nested.db"));
+ fs::remove_dir_all(&dir).ok();
+ }
+
+ #[test]
+ fn test_collect_db_salts_ignores_non_db_extensions() {
+ let dir = make_temp_dir("collect-ext");
+ let header = [0xbbu8; 16];
+ fs::write(dir.join("data.txt"), &header).unwrap();
+ fs::write(dir.join("data.json"), &header).unwrap();
+ fs::write(dir.join("data.sqlite"), &header).unwrap();
+
+ assert!(collect_db_salts(&dir).is_empty(), "非 .db 文件应被忽略");
+ fs::remove_dir_all(&dir).ok();
+ }
+
+ #[test]
+ fn test_collect_db_salts_multiple_files_unique_salts() {
+ let dir = make_temp_dir("collect-multi");
+ fs::write(dir.join("a.db"), &[0x11u8; 16]).unwrap();
+ fs::write(dir.join("b.db"), &[0x22u8; 16]).unwrap();
+ fs::write(dir.join("c.db"), &[0x33u8; 16]).unwrap();
+
+ let salts = collect_db_salts(&dir);
+ assert_eq!(salts.len(), 3);
+
+ let salt_vals: std::collections::HashSet<&str> =
+ salts.iter().map(|(s, _)| s.as_str()).collect();
+ assert!(salt_vals.contains("11".repeat(16).as_str()));
+ assert!(salt_vals.contains("22".repeat(16).as_str()));
+ assert!(salt_vals.contains("33".repeat(16).as_str()));
+ fs::remove_dir_all(&dir).ok();
+ }
+}