feat: 支持双模式 exe 入口

This commit is contained in:
phoenixray2000
2026-05-18 01:53:20 +08:00
committed by ylytdeng
parent 728dbb72df
commit f3c32d4ca2
8 changed files with 363 additions and 27 deletions

View File

@@ -1,4 +1,4 @@
# WeChatDecrypt 使用说明 (Web UI ) # WeChatDecrypt 使用说明 (Web UI + CLI 双模式)
## 快速开始 ## 快速开始
@@ -7,6 +7,25 @@
3. 浏览器**自动打开** `http://localhost:5678` (没自动开就手动复制粘贴) 3. 浏览器**自动打开** `http://localhost:5678` (没自动开就手动复制粘贴)
4. 右上角点 **🛠️ 工具** 展开工具箱, 按 tab 切到你要的板块 4. 右上角点 **🛠️ 工具** 展开工具箱, 按 tab 切到你要的板块
## CLI 用法
同一个 `WeChatDecrypt.exe` 带参数运行时会作为命令行入口:
```powershell
.\WeChatDecrypt.exe --help
.\WeChatDecrypt.exe status
.\WeChatDecrypt.exe decrypt
.\WeChatDecrypt.exe export --from-plan-csv export_plan.csv
.\WeChatDecrypt.exe export-all --write-plan-csv export_plan.csv
```
无参数或 `web` 子命令仍启动 Web UI:
```powershell
.\WeChatDecrypt.exe
.\WeChatDecrypt.exe web
```
## 工具箱 3 个 tab ## 工具箱 3 个 tab
### 📱 个人微信 ### 📱 个人微信
@@ -101,6 +120,6 @@ python app_gui.py # → 弹 tkinter 窗口
- Windows-only (不跨平台) - Windows-only (不跨平台)
- 没法远程访问 - 没法远程访问
单 exe **默认入口是 Web UI**。要打 tkinter 版的 exe, 改 单 exe **默认入口是 Web UI + CLI 分发器**。要打 tkinter 版的 exe, 改
`WeChatDecrypt.spec``Analysis(['monitor_web.py'])` `WeChatDecrypt.spec``Analysis(['wechat_decrypt_launcher.py'])`
`Analysis(['app_gui.py'])``build.bat` `Analysis(['app_gui.py'])``build.bat`

View File

@@ -517,8 +517,19 @@ build.bat # → dist\WeChatDecrypt.exe (~20MB)
``` ```
**单 exe 默认入口是 Web UI** (双击 → 自动开浏览器), 因为 Web UI 体验更好。 **单 exe 默认入口是 Web UI** (双击 → 自动开浏览器), 因为 Web UI 体验更好。
要打包成 tkinter 入口的话改 `WeChatDecrypt.spec` 里的 `Analysis(['monitor_web.py'])` 同一个 exe 也支持 CLI 子命令:
`Analysis(['app_gui.py'])``build.bat`
```powershell
.\WeChatDecrypt.exe --help
.\WeChatDecrypt.exe status
.\WeChatDecrypt.exe decrypt
.\WeChatDecrypt.exe export --from-plan-csv export_plan.csv
.\WeChatDecrypt.exe export-all --write-plan-csv export_plan.csv
```
要打包成 tkinter 入口的话改 `WeChatDecrypt.spec` 里的
`Analysis(['wechat_decrypt_launcher.py'])``Analysis(['app_gui.py'])`
`build.bat`
> 语音转 MP3 需要系统安装 [FFmpeg](https://ffmpeg.org/download.html) 并加入 PATH。 > 语音转 MP3 需要系统安装 [FFmpeg](https://ffmpeg.org/download.html) 并加入 PATH。

View File

@@ -1,17 +1,18 @@
# -*- mode: python ; coding: utf-8 -*- # -*- mode: python ; coding: utf-8 -*-
# PyInstaller spec — 打包 monitor_web.py 为单 exe。 # PyInstaller spec — 打包 wechat_decrypt_launcher.py 为双模式单 exe。
# #
# 启动后: # 启动后:
# 1. 起 HTTP 服务 + 监听线程 (如 keys 已就绪) # 1. 无参数/ web: 起 HTTP 服务 + 自动开浏览器到 http://localhost:5678
# 2. 自动开浏览器到 http://localhost:5678 # 2. 带 CLI 参数: 分发到 main.py / export_all_chats.py / 其他工具脚本
# 3. 用户在 Web UI 工具箱里操作 (解密 / 导出 / 朋友圈 / 企微 / 语音) # 3. 兼容 Web UI 内部 sys.executable + script.py 的子进程调用
# #
# (历史: 这个 spec 之前打包 tkinter app_gui.py, 现已删除 — 完全切到 Web UI) # (历史: 这个 spec 之前打包 tkinter app_gui.py, 现默认是 Web UI + CLI)
from PyInstaller.utils.hooks import collect_all from PyInstaller.utils.hooks import collect_all
# 所有子进程 (工具按钮触发) 需要的脚本, 打进 exe 同目录 # 所有子进程 (工具按钮触发) 需要的脚本, 打进 exe 同目录
datas = [ datas = [
('monitor_web.py', '.'),
('main.py', '.'), ('main.py', '.'),
('config.py', '.'), ('config.py', '.'),
('config.example.json', '.'), ('config.example.json', '.'),
@@ -44,7 +45,13 @@ datas = [
binaries = [] binaries = []
hiddenimports = [ hiddenimports = [
# 显式列, 避免 PyInstaller 漏 detect 导致打包后 import 报错 # 显式列, 避免 PyInstaller 漏 detect 导致打包后 import 报错
'argparse', 'csv', 'glob', 'hashlib', 'hmac', 'http.server', 'json',
'platform', 'queue', 'socketserver', 'sqlite3', '_sqlite3',
'subprocess', 'tempfile', 'threading', 'urllib.parse', 'uuid', 'wave',
'xml.etree.ElementTree',
'mcp', 'mcp.server', 'mcp.server.fastmcp',
'Crypto', 'Crypto.Cipher', 'Crypto.Cipher.AES', 'Crypto', 'Crypto.Cipher', 'Crypto.Cipher.AES',
'Crypto.Util', 'Crypto.Util.Padding',
'zstandard', 'zstandard',
'pilk', 'pilk',
] ]
@@ -55,7 +62,7 @@ datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
a = Analysis( a = Analysis(
['monitor_web.py'], # ← 入口从 app_gui.py 改成 monitor_web.py ['wechat_decrypt_launcher.py'],
pathex=[], pathex=[],
binaries=binaries, binaries=binaries,
datas=datas, datas=datas,

View File

@@ -1,7 +1,7 @@
@echo off @echo off
chcp 65001 >nul chcp 65001 >nul
echo ======================================== echo ========================================
echo WeChatDecrypt 打包脚本 (Web UI ) echo WeChatDecrypt 打包脚本 (Web UI + CLI 双模式)
echo ======================================== echo ========================================
echo. echo.
@@ -32,6 +32,7 @@ for %%F in (dist\WeChatDecrypt.exe) do echo 大小: %%~zF bytes
echo. echo.
echo 使用: 双击 WeChatDecrypt.exe echo 使用: 双击 WeChatDecrypt.exe
echo ^→ 自动启动浏览器到 http://localhost:5678 (Web UI) echo ^→ 自动启动浏览器到 http://localhost:5678 (Web UI)
echo CLI: WeChatDecrypt.exe --help
echo ======================================== echo ========================================
echo. echo.
pause pause

View File

@@ -10,18 +10,23 @@ import sys
CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json") CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json")
# 打包后 __file__ 指向临时目录,优先使用环境变量或5 cwd # 打包后 __file__ 指向临时目录,优先使用环境变量指定的 exe 所在目录。
def _app_base_dir(): def _app_base_dir():
d = os.environ.get("WECHAT_DECRYPT_APP_DIR") d = os.environ.get("WECHAT_DECRYPT_APP_DIR")
if d and os.path.isdir(d): if d and os.path.isdir(d):
return d return d
return os.path.dirname(os.path.abspath(__file__)) return os.path.dirname(os.path.abspath(__file__))
def _config_file_path(): def _config_file_path():
base = _app_base_dir() if os.environ.get("WECHAT_DECRYPT_APP_DIR"):
p = os.path.join(base, "config.json") return os.path.join(_app_base_dir(), "config.json")
p = os.path.join(_app_base_dir(), "config.json")
if os.path.exists(p): if os.path.exists(p):
return p return p
return CONFIG_FILE return CONFIG_FILE
_SYSTEM = platform.system().lower() _SYSTEM = platform.system().lower()
if _SYSTEM == "linux": if _SYSTEM == "linux":
@@ -278,7 +283,7 @@ def load_config():
# "all_keys.json"(项目根相对),也能写 "~/Documents/wechat_decrypted" / # "all_keys.json"(项目根相对),也能写 "~/Documents/wechat_decrypted" /
# "$HOME/wechat" / "%USERPROFILE%\\wechat"(跨用户便携)。 # "$HOME/wechat" / "%USERPROFILE%\\wechat"(跨用户便携)。
# 空字串 / null 不再触发 TypeError(用 cfg.get 而非 in)。 # 空字串 / null 不再触发 TypeError(用 cfg.get 而非 in)。
base = os.path.dirname(os.path.abspath(__file__)) base = _app_base_dir()
if cfg.get("db_dir"): if cfg.get("db_dir"):
cfg["db_dir"] = os.path.expanduser(os.path.expandvars(cfg["db_dir"])) cfg["db_dir"] = os.path.expanduser(os.path.expandvars(cfg["db_dir"]))
for key in ("keys_file", "decrypted_dir", "decoded_image_dir"): for key in ("keys_file", "decrypted_dir", "decoded_image_dir"):

View File

@@ -16,6 +16,7 @@ import xml.etree.ElementTree as ET
from Crypto.Cipher import AES from Crypto.Cipher import AES
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
import zstandard as zstd import zstandard as zstd
from config import _config_file_path, _DEFAULT
from decode_image import ImageResolver from decode_image import ImageResolver
from key_utils import get_key_info, key_path_variants, strip_key_metadata from key_utils import get_key_info, key_path_variants, strip_key_metadata
@@ -30,13 +31,16 @@ WAL_FRAME_HEADER_SZ = 24
# ============ 配置加载 ============ # ============ 配置加载 ============
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
CONFIG_FILE = os.path.join(SCRIPT_DIR, "config.json") CONFIG_FILE = _config_file_path()
with open(CONFIG_FILE, encoding="utf-8") as f: try:
with open(CONFIG_FILE, encoding="utf-8") as f:
_cfg = json.load(f) _cfg = json.load(f)
except FileNotFoundError:
_cfg = dict(_DEFAULT)
for _key in ("keys_file", "decrypted_dir"): for _key in ("keys_file", "decrypted_dir"):
if _key in _cfg and not os.path.isabs(_cfg[_key]): if _key in _cfg and not os.path.isabs(_cfg[_key]):
_cfg[_key] = os.path.join(SCRIPT_DIR, _cfg[_key]) _cfg[_key] = os.path.join(os.path.dirname(CONFIG_FILE), _cfg[_key])
DB_DIR = _cfg["db_dir"] DB_DIR = _cfg["db_dir"]
KEYS_FILE = _cfg["keys_file"] KEYS_FILE = _cfg["keys_file"]
@@ -55,8 +59,11 @@ if not DECODED_IMAGE_DIR:
elif not os.path.isabs(DECODED_IMAGE_DIR): elif not os.path.isabs(DECODED_IMAGE_DIR):
DECODED_IMAGE_DIR = os.path.join(SCRIPT_DIR, DECODED_IMAGE_DIR) DECODED_IMAGE_DIR = os.path.join(SCRIPT_DIR, DECODED_IMAGE_DIR)
with open(KEYS_FILE, encoding="utf-8") as f: try:
with open(KEYS_FILE, encoding="utf-8") as f:
ALL_KEYS = strip_key_metadata(json.load(f)) ALL_KEYS = strip_key_metadata(json.load(f))
except FileNotFoundError:
ALL_KEYS = {}
# ============ 解密函数 ============ # ============ 解密函数 ============

147
tests/test_exe_launcher.py Normal file
View File

@@ -0,0 +1,147 @@
import io
import os
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
import wechat_decrypt_launcher
import config
class LauncherDispatchTests(unittest.TestCase):
def _dispatch(self, argv):
calls = []
stdout = io.StringIO()
stderr = io.StringIO()
def runner(script, script_args):
calls.append((script, script_args))
return 0
code = wechat_decrypt_launcher.dispatch(
argv,
script_runner=runner,
stdout=stdout,
stderr=stderr,
)
return code, calls, stdout.getvalue(), stderr.getvalue()
def test_no_args_starts_web_ui(self):
code, calls, _, _ = self._dispatch([])
self.assertEqual(code, 0)
self.assertEqual(calls, [("monitor_web.py", [])])
def test_web_command_starts_web_ui(self):
code, calls, _, _ = self._dispatch(["web"])
self.assertEqual(code, 0)
self.assertEqual(calls, [("monitor_web.py", [])])
def test_decrypt_command_routes_to_main(self):
code, calls, _, _ = self._dispatch(["decrypt"])
self.assertEqual(code, 0)
self.assertEqual(calls, [("main.py", ["decrypt"])])
def test_export_all_command_routes_to_export_all_chats(self):
code, calls, _, _ = self._dispatch([
"export-all",
"--write-plan-csv",
"export_plan.csv",
])
self.assertEqual(code, 0)
self.assertEqual(
calls,
[(
"export_all_chats.py",
["--write-plan-csv", "export_plan.csv"],
)],
)
def test_script_style_main_py_call_routes_to_main(self):
code, calls, _, _ = self._dispatch(["main.py", "decrypt"])
self.assertEqual(code, 0)
self.assertEqual(calls, [("main.py", ["decrypt"])])
def test_unknown_command_returns_error_without_running_script(self):
code, calls, stdout, stderr = self._dispatch(["unknown"])
self.assertEqual(code, 2)
self.assertEqual(calls, [])
self.assertIn("未知命令", stderr)
self.assertIn("WeChatDecrypt.exe", stdout)
def test_prepare_runtime_overrides_stale_meipass_app_dir(self):
with tempfile.TemporaryDirectory() as exe_dir, \
tempfile.TemporaryDirectory() as stale_dir, \
patch.object(wechat_decrypt_launcher.sys, "frozen", True, create=True), \
patch.object(
wechat_decrypt_launcher.sys,
"executable",
os.path.join(exe_dir, "WeChatDecrypt.exe"),
), \
patch.dict(os.environ, {"WECHAT_DECRYPT_APP_DIR": stale_dir}):
old_cwd = os.getcwd()
try:
wechat_decrypt_launcher._prepare_runtime()
self.assertEqual(os.environ["WECHAT_DECRYPT_APP_DIR"], exe_dir)
self.assertEqual(os.getcwd(), exe_dir)
finally:
os.chdir(old_cwd)
def test_configure_stdio_uses_utf8(self):
stream = io.TextIOWrapper(io.BytesIO(), encoding="cp936")
with patch.object(wechat_decrypt_launcher.sys, "stdout", stream), \
patch.object(wechat_decrypt_launcher.sys, "stderr", stream):
wechat_decrypt_launcher._configure_stdio()
self.assertEqual(stream.encoding.lower().replace("_", "-"), "utf-8")
def test_pyinstaller_spec_includes_sqlite_hidden_imports(self):
with open("WeChatDecrypt.spec", encoding="utf-8") as f:
spec = f.read()
self.assertIn("'sqlite3'", spec)
self.assertIn("'_sqlite3'", spec)
self.assertIn("'wave'", spec)
self.assertIn("'mcp.server.fastmcp'", spec)
self.assertIn("'Crypto.Util'", spec)
self.assertIn("'Crypto.Util.Padding'", spec)
def test_config_file_path_uses_app_dir_even_when_config_missing(self):
with tempfile.TemporaryDirectory() as app_dir, \
patch.dict(os.environ, {"WECHAT_DECRYPT_APP_DIR": app_dir}):
expected = os.path.join(app_dir, "config.json")
self.assertEqual(config._config_file_path(), expected)
def test_mcp_server_import_allows_missing_keys_file(self):
with tempfile.TemporaryDirectory() as app_dir:
env = dict(os.environ)
env["WECHAT_DECRYPT_APP_DIR"] = app_dir
result = subprocess.run(
[
sys.executable,
"-X",
"utf8",
"-c",
"import mcp_server; print(mcp_server.ALL_KEYS)",
],
cwd=os.getcwd(),
env=env,
capture_output=True,
text=True,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("{}", result.stdout)
if __name__ == "__main__":
unittest.main()

139
wechat_decrypt_launcher.py Normal file
View File

@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""WeChatDecrypt.exe 双模式入口。
无参数时启动 Web UI带参数时分发到现有 CLI 脚本。该入口同时兼容
PyInstaller onefile 里 Web UI 子进程使用的 ``sys.executable script.py``
调用形态。
"""
import os
import runpy
import sys
MAIN_COMMANDS = {"decrypt", "export", "all", "status", "-s", "decode-images"}
HELP_COMMANDS = {"help", "-h", "--help"}
SCRIPT_ENTRYPOINTS = {
"main.py",
"monitor_web.py",
"export_all_chats.py",
"find_image_key.py",
"decrypt_sns.py",
"export_sns.py",
"find_wxwork_keys.py",
"decrypt_wxwork_db.py",
"export_wxwork_messages.py",
"voice_to_mp3.py",
"batch_decrypt_images.py",
"decrypt_db.py",
}
def _app_base_dir():
if getattr(sys, "frozen", False):
return os.path.dirname(sys.executable)
return os.path.dirname(os.path.abspath(__file__))
def _script_dir():
return getattr(sys, "_MEIPASS", _app_base_dir())
def _prepare_runtime():
base_dir = _app_base_dir()
os.environ["WECHAT_DECRYPT_APP_DIR"] = base_dir
os.chdir(base_dir)
def _configure_stdio():
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
stream.reconfigure(encoding="utf-8", errors="replace")
def print_usage(stream=None):
stream = stream or sys.stdout
stream.write(
"WeChatDecrypt.exe 用法:\n"
"\n"
" WeChatDecrypt.exe 启动 Web UI\n"
" WeChatDecrypt.exe web 启动 Web UI\n"
" WeChatDecrypt.exe status 查看状态\n"
" WeChatDecrypt.exe decrypt 提取密钥并解密数据库\n"
" WeChatDecrypt.exe export [args...] 解密并批量导出聊天\n"
" WeChatDecrypt.exe all 密钥 -> 解密 -> 导出\n"
" WeChatDecrypt.exe decode-images [args...] 批量解密 .dat 图片\n"
" WeChatDecrypt.exe export-all [args...] 直接调用 export_all_chats.py\n"
"\n"
"兼容脚本式调用:\n"
" WeChatDecrypt.exe main.py decrypt\n"
" WeChatDecrypt.exe export_all_chats.py --write-plan-csv export_plan.csv\n"
)
def _resolve_script(argv):
if not argv:
return "monitor_web.py", []
cmd = argv[0]
if cmd == "web":
return "monitor_web.py", argv[1:]
if cmd in MAIN_COMMANDS:
return "main.py", argv
if cmd == "export-all":
return "export_all_chats.py", argv[1:]
if cmd in SCRIPT_ENTRYPOINTS:
return cmd, argv[1:]
return None, None
def run_script(script, script_args):
_prepare_runtime()
script_path = os.path.join(_script_dir(), script)
if not os.path.exists(script_path):
script_path = os.path.join(_app_base_dir(), script)
if not os.path.exists(script_path):
raise FileNotFoundError(f"脚本不存在: {script_path}")
old_argv = sys.argv[:]
try:
sys.argv = [script] + list(script_args)
try:
runpy.run_path(script_path, run_name="__main__")
except SystemExit as exc:
if exc.code is None:
return 0
if isinstance(exc.code, int):
return exc.code
print(exc.code, file=sys.stderr)
return 1
return 0
finally:
sys.argv = old_argv
def dispatch(argv=None, script_runner=None, stdout=None, stderr=None):
argv = list(sys.argv[1:] if argv is None else argv)
stdout = stdout or sys.stdout
stderr = stderr or sys.stderr
runner = script_runner or run_script
if argv and argv[0] in HELP_COMMANDS:
print_usage(stdout)
return 0
script, script_args = _resolve_script(argv)
if script is None:
print_usage(stdout)
print(f"未知命令: {argv[0]}", file=stderr)
return 2
return runner(script, script_args)
def main():
_configure_stdio()
sys.exit(dispatch())
if __name__ == "__main__":
main()