diff --git a/EXE_USAGE.md b/EXE_USAGE.md
index 020bd44..adf8907 100644
--- a/EXE_USAGE.md
+++ b/EXE_USAGE.md
@@ -88,14 +88,19 @@ wxwork_export/ ← 企微聊天导出
monitor_web 默认 bind `0.0.0.0`, 同局域网其他设备可以访问
`http://<你的本机 IP>:5678`。如需要只允许本机访问, 修改源码 `PORT` 那一行附近的 bind 地址改成 `127.0.0.1`。
-## 历史: 为什么没有 tkinter GUI 了
+## 还有个 tkinter 桌面 GUI (备用, 共存)
-旧版本 (commit b986413 ~ a01d326) 提供过 `app_gui.py` tkinter 桌面 GUI, 但:
+如果你 **不想开浏览器** (公司机器禁浏览器 / 全离线场景), 可以用桌面 GUI:
-- tkinter 在中文字体下渲染糊
-- 90 年代 Windows 控件风格, 难看
-- 只能跑 Windows, 不跨平台
+```bash
+python app_gui.py # → 弹 tkinter 窗口
+```
+
+跟 Web UI 功能基本对齐 (8 个按钮 / 导出对话框), 缺点:
+- 中文字体下渲染较糊
+- Windows-only (不跨平台)
- 没法远程访问
-- 维护两套 UI (tkinter + Web) 代码重复
-commit (这次) 起完全切到 Web UI: 浏览器渲染清晰、跨平台、远程可访问、跟实时监听共享一套 SSE 通道, 同时没有功能损失 (8 个工具按钮 + 模态框筛选 + 终止 + 状态都在 Web 上)。
+单 exe **默认入口是 Web UI**。要打 tkinter 版的 exe, 改
+`WeChatDecrypt.spec` 里 `Analysis(['monitor_web.py'])` 为
+`Analysis(['app_gui.py'])` 再 `build.bat`。
diff --git a/README.md b/README.md
index c0f2319..c761d3e 100644
--- a/README.md
+++ b/README.md
@@ -62,21 +62,32 @@ python export_all_chats.py
-Windows — Web UI / EXE (推荐非技术用户)
+Windows — Web UI (推荐, 跨平台 + 实时监听)
```bash
-# 直接跑 Web UI (开发模式)
python monitor_web.py # → 浏览器自动开 http://localhost:5678
-
-# 或打包成单 exe 分发给别人
-build.bat # 输出 dist/WeChatDecrypt.exe
- # 双击 exe → 自动开浏览器 → Web UI 工具箱
```
-工具箱 3 个 tab (📱 个人微信 / 🏢 企业微信 / 🔧 工具),
-覆盖解密 / 导出 / 朋友圈 / 语音转 MP3 全部场景, 导出带模态框筛选 (不会动不动跑全量)。
+工具箱 3 个 tab (📱 个人微信 / 🏢 企业微信 / 🔧 工具), 覆盖解密 / 导出 /
+朋友圈 / 语音转 MP3 全部场景, 导出带模态框筛选 (不会一点就跑全量)。
+实时消息监听跟工具箱在同一页面。
-实时消息监听跟工具箱在同一页面。详见 [EXE_USAGE.md](./EXE_USAGE.md)。
+
+
+
+Windows — 桌面 GUI / EXE (tkinter, 适合不开浏览器的场景)
+
+```bash
+# 直接跑桌面 GUI
+python app_gui.py
+
+# 或打包成单 exe 分发给别人 (默认入口 Web UI)
+build.bat # 输出 dist/WeChatDecrypt.exe
+```
+
+跟 Web UI **共存**, 两者覆盖功能基本一致。tkinter 优点是不依赖浏览器
+(全离线 / 公司机器禁浏览器场景), 缺点是中文渲染糊 + 不跨平台 +
+没远程访问。 详见 [EXE_USAGE.md](./EXE_USAGE.md)。
@@ -328,7 +339,8 @@ make help # 列出所有命令
| 文件 | 说明 |
|---|---|
| `main.py` | **CLI 总入口** — 子命令 `decrypt` / `export` / `all` / `status` / `decode-images` / `help` |
-| `monitor_web.py` | **Web UI 总入口** — 实时监听 + 8 个工具按钮 + 导出筛选模态框 (`python monitor_web.py` 即用, PyInstaller 打包成单 exe 给非技术用户) |
+| `monitor_web.py` | **Web UI 总入口 (推荐)** — 浏览器界面: 实时监听 + 8 个工具按钮 + 导出筛选模态框, 也是 PyInstaller 单 exe 的入口 |
+| `app_gui.py` | **桌面 GUI (备用)** — tkinter 界面, 跟 Web UI 功能基本对齐, 适合不开浏览器场景 |
| `setup.sh` | 一键安装依赖 (macOS / Linux / Windows Git Bash) |
| `setup.py` | 交互式配置向导 (`python setup.py --check` 仅检查环境) |
| `cleanup.py` | 磁盘清理工具 (`status` 查看用量 / `--dry-run` 预览) |
@@ -450,9 +462,16 @@ WCDB (微信的 SQLCipher 封装) 会在进程内存中缓存派生后的 raw ke
`export_sns.py` 解析 SnsTimeLine 的 XML 时已加 **XXE 防护**(拒绝 `` / `` + 200KB 大小上限),避免恶意朋友圈 XML 通过 entity expansion 或外部实体引用执行 SSRF / 读取本地文件。`mcp_server.py` 解析其他类型 appmsg XML 同样有这层保护。
-### Web UI 工具箱 & 单 exe 打包
+### GUI 工具箱 (Web UI 推荐 + tkinter 备用, 共存)
+
+项目提供 **两套 UI 共存**, 用户按场景选:
+
+#### Web UI (`monitor_web.py`) — 推荐
+
+```bash
+python monitor_web.py # → 浏览器自动开 http://localhost:5678
+```
-`monitor_web.py` 既是实时消息监听 (浏览器 Web UI), 又是工具箱总入口。
右上角 🛠️ 工具 按钮展开 3 个 tab:
- **📱 个人微信**: Step 1 解密 / 图片密钥 → Step 2 导出聊天 / 批量解图片 / 朋友圈
@@ -465,28 +484,31 @@ WCDB (微信的 SQLCipher 封装) 会在进程内存中缓存派生后的 raw ke
- **跟实时监听共存**: 同一页面下方就是消息流, 互不影响
- **跨平台 + 远程可访问**: 浏览器渲染清晰, 默认 bind 0.0.0.0 同局域网可用
-#### 直接运行
+#### 桌面 GUI (`app_gui.py`) — 备用
```bash
-python monitor_web.py # → 浏览器自动开 http://localhost:5678
+python app_gui.py # 直接弹 tkinter 窗口, 不开浏览器
```
+适合: 公司机器禁浏览器 / 全离线场景 / 喜欢传统桌面应用的用户。
+功能跟 Web UI 基本对齐 (8 个按钮覆盖解密/导出/朋友圈/企微/语音),
+但中文字体下渲染较糊, Windows-only。
+
#### 打包为单 exe
```bash
pip install pyinstaller
-build.bat # → dist\WeChatDecrypt.exe
+build.bat # → dist\WeChatDecrypt.exe (~20MB)
```
-输出约 20MB 的单 exe, 双击即用, 无需安装 Python。
+**单 exe 默认入口是 Web UI** (双击 → 自动开浏览器), 因为 Web UI 体验更好。
+要打包成 tkinter 入口的话改 `WeChatDecrypt.spec` 里的 `Analysis(['monitor_web.py'])`
+为 `Analysis(['app_gui.py'])` 再 `build.bat`。
> 语音转 MP3 需要系统安装 [FFmpeg](https://ffmpeg.org/download.html) 并加入 PATH。
详细说明见 [EXE_USAGE.md](EXE_USAGE.md)。
-> **历史**: 旧版本提供过 tkinter `app_gui.py` 桌面 GUI, 在 commit 273fe65 后完全移除。
-> 原因: 渲染糊 / Windows-only / 维护两套 UI。Web UI 功能完全对齐且更好。
-
### WAL 处理
微信使用 SQLite WAL 模式,WAL 文件是**预分配固定大小** (4MB)。检测变化时:
diff --git a/app_gui.py b/app_gui.py
new file mode 100644
index 0000000..0bdc39a
--- /dev/null
+++ b/app_gui.py
@@ -0,0 +1,939 @@
+"""WeChat Decrypt GUI — 一键解密 / 导出消息 / 转换音频"""
+import os
+import sys
+import subprocess
+import threading
+import sqlite3
+import hashlib
+import glob as globmod
+import tkinter as tk
+from tkinter import ttk, scrolledtext
+
+# 确保工作目录为脚本所在目录(打包后也适用)
+if getattr(sys, "frozen", False):
+ BASE_DIR = os.path.dirname(sys.executable)
+else:
+ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
+os.chdir(BASE_DIR)
+os.environ["WECHAT_DECRYPT_APP_DIR"] = BASE_DIR
+
+
+# ── 子任务入口(当以 --task 参数调用时直接执行对应脚本) ──────────────────────
+
+# 显式导入:让 PyInstaller 收集子脚本需要的所有依赖
+# (这些脚本通过 exec 动态加载,PyInstaller 无法自动检测)
+import importlib.util # noqa: F401 - used for dynamic loading
+if False: # noqa: never executed, only for PyInstaller dependency detection
+ import sqlite3, hashlib, csv, json, re, glob, tempfile # noqa: F401
+ import xml.etree.ElementTree # noqa: F401
+ import functools, platform, ctypes, ctypes.wintypes # noqa: F401
+ import zstandard # noqa: F401
+ import pilk # noqa: F401
+ import Crypto, Crypto.Cipher, Crypto.Cipher.AES, Crypto.Util.Padding # noqa: F401
+ import wxwork_crypto # noqa: F401
+ import export_wxwork_messages # noqa: F401
+
+
+def _run_subtask(task: str):
+ """在子进程中被调用,直接执行对应脚本逻辑"""
+ # 强制 stdout/stderr 为 UTF-8
+ if sys.platform == "win32":
+ for s in (sys.stdout, sys.stderr):
+ if hasattr(s, "reconfigure"):
+ s.reconfigure(encoding="utf-8", errors="replace")
+
+ # onefile: _MEIPASS 临时目录; onedir: _internal/; 开发: BASE_DIR
+ if getattr(sys, "frozen", False):
+ script_dir = getattr(sys, "_MEIPASS", os.path.join(os.path.dirname(sys.executable), "_internal"))
+ else:
+ script_dir = BASE_DIR
+
+ # 让 import 能找到脚本同目录的模块
+ if script_dir not in sys.path:
+ sys.path.insert(0, script_dir)
+ if BASE_DIR not in sys.path:
+ sys.path.insert(0, BASE_DIR)
+
+ mapping = {
+ "decrypt": "main.py",
+ "export": "export_messages.py",
+ "voice": "voice_to_mp3.py",
+ "find_image_key": "find_image_key.py",
+ "decrypt_sns": "decrypt_sns.py",
+ "export_sns": "export_sns.py",
+ "find_wxwork_keys": "find_wxwork_keys.py",
+ "decrypt_wxwork": "decrypt_wxwork_db.py",
+ "export_wxwork": "export_wxwork_messages.py",
+ }
+ script = mapping.get(task)
+ if not script:
+ print(f"未知任务: {task}", flush=True)
+ sys.exit(1)
+
+ script_path = os.path.join(script_dir, script)
+ if not os.path.exists(script_path):
+ # 开发模式回退到 BASE_DIR
+ script_path = os.path.join(BASE_DIR, script)
+ if not os.path.exists(script_path):
+ print(f"脚本不存在: {script_path}", flush=True)
+ sys.exit(1)
+
+ # 将 decrypt 命令传给 main.py
+ if task == "decrypt":
+ sys.argv = ["main.py", "decrypt"]
+ elif task == "find_image_key":
+ sys.argv = ["find_image_key.py"]
+ elif task == "find_wxwork_keys":
+ sys.argv = ["find_wxwork_keys.py"]
+ elif task == "decrypt_wxwork":
+ sys.argv = ["decrypt_wxwork_db.py"]
+ elif task == "export_wxwork":
+ sys.argv = ["export_wxwork_messages.py"]
+ else:
+ sys.argv = [script]
+
+ # 设置环境变量,让 config.py 等脚本知道真正的应用目录
+ os.environ["WECHAT_DECRYPT_APP_DIR"] = BASE_DIR
+ os.chdir(BASE_DIR)
+
+ # 加载并执行脚本
+ spec = importlib.util.spec_from_file_location("__main__", script_path)
+ mod = importlib.util.module_from_spec(spec)
+ mod.__name__ = "__main__"
+ spec.loader.exec_module(mod)
+
+
+# ── 检查是否为子任务模式 ──────────────────────────────────────────────────────
+if len(sys.argv) >= 3 and sys.argv[1] == "--task":
+ _run_subtask(sys.argv[2])
+ sys.exit(0)
+
+# ── GUI 模式:隐藏控制台窗口 ────────────────────────────────────────────────
+if sys.platform == "win32":
+ try:
+ import ctypes
+ ctypes.windll.user32.ShowWindow(ctypes.windll.kernel32.GetConsoleWindow(), 0)
+ except Exception:
+ pass
+
+
+# ── 联系人发现 ────────────────────────────────────────────────────────────────
+
+def _load_contact_map(decrypted_dir):
+ """从 contact.db 加载联系人映射 {username: {remark, nick_name, ...}}"""
+ contact_map = {}
+ db_path = os.path.join(decrypted_dir, "contact", "contact.db")
+ if not os.path.exists(db_path):
+ return contact_map
+ try:
+ conn = sqlite3.connect(db_path)
+ for uname, alias, remark, nick_name in conn.execute(
+ "SELECT username, alias, remark, nick_name FROM contact"
+ ):
+ contact_map[uname] = {
+ "remark": remark or "",
+ "nick_name": nick_name or "",
+ }
+ conn.close()
+ except Exception:
+ pass
+ return contact_map
+
+
+def _display_name(username, contact_map):
+ info = contact_map.get(username, {})
+ return info.get("remark") or info.get("nick_name") or username
+
+
+def _discover_contacts():
+ """扫描所有联系人/会话,返回 (contacts, has_voice)
+ contacts: [(username, display_name), ...]
+ has_voice: 是否存在语音数据
+ """
+ from config import load_config
+ cfg = load_config()
+ decrypted_dir = cfg["decrypted_dir"]
+
+ if not os.path.isdir(decrypted_dir):
+ raise FileNotFoundError(f"解密目录不存在: {decrypted_dir}\n请先运行「解密数据库」")
+
+ contact_map = _load_contact_map(decrypted_dir)
+ usernames = set()
+ has_voice = False
+
+ # 从消息数据库扫描
+ msg_dir = os.path.join(decrypted_dir, "message")
+ if os.path.isdir(msg_dir):
+ db_files = [
+ f for f in globmod.glob(os.path.join(msg_dir, "message_*.db"))
+ if not f.endswith(("_fts.db", "_resource.db"))
+ ]
+ print(f"找到 {len(db_files)} 个消息数据库", flush=True)
+ for db_path in db_files:
+ try:
+ conn = sqlite3.connect(db_path)
+ hash_to_uname = {}
+ for row in conn.execute("SELECT rowid, user_name FROM Name2Id"):
+ uname = row[1]
+ if uname:
+ h = hashlib.md5(uname.encode()).hexdigest()
+ hash_to_uname[h] = uname
+ for (tbl,) in conn.execute(
+ "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'"
+ ):
+ h = tbl[4:]
+ uname = hash_to_uname.get(h)
+ if uname:
+ usernames.add(uname)
+ conn.close()
+ except Exception as e:
+ print(f" 读取 {os.path.basename(db_path)} 失败: {e}", flush=True)
+ continue
+ else:
+ print(f"消息目录不存在: {msg_dir}", flush=True)
+
+ # 从语音数据库扫描
+ voice_db = os.path.join(msg_dir, "media_0.db")
+ if os.path.exists(voice_db):
+ try:
+ conn = sqlite3.connect(voice_db)
+ name_map = {}
+ for rowid, uname in conn.execute("SELECT rowid, user_name FROM Name2Id"):
+ name_map[rowid] = uname
+ for (cid,) in conn.execute("SELECT DISTINCT chat_name_id FROM VoiceInfo"):
+ uname = name_map.get(cid)
+ if uname:
+ usernames.add(uname)
+ has_voice = True
+ conn.close()
+ except Exception as e:
+ print(f" 读取语音数据库失败: {e}", flush=True)
+
+ print(f"共发现 {len(usernames)} 个会话", flush=True)
+ result = [(u, _display_name(u, contact_map)) for u in usernames]
+ result.sort(key=lambda x: x[1].lower())
+ return result, has_voice
+
+
+# ── 导出选项对话框 ──────────────────────────────────────────────────────────
+
+class ExportOptionsDialog(tk.Toplevel):
+ def __init__(self, parent, contacts, has_voice=False):
+ """contacts: [(username, display_name), ...]
+ has_voice: 是否检测到语音数据
+ """
+ super().__init__(parent)
+ self.title("导出选项")
+ self.geometry("460x600")
+ self.transient(parent)
+ self.grab_set()
+ self.result = None
+ self.configure(bg="#f0f0f0")
+ self._contacts = contacts
+ self._vars = {} # username -> BooleanVar
+
+ # ── 导出格式 ──
+ fmt_frame = ttk.LabelFrame(self, text="导出格式", padding=6)
+ fmt_frame.pack(fill="x", padx=12, pady=(10, 4))
+
+ self._fmt_csv = tk.BooleanVar(value=True)
+ self._fmt_html = tk.BooleanVar(value=False)
+ self._fmt_json = tk.BooleanVar(value=False)
+
+ ttk.Checkbutton(fmt_frame, text="CSV(默认)", variable=self._fmt_csv).pack(side="left", padx=10)
+ ttk.Checkbutton(fmt_frame, text="HTML", variable=self._fmt_html).pack(side="left", padx=10)
+ ttk.Checkbutton(fmt_frame, text="JSON", variable=self._fmt_json).pack(side="left", padx=10)
+
+ # ── 其他选项 ──
+ opt_frame = ttk.LabelFrame(self, text="其他选项", padding=6)
+ opt_frame.pack(fill="x", padx=12, pady=(0, 4))
+
+ self._image_var = tk.BooleanVar(value=True)
+ ttk.Checkbutton(opt_frame, text="导出并解密图片",
+ variable=self._image_var).pack(anchor="w", padx=8)
+
+ self._sns_var = tk.BooleanVar(value=False)
+ ttk.Checkbutton(opt_frame, text="导出朋友圈动态(文案/评论)",
+ variable=self._sns_var).pack(anchor="w", padx=8)
+
+ self._sns_media_var = tk.BooleanVar(value=False)
+ ttk.Checkbutton(opt_frame, text=" ↳ 尝试下载朋友圈媒体(可能较慢)",
+ variable=self._sns_media_var).pack(anchor="w", padx=24)
+
+ self._voice_var = tk.BooleanVar(value=False)
+ if has_voice:
+ ttk.Checkbutton(opt_frame, text="同时转换语音为 MP3",
+ variable=self._voice_var).pack(anchor="w", padx=8)
+
+ # ── 联系人选择 ──
+ top = ttk.Frame(self)
+ top.pack(fill="x", padx=12, pady=(4, 4))
+
+ ttk.Label(top, text=f"共 {len(contacts)} 个会话",
+ font=("Microsoft YaHei UI", 10)).pack(side="left")
+
+ self._all_selected = True
+ self._toggle_btn = ttk.Button(top, text="取消全选", command=self._toggle_all)
+ self._toggle_btn.pack(side="right")
+
+ # 搜索框
+ search_frame = ttk.Frame(self)
+ search_frame.pack(fill="x", padx=12, pady=(0, 4))
+ self._search_var = tk.StringVar()
+ self._search_var.trace_add("write", lambda *_: self._filter_list())
+ ttk.Entry(search_frame, textvariable=self._search_var,
+ font=("Microsoft YaHei UI", 10)).pack(fill="x")
+
+ # 滚动区域
+ container = ttk.Frame(self)
+ container.pack(fill="both", expand=True, padx=12, pady=4)
+
+ self._canvas = tk.Canvas(container, bg="#ffffff", highlightthickness=0)
+ scrollbar = ttk.Scrollbar(container, orient="vertical", command=self._canvas.yview)
+ self._inner = ttk.Frame(self._canvas)
+
+ self._inner.bind("",
+ lambda e: self._canvas.configure(scrollregion=self._canvas.bbox("all")))
+ self._canvas.create_window((0, 0), window=self._inner, anchor="nw")
+ self._canvas.configure(yscrollcommand=scrollbar.set)
+
+ self._canvas.pack(side="left", fill="both", expand=True)
+ scrollbar.pack(side="right", fill="y")
+
+ # 绑定鼠标滚轮
+ self._canvas.bind("", lambda e: self._bind_mousewheel())
+ self._canvas.bind("", lambda e: self._unbind_mousewheel())
+
+ # 创建 Checkbutton 列表
+ self._cb_widgets = []
+ for username, dname in contacts:
+ var = tk.BooleanVar(value=True)
+ self._vars[username] = var
+ label = f"{dname} ({username})" if dname != username else username
+ cb = ttk.Checkbutton(self._inner, text=label, variable=var)
+ cb.pack(anchor="w", padx=6, pady=1)
+ self._cb_widgets.append((username, dname, cb))
+
+ # 底部按钮
+ bottom = ttk.Frame(self)
+ bottom.pack(fill="x", padx=12, pady=(4, 10))
+
+ ttk.Button(bottom, text="确定", command=self._on_ok).pack(side="right", padx=4)
+ ttk.Button(bottom, text="取消", command=self._on_cancel).pack(side="right", padx=4)
+
+ def _bind_mousewheel(self):
+ self._canvas.bind_all("",
+ lambda e: self._canvas.yview_scroll(-1 * (e.delta // 120), "units"))
+
+ def _unbind_mousewheel(self):
+ self._canvas.unbind_all("")
+
+ def _toggle_all(self):
+ self._all_selected = not self._all_selected
+ for var in self._vars.values():
+ var.set(self._all_selected)
+ self._toggle_btn.configure(text="取消全选" if self._all_selected else "全选")
+
+ def _filter_list(self):
+ keyword = self._search_var.get().strip().lower()
+ for username, dname, cb in self._cb_widgets:
+ if not keyword or keyword in dname.lower() or keyword in username.lower():
+ cb.pack(anchor="w", padx=6, pady=1)
+ else:
+ cb.pack_forget()
+
+ def _on_ok(self):
+ formats = []
+ if self._fmt_csv.get():
+ formats.append("csv")
+ if self._fmt_html.get():
+ formats.append("html")
+ if self._fmt_json.get():
+ formats.append("json")
+
+ if not formats and not self._voice_var.get() and not self._sns_var.get():
+ from tkinter import messagebox
+ messagebox.showwarning("提示", "请至少选择一种导出格式、朋友圈导出或语音转换", parent=self)
+ return
+
+ self.result = {
+ "contacts": [u for u, var in self._vars.items() if var.get()],
+ "formats": formats,
+ "include_voice": self._voice_var.get(),
+ "include_images": self._image_var.get(),
+ "include_sns": self._sns_var.get(),
+ "include_sns_media": self._sns_media_var.get(),
+ }
+ self.destroy()
+
+ def _on_cancel(self):
+ self.result = None
+ self.destroy()
+
+
+class WxworkExportOptionsDialog(tk.Toplevel):
+ def __init__(self, parent, conversations):
+ """conversations: [{conversation_id, display_name, kind, message_count, last_time}, ...]"""
+ super().__init__(parent)
+ self.title("企业微信导出选项")
+ self.geometry("560x620")
+ self.transient(parent)
+ self.grab_set()
+ self.result = None
+ self.configure(bg="#f0f0f0")
+ self._conversations = conversations
+ self._vars = {}
+
+ fmt_frame = ttk.LabelFrame(self, text="导出格式", padding=6)
+ fmt_frame.pack(fill="x", padx=12, pady=(10, 4))
+
+ self._fmt_csv = tk.BooleanVar(value=True)
+ self._fmt_html = tk.BooleanVar(value=False)
+ self._fmt_json = tk.BooleanVar(value=False)
+
+ ttk.Checkbutton(fmt_frame, text="CSV(默认)", variable=self._fmt_csv).pack(side="left", padx=10)
+ ttk.Checkbutton(fmt_frame, text="HTML", variable=self._fmt_html).pack(side="left", padx=10)
+ ttk.Checkbutton(fmt_frame, text="JSON", variable=self._fmt_json).pack(side="left", padx=10)
+
+ top = ttk.Frame(self)
+ top.pack(fill="x", padx=12, pady=(4, 4))
+ ttk.Label(top, text=f"共 {len(conversations)} 个企业微信会话",
+ font=("Microsoft YaHei UI", 10)).pack(side="left")
+
+ self._all_selected = True
+ self._toggle_btn = ttk.Button(top, text="取消全选", command=self._toggle_all)
+ self._toggle_btn.pack(side="right")
+
+ search_frame = ttk.Frame(self)
+ search_frame.pack(fill="x", padx=12, pady=(0, 4))
+ self._search_var = tk.StringVar()
+ self._search_var.trace_add("write", lambda *_: self._filter_list())
+ ttk.Entry(search_frame, textvariable=self._search_var,
+ font=("Microsoft YaHei UI", 10)).pack(fill="x")
+
+ container = ttk.Frame(self)
+ container.pack(fill="both", expand=True, padx=12, pady=4)
+
+ self._canvas = tk.Canvas(container, bg="#ffffff", highlightthickness=0)
+ scrollbar = ttk.Scrollbar(container, orient="vertical", command=self._canvas.yview)
+ self._inner = ttk.Frame(self._canvas)
+ self._inner.bind("",
+ lambda e: self._canvas.configure(scrollregion=self._canvas.bbox("all")))
+ self._canvas.create_window((0, 0), window=self._inner, anchor="nw")
+ self._canvas.configure(yscrollcommand=scrollbar.set)
+ self._canvas.pack(side="left", fill="both", expand=True)
+ scrollbar.pack(side="right", fill="y")
+
+ self._canvas.bind("", lambda e: self._bind_mousewheel())
+ self._canvas.bind("", lambda e: self._unbind_mousewheel())
+
+ self._cb_widgets = []
+ for conv in conversations:
+ cid = conv["conversation_id"]
+ var = tk.BooleanVar(value=True)
+ self._vars[cid] = var
+ last_time = self._format_time(conv.get("last_time"))
+ suffix = f" · {last_time}" if last_time else ""
+ label = (
+ f"[{conv.get('kind', '会话')}] {conv.get('display_name') or cid}"
+ f" · {conv.get('message_count', 0)} 条{suffix}"
+ )
+ cb = ttk.Checkbutton(self._inner, text=label, variable=var)
+ cb.pack(anchor="w", padx=6, pady=1)
+ self._cb_widgets.append((cid, label.lower(), cb))
+
+ bottom = ttk.Frame(self)
+ bottom.pack(fill="x", padx=12, pady=(4, 10))
+ ttk.Button(bottom, text="确定", command=self._on_ok).pack(side="right", padx=4)
+ ttk.Button(bottom, text="取消", command=self._on_cancel).pack(side="right", padx=4)
+
+ def _format_time(self, value):
+ if not value:
+ return ""
+ try:
+ from datetime import datetime
+ return datetime.fromtimestamp(int(value)).strftime("%Y-%m-%d")
+ except Exception:
+ return ""
+
+ def _bind_mousewheel(self):
+ self._canvas.bind_all("",
+ lambda e: self._canvas.yview_scroll(-1 * (e.delta // 120), "units"))
+
+ def _unbind_mousewheel(self):
+ self._canvas.unbind_all("")
+
+ def _toggle_all(self):
+ self._all_selected = not self._all_selected
+ for var in self._vars.values():
+ var.set(self._all_selected)
+ self._toggle_btn.configure(text="取消全选" if self._all_selected else "全选")
+
+ def _filter_list(self):
+ keyword = self._search_var.get().strip().lower()
+ for _cid, label, cb in self._cb_widgets:
+ if not keyword or keyword in label:
+ cb.pack(anchor="w", padx=6, pady=1)
+ else:
+ cb.pack_forget()
+
+ def _on_ok(self):
+ formats = []
+ if self._fmt_csv.get():
+ formats.append("csv")
+ if self._fmt_html.get():
+ formats.append("html")
+ if self._fmt_json.get():
+ formats.append("json")
+ if not formats:
+ from tkinter import messagebox
+ messagebox.showwarning("提示", "请至少选择一种导出格式", parent=self)
+ return
+ self.result = {
+ "conversations": [cid for cid, var in self._vars.items() if var.get()],
+ "formats": formats,
+ }
+ self.destroy()
+
+ def _on_cancel(self):
+ self.result = None
+ self.destroy()
+
+
+class App(tk.Tk):
+ def __init__(self):
+ super().__init__()
+ self.title("WeChat Decrypt 工具箱")
+ self.geometry("820x600")
+ self.resizable(True, True)
+ self.configure(bg="#f0f0f0")
+ self._running = False
+ self._auto_export = False
+ self._selected_contacts = None
+ self._export_formats = None
+ self._include_voice = False
+ self._include_images = True
+ self._include_sns = False
+ self._include_sns_media = False
+ self._selected_wxwork_conversations = None
+ self._wxwork_export_formats = None
+
+ self._build_ui()
+
+ # ── UI 构建 ────────────────────────────────────────────────────────────
+ def _build_ui(self):
+ style = ttk.Style(self)
+ style.theme_use("clam")
+ style.configure("Big.TButton", font=("Microsoft YaHei UI", 11), padding=(16, 10))
+ style.configure("TLabel", font=("Microsoft YaHei UI", 10), background="#f0f0f0")
+
+ # 标题
+ title = ttk.Label(self, text="WeChat Decrypt 工具箱", font=("Microsoft YaHei UI", 16, "bold"))
+ title.pack(pady=(14, 6))
+
+ # 按钮区域
+ btn_frame = ttk.Frame(self)
+ btn_frame.pack(fill="x", padx=20, pady=(4, 4))
+
+ self.btn_decrypt = ttk.Button(
+ btn_frame, text="① 微信解密", style="Big.TButton",
+ command=lambda: self._run_task("decrypt")
+ )
+ self.btn_decrypt.pack(side="left", expand=True, fill="x", padx=4)
+
+ self.btn_imgkey = ttk.Button(
+ btn_frame, text="② 图片密钥", style="Big.TButton",
+ command=lambda: self._run_task("find_image_key")
+ )
+ self.btn_imgkey.pack(side="left", expand=True, fill="x", padx=4)
+
+ self.btn_export = ttk.Button(
+ btn_frame, text="③ 导出数据", style="Big.TButton",
+ command=lambda: self._run_task("export")
+ )
+ self.btn_export.pack(side="left", expand=True, fill="x", padx=4)
+
+ self.btn_sns = ttk.Button(
+ btn_frame, text="④ 朋友圈图片", style="Big.TButton",
+ command=lambda: self._run_task("decrypt_sns")
+ )
+ self.btn_sns.pack(side="left", expand=True, fill="x", padx=4)
+
+ wxwork_frame = ttk.Frame(self)
+ wxwork_frame.pack(fill="x", padx=20, pady=(0, 6))
+
+ self.btn_wxwork = ttk.Button(
+ wxwork_frame, text="⑤ 企业微信解密", style="Big.TButton",
+ command=lambda: self._run_task("wxwork_decrypt")
+ )
+ self.btn_wxwork.pack(side="left", expand=True, fill="x", padx=4)
+
+ self.btn_wxwork_export = ttk.Button(
+ wxwork_frame, text="⑥ 企业微信导出", style="Big.TButton",
+ command=lambda: self._run_task("wxwork_export")
+ )
+ self.btn_wxwork_export.pack(side="left", expand=True, fill="x", padx=4)
+
+ # 提示信息
+ tips_frame = ttk.LabelFrame(self, text="使用提示", padding=6)
+ tips_frame.pack(fill="x", padx=20, pady=(0, 4))
+ tips_text = (
+ "• 微信解密:需要微信正在运行中,会自动提取密钥并解密\n"
+ "• 查找图片密钥:先在微信中打开 2-3 张图片查看,然后立即运行\n"
+ "• 导出数据:选择联系人和格式,可同时导出消息/图片/语音\n"
+ "• 朋友圈图片:解密朋友圈缓存图片(_t缩略图自动跳过)\n"
+ "• 企业微信解密:需要企业微信正在运行中,输出到 wxwork_decrypted/\n"
+ "• 企业微信导出:选择某个人或群,输出 CSV / HTML / JSON 到 wxwork_export/"
+ )
+ ttk.Label(tips_frame, text=tips_text, font=("Microsoft YaHei UI", 9),
+ wraplength=760, justify="left").pack(anchor="w")
+
+ # 进度条
+ self.progress = ttk.Progressbar(self, mode="indeterminate")
+ self.progress.pack(fill="x", padx=20, pady=(0, 4))
+
+ # 日志区域
+ log_label = ttk.Label(self, text="运行日志:")
+ log_label.pack(anchor="w", padx=20)
+
+ self.log = scrolledtext.ScrolledText(
+ self, wrap="word", height=18,
+ font=("Consolas", 10), bg="#1e1e1e", fg="#d4d4d4",
+ insertbackground="#fff", state="disabled"
+ )
+ self.log.pack(fill="both", expand=True, padx=20, pady=(2, 10))
+
+ # 底部状态
+ self.status_var = tk.StringVar(value="就绪")
+ status = ttk.Label(self, textvariable=self.status_var, font=("Microsoft YaHei UI", 9))
+ status.pack(anchor="w", padx=20, pady=(0, 8))
+
+ # ── 日志写入 ───────────────────────────────────────────────────────────
+ def _log(self, text: str):
+ self.log.configure(state="normal")
+ self.log.insert("end", text)
+ self.log.see("end")
+ self.log.configure(state="disabled")
+
+ def _clear_log(self):
+ self.log.configure(state="normal")
+ self.log.delete("1.0", "end")
+ self.log.configure(state="disabled")
+
+ # ── 按钮状态 ───────────────────────────────────────────────────────────
+ def _set_buttons(self, enabled: bool):
+ state = "normal" if enabled else "disabled"
+ self.btn_decrypt.configure(state=state)
+ self.btn_imgkey.configure(state=state)
+ self.btn_export.configure(state=state)
+ self.btn_sns.configure(state=state)
+ self.btn_wxwork.configure(state=state)
+ self.btn_wxwork_export.configure(state=state)
+
+ # ── 任务调度 ───────────────────────────────────────────────────────────
+ def _run_task(self, task: str):
+ if self._running:
+ return
+ self._running = True
+ self._selected_contacts = None
+ self._export_formats = None
+ self._include_voice = False
+ self._include_sns = False
+ self._include_sns_media = False
+ self._selected_wxwork_conversations = None
+ self._wxwork_export_formats = None
+ self._clear_log()
+ self._set_buttons(False)
+
+ if task == "export":
+ self.progress.start(15)
+ self.status_var.set("正在扫描联系人...")
+ threading.Thread(
+ target=self._discover_and_select, daemon=True
+ ).start()
+ elif task == "find_image_key":
+ self.progress.start(15)
+ self.status_var.set("正在扫描微信进程内存...")
+ threading.Thread(target=self._exec_task, args=(task,), daemon=True).start()
+ elif task == "decrypt_sns":
+ self.progress.start(15)
+ self.status_var.set("正在解密朋友圈图片...")
+ threading.Thread(target=self._exec_task, args=(task,), daemon=True).start()
+ elif task == "wxwork_decrypt":
+ self.progress.start(15)
+ self.status_var.set("正在解密企业微信数据库...")
+ threading.Thread(target=self._exec_wxwork_decrypt, daemon=True).start()
+ elif task == "wxwork_export":
+ self.progress.start(15)
+ self.status_var.set("正在扫描企业微信会话...")
+ threading.Thread(target=self._discover_wxwork_and_select, daemon=True).start()
+ else:
+ self.progress.start(15)
+ labels = {"decrypt": "解密数据库"}
+ self.status_var.set(f"正在{labels.get(task, task)}...")
+ threading.Thread(target=self._exec_task, args=(task,), daemon=True).start()
+
+ def _discover_and_select(self):
+ """后台扫描联系人,然后在主线程弹出选择对话框"""
+ try:
+ contacts, has_voice = _discover_contacts()
+ except Exception as e:
+ self.after(0, self._log, f"扫描联系人失败: {e}\n")
+ self.after(0, self._on_task_done)
+ return
+
+ if not contacts:
+ self.after(0, self._log, "未找到任何联系人/会话\n")
+ self.after(0, self._on_task_done)
+ return
+
+ self.after(0, self._show_contact_dialog, contacts, has_voice)
+
+ def _show_contact_dialog(self, contacts, has_voice):
+ self.progress.stop()
+ self.status_var.set(f"请选择导出选项 ({len(contacts)} 个会话)")
+
+ dlg = ExportOptionsDialog(self, contacts, has_voice=has_voice)
+ self.wait_window(dlg)
+
+ if dlg.result is None:
+ self._on_task_done()
+ return
+
+ if not dlg.result["contacts"]:
+ self._log("未选择任何联系人\n")
+ self._on_task_done()
+ return
+
+ self._selected_contacts = dlg.result["contacts"]
+ self._export_formats = dlg.result["formats"]
+ self._include_voice = dlg.result["include_voice"]
+ self._include_images = dlg.result["include_images"]
+ self._include_sns = dlg.result["include_sns"]
+ self._include_sns_media = dlg.result["include_sns_media"]
+
+ self._clear_log()
+ self.progress.start(15)
+ n_sel = len(dlg.result["contacts"])
+ parts = []
+ if self._export_formats:
+ parts.append(f"导出 {'/'.join(f.upper() for f in self._export_formats)}")
+ if self._include_sns:
+ parts.append("朋友圈")
+ if self._include_voice:
+ parts.append("转换语音")
+ action = " + ".join(parts) or "处理"
+ self.status_var.set(f"正在{action}...({n_sel}/{len(contacts)} 个联系人)")
+ threading.Thread(target=self._exec_combined, daemon=True).start()
+
+ def _discover_wxwork_and_select(self):
+ """后台扫描企业微信会话,然后在主线程弹出选择对话框"""
+ try:
+ from export_wxwork_messages import discover_conversations
+ conversations = discover_conversations()
+ except Exception as e:
+ self.after(0, self._log, f"扫描企业微信会话失败: {e}\n")
+ self.after(0, self._on_task_done)
+ return
+
+ if not conversations:
+ self.after(0, self._log, "未找到任何企业微信会话,请先运行「企业微信解密」\n")
+ self.after(0, self._on_task_done)
+ return
+
+ self.after(0, self._show_wxwork_dialog, conversations)
+
+ def _show_wxwork_dialog(self, conversations):
+ self.progress.stop()
+ self.status_var.set(f"请选择企业微信导出选项 ({len(conversations)} 个会话)")
+
+ dlg = WxworkExportOptionsDialog(self, conversations)
+ self.wait_window(dlg)
+
+ if dlg.result is None:
+ self._on_task_done()
+ return
+
+ if not dlg.result["conversations"]:
+ self._log("未选择任何企业微信会话\n")
+ self._on_task_done()
+ return
+
+ self._selected_wxwork_conversations = dlg.result["conversations"]
+ self._wxwork_export_formats = dlg.result["formats"]
+
+ self._clear_log()
+ self.progress.start(15)
+ n_sel = len(dlg.result["conversations"])
+ self.status_var.set(
+ f"正在导出企业微信 {'/'.join(f.upper() for f in self._wxwork_export_formats)}..."
+ f"({n_sel}/{len(conversations)} 个会话)"
+ )
+ threading.Thread(target=self._exec_wxwork_export, daemon=True).start()
+
+ # ── 子进程执行 ─────────────────────────────────────────────────────────
+ def _run_subprocess(self, task: str) -> int:
+ """运行子进程,返回退出码
+
+ 打包成 exe (`sys.frozen=True`) 时 sys.executable 就是 exe 本身,
+ `--task` 是它的子命令,直接走 [exe, --task, ...] 即可。
+ 开发模式 (python app_gui.py) 时 sys.executable 是 python 解释器,
+ 需要把当前脚本路径 __file__ 加上,否则 `python --task` 会被解释器
+ 误判为 `python --task` (报 unknown option)。
+ """
+ if getattr(sys, "frozen", False):
+ cmd = [sys.executable, "--task", task]
+ else:
+ cmd = [sys.executable, os.path.abspath(__file__), "--task", task]
+ self.after(0, self._log, f">>> {' '.join(cmd)}\n\n")
+
+ env = os.environ.copy()
+ env["PYTHONIOENCODING"] = "utf-8"
+ env["WECHAT_DECRYPT_APP_DIR"] = BASE_DIR
+ env["WECHAT_DECRYPT_GUI"] = "1"
+ env["WECHAT_DECRYPT_NONINTERACTIVE"] = "1"
+
+ if self._selected_contacts:
+ env["WECHAT_EXPORT_CONTACTS"] = ",".join(self._selected_contacts)
+ if self._export_formats:
+ env["WECHAT_EXPORT_FORMATS"] = ",".join(self._export_formats)
+ env["WECHAT_EXPORT_IMAGES"] = "1" if getattr(self, '_include_images', True) else "0"
+ if getattr(self, '_include_sns_media', False):
+ env["WECHAT_SNS_DOWNLOAD_MEDIA"] = "1"
+ if self._selected_wxwork_conversations:
+ env["WXWORK_EXPORT_CONVERSATIONS"] = ",".join(self._selected_wxwork_conversations)
+ if self._wxwork_export_formats:
+ env["WXWORK_EXPORT_FORMATS"] = ",".join(self._wxwork_export_formats)
+
+ proc = subprocess.Popen(
+ cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ cwd=BASE_DIR,
+ env=env,
+ creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0,
+ )
+
+ for raw in proc.stdout:
+ line = raw.decode("utf-8", errors="replace")
+ self.after(0, self._log, line)
+
+ proc.wait()
+ return proc.returncode
+
+ def _exec_combined(self):
+ """执行导出(消息 + 可选语音)"""
+ try:
+ if self._export_formats:
+ rc = self._run_subprocess("export")
+ if rc != 0:
+ self.after(0, self._log, f"\n❌ 导出失败 (返回码 {rc})\n")
+ self.after(0, self.status_var.set, f"失败 (返回码 {rc})")
+ return
+
+ if getattr(self, '_include_sns', False):
+ if self._export_formats:
+ self.after(0, self._log, "\n\n━━━ 开始导出朋友圈 ━━━\n\n")
+ rc = self._run_subprocess("export_sns")
+ if rc != 0:
+ self.after(0, self._log, f"\n❌ 朋友圈导出失败 (返回码 {rc})\n")
+ self.after(0, self.status_var.set, f"朋友圈导出失败 (返回码 {rc})")
+ return
+
+ if self._include_voice:
+ if self._export_formats or getattr(self, '_include_sns', False):
+ self.after(0, self._log, "\n\n━━━ 开始转换语音 ━━━\n\n")
+ rc = self._run_subprocess("voice")
+ if rc != 0:
+ self.after(0, self._log, f"\n❌ 语音转换失败 (返回码 {rc})\n")
+ self.after(0, self.status_var.set, f"语音转换失败 (返回码 {rc})")
+ return
+
+ self.after(0, self._log, "\n✅ 全部完成!\n")
+ self.after(0, self.status_var.set, "完成")
+ except Exception as e:
+ self.after(0, self._log, f"\n❌ 异常: {e}\n")
+ self.after(0, self.status_var.set, "异常")
+ finally:
+ self._selected_contacts = None
+ self._export_formats = None
+ self._include_voice = False
+ self._include_images = True
+ self._include_sns = False
+ self._include_sns_media = False
+ self.after(0, self._on_task_done)
+
+ def _exec_wxwork_decrypt(self):
+ """执行企业微信 key 提取 + 数据库解密。"""
+ try:
+ self.after(0, self._log, "━━━ 开始提取企业微信密钥 ━━━\n\n")
+ rc = self._run_subprocess("find_wxwork_keys")
+ if rc != 0:
+ self.after(0, self._log, f"\n❌ 企业微信密钥提取失败 (返回码 {rc})\n")
+ self.after(0, self.status_var.set, f"企业微信密钥提取失败 (返回码 {rc})")
+ return
+
+ self.after(0, self._log, "\n\n━━━ 开始解密企业微信数据库 ━━━\n\n")
+ rc = self._run_subprocess("decrypt_wxwork")
+ if rc != 0:
+ self.after(0, self._log, f"\n❌ 企业微信数据库解密失败 (返回码 {rc})\n")
+ self.after(0, self.status_var.set, f"企业微信解密失败 (返回码 {rc})")
+ return
+
+ self.after(0, self._log, "\n✅ 企业微信解密完成!输出目录: wxwork_decrypted\n")
+ self.after(0, self.status_var.set, "企业微信解密完成")
+ except Exception as e:
+ self.after(0, self._log, f"\n❌ 异常: {e}\n")
+ self.after(0, self.status_var.set, "异常")
+ finally:
+ self.after(0, self._on_task_done)
+
+ def _exec_wxwork_export(self):
+ """执行企业微信消息导出。"""
+ try:
+ rc = self._run_subprocess("export_wxwork")
+ if rc != 0:
+ self.after(0, self._log, f"\n❌ 企业微信导出失败 (返回码 {rc})\n")
+ self.after(0, self.status_var.set, f"企业微信导出失败 (返回码 {rc})")
+ return
+ self.after(0, self._log, "\n✅ 企业微信导出完成!输出目录: wxwork_export\n")
+ self.after(0, self.status_var.set, "企业微信导出完成")
+ except Exception as e:
+ self.after(0, self._log, f"\n❌ 异常: {e}\n")
+ self.after(0, self.status_var.set, "异常")
+ finally:
+ self._selected_wxwork_conversations = None
+ self._wxwork_export_formats = None
+ self.after(0, self._on_task_done)
+
+ def _exec_task(self, task: str):
+ """执行单一任务(解密)"""
+ try:
+ rc = self._run_subprocess(task)
+ if rc == 0:
+ self.after(0, self._log, "\n✅ 完成!\n")
+ self.after(0, self.status_var.set, "完成")
+ if task == "decrypt":
+ self._auto_export = True
+ else:
+ self.after(0, self._log, f"\n❌ 进程退出,返回码: {rc}\n")
+ self.after(0, self.status_var.set, f"失败 (返回码 {rc})")
+ except Exception as e:
+ self.after(0, self._log, f"\n❌ 异常: {e}\n")
+ self.after(0, self.status_var.set, "异常")
+ finally:
+ self.after(0, self._on_task_done)
+
+ def _on_task_done(self):
+ self._running = False
+ self.progress.stop()
+ self._set_buttons(True)
+ if self._auto_export:
+ self._auto_export = False
+ self._log("\n解密完成,自动进入导出流程...\n\n")
+ self.after(500, lambda: self._run_task("export"))
+
+
+if __name__ == "__main__":
+ app = App()
+ app.mainloop()