Compare commits

..

1 Commits

11 changed files with 707 additions and 44 deletions
+2 -2
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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();
}
});
+74
View File
@@ -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
View File
@@ -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,
+6
View File
@@ -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"),
+35 -10
View File
@@ -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);
}
});
+104 -1
View File
@@ -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>
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -1,5 +1,5 @@
"""微信数据库解密工具
"""
__version__ = "1.3.0"
__version__ = "1.7.10"
__author__ = "WeChat Decrypt Tool"
Generated
+1 -1
View File
@@ -872,7 +872,7 @@ wheels = [
[[package]]
name = "wechat-decrypt-tool"
version = "1.3.0"
version = "1.7.10"
source = { editable = "." }
dependencies = [
{ name = "aiofiles" },