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 @@
修改后会迁移整个 output 目录;如果目标目录已有内容,会先阻止并提示。
+
+
+
{{ desktopOutputDirProgressText }}
+
{{ desktopOutputDirProgressPercentText }}
+
+
+
+
+
+ {{ desktopOutputDirProgressDetail }} +
+
{{ desktopOutputDirMessage }}
@@ -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 + } }) diff --git a/pyproject.toml b/pyproject.toml index 15c4fde..6cbdc30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/wechat_decrypt_tool/__init__.py b/src/wechat_decrypt_tool/__init__.py index eb13771..c644ea6 100644 --- a/src/wechat_decrypt_tool/__init__.py +++ b/src/wechat_decrypt_tool/__init__.py @@ -1,5 +1,5 @@ """微信数据库解密工具 """ -__version__ = "1.3.0" +__version__ = "1.7.10" __author__ = "WeChat Decrypt Tool" diff --git a/uv.lock b/uv.lock index 5787421..33e8aa1 100644 --- a/uv.lock +++ b/uv.lock @@ -872,7 +872,7 @@ wheels = [ [[package]] name = "wechat-decrypt-tool" -version = "1.3.0" +version = "1.7.10" source = { editable = "." } dependencies = [ { name = "aiofiles" },