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 }); } });