mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
Compare commits
2 Commits
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))}` : ''
|
||||
])
|
||||
})()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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