mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
feat(desktop): 支持自定义 output 目录并迁移现有数据
- 支持在桌面端查看、选择和恢复默认 output 目录 - 安装器记录待应用目录,并在应用启动时自动迁移数据 - 后端支持 output 目录覆盖,补充桌面端与后端相关测试
This commit is contained in:
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 || "")),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user