mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
Compare commits
5 Commits
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wechat-data-analysis-desktop",
|
||||
"version": "1.3.0",
|
||||
"version": "1.7.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wechat-data-analysis-desktop",
|
||||
"version": "1.3.0",
|
||||
"version": "1.7.10",
|
||||
"dependencies": {
|
||||
"electron-updater": "^6.7.3"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "wechat-data-analysis-desktop",
|
||||
"private": true,
|
||||
"version": "1.3.0",
|
||||
"version": "1.7.10",
|
||||
"main": "src/main.cjs",
|
||||
"scripts": {
|
||||
"dev": "node scripts/dev.cjs",
|
||||
|
||||
+201
-19
@@ -22,12 +22,12 @@ const fs = require("fs");
|
||||
const http = require("http");
|
||||
const net = require("net");
|
||||
const path = require("path");
|
||||
const { Worker } = require("worker_threads");
|
||||
const {
|
||||
cleanupOutputDirectoryBackup,
|
||||
getDefaultOutputDirPath,
|
||||
getEffectiveOutputDirPath,
|
||||
migrateOutputDirectory,
|
||||
normalizeDirectoryPath,
|
||||
rollbackOutputDirectoryChange,
|
||||
} = require("./output-dir.cjs");
|
||||
|
||||
const DEFAULT_BACKEND_HOST = String(process.env.WECHAT_TOOL_HOST || "127.0.0.1").trim() || "127.0.0.1";
|
||||
@@ -45,6 +45,7 @@ let isQuitting = false;
|
||||
let desktopSettings = null;
|
||||
let backendPortChangeInProgress = false;
|
||||
let outputDirChangeInProgress = false;
|
||||
let outputDirChangeProgressState = null;
|
||||
|
||||
const gotSingleInstanceLock = app.requestSingleInstanceLock();
|
||||
if (!gotSingleInstanceLock) {
|
||||
@@ -279,7 +280,9 @@ function resolveOutputDir() {
|
||||
if (!dataDir) return null;
|
||||
|
||||
const envOutputDir = safeNormalizeDirectory(process.env.WECHAT_TOOL_OUTPUT_DIR || "");
|
||||
const settingsOutputDir = app.isPackaged ? safeNormalizeDirectory(loadDesktopSettings()?.outputDir || "") : "";
|
||||
// Allow dev-mode desktop runs to persist the chosen output directory too.
|
||||
// An explicit environment variable still wins so local launch overrides keep working.
|
||||
const settingsOutputDir = safeNormalizeDirectory(loadDesktopSettings()?.outputDir || "");
|
||||
|
||||
let chosen = null;
|
||||
try {
|
||||
@@ -705,6 +708,7 @@ function getOutputDirInfo() {
|
||||
const defaultPath = getDefaultOutputDir() || "";
|
||||
const currentPath = resolveOutputDir() || defaultPath;
|
||||
const hasPending = desktopSettings.pendingOutputDir !== null;
|
||||
const canChange = !!defaultPath && !!currentPath;
|
||||
const pendingPath =
|
||||
desktopSettings.pendingOutputDir === null
|
||||
? ""
|
||||
@@ -718,8 +722,8 @@ function getOutputDirInfo() {
|
||||
pendingPath,
|
||||
hasPending,
|
||||
lastError: String(desktopSettings.lastOutputDirError || "").trim(),
|
||||
canChange: !!app.isPackaged,
|
||||
changeUnavailableReason: app.isPackaged ? "" : "开发模式不支持界面修改 output 目录",
|
||||
canChange,
|
||||
changeUnavailableReason: canChange ? "" : "无法定位 output 目录",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -749,10 +753,6 @@ function setIgnoredUpdateVersion(version) {
|
||||
}
|
||||
|
||||
async function applyOutputDirChange(nextValue) {
|
||||
if (!app.isPackaged) {
|
||||
throw new Error("开发模式不支持界面修改 output 目录");
|
||||
}
|
||||
|
||||
const defaultPath = getDefaultOutputDir();
|
||||
const currentPath = resolveOutputDir();
|
||||
if (!defaultPath || !currentPath) {
|
||||
@@ -785,15 +785,41 @@ async function applyOutputDirChange(nextValue) {
|
||||
const wasBackendRunning = !!backendProc;
|
||||
let migration = null;
|
||||
let settingsSwitched = false;
|
||||
let retainedBackupPath = "";
|
||||
let backupCleanupWarning = "";
|
||||
|
||||
try {
|
||||
setOutputDirChangeProgressState({
|
||||
active: true,
|
||||
stage: "preparing",
|
||||
message: wasBackendRunning ? "正在暂停后端并准备迁移 output 目录" : "正在准备迁移 output 目录",
|
||||
percent: 1,
|
||||
});
|
||||
|
||||
if (wasBackendRunning) {
|
||||
await stopBackendAndWait({ timeoutMs: 10_000 });
|
||||
}
|
||||
|
||||
migration = migrateOutputDirectory({
|
||||
currentDir: currentPath,
|
||||
nextDir: nextPath,
|
||||
migration = await runOutputDirWorker(
|
||||
"migrate",
|
||||
{
|
||||
currentDir: currentPath,
|
||||
nextDir: nextPath,
|
||||
},
|
||||
(progress) => {
|
||||
setOutputDirChangeProgressState({
|
||||
active: true,
|
||||
...progress,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
setOutputDirChangeProgressState({
|
||||
active: true,
|
||||
stage: "switching",
|
||||
message: "正在应用新的 output 目录设置",
|
||||
percent: 99,
|
||||
currentFile: "",
|
||||
});
|
||||
|
||||
setOutputDirSetting(nextPath);
|
||||
@@ -803,11 +829,38 @@ async function applyOutputDirChange(nextValue) {
|
||||
ensureOutputLink();
|
||||
|
||||
if (wasBackendRunning) {
|
||||
setOutputDirChangeProgressState({
|
||||
active: true,
|
||||
stage: "restarting",
|
||||
message: "正在重启后端并应用新的 output 目录",
|
||||
percent: 99,
|
||||
});
|
||||
startBackend();
|
||||
await waitForBackend({ timeoutMs: 30_000 });
|
||||
}
|
||||
|
||||
retainedBackupPath = migration?.backupDir || "";
|
||||
if (retainedBackupPath) {
|
||||
try {
|
||||
cleanupOutputDirectoryBackup(retainedBackupPath);
|
||||
retainedBackupPath = "";
|
||||
} catch (cleanupErr) {
|
||||
backupCleanupWarning = `;旧 output 目录未能自动删除:${cleanupErr?.message || cleanupErr}`;
|
||||
logMain(
|
||||
`[main] failed to clean output dir backup ${retainedBackupPath}: ${cleanupErr?.message || cleanupErr}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setOutputDirChangeProgressState({
|
||||
active: true,
|
||||
stage: "complete",
|
||||
message: migration?.sourceWasEmpty ? "output 目录已切换" : "output 目录已迁移并切换",
|
||||
percent: 100,
|
||||
});
|
||||
const info = getOutputDirInfo();
|
||||
const successMessage =
|
||||
(migration?.sourceWasEmpty ? "output 目录已切换" : "output 目录已迁移并切换") + backupCleanupWarning;
|
||||
return {
|
||||
success: true,
|
||||
changed: true,
|
||||
@@ -815,16 +868,22 @@ async function applyOutputDirChange(nextValue) {
|
||||
defaultPath: info.defaultPath,
|
||||
isDefault: info.isDefault,
|
||||
pendingPath: info.pendingPath,
|
||||
backupPath: migration?.backupDir || "",
|
||||
backupPath: retainedBackupPath,
|
||||
sourceWasEmpty: !!migration?.sourceWasEmpty,
|
||||
message: migration?.sourceWasEmpty ? "output 目录已切换" : "output 目录已迁移并切换",
|
||||
message: successMessage,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err?.message || String(err);
|
||||
let rollbackMessage = "";
|
||||
if (migration?.changed) {
|
||||
try {
|
||||
rollbackOutputDirectoryChange({
|
||||
setOutputDirChangeProgressState({
|
||||
active: true,
|
||||
stage: "rolling-back",
|
||||
message: "迁移失败,正在回滚 output 目录",
|
||||
percent: 99,
|
||||
});
|
||||
await runOutputDirWorker("rollback", {
|
||||
previousDir: currentPath,
|
||||
currentDir: nextPath,
|
||||
backupDir: migration.backupDir,
|
||||
@@ -969,6 +1028,119 @@ function setWindowProgressBar(value) {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function makeIdleOutputDirChangeProgressState() {
|
||||
return {
|
||||
active: false,
|
||||
stage: "idle",
|
||||
message: "",
|
||||
percent: 0,
|
||||
bytesTransferred: 0,
|
||||
bytesTotal: 0,
|
||||
itemsTransferred: 0,
|
||||
itemsTotal: 0,
|
||||
currentFile: "",
|
||||
error: "",
|
||||
};
|
||||
}
|
||||
|
||||
function clampOutputDirProgressNumber(value) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n < 0) return 0;
|
||||
return n;
|
||||
}
|
||||
|
||||
function normalizeOutputDirChangeProgressState(next = {}) {
|
||||
const active = next?.active !== false;
|
||||
const percent = Math.max(0, Math.min(100, Math.round(Number(next?.percent || 0))));
|
||||
return {
|
||||
active,
|
||||
stage: String(next?.stage || (active ? "running" : "idle")),
|
||||
message: String(next?.message || ""),
|
||||
percent,
|
||||
bytesTransferred: clampOutputDirProgressNumber(next?.bytesTransferred),
|
||||
bytesTotal: clampOutputDirProgressNumber(next?.bytesTotal),
|
||||
itemsTransferred: clampOutputDirProgressNumber(next?.itemsTransferred),
|
||||
itemsTotal: clampOutputDirProgressNumber(next?.itemsTotal),
|
||||
currentFile: String(next?.currentFile || ""),
|
||||
error: String(next?.error || ""),
|
||||
};
|
||||
}
|
||||
|
||||
function getOutputDirChangeProgressState() {
|
||||
if (!outputDirChangeProgressState) {
|
||||
outputDirChangeProgressState = makeIdleOutputDirChangeProgressState();
|
||||
}
|
||||
return outputDirChangeProgressState;
|
||||
}
|
||||
|
||||
function setOutputDirChangeProgressState(next = {}) {
|
||||
outputDirChangeProgressState = normalizeOutputDirChangeProgressState(next);
|
||||
sendToRenderer("app:outputDirChangeProgress", outputDirChangeProgressState);
|
||||
|
||||
if (!outputDirChangeProgressState.active) {
|
||||
setWindowProgressBar(-1);
|
||||
return outputDirChangeProgressState;
|
||||
}
|
||||
|
||||
const ratio =
|
||||
outputDirChangeProgressState.percent > 0
|
||||
? Math.max(0.02, Math.min(1, outputDirChangeProgressState.percent / 100))
|
||||
: 2;
|
||||
setWindowProgressBar(ratio);
|
||||
return outputDirChangeProgressState;
|
||||
}
|
||||
|
||||
function clearOutputDirChangeProgressState() {
|
||||
return setOutputDirChangeProgressState({ active: false });
|
||||
}
|
||||
|
||||
function getOutputDirWorkerScriptPath() {
|
||||
return path.join(__dirname, "output-dir-worker.cjs");
|
||||
}
|
||||
|
||||
function runOutputDirWorker(action, payload, onProgress) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const worker = new Worker(getOutputDirWorkerScriptPath(), {
|
||||
workerData: {
|
||||
action: String(action || "migrate"),
|
||||
payload,
|
||||
},
|
||||
});
|
||||
|
||||
let settled = false;
|
||||
const finish = (err, result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (err) reject(err);
|
||||
else resolve(result);
|
||||
};
|
||||
|
||||
worker.on("message", (message) => {
|
||||
if (!message || typeof message !== "object") return;
|
||||
if (message.type === "progress") {
|
||||
if (typeof onProgress === "function") onProgress(message.progress || {});
|
||||
return;
|
||||
}
|
||||
if (message.type === "result") {
|
||||
finish(null, message.result);
|
||||
return;
|
||||
}
|
||||
if (message.type === "error") {
|
||||
finish(new Error(message.error?.message || "output 目录迁移失败"));
|
||||
}
|
||||
});
|
||||
|
||||
worker.once("error", (err) => {
|
||||
finish(err);
|
||||
});
|
||||
|
||||
worker.once("exit", (code) => {
|
||||
if (settled || code === 0) return;
|
||||
finish(new Error(`output 目录任务异常退出(code=${code})`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function looksLikeHtml(input) {
|
||||
if (!input) return false;
|
||||
const s = String(input);
|
||||
@@ -1611,14 +1783,21 @@ function startBackend() {
|
||||
if (backendProc) return backendProc;
|
||||
startWcdbSidecar();
|
||||
|
||||
const resolvedDataPath = resolveDataDir() || getUserDataDir() || repoRoot();
|
||||
const resolvedOutputPath = resolveOutputDir() || getDefaultOutputDir() || path.join(resolvedDataPath, "output");
|
||||
const env = {
|
||||
...process.env,
|
||||
WECHAT_TOOL_HOST: getBackendBindHost(),
|
||||
WECHAT_TOOL_PORT: String(getBackendPort()),
|
||||
WECHAT_TOOL_DATA_DIR: resolvedDataPath,
|
||||
WECHAT_TOOL_OUTPUT_DIR: resolvedOutputPath,
|
||||
// Make sure Python prints UTF-8 to stdout/stderr.
|
||||
PYTHONIOENCODING: process.env.PYTHONIOENCODING || "utf-8",
|
||||
};
|
||||
ensureWcdbSidecarEnv(env);
|
||||
logMain(
|
||||
`[main] startBackend packaged=${app.isPackaged} port=${env.WECHAT_TOOL_PORT} dataDir=${env.WECHAT_TOOL_DATA_DIR} outputDir=${env.WECHAT_TOOL_OUTPUT_DIR}`
|
||||
);
|
||||
|
||||
// In packaged mode we expect to provide the generated Nuxt output dir via env.
|
||||
if (app.isPackaged && !env.WECHAT_TOOL_UI_DIR) {
|
||||
@@ -1626,8 +1805,6 @@ function startBackend() {
|
||||
}
|
||||
|
||||
if (app.isPackaged) {
|
||||
env.WECHAT_TOOL_DATA_DIR = resolveDataDir() || app.getPath("userData");
|
||||
env.WECHAT_TOOL_OUTPUT_DIR = resolveOutputDir() || getDefaultOutputDir() || path.join(env.WECHAT_TOOL_DATA_DIR, "output");
|
||||
try {
|
||||
fs.mkdirSync(env.WECHAT_TOOL_DATA_DIR, { recursive: true });
|
||||
fs.mkdirSync(env.WECHAT_TOOL_OUTPUT_DIR, { recursive: true });
|
||||
@@ -2156,8 +2333,8 @@ function registerWindowIpc() {
|
||||
pendingPath: "",
|
||||
hasPending: false,
|
||||
lastError: err?.message || String(err),
|
||||
canChange: !!app.isPackaged,
|
||||
changeUnavailableReason: app.isPackaged ? "" : "开发模式不支持界面修改 output 目录",
|
||||
canChange: false,
|
||||
changeUnavailableReason: "无法读取 output 目录信息",
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -2166,6 +2343,10 @@ function registerWindowIpc() {
|
||||
return resolveOutputDir() || "";
|
||||
});
|
||||
|
||||
ipcMain.handle("app:getOutputDirChangeProgress", () => {
|
||||
return getOutputDirChangeProgressState();
|
||||
});
|
||||
|
||||
ipcMain.handle("app:openOutputDir", async () => {
|
||||
const outDir = resolveOutputDir();
|
||||
if (!outDir) throw new Error("无法定位 output 目录");
|
||||
@@ -2202,6 +2383,7 @@ function registerWindowIpc() {
|
||||
};
|
||||
} finally {
|
||||
outputDirChangeInProgress = false;
|
||||
clearOutputDirChangeProgressState();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
const { parentPort, workerData } = require("worker_threads");
|
||||
const { migrateOutputDirectory, rollbackOutputDirectoryChange } = require("./output-dir.cjs");
|
||||
|
||||
function serializeError(err) {
|
||||
return {
|
||||
message: err?.message || String(err),
|
||||
stack: err?.stack ? String(err.stack) : "",
|
||||
};
|
||||
}
|
||||
|
||||
function createProgressPoster() {
|
||||
let lastStage = "";
|
||||
let lastPercent = -1;
|
||||
let lastSentAt = 0;
|
||||
|
||||
return (progress) => {
|
||||
const stage = String(progress?.stage || "");
|
||||
const percent = Number(progress?.percent || 0);
|
||||
const now = Date.now();
|
||||
const shouldSend =
|
||||
stage !== lastStage ||
|
||||
percent >= 100 ||
|
||||
percent <= 0 ||
|
||||
percent >= lastPercent + 1 ||
|
||||
now - lastSentAt >= 120;
|
||||
|
||||
if (!shouldSend) return;
|
||||
|
||||
lastStage = stage;
|
||||
lastPercent = percent;
|
||||
lastSentAt = now;
|
||||
parentPort?.postMessage({ type: "progress", progress });
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const action = String(workerData?.action || "migrate");
|
||||
const payload = workerData?.payload && typeof workerData.payload === "object" ? workerData.payload : {};
|
||||
|
||||
if (action === "migrate") {
|
||||
const result = await migrateOutputDirectory({
|
||||
...payload,
|
||||
onProgress: createProgressPoster(),
|
||||
});
|
||||
parentPort?.postMessage({ type: "result", result });
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "rollback") {
|
||||
parentPort?.postMessage({
|
||||
type: "progress",
|
||||
progress: {
|
||||
stage: "rolling-back",
|
||||
message: "迁移失败,正在回滚 output 目录",
|
||||
percent: 99,
|
||||
bytesTransferred: 0,
|
||||
bytesTotal: 0,
|
||||
itemsTransferred: 0,
|
||||
itemsTotal: 0,
|
||||
currentFile: "",
|
||||
},
|
||||
});
|
||||
rollbackOutputDirectoryChange(payload);
|
||||
parentPort?.postMessage({ type: "result", result: { success: true } });
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`不支持的 output 目录 worker 动作:${action}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
parentPort?.postMessage({ type: "error", error: serializeError(err) });
|
||||
process.exitCode = 1;
|
||||
});
|
||||
+281
-8
@@ -1,5 +1,6 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { pipeline } = require("stream/promises");
|
||||
|
||||
const SENTINEL_NAMES = [
|
||||
"account_keys.json",
|
||||
@@ -10,6 +11,17 @@ const SENTINEL_NAMES = [
|
||||
"logs",
|
||||
];
|
||||
|
||||
const PROGRESS_STAGE_MESSAGES = {
|
||||
preparing: "正在准备迁移 output 目录",
|
||||
scanning: "正在扫描 output 目录",
|
||||
copying: "正在复制 output 数据",
|
||||
verifying: "正在校验已复制的数据",
|
||||
switching: "正在切换 output 目录",
|
||||
"rolling-back": "迁移失败,正在回滚 output 目录",
|
||||
restarting: "正在重启后端并应用新的 output 目录",
|
||||
complete: "output 目录迁移完成",
|
||||
};
|
||||
|
||||
function normalizeDirectoryPath(value) {
|
||||
const text = String(value || "").trim();
|
||||
if (!text) return "";
|
||||
@@ -136,7 +148,233 @@ function ensureTargetIsUsable(targetDir) {
|
||||
}
|
||||
}
|
||||
|
||||
function migrateOutputDirectory({ currentDir, nextDir, now = new Date() }) {
|
||||
function clampNonNegativeNumber(value) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n < 0) return 0;
|
||||
return n;
|
||||
}
|
||||
|
||||
function computeProgressPercent(stage, bytesTransferred, bytesTotal, itemsTransferred, itemsTotal) {
|
||||
if (stage === "preparing") return 1;
|
||||
if (stage === "scanning") return 2;
|
||||
if (stage === "verifying") return 96;
|
||||
if (stage === "switching") return 99;
|
||||
if (stage === "complete") return 100;
|
||||
|
||||
if (stage === "copying") {
|
||||
const ratio =
|
||||
bytesTotal > 0
|
||||
? Math.min(1, bytesTransferred / bytesTotal)
|
||||
: itemsTotal > 0
|
||||
? Math.min(1, itemsTransferred / itemsTotal)
|
||||
: 1;
|
||||
return Math.max(5, Math.min(94, Math.round(5 + ratio * 89)));
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function buildProgressSnapshot({
|
||||
stage = "preparing",
|
||||
bytesTransferred = 0,
|
||||
bytesTotal = 0,
|
||||
itemsTransferred = 0,
|
||||
itemsTotal = 0,
|
||||
currentFile = "",
|
||||
}) {
|
||||
const normalizedStage = String(stage || "preparing");
|
||||
const safeBytesTransferred = clampNonNegativeNumber(bytesTransferred);
|
||||
const safeBytesTotal = clampNonNegativeNumber(bytesTotal);
|
||||
const safeItemsTransferred = clampNonNegativeNumber(itemsTransferred);
|
||||
const safeItemsTotal = clampNonNegativeNumber(itemsTotal);
|
||||
return {
|
||||
stage: normalizedStage,
|
||||
message: PROGRESS_STAGE_MESSAGES[normalizedStage] || "正在迁移 output 目录",
|
||||
percent: computeProgressPercent(
|
||||
normalizedStage,
|
||||
safeBytesTransferred,
|
||||
safeBytesTotal,
|
||||
safeItemsTransferred,
|
||||
safeItemsTotal
|
||||
),
|
||||
bytesTransferred: safeBytesTransferred,
|
||||
bytesTotal: safeBytesTotal,
|
||||
itemsTransferred: safeItemsTransferred,
|
||||
itemsTotal: safeItemsTotal,
|
||||
currentFile: String(currentFile || ""),
|
||||
};
|
||||
}
|
||||
|
||||
function emitProgress(onProgress, payload) {
|
||||
if (typeof onProgress !== "function") return;
|
||||
onProgress(buildProgressSnapshot(payload));
|
||||
}
|
||||
|
||||
function sortDirectoryEntries(entries) {
|
||||
return entries.sort((a, b) => String(a.name || "").localeCompare(String(b.name || "")));
|
||||
}
|
||||
|
||||
function depthOfRelativePath(relativePath) {
|
||||
const text = String(relativePath || "").trim();
|
||||
if (!text) return 0;
|
||||
return text.split(path.sep).length;
|
||||
}
|
||||
|
||||
function collectCopyManifest(sourceDir) {
|
||||
const directories = [];
|
||||
const files = [];
|
||||
let totalBytes = 0;
|
||||
const stack = [""];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const relativeDir = stack.pop();
|
||||
const absoluteDir = relativeDir ? path.join(sourceDir, relativeDir) : sourceDir;
|
||||
const dirEntries = sortDirectoryEntries(fs.readdirSync(absoluteDir, { withFileTypes: true }));
|
||||
|
||||
for (const dirent of dirEntries) {
|
||||
const relativePath = relativeDir ? path.join(relativeDir, dirent.name) : dirent.name;
|
||||
const absolutePath = path.join(sourceDir, relativePath);
|
||||
const stat = fs.lstatSync(absolutePath);
|
||||
|
||||
if (dirent.isDirectory()) {
|
||||
directories.push({
|
||||
relativePath,
|
||||
mode: stat.mode,
|
||||
atime: stat.atime,
|
||||
mtime: stat.mtime,
|
||||
});
|
||||
stack.push(relativePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dirent.isFile()) {
|
||||
files.push({
|
||||
relativePath,
|
||||
size: stat.size,
|
||||
mode: stat.mode,
|
||||
atime: stat.atime,
|
||||
mtime: stat.mtime,
|
||||
});
|
||||
totalBytes += stat.size;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dirent.isSymbolicLink()) {
|
||||
throw new Error(`output 目录包含不支持的符号链接:${relativePath}`);
|
||||
}
|
||||
|
||||
throw new Error(`output 目录包含不支持的文件类型:${relativePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
directories.sort((a, b) => depthOfRelativePath(a.relativePath) - depthOfRelativePath(b.relativePath));
|
||||
|
||||
return {
|
||||
directories,
|
||||
files,
|
||||
totalBytes,
|
||||
totalItems: directories.length + files.length,
|
||||
};
|
||||
}
|
||||
|
||||
function applyStatMetadata(targetPath, statLike) {
|
||||
try {
|
||||
if (Number.isInteger(statLike?.mode)) {
|
||||
fs.chmodSync(targetPath, statLike.mode);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
if (statLike?.atime && statLike?.mtime) {
|
||||
fs.utimesSync(targetPath, statLike.atime, statLike.mtime);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function copyFileWithProgress({ sourcePath, targetPath, mode, onChunk }) {
|
||||
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
|
||||
const readStream = fs.createReadStream(sourcePath);
|
||||
readStream.on("data", (chunk) => {
|
||||
if (typeof onChunk === "function") onChunk(chunk.length);
|
||||
});
|
||||
|
||||
const writeStream = fs.createWriteStream(targetPath, {
|
||||
flags: "w",
|
||||
mode: Number.isInteger(mode) ? mode : undefined,
|
||||
});
|
||||
|
||||
await pipeline(readStream, writeStream);
|
||||
}
|
||||
|
||||
async function copyOutputTree({ sourceDir, targetDir, manifest, onProgress }) {
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
|
||||
let bytesTransferred = 0;
|
||||
let itemsTransferred = 0;
|
||||
|
||||
emitProgress(onProgress, {
|
||||
stage: "copying",
|
||||
bytesTransferred,
|
||||
bytesTotal: manifest.totalBytes,
|
||||
itemsTransferred,
|
||||
itemsTotal: manifest.totalItems,
|
||||
});
|
||||
|
||||
for (const dirEntry of manifest.directories) {
|
||||
const targetPath = path.join(targetDir, dirEntry.relativePath);
|
||||
fs.mkdirSync(targetPath, { recursive: true });
|
||||
itemsTransferred += 1;
|
||||
emitProgress(onProgress, {
|
||||
stage: "copying",
|
||||
bytesTransferred,
|
||||
bytesTotal: manifest.totalBytes,
|
||||
itemsTransferred,
|
||||
itemsTotal: manifest.totalItems,
|
||||
currentFile: dirEntry.relativePath,
|
||||
});
|
||||
}
|
||||
|
||||
for (const fileEntry of manifest.files) {
|
||||
const sourcePath = path.join(sourceDir, fileEntry.relativePath);
|
||||
const targetPath = path.join(targetDir, fileEntry.relativePath);
|
||||
|
||||
await copyFileWithProgress({
|
||||
sourcePath,
|
||||
targetPath,
|
||||
mode: fileEntry.mode,
|
||||
onChunk: (delta) => {
|
||||
bytesTransferred += clampNonNegativeNumber(delta);
|
||||
emitProgress(onProgress, {
|
||||
stage: "copying",
|
||||
bytesTransferred,
|
||||
bytesTotal: manifest.totalBytes,
|
||||
itemsTransferred,
|
||||
itemsTotal: manifest.totalItems,
|
||||
currentFile: fileEntry.relativePath,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
applyStatMetadata(targetPath, fileEntry);
|
||||
itemsTransferred += 1;
|
||||
emitProgress(onProgress, {
|
||||
stage: "copying",
|
||||
bytesTransferred,
|
||||
bytesTotal: manifest.totalBytes,
|
||||
itemsTransferred,
|
||||
itemsTotal: manifest.totalItems,
|
||||
currentFile: fileEntry.relativePath,
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = manifest.directories.length - 1; i >= 0; i -= 1) {
|
||||
const dirEntry = manifest.directories[i];
|
||||
applyStatMetadata(path.join(targetDir, dirEntry.relativePath), dirEntry);
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateOutputDirectory({ currentDir, nextDir, now = new Date(), onProgress } = {}) {
|
||||
const currentPath = normalizeDirectoryPath(currentDir);
|
||||
const targetPath = normalizeDirectoryPath(nextDir);
|
||||
if (!currentPath || !targetPath) {
|
||||
@@ -155,15 +393,19 @@ function migrateOutputDirectory({ currentDir, nextDir, now = new Date() }) {
|
||||
throw new Error("新旧 output 路径不能互相包含");
|
||||
}
|
||||
|
||||
emitProgress(onProgress, { stage: "scanning" });
|
||||
ensureTargetIsUsable(targetPath);
|
||||
|
||||
const sourceExists = pathExists(currentPath);
|
||||
if (sourceExists && !isDirectory(currentPath)) {
|
||||
throw new Error("当前 output 路径不是目录");
|
||||
}
|
||||
|
||||
const sourceWasEmpty = !sourceExists || !hasDirectoryContents(currentPath);
|
||||
if (sourceWasEmpty) {
|
||||
emitProgress(onProgress, { stage: "switching" });
|
||||
fs.mkdirSync(targetPath, { recursive: true });
|
||||
emitProgress(onProgress, { stage: "complete", itemsTransferred: 1, itemsTotal: 1 });
|
||||
return {
|
||||
changed: true,
|
||||
currentDir: currentPath,
|
||||
@@ -173,18 +415,34 @@ function migrateOutputDirectory({ currentDir, nextDir, now = new Date() }) {
|
||||
};
|
||||
}
|
||||
|
||||
const manifest = collectCopyManifest(currentPath);
|
||||
const tempTarget = makeUniqueSiblingPath(targetPath, "migrating", now);
|
||||
const backupDir = makeUniqueSiblingPath(currentPath, "backup", now);
|
||||
|
||||
fs.cpSync(currentPath, tempTarget, {
|
||||
recursive: true,
|
||||
force: false,
|
||||
errorOnExist: true,
|
||||
preserveTimestamps: true,
|
||||
});
|
||||
|
||||
try {
|
||||
await copyOutputTree({
|
||||
sourceDir: currentPath,
|
||||
targetDir: tempTarget,
|
||||
manifest,
|
||||
onProgress,
|
||||
});
|
||||
|
||||
emitProgress(onProgress, {
|
||||
stage: "verifying",
|
||||
bytesTransferred: manifest.totalBytes,
|
||||
bytesTotal: manifest.totalBytes,
|
||||
itemsTransferred: manifest.totalItems,
|
||||
itemsTotal: manifest.totalItems,
|
||||
});
|
||||
verifyCopiedOutputTree(currentPath, tempTarget);
|
||||
|
||||
emitProgress(onProgress, {
|
||||
stage: "switching",
|
||||
bytesTransferred: manifest.totalBytes,
|
||||
bytesTotal: manifest.totalBytes,
|
||||
itemsTransferred: manifest.totalItems,
|
||||
itemsTotal: manifest.totalItems,
|
||||
});
|
||||
if (pathExists(targetPath)) {
|
||||
fs.rmSync(targetPath, { recursive: true, force: true });
|
||||
}
|
||||
@@ -209,6 +467,13 @@ function migrateOutputDirectory({ currentDir, nextDir, now = new Date() }) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
emitProgress(onProgress, {
|
||||
stage: "complete",
|
||||
bytesTransferred: manifest.totalBytes,
|
||||
bytesTotal: manifest.totalBytes,
|
||||
itemsTransferred: manifest.totalItems,
|
||||
itemsTotal: manifest.totalItems,
|
||||
});
|
||||
return {
|
||||
changed: true,
|
||||
currentDir: currentPath,
|
||||
@@ -242,7 +507,15 @@ function rollbackOutputDirectoryChange({ previousDir, currentDir, backupDir, sou
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function cleanupOutputDirectoryBackup(backupDir) {
|
||||
const backupPath = normalizeDirectoryPath(backupDir);
|
||||
if (!backupPath || !pathExists(backupPath)) return false;
|
||||
fs.rmSync(backupPath, { recursive: true, force: true });
|
||||
return !pathExists(backupPath);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
cleanupOutputDirectoryBackup,
|
||||
getDefaultOutputDirPath,
|
||||
getEffectiveOutputDirPath,
|
||||
hasDirectoryContents,
|
||||
|
||||
@@ -84,10 +84,16 @@ contextBridge.exposeInMainWorld("wechatDesktop", {
|
||||
// Data/output folder helpers
|
||||
getOutputDirInfo: () => ipcRenderer.invoke("app:getOutputDirInfo"),
|
||||
getOutputDir: () => ipcRenderer.invoke("app:getOutputDir"),
|
||||
getOutputDirChangeProgress: () => ipcRenderer.invoke("app:getOutputDirChangeProgress"),
|
||||
setOutputDir: (dir) => ipcRenderer.invoke("app:setOutputDir", String(dir ?? "")),
|
||||
openOutputDir: () => ipcRenderer.invoke("app:openOutputDir"),
|
||||
getAccountInfo: (account) => ipcRenderer.invoke("app:getAccountInfo", String(account || "")),
|
||||
deleteAccountData: (account) => ipcRenderer.invoke("app:deleteAccountData", String(account || "")),
|
||||
onOutputDirChangeProgress: (callback) => {
|
||||
const handler = (_event, progress) => callback(progress);
|
||||
ipcRenderer.on("app:outputDirChangeProgress", handler);
|
||||
return () => ipcRenderer.removeListener("app:outputDirChangeProgress", handler);
|
||||
},
|
||||
|
||||
// Auto update
|
||||
getVersion: () => ipcRenderer.invoke("app:getVersion"),
|
||||
|
||||
@@ -5,6 +5,7 @@ const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
const {
|
||||
cleanupOutputDirectoryBackup,
|
||||
getDefaultOutputDirPath,
|
||||
getEffectiveOutputDirPath,
|
||||
migrateOutputDirectory,
|
||||
@@ -55,14 +56,14 @@ test("getEffectiveOutputDirPath prefers env, then settings, then default", () =>
|
||||
}
|
||||
});
|
||||
|
||||
test("migrateOutputDirectory switches empty source to a new directory", () => {
|
||||
test("migrateOutputDirectory switches empty source to a new directory", async () => {
|
||||
const root = makeTempDir();
|
||||
const currentDir = path.join(root, "current-output");
|
||||
const nextDir = path.join(root, "custom-output");
|
||||
|
||||
try {
|
||||
fs.mkdirSync(currentDir, { recursive: true });
|
||||
const result = migrateOutputDirectory({ currentDir, nextDir });
|
||||
const result = await migrateOutputDirectory({ currentDir, nextDir });
|
||||
assert.equal(result.changed, true);
|
||||
assert.equal(result.sourceWasEmpty, true);
|
||||
assert.equal(result.backupDir, "");
|
||||
@@ -73,7 +74,7 @@ test("migrateOutputDirectory switches empty source to a new directory", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("migrateOutputDirectory blocks non-empty targets", () => {
|
||||
test("migrateOutputDirectory blocks non-empty targets", async () => {
|
||||
const root = makeTempDir();
|
||||
const currentDir = path.join(root, "current-output");
|
||||
const nextDir = path.join(root, "custom-output");
|
||||
@@ -84,8 +85,8 @@ test("migrateOutputDirectory blocks non-empty targets", () => {
|
||||
fs.mkdirSync(nextDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(nextDir, "existing.txt"), "occupied");
|
||||
|
||||
assert.throws(
|
||||
() => migrateOutputDirectory({ currentDir, nextDir }),
|
||||
await assert.rejects(
|
||||
migrateOutputDirectory({ currentDir, nextDir }),
|
||||
/已有内容/
|
||||
);
|
||||
} finally {
|
||||
@@ -93,15 +94,15 @@ test("migrateOutputDirectory blocks non-empty targets", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("migrateOutputDirectory blocks invalid current paths", () => {
|
||||
test("migrateOutputDirectory blocks invalid current paths", async () => {
|
||||
const root = makeTempDir();
|
||||
const currentDir = path.join(root, "current-output");
|
||||
const nextDir = path.join(root, "custom-output");
|
||||
|
||||
try {
|
||||
fs.writeFileSync(currentDir, "not-a-directory");
|
||||
assert.throws(
|
||||
() => migrateOutputDirectory({ currentDir, nextDir }),
|
||||
await assert.rejects(
|
||||
migrateOutputDirectory({ currentDir, nextDir }),
|
||||
/不是目录/
|
||||
);
|
||||
} finally {
|
||||
@@ -109,7 +110,7 @@ test("migrateOutputDirectory blocks invalid current paths", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("migrateOutputDirectory copies data and leaves the old directory as a backup", () => {
|
||||
test("migrateOutputDirectory copies data and leaves the old directory as a backup", async () => {
|
||||
const root = makeTempDir();
|
||||
const currentDir = path.join(root, "current-output");
|
||||
const nextDir = path.join(root, "custom-output");
|
||||
@@ -120,7 +121,13 @@ test("migrateOutputDirectory copies data and leaves the old directory as a backu
|
||||
fs.writeFileSync(path.join(currentDir, "databases", "wxid_test", "session.db"), "session");
|
||||
fs.writeFileSync(path.join(currentDir, "databases", "wxid_test", "contact.db"), "contact");
|
||||
|
||||
const result = migrateOutputDirectory({ currentDir, nextDir, now: new Date("2026-03-30T08:00:00Z") });
|
||||
const progressEvents = [];
|
||||
const result = await migrateOutputDirectory({
|
||||
currentDir,
|
||||
nextDir,
|
||||
now: new Date("2026-03-30T08:00:00Z"),
|
||||
onProgress: (progress) => progressEvents.push(progress),
|
||||
});
|
||||
assert.equal(result.changed, true);
|
||||
assert.equal(result.sourceWasEmpty, false);
|
||||
assert.match(path.basename(result.backupDir), /^current-output\.backup-\d{14}$/);
|
||||
@@ -129,6 +136,9 @@ test("migrateOutputDirectory copies data and leaves the old directory as a backu
|
||||
assert.ok(fs.existsSync(path.join(nextDir, "databases", "wxid_test", "session.db")));
|
||||
assert.ok(fs.existsSync(result.backupDir));
|
||||
assert.equal(fs.existsSync(currentDir), false);
|
||||
assert.ok(progressEvents.some((event) => event.stage === "scanning"));
|
||||
assert.ok(progressEvents.some((event) => event.stage === "copying" && event.percent > 0));
|
||||
assert.ok(progressEvents.some((event) => event.stage === "complete" && event.percent === 100));
|
||||
} finally {
|
||||
cleanupDir(root);
|
||||
}
|
||||
@@ -160,3 +170,18 @@ test("rollbackOutputDirectoryChange restores the previous directory", () => {
|
||||
cleanupDir(root);
|
||||
}
|
||||
});
|
||||
|
||||
test("cleanupOutputDirectoryBackup removes a completed migration backup directory", () => {
|
||||
const root = makeTempDir();
|
||||
const backupDir = path.join(root, "current-output.backup-20260330080100");
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(backupDir, "databases"), { recursive: true });
|
||||
fs.writeFileSync(path.join(backupDir, "databases", "session.db"), "restored");
|
||||
|
||||
assert.equal(cleanupOutputDirectoryBackup(backupDir), true);
|
||||
assert.equal(fs.existsSync(backupDir), false);
|
||||
} finally {
|
||||
cleanupDir(root);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -214,6 +214,22 @@
|
||||
<div v-if="desktopOutputDirCanChange" class="text-[11px] text-[#909090]">
|
||||
修改后会迁移整个 output 目录;如果目标目录已有内容,会先阻止并提示。
|
||||
</div>
|
||||
<div v-if="desktopOutputDirProgress" class="rounded-[6px] border border-[#d8efe2] bg-[#f4fbf7] px-2.5 py-2">
|
||||
<div class="flex items-center justify-between gap-3 text-[11px] text-[#1b6b43]">
|
||||
<div class="min-w-0 truncate">{{ desktopOutputDirProgressText }}</div>
|
||||
<div class="shrink-0 tabular-nums">{{ desktopOutputDirProgressPercentText }}</div>
|
||||
</div>
|
||||
<div class="mt-1.5 h-2 overflow-hidden rounded-full bg-[#dceee3]">
|
||||
<div
|
||||
class="h-full rounded-full bg-[#07b75b] transition-[width] duration-200 ease-out"
|
||||
:class="desktopOutputDirProgressIndeterminate ? 'animate-pulse' : ''"
|
||||
:style="{ width: desktopOutputDirProgressBarWidth }"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="desktopOutputDirProgressDetail" class="mt-1 text-[10px] text-[#5d7a68] break-all">
|
||||
{{ desktopOutputDirProgressDetail }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="desktopOutputDirMessage" class="rounded-[6px] border border-[#d8efe2] bg-[#f4fbf7] px-2.5 py-1.5 text-[11px] text-[#1b6b43] whitespace-pre-wrap">
|
||||
{{ desktopOutputDirMessage }}
|
||||
</div>
|
||||
@@ -410,6 +426,8 @@ const desktopOutputDirMessage = ref('')
|
||||
const desktopOutputDirIsDefault = ref(true)
|
||||
const desktopOutputDirCanChange = ref(true)
|
||||
const desktopOutputDirUnavailableReason = ref('')
|
||||
const desktopOutputDirProgress = ref(null)
|
||||
let removeDesktopOutputDirProgressListener = null
|
||||
const desktopOutputDirText = computed(() => {
|
||||
if (!isDesktopEnv.value) return '仅桌面端可用'
|
||||
const v = String(desktopOutputDir.value || '').trim()
|
||||
@@ -424,6 +442,48 @@ const desktopOutputDirPendingText = computed(() => {
|
||||
const v = String(desktopOutputDirPending.value || '').trim()
|
||||
return v || ''
|
||||
})
|
||||
const desktopOutputDirProgressPercent = computed(() => {
|
||||
const n = Number(desktopOutputDirProgress.value?.percent || 0)
|
||||
if (!Number.isFinite(n) || n < 0) return 0
|
||||
return Math.max(0, Math.min(100, Math.round(n)))
|
||||
})
|
||||
const desktopOutputDirProgressPercentText = computed(() => `${desktopOutputDirProgressPercent.value}%`)
|
||||
const desktopOutputDirProgressText = computed(() => {
|
||||
const text = String(desktopOutputDirProgress.value?.message || '').trim()
|
||||
return text || '正在迁移 output 目录'
|
||||
})
|
||||
const desktopOutputDirProgressIndeterminate = computed(() => {
|
||||
const stage = String(desktopOutputDirProgress.value?.stage || '').trim()
|
||||
return stage === 'preparing' || stage === 'scanning' || stage === 'rolling-back' || stage === 'restarting'
|
||||
})
|
||||
const desktopOutputDirProgressBarWidth = computed(() => {
|
||||
if (!desktopOutputDirProgress.value) return '0%'
|
||||
if (desktopOutputDirProgressIndeterminate.value) return '28%'
|
||||
return `${Math.max(6, desktopOutputDirProgressPercent.value)}%`
|
||||
})
|
||||
const desktopOutputDirProgressDetail = computed(() => {
|
||||
const progress = desktopOutputDirProgress.value
|
||||
if (!progress) return ''
|
||||
|
||||
const parts = []
|
||||
const bytesTotal = Number(progress.bytesTotal || 0)
|
||||
const bytesTransferred = Number(progress.bytesTransferred || 0)
|
||||
const itemsTotal = Number(progress.itemsTotal || 0)
|
||||
const itemsTransferred = Number(progress.itemsTransferred || 0)
|
||||
|
||||
if (bytesTotal > 0) {
|
||||
parts.push(`${formatBytes(bytesTransferred)} / ${formatBytes(bytesTotal)}`)
|
||||
} else if (itemsTotal > 0) {
|
||||
parts.push(`${Math.min(itemsTransferred, itemsTotal)} / ${itemsTotal} 项`)
|
||||
}
|
||||
|
||||
const currentFile = String(progress.currentFile || '').trim()
|
||||
if (currentFile) {
|
||||
parts.push(currentFile)
|
||||
}
|
||||
|
||||
return parts.join(' · ')
|
||||
})
|
||||
const desktopOutputDirControlsDisabled = computed(() => (
|
||||
!isDesktopEnv.value || !desktopOutputDirCanChange.value || desktopOutputDirLoading.value || desktopOutputDirApplying.value
|
||||
))
|
||||
@@ -442,6 +502,37 @@ const switchTrackClass = (enabled, disabled = false) => {
|
||||
return enabled ? 'bg-[#07b75b] hover:brightness-95' : 'bg-[#d0d0d0] hover:brightness-95'
|
||||
}
|
||||
|
||||
const formatBytes = (value) => {
|
||||
const n = Number(value || 0)
|
||||
if (!Number.isFinite(n) || n <= 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let next = n
|
||||
let unitIndex = 0
|
||||
while (next >= 1024 && unitIndex < units.length - 1) {
|
||||
next /= 1024
|
||||
unitIndex += 1
|
||||
}
|
||||
const digits = next >= 100 || unitIndex === 0 ? 0 : next >= 10 ? 1 : 2
|
||||
return `${next.toFixed(digits)} ${units[unitIndex]}`
|
||||
}
|
||||
|
||||
const applyDesktopOutputDirProgress = (progress) => {
|
||||
if (!progress || progress.active === false) {
|
||||
desktopOutputDirProgress.value = null
|
||||
return
|
||||
}
|
||||
desktopOutputDirProgress.value = { ...progress }
|
||||
}
|
||||
|
||||
const refreshDesktopOutputDirProgress = async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!window.wechatDesktop?.getOutputDirChangeProgress) return
|
||||
try {
|
||||
const progress = await window.wechatDesktop.getOutputDirChangeProgress()
|
||||
applyDesktopOutputDirProgress(progress)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const sectionElements = computed(() => [
|
||||
{ key: 'desktop', el: desktopSectionRef.value },
|
||||
{ key: 'startup', el: startupSectionRef.value },
|
||||
@@ -676,12 +767,13 @@ const applyDesktopOutputDir = async (nextDir) => {
|
||||
return
|
||||
}
|
||||
if (!desktopOutputDirCanChange.value) {
|
||||
desktopOutputDirError.value = desktopOutputDirUnavailableReason.value || '开发模式不支持界面修改 output 目录'
|
||||
desktopOutputDirError.value = desktopOutputDirUnavailableReason.value || '当前环境不支持修改 output 目录'
|
||||
return
|
||||
}
|
||||
desktopOutputDirApplying.value = true
|
||||
desktopOutputDirError.value = ''
|
||||
desktopOutputDirMessage.value = ''
|
||||
desktopOutputDirProgress.value = null
|
||||
try {
|
||||
const res = await window.wechatDesktop.setOutputDir(String(nextDir ?? '').trim())
|
||||
if (res?.success === false) {
|
||||
@@ -856,6 +948,7 @@ watch(() => props.open, async (isOpen) => {
|
||||
await refreshBackendLogFileInfo()
|
||||
if (isDesktopEnv.value) {
|
||||
await refreshDesktopOutputDir()
|
||||
await refreshDesktopOutputDirProgress()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
@@ -864,6 +957,11 @@ onMounted(async () => {
|
||||
const isElectron = /electron/i.test(String(navigator.userAgent || ''))
|
||||
isDesktopEnv.value = isElectron && !!window.wechatDesktop
|
||||
window.addEventListener('keydown', onEscKeydown)
|
||||
if (window.wechatDesktop?.onOutputDirChangeProgress) {
|
||||
removeDesktopOutputDirProgressListener = window.wechatDesktop.onOutputDirChangeProgress((progress) => {
|
||||
applyDesktopOutputDirProgress(progress)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
desktopAutoRealtime.value = readLocalBoolSetting(DESKTOP_SETTING_AUTO_REALTIME_KEY, false)
|
||||
@@ -876,6 +974,7 @@ onMounted(async () => {
|
||||
await refreshDesktopAutoLaunch()
|
||||
await refreshDesktopCloseBehavior()
|
||||
await refreshDesktopOutputDir()
|
||||
await refreshDesktopOutputDirProgress()
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
@@ -885,6 +984,10 @@ onMounted(async () => {
|
||||
onBeforeUnmount(() => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
window.removeEventListener('keydown', onEscKeydown)
|
||||
if (typeof removeDesktopOutputDirProgressListener === 'function') {
|
||||
removeDesktopOutputDirProgressListener()
|
||||
removeDesktopOutputDirProgressListener = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -357,30 +357,6 @@ export const useApi = () => {
|
||||
return await request(url)
|
||||
}
|
||||
|
||||
// 朋友圈图片本地缓存候选(用于错图时手动选择)
|
||||
const listSnsMediaCandidates = async (params = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params && params.account) query.set('account', params.account)
|
||||
if (params && params.create_time != null) query.set('create_time', String(params.create_time))
|
||||
if (params && params.width != null) query.set('width', String(params.width))
|
||||
if (params && params.height != null) query.set('height', String(params.height))
|
||||
if (params && params.limit != null) query.set('limit', String(params.limit))
|
||||
if (params && params.offset != null) query.set('offset', String(params.offset))
|
||||
const url = '/sns/media_candidates' + (query.toString() ? `?${query.toString()}` : '')
|
||||
return await request(url)
|
||||
}
|
||||
|
||||
// 保存朋友圈图片手动匹配结果(本机)
|
||||
const saveSnsMediaPicks = async (data = {}) => {
|
||||
return await request('/sns/media_picks', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
account: data.account || null,
|
||||
picks: (data && data.picks && typeof data.picks === 'object' && !Array.isArray(data.picks)) ? data.picks : {}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const openChatMediaFolder = async (params = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params && params.account) query.set('account', params.account)
|
||||
@@ -477,7 +453,7 @@ export const useApi = () => {
|
||||
return await request(`/chat/exports/${encodeURIComponent(String(exportId))}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// 朋友圈导出(离线 HTML zip)
|
||||
// 朋友圈导出(离线 ZIP,支持 HTML / JSON / TXT)
|
||||
const createSnsExport = async (data = {}) => {
|
||||
return await request('/sns/exports', {
|
||||
method: 'POST',
|
||||
@@ -485,6 +461,7 @@ export const useApi = () => {
|
||||
account: data.account || null,
|
||||
scope: data.scope || 'selected',
|
||||
usernames: Array.isArray(data.usernames) ? data.usernames : [],
|
||||
format: data.format || 'html',
|
||||
use_cache: data.use_cache == null ? true : !!data.use_cache,
|
||||
output_dir: data.output_dir == null ? null : String(data.output_dir || '').trim(),
|
||||
file_name: data.file_name || null
|
||||
@@ -667,8 +644,6 @@ export const useApi = () => {
|
||||
resolveAppMsg,
|
||||
listSnsTimeline,
|
||||
listSnsUsers,
|
||||
listSnsMediaCandidates,
|
||||
saveSnsMediaPicks,
|
||||
openChatMediaFolder,
|
||||
downloadChatEmoji,
|
||||
saveMediaKeys,
|
||||
|
||||
+457
-423
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "wechat-decrypt-tool"
|
||||
version = "1.3.0"
|
||||
version = "1.7.10"
|
||||
description = "Modern WeChat database decryption tool with React frontend"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -43,6 +43,9 @@ include = [
|
||||
"src/wechat_decrypt_tool/native/VoipEngine.dll",
|
||||
"src/wechat_decrypt_tool/native/wcdb_api.dll",
|
||||
"src/wechat_decrypt_tool/native/WCDB.dll",
|
||||
"src/wechat_decrypt_tool/native/weflow_wasm/weflow_wasm_keystream.js",
|
||||
"src/wechat_decrypt_tool/native/weflow_wasm/wasm_video_decode.js",
|
||||
"src/wechat_decrypt_tool/native/weflow_wasm/wasm_video_decode.wasm",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""微信数据库解密工具
|
||||
"""
|
||||
|
||||
__version__ = "1.3.0"
|
||||
__version__ = "1.7.10"
|
||||
__author__ = "WeChat Decrypt Tool"
|
||||
|
||||
@@ -35,7 +35,6 @@ from .routers.sns_export import router as _sns_export_router
|
||||
from .routers.wechat_detection import router as _wechat_detection_router
|
||||
from .routers.wrapped import router as _wrapped_router
|
||||
from .request_logging import log_server_errors_middleware
|
||||
from .sns_stage_timing import add_sns_stage_timing_headers
|
||||
from .wcdb_realtime import WCDB_REALTIME, shutdown as _wcdb_shutdown
|
||||
from .routers.biz import router as _biz_router
|
||||
from .routers.system import router as _system_router
|
||||
@@ -56,31 +55,9 @@ app.add_middleware(
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
expose_headers=["X-SNS-Source", "X-SNS-Hit-Type", "X-SNS-X-Enc"],
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def _add_sns_stage_timing_headers(request: Request, call_next):
|
||||
"""Expose SNS stage metadata to the frontend without extra requests.
|
||||
|
||||
`<img>` elements can't read response headers, but browsers can surface `Server-Timing`
|
||||
via `performance.getEntriesByName(...).serverTiming` when `Timing-Allow-Origin` is set.
|
||||
"""
|
||||
|
||||
response = await call_next(request)
|
||||
try:
|
||||
add_sns_stage_timing_headers(
|
||||
response.headers,
|
||||
source=str(response.headers.get("X-SNS-Source") or ""),
|
||||
hit_type=str(response.headers.get("X-SNS-Hit-Type") or ""),
|
||||
x_enc=str(response.headers.get("X-SNS-X-Enc") or ""),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return response
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def _log_server_errors(request: Request, call_next):
|
||||
return await log_server_errors_middleware(request_logger, request, call_next)
|
||||
|
||||
@@ -10,9 +10,9 @@ 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.
|
||||
- Production Moments image/video decryption should prefer the vendored
|
||||
WxIsaac64/WASM path. This pure-Python implementation is only a fallback when
|
||||
Node/WASM is unavailable.
|
||||
- This ISAAC-64 implementation may not perfectly match WxIsaac64; treat it as
|
||||
best-effort.
|
||||
"""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -0,0 +1,122 @@
|
||||
// Generate WeChat/WeFlow WxIsaac64 keystream via the vendored WASM module.
|
||||
//
|
||||
// Usage:
|
||||
// node weflow_wasm_keystream.js <key> <size>
|
||||
//
|
||||
// Prints a base64-encoded keystream to stdout (no extra logs).
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const vm = require('vm')
|
||||
|
||||
function usageAndExit() {
|
||||
process.stderr.write('Usage: node weflow_wasm_keystream.js <key> <size>\\n')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
const key = String(process.argv[2] || '').trim()
|
||||
const size = Number(process.argv[3] || 0)
|
||||
|
||||
if (!key || !Number.isFinite(size) || size <= 0) usageAndExit()
|
||||
|
||||
const basePath = __dirname
|
||||
const wasmPath = path.join(basePath, 'wasm_video_decode.wasm')
|
||||
const jsPath = path.join(basePath, 'wasm_video_decode.js')
|
||||
|
||||
if (!fs.existsSync(wasmPath) || !fs.existsSync(jsPath)) {
|
||||
process.stderr.write(`Vendored WASM assets not found: ${basePath}\\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const wasmBinary = fs.readFileSync(wasmPath)
|
||||
const jsContent = fs.readFileSync(jsPath, 'utf8')
|
||||
|
||||
let capturedKeystream = null
|
||||
let resolveInit
|
||||
let rejectInit
|
||||
const initPromise = new Promise((res, rej) => {
|
||||
resolveInit = res
|
||||
rejectInit = rej
|
||||
})
|
||||
|
||||
const mockGlobal = {
|
||||
console: { log: () => {}, error: () => {} },
|
||||
Buffer,
|
||||
Uint8Array,
|
||||
Int8Array,
|
||||
Uint16Array,
|
||||
Int16Array,
|
||||
Uint32Array,
|
||||
Int32Array,
|
||||
Float32Array,
|
||||
Float64Array,
|
||||
BigInt64Array,
|
||||
BigUint64Array,
|
||||
Array,
|
||||
Object,
|
||||
Function,
|
||||
String,
|
||||
Number,
|
||||
Boolean,
|
||||
Error,
|
||||
Promise,
|
||||
require,
|
||||
process,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval,
|
||||
clearInterval,
|
||||
}
|
||||
|
||||
mockGlobal.Module = {
|
||||
onRuntimeInitialized: () => resolveInit(),
|
||||
wasmBinary,
|
||||
print: () => {},
|
||||
printErr: () => {},
|
||||
}
|
||||
|
||||
mockGlobal.self = mockGlobal
|
||||
mockGlobal.self.location = { href: jsPath }
|
||||
mockGlobal.WorkerGlobalScope = function () {}
|
||||
mockGlobal.VTS_WASM_URL = `file://${wasmPath}`
|
||||
|
||||
mockGlobal.wasm_isaac_generate = (ptr, n) => {
|
||||
const buf = new Uint8Array(mockGlobal.Module.HEAPU8.buffer, ptr, n)
|
||||
capturedKeystream = new Uint8Array(buf)
|
||||
}
|
||||
|
||||
try {
|
||||
const context = vm.createContext(mockGlobal)
|
||||
new vm.Script(jsContent, { filename: jsPath }).runInContext(context)
|
||||
} catch (e) {
|
||||
rejectInit(e)
|
||||
}
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
await initPromise
|
||||
|
||||
if (!mockGlobal.Module.WxIsaac64 && mockGlobal.Module.asm && mockGlobal.Module.asm.WxIsaac64) {
|
||||
mockGlobal.Module.WxIsaac64 = mockGlobal.Module.asm.WxIsaac64
|
||||
}
|
||||
if (!mockGlobal.Module.WxIsaac64) {
|
||||
throw new Error('WxIsaac64 not found in WASM module')
|
||||
}
|
||||
|
||||
const alignedSize = Math.ceil(size / 8) * 8
|
||||
|
||||
capturedKeystream = null
|
||||
const isaac = new mockGlobal.Module.WxIsaac64(key)
|
||||
isaac.generate(alignedSize)
|
||||
if (isaac.delete) isaac.delete()
|
||||
|
||||
if (!capturedKeystream) throw new Error('Failed to capture keystream')
|
||||
|
||||
const out = Buffer.from(capturedKeystream)
|
||||
out.reverse()
|
||||
process.stdout.write(out.subarray(0, size).toString('base64'))
|
||||
} catch (e) {
|
||||
process.stderr.write(String(e && e.stack ? e.stack : e) + '\\n')
|
||||
process.exit(1)
|
||||
}
|
||||
})()
|
||||
@@ -1,4 +1,3 @@
|
||||
from bisect import bisect_left, bisect_right
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
import os
|
||||
@@ -20,7 +19,6 @@ from starlette.background import BackgroundTask
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import Response, FileResponse # 返回视频文件
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..chat_helpers import _load_contact_rows, _pick_display_name, _resolve_account_dir
|
||||
from ..logging_config import get_logger
|
||||
@@ -44,8 +42,6 @@ logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter(route_class=PathFixRoute)
|
||||
|
||||
SNS_MEDIA_PICKS_FILE = "_sns_media_picks.json"
|
||||
|
||||
_SNS_VIDEO_KEY_RE = re.compile(r'<enc\s+key="(\d+)"', flags=re.IGNORECASE)
|
||||
_MP_BIZ_RE = re.compile(r"__biz=([A-Za-z0-9_=+-]+)")
|
||||
_ZSTD_MAGIC = b"\x28\xb5\x2f\xfd"
|
||||
@@ -860,233 +856,6 @@ def _parse_timeline_xml(xml_text: str, fallback_username: str) -> dict[str, Any]
|
||||
return out
|
||||
|
||||
|
||||
def _image_size_from_bytes(data: bytes, media_type: str) -> tuple[int, int]:
|
||||
mt = str(media_type or "").lower()
|
||||
if mt == "image/png":
|
||||
# PNG IHDR width/height are stored at byte offsets 16..24
|
||||
if len(data) >= 24 and data.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
try:
|
||||
w = int.from_bytes(data[16:20], "big")
|
||||
h = int.from_bytes(data[20:24], "big")
|
||||
return w, h
|
||||
except Exception:
|
||||
return 0, 0
|
||||
return 0, 0
|
||||
if mt in {"image/jpeg", "image/jpg"}:
|
||||
# Minimal JPEG SOF parser.
|
||||
if len(data) < 4 or (not data.startswith(b"\xFF\xD8")):
|
||||
return 0, 0
|
||||
i = 2
|
||||
while i + 3 < len(data):
|
||||
if data[i] != 0xFF:
|
||||
i += 1
|
||||
continue
|
||||
# Skip padding 0xFF bytes.
|
||||
while i < len(data) and data[i] == 0xFF:
|
||||
i += 1
|
||||
if i >= len(data):
|
||||
break
|
||||
marker = data[i]
|
||||
i += 1
|
||||
# Markers without a segment length.
|
||||
if marker in (0xD8, 0xD9):
|
||||
continue
|
||||
if marker == 0xDA: # Start of scan.
|
||||
break
|
||||
if i + 1 >= len(data):
|
||||
break
|
||||
seg_len = (data[i] << 8) + data[i + 1]
|
||||
i += 2
|
||||
if seg_len < 2:
|
||||
break
|
||||
# SOF markers which contain width/height.
|
||||
if marker in {
|
||||
0xC0,
|
||||
0xC1,
|
||||
0xC2,
|
||||
0xC3,
|
||||
0xC5,
|
||||
0xC6,
|
||||
0xC7,
|
||||
0xC9,
|
||||
0xCA,
|
||||
0xCB,
|
||||
0xCD,
|
||||
0xCE,
|
||||
0xCF,
|
||||
}:
|
||||
# segment: [precision(1), height(2), width(2), ...]
|
||||
if i + 4 < len(data):
|
||||
try:
|
||||
h = (data[i + 1] << 8) + data[i + 2]
|
||||
w = (data[i + 3] << 8) + data[i + 4]
|
||||
return w, h
|
||||
except Exception:
|
||||
return 0, 0
|
||||
i += seg_len - 2
|
||||
return 0, 0
|
||||
return 0, 0
|
||||
|
||||
|
||||
@lru_cache(maxsize=16)
|
||||
def _sns_img_time_index(wxid_dir_str: str) -> tuple[list[float], list[str]]:
|
||||
"""Build a (mtime_sorted, path_sorted) index for local Moments cache images.
|
||||
|
||||
WeChat stores encrypted SNS cache images under:
|
||||
`{wxid_dir}/cache/YYYY-MM/Sns/Img/<2hex>/<30hex>`
|
||||
"""
|
||||
wxid_dir = Path(str(wxid_dir_str or "").strip())
|
||||
out: list[tuple[float, str]] = []
|
||||
|
||||
cache_root = wxid_dir / "cache"
|
||||
try:
|
||||
month_dirs = [p for p in cache_root.iterdir() if p.is_dir()]
|
||||
except Exception:
|
||||
month_dirs = []
|
||||
|
||||
for mdir in month_dirs:
|
||||
img_root = mdir / "Sns" / "Img"
|
||||
try:
|
||||
if not (img_root.exists() and img_root.is_dir()):
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
# The Img dir uses a 2-level layout; keep this tight (no global rglob).
|
||||
try:
|
||||
for sub in img_root.iterdir():
|
||||
if not sub.is_dir():
|
||||
continue
|
||||
for f in sub.iterdir():
|
||||
try:
|
||||
if not f.is_file():
|
||||
continue
|
||||
st = f.stat()
|
||||
out.append((float(st.st_mtime), str(f)))
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
out.sort(key=lambda x: x[0])
|
||||
mtimes = [m for m, _p in out]
|
||||
paths = [_p for _m, _p in out]
|
||||
return mtimes, paths
|
||||
|
||||
|
||||
def _normalize_hex32(value: Optional[str]) -> str:
|
||||
"""Return the first 32 hex chars from value, or '' if not present."""
|
||||
s = str(value or "").strip().lower()
|
||||
if not s:
|
||||
return ""
|
||||
# Keep only hex chars. Some attrs may contain separators or be wrapped.
|
||||
s = re.sub(r"[^0-9a-f]", "", s)
|
||||
if len(s) < 32:
|
||||
return ""
|
||||
return s[:32]
|
||||
|
||||
|
||||
def _sns_media_picks_path(account_dir: Path) -> Path:
|
||||
return account_dir / SNS_MEDIA_PICKS_FILE
|
||||
|
||||
|
||||
def _sns_post_id_from_media_key(media_key: str) -> str:
|
||||
# Frontend stores picks under `${postId}:${idx}`.
|
||||
s = str(media_key or "").strip()
|
||||
if not s:
|
||||
return ""
|
||||
return s.split(":", 1)[0].strip()
|
||||
|
||||
|
||||
@lru_cache(maxsize=32)
|
||||
def _load_sns_media_picks_cached(path_str: str, mtime: float) -> dict[str, str]:
|
||||
p = Path(str(path_str or "").strip())
|
||||
try:
|
||||
raw = p.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
try:
|
||||
obj = json.loads(raw)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
picks_obj = obj.get("picks") if isinstance(obj, dict) else None
|
||||
if not isinstance(picks_obj, dict):
|
||||
return {}
|
||||
|
||||
out: dict[str, str] = {}
|
||||
for k, v in picks_obj.items():
|
||||
mk = str(k or "").strip()
|
||||
if not mk:
|
||||
continue
|
||||
ck = _normalize_hex32(str(v or ""))
|
||||
if not ck:
|
||||
continue
|
||||
out[mk] = ck
|
||||
return out
|
||||
|
||||
|
||||
def _load_sns_media_picks(account_dir: Path) -> dict[str, str]:
|
||||
p = _sns_media_picks_path(account_dir)
|
||||
try:
|
||||
st = p.stat()
|
||||
mtime = float(st.st_mtime)
|
||||
except Exception:
|
||||
mtime = 0.0
|
||||
return _load_sns_media_picks_cached(str(p), mtime)
|
||||
|
||||
|
||||
def _save_sns_media_picks(account_dir: Path, picks: dict[str, str]) -> int:
|
||||
# Normalize + keep it stable for easier diff/debugging.
|
||||
out: dict[str, str] = {}
|
||||
for k, v in (picks or {}).items():
|
||||
mk = str(k or "").strip()
|
||||
if not mk:
|
||||
continue
|
||||
ck = _normalize_hex32(str(v or ""))
|
||||
if not ck:
|
||||
continue
|
||||
out[mk] = ck
|
||||
|
||||
try:
|
||||
payload = {"updated_at": int(time.time()), "picks": dict(sorted(out.items(), key=lambda x: x[0]))}
|
||||
_sns_media_picks_path(account_dir).write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
_load_sns_media_picks_cached.cache_clear()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return len(out)
|
||||
|
||||
|
||||
@lru_cache(maxsize=16)
|
||||
def _sns_img_roots(wxid_dir_str: str) -> tuple[str, ...]:
|
||||
"""List all month cache roots that contain `Sns/Img`."""
|
||||
wxid_dir = Path(str(wxid_dir_str or "").strip())
|
||||
cache_root = wxid_dir / "cache"
|
||||
try:
|
||||
month_dirs = [p for p in cache_root.iterdir() if p.is_dir()]
|
||||
except Exception:
|
||||
month_dirs = []
|
||||
|
||||
roots: list[str] = []
|
||||
for mdir in month_dirs:
|
||||
img_root = mdir / "Sns" / "Img"
|
||||
try:
|
||||
if img_root.exists() and img_root.is_dir():
|
||||
roots.append(str(img_root))
|
||||
except Exception:
|
||||
continue
|
||||
# Keep it stable (helps debugging and caching predictability).
|
||||
roots.sort()
|
||||
return tuple(roots)
|
||||
|
||||
@lru_cache(maxsize=16)
|
||||
def _sns_video_roots(wxid_dir_str: str) -> tuple[str, ...]:
|
||||
"""List all month cache roots that contain `Sns/Video`."""
|
||||
@@ -1139,268 +908,6 @@ def _resolve_sns_cached_video_path(
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_sns_cached_image_path_by_md5(
|
||||
*,
|
||||
wxid_dir: Path,
|
||||
md5: str,
|
||||
create_time: int,
|
||||
) -> Optional[str]:
|
||||
"""Try to resolve SNS cache image by md5-based cache path layout."""
|
||||
md5_32 = _normalize_hex32(md5)
|
||||
if not md5_32:
|
||||
return None
|
||||
|
||||
sub = md5_32[:2]
|
||||
rest = md5_32[2:]
|
||||
roots = _sns_img_roots(str(wxid_dir))
|
||||
if not roots:
|
||||
return None
|
||||
|
||||
best: tuple[float, str] | None = None
|
||||
for root_str in roots:
|
||||
try:
|
||||
p = Path(root_str) / sub / rest
|
||||
if not (p.exists() and p.is_file()):
|
||||
continue
|
||||
# Prefer the cache file closest to the post create_time (if provided),
|
||||
# otherwise pick the newest one.
|
||||
st = p.stat()
|
||||
if create_time > 0:
|
||||
score = abs(float(st.st_mtime) - float(create_time))
|
||||
else:
|
||||
score = -float(st.st_mtime)
|
||||
if best is None or score < best[0]:
|
||||
best = (score, str(p))
|
||||
except Exception:
|
||||
continue
|
||||
return best[1] if best else None
|
||||
|
||||
|
||||
def _sns_cache_key_from_path(p: Path) -> str:
|
||||
"""Return the 32-hex cache key for a SNS cache file path, or ''."""
|
||||
try:
|
||||
# cache/.../Sns/Img/<2hex>/<30hex>
|
||||
key = f"{p.parent.name}{p.name}"
|
||||
except Exception:
|
||||
return ""
|
||||
return _normalize_hex32(key)
|
||||
|
||||
|
||||
def _generate_sns_cache_key(tid: str, media_id: str, media_type: int = 2) -> str:
|
||||
"""
|
||||
公式: md5(tid_mediaId_type)
|
||||
Example: 14852422213384352392_14852422213963625090_2 -> 6d479249ca5a090fab5c42c79bc56b89
|
||||
"""
|
||||
if not tid or not media_id:
|
||||
return ""
|
||||
|
||||
raw_key = f"{tid}_{media_id}_{media_type}"
|
||||
|
||||
try:
|
||||
return hashlib.md5(raw_key.encode("utf-8")).hexdigest()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def _resolve_sns_cached_image_path_by_cache_key(
|
||||
*,
|
||||
wxid_dir: Path,
|
||||
cache_key: str,
|
||||
create_time: int,
|
||||
) -> Optional[str]:
|
||||
"""Resolve SNS cache image by `<2hex>/<30hex>` cache key."""
|
||||
key32 = _normalize_hex32(cache_key)
|
||||
if not key32:
|
||||
return None
|
||||
|
||||
sub = key32[:2]
|
||||
rest = key32[2:]
|
||||
roots = _sns_img_roots(str(wxid_dir))
|
||||
if not roots:
|
||||
return None
|
||||
|
||||
best: tuple[float, str] | None = None
|
||||
for root_str in roots:
|
||||
try:
|
||||
p = Path(root_str) / sub / rest
|
||||
if not (p.exists() and p.is_file()):
|
||||
continue
|
||||
st = p.stat()
|
||||
if create_time > 0:
|
||||
score = abs(float(st.st_mtime) - float(create_time))
|
||||
else:
|
||||
score = -float(st.st_mtime)
|
||||
if best is None or score < best[0]:
|
||||
best = (score, str(p))
|
||||
except Exception:
|
||||
continue
|
||||
return best[1] if best else None
|
||||
|
||||
|
||||
@lru_cache(maxsize=4096)
|
||||
def _resolve_sns_cached_image_path(
|
||||
*,
|
||||
account_dir_str: str,
|
||||
create_time: int,
|
||||
width: int,
|
||||
height: int,
|
||||
idx: int,
|
||||
total_size: int = 0,
|
||||
) -> Optional[str]:
|
||||
"""Best-effort resolve a local cached SNS image for a post+media meta."""
|
||||
total_size_i = int(total_size or 0)
|
||||
must_match_size = width > 0 and height > 0
|
||||
# Without size/total_size, time-only matching is too error-prone and can easily mix images.
|
||||
if (not must_match_size) and total_size_i <= 0:
|
||||
return None
|
||||
|
||||
account_dir = Path(str(account_dir_str or "").strip())
|
||||
if not account_dir.exists():
|
||||
return None
|
||||
|
||||
wxid_dir = _resolve_account_wxid_dir(account_dir)
|
||||
if not wxid_dir:
|
||||
return None
|
||||
|
||||
mtimes, paths = _sns_img_time_index(str(wxid_dir))
|
||||
if not mtimes:
|
||||
return None
|
||||
|
||||
create_time_i = int(create_time or 0)
|
||||
if create_time_i > 0:
|
||||
# We don't know when the image was cached (could be close to create_time, could be hours later).
|
||||
# Use a generous window but keep it bounded for performance.
|
||||
window = 72 * 3600 # 72h
|
||||
lo = create_time_i - window
|
||||
hi = create_time_i + window
|
||||
|
||||
l = bisect_left(mtimes, lo)
|
||||
r = bisect_right(mtimes, hi)
|
||||
if l >= r:
|
||||
# Fallback: search the newest N files if time window has no hits.
|
||||
l = max(0, len(mtimes) - 800)
|
||||
r = len(mtimes)
|
||||
else:
|
||||
# Missing createTime: only probe the newest cache entries.
|
||||
l = max(0, len(mtimes) - 800)
|
||||
r = len(mtimes)
|
||||
|
||||
# Rank by time proximity to create_time (or by recency when createTime is missing).
|
||||
candidates: list[tuple[float, str]] = []
|
||||
for j in range(l, r):
|
||||
try:
|
||||
if create_time_i > 0:
|
||||
candidates.append((abs(mtimes[j] - float(create_time_i)), paths[j]))
|
||||
else:
|
||||
candidates.append((-mtimes[j], paths[j]))
|
||||
except Exception:
|
||||
continue
|
||||
candidates.sort(key=lambda x: x[0])
|
||||
|
||||
matched: list[tuple[int, float, str]] = []
|
||||
# Limit the work per request.
|
||||
max_probe = 2000 if (r - l) <= 2000 else 2000
|
||||
for _diff, pstr in candidates[:max_probe]:
|
||||
try:
|
||||
p = Path(pstr)
|
||||
payload, media_type = _read_and_maybe_decrypt_media(p, account_dir)
|
||||
if not payload or not str(media_type or "").startswith("image/"):
|
||||
continue
|
||||
if must_match_size:
|
||||
w0, h0 = _image_size_from_bytes(payload, str(media_type or ""))
|
||||
if (w0, h0) != (width, height):
|
||||
continue
|
||||
|
||||
size_diff = abs(len(payload) - total_size_i) if total_size_i > 0 else 0
|
||||
# When totalSize is available, it tends to be a stronger discriminator than mtime.
|
||||
matched.append((int(size_diff), float(_diff), pstr))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not matched:
|
||||
return None
|
||||
if must_match_size:
|
||||
matched.sort(key=lambda x: (x[0], x[1], x[2]))
|
||||
# If we have totalSize, treat it as a strong discriminator and always take the best match.
|
||||
if total_size_i > 0:
|
||||
return matched[0][2]
|
||||
idx0 = max(0, int(idx or 0))
|
||||
return matched[idx0][2] if idx0 < len(matched) else None
|
||||
# No size: only return a best-effort match when totalSize is available.
|
||||
if total_size_i > 0:
|
||||
matched.sort(key=lambda x: (x[0], x[1], x[2]))
|
||||
return matched[0][2]
|
||||
return None
|
||||
|
||||
|
||||
@lru_cache(maxsize=2048)
|
||||
def _list_sns_cached_image_candidate_keys(
|
||||
*,
|
||||
account_dir_str: str,
|
||||
create_time: int,
|
||||
width: int,
|
||||
height: int,
|
||||
) -> tuple[str, ...]:
|
||||
"""List local SNS cache candidates (as 32-hex cache keys) for a media item.
|
||||
|
||||
The ordering matches `_resolve_sns_cached_image_path()`'s scan order, so `idx`
|
||||
is stable within the same (account, create_time, width, height) input.
|
||||
"""
|
||||
if create_time <= 0 or width <= 0 or height <= 0:
|
||||
return tuple()
|
||||
|
||||
account_dir = Path(str(account_dir_str or "").strip())
|
||||
if not account_dir.exists():
|
||||
return tuple()
|
||||
|
||||
wxid_dir = _resolve_account_wxid_dir(account_dir)
|
||||
if not wxid_dir:
|
||||
return tuple()
|
||||
|
||||
mtimes, paths = _sns_img_time_index(str(wxid_dir))
|
||||
if not mtimes:
|
||||
return tuple()
|
||||
|
||||
window = 72 * 3600 # 72h
|
||||
lo = create_time - window
|
||||
hi = create_time + window
|
||||
|
||||
l = bisect_left(mtimes, lo)
|
||||
r = bisect_right(mtimes, hi)
|
||||
if l >= r:
|
||||
l = max(0, len(mtimes) - 800)
|
||||
r = len(mtimes)
|
||||
|
||||
candidates: list[tuple[float, str]] = []
|
||||
for j in range(l, r):
|
||||
try:
|
||||
candidates.append((abs(mtimes[j] - float(create_time)), paths[j]))
|
||||
except Exception:
|
||||
continue
|
||||
candidates.sort(key=lambda x: x[0])
|
||||
|
||||
max_probe = 2000 if (r - l) <= 2000 else 2000
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for _diff, pstr in candidates[:max_probe]:
|
||||
try:
|
||||
p = Path(pstr)
|
||||
payload, media_type = _read_and_maybe_decrypt_media(p, account_dir)
|
||||
if not payload or not str(media_type or "").startswith("image/"):
|
||||
continue
|
||||
w0, h0 = _image_size_from_bytes(payload, str(media_type or ""))
|
||||
if (w0, h0) != (width, height):
|
||||
continue
|
||||
key = _sns_cache_key_from_path(p)
|
||||
if not key or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(key)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return tuple(out)
|
||||
|
||||
def _get_sns_covers(account_dir: Path, target_wxid: str, limit: int = 20) -> list[dict[str, Any]]:
|
||||
"""无论多古老,强行揪出用户的朋友圈封面历史 (type=7)。
|
||||
|
||||
@@ -2575,47 +2082,6 @@ def list_sns_users(
|
||||
return {"items": items, "count": len(items), "limit": lim}
|
||||
|
||||
|
||||
class SnsMediaPicksSaveRequest(BaseModel):
|
||||
account: Optional[str] = Field(None, description="账号目录名(可选,默认使用第一个)")
|
||||
picks: dict[str, str] = Field(default_factory=dict, description="手动匹配表:`${postId}:${idx}` -> 32hex cacheKey")
|
||||
|
||||
|
||||
@router.post("/api/sns/media_picks", summary="保存朋友圈图片手动匹配结果(本机)")
|
||||
async def save_sns_media_picks(request: SnsMediaPicksSaveRequest):
|
||||
account_dir = _resolve_account_dir(request.account)
|
||||
count = _save_sns_media_picks(account_dir, request.picks or {})
|
||||
return {"status": "success", "count": int(count)}
|
||||
|
||||
|
||||
@router.get("/api/sns/media_candidates", summary="获取朋友圈图片本地缓存候选")
|
||||
def list_sns_media_candidates(
|
||||
account: Optional[str] = None,
|
||||
create_time: int = 0,
|
||||
width: int = 0,
|
||||
height: int = 0,
|
||||
limit: int = 24,
|
||||
offset: int = 0,
|
||||
):
|
||||
if limit <= 0:
|
||||
raise HTTPException(status_code=400, detail="Invalid limit.")
|
||||
if limit > 200:
|
||||
limit = 200
|
||||
if offset < 0:
|
||||
offset = 0
|
||||
|
||||
account_dir = _resolve_account_dir(account)
|
||||
keys = _list_sns_cached_image_candidate_keys(
|
||||
account_dir_str=str(account_dir),
|
||||
create_time=int(create_time or 0),
|
||||
width=int(width or 0),
|
||||
height=int(height or 0),
|
||||
)
|
||||
total = len(keys)
|
||||
end = min(total, offset + limit)
|
||||
items = [{"idx": i, "key": keys[i]} for i in range(offset, end)]
|
||||
return {"count": total, "items": items, "hasMore": end < total, "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
def _is_allowed_sns_media_host(host: str) -> bool:
|
||||
return _sns_media.is_allowed_sns_media_host(host)
|
||||
|
||||
@@ -2902,10 +2368,7 @@ async def _try_fetch_and_decrypt_sns_remote(
|
||||
token: str,
|
||||
use_cache: bool,
|
||||
) -> Optional[Response]:
|
||||
"""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.
|
||||
"""
|
||||
"""Try remote download+decrypt first (accurate when keys are present)."""
|
||||
res = await _sns_media.try_fetch_and_decrypt_sns_image_remote(
|
||||
account_dir=account_dir,
|
||||
url=str(url or ""),
|
||||
@@ -2918,34 +2381,18 @@ async def _try_fetch_and_decrypt_sns_remote(
|
||||
|
||||
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"] = str(res.source or "remote")
|
||||
if res.x_enc:
|
||||
resp.headers["X-SNS-X-Enc"] = str(res.x_enc)
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/api/sns/media", summary="获取朋友圈图片(下载解密优先)")
|
||||
async def get_sns_media(
|
||||
account: Optional[str] = None,
|
||||
create_time: int = 0,
|
||||
width: int = 0,
|
||||
height: int = 0,
|
||||
total_size: int = 0,
|
||||
idx: int = 0,
|
||||
avoid_picked: int = 0,
|
||||
post_id: Optional[str] = None,
|
||||
media_id: Optional[str] = None,
|
||||
post_type: int = 1,
|
||||
media_type: int = 2,
|
||||
pick: Optional[str] = None,
|
||||
md5: Optional[str] = None,
|
||||
token: Optional[str] = None,
|
||||
key: Optional[str] = None,
|
||||
use_cache: int = 1,
|
||||
url: Optional[str] = None,
|
||||
):
|
||||
account_dir = _resolve_account_dir(account)
|
||||
wxid_dir = _resolve_account_wxid_dir(account_dir)
|
||||
|
||||
try:
|
||||
use_cache_flag = bool(int(use_cache or 1))
|
||||
@@ -2963,179 +2410,7 @@ async def get_sns_media(
|
||||
if remote_resp is not None:
|
||||
return remote_resp
|
||||
|
||||
# Cache disabled: do not fall back to local cache heuristics.
|
||||
if not use_cache_flag:
|
||||
raise HTTPException(status_code=404, detail="SNS media not found (cache disabled).")
|
||||
|
||||
if wxid_dir and post_id and media_id:
|
||||
if int(post_type) == 7:
|
||||
raw_key = f"{post_id}_{media_id}_4" # 硬编码
|
||||
|
||||
md5_str = hashlib.md5(raw_key.encode("utf-8")).hexdigest()
|
||||
bkg_path = wxid_dir / "business" / "sns" / "bkg" / md5_str[:2] / md5_str
|
||||
|
||||
if bkg_path.exists() and bkg_path.is_file():
|
||||
print(f"===== Hit Bkg Cover ======= {bkg_path}")
|
||||
|
||||
return FileResponse(bkg_path, media_type="image/jpeg",
|
||||
headers={"Cache-Control": "public, max-age=31536000", "X-SNS-Source": "bkg-cover"})
|
||||
exact_match_path = None
|
||||
hit_type = ""
|
||||
|
||||
# 尝试 1: 使用 post_type 计算 MD5
|
||||
key_post = _generate_sns_cache_key(post_id, media_id, post_type)
|
||||
exact_match_path = _resolve_sns_cached_image_path_by_cache_key(
|
||||
wxid_dir=wxid_dir,
|
||||
cache_key=key_post,
|
||||
create_time=0
|
||||
)
|
||||
if exact_match_path:
|
||||
hit_type = "post_type"
|
||||
|
||||
# 尝试 2: 如果没找到,并且 media_type 和 post_type 不一样,再试一次
|
||||
if not exact_match_path and post_type != media_type:
|
||||
key_media = _generate_sns_cache_key(post_id, media_id, media_type)
|
||||
exact_match_path = _resolve_sns_cached_image_path_by_cache_key(
|
||||
wxid_dir=wxid_dir,
|
||||
cache_key=key_media,
|
||||
create_time=0
|
||||
)
|
||||
if exact_match_path:
|
||||
hit_type = "media_type"
|
||||
|
||||
# 如果通过这两种精确定位找到了文件,直接返回
|
||||
if exact_match_path:
|
||||
print(f"=====exact_match_path======={exact_match_path}============= (Hit: {hit_type})")
|
||||
try:
|
||||
payload, mtype = _read_and_maybe_decrypt_media(Path(exact_match_path), account_dir)
|
||||
if payload and str(mtype or "").startswith("image/"):
|
||||
resp = Response(content=payload, media_type=str(mtype or "image/jpeg"))
|
||||
resp.headers["Cache-Control"] = "public, max-age=31536000"
|
||||
resp.headers["X-SNS-Source"] = "deterministic-hash"
|
||||
# 在 Header 里塞入到底是哪个 type 命中的,方便 F12 调试
|
||||
resp.headers["X-SNS-Hit-Type"] = hit_type
|
||||
return resp
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print("no exact match path, falling back...")
|
||||
|
||||
# 0) User-picked cache key override (stable across candidate ordering).
|
||||
pick_key = _normalize_hex32(pick)
|
||||
if pick_key:
|
||||
wxid_dir = _resolve_account_wxid_dir(account_dir)
|
||||
if wxid_dir:
|
||||
local = _resolve_sns_cached_image_path_by_cache_key(
|
||||
wxid_dir=wxid_dir,
|
||||
cache_key=pick_key,
|
||||
create_time=int(create_time or 0),
|
||||
)
|
||||
if local:
|
||||
try:
|
||||
payload, media_type = _read_and_maybe_decrypt_media(Path(local), account_dir)
|
||||
if payload and str(media_type or "").startswith("image/"):
|
||||
resp = Response(content=payload, media_type=str(media_type or "image/jpeg"))
|
||||
resp.headers["Cache-Control"] = "public, max-age=86400"
|
||||
resp.headers["X-SNS-Source"] = "manual-pick"
|
||||
return resp
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Optional: avoid using a cache image that was manually pinned to another post.
|
||||
# Only applies when frontend enables this setting and the current media has no explicit `pick`.
|
||||
try:
|
||||
avoid_flag = bool(int(avoid_picked or 0))
|
||||
except Exception:
|
||||
avoid_flag = False
|
||||
cur_post_id = str(post_id or "").strip()
|
||||
reserved_other: set[str] = set()
|
||||
if avoid_flag and (not pick_key) and cur_post_id:
|
||||
picks_map = _load_sns_media_picks(account_dir)
|
||||
for mk, ck in (picks_map or {}).items():
|
||||
pid = _sns_post_id_from_media_key(mk)
|
||||
if not pid or pid == cur_post_id:
|
||||
continue
|
||||
if ck:
|
||||
reserved_other.add(str(ck))
|
||||
|
||||
# 1) Try local decrypted cache first (works for old posts where CDN URLs return placeholders).
|
||||
local = _resolve_sns_cached_image_path(
|
||||
account_dir_str=str(account_dir),
|
||||
create_time=int(create_time or 0),
|
||||
width=int(width or 0),
|
||||
height=int(height or 0),
|
||||
idx=max(0, int(idx or 0)),
|
||||
total_size=int(total_size or 0),
|
||||
)
|
||||
if local and reserved_other:
|
||||
try:
|
||||
ck0 = _sns_cache_key_from_path(Path(local))
|
||||
if ck0 and ck0 in reserved_other:
|
||||
local = None
|
||||
except Exception:
|
||||
pass
|
||||
if local:
|
||||
try:
|
||||
payload, media_type = _read_and_maybe_decrypt_media(Path(local), account_dir)
|
||||
if payload and str(media_type or "").startswith("image/"):
|
||||
resp = Response(content=payload, media_type=str(media_type or "image/jpeg"))
|
||||
resp.headers["Cache-Control"] = "public, max-age=86400"
|
||||
resp.headers["X-SNS-Source"] = "local-heuristic"
|
||||
return resp
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 1.5) If enabled, and the default match was skipped (or not found), pick the next candidate
|
||||
# that is not reserved by a manual pick on another post.
|
||||
if reserved_other and int(create_time or 0) > 0 and int(width or 0) > 0 and int(height or 0) > 0:
|
||||
wxid_dir = _resolve_account_wxid_dir(account_dir)
|
||||
if wxid_dir:
|
||||
keys = _list_sns_cached_image_candidate_keys(
|
||||
account_dir_str=str(account_dir),
|
||||
create_time=int(create_time or 0),
|
||||
width=int(width or 0),
|
||||
height=int(height or 0),
|
||||
)
|
||||
base_idx = max(0, int(idx or 0))
|
||||
for ck in keys[base_idx:]:
|
||||
if not ck or ck in reserved_other:
|
||||
continue
|
||||
local2 = _resolve_sns_cached_image_path_by_cache_key(
|
||||
wxid_dir=wxid_dir,
|
||||
cache_key=str(ck),
|
||||
create_time=int(create_time or 0),
|
||||
)
|
||||
if not local2:
|
||||
continue
|
||||
try:
|
||||
payload, media_type = _read_and_maybe_decrypt_media(Path(local2), account_dir)
|
||||
if payload and str(media_type or "").startswith("image/"):
|
||||
resp = Response(content=payload, media_type=str(media_type or "image/jpeg"))
|
||||
resp.headers["Cache-Control"] = "public, max-age=86400"
|
||||
resp.headers["X-SNS-Source"] = "local-heuristic-next"
|
||||
return resp
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# 2) Fallback to the remote URL (may still return a Tencent placeholder image).
|
||||
u = str(url or "").strip()
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="SNS media not found.")
|
||||
|
||||
# Delay-import to avoid pulling requests machinery during normal timeline listing.
|
||||
from .chat_media import proxy_image # pylint: disable=import-outside-toplevel
|
||||
|
||||
try:
|
||||
resp0 = await proxy_image(u)
|
||||
try:
|
||||
resp0.headers["X-SNS-Source"] = "proxy"
|
||||
except Exception:
|
||||
pass
|
||||
return resp0
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"Fetch sns media failed: {e}")
|
||||
raise HTTPException(status_code=404, detail="SNS media not found.")
|
||||
|
||||
|
||||
@router.get("/api/sns/article_thumb", summary="提取公众号文章封面图")
|
||||
@@ -3197,8 +2472,7 @@ async def get_sns_video_remote(
|
||||
if path is None:
|
||||
raise HTTPException(status_code=404, detail="SNS remote video not found.")
|
||||
|
||||
headers = {"X-SNS-Source": "remote-video-cache" if use_cache_flag else "remote-video"}
|
||||
headers["Cache-Control"] = "public, max-age=86400" if use_cache_flag else "no-store"
|
||||
headers = {"Cache-Control": "public, max-age=86400" if use_cache_flag else "no-store"}
|
||||
|
||||
if use_cache_flag:
|
||||
return FileResponse(str(path), media_type="video/mp4", headers=headers)
|
||||
|
||||
@@ -13,23 +13,26 @@ from ..sns_export_service import SNS_EXPORT_MANAGER
|
||||
router = APIRouter(route_class=PathFixRoute)
|
||||
|
||||
ExportScope = Literal["selected", "all"]
|
||||
ExportFormat = Literal["html", "json", "txt"]
|
||||
|
||||
|
||||
class SnsExportCreateRequest(BaseModel):
|
||||
account: Optional[str] = Field(None, description="账号目录名(可选,默认使用第一个)")
|
||||
scope: ExportScope = Field("selected", description="导出范围:selected=指定联系人;all=全部联系人")
|
||||
usernames: list[str] = Field(default_factory=list, description="朋友圈 username 列表(scope=selected 时使用)")
|
||||
format: ExportFormat = Field("html", description="导出格式:html/json/txt")
|
||||
use_cache: bool = Field(True, description="是否复用导出过程中的本地缓存(默认开启)")
|
||||
output_dir: Optional[str] = Field(None, description="导出目录绝对路径(可选;不填时使用默认目录)")
|
||||
file_name: Optional[str] = Field(None, description="导出 zip 文件名(可选,不含/含 .zip 都可)")
|
||||
|
||||
|
||||
@router.post("/api/sns/exports", summary="创建朋友圈导出任务(离线 HTML zip)")
|
||||
@router.post("/api/sns/exports", summary="创建朋友圈导出任务(离线 ZIP,支持 HTML/JSON/TXT)")
|
||||
async def create_sns_export(req: SnsExportCreateRequest):
|
||||
job = SNS_EXPORT_MANAGER.create_job(
|
||||
account=req.account,
|
||||
scope=req.scope,
|
||||
usernames=req.usernames,
|
||||
export_format=req.format,
|
||||
use_cache=bool(req.use_cache),
|
||||
output_dir=req.output_dir,
|
||||
file_name=req.file_name,
|
||||
@@ -111,4 +114,3 @@ async def cancel_sns_export(export_id: str):
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="Export not found.")
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,8 +8,8 @@ so it can be reused by:
|
||||
- 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 images: match WeFlow's Electron implementation by generating the WxIsaac64
|
||||
keystream from WASM and XORing the full payload in-memory.
|
||||
- SNS videos: encrypted only for the first 128KB; decrypt via WeFlow's WxIsaac64 (WASM keystream)
|
||||
and XOR in-place.
|
||||
"""
|
||||
@@ -31,9 +31,11 @@ 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__)
|
||||
_PACKAGE_DIR = Path(__file__).resolve().parent
|
||||
_NATIVE_DIR = _PACKAGE_DIR / "native"
|
||||
_WEFLOW_WASM_DIR = _NATIVE_DIR / "weflow_wasm"
|
||||
|
||||
|
||||
def is_allowed_sns_media_host(host: str) -> bool:
|
||||
@@ -96,11 +98,16 @@ def _detect_mp4_ftyp(head: bytes) -> bool:
|
||||
|
||||
@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)
|
||||
"""Locate the bundled Node helper that wraps the vendored wasm_video_decode.* assets."""
|
||||
bundled = _WEFLOW_WASM_DIR / "weflow_wasm_keystream.js"
|
||||
if bundled.exists() and bundled.is_file():
|
||||
return str(bundled)
|
||||
|
||||
# Development fallback: allow the repo-level helper to proxy into the vendored assets.
|
||||
repo_root = _PACKAGE_DIR.parents[1]
|
||||
legacy = repo_root / "tools" / "weflow_wasm_keystream.js"
|
||||
if legacy.exists() and legacy.is_file():
|
||||
return str(legacy)
|
||||
return ""
|
||||
|
||||
|
||||
@@ -416,6 +423,24 @@ def detect_image_mime(data: bytes) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def weflow_decrypt_sns_image_bytes(payload: bytes, key: str) -> bytes:
|
||||
"""Decrypt a Moments image with the same full-file XOR flow that WeFlow uses."""
|
||||
raw = bytes(payload or b"")
|
||||
key_text = str(key or "").strip()
|
||||
if not raw or not key_text:
|
||||
return raw
|
||||
|
||||
ks = weflow_wxisaac64_keystream(key_text, len(raw))
|
||||
if not ks:
|
||||
return raw
|
||||
|
||||
out = bytearray(raw)
|
||||
n = min(len(out), len(ks))
|
||||
for i in range(n):
|
||||
out[i] ^= ks[i]
|
||||
return bytes(out)
|
||||
|
||||
|
||||
_SNS_REMOTE_CACHE_EXTS = [
|
||||
".jpg",
|
||||
".jpeg",
|
||||
@@ -558,7 +583,7 @@ async def try_fetch_and_decrypt_sns_image_remote(
|
||||
token: str,
|
||||
use_cache: bool,
|
||||
) -> Optional[SnsRemoteImageResult]:
|
||||
"""Try WeFlow-style: download from CDN -> decrypt via wcdb_decrypt_sns_image -> return bytes.
|
||||
"""Try WeFlow-style: download from CDN -> WxIsaac64 full-file XOR -> return bytes.
|
||||
|
||||
Returns a SnsRemoteImageResult on success, or None on failure so caller can fall back to
|
||||
local cache matching logic.
|
||||
@@ -652,7 +677,7 @@ async def try_fetch_and_decrypt_sns_image_remote(
|
||||
|
||||
if need_decrypt:
|
||||
try:
|
||||
decoded2 = _wcdb_decrypt_sns_image(raw, k)
|
||||
decoded2 = weflow_decrypt_sns_image_bytes(raw, k)
|
||||
mt2 = detect_image_mime(decoded2)
|
||||
if mt2:
|
||||
decoded = decoded2
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import re
|
||||
from collections.abc import MutableMapping
|
||||
|
||||
|
||||
def add_sns_stage_timing_headers(
|
||||
headers: MutableMapping[str, str],
|
||||
*,
|
||||
source: str,
|
||||
hit_type: str = "",
|
||||
x_enc: str = "",
|
||||
) -> None:
|
||||
"""Inject `Server-Timing` + `Timing-Allow-Origin` for SNS media stage inspection.
|
||||
|
||||
The frontend can't read `<img>` response headers, but browsers expose `Server-Timing` metrics
|
||||
via `performance.getEntriesByName(...).serverTiming` when `Timing-Allow-Origin` allows it.
|
||||
|
||||
This helper is intentionally side-effect free beyond mutating `headers`.
|
||||
"""
|
||||
|
||||
src = str(source or "").strip()
|
||||
if not src:
|
||||
return
|
||||
|
||||
ht = str(hit_type or "").strip()
|
||||
xe = str(x_enc or "").strip()
|
||||
|
||||
if "Timing-Allow-Origin" not in headers:
|
||||
headers["Timing-Allow-Origin"] = "*"
|
||||
|
||||
def _esc(v: str) -> str:
|
||||
return v.replace("\\", "\\\\").replace('"', '\\"')
|
||||
|
||||
def _token(v: str) -> str:
|
||||
raw = str(v or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
raw = raw.replace(" ", "_")
|
||||
safe = re.sub(r"[^0-9A-Za-z_.-]+", "_", raw).strip("_")
|
||||
if not safe:
|
||||
return ""
|
||||
return safe[:64]
|
||||
|
||||
parts: list[str] = []
|
||||
src_tok = _token(src) or "unknown"
|
||||
parts.append(f'sns_source_{src_tok};dur=0;desc="{_esc(src)}"')
|
||||
if ht:
|
||||
ht_tok = _token(ht)
|
||||
if ht_tok:
|
||||
parts.append(f'sns_hit_{ht_tok};dur=0;desc="{_esc(ht)}"')
|
||||
if xe:
|
||||
xe_tok = _token(xe)
|
||||
if xe_tok:
|
||||
parts.append(f'sns_xenc_{xe_tok};dur=0;desc="{_esc(xe)}"')
|
||||
|
||||
existing = str(headers.get("Server-Timing") or "").strip()
|
||||
# Some responses may already have upstream `Server-Timing` metrics. Always append ours so
|
||||
# the frontend can consistently read `sns_source_*` via ResourceTiming.serverTiming.
|
||||
if existing and re.search(r"(^|,\\s*)sns_source(_|;)", existing):
|
||||
return
|
||||
|
||||
combined = ", ".join(parts)
|
||||
headers["Server-Timing"] = f"{existing}, {combined}" if existing else combined
|
||||
|
||||
+16
-3
@@ -15,6 +15,20 @@ from wechat_decrypt_tool import sns_media # noqa: E402 pylint: disable=wrong-i
|
||||
|
||||
|
||||
class TestSnsMedia(unittest.TestCase):
|
||||
def test_weflow_wxisaac64_script_path_uses_bundled_helper(self):
|
||||
sns_media._weflow_wxisaac64_script_path.cache_clear()
|
||||
script = sns_media._weflow_wxisaac64_script_path()
|
||||
self.assertTrue(script)
|
||||
|
||||
script_path = Path(script)
|
||||
normalized = script.replace("\\", "/")
|
||||
self.assertTrue(script_path.exists())
|
||||
self.assertEqual(script_path.name, "weflow_wasm_keystream.js")
|
||||
self.assertIn("/src/wechat_decrypt_tool/native/weflow_wasm/", normalized)
|
||||
self.assertNotIn("/WeFlow/", normalized)
|
||||
self.assertTrue((script_path.parent / "wasm_video_decode.js").exists())
|
||||
self.assertTrue((script_path.parent / "wasm_video_decode.wasm").exists())
|
||||
|
||||
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)
|
||||
@@ -131,7 +145,7 @@ class TestSnsMedia(unittest.TestCase):
|
||||
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):
|
||||
with mock.patch("wechat_decrypt_tool.sns_media.weflow_decrypt_sns_image_bytes", return_value=decoded):
|
||||
res = asyncio.run(
|
||||
sns_media.try_fetch_and_decrypt_sns_image_remote(
|
||||
account_dir=account_dir,
|
||||
@@ -161,7 +175,7 @@ class TestSnsMedia(unittest.TestCase):
|
||||
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):
|
||||
with mock.patch("wechat_decrypt_tool.sns_media.weflow_decrypt_sns_image_bytes", return_value=decoded_bad):
|
||||
res = asyncio.run(
|
||||
sns_media.try_fetch_and_decrypt_sns_image_remote(
|
||||
account_dir=account_dir,
|
||||
@@ -177,4 +191,3 @@ class TestSnsMedia(unittest.TestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import asyncio
|
||||
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.routers import sns # noqa: E402 pylint: disable=wrong-import-position
|
||||
|
||||
|
||||
class TestSnsMediaRouteWeFlowDefault(unittest.TestCase):
|
||||
def test_route_stops_after_remote_miss_by_default(self):
|
||||
with TemporaryDirectory() as td:
|
||||
account_dir = Path(td) / "acc"
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with mock.patch("wechat_decrypt_tool.routers.sns._resolve_account_dir", return_value=account_dir):
|
||||
with mock.patch("wechat_decrypt_tool.routers.sns._try_fetch_and_decrypt_sns_remote", return_value=None):
|
||||
with self.assertRaises(sns.HTTPException) as ctx:
|
||||
asyncio.run(
|
||||
sns.get_sns_media(
|
||||
account="acc",
|
||||
url="https://mmsns.qpic.cn/sns/test/0",
|
||||
key="123",
|
||||
token="tkn",
|
||||
use_cache=1,
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(ctx.exception.status_code, 404)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,40 +0,0 @@
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from starlette.responses import Response
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
from wechat_decrypt_tool.sns_stage_timing import add_sns_stage_timing_headers # noqa: E402 pylint: disable=wrong-import-position
|
||||
|
||||
|
||||
class TestSnsStageServerTiming(unittest.TestCase):
|
||||
def test_injects_server_timing_when_missing(self):
|
||||
resp = Response(content=b"ok")
|
||||
add_sns_stage_timing_headers(resp.headers, source="proxy")
|
||||
st = str(resp.headers.get("Server-Timing") or "")
|
||||
self.assertIn("sns_source_", st)
|
||||
self.assertIn("proxy", st)
|
||||
|
||||
def test_appends_when_upstream_server_timing_exists(self):
|
||||
resp = Response(content=b"ok")
|
||||
resp.headers["Server-Timing"] = "edge;dur=1"
|
||||
add_sns_stage_timing_headers(resp.headers, source="proxy")
|
||||
st = str(resp.headers.get("Server-Timing") or "")
|
||||
self.assertIn("edge;dur=1", st)
|
||||
self.assertIn("sns_source_", st)
|
||||
|
||||
def test_does_not_duplicate_existing_sns_source_metric(self):
|
||||
resp = Response(content=b"ok")
|
||||
resp.headers["Server-Timing"] = 'sns_source_proxy;dur=0;desc="proxy"'
|
||||
add_sns_stage_timing_headers(resp.headers, source="proxy")
|
||||
st = str(resp.headers.get("Server-Timing") or "")
|
||||
self.assertEqual(st.count("sns_source_"), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,122 +1,2 @@
|
||||
// Generate WeChat/WeFlow WxIsaac64 keystream via WeFlow's WASM module.
|
||||
//
|
||||
// Usage:
|
||||
// node tools/weflow_wasm_keystream.js <key> <size>
|
||||
//
|
||||
// Prints a base64-encoded keystream to stdout (no extra logs).
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const vm = require('vm')
|
||||
|
||||
function usageAndExit() {
|
||||
process.stderr.write('Usage: node tools/weflow_wasm_keystream.js <key> <size>\\n')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
const key = String(process.argv[2] || '').trim()
|
||||
const size = Number(process.argv[3] || 0)
|
||||
|
||||
if (!key || !Number.isFinite(size) || size <= 0) usageAndExit()
|
||||
|
||||
const basePath = path.join(__dirname, '..', 'WeFlow', 'electron', 'assets', 'wasm')
|
||||
const wasmPath = path.join(basePath, 'wasm_video_decode.wasm')
|
||||
const jsPath = path.join(basePath, 'wasm_video_decode.js')
|
||||
|
||||
if (!fs.existsSync(wasmPath) || !fs.existsSync(jsPath)) {
|
||||
process.stderr.write(`WeFlow WASM assets not found: ${basePath}\\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const wasmBinary = fs.readFileSync(wasmPath)
|
||||
const jsContent = fs.readFileSync(jsPath, 'utf8')
|
||||
|
||||
let capturedKeystream = null
|
||||
let resolveInit
|
||||
let rejectInit
|
||||
const initPromise = new Promise((res, rej) => {
|
||||
resolveInit = res
|
||||
rejectInit = rej
|
||||
})
|
||||
|
||||
const mockGlobal = {
|
||||
console: { log: () => {}, error: () => {} }, // keep stdout clean
|
||||
Buffer,
|
||||
Uint8Array,
|
||||
Int8Array,
|
||||
Uint16Array,
|
||||
Int16Array,
|
||||
Uint32Array,
|
||||
Int32Array,
|
||||
Float32Array,
|
||||
Float64Array,
|
||||
BigInt64Array,
|
||||
BigUint64Array,
|
||||
Array,
|
||||
Object,
|
||||
Function,
|
||||
String,
|
||||
Number,
|
||||
Boolean,
|
||||
Error,
|
||||
Promise,
|
||||
require,
|
||||
process,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval,
|
||||
clearInterval,
|
||||
}
|
||||
|
||||
mockGlobal.Module = {
|
||||
onRuntimeInitialized: () => resolveInit(),
|
||||
wasmBinary,
|
||||
print: () => {},
|
||||
printErr: () => {},
|
||||
}
|
||||
|
||||
mockGlobal.self = mockGlobal
|
||||
mockGlobal.self.location = { href: jsPath }
|
||||
mockGlobal.WorkerGlobalScope = function () {}
|
||||
mockGlobal.VTS_WASM_URL = `file://${wasmPath}`
|
||||
|
||||
mockGlobal.wasm_isaac_generate = (ptr, n) => {
|
||||
const buf = new Uint8Array(mockGlobal.Module.HEAPU8.buffer, ptr, n)
|
||||
capturedKeystream = new Uint8Array(buf) // copy view
|
||||
}
|
||||
|
||||
try {
|
||||
const context = vm.createContext(mockGlobal)
|
||||
new vm.Script(jsContent, { filename: jsPath }).runInContext(context)
|
||||
} catch (e) {
|
||||
rejectInit(e)
|
||||
}
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
await initPromise
|
||||
|
||||
if (!mockGlobal.Module.WxIsaac64 && mockGlobal.Module.asm && mockGlobal.Module.asm.WxIsaac64) {
|
||||
mockGlobal.Module.WxIsaac64 = mockGlobal.Module.asm.WxIsaac64
|
||||
}
|
||||
if (!mockGlobal.Module.WxIsaac64) {
|
||||
throw new Error('WxIsaac64 not found in WASM module')
|
||||
}
|
||||
|
||||
capturedKeystream = null
|
||||
const isaac = new mockGlobal.Module.WxIsaac64(key)
|
||||
isaac.generate(size)
|
||||
if (isaac.delete) isaac.delete()
|
||||
|
||||
if (!capturedKeystream) throw new Error('Failed to capture keystream')
|
||||
|
||||
const out = Buffer.from(capturedKeystream)
|
||||
// Match WeFlow worker logic: reverse the captured Uint8Array.
|
||||
out.reverse()
|
||||
process.stdout.write(out.toString('base64'))
|
||||
} catch (e) {
|
||||
process.stderr.write(String(e && e.stack ? e.stack : e) + '\\n')
|
||||
process.exit(1)
|
||||
}
|
||||
})()
|
||||
|
||||
require(path.join(__dirname, '..', 'src', 'wechat_decrypt_tool', 'native', 'weflow_wasm', 'weflow_wasm_keystream.js'))
|
||||
|
||||
Reference in New Issue
Block a user