Compare commits

..

7 Commits

23 changed files with 2771 additions and 711 deletions
+65 -1
View File
@@ -14,6 +14,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Derive version from tag
shell: pwsh
@@ -26,6 +28,67 @@ jobs:
"TAG_NAME=$tag" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"VERSION=$version" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
- name: Generate release notes from commits
shell: pwsh
run: |
git fetch --force --tags
$tag = $env:TAG_NAME
if ([string]::IsNullOrWhiteSpace($tag)) {
throw "TAG_NAME is empty"
}
$repo = "${{ github.repository }}"
$prev = ""
try {
$commit = (git rev-list -n 1 $tag).Trim()
if (-not [string]::IsNullOrWhiteSpace($commit)) {
$prev = (git describe --tags --abbrev=0 "$commit^" 2>$null).Trim()
}
} catch {}
if ([string]::IsNullOrWhiteSpace($prev)) {
# Fallback: best-effort previous version tag by semver-ish sorting.
$prev = (git tag --list "v*" --sort=-v:refname | Where-Object { $_ -ne $tag } | Select-Object -First 1)
}
$range = ""
if (-not [string]::IsNullOrWhiteSpace($prev)) {
$range = "$prev..$tag"
}
$lines = @()
if (-not [string]::IsNullOrWhiteSpace($range)) {
$lines = @(git log --no-merges --pretty=format:"- %s (%h)" --reverse $range)
} else {
# First release tag / missing history: include a small recent window.
$lines = @(git log --no-merges --pretty=format:"- %s (%h)" --reverse -n 50)
}
if (-not $lines -or $lines.Count -eq 0) {
$lines = @("- 修复了一些已知问题,提升了稳定性。")
}
$max = 60
if ($lines.Count -gt $max) {
$total = $lines.Count
$lines = @($lines | Select-Object -First $max)
$lines += "- ...(共 $total 条提交,更多请查看完整变更链接)"
}
$body = @()
$body += "## 更新内容 ($tag)"
$body += ""
$body += $lines
if (-not [string]::IsNullOrWhiteSpace($prev)) {
$body += ""
$body += "完整变更: https://github.com/$repo/compare/$prev...$tag"
}
($body -join "`n") | Out-File -FilePath release-notes.md -Encoding utf8
- name: Setup Node.js
uses: actions/setup-node@v4
with:
@@ -71,7 +134,8 @@ jobs:
with:
tag_name: ${{ env.TAG_NAME }}
name: ${{ env.TAG_NAME }}
generate_release_notes: true
body_path: release-notes.md
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>
+85 -8
View File
@@ -7,6 +7,9 @@
"": {
"name": "wechat-data-analysis-desktop",
"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",
+11 -1
View File
@@ -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,
+513 -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,330 @@ 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 looksLikeHtml(input) {
if (!input) return false;
const s = String(input);
if (!s.includes("<") || !s.includes(">")) return false;
// Be conservative: only treat the note as HTML if it contains common tags we expect from GitHub-rendered bodies.
return /<(p|div|br|ul|ol|li|a|strong|em|tt|code|pre|h[1-6])\b/i.test(s);
}
function htmlToPlainText(html) {
if (!html) return "";
let text = String(html);
// Drop script/style blocks entirely.
text = text.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "");
text = text.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "");
// Keep links readable after stripping tags.
text = text.replace(
/<a\s+[^>]*href=(["'])([^"']+)\1[^>]*>([\s\S]*?)<\/a>/gi,
(_m, _q, href, inner) => {
const innerText = String(inner).replace(/<[^>]*>/g, "").trim();
const url = String(href || "").trim();
if (!url) return innerText;
if (!innerText) return url;
return `${innerText} (${url})`;
}
);
// Preserve line breaks / list structure before stripping remaining tags.
text = text.replace(/<\s*br\s*\/?>/gi, "\n");
text = text.replace(/<\/\s*(p|div|h1|h2|h3|h4|h5|h6)\s*>/gi, "\n");
text = text.replace(/<\s*li[^>]*>/gi, "- ");
text = text.replace(/<\/\s*li\s*>/gi, "\n");
text = text.replace(/<\/\s*(ul|ol)\s*>/gi, "\n");
// Strip remaining tags.
text = text.replace(/<[^>]*>/g, "");
// Decode the handful of entities we commonly see from GitHub-rendered HTML.
const named = {
nbsp: " ",
amp: "&",
lt: "<",
gt: ">",
quot: '"',
apos: "'",
"#39": "'",
};
text = text.replace(/&([a-z0-9#]+);/gi, (m, name) => {
const key = String(name || "").toLowerCase();
if (named[key] != null) return named[key];
// Numeric entities (decimal / hex).
const decMatch = key.match(/^#(\d+)$/);
if (decMatch) {
const n = Number(decMatch[1]);
if (Number.isFinite(n) && n >= 0 && n <= 0x10ffff) {
try {
return String.fromCodePoint(n);
} catch {
return m;
}
}
return m;
}
const hexMatch = key.match(/^#x([0-9a-f]+)$/i);
if (hexMatch) {
const n = Number.parseInt(hexMatch[1], 16);
if (Number.isFinite(n) && n >= 0 && n <= 0x10ffff) {
try {
return String.fromCodePoint(n);
} catch {
return m;
}
}
return m;
}
return m;
});
// Normalize whitespace/newlines.
text = text.replace(/\r\n/g, "\n");
text = text.replace(/\n{3,}/g, "\n\n");
return text.trim();
}
function normalizeReleaseNotes(releaseNotes) {
if (!releaseNotes) return "";
const normalizeText = (value) => {
if (value == null) return "";
const raw = typeof value === "string" ? value : String(value);
const trimmed = raw.trim();
if (!trimmed) return "";
if (looksLikeHtml(trimmed)) return htmlToPlainText(trimmed);
return trimmed;
};
if (typeof releaseNotes === "string") return normalizeText(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}` : "", normalizeText(noteText)]
.filter(Boolean)
.join("\n");
if (block) parts.push(block);
}
return parts.join("\n\n");
}
try {
return normalizeText(JSON.stringify(releaseNotes, null, 2));
} catch {
return normalizeText(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 +581,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 +808,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 +1051,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 +1138,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 +1166,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-wechat-green" :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-wechat-green text-white text-sm hover:bg-wechat-green-hover"
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-wechat-green text-white text-sm hover:bg-wechat-green-hover"
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>
@@ -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>
+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
}
}
+41 -1
View File
@@ -90,6 +90,33 @@
</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>
@@ -123,6 +150,13 @@ import { DESKTOP_SETTING_AUTO_REALTIME_KEY, DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY,
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)
@@ -225,9 +259,14 @@ const onSnsUseCacheToggle = (ev) => {
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)
@@ -235,6 +274,7 @@ onMounted(async () => {
snsUseCache.value = readLocalBoolSetting(SNS_SETTING_USE_CACHE_KEY, true)
if (isDesktopEnv.value) {
void desktopUpdate.initListeners()
await refreshDesktopAutoLaunch()
await refreshDesktopCloseBehavior()
}
+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 = ''
+62 -21
View File
@@ -1,13 +1,23 @@
from __future__ import annotations
"""ISAAC-64 PRNG (WeFlow compatible).
"""ISAAC-64 PRNG (best-effort fallback).
WeChat SNS live photo/video decryption uses a keystream generated by ISAAC-64 and
XORs the first 128KB of the mp4 file. WeFlow's implementation reverses the
generated byte array, so we mirror that behavior for compatibility.
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
from typing import Any, Literal
_MASK_64 = 0xFFFFFFFFFFFFFFFF
@@ -143,27 +153,58 @@ class Isaac64:
self.bb = _u64(self.mm[(y >> 11) & 255] + x)
self.randrsl[i] = self.bb
def get_next(self) -> int:
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
idx = 256 - self.randcnt
self.randcnt -= 1
return _u64(self.randrsl[idx])
return _u64(self.randrsl[self.randcnt])
def generate_keystream(self, size: int) -> bytes:
"""Generate a keystream of `size` bytes (must be multiple of 8)."""
if size <= 0:
# 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""
if size % 8 != 0:
raise ValueError("ISAAC64 keystream size must be multiple of 8 bytes.")
blocks = (want + 7) // 8
out = bytearray()
count = size // 8
for _ in range(count):
out.extend(int(self.get_next()).to_bytes(8, "little", signed=False))
# WeFlow reverses the entire byte array (Uint8Array.reverse()).
out.reverse()
return bytes(out)
for _ in range(blocks):
out.extend(self._raw_to_bytes(self.rand_u64(), word_format))
return bytes(out[:want])
+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)
+27 -382
View File
@@ -26,6 +26,7 @@ from ..chat_helpers import _load_contact_rows, _pick_display_name, _resolve_acco
from ..logging_config import get_logger
from ..media_helpers import _read_and_maybe_decrypt_media, _resolve_account_wxid_dir
from ..path_fix import PathFixRoute
from .. import sns_media as _sns_media
from ..wcdb_realtime import (
WCDBRealtimeError,
WCDB_REALTIME,
@@ -2387,62 +2388,11 @@ def list_sns_media_candidates(
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")
)
return _sns_media.is_allowed_sns_media_host(host)
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
return _sns_media.fix_sns_cdn_url(url, token=token, is_video=is_video)
def _detect_mp4_ftyp(head: bytes) -> bool:
@@ -2461,40 +2411,7 @@ def _weflow_wxisaac64_script_path() -> str:
@lru_cache(maxsize=64)
def _weflow_wxisaac64_keystream(key: str, size: int) -> bytes:
"""Generate keystream via WeFlow's WASM (preferred; matches real 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 not script:
script = ""
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 (WeFlow-compatible reverse).
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]
return _sns_media.weflow_wxisaac64_keystream(key, size)
_SNS_REMOTE_VIDEO_CACHE_EXTS = [
@@ -2595,55 +2512,7 @@ async def _download_sns_remote_to_file(url: str, dest_path: Path, *, max_bytes:
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
# Prefer WeFlow's real keystream generator (WASM) to ensure compatibility.
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
return _sns_media.maybe_decrypt_sns_video_file(path, key)
async def _materialize_sns_remote_video(
@@ -2654,124 +2523,21 @@ async def _materialize_sns_remote_video(
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 (WeFlow ISAAC64) 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
return await _sns_media.materialize_sns_remote_video(
account_dir=account_dir,
url=url,
key=key,
token=token,
use_cache=use_cache,
)
def _best_effort_unlink(path: str) -> None:
try:
Path(path).unlink(missing_ok=True)
except Exception:
pass
_sns_media.best_effort_unlink(path)
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 ""
return _sns_media.detect_image_mime(data)
_SNS_REMOTE_CACHE_EXTS = [
@@ -2907,146 +2673,25 @@ async def _try_fetch_and_decrypt_sns_remote(
token: str,
use_cache: bool,
) -> Optional[Response]:
"""Try WeFlow-style: download from CDN -> decrypt via wcdb_decrypt_sns_image -> return bytes.
"""Try remote download+decrypt first (accurate when keys are present).
Returns a Response on success, or None on failure so caller can fall back to local cache matching.
"""
u_fixed = _fix_sns_cdn_url(url, token=token, is_video=False)
if not u_fixed:
res = await _sns_media.try_fetch_and_decrypt_sns_image_remote(
account_dir=account_dir,
url=str(url or ""),
key=str(key or ""),
token=str(token or ""),
use_cache=bool(use_cache),
)
if res is None:
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 ""))
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:
return FileResponse(
existing,
media_type=mt,
headers={
"Cache-Control": "public, max-age=86400",
"X-SNS-Source": "remote-cache",
},
)
except Exception:
pass
try:
raw, content_type, x_enc = await _download_sns_remote_bytes(u_fixed)
except Exception as e:
logger.info("[sns] 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] 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:
pass
resp = Response(content=decoded, media_type=mt)
resp = Response(content=res.payload, media_type=res.media_type)
resp.headers["Cache-Control"] = "public, max-age=86400" if use_cache else "no-store"
resp.headers["X-SNS-Source"] = "remote-decrypt" if decrypted else "remote"
if x_enc:
resp.headers["X-SNS-X-Enc"] = x_enc
resp.headers["X-SNS-Source"] = str(res.source or "remote")
if res.x_enc:
resp.headers["X-SNS-X-Enc"] = str(res.x_enc)
return resp
+13 -8
View File
@@ -31,19 +31,23 @@ from .chat_export_service import ( # pylint: disable=protected-access
_zip_write_tree,
)
# Reuse WeFlow-compatible SNS remote download/decrypt helpers.
# Reuse SNS timeline/local cache helpers.
from .routers.sns import ( # pylint: disable=protected-access
_fix_sns_cdn_url,
_generate_sns_cache_key,
_materialize_sns_remote_video,
_resolve_sns_cached_image_path,
_resolve_sns_cached_image_path_by_cache_key,
_resolve_sns_cached_image_path_by_md5,
_resolve_sns_cached_video_path,
_try_fetch_and_decrypt_sns_remote,
list_sns_timeline,
)
# SNS remote download+decrypt helpers (shared with API endpoints).
from .sns_media import ( # pylint: disable=protected-access
fix_sns_cdn_url as _fix_sns_cdn_url,
materialize_sns_remote_video as _materialize_sns_remote_video,
try_fetch_and_decrypt_sns_image_remote as _try_fetch_and_decrypt_sns_image_remote,
)
logger = get_logger(__name__)
ExportStatus = Literal["queued", "running", "done", "error", "cancelled"]
@@ -624,8 +628,8 @@ class SnsExportManager:
# 0) Prefer WeFlow-style remote download+decrypt (accurate when keys are present).
if fixed:
should_cancel()
resp = run_async(
_try_fetch_and_decrypt_sns_remote(
res = run_async(
_try_fetch_and_decrypt_sns_image_remote(
account_dir=account_dir,
url=fixed,
key=str(key or ""),
@@ -633,8 +637,9 @@ class SnsExportManager:
use_cache=use_cache,
)
)
if resp is not None:
payload, mt = _response_bytes(resp)
if res is not None:
payload = bytes(res.payload or b"")
mt = str(res.media_type or "")
# 1) Local cache fallback (only when cache is enabled; mirrors `/api/sns/media` semantics).
if (not payload) and use_cache:
+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,
)
+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()