Compare commits

..

2 Commits

13 changed files with 1552 additions and 69 deletions
+90 -11
View File
@@ -1,6 +1,22 @@
; This file is included for both installer and uninstaller builds.
; Guard installer-only pages/functions to avoid "function not referenced" warnings
; when electron-builder compiles the standalone uninstaller.
!define /ifndef WDA_DEFAULT_SETTINGS_PATH "$APPDATA\${APP_FILENAME}\desktop-settings.json"
!define /ifndef WDA_DEFAULT_OUTPUT_DIR "$APPDATA\${APP_FILENAME}\output"
!ifdef APP_PRODUCT_FILENAME
!define /ifndef WDA_PRODUCT_SETTINGS_PATH "$APPDATA\${APP_PRODUCT_FILENAME}\desktop-settings.json"
!define /ifndef WDA_PRODUCT_OUTPUT_DIR "$APPDATA\${APP_PRODUCT_FILENAME}\output"
!else
!define /ifndef WDA_PRODUCT_SETTINGS_PATH ""
!define /ifndef WDA_PRODUCT_OUTPUT_DIR ""
!endif
!ifdef APP_PACKAGE_NAME
!define /ifndef WDA_PACKAGE_SETTINGS_PATH "$APPDATA\${APP_PACKAGE_NAME}\desktop-settings.json"
!define /ifndef WDA_PACKAGE_OUTPUT_DIR "$APPDATA\${APP_PACKAGE_NAME}\output"
!else
!define /ifndef WDA_PACKAGE_SETTINGS_PATH ""
!define /ifndef WDA_PACKAGE_OUTPUT_DIR ""
!endif
!ifndef BUILD_UNINSTALLER
!include nsDialogs.nsh
!include LogicLib.nsh
@@ -13,6 +29,10 @@
!define /ifndef MUI_DIRECTORYPAGE_TEXT_DESTINATION "安装位置:"
Var WDA_InstallDirPage
Var WDA_OutputDirPage
Var WDA_OutputDirInput
Var WDA_OutputDirBrowseButton
Var WDA_SelectedOutputDir
!macro customInit
; Safety: older versions created an `output` junction inside the install directory that points to the
@@ -22,17 +42,10 @@ Var WDA_InstallDirPage
!macroend
!macro customInstall
; Provide a safe, non-junction way for users to locate the real per-user output directory.
; The actual data is NOT stored inside $INSTDIR (it is wiped on update/reinstall).
; `open-output.cmd` uses %APPDATA% so it works for the current user.
FileOpen $0 "$INSTDIR\output-location.txt" w
FileWrite $0 "WeChatDataAnalysis output folder (per user):$\r$\n%APPDATA%\\${APP_PACKAGE_NAME}\\output$\r$\n"
FileClose $0
FileOpen $1 "$INSTDIR\open-output.cmd" w
; NSIS escaping: use $\" to output a literal quote character into the .cmd file.
FileWrite $1 "@echo off$\r$\nexplorer $\"%APPDATA%\\${APP_PACKAGE_NAME}\\output$\"$\r$\n"
FileClose $1
${If} $WDA_SelectedOutputDir == ""
Call WDA_InitOutputDirSelection
${EndIf}
Call WDA_WritePendingOutputDirSetting
!macroend
Function WDA_RemoveLegacyOutputLink
@@ -47,9 +60,27 @@ FunctionEnd
; the final install location (includes the app sub-folder).
!ifdef allowToChangeInstallationDirectory
Page custom WDA_InstallDirPageCreate WDA_InstallDirPageLeave
Page custom WDA_OutputDirPageCreate WDA_OutputDirPageLeave
!endif
!macroend
Function WDA_InitOutputDirSelection
StrCpy $WDA_SelectedOutputDir "${WDA_DEFAULT_OUTPUT_DIR}"
nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -Command "& { param([string] $$defaultSettingsPath, [string] $$defaultOutputPath, [string] $$legacySettingsPath1, [string] $$legacySettingsPath2) $$candidates = @($$defaultSettingsPath, $$legacySettingsPath1, $$legacySettingsPath2) | Where-Object { -not [string]::IsNullOrWhiteSpace($$_) } | Select-Object -Unique; $$settingsPath = $$defaultSettingsPath; foreach ($$candidate in $$candidates) { if (Test-Path -LiteralPath $$candidate) { $$settingsPath = $$candidate; break } }; $$result = $$defaultOutputPath; if (Test-Path -LiteralPath $$settingsPath) { try { $$json = Get-Content -LiteralPath $$settingsPath -Raw | ConvertFrom-Json; $$value = [string] $$json.pendingOutputDir; if ([string]::IsNullOrWhiteSpace($$value)) { $$value = [string] $$json.outputDir }; if ($$value -eq '''') { $$result = $$defaultOutputPath } elseif (-not [string]::IsNullOrWhiteSpace($$value)) { $$result = $$value } } catch {} }; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::Write($$result) }" "${WDA_DEFAULT_SETTINGS_PATH}" "${WDA_DEFAULT_OUTPUT_DIR}" "${WDA_PRODUCT_SETTINGS_PATH}" "${WDA_PACKAGE_SETTINGS_PATH}"'
Pop $0
Pop $1
${If} $0 == "0"
${AndIf} $1 != ""
StrCpy $WDA_SelectedOutputDir "$1"
${EndIf}
FunctionEnd
Function WDA_WritePendingOutputDirSetting
nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -Command "& { param([string] $$defaultSettingsPath, [string] $$defaultOutputPath, [string] $$selectedOutputPath, [string] $$legacySettingsPath1, [string] $$legacySettingsPath2) $$candidates = @($$defaultSettingsPath, $$legacySettingsPath1, $$legacySettingsPath2) | Where-Object { -not [string]::IsNullOrWhiteSpace($$_) } | Select-Object -Unique; $$sourceSettingsPath = $$defaultSettingsPath; foreach ($$candidate in $$candidates) { if (Test-Path -LiteralPath $$candidate) { $$sourceSettingsPath = $$candidate; break } }; if ([string]::IsNullOrWhiteSpace($$selectedOutputPath)) { $$selectedOutputPath = $$defaultOutputPath }; $$pending = if ([string]::Equals($$selectedOutputPath, $$defaultOutputPath, [System.StringComparison]::OrdinalIgnoreCase)) { '''' } else { $$selectedOutputPath }; $$obj = @{}; if (Test-Path -LiteralPath $$sourceSettingsPath) { try { $$existing = Get-Content -LiteralPath $$sourceSettingsPath -Raw | ConvertFrom-Json; if ($$null -ne $$existing) { $$existing.PSObject.Properties | ForEach-Object { $$obj[$$_.Name] = $$_.Value } } } catch {} }; $$obj[''pendingOutputDir''] = $$pending; $$dir = Split-Path -Parent $$defaultSettingsPath; New-Item -ItemType Directory -Force -Path $$dir | Out-Null; $$json = [PSCustomObject] $$obj | ConvertTo-Json -Depth 10; Set-Content -LiteralPath $$defaultSettingsPath -Value $$json -Encoding UTF8 }" "${WDA_DEFAULT_SETTINGS_PATH}" "${WDA_DEFAULT_OUTPUT_DIR}" "$WDA_SelectedOutputDir" "${WDA_PRODUCT_SETTINGS_PATH}" "${WDA_PACKAGE_SETTINGS_PATH}"'
Pop $0
Pop $1
FunctionEnd
Function WDA_EnsureAppSubDir
; Normalize $INSTDIR to always end with "\${APP_FILENAME}" (avoid cluttering a parent folder).
StrCpy $0 "$INSTDIR"
@@ -105,6 +136,48 @@ FunctionEnd
Function WDA_InstallDirPageLeave
FunctionEnd
Function WDA_OutputDirBrowse
nsDialogs::SelectFolderDialog "选择 output 目录" "$WDA_SelectedOutputDir"
Pop $0
${If} $0 != error
StrCpy $WDA_SelectedOutputDir "$0"
${NSD_SetText} $WDA_OutputDirInput "$0"
${EndIf}
FunctionEnd
Function WDA_OutputDirPageCreate
Call WDA_InitOutputDirSelection
nsDialogs::Create 1018
Pop $WDA_OutputDirPage
${If} $WDA_OutputDirPage == error
Abort
${EndIf}
${NSD_CreateLabel} 0u 0u 100% 24u "请选择 output 目录(保存解密数据库、导出内容、缓存、日志等)。"
Pop $0
${NSD_CreateText} 0u 28u 78% 12u "$WDA_SelectedOutputDir"
Pop $WDA_OutputDirInput
${NSD_CreateButton} 82% 27u 18% 14u "浏览..."
Pop $WDA_OutputDirBrowseButton
${NSD_OnClick} $WDA_OutputDirBrowseButton WDA_OutputDirBrowse
${NSD_CreateLabel} 0u 52u 100% 28u "安装器只记录你的选择;真正的数据迁移会在首次启动应用时执行。若目标目录已有内容,应用会阻止切换并提示处理。"
Pop $0
nsDialogs::Show
FunctionEnd
Function WDA_OutputDirPageLeave
${NSD_GetText} $WDA_OutputDirInput $WDA_SelectedOutputDir
${If} $WDA_SelectedOutputDir == ""
StrCpy $WDA_SelectedOutputDir "${WDA_DEFAULT_OUTPUT_DIR}"
${EndIf}
FunctionEnd
!endif
!ifdef BUILD_UNINSTALLER
@@ -177,6 +250,12 @@ FunctionEnd
RMDir /r "$APPDATA\${APP_PACKAGE_NAME}"
!endif
IfFileExists "$INSTDIR\output-location.path" 0 WDA_SkipCustomOutputDelete
nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -Command "& { param([string] $$pathFile, [string] $$defaultPath1, [string] $$defaultPath2, [string] $$defaultPath3) if (Test-Path -LiteralPath $$pathFile) { $$target = (Get-Content -LiteralPath $$pathFile -Raw).Trim(); $$defaults = @($$defaultPath1, $$defaultPath2, $$defaultPath3) | Where-Object { -not [string]::IsNullOrWhiteSpace($$_) }; $$isDefault = $$false; foreach ($$defaultPath in $$defaults) { if ([string]::Equals($$target, $$defaultPath, [System.StringComparison]::OrdinalIgnoreCase)) { $$isDefault = $$true; break } }; if (-not $$isDefault -and -not [string]::IsNullOrWhiteSpace($$target) -and (Test-Path -LiteralPath $$target)) { Remove-Item -LiteralPath $$target -Recurse -Force -ErrorAction SilentlyContinue } } }" "$INSTDIR\output-location.path" "${WDA_DEFAULT_OUTPUT_DIR}" "${WDA_PRODUCT_OUTPUT_DIR}" "${WDA_PACKAGE_OUTPUT_DIR}"'
Pop $0
Pop $1
WDA_SkipCustomOutputDelete:
${if} $installMode == "all"
SetShellVarContext all
${endif}
+369 -22
View File
@@ -21,6 +21,13 @@ const fs = require("fs");
const http = require("http");
const net = require("net");
const path = require("path");
const {
getDefaultOutputDirPath,
getEffectiveOutputDirPath,
migrateOutputDirectory,
normalizeDirectoryPath,
rollbackOutputDirectoryChange,
} = require("./output-dir.cjs");
const DEFAULT_BACKEND_HOST = String(process.env.WECHAT_TOOL_HOST || "127.0.0.1").trim() || "127.0.0.1";
const DEFAULT_BACKEND_PORT = parsePort(process.env.WECHAT_TOOL_PORT) ?? 10392;
@@ -32,6 +39,7 @@ let tray = null;
let isQuitting = false;
let desktopSettings = null;
let backendPortChangeInProgress = false;
let outputDirChangeInProgress = false;
const gotSingleInstanceLock = app.requestSingleInstanceLock();
if (!gotSingleInstanceLock) {
@@ -216,9 +224,76 @@ function resolveDataDir() {
}
function getUserDataDir() {
// Backwards-compat: we historically used Electron's userData directory for runtime storage.
// Keep this name but resolve to the effective data dir (can be overridden via env).
return resolveDataDir();
try {
const dir = app.getPath("userData");
if (!dir) return null;
fs.mkdirSync(dir, { recursive: true });
return dir;
} catch {
return null;
}
}
function safeNormalizeDirectory(value) {
try {
return normalizeDirectoryPath(value || "");
} catch {
return "";
}
}
function getDefaultOutputDir() {
const dataDir = resolveDataDir();
if (!dataDir) return null;
try {
return getDefaultOutputDirPath(dataDir);
} catch {
return null;
}
}
function syncOutputDirEnv(nextDir) {
const normalized = safeNormalizeDirectory(nextDir);
if (normalized) process.env.WECHAT_TOOL_OUTPUT_DIR = normalized;
else delete process.env.WECHAT_TOOL_OUTPUT_DIR;
}
function normalizePendingOutputDirValue(value) {
if (value == null) return null;
const text = String(value).trim();
if (!text) return "";
try {
return normalizeDirectoryPath(text);
} catch {
return null;
}
}
function resolveOutputDir() {
const dataDir = resolveDataDir();
if (!dataDir) return null;
const envOutputDir = safeNormalizeDirectory(process.env.WECHAT_TOOL_OUTPUT_DIR || "");
const settingsOutputDir = app.isPackaged ? safeNormalizeDirectory(loadDesktopSettings()?.outputDir || "") : "";
let chosen = null;
try {
chosen = getEffectiveOutputDirPath({
dataDir,
envOutputDir,
settingsOutputDir,
});
} catch {
chosen = getDefaultOutputDir();
}
if (!chosen) return null;
try {
fs.mkdirSync(chosen, { recursive: true });
} catch {}
syncOutputDirEnv(chosen);
return chosen;
}
function sanitizeAccountName(account) {
@@ -261,7 +336,8 @@ function resolveAccountDirInOutput(account) {
const dataDir = resolveDataDir();
if (!dataDir) throw new Error("无法定位数据目录");
const outputDir = path.join(dataDir, "output");
const outputDir = resolveOutputDir();
if (!outputDir) throw new Error("无法定位 output 目录");
const databasesDir = path.join(outputDir, "databases");
const accountName = sanitizeAccountName(account);
@@ -311,8 +387,8 @@ function getAccountInfoFromDisk(account) {
};
}
function removeAccountFromKeyStore(dataDir, accountName) {
const keyStorePath = path.join(dataDir, "output", "account_keys.json");
function removeAccountFromKeyStore(outputDir, accountName) {
const keyStorePath = path.join(outputDir, "account_keys.json");
try {
if (!fs.existsSync(keyStorePath)) return false;
const raw = fs.readFileSync(keyStorePath, { encoding: "utf8" });
@@ -328,7 +404,7 @@ function removeAccountFromKeyStore(dataDir, accountName) {
}
async function deleteAccountDataFromDisk(account) {
const { dataDir, outputDir, databasesDir, accountName, accountDir } = resolveAccountDirInOutput(account);
const { outputDir, databasesDir, accountName, accountDir } = resolveAccountDirInOutput(account);
if (!fs.existsSync(accountDir) || !fs.statSync(accountDir).isDirectory()) {
throw new Error("账号数据不存在");
}
@@ -348,7 +424,7 @@ async function deleteAccountDataFromDisk(account) {
} catch {}
fs.rmSync(accountDir, { recursive: true, force: true });
const removedKeyCache = removeAccountFromKeyStore(dataDir, accountName);
const removedKeyCache = removeAccountFromKeyStore(outputDir, accountName);
const accounts = listDecryptedAccountsOnDisk(databasesDir);
result = {
status: "success",
@@ -394,10 +470,8 @@ function ensureOutputLink() {
if (!app.isPackaged) return;
const exeDir = getExeDir();
const dataDir = resolveDataDir();
if (!exeDir || !dataDir) return;
const target = path.join(dataDir, "output");
const target = resolveOutputDir();
if (!exeDir || !target) return;
const legacyLinkPath = path.join(exeDir, "output");
// Ensure the real output dir exists.
@@ -443,6 +517,11 @@ function ensureOutputLink() {
fs.writeFileSync(p, text, { encoding: "utf8" });
} catch {}
try {
const p = path.join(exeDir, "output-location.path");
fs.writeFileSync(p, `${target}\n`, { encoding: "utf8" });
} catch {}
try {
const p = path.join(exeDir, "open-output.cmd");
const text = `@echo off\r\nexplorer \"${target}\"\r\n`;
@@ -510,6 +589,12 @@ function loadDesktopSettings() {
ignoredUpdateVersion: "",
// Backend (FastAPI) listens on this port. Used in packaged builds.
backendPort: DEFAULT_BACKEND_PORT,
// Custom output dir; empty string means use the default dataDir/output.
outputDir: "",
// Pending output dir written by the installer before the next app startup.
pendingOutputDir: null,
// Last startup/apply failure when changing output dir.
lastOutputDirError: "",
// Tracks the packaged UI build so we can invalidate Chromium's HTTP cache
// after upgrades without wiping user data/localStorage.
lastSeenUiBuildId: "",
@@ -530,6 +615,12 @@ function loadDesktopSettings() {
const parsed = JSON.parse(raw || "{}");
desktopSettings = { ...defaults, ...(parsed && typeof parsed === "object" ? parsed : {}) };
desktopSettings.backendPort = parsePort(desktopSettings.backendPort) ?? defaults.backendPort;
desktopSettings.outputDir = safeNormalizeDirectory(desktopSettings.outputDir || "");
desktopSettings.pendingOutputDir =
parsed && typeof parsed === "object" && Object.prototype.hasOwnProperty.call(parsed, "pendingOutputDir")
? normalizePendingOutputDirValue(parsed.pendingOutputDir)
: defaults.pendingOutputDir;
desktopSettings.lastOutputDirError = String(desktopSettings.lastOutputDirError || "").trim();
} catch (err) {
desktopSettings = { ...defaults };
logMain(`[main] failed to load settings: ${err?.message || err}`);
@@ -551,6 +642,82 @@ function persistDesktopSettings() {
}
}
function snapshotOutputDirSettings() {
loadDesktopSettings();
return {
outputDir: desktopSettings.outputDir,
pendingOutputDir: desktopSettings.pendingOutputDir,
lastOutputDirError: desktopSettings.lastOutputDirError,
};
}
function restoreOutputDirSettings(snapshot) {
loadDesktopSettings();
desktopSettings.outputDir = safeNormalizeDirectory(snapshot?.outputDir || "");
desktopSettings.pendingOutputDir = normalizePendingOutputDirValue(snapshot?.pendingOutputDir);
desktopSettings.lastOutputDirError = String(snapshot?.lastOutputDirError || "").trim();
const effectiveOutputDir = desktopSettings.outputDir || getDefaultOutputDir() || "";
syncOutputDirEnv(effectiveOutputDir);
persistDesktopSettings();
}
function setOutputDirSetting(nextDir) {
loadDesktopSettings();
const defaultDir = getDefaultOutputDir();
const normalized = safeNormalizeDirectory(nextDir || "");
if (!normalized || (defaultDir && normalized === defaultDir)) {
desktopSettings.outputDir = "";
} else {
desktopSettings.outputDir = normalized;
}
syncOutputDirEnv(desktopSettings.outputDir || defaultDir || "");
persistDesktopSettings();
return desktopSettings.outputDir;
}
function setPendingOutputDirSetting(nextDir) {
loadDesktopSettings();
desktopSettings.pendingOutputDir = normalizePendingOutputDirValue(nextDir);
persistDesktopSettings();
return desktopSettings.pendingOutputDir;
}
function clearPendingOutputDirSetting() {
loadDesktopSettings();
desktopSettings.pendingOutputDir = null;
persistDesktopSettings();
}
function setOutputDirLastError(message) {
loadDesktopSettings();
desktopSettings.lastOutputDirError = String(message || "").trim();
persistDesktopSettings();
return desktopSettings.lastOutputDirError;
}
function getOutputDirInfo() {
loadDesktopSettings();
const defaultPath = getDefaultOutputDir() || "";
const currentPath = resolveOutputDir() || defaultPath;
const hasPending = desktopSettings.pendingOutputDir !== null;
const pendingPath =
desktopSettings.pendingOutputDir === null
? ""
: desktopSettings.pendingOutputDir === ""
? defaultPath
: safeNormalizeDirectory(desktopSettings.pendingOutputDir);
return {
path: currentPath || "",
defaultPath,
isDefault: !!currentPath && !!defaultPath && currentPath === defaultPath,
pendingPath,
hasPending,
lastError: String(desktopSettings.lastOutputDirError || "").trim(),
canChange: !!app.isPackaged,
changeUnavailableReason: app.isPackaged ? "" : "开发模式不支持界面修改 output 目录",
};
}
function getCloseBehavior() {
const v = String(loadDesktopSettings()?.closeBehavior || "").trim().toLowerCase();
return v === "exit" ? "exit" : "tray";
@@ -576,6 +743,137 @@ function setIgnoredUpdateVersion(version) {
return desktopSettings.ignoredUpdateVersion;
}
async function applyOutputDirChange(nextValue) {
if (!app.isPackaged) {
throw new Error("开发模式不支持界面修改 output 目录");
}
const defaultPath = getDefaultOutputDir();
const currentPath = resolveOutputDir();
if (!defaultPath || !currentPath) {
throw new Error("无法定位 output 目录");
}
const rawText = String(nextValue ?? "").trim();
const nextPath = rawText ? normalizeDirectoryPath(rawText) : defaultPath;
const previousSettings = snapshotOutputDirSettings();
if (nextPath === currentPath) {
setOutputDirSetting(nextPath);
clearPendingOutputDirSetting();
setOutputDirLastError("");
ensureOutputLink();
const info = getOutputDirInfo();
return {
success: true,
changed: false,
path: info.path,
defaultPath: info.defaultPath,
isDefault: info.isDefault,
pendingPath: info.pendingPath,
backupPath: "",
sourceWasEmpty: false,
message: "output 目录未变化",
};
}
const wasBackendRunning = !!backendProc;
let migration = null;
let settingsSwitched = false;
try {
if (wasBackendRunning) {
await stopBackendAndWait({ timeoutMs: 10_000 });
}
migration = migrateOutputDirectory({
currentDir: currentPath,
nextDir: nextPath,
});
setOutputDirSetting(nextPath);
clearPendingOutputDirSetting();
setOutputDirLastError("");
settingsSwitched = true;
ensureOutputLink();
if (wasBackendRunning) {
startBackend();
await waitForBackend({ timeoutMs: 30_000 });
}
const info = getOutputDirInfo();
return {
success: true,
changed: true,
path: info.path,
defaultPath: info.defaultPath,
isDefault: info.isDefault,
pendingPath: info.pendingPath,
backupPath: migration?.backupDir || "",
sourceWasEmpty: !!migration?.sourceWasEmpty,
message: migration?.sourceWasEmpty ? "output 目录已切换" : "output 目录已迁移并切换",
};
} catch (err) {
const message = err?.message || String(err);
let rollbackMessage = "";
if (migration?.changed) {
try {
rollbackOutputDirectoryChange({
previousDir: currentPath,
currentDir: nextPath,
backupDir: migration.backupDir,
sourceWasEmpty: migration.sourceWasEmpty,
});
} catch (rollbackErr) {
logMain(`[main] output dir rollback failed: ${rollbackErr?.message || rollbackErr}`);
rollbackMessage = `;回滚失败:${rollbackErr?.message || rollbackErr}`;
if (migration?.backupDir) {
rollbackMessage += `;备份目录:${migration.backupDir}`;
}
}
}
if (settingsSwitched) {
restoreOutputDirSettings(previousSettings);
} else {
syncOutputDirEnv(currentPath);
}
ensureOutputLink();
if (wasBackendRunning) {
try {
startBackend();
await waitForBackend({ timeoutMs: 30_000 });
} catch (restartErr) {
throw new Error(
`切换 output 目录失败:${message}${rollbackMessage};且旧后端恢复失败:${restartErr?.message || restartErr}`
);
}
}
if (rollbackMessage) {
throw new Error(`切换 output 目录失败:${message}${rollbackMessage}`);
}
throw err;
}
}
async function applyPendingOutputDirOnStartup() {
if (!app.isPackaged) return;
loadDesktopSettings();
if (desktopSettings.pendingOutputDir === null) return;
try {
await applyOutputDirChange(desktopSettings.pendingOutputDir);
} catch (err) {
clearPendingOutputDirSetting();
setOutputDirLastError(`安装时设置的 output 目录未能应用:${err?.message || err}`);
ensureOutputLink();
logMain(`[main] failed to apply pending output dir: ${err?.message || err}`);
}
}
async function refreshRendererCacheForPackagedUi() {
if (!app.isPackaged) return;
@@ -1171,11 +1469,11 @@ function startBackend() {
}
if (app.isPackaged) {
if (!env.WECHAT_TOOL_DATA_DIR) {
env.WECHAT_TOOL_DATA_DIR = app.getPath("userData");
}
env.WECHAT_TOOL_DATA_DIR = resolveDataDir() || app.getPath("userData");
env.WECHAT_TOOL_OUTPUT_DIR = resolveOutputDir() || getDefaultOutputDir() || path.join(env.WECHAT_TOOL_DATA_DIR, "output");
try {
fs.mkdirSync(env.WECHAT_TOOL_DATA_DIR, { recursive: true });
fs.mkdirSync(env.WECHAT_TOOL_OUTPUT_DIR, { recursive: true });
} catch {}
const backendExe = getPackagedBackendPath();
@@ -1689,16 +1987,31 @@ function registerWindowIpc() {
}
});
ipcMain.handle("app:getOutputDirInfo", () => {
try {
return getOutputDirInfo();
} catch (err) {
logMain(`[main] app:getOutputDirInfo failed: ${err?.message || err}`);
return {
path: "",
defaultPath: "",
isDefault: true,
pendingPath: "",
hasPending: false,
lastError: err?.message || String(err),
canChange: !!app.isPackaged,
changeUnavailableReason: app.isPackaged ? "" : "开发模式不支持界面修改 output 目录",
};
}
});
ipcMain.handle("app:getOutputDir", () => {
const dir = resolveDataDir();
if (!dir) return "";
return path.join(dir, "output");
return resolveOutputDir() || "";
});
ipcMain.handle("app:openOutputDir", async () => {
const dir = resolveDataDir();
if (!dir) throw new Error("无法定位数据目录");
const outDir = path.join(dir, "output");
const outDir = resolveOutputDir();
if (!outDir) throw new Error("无法定位 output 目录");
try {
fs.mkdirSync(outDir, { recursive: true });
} catch {}
@@ -1713,6 +2026,28 @@ function registerWindowIpc() {
}
});
ipcMain.handle("app:setOutputDir", async (_event, nextDir) => {
if (outputDirChangeInProgress) {
return {
success: false,
error: "output 目录切换中,请稍后重试",
};
}
outputDirChangeInProgress = true;
try {
return await applyOutputDirChange(nextDir);
} catch (err) {
const message = err?.message || String(err);
logMain(`[main] app:setOutputDir failed: ${message}`);
return {
success: false,
error: message,
};
} finally {
outputDirChangeInProgress = false;
}
});
ipcMain.handle("app:getAccountInfo", async (_event, account) => {
try {
return getAccountInfoFromDisk(account);
@@ -1796,6 +2131,8 @@ async function main() {
// Resolve/create the data dir early so we can log reliably and place helper files
// next to the installed exe for easier access.
resolveDataDir();
loadDesktopSettings();
await applyPendingOutputDirOnStartup();
ensureOutputLink();
await ensureBackendPortAvailableOnStartup();
@@ -1876,10 +2213,20 @@ if (gotSingleInstanceLock) {
stopBackend();
try {
const dir = getUserDataDir();
const outputDir = resolveOutputDir();
if (dir) {
const detailLines = [
`启动失败:${err?.message || err}`,
"",
`桌面日志目录:${dir}`,
"文件:desktop-main.log / backend-stdio.log",
];
if (outputDir) {
detailLines.push("", `当前 output 目录:${outputDir}`, "其中 output\\logs\\... 也在这里");
}
dialog.showErrorBox(
"WeChatDataAnalysis 启动失败",
`启动失败:${err?.message || err}\n\n请查看日志目录:\n${dir}\n\n文件:desktop-main.log / backend-stdio.log / output\\\\logs\\\\...`
detailLines.join("\n")
);
shell.openPath(dir);
}
+252
View File
@@ -0,0 +1,252 @@
const fs = require("fs");
const path = require("path");
const SENTINEL_NAMES = [
"account_keys.json",
"runtime_settings.json",
"message_edits.db",
"databases",
"exports",
"logs",
];
function normalizeDirectoryPath(value) {
const text = String(value || "").trim();
if (!text) return "";
const expanded = text.replace(/^~(?=$|[\\/])/, process.env.USERPROFILE || process.env.HOME || "~");
if (!path.isAbsolute(expanded)) {
throw new Error("output 目录必须使用绝对路径");
}
return path.resolve(expanded);
}
function getDefaultOutputDirPath(dataDir) {
const base = normalizeDirectoryPath(dataDir);
if (!base) throw new Error("无法定位数据目录");
return path.join(base, "output");
}
function getEffectiveOutputDirPath({ dataDir, envOutputDir, settingsOutputDir }) {
const envPath = normalizeDirectoryPath(envOutputDir || "");
if (envPath) return envPath;
const settingsPath = normalizeDirectoryPath(settingsOutputDir || "");
if (settingsPath) return settingsPath;
return getDefaultOutputDirPath(dataDir);
}
function hasDirectoryContents(dirPath) {
try {
return fs.readdirSync(dirPath).length > 0;
} catch (err) {
if (err && err.code === "ENOENT") return false;
throw err;
}
}
function pathExists(dirPath) {
try {
fs.accessSync(dirPath);
return true;
} catch {
return false;
}
}
function isDirectory(dirPath) {
try {
return fs.statSync(dirPath).isDirectory();
} catch {
return false;
}
}
function isPathInside(parentPath, candidatePath) {
const parent = path.resolve(parentPath);
const candidate = path.resolve(candidatePath);
if (parent === candidate) return false;
const relative = path.relative(parent, candidate);
return !!relative && !relative.startsWith("..") && !path.isAbsolute(relative);
}
function collectSentinels(sourceDir) {
const sentinels = [];
for (const name of SENTINEL_NAMES) {
const sourcePath = path.join(sourceDir, name);
if (!pathExists(sourcePath)) continue;
sentinels.push({
name,
isDir: isDirectory(sourcePath),
size: !isDirectory(sourcePath) ? fs.statSync(sourcePath).size : null,
});
}
return sentinels;
}
function verifyCopiedOutputTree(sourceDir, copiedDir) {
const sentinels = collectSentinels(sourceDir);
for (const item of sentinels) {
const copiedPath = path.join(copiedDir, item.name);
if (!pathExists(copiedPath)) {
throw new Error(`迁移校验失败:缺少 ${item.name}`);
}
if (item.isDir) {
if (!isDirectory(copiedPath)) {
throw new Error(`迁移校验失败:${item.name} 不是目录`);
}
continue;
}
const copiedStat = fs.statSync(copiedPath);
if (copiedStat.size !== item.size) {
throw new Error(`迁移校验失败:${item.name} 大小不一致`);
}
}
}
function makeTimestamp(now = new Date()) {
const parts = [
now.getFullYear(),
String(now.getMonth() + 1).padStart(2, "0"),
String(now.getDate()).padStart(2, "0"),
String(now.getHours()).padStart(2, "0"),
String(now.getMinutes()).padStart(2, "0"),
String(now.getSeconds()).padStart(2, "0"),
];
return parts.join("");
}
function makeUniqueSiblingPath(basePath, suffix, now = new Date()) {
const stamp = makeTimestamp(now);
let attempt = 0;
while (true) {
const candidate = `${basePath}.${suffix}-${stamp}${attempt ? `-${attempt}` : ""}`;
if (!pathExists(candidate)) return candidate;
attempt += 1;
}
}
function ensureTargetIsUsable(targetDir) {
if (!pathExists(targetDir)) return;
if (!isDirectory(targetDir)) {
throw new Error("目标 output 路径已存在且不是目录");
}
if (hasDirectoryContents(targetDir)) {
throw new Error("目标 output 目录已有内容,请先清空后再重试");
}
}
function migrateOutputDirectory({ currentDir, nextDir, now = new Date() }) {
const currentPath = normalizeDirectoryPath(currentDir);
const targetPath = normalizeDirectoryPath(nextDir);
if (!currentPath || !targetPath) {
throw new Error("output 路径不能为空");
}
if (currentPath === targetPath) {
return {
changed: false,
currentDir: currentPath,
targetDir: targetPath,
sourceWasEmpty: !hasDirectoryContents(currentPath),
backupDir: "",
};
}
if (isPathInside(currentPath, targetPath) || isPathInside(targetPath, currentPath)) {
throw new Error("新旧 output 路径不能互相包含");
}
ensureTargetIsUsable(targetPath);
const sourceExists = pathExists(currentPath);
if (sourceExists && !isDirectory(currentPath)) {
throw new Error("当前 output 路径不是目录");
}
const sourceWasEmpty = !sourceExists || !hasDirectoryContents(currentPath);
if (sourceWasEmpty) {
fs.mkdirSync(targetPath, { recursive: true });
return {
changed: true,
currentDir: currentPath,
targetDir: targetPath,
sourceWasEmpty: true,
backupDir: "",
};
}
const tempTarget = makeUniqueSiblingPath(targetPath, "migrating", now);
const backupDir = makeUniqueSiblingPath(currentPath, "backup", now);
fs.cpSync(currentPath, tempTarget, {
recursive: true,
force: false,
errorOnExist: true,
preserveTimestamps: true,
});
try {
verifyCopiedOutputTree(currentPath, tempTarget);
if (pathExists(targetPath)) {
fs.rmSync(targetPath, { recursive: true, force: true });
}
fs.renameSync(currentPath, backupDir);
try {
fs.renameSync(tempTarget, targetPath);
} catch (err) {
try {
if (!pathExists(currentPath) && pathExists(backupDir)) {
fs.renameSync(backupDir, currentPath);
}
} catch {}
throw err;
}
} catch (err) {
try {
if (pathExists(tempTarget)) {
fs.rmSync(tempTarget, { recursive: true, force: true });
}
} catch {}
throw err;
}
return {
changed: true,
currentDir: currentPath,
targetDir: targetPath,
sourceWasEmpty: false,
backupDir,
};
}
function rollbackOutputDirectoryChange({ previousDir, currentDir, backupDir, sourceWasEmpty }) {
const previousPath = normalizeDirectoryPath(previousDir);
const currentPath = normalizeDirectoryPath(currentDir);
try {
if (currentPath && pathExists(currentPath)) {
fs.rmSync(currentPath, { recursive: true, force: true });
}
} catch {}
if (sourceWasEmpty) {
return;
}
const backupPath = normalizeDirectoryPath(backupDir);
if (!backupPath || !pathExists(backupPath)) return;
try {
if (!pathExists(previousPath)) {
fs.renameSync(backupPath, previousPath);
}
} catch {}
}
module.exports = {
getDefaultOutputDirPath,
getEffectiveOutputDirPath,
hasDirectoryContents,
migrateOutputDirectory,
normalizeDirectoryPath,
rollbackOutputDirectoryChange,
};
+2
View File
@@ -82,7 +82,9 @@ contextBridge.exposeInMainWorld("wechatDesktop", {
chooseDirectory: (options = {}) => ipcRenderer.invoke("dialog:chooseDirectory", options),
// Data/output folder helpers
getOutputDirInfo: () => ipcRenderer.invoke("app:getOutputDirInfo"),
getOutputDir: () => ipcRenderer.invoke("app:getOutputDir"),
setOutputDir: (dir) => ipcRenderer.invoke("app:setOutputDir", String(dir ?? "")),
openOutputDir: () => ipcRenderer.invoke("app:openOutputDir"),
getAccountInfo: (account) => ipcRenderer.invoke("app:getAccountInfo", String(account || "")),
deleteAccountData: (account) => ipcRenderer.invoke("app:deleteAccountData", String(account || "")),
+162
View File
@@ -0,0 +1,162 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("fs");
const os = require("os");
const path = require("path");
const {
getDefaultOutputDirPath,
getEffectiveOutputDirPath,
migrateOutputDirectory,
normalizeDirectoryPath,
rollbackOutputDirectoryChange,
} = require("../src/output-dir.cjs");
function makeTempDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), "wda-output-"));
}
function cleanupDir(dirPath) {
try {
fs.rmSync(dirPath, { recursive: true, force: true });
} catch {}
}
test("normalizeDirectoryPath requires absolute paths", () => {
assert.throws(() => normalizeDirectoryPath("relative/path"), /绝对路径/);
});
test("getEffectiveOutputDirPath prefers env, then settings, then default", () => {
const root = makeTempDir();
const envDir = path.join(root, "env-output");
const settingsDir = path.join(root, "settings-output");
const defaultDir = path.join(root, "data", "output");
try {
assert.equal(
getEffectiveOutputDirPath({
dataDir: path.join(root, "data"),
envOutputDir: envDir,
settingsOutputDir: settingsDir,
}),
path.resolve(envDir)
);
assert.equal(
getEffectiveOutputDirPath({
dataDir: path.join(root, "data"),
envOutputDir: "",
settingsOutputDir: settingsDir,
}),
path.resolve(settingsDir)
);
assert.equal(getDefaultOutputDirPath(path.join(root, "data")), path.resolve(defaultDir));
} finally {
cleanupDir(root);
}
});
test("migrateOutputDirectory switches empty source to a new directory", () => {
const root = makeTempDir();
const currentDir = path.join(root, "current-output");
const nextDir = path.join(root, "custom-output");
try {
fs.mkdirSync(currentDir, { recursive: true });
const result = migrateOutputDirectory({ currentDir, nextDir });
assert.equal(result.changed, true);
assert.equal(result.sourceWasEmpty, true);
assert.equal(result.backupDir, "");
assert.ok(fs.existsSync(nextDir));
assert.equal(fs.existsSync(currentDir), true);
} finally {
cleanupDir(root);
}
});
test("migrateOutputDirectory blocks non-empty targets", () => {
const root = makeTempDir();
const currentDir = path.join(root, "current-output");
const nextDir = path.join(root, "custom-output");
try {
fs.mkdirSync(path.join(currentDir, "logs"), { recursive: true });
fs.writeFileSync(path.join(currentDir, "runtime_settings.json"), "{}");
fs.mkdirSync(nextDir, { recursive: true });
fs.writeFileSync(path.join(nextDir, "existing.txt"), "occupied");
assert.throws(
() => migrateOutputDirectory({ currentDir, nextDir }),
/已有内容/
);
} finally {
cleanupDir(root);
}
});
test("migrateOutputDirectory blocks invalid current paths", () => {
const root = makeTempDir();
const currentDir = path.join(root, "current-output");
const nextDir = path.join(root, "custom-output");
try {
fs.writeFileSync(currentDir, "not-a-directory");
assert.throws(
() => migrateOutputDirectory({ currentDir, nextDir }),
/不是目录/
);
} finally {
cleanupDir(root);
}
});
test("migrateOutputDirectory copies data and leaves the old directory as a backup", () => {
const root = makeTempDir();
const currentDir = path.join(root, "current-output");
const nextDir = path.join(root, "custom-output");
try {
fs.mkdirSync(path.join(currentDir, "databases", "wxid_test"), { recursive: true });
fs.writeFileSync(path.join(currentDir, "runtime_settings.json"), "{\"backend_port\":10392}");
fs.writeFileSync(path.join(currentDir, "databases", "wxid_test", "session.db"), "session");
fs.writeFileSync(path.join(currentDir, "databases", "wxid_test", "contact.db"), "contact");
const result = migrateOutputDirectory({ currentDir, nextDir, now: new Date("2026-03-30T08:00:00Z") });
assert.equal(result.changed, true);
assert.equal(result.sourceWasEmpty, false);
assert.match(path.basename(result.backupDir), /^current-output\.backup-\d{14}$/);
assert.ok(fs.existsSync(nextDir));
assert.ok(fs.existsSync(path.join(nextDir, "runtime_settings.json")));
assert.ok(fs.existsSync(path.join(nextDir, "databases", "wxid_test", "session.db")));
assert.ok(fs.existsSync(result.backupDir));
assert.equal(fs.existsSync(currentDir), false);
} finally {
cleanupDir(root);
}
});
test("rollbackOutputDirectoryChange restores the previous directory", () => {
const root = makeTempDir();
const previousDir = path.join(root, "current-output");
const currentDir = path.join(root, "custom-output");
const backupDir = path.join(root, "current-output.backup-20260330080100");
try {
fs.mkdirSync(path.join(currentDir, "databases"), { recursive: true });
fs.writeFileSync(path.join(currentDir, "databases", "temp.db"), "temp");
fs.mkdirSync(path.join(backupDir, "databases"), { recursive: true });
fs.writeFileSync(path.join(backupDir, "databases", "session.db"), "restored");
rollbackOutputDirectoryChange({
previousDir,
currentDir,
backupDir,
sourceWasEmpty: false,
});
assert.equal(fs.existsSync(currentDir), false);
assert.ok(fs.existsSync(path.join(previousDir, "databases", "session.db")));
assert.equal(fs.existsSync(backupDir), false);
} finally {
cleanupDir(root);
}
});
+170 -15
View File
@@ -4,9 +4,9 @@
class="settings-dialog fixed inset-0 z-[120] flex items-center justify-center bg-black/40 px-4 py-4 backdrop-blur-md sm:py-8"
@click.self="handleClose"
>
<div class="settings-dialog-panel flex h-[80vh] min-h-[380px] w-full max-w-[760px] overflow-hidden rounded-[10px] border border-[#e2e2e2] bg-white shadow-2xl">
<div class="settings-dialog-panel flex h-[80vh] min-h-[380px] w-full max-w-[880px] overflow-hidden rounded-[10px] border border-[#e2e2e2] bg-white shadow-2xl">
<!-- Sidebar -->
<aside class="flex w-[180px] shrink-0 flex-col bg-[#fcfcfc] border-r border-[#eeeeee]">
<aside class="flex w-[160px] shrink-0 flex-col bg-[#fcfcfc] border-r border-[#eeeeee]">
<div class="mt-4 mb-2 flex items-center px-4 gap-2">
<div class="flex h-6 w-6 items-center justify-center rounded-[5px] bg-[#e7f5ee] text-[#07b75b]">
<svg class="h-[15px] w-[15px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
@@ -149,19 +149,74 @@
</div>
<div class="px-3.5 py-3">
<div class="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">output 目录</div>
<div class="mt-0.5 text-[11px] text-[#909090] break-words">{{ desktopOutputDirText }}</div>
<div class="flex flex-col gap-2.5">
<div class="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">output 目录</div>
<div class="mt-0.5 text-[11px] text-[#909090] break-words">
当前{{ desktopOutputDirText }}
<span class="ml-1 text-[#666]">{{ desktopOutputDirIsDefault ? '(默认位置)' : '(自定义位置)' }}</span>
</div>
<div class="mt-0.5 text-[11px] text-[#909090] break-words">默认{{ desktopOutputDirDefaultText }}</div>
<div v-if="desktopOutputDirPendingText" class="mt-0.5 text-[11px] text-amber-700 break-words">
待应用{{ desktopOutputDirPendingText }}
</div>
<div v-if="desktopOutputDirUnavailableReason" class="mt-1 text-[11px] text-amber-700 break-words">
{{ desktopOutputDirUnavailableReason }}
</div>
</div>
<button
type="button"
class="shrink-0 rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!isDesktopEnv || desktopOutputDirLoading || desktopOutputDirApplying"
@click="onDesktopOpenOutputDir"
>
打开当前 output
</button>
</div>
<div class="flex flex-col gap-1.5 sm:flex-row sm:items-center">
<input
v-model="desktopOutputDirInput"
type="text"
spellcheck="false"
class="min-w-0 flex-1 rounded-[6px] border border-[#e2e2e2] bg-white px-2.5 py-1.5 text-[12px] text-[#333] outline-none transition focus:border-[#07b75b] focus:ring-1 focus:ring-[#07b75b]/30"
:disabled="desktopOutputDirControlsDisabled"
:placeholder="desktopOutputDirCanChange ? '选择新的 output 目录' : '当前环境不支持修改 output 目录'"
@keyup.enter="onDesktopOutputDirApply"
/>
<div class="flex shrink-0 items-center gap-1.5">
<button
type="button"
class="rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
:disabled="desktopOutputDirControlsDisabled"
@click="onDesktopChooseOutputDir"
>
选择文件夹
</button>
<button
type="button"
class="rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
:disabled="desktopOutputDirControlsDisabled"
@click="onDesktopOutputDirApply"
>
{{ desktopOutputDirApplying ? '迁移中...' : '应用' }}
</button>
<button
type="button"
class="rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
:disabled="desktopOutputDirControlsDisabled"
@click="onDesktopOutputDirReset"
>
恢复默认
</button>
</div>
</div>
<div v-if="desktopOutputDirCanChange" class="text-[11px] text-[#909090]">
修改后会迁移整个 output 目录如果目标目录已有内容会先阻止并提示
</div>
<div v-if="desktopOutputDirMessage" class="rounded-[6px] border border-[#d8efe2] bg-[#f4fbf7] px-2.5 py-1.5 text-[11px] text-[#1b6b43] whitespace-pre-wrap">
{{ desktopOutputDirMessage }}
</div>
<button
type="button"
class="shrink-0 rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!isDesktopEnv || desktopOutputDirLoading"
@click="onDesktopOpenOutputDir"
>
打开 output
</button>
</div>
<div v-if="desktopOutputDirError" class="mt-1.5 text-[11px] text-red-600 whitespace-pre-wrap">
{{ desktopOutputDirError }}
@@ -345,13 +400,33 @@ const desktopBackendPortError = ref('')
const desktopBackendPortDefault = ref(10392)
const desktopOutputDir = ref('')
const desktopOutputDirDefault = ref('')
const desktopOutputDirInput = ref('')
const desktopOutputDirPending = ref('')
const desktopOutputDirLoading = ref(false)
const desktopOutputDirApplying = ref(false)
const desktopOutputDirError = ref('')
const desktopOutputDirMessage = ref('')
const desktopOutputDirIsDefault = ref(true)
const desktopOutputDirCanChange = ref(true)
const desktopOutputDirUnavailableReason = ref('')
const desktopOutputDirText = computed(() => {
if (!isDesktopEnv.value) return '仅桌面端可用'
const v = String(desktopOutputDir.value || '').trim()
return v || '—'
})
const desktopOutputDirDefaultText = computed(() => {
if (!isDesktopEnv.value) return '仅桌面端可用'
const v = String(desktopOutputDirDefault.value || '').trim()
return v || '—'
})
const desktopOutputDirPendingText = computed(() => {
const v = String(desktopOutputDirPending.value || '').trim()
return v || ''
})
const desktopOutputDirControlsDisabled = computed(() => (
!isDesktopEnv.value || !desktopOutputDirCanChange.value || desktopOutputDirLoading.value || desktopOutputDirApplying.value
))
const desktopLogFilePath = ref('')
const desktopLogFileLoading = ref(false)
@@ -530,12 +605,33 @@ const refreshDesktopBackendPort = async () => {
const refreshDesktopOutputDir = async () => {
if (!process.client || typeof window === 'undefined') return
if (!window.wechatDesktop?.getOutputDir) return
if (!window.wechatDesktop?.getOutputDir && !window.wechatDesktop?.getOutputDirInfo) return
desktopOutputDirLoading.value = true
desktopOutputDirError.value = ''
try {
if (window.wechatDesktop?.getOutputDirInfo) {
const info = await window.wechatDesktop.getOutputDirInfo()
desktopOutputDir.value = String(info?.path || '').trim()
desktopOutputDirDefault.value = String(info?.defaultPath || '').trim()
desktopOutputDirPending.value = String(info?.pendingPath || '').trim()
desktopOutputDirIsDefault.value = !!info?.isDefault
desktopOutputDirCanChange.value = info?.canChange !== false
desktopOutputDirUnavailableReason.value = String(info?.changeUnavailableReason || '').trim()
desktopOutputDirInput.value = desktopOutputDir.value || desktopOutputDirDefault.value
if (info?.lastError) {
desktopOutputDirError.value = String(info.lastError || '').trim()
}
return
}
const v = await window.wechatDesktop.getOutputDir()
desktopOutputDir.value = String(v || '').trim()
desktopOutputDirDefault.value = desktopOutputDir.value
desktopOutputDirPending.value = ''
desktopOutputDirIsDefault.value = true
desktopOutputDirCanChange.value = false
desktopOutputDirUnavailableReason.value = '当前桌面环境不支持修改 output 目录'
desktopOutputDirInput.value = desktopOutputDir.value
} catch (e) {
desktopOutputDirError.value = e?.message || '读取 output 目录失败'
} finally {
@@ -558,6 +654,62 @@ const onDesktopOpenOutputDir = async () => {
}
}
const onDesktopChooseOutputDir = async () => {
if (!process.client || typeof window === 'undefined') return
if (!window.wechatDesktop?.chooseDirectory) return
desktopOutputDirError.value = ''
desktopOutputDirMessage.value = ''
try {
const result = await window.wechatDesktop.chooseDirectory({ title: '选择新的 output 目录' })
if (result && !result.canceled && Array.isArray(result.filePaths) && result.filePaths.length > 0) {
desktopOutputDirInput.value = String(result.filePaths[0] || '').trim()
}
} catch (e) {
desktopOutputDirError.value = e?.message || '选择 output 目录失败'
}
}
const applyDesktopOutputDir = async (nextDir) => {
if (!process.client || typeof window === 'undefined') return
if (!window.wechatDesktop?.setOutputDir) {
desktopOutputDirError.value = '当前桌面环境不支持修改 output 目录'
return
}
if (!desktopOutputDirCanChange.value) {
desktopOutputDirError.value = desktopOutputDirUnavailableReason.value || '开发模式不支持界面修改 output 目录'
return
}
desktopOutputDirApplying.value = true
desktopOutputDirError.value = ''
desktopOutputDirMessage.value = ''
try {
const res = await window.wechatDesktop.setOutputDir(String(nextDir ?? '').trim())
if (res?.success === false) {
desktopOutputDirError.value = String(res?.error || '修改 output 目录失败').trim()
await refreshDesktopOutputDir()
return
}
await refreshDesktopOutputDir()
desktopOutputDirMessage.value = String(
res?.message || (res?.changed === false ? 'output 目录未变化' : 'output 目录已更新')
).trim()
} catch (e) {
desktopOutputDirError.value = e?.message || '修改 output 目录失败'
await refreshDesktopOutputDir()
} finally {
desktopOutputDirApplying.value = false
}
}
const onDesktopOutputDirApply = async () => {
await applyDesktopOutputDir(desktopOutputDirInput.value)
}
const onDesktopOutputDirReset = async () => {
desktopOutputDirInput.value = desktopOutputDirDefault.value
await applyDesktopOutputDir('')
}
const refreshBackendLogFileInfo = async () => {
if (!process.client || typeof window === 'undefined') return
desktopLogFileLoading.value = true
@@ -702,6 +854,9 @@ const onDesktopCheckUpdates = async () => {
watch(() => props.open, async (isOpen) => {
if (!isOpen) return
await refreshBackendLogFileInfo()
if (isDesktopEnv.value) {
await refreshDesktopOutputDir()
}
}, { immediate: true })
onMounted(async () => {
+41 -1
View File
@@ -74,6 +74,7 @@ export const useChatMessages = ({
let highlightTimer = null
const messageTypeFilter = ref('all')
const localMediaVersion = ref(0)
const messageTypeFilterOptions = [
{ value: 'all', label: '全部' },
{ value: 'text', label: '文本' },
@@ -95,9 +96,39 @@ export const useChatMessages = ({
const normalizeMessage = createMessageNormalizer({
apiBase,
getSelectedAccount: () => selectedAccount.value,
getSelectedContact: () => selectedContact.value
getSelectedContact: () => selectedContact.value,
getLocalMediaVersion: () => localMediaVersion.value
})
const bumpLocalMediaVersion = () => {
localMediaVersion.value = (localMediaVersion.value + 1) % 1000000000
return localMediaVersion.value
}
const renormalizeLoadedMessages = (username) => {
const key = String(username || '').trim()
if (!key) return
const existing = allMessages.value[key]
if (!Array.isArray(existing) || !existing.length) return
const refreshed = dedupeMessagesById(existing.map((message) => {
const normalized = normalizeMessage(message)
return {
...message,
...normalized,
_emojiDownloading: !!message?._emojiDownloading,
_emojiDownloaded: typeof message?._emojiDownloaded === 'boolean' ? message._emojiDownloaded : normalized._emojiDownloaded,
_quoteImageError: false,
_quoteThumbError: false
}
}))
allMessages.value = {
...allMessages.value,
[key]: refreshed
}
}
const messages = computed(() => {
if (!selectedContact.value) return []
return allMessages.value[selectedContact.value.username] || []
@@ -534,9 +565,17 @@ export const useChatMessages = ({
const refreshSelectedMessages = async () => {
if (!selectedContact.value) return
bumpLocalMediaVersion()
await loadMessages({ username: selectedContact.value.username, reset: true })
}
const refreshCurrentMessageMedia = async () => {
if (!selectedContact.value?.username) return
bumpLocalMediaVersion()
renormalizeLoadedMessages(selectedContact.value.username)
await nextTick()
}
const refreshRealtimeIncremental = async () => {
if (!realtimeEnabled.value || !selectedAccount.value || !selectedContact.value?.username) return
if (searchContext.value?.active || isLoadingMessages.value) return
@@ -912,6 +951,7 @@ export const useChatMessages = ({
loadMessages,
loadMoreMessages,
refreshSelectedMessages,
refreshCurrentMessageMedia,
refreshRealtimeIncremental,
queueRealtimeRefresh,
tryEnableRealtimeAuto,
+8 -4
View File
@@ -17,11 +17,12 @@ const buildAccountMediaUrl = (apiBase, path, parts) => {
return `${apiBase}${path}?${parts.filter(Boolean).join('&')}`
}
export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelectedContact }) => {
export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelectedContact, getLocalMediaVersion }) => {
return (msg) => {
const account = String(getSelectedAccount?.() || '').trim()
const contact = getSelectedContact?.() || null
const username = String(contact?.username || '').trim()
const localMediaVersion = Number(getLocalMediaVersion?.() || 0)
const isSent = !!msg.isSent
const sender = isSent ? '我' : (msg.senderDisplayName || msg.senderUsername || contact?.name || '')
const fallbackAvatar = (!isSent && !contact?.isGroup) ? (contact?.avatar || null) : null
@@ -66,7 +67,8 @@ export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelect
`account=${encodeURIComponent(account)}`,
msg.imageMd5 ? `md5=${encodeURIComponent(msg.imageMd5)}` : '',
msg.imageFileId ? `file_id=${encodeURIComponent(msg.imageFileId)}` : '',
`username=${encodeURIComponent(username)}`
`username=${encodeURIComponent(username)}`,
localMediaVersion > 0 ? `v=${encodeURIComponent(String(localMediaVersion))}` : ''
])
})()
@@ -86,7 +88,8 @@ export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelect
`account=${encodeURIComponent(account)}`,
msg.videoThumbMd5 ? `md5=${encodeURIComponent(msg.videoThumbMd5)}` : '',
msg.videoThumbFileId ? `file_id=${encodeURIComponent(msg.videoThumbFileId)}` : '',
`username=${encodeURIComponent(username)}`
`username=${encodeURIComponent(username)}`,
localMediaVersion > 0 ? `v=${encodeURIComponent(String(localMediaVersion))}` : ''
])
})()
@@ -158,7 +161,8 @@ export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelect
return buildAccountMediaUrl(apiBase, '/chat/media/image', [
`account=${encodeURIComponent(account)}`,
`server_id=${encodeURIComponent(quoteServerIdStr)}`,
username ? `username=${encodeURIComponent(username)}` : ''
username ? `username=${encodeURIComponent(username)}` : '',
localMediaVersion > 0 ? `v=${encodeURIComponent(String(localMediaVersion))}` : ''
])
})()
+27
View File
@@ -216,6 +216,7 @@ const {
loadMessages,
loadMoreMessages,
refreshSelectedMessages,
refreshCurrentMessageMedia,
queueRealtimeRefresh,
tryEnableRealtimeAuto,
resetMessageState,
@@ -568,6 +569,28 @@ const onGlobalKeyDown = (event) => {
}
}
let lastResumeMediaRefreshAt = 0
const maybeRefreshMediaOnResume = () => {
if (!process.client) return
if (!selectedContact.value?.username) return
if (searchContext.value?.active) return
const now = Date.now()
if ((now - lastResumeMediaRefreshAt) < 1200) return
lastResumeMediaRefreshAt = now
void refreshCurrentMessageMedia()
}
const onWindowFocus = () => {
maybeRefreshMediaOnResume()
}
const onVisibilityChange = () => {
if (document.visibilityState !== 'visible') return
maybeRefreshMediaOnResume()
}
onMounted(async () => {
if (!process.client) return
@@ -585,6 +608,8 @@ onMounted(async () => {
document.addEventListener('touchmove', onFloatingWindowMouseMove)
document.addEventListener('touchend', onFloatingWindowMouseUp)
document.addEventListener('touchcancel', onFloatingWindowMouseUp)
window.addEventListener('focus', onWindowFocus)
document.addEventListener('visibilitychange', onVisibilityChange)
logChatBootstrap('loadContacts:start', {
selectedAccount: selectedAccount.value
@@ -635,6 +660,8 @@ onUnmounted(() => {
document.removeEventListener('touchmove', onFloatingWindowMouseMove)
document.removeEventListener('touchend', onFloatingWindowMouseUp)
document.removeEventListener('touchcancel', onFloatingWindowMouseUp)
window.removeEventListener('focus', onWindowFocus)
document.removeEventListener('visibilitychange', onVisibilityChange)
if (locateServerIdTimer) clearTimeout(locateServerIdTimer)
locateServerIdTimer = null
+8 -3
View File
@@ -3,6 +3,9 @@ from __future__ import annotations
import os
from pathlib import Path
ENV_DATA_DIR_KEY = "WECHAT_TOOL_DATA_DIR"
ENV_OUTPUT_DIR_KEY = "WECHAT_TOOL_OUTPUT_DIR"
def get_data_dir() -> Path:
"""Base writable directory for all runtime output (logs, databases, key store).
@@ -12,13 +15,16 @@ def get_data_dir() -> Path:
- Dev defaults to the current working directory (repo root).
"""
v = os.environ.get("WECHAT_TOOL_DATA_DIR", "").strip()
v = os.environ.get(ENV_DATA_DIR_KEY, "").strip()
if v:
return Path(v)
return Path(v).expanduser()
return Path.cwd()
def get_output_dir() -> Path:
v = os.environ.get(ENV_OUTPUT_DIR_KEY, "").strip()
if v:
return Path(v).expanduser()
return get_data_dir() / "output"
@@ -28,4 +34,3 @@ def get_output_databases_dir() -> Path:
def get_account_keys_path() -> Path:
return get_output_dir() / "account_keys.json"
+120 -13
View File
@@ -67,6 +67,87 @@ logger = get_logger(__name__)
router = APIRouter(route_class=PathFixRoute)
def _build_uncached_media_response(data: bytes, media_type: str) -> Response:
resp = Response(content=data, media_type=media_type)
resp.headers["Cache-Control"] = "no-store"
return resp
def _image_candidate_variant_rank(path: Path) -> int:
stem = str(path.stem or "").lower()
if stem.endswith(("_b", ".b")):
return 0
if stem.endswith(("_h", ".h")):
return 1
if stem.endswith(("_c", ".c")):
return 3
if stem.endswith(("_t", ".t")):
return 4
return 2
def _image_candidate_stat(path: Optional[Path]) -> tuple[int, float]:
if not path:
return 0, 0.0
try:
st = path.stat()
return int(st.st_size), float(st.st_mtime)
except Exception:
return 0, 0.0
def _should_prefer_live_image_candidates(
*,
cached_path: Optional[Path],
live_candidates: list[Path],
) -> bool:
if not live_candidates:
return False
if not cached_path:
return True
best_live = live_candidates[0]
live_rank = _image_candidate_variant_rank(best_live)
if live_rank < 2:
return True
cache_size, cache_mtime = _image_candidate_stat(cached_path)
live_size, live_mtime = _image_candidate_stat(best_live)
if live_rank == 2 and live_size > cache_size:
return True
if live_rank == 2 and live_size >= cache_size and live_mtime > cache_mtime:
return True
return False
def _write_cached_chat_image(account_dir: Path, md5: str, data: bytes) -> None:
md5_norm = str(md5 or "").strip().lower()
if (not md5_norm) or (not data):
return
ext = _detect_image_extension(data)
out_path = _get_decrypted_resource_path(account_dir, md5_norm, ext)
out_path.parent.mkdir(parents=True, exist_ok=True)
for stale_ext in ("jpg", "png", "gif", "webp", "dat"):
stale_path = _get_decrypted_resource_path(account_dir, md5_norm, stale_ext)
if stale_path == out_path:
continue
try:
if stale_path.exists():
stale_path.unlink()
except Exception:
pass
try:
if out_path.exists() and out_path.read_bytes() == data:
return
except Exception:
pass
out_path.write_bytes(data)
def _resolve_avatar_remote_url(*, account_dir: Path, username: str) -> str:
u = str(username or "").strip()
if not u:
@@ -1311,20 +1392,26 @@ async def get_chat_image(
if md5_from_msg:
md5 = md5_from_msg
# md5 模式:优先从解密资源目录读取(更快)
cached_path: Optional[Path] = None
cached_data = b""
cached_media_type = "application/octet-stream"
# md5 模式:优先检查解密资源目录;如果微信目录里已经有更高质量版本,会在后面自动升级。
if md5:
decrypted_path = _try_find_decrypted_resource(account_dir, str(md5).lower())
if decrypted_path:
data = decrypted_path.read_bytes()
media_type = _detect_image_media_type(data[:32])
if media_type != "application/octet-stream" and _is_probably_valid_image(data, media_type):
return Response(content=data, media_type=media_type)
cached_path = decrypted_path
cached_data = data
cached_media_type = media_type
# Corrupted cached file (e.g. wrong ext / partial data): remove and regenerate from source.
try:
if decrypted_path.suffix.lower() in {".jpg", ".jpeg", ".png", ".gif", ".webp"}:
elif decrypted_path.suffix.lower() in {".jpg", ".jpeg", ".png", ".gif", ".webp"}:
try:
decrypted_path.unlink()
except Exception:
pass
except Exception:
pass
# 回退:从微信数据目录实时定位并解密
wxid_dir = _resolve_account_wxid_dir(account_dir)
@@ -1414,11 +1501,36 @@ async def get_chat_image(
break
if not p:
if cached_path:
return _build_uncached_media_response(cached_data, cached_media_type)
raise HTTPException(status_code=404, detail="Image not found.")
candidates.extend(_iter_media_source_candidates(p))
candidates = _order_media_candidates(candidates)
if cached_path:
try:
cached_key = str(cached_path.resolve())
except Exception:
cached_key = str(cached_path)
live_candidates: list[Path] = []
seen_live: set[str] = set()
for candidate in candidates:
try:
key = str(candidate.resolve())
except Exception:
key = str(candidate)
if key == cached_key or key in seen_live:
continue
seen_live.add(key)
live_candidates.append(candidate)
if _should_prefer_live_image_candidates(cached_path=cached_path, live_candidates=live_candidates):
candidates = [*live_candidates, cached_path]
else:
candidates = [cached_path, *live_candidates]
logger.info(f"chat_image: md5={md5} file_id={file_id} candidates={len(candidates)} first={p}")
data = b""
@@ -1443,19 +1555,14 @@ async def get_chat_image(
# 仅在 md5 有效时缓存到 resource 目录;file_id 可能非常长,避免写入超长文件名
if md5 and media_type.startswith("image/"):
try:
out_md5 = str(md5).lower()
ext = _detect_image_extension(data)
out_path = _get_decrypted_resource_path(account_dir, out_md5, ext)
out_path.parent.mkdir(parents=True, exist_ok=True)
if not out_path.exists():
out_path.write_bytes(data)
_write_cached_chat_image(account_dir, str(md5), data)
except Exception:
pass
logger.info(
f"chat_image: md5={md5} file_id={file_id} chosen={chosen} media_type={media_type} bytes={len(data)}"
)
return Response(content=data, media_type=media_type)
return _build_uncached_media_response(data, media_type)
@router.get("/api/chat/media/emoji", summary="获取表情消息资源")
@@ -0,0 +1,210 @@
import hashlib
import importlib
import json
import logging
import os
import sqlite3
import sys
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from fastapi import FastAPI
from fastapi.testclient import TestClient
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
class TestChatMediaImageCacheUpgrade(unittest.TestCase):
def _seed_contact_db(self, path: Path, *, account: str, username: str) -> None:
conn = sqlite3.connect(str(path))
try:
conn.execute(
"""
CREATE TABLE contact (
username TEXT,
remark TEXT,
nick_name TEXT,
alias TEXT,
local_type INTEGER,
verify_flag INTEGER,
big_head_url TEXT,
small_head_url TEXT
)
"""
)
conn.execute(
"""
CREATE TABLE stranger (
username TEXT,
remark TEXT,
nick_name TEXT,
alias TEXT,
local_type INTEGER,
verify_flag INTEGER,
big_head_url TEXT,
small_head_url TEXT
)
"""
)
conn.execute(
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(account, "", "", "", 1, 0, "", ""),
)
conn.execute(
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(username, "", "测试好友", "", 1, 0, "", ""),
)
conn.commit()
finally:
conn.close()
def _seed_session_db(self, path: Path, *, username: str) -> None:
conn = sqlite3.connect(str(path))
try:
conn.execute(
"""
CREATE TABLE SessionTable (
username TEXT,
is_hidden INTEGER,
sort_timestamp INTEGER
)
"""
)
conn.execute("INSERT INTO SessionTable VALUES (?, ?, ?)", (username, 0, 1735689600))
conn.commit()
finally:
conn.close()
def _seed_source_info(self, account_dir: Path, *, wxid_dir: Path) -> None:
payload = {
"wxid_dir": str(wxid_dir),
"db_storage_path": "",
}
(account_dir / "_source.json").write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
def _seed_cached_resource(self, account_dir: Path, *, md5: str, payload: bytes) -> Path:
resource_dir = account_dir / "resource" / md5[:2]
resource_dir.mkdir(parents=True, exist_ok=True)
target = resource_dir / f"{md5}.jpg"
target.write_bytes(payload)
return target
def _seed_live_variant(self, wxid_dir: Path, *, username: str, md5: str, suffix: str, payload: bytes) -> Path:
chat_hash = hashlib.md5(username.encode("utf-8")).hexdigest()
target = wxid_dir / "msg" / "attach" / chat_hash / "2026-03" / "Img" / f"{md5}{suffix}.dat"
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(payload)
return target
def _build_client(self):
import wechat_decrypt_tool.logging_config as logging_config
import wechat_decrypt_tool.app_paths as app_paths
import wechat_decrypt_tool.media_helpers as media_helpers
import wechat_decrypt_tool.routers.chat_media as chat_media
logging.shutdown()
importlib.reload(logging_config)
importlib.reload(app_paths)
importlib.reload(media_helpers)
importlib.reload(chat_media)
app = FastAPI()
app.include_router(chat_media.router)
return TestClient(app)
def test_live_high_variant_replaces_stale_cached_thumb(self):
with TemporaryDirectory() as td:
root = Path(td)
account = "wxid_test"
username = "wxid_friend"
md5 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
account_dir = root / "output" / "databases" / account
wxid_dir = root / "wxid_source"
account_dir.mkdir(parents=True, exist_ok=True)
wxid_dir.mkdir(parents=True, exist_ok=True)
self._seed_contact_db(account_dir / "contact.db", account=account, username=username)
self._seed_session_db(account_dir / "session.db", username=username)
self._seed_source_info(account_dir, wxid_dir=wxid_dir)
cached_thumb = b"\xff\xd8\xff\xd9"
live_original = b"\xff\xd8\xff\xe0" + (b"\x00" * 48) + b"\xff\xd9"
cache_path = self._seed_cached_resource(account_dir, md5=md5, payload=cached_thumb)
self._seed_live_variant(wxid_dir, username=username, md5=md5, suffix="_h", payload=live_original)
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
client = None
try:
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
client = self._build_client()
resp = client.get(
"/api/chat/media/image",
params={"account": account, "md5": md5, "username": username},
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.content, live_original)
self.assertEqual(resp.headers.get("cache-control"), "no-store")
self.assertEqual(cache_path.read_bytes(), live_original)
finally:
try:
client.close()
except Exception:
pass
logging.shutdown()
if prev_data is None:
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
else:
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
def test_cached_original_is_not_downgraded_by_live_thumb(self):
with TemporaryDirectory() as td:
root = Path(td)
account = "wxid_test"
username = "wxid_friend"
md5 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
account_dir = root / "output" / "databases" / account
wxid_dir = root / "wxid_source"
account_dir.mkdir(parents=True, exist_ok=True)
wxid_dir.mkdir(parents=True, exist_ok=True)
self._seed_contact_db(account_dir / "contact.db", account=account, username=username)
self._seed_session_db(account_dir / "session.db", username=username)
self._seed_source_info(account_dir, wxid_dir=wxid_dir)
cached_original = b"\xff\xd8\xff\xe0" + (b"\x11" * 64) + b"\xff\xd9"
live_thumb = b"\xff\xd8\xff\xd9"
cache_path = self._seed_cached_resource(account_dir, md5=md5, payload=cached_original)
self._seed_live_variant(wxid_dir, username=username, md5=md5, suffix="_t", payload=live_thumb)
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
client = None
try:
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
client = self._build_client()
resp = client.get(
"/api/chat/media/image",
params={"account": account, "md5": md5, "username": username},
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.content, cached_original)
self.assertEqual(resp.headers.get("cache-control"), "no-store")
self.assertEqual(cache_path.read_bytes(), cached_original)
finally:
try:
client.close()
except Exception:
pass
logging.shutdown()
if prev_data is None:
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
else:
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
if __name__ == "__main__":
unittest.main()
+93
View File
@@ -0,0 +1,93 @@
import importlib
import logging
import os
import sys
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
def _close_logging_handlers() -> None:
for logger_name in ("", "uvicorn", "uvicorn.access", "uvicorn.error", "fastapi"):
lg = logging.getLogger(logger_name)
for handler in lg.handlers[:]:
try:
handler.close()
except Exception:
pass
try:
lg.removeHandler(handler)
except Exception:
pass
class TestOutputDirOverride(unittest.TestCase):
def setUp(self) -> None:
self._prev_data_dir = os.environ.get("WECHAT_TOOL_DATA_DIR")
self._prev_output_dir = os.environ.get("WECHAT_TOOL_OUTPUT_DIR")
self._data_dir = TemporaryDirectory()
self._output_dir = TemporaryDirectory()
os.environ["WECHAT_TOOL_DATA_DIR"] = self._data_dir.name
os.environ["WECHAT_TOOL_OUTPUT_DIR"] = self._output_dir.name
import wechat_decrypt_tool.app_paths as app_paths
import wechat_decrypt_tool.key_store as key_store
import wechat_decrypt_tool.logging_config as logging_config
import wechat_decrypt_tool.runtime_settings as runtime_settings
importlib.reload(app_paths)
importlib.reload(logging_config)
importlib.reload(runtime_settings)
importlib.reload(key_store)
self.app_paths = app_paths
self.key_store = key_store
self.logging_config = logging_config
self.runtime_settings = runtime_settings
def tearDown(self) -> None:
_close_logging_handlers()
if self._prev_data_dir is None:
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
else:
os.environ["WECHAT_TOOL_DATA_DIR"] = self._prev_data_dir
if self._prev_output_dir is None:
os.environ.pop("WECHAT_TOOL_OUTPUT_DIR", None)
else:
os.environ["WECHAT_TOOL_OUTPUT_DIR"] = self._prev_output_dir
self._data_dir.cleanup()
self._output_dir.cleanup()
def test_app_paths_prefers_output_dir_override(self) -> None:
self.assertEqual(self.app_paths.get_output_dir(), Path(self._output_dir.name))
self.assertEqual(
self.app_paths.get_output_databases_dir(),
Path(self._output_dir.name) / "databases",
)
def test_logging_runtime_settings_and_key_store_use_output_override(self) -> None:
log_file = self.logging_config.setup_logging()
self.assertTrue(log_file.is_relative_to(Path(self._output_dir.name) / "logs"))
self.runtime_settings.write_backend_port_setting(12001)
runtime_settings_path = Path(self._output_dir.name) / "runtime_settings.json"
self.assertTrue(runtime_settings_path.exists())
self.assertEqual(self.runtime_settings.read_backend_port_setting(), 12001)
self.key_store.upsert_account_keys_in_store("wxid_test", db_key="abc123")
key_store_path = Path(self._output_dir.name) / "account_keys.json"
self.assertTrue(key_store_path.exists())
self.assertEqual(
self.key_store.get_account_keys_from_store("wxid_test").get("db_key"),
"abc123",
)
if __name__ == "__main__":
unittest.main()