feat: 支持双模式 exe 入口
This commit is contained in:
25
EXE_USAGE.md
25
EXE_USAGE.md
@@ -1,4 +1,4 @@
|
||||
# WeChatDecrypt 使用说明 (Web UI 版)
|
||||
# WeChatDecrypt 使用说明 (Web UI + CLI 双模式)
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -7,6 +7,25 @@
|
||||
3. 浏览器**自动打开** `http://localhost:5678` (没自动开就手动复制粘贴)
|
||||
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
|
||||
|
||||
### 📱 个人微信
|
||||
@@ -101,6 +120,6 @@ python app_gui.py # → 弹 tkinter 窗口
|
||||
- Windows-only (不跨平台)
|
||||
- 没法远程访问
|
||||
|
||||
单 exe **默认入口是 Web UI**。要打 tkinter 版的 exe, 改
|
||||
`WeChatDecrypt.spec` 里 `Analysis(['monitor_web.py'])` 为
|
||||
单 exe **默认入口是 Web UI + CLI 分发器**。要打 tkinter 版的 exe, 改
|
||||
`WeChatDecrypt.spec` 里 `Analysis(['wechat_decrypt_launcher.py'])` 为
|
||||
`Analysis(['app_gui.py'])` 再 `build.bat`。
|
||||
|
||||
15
README.md
15
README.md
@@ -517,8 +517,19 @@ build.bat # → dist\WeChatDecrypt.exe (~20MB)
|
||||
```
|
||||
|
||||
**单 exe 默认入口是 Web UI** (双击 → 自动开浏览器), 因为 Web UI 体验更好。
|
||||
要打包成 tkinter 入口的话改 `WeChatDecrypt.spec` 里的 `Analysis(['monitor_web.py'])`
|
||||
为 `Analysis(['app_gui.py'])` 再 `build.bat`。
|
||||
同一个 exe 也支持 CLI 子命令:
|
||||
|
||||
```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。
|
||||
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
# PyInstaller spec — 打包 monitor_web.py 为单 exe。
|
||||
# PyInstaller spec — 打包 wechat_decrypt_launcher.py 为双模式单 exe。
|
||||
#
|
||||
# 启动后:
|
||||
# 1. 起 HTTP 服务 + 监听线程 (如 keys 已就绪)
|
||||
# 2. 自动开浏览器到 http://localhost:5678
|
||||
# 3. 用户在 Web UI 工具箱里操作 (解密 / 导出 / 朋友圈 / 企微 / 语音)
|
||||
# 1. 无参数/ web: 起 HTTP 服务 + 自动开浏览器到 http://localhost:5678
|
||||
# 2. 带 CLI 参数: 分发到 main.py / export_all_chats.py / 其他工具脚本
|
||||
# 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
|
||||
|
||||
# 所有子进程 (工具按钮触发) 需要的脚本, 打进 exe 同目录
|
||||
datas = [
|
||||
('monitor_web.py', '.'),
|
||||
('main.py', '.'),
|
||||
('config.py', '.'),
|
||||
('config.example.json', '.'),
|
||||
@@ -44,7 +45,13 @@ datas = [
|
||||
binaries = []
|
||||
hiddenimports = [
|
||||
# 显式列, 避免 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.Util', 'Crypto.Util.Padding',
|
||||
'zstandard',
|
||||
'pilk',
|
||||
]
|
||||
@@ -55,7 +62,7 @@ datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
|
||||
|
||||
a = Analysis(
|
||||
['monitor_web.py'], # ← 入口从 app_gui.py 改成 monitor_web.py
|
||||
['wechat_decrypt_launcher.py'],
|
||||
pathex=[],
|
||||
binaries=binaries,
|
||||
datas=datas,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
echo ========================================
|
||||
echo WeChatDecrypt 打包脚本 (Web UI 版)
|
||||
echo WeChatDecrypt 打包脚本 (Web UI + CLI 双模式)
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
@@ -32,6 +32,7 @@ for %%F in (dist\WeChatDecrypt.exe) do echo 大小: %%~zF bytes
|
||||
echo.
|
||||
echo 使用: 双击 WeChatDecrypt.exe
|
||||
echo ^→ 自动启动浏览器到 http://localhost:5678 (Web UI)
|
||||
echo CLI: WeChatDecrypt.exe --help
|
||||
echo ========================================
|
||||
echo.
|
||||
pause
|
||||
|
||||
13
config.py
13
config.py
@@ -10,18 +10,23 @@ import sys
|
||||
|
||||
CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json")
|
||||
|
||||
# 打包后 __file__ 指向临时目录,优先使用环境变量或5 cwd
|
||||
# 打包后 __file__ 指向临时目录,优先使用环境变量指定的 exe 所在目录。
|
||||
def _app_base_dir():
|
||||
d = os.environ.get("WECHAT_DECRYPT_APP_DIR")
|
||||
if d and os.path.isdir(d):
|
||||
return d
|
||||
return os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def _config_file_path():
|
||||
base = _app_base_dir()
|
||||
p = os.path.join(base, "config.json")
|
||||
if os.environ.get("WECHAT_DECRYPT_APP_DIR"):
|
||||
return os.path.join(_app_base_dir(), "config.json")
|
||||
p = os.path.join(_app_base_dir(), "config.json")
|
||||
if os.path.exists(p):
|
||||
return p
|
||||
return CONFIG_FILE
|
||||
|
||||
|
||||
_SYSTEM = platform.system().lower()
|
||||
|
||||
if _SYSTEM == "linux":
|
||||
@@ -278,7 +283,7 @@ def load_config():
|
||||
# "all_keys.json"(项目根相对),也能写 "~/Documents/wechat_decrypted" /
|
||||
# "$HOME/wechat" / "%USERPROFILE%\\wechat"(跨用户便携)。
|
||||
# 空字串 / null 不再触发 TypeError(用 cfg.get 而非 in)。
|
||||
base = os.path.dirname(os.path.abspath(__file__))
|
||||
base = _app_base_dir()
|
||||
if cfg.get("db_dir"):
|
||||
cfg["db_dir"] = os.path.expanduser(os.path.expandvars(cfg["db_dir"]))
|
||||
for key in ("keys_file", "decrypted_dir", "decoded_image_dir"):
|
||||
|
||||
@@ -16,7 +16,8 @@ import xml.etree.ElementTree as ET
|
||||
from Crypto.Cipher import AES
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
import zstandard as zstd
|
||||
from decode_image import ImageResolver
|
||||
from config import _config_file_path, _DEFAULT
|
||||
from decode_image import ImageResolver
|
||||
from key_utils import get_key_info, key_path_variants, strip_key_metadata
|
||||
|
||||
# ============ 加密常量 ============
|
||||
@@ -29,14 +30,17 @@ WAL_HEADER_SZ = 32
|
||||
WAL_FRAME_HEADER_SZ = 24
|
||||
|
||||
# ============ 配置加载 ============
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
CONFIG_FILE = os.path.join(SCRIPT_DIR, "config.json")
|
||||
|
||||
with open(CONFIG_FILE, encoding="utf-8") as f:
|
||||
_cfg = json.load(f)
|
||||
for _key in ("keys_file", "decrypted_dir"):
|
||||
if _key in _cfg and not os.path.isabs(_cfg[_key]):
|
||||
_cfg[_key] = os.path.join(SCRIPT_DIR, _cfg[_key])
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
CONFIG_FILE = _config_file_path()
|
||||
|
||||
try:
|
||||
with open(CONFIG_FILE, encoding="utf-8") as f:
|
||||
_cfg = json.load(f)
|
||||
except FileNotFoundError:
|
||||
_cfg = dict(_DEFAULT)
|
||||
for _key in ("keys_file", "decrypted_dir"):
|
||||
if _key in _cfg and not os.path.isabs(_cfg[_key]):
|
||||
_cfg[_key] = os.path.join(os.path.dirname(CONFIG_FILE), _cfg[_key])
|
||||
|
||||
DB_DIR = _cfg["db_dir"]
|
||||
KEYS_FILE = _cfg["keys_file"]
|
||||
@@ -55,8 +59,11 @@ if not DECODED_IMAGE_DIR:
|
||||
elif not os.path.isabs(DECODED_IMAGE_DIR):
|
||||
DECODED_IMAGE_DIR = os.path.join(SCRIPT_DIR, DECODED_IMAGE_DIR)
|
||||
|
||||
with open(KEYS_FILE, encoding="utf-8") as f:
|
||||
ALL_KEYS = strip_key_metadata(json.load(f))
|
||||
try:
|
||||
with open(KEYS_FILE, encoding="utf-8") as f:
|
||||
ALL_KEYS = strip_key_metadata(json.load(f))
|
||||
except FileNotFoundError:
|
||||
ALL_KEYS = {}
|
||||
|
||||
# ============ 解密函数 ============
|
||||
|
||||
|
||||
147
tests/test_exe_launcher.py
Normal file
147
tests/test_exe_launcher.py
Normal 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
139
wechat_decrypt_launcher.py
Normal 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()
|
||||
Reference in New Issue
Block a user