Compare commits

...

3 Commits

7 changed files with 382 additions and 23 deletions
+32
View File
@@ -14,6 +14,34 @@
Var WDA_InstallDirPage
!macro customInit
; Safety: older versions created an `output` junction inside the install directory that points to the
; per-user AppData `output` folder. Some uninstall/update flows may traverse that junction and delete
; real user data. Remove it as early as possible during install/update.
Call WDA_RemoveLegacyOutputLink
!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
!macroend
Function WDA_RemoveLegacyOutputLink
; $INSTDIR is usually the full install directory. Be defensive and also try the nested path
; in case the installer is running before electron-builder appends "\${APP_FILENAME}".
RMDir "$INSTDIR\output"
RMDir "$INSTDIR\${APP_FILENAME}\output"
FunctionEnd
!macro customPageAfterChangeDir
; Add a confirmation page after the directory picker so users clearly see
; the final install location (includes the app sub-folder).
@@ -90,6 +118,10 @@ Var /GLOBAL WDA_DeleteUserData
!macro customUnInit
; Default: keep user data (also applies to silent uninstall / update uninstall).
StrCpy $WDA_DeleteUserData "0"
; Safety: if an older build created an `output` junction inside the install dir, remove it early so
; directory cleanup can't traverse it and delete the real per-user output folder.
RMDir "$INSTDIR\output"
!macroend
!macro customUnWelcomePage
+174 -13
View File
@@ -123,6 +123,68 @@ function isPortAvailable(port, host) {
});
}
function getEphemeralPort(host) {
return new Promise((resolve) => {
try {
const srv = net.createServer();
srv.unref();
srv.once("error", () => resolve(null));
srv.listen({ port: 0, host }, () => {
const addr = srv.address();
const p = addr && typeof addr === "object" ? Number(addr.port) : null;
srv.close(() => resolve(Number.isInteger(p) ? p : null));
});
} catch {
resolve(null);
}
});
}
async function chooseAvailablePort(preferredPort, host) {
const preferred = parsePort(preferredPort);
if (preferred != null && (await isPortAvailable(preferred, host))) return preferred;
// Keep the port close to the user's expectation when possible.
if (preferred != null) {
for (let i = 1; i <= 50; i += 1) {
const cand = preferred + i;
if (cand > 65535) break;
if (await isPortAvailable(cand, host)) return cand;
}
}
// Fall back to an OS-chosen ephemeral port.
const random = await getEphemeralPort(host);
if (random != null && (await isPortAvailable(random, host))) return random;
return null;
}
async function ensureBackendPortAvailableOnStartup() {
// Avoid surprising behavior in dev: the frontend dev server expects a stable backend port.
if (!app.isPackaged) return getBackendPort();
const bindHost = getBackendBindHost();
const currentPort = getBackendPort();
const ok = await isPortAvailable(currentPort, bindHost);
if (ok) return currentPort;
const chosen = await chooseAvailablePort(currentPort, bindHost);
if (chosen == null) {
logMain(`[main] backend port unavailable: ${currentPort} host=${bindHost}; failed to find a free port`);
return currentPort;
}
try {
setBackendPortSetting(chosen);
logMain(`[main] backend port ${currentPort} unavailable; switched to ${chosen}`);
} catch (err) {
logMain(`[main] failed to persist backend port ${chosen}: ${err?.message || err}`);
}
return getBackendPort();
}
function resolveDataDir() {
if (resolvedDataDir) return resolvedDataDir;
@@ -163,7 +225,11 @@ function getExeDir() {
function ensureOutputLink() {
// Users often expect an `output/` folder near the installed exe. We keep the real data
// in the per-user data dir, and (when possible) create a Windows junction next to the exe.
// in the per-user data dir.
//
// NOTE: We intentionally avoid creating a junction/symlink inside the install directory.
// Some uninstall/update flows may traverse reparse points and delete the target directory,
// causing data loss (the install dir is removed on every update/reinstall).
if (!app.isPackaged) return;
const exeDir = getExeDir();
@@ -171,26 +237,56 @@ function ensureOutputLink() {
if (!exeDir || !dataDir) return;
const target = path.join(dataDir, "output");
const linkPath = path.join(exeDir, "output");
const legacyLinkPath = path.join(exeDir, "output");
// If the target doesn't exist yet, create it so the link points somewhere real.
// Ensure the real output dir exists.
try {
fs.mkdirSync(target, { recursive: true });
} catch {}
// If something already exists at linkPath, do not overwrite it.
// Best-effort: remove a legacy junction/symlink at `exeDir/output` so uninstallers can't
// accidentally traverse it and delete the real per-user output directory.
try {
if (fs.existsSync(linkPath)) return;
const st = fs.lstatSync(legacyLinkPath);
if (st.isSymbolicLink()) {
try {
fs.unlinkSync(legacyLinkPath);
logMain(`[main] removed legacy output link: ${legacyLinkPath}`);
} catch (err) {
logMain(`[main] failed to remove legacy output link: ${err?.message || err}`);
}
} else if (st.isDirectory()) {
const entries = fs.readdirSync(legacyLinkPath);
if (Array.isArray(entries) && entries.length === 0) {
// Remove an empty real directory to reduce confusion (it will be recreated by the backend if needed).
fs.rmdirSync(legacyLinkPath);
} else {
// Do not overwrite non-empty directories to avoid data loss.
// Note: data stored here will be wiped on update/reinstall.
logMain(
`[main] output dir exists in install dir (not a link): ${legacyLinkPath}. real data dir output: ${target}`
);
}
} else {
logMain(`[main] output path exists and is not a directory/link: ${legacyLinkPath}`);
}
} catch {
return;
// Doesn't exist yet.
}
// Best-effort: drop a helper file next to the exe so users can find the real data.
// This avoids the data-loss risks of using junctions/symlinks under the install directory.
try {
fs.symlinkSync(target, linkPath, "junction");
logMain(`[main] created output link: ${linkPath} -> ${target}`);
} catch (err) {
logMain(`[main] failed to create output link: ${err?.message || err}`);
}
const p = path.join(exeDir, "output-location.txt");
const text = `WeChatDataAnalysis data directory\n\nOutput folder:\n${target}\n`;
fs.writeFileSync(p, text, { encoding: "utf8" });
} catch {}
try {
const p = path.join(exeDir, "open-output.cmd");
const text = `@echo off\r\nexplorer \"${target}\"\r\n`;
fs.writeFileSync(p, text, { encoding: "utf8" });
} catch {}
}
function getMainLogPath() {
@@ -990,6 +1086,16 @@ async function waitForBackend({ timeoutMs, healthUrl } = {}) {
const startedAt = Date.now();
// eslint-disable-next-line no-constant-condition
while (true) {
// If the backend process died, fail fast (otherwise we'd wait for the full timeout).
if (!backendProc) {
throw new Error(`Backend process exited before becoming ready: ${url}`);
}
if (backendProc.exitCode != null) {
throw new Error(
`Backend process exited (code=${backendProc.exitCode} signal=${backendProc.signalCode || "null"}): ${url}`
);
}
try {
const code = await httpGet(url);
if (code >= 200 && code < 500) return;
@@ -1254,6 +1360,30 @@ function registerWindowIpc() {
}
});
ipcMain.handle("app:getOutputDir", () => {
const dir = resolveDataDir();
if (!dir) return "";
return path.join(dir, "output");
});
ipcMain.handle("app:openOutputDir", async () => {
const dir = resolveDataDir();
if (!dir) throw new Error("无法定位数据目录");
const outDir = path.join(dir, "output");
try {
fs.mkdirSync(outDir, { recursive: true });
} catch {}
try {
const err = await shell.openPath(outDir);
if (err) throw new Error(err);
return { success: true, path: outDir };
} catch (e) {
const message = e?.message || String(e);
logMain(`[main] openOutputDir failed: ${message}`);
throw new Error(message);
}
});
ipcMain.handle("app:checkForUpdates", async () => {
return await checkForUpdatesInternal();
});
@@ -1272,6 +1402,11 @@ function registerWindowIpc() {
}
try {
// Safety: remove legacy `output` junctions in the install dir before triggering the NSIS update/uninstall.
// Some uninstall flows may traverse reparse points and delete the real per-user output directory.
try {
ensureOutputLink();
} catch {}
autoUpdater.quitAndInstall(false, true);
return { success: true };
} catch (err) {
@@ -1312,15 +1447,41 @@ async function main() {
registerWindowIpc();
registerDebugShortcuts();
// Resolve/create the data dir early so we can log reliably and (optionally) place a link
// Resolve/create the data dir early so we can log reliably and place helper files
// next to the installed exe for easier access.
resolveDataDir();
ensureOutputLink();
await ensureBackendPortAvailableOnStartup();
logMain(`[main] app.isPackaged=${app.isPackaged} argv=${JSON.stringify(process.argv)}`);
startBackend();
await waitForBackend({ timeoutMs: 30_000 });
try {
await waitForBackend({ timeoutMs: 30_000 });
} catch (err) {
// In some environments a specific port may be blocked/reserved (WSAEACCES) or taken.
// Best-effort: pick a new port and retry once so the app can still start.
if (app.isPackaged) {
const prevPort = getBackendPort();
const bindHost = getBackendBindHost();
const nextPort = await chooseAvailablePort(prevPort + 1, bindHost);
if (nextPort != null && nextPort !== prevPort) {
logMain(`[main] backend not ready on port ${prevPort}; retrying on ${nextPort}`);
try {
setBackendPortSetting(nextPort);
await restartBackend({ timeoutMs: 30_000 });
logMain(`[main] backend retry succeeded on port ${nextPort}`);
} catch (retryErr) {
logMain(`[main] backend retry failed: ${retryErr?.stack || String(retryErr)}`);
throw retryErr;
}
} else {
throw err;
}
} else {
throw err;
}
}
const win = createMainWindow();
mainWindow = win;
+4
View File
@@ -19,6 +19,10 @@ contextBridge.exposeInMainWorld("wechatDesktop", {
chooseDirectory: (options = {}) => ipcRenderer.invoke("dialog:chooseDirectory", options),
// Data/output folder helpers
getOutputDir: () => ipcRenderer.invoke("app:getOutputDir"),
openOutputDir: () => ipcRenderer.invoke("app:openOutputDir"),
// Auto update
getVersion: () => ipcRenderer.invoke("app:getVersion"),
checkForUpdates: () => ipcRenderer.invoke("app:checkForUpdates"),
+58
View File
@@ -142,6 +142,24 @@
<div v-if="desktopBackendPortError" class="text-xs text-red-600 whitespace-pre-wrap -mt-1.5">
{{ desktopBackendPortError }}
</div>
<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>
<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="text-xs text-red-600 whitespace-pre-wrap -mt-1.5">
{{ desktopOutputDirError }}
</div>
</div>
</section>
@@ -288,6 +306,15 @@ const desktopBackendPortApplying = ref(false)
const desktopBackendPortError = ref('')
const desktopBackendPortDefault = ref(10392)
const desktopOutputDir = ref('')
const desktopOutputDirLoading = ref(false)
const desktopOutputDirError = ref('')
const desktopOutputDirText = computed(() => {
if (!isDesktopEnv.value) return '仅桌面端可用'
const v = String(desktopOutputDir.value || '').trim()
return v || '—'
})
const switchTrackClass = (enabled, disabled = false) => {
if (disabled) return enabled ? 'bg-[#07b75b] opacity-50 cursor-not-allowed' : 'bg-[#d0d0d0] opacity-50 cursor-not-allowed'
return enabled ? 'bg-[#07b75b] hover:brightness-95' : 'bg-[#d0d0d0] hover:brightness-95'
@@ -437,6 +464,36 @@ const refreshDesktopBackendPort = async () => {
}
}
const refreshDesktopOutputDir = async () => {
if (!process.client || typeof window === 'undefined') return
if (!window.wechatDesktop?.getOutputDir) return
desktopOutputDirLoading.value = true
desktopOutputDirError.value = ''
try {
const v = await window.wechatDesktop.getOutputDir()
desktopOutputDir.value = String(v || '').trim()
} catch (e) {
desktopOutputDirError.value = e?.message || '读取 output 目录失败'
} finally {
desktopOutputDirLoading.value = false
}
}
const onDesktopOpenOutputDir = async () => {
if (!process.client || typeof window === 'undefined') return
if (!window.wechatDesktop?.openOutputDir) return
desktopOutputDirLoading.value = true
desktopOutputDirError.value = ''
try {
const res = await window.wechatDesktop.openOutputDir()
if (res?.path) desktopOutputDir.value = String(res.path || '').trim()
} catch (e) {
desktopOutputDirError.value = e?.message || '打开 output 目录失败'
} finally {
desktopOutputDirLoading.value = false
}
}
const applyDesktopBackendPort = async () => {
if (!process.client || typeof window === 'undefined') return
const raw = String(desktopBackendPortInput.value || '').trim()
@@ -567,6 +624,7 @@ onMounted(async () => {
void desktopUpdate.initListeners()
await refreshDesktopAutoLaunch()
await refreshDesktopCloseBehavior()
await refreshDesktopOutputDir()
}
await nextTick()
+6 -5
View File
@@ -10,8 +10,13 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.responses import FileResponse
from starlette.staticfiles import StaticFiles
from . import __version__ as APP_VERSION
from .logging_config import setup_logging, get_logger
# 初始化日志系统
setup_logging()
logger = get_logger(__name__)
from . import __version__ as APP_VERSION
from .path_fix import PathFixRoute
from .chat_realtime_autosync import CHAT_REALTIME_AUTOSYNC
from .routers.chat import router as _chat_router
@@ -30,10 +35,6 @@ from .routers.wrapped import router as _wrapped_router
from .sns_stage_timing import add_sns_stage_timing_headers
from .wcdb_realtime import WCDB_REALTIME, shutdown as _wcdb_shutdown
# 初始化日志系统
setup_logging()
logger = get_logger(__name__)
app = FastAPI(
title="微信数据库解密工具",
description="现代化的微信数据库解密工具,支持微信信息检测和数据库解密功能",
+45 -5
View File
@@ -53,9 +53,9 @@ class WeChatLogger:
return cls._instance
def __init__(self):
if not self._initialized:
self.setup_logging()
WeChatLogger._initialized = True
# Lazy-init in `setup_logging()` / accessors to avoid double-initialization when
# callers instantiate the manager and then call `setup_logging()` again.
pass
def setup_logging(self, log_level: str = "INFO"):
"""设置日志配置"""
@@ -66,7 +66,9 @@ class WeChatLogger:
# 创建日志目录
now = datetime.now()
log_dir = Path("output/logs") / str(now.year) / f"{now.month:02d}" / f"{now.day:02d}"
from .app_paths import get_output_dir
log_dir = get_output_dir() / "logs" / str(now.year) / f"{now.month:02d}" / f"{now.day:02d}"
log_dir.mkdir(parents=True, exist_ok=True)
# 设置日志文件名
@@ -77,6 +79,10 @@ class WeChatLogger:
root_logger = logging.getLogger()
for handler in root_logger.handlers[:]:
root_logger.removeHandler(handler)
try:
handler.close()
except Exception:
pass
# 配置日志格式
# 文件格式(无颜色)
@@ -109,22 +115,48 @@ class WeChatLogger:
# 只为uvicorn日志器添加文件处理器,保持其原有的控制台处理器(带颜色)
uvicorn_logger = logging.getLogger("uvicorn")
for handler in uvicorn_logger.handlers[:]:
if isinstance(handler, logging.FileHandler):
uvicorn_logger.removeHandler(handler)
try:
handler.close()
except Exception:
pass
uvicorn_logger.addHandler(file_handler)
uvicorn_logger.setLevel(level)
# 只为uvicorn.access日志器添加文件处理器
uvicorn_access_logger = logging.getLogger("uvicorn.access")
for handler in uvicorn_access_logger.handlers[:]:
if isinstance(handler, logging.FileHandler):
uvicorn_access_logger.removeHandler(handler)
try:
handler.close()
except Exception:
pass
uvicorn_access_logger.addHandler(file_handler)
uvicorn_access_logger.setLevel(level)
# 只为uvicorn.error日志器添加文件处理器
uvicorn_error_logger = logging.getLogger("uvicorn.error")
for handler in uvicorn_error_logger.handlers[:]:
if isinstance(handler, logging.FileHandler):
uvicorn_error_logger.removeHandler(handler)
try:
handler.close()
except Exception:
pass
uvicorn_error_logger.addHandler(file_handler)
uvicorn_error_logger.setLevel(level)
# 配置FastAPI日志器
fastapi_logger = logging.getLogger("fastapi")
fastapi_logger.handlers = []
for handler in fastapi_logger.handlers[:]:
fastapi_logger.removeHandler(handler)
try:
handler.close()
except Exception:
pass
fastapi_logger.addHandler(file_handler)
fastapi_logger.addHandler(console_handler)
fastapi_logger.setLevel(level)
@@ -136,6 +168,8 @@ class WeChatLogger:
logger.info(f"日志文件: {self.log_file}")
logger.info(f"日志级别: {logging.getLevelName(level)}")
logger.info("=" * 60)
WeChatLogger._initialized = True
return self.log_file
@@ -145,6 +179,8 @@ class WeChatLogger:
def get_log_file_path(self) -> Path:
"""获取当前日志文件路径"""
if not hasattr(self, "log_file"):
self.setup_logging()
return self.log_file
@@ -157,10 +193,14 @@ def setup_logging(log_level: str = "INFO") -> Path:
def get_logger(name: str) -> logging.Logger:
"""获取日志器的便捷函数"""
logger_manager = WeChatLogger()
if not WeChatLogger._initialized:
logger_manager.setup_logging()
return logger_manager.get_logger(name)
def get_log_file_path() -> Path:
"""获取当前日志文件路径的便捷函数"""
logger_manager = WeChatLogger()
if not WeChatLogger._initialized:
logger_manager.setup_logging()
return logger_manager.get_log_file_path()
+63
View File
@@ -0,0 +1,63 @@
import os
import sys
import unittest
import importlib
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:
# Close handlers to avoid Windows temp dir cleanup failures (FileHandler holds a lock).
import logging
for logger_name in ("", "uvicorn", "uvicorn.access", "uvicorn.error", "fastapi"):
lg = logging.getLogger(logger_name)
for h in lg.handlers[:]:
try:
h.close()
except Exception:
pass
try:
lg.removeHandler(h)
except Exception:
pass
class TestLoggingConfigDataDir(unittest.TestCase):
def setUp(self):
self._prev_data_dir = os.environ.get("WECHAT_TOOL_DATA_DIR")
self._td = TemporaryDirectory()
os.environ["WECHAT_TOOL_DATA_DIR"] = self._td.name
import wechat_decrypt_tool.app_paths as app_paths
import wechat_decrypt_tool.logging_config as logging_config
importlib.reload(app_paths)
importlib.reload(logging_config)
self.logging_config = logging_config
def tearDown(self):
_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
self._td.cleanup()
def test_setup_logging_uses_wechat_tool_data_dir(self):
log_file = self.logging_config.setup_logging()
base = Path(self._td.name) / "output" / "logs"
self.assertTrue(log_file.is_relative_to(base))
self.assertTrue(log_file.exists())
if __name__ == "__main__":
unittest.main()