feat(desktop): 支持自定义 output 目录并迁移现有数据

- 支持在桌面端查看、选择和恢复默认 output 目录
- 安装器记录待应用目录,并在应用启动时自动迁移数据
- 后端支持 output 目录覆盖,补充桌面端与后端相关测试
This commit is contained in:
2977094657
2026-03-30 22:01:51 +08:00
Unverified
parent 673d1f916a
commit 697e788318
8 changed files with 1146 additions and 51 deletions
+90 -11
View File
@@ -1,6 +1,22 @@
; This file is included for both installer and uninstaller builds.
; Guard installer-only pages/functions to avoid "function not referenced" warnings
; when electron-builder compiles the standalone uninstaller.
!define /ifndef WDA_DEFAULT_SETTINGS_PATH "$APPDATA\${APP_FILENAME}\desktop-settings.json"
!define /ifndef WDA_DEFAULT_OUTPUT_DIR "$APPDATA\${APP_FILENAME}\output"
!ifdef APP_PRODUCT_FILENAME
!define /ifndef WDA_PRODUCT_SETTINGS_PATH "$APPDATA\${APP_PRODUCT_FILENAME}\desktop-settings.json"
!define /ifndef WDA_PRODUCT_OUTPUT_DIR "$APPDATA\${APP_PRODUCT_FILENAME}\output"
!else
!define /ifndef WDA_PRODUCT_SETTINGS_PATH ""
!define /ifndef WDA_PRODUCT_OUTPUT_DIR ""
!endif
!ifdef APP_PACKAGE_NAME
!define /ifndef WDA_PACKAGE_SETTINGS_PATH "$APPDATA\${APP_PACKAGE_NAME}\desktop-settings.json"
!define /ifndef WDA_PACKAGE_OUTPUT_DIR "$APPDATA\${APP_PACKAGE_NAME}\output"
!else
!define /ifndef WDA_PACKAGE_SETTINGS_PATH ""
!define /ifndef WDA_PACKAGE_OUTPUT_DIR ""
!endif
!ifndef BUILD_UNINSTALLER
!include nsDialogs.nsh
!include LogicLib.nsh
@@ -13,6 +29,10 @@
!define /ifndef MUI_DIRECTORYPAGE_TEXT_DESTINATION "安装位置:"
Var WDA_InstallDirPage
Var WDA_OutputDirPage
Var WDA_OutputDirInput
Var WDA_OutputDirBrowseButton
Var WDA_SelectedOutputDir
!macro customInit
; Safety: older versions created an `output` junction inside the install directory that points to the
@@ -22,17 +42,10 @@ Var WDA_InstallDirPage
!macroend
!macro customInstall
; Provide a safe, non-junction way for users to locate the real per-user output directory.
; The actual data is NOT stored inside $INSTDIR (it is wiped on update/reinstall).
; `open-output.cmd` uses %APPDATA% so it works for the current user.
FileOpen $0 "$INSTDIR\output-location.txt" w
FileWrite $0 "WeChatDataAnalysis output folder (per user):$\r$\n%APPDATA%\\${APP_PACKAGE_NAME}\\output$\r$\n"
FileClose $0
FileOpen $1 "$INSTDIR\open-output.cmd" w
; NSIS escaping: use $\" to output a literal quote character into the .cmd file.
FileWrite $1 "@echo off$\r$\nexplorer $\"%APPDATA%\\${APP_PACKAGE_NAME}\\output$\"$\r$\n"
FileClose $1
${If} $WDA_SelectedOutputDir == ""
Call WDA_InitOutputDirSelection
${EndIf}
Call WDA_WritePendingOutputDirSetting
!macroend
Function WDA_RemoveLegacyOutputLink
@@ -47,9 +60,27 @@ FunctionEnd
; the final install location (includes the app sub-folder).
!ifdef allowToChangeInstallationDirectory
Page custom WDA_InstallDirPageCreate WDA_InstallDirPageLeave
Page custom WDA_OutputDirPageCreate WDA_OutputDirPageLeave
!endif
!macroend
Function WDA_InitOutputDirSelection
StrCpy $WDA_SelectedOutputDir "${WDA_DEFAULT_OUTPUT_DIR}"
nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -Command "& { param([string] $$defaultSettingsPath, [string] $$defaultOutputPath, [string] $$legacySettingsPath1, [string] $$legacySettingsPath2) $$candidates = @($$defaultSettingsPath, $$legacySettingsPath1, $$legacySettingsPath2) | Where-Object { -not [string]::IsNullOrWhiteSpace($$_) } | Select-Object -Unique; $$settingsPath = $$defaultSettingsPath; foreach ($$candidate in $$candidates) { if (Test-Path -LiteralPath $$candidate) { $$settingsPath = $$candidate; break } }; $$result = $$defaultOutputPath; if (Test-Path -LiteralPath $$settingsPath) { try { $$json = Get-Content -LiteralPath $$settingsPath -Raw | ConvertFrom-Json; $$value = [string] $$json.pendingOutputDir; if ([string]::IsNullOrWhiteSpace($$value)) { $$value = [string] $$json.outputDir }; if ($$value -eq '''') { $$result = $$defaultOutputPath } elseif (-not [string]::IsNullOrWhiteSpace($$value)) { $$result = $$value } } catch {} }; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::Write($$result) }" "${WDA_DEFAULT_SETTINGS_PATH}" "${WDA_DEFAULT_OUTPUT_DIR}" "${WDA_PRODUCT_SETTINGS_PATH}" "${WDA_PACKAGE_SETTINGS_PATH}"'
Pop $0
Pop $1
${If} $0 == "0"
${AndIf} $1 != ""
StrCpy $WDA_SelectedOutputDir "$1"
${EndIf}
FunctionEnd
Function WDA_WritePendingOutputDirSetting
nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -Command "& { param([string] $$defaultSettingsPath, [string] $$defaultOutputPath, [string] $$selectedOutputPath, [string] $$legacySettingsPath1, [string] $$legacySettingsPath2) $$candidates = @($$defaultSettingsPath, $$legacySettingsPath1, $$legacySettingsPath2) | Where-Object { -not [string]::IsNullOrWhiteSpace($$_) } | Select-Object -Unique; $$sourceSettingsPath = $$defaultSettingsPath; foreach ($$candidate in $$candidates) { if (Test-Path -LiteralPath $$candidate) { $$sourceSettingsPath = $$candidate; break } }; if ([string]::IsNullOrWhiteSpace($$selectedOutputPath)) { $$selectedOutputPath = $$defaultOutputPath }; $$pending = if ([string]::Equals($$selectedOutputPath, $$defaultOutputPath, [System.StringComparison]::OrdinalIgnoreCase)) { '''' } else { $$selectedOutputPath }; $$obj = @{}; if (Test-Path -LiteralPath $$sourceSettingsPath) { try { $$existing = Get-Content -LiteralPath $$sourceSettingsPath -Raw | ConvertFrom-Json; if ($$null -ne $$existing) { $$existing.PSObject.Properties | ForEach-Object { $$obj[$$_.Name] = $$_.Value } } } catch {} }; $$obj[''pendingOutputDir''] = $$pending; $$dir = Split-Path -Parent $$defaultSettingsPath; New-Item -ItemType Directory -Force -Path $$dir | Out-Null; $$json = [PSCustomObject] $$obj | ConvertTo-Json -Depth 10; Set-Content -LiteralPath $$defaultSettingsPath -Value $$json -Encoding UTF8 }" "${WDA_DEFAULT_SETTINGS_PATH}" "${WDA_DEFAULT_OUTPUT_DIR}" "$WDA_SelectedOutputDir" "${WDA_PRODUCT_SETTINGS_PATH}" "${WDA_PACKAGE_SETTINGS_PATH}"'
Pop $0
Pop $1
FunctionEnd
Function WDA_EnsureAppSubDir
; Normalize $INSTDIR to always end with "\${APP_FILENAME}" (avoid cluttering a parent folder).
StrCpy $0 "$INSTDIR"
@@ -105,6 +136,48 @@ FunctionEnd
Function WDA_InstallDirPageLeave
FunctionEnd
Function WDA_OutputDirBrowse
nsDialogs::SelectFolderDialog "选择 output 目录" "$WDA_SelectedOutputDir"
Pop $0
${If} $0 != error
StrCpy $WDA_SelectedOutputDir "$0"
${NSD_SetText} $WDA_OutputDirInput "$0"
${EndIf}
FunctionEnd
Function WDA_OutputDirPageCreate
Call WDA_InitOutputDirSelection
nsDialogs::Create 1018
Pop $WDA_OutputDirPage
${If} $WDA_OutputDirPage == error
Abort
${EndIf}
${NSD_CreateLabel} 0u 0u 100% 24u "请选择 output 目录(保存解密数据库、导出内容、缓存、日志等)。"
Pop $0
${NSD_CreateText} 0u 28u 78% 12u "$WDA_SelectedOutputDir"
Pop $WDA_OutputDirInput
${NSD_CreateButton} 82% 27u 18% 14u "浏览..."
Pop $WDA_OutputDirBrowseButton
${NSD_OnClick} $WDA_OutputDirBrowseButton WDA_OutputDirBrowse
${NSD_CreateLabel} 0u 52u 100% 28u "安装器只记录你的选择;真正的数据迁移会在首次启动应用时执行。若目标目录已有内容,应用会阻止切换并提示处理。"
Pop $0
nsDialogs::Show
FunctionEnd
Function WDA_OutputDirPageLeave
${NSD_GetText} $WDA_OutputDirInput $WDA_SelectedOutputDir
${If} $WDA_SelectedOutputDir == ""
StrCpy $WDA_SelectedOutputDir "${WDA_DEFAULT_OUTPUT_DIR}"
${EndIf}
FunctionEnd
!endif
!ifdef BUILD_UNINSTALLER
@@ -177,6 +250,12 @@ FunctionEnd
RMDir /r "$APPDATA\${APP_PACKAGE_NAME}"
!endif
IfFileExists "$INSTDIR\output-location.path" 0 WDA_SkipCustomOutputDelete
nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -Command "& { param([string] $$pathFile, [string] $$defaultPath1, [string] $$defaultPath2, [string] $$defaultPath3) if (Test-Path -LiteralPath $$pathFile) { $$target = (Get-Content -LiteralPath $$pathFile -Raw).Trim(); $$defaults = @($$defaultPath1, $$defaultPath2, $$defaultPath3) | Where-Object { -not [string]::IsNullOrWhiteSpace($$_) }; $$isDefault = $$false; foreach ($$defaultPath in $$defaults) { if ([string]::Equals($$target, $$defaultPath, [System.StringComparison]::OrdinalIgnoreCase)) { $$isDefault = $$true; break } }; if (-not $$isDefault -and -not [string]::IsNullOrWhiteSpace($$target) -and (Test-Path -LiteralPath $$target)) { Remove-Item -LiteralPath $$target -Recurse -Force -ErrorAction SilentlyContinue } } }" "$INSTDIR\output-location.path" "${WDA_DEFAULT_OUTPUT_DIR}" "${WDA_PRODUCT_OUTPUT_DIR}" "${WDA_PACKAGE_OUTPUT_DIR}"'
Pop $0
Pop $1
WDA_SkipCustomOutputDelete:
${if} $installMode == "all"
SetShellVarContext all
${endif}
+369 -22
View File
@@ -21,6 +21,13 @@ const fs = require("fs");
const http = require("http");
const net = require("net");
const path = require("path");
const {
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";
const DEFAULT_BACKEND_PORT = parsePort(process.env.WECHAT_TOOL_PORT) ?? 10392;
@@ -32,6 +39,7 @@ let tray = null;
let isQuitting = false;
let desktopSettings = null;
let backendPortChangeInProgress = false;
let outputDirChangeInProgress = false;
const gotSingleInstanceLock = app.requestSingleInstanceLock();
if (!gotSingleInstanceLock) {
@@ -216,9 +224,76 @@ function resolveDataDir() {
}
function getUserDataDir() {
// Backwards-compat: we historically used Electron's userData directory for runtime storage.
// Keep this name but resolve to the effective data dir (can be overridden via env).
return resolveDataDir();
try {
const dir = app.getPath("userData");
if (!dir) return null;
fs.mkdirSync(dir, { recursive: true });
return dir;
} catch {
return null;
}
}
function safeNormalizeDirectory(value) {
try {
return normalizeDirectoryPath(value || "");
} catch {
return "";
}
}
function getDefaultOutputDir() {
const dataDir = resolveDataDir();
if (!dataDir) return null;
try {
return getDefaultOutputDirPath(dataDir);
} catch {
return null;
}
}
function syncOutputDirEnv(nextDir) {
const normalized = safeNormalizeDirectory(nextDir);
if (normalized) process.env.WECHAT_TOOL_OUTPUT_DIR = normalized;
else delete process.env.WECHAT_TOOL_OUTPUT_DIR;
}
function normalizePendingOutputDirValue(value) {
if (value == null) return null;
const text = String(value).trim();
if (!text) return "";
try {
return normalizeDirectoryPath(text);
} catch {
return null;
}
}
function resolveOutputDir() {
const dataDir = resolveDataDir();
if (!dataDir) return null;
const envOutputDir = safeNormalizeDirectory(process.env.WECHAT_TOOL_OUTPUT_DIR || "");
const settingsOutputDir = app.isPackaged ? safeNormalizeDirectory(loadDesktopSettings()?.outputDir || "") : "";
let chosen = null;
try {
chosen = getEffectiveOutputDirPath({
dataDir,
envOutputDir,
settingsOutputDir,
});
} catch {
chosen = getDefaultOutputDir();
}
if (!chosen) return null;
try {
fs.mkdirSync(chosen, { recursive: true });
} catch {}
syncOutputDirEnv(chosen);
return chosen;
}
function sanitizeAccountName(account) {
@@ -261,7 +336,8 @@ function resolveAccountDirInOutput(account) {
const dataDir = resolveDataDir();
if (!dataDir) throw new Error("无法定位数据目录");
const outputDir = path.join(dataDir, "output");
const outputDir = resolveOutputDir();
if (!outputDir) throw new Error("无法定位 output 目录");
const databasesDir = path.join(outputDir, "databases");
const accountName = sanitizeAccountName(account);
@@ -311,8 +387,8 @@ function getAccountInfoFromDisk(account) {
};
}
function removeAccountFromKeyStore(dataDir, accountName) {
const keyStorePath = path.join(dataDir, "output", "account_keys.json");
function removeAccountFromKeyStore(outputDir, accountName) {
const keyStorePath = path.join(outputDir, "account_keys.json");
try {
if (!fs.existsSync(keyStorePath)) return false;
const raw = fs.readFileSync(keyStorePath, { encoding: "utf8" });
@@ -328,7 +404,7 @@ function removeAccountFromKeyStore(dataDir, accountName) {
}
async function deleteAccountDataFromDisk(account) {
const { dataDir, outputDir, databasesDir, accountName, accountDir } = resolveAccountDirInOutput(account);
const { outputDir, databasesDir, accountName, accountDir } = resolveAccountDirInOutput(account);
if (!fs.existsSync(accountDir) || !fs.statSync(accountDir).isDirectory()) {
throw new Error("账号数据不存在");
}
@@ -348,7 +424,7 @@ async function deleteAccountDataFromDisk(account) {
} catch {}
fs.rmSync(accountDir, { recursive: true, force: true });
const removedKeyCache = removeAccountFromKeyStore(dataDir, accountName);
const removedKeyCache = removeAccountFromKeyStore(outputDir, accountName);
const accounts = listDecryptedAccountsOnDisk(databasesDir);
result = {
status: "success",
@@ -394,10 +470,8 @@ function ensureOutputLink() {
if (!app.isPackaged) return;
const exeDir = getExeDir();
const dataDir = resolveDataDir();
if (!exeDir || !dataDir) return;
const target = path.join(dataDir, "output");
const target = resolveOutputDir();
if (!exeDir || !target) return;
const legacyLinkPath = path.join(exeDir, "output");
// Ensure the real output dir exists.
@@ -443,6 +517,11 @@ function ensureOutputLink() {
fs.writeFileSync(p, text, { encoding: "utf8" });
} catch {}
try {
const p = path.join(exeDir, "output-location.path");
fs.writeFileSync(p, `${target}\n`, { encoding: "utf8" });
} catch {}
try {
const p = path.join(exeDir, "open-output.cmd");
const text = `@echo off\r\nexplorer \"${target}\"\r\n`;
@@ -510,6 +589,12 @@ function loadDesktopSettings() {
ignoredUpdateVersion: "",
// Backend (FastAPI) listens on this port. Used in packaged builds.
backendPort: DEFAULT_BACKEND_PORT,
// Custom output dir; empty string means use the default dataDir/output.
outputDir: "",
// Pending output dir written by the installer before the next app startup.
pendingOutputDir: null,
// Last startup/apply failure when changing output dir.
lastOutputDirError: "",
// Tracks the packaged UI build so we can invalidate Chromium's HTTP cache
// after upgrades without wiping user data/localStorage.
lastSeenUiBuildId: "",
@@ -530,6 +615,12 @@ function loadDesktopSettings() {
const parsed = JSON.parse(raw || "{}");
desktopSettings = { ...defaults, ...(parsed && typeof parsed === "object" ? parsed : {}) };
desktopSettings.backendPort = parsePort(desktopSettings.backendPort) ?? defaults.backendPort;
desktopSettings.outputDir = safeNormalizeDirectory(desktopSettings.outputDir || "");
desktopSettings.pendingOutputDir =
parsed && typeof parsed === "object" && Object.prototype.hasOwnProperty.call(parsed, "pendingOutputDir")
? normalizePendingOutputDirValue(parsed.pendingOutputDir)
: defaults.pendingOutputDir;
desktopSettings.lastOutputDirError = String(desktopSettings.lastOutputDirError || "").trim();
} catch (err) {
desktopSettings = { ...defaults };
logMain(`[main] failed to load settings: ${err?.message || err}`);
@@ -551,6 +642,82 @@ function persistDesktopSettings() {
}
}
function snapshotOutputDirSettings() {
loadDesktopSettings();
return {
outputDir: desktopSettings.outputDir,
pendingOutputDir: desktopSettings.pendingOutputDir,
lastOutputDirError: desktopSettings.lastOutputDirError,
};
}
function restoreOutputDirSettings(snapshot) {
loadDesktopSettings();
desktopSettings.outputDir = safeNormalizeDirectory(snapshot?.outputDir || "");
desktopSettings.pendingOutputDir = normalizePendingOutputDirValue(snapshot?.pendingOutputDir);
desktopSettings.lastOutputDirError = String(snapshot?.lastOutputDirError || "").trim();
const effectiveOutputDir = desktopSettings.outputDir || getDefaultOutputDir() || "";
syncOutputDirEnv(effectiveOutputDir);
persistDesktopSettings();
}
function setOutputDirSetting(nextDir) {
loadDesktopSettings();
const defaultDir = getDefaultOutputDir();
const normalized = safeNormalizeDirectory(nextDir || "");
if (!normalized || (defaultDir && normalized === defaultDir)) {
desktopSettings.outputDir = "";
} else {
desktopSettings.outputDir = normalized;
}
syncOutputDirEnv(desktopSettings.outputDir || defaultDir || "");
persistDesktopSettings();
return desktopSettings.outputDir;
}
function setPendingOutputDirSetting(nextDir) {
loadDesktopSettings();
desktopSettings.pendingOutputDir = normalizePendingOutputDirValue(nextDir);
persistDesktopSettings();
return desktopSettings.pendingOutputDir;
}
function clearPendingOutputDirSetting() {
loadDesktopSettings();
desktopSettings.pendingOutputDir = null;
persistDesktopSettings();
}
function setOutputDirLastError(message) {
loadDesktopSettings();
desktopSettings.lastOutputDirError = String(message || "").trim();
persistDesktopSettings();
return desktopSettings.lastOutputDirError;
}
function getOutputDirInfo() {
loadDesktopSettings();
const defaultPath = getDefaultOutputDir() || "";
const currentPath = resolveOutputDir() || defaultPath;
const hasPending = desktopSettings.pendingOutputDir !== null;
const pendingPath =
desktopSettings.pendingOutputDir === null
? ""
: desktopSettings.pendingOutputDir === ""
? defaultPath
: safeNormalizeDirectory(desktopSettings.pendingOutputDir);
return {
path: currentPath || "",
defaultPath,
isDefault: !!currentPath && !!defaultPath && currentPath === defaultPath,
pendingPath,
hasPending,
lastError: String(desktopSettings.lastOutputDirError || "").trim(),
canChange: !!app.isPackaged,
changeUnavailableReason: app.isPackaged ? "" : "开发模式不支持界面修改 output 目录",
};
}
function getCloseBehavior() {
const v = String(loadDesktopSettings()?.closeBehavior || "").trim().toLowerCase();
return v === "exit" ? "exit" : "tray";
@@ -576,6 +743,137 @@ function setIgnoredUpdateVersion(version) {
return desktopSettings.ignoredUpdateVersion;
}
async function applyOutputDirChange(nextValue) {
if (!app.isPackaged) {
throw new Error("开发模式不支持界面修改 output 目录");
}
const defaultPath = getDefaultOutputDir();
const currentPath = resolveOutputDir();
if (!defaultPath || !currentPath) {
throw new Error("无法定位 output 目录");
}
const rawText = String(nextValue ?? "").trim();
const nextPath = rawText ? normalizeDirectoryPath(rawText) : defaultPath;
const previousSettings = snapshotOutputDirSettings();
if (nextPath === currentPath) {
setOutputDirSetting(nextPath);
clearPendingOutputDirSetting();
setOutputDirLastError("");
ensureOutputLink();
const info = getOutputDirInfo();
return {
success: true,
changed: false,
path: info.path,
defaultPath: info.defaultPath,
isDefault: info.isDefault,
pendingPath: info.pendingPath,
backupPath: "",
sourceWasEmpty: false,
message: "output 目录未变化",
};
}
const wasBackendRunning = !!backendProc;
let migration = null;
let settingsSwitched = false;
try {
if (wasBackendRunning) {
await stopBackendAndWait({ timeoutMs: 10_000 });
}
migration = migrateOutputDirectory({
currentDir: currentPath,
nextDir: nextPath,
});
setOutputDirSetting(nextPath);
clearPendingOutputDirSetting();
setOutputDirLastError("");
settingsSwitched = true;
ensureOutputLink();
if (wasBackendRunning) {
startBackend();
await waitForBackend({ timeoutMs: 30_000 });
}
const info = getOutputDirInfo();
return {
success: true,
changed: true,
path: info.path,
defaultPath: info.defaultPath,
isDefault: info.isDefault,
pendingPath: info.pendingPath,
backupPath: migration?.backupDir || "",
sourceWasEmpty: !!migration?.sourceWasEmpty,
message: migration?.sourceWasEmpty ? "output 目录已切换" : "output 目录已迁移并切换",
};
} catch (err) {
const message = err?.message || String(err);
let rollbackMessage = "";
if (migration?.changed) {
try {
rollbackOutputDirectoryChange({
previousDir: currentPath,
currentDir: nextPath,
backupDir: migration.backupDir,
sourceWasEmpty: migration.sourceWasEmpty,
});
} catch (rollbackErr) {
logMain(`[main] output dir rollback failed: ${rollbackErr?.message || rollbackErr}`);
rollbackMessage = `;回滚失败:${rollbackErr?.message || rollbackErr}`;
if (migration?.backupDir) {
rollbackMessage += `;备份目录:${migration.backupDir}`;
}
}
}
if (settingsSwitched) {
restoreOutputDirSettings(previousSettings);
} else {
syncOutputDirEnv(currentPath);
}
ensureOutputLink();
if (wasBackendRunning) {
try {
startBackend();
await waitForBackend({ timeoutMs: 30_000 });
} catch (restartErr) {
throw new Error(
`切换 output 目录失败:${message}${rollbackMessage};且旧后端恢复失败:${restartErr?.message || restartErr}`
);
}
}
if (rollbackMessage) {
throw new Error(`切换 output 目录失败:${message}${rollbackMessage}`);
}
throw err;
}
}
async function applyPendingOutputDirOnStartup() {
if (!app.isPackaged) return;
loadDesktopSettings();
if (desktopSettings.pendingOutputDir === null) return;
try {
await applyOutputDirChange(desktopSettings.pendingOutputDir);
} catch (err) {
clearPendingOutputDirSetting();
setOutputDirLastError(`安装时设置的 output 目录未能应用:${err?.message || err}`);
ensureOutputLink();
logMain(`[main] failed to apply pending output dir: ${err?.message || err}`);
}
}
async function refreshRendererCacheForPackagedUi() {
if (!app.isPackaged) return;
@@ -1171,11 +1469,11 @@ function startBackend() {
}
if (app.isPackaged) {
if (!env.WECHAT_TOOL_DATA_DIR) {
env.WECHAT_TOOL_DATA_DIR = app.getPath("userData");
}
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 });
} catch {}
const backendExe = getPackagedBackendPath();
@@ -1689,16 +1987,31 @@ function registerWindowIpc() {
}
});
ipcMain.handle("app:getOutputDirInfo", () => {
try {
return getOutputDirInfo();
} catch (err) {
logMain(`[main] app:getOutputDirInfo failed: ${err?.message || err}`);
return {
path: "",
defaultPath: "",
isDefault: true,
pendingPath: "",
hasPending: false,
lastError: err?.message || String(err),
canChange: !!app.isPackaged,
changeUnavailableReason: app.isPackaged ? "" : "开发模式不支持界面修改 output 目录",
};
}
});
ipcMain.handle("app:getOutputDir", () => {
const dir = resolveDataDir();
if (!dir) return "";
return path.join(dir, "output");
return resolveOutputDir() || "";
});
ipcMain.handle("app:openOutputDir", async () => {
const dir = resolveDataDir();
if (!dir) throw new Error("无法定位数据目录");
const outDir = path.join(dir, "output");
const outDir = resolveOutputDir();
if (!outDir) throw new Error("无法定位 output 目录");
try {
fs.mkdirSync(outDir, { recursive: true });
} catch {}
@@ -1713,6 +2026,28 @@ function registerWindowIpc() {
}
});
ipcMain.handle("app:setOutputDir", async (_event, nextDir) => {
if (outputDirChangeInProgress) {
return {
success: false,
error: "output 目录切换中,请稍后重试",
};
}
outputDirChangeInProgress = true;
try {
return await applyOutputDirChange(nextDir);
} catch (err) {
const message = err?.message || String(err);
logMain(`[main] app:setOutputDir failed: ${message}`);
return {
success: false,
error: message,
};
} finally {
outputDirChangeInProgress = false;
}
});
ipcMain.handle("app:getAccountInfo", async (_event, account) => {
try {
return getAccountInfoFromDisk(account);
@@ -1796,6 +2131,8 @@ async function main() {
// Resolve/create the data dir early so we can log reliably and place helper files
// next to the installed exe for easier access.
resolveDataDir();
loadDesktopSettings();
await applyPendingOutputDirOnStartup();
ensureOutputLink();
await ensureBackendPortAvailableOnStartup();
@@ -1876,10 +2213,20 @@ if (gotSingleInstanceLock) {
stopBackend();
try {
const dir = getUserDataDir();
const outputDir = resolveOutputDir();
if (dir) {
const detailLines = [
`启动失败:${err?.message || err}`,
"",
`桌面日志目录:${dir}`,
"文件:desktop-main.log / backend-stdio.log",
];
if (outputDir) {
detailLines.push("", `当前 output 目录:${outputDir}`, "其中 output\\logs\\... 也在这里");
}
dialog.showErrorBox(
"WeChatDataAnalysis 启动失败",
`启动失败:${err?.message || err}\n\n请查看日志目录:\n${dir}\n\n文件:desktop-main.log / backend-stdio.log / output\\\\logs\\\\...`
detailLines.join("\n")
);
shell.openPath(dir);
}
+252
View File
@@ -0,0 +1,252 @@
const fs = require("fs");
const path = require("path");
const SENTINEL_NAMES = [
"account_keys.json",
"runtime_settings.json",
"message_edits.db",
"databases",
"exports",
"logs",
];
function normalizeDirectoryPath(value) {
const text = String(value || "").trim();
if (!text) return "";
const expanded = text.replace(/^~(?=$|[\\/])/, process.env.USERPROFILE || process.env.HOME || "~");
if (!path.isAbsolute(expanded)) {
throw new Error("output 目录必须使用绝对路径");
}
return path.resolve(expanded);
}
function getDefaultOutputDirPath(dataDir) {
const base = normalizeDirectoryPath(dataDir);
if (!base) throw new Error("无法定位数据目录");
return path.join(base, "output");
}
function getEffectiveOutputDirPath({ dataDir, envOutputDir, settingsOutputDir }) {
const envPath = normalizeDirectoryPath(envOutputDir || "");
if (envPath) return envPath;
const settingsPath = normalizeDirectoryPath(settingsOutputDir || "");
if (settingsPath) return settingsPath;
return getDefaultOutputDirPath(dataDir);
}
function hasDirectoryContents(dirPath) {
try {
return fs.readdirSync(dirPath).length > 0;
} catch (err) {
if (err && err.code === "ENOENT") return false;
throw err;
}
}
function pathExists(dirPath) {
try {
fs.accessSync(dirPath);
return true;
} catch {
return false;
}
}
function isDirectory(dirPath) {
try {
return fs.statSync(dirPath).isDirectory();
} catch {
return false;
}
}
function isPathInside(parentPath, candidatePath) {
const parent = path.resolve(parentPath);
const candidate = path.resolve(candidatePath);
if (parent === candidate) return false;
const relative = path.relative(parent, candidate);
return !!relative && !relative.startsWith("..") && !path.isAbsolute(relative);
}
function collectSentinels(sourceDir) {
const sentinels = [];
for (const name of SENTINEL_NAMES) {
const sourcePath = path.join(sourceDir, name);
if (!pathExists(sourcePath)) continue;
sentinels.push({
name,
isDir: isDirectory(sourcePath),
size: !isDirectory(sourcePath) ? fs.statSync(sourcePath).size : null,
});
}
return sentinels;
}
function verifyCopiedOutputTree(sourceDir, copiedDir) {
const sentinels = collectSentinels(sourceDir);
for (const item of sentinels) {
const copiedPath = path.join(copiedDir, item.name);
if (!pathExists(copiedPath)) {
throw new Error(`迁移校验失败:缺少 ${item.name}`);
}
if (item.isDir) {
if (!isDirectory(copiedPath)) {
throw new Error(`迁移校验失败:${item.name} 不是目录`);
}
continue;
}
const copiedStat = fs.statSync(copiedPath);
if (copiedStat.size !== item.size) {
throw new Error(`迁移校验失败:${item.name} 大小不一致`);
}
}
}
function makeTimestamp(now = new Date()) {
const parts = [
now.getFullYear(),
String(now.getMonth() + 1).padStart(2, "0"),
String(now.getDate()).padStart(2, "0"),
String(now.getHours()).padStart(2, "0"),
String(now.getMinutes()).padStart(2, "0"),
String(now.getSeconds()).padStart(2, "0"),
];
return parts.join("");
}
function makeUniqueSiblingPath(basePath, suffix, now = new Date()) {
const stamp = makeTimestamp(now);
let attempt = 0;
while (true) {
const candidate = `${basePath}.${suffix}-${stamp}${attempt ? `-${attempt}` : ""}`;
if (!pathExists(candidate)) return candidate;
attempt += 1;
}
}
function ensureTargetIsUsable(targetDir) {
if (!pathExists(targetDir)) return;
if (!isDirectory(targetDir)) {
throw new Error("目标 output 路径已存在且不是目录");
}
if (hasDirectoryContents(targetDir)) {
throw new Error("目标 output 目录已有内容,请先清空后再重试");
}
}
function migrateOutputDirectory({ currentDir, nextDir, now = new Date() }) {
const currentPath = normalizeDirectoryPath(currentDir);
const targetPath = normalizeDirectoryPath(nextDir);
if (!currentPath || !targetPath) {
throw new Error("output 路径不能为空");
}
if (currentPath === targetPath) {
return {
changed: false,
currentDir: currentPath,
targetDir: targetPath,
sourceWasEmpty: !hasDirectoryContents(currentPath),
backupDir: "",
};
}
if (isPathInside(currentPath, targetPath) || isPathInside(targetPath, currentPath)) {
throw new Error("新旧 output 路径不能互相包含");
}
ensureTargetIsUsable(targetPath);
const sourceExists = pathExists(currentPath);
if (sourceExists && !isDirectory(currentPath)) {
throw new Error("当前 output 路径不是目录");
}
const sourceWasEmpty = !sourceExists || !hasDirectoryContents(currentPath);
if (sourceWasEmpty) {
fs.mkdirSync(targetPath, { recursive: true });
return {
changed: true,
currentDir: currentPath,
targetDir: targetPath,
sourceWasEmpty: true,
backupDir: "",
};
}
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 {
verifyCopiedOutputTree(currentPath, tempTarget);
if (pathExists(targetPath)) {
fs.rmSync(targetPath, { recursive: true, force: true });
}
fs.renameSync(currentPath, backupDir);
try {
fs.renameSync(tempTarget, targetPath);
} catch (err) {
try {
if (!pathExists(currentPath) && pathExists(backupDir)) {
fs.renameSync(backupDir, currentPath);
}
} catch {}
throw err;
}
} catch (err) {
try {
if (pathExists(tempTarget)) {
fs.rmSync(tempTarget, { recursive: true, force: true });
}
} catch {}
throw err;
}
return {
changed: true,
currentDir: currentPath,
targetDir: targetPath,
sourceWasEmpty: false,
backupDir,
};
}
function rollbackOutputDirectoryChange({ previousDir, currentDir, backupDir, sourceWasEmpty }) {
const previousPath = normalizeDirectoryPath(previousDir);
const currentPath = normalizeDirectoryPath(currentDir);
try {
if (currentPath && pathExists(currentPath)) {
fs.rmSync(currentPath, { recursive: true, force: true });
}
} catch {}
if (sourceWasEmpty) {
return;
}
const backupPath = normalizeDirectoryPath(backupDir);
if (!backupPath || !pathExists(backupPath)) return;
try {
if (!pathExists(previousPath)) {
fs.renameSync(backupPath, previousPath);
}
} catch {}
}
module.exports = {
getDefaultOutputDirPath,
getEffectiveOutputDirPath,
hasDirectoryContents,
migrateOutputDirectory,
normalizeDirectoryPath,
rollbackOutputDirectoryChange,
};
+2
View File
@@ -82,7 +82,9 @@ contextBridge.exposeInMainWorld("wechatDesktop", {
chooseDirectory: (options = {}) => ipcRenderer.invoke("dialog:chooseDirectory", options),
// Data/output folder helpers
getOutputDirInfo: () => ipcRenderer.invoke("app:getOutputDirInfo"),
getOutputDir: () => ipcRenderer.invoke("app:getOutputDir"),
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 || "")),
+162
View File
@@ -0,0 +1,162 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("fs");
const os = require("os");
const path = require("path");
const {
getDefaultOutputDirPath,
getEffectiveOutputDirPath,
migrateOutputDirectory,
normalizeDirectoryPath,
rollbackOutputDirectoryChange,
} = require("../src/output-dir.cjs");
function makeTempDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), "wda-output-"));
}
function cleanupDir(dirPath) {
try {
fs.rmSync(dirPath, { recursive: true, force: true });
} catch {}
}
test("normalizeDirectoryPath requires absolute paths", () => {
assert.throws(() => normalizeDirectoryPath("relative/path"), /绝对路径/);
});
test("getEffectiveOutputDirPath prefers env, then settings, then default", () => {
const root = makeTempDir();
const envDir = path.join(root, "env-output");
const settingsDir = path.join(root, "settings-output");
const defaultDir = path.join(root, "data", "output");
try {
assert.equal(
getEffectiveOutputDirPath({
dataDir: path.join(root, "data"),
envOutputDir: envDir,
settingsOutputDir: settingsDir,
}),
path.resolve(envDir)
);
assert.equal(
getEffectiveOutputDirPath({
dataDir: path.join(root, "data"),
envOutputDir: "",
settingsOutputDir: settingsDir,
}),
path.resolve(settingsDir)
);
assert.equal(getDefaultOutputDirPath(path.join(root, "data")), path.resolve(defaultDir));
} finally {
cleanupDir(root);
}
});
test("migrateOutputDirectory switches empty source to a new directory", () => {
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 });
assert.equal(result.changed, true);
assert.equal(result.sourceWasEmpty, true);
assert.equal(result.backupDir, "");
assert.ok(fs.existsSync(nextDir));
assert.equal(fs.existsSync(currentDir), true);
} finally {
cleanupDir(root);
}
});
test("migrateOutputDirectory blocks non-empty targets", () => {
const root = makeTempDir();
const currentDir = path.join(root, "current-output");
const nextDir = path.join(root, "custom-output");
try {
fs.mkdirSync(path.join(currentDir, "logs"), { recursive: true });
fs.writeFileSync(path.join(currentDir, "runtime_settings.json"), "{}");
fs.mkdirSync(nextDir, { recursive: true });
fs.writeFileSync(path.join(nextDir, "existing.txt"), "occupied");
assert.throws(
() => migrateOutputDirectory({ currentDir, nextDir }),
/已有内容/
);
} finally {
cleanupDir(root);
}
});
test("migrateOutputDirectory blocks invalid current paths", () => {
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 }),
/不是目录/
);
} finally {
cleanupDir(root);
}
});
test("migrateOutputDirectory copies data and leaves the old directory as a backup", () => {
const root = makeTempDir();
const currentDir = path.join(root, "current-output");
const nextDir = path.join(root, "custom-output");
try {
fs.mkdirSync(path.join(currentDir, "databases", "wxid_test"), { recursive: true });
fs.writeFileSync(path.join(currentDir, "runtime_settings.json"), "{\"backend_port\":10392}");
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") });
assert.equal(result.changed, true);
assert.equal(result.sourceWasEmpty, false);
assert.match(path.basename(result.backupDir), /^current-output\.backup-\d{14}$/);
assert.ok(fs.existsSync(nextDir));
assert.ok(fs.existsSync(path.join(nextDir, "runtime_settings.json")));
assert.ok(fs.existsSync(path.join(nextDir, "databases", "wxid_test", "session.db")));
assert.ok(fs.existsSync(result.backupDir));
assert.equal(fs.existsSync(currentDir), false);
} finally {
cleanupDir(root);
}
});
test("rollbackOutputDirectoryChange restores the previous directory", () => {
const root = makeTempDir();
const previousDir = path.join(root, "current-output");
const currentDir = path.join(root, "custom-output");
const backupDir = path.join(root, "current-output.backup-20260330080100");
try {
fs.mkdirSync(path.join(currentDir, "databases"), { recursive: true });
fs.writeFileSync(path.join(currentDir, "databases", "temp.db"), "temp");
fs.mkdirSync(path.join(backupDir, "databases"), { recursive: true });
fs.writeFileSync(path.join(backupDir, "databases", "session.db"), "restored");
rollbackOutputDirectoryChange({
previousDir,
currentDir,
backupDir,
sourceWasEmpty: false,
});
assert.equal(fs.existsSync(currentDir), false);
assert.ok(fs.existsSync(path.join(previousDir, "databases", "session.db")));
assert.equal(fs.existsSync(backupDir), false);
} finally {
cleanupDir(root);
}
});
+170 -15
View File
@@ -4,9 +4,9 @@
class="settings-dialog fixed inset-0 z-[120] flex items-center justify-center bg-black/40 px-4 py-4 backdrop-blur-md sm:py-8"
@click.self="handleClose"
>
<div class="settings-dialog-panel flex h-[80vh] min-h-[380px] w-full max-w-[760px] overflow-hidden rounded-[10px] border border-[#e2e2e2] bg-white shadow-2xl">
<div class="settings-dialog-panel flex h-[80vh] min-h-[380px] w-full max-w-[880px] overflow-hidden rounded-[10px] border border-[#e2e2e2] bg-white shadow-2xl">
<!-- Sidebar -->
<aside class="flex w-[180px] shrink-0 flex-col bg-[#fcfcfc] border-r border-[#eeeeee]">
<aside class="flex w-[160px] shrink-0 flex-col bg-[#fcfcfc] border-r border-[#eeeeee]">
<div class="mt-4 mb-2 flex items-center px-4 gap-2">
<div class="flex h-6 w-6 items-center justify-center rounded-[5px] bg-[#e7f5ee] text-[#07b75b]">
<svg class="h-[15px] w-[15px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
@@ -149,19 +149,74 @@
</div>
<div class="px-3.5 py-3">
<div class="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">output 目录</div>
<div class="mt-0.5 text-[11px] text-[#909090] break-words">{{ desktopOutputDirText }}</div>
<div class="flex flex-col gap-2.5">
<div class="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">output 目录</div>
<div class="mt-0.5 text-[11px] text-[#909090] break-words">
当前{{ desktopOutputDirText }}
<span class="ml-1 text-[#666]">{{ desktopOutputDirIsDefault ? '(默认位置)' : '(自定义位置)' }}</span>
</div>
<div class="mt-0.5 text-[11px] text-[#909090] break-words">默认{{ desktopOutputDirDefaultText }}</div>
<div v-if="desktopOutputDirPendingText" class="mt-0.5 text-[11px] text-amber-700 break-words">
待应用{{ desktopOutputDirPendingText }}
</div>
<div v-if="desktopOutputDirUnavailableReason" class="mt-1 text-[11px] text-amber-700 break-words">
{{ desktopOutputDirUnavailableReason }}
</div>
</div>
<button
type="button"
class="shrink-0 rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!isDesktopEnv || desktopOutputDirLoading || desktopOutputDirApplying"
@click="onDesktopOpenOutputDir"
>
打开当前 output
</button>
</div>
<div class="flex flex-col gap-1.5 sm:flex-row sm:items-center">
<input
v-model="desktopOutputDirInput"
type="text"
spellcheck="false"
class="min-w-0 flex-1 rounded-[6px] border border-[#e2e2e2] bg-white px-2.5 py-1.5 text-[12px] text-[#333] outline-none transition focus:border-[#07b75b] focus:ring-1 focus:ring-[#07b75b]/30"
:disabled="desktopOutputDirControlsDisabled"
:placeholder="desktopOutputDirCanChange ? '选择新的 output 目录' : '当前环境不支持修改 output 目录'"
@keyup.enter="onDesktopOutputDirApply"
/>
<div class="flex shrink-0 items-center gap-1.5">
<button
type="button"
class="rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
:disabled="desktopOutputDirControlsDisabled"
@click="onDesktopChooseOutputDir"
>
选择文件夹
</button>
<button
type="button"
class="rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
:disabled="desktopOutputDirControlsDisabled"
@click="onDesktopOutputDirApply"
>
{{ desktopOutputDirApplying ? '迁移中...' : '应用' }}
</button>
<button
type="button"
class="rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
:disabled="desktopOutputDirControlsDisabled"
@click="onDesktopOutputDirReset"
>
恢复默认
</button>
</div>
</div>
<div v-if="desktopOutputDirCanChange" class="text-[11px] text-[#909090]">
修改后会迁移整个 output 目录如果目标目录已有内容会先阻止并提示
</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>
<button
type="button"
class="shrink-0 rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!isDesktopEnv || desktopOutputDirLoading"
@click="onDesktopOpenOutputDir"
>
打开 output
</button>
</div>
<div v-if="desktopOutputDirError" class="mt-1.5 text-[11px] text-red-600 whitespace-pre-wrap">
{{ desktopOutputDirError }}
@@ -345,13 +400,33 @@ const desktopBackendPortError = ref('')
const desktopBackendPortDefault = ref(10392)
const desktopOutputDir = ref('')
const desktopOutputDirDefault = ref('')
const desktopOutputDirInput = ref('')
const desktopOutputDirPending = ref('')
const desktopOutputDirLoading = ref(false)
const desktopOutputDirApplying = ref(false)
const desktopOutputDirError = ref('')
const desktopOutputDirMessage = ref('')
const desktopOutputDirIsDefault = ref(true)
const desktopOutputDirCanChange = ref(true)
const desktopOutputDirUnavailableReason = ref('')
const desktopOutputDirText = computed(() => {
if (!isDesktopEnv.value) return '仅桌面端可用'
const v = String(desktopOutputDir.value || '').trim()
return v || '—'
})
const desktopOutputDirDefaultText = computed(() => {
if (!isDesktopEnv.value) return '仅桌面端可用'
const v = String(desktopOutputDirDefault.value || '').trim()
return v || '—'
})
const desktopOutputDirPendingText = computed(() => {
const v = String(desktopOutputDirPending.value || '').trim()
return v || ''
})
const desktopOutputDirControlsDisabled = computed(() => (
!isDesktopEnv.value || !desktopOutputDirCanChange.value || desktopOutputDirLoading.value || desktopOutputDirApplying.value
))
const desktopLogFilePath = ref('')
const desktopLogFileLoading = ref(false)
@@ -530,12 +605,33 @@ const refreshDesktopBackendPort = async () => {
const refreshDesktopOutputDir = async () => {
if (!process.client || typeof window === 'undefined') return
if (!window.wechatDesktop?.getOutputDir) return
if (!window.wechatDesktop?.getOutputDir && !window.wechatDesktop?.getOutputDirInfo) return
desktopOutputDirLoading.value = true
desktopOutputDirError.value = ''
try {
if (window.wechatDesktop?.getOutputDirInfo) {
const info = await window.wechatDesktop.getOutputDirInfo()
desktopOutputDir.value = String(info?.path || '').trim()
desktopOutputDirDefault.value = String(info?.defaultPath || '').trim()
desktopOutputDirPending.value = String(info?.pendingPath || '').trim()
desktopOutputDirIsDefault.value = !!info?.isDefault
desktopOutputDirCanChange.value = info?.canChange !== false
desktopOutputDirUnavailableReason.value = String(info?.changeUnavailableReason || '').trim()
desktopOutputDirInput.value = desktopOutputDir.value || desktopOutputDirDefault.value
if (info?.lastError) {
desktopOutputDirError.value = String(info.lastError || '').trim()
}
return
}
const v = await window.wechatDesktop.getOutputDir()
desktopOutputDir.value = String(v || '').trim()
desktopOutputDirDefault.value = desktopOutputDir.value
desktopOutputDirPending.value = ''
desktopOutputDirIsDefault.value = true
desktopOutputDirCanChange.value = false
desktopOutputDirUnavailableReason.value = '当前桌面环境不支持修改 output 目录'
desktopOutputDirInput.value = desktopOutputDir.value
} catch (e) {
desktopOutputDirError.value = e?.message || '读取 output 目录失败'
} finally {
@@ -558,6 +654,62 @@ const onDesktopOpenOutputDir = async () => {
}
}
const onDesktopChooseOutputDir = async () => {
if (!process.client || typeof window === 'undefined') return
if (!window.wechatDesktop?.chooseDirectory) return
desktopOutputDirError.value = ''
desktopOutputDirMessage.value = ''
try {
const result = await window.wechatDesktop.chooseDirectory({ title: '选择新的 output 目录' })
if (result && !result.canceled && Array.isArray(result.filePaths) && result.filePaths.length > 0) {
desktopOutputDirInput.value = String(result.filePaths[0] || '').trim()
}
} catch (e) {
desktopOutputDirError.value = e?.message || '选择 output 目录失败'
}
}
const applyDesktopOutputDir = async (nextDir) => {
if (!process.client || typeof window === 'undefined') return
if (!window.wechatDesktop?.setOutputDir) {
desktopOutputDirError.value = '当前桌面环境不支持修改 output 目录'
return
}
if (!desktopOutputDirCanChange.value) {
desktopOutputDirError.value = desktopOutputDirUnavailableReason.value || '开发模式不支持界面修改 output 目录'
return
}
desktopOutputDirApplying.value = true
desktopOutputDirError.value = ''
desktopOutputDirMessage.value = ''
try {
const res = await window.wechatDesktop.setOutputDir(String(nextDir ?? '').trim())
if (res?.success === false) {
desktopOutputDirError.value = String(res?.error || '修改 output 目录失败').trim()
await refreshDesktopOutputDir()
return
}
await refreshDesktopOutputDir()
desktopOutputDirMessage.value = String(
res?.message || (res?.changed === false ? 'output 目录未变化' : 'output 目录已更新')
).trim()
} catch (e) {
desktopOutputDirError.value = e?.message || '修改 output 目录失败'
await refreshDesktopOutputDir()
} finally {
desktopOutputDirApplying.value = false
}
}
const onDesktopOutputDirApply = async () => {
await applyDesktopOutputDir(desktopOutputDirInput.value)
}
const onDesktopOutputDirReset = async () => {
desktopOutputDirInput.value = desktopOutputDirDefault.value
await applyDesktopOutputDir('')
}
const refreshBackendLogFileInfo = async () => {
if (!process.client || typeof window === 'undefined') return
desktopLogFileLoading.value = true
@@ -702,6 +854,9 @@ const onDesktopCheckUpdates = async () => {
watch(() => props.open, async (isOpen) => {
if (!isOpen) return
await refreshBackendLogFileInfo()
if (isDesktopEnv.value) {
await refreshDesktopOutputDir()
}
}, { immediate: true })
onMounted(async () => {
+8 -3
View File
@@ -3,6 +3,9 @@ from __future__ import annotations
import os
from pathlib import Path
ENV_DATA_DIR_KEY = "WECHAT_TOOL_DATA_DIR"
ENV_OUTPUT_DIR_KEY = "WECHAT_TOOL_OUTPUT_DIR"
def get_data_dir() -> Path:
"""Base writable directory for all runtime output (logs, databases, key store).
@@ -12,13 +15,16 @@ def get_data_dir() -> Path:
- Dev defaults to the current working directory (repo root).
"""
v = os.environ.get("WECHAT_TOOL_DATA_DIR", "").strip()
v = os.environ.get(ENV_DATA_DIR_KEY, "").strip()
if v:
return Path(v)
return Path(v).expanduser()
return Path.cwd()
def get_output_dir() -> Path:
v = os.environ.get(ENV_OUTPUT_DIR_KEY, "").strip()
if v:
return Path(v).expanduser()
return get_data_dir() / "output"
@@ -28,4 +34,3 @@ def get_output_databases_dir() -> Path:
def get_account_keys_path() -> Path:
return get_output_dir() / "account_keys.json"
+93
View File
@@ -0,0 +1,93 @@
import importlib
import logging
import os
import sys
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
def _close_logging_handlers() -> None:
for logger_name in ("", "uvicorn", "uvicorn.access", "uvicorn.error", "fastapi"):
lg = logging.getLogger(logger_name)
for handler in lg.handlers[:]:
try:
handler.close()
except Exception:
pass
try:
lg.removeHandler(handler)
except Exception:
pass
class TestOutputDirOverride(unittest.TestCase):
def setUp(self) -> None:
self._prev_data_dir = os.environ.get("WECHAT_TOOL_DATA_DIR")
self._prev_output_dir = os.environ.get("WECHAT_TOOL_OUTPUT_DIR")
self._data_dir = TemporaryDirectory()
self._output_dir = TemporaryDirectory()
os.environ["WECHAT_TOOL_DATA_DIR"] = self._data_dir.name
os.environ["WECHAT_TOOL_OUTPUT_DIR"] = self._output_dir.name
import wechat_decrypt_tool.app_paths as app_paths
import wechat_decrypt_tool.key_store as key_store
import wechat_decrypt_tool.logging_config as logging_config
import wechat_decrypt_tool.runtime_settings as runtime_settings
importlib.reload(app_paths)
importlib.reload(logging_config)
importlib.reload(runtime_settings)
importlib.reload(key_store)
self.app_paths = app_paths
self.key_store = key_store
self.logging_config = logging_config
self.runtime_settings = runtime_settings
def tearDown(self) -> None:
_close_logging_handlers()
if self._prev_data_dir is None:
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
else:
os.environ["WECHAT_TOOL_DATA_DIR"] = self._prev_data_dir
if self._prev_output_dir is None:
os.environ.pop("WECHAT_TOOL_OUTPUT_DIR", None)
else:
os.environ["WECHAT_TOOL_OUTPUT_DIR"] = self._prev_output_dir
self._data_dir.cleanup()
self._output_dir.cleanup()
def test_app_paths_prefers_output_dir_override(self) -> None:
self.assertEqual(self.app_paths.get_output_dir(), Path(self._output_dir.name))
self.assertEqual(
self.app_paths.get_output_databases_dir(),
Path(self._output_dir.name) / "databases",
)
def test_logging_runtime_settings_and_key_store_use_output_override(self) -> None:
log_file = self.logging_config.setup_logging()
self.assertTrue(log_file.is_relative_to(Path(self._output_dir.name) / "logs"))
self.runtime_settings.write_backend_port_setting(12001)
runtime_settings_path = Path(self._output_dir.name) / "runtime_settings.json"
self.assertTrue(runtime_settings_path.exists())
self.assertEqual(self.runtime_settings.read_backend_port_setting(), 12001)
self.key_store.upsert_account_keys_in_store("wxid_test", db_key="abc123")
key_store_path = Path(self._output_dir.name) / "account_keys.json"
self.assertTrue(key_store_path.exists())
self.assertEqual(
self.key_store.get_account_keys_from_store("wxid_test").get("db_key"),
"abc123",
)
if __name__ == "__main__":
unittest.main()