Compare commits

...

5 Commits

11 changed files with 530 additions and 33 deletions
+2
View File
@@ -44,6 +44,8 @@ pnpm-lock.yaml
/desktop/resources/ui/*
!/desktop/resources/ui/.gitkeep
/desktop/resources/backend/*.exe
/desktop/resources/backend/native/*
/desktop/resources/backend/pyproject.toml
!/desktop/resources/backend/.gitkeep
/desktop/resources/icon.ico
+23 -1
View File
@@ -25,7 +25,29 @@
},
"files": [
"src/**/*",
"package.json"
"package.json",
{
"from": "node_modules",
"to": "node_modules",
"filter": [
"electron-updater/**/*",
"builder-util-runtime/**/*",
"debug/**/*",
"ms/**/*",
"sax/**/*",
"js-yaml/**/*",
"argparse/**/*",
"lazy-val/**/*",
"lodash.escaperegexp/**/*",
"lodash.isequal/**/*",
"tiny-typed-emitter/**/*",
"fs-extra/**/*",
"graceful-fs/**/*",
"jsonfile/**/*",
"universalify/**/*",
"semver/**/*"
]
}
],
"extraResources": [
{
+87 -1
View File
@@ -13,8 +13,63 @@ fs.mkdirSync(distDir, { recursive: true });
fs.mkdirSync(workDir, { recursive: true });
fs.mkdirSync(specDir, { recursive: true });
function parseVersionTuple(rawVersion) {
const nums = String(rawVersion || "")
.split(/[^\d]+/)
.map((x) => Number.parseInt(x, 10))
.filter((n) => Number.isInteger(n) && n >= 0);
while (nums.length < 4) nums.push(0);
return nums.slice(0, 4);
}
function buildVersionInfoText(versionTuple, versionDot) {
const [a, b, c, d] = versionTuple;
return `# UTF-8
VSVersionInfo(
ffi=FixedFileInfo(
filevers=(${a}, ${b}, ${c}, ${d}),
prodvers=(${a}, ${b}, ${c}, ${d}),
mask=0x3f,
flags=0x0,
OS=0x4,
fileType=0x1,
subtype=0x0,
date=(0, 0)
),
kids=[
StringFileInfo([
StringTable(
'080404B0',
[StringStruct('CompanyName', 'LifeArchiveProject'),
StringStruct('FileDescription', 'WeFlow'),
StringStruct('FileVersion', '${versionDot}'),
StringStruct('InternalName', 'weflow'),
StringStruct('LegalCopyright', 'github.com/hicccc77/WeFlow'),
StringStruct('OriginalFilename', 'weflow.exe'),
StringStruct('ProductName', 'WeFlow'),
StringStruct('ProductVersion', '${versionDot}')])
]),
VarFileInfo([VarStruct('Translation', [2052, 1200])])
]
)
`;
}
const nativeDir = path.join(repoRoot, "src", "wechat_decrypt_tool", "native");
const addData = `${nativeDir};wechat_decrypt_tool/native`;
const projectToml = path.join(repoRoot, "pyproject.toml");
const desktopPackageJsonPath = path.join(repoRoot, "desktop", "package.json");
let desktopVersion = "1.3.0";
try {
const pkg = JSON.parse(fs.readFileSync(desktopPackageJsonPath, { encoding: "utf8" }));
const v = String(pkg?.version || "").trim();
if (v) desktopVersion = v;
} catch {}
const versionTuple = parseVersionTuple(desktopVersion);
const versionDot = versionTuple.join(".");
const versionFilePath = path.join(workDir, "weflow-version.txt");
fs.writeFileSync(versionFilePath, buildVersionInfoText(versionTuple, versionDot), { encoding: "utf8" });
const args = [
"run",
@@ -30,11 +85,42 @@ const args = [
workDir,
"--specpath",
specDir,
"--version-file",
versionFilePath,
"--add-data",
addData,
entry,
];
const r = spawnSync("uv", args, { cwd: repoRoot, stdio: "inherit" });
process.exit(r.status ?? 1);
if ((r.status ?? 1) !== 0) {
process.exit(r.status ?? 1);
}
// Keep a stable external native folder for packaged runtime to avoid relying on
// onefile temp extraction paths when wcdb_api.dll performs environment checks.
const packagedNativeDir = path.join(distDir, "native");
try {
fs.rmSync(packagedNativeDir, { recursive: true, force: true });
} catch {}
fs.mkdirSync(packagedNativeDir, { recursive: true });
for (const name of fs.readdirSync(nativeDir)) {
const src = path.join(nativeDir, name);
const dst = path.join(packagedNativeDir, name);
try {
if (fs.statSync(src).isFile()) {
fs.copyFileSync(src, dst);
}
} catch {}
}
// Provide the project marker next to packaged backend resources.
if (fs.existsSync(projectToml)) {
try {
fs.copyFileSync(projectToml, path.join(distDir, "pyproject.toml"));
} catch {}
}
process.exit(0);
+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
+201 -15
View File
@@ -8,7 +8,13 @@ const {
dialog,
shell,
} = require("electron");
const { autoUpdater } = require("electron-updater");
let autoUpdater = null;
let autoUpdaterLoadError = null;
try {
({ autoUpdater } = require("electron-updater"));
} catch (err) {
autoUpdaterLoadError = err;
}
const { spawn, spawnSync } = require("child_process");
const fs = require("fs");
const http = require("http");
@@ -117,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;
@@ -157,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();
@@ -165,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() {
@@ -297,6 +399,12 @@ function isAutoUpdateEnabled() {
const forced = parseEnvBool(process.env.AUTO_UPDATE_ENABLED);
let enabled = forced != null ? forced : !!app.isPackaged;
if (enabled && !autoUpdater) {
enabled = false;
logMain(
`[main] auto-update disabled: electron-updater unavailable: ${autoUpdaterLoadError?.message || "unknown error"}`
);
}
// In packaged builds electron-updater reads update config from app-update.yml.
// If missing, treat auto-update as disabled to avoid noisy errors.
@@ -823,6 +931,10 @@ function getPackagedBackendPath() {
return path.join(process.resourcesPath, "backend", "wechat-backend.exe");
}
function getPackagedWcdbDllPath() {
return path.join(process.resourcesPath, "backend", "native", "wcdb_api.dll");
}
function startBackend() {
if (backendProc) return backendProc;
@@ -853,8 +965,17 @@ function startBackend() {
`Packaged backend not found: ${backendExe}. Build it into desktop/resources/backend/wechat-backend.exe`
);
}
const packagedWcdbDll = getPackagedWcdbDllPath();
if (fs.existsSync(packagedWcdbDll)) {
env.WECHAT_TOOL_WCDB_API_DLL_PATH = packagedWcdbDll;
logMain(`[main] using packaged wcdb_api.dll: ${packagedWcdbDll}`);
} else {
logMain(`[main] packaged wcdb_api.dll not found: ${packagedWcdbDll}`);
}
const backendCwd = path.dirname(backendExe);
backendProc = spawn(backendExe, [], {
cwd: env.WECHAT_TOOL_DATA_DIR,
cwd: backendCwd,
env,
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
@@ -965,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;
@@ -1229,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();
});
@@ -1247,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) {
@@ -1287,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()
+9 -6
View File
@@ -253,7 +253,9 @@ def _ensure_initialized() -> None:
return
rc = int(lib.wcdb_init())
if rc != 0:
raise WCDBRealtimeError(f"wcdb_init failed: {rc}")
logs = get_native_logs(require_initialized=False)
hint = f" logs={logs[:6]}" if logs else ""
raise WCDBRealtimeError(f"wcdb_init failed: {rc}.{hint}")
_initialized = True
@@ -315,11 +317,12 @@ def _call_out_error(fn, *args) -> None:
pass
def get_native_logs() -> list[str]:
try:
_ensure_initialized()
except Exception:
return []
def get_native_logs(*, require_initialized: bool = True) -> list[str]:
if require_initialized:
try:
_ensure_initialized()
except Exception:
return []
lib = _load_wcdb_lib()
out = ctypes.c_char_p()
rc = int(lib.wcdb_get_logs(ctypes.byref(out)))
+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()