Compare commits

...

12 Commits

  • improvement(wrapped): 年度总结仅保留 Modern 主题
    - 移除复古主题切换入口(控制面板/左上角按钮)与 Win98/CRT 相关 UI
    
    - 简化 useWrappedTheme:仅保留 off(Modern),历史主题值自动回退
    
    - Modern 下也展示 LuckyBlock 占位图,并同步更新 README 说明
  • improvement(sns-media): 统一朋友圈远程媒体下载/解密/缓存逻辑
    - 新增 sns_media 模块:CDN URL 归一化、远程下载、图片 wcdb_api 解密、视频 WxIsaac64(WeFlow WASM)/ISAAC64 兜底解密与缓存
    
    - routers/sns 与 sns_export_service 复用该模块,收敛重复实现
    
    - 调整 ISAAC64 兜底实现:明确 keystream 生成与字节序格式,作为 WASM 不可用时的 best-effort
    
    - 增加单测覆盖:URL 改写、视频异或解密、缓存命中/升级、解密失败
  • feat(decrypt): 解密支持 SSE 实时进度
    - 新增 /api/decrypt_stream(GET + SSE):扫描 db_storage,逐库解密并推送 start/progress/complete/error
    
    - 前端解密页优先使用 SSE 展示实时进度,不支持时回退到原 POST(无进度)
    
    - 增加流式接口单测:验证事件序列与输出落盘
  • feat(app-shell): 桌面端集成自动更新(electron-updater)
    - 集成 electron-updater:检查更新/下载/安装/忽略此版本,并推送下载进度到前端
    
    - 打包版启动后自动检查更新;托盘菜单支持手动检查
    
    - preload 暴露 updater IPC + __brand 标记;前端新增更新弹窗与设置页版本/检查更新入口
    
    - 补全发布配置:artifactName/publish;release workflow 增加上传 latest.yml
  • chore(gitignore): 忽略本地构建产物与调研仓库
    - 忽略桌面端打包测试产物:desktop/dist-updater-test/
    
    - 忽略本地配置与临时脚本:wechat_db_config.json、.claude/settings.local.json、tools/tmp_isaac64_compare.js
    
    - 忽略调研/外部参考仓库目录:refs/、WeFlow/、win95/、py_wx_key/
  • feat(sns): 朋友圈页支持联系人侧栏、导出与 Live Photo
    - 左侧新增朋友圈联系人列表(按发圈数),支持搜索与“全部/单人”筛选
    - 新增“导出全部/导出此人”,展示导出状态并支持下载 ZIP(SSE 优先,轮询兜底)
    - Live Photo/实况:悬停播放、静音切换与预览弹窗
    - 媒体请求统一透传 use_cache;关闭缓存时追加时间戳避免浏览器缓存
  • feat(settings): 增加朋友圈图片使用缓存开关
    - 新增本地设置项 sns.settings.useCache(默认开启)
    - 设置页增加“朋友圈图片使用缓存”开关与说明,用于控制下载解密失败时的缓存回退策略
  • feat(sns-export): 支持朋友圈 HTML 离线导出(ZIP)
    - 新增导出任务:创建/查询/取消/下载 ZIP
    - 提供 SSE 进度流 /api/sns/exports/{id}/events(用于前端实时展示进度)
    - 复用聊天导出 CSS/emoji 能力,导出媒体优先本地缓存,必要时远程下载解密
    - 后端 app 注册 sns_export 路由
  • feat(sns): 增强朋友圈时间线/媒体获取与实时同步
    - 新增 /api/sns/users:按发圈数统计联系人(支持 keyword/limit)
    - 新增 /api/sns/realtime/sync_latest:WCDB 实时增量同步到解密库(append-only),并持久化 sync state
    - 朋友圈媒体优先走“远程下载+解密”:图片支持 wcdb_decrypt_sns_image,视频/实况支持 ISAAC64(WeFlow 逻辑)
    - 增加 WeFlow WASM keystream(Node) 优先 + Python ISAAC64 fallback,提升兼容性
    - wcdb_api.dll 支持多路径自动发现/环境变量覆盖,并在状态信息中回传实际使用路径
36 changed files with 7643 additions and 471 deletions
+1
View File
@@ -75,3 +75,4 @@ jobs:
files: |
desktop/dist/*Setup*.exe
desktop/dist/*Setup*.exe.blockmap
desktop/dist/latest.yml
+8
View File
@@ -16,8 +16,11 @@ wheels/
# Local config templates
/wechat_db_config_template.json
/wechat_db_config.json
.ace-tool/
pnpm-lock.yaml
/tools/tmp_isaac64_compare.js
/.claude/settings.local.json
# Local dev repos and data
/WxDatDecrypt/
@@ -26,10 +29,15 @@ pnpm-lock.yaml
/vue3-wechat-tool/
/wechatDataBackup/
/wx_key/
/refs/
/WeFlow/
/win95/
/py_wx_key/
# Electron desktop app
/desktop/node_modules/
/desktop/dist/
/desktop/dist-updater-test/
/desktop/build/
/desktop/resources/ui/*
!/desktop/resources/ui/.gitkeep
+4 -12
View File
@@ -65,26 +65,18 @@
## 年度总结
年度总结现在支持 3 种不同风格(style1、style2、style3。如果你对某个风格有更好的修改建议,或有新风格的点子,欢迎到 Issue 区反馈:https://github.com/LifeArchiveProject/WeChatDataAnalysis/issues
年度总结目前只保留「现代(Modern)」风格。如果你对年度总结有更好的修改建议,欢迎到 Issue 区反馈:https://github.com/LifeArchiveProject/WeChatDataAnalysis/issues
> ⚠️ **提醒**:年度总结目前还不是最终版本,后续还会增加新总结或新风格
> ⚠️ **提醒**:年度总结目前还不是最终版本,后续还会增加新总结或新内容
也欢迎加入下方 QQ 群一起讨论。
<table>
<tr>
<td align="center"><b>Style 1</b></td>
<td align="center"><b>Style 2</b></td>
<td align="center"><b>Modern</b></td>
</tr>
<tr>
<td><img src="frontend/public/style1.png" alt="年度总结 Style 1" width="400"/></td>
<td><img src="frontend/public/style2.png" alt="年度总结 Style 2" width="400"/></td>
</tr>
<tr>
<td align="center" colspan="2"><b>Style 3</b></td>
</tr>
<tr>
<td align="center" colspan="2"><img src="frontend/public/style3.png" alt="年度总结 Style 3" width="400"/></td>
<td align="center"><img src="frontend/public/style1.png" alt="年度总结 Modern" width="400"/></td>
</tr>
</table>
+87 -10
View File
@@ -1,12 +1,15 @@
{
"name": "wechat-data-analysis-desktop",
"version": "0.2.1",
"version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wechat-data-analysis-desktop",
"version": "0.2.1",
"version": "1.3.0",
"dependencies": {
"electron-updater": "^6.7.3"
},
"devDependencies": {
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
@@ -1105,7 +1108,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/assert-plus": {
@@ -1295,7 +1297,6 @@
"version": "9.5.1",
"resolved": "https://registry.npmmirror.com/builder-util-runtime/-/builder-util-runtime-9.5.1.tgz",
"integrity": "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.3.4",
@@ -1796,7 +1797,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -2252,6 +2252,69 @@
"node": ">= 10.0.0"
}
},
"node_modules/electron-updater": {
"version": "6.7.3",
"resolved": "https://registry.npmmirror.com/electron-updater/-/electron-updater-6.7.3.tgz",
"integrity": "sha512-EgkT8Z9noqXKbwc3u5FkJA+r48jwZ5DTUiOkJMOTEEH//n5Am6wfQGz7nvSFEA2oIAMv9jRzn5JKTyWeSKOPgg==",
"license": "MIT",
"dependencies": {
"builder-util-runtime": "9.5.1",
"fs-extra": "^10.1.0",
"js-yaml": "^4.1.0",
"lazy-val": "^1.0.5",
"lodash.escaperegexp": "^4.1.2",
"lodash.isequal": "^4.5.0",
"semver": "~7.7.3",
"tiny-typed-emitter": "^2.1.0"
}
},
"node_modules/electron-updater/node_modules/fs-extra": {
"version": "10.1.0",
"resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-10.1.0.tgz",
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/electron-updater/node_modules/jsonfile": {
"version": "6.2.0",
"resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.2.0.tgz",
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/electron-updater/node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/electron-updater/node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/electron-winstaller": {
"version": "5.4.0",
"resolved": "https://registry.npmmirror.com/electron-winstaller/-/electron-winstaller-5.4.0.tgz",
@@ -2816,7 +2879,6 @@
"version": "4.2.11",
"resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC"
},
"node_modules/has-flag": {
@@ -3139,7 +3201,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -3207,7 +3268,6 @@
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/lazy-val/-/lazy-val-1.0.5.tgz",
"integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash": {
@@ -3217,6 +3277,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/lodash.escaperegexp": {
"version": "4.1.2",
"resolved": "https://registry.npmmirror.com/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz",
"integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==",
"license": "MIT"
},
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmmirror.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
"license": "MIT"
},
"node_modules/log-symbols": {
"version": "4.1.0",
"resolved": "https://registry.npmmirror.com/log-symbols/-/log-symbols-4.1.0.tgz",
@@ -3535,7 +3608,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/negotiator": {
@@ -4272,7 +4344,6 @@
"version": "1.4.4",
"resolved": "https://registry.npmmirror.com/sax/-/sax-1.4.4.tgz",
"integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=11.0.0"
@@ -4767,6 +4838,12 @@
"semver": "bin/semver"
}
},
"node_modules/tiny-typed-emitter": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz",
"integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz",
+12 -2
View File
@@ -1,7 +1,7 @@
{
"name": "wechat-data-analysis-desktop",
"private": true,
"version": "0.2.1",
"version": "1.3.0",
"main": "src/main.cjs",
"scripts": {
"dev": "concurrently -k -s first \"cd ..\\\\frontend && npm run dev\" \"cross-env ELECTRON_START_URL=http://localhost:3000 electron .\"",
@@ -9,11 +9,15 @@
"build:ui": "pushd ..\\\\frontend && npm run generate && popd && node scripts\\\\copy-ui.cjs",
"build:backend": "uv sync --extra build && node scripts/build-backend.cjs",
"build:icon": "node scripts/build-icon.cjs",
"dist": "npm run build:ui && npm run build:backend && npm run build:icon && electron-builder --win --x64"
"dist": "npm run build:ui && npm run build:backend && npm run build:icon && electron-builder --win --x64 --publish never"
},
"dependencies": {
"electron-updater": "^6.7.3"
},
"build": {
"appId": "com.lifearchive.wechatdataanalysis",
"productName": "WeChatDataAnalysis",
"artifactName": "${productName}-${version}-Setup.${ext}",
"icon": "build/icon.ico",
"asar": true,
"directories": {
@@ -39,6 +43,12 @@
"nsis"
]
},
"publish": {
"provider": "github",
"owner": "LifeArchiveProject",
"repo": "WeChatDataAnalysis",
"releaseType": "release"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
+412 -28
View File
@@ -8,7 +8,8 @@ const {
dialog,
shell,
} = require("electron");
const { spawn } = require("child_process");
const { autoUpdater } = require("electron-updater");
const { spawn, spawnSync } = require("child_process");
const fs = require("fs");
const http = require("http");
const path = require("path");
@@ -25,6 +26,22 @@ let tray = null;
let isQuitting = false;
let desktopSettings = null;
const gotSingleInstanceLock = app.requestSingleInstanceLock();
if (!gotSingleInstanceLock) {
// If we allow a second instance to boot it will try to spawn another backend on the same port.
// Quit early to avoid leaving orphan backend processes around.
try {
app.quit();
} catch {}
} else {
app.on("second-instance", () => {
try {
if (app.isReady()) showMainWindow();
else app.whenReady().then(() => showMainWindow());
} catch {}
});
}
function nowIso() {
return new Date().toISOString();
}
@@ -127,6 +144,8 @@ function loadDesktopSettings() {
// 'tray' (default): closing the window hides it to the system tray.
// 'exit': closing the window quits the app.
closeBehavior: "tray",
// When set, suppress the auto-update prompt for this exact version.
ignoredUpdateVersion: "",
};
const p = getDesktopSettingsPath();
@@ -177,6 +196,229 @@ function setCloseBehavior(next) {
return desktopSettings.closeBehavior;
}
function getIgnoredUpdateVersion() {
const v = String(loadDesktopSettings()?.ignoredUpdateVersion || "").trim();
return v || "";
}
function setIgnoredUpdateVersion(version) {
loadDesktopSettings();
desktopSettings.ignoredUpdateVersion = String(version || "").trim();
persistDesktopSettings();
return desktopSettings.ignoredUpdateVersion;
}
function parseEnvBool(value) {
if (value == null) return null;
const v = String(value).trim().toLowerCase();
if (!v) return null;
if (v === "1" || v === "true" || v === "yes" || v === "y" || v === "on") return true;
if (v === "0" || v === "false" || v === "no" || v === "n" || v === "off") return false;
return null;
}
let autoUpdateEnabledCache = null;
function isAutoUpdateEnabled() {
if (autoUpdateEnabledCache != null) return !!autoUpdateEnabledCache;
const forced = parseEnvBool(process.env.AUTO_UPDATE_ENABLED);
let enabled = forced != null ? forced : !!app.isPackaged;
// In packaged builds electron-updater reads update config from app-update.yml.
// If missing, treat auto-update as disabled to avoid noisy errors.
if (enabled && app.isPackaged) {
try {
const updateConfigPath = path.join(process.resourcesPath, "app-update.yml");
if (!fs.existsSync(updateConfigPath)) {
enabled = false;
logMain(`[main] auto-update disabled: missing ${updateConfigPath}`);
}
} catch (err) {
enabled = false;
logMain(`[main] auto-update disabled: failed to check app-update.yml: ${err?.message || err}`);
}
}
autoUpdateEnabledCache = enabled;
return enabled;
}
let autoUpdaterInitialized = false;
let updateDownloadInProgress = false;
let installOnDownload = false;
let updateDownloaded = false;
let lastUpdateInfo = null;
function sendToRenderer(channel, payload) {
try {
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.send(channel, payload);
} catch (err) {
logMain(`[main] failed to send ${channel}: ${err?.message || err}`);
}
}
function setWindowProgressBar(value) {
try {
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.setProgressBar(value);
} catch {}
}
function normalizeReleaseNotes(releaseNotes) {
if (!releaseNotes) return "";
if (typeof releaseNotes === "string") return releaseNotes;
if (Array.isArray(releaseNotes)) {
const parts = [];
for (const item of releaseNotes) {
const version = item?.version ? String(item.version) : "";
const note = item?.note;
const noteText =
typeof note === "string" ? note : note != null ? JSON.stringify(note, null, 2) : "";
const block = [version ? `v${version}` : "", noteText].filter(Boolean).join("\n");
if (block) parts.push(block);
}
return parts.join("\n\n");
}
try {
return JSON.stringify(releaseNotes, null, 2);
} catch {
return String(releaseNotes);
}
}
function initAutoUpdater() {
if (autoUpdaterInitialized) return;
autoUpdaterInitialized = true;
// Configure auto-updater (align with WeFlow).
autoUpdater.autoDownload = false;
// Don't install automatically on quit; let the user choose when to restart/install.
autoUpdater.autoInstallOnAppQuit = false;
autoUpdater.disableDifferentialDownload = true;
autoUpdater.on("download-progress", (progress) => {
sendToRenderer("app:downloadProgress", progress);
const percent = Number(progress?.percent || 0);
if (Number.isFinite(percent) && percent > 0) {
setWindowProgressBar(Math.max(0, Math.min(1, percent / 100)));
}
});
autoUpdater.on("update-downloaded", () => {
updateDownloadInProgress = false;
updateDownloaded = true;
installOnDownload = false;
setWindowProgressBar(-1);
const payload = {
version: lastUpdateInfo?.version ? String(lastUpdateInfo.version) : "",
releaseNotes: normalizeReleaseNotes(lastUpdateInfo?.releaseNotes),
};
sendToRenderer("app:updateDownloaded", payload);
try {
// If the window is hidden to tray, show a lightweight hint instead of forcing UI focus.
tray?.displayBalloon?.({
title: "更新已下载完成",
content: "可在弹窗中选择“立即重启安装”,或稍后再安装。",
});
} catch {}
});
autoUpdater.on("error", (err) => {
updateDownloadInProgress = false;
installOnDownload = false;
updateDownloaded = false;
setWindowProgressBar(-1);
const message = err?.message || String(err);
logMain(`[main] autoUpdater error: ${message}`);
sendToRenderer("app:updateError", { message });
});
}
async function checkForUpdatesInternal() {
const enabled = isAutoUpdateEnabled();
if (!enabled) return { hasUpdate: false, enabled: false };
initAutoUpdater();
try {
const result = await autoUpdater.checkForUpdates();
const updateInfo = result?.updateInfo;
lastUpdateInfo = updateInfo || null;
const latestVersion = updateInfo?.version ? String(updateInfo.version) : "";
const currentVersion = (() => {
try {
return app.getVersion();
} catch {
return "";
}
})();
if (latestVersion && currentVersion && latestVersion !== currentVersion) {
return {
hasUpdate: true,
enabled: true,
version: latestVersion,
releaseNotes: normalizeReleaseNotes(updateInfo?.releaseNotes),
};
}
return { hasUpdate: false, enabled: true };
} catch (err) {
const message = err?.message || String(err);
logMain(`[main] checkForUpdates failed: ${message}`);
return { hasUpdate: false, enabled: true, error: message };
}
}
async function downloadAndInstallInternal() {
if (!isAutoUpdateEnabled()) {
throw new Error("自动更新已禁用");
}
initAutoUpdater();
if (updateDownloadInProgress) {
throw new Error("正在下载更新中,请稍候…");
}
updateDownloadInProgress = true;
installOnDownload = true;
updateDownloaded = false;
setWindowProgressBar(0);
try {
// Ensure update info is up-to-date (downloadUpdate relies on the last check).
await autoUpdater.checkForUpdates();
await autoUpdater.downloadUpdate();
return { success: true };
} catch (err) {
updateDownloadInProgress = false;
installOnDownload = false;
setWindowProgressBar(-1);
throw err;
}
}
function checkForUpdatesOnStartup() {
if (!isAutoUpdateEnabled()) return;
if (!app.isPackaged) return; // keep dev noise-free by default
setTimeout(async () => {
const result = await checkForUpdatesInternal();
if (!result?.hasUpdate) return;
const ignored = getIgnoredUpdateVersion();
if (ignored && ignored === result.version) return;
sendToRenderer("app:updateAvailable", {
version: result.version,
releaseNotes: result.releaseNotes || "",
});
}, 3000);
}
function getTrayIconPath() {
// Prefer an icon shipped in `src/` so it works both in dev and packaged (asar) builds.
const shipped = path.join(__dirname, "icon.ico");
@@ -238,6 +480,91 @@ function createTray() {
label: "显示",
click: () => showMainWindow(),
},
{
label: "检查更新...",
click: async () => {
try {
if (!isAutoUpdateEnabled()) {
await dialog.showMessageBox({
type: "info",
title: "检查更新",
message: "自动更新已禁用(仅打包版本可用)。",
buttons: ["确定"],
noLink: true,
});
return;
}
const result = await checkForUpdatesInternal();
if (result?.error) {
await dialog.showMessageBox({
type: "error",
title: "检查更新失败",
message: result.error,
buttons: ["确定"],
noLink: true,
});
return;
}
if (result?.hasUpdate && result?.version) {
const { response } = await dialog.showMessageBox({
type: "info",
title: "发现新版本",
message: `发现新版本 ${result.version},是否立即更新?`,
detail: result.releaseNotes ? `更新内容:\n${result.releaseNotes}` : undefined,
buttons: ["立即更新", "稍后", "忽略此版本"],
defaultId: 0,
cancelId: 1,
noLink: true,
});
if (response === 0) {
try {
await downloadAndInstallInternal();
} catch (err) {
const message = err?.message || String(err);
logMain(`[main] downloadAndInstall failed (tray): ${message}`);
await dialog.showMessageBox({
type: "error",
title: "更新失败",
message,
buttons: ["确定"],
noLink: true,
});
}
} else if (response === 2) {
try {
setIgnoredUpdateVersion(result.version);
} catch {}
}
return;
}
await dialog.showMessageBox({
type: "info",
title: "检查更新",
message: "当前已是最新版本。",
buttons: ["确定"],
noLink: true,
});
} catch (err) {
const message = err?.message || String(err);
logMain(`[main] tray check updates failed: ${message}`);
await dialog.showMessageBox({
type: "error",
title: "检查更新失败",
message,
buttons: ["确定"],
noLink: true,
});
}
},
},
{
type: "separator",
},
{
label: "退出",
click: () => {
@@ -380,17 +707,28 @@ function startBackend() {
function stopBackend() {
if (!backendProc) return;
try {
if (process.platform === "win32" && backendProc.pid) {
// Ensure child tree is killed on Windows.
spawn("taskkill", ["/pid", String(backendProc.pid), "/T", "/F"], {
stdio: "ignore",
windowsHide: true,
});
return;
}
} catch {}
const pid = backendProc.pid;
logMain(`[main] stopBackend pid=${pid || "?"}`);
// Best-effort: ensure process tree is gone on Windows. Use spawnSync so the kill
// isn't aborted by the app quitting immediately after "before-quit".
if (process.platform === "win32" && pid) {
const systemRoot = process.env.SystemRoot || process.env.WINDIR || "C:\\Windows";
const taskkillExe = path.join(systemRoot, "System32", "taskkill.exe");
const args = ["/pid", String(pid), "/T", "/F"];
try {
const exe = fs.existsSync(taskkillExe) ? taskkillExe : "taskkill";
const r = spawnSync(exe, args, { stdio: "ignore", windowsHide: true, timeout: 5000 });
if (r?.error) logMain(`[main] taskkill failed: ${r.error?.message || r.error}`);
else if (typeof r?.status === "number" && r.status !== 0)
logMain(`[main] taskkill exit code=${r.status}`);
} catch (err) {
logMain(`[main] taskkill exception: ${err?.message || err}`);
}
}
// Fallback: kill the direct process (taskkill might be missing from PATH in some envs).
try {
backendProc.kill();
} catch {}
@@ -612,6 +950,47 @@ function registerWindowIpc() {
}
});
ipcMain.handle("app:getVersion", () => {
try {
return app.getVersion();
} catch (err) {
logMain(`[main] getVersion failed: ${err?.message || err}`);
return "";
}
});
ipcMain.handle("app:checkForUpdates", async () => {
return await checkForUpdatesInternal();
});
ipcMain.handle("app:downloadAndInstall", async () => {
return await downloadAndInstallInternal();
});
ipcMain.handle("app:installUpdate", async () => {
if (!isAutoUpdateEnabled()) {
throw new Error("自动更新已禁用");
}
initAutoUpdater();
if (!updateDownloaded) {
throw new Error("更新尚未下载完成");
}
try {
autoUpdater.quitAndInstall(false, true);
return { success: true };
} catch (err) {
const message = err?.message || String(err);
logMain(`[main] installUpdate failed: ${message}`);
throw new Error(message);
}
});
ipcMain.handle("app:ignoreUpdate", async (_event, version) => {
setIgnoredUpdateVersion(version);
return { success: true };
});
ipcMain.handle("dialog:chooseDirectory", async (_event, options) => {
try {
const result = await dialog.showOpenDialog({
@@ -658,6 +1037,9 @@ async function main() {
await loadWithRetry(win, startUrl);
// Auto-check updates after the UI has loaded (packaged builds only).
checkForUpdatesOnStartup();
// If debug mode is enabled, auto-open DevTools so the user doesn't need menu/shortcuts.
if (debugEnabled()) {
try {
@@ -683,20 +1065,22 @@ app.on("before-quit", () => {
stopBackend();
});
main().catch((err) => {
// eslint-disable-next-line no-console
console.error(err);
logMain(`[main] fatal: ${err?.stack || String(err)}`);
stopBackend();
try {
const dir = getUserDataDir();
if (dir) {
dialog.showErrorBox(
"WeChatDataAnalysis 启动失败",
`启动失败:${err?.message || err}\n\n请查看日志目录:\n${dir}\n\n文件:desktop-main.log / backend-stdio.log / output\\\\logs\\\\...`
);
shell.openPath(dir);
}
} catch {}
app.quit();
});
if (gotSingleInstanceLock) {
main().catch((err) => {
// eslint-disable-next-line no-console
console.error(err);
logMain(`[main] fatal: ${err?.stack || String(err)}`);
stopBackend();
try {
const dir = getUserDataDir();
if (dir) {
dialog.showErrorBox(
"WeChatDataAnalysis 启动失败",
`启动失败:${err?.message || err}\n\n请查看日志目录:\n${dir}\n\n文件:desktop-main.log / backend-stdio.log / output\\\\logs\\\\...`
);
shell.openPath(dir);
}
} catch {}
app.quit();
});
}
+29
View File
@@ -1,6 +1,8 @@
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("wechatDesktop", {
// Marker used by the frontend to distinguish the Electron desktop shell from the pure web build.
__brand: "WeChatDataAnalysisDesktop",
minimize: () => ipcRenderer.invoke("window:minimize"),
toggleMaximize: () => ipcRenderer.invoke("window:toggleMaximize"),
close: () => ipcRenderer.invoke("window:close"),
@@ -13,4 +15,31 @@ contextBridge.exposeInMainWorld("wechatDesktop", {
setCloseBehavior: (behavior) => ipcRenderer.invoke("app:setCloseBehavior", String(behavior || "")),
chooseDirectory: (options = {}) => ipcRenderer.invoke("dialog:chooseDirectory", options),
// Auto update
getVersion: () => ipcRenderer.invoke("app:getVersion"),
checkForUpdates: () => ipcRenderer.invoke("app:checkForUpdates"),
downloadAndInstall: () => ipcRenderer.invoke("app:downloadAndInstall"),
installUpdate: () => ipcRenderer.invoke("app:installUpdate"),
ignoreUpdate: (version) => ipcRenderer.invoke("app:ignoreUpdate", String(version || "")),
onDownloadProgress: (callback) => {
const handler = (_event, progress) => callback(progress);
ipcRenderer.on("app:downloadProgress", handler);
return () => ipcRenderer.removeListener("app:downloadProgress", handler);
},
onUpdateAvailable: (callback) => {
const handler = (_event, info) => callback(info);
ipcRenderer.on("app:updateAvailable", handler);
return () => ipcRenderer.removeListener("app:updateAvailable", handler);
},
onUpdateDownloaded: (callback) => {
const handler = (_event, info) => callback(info);
ipcRenderer.on("app:updateDownloaded", handler);
return () => ipcRenderer.removeListener("app:updateDownloaded", handler);
},
onUpdateError: (callback) => {
const handler = (_event, payload) => callback(payload);
ipcRenderer.on("app:updateError", handler);
return () => ipcRenderer.removeListener("app:updateError", handler);
},
});
+31 -1
View File
@@ -8,6 +8,22 @@
<NuxtPage />
</div>
</div>
<ClientOnly v-if="isDesktopUpdater">
<DesktopUpdateDialog
:open="desktopUpdate.open.value"
:info="desktopUpdate.info.value"
:is-downloading="desktopUpdate.isDownloading.value"
:ready-to-install="desktopUpdate.readyToInstall.value"
:progress="desktopUpdate.progress.value"
:error="desktopUpdate.error.value"
:has-ignore="true"
@close="desktopUpdate.dismiss"
@update="desktopUpdate.startUpdate"
@install="desktopUpdate.installUpdate"
@ignore="desktopUpdate.ignore"
/>
</ClientOnly>
</div>
</template>
@@ -16,12 +32,14 @@ import { useChatAccountsStore } from '~/stores/chatAccounts'
import { usePrivacyStore } from '~/stores/privacy'
const route = useRoute()
const desktopUpdate = useDesktopUpdate()
// In Electron the server/pre-render doesn't know about `window.wechatDesktop`.
// If we render different DOM on server vs client, Vue hydration will keep the
// server HTML (no patch) and the layout/CSS fixes won't apply reliably.
// So we detect desktop onMounted and update reactively.
const isDesktop = ref(false)
const isDesktopUpdater = ref(false)
const updateDprVar = () => {
const dpr = window.devicePixelRatio || 1
@@ -29,10 +47,22 @@ const updateDprVar = () => {
}
onMounted(() => {
isDesktop.value = !!window?.wechatDesktop
const isElectron = /electron/i.test(String(navigator.userAgent || ''))
const api = window?.wechatDesktop
isDesktop.value = isElectron && !!api
const brandOk = !api?.__brand || api.__brand === 'WeChatDataAnalysisDesktop'
isDesktopUpdater.value =
isDesktop.value &&
brandOk &&
typeof api?.checkForUpdates === 'function' &&
typeof api?.downloadAndInstall === 'function'
updateDprVar()
window.addEventListener('resize', updateDprVar)
if (isDesktopUpdater.value) {
void desktopUpdate.initListeners()
}
// Init global UI state.
const chatAccounts = useChatAccountsStore()
const privacy = usePrivacyStore()
+164
View File
@@ -0,0 +1,164 @@
<template>
<Teleport to="body">
<div v-if="open && info" class="fixed inset-0 z-[9999] flex items-center justify-center">
<div class="absolute inset-0 bg-black/40" @click="onBackdropClick" />
<div class="relative w-[min(520px,calc(100vw-32px))] rounded-lg bg-white shadow-xl border border-gray-200">
<button
class="absolute right-3 top-3 h-8 w-8 rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-700"
type="button"
@click="emitClose"
aria-label="Close"
>
<span class="text-xl leading-none">&times;</span>
</button>
<div class="px-5 pt-5 pb-4">
<div class="text-xs text-gray-500">
{{ readyToInstall ? '更新已下载完成' : '发现新版本' }}
</div>
<div class="mt-1 text-lg font-semibold text-gray-900">
{{ info.version || '—' }}
</div>
<div v-if="readyToInstall" class="mt-2 text-xs text-gray-600">
你可以选择现在重启安装或稍后再安装
</div>
<div class="mt-4 rounded-md border border-gray-200 bg-gray-50 p-3">
<div class="text-xs font-medium text-gray-700">更新内容</div>
<div class="mt-2 text-xs text-gray-700 whitespace-pre-wrap break-words">
{{ info.releaseNotes || '修复了一些已知问题,提升了稳定性。' }}
</div>
</div>
<div v-if="error" class="mt-3 text-xs text-red-600 whitespace-pre-wrap break-words">
{{ error }}
</div>
<div v-if="isDownloading" class="mt-4">
<div class="flex items-center justify-between gap-3 text-xs text-gray-600">
<span v-if="speedText">{{ speedText }}</span>
<span v-else>下载中...</span>
<span>{{ percentText }}</span>
<span v-if="remainingText">剩余 {{ remainingText }}</span>
</div>
<div class="mt-2 h-2 w-full rounded bg-gray-200 overflow-hidden">
<div class="h-2 bg-emerald-500" :style="{ width: `${percent}%` }" />
</div>
</div>
<div v-if="isDownloading" class="mt-5 flex items-center justify-end gap-2">
<button
class="px-3 py-1.5 rounded-md border border-gray-200 bg-white text-sm text-gray-700 hover:bg-gray-50"
type="button"
@click="emitClose"
>
后台下载
</button>
</div>
<div v-else class="mt-5 flex items-center justify-end gap-2">
<button
class="px-3 py-1.5 rounded-md border border-gray-200 bg-white text-sm text-gray-700 hover:bg-gray-50"
type="button"
@click="emitClose"
>
稍后
</button>
<button
v-if="readyToInstall"
class="px-3 py-1.5 rounded-md bg-emerald-600 text-white text-sm hover:bg-emerald-700"
type="button"
@click="emitInstall"
>
立即重启安装
</button>
<template v-else>
<button
v-if="hasIgnore"
class="px-3 py-1.5 rounded-md border border-gray-200 bg-white text-sm text-gray-700 hover:bg-gray-50"
type="button"
@click="emitIgnore"
>
忽略此版本
</button>
<button
class="px-3 py-1.5 rounded-md bg-emerald-600 text-white text-sm hover:bg-emerald-700"
type="button"
@click="emitUpdate"
>
立即更新
</button>
</template>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
const props = defineProps({
open: { type: Boolean, default: false },
info: { type: Object, default: null }, // { version, releaseNotes }
isDownloading: { type: Boolean, default: false },
readyToInstall: { type: Boolean, default: false },
progress: { type: [Number, Object], default: () => ({ percent: 0 }) },
error: { type: String, default: "" },
hasIgnore: { type: Boolean, default: true },
});
const emit = defineEmits(["close", "update", "install", "ignore"]);
const safeProgress = computed(() => {
if (typeof props.progress === "number") return { percent: props.progress };
if (props.progress && typeof props.progress === "object") return props.progress;
return { percent: 0 };
});
const percent = computed(() => {
const p = Number(safeProgress.value?.percent || 0);
if (!Number.isFinite(p)) return 0;
return Math.max(0, Math.min(100, p));
});
const percentText = computed(() => `${percent.value.toFixed(0)}%`);
const formatBytes = (bytes) => {
const b = Number(bytes || 0);
if (!Number.isFinite(b) || b <= 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(b) / Math.log(k));
const idx = Math.max(0, Math.min(i, sizes.length - 1));
return `${(b / Math.pow(k, idx)).toFixed(1)} ${sizes[idx]}`;
};
const speedText = computed(() => {
const bps = safeProgress.value?.bytesPerSecond;
if (bps == null) return "";
return `${formatBytes(bps)}/s`;
});
const remainingText = computed(() => {
const s = safeProgress.value?.remaining;
const sec = Number(s);
if (!Number.isFinite(sec)) return "";
if (sec < 60) return `${Math.ceil(sec)}`;
const min = Math.floor(sec / 60);
const rem = Math.ceil(sec % 60);
return `${min}${rem}`;
});
const emitClose = () => emit("close");
const emitUpdate = () => emit("update");
const emitInstall = () => emit("install");
const emitIgnore = () => emit("ignore");
const onBackdropClick = () => {
emitClose();
};
</script>
+29
View File
@@ -0,0 +1,29 @@
<template>
<svg
:width="size"
:height="size"
viewBox="0 0 24 24"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<!-- Keep the SVG identical to WeFlow/src/components/LivePhotoIcon.tsx for visual consistency -->
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
<g stroke="currentColor" stroke-width="2">
<circle fill="currentColor" stroke="none" cx="12" cy="12" r="2.5" />
<circle cx="12" cy="12" r="5.5" />
<circle cx="12" cy="12" r="9" stroke-dasharray="1 3.7" />
</g>
</g>
</svg>
</template>
<script setup>
defineProps({
size: {
type: [Number, String],
default: 24
}
})
</script>
@@ -115,7 +115,7 @@
@error="onShownAvatarError"
/>
<img
v-else-if="isGameboy && phase === 'idle'"
v-else-if="(isGameboy || isModern) && phase === 'idle'"
src="/assets/images/LuckyBlock.png"
class="w-full h-full object-contain"
alt="Lucky Block"
@@ -258,6 +258,7 @@ const props = defineProps({
const { theme } = useWrappedTheme()
const isGameboy = computed(() => theme.value === 'gameboy')
const isModern = computed(() => theme.value === 'off')
const isRetro = computed(() => isGameboy.value)
const nfInt = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 })
@@ -39,7 +39,6 @@
</div>
<div class="flex gap-2 items-end">
<WrappedThemeSwitcher />
<button
class="inline-flex items-center justify-center px-4 py-2 rounded-lg bg-[#07C160] text-white text-sm wrapped-label hover:bg-[#06AD56] disabled:opacity-60 disabled:cursor-not-allowed transition controls-btn"
:disabled="loading"
@@ -83,73 +82,3 @@ const yearOptions = computed(() => {
return years
})
</script>
<style scoped>
/* 复古模式 - 控制面板样式 */
.wrapped-retro .controls-panel {
background-color: var(--wrapped-card-bg);
border-color: var(--wrapped-border);
}
.wrapped-retro .controls-label {
color: var(--wrapped-text-secondary);
}
.wrapped-retro .controls-select {
background-color: var(--wrapped-bg);
border-color: var(--wrapped-border);
color: var(--wrapped-text);
}
.wrapped-retro .controls-select:focus {
--tw-ring-color: var(--wrapped-accent);
}
.wrapped-retro .controls-checkbox {
border-color: var(--wrapped-border);
color: var(--wrapped-accent);
}
.wrapped-retro .controls-checkbox:focus {
--tw-ring-color: var(--wrapped-accent);
}
.wrapped-retro .controls-hint {
color: var(--wrapped-text-secondary);
}
.wrapped-retro .controls-warning {
color: var(--wrapped-warning);
}
.wrapped-retro .controls-btn {
background-color: var(--wrapped-accent);
color: var(--wrapped-bg);
}
.wrapped-retro .controls-btn:hover:not(:disabled) {
filter: brightness(1.1);
}
/* Win98 特殊样式 */
.wrapped-theme-win98 .controls-panel {
border-radius: 0;
border: 1px solid #808080;
background: #c0c0c0;
box-shadow:
inset 1px 1px 0 #ffffff,
inset -1px -1px 0 #000000;
}
.wrapped-theme-win98 .controls-select {
border-radius: 0;
}
.wrapped-theme-win98 .controls-btn {
border-radius: 0;
}
.wrapped-theme-win98 .controls-warning {
color: #800000;
}
</style>
+39
View File
@@ -236,6 +236,16 @@ export const useApi = () => {
return await request(url)
}
// 朋友圈联系人列表(按发圈数统计)
const listSnsUsers = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
if (params && params.keyword) query.set('keyword', String(params.keyword))
if (params && params.limit != null) query.set('limit', String(params.limit))
const url = '/sns/users' + (query.toString() ? `?${query.toString()}` : '')
return await request(url)
}
// 朋友圈图片本地缓存候选(用于错图时手动选择)
const listSnsMediaCandidates = async (params = {}) => {
const query = new URLSearchParams()
@@ -356,6 +366,31 @@ export const useApi = () => {
return await request(`/chat/exports/${encodeURIComponent(String(exportId))}`, { method: 'DELETE' })
}
// 朋友圈导出(离线 HTML zip
const createSnsExport = async (data = {}) => {
return await request('/sns/exports', {
method: 'POST',
body: {
account: data.account || null,
scope: data.scope || 'selected',
usernames: Array.isArray(data.usernames) ? data.usernames : [],
use_cache: data.use_cache == null ? true : !!data.use_cache,
output_dir: data.output_dir == null ? null : String(data.output_dir || '').trim(),
file_name: data.file_name || null
}
})
}
const getSnsExport = async (exportId) => {
if (!exportId) throw new Error('Missing exportId')
return await request(`/sns/exports/${encodeURIComponent(String(exportId))}`)
}
const cancelSnsExport = async (exportId) => {
if (!exportId) throw new Error('Missing exportId')
return await request(`/sns/exports/${encodeURIComponent(String(exportId))}`, { method: 'DELETE' })
}
// 联系人
const listChatContacts = async (params = {}) => {
const query = new URLSearchParams()
@@ -454,6 +489,7 @@ export const useApi = () => {
resolveNestedChatHistory,
resolveAppMsg,
listSnsTimeline,
listSnsUsers,
listSnsMediaCandidates,
saveSnsMediaPicks,
openChatMediaFolder,
@@ -465,6 +501,9 @@ export const useApi = () => {
getChatExport,
listChatExports,
cancelChatExport,
createSnsExport,
getSnsExport,
cancelSnsExport,
listChatContacts,
exportChatContacts,
getWrappedAnnual,
+236
View File
@@ -0,0 +1,236 @@
let listenersInitialized = false;
let removeListeners = [];
const getDesktopApi = () => {
if (!process.client) return null;
if (typeof window === "undefined") return null;
return window?.wechatDesktop || null;
};
const isDesktopShell = () => !!getDesktopApi();
const isUpdaterSupported = () => {
const api = getDesktopApi();
if (!api) return false;
// If the bridge exposes a brand marker, ensure it's our Electron shell.
if (api.__brand && api.__brand !== "WeChatDataAnalysisDesktop") return false;
// Require updater IPC to avoid showing update UI in the pure web build.
return (
typeof api.getVersion === "function" &&
typeof api.checkForUpdates === "function" &&
typeof api.downloadAndInstall === "function"
);
};
export const useDesktopUpdate = () => {
const info = useState("desktopUpdate.info", () => null);
const open = useState("desktopUpdate.open", () => false);
const isDownloading = useState("desktopUpdate.isDownloading", () => false);
const readyToInstall = useState("desktopUpdate.readyToInstall", () => false);
const progress = useState("desktopUpdate.progress", () => ({ percent: 0 }));
const error = useState("desktopUpdate.error", () => "");
const currentVersion = useState("desktopUpdate.currentVersion", () => "");
const manualCheckLoading = useState("desktopUpdate.manualCheckLoading", () => false);
const lastCheckMessage = useState("desktopUpdate.lastCheckMessage", () => "");
const lastCheckAt = useState("desktopUpdate.lastCheckAt", () => 0);
const setUpdateInfo = (payload) => {
if (!payload) return;
const version = String(payload?.version || "").trim();
const releaseNotes = String(payload?.releaseNotes || "");
if (!version) return;
info.value = { version, releaseNotes };
readyToInstall.value = false;
};
const dismiss = () => {
open.value = false;
};
const refreshVersion = async () => {
if (!isUpdaterSupported()) return "";
try {
const v = await getDesktopApi()?.getVersion?.();
currentVersion.value = String(v || "");
return currentVersion.value;
} catch {
return currentVersion.value || "";
}
};
const initListeners = async () => {
if (!isUpdaterSupported()) return;
if (listenersInitialized) return;
listenersInitialized = true;
await refreshVersion();
const unsubs = [];
const unUpdate = window.wechatDesktop?.onUpdateAvailable?.((payload) => {
error.value = "";
isDownloading.value = false;
readyToInstall.value = false;
progress.value = { percent: 0 };
setUpdateInfo(payload);
open.value = true;
});
if (typeof unUpdate === "function") unsubs.push(unUpdate);
const unProgress = window.wechatDesktop?.onDownloadProgress?.((p) => {
progress.value = p || { percent: 0 };
const percent = Number(progress.value?.percent || 0);
if (Number.isFinite(percent) && percent > 0) {
isDownloading.value = true;
}
});
if (typeof unProgress === "function") unsubs.push(unProgress);
const unDownloaded = window.wechatDesktop?.onUpdateDownloaded?.((payload) => {
// Download finished. Keep the dialog open and let the user decide when to install.
setUpdateInfo(payload || info.value || {});
isDownloading.value = false;
readyToInstall.value = true;
progress.value = { ...(progress.value || {}), percent: 100 };
open.value = true;
});
if (typeof unDownloaded === "function") unsubs.push(unDownloaded);
const unError = window.wechatDesktop?.onUpdateError?.((payload) => {
const msg = String(payload?.message || "");
if (msg) error.value = msg;
isDownloading.value = false;
readyToInstall.value = false;
});
if (typeof unError === "function") unsubs.push(unError);
removeListeners = unsubs;
};
const startUpdate = async () => {
if (!isUpdaterSupported()) return;
error.value = "";
isDownloading.value = true;
readyToInstall.value = false;
progress.value = { percent: 0 };
try {
await getDesktopApi()?.downloadAndInstall?.();
} catch (e) {
const msg = e?.message || String(e);
error.value = msg;
isDownloading.value = false;
}
};
const installUpdate = async () => {
if (!isUpdaterSupported()) return;
if (!getDesktopApi()?.installUpdate) return;
error.value = "";
try {
await getDesktopApi()?.installUpdate?.();
} catch (e) {
const msg = e?.message || String(e);
error.value = msg;
}
};
const ignore = async () => {
if (!isUpdaterSupported()) return;
const version = String(info.value?.version || "").trim();
if (!version) return;
try {
await getDesktopApi()?.ignoreUpdate?.(version);
} catch (e) {
const msg = e?.message || String(e);
error.value = msg;
} finally {
// Hide the dialog locally; startup auto-check will also respect the ignore.
open.value = false;
info.value = null;
}
};
const manualCheck = async () => {
if (!isDesktopShell()) {
lastCheckMessage.value = "仅桌面端可用。";
return { hasUpdate: false };
}
if (!isUpdaterSupported()) {
lastCheckMessage.value = "当前桌面端版本不支持自动更新。";
return { hasUpdate: false };
}
manualCheckLoading.value = true;
error.value = "";
lastCheckMessage.value = "";
try {
await refreshVersion();
const res = await getDesktopApi()?.checkForUpdates?.();
lastCheckAt.value = Date.now();
if (res?.enabled === false) {
lastCheckMessage.value = "自动更新已禁用(仅打包版本可用)。";
return res;
}
if (res?.error) {
lastCheckMessage.value = `检查更新失败:${String(res.error)}`;
return res;
}
if (res?.hasUpdate && res?.version) {
setUpdateInfo({ version: res.version, releaseNotes: res.releaseNotes || "" });
open.value = true;
lastCheckMessage.value = `发现新版本:${String(res.version)}`;
return res;
}
lastCheckMessage.value = "当前已是最新版本。";
return res;
} catch (e) {
const msg = e?.message || String(e);
lastCheckMessage.value = `检查更新失败:${msg}`;
return { hasUpdate: false, error: msg };
} finally {
manualCheckLoading.value = false;
}
};
const cleanup = () => {
try {
for (const fn of removeListeners) fn?.();
} catch {}
removeListeners = [];
listenersInitialized = false;
};
return {
info,
open,
isDownloading,
readyToInstall,
progress,
error,
currentVersion,
manualCheckLoading,
lastCheckMessage,
lastCheckAt,
initListeners,
refreshVersion,
manualCheck,
startUpdate,
installUpdate,
ignore,
dismiss,
cleanup,
};
};
+11 -89
View File
@@ -1,109 +1,31 @@
/**
* 年度总结页面主题管理 composable
* 支持三种主题:modern(现代)、gameboyGame Boy)、win98Windows 98
* 仅保留 modern(现代)主题
*/
const STORAGE_KEY = 'wrapped-theme'
const VALID_THEMES = ['off', 'gameboy', 'win98']
const RETRO_THEMES = new Set(['gameboy'])
// 全局响应式状态(跨组件共享)
const theme = ref('off')
let initialized = false
let keyboardInitialized = false
// Note: 历史上曾尝试过 gameboy / win98 等主题,但目前已移除,仅保留 Modern。
const theme = ref('off') // off === Modern
export function useWrappedTheme() {
// 初始化:从 localStorage 读取(仅执行一次)
const initTheme = () => {
if (initialized || !import.meta.client) return
const saved = localStorage.getItem(STORAGE_KEY)
if (saved && VALID_THEMES.includes(saved)) {
theme.value = saved
}
initialized = true
}
// 设置主题
const setTheme = (newTheme) => {
if (!VALID_THEMES.includes(newTheme)) {
console.warn(`Invalid theme: ${newTheme}`)
return
}
theme.value = newTheme
if (import.meta.client) {
localStorage.setItem(STORAGE_KEY, newTheme)
// Only keep Modern.
if (newTheme !== 'off') {
console.warn(`Wrapped theme '${newTheme}' has been removed; falling back to Modern.`)
}
theme.value = 'off'
}
// 切换到下一个主题(循环)
const cycleTheme = () => {
const currentIndex = VALID_THEMES.indexOf(theme.value)
const nextIndex = (currentIndex + 1) % VALID_THEMES.length
setTheme(VALID_THEMES[nextIndex])
}
const cycleTheme = () => setTheme('off')
// 计算属性:是否为复古模式(非 off)
const isRetro = computed(() => theme.value !== 'off')
// 计算属性:当前主题的 CSS 类名
const themeClass = computed(() => {
if (theme.value === 'off') return ''
// Note: not every non-modern theme is "retro pixel/CRT".
// Keep wrapped-retro for themes that rely on pixel/CRT shared styles.
const base = RETRO_THEMES.has(theme.value) ? 'wrapped-retro ' : ''
return `${base}wrapped-theme-${theme.value}`
})
// 计算属性:主题显示名称
const themeName = computed(() => {
const names = {
off: 'Modern',
gameboy: 'Game Boy',
win98: 'Windows 98'
}
return names[theme.value] || 'Modern'
})
// 全局 F1-F3 快捷键切换主题(仅初始化一次)
const initKeyboardShortcuts = () => {
if (keyboardInitialized || !import.meta.client) return
keyboardInitialized = true
const handleKeydown = (e) => {
// 检查是否在可编辑元素中
const el = e.target
if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT' || el.isContentEditable)) {
return
}
if (e.key === 'F1') {
e.preventDefault()
setTheme('off')
} else if (e.key === 'F2') {
e.preventDefault()
setTheme('gameboy')
} else if (e.key === 'F3') {
e.preventDefault()
setTheme('win98')
}
}
window.addEventListener('keydown', handleKeydown)
}
// 客户端挂载后再初始化:避免 SSR 与首帧 hydration 不一致
onMounted(() => {
initTheme()
initKeyboardShortcuts()
})
const isRetro = computed(() => false)
const themeClass = computed(() => '')
return {
theme: readonly(theme),
setTheme,
cycleTheme,
isRetro,
themeClass,
themeName,
VALID_THEMES
themeClass
}
}
+200 -30
View File
@@ -125,6 +125,40 @@
</button>
</div>
</div>
<!-- 解密进度 -->
<div v-if="loading || dbDecryptProgress.total > 0" class="mt-6">
<div class="flex items-center justify-between mb-2">
<div class="text-sm text-[#7F7F7F]">
{{ dbDecryptProgress.message || (loading ? '解密中...' : '') }}
</div>
<div v-if="dbDecryptProgress.total > 0" class="text-sm font-mono text-[#000000e6]">
{{ dbDecryptProgress.current }} / {{ dbDecryptProgress.total }}
</div>
</div>
<div class="w-full bg-gray-200 rounded-full h-2 overflow-hidden">
<div
class="h-full bg-[#07C160] transition-all duration-300"
:style="{ width: dbProgressPercent + '%' }"
></div>
</div>
<div v-if="dbDecryptProgress.current_file" class="mt-2 text-xs text-[#7F7F7F] truncate font-mono">
{{ dbDecryptProgress.current_file }}
</div>
<div v-if="dbDecryptProgress.total > 0" class="mt-3 grid grid-cols-2 gap-4 text-center">
<div class="bg-gray-50 rounded-lg p-3">
<div class="text-lg font-bold text-[#07C160]">{{ dbDecryptProgress.success_count }}</div>
<div class="text-xs text-[#7F7F7F]">成功</div>
</div>
<div class="bg-gray-50 rounded-lg p-3">
<div class="text-lg font-bold text-[#FA5151]">{{ dbDecryptProgress.fail_count }}</div>
<div class="text-xs text-[#7F7F7F]">失败</div>
</div>
</div>
</div>
</form>
</div>
</div>
@@ -413,7 +447,7 @@
</style>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { useApi } from '~/composables/useApi'
const { decryptDatabase, saveMediaKeys, getSavedKeys, getDbKey, getImageKey, getWxStatus } = useApi()
@@ -625,6 +659,22 @@ const clearManualKeys = () => {
const mediaDecryptResult = ref(null)
const mediaDecrypting = ref(false)
// 数据库解密进度(SSE
const dbDecryptProgress = reactive({
current: 0,
total: 0,
success_count: 0,
fail_count: 0,
current_file: '',
status: '',
message: ''
})
const dbProgressPercent = computed(() => {
if (dbDecryptProgress.total === 0) return 0
return Math.round((dbDecryptProgress.current / dbDecryptProgress.total) * 100)
})
// 实时解密进度
const decryptProgress = reactive({
current: 0,
@@ -673,6 +723,27 @@ const validateForm = () => {
return isValid
}
let dbDecryptEventSource = null
onBeforeUnmount(() => {
try {
if (dbDecryptEventSource) dbDecryptEventSource.close()
} catch (e) {
// ignore
} finally {
dbDecryptEventSource = null
}
})
const resetDbDecryptProgress = () => {
dbDecryptProgress.current = 0
dbDecryptProgress.total = 0
dbDecryptProgress.success_count = 0
dbDecryptProgress.fail_count = 0
dbDecryptProgress.current_file = ''
dbDecryptProgress.status = ''
dbDecryptProgress.message = ''
}
// 处理解密
const handleDecrypt = async () => {
if (!validateForm()) {
@@ -682,43 +753,142 @@ const handleDecrypt = async () => {
loading.value = true
error.value = ''
warning.value = ''
resetDbDecryptProgress()
try {
const result = await decryptDatabase({
key: formData.key,
db_storage_path: formData.db_storage_path
})
if (result.status === 'completed') {
// 解密成功,保存结果并进入下一步
decryptResult.value = result
if (process.client && typeof window !== 'undefined') {
sessionStorage.setItem('decryptResult', JSON.stringify(result))
}
// 记录当前账号(用于图片解密/密钥保存)
try {
const accounts = Object.keys(result.account_results || {})
if (accounts.length > 0) mediaAccount.value = accounts[0]
} catch (e) {
// ignore
const canSse = process.client && typeof window !== 'undefined' && typeof EventSource !== 'undefined'
// Fallback: 如果环境不支持 SSE,则使用普通 POST(无进度)。
if (!canSse) {
const result = await decryptDatabase({
key: formData.key,
db_storage_path: formData.db_storage_path
})
if (result.status === 'completed') {
decryptResult.value = result
if (process.client && typeof window !== 'undefined') {
sessionStorage.setItem('decryptResult', JSON.stringify(result))
}
try {
const accounts = Object.keys(result.account_results || {})
if (accounts.length > 0) mediaAccount.value = accounts[0]
} catch (e) {}
clearManualKeys()
currentStep.value = 1
await prefillKeysForAccount(mediaAccount.value)
} else if (result.status === 'failed') {
if (result.failure_count > 0 && result.success_count === 0) {
error.value = result.message || '所有文件解密失败'
} else {
error.value = '部分文件解密失败,请检查密钥是否正确'
}
} else {
error.value = result.message || '解密失败,请检查输入信息'
}
// 进入图片密钥填写步骤
clearManualKeys()
currentStep.value = 1
await prefillKeysForAccount(mediaAccount.value)
} else if (result.status === 'failed') {
if (result.failure_count > 0 && result.success_count === 0) {
error.value = result.message || '所有文件解密失败'
} else {
error.value = '部分文件解密失败,请检查密钥是否正确'
loading.value = false
return
}
// SSE: 解密过程实时推送进度
if (dbDecryptEventSource) {
try {
dbDecryptEventSource.close()
} catch (e) {}
dbDecryptEventSource = null
}
const params = new URLSearchParams()
params.set('key', formData.key)
params.set('db_storage_path', formData.db_storage_path)
const url = `http://localhost:8000/api/decrypt_stream?${params.toString()}`
dbDecryptProgress.message = '连接中...'
const eventSource = new EventSource(url)
dbDecryptEventSource = eventSource
eventSource.onmessage = async (event) => {
try {
const data = JSON.parse(event.data)
if (data.type === 'scanning') {
dbDecryptProgress.message = data.message || '正在扫描数据库文件...'
} else if (data.type === 'start') {
dbDecryptProgress.total = data.total || 0
dbDecryptProgress.message = data.message || '开始解密...'
} else if (data.type === 'progress') {
dbDecryptProgress.current = data.current || 0
dbDecryptProgress.total = data.total || 0
dbDecryptProgress.success_count = data.success_count || 0
dbDecryptProgress.fail_count = data.fail_count || 0
dbDecryptProgress.current_file = data.current_file || ''
dbDecryptProgress.status = data.status || ''
dbDecryptProgress.message = data.message || ''
} else if (data.type === 'phase') {
// e.g. building cache
dbDecryptProgress.message = data.message || ''
} else if (data.type === 'complete') {
dbDecryptProgress.status = 'complete'
dbDecryptProgress.current = data.total_databases || dbDecryptProgress.total
dbDecryptProgress.total = data.total_databases || dbDecryptProgress.total
dbDecryptProgress.success_count = data.success_count || 0
dbDecryptProgress.fail_count = data.failure_count || 0
dbDecryptProgress.message = data.message || '解密完成'
decryptResult.value = data
if (process.client && typeof window !== 'undefined') {
sessionStorage.setItem('decryptResult', JSON.stringify(data))
}
try {
const accounts = Object.keys(data.account_results || {})
if (accounts.length > 0) mediaAccount.value = accounts[0]
} catch (e) {}
try {
eventSource.close()
} catch (e) {}
dbDecryptEventSource = null
loading.value = false
if (data.status === 'completed') {
clearManualKeys()
currentStep.value = 1
await prefillKeysForAccount(mediaAccount.value)
} else if (data.status === 'failed') {
error.value = data.message || '所有文件解密失败'
} else {
error.value = data.message || '解密失败,请检查输入信息'
}
} else if (data.type === 'error') {
error.value = data.message || '解密失败,请检查输入信息'
try {
eventSource.close()
} catch (e) {}
dbDecryptEventSource = null
loading.value = false
}
} catch (e) {
console.error('解析SSE消息失败:', e)
}
}
eventSource.onerror = (e) => {
console.error('SSE连接错误:', e)
try {
eventSource.close()
} catch (err) {}
dbDecryptEventSource = null
if (loading.value) {
error.value = 'SSE连接中断,请重试'
loading.value = false
}
} else {
error.value = result.message || '解密失败,请检查输入信息'
}
} catch (err) {
error.value = err.message || '解密过程中发生错误'
} finally {
loading.value = false
}
}
+70 -2
View File
@@ -89,6 +89,53 @@
</div>
</div>
</div>
<div class="rounded-lg border border-gray-200">
<div class="px-4 py-3 border-b border-gray-200 bg-gray-50">
<div class="text-sm font-medium text-gray-900">更新</div>
</div>
<div class="px-4 py-3 space-y-3">
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
<div class="text-sm font-medium text-gray-900">当前版本</div>
<div class="text-xs text-gray-500">
{{ desktopVersionText }}
</div>
</div>
<button
type="button"
class="text-sm px-3 py-1.5 rounded-md border border-gray-200 bg-white hover:bg-gray-50 disabled:opacity-50"
:disabled="!isDesktopEnv || desktopUpdate.manualCheckLoading.value"
@click="onDesktopCheckUpdates"
>
{{ desktopUpdate.manualCheckLoading.value ? '检查中...' : '检查更新' }}
</button>
</div>
<div v-if="desktopUpdate.lastCheckMessage.value" class="text-xs text-gray-600 whitespace-pre-wrap break-words">
{{ desktopUpdate.lastCheckMessage.value }}
</div>
</div>
</div>
<div class="rounded-lg border border-gray-200">
<div class="px-4 py-3 border-b border-gray-200 bg-gray-50">
<div class="text-sm font-medium text-gray-900">朋友圈</div>
</div>
<div class="px-4 py-3 space-y-4">
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
<div class="text-sm font-medium text-gray-900">朋友圈图片使用缓存</div>
<div class="text-xs text-gray-500">开启下载解密失败时回退本地缓存默认开启关闭每次都走下载+解密</div>
</div>
<input
type="checkbox"
class="h-4 w-4"
:checked="snsUseCache"
@change="onSnsUseCacheToggle"
/>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -98,14 +145,22 @@
</template>
<script setup>
import { DESKTOP_SETTING_AUTO_REALTIME_KEY, DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, readLocalBoolSetting, writeLocalBoolSetting } from '~/utils/desktop-settings'
import { DESKTOP_SETTING_AUTO_REALTIME_KEY, DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, SNS_SETTING_USE_CACHE_KEY, readLocalBoolSetting, writeLocalBoolSetting } from '~/utils/desktop-settings'
useHead({ title: '设置 - 微信数据分析助手' })
const isDesktopEnv = ref(false)
const desktopUpdate = useDesktopUpdate()
const desktopVersionText = computed(() => {
if (!isDesktopEnv.value) return '仅桌面端可用'
const v = String(desktopUpdate.currentVersion.value || '').trim()
return v || '—'
})
const desktopAutoRealtime = ref(false)
const desktopDefaultToChatWhenData = ref(false)
const snsUseCache = ref(true)
const desktopAutoLaunch = ref(false)
const desktopAutoLaunchLoading = ref(false)
@@ -198,15 +253,28 @@ const onDesktopDefaultToChatToggle = (ev) => {
writeLocalBoolSetting(DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, checked)
}
const onSnsUseCacheToggle = (ev) => {
const checked = !!ev?.target?.checked
snsUseCache.value = checked
writeLocalBoolSetting(SNS_SETTING_USE_CACHE_KEY, checked)
}
const onDesktopCheckUpdates = async () => {
await desktopUpdate.manualCheck()
}
onMounted(async () => {
if (process.client && typeof window !== 'undefined') {
isDesktopEnv.value = !!window.wechatDesktop
const isElectron = /electron/i.test(String(navigator.userAgent || ''))
isDesktopEnv.value = isElectron && !!window.wechatDesktop
}
desktopAutoRealtime.value = readLocalBoolSetting(DESKTOP_SETTING_AUTO_REALTIME_KEY, false)
desktopDefaultToChatWhenData.value = readLocalBoolSetting(DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, false)
snsUseCache.value = readLocalBoolSetting(SNS_SETTING_USE_CACHE_KEY, true)
if (isDesktopEnv.value) {
void desktopUpdate.initListeners()
await refreshDesktopAutoLaunch()
await refreshDesktopCloseBehavior()
}
+966 -55
View File
File diff suppressed because it is too large Load Diff
+5 -54
View File
@@ -7,10 +7,8 @@
>
<!-- PPT 风格单张卡片占据全页面鼠标滚轮切换 -->
<WrappedDeckBackground />
<!-- CRT 叠加层仅用于像素屏类主题Win98 等桌面 GUI 主题不应开启 -->
<WrappedCRTOverlay v-if="theme === 'gameboy'" />
<!-- 左上角刷新 + 复古模式开关 -->
<!-- 左上角返回 + 刷新 -->
<div class="absolute top-6 left-6 z-20 select-none">
<div class="flex items-center gap-3">
<button
@@ -59,24 +57,6 @@
</svg>
</button>
<button
type="button"
class="pointer-events-auto inline-flex items-center justify-center w-9 h-9 rounded-full bg-transparent transition disabled:opacity-60 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-[#07C160]/30"
:class="isRetro ? 'text-[#07C160] hover:bg-[#07C160]/10' : 'text-[#00000055] hover:bg-[#000000]/5'"
:aria-pressed="isRetro ? 'true' : 'false'"
:aria-label="`复古模式当前${theme === 'off' ? 'Modern' : theme.toUpperCase()}`"
:title="`复古模式:${theme === 'off' ? 'Modern' : theme.toUpperCase()}(点击切换)`"
@click="cycleTheme"
>
<img
src="/assets/images/wechat-audio-dark.png"
class="w-4 h-4 transition"
:style="{ filter: isRetro ? 'none' : 'grayscale(1)', opacity: isRetro ? '1' : '0.55' }"
alt=""
aria-hidden="true"
draggable="false"
/>
</button>
</div>
<div v-if="error" class="mt-2 pointer-events-auto bg-white/90 backdrop-blur rounded-xl border border-red-200 px-3 py-2">
@@ -205,11 +185,6 @@
</section>
</div>
<!-- Win98底部任务栏 -->
<WrappedWin98Taskbar
v-if="theme === 'win98'"
:title="taskbarTitle"
/>
</div>
</template>
@@ -229,8 +204,8 @@ const year = ref(Number(route.query?.year) || new Date().getFullYear())
// 分享视图不展示账号信息:默认让后端自动选择;需要指定时可用 query ?account=wxid_xxx
const account = ref(typeof route.query?.account === 'string' ? route.query.account : '')
// 主题管理:modern / gameboy / win98
const { theme, cycleTheme, isRetro, themeClass } = useWrappedTheme()
// 主题:仅保留 Modern
const { isRetro, themeClass } = useWrappedTheme()
const accounts = ref([])
const accountsLoading = ref(true)
@@ -262,13 +237,6 @@ const wheelAcc = ref(0)
let navUnlockTimer = null
let deckResizeObserver = null
// 各主题的背景颜色
const THEME_BG = {
off: '#F3FFF8', // Modern: 浅绿
gameboy: '#9bbc0f', // Game Boy: 亮绿
win98: '#008080' // Win98: 经典桌面青色
}
const slides = computed(() => {
const cards = Array.isArray(report.value?.cards) ? report.value.cards : []
const out = [{ key: 'cover' }]
@@ -276,15 +244,7 @@ const slides = computed(() => {
return out
})
const taskbarTitle = computed(() => {
if (theme.value !== 'win98') return ''
if (activeIndex.value === 0) return `${year.value} WeChat Wrapped`
const idx = activeIndex.value - 1
const c = report.value?.cards?.[idx]
return String(c?.title || 'WeChat Wrapped')
})
const currentBg = computed(() => THEME_BG[theme.value] || THEME_BG.off)
const currentBg = computed(() => '#F3FFF8')
const deckTrackClass = computed(() => 'z-10')
const applyViewportBg = () => {
@@ -423,11 +383,8 @@ const onTouchEnd = (e) => {
const updateViewport = () => {
const h = Math.round(deckEl.value?.getBoundingClientRect?.().height || deckEl.value?.clientHeight || window.innerHeight || 0)
if (!h) return
// Reserve space for the Win98 taskbar at the bottom.
const offset = theme.value === 'win98' ? 40 : 0
const effective = Math.max(0, h - offset)
// Avoid endless reflows from 1px rounding errors (especially in Electron).
if (Math.abs(viewportHeight.value - effective) > 1) viewportHeight.value = effective
if (Math.abs(viewportHeight.value - h) > 1) viewportHeight.value = h
}
const loadAccounts = async () => {
@@ -592,12 +549,6 @@ onMounted(async () => {
}
})
// Theme switch may change reserved UI space (e.g., Win98 taskbar)
watch(theme, () => {
applyViewportBg()
updateViewport()
})
onBeforeUnmount(() => {
if (import.meta.client) {
document.documentElement.style.backgroundColor = ''
+2
View File
@@ -1,5 +1,7 @@
export const DESKTOP_SETTING_AUTO_REALTIME_KEY = 'desktop.settings.autoRealtime'
export const DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY = 'desktop.settings.defaultToChatWhenData'
// 朋友圈图片:是否允许使用缓存(默认开启)。关闭后会尽量每次都走下载+解密流程。
export const SNS_SETTING_USE_CACHE_KEY = 'sns.settings.useCache'
export const readLocalBoolSetting = (key, fallback = false) => {
if (!process.client) return !!fallback
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "wechat-decrypt-tool"
version = "0.2.1"
version = "1.3.0"
description = "Modern WeChat database decryption tool with React frontend"
readme = "README.md"
requires-python = ">=3.11"
+2 -2
View File
@@ -1,5 +1,5 @@
"""微信数据库解密工具
"""
__version__ = "0.1.0"
__author__ = "WeChat Decrypt Tool"
__version__ = "1.3.0"
__author__ = "WeChat Decrypt Tool"
+4 -1
View File
@@ -9,6 +9,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.responses import FileResponse
from starlette.staticfiles import StaticFiles
from . import __version__ as APP_VERSION
from .logging_config import setup_logging, get_logger
from .path_fix import PathFixRoute
from .chat_realtime_autosync import CHAT_REALTIME_AUTOSYNC
@@ -21,6 +22,7 @@ from .routers.health import router as _health_router
from .routers.keys import router as _keys_router
from .routers.media import router as _media_router
from .routers.sns import router as _sns_router
from .routers.sns_export import router as _sns_export_router
from .routers.wechat_detection import router as _wechat_detection_router
from .routers.wrapped import router as _wrapped_router
from .wcdb_realtime import WCDB_REALTIME, shutdown as _wcdb_shutdown
@@ -32,7 +34,7 @@ logger = get_logger(__name__)
app = FastAPI(
title="微信数据库解密工具",
description="现代化的微信数据库解密工具,支持微信信息检测和数据库解密功能",
version="0.1.0",
version=APP_VERSION,
)
# 设置自定义路由类
@@ -57,6 +59,7 @@ app.include_router(_chat_contacts_router)
app.include_router(_chat_export_router)
app.include_router(_chat_media_router)
app.include_router(_sns_router)
app.include_router(_sns_export_router)
app.include_router(_wrapped_router)
+210
View File
@@ -0,0 +1,210 @@
from __future__ import annotations
"""ISAAC-64 PRNG (best-effort fallback).
In this repo, Moments (SNS) *video* decryption uses a keystream generator that
matches WeFlow's WxIsaac64 (WASM) behavior and XORs only the first 128KB of the
MP4.
This module provides a pure-Python ISAAC-64 implementation so the backend can
still attempt to generate a keystream when the WASM helper is unavailable.
Notes:
- Moments *image* decryption is handled via `wcdb_api.dll` (`wcdb_decrypt_sns_image`)
because "ISAAC-64 full-file XOR" is not reliably reproducible for images across
different versions/samples.
- This ISAAC-64 implementation may not perfectly match WxIsaac64; treat it as
best-effort.
"""
from typing import Any, Literal
_MASK_64 = 0xFFFFFFFFFFFFFFFF
def _u64(v: int) -> int:
return int(v) & _MASK_64
class Isaac64:
def __init__(self, seed: Any):
seed_text = str(seed).strip()
if not seed_text:
seed_val = 0
else:
try:
# WeFlow seeds with BigInt(seed), where seed is usually a decimal string.
seed_val = int(seed_text, 0)
except Exception:
seed_val = 0
self.mm = [_u64(0) for _ in range(256)]
self.aa = _u64(0)
self.bb = _u64(0)
self.cc = _u64(0)
self.randrsl = [_u64(0) for _ in range(256)]
self.randrsl[0] = _u64(seed_val)
self.randcnt = 0
self._init(True)
def _init(self, flag: bool) -> None:
a = b = c = d = e = f = g = h = _u64(0x9E3779B97F4A7C15)
def mix() -> tuple[int, int, int, int, int, int, int, int]:
nonlocal a, b, c, d, e, f, g, h
a = _u64(a - e)
f = _u64(f ^ (h >> 9))
h = _u64(h + a)
b = _u64(b - f)
g = _u64(g ^ _u64(a << 9))
a = _u64(a + b)
c = _u64(c - g)
h = _u64(h ^ (b >> 23))
b = _u64(b + c)
d = _u64(d - h)
a = _u64(a ^ _u64(c << 15))
c = _u64(c + d)
e = _u64(e - a)
b = _u64(b ^ (d >> 14))
d = _u64(d + e)
f = _u64(f - b)
c = _u64(c ^ _u64(e << 20))
e = _u64(e + f)
g = _u64(g - c)
d = _u64(d ^ (f >> 17))
f = _u64(f + g)
h = _u64(h - d)
e = _u64(e ^ _u64(g << 14))
g = _u64(g + h)
return a, b, c, d, e, f, g, h
for _ in range(4):
mix()
for i in range(0, 256, 8):
if flag:
a = _u64(a + self.randrsl[i])
b = _u64(b + self.randrsl[i + 1])
c = _u64(c + self.randrsl[i + 2])
d = _u64(d + self.randrsl[i + 3])
e = _u64(e + self.randrsl[i + 4])
f = _u64(f + self.randrsl[i + 5])
g = _u64(g + self.randrsl[i + 6])
h = _u64(h + self.randrsl[i + 7])
mix()
self.mm[i] = a
self.mm[i + 1] = b
self.mm[i + 2] = c
self.mm[i + 3] = d
self.mm[i + 4] = e
self.mm[i + 5] = f
self.mm[i + 6] = g
self.mm[i + 7] = h
if flag:
for i in range(0, 256, 8):
a = _u64(a + self.mm[i])
b = _u64(b + self.mm[i + 1])
c = _u64(c + self.mm[i + 2])
d = _u64(d + self.mm[i + 3])
e = _u64(e + self.mm[i + 4])
f = _u64(f + self.mm[i + 5])
g = _u64(g + self.mm[i + 6])
h = _u64(h + self.mm[i + 7])
mix()
self.mm[i] = a
self.mm[i + 1] = b
self.mm[i + 2] = c
self.mm[i + 3] = d
self.mm[i + 4] = e
self.mm[i + 5] = f
self.mm[i + 6] = g
self.mm[i + 7] = h
self._isaac64()
self.randcnt = 256
def _isaac64(self) -> None:
self.cc = _u64(self.cc + 1)
self.bb = _u64(self.bb + self.cc)
for i in range(256):
x = self.mm[i]
if (i & 3) == 0:
# aa ^= ~(aa << 21)
self.aa = _u64(self.aa ^ (_u64(self.aa << 21) ^ _MASK_64))
elif (i & 3) == 1:
self.aa = _u64(self.aa ^ (self.aa >> 5))
elif (i & 3) == 2:
self.aa = _u64(self.aa ^ _u64(self.aa << 12))
else:
self.aa = _u64(self.aa ^ (self.aa >> 33))
self.aa = _u64(self.mm[(i + 128) & 255] + self.aa)
y = _u64(self.mm[(x >> 3) & 255] + self.aa + self.bb)
self.mm[i] = y
self.bb = _u64(self.mm[(y >> 11) & 255] + x)
self.randrsl[i] = self.bb
def rand_u64(self) -> int:
"""Return the next ISAAC-64 output as an unsigned 64-bit integer.
Note: The original reference `rand()` consumes `randrsl[]` in reverse order.
"""
if self.randcnt == 0:
self._isaac64()
self.randcnt = 256
self.randcnt -= 1
return _u64(self.randrsl[self.randcnt])
# Backward-compatible alias (older callers used `get_next()`).
def get_next(self) -> int: # pragma: no cover
return self.rand_u64()
KeystreamWordFormat = Literal["raw_le", "raw_be", "be_swap32", "le_swap32"]
@staticmethod
def _raw_to_bytes(raw: int, word_format: KeystreamWordFormat) -> bytes:
"""Serialize one 64-bit `rand()` output to 8 bytes.
- raw_le/raw_be: direct endianness of the 64-bit integer.
- be_swap32: big-endian bytes with 32-bit halves swapped (BE(lo32)||BE(hi32)).
This matches the byte layout implied by the doc's `htonl(hi32)||htonl(lo32)`
pattern when the resulting u64 is read as bytes on little-endian hosts.
- le_swap32: little-endian bytes with 32-bit halves swapped.
"""
v = _u64(raw)
if word_format == "raw_le":
return int(v).to_bytes(8, "little", signed=False)
if word_format == "raw_be":
return int(v).to_bytes(8, "big", signed=False)
if word_format == "be_swap32":
b = int(v).to_bytes(8, "big", signed=False)
return b[4:8] + b[0:4]
if word_format == "le_swap32":
b = int(v).to_bytes(8, "little", signed=False)
return b[4:8] + b[0:4]
raise ValueError(f"Unknown ISAAC64 word_format: {word_format}")
def generate_keystream(self, size: int, *, word_format: KeystreamWordFormat = "be_swap32") -> bytes:
"""Generate a keystream of `size` bytes.
This mirrors the decryption loop behavior: produce a new 8-byte keyblock
for every 8 bytes of input, and slice for tail bytes.
"""
want = int(size or 0)
if want <= 0:
return b""
blocks = (want + 7) // 8
out = bytearray()
for _ in range(blocks):
out.extend(self._raw_to_bytes(self.rand_u64(), word_format))
return bytes(out[:want])
Binary file not shown.
+283 -3
View File
@@ -1,10 +1,20 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from __future__ import annotations
import asyncio
import json
import os
import time
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from starlette.responses import StreamingResponse
from ..app_paths import get_output_databases_dir
from ..logging_config import get_logger
from ..path_fix import PathFixRoute
from ..key_store import upsert_account_keys_in_store
from ..wechat_decrypt import decrypt_wechat_databases
from ..wechat_decrypt import WeChatDatabaseDecryptor, decrypt_wechat_databases
logger = get_logger(__name__)
@@ -72,3 +82,273 @@ async def decrypt_databases(request: DecryptRequest):
except Exception as e:
logger.error(f"解密API异常: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/decrypt_stream", summary="解密微信数据库(SSE实时进度)")
async def decrypt_databases_stream(
request: Request,
key: str | None = None,
db_storage_path: str | None = None,
):
"""通过SSE实时推送数据库解密进度。
注意:EventSource 只支持 GET,因此参数通过 querystring 传递。
"""
def _sse(payload: dict) -> str:
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
async def generate_progress():
# 1) Basic validation (keep 200 + SSE error event, avoid 422 breaking EventSource).
k = str(key or "").strip()
p = str(db_storage_path or "").strip()
if not k or len(k) != 64:
yield _sse({"type": "error", "message": "密钥格式无效,必须是64位十六进制字符串"})
return
try:
bytes.fromhex(k)
except Exception:
yield _sse({"type": "error", "message": "密钥必须是有效的十六进制字符串"})
return
if not p:
yield _sse({"type": "error", "message": "请提供 db_storage_path 参数"})
return
storage_path = Path(p)
if not storage_path.exists():
yield _sse({"type": "error", "message": f"指定的数据库路径不存在: {p}"})
return
# 2) Scan databases.
yield _sse({"type": "scanning", "message": "正在扫描数据库文件..."})
await asyncio.sleep(0)
account_name = "unknown_account"
path_parts = storage_path.parts
account_patterns = ["wxid_"]
for part in path_parts:
for pattern in account_patterns:
if part.startswith(pattern):
parts = part.split("_")
if len(parts) >= 3:
account_name = "_".join(parts[:-1])
else:
account_name = part
break
if account_name != "unknown_account":
break
if account_name == "unknown_account":
for part in reversed(path_parts):
if part != "db_storage" and len(part) > 3:
account_name = part
break
databases: list[dict] = []
for root, _dirs, files in os.walk(storage_path):
if "db_storage" not in str(root):
continue
for file_name in files:
if not file_name.endswith(".db"):
continue
if file_name in ["key_info.db"]:
continue
db_path = os.path.join(root, file_name)
databases.append({"path": db_path, "name": file_name, "account": account_name})
if not databases:
yield _sse({"type": "error", "message": "未找到微信数据库文件!请检查 db_storage_path 是否正确"})
return
account_databases = {account_name: databases}
total_databases = sum(len(dbs) for dbs in account_databases.values())
yield _sse({"type": "start", "total": total_databases, "message": f"开始解密 {total_databases} 个数据库"})
await asyncio.sleep(0)
# 3) Init output dir & decryptor.
base_output_dir = get_output_databases_dir()
base_output_dir.mkdir(parents=True, exist_ok=True)
try:
decryptor = WeChatDatabaseDecryptor(k)
except ValueError as e:
yield _sse({"type": "error", "message": f"密钥错误: {e}"})
return
# 4) Decrypt per account, stream progress.
success_count = 0
fail_count = 0
processed_files: list[str] = []
failed_files: list[str] = []
account_results: dict = {}
overall_current = 0
for account, dbs in account_databases.items():
account_output_dir = base_output_dir / account
account_output_dir.mkdir(parents=True, exist_ok=True)
# Save a hint for later UI (same as non-stream endpoint).
try:
source_db_storage_path = p
wxid_dir = ""
if storage_path.name.lower() == "db_storage":
wxid_dir = str(storage_path.parent)
else:
wxid_dir = str(storage_path)
(account_output_dir / "_source.json").write_text(
json.dumps({"db_storage_path": source_db_storage_path, "wxid_dir": wxid_dir}, ensure_ascii=False, indent=2),
encoding="utf-8",
)
except Exception:
pass
account_success = 0
account_processed: list[str] = []
account_failed: list[str] = []
for db_info in dbs:
if await request.is_disconnected():
return
overall_current += 1
db_path = str(db_info.get("path") or "")
db_name = str(db_info.get("name") or "")
current_file = f"{account}/{db_name}" if account else db_name
# Emit a "processing" event so UI updates immediately for large db files.
yield _sse(
{
"type": "progress",
"current": overall_current,
"total": total_databases,
"success_count": success_count,
"fail_count": fail_count,
"current_file": current_file,
"status": "processing",
"message": "解密中...",
}
)
output_path = account_output_dir / db_name
task = asyncio.create_task(asyncio.to_thread(decryptor.decrypt_database, db_path, str(output_path)))
# Wait with heartbeat (can't yield while awaiting the thread directly).
last_heartbeat = time.time()
while not task.done():
if await request.is_disconnected():
return
now = time.time()
if now - last_heartbeat > 15:
last_heartbeat = now
# SSE comment heartbeat; browsers ignore but keeps proxies alive.
yield ": ping\n\n"
await asyncio.sleep(0.6)
try:
ok = bool(task.result())
except Exception:
ok = False
if ok:
account_success += 1
success_count += 1
account_processed.append(str(output_path))
processed_files.append(str(output_path))
status = "success"
msg = "解密成功"
else:
account_failed.append(db_path)
failed_files.append(db_path)
fail_count += 1
status = "fail"
msg = "解密失败"
yield _sse(
{
"type": "progress",
"current": overall_current,
"total": total_databases,
"success_count": success_count,
"fail_count": fail_count,
"current_file": current_file,
"status": status,
"message": msg,
}
)
if overall_current % 5 == 0:
await asyncio.sleep(0)
account_results[account] = {
"total": len(dbs),
"success": account_success,
"failed": len(dbs) - account_success,
"output_dir": str(account_output_dir),
"processed_files": account_processed,
"failed_files": account_failed,
}
# Build cache table (keep behavior consistent with the POST endpoint).
if os.environ.get("WECHAT_TOOL_BUILD_SESSION_LAST_MESSAGE", "1") != "0":
yield _sse(
{
"type": "phase",
"phase": "session_last_message",
"account": account,
"message": "正在构建会话缓存(最后一条消息)...",
}
)
await asyncio.sleep(0)
try:
from ..session_last_message import build_session_last_message_table
task = asyncio.create_task(
asyncio.to_thread(
build_session_last_message_table,
account_output_dir,
rebuild=True,
include_hidden=True,
include_official=True,
)
)
last_heartbeat = time.time()
while not task.done():
if await request.is_disconnected():
return
now = time.time()
if now - last_heartbeat > 15:
last_heartbeat = now
yield ": ping\n\n"
await asyncio.sleep(0.6)
account_results[account]["session_last_message"] = task.result()
except Exception as e:
account_results[account]["session_last_message"] = {"status": "error", "message": str(e)}
status = "completed" if success_count > 0 else "failed"
result = {
"status": status,
"total_databases": total_databases,
"success_count": success_count,
"failure_count": total_databases - success_count,
"output_directory": str(base_output_dir.absolute()),
"message": f"解密完成: 成功 {success_count}/{total_databases}",
"processed_files": processed_files,
"failed_files": failed_files,
"account_results": account_results,
}
# Save db key for frontend autofill.
try:
for account in (account_results or {}).keys():
upsert_account_keys_in_store(str(account), db_key=k)
except Exception:
pass
yield _sse({"type": "complete", **result})
headers = {"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"}
return StreamingResponse(generate_progress(), media_type="text/event-stream", headers=headers)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,114 @@
import asyncio
import json
import time
from typing import Literal, Optional
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, StreamingResponse
from pydantic import BaseModel, Field
from ..path_fix import PathFixRoute
from ..sns_export_service import SNS_EXPORT_MANAGER
router = APIRouter(route_class=PathFixRoute)
ExportScope = Literal["selected", "all"]
class SnsExportCreateRequest(BaseModel):
account: Optional[str] = Field(None, description="账号目录名(可选,默认使用第一个)")
scope: ExportScope = Field("selected", description="导出范围:selected=指定联系人;all=全部联系人")
usernames: list[str] = Field(default_factory=list, description="朋友圈 username 列表(scope=selected 时使用)")
use_cache: bool = Field(True, description="是否复用导出过程中的本地缓存(默认开启)")
output_dir: Optional[str] = Field(None, description="导出目录绝对路径(可选;不填时使用默认目录)")
file_name: Optional[str] = Field(None, description="导出 zip 文件名(可选,不含/含 .zip 都可)")
@router.post("/api/sns/exports", summary="创建朋友圈导出任务(离线 HTML zip)")
async def create_sns_export(req: SnsExportCreateRequest):
job = SNS_EXPORT_MANAGER.create_job(
account=req.account,
scope=req.scope,
usernames=req.usernames,
use_cache=bool(req.use_cache),
output_dir=req.output_dir,
file_name=req.file_name,
)
return {"status": "success", "job": job.to_public_dict()}
@router.get("/api/sns/exports", summary="列出导出任务(内存)")
async def list_sns_exports():
jobs = [j.to_public_dict() for j in SNS_EXPORT_MANAGER.list_jobs()]
jobs.sort(key=lambda x: int(x.get("createdAt") or 0), reverse=True)
return {"status": "success", "jobs": jobs}
@router.get("/api/sns/exports/{export_id}", summary="获取导出任务状态")
async def get_sns_export(export_id: str):
job = SNS_EXPORT_MANAGER.get_job(str(export_id or "").strip())
if not job:
raise HTTPException(status_code=404, detail="Export not found.")
return {"status": "success", "job": job.to_public_dict()}
@router.get("/api/sns/exports/{export_id}/download", summary="下载导出 zip")
async def download_sns_export(export_id: str):
job = SNS_EXPORT_MANAGER.get_job(str(export_id or "").strip())
if not job:
raise HTTPException(status_code=404, detail="Export not found.")
if not job.zip_path or (not job.zip_path.exists()):
raise HTTPException(status_code=409, detail="Export not ready.")
return FileResponse(
str(job.zip_path),
media_type="application/zip",
filename=job.zip_path.name,
)
@router.get("/api/sns/exports/{export_id}/events", summary="导出任务进度 SSE")
async def stream_sns_export_events(export_id: str, request: Request):
export_id = str(export_id or "").strip()
job0 = SNS_EXPORT_MANAGER.get_job(export_id)
if not job0:
raise HTTPException(status_code=404, detail="Export not found.")
async def gen():
last_payload = ""
last_heartbeat = 0.0
while True:
if await request.is_disconnected():
break
job = SNS_EXPORT_MANAGER.get_job(export_id)
if not job:
yield "event: error\ndata: " + json.dumps({"error": "Export not found."}, ensure_ascii=False) + "\n\n"
break
payload = json.dumps(job.to_public_dict(), ensure_ascii=False)
if payload != last_payload:
last_payload = payload
yield f"data: {payload}\n\n"
now = time.time()
if now - last_heartbeat > 15:
last_heartbeat = now
yield ": ping\n\n"
if job.status in {"done", "error", "cancelled"}:
break
await asyncio.sleep(0.6)
headers = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
return StreamingResponse(gen(), media_type="text/event-stream", headers=headers)
@router.delete("/api/sns/exports/{export_id}", summary="取消导出任务")
async def cancel_sns_export(export_id: str):
ok = SNS_EXPORT_MANAGER.cancel_job(str(export_id or "").strip())
if not ok:
raise HTTPException(status_code=404, detail="Export not found.")
return {"status": "success"}
File diff suppressed because it is too large Load Diff
+710
View File
@@ -0,0 +1,710 @@
from __future__ import annotations
"""SNS (Moments) remote media download + decryption helpers.
This module centralizes the "remote URL -> download -> decrypt -> validate -> cache" pipeline
so it can be reused by:
- FastAPI endpoints (`routers/sns.py`)
- Offline export (`sns_export_service.py`)
Important notes (empirical, matches current repo behavior):
- SNS images: prefer `wcdb_api.dll` export `wcdb_decrypt_sns_image` (black-box). Pure ISAAC64
keystream XOR is NOT reliable for images across versions.
- SNS videos: encrypted only for the first 128KB; decrypt via WeFlow's WxIsaac64 (WASM keystream)
and XOR in-place.
"""
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse
import base64
import hashlib
import html
import os
import re
import subprocess
import time
import httpx
from fastapi import HTTPException
from .logging_config import get_logger
from .wcdb_realtime import decrypt_sns_image as _wcdb_decrypt_sns_image
logger = get_logger(__name__)
def is_allowed_sns_media_host(host: str) -> bool:
h = str(host or "").strip().lower()
if not h:
return False
# Images: qpic/qlogo. Thumbs: *.tc.qq.com. Videos/live photos: *.video.qq.com.
return h.endswith(".qpic.cn") or h.endswith(".qlogo.cn") or h.endswith(".tc.qq.com") or h.endswith(".video.qq.com")
def fix_sns_cdn_url(url: str, *, token: str = "", is_video: bool = False) -> str:
"""WeFlow-compatible SNS CDN URL normalization.
- Force https for Tencent CDNs.
- For images, replace `/150` with `/0` to request the original.
- If token is provided and url doesn't contain it, append `token=<token>&idx=1`.
"""
u = html.unescape(str(url or "")).strip()
if not u:
return ""
# Only touch Tencent CDNs; keep other URLs intact.
try:
p = urlparse(u)
host = str(p.hostname or "").lower()
if not is_allowed_sns_media_host(host):
return u
except Exception:
return u
# http -> https
u = re.sub(r"^http://", "https://", u, flags=re.I)
# /150 -> /0 (image only)
if not is_video:
u = re.sub(r"/150(?=($|\\?))", "/0", u)
tok = str(token or "").strip()
if tok and ("token=" not in u):
if is_video:
# Match WeFlow: place `token&idx=1` in front of existing query params.
base, sep, qs = u.partition("?")
if sep:
qs = qs.lstrip("&")
u = f"{base}?token={tok}&idx=1"
if qs:
u = f"{u}&{qs}"
else:
u = f"{u}?token={tok}&idx=1"
else:
connector = "&" if "?" in u else "?"
u = f"{u}{connector}token={tok}&idx=1"
return u
def _detect_mp4_ftyp(head: bytes) -> bool:
return bool(head) and len(head) >= 8 and head[4:8] == b"ftyp"
@lru_cache(maxsize=1)
def _weflow_wxisaac64_script_path() -> str:
"""Locate the Node helper that wraps WeFlow's wasm_video_decode.* assets."""
repo_root = Path(__file__).resolve().parents[2]
script = repo_root / "tools" / "weflow_wasm_keystream.js"
if script.exists() and script.is_file():
return str(script)
return ""
@lru_cache(maxsize=64)
def weflow_wxisaac64_keystream(key: str, size: int) -> bytes:
"""Generate keystream via WeFlow's WASM (preferred; matches real video decryption)."""
key_text = str(key or "").strip()
if not key_text or size <= 0:
return b""
# WeFlow is the source-of-truth; use its WASM first, then fall back to our pure-python ISAAC64.
script = _weflow_wxisaac64_script_path()
if script:
try:
# The JS helper prints ONLY base64 bytes to stdout; keep stderr for debugging.
proc = subprocess.run(
["node", script, key_text, str(int(size))],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=30,
check=False,
)
if proc.returncode == 0:
out_b64 = (proc.stdout or b"").strip()
if out_b64:
return base64.b64decode(out_b64, validate=False)
except Exception:
pass
# Fallback: pure python ISAAC64 (best-effort; may not match WxIsaac64 for all versions).
from .isaac64 import Isaac64 # pylint: disable=import-outside-toplevel
want = int(size)
# ISAAC64 generates 8-byte words; generate enough and slice.
size8 = ((want + 7) // 8) * 8
return Isaac64(key_text).generate_keystream(size8)[:want]
_SNS_REMOTE_VIDEO_CACHE_EXTS = [
".mp4",
".bin", # legacy/unknown
]
def _sns_remote_video_cache_dir_and_stem(account_dir: Path, *, url: str, key: str) -> tuple[Path, str]:
digest = hashlib.md5(f"video|{url}|{key}".encode("utf-8", errors="ignore")).hexdigest()
cache_dir = account_dir / "sns_remote_video_cache" / digest[:2]
return cache_dir, digest
def _sns_remote_video_cache_existing_path(cache_dir: Path, stem: str) -> Optional[Path]:
for ext in _SNS_REMOTE_VIDEO_CACHE_EXTS:
p = cache_dir / f"{stem}{ext}"
try:
if p.exists() and p.is_file():
return p
except Exception:
continue
return None
async def _download_sns_remote_to_file(url: str, dest_path: Path, *, max_bytes: int) -> tuple[str, str]:
"""Download SNS media to file (streaming) from Tencent CDN.
Returns: (content_type, x_enc)
"""
u = str(url or "").strip()
if not u:
return "", ""
# Safety: only allow Tencent CDN hosts.
try:
p = urlparse(u)
host = str(p.hostname or "").lower()
if not is_allowed_sns_media_host(host):
raise HTTPException(status_code=400, detail="SNS media host not allowed.")
except HTTPException:
raise
except Exception:
raise HTTPException(status_code=400, detail="Invalid SNS media URL.")
base_headers = {
"User-Agent": "MicroMessenger Client",
"Accept": "*/*",
# Do not request compression for video streams.
"Connection": "keep-alive",
}
header_variants = [
{},
# WeFlow/Electron: MicroMessenger UA + servicewechat.com referer passes some CDN anti-hotlink checks.
{
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351",
"Referer": "https://servicewechat.com/",
"Origin": "https://servicewechat.com",
},
{"Referer": "https://wx.qq.com/", "Origin": "https://wx.qq.com"},
{"Referer": "https://mp.weixin.qq.com/", "Origin": "https://mp.weixin.qq.com"},
]
last_err: Exception | None = None
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
for extra in header_variants:
headers = dict(base_headers)
headers.update(extra)
try:
if dest_path.exists():
try:
dest_path.unlink(missing_ok=True)
except Exception:
pass
total = 0
async with client.stream("GET", u, headers=headers) as resp:
resp.raise_for_status()
content_type = str(resp.headers.get("Content-Type") or "").strip()
x_enc = str(resp.headers.get("x-enc") or "").strip()
dest_path.parent.mkdir(parents=True, exist_ok=True)
with dest_path.open("wb") as f:
async for chunk in resp.aiter_bytes():
if not chunk:
continue
total += len(chunk)
if total > max_bytes:
raise HTTPException(status_code=400, detail="SNS video too large.")
f.write(chunk)
return content_type, x_enc
except HTTPException:
raise
except Exception as e:
last_err = e
continue
raise last_err or RuntimeError("sns remote download failed")
def maybe_decrypt_sns_video_file(path: Path, key: str) -> bool:
"""Decrypt the first 128KB of an encrypted mp4 file in-place (WeFlow/Isaac64).
Returns True if decryption was performed, False otherwise.
"""
key_text = str(key or "").strip()
if not key_text:
return False
try:
size = int(path.stat().st_size)
except Exception:
return False
if size <= 8:
return False
decrypt_size = min(131072, size)
if decrypt_size <= 0:
return False
try:
with path.open("r+b") as f:
head = f.read(8)
if _detect_mp4_ftyp(head):
return False
f.seek(0)
buf = bytearray(f.read(decrypt_size))
if not buf:
return False
ks = weflow_wxisaac64_keystream(key_text, decrypt_size)
n = min(len(buf), len(ks))
for i in range(n):
buf[i] ^= ks[i]
f.seek(0)
f.write(buf)
f.flush()
f.seek(0)
head2 = f.read(8)
if _detect_mp4_ftyp(head2):
return True
# Still return True to indicate we mutated bytes; caller may treat as failure if desired.
return True
except Exception:
return False
async def materialize_sns_remote_video(
*,
account_dir: Path,
url: str,
key: str,
token: str,
use_cache: bool,
) -> Optional[Path]:
"""Download SNS video from CDN, decrypt (if needed), and return a local mp4 path."""
fixed_url = fix_sns_cdn_url(str(url or ""), token=str(token or ""), is_video=True)
if not fixed_url:
return None
cache_dir, cache_stem = _sns_remote_video_cache_dir_and_stem(account_dir, url=fixed_url, key=str(key or ""))
if use_cache:
existing = _sns_remote_video_cache_existing_path(cache_dir, cache_stem)
if existing is not None:
# Best-effort migrate legacy `.bin` -> `.mp4` when it's already decrypted.
try:
if existing.suffix.lower() == ".bin":
with existing.open("rb") as f:
head = f.read(8)
if _detect_mp4_ftyp(head):
target = cache_dir / f"{cache_stem}.mp4"
cache_dir.mkdir(parents=True, exist_ok=True)
os.replace(str(existing), str(target))
existing = target
except Exception:
pass
return existing
# Download to a temp file first.
cache_dir.mkdir(parents=True, exist_ok=True)
tmp_path = cache_dir / f"{cache_stem}.mp4.{time.time_ns()}.tmp"
try:
await _download_sns_remote_to_file(fixed_url, tmp_path, max_bytes=200 * 1024 * 1024)
except Exception:
try:
tmp_path.unlink(missing_ok=True)
except Exception:
pass
return None
# Decrypt in-place if the file isn't already a mp4.
maybe_decrypt_sns_video_file(tmp_path, str(key or ""))
# Validate: mp4 must have `ftyp` at offset 4.
ok_mp4 = False
try:
with tmp_path.open("rb") as f:
head = f.read(8)
ok_mp4 = _detect_mp4_ftyp(head)
except Exception:
ok_mp4 = False
if not ok_mp4:
try:
tmp_path.unlink(missing_ok=True)
except Exception:
pass
return None
if use_cache:
final_path = cache_dir / f"{cache_stem}.mp4"
try:
os.replace(str(tmp_path), str(final_path))
except Exception:
# If rename fails, keep tmp_path as fallback.
final_path = tmp_path
# Remove other extensions for the same cache key.
for other_ext in _SNS_REMOTE_VIDEO_CACHE_EXTS:
if other_ext.lower() == ".mp4":
continue
other = cache_dir / f"{cache_stem}{other_ext}"
try:
if other.exists() and other.is_file():
other.unlink(missing_ok=True)
except Exception:
continue
return final_path
# Cache disabled: keep the decrypted tmp_path (caller should delete it).
return tmp_path
def best_effort_unlink(path: str) -> None:
try:
Path(path).unlink(missing_ok=True)
except Exception:
pass
def detect_image_mime(data: bytes) -> str:
"""Sniff image mime type by magic bytes.
IMPORTANT: Do NOT trust HTTP Content-Type as a fallback here. We use this for
validating decrypted bytes. If we blindly trust `image/*`, a failed decrypt
would poison the disk cache and the frontend would keep showing broken images.
"""
if not data:
return ""
if data.startswith(b"\xFF\xD8\xFF"):
return "image/jpeg"
if data.startswith(b"\x89PNG\r\n\x1a\n"):
return "image/png"
if len(data) >= 6 and data[:6] in (b"GIF87a", b"GIF89a"):
return "image/gif"
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return "image/webp"
if len(data) >= 12 and data[4:8] == b"ftyp":
# ISO BMFF based image formats (HEIF/HEIC/AVIF).
brand = data[8:12]
if brand == b"avif":
return "image/avif"
if brand in (b"heic", b"heix", b"hevc", b"hevx"):
return "image/heic"
if brand in (b"heif", b"mif1", b"msf1"):
return "image/heif"
if data.startswith(b"BM"):
return "image/bmp"
return ""
_SNS_REMOTE_CACHE_EXTS = [
".jpg",
".jpeg",
".png",
".gif",
".webp",
".bmp",
".avif",
".heic",
".heif",
".bin", # legacy/unknown
]
def _mime_to_ext(mt: str) -> str:
m = str(mt or "").split(";", 1)[0].strip().lower()
return {
"image/jpeg": ".jpg",
"image/jpg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/webp": ".webp",
"image/bmp": ".bmp",
"image/avif": ".avif",
"image/heic": ".heic",
"image/heif": ".heif",
}.get(m, ".bin")
def _ext_to_mime(ext: str) -> str:
e = str(ext or "").strip().lower().lstrip(".")
return {
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"png": "image/png",
"gif": "image/gif",
"webp": "image/webp",
"bmp": "image/bmp",
"avif": "image/avif",
"heic": "image/heic",
"heif": "image/heif",
}.get(e, "")
def _sns_remote_cache_dir_and_stem(account_dir: Path, *, url: str, key: str) -> tuple[Path, str]:
digest = hashlib.md5(f"{url}|{key}".encode("utf-8", errors="ignore")).hexdigest()
cache_dir = account_dir / "sns_remote_cache" / digest[:2]
return cache_dir, digest
def _sns_remote_cache_existing_path(cache_dir: Path, stem: str) -> Optional[Path]:
for ext in _SNS_REMOTE_CACHE_EXTS:
p = cache_dir / f"{stem}{ext}"
try:
if p.exists() and p.is_file():
return p
except Exception:
continue
return None
def _sniff_image_mime_from_file(path: Path) -> str:
try:
with path.open("rb") as f:
head = f.read(64)
return detect_image_mime(head)
except Exception:
return ""
async def _download_sns_remote_bytes(url: str) -> tuple[bytes, str, str]:
"""Download SNS media bytes from Tencent CDN with a few safe header variants."""
u = str(url or "").strip()
if not u:
return b"", "", ""
max_bytes = 25 * 1024 * 1024
base_headers = {
"User-Agent": "MicroMessenger Client",
"Accept": "*/*",
"Accept-Language": "zh-CN,zh;q=0.9",
# Avoid brotli dependency issues; images are already compressed anyway.
"Accept-Encoding": "identity",
"Connection": "keep-alive",
}
# Some CDN endpoints return a small placeholder image for certain UA/Referer
# combinations but still respond 200. Try the simplest (base headers only)
# first to maximize the chance of getting the real media in one request.
header_variants = [
{},
# WeFlow/Electron: MicroMessenger UA + servicewechat.com referer passes some CDN anti-hotlink checks.
{
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351",
"Referer": "https://servicewechat.com/",
"Origin": "https://servicewechat.com",
},
{"Referer": "https://wx.qq.com/", "Origin": "https://wx.qq.com"},
{"Referer": "https://mp.weixin.qq.com/", "Origin": "https://mp.weixin.qq.com"},
]
last_err: Exception | None = None
async with httpx.AsyncClient(timeout=20.0, follow_redirects=True) as client:
for extra in header_variants:
headers = dict(base_headers)
headers.update(extra)
try:
resp = await client.get(u, headers=headers)
resp.raise_for_status()
payload = bytes(resp.content or b"")
if len(payload) > max_bytes:
raise HTTPException(status_code=400, detail="SNS media too large (>25MB).")
content_type = str(resp.headers.get("Content-Type") or "").strip()
x_enc = str(resp.headers.get("x-enc") or "").strip()
return payload, content_type, x_enc
except HTTPException:
raise
except Exception as e:
last_err = e
continue
raise last_err or RuntimeError("sns remote download failed")
@dataclass(frozen=True)
class SnsRemoteImageResult:
payload: bytes
media_type: str
source: str
x_enc: str = ""
cache_path: Optional[Path] = None
async def try_fetch_and_decrypt_sns_image_remote(
*,
account_dir: Path,
url: str,
key: str,
token: str,
use_cache: bool,
) -> Optional[SnsRemoteImageResult]:
"""Try WeFlow-style: download from CDN -> decrypt via wcdb_decrypt_sns_image -> return bytes.
Returns a SnsRemoteImageResult on success, or None on failure so caller can fall back to
local cache matching logic.
"""
u_fixed = fix_sns_cdn_url(url, token=token, is_video=False)
if not u_fixed:
return None
try:
p = urlparse(u_fixed)
host = str(p.hostname or "").strip().lower()
except Exception:
return None
if not is_allowed_sns_media_host(host):
return None
cache_dir, cache_stem = _sns_remote_cache_dir_and_stem(account_dir, url=u_fixed, key=str(key or ""))
cache_path: Optional[Path] = None
if use_cache:
try:
existing = _sns_remote_cache_existing_path(cache_dir, cache_stem)
if existing is not None:
mt = _ext_to_mime(existing.suffix)
# Upgrade legacy `.bin` cache to a proper image extension once.
if (existing.suffix or "").lower() == ".bin" or (not mt):
mt2 = _sniff_image_mime_from_file(existing)
if not mt2:
try:
existing.unlink(missing_ok=True)
except Exception:
pass
existing = None
else:
ext2 = _mime_to_ext(mt2)
if ext2 != ".bin":
try:
cache_dir.mkdir(parents=True, exist_ok=True)
desired = cache_dir / f"{cache_stem}{ext2}"
if desired.exists():
# Another process/version already wrote the real file; drop legacy bin.
existing.unlink(missing_ok=True)
existing = desired
else:
os.replace(str(existing), str(desired))
existing = desired
except Exception:
pass
mt = mt2
if existing is not None and mt:
try:
payload = existing.read_bytes()
except Exception:
payload = b""
if payload:
return SnsRemoteImageResult(
payload=payload,
media_type=mt,
source="remote-cache",
x_enc="",
cache_path=existing,
)
except Exception:
pass
try:
raw, _content_type, x_enc = await _download_sns_remote_bytes(u_fixed)
except Exception as e:
logger.info("[sns_media] remote download failed: %s", e)
return None
if not raw:
return None
# First, validate whether the CDN already returned a real image.
mt_raw = detect_image_mime(raw)
decoded = raw
mt = mt_raw
decrypted = False
k = str(key or "").strip()
# Only attempt decryption when bytes do NOT look like an image, or when CDN explicitly
# signals encryption (x-enc). Some endpoints return already-decoded PNG/JPEG even when
# urlAttrs.enc_idx == 1, and decrypting those would corrupt the bytes.
need_decrypt = bool(k) and (not mt_raw) and bool(raw)
if k and x_enc and str(x_enc).strip() not in ("0", "false", "False"):
need_decrypt = True
if need_decrypt:
try:
decoded2 = _wcdb_decrypt_sns_image(raw, k)
mt2 = detect_image_mime(decoded2)
if mt2:
decoded = decoded2
mt = mt2
decrypted = decoded2 != raw
else:
# Decrypt failed; if raw is a real image, keep it. Otherwise treat as failure.
if mt_raw:
decoded = raw
mt = mt_raw
decrypted = False
else:
return None
except Exception as e:
logger.info("[sns_media] remote decrypt failed: %s", e)
if not mt_raw:
return None
decoded = raw
mt = mt_raw
decrypted = False
if not mt:
return None
if use_cache:
try:
ext = _mime_to_ext(mt)
cache_dir.mkdir(parents=True, exist_ok=True)
cache_path = cache_dir / f"{cache_stem}{ext}"
tmp = cache_path.with_suffix(cache_path.suffix + f".{time.time_ns()}.tmp")
tmp.write_bytes(decoded)
os.replace(str(tmp), str(cache_path))
# Remove other extensions for the same cache key to avoid stale duplicates.
for other_ext in _SNS_REMOTE_CACHE_EXTS:
if other_ext.lower() == ext.lower():
continue
other = cache_dir / f"{cache_stem}{other_ext}"
try:
if other.exists() and other.is_file():
other.unlink(missing_ok=True)
except Exception:
continue
except Exception:
cache_path = None
return SnsRemoteImageResult(
payload=decoded,
media_type=mt,
source="remote-decrypt" if decrypted else "remote",
x_enc=str(x_enc or "").strip(),
cache_path=cache_path,
)
@@ -0,0 +1,274 @@
"""SNS (Moments) realtime -> decrypted sqlite incremental sync.
Why:
- We can read the latest Moments via WCDB realtime, but the decrypted snapshot (`output/databases/{account}/sns.db`)
can lag behind or miss data (e.g. you viewed it when it was visible, then it became "only last 3 days").
- For export/offline browsing, we want to keep a local append-only cache of Moments that were visible at some point.
This module runs a lightweight background poller that watches db_storage/sns*.db mtime changes and triggers a cheap
incremental sync of the latest N Moments into the decrypted snapshot.
"""
from __future__ import annotations
import os
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
from fastapi import HTTPException
from .chat_helpers import _list_decrypted_accounts, _resolve_account_dir
from .logging_config import get_logger
from .wcdb_realtime import WCDB_REALTIME
logger = get_logger(__name__)
def _env_bool(name: str, default: bool) -> bool:
raw = str(os.environ.get(name, "") or "").strip().lower()
if not raw:
return default
return raw not in {"0", "false", "no", "off"}
def _env_int(name: str, default: int, *, min_v: int, max_v: int) -> int:
raw = str(os.environ.get(name, "") or "").strip()
try:
v = int(raw)
except Exception:
v = int(default)
if v < min_v:
v = min_v
if v > max_v:
v = max_v
return v
def _mtime_ns(path: Path) -> int:
try:
st = path.stat()
m_ns = int(getattr(st, "st_mtime_ns", 0) or 0)
if m_ns <= 0:
m_ns = int(float(getattr(st, "st_mtime", 0.0) or 0.0) * 1_000_000_000)
return int(m_ns)
except Exception:
return 0
def _scan_sns_db_mtime_ns(db_storage_dir: Path) -> int:
"""Best-effort "latest mtime" signal for sns.db buckets."""
base = Path(db_storage_dir)
candidates: list[Path] = [
base / "sns" / "sns.db",
base / "sns" / "sns.db-wal",
base / "sns" / "sns.db-shm",
base / "sns.db",
base / "sns.db-wal",
base / "sns.db-shm",
]
max_ns = 0
for p in candidates:
v = _mtime_ns(p)
if v > max_ns:
max_ns = v
return int(max_ns)
@dataclass
class _AccountState:
last_mtime_ns: int = 0
due_at: float = 0.0
last_sync_end_at: float = 0.0
thread: Optional[threading.Thread] = None
class SnsRealtimeAutoSyncService:
def __init__(self) -> None:
self._enabled = _env_bool("WECHAT_TOOL_SNS_AUTOSYNC", True)
self._interval_ms = _env_int("WECHAT_TOOL_SNS_AUTOSYNC_INTERVAL_MS", 2000, min_v=500, max_v=60_000)
self._debounce_ms = _env_int("WECHAT_TOOL_SNS_AUTOSYNC_DEBOUNCE_MS", 800, min_v=0, max_v=60_000)
self._min_sync_interval_ms = _env_int(
"WECHAT_TOOL_SNS_AUTOSYNC_MIN_SYNC_INTERVAL_MS", 5000, min_v=0, max_v=300_000
)
self._workers = _env_int("WECHAT_TOOL_SNS_AUTOSYNC_WORKERS", 1, min_v=1, max_v=4)
self._max_scan = _env_int("WECHAT_TOOL_SNS_AUTOSYNC_MAX_SCAN", 200, min_v=20, max_v=2000)
self._mu = threading.Lock()
self._states: dict[str, _AccountState] = {}
self._stop = threading.Event()
self._thread: Optional[threading.Thread] = None
def start(self) -> None:
if not self._enabled:
logger.info("[sns-autosync] disabled by env WECHAT_TOOL_SNS_AUTOSYNC=0")
return
with self._mu:
if self._thread is not None and self._thread.is_alive():
return
self._stop.clear()
th = threading.Thread(target=self._run, name="sns-realtime-autosync", daemon=True)
self._thread = th
th.start()
logger.info(
"[sns-autosync] started interval_ms=%s debounce_ms=%s min_sync_interval_ms=%s max_scan=%s workers=%s",
int(self._interval_ms),
int(self._debounce_ms),
int(self._min_sync_interval_ms),
int(self._max_scan),
int(self._workers),
)
def stop(self) -> None:
self._stop.set()
with self._mu:
self._thread = None
def _run(self) -> None:
while not self._stop.is_set():
tick_t0 = time.perf_counter()
try:
self._tick()
except Exception:
logger.exception("[sns-autosync] tick failed")
elapsed_ms = (time.perf_counter() - tick_t0) * 1000.0
sleep_ms = max(200.0, float(self._interval_ms) - elapsed_ms)
self._stop.wait(timeout=sleep_ms / 1000.0)
def _tick(self) -> None:
accounts = _list_decrypted_accounts()
now = time.time()
if not accounts:
return
for acc in accounts:
if self._stop.is_set():
break
try:
account_dir = _resolve_account_dir(acc)
except HTTPException:
continue
except Exception:
continue
info = WCDB_REALTIME.get_status(account_dir)
available = bool(info.get("dll_present") and info.get("key_present") and info.get("db_storage_dir"))
if not available:
continue
db_storage_dir = Path(str(info.get("db_storage_dir") or "").strip())
if not db_storage_dir.exists() or not db_storage_dir.is_dir():
continue
mtime_ns = _scan_sns_db_mtime_ns(db_storage_dir)
with self._mu:
st = self._states.setdefault(acc, _AccountState())
if mtime_ns and mtime_ns != st.last_mtime_ns:
st.last_mtime_ns = int(mtime_ns)
st.due_at = now + (float(self._debounce_ms) / 1000.0)
# Schedule daemon threads.
to_start: list[threading.Thread] = []
with self._mu:
keep = set(accounts)
for acc in list(self._states.keys()):
if acc not in keep:
self._states.pop(acc, None)
running = 0
for st in self._states.values():
th = st.thread
if th is not None and th.is_alive():
running += 1
elif th is not None and (not th.is_alive()):
st.thread = None
for acc, st in self._states.items():
if running >= int(self._workers):
break
if st.due_at <= 0 or st.due_at > now:
continue
if st.thread is not None and st.thread.is_alive():
continue
since = now - float(st.last_sync_end_at or 0.0)
min_interval = float(self._min_sync_interval_ms) / 1000.0
if min_interval > 0 and since < min_interval:
st.due_at = now + (min_interval - since)
continue
st.due_at = 0.0
th = threading.Thread(
target=self._sync_account_runner,
args=(acc,),
name=f"sns-autosync-{acc}",
daemon=True,
)
st.thread = th
to_start.append(th)
running += 1
for th in to_start:
if self._stop.is_set():
break
try:
th.start()
except Exception:
with self._mu:
for acc, st in self._states.items():
if st.thread is th:
st.thread = None
break
def _sync_account_runner(self, account: str) -> None:
account = str(account or "").strip()
try:
if self._stop.is_set() or (not account):
return
res = self._sync_account(account)
upserted = int((res or {}).get("upserted") or 0)
logger.info("[sns-autosync] sync done account=%s upserted=%s", account, upserted)
except Exception:
logger.exception("[sns-autosync] sync failed account=%s", account)
finally:
with self._mu:
st = self._states.get(account)
if st is not None:
st.thread = None
st.last_sync_end_at = time.time()
def _sync_account(self, account: str) -> dict[str, Any]:
account = str(account or "").strip()
if not account:
return {"status": "skipped", "reason": "missing account"}
try:
account_dir = _resolve_account_dir(account)
except Exception as e:
return {"status": "skipped", "reason": f"resolve account failed: {e}"}
info = WCDB_REALTIME.get_status(account_dir)
available = bool(info.get("dll_present") and info.get("key_present") and info.get("db_storage_dir"))
if not available:
return {"status": "skipped", "reason": "realtime not available"}
# Import lazily to avoid startup import ordering issues.
from .routers.sns import sync_sns_realtime_timeline_latest
try:
return sync_sns_realtime_timeline_latest(
account=account,
max_scan=int(self._max_scan),
force=0,
)
except HTTPException as e:
return {"status": "error", "error": str(e.detail or "")}
except Exception as e:
return {"status": "error", "error": str(e)}
SNS_REALTIME_AUTOSYNC = SnsRealtimeAutoSyncService()
+129 -6
View File
@@ -1,6 +1,8 @@
import ctypes
import binascii
import json
import os
import re
import sys
import threading
import time
@@ -20,7 +22,51 @@ class WCDBRealtimeError(RuntimeError):
_NATIVE_DIR = Path(__file__).resolve().parent / "native"
_WCDB_API_DLL = _NATIVE_DIR / "wcdb_api.dll"
_DEFAULT_WCDB_API_DLL = _NATIVE_DIR / "wcdb_api.dll"
_WCDB_API_DLL_SELECTED: Optional[Path] = None
def _candidate_wcdb_api_dll_paths() -> list[Path]:
"""Return possible locations for wcdb_api.dll (prefer WeFlow's newer build when present)."""
cands: list[Path] = []
env = str(os.environ.get("WECHAT_TOOL_WCDB_API_DLL_PATH", "") or "").strip()
if env:
cands.append(Path(env))
# Repo checkout convenience: reuse bundled WeFlow / echotrace DLLs when available.
try:
repo_root = Path(__file__).resolve().parents[2]
except Exception:
repo_root = Path.cwd()
for p in [
repo_root / "WeFlow" / "resources" / "wcdb_api.dll",
repo_root / "echotrace" / "assets" / "dll" / "wcdb_api.dll",
_DEFAULT_WCDB_API_DLL,
]:
if p not in cands:
cands.append(p)
return cands
def _resolve_wcdb_api_dll_path() -> Path:
global _WCDB_API_DLL_SELECTED
if _WCDB_API_DLL_SELECTED is not None:
return _WCDB_API_DLL_SELECTED
for p in _candidate_wcdb_api_dll_paths():
try:
if p.exists() and p.is_file():
_WCDB_API_DLL_SELECTED = p
return p
except Exception:
continue
# Fall back to the default path even if it doesn't exist; caller will raise a clear error.
_WCDB_API_DLL_SELECTED = _DEFAULT_WCDB_API_DLL
return _WCDB_API_DLL_SELECTED
_lib_lock = threading.Lock()
_lib: Optional[ctypes.CDLL] = None
@@ -40,16 +86,18 @@ def _load_wcdb_lib() -> ctypes.CDLL:
if not _is_windows():
raise WCDBRealtimeError("WCDB realtime mode is only supported on Windows.")
if not _WCDB_API_DLL.exists():
raise WCDBRealtimeError(f"Missing wcdb_api.dll at: {_WCDB_API_DLL}")
wcdb_api_dll = _resolve_wcdb_api_dll_path()
if not wcdb_api_dll.exists():
raise WCDBRealtimeError(f"Missing wcdb_api.dll at: {wcdb_api_dll}")
# Ensure dependent DLLs (e.g. WCDB.dll) can be found.
try:
os.add_dll_directory(str(_NATIVE_DIR))
os.add_dll_directory(str(wcdb_api_dll.parent))
except Exception:
pass
lib = ctypes.CDLL(str(_WCDB_API_DLL))
lib = ctypes.CDLL(str(wcdb_api_dll))
logger.info("[wcdb] using wcdb_api.dll: %s", wcdb_api_dll)
# Signatures
lib.wcdb_init.argtypes = []
@@ -144,6 +192,19 @@ def _load_wcdb_lib() -> ctypes.CDLL:
# Older wcdb_api.dll may not expose this export.
pass
# Optional (newer DLLs): wcdb_decrypt_sns_image(encrypted_data, len, key, out_hex)
# WeFlow uses this to decrypt Moments CDN images.
try:
lib.wcdb_decrypt_sns_image.argtypes = [
ctypes.c_void_p,
ctypes.c_int32,
ctypes.c_char_p,
ctypes.POINTER(ctypes.c_void_p),
]
lib.wcdb_decrypt_sns_image.restype = ctypes.c_int32
except Exception:
pass
lib.wcdb_get_logs.argtypes = [ctypes.POINTER(ctypes.c_char_p)]
lib.wcdb_get_logs.restype = ctypes.c_int
@@ -488,6 +549,63 @@ def get_sns_timeline(
return []
def decrypt_sns_image(encrypted_data: bytes, key: str) -> bytes:
"""Decrypt Moments CDN image bytes using WCDB DLL (WeFlow compatible).
Notes:
- Requires a newer wcdb_api.dll export: wcdb_decrypt_sns_image.
- On failure, returns the original encrypted_data (best-effort behavior like WeFlow).
"""
_ensure_initialized()
lib = _load_wcdb_lib()
fn = getattr(lib, "wcdb_decrypt_sns_image", None)
if not fn:
raise WCDBRealtimeError("Current wcdb_api.dll does not support sns image decryption.")
raw = bytes(encrypted_data or b"")
if not raw:
return b""
k = str(key or "").strip()
if not k:
return raw
out_ptr = ctypes.c_void_p()
buf = ctypes.create_string_buffer(raw, len(raw))
rc = 0
try:
rc = int(
fn(
ctypes.cast(buf, ctypes.c_void_p),
ctypes.c_int32(len(raw)),
k.encode("utf-8"),
ctypes.byref(out_ptr),
)
)
if rc != 0 or not out_ptr.value:
return raw
hex_bytes = ctypes.cast(out_ptr, ctypes.c_char_p).value or b""
if not hex_bytes:
return raw
# Defensive: keep only hex chars (some builds may include whitespace).
hex_clean = re.sub(rb"[^0-9a-fA-F]", b"", hex_bytes)
if not hex_clean:
return raw
try:
return binascii.unhexlify(hex_clean)
except Exception:
return raw
finally:
try:
if out_ptr.value:
lib.wcdb_free_string(ctypes.cast(out_ptr, ctypes.c_char_p))
except Exception:
pass
def shutdown() -> None:
global _initialized
lib = _load_wcdb_lib()
@@ -573,11 +691,16 @@ class WCDBRealtimeManager:
except Exception as e:
err = str(e)
dll_ok = _WCDB_API_DLL.exists()
dll_path = _resolve_wcdb_api_dll_path()
try:
dll_ok = bool(dll_path.exists())
except Exception:
dll_ok = False
connected = self.is_connected(account)
return {
"account": account,
"dll_present": bool(dll_ok),
"wcdb_api_dll": str(dll_path),
"key_present": bool(key_ok),
"db_storage_dir": str(db_storage_dir) if db_storage_dir else "",
"session_db_path": str(session_db_path) if session_db_path else "",
+91
View File
@@ -0,0 +1,91 @@
import json
import os
import sys
import unittest
import importlib
from pathlib import Path
from tempfile import TemporaryDirectory
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
class TestDecryptStreamSSE(unittest.TestCase):
def test_decrypt_stream_reports_progress(self):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from wechat_decrypt_tool.wechat_decrypt import SQLITE_HEADER
with TemporaryDirectory() as td:
root = Path(td)
prev_data_dir = os.environ.get("WECHAT_TOOL_DATA_DIR")
prev_build_cache = os.environ.get("WECHAT_TOOL_BUILD_SESSION_LAST_MESSAGE")
try:
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
os.environ["WECHAT_TOOL_BUILD_SESSION_LAST_MESSAGE"] = "0"
import wechat_decrypt_tool.app_paths as app_paths
import wechat_decrypt_tool.routers.decrypt as decrypt_router
importlib.reload(app_paths)
importlib.reload(decrypt_router)
db_storage = root / "xwechat_files" / "wxid_foo_bar" / "db_storage"
db_storage.mkdir(parents=True, exist_ok=True)
# Fake a decrypted sqlite db (>= 4096 bytes) so decryptor falls back to copy.
(db_storage / "MSG0.db").write_bytes(SQLITE_HEADER + b"\x00" * (4096 - len(SQLITE_HEADER)))
app = FastAPI()
app.include_router(decrypt_router.router)
client = TestClient(app)
events: list[dict] = []
with client.stream(
"GET",
"/api/decrypt_stream",
params={"key": "00" * 32, "db_storage_path": str(db_storage)},
) as resp:
self.assertEqual(resp.status_code, 200)
self.assertIn("text/event-stream", resp.headers.get("content-type", ""))
for line in resp.iter_lines():
if not line:
continue
if isinstance(line, bytes):
line = line.decode("utf-8", errors="ignore")
line = str(line)
if line.startswith(":"):
continue
if not line.startswith("data: "):
continue
payload = json.loads(line[len("data: ") :])
events.append(payload)
if payload.get("type") in {"complete", "error"}:
break
types = {e.get("type") for e in events}
self.assertIn("start", types)
self.assertIn("progress", types)
self.assertEqual(events[-1].get("type"), "complete")
out = root / "output" / "databases" / "wxid_foo" / "MSG0.db"
self.assertTrue(out.exists())
finally:
if prev_data_dir is None:
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
else:
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data_dir
if prev_build_cache is None:
os.environ.pop("WECHAT_TOOL_BUILD_SESSION_LAST_MESSAGE", None)
else:
os.environ["WECHAT_TOOL_BUILD_SESSION_LAST_MESSAGE"] = prev_build_cache
if __name__ == "__main__":
unittest.main()
+180
View File
@@ -0,0 +1,180 @@
import asyncio
import hashlib
import sys
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from wechat_decrypt_tool import sns_media # noqa: E402 pylint: disable=wrong-import-position
class TestSnsMedia(unittest.TestCase):
def test_fix_sns_cdn_url_image_rewrites_150_and_appends_token(self):
u = "http://mmsns.qpic.cn/sns/abc/150"
out = sns_media.fix_sns_cdn_url(u, token="tkn", is_video=False)
self.assertEqual(out, "https://mmsns.qpic.cn/sns/abc/0?token=tkn&idx=1")
u2 = "https://mmsns.qpic.cn/sns/abc/150?foo=bar"
out2 = sns_media.fix_sns_cdn_url(u2, token="tkn", is_video=False)
self.assertEqual(out2, "https://mmsns.qpic.cn/sns/abc/0?foo=bar&token=tkn&idx=1")
def test_fix_sns_cdn_url_video_places_token_first(self):
u = "https://snsvideodownload.video.qq.com/abc.mp4?foo=1&bar=2"
out = sns_media.fix_sns_cdn_url(u, token="tkn", is_video=True)
self.assertEqual(out, "https://snsvideodownload.video.qq.com/abc.mp4?token=tkn&idx=1&foo=1&bar=2")
def test_fix_sns_cdn_url_non_tencent_host_passthrough(self):
u = "http://example.com/a/150?x=1"
out = sns_media.fix_sns_cdn_url(u, token="tkn", is_video=False)
self.assertEqual(out, u)
def test_maybe_decrypt_sns_video_file_xors_inplace(self):
# Build a fake MP4 header (ftyp at offset 4) and encrypt it by XORing with a keystream.
plain = b"\x00\x00\x00\x20ftypisom" + b"\x00" * 48
ks = bytes(range(len(plain)))
enc = bytes([plain[i] ^ ks[i] for i in range(len(plain))])
with TemporaryDirectory() as td:
p = Path(td) / "v.mp4"
p.write_bytes(enc)
with mock.patch("wechat_decrypt_tool.sns_media.weflow_wxisaac64_keystream", return_value=ks):
did = sns_media.maybe_decrypt_sns_video_file(p, key="1")
self.assertTrue(did)
self.assertEqual(p.read_bytes(), plain)
# Second run should be a no-op because it already looks like a MP4.
did2 = sns_media.maybe_decrypt_sns_video_file(p, key="1")
self.assertFalse(did2)
def test_try_fetch_and_decrypt_sns_image_remote_cache_hit(self):
with TemporaryDirectory() as td:
account_dir = Path(td) / "acc"
account_dir.mkdir(parents=True, exist_ok=True)
url = "https://mmsns.qpic.cn/sns/test/0?token=tkn&idx=1"
key = "123"
fixed = sns_media.fix_sns_cdn_url(url, token="tkn", is_video=False)
digest = hashlib.md5(f"{fixed}|{key}".encode("utf-8", errors="ignore")).hexdigest()
cache_dir = account_dir / "sns_remote_cache" / digest[:2]
cache_dir.mkdir(parents=True, exist_ok=True)
cache_path = cache_dir / f"{digest}.jpg"
payload = b"\xff\xd8\xff\x00fakejpeg"
cache_path.write_bytes(payload)
res = asyncio.run(
sns_media.try_fetch_and_decrypt_sns_image_remote(
account_dir=account_dir,
url=url,
key=key,
token="tkn",
use_cache=True,
)
)
self.assertIsNotNone(res)
assert res is not None
self.assertEqual(res.source, "remote-cache")
self.assertEqual(res.media_type, "image/jpeg")
self.assertEqual(res.payload, payload)
self.assertTrue(res.cache_path and res.cache_path.exists())
def test_try_fetch_and_decrypt_sns_image_remote_cache_upgrades_bin_extension(self):
with TemporaryDirectory() as td:
account_dir = Path(td) / "acc"
account_dir.mkdir(parents=True, exist_ok=True)
url = "https://mmsns.qpic.cn/sns/test/0?token=tkn&idx=1"
key = "123"
fixed = sns_media.fix_sns_cdn_url(url, token="tkn", is_video=False)
digest = hashlib.md5(f"{fixed}|{key}".encode("utf-8", errors="ignore")).hexdigest()
cache_dir = account_dir / "sns_remote_cache" / digest[:2]
cache_dir.mkdir(parents=True, exist_ok=True)
bin_path = cache_dir / f"{digest}.bin"
png_payload = b"\x89PNG\r\n\x1a\n" + b"fakepng"
bin_path.write_bytes(png_payload)
res = asyncio.run(
sns_media.try_fetch_and_decrypt_sns_image_remote(
account_dir=account_dir,
url=url,
key=key,
token="tkn",
use_cache=True,
)
)
self.assertIsNotNone(res)
assert res is not None
self.assertEqual(res.source, "remote-cache")
self.assertEqual(res.media_type, "image/png")
self.assertTrue(res.cache_path and res.cache_path.suffix.lower() == ".png")
self.assertTrue(res.cache_path and res.cache_path.exists())
self.assertFalse(bin_path.exists())
def test_try_fetch_and_decrypt_sns_image_remote_decrypts_when_needed(self):
raw = b"\x01\x02\x03\x04not_an_image"
decoded = b"\x89PNG\r\n\x1a\n" + b"decoded"
async def fake_download(_url: str):
return raw, "image/jpeg", "1"
with TemporaryDirectory() as td:
account_dir = Path(td) / "acc"
account_dir.mkdir(parents=True, exist_ok=True)
with mock.patch("wechat_decrypt_tool.sns_media._download_sns_remote_bytes", side_effect=fake_download):
with mock.patch("wechat_decrypt_tool.sns_media._wcdb_decrypt_sns_image", return_value=decoded):
res = asyncio.run(
sns_media.try_fetch_and_decrypt_sns_image_remote(
account_dir=account_dir,
url="https://mmsns.qpic.cn/sns/test/0",
key="123",
token="tkn",
use_cache=False,
)
)
self.assertIsNotNone(res)
assert res is not None
self.assertEqual(res.media_type, "image/png")
self.assertEqual(res.source, "remote-decrypt")
self.assertEqual(res.x_enc, "1")
self.assertEqual(res.payload, decoded)
def test_try_fetch_and_decrypt_sns_image_remote_decrypt_failure_returns_none(self):
raw = b"\x01\x02\x03\x04not_an_image"
decoded_bad = b"\x00\x00\x00\x00still_bad"
async def fake_download(_url: str):
return raw, "image/jpeg", "1"
with TemporaryDirectory() as td:
account_dir = Path(td) / "acc"
account_dir.mkdir(parents=True, exist_ok=True)
with mock.patch("wechat_decrypt_tool.sns_media._download_sns_remote_bytes", side_effect=fake_download):
with mock.patch("wechat_decrypt_tool.sns_media._wcdb_decrypt_sns_image", return_value=decoded_bad):
res = asyncio.run(
sns_media.try_fetch_and_decrypt_sns_image_remote(
account_dir=account_dir,
url="https://mmsns.qpic.cn/sns/test/0",
key="123",
token="tkn",
use_cache=False,
)
)
self.assertIsNone(res)
if __name__ == "__main__":
unittest.main()
+122
View File
@@ -0,0 +1,122 @@
// Generate WeChat/WeFlow WxIsaac64 keystream via WeFlow's WASM module.
//
// Usage:
// node tools/weflow_wasm_keystream.js <key> <size>
//
// Prints a base64-encoded keystream to stdout (no extra logs).
const fs = require('fs')
const path = require('path')
const vm = require('vm')
function usageAndExit() {
process.stderr.write('Usage: node tools/weflow_wasm_keystream.js <key> <size>\\n')
process.exit(2)
}
const key = String(process.argv[2] || '').trim()
const size = Number(process.argv[3] || 0)
if (!key || !Number.isFinite(size) || size <= 0) usageAndExit()
const basePath = path.join(__dirname, '..', 'WeFlow', 'electron', 'assets', 'wasm')
const wasmPath = path.join(basePath, 'wasm_video_decode.wasm')
const jsPath = path.join(basePath, 'wasm_video_decode.js')
if (!fs.existsSync(wasmPath) || !fs.existsSync(jsPath)) {
process.stderr.write(`WeFlow WASM assets not found: ${basePath}\\n`)
process.exit(1)
}
const wasmBinary = fs.readFileSync(wasmPath)
const jsContent = fs.readFileSync(jsPath, 'utf8')
let capturedKeystream = null
let resolveInit
let rejectInit
const initPromise = new Promise((res, rej) => {
resolveInit = res
rejectInit = rej
})
const mockGlobal = {
console: { log: () => {}, error: () => {} }, // keep stdout clean
Buffer,
Uint8Array,
Int8Array,
Uint16Array,
Int16Array,
Uint32Array,
Int32Array,
Float32Array,
Float64Array,
BigInt64Array,
BigUint64Array,
Array,
Object,
Function,
String,
Number,
Boolean,
Error,
Promise,
require,
process,
setTimeout,
clearTimeout,
setInterval,
clearInterval,
}
mockGlobal.Module = {
onRuntimeInitialized: () => resolveInit(),
wasmBinary,
print: () => {},
printErr: () => {},
}
mockGlobal.self = mockGlobal
mockGlobal.self.location = { href: jsPath }
mockGlobal.WorkerGlobalScope = function () {}
mockGlobal.VTS_WASM_URL = `file://${wasmPath}`
mockGlobal.wasm_isaac_generate = (ptr, n) => {
const buf = new Uint8Array(mockGlobal.Module.HEAPU8.buffer, ptr, n)
capturedKeystream = new Uint8Array(buf) // copy view
}
try {
const context = vm.createContext(mockGlobal)
new vm.Script(jsContent, { filename: jsPath }).runInContext(context)
} catch (e) {
rejectInit(e)
}
;(async () => {
try {
await initPromise
if (!mockGlobal.Module.WxIsaac64 && mockGlobal.Module.asm && mockGlobal.Module.asm.WxIsaac64) {
mockGlobal.Module.WxIsaac64 = mockGlobal.Module.asm.WxIsaac64
}
if (!mockGlobal.Module.WxIsaac64) {
throw new Error('WxIsaac64 not found in WASM module')
}
capturedKeystream = null
const isaac = new mockGlobal.Module.WxIsaac64(key)
isaac.generate(size)
if (isaac.delete) isaac.delete()
if (!capturedKeystream) throw new Error('Failed to capture keystream')
const out = Buffer.from(capturedKeystream)
// Match WeFlow worker logic: reverse the captured Uint8Array.
out.reverse()
process.stdout.write(out.toString('base64'))
} catch (e) {
process.stderr.write(String(e && e.stack ? e.stack : e) + '\\n')
process.exit(1)
}
})()
Generated
+1 -1
View File
@@ -866,7 +866,7 @@ wheels = [
[[package]]
name = "wechat-decrypt-tool"
version = "0.2.1"
version = "1.3.0"
source = { editable = "." }
dependencies = [
{ name = "aiofiles" },