- 移除前端localStorage依赖,改用后端SQLite作为唯一数据源 - 新增getWanchuanConfig和saveWanchuanConfig函数用于配置读写 - 添加getBoundKnowledgeBase函数统一获取绑定知识库信息 - 支持桌面应用端口变化时正确读取配置 refactor(settings): 重构万川平台配置管理逻辑 - 移除localStorage配置存储,改为后端API调用 - 实现配置自动恢复和防抖保存机制 - 添加token过期自动重登功能 - 优化知识库选择和连接状态管理 fix(knowledge): 修复知识库上传异步问题 - 将getBoundKnowledgeBase调用改为await异步处理 - 统一各页面的知识库信息获取方式 - 修正上传接口datasetId使用逻辑 feat(electron): 添加chatlog.exe存在性检查 - 新增ensureChatlogExe函数验证执行文件存在 - 防止杀毒软件误删导致的ENONENT错误 - 提供用户友好的错误提示和解决方案
1359 lines
45 KiB
JavaScript
1359 lines
45 KiB
JavaScript
const { app, BrowserWindow, ipcMain } = require('electron');
|
||
const fs = require('fs');
|
||
const http = require('http');
|
||
const net = require('net');
|
||
const os = require('os');
|
||
const path = require('path');
|
||
const { spawn } = require('child_process');
|
||
|
||
let mainWindow;
|
||
let isQuitting = false;
|
||
let isInViewMode = false;
|
||
let decryptPollTimer = null;
|
||
let backendPort = null;
|
||
let backendUrl = null;
|
||
let lastAccountFingerprint = '';
|
||
let activeChatlogConfig = null;
|
||
let malformedRepairTimer = null;
|
||
let malformedRepairInProgress = false;
|
||
let messageQueryRestartInProgress = false;
|
||
let startupValidationInProgress = false;
|
||
const pendingMalformedDbPaths = new Set();
|
||
const malformedRepairAttempts = new Set();
|
||
const malformedRepairNotified = new Set();
|
||
const messageQueryRestartAttempts = new Set();
|
||
|
||
const processes = {
|
||
chatlog: null,
|
||
fastapi: null,
|
||
};
|
||
|
||
function isPackaged() {
|
||
return app.isPackaged;
|
||
}
|
||
|
||
function projectRoot() {
|
||
return isPackaged() ? process.resourcesPath : path.resolve(__dirname, '..');
|
||
}
|
||
|
||
function resourcePath(...parts) {
|
||
return path.join(projectRoot(), ...parts);
|
||
}
|
||
|
||
function chatlogExePath() {
|
||
return resourcePath('chatlog.exe');
|
||
}
|
||
|
||
// 启动 chatlog.exe 前确认文件确实存在。
|
||
// 该文件常被杀毒软件(尤其 Windows Defender)误判为风险程序而静默隔离/删除,
|
||
// 导致安装后“有时候”运行就报 spawn ... ENOENT。这里把底层的 ENOENT
|
||
// 提前转成一句用户能看懂、能自救的中文提示。
|
||
function ensureChatlogExe() {
|
||
const exePath = chatlogExePath();
|
||
if (!fs.existsSync(exePath)) {
|
||
throw new Error(
|
||
`未找到 chatlog.exe(应在:${exePath})。` +
|
||
`该文件可能被杀毒软件误删或隔离。请将安装目录加入杀毒软件白名单,` +
|
||
`从隔离区恢复 chatlog.exe,或重新安装本应用后再试。`
|
||
);
|
||
}
|
||
return exePath;
|
||
}
|
||
|
||
function backendExePath() {
|
||
return resourcePath('backend', 'ChatLabBackend.exe');
|
||
}
|
||
|
||
function backendSourceDir() {
|
||
return resourcePath('chatlog_fastAPI');
|
||
}
|
||
|
||
function frontendDistDir() {
|
||
return isPackaged()
|
||
? resourcePath('frontend')
|
||
: resourcePath('chatlab-web', 'frontend', 'dist');
|
||
}
|
||
|
||
function appIconPath() {
|
||
const iconName = process.platform === 'win32' ? 'icon.ico' : 'icon.png';
|
||
const candidate = path.join(__dirname, 'build', iconName);
|
||
return fs.existsSync(candidate) ? candidate : undefined;
|
||
}
|
||
|
||
function appDataDir() {
|
||
const dir = path.join(app.getPath('appData'), 'ChatLab');
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
return dir;
|
||
}
|
||
|
||
function send(channel, payload) {
|
||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||
mainWindow.webContents.send(channel, payload);
|
||
}
|
||
}
|
||
|
||
function log(source, text) {
|
||
send('log', { source, text: sanitizeLog(String(text || '')) });
|
||
}
|
||
|
||
function stripAnsi(text) {
|
||
return String(text || '').replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '');
|
||
}
|
||
|
||
function sanitizeLog(text) {
|
||
return stripAnsi(text)
|
||
.replace(/("(?:data_key|dataKey|img_key|imgKey)"\s*:\s*")([^"]+)(")/gi, '$1********$3')
|
||
.replace(/("(?:APIKey|apiKey|api_key)"\s*:\s*")([^"]+)(")/g, '$1sk-****$3')
|
||
.replace(/\b(data_key|dataKey|DataKey)(\s*[:=]\s*)([a-fA-F0-9]{8})[a-fA-F0-9]{48}([a-fA-F0-9]{8})/g, '$1$2$3****$4')
|
||
.replace(/\b(img_key|imgKey|ImgKey)(\s*[:=]\s*)([a-fA-F0-9]{8})[a-fA-F0-9]{48}([a-fA-F0-9]{8})/g, '$1$2$3****$4')
|
||
.replace(/Data\s*Key:\s*\[?([a-fA-F0-9]{8})[a-fA-F0-9]{48}([a-fA-F0-9]{8})\]?/gi, 'Data Key: [$1****$2]')
|
||
.replace(/Image\s*Key:\s*\[?([a-fA-F0-9]{8})[a-fA-F0-9]{48}([a-fA-F0-9]{8})\]?/gi, 'Image Key: [$1****$2]')
|
||
.replace(/sk-[A-Za-z0-9_\-]{8,}/g, 'sk-****');
|
||
}
|
||
|
||
function normalizePathForCompare(value) {
|
||
return String(value || '')
|
||
.trim()
|
||
.replace(/[\\/]+$/, '')
|
||
.replace(/\//g, '\\')
|
||
.toLowerCase();
|
||
}
|
||
|
||
function parseDetectedDataDir(text) {
|
||
const matches = [...stripAnsi(text).matchAll(/DataDir\s*[::]\s*([A-Za-z]:\\[^\r\n]+)/gi)];
|
||
if (matches.length === 0) return '';
|
||
return matches[matches.length - 1][1].trim();
|
||
}
|
||
|
||
function extractKeyFromOutput(text, label) {
|
||
const regex = new RegExp(`${label}\\s*Key\\s*:\\s*\\[?([a-fA-F0-9]{64})\\]?`, 'gi');
|
||
const matches = [...stripAnsi(text).matchAll(regex)];
|
||
if (matches.length === 0) return '';
|
||
return matches[matches.length - 1][1];
|
||
}
|
||
|
||
function hasRunningServices() {
|
||
return Object.values(processes).some(Boolean);
|
||
}
|
||
|
||
function createWindow() {
|
||
const icon = appIconPath();
|
||
mainWindow = new BrowserWindow({
|
||
width: 1360,
|
||
height: 860,
|
||
minWidth: 1180,
|
||
minHeight: 760,
|
||
...(icon ? { icon } : {}),
|
||
title: 'ChatLab 售后智能助手',
|
||
webPreferences: {
|
||
preload: path.join(__dirname, 'preload.js'),
|
||
nodeIntegration: false,
|
||
contextIsolation: true,
|
||
},
|
||
});
|
||
|
||
mainWindow.loadFile('index.html');
|
||
isInViewMode = false;
|
||
mainWindow.setMenuBarVisibility(false);
|
||
|
||
mainWindow.on('close', async (e) => {
|
||
if (isQuitting) return;
|
||
e.preventDefault();
|
||
|
||
if (isInViewMode) {
|
||
isInViewMode = false;
|
||
try {
|
||
if (mainWindow.isMaximized()) mainWindow.unmaximize();
|
||
mainWindow.setSize(1360, 860);
|
||
mainWindow.center();
|
||
await mainWindow.loadFile('index.html');
|
||
} catch (err) {
|
||
log('App(Error)', `Failed to return to launcher: ${err.message}`);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (hasRunningServices()) {
|
||
send('show-close-warning');
|
||
return;
|
||
}
|
||
|
||
isQuitting = true;
|
||
app.quit();
|
||
});
|
||
}
|
||
|
||
function waitForClose(proc, timeoutMs = 5000) {
|
||
return new Promise((resolve) => {
|
||
if (!proc) return resolve();
|
||
let done = false;
|
||
const timer = setTimeout(() => {
|
||
if (!done) resolve();
|
||
}, timeoutMs);
|
||
proc.once('close', () => {
|
||
done = true;
|
||
clearTimeout(timer);
|
||
resolve();
|
||
});
|
||
});
|
||
}
|
||
|
||
function killProcessTree(proc) {
|
||
if (!proc) return;
|
||
try {
|
||
if (process.platform === 'win32') {
|
||
spawn('taskkill', ['/pid', String(proc.pid), '/f', '/t'], { windowsHide: true });
|
||
} else {
|
||
proc.kill('SIGTERM');
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to kill process tree:', e);
|
||
}
|
||
}
|
||
|
||
async function stopProcess(key) {
|
||
const proc = processes[key];
|
||
if (!proc) return;
|
||
killProcessTree(proc);
|
||
await waitForClose(proc);
|
||
processes[key] = null;
|
||
send('process-stopped', key);
|
||
}
|
||
|
||
async function cleanupProcesses() {
|
||
stopDecryptPolling();
|
||
await Promise.all(Object.keys(processes).map((key) => stopProcess(key)));
|
||
}
|
||
|
||
function getFreePort() {
|
||
return new Promise((resolve, reject) => {
|
||
const server = net.createServer();
|
||
server.listen(0, '127.0.0.1', () => {
|
||
const address = server.address();
|
||
const port = address.port;
|
||
server.close(() => resolve(port));
|
||
});
|
||
server.on('error', reject);
|
||
});
|
||
}
|
||
|
||
function httpJson(url, timeoutMs = 5000, method = 'GET') {
|
||
return new Promise((resolve, reject) => {
|
||
const req = http.request(url, { method, timeout: timeoutMs }, (res) => {
|
||
let body = '';
|
||
res.on('data', (chunk) => { body += chunk; });
|
||
res.on('end', () => {
|
||
try {
|
||
resolve({ statusCode: res.statusCode, data: body ? JSON.parse(body) : {} });
|
||
} catch {
|
||
resolve({ statusCode: res.statusCode, data: body });
|
||
}
|
||
});
|
||
});
|
||
req.on('error', reject);
|
||
req.on('timeout', () => {
|
||
req.destroy(new Error('request timeout'));
|
||
});
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
function postJson(url, payload = {}, timeoutMs = 5000) {
|
||
return new Promise((resolve, reject) => {
|
||
const body = JSON.stringify(payload);
|
||
const req = http.request(url, {
|
||
method: 'POST',
|
||
timeout: timeoutMs,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Content-Length': Buffer.byteLength(body),
|
||
},
|
||
}, (res) => {
|
||
let responseBody = '';
|
||
res.on('data', (chunk) => { responseBody += chunk; });
|
||
res.on('end', () => {
|
||
try {
|
||
resolve({ statusCode: res.statusCode, data: responseBody ? JSON.parse(responseBody) : {} });
|
||
} catch {
|
||
resolve({ statusCode: res.statusCode, data: responseBody });
|
||
}
|
||
});
|
||
});
|
||
req.on('error', reject);
|
||
req.on('timeout', () => {
|
||
req.destroy(new Error('request timeout'));
|
||
});
|
||
req.write(body);
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
function buildUrl(base, params = {}) {
|
||
const url = new URL(base);
|
||
for (const [key, value] of Object.entries(params)) {
|
||
if (value !== undefined && value !== null && value !== '') {
|
||
url.searchParams.set(key, String(value));
|
||
}
|
||
}
|
||
return url.toString();
|
||
}
|
||
|
||
function sleep(ms) {
|
||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
}
|
||
|
||
async function waitForHealth(timeoutMs = 30000) {
|
||
const started = Date.now();
|
||
while (Date.now() - started < timeoutMs) {
|
||
try {
|
||
const res = await httpJson(`${backendUrl}/health`, 3000);
|
||
if (res.statusCode === 200 && res.data && res.data.ok) {
|
||
return res.data;
|
||
}
|
||
} catch {}
|
||
await new Promise((r) => setTimeout(r, 800));
|
||
}
|
||
throw new Error('后端服务启动超时');
|
||
}
|
||
|
||
async function waitForChatlogReady(timeoutMs = 120000) {
|
||
const started = Date.now();
|
||
while (Date.now() - started < timeoutMs) {
|
||
let ready = false;
|
||
try {
|
||
const res = await httpJson('http://127.0.0.1:5030/api/v1/chatroom?format=json&limit=1', 3000);
|
||
const body = typeof res.data === 'string' ? res.data : JSON.stringify(res.data);
|
||
ready = res.statusCode === 200 && !body.includes('decrypting');
|
||
} catch {}
|
||
if (ready) {
|
||
await sleep(1200);
|
||
const validation = await validateChatlogMessageQuery({ repair: true });
|
||
if (
|
||
validation.pending ||
|
||
malformedRepairInProgress ||
|
||
malformedRepairTimer ||
|
||
messageQueryRestartInProgress
|
||
) {
|
||
await sleep(800);
|
||
continue;
|
||
}
|
||
if (!validation.ok) {
|
||
throw new Error(validation.message || '消息索引未就绪,请回到启动界面点击“重新识别当前微信账号”。');
|
||
}
|
||
send('decrypt-ready');
|
||
try {
|
||
if (backendUrl) await httpJson(`${backendUrl}/api/system/refresh-account`, 5000, 'POST');
|
||
} catch {}
|
||
return validation;
|
||
}
|
||
const elapsed = Math.floor((Date.now() - started) / 1000);
|
||
send('decrypt-status', { elapsed });
|
||
await sleep(1500);
|
||
}
|
||
throw new Error('等待微信数据解密超时,请确认 PC 微信已登录,并在微信里点击聊天窗口翻看历史消息。');
|
||
}
|
||
|
||
function responseBodyText(data) {
|
||
return typeof data === 'string' ? data : JSON.stringify(data || {});
|
||
}
|
||
|
||
function isTimeRangeNotFoundResponse(response) {
|
||
return response
|
||
&& response.statusCode === 404
|
||
&& /time range not found/i.test(responseBodyText(response.data));
|
||
}
|
||
|
||
function extractItems(data) {
|
||
if (Array.isArray(data)) return data;
|
||
if (data && Array.isArray(data.items)) return data.items;
|
||
if (data && Array.isArray(data.data)) return data.data;
|
||
return [];
|
||
}
|
||
|
||
function getSessionTalker(item) {
|
||
return String(item.userName || item.UserName || item.name || item.Name || '');
|
||
}
|
||
|
||
function parseSessionTime(item) {
|
||
const value = item.nTime
|
||
|| item.NTime
|
||
|| item.time
|
||
|| item.Time
|
||
|| item.updateTime
|
||
|| item.UpdateTime
|
||
|| item.lastTime
|
||
|| item.LastTime
|
||
|| '';
|
||
if (!value) return 0;
|
||
if (typeof value === 'number' || /^\d+$/.test(String(value))) {
|
||
const n = Number(value);
|
||
if (!Number.isFinite(n) || n <= 0) return 0;
|
||
return n > 1e12 ? n : n * 1000;
|
||
}
|
||
const parsed = Date.parse(String(value).replace(' ', 'T'));
|
||
return Number.isFinite(parsed) ? parsed : 0;
|
||
}
|
||
|
||
function formatDateParam(date) {
|
||
const yyyy = date.getFullYear();
|
||
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
||
const dd = String(date.getDate()).padStart(2, '0');
|
||
return `${yyyy}-${mm}-${dd}`;
|
||
}
|
||
|
||
function buildValidationTimeRange(session) {
|
||
const ts = parseSessionTime(session || {});
|
||
if (!ts) return '1970-01-01,2099-12-31';
|
||
const start = new Date(ts);
|
||
start.setHours(0, 0, 0, 0);
|
||
start.setDate(start.getDate() - 1);
|
||
const end = new Date(ts);
|
||
end.setHours(0, 0, 0, 0);
|
||
end.setDate(end.getDate() + 1);
|
||
return `${formatDateParam(start)},${formatDateParam(end)}`;
|
||
}
|
||
|
||
function buildRecentValidationTimeRange(days) {
|
||
const start = new Date();
|
||
start.setHours(0, 0, 0, 0);
|
||
start.setDate(start.getDate() - days);
|
||
const end = new Date();
|
||
end.setHours(0, 0, 0, 0);
|
||
end.setDate(end.getDate() + 1);
|
||
return `${formatDateParam(start)},${formatDateParam(end)}`;
|
||
}
|
||
|
||
async function waitForChatlogHttpReady(timeoutMs = 120000) {
|
||
const started = Date.now();
|
||
while (Date.now() - started < timeoutMs) {
|
||
try {
|
||
const res = await httpJson('http://127.0.0.1:5030/api/v1/chatroom?format=json&limit=1', 3000);
|
||
const body = responseBodyText(res.data);
|
||
if (res.statusCode === 200 && !body.includes('decrypting')) return true;
|
||
} catch {}
|
||
const elapsed = Math.floor((Date.now() - started) / 1000);
|
||
send('decrypt-status', { elapsed });
|
||
await sleep(1500);
|
||
}
|
||
throw new Error('等待底层服务重建消息索引超时,请确认 PC 微信已登录,并在微信里打开聊天窗口翻看历史消息。');
|
||
}
|
||
|
||
async function checkChatlogMessageQuery() {
|
||
await sleep(1500);
|
||
const sessions = await httpJson(buildUrl('http://127.0.0.1:5030/api/v1/session', {
|
||
limit: 50,
|
||
format: 'json',
|
||
}), 5000);
|
||
if (sessions.statusCode !== 200) {
|
||
return {
|
||
ok: false,
|
||
pending: true,
|
||
message: `chatlog session HTTP ${sessions.statusCode}`,
|
||
};
|
||
}
|
||
const chatrooms = extractItems(sessions.data)
|
||
.filter((item) => getSessionTalker(item).endsWith('@chatroom'))
|
||
.sort((a, b) => parseSessionTime(b) - parseSessionTime(a));
|
||
|
||
if (chatrooms.length === 0) {
|
||
return {
|
||
ok: false,
|
||
pending: true,
|
||
message: '尚未读取到最近群聊会话,等待 chatlog 自动解密消息库。',
|
||
};
|
||
}
|
||
|
||
const ranges = [
|
||
buildRecentValidationTimeRange(7),
|
||
buildRecentValidationTimeRange(30),
|
||
];
|
||
let lastStatus = '';
|
||
for (const session of chatrooms.slice(0, 10)) {
|
||
const talker = getSessionTalker(session);
|
||
if (!talker) continue;
|
||
|
||
for (const time of ranges) {
|
||
const res = await httpJson(buildUrl('http://127.0.0.1:5030/api/v1/chatlog', {
|
||
talker,
|
||
limit: 20,
|
||
time,
|
||
format: 'json',
|
||
}), 5000);
|
||
|
||
if (res.statusCode === 200) {
|
||
return {
|
||
ok: true,
|
||
messageIndexReady: true,
|
||
talker,
|
||
time,
|
||
sampleCount: extractItems(res.data).length,
|
||
chatroomCount: chatrooms.length,
|
||
};
|
||
}
|
||
|
||
lastStatus = `talker=${talker} time=${time} HTTP ${res.statusCode}: ${responseBodyText(res.data)}`;
|
||
if (!isTimeRangeNotFoundResponse(res) && res.statusCode !== 404) {
|
||
return {
|
||
ok: false,
|
||
pending: true,
|
||
message: `消息查询暂未就绪:${lastStatus}`,
|
||
};
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
ok: false,
|
||
pending: true,
|
||
timeRangeNotFound: true,
|
||
message: `最近 ${Math.min(chatrooms.length, 10)} 个群聊消息仍返回 404,等待 chatlog 自动解密完成。${lastStatus}`,
|
||
chatroomCount: chatrooms.length,
|
||
};
|
||
}
|
||
|
||
async function validateChatlogMessageQuery({ repair = true } = {}) {
|
||
if (startupValidationInProgress || messageQueryRestartInProgress) {
|
||
return { ok: false, pending: true };
|
||
}
|
||
startupValidationInProgress = true;
|
||
try {
|
||
const result = await checkChatlogMessageQuery();
|
||
if (result.ok || !result.timeRangeNotFound) return result;
|
||
log(
|
||
'Chatlog(Validate)',
|
||
`${result.message || '消息抽样查询返回 time range not found。'} 会话接口已就绪,本次不再把它当作账号识别失败;如具体聊天记录为空,再在聊天页提示处理。`
|
||
);
|
||
return {
|
||
...result,
|
||
ok: true,
|
||
pending: false,
|
||
messageIndexReady: false,
|
||
message: 'chatlog 会话接口已就绪;部分聊天记录索引暂不可用,进入系统后在聊天页按具体群聊提示处理。',
|
||
};
|
||
} catch (e) {
|
||
log('Chatlog(Validate)', `消息查询自检未完成: ${e.message || e}`);
|
||
return { ok: false, pending: true, error: e.message || String(e) };
|
||
} finally {
|
||
startupValidationInProgress = false;
|
||
}
|
||
}
|
||
|
||
async function repairChatlogMessageIndex(validationResult) {
|
||
const config = activeChatlogConfig ? { ...activeChatlogConfig } : null;
|
||
const fingerprint = lastAccountFingerprint || (config ? fingerprintConfig(config) : '');
|
||
const message = validationResult.message || '消息索引未就绪,chatlog 返回 time range not found。';
|
||
if (!config || !fingerprint) {
|
||
log('Chatlog(Validate)', `${message} 当前账号配置为空,无法自动重建消息缓存。`);
|
||
return {
|
||
...validationResult,
|
||
ok: false,
|
||
message: '消息索引未就绪,且当前账号配置为空。请回到启动界面点击“重新识别当前微信账号”。',
|
||
};
|
||
}
|
||
|
||
if (messageQueryRestartAttempts.has(fingerprint)) {
|
||
const failedMessage = '当前账号消息索引仍未就绪,已自动重建过一次。本次不会进入系统;请在启动界面点击“重新识别当前微信账号”后重试。';
|
||
log('Chatlog(Validate)', failedMessage);
|
||
send('process-error', { key: 'chatlog', message: failedMessage });
|
||
return {
|
||
...validationResult,
|
||
ok: false,
|
||
message: failedMessage,
|
||
removedMessageCache: 0,
|
||
};
|
||
}
|
||
|
||
messageQueryRestartAttempts.add(fingerprint);
|
||
messageQueryRestartInProgress = true;
|
||
let removed = [];
|
||
try {
|
||
log('Chatlog(Validate)', message);
|
||
log('Chatlog(Validate)', '正在清理当前账号生成的消息缓存并重启底层服务;不会删除微信原始数据。');
|
||
await stopProcess('chatlog');
|
||
removed = removeGeneratedMessageCache(config.workDir);
|
||
if (removed.length > 0) {
|
||
log('Chatlog(Repair)', `已清理 ${removed.length} 个当前账号消息缓存目录/文件,接下来会自动重建。`);
|
||
} else {
|
||
log('Chatlog(Repair)', '未找到可清理的当前账号消息缓存,继续重新识别并等待 chatlog 重建索引。');
|
||
}
|
||
send('decrypt-reset');
|
||
send('show-decrypt-dialog');
|
||
await startChatlog({ forceRefresh: false, startPolling: false });
|
||
await waitForChatlogHttpReady(120000);
|
||
const retry = await checkChatlogMessageQuery();
|
||
if (retry.ok) {
|
||
messageQueryRestartAttempts.delete(fingerprint);
|
||
return {
|
||
...retry,
|
||
repaired: true,
|
||
removedMessageCache: removed.length,
|
||
};
|
||
}
|
||
const failedMessage = '消息索引重建后仍返回 time range not found。请在启动界面点击“重新识别当前微信账号”,并确认微信里已打开聊天窗口、能翻看到历史消息。';
|
||
log('Chatlog(Validate)', failedMessage);
|
||
send('process-error', { key: 'chatlog', message: failedMessage });
|
||
return {
|
||
...retry,
|
||
ok: false,
|
||
message: failedMessage,
|
||
removedMessageCache: removed.length,
|
||
};
|
||
} catch (e) {
|
||
const failedMessage = `重建底层消息索引失败: ${e.message || e}`;
|
||
log('Chatlog(Validate)', failedMessage);
|
||
send('process-error', { key: 'chatlog', message: failedMessage });
|
||
return {
|
||
...validationResult,
|
||
ok: false,
|
||
message: failedMessage,
|
||
removedMessageCache: removed.length,
|
||
};
|
||
} finally {
|
||
messageQueryRestartInProgress = false;
|
||
}
|
||
}
|
||
|
||
async function startBackend() {
|
||
if (processes.fastapi) return;
|
||
backendPort = backendPort || await getFreePort();
|
||
backendUrl = `http://127.0.0.1:${backendPort}`;
|
||
|
||
const env = {
|
||
...process.env,
|
||
CHATLAB_DATA_DIR: appDataDir(),
|
||
CHATLAB_STATIC_DIR: frontendDistDir(),
|
||
CHATLAB_BACKEND_PORT: String(backendPort),
|
||
};
|
||
|
||
let command;
|
||
let args;
|
||
let cwd;
|
||
let shell = false;
|
||
|
||
if (isPackaged()) {
|
||
command = backendExePath();
|
||
args = [];
|
||
cwd = path.dirname(command);
|
||
} else {
|
||
command = 'python';
|
||
args = ['run_backend.py'];
|
||
cwd = backendSourceDir();
|
||
shell = true;
|
||
}
|
||
|
||
log('FastAPI', `启动业务后端: ${isPackaged() ? command : `${command} ${args.join(' ')}`} (port=${backendPort})`);
|
||
const proc = spawn(command, args, { cwd, env, shell, windowsHide: true });
|
||
processes.fastapi = proc;
|
||
|
||
proc.stdout.on('data', (data) => log('FastAPI', data.toString()));
|
||
proc.stderr.on('data', (data) => log('FastAPI', data.toString()));
|
||
proc.on('close', (code) => {
|
||
processes.fastapi = null;
|
||
send('process-stopped', 'fastapi');
|
||
log('FastAPI', `进程退出,退出码 ${code}`);
|
||
});
|
||
|
||
send('process-started', 'fastapi');
|
||
await waitForHealth(45000);
|
||
}
|
||
|
||
function getWeChatProcesses() {
|
||
return new Promise((resolve) => {
|
||
if (process.platform !== 'win32') return resolve([]);
|
||
const ps = spawn('powershell.exe', [
|
||
'-NoProfile',
|
||
'-Command',
|
||
"Get-CimInstance Win32_Process | Where-Object { $_.Name -match '^(Weixin|WeChat)\\.exe$' } | Select-Object ProcessId,Name,CommandLine | ConvertTo-Json -Compress",
|
||
], { windowsHide: true });
|
||
let output = '';
|
||
let errorOutput = '';
|
||
ps.stdout.on('data', (d) => { output += d.toString(); });
|
||
ps.stderr.on('data', (d) => { errorOutput += d.toString(); });
|
||
ps.on('close', () => {
|
||
try {
|
||
const parsed = JSON.parse(output || '[]');
|
||
const list = (Array.isArray(parsed) ? parsed : [parsed])
|
||
.map((item) => ({
|
||
pid: Number(item.ProcessId),
|
||
name: String(item.Name || ''),
|
||
commandLine: String(item.CommandLine || ''),
|
||
}))
|
||
.filter((item) => item.pid);
|
||
resolve(list);
|
||
} catch {
|
||
const ids = output.split(/\s+/).map((x) => Number(x)).filter(Boolean);
|
||
resolve(ids.map((pid) => ({ pid, name: '', commandLine: '' })));
|
||
}
|
||
});
|
||
ps.on('error', () => {
|
||
if (errorOutput) log('Chatlog(Init)', `读取微信进程失败: ${errorOutput.trim()}`);
|
||
resolve([]);
|
||
});
|
||
});
|
||
}
|
||
|
||
function selectMainWeChatProcess(processes) {
|
||
const candidates = processes
|
||
.filter((item) => /^(Weixin|WeChat)\.exe$/i.test(item.name))
|
||
.filter((item) => !/\s--type=/i.test(item.commandLine));
|
||
return candidates.find((item) => /\s--scene=desktop\b/i.test(item.commandLine))
|
||
|| candidates.find((item) => /Weixin\.exe/i.test(item.name))
|
||
|| candidates[0]
|
||
|| null;
|
||
}
|
||
|
||
function runChatlogKey(args, label) {
|
||
return new Promise((resolve, reject) => {
|
||
let exePath;
|
||
try {
|
||
exePath = ensureChatlogExe();
|
||
} catch (e) {
|
||
reject(e);
|
||
return;
|
||
}
|
||
const keyProcess = spawn(exePath, args, {
|
||
cwd: projectRoot(),
|
||
windowsHide: true,
|
||
});
|
||
let extractedDataKey = '';
|
||
let extractedImgKey = '';
|
||
let detectedDataDir = '';
|
||
let rawOutput = '';
|
||
|
||
const handleOutput = (data) => {
|
||
const output = data.toString();
|
||
rawOutput += output;
|
||
log('Chatlog(Init)', output);
|
||
extractedDataKey = extractKeyFromOutput(rawOutput, 'Data') || extractedDataKey;
|
||
extractedImgKey = extractKeyFromOutput(rawOutput, 'Image') || extractedImgKey;
|
||
detectedDataDir = parseDetectedDataDir(rawOutput) || detectedDataDir;
|
||
};
|
||
|
||
keyProcess.stdout.on('data', handleOutput);
|
||
keyProcess.stderr.on('data', handleOutput);
|
||
keyProcess.on('error', reject);
|
||
keyProcess.on('close', (code) => {
|
||
const partial = !extractedDataKey && /Data\s*Key\s*:\s*\[\s*\]/i.test(stripAnsi(rawOutput));
|
||
if (code !== 0 && !extractedDataKey && !extractedImgKey && !detectedDataDir) {
|
||
reject(new Error(`${label} 未能识别当前微信账号,请确认微信已登录,必要时用管理员权限启动。退出码 ${code}。${sanitizeLog(rawOutput).slice(-500)}`));
|
||
return;
|
||
}
|
||
resolve({
|
||
dataKey: extractedDataKey,
|
||
imgKey: extractedImgKey,
|
||
detectedDataDir,
|
||
rawOutput,
|
||
partial,
|
||
exitCode: code,
|
||
label,
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
function runChatlogDecrypt(config, dataKey) {
|
||
return new Promise((resolve) => {
|
||
if (!config || !dataKey || !config.workDir || !config.dataDir) {
|
||
resolve(false);
|
||
return;
|
||
}
|
||
const args = ['decrypt', '-k', dataKey];
|
||
args.push('-w', config.workDir);
|
||
args.push('-d', config.dataDir);
|
||
args.push('-p', config.platform || 'windows');
|
||
args.push('-v', String(config.version || 4));
|
||
|
||
log('Chatlog(Init)', '正在同步解密消息与语音媒体库...');
|
||
const proc = spawn(chatlogExePath(), args, { cwd: projectRoot(), windowsHide: true });
|
||
let output = '';
|
||
const handleOutput = (data) => {
|
||
output += data.toString();
|
||
};
|
||
proc.stdout.on('data', handleOutput);
|
||
proc.stderr.on('data', handleOutput);
|
||
proc.on('error', (e) => {
|
||
log('Chatlog(Init)', `同步解密启动失败: ${e.message || e}`);
|
||
resolve(false);
|
||
});
|
||
proc.on('close', (code) => {
|
||
const text = sanitizeLog(output).trim();
|
||
if (code === 0) {
|
||
log('Chatlog(Init)', text || '同步解密完成');
|
||
resolve(true);
|
||
} else {
|
||
log('Chatlog(Init)', `同步解密失败,后续继续启动自动解密服务。退出码 ${code}。${text.slice(-500)}`);
|
||
resolve(false);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
async function runChatlogKeyForce(mainWeChatProcess = null) {
|
||
const attempts = [];
|
||
if (mainWeChatProcess?.pid) {
|
||
attempts.push({
|
||
label: `key --force --pid ${mainWeChatProcess.pid}`,
|
||
args: ['key', '--force', '--pid', String(mainWeChatProcess.pid)],
|
||
});
|
||
}
|
||
attempts.push({ label: 'key --force', args: ['key', '--force'] });
|
||
|
||
let best = null;
|
||
let lastError = null;
|
||
for (const attempt of attempts) {
|
||
log('Chatlog(Init)', `执行 ${attempt.label}`);
|
||
try {
|
||
const result = await runChatlogKey(attempt.args, attempt.label);
|
||
best = mergeKeyResults(best, result);
|
||
if (best.dataKey) return best;
|
||
if (attempts.length > 1) {
|
||
log('Chatlog(Init)', `${attempt.label} 未输出 Data Key,继续尝试备用识别方式。`);
|
||
}
|
||
} catch (e) {
|
||
lastError = e;
|
||
log('Chatlog(Init)', `${attempt.label} 失败: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
if (best) return best;
|
||
throw lastError || new Error('未能识别当前微信账号。');
|
||
}
|
||
|
||
function getChatlogVersionText() {
|
||
return new Promise((resolve) => {
|
||
const proc = spawn(chatlogExePath(), ['version'], { cwd: projectRoot(), windowsHide: true });
|
||
let output = '';
|
||
proc.stdout.on('data', (data) => { output += data.toString(); });
|
||
proc.stderr.on('data', (data) => { output += data.toString(); });
|
||
proc.on('error', () => resolve(''));
|
||
proc.on('close', () => resolve(sanitizeLog(output).trim()));
|
||
});
|
||
}
|
||
|
||
async function syncChatlogContextToBackend(config) {
|
||
if (!backendUrl || !config) return;
|
||
const payload = {
|
||
account: config.account || '',
|
||
workDir: config.workDir || '',
|
||
dataDir: config.dataDir || '',
|
||
platform: config.platform || 'windows',
|
||
version: config.version || 4,
|
||
chatlogExe: chatlogExePath(),
|
||
chatlogVersion: await getChatlogVersionText(),
|
||
};
|
||
try {
|
||
await postJson(`${backendUrl}/api/system/chatlog-context`, payload, 5000);
|
||
} catch (e) {
|
||
log('Chatlog(Init)', `同步 chatlog 诊断上下文失败: ${e.message || e}`);
|
||
}
|
||
}
|
||
|
||
function mergeKeyResults(prev, next) {
|
||
if (!prev) return next;
|
||
return {
|
||
...next,
|
||
dataKey: next.dataKey || prev.dataKey || '',
|
||
imgKey: next.imgKey || prev.imgKey || '',
|
||
detectedDataDir: next.detectedDataDir || prev.detectedDataDir || '',
|
||
rawOutput: `${prev.rawOutput || ''}\n${next.rawOutput || ''}`,
|
||
partial: Boolean(next.partial || prev.partial),
|
||
};
|
||
}
|
||
|
||
function singleMatchOrThrow(matches, matchedBy) {
|
||
if (matches.length <= 1) return matches[0] || null;
|
||
throw new Error(`chatlog 配置中有多个账号匹配同一个 ${matchedBy},为避免打开错误账号,请点击“重新识别当前微信账号”后重试。`);
|
||
}
|
||
|
||
function readCurrentChatlogConfig(hints = {}) {
|
||
const configPath = path.join(os.homedir(), '.chatlog', 'chatlog.json');
|
||
if (!fs.existsSync(configPath)) {
|
||
throw new Error('未找到 chatlog 配置文件,请确认当前微信账号已完成密钥识别');
|
||
}
|
||
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||
const history = Array.isArray(config.history) ? config.history : [];
|
||
const lastAccount = config.last_account || '';
|
||
const detectedDataDir = normalizePathForCompare(hints.detectedDataDir);
|
||
const imgKey = String(hints.imgKey || '').trim().toLowerCase();
|
||
const dataKey = String(hints.dataKey || '').trim().toLowerCase();
|
||
const hasOnlyPartialKey = !dataKey && Boolean(detectedDataDir || imgKey || hints.partial);
|
||
|
||
let matched = null;
|
||
let matchedBy = '';
|
||
|
||
if (detectedDataDir) {
|
||
matched = history.find((item) => normalizePathForCompare(item.data_dir) === detectedDataDir) || null;
|
||
if (matched) matchedBy = 'data_dir';
|
||
}
|
||
|
||
if (!matched && imgKey) {
|
||
const matches = history.filter((item) => String(item.img_key || '').trim().toLowerCase() === imgKey);
|
||
matched = singleMatchOrThrow(matches, 'img_key');
|
||
if (matched) matchedBy = 'img_key';
|
||
}
|
||
|
||
if (!matched && dataKey) {
|
||
const matches = history.filter((item) => String(item.data_key || '').trim().toLowerCase() === dataKey);
|
||
matched = singleMatchOrThrow(matches, 'data_key');
|
||
if (matched) matchedBy = 'data_key';
|
||
}
|
||
|
||
if (!matched) {
|
||
if (detectedDataDir || hasOnlyPartialKey) {
|
||
throw new Error('本次微信识别结果未能匹配 chatlog 配置中的账号。为避免启动旧账号数据,请在微信中打开聊天窗口并点击“重新识别当前微信账号”。');
|
||
}
|
||
matched = history.find((item) => item.account === lastAccount) || history[history.length - 1];
|
||
if (matched) matchedBy = matched.account === lastAccount ? 'last_account' : 'history_tail';
|
||
}
|
||
|
||
if (!matched) {
|
||
throw new Error('chatlog 配置中没有账号历史记录');
|
||
}
|
||
|
||
if (detectedDataDir && ['last_account', 'history_tail'].includes(matchedBy) && normalizePathForCompare(matched.data_dir) !== detectedDataDir) {
|
||
throw new Error('本次微信识别到的数据目录与 chatlog 配置中的账号不一致。为避免显示旧账号记录,请在微信中打开聊天窗口并点击“重新识别当前微信账号”。');
|
||
}
|
||
|
||
return {
|
||
account: matched.account || lastAccount || '',
|
||
workDir: matched.work_dir || '',
|
||
dataDir: matched.data_dir || '',
|
||
platform: matched.platform || 'windows',
|
||
version: matched.version || 4,
|
||
imgKey: matched.img_key || '',
|
||
dataKey: matched.data_key || '',
|
||
matchedBy,
|
||
lastAccount,
|
||
};
|
||
}
|
||
|
||
function hasDecryptedDb(workDir) {
|
||
try {
|
||
if (!workDir || !fs.existsSync(workDir)) return false;
|
||
const root = path.resolve(workDir);
|
||
const stack = [{ dir: root, depth: 0 }];
|
||
let visited = 0;
|
||
while (stack.length > 0 && visited < 2000) {
|
||
const current = stack.pop();
|
||
visited += 1;
|
||
for (const entry of fs.readdirSync(current.dir, { withFileTypes: true })) {
|
||
const fullPath = path.join(current.dir, entry.name);
|
||
if (entry.isFile() && entry.name.toLowerCase().endsWith('.db')) {
|
||
return true;
|
||
}
|
||
if (entry.isDirectory() && current.depth < 5) {
|
||
stack.push({ dir: fullPath, depth: current.depth + 1 });
|
||
}
|
||
}
|
||
}
|
||
return false;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function pathStartsWith(childPath, parentPath) {
|
||
const child = path.resolve(childPath).toLowerCase();
|
||
const parent = path.resolve(parentPath).toLowerCase();
|
||
return child === parent || child.startsWith(`${parent}${path.sep}`);
|
||
}
|
||
|
||
function collectMalformedDbPaths(text) {
|
||
const clean = stripAnsi(text);
|
||
if (!/database disk image is malformed/i.test(clean)) return [];
|
||
return [...clean.matchAll(/([A-Za-z]:\\[^\r\n"]+?\.db)(?=[\s"']|$|[^\r\n]*database disk image is malformed)/gi)]
|
||
.map((match) => match[1].trim())
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function removeGeneratedDbFiles(dbPaths, workDir) {
|
||
const removed = [];
|
||
const generatedDirs = new Set();
|
||
const candidates = new Set();
|
||
const normalizedWorkDir = normalizePathForCompare(workDir);
|
||
const rawWeChatMarkers = ['\\xwechat_files\\', '\\wechat files\\'];
|
||
|
||
for (const dbPath of dbPaths) {
|
||
if (!dbPath || !workDir || !pathStartsWith(dbPath, workDir)) continue;
|
||
const normalizedDbPath = normalizePathForCompare(dbPath);
|
||
if (rawWeChatMarkers.some((marker) => normalizedDbPath.includes(marker))) continue;
|
||
if (rawWeChatMarkers.some((marker) => normalizedWorkDir.includes(marker))) continue;
|
||
const parentDir = path.dirname(dbPath);
|
||
if (normalizePathForCompare(parentDir).endsWith('\\db_storage\\message')) {
|
||
generatedDirs.add(parentDir);
|
||
}
|
||
candidates.add(dbPath);
|
||
candidates.add(`${dbPath}-wal`);
|
||
candidates.add(`${dbPath}-shm`);
|
||
}
|
||
|
||
for (const dirPath of generatedDirs) {
|
||
try {
|
||
if (!pathStartsWith(dirPath, workDir)) continue;
|
||
if (fs.existsSync(dirPath)) {
|
||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||
removed.push(dirPath);
|
||
}
|
||
} catch (e) {
|
||
log('Chatlog(Repair)', `清理损坏解密缓存目录失败: ${dirPath} (${e.message})`);
|
||
}
|
||
}
|
||
|
||
for (const filePath of candidates) {
|
||
try {
|
||
if (!pathStartsWith(filePath, workDir)) continue;
|
||
if (fs.existsSync(filePath)) {
|
||
fs.rmSync(filePath, { force: true });
|
||
removed.push(filePath);
|
||
}
|
||
} catch (e) {
|
||
log('Chatlog(Repair)', `清理损坏解密缓存失败: ${filePath} (${e.message})`);
|
||
}
|
||
}
|
||
|
||
return removed;
|
||
}
|
||
|
||
function removeGeneratedMessageCache(workDir) {
|
||
if (!workDir) return [];
|
||
const normalizedWorkDir = normalizePathForCompare(workDir);
|
||
const rawWeChatMarkers = ['\\xwechat_files\\', '\\wechat files\\'];
|
||
if (rawWeChatMarkers.some((marker) => normalizedWorkDir.includes(marker))) return [];
|
||
|
||
const messageDir = path.join(workDir, 'db_storage', 'message');
|
||
try {
|
||
if (!pathStartsWith(messageDir, workDir) || !fs.existsSync(messageDir)) return [];
|
||
fs.rmSync(messageDir, { recursive: true, force: true });
|
||
return [messageDir];
|
||
} catch (e) {
|
||
log('Chatlog(Repair)', `清理解密消息缓存目录失败: ${messageDir} (${e.message})`);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function scheduleMalformedCacheRepair(text) {
|
||
const paths = collectMalformedDbPaths(text);
|
||
if (paths.length === 0) return;
|
||
for (const dbPath of paths) pendingMalformedDbPaths.add(dbPath);
|
||
if (malformedRepairTimer || malformedRepairInProgress) return;
|
||
malformedRepairTimer = setTimeout(() => {
|
||
malformedRepairTimer = null;
|
||
repairMalformedCacheAndRestart().catch((e) => {
|
||
malformedRepairInProgress = false;
|
||
log('Chatlog(Repair)', e.message || String(e));
|
||
});
|
||
}, 800);
|
||
}
|
||
|
||
async function repairMalformedCacheAndRestart() {
|
||
if (malformedRepairInProgress) return;
|
||
const config = activeChatlogConfig ? { ...activeChatlogConfig } : null;
|
||
const fingerprint = lastAccountFingerprint || (config ? fingerprintConfig(config) : '');
|
||
const dbPaths = [...pendingMalformedDbPaths];
|
||
pendingMalformedDbPaths.clear();
|
||
|
||
if (!config || !config.workDir || dbPaths.length === 0) return;
|
||
|
||
if (malformedRepairAttempts.has(fingerprint)) {
|
||
if (!malformedRepairNotified.has(fingerprint)) {
|
||
malformedRepairNotified.add(fingerprint);
|
||
log('Chatlog(Repair)', '当前账号解密缓存再次损坏,已自动修复过一次。请在微信打开聊天窗口并翻看历史消息后,点击“重新识别当前微信账号”;必要时用管理员权限启动。');
|
||
send('show-decrypt-dialog');
|
||
}
|
||
return;
|
||
}
|
||
|
||
malformedRepairInProgress = true;
|
||
malformedRepairAttempts.add(fingerprint);
|
||
|
||
try {
|
||
log('Chatlog(Repair)', '检测到当前账号的解密缓存库损坏,正在自动清理本账号生成缓存并重新解密。');
|
||
await stopProcess('chatlog');
|
||
const removed = removeGeneratedDbFiles(dbPaths, config.workDir);
|
||
if (removed.length > 0) {
|
||
log('Chatlog(Repair)', `已清理 ${removed.length} 个损坏缓存文件;不会删除微信原始数据。`);
|
||
} else {
|
||
log('Chatlog(Repair)', '未找到可安全清理的缓存文件,已停止避免误删微信原始数据。');
|
||
}
|
||
send('decrypt-reset');
|
||
send('show-decrypt-dialog');
|
||
await startChatlog({ forceRefresh: true });
|
||
try {
|
||
await httpJson(`${backendUrl}/api/system/refresh-account`, 5000, 'POST');
|
||
} catch {}
|
||
} finally {
|
||
malformedRepairInProgress = false;
|
||
}
|
||
}
|
||
|
||
function fingerprintConfig(config) {
|
||
return `${config.account || ''}|${config.workDir || ''}|${config.dataDir || ''}`;
|
||
}
|
||
|
||
function startDecryptPolling() {
|
||
if (decryptPollTimer) clearInterval(decryptPollTimer);
|
||
const startTime = Date.now();
|
||
let validationTriggered = false;
|
||
let tickInProgress = false;
|
||
|
||
decryptPollTimer = setInterval(async () => {
|
||
if (tickInProgress) return;
|
||
tickInProgress = true;
|
||
try {
|
||
const res = await httpJson('http://127.0.0.1:5030/api/v1/chatroom?format=json&limit=1', 3000);
|
||
const body = typeof res.data === 'string' ? res.data : JSON.stringify(res.data);
|
||
if (res.statusCode === 200 && !body.includes('decrypting')) {
|
||
await new Promise((r) => setTimeout(r, 1200));
|
||
let validation = { ok: true };
|
||
if (!validationTriggered) {
|
||
validationTriggered = true;
|
||
validation = await validateChatlogMessageQuery({ repair: true });
|
||
}
|
||
if (validation.pending) {
|
||
validationTriggered = false;
|
||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||
send('decrypt-status', { elapsed });
|
||
return;
|
||
}
|
||
if (malformedRepairInProgress || malformedRepairTimer || messageQueryRestartInProgress) {
|
||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||
send('decrypt-status', { elapsed });
|
||
return;
|
||
}
|
||
if (!validation.ok) {
|
||
stopDecryptPolling();
|
||
send('process-error', {
|
||
key: 'chatlog',
|
||
message: validation.message || '消息索引未就绪,请点击“重新识别当前微信账号”后重试。',
|
||
});
|
||
return;
|
||
}
|
||
stopDecryptPolling();
|
||
send('decrypt-ready');
|
||
try {
|
||
if (backendUrl) await httpJson(`${backendUrl}/api/system/refresh-account`, 5000, 'POST');
|
||
} catch {}
|
||
return;
|
||
}
|
||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||
send('decrypt-status', { elapsed });
|
||
} catch {
|
||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||
send('decrypt-status', { elapsed });
|
||
} finally {
|
||
tickInProgress = false;
|
||
}
|
||
}, 2000);
|
||
}
|
||
|
||
function stopDecryptPolling() {
|
||
if (decryptPollTimer) {
|
||
clearInterval(decryptPollTimer);
|
||
decryptPollTimer = null;
|
||
}
|
||
}
|
||
|
||
async function startChatlog({ forceRefresh = false, startPolling = true } = {}) {
|
||
if (processes.chatlog) return;
|
||
|
||
const wechatPids = await getWeChatProcesses();
|
||
if (wechatPids.length === 0) {
|
||
throw new Error('未检测到 PC 微信进程,请先登录 PC 微信后再启动。');
|
||
}
|
||
|
||
let keys = {};
|
||
if (forceRefresh) {
|
||
const mainWeChatProcess = selectMainWeChatProcess(wechatPids);
|
||
if (mainWeChatProcess) {
|
||
log('Chatlog(Init)', `已选择微信主进程用于 Data Key 识别: ${mainWeChatProcess.name || 'WeChat'} pid=${mainWeChatProcess.pid}`);
|
||
} else {
|
||
log('Chatlog(Init)', '未能确认微信主进程,将使用 chatlog 默认识别方式。');
|
||
}
|
||
log('Chatlog(Init)', '正在强制重新识别当前微信账号...');
|
||
keys = await runChatlogKeyForce(mainWeChatProcess);
|
||
} else {
|
||
log('Chatlog(Init)', '使用当前账号配置启动自动解密服务;如需切换账号,请点击“重新识别当前微信账号”。');
|
||
}
|
||
|
||
const config = readCurrentChatlogConfig(keys);
|
||
const effectiveDataKey = keys.dataKey || config.dataKey || '';
|
||
const effectiveImgKey = keys.imgKey || config.imgKey || '';
|
||
activeChatlogConfig = config;
|
||
await syncChatlogContextToBackend(config);
|
||
|
||
if (!effectiveDataKey) {
|
||
send('show-decrypt-dialog');
|
||
throw new Error('未能获取当前微信账号 Data Key,且 chatlog 配置中没有同账号缓存密钥。请确认微信已登录,打开任意聊天窗口并向上翻看历史消息,必要时用管理员权限启动后点击“重新识别当前微信账号”。');
|
||
}
|
||
|
||
await runChatlogDecrypt(config, effectiveDataKey);
|
||
|
||
if (!keys.dataKey && config.dataKey) {
|
||
log('Chatlog(Init)', `本次 key --force 未输出 Data Key,已使用当前账号配置缓存的 Data Key 启动自动解密服务(匹配方式: ${config.matchedBy})。后续消息库解密交给 chatlog server --auto-decrypt 持续处理。`);
|
||
}
|
||
|
||
if (keys.detectedDataDir && config.matchedBy === 'data_dir') {
|
||
log('Chatlog(Init)', '已按本次识别到的微信数据目录匹配当前账号。');
|
||
} else if (config.matchedBy) {
|
||
log('Chatlog(Init)', `已匹配当前账号配置(匹配方式: ${config.matchedBy})。`);
|
||
}
|
||
|
||
lastAccountFingerprint = fingerprintConfig(config);
|
||
|
||
if (!hasDecryptedDb(config.workDir)) {
|
||
log('Chatlog(Init)', '检测到当前账号尚未完成解密,将显示首次解密引导。');
|
||
send('show-decrypt-dialog');
|
||
} else {
|
||
log('Chatlog(Init)', `当前账号已识别,工作目录已就绪: ${config.account || '(unknown account)'}`);
|
||
}
|
||
|
||
const args = ['server', '--auto-decrypt', '-k', effectiveDataKey];
|
||
if (config.workDir) args.push('-w', config.workDir);
|
||
if (config.dataDir) args.push('-d', config.dataDir);
|
||
args.push('-p', config.platform || 'windows');
|
||
args.push('-v', String(config.version || 4));
|
||
if (effectiveImgKey) args.push('-i', effectiveImgKey);
|
||
log('Chatlog(Init)', `使用已匹配账号参数启动底层服务(匹配方式: ${config.matchedBy || 'unknown'})。`);
|
||
|
||
log('Chatlog(Init)', `启动底层服务,账号指纹: ${maskFingerprint(lastAccountFingerprint)}`);
|
||
const proc = spawn(chatlogExePath(), args, { cwd: projectRoot(), windowsHide: true });
|
||
processes.chatlog = proc;
|
||
|
||
const handleServerOutput = (data) => {
|
||
const text = data.toString();
|
||
log('Chatlog(Go)', text);
|
||
scheduleMalformedCacheRepair(text);
|
||
};
|
||
|
||
proc.stdout.on('data', handleServerOutput);
|
||
proc.stderr.on('data', handleServerOutput);
|
||
proc.on('close', (code) => {
|
||
processes.chatlog = null;
|
||
stopDecryptPolling();
|
||
send('process-stopped', 'chatlog');
|
||
if (!malformedRepairInProgress) send('decrypt-reset');
|
||
log('Chatlog(Go)', `进程退出,退出码 ${code}`);
|
||
});
|
||
|
||
send('process-started', 'chatlog');
|
||
if (startPolling) startDecryptPolling();
|
||
}
|
||
|
||
function maskFingerprint(value) {
|
||
if (!value) return '';
|
||
return value.length <= 16 ? value : `${value.slice(0, 8)}...${value.slice(-8)}`;
|
||
}
|
||
|
||
async function openAppWindow() {
|
||
await startBackend();
|
||
if (!processes.chatlog) {
|
||
await startChatlog({ forceRefresh: false, startPolling: false });
|
||
}
|
||
await waitForChatlogReady();
|
||
await waitForHealth(30000);
|
||
if (!mainWindow.isMaximized()) mainWindow.maximize();
|
||
mainWindow.loadURL(backendUrl);
|
||
isInViewMode = true;
|
||
}
|
||
|
||
async function refreshCurrentAccount() {
|
||
const fingerprint = lastAccountFingerprint || (activeChatlogConfig ? fingerprintConfig(activeChatlogConfig) : '');
|
||
let removed = [];
|
||
|
||
await stopProcess('chatlog');
|
||
|
||
if (fingerprint) {
|
||
messageQueryRestartAttempts.delete(fingerprint);
|
||
malformedRepairAttempts.delete(fingerprint);
|
||
malformedRepairNotified.delete(fingerprint);
|
||
}
|
||
if (activeChatlogConfig?.workDir) {
|
||
removed = removeGeneratedMessageCache(activeChatlogConfig.workDir);
|
||
if (removed.length > 0) {
|
||
log('Chatlog(Repair)', '已清理当前账号的消息解密缓存,接下来会重新识别并自动重建消息索引。');
|
||
}
|
||
}
|
||
send('decrypt-reset');
|
||
await startChatlog({ forceRefresh: true, startPolling: false });
|
||
const validation = await waitForChatlogReady(120000);
|
||
try {
|
||
if (backendUrl) await httpJson(`${backendUrl}/api/system/refresh-account`, 5000, 'POST');
|
||
} catch {}
|
||
return {
|
||
ok: true,
|
||
fingerprint: lastAccountFingerprint,
|
||
messageIndexReady: Boolean(validation?.ok),
|
||
removedMessageCache: removed.length + (validation?.removedMessageCache || 0),
|
||
};
|
||
}
|
||
|
||
app.whenReady().then(() => {
|
||
createWindow();
|
||
app.on('activate', () => {
|
||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||
});
|
||
});
|
||
|
||
app.on('before-quit', () => {
|
||
cleanupProcesses();
|
||
});
|
||
|
||
app.on('window-all-closed', () => {
|
||
if (process.platform !== 'darwin') app.quit();
|
||
});
|
||
|
||
ipcMain.handle('start-all', async () => {
|
||
await openAppWindow();
|
||
return { ok: true, backendUrl };
|
||
});
|
||
|
||
ipcMain.on('start-chatlog', async (event) => {
|
||
try {
|
||
await startChatlog({ forceRefresh: false });
|
||
} catch (e) {
|
||
log('Chatlog(Error)', e.message);
|
||
event.sender.send('process-error', { key: 'chatlog', message: e.message });
|
||
}
|
||
});
|
||
|
||
ipcMain.on('start-fastapi', async (event) => {
|
||
try {
|
||
await startBackend();
|
||
} catch (e) {
|
||
log('FastAPI(Error)', e.message);
|
||
event.sender.send('process-error', { key: 'fastapi', message: e.message });
|
||
}
|
||
});
|
||
|
||
ipcMain.on('start-frontend', async (event) => {
|
||
try {
|
||
await openAppWindow();
|
||
} catch (e) {
|
||
log('App(Error)', e.message);
|
||
event.sender.send('process-error', { key: 'frontend', message: e.message });
|
||
}
|
||
});
|
||
|
||
ipcMain.on('stop-chatlog', () => stopProcess('chatlog'));
|
||
ipcMain.on('stop-fastapi', () => stopProcess('fastapi'));
|
||
ipcMain.on('stop-frontend', () => {});
|
||
|
||
ipcMain.handle('refresh-current-account', async () => refreshCurrentAccount());
|
||
|
||
ipcMain.handle('get-process-status', () => ({
|
||
chatlog: processes.chatlog !== null,
|
||
fastapi: processes.fastapi !== null,
|
||
frontend: isInViewMode,
|
||
backendUrl,
|
||
accountFingerprint: lastAccountFingerprint,
|
||
}));
|
||
|
||
ipcMain.on('open-in-app', async (event) => {
|
||
try {
|
||
await openAppWindow();
|
||
} catch (e) {
|
||
log('App(Error)', e.message);
|
||
event.sender.send('process-error', { key: 'app', message: e.message });
|
||
}
|
||
});
|