diff --git a/desktop/package.json b/desktop/package.json index 2709dce..5c8d9aa 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -57,6 +57,14 @@ { "from": "resources/backend", "to": "backend" + }, + { + "from": "src/wcdb-sidecar.cjs", + "to": "wcdb-sidecar.cjs" + }, + { + "from": "vendor/koffi", + "to": "koffi" } ], "win": { diff --git a/desktop/src/main.cjs b/desktop/src/main.cjs index 7f4bc51..ddea1c6 100644 --- a/desktop/src/main.cjs +++ b/desktop/src/main.cjs @@ -17,6 +17,7 @@ try { autoUpdaterLoadError = err; } const { spawn, spawnSync } = require("child_process"); +const crypto = require("crypto"); const fs = require("fs"); const http = require("http"); const net = require("net"); @@ -33,6 +34,10 @@ const DEFAULT_BACKEND_HOST = String(process.env.WECHAT_TOOL_HOST || "127.0.0.1") const DEFAULT_BACKEND_PORT = parsePort(process.env.WECHAT_TOOL_PORT) ?? 10392; let backendProc = null; +let wcdbSidecarProc = null; +let wcdbSidecarPort = null; +let wcdbSidecarUrl = ""; +let wcdbSidecarToken = ""; let resolvedDataDir = null; let mainWindow = null; let tray = null; @@ -1452,8 +1457,159 @@ function getPackagedWcdbDllPath() { return path.join(process.resourcesPath, "backend", "native", "wcdb_api.dll"); } +function getDevWcdbDllPath() { + return path.join(repoRoot(), "src", "wechat_decrypt_tool", "native", "wcdb_api.dll"); +} + +function getWcdbDllPath() { + return app.isPackaged ? getPackagedWcdbDllPath() : getDevWcdbDllPath(); +} + +function getWcdbDllDir() { + return path.dirname(getWcdbDllPath()); +} + +function getWcdbSidecarScriptPath() { + return app.isPackaged + ? path.join(process.resourcesPath, "wcdb-sidecar.cjs") + : path.join(__dirname, "wcdb-sidecar.cjs"); +} + +function getKoffiDir() { + return app.isPackaged ? path.join(process.resourcesPath, "koffi") : path.join(repoRoot(), "desktop", "vendor", "koffi"); +} + +function getWcdbSidecarStdioLogPath(dataDir) { + return path.join(dataDir, "wcdb-sidecar-stdio.log"); +} + +function getWcdbSidecarPort() { + if (parsePort(wcdbSidecarPort) != null) return wcdbSidecarPort; + const envPort = parsePort(process.env.WECHAT_TOOL_WCDB_SIDECAR_PORT); + wcdbSidecarPort = envPort ?? Math.min(65535, getBackendPort() + 101); + return wcdbSidecarPort; +} + +async function prepareWcdbSidecarPort() { + if (parsePort(wcdbSidecarPort) != null) return wcdbSidecarPort; + const envPort = parsePort(process.env.WECHAT_TOOL_WCDB_SIDECAR_PORT); + if (envPort != null) { + wcdbSidecarPort = envPort; + return wcdbSidecarPort; + } + const preferred = Math.min(65535, getBackendPort() + 101); + wcdbSidecarPort = await chooseAvailablePort(preferred, "127.0.0.1"); + if (wcdbSidecarPort == null) wcdbSidecarPort = preferred; + return wcdbSidecarPort; +} + +function getWcdbResourcePaths() { + const out = []; + const seen = new Set(); + + const add = (value) => { + const raw = String(value || "").trim(); + if (!raw) return; + const resolved = path.resolve(raw); + const key = resolved.toLowerCase(); + if (seen.has(key)) return; + seen.add(key); + out.push(resolved); + }; + + const dllDir = getWcdbDllDir(); + add(dllDir); + add(path.dirname(dllDir)); + add(repoRoot()); + add(path.join(repoRoot(), "resources")); + const dataDir = resolveDataDir(); + if (dataDir) { + add(dataDir); + add(path.join(dataDir, "resources")); + } + return out; +} + +function ensureWcdbSidecarEnv(env) { + if (!wcdbSidecarUrl || !wcdbSidecarToken) return env; + env.WECHAT_TOOL_WCDB_SIDECAR_URL = wcdbSidecarUrl; + env.WECHAT_TOOL_WCDB_SIDECAR_TOKEN = wcdbSidecarToken; + return env; +} + +function startWcdbSidecar() { + if (process.env.WECHAT_TOOL_WCDB_SIDECAR === "0") return null; + if (wcdbSidecarProc && wcdbSidecarProc.exitCode == null) return wcdbSidecarProc; + if (process.platform !== "win32") return null; + + const dllPath = getWcdbDllPath(); + const sidecarScript = getWcdbSidecarScriptPath(); + const koffiDir = getKoffiDir(); + if (!fs.existsSync(dllPath)) { + logMain(`[wcdb-sidecar] skip: missing wcdb_api.dll ${dllPath}`); + return null; + } + if (!fs.existsSync(sidecarScript)) { + logMain(`[wcdb-sidecar] skip: missing sidecar script ${sidecarScript}`); + return null; + } + if (!fs.existsSync(koffiDir)) { + logMain(`[wcdb-sidecar] skip: missing koffi runtime ${koffiDir}`); + return null; + } + + const port = getWcdbSidecarPort(); + const host = "127.0.0.1"; + wcdbSidecarUrl = `http://${host}:${port}`; + wcdbSidecarToken = wcdbSidecarToken || crypto.randomBytes(24).toString("hex"); + + const env = { + ...process.env, + ELECTRON_RUN_AS_NODE: "1", + WECHAT_TOOL_WCDB_SIDECAR_HOST: host, + WECHAT_TOOL_WCDB_SIDECAR_PORT: String(port), + WECHAT_TOOL_WCDB_SIDECAR_TOKEN: wcdbSidecarToken, + WECHAT_TOOL_WCDB_API_DLL_PATH: dllPath, + WECHAT_TOOL_WCDB_DLL_DIR: getWcdbDllDir(), + WECHAT_TOOL_WCDB_RESOURCE_PATHS: JSON.stringify(getWcdbResourcePaths()), + WECHAT_TOOL_KOFFI_DIR: koffiDir, + }; + + logMain(`[wcdb-sidecar] starting url=${wcdbSidecarUrl} dll=${dllPath}`); + wcdbSidecarProc = spawn(process.execPath, [sidecarScript], { + cwd: path.dirname(sidecarScript), + env, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + + const dataDir = resolveDataDir() || getUserDataDir() || repoRoot(); + attachBackendStdio(wcdbSidecarProc, getWcdbSidecarStdioLogPath(dataDir)); + + const proc = wcdbSidecarProc; + proc.on("exit", (code, signal) => { + if (wcdbSidecarProc === proc) wcdbSidecarProc = null; + logMain(`[wcdb-sidecar] exited code=${code} signal=${signal}`); + }); + + process.env.WECHAT_TOOL_WCDB_SIDECAR_URL = wcdbSidecarUrl; + process.env.WECHAT_TOOL_WCDB_SIDECAR_TOKEN = wcdbSidecarToken; + return wcdbSidecarProc; +} + +function stopWcdbSidecar() { + if (!wcdbSidecarProc) return; + const pid = wcdbSidecarProc.pid; + logMain(`[wcdb-sidecar] stop pid=${pid || "?"}`); + try { + wcdbSidecarProc.kill(); + } catch {} + wcdbSidecarProc = null; +} + function startBackend() { if (backendProc) return backendProc; + startWcdbSidecar(); const env = { ...process.env, @@ -1462,6 +1618,7 @@ function startBackend() { // Make sure Python prints UTF-8 to stdout/stderr. PYTHONIOENCODING: process.env.PYTHONIOENCODING || "utf-8", }; + ensureWcdbSidecarEnv(env); // In packaged mode we expect to provide the generated Nuxt output dir via env. if (app.isPackaged && !env.WECHAT_TOOL_UI_DIR) { @@ -2135,6 +2292,7 @@ async function main() { await applyPendingOutputDirOnStartup(); ensureOutputLink(); await ensureBackendPortAvailableOnStartup(); + await prepareWcdbSidecarPort(); logMain(`[main] app.isPackaged=${app.isPackaged} argv=${JSON.stringify(process.argv)}`); @@ -2190,6 +2348,7 @@ async function main() { app.on("window-all-closed", () => { stopBackend(); + stopWcdbSidecar(); if (process.platform !== "darwin") app.quit(); }); @@ -2203,6 +2362,7 @@ app.on("before-quit", () => { isQuitting = true; destroyTray(); stopBackend(); + stopWcdbSidecar(); }); if (gotSingleInstanceLock) { @@ -2211,6 +2371,7 @@ if (gotSingleInstanceLock) { console.error(err); logMain(`[main] fatal: ${err?.stack || String(err)}`); stopBackend(); + stopWcdbSidecar(); try { const dir = getUserDataDir(); const outputDir = resolveOutputDir(); diff --git a/desktop/src/wcdb-sidecar.cjs b/desktop/src/wcdb-sidecar.cjs new file mode 100644 index 0000000..0a6c676 --- /dev/null +++ b/desktop/src/wcdb-sidecar.cjs @@ -0,0 +1,545 @@ +"use strict"; + +const fs = require("fs"); +const http = require("http"); +const path = require("path"); + +const HOST = String(process.env.WECHAT_TOOL_WCDB_SIDECAR_HOST || "127.0.0.1").trim() || "127.0.0.1"; +const PORT = Number.parseInt(String(process.env.WECHAT_TOOL_WCDB_SIDECAR_PORT || "0").trim(), 10); +const TOKEN = String(process.env.WECHAT_TOOL_WCDB_SIDECAR_TOKEN || "").trim(); + +let koffi = null; +let nativeLib = null; +let nativeDllPath = ""; +let initialized = false; +let protectionResults = []; +const preloadedLibs = []; +const funcs = Object.create(null); + +class ApiError extends Error { + constructor(message, rc = 0, details = null) { + super(message); + this.name = "ApiError"; + this.rc = rc; + this.details = details; + } +} + +function log(message) { + process.stderr.write(`[wcdb-sidecar] ${new Date().toISOString()} ${message}\n`); +} + +function jsonResponse(res, statusCode, payload) { + const body = Buffer.from(JSON.stringify(payload), "utf8"); + res.writeHead(statusCode, { + "Content-Type": "application/json; charset=utf-8", + "Content-Length": String(body.length), + "Cache-Control": "no-store", + }); + res.end(body); +} + +function readRequestJson(req) { + return new Promise((resolve, reject) => { + const chunks = []; + let total = 0; + req.on("data", (chunk) => { + total += chunk.length; + if (total > 16 * 1024 * 1024) { + reject(new ApiError("request body too large")); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on("end", () => { + try { + const raw = Buffer.concat(chunks).toString("utf8").trim(); + resolve(raw ? JSON.parse(raw) : {}); + } catch (err) { + reject(new ApiError(`invalid json: ${err?.message || err}`)); + } + }); + req.on("error", reject); + }); +} + +function parseResourcePaths() { + const out = []; + const seen = new Set(); + + function add(value) { + const raw = String(value || "").trim(); + if (!raw) return; + const resolved = path.resolve(raw); + const key = resolved.toLowerCase(); + if (seen.has(key)) return; + seen.add(key); + out.push(resolved); + } + + const raw = String(process.env.WECHAT_TOOL_WCDB_RESOURCE_PATHS || "").trim(); + if (raw) { + try { + const decoded = JSON.parse(raw); + if (Array.isArray(decoded)) { + for (const item of decoded) add(item); + } + } catch { + for (const item of raw.split(path.delimiter)) add(item); + } + } + + const dllDir = getDllDir(); + add(dllDir); + add(path.dirname(dllDir)); + add(process.cwd()); + return out; +} + +function getDllDir() { + const fromEnv = String(process.env.WECHAT_TOOL_WCDB_DLL_DIR || "").trim(); + if (fromEnv) return path.resolve(fromEnv); + + const dllPath = String(process.env.WECHAT_TOOL_WCDB_API_DLL_PATH || "").trim(); + if (dllPath) return path.dirname(path.resolve(dllPath)); + + return process.cwd(); +} + +function getDllPath() { + const fromEnv = String(process.env.WECHAT_TOOL_WCDB_API_DLL_PATH || "").trim(); + if (fromEnv) return path.resolve(fromEnv); + return path.join(getDllDir(), "wcdb_api.dll"); +} + +function loadKoffi() { + if (koffi) return koffi; + + const koffiDir = String(process.env.WECHAT_TOOL_KOFFI_DIR || "").trim(); + if (koffiDir) { + koffi = require(path.resolve(koffiDir)); + } else { + koffi = require("koffi"); + } + return koffi; +} + +function tryFunc(signature) { + try { + return nativeLib.func(signature); + } catch { + return null; + } +} + +function loadNative() { + if (nativeLib) return nativeLib; + + const ffi = loadKoffi(); + const dllDir = getDllDir(); + nativeDllPath = getDllPath(); + if (!fs.existsSync(nativeDllPath)) { + throw new ApiError(`wcdb_api.dll not found: ${nativeDllPath}`); + } + + try { + process.chdir(dllDir); + } catch {} + + for (const dep of ["WCDB.dll", "SDL2.dll", "VoipEngine.dll"]) { + const depPath = path.join(dllDir, dep); + if (!fs.existsSync(depPath)) continue; + try { + preloadedLibs.push(ffi.load(depPath)); + log(`preloaded dependency ${depPath}`); + } catch (err) { + log(`preload dependency failed ${depPath}: ${err?.message || err}`); + } + } + + nativeLib = ffi.load(nativeDllPath); + funcs.InitProtection = tryFunc("int32 InitProtection(const char* resourcePath)"); + funcs.wcdb_init = tryFunc("int32 wcdb_init()"); + funcs.wcdb_shutdown = tryFunc("int32 wcdb_shutdown()"); + funcs.wcdb_open_account = tryFunc("int32 wcdb_open_account(const char* path, const char* key, _Out_ int64* handle)"); + funcs.wcdb_close_account = tryFunc("int32 wcdb_close_account(int64 handle)"); + funcs.wcdb_set_my_wxid = tryFunc("int32 wcdb_set_my_wxid(int64 handle, const char* wxid)"); + funcs.wcdb_get_sessions = tryFunc("int32 wcdb_get_sessions(int64 handle, _Out_ void** outJson)"); + funcs.wcdb_get_messages = tryFunc( + "int32 wcdb_get_messages(int64 handle, const char* username, int32 limit, int32 offset, _Out_ void** outJson)" + ); + funcs.wcdb_get_message_count = tryFunc( + "int32 wcdb_get_message_count(int64 handle, const char* username, _Out_ int32* count)" + ); + funcs.wcdb_get_display_names = tryFunc( + "int32 wcdb_get_display_names(int64 handle, const char* usernamesJson, _Out_ void** outJson)" + ); + funcs.wcdb_get_avatar_urls = tryFunc( + "int32 wcdb_get_avatar_urls(int64 handle, const char* usernamesJson, _Out_ void** outJson)" + ); + funcs.wcdb_get_group_member_count = tryFunc( + "int32 wcdb_get_group_member_count(int64 handle, const char* chatroomId, _Out_ int32* count)" + ); + funcs.wcdb_get_group_members = tryFunc( + "int32 wcdb_get_group_members(int64 handle, const char* chatroomId, _Out_ void** outJson)" + ); + funcs.wcdb_get_group_nicknames = tryFunc( + "int32 wcdb_get_group_nicknames(int64 handle, const char* chatroomId, _Out_ void** outJson)" + ); + funcs.wcdb_exec_query = tryFunc( + "int32 wcdb_exec_query(int64 handle, const char* kind, const char* dbPath, const char* sql, _Out_ void** outJson)" + ); + funcs.wcdb_update_message = tryFunc( + "int32 wcdb_update_message(int64 handle, const char* sessionId, int64 localId, int32 createTime, const char* newContent, _Out_ void** outError)" + ); + funcs.wcdb_delete_message = tryFunc( + "int32 wcdb_delete_message(int64 handle, const char* sessionId, int64 localId, int32 createTime, const char* dbPathHint, _Out_ void** outError)" + ); + funcs.wcdb_get_sns_timeline = tryFunc( + "int32 wcdb_get_sns_timeline(int64 handle, int32 limit, int32 offset, const char* usernamesJson, const char* keyword, int32 startTime, int32 endTime, _Out_ void** outJson)" + ); + funcs.wcdb_decrypt_sns_image = tryFunc( + "int32 wcdb_decrypt_sns_image(void* encryptedData, int32 len, const char* key, _Out_ void** outHex)" + ); + funcs.wcdb_get_logs = tryFunc("int32 wcdb_get_logs(_Out_ void** outJson)"); + funcs.wcdb_free_string = tryFunc("void wcdb_free_string(void* ptr)"); + + if (!funcs.wcdb_init || !funcs.wcdb_open_account || !funcs.wcdb_get_logs || !funcs.wcdb_free_string) { + throw new ApiError("wcdb_api.dll is missing required exports"); + } + + log(`loaded ${nativeDllPath}`); + return nativeLib; +} + +function requireFunc(name) { + loadNative(); + const fn = funcs[name]; + if (!fn) throw new ApiError(`${name} not exported`, -404); + return fn; +} + +function ptrToString(ptr) { + if (!ptr) return ""; + return loadKoffi().decode(ptr, "char", -1); +} + +function freeStringPtr(ptr) { + if (!ptr || !funcs.wcdb_free_string) return; + try { + funcs.wcdb_free_string(ptr); + } catch {} +} + +function getLogs() { + try { + loadNative(); + const out = [null]; + const rc = Number(funcs.wcdb_get_logs(out)); + try { + if (rc !== 0 || !out[0]) return []; + const payload = ptrToString(out[0]); + const decoded = JSON.parse(payload || "[]"); + return Array.isArray(decoded) ? decoded.map((x) => String(x)) : []; + } finally { + freeStringPtr(out[0]); + } + } catch { + return []; + } +} + +function callOutJson(name, args) { + const fn = requireFunc(name); + const out = [null]; + const rc = Number(fn(...args, out)); + try { + if (rc !== 0) throw new ApiError(`${name} failed`, rc); + return ptrToString(out[0]); + } finally { + freeStringPtr(out[0]); + } +} + +function callOutError(name, args) { + const fn = requireFunc(name); + const out = [null]; + const rc = Number(fn(...args, out)); + try { + if (rc !== 0) { + const message = ptrToString(out[0]) || `${name} failed`; + throw new ApiError(message, rc); + } + return null; + } finally { + freeStringPtr(out[0]); + } +} + +function ensureInitialized() { + loadNative(); + if (initialized) { + return { initialized: true, dllPath: nativeDllPath, protection: protectionResults }; + } + + protectionResults = []; + if (funcs.InitProtection) { + for (const resourcePath of parseResourcePaths()) { + try { + const rc = Number(funcs.InitProtection(resourcePath)); + protectionResults.push({ path: resourcePath, rc }); + log(`InitProtection rc=${rc} path=${resourcePath}`); + if (rc === 0) break; + } catch (err) { + protectionResults.push({ path: resourcePath, error: String(err?.message || err) }); + } + } + } + + const rc = Number(funcs.wcdb_init()); + if (rc !== 0) { + throw new ApiError("wcdb_init failed", rc, { protection: protectionResults }); + } + initialized = true; + return { initialized: true, dllPath: nativeDllPath, protection: protectionResults }; +} + +function normalizeHandle(value) { + const n = Number(value || 0); + if (!Number.isFinite(n) || n <= 0) throw new ApiError("invalid handle"); + return n; +} + +function handleAction(action, payload) { + const data = payload && typeof payload === "object" ? payload : {}; + + switch (action) { + case "init": + return ensureInitialized(); + + case "get_logs": + loadNative(); + return { logs: getLogs() }; + + case "open_account": { + ensureInitialized(); + const dbPath = String(data.path || "").trim(); + const key = String(data.key || "").trim(); + if (!dbPath) throw new ApiError("missing account path"); + if (key.length !== 64) throw new ApiError("invalid db key"); + const out = [0]; + const rc = Number(requireFunc("wcdb_open_account")(dbPath, key, out)); + const handle = Number(out[0] || 0); + if (rc !== 0 || handle <= 0) throw new ApiError("wcdb_open_account failed", rc); + return { handle }; + } + + case "close_account": { + const handle = normalizeHandle(data.handle); + try { + requireFunc("wcdb_close_account")(handle); + } catch {} + return { closed: true }; + } + + case "set_my_wxid": { + const fn = requireFunc("wcdb_set_my_wxid"); + const rc = Number(fn(normalizeHandle(data.handle), String(data.wxid || "").trim())); + return { success: rc === 0, rc }; + } + + case "get_sessions": + return { payload: callOutJson("wcdb_get_sessions", [normalizeHandle(data.handle)]) }; + + case "get_messages": + return { + payload: callOutJson("wcdb_get_messages", [ + normalizeHandle(data.handle), + String(data.username || "").trim(), + Number.parseInt(String(data.limit || 0), 10) || 0, + Number.parseInt(String(data.offset || 0), 10) || 0, + ]), + }; + + case "get_message_count": { + const out = [0]; + const rc = Number( + requireFunc("wcdb_get_message_count")(normalizeHandle(data.handle), String(data.username || "").trim(), out) + ); + return { count: rc === 0 ? Number(out[0] || 0) : 0, rc }; + } + + case "get_display_names": + return { + payload: callOutJson("wcdb_get_display_names", [ + normalizeHandle(data.handle), + JSON.stringify(Array.isArray(data.usernames) ? data.usernames : []), + ]), + }; + + case "get_avatar_urls": + return { + payload: callOutJson("wcdb_get_avatar_urls", [ + normalizeHandle(data.handle), + JSON.stringify(Array.isArray(data.usernames) ? data.usernames : []), + ]), + }; + + case "get_group_members": + return { + payload: callOutJson("wcdb_get_group_members", [ + normalizeHandle(data.handle), + String(data.chatroom_id || "").trim(), + ]), + }; + + case "get_group_nicknames": + return { + payload: callOutJson("wcdb_get_group_nicknames", [ + normalizeHandle(data.handle), + String(data.chatroom_id || "").trim(), + ]), + }; + + case "exec_query": + return { + payload: callOutJson("wcdb_exec_query", [ + normalizeHandle(data.handle), + String(data.kind || "").trim(), + data.path == null ? null : String(data.path || "").trim(), + String(data.sql || ""), + ]), + }; + + case "update_message": + callOutError("wcdb_update_message", [ + normalizeHandle(data.handle), + String(data.session_id || "").trim(), + Number(data.local_id || 0), + Number.parseInt(String(data.create_time || 0), 10) || 0, + String(data.new_content || ""), + ]); + return { success: true }; + + case "delete_message": + callOutError("wcdb_delete_message", [ + normalizeHandle(data.handle), + String(data.session_id || "").trim(), + Number(data.local_id || 0), + Number.parseInt(String(data.create_time || 0), 10) || 0, + String(data.db_path_hint || ""), + ]); + return { success: true }; + + case "get_sns_timeline": + return { + payload: callOutJson("wcdb_get_sns_timeline", [ + normalizeHandle(data.handle), + Number.parseInt(String(data.limit || 0), 10) || 0, + Number.parseInt(String(data.offset || 0), 10) || 0, + JSON.stringify(Array.isArray(data.usernames) ? data.usernames : []), + String(data.keyword || ""), + Number.parseInt(String(data.start_time || 0), 10) || 0, + Number.parseInt(String(data.end_time || 0), 10) || 0, + ]), + }; + + case "decrypt_sns_image": { + ensureInitialized(); + const raw = Buffer.from(String(data.data_b64 || ""), "base64"); + if (!raw.length) return { data_b64: "" }; + const key = String(data.key || "").trim(); + if (!key) return { data_b64: raw.toString("base64") }; + + const out = [null]; + const rc = Number(requireFunc("wcdb_decrypt_sns_image")(raw, raw.length, key, out)); + try { + if (rc !== 0 || !out[0]) return { data_b64: raw.toString("base64"), rc }; + const hex = ptrToString(out[0]).replace(/[^0-9a-f]/gi, ""); + if (!hex) return { data_b64: raw.toString("base64"), rc }; + return { data_b64: Buffer.from(hex, "hex").toString("base64"), rc }; + } finally { + freeStringPtr(out[0]); + } + } + + case "shutdown": { + if (nativeLib && initialized && funcs.wcdb_shutdown) { + try { + funcs.wcdb_shutdown(); + } finally { + initialized = false; + } + } + return { shutdown: true }; + } + + default: + throw new ApiError(`unknown action: ${action}`); + } +} + +async function handleRequest(req, res) { + try { + if (req.method === "GET" && req.url === "/health") { + jsonResponse(res, 200, { ok: true, initialized, dllPath: nativeDllPath || getDllPath() }); + return; + } + + if (req.method !== "POST" || req.url !== "/call") { + jsonResponse(res, 404, { ok: false, error: "not found" }); + return; + } + + if (TOKEN && String(req.headers["x-wcdb-sidecar-token"] || "") !== TOKEN) { + jsonResponse(res, 401, { ok: false, error: "unauthorized" }); + return; + } + + const body = await readRequestJson(req); + const action = String(body?.action || "").trim(); + const result = handleAction(action, body?.payload || {}); + jsonResponse(res, 200, { ok: true, result }); + } catch (err) { + const rc = Number.isFinite(err?.rc) ? Number(err.rc) : 0; + const error = err?.message || String(err); + log(`error rc=${rc} message=${error}`); + jsonResponse(res, 200, { + ok: false, + error, + rc, + details: err?.details || null, + logs: getLogs().slice(-20), + }); + } +} + +if (!Number.isInteger(PORT) || PORT <= 0 || PORT > 65535) { + log(`invalid sidecar port: ${process.env.WECHAT_TOOL_WCDB_SIDECAR_PORT || ""}`); + process.exit(2); +} + +const server = http.createServer((req, res) => { + void handleRequest(req, res); +}); + +server.listen(PORT, HOST, () => { + log(`listening http://${HOST}:${PORT} dll=${getDllPath()} koffi=${process.env.WECHAT_TOOL_KOFFI_DIR || "node_modules"}`); +}); + +process.on("SIGTERM", () => { + try { + server.close(() => process.exit(0)); + } catch { + process.exit(0); + } +}); + +process.on("SIGINT", () => { + try { + server.close(() => process.exit(0)); + } catch { + process.exit(0); + } +}); diff --git a/desktop/vendor/koffi/LICENSE.txt b/desktop/vendor/koffi/LICENSE.txt new file mode 100644 index 0000000..f5446cd --- /dev/null +++ b/desktop/vendor/koffi/LICENSE.txt @@ -0,0 +1,22 @@ +MIT License + +Copyright (C) 2025 Niels Martignène + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the “Software”), to deal in +the Software without restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/desktop/vendor/koffi/build/koffi/win32_x64/koffi.node b/desktop/vendor/koffi/build/koffi/win32_x64/koffi.node new file mode 100644 index 0000000..4cb42f4 Binary files /dev/null and b/desktop/vendor/koffi/build/koffi/win32_x64/koffi.node differ diff --git a/desktop/vendor/koffi/index.js b/desktop/vendor/koffi/index.js new file mode 100644 index 0000000..085c0af --- /dev/null +++ b/desktop/vendor/koffi/index.js @@ -0,0 +1,634 @@ +"use strict"; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __commonJS = (cb, mod3) => function __require() { + return mod3 || (0, cb[__getOwnPropNames(cb)[0]])((mod3 = { exports: {} }).exports, mod3), mod3.exports; +}; + +// package/src/cnoke/src/tools.js +var require_tools = __commonJS({ + "package/src/cnoke/src/tools.js"(exports2, module2) { + "use strict"; + var crypto = require("crypto"); + var fs2 = require("fs"); + var http = require("https"); + var path2 = require("path"); + var zlib = require("zlib"); + async function download_http(url, dest) { + console.log(">> Downloading " + url); + let [tmp_name, file] = open_temporary_stream(dest); + try { + await new Promise((resolve, reject) => { + let request = http.get(url, (response) => { + if (response.statusCode != 200) { + let err = new Error(`Download failed: ${response.statusMessage} [${response.statusCode}]`); + err.code = response.statusCode; + reject(err); + return; + } + response.pipe(file); + file.on("finish", () => file.close(() => { + try { + fs2.renameSync(file.path, dest); + } catch (err) { + if (!fs2.existsSync(dest)) + reject(err); + } + resolve(); + })); + }); + request.on("error", reject); + file.on("error", reject); + }); + } catch (err) { + file.close(); + try { + fs2.unlinkSync(tmp_name); + } catch (err2) { + if (err2.code != "ENOENT") + throw err2; + } + throw err; + } + } + function open_temporary_stream(prefix) { + let buf = Buffer.allocUnsafe(4); + for (; ; ) { + try { + crypto.randomFillSync(buf); + let suffix = buf.toString("hex").padStart(8, "0"); + let filename2 = `${prefix}.${suffix}`; + let file = fs2.createWriteStream(filename2, { flags: "wx", mode: 420 }); + return [filename2, file]; + } catch (err) { + if (err.code != "EEXIST") + throw err; + } + } + } + function extract_targz(filename2, dest_dir, strip = 0) { + let reader = fs2.createReadStream(filename2).pipe(zlib.createGunzip()); + return new Promise((resolve, reject) => { + let header = null; + let extended = {}; + reader.on("readable", () => { + try { + for (; ; ) { + if (header == null) { + let buf = reader.read(512); + if (buf == null) + break; + if (!buf[0]) + continue; + header = { + filename: buf.toString("utf-8", 0, 100).replace(/\0/g, ""), + mode: parseInt(buf.toString("ascii", 100, 109), 8), + size: parseInt(buf.toString("ascii", 124, 137), 8), + type: String.fromCharCode(buf[156]) + }; + Object.assign(header, extended); + extended = {}; + header.filename = header.filename.replace(/\\/g, "/"); + if (!header.filename.length) + throw new Error(`Insecure empty filename inside TAR archive`); + if (path_is_absolute(header.filename[0])) + throw new Error(`Insecure filename starting with / inside TAR archive`); + if (path_has_dotdot(header.filename)) + throw new Error(`Insecure filename containing '..' inside TAR archive`); + for (let i = 0; i < strip; i++) + header.filename = header.filename.substr(header.filename.indexOf("/") + 1); + } + let aligned = Math.floor((header.size + 511) / 512) * 512; + let data = header.size ? reader.read(aligned) : null; + if (data == null) { + if (header.size) + break; + data = Buffer.alloc(0); + } + data = data.subarray(0, header.size); + if (header.type == "0" || header.type == "7") { + let filename3 = dest_dir + "/" + header.filename; + let dirname = path2.dirname(filename3); + fs2.mkdirSync(dirname, { recursive: true, mode: 493 }); + fs2.writeFileSync(filename3, data, { mode: header.mode }); + } else if (header.type == "5") { + let filename3 = dest_dir + "/" + header.filename; + fs2.mkdirSync(filename3, { recursive: true, mode: header.mode }); + } else if (header.type == "L") { + extended.filename = data.toString("utf-8").replace(/\0/g, ""); + } else if (header.type == "x") { + let str = data.toString("utf-8"); + try { + while (str.length) { + let matches = str.match(/^([0-9]+) ([a-zA-Z0-9\._]+)=(.*)\n/); + let skip = parseInt(matches[1], 10); + let key = matches[2]; + let value = matches[3]; + switch (key) { + case "path": + { + extended.filename = value; + } + break; + case "size": + { + extended.size = parseInt(value, 10); + } + break; + } + str = str.substr(skip).trimStart(); + } + } catch (err) { + throw new Error("Malformed PAX entry"); + } + } + header = null; + } + } catch (err) { + reject(err); + } + }); + reader.on("error", reject); + reader.on("end", resolve); + }); + } + function path_is_absolute(path3) { + if (process.platform == "win32" && path3.match(/^[a-zA-Z]:/)) + path3 = path3.substr(2); + return is_path_separator(path3[0]); + } + function path_has_dotdot(path3) { + let start = 0; + for (; ; ) { + let offset = path3.indexOf("..", start); + if (offset < 0) + break; + start = offset + 2; + if (offset && !is_path_separator(path3[offset - 1])) + continue; + if (offset + 2 < path3.length && !is_path_separator(path3[offset + 2])) + continue; + return true; + } + return false; + } + function is_path_separator(c) { + if (c == "/") + return true; + if (process.platform == "win32" && c == "\\") + return true; + return false; + } + function sync_files(src_dir, dest_dir) { + let keep = /* @__PURE__ */ new Set(); + { + let entries = fs2.readdirSync(src_dir, { withFileTypes: true }); + for (let entry of entries) { + if (!entry.isFile()) + continue; + keep.add(entry.name); + fs2.copyFileSync(src_dir + `/${entry.name}`, dest_dir + `/${entry.name}`); + } + } + { + let entries = fs2.readdirSync(dest_dir, { withFileTypes: true }); + for (let entry of entries) { + if (!entry.isFile()) + continue; + if (keep.has(entry.name)) + continue; + fs2.unlinkSync(dest_dir + `/${entry.name}`); + } + } + } + function determine_arch2() { + let arch = process.arch; + if (arch == "riscv32" || arch == "riscv64") { + let buf = read_file_header(process.execPath, 512); + let header = decode_elf_header(buf); + let float_abi = header.e_flags & 6; + switch (float_abi) { + case 0: + { + } + break; + case 2: + { + arch += "f"; + } + break; + case 4: + { + arch += "d"; + } + break; + case 6: + { + arch += "q"; + } + break; + } + } else if (arch == "arm") { + let buf = read_file_header(process.execPath, 512); + let header = decode_elf_header(buf); + if (header.e_flags & 1024) { + arch += "hf"; + } else if (header.e_flags & 512) { + arch += "sf"; + } else { + throw new Error("Unknown ARM floating-point ABI"); + } + } + return arch; + } + function read_file_header(filename2, read) { + let fd = null; + try { + let fd2 = fs2.openSync(filename2); + let buf = Buffer.allocUnsafe(read); + let len = fs2.readSync(fd2, buf); + return buf.subarray(0, len); + } finally { + if (fd != null) + fs2.closeSync(fd); + } + } + function decode_elf_header(buf) { + let header = {}; + if (buf.length < 16) + throw new Error("Truncated header"); + if (buf[0] != 127 || buf[1] != 69 || buf[2] != 76 || buf[3] != 70) + throw new Error("Invalid magic number"); + if (buf[6] != 1) + throw new Error("Invalid ELF version"); + if (buf[5] != 1) + throw new Error("Big-endian architectures are not supported"); + let machine = buf.readUInt16LE(18); + switch (machine) { + case 3: + { + header.e_machine = "ia32"; + } + break; + case 40: + { + header.e_machine = "arm"; + } + break; + case 62: + { + header.e_machine = "amd64"; + } + break; + case 183: + { + header.e_machine = "arm64"; + } + break; + case 243: + { + switch (buf[4]) { + case 1: + { + header.e_machine = "riscv32"; + } + break; + case 2: + { + header.e_machine = "riscv64"; + } + break; + } + } + break; + case 248: + { + switch (buf[4]) { + case 1: + { + header.e_machine = "loong32"; + } + break; + case 2: + { + header.e_machine = "loong64"; + } + break; + } + } + break; + default: + throw new Error("Unknown ELF machine type"); + } + switch (buf[4]) { + case 1: + { + buf = buf.subarray(0, 68); + if (buf.length < 68) + throw new Error("Truncated ELF header"); + header.ei_class = 32; + header.e_flags = buf.readUInt32LE(36); + } + break; + case 2: + { + buf = buf.subarray(0, 120); + if (buf.length < 120) + throw new Error("Truncated ELF header"); + header.ei_class = 64; + header.e_flags = buf.readUInt32LE(48); + } + break; + default: + throw new Error("Invalid ELF class"); + } + return header; + } + function unlink_recursive(path3) { + try { + fs2.rmSync(path3, { recursive: true, maxRetries: process.platform == "win32" ? 3 : 0 }); + } catch (err) { + if (err.code !== "ENOENT") + throw err; + } + } + function get_napi_version2(napi, major) { + if (napi > 8) + return null; + const support = { + 6: ["6.14.2", "6.14.2", "6.14.2"], + 8: ["8.6.0", "8.10.0", "8.11.2"], + 9: ["9.0.0", "9.3.0", "9.11.0"], + 10: ["10.0.0", "10.0.0", "10.0.0", "10.16.0", "10.17.0", "10.20.0", "10.23.0"], + 11: ["11.0.0", "11.0.0", "11.0.0", "11.8.0"], + 12: ["12.0.0", "12.0.0", "12.0.0", "12.0.0", "12.11.0", "12.17.0", "12.19.0", "12.22.0"], + 13: ["13.0.0", "13.0.0", "13.0.0", "13.0.0", "13.0.0"], + 14: ["14.0.0", "14.0.0", "14.0.0", "14.0.0", "14.0.0", "14.0.0", "14.12.0", "14.17.0"], + 15: ["15.0.0", "15.0.0", "15.0.0", "15.0.0", "15.0.0", "15.0.0", "15.0.0", "15.12.0"] + }; + const max = Math.max(...Object.keys(support).map((k) => parseInt(k, 10))); + if (major > max) + return major + ".0.0"; + if (support[major] == null) + return null; + let required = support[major][napi - 1] || null; + return required; + } + function cmp_version(ver1, ver2) { + ver1 = String(ver1).replace(/-.*$/, "").split(".").reduce((acc, v, idx) => acc + parseInt(v, 10) * Math.pow(10, 2 * (5 - idx)), 0); + ver2 = String(ver2).replace(/-.*$/, "").split(".").reduce((acc, v, idx) => acc + parseInt(v, 10) * Math.pow(10, 2 * (5 - idx)), 0); + let cmp = Math.min(Math.max(ver1 - ver2, -1), 1); + return cmp; + } + module2.exports = { + download_http, + extract_targz, + path_is_absolute, + path_has_dotdot, + sync_files, + determine_arch: determine_arch2, + unlink_recursive, + get_napi_version: get_napi_version2, + cmp_version + }; + } +}); + +// package/src/koffi/package.json +var require_package = __commonJS({ + "package/src/koffi/package.json"(exports2, module2) { + module2.exports = { + name: "koffi", + version: "2.15.2", + description: "Fast and simple C FFI (foreign function interface) for Node.js", + keywords: [ + "foreign", + "function", + "interface", + "ffi", + "binding", + "c", + "napi" + ], + repository: { + type: "git", + url: "https://github.com/Koromix/koffi" + }, + homepage: "https://koffi.dev/", + author: { + name: "Niels Martign\xE8ne", + email: "niels.martignene@protonmail.com", + url: "https://koromix.dev/" + }, + main: "./index.js", + types: "./index.d.ts", + scripts: { + test: "node tools/koffi.js test", + prepack: `echo 'Use "npm run package" instead' && false`, + prepublishOnly: `echo 'Use "npm run package" instead' && false`, + package: "node tools/koffi.js build" + }, + license: "MIT", + cnoke: { + api: "../../vendor/node-api-headers", + output: "../../bin/Koffi/{{ toolchain }}", + node: 16, + napi: 8, + require: "./index.js" + }, + funding: "https://liberapay.com/Koromix" + }; + } +}); + +// package/src/koffi/src/init.js +var require_init = __commonJS({ + "package/src/koffi/src/init.js"(exports, module) { + var fs = require("fs"); + var path = require("path"); + var util = require("util"); + var { get_napi_version, determine_arch } = require_tools(); + var pkg = require_package(); + function detect() { + if (process.versions.napi == null || process.versions.napi < pkg.cnoke.napi) { + let major = parseInt(process.versions.node, 10); + let required = get_napi_version(pkg.cnoke.napi, major); + if (required != null) { + throw new Error(`This engine is based on Node ${process.versions.node}, but ${pkg.name} requires Node >= ${required} in the Node ${major}.x branch (N-API >= ${pkg.cnoke.napi})`); + } else { + throw new Error(`This engine is based on Node ${process.versions.node}, but ${pkg.name} does not support the Node ${major}.x branch (N-API < ${pkg.cnoke.napi})`); + } + } + let arch = determine_arch(); + let triplet3 = `${process.platform}_${arch}`; + return triplet3; + } + function init(triplet, native) { + if (native == null) { + let roots = [path.join(__dirname, "..")]; + let triplets = [triplet]; + if (process.resourcesPath != null) + roots.push(process.resourcesPath); + if (triplet.startsWith("linux_")) { + let musl = triplet.replace(/^linux_/, "musl_"); + triplets.push(musl); + } + let filenames = roots.flatMap((root) => triplets.flatMap((triplet3) => [ + `${root}/koffi/build/koffi/${triplet3}/koffi.node`, + `${root}/build/koffi/${triplet3}/koffi.node`, + `${root}/koffi/${triplet3}/koffi.node`, + `${root}/node_modules/koffi/build/koffi/${triplet3}/koffi.node`, + `${root}/../../bin/Koffi/${triplet3}/koffi.node` + ])); + let first_err = null; + for (let filename of filenames) { + if (!fs.existsSync(filename)) + continue; + try { + native = eval("require")(filename); + } catch (err) { + if (first_err == null) + first_err = err; + continue; + } + break; + } + if (first_err != null) + throw first_err; + } + if (native == null) + throw new Error("Cannot find the native Koffi module; did you bundle it correctly?"); + if (native.version != pkg.version) + throw new Error("Mismatched native Koffi modules"); + let mod = wrap(native); + return mod; + } + function wrap(native3) { + let obj = { + ...native3, + // Deprecated functions + handle: util.deprecate(native3.opaque, "The koffi.handle() function was deprecated in Koffi 2.1, use koffi.opaque() instead", "KOFFI001"), + callback: util.deprecate(native3.proto, "The koffi.callback() function was deprecated in Koffi 2.4, use koffi.proto() instead", "KOFFI002") + }; + obj.load = (...args) => { + let lib = native3.load(...args); + lib.cdecl = util.deprecate((...args2) => lib.func("__cdecl", ...args2), "The koffi.cdecl() function was deprecated in Koffi 2.7, use koffi.func(...) instead", "KOFFI003"); + lib.stdcall = util.deprecate((...args2) => lib.func("__stdcall", ...args2), 'The koffi.stdcall() function was deprecated in Koffi 2.7, use koffi.func("__stdcall", ...) instead', "KOFFI004"); + lib.fastcall = util.deprecate((...args2) => lib.func("__fastcall", ...args2), 'The koffi.fastcall() function was deprecated in Koffi 2.7, use koffi.func("__fastcall", ...) instead', "KOFFI005"); + lib.thiscall = util.deprecate((...args2) => lib.func("__thiscall", ...args2), 'The koffi.thiscall() function was deprecated in Koffi 2.7, use koffi.func("__thiscall", ...) instead', "KOFFI006"); + return lib; + }; + return obj; + } + module.exports = { + detect, + init + }; + } +}); + +// package/src/koffi/index.js +var { detect: detect2, init: init2 } = require_init(); +var triplet2 = detect2(); +var native2 = null; +try { + switch (triplet2) { + case "darwin_arm64": + { + native2 = require("./build/koffi/darwin_arm64/koffi.node"); + } + break; + case "darwin_x64": + { + native2 = require("./build/koffi/darwin_x64/koffi.node"); + } + break; + case "freebsd_arm64": + { + native2 = require("./build/koffi/freebsd_arm64/koffi.node"); + } + break; + case "freebsd_ia32": + { + native2 = require("./build/koffi/freebsd_ia32/koffi.node"); + } + break; + case "freebsd_x64": + { + native2 = require("./build/koffi/freebsd_x64/koffi.node"); + } + break; + case "linux_armhf": + { + native2 = require("./build/koffi/linux_armhf/koffi.node"); + } + break; + case "linux_arm64": + { + native2 = require("./build/koffi/linux_arm64/koffi.node"); + } + break; + case "linux_ia32": + { + native2 = require("./build/koffi/linux_ia32/koffi.node"); + } + break; + case "linux_loong64": + { + native2 = require("./build/koffi/linux_loong64/koffi.node"); + } + break; + case "linux_riscv64d": + { + native2 = require("./build/koffi/linux_riscv64d/koffi.node"); + } + break; + case "linux_x64": + { + native2 = require("./build/koffi/linux_x64/koffi.node"); + } + break; + case "openbsd_ia32": + { + native2 = require("./build/koffi/openbsd_ia32/koffi.node"); + } + break; + case "openbsd_x64": + { + native2 = require("./build/koffi/openbsd_x64/koffi.node"); + } + break; + case "win32_arm64": + { + native2 = require("./build/koffi/win32_arm64/koffi.node"); + } + break; + case "win32_ia32": + { + native2 = require("./build/koffi/win32_ia32/koffi.node"); + } + break; + case "win32_x64": + { + native2 = require("./build/koffi/win32_x64/koffi.node"); + } + break; + } +} catch { + try { + switch (triplet2) { + case "linux_arm64": + { + native2 = require("./build/koffi/musl_arm64/koffi.node"); + } + break; + case "linux_x64": + { + native2 = require("./build/koffi/musl_x64/koffi.node"); + } + break; + } + } catch { + } +} +var mod2 = init2(triplet2, native2); +module.exports = mod2; diff --git a/desktop/vendor/koffi/package.json b/desktop/vendor/koffi/package.json new file mode 100644 index 0000000..2002e4d --- /dev/null +++ b/desktop/vendor/koffi/package.json @@ -0,0 +1,26 @@ +{ + "name": "koffi", + "version": "2.15.2", + "description": "Fast and simple C FFI (foreign function interface) for Node.js", + "repository": { + "type": "git", + "url": "https://github.com/Koromix/koffi" + }, + "homepage": "https://koffi.dev/", + "author": { + "name": "Niels Martignène", + "email": "niels.martignene@protonmail.com", + "url": "https://koromix.dev/" + }, + "main": "./index.js", + "types": "./index.d.ts", + "license": "MIT", + "cnoke": { + "api": "../../vendor/node-api-headers", + "output": "build/koffi/{{ toolchain }}", + "node": 16, + "napi": 8, + "require": "./index.js" + }, + "funding": "https://liberapay.com/Koromix" +} \ No newline at end of file diff --git a/src/wechat_decrypt_tool/native/SDL2.dll b/src/wechat_decrypt_tool/native/SDL2.dll new file mode 100644 index 0000000..e26bcb1 Binary files /dev/null and b/src/wechat_decrypt_tool/native/SDL2.dll differ diff --git a/src/wechat_decrypt_tool/native/wcdb_api.dll b/src/wechat_decrypt_tool/native/wcdb_api.dll index 7bbb0f4..4dc46d7 100644 Binary files a/src/wechat_decrypt_tool/native/wcdb_api.dll and b/src/wechat_decrypt_tool/native/wcdb_api.dll differ diff --git a/src/wechat_decrypt_tool/wcdb_realtime.py b/src/wechat_decrypt_tool/wcdb_realtime.py index 11bc804..a7dd312 100644 --- a/src/wechat_decrypt_tool/wcdb_realtime.py +++ b/src/wechat_decrypt_tool/wcdb_realtime.py @@ -1,4 +1,5 @@ import ctypes +import base64 import binascii import json import os @@ -6,6 +7,8 @@ import re import sys import threading import time +import urllib.error +import urllib.request from dataclasses import dataclass from pathlib import Path from typing import Any, Optional @@ -121,14 +124,188 @@ def _resolve_wcdb_api_dll_path() -> Path: _lib_lock = threading.Lock() _lib: Optional[ctypes.CDLL] = None _initialized = False +_loaded_wcdb_api_dll: Optional[Path] = None +_preloaded_native_libs: list[ctypes.CDLL] = [] +_protection_checked = False +_protection_result: Optional[tuple[int, str]] = None def _is_windows() -> bool: return sys.platform.startswith("win") +def _iter_wcdb_resource_paths(wcdb_api_dll: Path) -> tuple[Path, ...]: + candidates: list[Path] = [] + seen: set[str] = set() + + def add(path: str | Path | None) -> None: + if not path: + return + try: + resolved = Path(path).resolve() + except Exception: + resolved = Path(path) + key = str(resolved).replace("/", "\\").rstrip("\\").lower() + if key in seen: + return + seen.add(key) + candidates.append(resolved) + + dll_dir = wcdb_api_dll.parent + add(dll_dir) + add(dll_dir.parent) + add(_NATIVE_DIR) + add(_NATIVE_DIR.parent) + + cwd = Path.cwd() + add(cwd) + add(cwd / "resources") + + data_dir = str(os.environ.get("WECHAT_TOOL_DATA_DIR", "") or "").strip() + if data_dir: + add(data_dir) + add(Path(data_dir) / "resources") + + if getattr(sys, "frozen", False): + try: + exe_dir = Path(sys.executable).resolve().parent + except Exception: + exe_dir = Path(sys.executable).parent + add(exe_dir) + add(exe_dir / "resources") + + return tuple(candidates) + + +def _preload_wcdb_dependencies(wcdb_api_dll: Path) -> None: + dll_dir = wcdb_api_dll.parent + for name in ("WCDB.dll", "SDL2.dll", "VoipEngine.dll"): + dep_path = dll_dir / name + if not dep_path.exists(): + continue + try: + _preloaded_native_libs.append(ctypes.CDLL(str(dep_path))) + logger.info("[wcdb] preloaded dependency: %s", dep_path) + except Exception as exc: + logger.warning("[wcdb] preload dependency failed: %s err=%s", dep_path, exc) + + +def _run_init_protection(lib: ctypes.CDLL, wcdb_api_dll: Path) -> None: + global _protection_checked, _protection_result + if _protection_checked: + return + _protection_checked = True + + fn = getattr(lib, "InitProtection", None) + if not fn: + logger.info("[wcdb] InitProtection not exported: %s", wcdb_api_dll) + return + + try: + fn.argtypes = [ctypes.c_char_p] + fn.restype = ctypes.c_int32 + except Exception: + pass + + best: Optional[tuple[int, str]] = None + for resource_path in _iter_wcdb_resource_paths(wcdb_api_dll): + try: + rc = int(fn(str(resource_path).encode("utf-8"))) + logger.info("[wcdb] InitProtection rc=%s path=%s", rc, resource_path) + if rc == 0: + _protection_result = (rc, str(resource_path)) + return + if best is None: + best = (rc, str(resource_path)) + except Exception as exc: + logger.warning("[wcdb] InitProtection exception path=%s err=%s", resource_path, exc) + + _protection_result = best + + +def _format_protection_hint() -> str: + if not _protection_result: + return "" + rc, resource_path = _protection_result + return f" protection_rc={rc} protection_path={resource_path}" + + +def _sidecar_url() -> str: + return str(os.environ.get("WECHAT_TOOL_WCDB_SIDECAR_URL", "") or "").strip().rstrip("/") + + +def _sidecar_enabled() -> bool: + return bool(_sidecar_url()) + + +def _sidecar_call(action: str, payload: Optional[dict[str, Any]] = None, *, timeout: float = 30.0) -> dict[str, Any]: + base_url = _sidecar_url() + if not base_url: + raise WCDBRealtimeError("WCDB sidecar is not configured.") + + token = str(os.environ.get("WECHAT_TOOL_WCDB_SIDECAR_TOKEN", "") or "").strip() + body = json.dumps( + { + "action": str(action or "").strip(), + "payload": payload or {}, + }, + ensure_ascii=False, + ).encode("utf-8") + + deadline = time.monotonic() + max(1.0, float(timeout or 30.0)) + last_err: Exception | None = None + attempts = 20 if action == "init" else 3 + for attempt in range(attempts): + headers = { + "Content-Type": "application/json; charset=utf-8", + "Accept": "application/json", + } + if token: + headers["X-WCDB-Sidecar-Token"] = token + req = urllib.request.Request( + f"{base_url}/call", + data=body, + headers=headers, + method="POST", + ) + try: + remaining = max(0.5, deadline - time.monotonic()) + with urllib.request.urlopen(req, timeout=min(remaining, max(0.5, timeout))) as resp: + raw = resp.read() + decoded = json.loads(raw.decode("utf-8", errors="replace") or "{}") + if not isinstance(decoded, dict): + raise WCDBRealtimeError("WCDB sidecar returned invalid response.") + if decoded.get("ok"): + result = decoded.get("result") + return result if isinstance(result, dict) else {} + rc = decoded.get("rc") + err = str(decoded.get("error") or "WCDB sidecar call failed") + logs = decoded.get("logs") + hint = "" + if isinstance(logs, list) and logs: + hint = f" logs={[str(x) for x in logs[:6]]}" + raise WCDBRealtimeError(f"{err} rc={rc}.{hint}") + except WCDBRealtimeError: + raise + except (urllib.error.URLError, TimeoutError, OSError) as exc: + last_err = exc + if attempt >= attempts - 1 or time.monotonic() >= deadline: + break + time.sleep(0.15) + except Exception as exc: + last_err = exc + break + + raise WCDBRealtimeError(f"WCDB sidecar unavailable: {last_err}") + + +def _sidecar_payload(action: str, payload: Optional[dict[str, Any]] = None, *, timeout: float = 30.0) -> str: + result = _sidecar_call(action, payload, timeout=timeout) + return str(result.get("payload") or "") + + def _load_wcdb_lib() -> ctypes.CDLL: - global _lib + global _lib, _loaded_wcdb_api_dll with _lib_lock: if _lib is not None: return _lib @@ -146,6 +323,7 @@ def _load_wcdb_lib() -> ctypes.CDLL: except Exception: pass + _preload_wcdb_dependencies(wcdb_api_dll) lib = ctypes.CDLL(str(wcdb_api_dll)) logger.info("[wcdb] using wcdb_api.dll: %s", wcdb_api_dll) @@ -291,20 +469,50 @@ def _load_wcdb_lib() -> ctypes.CDLL: lib.wcdb_free_string.argtypes = [ctypes.c_char_p] lib.wcdb_free_string.restype = None + _loaded_wcdb_api_dll = wcdb_api_dll _lib = lib return lib def _ensure_initialized() -> None: - global _initialized + global _initialized, _loaded_wcdb_api_dll, _protection_result + if _sidecar_enabled(): + with _lib_lock: + if _initialized: + return + result = _sidecar_call("init", timeout=30.0) + dll_path = str(result.get("dllPath") or "").strip() + if dll_path: + try: + _loaded_wcdb_api_dll = Path(dll_path) + except Exception: + pass + protection = result.get("protection") + if isinstance(protection, list): + for item in protection: + if isinstance(item, dict) and "rc" in item: + try: + _protection_result = (int(item.get("rc")), str(item.get("path") or "")) + if int(item.get("rc")) == 0: + break + except Exception: + continue + with _lib_lock: + _initialized = True + return + lib = _load_wcdb_lib() with _lib_lock: if _initialized: return + wcdb_api_dll = _loaded_wcdb_api_dll or _resolve_wcdb_api_dll_path() + _run_init_protection(lib, wcdb_api_dll) rc = int(lib.wcdb_init()) if rc != 0: logs = get_native_logs(require_initialized=False) - hint = f" logs={logs[:6]}" if logs else "" + hint = _format_protection_hint() + if logs: + hint += f" logs={logs[:6]}" raise WCDBRealtimeError(f"wcdb_init failed: {rc}.{hint}") _initialized = True @@ -368,6 +576,21 @@ def _call_out_error(fn, *args) -> None: def get_native_logs(*, require_initialized: bool = True) -> list[str]: + if _sidecar_enabled(): + if require_initialized: + try: + _ensure_initialized() + except Exception: + return [] + try: + result = _sidecar_call("get_logs", timeout=5.0) + logs = result.get("logs") + if isinstance(logs, list): + return [str(x) for x in logs] + return [] + except Exception: + return [] + if require_initialized: try: _ensure_initialized() @@ -404,6 +627,20 @@ def open_account(session_db_path: Path, key_hex: str) -> int: if len(key) != 64: raise WCDBRealtimeError("Invalid db key (must be 64 hex chars).") + if _sidecar_enabled(): + result = _sidecar_call( + "open_account", + { + "path": str(p), + "key": key, + }, + timeout=30.0, + ) + handle = int(result.get("handle") or 0) + if handle <= 0: + raise WCDBRealtimeError("wcdb_open_account failed: invalid sidecar handle.") + return handle + lib = _load_wcdb_lib() out_handle = ctypes.c_int64(0) rc = int(lib.wcdb_open_account(str(p).encode("utf-8"), key.encode("utf-8"), ctypes.byref(out_handle))) @@ -421,15 +658,22 @@ def set_my_wxid(handle: int, wxid: str) -> bool: except Exception: return False + w = str(wxid or "").strip() + if not w: + return False + + if _sidecar_enabled(): + try: + result = _sidecar_call("set_my_wxid", {"handle": int(handle), "wxid": w}, timeout=10.0) + return bool(result.get("success")) + except Exception: + return False + lib = _load_wcdb_lib() fn = getattr(lib, "wcdb_set_my_wxid", None) if not fn: return False - w = str(wxid or "").strip() - if not w: - return False - try: rc = int(fn(ctypes.c_int64(int(handle)), w.encode("utf-8"))) except Exception: @@ -449,6 +693,12 @@ def close_account(handle: int) -> None: _ensure_initialized() except Exception: return + if _sidecar_enabled(): + try: + _sidecar_call("close_account", {"handle": h}, timeout=5.0) + except Exception: + pass + return lib = _load_wcdb_lib() try: lib.wcdb_close_account(ctypes.c_int64(h)) @@ -458,6 +708,13 @@ def close_account(handle: int) -> None: def get_sessions(handle: int) -> list[dict[str, Any]]: _ensure_initialized() + if _sidecar_enabled(): + payload = _sidecar_payload("get_sessions", {"handle": int(handle)}, timeout=30.0) + decoded = _safe_load_json(payload) + if isinstance(decoded, list): + return [x for x in decoded if isinstance(x, dict)] + return [] + lib = _load_wcdb_lib() payload = _call_out_json(lib.wcdb_get_sessions, ctypes.c_int64(int(handle))) decoded = _safe_load_json(payload) @@ -472,10 +729,26 @@ def get_sessions(handle: int) -> list[dict[str, Any]]: def get_messages(handle: int, username: str, *, limit: int = 50, offset: int = 0) -> list[dict[str, Any]]: _ensure_initialized() - lib = _load_wcdb_lib() u = str(username or "").strip() if not u: return [] + if _sidecar_enabled(): + payload = _sidecar_payload( + "get_messages", + { + "handle": int(handle), + "username": u, + "limit": int(limit), + "offset": int(offset), + }, + timeout=30.0, + ) + decoded = _safe_load_json(payload) + if isinstance(decoded, list): + return [x for x in decoded if isinstance(x, dict)] + return [] + + lib = _load_wcdb_lib() payload = _call_out_json( lib.wcdb_get_messages, ctypes.c_int64(int(handle)), @@ -495,10 +768,21 @@ def get_messages(handle: int, username: str, *, limit: int = 50, offset: int = 0 def get_message_count(handle: int, username: str) -> int: _ensure_initialized() - lib = _load_wcdb_lib() u = str(username or "").strip() if not u: return 0 + if _sidecar_enabled(): + result = _sidecar_call( + "get_message_count", + {"handle": int(handle), "username": u}, + timeout=30.0, + ) + try: + return int(result.get("count") or 0) + except Exception: + return 0 + + lib = _load_wcdb_lib() out_count = ctypes.c_int32(0) rc = int(lib.wcdb_get_message_count(ctypes.c_int64(int(handle)), u.encode("utf-8"), ctypes.byref(out_count))) if rc != 0: @@ -508,11 +792,22 @@ def get_message_count(handle: int, username: str) -> int: def get_display_names(handle: int, usernames: list[str]) -> dict[str, str]: _ensure_initialized() - lib = _load_wcdb_lib() uniq = [str(u or "").strip() for u in usernames if str(u or "").strip()] uniq = list(dict.fromkeys(uniq)) if not uniq: return {} + if _sidecar_enabled(): + out_json = _sidecar_payload( + "get_display_names", + {"handle": int(handle), "usernames": uniq}, + timeout=30.0, + ) + decoded = _safe_load_json(out_json) + if isinstance(decoded, dict): + return {str(k): str(v) for k, v in decoded.items()} + return {} + + lib = _load_wcdb_lib() payload = json.dumps(uniq, ensure_ascii=False).encode("utf-8") out_json = _call_out_json(lib.wcdb_get_display_names, ctypes.c_int64(int(handle)), payload) decoded = _safe_load_json(out_json) @@ -523,11 +818,22 @@ def get_display_names(handle: int, usernames: list[str]) -> dict[str, str]: def get_avatar_urls(handle: int, usernames: list[str]) -> dict[str, str]: _ensure_initialized() - lib = _load_wcdb_lib() uniq = [str(u or "").strip() for u in usernames if str(u or "").strip()] uniq = list(dict.fromkeys(uniq)) if not uniq: return {} + if _sidecar_enabled(): + out_json = _sidecar_payload( + "get_avatar_urls", + {"handle": int(handle), "usernames": uniq}, + timeout=30.0, + ) + decoded = _safe_load_json(out_json) + if isinstance(decoded, dict): + return {str(k): str(v) for k, v in decoded.items()} + return {} + + lib = _load_wcdb_lib() payload = json.dumps(uniq, ensure_ascii=False).encode("utf-8") out_json = _call_out_json(lib.wcdb_get_avatar_urls, ctypes.c_int64(int(handle)), payload) decoded = _safe_load_json(out_json) @@ -538,10 +844,21 @@ def get_avatar_urls(handle: int, usernames: list[str]) -> dict[str, str]: def get_group_members(handle: int, chatroom_id: str) -> list[dict[str, Any]]: _ensure_initialized() - lib = _load_wcdb_lib() cid = str(chatroom_id or "").strip() if not cid: return [] + if _sidecar_enabled(): + out_json = _sidecar_payload( + "get_group_members", + {"handle": int(handle), "chatroom_id": cid}, + timeout=30.0, + ) + decoded = _safe_load_json(out_json) + if isinstance(decoded, list): + return [x for x in decoded if isinstance(x, dict)] + return [] + + lib = _load_wcdb_lib() out_json = _call_out_json(lib.wcdb_get_group_members, ctypes.c_int64(int(handle)), cid.encode("utf-8")) decoded = _safe_load_json(out_json) if isinstance(decoded, list): @@ -555,15 +872,29 @@ def get_group_members(handle: int, chatroom_id: str) -> list[dict[str, Any]]: def get_group_nicknames(handle: int, chatroom_id: str) -> dict[str, str]: _ensure_initialized() + cid = str(chatroom_id or "").strip() + if not cid: + return {} + + if _sidecar_enabled(): + try: + out_json = _sidecar_payload( + "get_group_nicknames", + {"handle": int(handle), "chatroom_id": cid}, + timeout=30.0, + ) + except Exception: + return {} + decoded = _safe_load_json(out_json) + if isinstance(decoded, dict): + return {str(k): str(v) for k, v in decoded.items()} + return {} + lib = _load_wcdb_lib() fn = getattr(lib, "wcdb_get_group_nicknames", None) if not fn: return {} - cid = str(chatroom_id or "").strip() - if not cid: - return {} - out_json = _call_out_json(fn, ctypes.c_int64(int(handle)), cid.encode("utf-8")) decoded = _safe_load_json(out_json) if isinstance(decoded, dict): @@ -577,11 +908,6 @@ def exec_query(handle: int, *, kind: str, path: Optional[str], sql: str) -> list This is primarily used for SNS/other dbs that are not directly exposed by dedicated APIs. """ _ensure_initialized() - lib = _load_wcdb_lib() - fn = getattr(lib, "wcdb_exec_query", None) - if not fn: - raise WCDBRealtimeError("Current wcdb_api.dll does not support exec_query.") - k = str(kind or "").strip() if not k: raise WCDBRealtimeError("Missing kind for exec_query.") @@ -592,6 +918,27 @@ def exec_query(handle: int, *, kind: str, path: Optional[str], sql: str) -> list p = None if path is None else str(path or "").strip() + if _sidecar_enabled(): + out_json = _sidecar_payload( + "exec_query", + { + "handle": int(handle), + "kind": k, + "path": p, + "sql": s, + }, + timeout=60.0, + ) + decoded = _safe_load_json(out_json) + if isinstance(decoded, list): + return [x for x in decoded if isinstance(x, dict)] + return [] + + lib = _load_wcdb_lib() + fn = getattr(lib, "wcdb_exec_query", None) + if not fn: + raise WCDBRealtimeError("Current wcdb_api.dll does not support exec_query.") + out_json = _call_out_json( fn, ctypes.c_int64(int(handle)), @@ -615,15 +962,29 @@ def update_message(handle: int, *, session_id: str, local_id: int, create_time: Requires wcdb_update_message export in wcdb_api.dll. """ _ensure_initialized() + sid = str(session_id or "").strip() + if not sid: + raise WCDBRealtimeError("Missing session_id for update_message.") + + if _sidecar_enabled(): + _sidecar_call( + "update_message", + { + "handle": int(handle), + "session_id": sid, + "local_id": int(local_id or 0), + "create_time": int(create_time or 0), + "new_content": str(new_content or ""), + }, + timeout=30.0, + ) + return + lib = _load_wcdb_lib() fn = getattr(lib, "wcdb_update_message", None) if not fn: raise WCDBRealtimeError("Current wcdb_api.dll does not support update_message.") - sid = str(session_id or "").strip() - if not sid: - raise WCDBRealtimeError("Missing session_id for update_message.") - _call_out_error( fn, ctypes.c_int64(int(handle)), @@ -647,16 +1008,30 @@ def delete_message( Requires wcdb_delete_message export in wcdb_api.dll. """ _ensure_initialized() - lib = _load_wcdb_lib() - fn = getattr(lib, "wcdb_delete_message", None) - if not fn: - raise WCDBRealtimeError("Current wcdb_api.dll does not support delete_message.") - sid = str(session_id or "").strip() if not sid: raise WCDBRealtimeError("Missing session_id for delete_message.") hint = str(db_path_hint or "").strip() + if _sidecar_enabled(): + _sidecar_call( + "delete_message", + { + "handle": int(handle), + "session_id": sid, + "local_id": int(local_id or 0), + "create_time": int(create_time or 0), + "db_path_hint": hint, + }, + timeout=30.0, + ) + return + + lib = _load_wcdb_lib() + fn = getattr(lib, "wcdb_delete_message", None) + if not fn: + raise WCDBRealtimeError("Current wcdb_api.dll does not support delete_message.") + _call_out_error( fn, ctypes.c_int64(int(handle)), @@ -682,11 +1057,6 @@ def get_sns_timeline( Requires a newer wcdb_api.dll export: wcdb_get_sns_timeline. """ _ensure_initialized() - lib = _load_wcdb_lib() - fn = getattr(lib, "wcdb_get_sns_timeline", None) - if not fn: - raise WCDBRealtimeError("Current wcdb_api.dll does not support sns timeline.") - lim = max(0, int(limit or 0)) off = max(0, int(offset or 0)) @@ -696,6 +1066,30 @@ def get_sns_timeline( kw = str(keyword or "").strip() + if _sidecar_enabled(): + payload = _sidecar_payload( + "get_sns_timeline", + { + "handle": int(handle), + "limit": lim, + "offset": off, + "usernames": users, + "keyword": kw, + "start_time": int(start_time or 0), + "end_time": int(end_time or 0), + }, + timeout=60.0, + ) + decoded = _safe_load_json(payload) + if isinstance(decoded, list): + return [x for x in decoded if isinstance(x, dict)] + return [] + + lib = _load_wcdb_lib() + fn = getattr(lib, "wcdb_get_sns_timeline", None) + if not fn: + raise WCDBRealtimeError("Current wcdb_api.dll does not support sns timeline.") + payload = _call_out_json( fn, ctypes.c_int64(int(handle)), @@ -724,11 +1118,6 @@ def decrypt_sns_image(encrypted_data: bytes, key: str) -> bytes: - On failure, returns the original encrypted_data (best-effort behavior like WeFlow). """ _ensure_initialized() - lib = _load_wcdb_lib() - fn = getattr(lib, "wcdb_decrypt_sns_image", None) - if not fn: - raise WCDBRealtimeError("Current wcdb_api.dll does not support sns image decryption.") - raw = bytes(encrypted_data or b"") if not raw: return b"" @@ -737,6 +1126,28 @@ def decrypt_sns_image(encrypted_data: bytes, key: str) -> bytes: if not k: return raw + if _sidecar_enabled(): + result = _sidecar_call( + "decrypt_sns_image", + { + "data_b64": base64.b64encode(raw).decode("ascii"), + "key": k, + }, + timeout=60.0, + ) + data_b64 = str(result.get("data_b64") or "") + if not data_b64: + return raw + try: + return base64.b64decode(data_b64) + except Exception: + return raw + + lib = _load_wcdb_lib() + fn = getattr(lib, "wcdb_decrypt_sns_image", None) + if not fn: + raise WCDBRealtimeError("Current wcdb_api.dll does not support sns image decryption.") + out_ptr = ctypes.c_void_p() buf = ctypes.create_string_buffer(raw, len(raw)) rc = 0 @@ -775,6 +1186,17 @@ def decrypt_sns_image(encrypted_data: bytes, key: str) -> bytes: def shutdown() -> None: global _initialized + if _sidecar_enabled(): + with _lib_lock: + if not _initialized: + return + try: + _sidecar_call("shutdown", timeout=5.0) + finally: + with _lib_lock: + _initialized = False + return + lib = _load_wcdb_lib() with _lib_lock: if not _initialized: @@ -893,6 +1315,7 @@ class WCDBRealtimeManager: with self._mu: failed_at = self._failed.get(account) if failed_at is not None and (time.monotonic() - failed_at) < self._FAILED_TTL: + logger.warning("[wcdb] recent failure cache hit account=%s ttl=%ss", account, int(self._FAILED_TTL)) raise WCDBRealtimeError("WCDB connection recently failed; retry after 60s.") deadline = time.monotonic() + timeout @@ -926,9 +1349,11 @@ class WCDBRealtimeManager: if len(key) != 64: with self._mu: self._failed[account] = time.monotonic() + logger.warning("[wcdb] missing/invalid db key account=%s key_len=%s", account, len(key)) raise WCDBRealtimeError("Missing db key for this account (call /api/keys or decrypt first).") db_storage_dir = _resolve_account_db_storage_dir(account_dir) if db_storage_dir is None: + logger.warning("[wcdb] db_storage resolve failed account=%s account_dir=%s", account, account_dir) raise WCDBRealtimeError("Cannot resolve db_storage directory for this account.") session_db_path = _resolve_session_db_path(db_storage_dir) @@ -952,14 +1377,27 @@ class WCDBRealtimeManager: if open_thread.is_alive(): with self._mu: self._failed[account] = time.monotonic() + logger.warning( + "[wcdb] open_account timeout account=%s timeout=%ss session_db=%s", + account, + int(timeout), + session_db_path, + ) raise WCDBRealtimeError( f"open_account timed out after {timeout:.0f}s for {session_db_path}" ) if _open_err: with self._mu: self._failed[account] = time.monotonic() + logger.warning( + "[wcdb] open_account failed account=%s session_db=%s error=%s", + account, + session_db_path, + _open_err[0], + ) raise _open_err[0] if not _handle_box: + logger.warning("[wcdb] open_account returned no handle account=%s session_db=%s", account, session_db_path) raise WCDBRealtimeError("open_account returned no handle.") handle = _handle_box[0] @@ -980,7 +1418,8 @@ class WCDBRealtimeManager: with self._mu: self._conns[account] = conn - logger.info(f"[wcdb] connected account={account} session_db={session_db_path}") + self._failed.pop(account, None) + logger.info("[wcdb] connected account=%s handle=%s session_db=%s", account, int(handle), session_db_path) return conn finally: with self._mu: