From e0e50143c9f1c2b992b8bb7c151a66c874beb8e8 Mon Sep 17 00:00:00 2001 From: 2977094657 <2977094657@qq.com> Date: Thu, 16 Apr 2026 21:19:04 +0800 Subject: [PATCH] =?UTF-8?q?improvement(desktop-output):=20=E8=A1=A5?= =?UTF-8?q?=E5=85=85=20output=20=E7=9B=AE=E5=BD=95=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6=E5=8F=8D=E9=A6=88=E5=B9=B6=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 output 目录迁移改为 worker 异步执行,并补充扫描、复制、校验、回滚阶段进度 - 设置页增加迁移进度展示,支持恢复进行中的状态并展示当前文件/传输体积 - 迁移完成后自动清理旧备份目录,并同步源码/桌面端版本号到 v1.7.10 --- desktop/package-lock.json | 4 +- desktop/package.json | 2 +- desktop/src/main.cjs | 220 +++++++++++++++++-- desktop/src/output-dir-worker.cjs | 74 +++++++ desktop/src/output-dir.cjs | 289 ++++++++++++++++++++++++- desktop/src/preload.cjs | 6 + desktop/tests/output-dir.test.cjs | 45 +++- frontend/components/SettingsDialog.vue | 105 ++++++++- pyproject.toml | 2 +- src/wechat_decrypt_tool/__init__.py | 2 +- uv.lock | 2 +- 11 files changed, 707 insertions(+), 44 deletions(-) create mode 100644 desktop/src/output-dir-worker.cjs diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 11a8eb8..7da546f 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -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" }, diff --git a/desktop/package.json b/desktop/package.json index 5c8d9aa..6cb8e73 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -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", diff --git a/desktop/src/main.cjs b/desktop/src/main.cjs index ddea1c6..eb51269 100644 --- a/desktop/src/main.cjs +++ b/desktop/src/main.cjs @@ -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(); } }); diff --git a/desktop/src/output-dir-worker.cjs b/desktop/src/output-dir-worker.cjs new file mode 100644 index 0000000..4769ead --- /dev/null +++ b/desktop/src/output-dir-worker.cjs @@ -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; +}); diff --git a/desktop/src/output-dir.cjs b/desktop/src/output-dir.cjs index fe2881c..ffbbdbf 100644 --- a/desktop/src/output-dir.cjs +++ b/desktop/src/output-dir.cjs @@ -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, diff --git a/desktop/src/preload.cjs b/desktop/src/preload.cjs index 418f08a..d3cb760 100644 --- a/desktop/src/preload.cjs +++ b/desktop/src/preload.cjs @@ -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"), diff --git a/desktop/tests/output-dir.test.cjs b/desktop/tests/output-dir.test.cjs index 3b7b908..dc72cca 100644 --- a/desktop/tests/output-dir.test.cjs +++ b/desktop/tests/output-dir.test.cjs @@ -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); + } +}); diff --git a/frontend/components/SettingsDialog.vue b/frontend/components/SettingsDialog.vue index 7053892..610fe82 100644 --- a/frontend/components/SettingsDialog.vue +++ b/frontend/components/SettingsDialog.vue @@ -214,6 +214,22 @@