Compare commits

..

12 Commits

32 changed files with 3418 additions and 328 deletions
+8
View File
@@ -57,6 +57,14 @@
{
"from": "resources/backend",
"to": "backend"
},
{
"from": "src/wcdb-sidecar.cjs",
"to": "wcdb-sidecar.cjs"
},
{
"from": "vendor/koffi",
"to": "koffi"
}
],
"win": {
+161
View File
@@ -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();
+545
View File
@@ -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);
}
});
+22
View File
@@ -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.
+634
View File
@@ -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;
+26
View File
@@ -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"
}
+27
View File
@@ -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,
+25 -1
View File
@@ -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
View File
@@ -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>
+276
View File
@@ -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>
+11 -2
View File
@@ -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 })
+4
View File
@@ -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):
+2 -8
View File
@@ -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
+2 -7
View File
@@ -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.
+2 -1
View File
@@ -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
+25 -7
View File
@@ -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="获取表情消息资源")
+11 -2
View File
@@ -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)
+35
View File
@@ -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"
+480 -41
View File
@@ -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:
+76 -4
View File
@@ -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,
+162 -67
View File
@@ -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 {
+59 -2
View File
@@ -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()
+112
View File
@@ -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()
+97 -4
View File
@@ -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()