diff --git a/desktop/scripts/installer-custom.nsh b/desktop/scripts/installer-custom.nsh index 33e7240..9938492 100644 --- a/desktop/scripts/installer-custom.nsh +++ b/desktop/scripts/installer-custom.nsh @@ -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} diff --git a/desktop/src/main.cjs b/desktop/src/main.cjs index f0428e8..7f4bc51 100644 --- a/desktop/src/main.cjs +++ b/desktop/src/main.cjs @@ -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); } diff --git a/desktop/src/output-dir.cjs b/desktop/src/output-dir.cjs new file mode 100644 index 0000000..fe2881c --- /dev/null +++ b/desktop/src/output-dir.cjs @@ -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, +}; diff --git a/desktop/src/preload.cjs b/desktop/src/preload.cjs index 33ab114..418f08a 100644 --- a/desktop/src/preload.cjs +++ b/desktop/src/preload.cjs @@ -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 || "")), diff --git a/desktop/tests/output-dir.test.cjs b/desktop/tests/output-dir.test.cjs new file mode 100644 index 0000000..3b7b908 --- /dev/null +++ b/desktop/tests/output-dir.test.cjs @@ -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); + } +}); diff --git a/frontend/components/SettingsDialog.vue b/frontend/components/SettingsDialog.vue index 3f13eaf..7053892 100644 --- a/frontend/components/SettingsDialog.vue +++ b/frontend/components/SettingsDialog.vue @@ -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" > -
+
-