mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
Compare commits
12 Commits
@@ -57,6 +57,14 @@
|
||||
{
|
||||
"from": "resources/backend",
|
||||
"to": "backend"
|
||||
},
|
||||
{
|
||||
"from": "src/wcdb-sidecar.cjs",
|
||||
"to": "wcdb-sidecar.cjs"
|
||||
},
|
||||
{
|
||||
"from": "vendor/koffi",
|
||||
"to": "koffi"
|
||||
}
|
||||
],
|
||||
"win": {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Vendored
+22
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (C) 2025 Niels Martignène <niels.martignene@protonmail.com>
|
||||
|
||||
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.
|
||||
Binary file not shown.
Vendored
+634
@@ -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;
|
||||
Vendored
+26
@@ -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"
|
||||
}
|
||||
@@ -61,6 +61,22 @@ export const useApi = () => {
|
||||
body: data
|
||||
})
|
||||
}
|
||||
|
||||
// 导入预览API
|
||||
const importDecryptedPreview = async (data) => {
|
||||
return await request('/import_decrypted/preview', {
|
||||
method: 'POST',
|
||||
body: data
|
||||
})
|
||||
}
|
||||
|
||||
// 导入已解密目录API
|
||||
const importDecrypted = async (data) => {
|
||||
return await request('/import_decrypted', {
|
||||
method: 'POST',
|
||||
body: data
|
||||
})
|
||||
}
|
||||
|
||||
// 健康检查API
|
||||
const healthCheck = async () => {
|
||||
@@ -601,10 +617,21 @@ export const useApi = () => {
|
||||
return `${base}/biz/proxy_image?${query.toString()}`
|
||||
}
|
||||
|
||||
const pickSystemDirectory = async (params = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params && params.title) query.set('title', params.title)
|
||||
if (params && params.initial_dir) query.set('initial_dir', params.initial_dir)
|
||||
const url = '/system/pick_directory' + (query.toString() ? `?${query.toString()}` : '')
|
||||
return await request(url)
|
||||
}
|
||||
|
||||
return {
|
||||
pickSystemDirectory,
|
||||
detectWechat,
|
||||
detectCurrentAccount,
|
||||
decryptDatabase,
|
||||
importDecryptedPreview,
|
||||
importDecrypted,
|
||||
healthCheck,
|
||||
listChatAccounts,
|
||||
getChatAccountInfo,
|
||||
|
||||
@@ -606,16 +606,36 @@ const onGlobalKeyDown = (event) => {
|
||||
}
|
||||
}
|
||||
|
||||
const RESUME_MEDIA_REFRESH_MIN_INTERVAL_MS = 1200
|
||||
const RESUME_MEDIA_REFRESH_MIN_HIDDEN_MS = 30 * 1000
|
||||
|
||||
let lastResumeMediaRefreshAt = 0
|
||||
let lastPageHiddenAt = 0
|
||||
|
||||
const hasLoadedConversationMedia = () => {
|
||||
const list = Array.isArray(messages.value) ? messages.value : []
|
||||
return list.some((message) => {
|
||||
return !!(
|
||||
String(message?.imageUrl || '').trim()
|
||||
|| String(message?.videoThumbUrl || '').trim()
|
||||
|| String(message?.quoteImageUrl || '').trim()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const maybeRefreshMediaOnResume = () => {
|
||||
if (!process.client) return
|
||||
if (!selectedContact.value?.username) return
|
||||
if (searchContext.value?.active) return
|
||||
if (!hasLoadedConversationMedia()) return
|
||||
|
||||
const hiddenDuration = lastPageHiddenAt > 0 ? (Date.now() - lastPageHiddenAt) : 0
|
||||
if (hiddenDuration < RESUME_MEDIA_REFRESH_MIN_HIDDEN_MS) return
|
||||
|
||||
const now = Date.now()
|
||||
if ((now - lastResumeMediaRefreshAt) < 1200) return
|
||||
if ((now - lastResumeMediaRefreshAt) < RESUME_MEDIA_REFRESH_MIN_INTERVAL_MS) return
|
||||
lastResumeMediaRefreshAt = now
|
||||
lastPageHiddenAt = 0
|
||||
void refreshCurrentMessageMedia()
|
||||
}
|
||||
|
||||
@@ -624,6 +644,10 @@ const onWindowFocus = () => {
|
||||
}
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
lastPageHiddenAt = Date.now()
|
||||
return
|
||||
}
|
||||
if (document.visibilityState !== 'visible') return
|
||||
maybeRefreshMediaOnResume()
|
||||
}
|
||||
|
||||
+175
-182
@@ -24,26 +24,36 @@
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- 兜底:允许输入数据库路径再次检测 -->
|
||||
<div class="bg-white rounded-xl p-4 border border-[#EDEDED] mb-4">
|
||||
<label class="block text-sm text-[#000000e6] mb-2">数据库文件夹路径(可选)</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="customPath"
|
||||
type="text"
|
||||
placeholder="例如:D:\wechatMSG\xwechat_files"
|
||||
class="flex-1 px-4 py-2 bg-white border border-[#EDEDED] rounded-lg font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[#07C160] focus:border-transparent transition-all duration-200"
|
||||
/>
|
||||
<button @click="startDetection" class="px-4 py-2 bg-[#07C160] text-white rounded-lg text-sm hover:bg-[#06AD56]">重新检测</button>
|
||||
<!-- 兜底:唤起原生目录选择器再次检测 -->
|
||||
<div class="bg-white rounded-xl p-4 md:p-5 border border-[#EDEDED] mb-6 flex flex-col md:flex-row md:items-center justify-between gap-4 shadow-sm hover:shadow transition-shadow duration-200">
|
||||
<div>
|
||||
<h3 class="text-sm font-bold text-[#000000e6] flex items-center">
|
||||
未找到想要的账号?
|
||||
<!-- <span class="ml-2 px-2 py-0.5 bg-gray-100 text-gray-500 rounded text-xs font-normal">深度检测兜底</span>-->
|
||||
</h3>
|
||||
<p class="text-xs text-[#7F7F7F] mt-1.5">
|
||||
<span v-if="customPath">当前指定检测路径:<span class="font-mono bg-gray-50 px-1 rounded text-[#000000e6]">{{ customPath }}</span></span>
|
||||
<span v-else>如果自动检测漏了,您可以手动指定微信数据根目录 (通常名为 xwechat_files) 让系统重新扫描。</span>
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-xs text-[#7F7F7F] mt-1">未找到时可填写 xwechat_files 根目录。</p>
|
||||
<button @click="handlePickDirectory" :disabled="loading"
|
||||
class="shrink-0 px-5 py-2.5 bg-[#07C160] text-white rounded-xl text-sm font-medium hover:bg-[#06AD56] focus:ring-2 focus:ring-[#07C160] focus:ring-offset-1 disabled:opacity-50 transition-all duration-200 flex items-center justify-center">
|
||||
<svg v-if="!loading" class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
|
||||
</svg>
|
||||
<svg v-else class="animate-spin w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ loading ? '检测中...' : '手动选择目录检测' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<div>
|
||||
<!-- 检测中状态 -->
|
||||
<div v-if="loading" class="bg-white rounded-2xl p-12 text-center">
|
||||
<svg class="w-16 h-16 mx-auto animate-spin text-[#07C160]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div v-if="loading" class="absolute inset-0 bg-white/80 backdrop-blur-sm z-20 rounded-2xl flex flex-col items-center justify-center border border-[#EDEDED]">
|
||||
<svg class="w-16 h-16 animate-spin text-[#07C160]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
@@ -51,16 +61,16 @@
|
||||
</div>
|
||||
|
||||
<!-- 检测结果内容 -->
|
||||
<div v-else-if="detectionResult">
|
||||
<div v-if="detectionResult && !loading">
|
||||
<!-- 错误信息 -->
|
||||
<div v-if="detectionResult.error" class="bg-white rounded-2xl border border-red-200 p-8">
|
||||
<div v-if="detectionResult.error" class="bg-red-50 rounded-2xl border border-red-100 p-8">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-8 h-8 text-red-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-8 h-8 text-red-500 mr-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-lg font-medium text-red-600">检测失败</p>
|
||||
<p class="text-red-500 mt-1">{{ detectionResult.error }}</p>
|
||||
<p class="text-lg font-bold text-red-800">未找到微信数据</p>
|
||||
<p class="text-red-600 mt-1 text-sm">{{ detectionResult.error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -114,89 +124,91 @@
|
||||
</div>
|
||||
|
||||
<!-- 账户列表 -->
|
||||
<div v-if="detectionResult.data?.accounts && detectionResult.data.accounts.length > 0"
|
||||
class="bg-white rounded-2xl border border-[#EDEDED] overflow-hidden">
|
||||
<div class="p-4 border-b border-[#EDEDED] bg-gray-50">
|
||||
<h3 class="text-base font-semibold text-[#000000e6]">微信账户详情</h3>
|
||||
</div>
|
||||
<div class="divide-y divide-[#EDEDED] max-h-64 overflow-y-auto">
|
||||
<!-- 将当前登录账号放在第一位 -->
|
||||
<div v-for="(account, index) in sortedAccounts" :key="index"
|
||||
:class="['p-4 hover:bg-gray-50 transition-all duration-200', isCurrentAccount(account.account_name) ? 'bg-[#07C160]/5' : '']">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-gradient-to-br from-[#07C160]/10 to-[#91D300]/10 rounded-full flex items-center justify-center mr-4">
|
||||
<span class="text-[#07C160] font-bold text-lg">{{ account.account_name?.charAt(0)?.toUpperCase() || 'U' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center">
|
||||
<p class="text-lg font-medium text-[#000000e6]">{{ account.account_name || '未知账户' }}</p>
|
||||
<!-- 当前登录账号标识 -->
|
||||
<span v-if="isCurrentAccount(account.account_name)"
|
||||
class="ml-2 inline-flex items-center px-2 py-1 bg-[#07C160]/10 text-[#07C160] rounded text-xs font-medium">
|
||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
当前登录
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center mt-1 space-x-4 text-sm text-[#7F7F7F]">
|
||||
<span class="flex items-center">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4"/>
|
||||
</svg>
|
||||
{{ account.database_count }} 个数据库
|
||||
</span>
|
||||
<span v-if="account.data_dir" class="flex items-center">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
|
||||
</svg>
|
||||
数据目录已找到
|
||||
</span>
|
||||
<div v-if="detectionResult.data?.accounts && detectionResult.data.accounts.length > 0"
|
||||
class="bg-white rounded-2xl border border-[#EDEDED] overflow-hidden shadow-sm">
|
||||
<div class="p-4 border-b border-[#EDEDED] bg-gray-50 flex items-center justify-between">
|
||||
<h3 class="text-base font-bold text-[#000000e6]">可操作的微信账户</h3>
|
||||
<span class="text-xs text-gray-500">点击解密即可提取数据</span>
|
||||
</div>
|
||||
<div class="divide-y divide-[#EDEDED] max-h-96 overflow-y-auto">
|
||||
<div v-for="(account, index) in sortedAccounts" :key="index"
|
||||
:class="['p-5 transition-all duration-200 relative overflow-hidden', isCurrentAccount(account.account_name) ? 'bg-[#07C160]/5 border border-[#07C160]/20' : 'hover:bg-[#F9F9F9]']">
|
||||
|
||||
<div v-if="isCurrentAccount(account.account_name)" class="absolute top-0 right-0 bg-gradient-to-l from-[#07C160]/20 to-transparent px-4 py-1 rounded-bl-xl flex items-center">
|
||||
<span class="text-xs text-[#07C160] font-bold flex items-center">
|
||||
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||
最近登录账户
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mt-1">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center">
|
||||
<template v-if="isCurrentAccount(account.account_name) && currentAccountInfo?.avatar">
|
||||
<img :src="currentAccountInfo.avatar" class="w-14 h-14 rounded-xl border-2 border-[#07C160]/30 mr-4 shadow-sm object-cover bg-white" alt=""/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="w-14 h-14 bg-gradient-to-br from-[#07C160]/10 to-[#91D300]/10 rounded-xl flex items-center justify-center mr-4 shadow-inner">
|
||||
<span class="text-[#07C160] font-bold text-xl">{{ account.account_name?.charAt(0)?.toUpperCase() || 'U' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<div class="flex flex-col">
|
||||
<template v-if="isCurrentAccount(account.account_name) && currentAccountInfo?.nickname">
|
||||
<p class="text-xl font-extrabold text-[#000000e6] leading-tight">{{ currentAccountInfo.nickname }}</p>
|
||||
<p class="text-xs text-[#7F7F7F] mt-1 font-mono">wxid: {{ account.account_name }}</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="text-lg font-bold text-[#000000e6]">{{ account.account_name || '未知账户' }}</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mt-2 space-x-4 text-sm text-[#7F7F7F]">
|
||||
<span class="flex items-center">
|
||||
<svg class="w-4 h-4 mr-1 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4"/>
|
||||
</svg>
|
||||
{{ account.database_count }} 个库文件
|
||||
</span>
|
||||
<span v-if="account.data_dir" class="flex items-center">
|
||||
<svg class="w-4 h-4 mr-1 text-[#07C160]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
路径已确认
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button @click="goToDecrypt(account)"
|
||||
class="inline-flex items-center px-6 py-2.5 bg-[#07C160] text-white rounded-xl font-bold hover:bg-[#06AD56] hover:shadow-md hover:-translate-y-0.5 transition-all duration-200 text-sm shrink-0 z-10">
|
||||
解密提取
|
||||
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 pt-3 border-t border-dashed border-gray-200 text-sm text-gray-400">
|
||||
<p v-if="account.data_dir" class="font-mono text-xs truncate" title="复制路径">
|
||||
📂 {{ account.data_dir }}
|
||||
</p>
|
||||
</div>
|
||||
<button @click="goToDecrypt(account)"
|
||||
class="inline-flex items-center px-4 py-2 bg-[#07C160] text-white rounded-lg font-medium hover:bg-[#06AD56] transition-all duration-200 text-sm">
|
||||
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
解密
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 展开更多信息 -->
|
||||
<div class="mt-4 text-sm text-[#7F7F7F]">
|
||||
<p v-if="account.data_dir" class="font-mono text-xs truncate">
|
||||
数据路径:{{ account.data_dir }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 无账户提示 -->
|
||||
<div v-else class="bg-white rounded-2xl p-12 text-center border border-[#EDEDED]">
|
||||
<svg class="w-16 h-16 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<p class="text-lg text-[#000000e6] font-medium">没有在这台设备上发现微信数据</p>
|
||||
<p class="text-sm text-gray-500 mt-2">您可以尝试通过上方的按钮手动指定 "xwechat_files" 文件夹路径。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 无账户提示 -->
|
||||
<div v-else class="bg-white rounded-2xl p-12 text-center">
|
||||
<svg class="w-16 h-16 mx-auto text-[#7F7F7F] mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<p class="text-lg text-[#7F7F7F]">未检测到微信账户数据</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 未检测状态 -->
|
||||
<div v-else class="bg-white rounded-2xl p-12 text-center">
|
||||
<svg class="w-16 h-16 mx-auto text-[#7F7F7F] mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
<p class="text-lg text-[#7F7F7F] mb-4">暂无检测结果</p>
|
||||
<NuxtLink to="/"
|
||||
class="inline-flex items-center px-6 py-3 bg-[#07C160] text-white rounded-lg font-medium hover:bg-[#06AD56] transition-colors">
|
||||
返回首页开始检测
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -204,34 +216,79 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { useAppStore } from '~/stores/app'
|
||||
import {computed, onMounted, ref} from 'vue'
|
||||
import {useApi} from '~/composables/useApi'
|
||||
import {useAppStore} from '~/stores/app'
|
||||
|
||||
const { detectWechat } = useApi()
|
||||
const { detectWechat, pickSystemDirectory } = useApi()
|
||||
const appStore = useAppStore()
|
||||
const loading = ref(false)
|
||||
const detectionResult = ref(null)
|
||||
const customPath = ref('')
|
||||
const STORAGE_KEY = 'wechat_data_root_path'
|
||||
|
||||
const isDesktopShell = () => {
|
||||
if (!process.client || typeof window === 'undefined') return false
|
||||
return !!window.wechatDesktop?.__brand
|
||||
}
|
||||
|
||||
// 唤起目录选择器并自动检测
|
||||
const handlePickDirectory = async () => {
|
||||
let path = ''
|
||||
|
||||
if (isDesktopShell()) {
|
||||
try {
|
||||
const res = await window.wechatDesktop.chooseDirectory({
|
||||
title: '请选择微信数据根目录 (通常名为 xwechat_files)'
|
||||
})
|
||||
if (!res || res.canceled || !res.filePaths?.length) return
|
||||
path = res.filePaths[0]
|
||||
} catch (e) {
|
||||
console.error('桌面端选择目录失败:', e)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const res = await pickSystemDirectory({
|
||||
title: '请选择微信数据根目录 (通常名为 xwechat_files)'
|
||||
})
|
||||
if (!res || !res.path) return // 用户取消
|
||||
path = res.path
|
||||
} catch (e) {
|
||||
console.error('通过API唤起系统目录选择器失败:', e)
|
||||
path = window.prompt('无法直接唤起窗口,请输入 xwechat_files 目录的绝对路径:')
|
||||
if (!path) return
|
||||
}
|
||||
}
|
||||
|
||||
if (path) {
|
||||
customPath.value = path
|
||||
// 选完后立刻启动重新检测
|
||||
startDetection()
|
||||
}
|
||||
}
|
||||
|
||||
// 计算属性:将当前登录账号排在第一位
|
||||
const sortedAccounts = computed(() => {
|
||||
if (!detectionResult.value?.data?.accounts) return []
|
||||
|
||||
const accounts = [...detectionResult.value.data.accounts]
|
||||
const currentAccountName = detectionResult.value.data?.current_account?.current_account
|
||||
|
||||
if (!currentAccountName) return accounts
|
||||
|
||||
// 将当前登录账号移到第一位
|
||||
const sorted = accounts.sort((a, b) => {
|
||||
if (a.account_name === currentAccountName) return -1
|
||||
if (b.account_name === currentAccountName) return 1
|
||||
|
||||
const current = detectionResult.value.data?.current_account
|
||||
const currentTargetName = current?.matched_folder || current?.current_account
|
||||
|
||||
if (!currentTargetName) return accounts
|
||||
|
||||
// 置顶最近登录账号
|
||||
return accounts.sort((a, b) => {
|
||||
if (a.account_name === currentTargetName) return -1
|
||||
if (b.account_name === currentTargetName) return 1
|
||||
return 0
|
||||
})
|
||||
|
||||
return sorted
|
||||
})
|
||||
|
||||
|
||||
const currentAccountInfo = computed(() => {
|
||||
return detectionResult.value?.data?.current_account || null
|
||||
})
|
||||
|
||||
// 开始检测
|
||||
@@ -247,9 +304,10 @@ const startDetection = async () => {
|
||||
// 检测微信安装信息
|
||||
let result = await detectWechat(params)
|
||||
|
||||
// 如果用户提供/缓存的路径不可用,自动回退到“自动检测”(避免因错误缓存导致一直检测不到)
|
||||
// 如果用户提供/缓存的路径不可用,自动回退到“自动检测”
|
||||
const hasCustomPath = !!(params.data_root_path && String(params.data_root_path).trim())
|
||||
const accounts0 = Array.isArray(result?.data?.accounts) ? result.data.accounts : []
|
||||
|
||||
if (hasCustomPath && (result?.status !== 'success' || accounts0.length === 0)) {
|
||||
try {
|
||||
const fallback = await detectWechat({})
|
||||
@@ -267,7 +325,7 @@ const startDetection = async () => {
|
||||
}
|
||||
|
||||
detectionResult.value = result
|
||||
|
||||
|
||||
if (result.status === 'success') {
|
||||
const current = result?.data?.current_account || null
|
||||
if (current) {
|
||||
@@ -288,6 +346,7 @@ const startDetection = async () => {
|
||||
}
|
||||
if (toSave) {
|
||||
localStorage.setItem(STORAGE_KEY, toSave)
|
||||
customPath.value = toSave
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
@@ -296,7 +355,7 @@ const startDetection = async () => {
|
||||
console.error('检测过程中发生错误:', err)
|
||||
detectionResult.value = {
|
||||
status: 'error',
|
||||
error: err.message || '检测过程中出现错误'
|
||||
error: err.message || '未在常规路径下扫描到您的微信数据。'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
@@ -305,7 +364,6 @@ const startDetection = async () => {
|
||||
|
||||
// 跳转到解密页面并传递账户信息
|
||||
const goToDecrypt = (account) => {
|
||||
// 将选中的账户信息存储到sessionStorage
|
||||
if (process.client && typeof window !== 'undefined') {
|
||||
sessionStorage.setItem('selectedAccount', JSON.stringify({
|
||||
account_name: account.account_name,
|
||||
@@ -314,7 +372,6 @@ const goToDecrypt = (account) => {
|
||||
databases: account.databases
|
||||
}))
|
||||
}
|
||||
// 跳转到解密页面
|
||||
navigateTo('/decrypt')
|
||||
}
|
||||
|
||||
@@ -323,29 +380,9 @@ const isCurrentAccount = (accountName) => {
|
||||
if (!detectionResult.value?.data?.current_account) {
|
||||
return false
|
||||
}
|
||||
return detectionResult.value.data.current_account.current_account === accountName
|
||||
}
|
||||
|
||||
// 获取当前登录账号信息
|
||||
const getCurrentAccountInfo = () => {
|
||||
return detectionResult.value?.data?.current_account
|
||||
}
|
||||
|
||||
// 格式化时间显示
|
||||
const formatTime = (timeString) => {
|
||||
if (!timeString) return ''
|
||||
try {
|
||||
const date = new Date(timeString)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
} catch {
|
||||
return timeString
|
||||
}
|
||||
const current = detectionResult.value.data.current_account
|
||||
// 支持严格匹配或通过后缀兼容的匹配
|
||||
return accountName === current.matched_folder || accountName === current.current_account
|
||||
}
|
||||
|
||||
// 页面加载时自动检测
|
||||
@@ -357,49 +394,5 @@ onMounted(() => {
|
||||
} catch {}
|
||||
}
|
||||
startDetection()
|
||||
|
||||
// 调试:检查各元素高度(仅开发环境)
|
||||
if (process.dev && process.client) {
|
||||
setTimeout(() => {
|
||||
const mainContainer = document.querySelector('.min-h-screen')
|
||||
const contentContainer = document.querySelector('.max-w-6xl')
|
||||
|
||||
console.log('=== 高度调试信息 ===')
|
||||
console.log('视口高度:', window.innerHeight)
|
||||
console.log('主容器高度:', mainContainer?.scrollHeight)
|
||||
console.log('内容容器高度:', contentContainer?.scrollHeight)
|
||||
console.log('body滚动高度:', document.body.scrollHeight)
|
||||
console.log('documentElement滚动高度:', document.documentElement.scrollHeight)
|
||||
|
||||
// 检查是否有滚动条
|
||||
const hasVerticalScrollbar = document.documentElement.scrollHeight > window.innerHeight
|
||||
console.log('是否有垂直滚动条:', hasVerticalScrollbar)
|
||||
}, 1000)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.8s ease-out;
|
||||
}
|
||||
|
||||
/* 网格背景 */
|
||||
.bg-grid-pattern {
|
||||
background-image:
|
||||
linear-gradient(rgba(7, 193, 96, 0.1) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(7, 193, 96, 0.1) 1px, transparent 1px);
|
||||
background-size: 50px 50px;
|
||||
}
|
||||
</style>
|
||||
</script>
|
||||
@@ -0,0 +1,276 @@
|
||||
<template>
|
||||
<div class="import-page min-h-screen flex items-center justify-center py-8">
|
||||
|
||||
<div class="max-w-2xl mx-auto px-6 w-full">
|
||||
<div class="bg-white rounded-3xl border border-[#EDEDED] shadow-sm overflow-hidden">
|
||||
<div class="p-8 md:p-12">
|
||||
<!-- 标题部分 -->
|
||||
<div class="flex items-center mb-8">
|
||||
<div class="w-14 h-14 bg-[#91D300] rounded-2xl flex items-center justify-center mr-5 shadow-sm">
|
||||
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-[#000000e6]">数据导入</h2>
|
||||
<p class="text-[#7F7F7F] mt-1">导入已解密的数据库备份目录</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-blue-50 border border-blue-100 rounded-2xl p-6 mb-8">
|
||||
<h3 class="text-blue-800 font-bold mb-3 flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
标准目录结构要求
|
||||
</h3>
|
||||
<ul class="text-sm text-blue-700 space-y-2 list-disc list-inside opacity-90">
|
||||
<li><strong>预期目标:</strong>请选择形如 <strong>/output/wxid_xxxxx/</strong> 这一级目录</li>
|
||||
<li><strong>databases/</strong> 目录:存放扁平化的 .db 文件</li>
|
||||
<li><strong>account.json</strong> 文件:系统会自动生成</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 初始状态:选择目录 -->
|
||||
<div v-if="!importPreview && !importError && !importing" class="flex flex-col items-center justify-center py-12 border-2 border-dashed border-[#EDEDED] rounded-3xl hover:border-[#91D300] transition-colors cursor-pointer group" @click="handlePickDirectory">
|
||||
<div class="w-20 h-20 bg-gray-50 rounded-full flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300">
|
||||
<svg class="w-10 h-10 text-gray-400 group-hover:text-[#91D300]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-[#000000e6] font-medium text-lg">点击选择备份目录</div>
|
||||
<p class="text-[#7F7F7F] text-sm mt-2">支持原生目录选择器</p>
|
||||
</div>
|
||||
|
||||
<!-- 导入进度状态 -->
|
||||
<div v-if="importing" class="animate-fade-in py-12">
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="relative w-32 h-32 mb-8">
|
||||
<svg class="w-full h-full" viewBox="0 0 100 100">
|
||||
<circle class="text-gray-100" stroke-width="8" stroke="currentColor" fill="transparent" r="42" cx="50" cy="50"/>
|
||||
<circle class="text-[#91D300] transition-all duration-500" stroke-width="8" :stroke-dasharray="263.89" :stroke-dashoffset="263.89 * (1 - importProgress / 100)" stroke-linecap="round" stroke="currentColor" fill="transparent" r="42" cx="50" cy="50" transform="rotate(-90 50 50)"/>
|
||||
</svg>
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<span class="text-2xl font-bold text-gray-900">{{ importProgress }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-bold text-gray-900 mb-2">{{ importMessage }}</h3>
|
||||
<p class="text-sm text-gray-500">正在为您处理数据,请稍候...</p>
|
||||
|
||||
<div class="w-full max-w-xs bg-gray-100 h-1.5 rounded-full mt-8 overflow-hidden">
|
||||
<div class="bg-[#91D300] h-full transition-all duration-500" :style="{ width: importProgress + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 预览状态:显示账号信息 -->
|
||||
<div v-if="importPreview && !importing" class="animate-fade-in">
|
||||
<div class="flex flex-col items-center py-8 bg-[#FBFBFB] rounded-3xl border border-[#EDEDED] mb-8">
|
||||
<div class="w-28 h-28 rounded-full overflow-hidden border-4 border-white shadow-md mb-5">
|
||||
<img :src="importPreview.avatar_url || '/Contact.png'" class="w-full h-full object-cover" alt="头像">
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-xl font-bold text-gray-900">{{ importPreview.nick }}</div>
|
||||
<div class="text-sm text-gray-500 font-mono mt-1 bg-white px-3 py-1 rounded-full border border-[#EDEDED] inline-block">{{ importPreview.username }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex gap-6">
|
||||
<div class="flex items-center text-sm text-gray-600">
|
||||
<div class="w-2 h-2 rounded-full bg-[#07C160] mr-2"></div>
|
||||
数据库已就绪
|
||||
</div>
|
||||
<div class="flex items-center text-sm text-gray-600">
|
||||
<div class="w-2 h-2 rounded-full mr-2" :class="importPreview.has_resource ? 'bg-[#07C160]' : 'bg-gray-300'"></div>
|
||||
资源文件{{ importPreview.has_resource ? '已发现' : '未发现' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<button @click="resetImport"
|
||||
class="flex-1 px-8 py-4 border border-[#EDEDED] text-gray-600 rounded-2xl font-bold hover:bg-gray-50 transition-all">
|
||||
重新选择
|
||||
</button>
|
||||
<button @click="confirmImport" :disabled="importing"
|
||||
class="flex-[2] px-8 py-4 bg-[#91D300] text-white rounded-2xl font-bold hover:bg-[#82BD00] shadow-lg shadow-[#91D300]/20 disabled:opacity-50 transition-all flex items-center justify-center transform hover:scale-[1.02] active:scale-[0.98]">
|
||||
<span v-if="!importing">确认导入此账号</span>
|
||||
<span v-else>正在导入数据...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div v-if="importError && !importing" class="animate-fade-in">
|
||||
<div class="p-6 bg-red-50 border border-red-100 rounded-2xl flex items-start mb-8">
|
||||
<svg class="w-6 h-6 text-red-500 mr-3 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="font-bold text-red-800 mb-1">导入失败</p>
|
||||
<p class="text-sm text-red-600">{{ importError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="resetImport"
|
||||
class="w-full px-8 py-4 bg-white border border-[#EDEDED] text-[#07C160] rounded-2xl font-bold hover:bg-gray-50 transition-all flex items-center justify-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
重新选择目录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 返回首页 -->
|
||||
<div class="mt-12 text-center">
|
||||
<NuxtLink to="/" class="text-[#7F7F7F] hover:text-[#07C160] text-sm transition-colors inline-flex items-center">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||
</svg>
|
||||
返回首页
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {ref, onUnmounted} from 'vue'
|
||||
import {useApi} from '~/composables/useApi'
|
||||
import {useApiBase} from '~/composables/useApiBase'
|
||||
|
||||
const importing = ref(false)
|
||||
const importProgress = ref(0)
|
||||
const importMessage = ref('正在准备...')
|
||||
const importPreview = ref(null)
|
||||
const importError = ref('')
|
||||
const selectedImportPath = ref('')
|
||||
|
||||
let eventSource = null
|
||||
|
||||
onUnmounted(() => {
|
||||
if (eventSource) {
|
||||
eventSource.close()
|
||||
}
|
||||
})
|
||||
|
||||
const isDesktopShell = () => {
|
||||
if (!process.client || typeof window === 'undefined') return false
|
||||
return !!window.wechatDesktop?.__brand
|
||||
}
|
||||
|
||||
const resetImport = () => {
|
||||
importPreview.value = null
|
||||
importError.value = ''
|
||||
selectedImportPath.value = ''
|
||||
importing.value = false
|
||||
importProgress.value = 0
|
||||
importMessage.value = '正在准备...'
|
||||
}
|
||||
|
||||
const { importDecryptedPreview, pickSystemDirectory } = useApi()
|
||||
const apiBase = useApiBase()
|
||||
|
||||
const handlePickDirectory = async () => {
|
||||
let path = ''
|
||||
|
||||
if (isDesktopShell()) {
|
||||
try {
|
||||
const res = await window.wechatDesktop.chooseDirectory({
|
||||
title: '请选择解密输出目录 (如: output/wxid_xxxxx)'
|
||||
})
|
||||
if (!res || res.canceled || !res.filePaths?.length) return
|
||||
path = res.filePaths[0]
|
||||
} catch (e) {
|
||||
console.error('选择目录失败:', e)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const res = await pickSystemDirectory({ title: '请选择解密输出目录 (需选到 wxid_xxx 层级)' })
|
||||
if (!res || !res.path) return
|
||||
path = res.path
|
||||
} catch (e) {
|
||||
console.error('唤起目录选择器失败:', e)
|
||||
path = window.prompt('无法唤起选择器,请输入已解密目录的绝对路径:')
|
||||
if (!path) return
|
||||
}
|
||||
}
|
||||
|
||||
if (path && !path.includes('wxid_')) {
|
||||
const isOk = window.confirm(`你选择的目录为:\n${path}\n\n该目录似乎不符合 "wxid_xxxxx" 的格式。确定要继续吗?`)
|
||||
if (!isOk) return
|
||||
}
|
||||
|
||||
selectedImportPath.value = path
|
||||
importError.value = ''
|
||||
importPreview.value = null
|
||||
|
||||
try {
|
||||
importPreview.value = await importDecryptedPreview({import_path: path})
|
||||
} catch (e) {
|
||||
importError.value = e.message || '目录格式不正确,请确保包含 databases 目录和 account.json'
|
||||
}
|
||||
}
|
||||
|
||||
const confirmImport = async () => {
|
||||
if (!selectedImportPath.value) return
|
||||
|
||||
importing.value = true
|
||||
importError.value = ''
|
||||
importProgress.value = 0
|
||||
importMessage.value = '启动导入程序...'
|
||||
|
||||
const url = new URL(`${apiBase.replace(/\/$/, '')}/import_decrypted`, window.location.origin)
|
||||
url.searchParams.set('import_path', selectedImportPath.value)
|
||||
|
||||
if (eventSource) eventSource.close()
|
||||
|
||||
eventSource = new EventSource(url.toString())
|
||||
|
||||
eventSource.onmessage = async (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
|
||||
if (data.type === 'progress') {
|
||||
importProgress.value = data.percent || 0
|
||||
importMessage.value = data.message || '正在处理...'
|
||||
} else if (data.type === 'complete') {
|
||||
importProgress.value = 100
|
||||
importMessage.value = '导入完成!'
|
||||
eventSource.close()
|
||||
|
||||
// 延迟跳转,让用户看到 100%
|
||||
setTimeout(async () => {
|
||||
await navigateTo('/chat')
|
||||
}, 1000)
|
||||
} else if (data.type === 'error') {
|
||||
importError.value = data.message || '导入失败'
|
||||
importing.value = false
|
||||
eventSource.close()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析 SSE 数据失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.onerror = (e) => {
|
||||
console.error('EventSource 错误:', e)
|
||||
importError.value = '与服务器连接断开或发生错误'
|
||||
importing.value = false
|
||||
eventSource.close()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
@@ -41,6 +41,14 @@
|
||||
</svg>
|
||||
<span>直接解密</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink to="/import"
|
||||
class="group inline-flex items-center px-12 py-4 bg-white text-[#91D300] border border-[#91D300] rounded-lg text-lg font-medium hover:bg-[#F7F7F7] transform hover:scale-105 transition-all duration-200">
|
||||
<svg class="w-6 h-6 mr-3 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||||
</svg>
|
||||
<span>数据导入</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink to="/chat"
|
||||
class="group inline-flex items-center px-12 py-4 bg-white text-[#10AEEF] border border-[#10AEEF] rounded-lg text-lg font-medium hover:bg-[#F7F7F7] transform hover:scale-105 transition-all duration-200">
|
||||
@@ -70,6 +78,8 @@ import { onMounted } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, readLocalBoolSetting } from '~/lib/desktop-settings'
|
||||
|
||||
const { listChatAccounts } = useApi()
|
||||
|
||||
onMounted(async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
|
||||
@@ -77,8 +87,7 @@ onMounted(async () => {
|
||||
if (!enabled) return
|
||||
|
||||
try {
|
||||
const api = useApi()
|
||||
const resp = await api.listChatAccounts()
|
||||
const resp = await listChatAccounts()
|
||||
const accounts = resp?.accounts || []
|
||||
if (accounts.length) {
|
||||
await navigateTo('/chat', { replace: true })
|
||||
|
||||
@@ -25,6 +25,7 @@ from .routers.chat_contacts import router as _chat_contacts_router
|
||||
from .routers.chat_export import router as _chat_export_router
|
||||
from .routers.chat_media import router as _chat_media_router
|
||||
from .routers.decrypt import router as _decrypt_router
|
||||
from .routers.import_decrypted import router as _import_decrypted_router
|
||||
from .routers.health import router as _health_router
|
||||
from .routers.admin import router as _admin_router
|
||||
from .routers.keys import router as _keys_router
|
||||
@@ -37,6 +38,7 @@ from .request_logging import log_server_errors_middleware
|
||||
from .sns_stage_timing import add_sns_stage_timing_headers
|
||||
from .wcdb_realtime import WCDB_REALTIME, shutdown as _wcdb_shutdown
|
||||
from .routers.biz import router as _biz_router
|
||||
from .routers.system import router as _system_router
|
||||
|
||||
app = FastAPI(
|
||||
title="微信数据库解密工具",
|
||||
@@ -87,6 +89,7 @@ async def _log_server_errors(request: Request, call_next):
|
||||
app.include_router(_health_router)
|
||||
app.include_router(_admin_router)
|
||||
app.include_router(_wechat_detection_router)
|
||||
app.include_router(_import_decrypted_router)
|
||||
app.include_router(_decrypt_router)
|
||||
app.include_router(_keys_router)
|
||||
app.include_router(_media_router)
|
||||
@@ -98,6 +101,7 @@ app.include_router(_sns_router)
|
||||
app.include_router(_sns_export_router)
|
||||
app.include_router(_wrapped_router)
|
||||
app.include_router(_biz_router)
|
||||
app.include_router(_system_router)
|
||||
|
||||
|
||||
class _SPAStaticFiles(StaticFiles):
|
||||
|
||||
@@ -14,7 +14,7 @@ from fastapi import HTTPException
|
||||
|
||||
from .app_paths import get_output_databases_dir
|
||||
from .logging_config import get_logger
|
||||
from .sqlite_diagnostics import collect_sqlite_diagnostics, format_sqlite_diagnostics
|
||||
from .sqlite_diagnostics import collect_sqlite_diagnostics, format_sqlite_diagnostics, is_usable_sqlite_db
|
||||
|
||||
try:
|
||||
import zstandard as zstd # type: ignore
|
||||
@@ -29,13 +29,7 @@ _SQLITE_HEADER = b"SQLite format 3\x00"
|
||||
|
||||
|
||||
def _is_valid_decrypted_sqlite(path: Path) -> bool:
|
||||
try:
|
||||
if not path.exists() or (not path.is_file()):
|
||||
return False
|
||||
with path.open("rb") as f:
|
||||
return f.read(len(_SQLITE_HEADER)) == _SQLITE_HEADER
|
||||
except Exception:
|
||||
return False
|
||||
return is_usable_sqlite_db(path)
|
||||
|
||||
|
||||
def _list_decrypted_accounts() -> list[str]:
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_IGNORED_SOURCE_DATABASE_NAMES = frozenset({"key_info.db"})
|
||||
_INDEX_DATABASE_NAMES = frozenset({"chat_search_index.db", "chat_search_index.tmp.db"})
|
||||
_INDEX_DATABASE_SUFFIXES = ("_fts.db",)
|
||||
_INTERNAL_OUTPUT_DATABASE_NAMES = frozenset(
|
||||
{
|
||||
"chat_search_index.db",
|
||||
"chat_search_index.tmp.db",
|
||||
"session_last_message.db",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def normalize_database_file_name(file_name: str | Path) -> str:
|
||||
return Path(str(file_name or "")).name.strip().lower()
|
||||
|
||||
|
||||
def is_index_database_file(file_name: str | Path) -> bool:
|
||||
lower_name = normalize_database_file_name(file_name)
|
||||
if not lower_name:
|
||||
return False
|
||||
if lower_name in _INDEX_DATABASE_NAMES:
|
||||
return True
|
||||
return lower_name.endswith(_INDEX_DATABASE_SUFFIXES)
|
||||
|
||||
|
||||
def should_skip_source_database(file_name: str | Path) -> bool:
|
||||
lower_name = normalize_database_file_name(file_name)
|
||||
if not lower_name:
|
||||
return True
|
||||
if lower_name in _IGNORED_SOURCE_DATABASE_NAMES:
|
||||
return True
|
||||
return is_index_database_file(lower_name)
|
||||
|
||||
|
||||
def should_include_in_database_count(file_name: str | Path) -> bool:
|
||||
lower_name = normalize_database_file_name(file_name)
|
||||
if not lower_name.endswith(".db"):
|
||||
return False
|
||||
if should_skip_source_database(lower_name):
|
||||
return False
|
||||
if lower_name in _INTERNAL_OUTPUT_DATABASE_NAMES:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def list_countable_database_names(account_dir: Path) -> list[str]:
|
||||
if not account_dir.exists():
|
||||
return []
|
||||
|
||||
db_files = [
|
||||
path.name
|
||||
for path in account_dir.glob("*.db")
|
||||
if path.is_file() and should_include_in_database_count(path.name)
|
||||
]
|
||||
db_files.sort()
|
||||
return db_files
|
||||
@@ -18,6 +18,7 @@ from fastapi import HTTPException
|
||||
|
||||
from .app_paths import get_output_databases_dir
|
||||
from .logging_config import get_logger
|
||||
from .sqlite_diagnostics import is_usable_sqlite_db
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -28,13 +29,7 @@ _SQLITE_HEADER = b"SQLite format 3\x00"
|
||||
|
||||
|
||||
def _is_valid_decrypted_sqlite(path: Path) -> bool:
|
||||
try:
|
||||
if not path.exists() or (not path.is_file()):
|
||||
return False
|
||||
with path.open("rb") as f:
|
||||
return f.read(len(_SQLITE_HEADER)) == _SQLITE_HEADER
|
||||
except Exception:
|
||||
return False
|
||||
return is_usable_sqlite_db(path)
|
||||
|
||||
|
||||
def _list_decrypted_accounts() -> list[str]:
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -70,6 +70,7 @@ from ..chat_helpers import (
|
||||
from ..media_helpers import _resolve_account_db_storage_dir, _try_find_decrypted_resource
|
||||
from .. import chat_edit_store
|
||||
from ..app_paths import get_output_dir
|
||||
from ..database_filters import list_countable_database_names
|
||||
from ..key_store import remove_account_keys_from_store
|
||||
from ..path_fix import PathFixRoute
|
||||
from ..session_last_message import (
|
||||
@@ -3892,7 +3893,7 @@ async def list_chat_accounts():
|
||||
@router.get("/api/chat/account_info", summary="获取当前账号信息")
|
||||
def get_chat_account_info(account: Optional[str] = None):
|
||||
account_dir = _resolve_account_dir(account)
|
||||
db_files = sorted([p.name for p in account_dir.glob("*.db") if p.is_file()])
|
||||
db_files = list_countable_database_names(account_dir)
|
||||
|
||||
session_db = account_dir / "session.db"
|
||||
session_updated_at = 0
|
||||
|
||||
@@ -12,7 +12,7 @@ from typing import Any, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, Response
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -67,10 +67,27 @@ logger = get_logger(__name__)
|
||||
router = APIRouter(route_class=PathFixRoute)
|
||||
|
||||
|
||||
def _build_uncached_media_response(data: bytes, media_type: str) -> Response:
|
||||
resp = Response(content=data, media_type=media_type)
|
||||
resp.headers["Cache-Control"] = "no-store"
|
||||
return resp
|
||||
CHAT_MEDIA_BROWSER_CACHE_SECONDS = 24 * 60 * 60
|
||||
|
||||
|
||||
def _build_cached_media_response(request: Optional[Request], data: bytes, media_type: str) -> Response:
|
||||
payload = bytes(data or b"")
|
||||
etag = f'"{hashlib.sha1(payload).hexdigest()}"'
|
||||
cache_control = f"private, max-age={CHAT_MEDIA_BROWSER_CACHE_SECONDS}"
|
||||
headers = {
|
||||
"Cache-Control": cache_control,
|
||||
"ETag": etag,
|
||||
}
|
||||
|
||||
try:
|
||||
if_none_match = str(request.headers.get("if-none-match") or "").strip() if request else ""
|
||||
except Exception:
|
||||
if_none_match = ""
|
||||
|
||||
if if_none_match and if_none_match == etag:
|
||||
return Response(status_code=304, headers=headers)
|
||||
|
||||
return Response(content=payload, media_type=media_type, headers=headers)
|
||||
|
||||
|
||||
def _image_candidate_variant_rank(path: Path) -> int:
|
||||
@@ -1363,6 +1380,7 @@ async def download_chat_emoji(req: EmojiDownloadRequest):
|
||||
|
||||
@router.get("/api/chat/media/image", summary="获取图片消息资源")
|
||||
async def get_chat_image(
|
||||
request: Request,
|
||||
md5: Optional[str] = None,
|
||||
file_id: Optional[str] = None,
|
||||
server_id: Optional[int] = None,
|
||||
@@ -1502,7 +1520,7 @@ async def get_chat_image(
|
||||
|
||||
if not p:
|
||||
if cached_path:
|
||||
return _build_uncached_media_response(cached_data, cached_media_type)
|
||||
return _build_cached_media_response(request, cached_data, cached_media_type)
|
||||
raise HTTPException(status_code=404, detail="Image not found.")
|
||||
|
||||
candidates.extend(_iter_media_source_candidates(p))
|
||||
@@ -1562,7 +1580,7 @@ async def get_chat_image(
|
||||
logger.info(
|
||||
f"chat_image: md5={md5} file_id={file_id} chosen={chosen} media_type={media_type} bytes={len(data)}"
|
||||
)
|
||||
return _build_uncached_media_response(data, media_type)
|
||||
return _build_cached_media_response(request, data, media_type)
|
||||
|
||||
|
||||
@router.get("/api/chat/media/emoji", summary="获取表情消息资源")
|
||||
|
||||
@@ -16,7 +16,12 @@ from ..chat_realtime_autosync import CHAT_REALTIME_AUTOSYNC
|
||||
from ..logging_config import get_logger
|
||||
from ..path_fix import PathFixRoute
|
||||
from ..key_store import upsert_account_keys_in_store
|
||||
from ..wechat_decrypt import WeChatDatabaseDecryptor, decrypt_wechat_databases, scan_account_databases_from_path
|
||||
from ..wechat_decrypt import (
|
||||
WeChatDatabaseDecryptor,
|
||||
build_decrypt_summary_message,
|
||||
decrypt_wechat_databases,
|
||||
scan_account_databases_from_path,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -463,7 +468,11 @@ async def decrypt_databases_stream(
|
||||
"success_count": success_count,
|
||||
"failure_count": total_databases - success_count,
|
||||
"output_directory": str(base_output_dir.absolute()),
|
||||
"message": f"解密完成: 成功 {success_count}/{total_databases}",
|
||||
"message": build_decrypt_summary_message(
|
||||
success_count=success_count,
|
||||
total_databases=total_databases,
|
||||
diagnostic_warning_count=diagnostic_warning_count,
|
||||
),
|
||||
"processed_files": processed_files,
|
||||
"failed_files": failed_files,
|
||||
"account_results": account_results,
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import json
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..app_paths import get_output_databases_dir
|
||||
from ..logging_config import get_logger
|
||||
from ..path_fix import PathFixRoute
|
||||
from ..session_last_message import build_session_last_message_table
|
||||
from ..media_helpers import _wxgf_to_image_bytes
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter(route_class=PathFixRoute)
|
||||
|
||||
class ImportRequest(BaseModel):
|
||||
import_path: str = Field(..., description="已解密的数据库和资源所在目录的绝对路径")
|
||||
|
||||
def _is_valid_sqlite(path: Path) -> bool:
|
||||
SQLITE_HEADER = b"SQLite format 3\x00"
|
||||
try:
|
||||
if not path.exists() or not path.is_file():
|
||||
return False
|
||||
with path.open("rb") as f:
|
||||
return f.read(len(SQLITE_HEADER)) == SQLITE_HEADER
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _validate_import_structure(import_path: Path) -> dict:
|
||||
"""
|
||||
验证导入目录结构:
|
||||
- databases/ (必须包含 contact.db, session.db)
|
||||
- resource/ (可选)
|
||||
- account.json (必须包含 username, nick)
|
||||
"""
|
||||
db_dir = import_path / "databases"
|
||||
account_json_path = import_path / "account.json"
|
||||
|
||||
if not db_dir.exists() or not db_dir.is_dir():
|
||||
raise HTTPException(status_code=400, detail="未找到 databases 目录")
|
||||
|
||||
if not account_json_path.exists():
|
||||
raise HTTPException(status_code=400, detail="未找到 account.json 文件")
|
||||
|
||||
# 验证关键数据库
|
||||
required_dbs = ["contact.db", "session.db"]
|
||||
for db_name in required_dbs:
|
||||
if not _is_valid_sqlite(db_dir / db_name):
|
||||
raise HTTPException(status_code=400, detail=f"databases 目录中未找到有效的 {db_name}")
|
||||
|
||||
# 解析 account.json
|
||||
try:
|
||||
account_info = json.loads(account_json_path.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"解析 account.json 失败: {e}")
|
||||
|
||||
username = account_info.get("username")
|
||||
nick = account_info.get("nick")
|
||||
|
||||
if not username or not nick:
|
||||
raise HTTPException(status_code=400, detail="account.json 中缺少 username 或 nick")
|
||||
|
||||
return {
|
||||
"username": username,
|
||||
"nick": nick,
|
||||
"avatar_url": account_info.get("avatar_url", ""),
|
||||
"has_resource": (import_path / "resource").exists()
|
||||
}
|
||||
|
||||
@router.post("/api/import_decrypted/preview", summary="预览待导入的账号信息")
|
||||
async def preview_import(request: ImportRequest):
|
||||
import_path = Path(request.import_path.strip())
|
||||
if not import_path.exists() or not import_path.is_dir():
|
||||
raise HTTPException(status_code=400, detail="导入路径不存在或不是目录")
|
||||
|
||||
return _validate_import_structure(import_path)
|
||||
|
||||
@router.get("/api/import_decrypted", summary="执行导入已解密的数据库和资源目录 (SSE)")
|
||||
async def import_decrypted_directory(
|
||||
import_path: str = Query(..., description="已解密的数据库和资源所在目录的绝对路径")
|
||||
):
|
||||
import_path_obj = Path(import_path.strip())
|
||||
|
||||
def _sse(data: dict):
|
||||
return f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||||
|
||||
async def generate_progress():
|
||||
try:
|
||||
if not import_path_obj.exists() or not import_path_obj.is_dir():
|
||||
yield _sse({"type": "error", "message": "导入路径不存在或不是目录"})
|
||||
return
|
||||
|
||||
yield _sse({"type": "progress", "percent": 5, "message": "正在验证目录结构..."})
|
||||
# 1. 验证并获取账号信息
|
||||
try:
|
||||
info = await asyncio.to_thread(_validate_import_structure, import_path_obj)
|
||||
except HTTPException as e:
|
||||
yield _sse({"type": "error", "message": e.detail})
|
||||
return
|
||||
except Exception as e:
|
||||
yield _sse({"type": "error", "message": f"验证失败: {e}"})
|
||||
return
|
||||
|
||||
account_name = info["username"]
|
||||
yield _sse({"type": "progress", "percent": 10, "message": f"验证成功: {account_name}"})
|
||||
|
||||
# 2. 准备输出目录
|
||||
output_base = get_output_databases_dir()
|
||||
account_output_dir = output_base / account_name
|
||||
await asyncio.to_thread(account_output_dir.mkdir, parents=True, exist_ok=True)
|
||||
|
||||
yield _sse({"type": "progress", "percent": 15, "message": "正在准备目标目录..."})
|
||||
|
||||
# 3. 导入 databases 目录下的 .db 文件
|
||||
db_src_dir = import_path_obj / "databases"
|
||||
db_files = [f for f in db_src_dir.iterdir() if f.is_file() and f.suffix == ".db"]
|
||||
imported_files = []
|
||||
|
||||
for i, item in enumerate(db_files):
|
||||
target = account_output_dir / item.name
|
||||
def _do_import_db(src, dst):
|
||||
if dst.exists():
|
||||
dst.unlink()
|
||||
try:
|
||||
os.link(src, dst)
|
||||
except Exception:
|
||||
shutil.copy2(src, dst)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(_do_import_db, item, target)
|
||||
imported_files.append(item.name)
|
||||
except Exception as e:
|
||||
logger.error(f"导入数据库失败: {item.name}, error: {e}")
|
||||
|
||||
percent = 15 + int((i + 1) / (len(db_files) or 1) * 15)
|
||||
yield _sse({"type": "progress", "percent": percent, "message": f"正在导入数据库: {item.name}"})
|
||||
|
||||
# 4. 导入 resource 目录
|
||||
resource_src = import_path_obj / "resource"
|
||||
if resource_src.exists() and resource_src.is_dir():
|
||||
yield _sse({"type": "progress", "percent": 30, "message": "正在导入资源文件 (这可能需要一些时间)..."})
|
||||
resource_dst = account_output_dir / "resource"
|
||||
|
||||
def _do_import_resource(src, dst):
|
||||
if dst.exists():
|
||||
if dst.is_symlink() or dst.is_file():
|
||||
dst.unlink()
|
||||
else:
|
||||
shutil.rmtree(dst)
|
||||
try:
|
||||
os.symlink(src, dst, target_is_directory=True)
|
||||
except Exception:
|
||||
shutil.copytree(src, dst, dirs_exist_ok=True)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(_do_import_resource, resource_src, resource_dst)
|
||||
except Exception as e:
|
||||
logger.error(f"导入 resource 目录失败: {e}")
|
||||
|
||||
# 5. 转换 .wxgf 资源 (新增加的流程)
|
||||
yield _sse({"type": "progress", "percent": 50, "message": "正在搜索并转换 .wxgf 图片..."})
|
||||
|
||||
if resource_dst.exists():
|
||||
# 搜索 wxgf 文件
|
||||
def _find_wxgf(root_dir):
|
||||
found = []
|
||||
for root, _, files in os.walk(root_dir):
|
||||
for f in files:
|
||||
if f.lower().endswith(".wxgf"):
|
||||
found.append(Path(root) / f)
|
||||
return found
|
||||
|
||||
wxgf_files = await asyncio.to_thread(_find_wxgf, resource_dst)
|
||||
|
||||
if wxgf_files:
|
||||
total_wxgf = len(wxgf_files)
|
||||
converted_count = 0
|
||||
for i, wxgf_path in enumerate(wxgf_files):
|
||||
def _convert_one(p):
|
||||
jpg_p = p.with_suffix(".wxgf.jpg")
|
||||
if not jpg_p.exists():
|
||||
data = p.read_bytes()
|
||||
if data.startswith(b"wxgf"):
|
||||
converted = _wxgf_to_image_bytes(data)
|
||||
if converted:
|
||||
jpg_p.write_bytes(converted)
|
||||
return True
|
||||
else:
|
||||
return True # 已经存在视为成功
|
||||
return False
|
||||
|
||||
try:
|
||||
success = await asyncio.to_thread(_convert_one, wxgf_path)
|
||||
if success:
|
||||
converted_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"转换 wxgf 失败: {wxgf_path}, {e}")
|
||||
|
||||
if i % max(1, total_wxgf // 20) == 0 or i == total_wxgf - 1:
|
||||
progress_val = 50 + int((i + 1) / total_wxgf * 30)
|
||||
yield _sse({"type": "progress", "percent": progress_val, "message": f"转换 wxgf 图片: {i+1}/{total_wxgf}"})
|
||||
|
||||
logger.info(f"账号 {account_name} 转换完成: {converted_count}/{total_wxgf} 个 .wxgf 文件")
|
||||
|
||||
# 6. 复制 account.json
|
||||
yield _sse({"type": "progress", "percent": 85, "message": "正在更新账号配置..."})
|
||||
try:
|
||||
await asyncio.to_thread(shutil.copy2, import_path_obj / "account.json", account_output_dir / "account.json")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 7. 保存来源信息
|
||||
def _save_source_info(dst, path, info):
|
||||
(dst / "_source.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"db_storage_path": str(path),
|
||||
"import_mode": "manual_import",
|
||||
"imported_at": __import__('datetime').datetime.now().isoformat(),
|
||||
"original_info": info
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(_save_source_info, account_output_dir, import_path_obj, info)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 8. 构建缓存
|
||||
yield _sse({"type": "progress", "percent": 90, "message": "正在构建会话缓存 (这可能需要较长时间)..."})
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
build_session_last_message_table,
|
||||
account_output_dir,
|
||||
rebuild=True,
|
||||
include_hidden=True,
|
||||
include_official=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"构建会话缓存失败: {e}")
|
||||
|
||||
yield _sse({
|
||||
"type": "complete",
|
||||
"status": "success",
|
||||
"account": account_name,
|
||||
"nick": info["nick"],
|
||||
"message": f"成功导入账号 {info['nick']} ({account_name})"
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"导入过程中发生异常: {e}", exc_info=True)
|
||||
yield _sse({"type": "error", "message": f"导入失败: {str(e)}"})
|
||||
|
||||
headers = {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no"
|
||||
}
|
||||
return StreamingResponse(generate_progress(), headers=headers)
|
||||
@@ -0,0 +1,35 @@
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
import asyncio
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _open_folder_dialog(title: str, initial_dir: str) -> str:
|
||||
# 延迟导入并放在独立线程运行,避免阻塞 FastAPI 主线程或发生 GUI 线程冲突
|
||||
import tkinter as tk
|
||||
from tkinter import filedialog
|
||||
|
||||
root = tk.Tk()
|
||||
root.withdraw() # 隐藏主窗口
|
||||
root.attributes('-topmost', True) # 确保弹窗在最前
|
||||
|
||||
folder_path = filedialog.askdirectory(
|
||||
parent=root,
|
||||
title=title,
|
||||
initialdir=initial_dir
|
||||
)
|
||||
|
||||
root.destroy()
|
||||
return folder_path
|
||||
|
||||
|
||||
@router.get("/api/system/pick_directory", summary="唤起本地原生目录选择器")
|
||||
async def pick_directory(title: str = "请选择目录", initial_dir: str = ""):
|
||||
loop = asyncio.get_running_loop()
|
||||
with ThreadPoolExecutor() as pool:
|
||||
# 在子线程中执行 GUI 操作
|
||||
folder_path = await loop.run_in_executor(pool, _open_folder_dialog, title, initial_dir)
|
||||
|
||||
return {"path": folder_path}
|
||||
@@ -21,7 +21,21 @@ async def detect_wechat_detailed(data_root_path: Optional[str] = None):
|
||||
|
||||
# 检测当前登录账号
|
||||
current_account_info = detect_current_logged_in_account(data_root_path)
|
||||
|
||||
# 【新增特性】目录匹配校验:处理目录名 wxid_xxxx_yyyy 与真实 wxid_xxxx 的适配
|
||||
if current_account_info and current_account_info.get("current_account"):
|
||||
base_wxid = current_account_info["current_account"]
|
||||
current_account_info["matched_folder"] = base_wxid # 默认兜底
|
||||
|
||||
# 遍历寻找以该 wxid 开头的用户文件夹(支持后缀匹配)
|
||||
for acc in info.get("accounts", []):
|
||||
acc_name = acc["account_name"]
|
||||
if acc_name == base_wxid or acc_name.startswith(f"{base_wxid}_"):
|
||||
current_account_info["matched_folder"] = acc_name
|
||||
break
|
||||
|
||||
info['current_account'] = current_account_info
|
||||
# logger.info(current_account_info)
|
||||
|
||||
# 添加一些统计信息
|
||||
stats = {
|
||||
|
||||
@@ -123,6 +123,40 @@ def collect_sqlite_diagnostics(
|
||||
return diagnostics
|
||||
|
||||
|
||||
def is_usable_sqlite_db(path: str | Path) -> bool:
|
||||
db_path = Path(path)
|
||||
if not db_path.exists() or (not db_path.is_file()):
|
||||
return False
|
||||
|
||||
try:
|
||||
if int(db_path.stat().st_size) <= len(SQLITE_HEADER):
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
try:
|
||||
with db_path.open("rb") as f:
|
||||
if f.read(len(SQLITE_HEADER)) != SQLITE_HEADER:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
conn: sqlite3.Connection | None = None
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.execute("PRAGMA schema_version").fetchone()
|
||||
row = conn.execute("SELECT name FROM sqlite_master WHERE type='table' LIMIT 1").fetchone()
|
||||
return row is not None
|
||||
except Exception:
|
||||
return False
|
||||
finally:
|
||||
if conn is not None:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def sqlite_diagnostics_status(diagnostics: Mapping[str, Any]) -> str:
|
||||
if not diagnostics:
|
||||
return "not_run"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -21,6 +21,7 @@ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
|
||||
from .app_paths import get_output_databases_dir
|
||||
from .database_filters import should_skip_source_database
|
||||
from .sqlite_diagnostics import collect_sqlite_diagnostics, sqlite_diagnostics_status
|
||||
|
||||
# 注意:不再支持默认密钥,所有密钥必须通过参数传入
|
||||
@@ -64,6 +65,59 @@ def _derive_account_name_from_path(path: Path) -> str:
|
||||
return "unknown_account"
|
||||
|
||||
|
||||
def _build_decrypt_failure_message(result: dict) -> str:
|
||||
failed_pages = int(result.get("failed_pages") or 0)
|
||||
successful_pages = int(result.get("successful_pages") or 0)
|
||||
diagnostic_status = str(result.get("diagnostic_status") or "").strip()
|
||||
diagnostics = dict(result.get("diagnostics") or {})
|
||||
|
||||
detail = (
|
||||
diagnostics.get("quick_check_error")
|
||||
or diagnostics.get("connect_error")
|
||||
or diagnostics.get("table_list_error")
|
||||
or diagnostics.get("page_count_error")
|
||||
or diagnostics.get("quick_check")
|
||||
or diagnostic_status
|
||||
)
|
||||
detail_text = " ".join(str(detail or "").split()).strip()
|
||||
|
||||
if failed_pages > 0 and successful_pages == 0:
|
||||
if detail_text:
|
||||
return f"数据库校验未通过,密钥可能不匹配当前账号: {detail_text}"
|
||||
return "数据库校验未通过,密钥可能不匹配当前账号"
|
||||
|
||||
if diagnostic_status and diagnostic_status != "ok":
|
||||
if detail_text:
|
||||
return f"解密输出不是有效的 SQLite 数据库: {detail_text}"
|
||||
return "解密输出不是有效的 SQLite 数据库"
|
||||
|
||||
if failed_pages > 0:
|
||||
return "解密输出包含页失败,结果不完整"
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def build_decrypt_summary_message(*, success_count: int, total_databases: int, diagnostic_warning_count: int) -> str:
|
||||
success_count = int(success_count or 0)
|
||||
total_databases = int(total_databases or 0)
|
||||
diagnostic_warning_count = int(diagnostic_warning_count or 0)
|
||||
|
||||
if total_databases <= 0:
|
||||
return "未找到可解密的数据库"
|
||||
|
||||
if success_count <= 0:
|
||||
if diagnostic_warning_count > 0:
|
||||
return "解密失败:数据库校验未通过,密钥可能不匹配当前账号。"
|
||||
return "解密失败:未能成功解密任何数据库。"
|
||||
|
||||
if success_count < total_databases:
|
||||
if diagnostic_warning_count > 0:
|
||||
return f"解密部分成功:成功 {success_count}/{total_databases},其余数据库校验未通过。"
|
||||
return f"解密部分成功:成功 {success_count}/{total_databases}。"
|
||||
|
||||
return f"解密完成: 成功 {success_count}/{total_databases}"
|
||||
|
||||
|
||||
def _resolve_db_storage_roots(storage_path: Path) -> list[Path]:
|
||||
try:
|
||||
target = storage_path.resolve()
|
||||
@@ -158,7 +212,7 @@ def scan_account_databases_from_path(db_storage_path: str) -> dict:
|
||||
for file_name in files:
|
||||
if not file_name.endswith(".db"):
|
||||
continue
|
||||
if file_name in ["key_info.db"]:
|
||||
if should_skip_source_database(file_name):
|
||||
continue
|
||||
db_path = os.path.join(root, file_name)
|
||||
databases.append(
|
||||
@@ -266,7 +320,8 @@ class WeChatDatabaseDecryptor:
|
||||
result["failed_page_samples"].append(item)
|
||||
|
||||
def _finalize(success: bool, error: str = "") -> bool:
|
||||
result["success"] = bool(success)
|
||||
normalized_success = bool(success)
|
||||
result["success"] = normalized_success
|
||||
if error:
|
||||
result["error"] = " ".join(str(error).split()).strip()
|
||||
|
||||
@@ -281,6 +336,19 @@ class WeChatDatabaseDecryptor:
|
||||
result["diagnostics"] = diagnostics
|
||||
result["diagnostic_status"] = sqlite_diagnostics_status(diagnostics)
|
||||
|
||||
if normalized_success:
|
||||
failure_message = _build_decrypt_failure_message(result)
|
||||
if failure_message:
|
||||
normalized_success = False
|
||||
result["success"] = False
|
||||
if not result["error"]:
|
||||
result["error"] = failure_message
|
||||
if output_file.exists():
|
||||
try:
|
||||
output_file.unlink()
|
||||
except Exception as exc:
|
||||
logger.warning("删除无效解密输出失败: %s, 错误: %s", output_file, exc)
|
||||
|
||||
payload = {
|
||||
"db_name": result["db_name"],
|
||||
"db_path": result["db_path"],
|
||||
@@ -307,7 +375,7 @@ class WeChatDatabaseDecryptor:
|
||||
log_fn = logger.warning
|
||||
log_fn("[decrypt.diagnostic] %s", json.dumps(payload, ensure_ascii=False, sort_keys=True))
|
||||
self.last_result = result
|
||||
return bool(success)
|
||||
return bool(result["success"])
|
||||
|
||||
logger.info(f"开始解密数据库: {db_path}")
|
||||
|
||||
@@ -693,7 +761,11 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
|
||||
# 返回结果
|
||||
result = {
|
||||
"status": "success" if success_count > 0 else "error",
|
||||
"message": f"解密完成: 成功 {success_count}/{total_databases}",
|
||||
"message": build_decrypt_summary_message(
|
||||
success_count=success_count,
|
||||
total_databases=total_databases,
|
||||
diagnostic_warning_count=diagnostic_warning_count,
|
||||
),
|
||||
"total_databases": total_databases,
|
||||
"successful_count": success_count,
|
||||
"failed_count": total_databases - success_count,
|
||||
|
||||
@@ -13,6 +13,7 @@ from typing import List, Dict, Any, Union
|
||||
from ctypes import wintypes
|
||||
from datetime import datetime
|
||||
|
||||
from .database_filters import should_skip_source_database
|
||||
|
||||
|
||||
def get_wx_db(msg_dir: str = None,
|
||||
@@ -49,7 +50,7 @@ def get_wx_db(msg_dir: str = None,
|
||||
wxid_dirs[os.path.basename(sub_dir)] = os.path.join(msg_dir, sub_dir)
|
||||
else:
|
||||
wxid_dirs[os.path.basename(msg_dir)] = msg_dir
|
||||
|
||||
|
||||
for wxid, wxid_dir in wxid_dirs.items():
|
||||
if wxids and wxid not in wxids: # 如果指定wxid,则过滤掉其他wxid
|
||||
continue
|
||||
@@ -60,8 +61,7 @@ def get_wx_db(msg_dir: str = None,
|
||||
for file_name in files:
|
||||
if not file_name.endswith(".db"):
|
||||
continue
|
||||
# 排除不需要解密的数据库
|
||||
if file_name in ["key_info.db"]:
|
||||
if should_skip_source_database(file_name):
|
||||
continue
|
||||
db_type = re.sub(r"\d*\.db$", "", file_name)
|
||||
if db_types and db_type not in db_types: # 如果指定db_type,则过滤掉其他db_type
|
||||
@@ -70,6 +70,7 @@ def get_wx_db(msg_dir: str = None,
|
||||
result.append({"wxid": wxid, "db_type": db_type, "db_path": db_path, "wxid_dir": wxid_dir})
|
||||
return result
|
||||
|
||||
|
||||
# Windows API 常量和结构
|
||||
PROCESS_QUERY_INFORMATION = 0x0400
|
||||
PROCESS_VM_READ = 0x0010
|
||||
@@ -87,6 +88,7 @@ CreateToolhelp32Snapshot = kernel32.CreateToolhelp32Snapshot
|
||||
Process32FirstW = kernel32.Process32FirstW
|
||||
Process32NextW = kernel32.Process32NextW
|
||||
|
||||
|
||||
class PROCESSENTRY32W(ctypes.Structure):
|
||||
_fields_ = [
|
||||
('dwSize', wintypes.DWORD),
|
||||
@@ -105,6 +107,98 @@ class PROCESSENTRY32W(ctypes.Structure):
|
||||
# 删除了WeChatDecryptor类,解密功能已移至独立的wechat_decrypt.py脚本
|
||||
|
||||
|
||||
def parse_global_config(base_path: str) -> dict:
|
||||
"""
|
||||
解析 all_users/config/global_config 获取最近登录用户信息
|
||||
基于 AES-128-CFB 解密,并解析 MMKV 的 Varint 格式
|
||||
"""
|
||||
try:
|
||||
import os
|
||||
config_path = os.path.join(base_path, 'all_users', 'config', 'global_config')
|
||||
if not os.path.exists(config_path):
|
||||
return None
|
||||
|
||||
with open(config_path, 'rb') as f:
|
||||
full_data = f.read()
|
||||
|
||||
if len(full_data) <= 4:
|
||||
return None
|
||||
|
||||
encrypted_data = full_data[4:]
|
||||
|
||||
# 核心修复 1:强制截断取前 16 字节,等同于 Rust 中的 b"xwechat_crypt_ke"
|
||||
key = b'xwechat_crypt_key'[:16]
|
||||
iv = b'\0' * 16
|
||||
|
||||
# 尝试主流的两种密码库
|
||||
try:
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
cipher = Cipher(algorithms.AES(key), modes.CFB(iv), backend=default_backend())
|
||||
decryptor = cipher.decryptor()
|
||||
decrypted = decryptor.update(encrypted_data) + decryptor.finalize()
|
||||
except ImportError:
|
||||
from Crypto.Cipher import AES
|
||||
# PyCryptodome 中 CFB 模式默认 segment_size 是 8,需要指定为 128
|
||||
cipher = AES.new(key, AES.MODE_CFB, iv=iv, segment_size=128)
|
||||
decrypted = cipher.decrypt(encrypted_data)
|
||||
|
||||
# MMKV Varint 长度解码
|
||||
def decode_varint(data, offset):
|
||||
result = 0
|
||||
shift = 0
|
||||
while offset < len(data):
|
||||
byte = data[offset]
|
||||
offset += 1
|
||||
result |= (byte & 0x7f) << shift
|
||||
if not (byte & 0x80):
|
||||
break
|
||||
shift += 7
|
||||
return result, offset
|
||||
|
||||
def extract_mmkv_string(data: bytes, key_str: str) -> str:
|
||||
key_bytes = key_str.encode('utf-8')
|
||||
idx = data.find(key_bytes)
|
||||
if idx == -1: return None
|
||||
|
||||
offset = idx + len(key_bytes)
|
||||
try:
|
||||
value_len, offset = decode_varint(data, offset)
|
||||
if value_len <= 0 or offset >= len(data):
|
||||
return None
|
||||
|
||||
str_len, offset = decode_varint(data, offset)
|
||||
|
||||
if str_len > 0 and offset + str_len <= len(data):
|
||||
return data[offset:offset + str_len].decode('utf-8', errors='ignore')
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
wxid = extract_mmkv_string(decrypted, 'mmkv_key_user_name')
|
||||
nickname = extract_mmkv_string(decrypted, 'mmkv_key_nick_name')
|
||||
avatar_url = extract_mmkv_string(decrypted, 'mmkv_key_head_img_url')
|
||||
|
||||
# 核心修复 2:参考 Rust 逻辑,头像链接往往以 "/0" 结尾(微信头像的尺寸标识)
|
||||
if not avatar_url and b'http' in decrypted:
|
||||
http_idx = decrypted.find(b'http')
|
||||
slash_zero_idx = decrypted.find(b'/0', http_idx)
|
||||
if slash_zero_idx != -1:
|
||||
# 包含 "/0" 这两个字符本身,所以是 +2
|
||||
avatar_url = decrypted[http_idx:slash_zero_idx + 2].decode('utf-8', errors='ignore')
|
||||
|
||||
if wxid or nickname:
|
||||
return {
|
||||
"wxid": wxid,
|
||||
"nickname": nickname,
|
||||
"avatar": avatar_url
|
||||
}
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"[DEBUG] 解析 global_config 失败: {e}")
|
||||
return None
|
||||
|
||||
def find_wechat_databases() -> List[str]:
|
||||
"""在新的xwechat_files目录中查找微信数据库文件
|
||||
|
||||
@@ -119,13 +213,13 @@ def find_wechat_databases() -> List[str]:
|
||||
# 检查新的微信4.0+目录结构
|
||||
wechat_dirs = [
|
||||
documents_dir / "xwechat_files", # 新版微信4.0+
|
||||
documents_dir / "WeChat Files" # 旧版微信
|
||||
documents_dir / "WeChat Files" # 旧版微信
|
||||
]
|
||||
|
||||
|
||||
for wechat_dir in wechat_dirs:
|
||||
if not wechat_dir.exists():
|
||||
continue
|
||||
|
||||
|
||||
# 查找用户目录(wxid_*模式)
|
||||
for user_dir in wechat_dir.iterdir():
|
||||
if not user_dir.is_dir():
|
||||
@@ -149,7 +243,7 @@ def find_wechat_databases() -> List[str]:
|
||||
for db_file in multi_dir.glob("*.db"):
|
||||
if db_file.is_file():
|
||||
db_files.append(str(db_file))
|
||||
|
||||
|
||||
return db_files
|
||||
|
||||
|
||||
@@ -158,7 +252,7 @@ def get_process_exe_path(process_id):
|
||||
h_process = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, False, process_id)
|
||||
if not h_process:
|
||||
return None
|
||||
|
||||
|
||||
exe_path = ctypes.create_unicode_buffer(MAX_PATH)
|
||||
if GetModuleFileNameExW(h_process, None, exe_path, MAX_PATH) > 0:
|
||||
CloseHandle(h_process)
|
||||
@@ -167,35 +261,37 @@ def get_process_exe_path(process_id):
|
||||
CloseHandle(h_process)
|
||||
return None
|
||||
|
||||
|
||||
def get_process_list():
|
||||
"""获取系统进程列表"""
|
||||
h_process_snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)
|
||||
if h_process_snap == ctypes.wintypes.HANDLE(-1).value:
|
||||
return []
|
||||
|
||||
|
||||
pe32 = PROCESSENTRY32W()
|
||||
pe32.dwSize = ctypes.sizeof(PROCESSENTRY32W)
|
||||
process_list = []
|
||||
|
||||
|
||||
if not Process32FirstW(h_process_snap, ctypes.byref(pe32)):
|
||||
CloseHandle(h_process_snap)
|
||||
return []
|
||||
|
||||
|
||||
while True:
|
||||
process_list.append((pe32.th32ProcessID, pe32.szExeFile))
|
||||
if not Process32NextW(h_process_snap, ctypes.byref(pe32)):
|
||||
break
|
||||
|
||||
|
||||
CloseHandle(h_process_snap)
|
||||
return process_list
|
||||
|
||||
|
||||
def auto_detect_wechat_data_dirs():
|
||||
"""
|
||||
自动检测微信数据目录 - 多策略组合检测
|
||||
:return: 检测到的微信数据目录列表
|
||||
"""
|
||||
detected_dirs = []
|
||||
|
||||
|
||||
# 策略1:注册表检测已移除
|
||||
|
||||
# 策略2和策略3:注册表相关检测已移除
|
||||
@@ -211,24 +307,24 @@ def auto_detect_wechat_data_dirs():
|
||||
for drive in drives:
|
||||
if not os.path.exists(drive):
|
||||
continue
|
||||
|
||||
|
||||
try:
|
||||
# 扫描驱动器根目录和常见目录
|
||||
scan_paths = [
|
||||
drive + os.sep,
|
||||
os.path.join(drive + os.sep, "Users"),
|
||||
]
|
||||
|
||||
|
||||
for scan_path in scan_paths:
|
||||
if not os.path.exists(scan_path):
|
||||
continue
|
||||
|
||||
|
||||
try:
|
||||
for item in os.listdir(scan_path):
|
||||
item_path = os.path.join(scan_path, item)
|
||||
if not os.path.isdir(item_path):
|
||||
continue
|
||||
|
||||
|
||||
# 检查是否匹配微信目录模式
|
||||
for pattern in common_wechat_patterns:
|
||||
if pattern.lower() in item.lower():
|
||||
@@ -242,7 +338,7 @@ def auto_detect_wechat_data_dirs():
|
||||
continue
|
||||
except (PermissionError, OSError):
|
||||
continue
|
||||
|
||||
|
||||
# 策略2:进程内存分析(简化版)
|
||||
try:
|
||||
process_list = get_process_list()
|
||||
@@ -263,7 +359,7 @@ def auto_detect_wechat_data_dirs():
|
||||
current = parent
|
||||
else:
|
||||
break
|
||||
|
||||
|
||||
for parent_dir in parent_dirs:
|
||||
for pattern in common_wechat_patterns:
|
||||
potential_dir = os.path.join(parent_dir, pattern)
|
||||
@@ -275,7 +371,7 @@ def auto_detect_wechat_data_dirs():
|
||||
pass
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
return detected_dirs
|
||||
|
||||
|
||||
@@ -300,6 +396,7 @@ def has_wxid_directories(directory):
|
||||
except:
|
||||
return False
|
||||
|
||||
|
||||
def get_wx_dir_by_reg(wxid="all"):
|
||||
"""
|
||||
通过多种方法获取微信目录 - 改进的自动检测
|
||||
@@ -327,6 +424,7 @@ def get_wx_dir_by_reg(wxid="all"):
|
||||
|
||||
return wx_dir if os.path.exists(wx_dir) else None
|
||||
|
||||
|
||||
def detect_wechat_accounts_from_backup(backup_base_path: str = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
从指定的备份路径检测微信账号
|
||||
@@ -393,8 +491,8 @@ def detect_wechat_accounts_from_backup(backup_base_path: str = None) -> List[Dic
|
||||
for data_item in os.listdir(backup_base_path):
|
||||
data_item_path = os.path.join(backup_base_path, data_item)
|
||||
if (os.path.isdir(data_item_path) and
|
||||
data_item.startswith(f"{account_name}_") and
|
||||
data_item != "Backup"):
|
||||
data_item.startswith(f"{account_name}_") and
|
||||
data_item != "Backup"):
|
||||
data_dir = data_item_path
|
||||
break
|
||||
except (PermissionError, OSError):
|
||||
@@ -525,9 +623,9 @@ def detect_wechat_accounts_from_login(login_base_path: str = None) -> List[Dict[
|
||||
for data_item in os.listdir(base_path):
|
||||
data_item_path = os.path.join(base_path, data_item)
|
||||
if (
|
||||
os.path.isdir(data_item_path)
|
||||
and data_item.startswith(f"{account_name}_")
|
||||
and data_item not in ["Backup", "all_users"]
|
||||
os.path.isdir(data_item_path)
|
||||
and data_item.startswith(f"{account_name}_")
|
||||
and data_item not in ["Backup", "all_users"]
|
||||
):
|
||||
data_dir = data_item_path
|
||||
break
|
||||
@@ -551,6 +649,7 @@ def detect_wechat_accounts_from_login(login_base_path: str = None) -> List[Dict[
|
||||
|
||||
return accounts
|
||||
|
||||
|
||||
def collect_account_databases(data_dir: str, account_name: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
收集指定账号数据目录下的所有数据库文件
|
||||
@@ -574,8 +673,7 @@ def collect_account_databases(data_dir: str, account_name: str) -> List[Dict[str
|
||||
if not file_name.endswith('.db'):
|
||||
continue
|
||||
|
||||
# 排除不需要解密的数据库
|
||||
if file_name in ["key_info.db"]:
|
||||
if should_skip_source_database(file_name):
|
||||
continue
|
||||
|
||||
db_path = os.path.join(root, file_name)
|
||||
@@ -801,40 +899,37 @@ def detect_wechat_installation(data_root_path: str | None = None) -> Dict[str, A
|
||||
|
||||
def detect_current_logged_in_account(base_path: str = None) -> Dict[str, Any]:
|
||||
"""
|
||||
通过key_info.db文件时间检测当前登录的微信账号
|
||||
|
||||
Args:
|
||||
base_path: 微信数据根目录,如果为None则自动检测
|
||||
|
||||
Returns:
|
||||
当前登录账号信息
|
||||
通过 global_config 解析 或 key_info.db 时间检测当前登录的微信账号
|
||||
"""
|
||||
current_account = None
|
||||
latest_time = None
|
||||
|
||||
# 添加调试信息
|
||||
print(f"[DEBUG] 开始检测当前登录账号,提供的base_path: {base_path}")
|
||||
|
||||
# 如果没有指定路径,尝试自动检测
|
||||
# print(f"[DEBUG] 开始检测当前登录账号,提供的base_path: {base_path}")
|
||||
|
||||
if base_path is None:
|
||||
detected_dirs = auto_detect_wechat_data_dirs()
|
||||
print(f"[DEBUG] 自动检测到的目录: {detected_dirs}")
|
||||
if not detected_dirs:
|
||||
return {
|
||||
"current_account": None,
|
||||
"latest_time": None,
|
||||
"message": "未检测到微信数据目录"
|
||||
}
|
||||
return {"current_account": None, "message": "未检测到微信数据目录"}
|
||||
base_path = detected_dirs[0]
|
||||
|
||||
print(f"[DEBUG] 使用的base_path: {base_path}")
|
||||
|
||||
# 查找登录信息目录 - 尝试多个可能的路径
|
||||
|
||||
# 1. 新特性:优先尝试从 global_config 解析完整用户信息
|
||||
parsed_config = parse_global_config(base_path)
|
||||
if parsed_config and parsed_config.get('wxid'):
|
||||
print(f"[DEBUG] 从 global_config 成功解析出账号: {parsed_config['wxid']}")
|
||||
return {
|
||||
"current_account": parsed_config["wxid"], # 不带校验位的 wxid
|
||||
"nickname": parsed_config.get("nickname"),
|
||||
"avatar": parsed_config.get("avatar"),
|
||||
"latest_time": None,
|
||||
"message": f"通过 global_config 检测到最近登录账号: {parsed_config['wxid']}"
|
||||
}
|
||||
|
||||
# 2. 降级回退机制:原先基于 key_info.db 的时间探测逻辑
|
||||
latest_time = None
|
||||
current_account = None
|
||||
|
||||
possible_login_paths = [
|
||||
os.path.join(base_path, "all_users", "login"), # 标准路径
|
||||
os.path.join(base_path, "login"), # 备选路径1
|
||||
os.path.join(base_path, "all_users", "login"),
|
||||
os.path.join(base_path, "login"),
|
||||
]
|
||||
|
||||
|
||||
# 也尝试在子目录中查找
|
||||
try:
|
||||
for item in os.listdir(base_path):
|
||||
@@ -842,11 +937,11 @@ def detect_current_logged_in_account(base_path: str = None) -> Dict[str, Any]:
|
||||
if os.path.isdir(item_path):
|
||||
possible_login_paths.extend([
|
||||
os.path.join(item_path, "all_users", "login"), # 子目录中的标准路径
|
||||
os.path.join(item_path, "login"), # 子目录中的备选路径
|
||||
os.path.join(item_path, "login"), # 子目录中的备选路径
|
||||
])
|
||||
except (PermissionError, OSError):
|
||||
pass
|
||||
|
||||
|
||||
login_dir = None
|
||||
for path in possible_login_paths:
|
||||
print(f"[DEBUG] 检查路径: {path}")
|
||||
@@ -854,49 +949,49 @@ def detect_current_logged_in_account(base_path: str = None) -> Dict[str, Any]:
|
||||
login_dir = path
|
||||
print(f"[DEBUG] 找到登录目录: {login_dir}")
|
||||
break
|
||||
|
||||
|
||||
if not login_dir:
|
||||
return {
|
||||
"current_account": None,
|
||||
"latest_time": None,
|
||||
"message": f"未找到登录信息目录,尝试的路径: {possible_login_paths}"
|
||||
}
|
||||
|
||||
|
||||
try:
|
||||
# 遍历登录目录下的所有账号文件夹
|
||||
items = os.listdir(login_dir)
|
||||
print(f"[DEBUG] 登录目录内容: {items}")
|
||||
|
||||
|
||||
for item in items:
|
||||
item_path = os.path.join(login_dir, item)
|
||||
print(f"[DEBUG] 检查项目: {item}, 路径: {item_path}, 是否为目录: {os.path.isdir(item_path)}")
|
||||
|
||||
|
||||
if not os.path.isdir(item_path):
|
||||
continue
|
||||
|
||||
|
||||
# 检查key_info.db文件
|
||||
key_info_path = os.path.join(item_path, "key_info.db")
|
||||
print(f"[DEBUG] 检查key_info.db文件: {key_info_path}, 是否存在: {os.path.exists(key_info_path)}")
|
||||
|
||||
|
||||
if not os.path.exists(key_info_path):
|
||||
continue
|
||||
|
||||
|
||||
# 获取文件修改时间
|
||||
try:
|
||||
file_time = os.path.getmtime(key_info_path)
|
||||
file_datetime = datetime.fromtimestamp(file_time)
|
||||
print(f"[DEBUG] 找到key_info.db文件: {key_info_path}, 修改时间: {file_datetime}")
|
||||
|
||||
|
||||
# 更新最新登录的账号
|
||||
if latest_time is None or file_time > latest_time:
|
||||
latest_time = file_time
|
||||
current_account = item
|
||||
print(f"[DEBUG] 更新最新登录账号: {current_account}, 时间: {file_datetime}")
|
||||
|
||||
|
||||
except OSError as e:
|
||||
print(f"[DEBUG] 无法获取文件时间: {key_info_path}, 错误: {e}")
|
||||
continue
|
||||
|
||||
|
||||
except (PermissionError, OSError) as e:
|
||||
print(f"[DEBUG] 无法访问登录目录: {login_dir}, 错误: {e}")
|
||||
return {
|
||||
@@ -904,7 +999,7 @@ def detect_current_logged_in_account(base_path: str = None) -> Dict[str, Any]:
|
||||
"latest_time": None,
|
||||
"message": f"无法访问登录目录: {e}"
|
||||
}
|
||||
|
||||
|
||||
if current_account:
|
||||
print(f"[DEBUG] 最终结果: 当前登录账号 {current_account}, 时间 {latest_time}")
|
||||
return {
|
||||
|
||||
@@ -18,6 +18,10 @@ sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
class TestChatMediaImageCacheUpgrade(unittest.TestCase):
|
||||
def assert_cacheable_chat_image_response(self, resp) -> None:
|
||||
self.assertEqual(resp.headers.get("cache-control"), "private, max-age=86400")
|
||||
self.assertTrue(str(resp.headers.get("etag") or "").strip())
|
||||
|
||||
def _seed_contact_db(self, path: Path, *, account: str, username: str) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
@@ -147,7 +151,7 @@ class TestChatMediaImageCacheUpgrade(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.content, live_original)
|
||||
self.assertEqual(resp.headers.get("cache-control"), "no-store")
|
||||
self.assert_cacheable_chat_image_response(resp)
|
||||
self.assertEqual(cache_path.read_bytes(), live_original)
|
||||
finally:
|
||||
try:
|
||||
@@ -192,7 +196,7 @@ class TestChatMediaImageCacheUpgrade(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.content, cached_original)
|
||||
self.assertEqual(resp.headers.get("cache-control"), "no-store")
|
||||
self.assert_cacheable_chat_image_response(resp)
|
||||
self.assertEqual(cache_path.read_bytes(), cached_original)
|
||||
finally:
|
||||
try:
|
||||
@@ -205,6 +209,59 @@ class TestChatMediaImageCacheUpgrade(unittest.TestCase):
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
|
||||
|
||||
def test_chat_image_supports_etag_revalidation(self):
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
account = "wxid_test"
|
||||
username = "wxid_friend"
|
||||
md5 = "cccccccccccccccccccccccccccccccc"
|
||||
|
||||
account_dir = root / "output" / "databases" / account
|
||||
wxid_dir = root / "wxid_source"
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
wxid_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._seed_contact_db(account_dir / "contact.db", account=account, username=username)
|
||||
self._seed_session_db(account_dir / "session.db", username=username)
|
||||
self._seed_source_info(account_dir, wxid_dir=wxid_dir)
|
||||
|
||||
cached_original = b"\xff\xd8\xff\xe0" + (b"\x22" * 64) + b"\xff\xd9"
|
||||
self._seed_cached_resource(account_dir, md5=md5, payload=cached_original)
|
||||
|
||||
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
|
||||
client = None
|
||||
try:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
|
||||
client = self._build_client()
|
||||
first = client.get(
|
||||
"/api/chat/media/image",
|
||||
params={"account": account, "md5": md5, "username": username},
|
||||
)
|
||||
self.assertEqual(first.status_code, 200)
|
||||
self.assertEqual(first.content, cached_original)
|
||||
self.assert_cacheable_chat_image_response(first)
|
||||
|
||||
etag = str(first.headers.get("etag") or "").strip()
|
||||
second = client.get(
|
||||
"/api/chat/media/image",
|
||||
params={"account": account, "md5": md5, "username": username},
|
||||
headers={"If-None-Match": etag},
|
||||
)
|
||||
self.assertEqual(second.status_code, 304)
|
||||
self.assertEqual(second.content, b"")
|
||||
self.assertEqual(second.headers.get("etag"), etag)
|
||||
self.assertEqual(second.headers.get("cache-control"), "private, max-age=86400")
|
||||
finally:
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
logging.shutdown()
|
||||
if prev_data is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
def _close_logging_handlers() -> None:
|
||||
for logger_name in ("", "uvicorn", "uvicorn.access", "uvicorn.error", "fastapi"):
|
||||
lg = logging.getLogger(logger_name)
|
||||
for handler in lg.handlers[:]:
|
||||
try:
|
||||
handler.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
lg.removeHandler(handler)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _seed_sqlite(path: Path, table_name: str = "demo") -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute(f"CREATE TABLE {table_name}(id INTEGER PRIMARY KEY, value TEXT)")
|
||||
conn.execute(f"INSERT INTO {table_name}(value) VALUES ('ok')")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestDatabaseFilters(unittest.TestCase):
|
||||
def test_scan_account_databases_skips_index_databases(self):
|
||||
from wechat_decrypt_tool.wechat_decrypt import scan_account_databases_from_path
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
db_storage = Path(td) / "xwechat_files" / "wxid_demo_user" / "db_storage"
|
||||
db_storage.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_seed_sqlite(db_storage / "MSG0.db")
|
||||
_seed_sqlite(db_storage / "contact_fts.db")
|
||||
_seed_sqlite(db_storage / "favorite_fts.db")
|
||||
_seed_sqlite(db_storage / "message_fts.db")
|
||||
_seed_sqlite(db_storage / "key_info.db")
|
||||
|
||||
result = scan_account_databases_from_path(str(db_storage))
|
||||
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertEqual(list(result["account_databases"].keys()), ["wxid_demo"])
|
||||
db_names = sorted(db["name"] for db in result["account_databases"]["wxid_demo"])
|
||||
self.assertEqual(db_names, ["MSG0.db"])
|
||||
|
||||
def test_collect_account_databases_skips_index_databases(self):
|
||||
from wechat_decrypt_tool.wechat_detection import collect_account_databases
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
data_dir = Path(td) / "wxid_demo_user"
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_seed_sqlite(data_dir / "contact.db", "contact")
|
||||
_seed_sqlite(data_dir / "contact_fts.db")
|
||||
_seed_sqlite(data_dir / "favorite_fts.db")
|
||||
_seed_sqlite(data_dir / "message_fts.db")
|
||||
|
||||
databases = collect_account_databases(str(data_dir), "wxid_demo")
|
||||
db_names = sorted(db["name"] for db in databases)
|
||||
self.assertEqual(db_names, ["contact.db"])
|
||||
|
||||
def test_chat_account_info_hides_index_and_internal_databases(self):
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
prev_data_dir = os.environ.get("WECHAT_TOOL_DATA_DIR")
|
||||
try:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
|
||||
|
||||
import wechat_decrypt_tool.app_paths as app_paths
|
||||
import wechat_decrypt_tool.routers.chat as chat_router
|
||||
|
||||
importlib.reload(app_paths)
|
||||
importlib.reload(chat_router)
|
||||
|
||||
account_dir = root / "output" / "databases" / "wxid_demo"
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_seed_sqlite(account_dir / "contact.db", "contact")
|
||||
_seed_sqlite(account_dir / "session.db", "session_table")
|
||||
_seed_sqlite(account_dir / "message_fts.db")
|
||||
_seed_sqlite(account_dir / "chat_search_index.db")
|
||||
_seed_sqlite(account_dir / "session_last_message.db")
|
||||
|
||||
result = chat_router.get_chat_account_info("wxid_demo")
|
||||
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertEqual(result["database_count"], 2)
|
||||
self.assertEqual(result["databases"], ["contact.db", "session.db"])
|
||||
finally:
|
||||
_close_logging_handlers()
|
||||
if prev_data_dir is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data_dir
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,5 +1,7 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import unittest
|
||||
import importlib
|
||||
@@ -11,13 +13,25 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
def _close_logging_handlers() -> None:
|
||||
for logger_name in ("", "uvicorn", "uvicorn.access", "uvicorn.error", "fastapi"):
|
||||
lg = logging.getLogger(logger_name)
|
||||
for handler in lg.handlers[:]:
|
||||
try:
|
||||
handler.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
lg.removeHandler(handler)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class TestDecryptStreamSSE(unittest.TestCase):
|
||||
def test_decrypt_stream_reports_progress(self):
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from wechat_decrypt_tool.wechat_decrypt import SQLITE_HEADER
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
|
||||
@@ -36,8 +50,14 @@ class TestDecryptStreamSSE(unittest.TestCase):
|
||||
db_storage = root / "xwechat_files" / "wxid_foo_bar" / "db_storage"
|
||||
db_storage.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Fake a decrypted sqlite db (>= 4096 bytes) so decryptor falls back to copy.
|
||||
(db_storage / "MSG0.db").write_bytes(SQLITE_HEADER + b"\x00" * (4096 - len(SQLITE_HEADER)))
|
||||
db_path = db_storage / "MSG0.db"
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
try:
|
||||
conn.execute("CREATE TABLE demo(id INTEGER PRIMARY KEY, value TEXT)")
|
||||
conn.execute("INSERT INTO demo(value) VALUES ('ok')")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(decrypt_router.router)
|
||||
@@ -72,10 +92,83 @@ class TestDecryptStreamSSE(unittest.TestCase):
|
||||
self.assertIn("start", types)
|
||||
self.assertIn("progress", types)
|
||||
self.assertEqual(events[-1].get("type"), "complete")
|
||||
self.assertEqual(events[-1].get("status"), "completed")
|
||||
|
||||
out = root / "output" / "databases" / "wxid_foo" / "MSG0.db"
|
||||
self.assertTrue(out.exists())
|
||||
finally:
|
||||
_close_logging_handlers()
|
||||
if prev_data_dir is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data_dir
|
||||
if prev_build_cache is None:
|
||||
os.environ.pop("WECHAT_TOOL_BUILD_SESSION_LAST_MESSAGE", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_BUILD_SESSION_LAST_MESSAGE"] = prev_build_cache
|
||||
|
||||
def test_decrypt_stream_marks_invalid_output_as_failed(self):
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
|
||||
prev_data_dir = os.environ.get("WECHAT_TOOL_DATA_DIR")
|
||||
prev_build_cache = os.environ.get("WECHAT_TOOL_BUILD_SESSION_LAST_MESSAGE")
|
||||
try:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
|
||||
os.environ["WECHAT_TOOL_BUILD_SESSION_LAST_MESSAGE"] = "0"
|
||||
|
||||
import wechat_decrypt_tool.app_paths as app_paths
|
||||
import wechat_decrypt_tool.routers.decrypt as decrypt_router
|
||||
|
||||
importlib.reload(app_paths)
|
||||
importlib.reload(decrypt_router)
|
||||
|
||||
db_storage = root / "xwechat_files" / "wxid_bad_case" / "db_storage"
|
||||
db_storage.mkdir(parents=True, exist_ok=True)
|
||||
(db_storage / "MSG0.db").write_bytes(b"\x01" * 4096)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(decrypt_router.router)
|
||||
client = TestClient(app)
|
||||
|
||||
events: list[dict] = []
|
||||
with client.stream(
|
||||
"GET",
|
||||
"/api/decrypt_stream",
|
||||
params={"key": "00" * 32, "db_storage_path": str(db_storage)},
|
||||
) as resp:
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertIn("text/event-stream", resp.headers.get("content-type", ""))
|
||||
|
||||
for line in resp.iter_lines():
|
||||
if not line:
|
||||
continue
|
||||
if isinstance(line, bytes):
|
||||
line = line.decode("utf-8", errors="ignore")
|
||||
line = str(line)
|
||||
|
||||
if line.startswith(":"):
|
||||
continue
|
||||
if not line.startswith("data: "):
|
||||
continue
|
||||
payload = json.loads(line[len("data: ") :])
|
||||
events.append(payload)
|
||||
if payload.get("type") in {"complete", "error"}:
|
||||
break
|
||||
|
||||
self.assertEqual(events[-1].get("type"), "complete")
|
||||
self.assertEqual(events[-1].get("status"), "failed")
|
||||
self.assertEqual(events[-1].get("success_count"), 0)
|
||||
self.assertEqual(events[-1].get("failure_count"), 1)
|
||||
self.assertIn("密钥可能不匹配", str(events[-1].get("message") or ""))
|
||||
|
||||
out = root / "output" / "databases" / "wxid_bad" / "MSG0.db"
|
||||
self.assertFalse(out.exists())
|
||||
finally:
|
||||
_close_logging_handlers()
|
||||
if prev_data_dir is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import importlib
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
def _seed_sqlite(path: Path, table_name: str) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute(f"CREATE TABLE {table_name}(id INTEGER PRIMARY KEY, value TEXT)")
|
||||
conn.execute(f"INSERT INTO {table_name}(value) VALUES ('ok')")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestDecryptedAccountValidation(unittest.TestCase):
|
||||
def test_invalid_header_only_databases_are_ignored(self):
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
prev_data_dir = os.environ.get("WECHAT_TOOL_DATA_DIR")
|
||||
try:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
|
||||
|
||||
import wechat_decrypt_tool.app_paths as app_paths
|
||||
import wechat_decrypt_tool.chat_helpers as chat_helpers
|
||||
import wechat_decrypt_tool.media_helpers as media_helpers
|
||||
|
||||
importlib.reload(app_paths)
|
||||
importlib.reload(chat_helpers)
|
||||
importlib.reload(media_helpers)
|
||||
|
||||
output_dir = root / "output" / "databases"
|
||||
bad_dir = output_dir / "wxid_bad"
|
||||
bad_dir.mkdir(parents=True, exist_ok=True)
|
||||
(bad_dir / "session.db").write_bytes(b"SQLite format 3\x00")
|
||||
(bad_dir / "contact.db").write_bytes(b"SQLite format 3\x00")
|
||||
|
||||
good_dir = output_dir / "wxid_good"
|
||||
good_dir.mkdir(parents=True, exist_ok=True)
|
||||
_seed_sqlite(good_dir / "session.db", "SessionTable")
|
||||
_seed_sqlite(good_dir / "contact.db", "contact")
|
||||
|
||||
self.assertEqual(chat_helpers._list_decrypted_accounts(), ["wxid_good"])
|
||||
self.assertEqual(media_helpers._list_decrypted_accounts(), ["wxid_good"])
|
||||
finally:
|
||||
if prev_data_dir is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data_dir
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user