feat: 本地 HTTP API + 设置面板 + 账号自助入口

让 WechatExplorer 既能用图形界面浏览聊天记录,也能作为本机 MCP 数据源被
Claude / Codex 等客户端通过 127.0.0.1:6131 直接拉取。

本机 HTTP API
  - 新增 http-server: 8 个端点,覆盖 health / current_time / contact /
    chatroom / recent_chat / chatlog / group_snapshot / resolve / report
  - 时间参数支持 YYYY-MM-DD / YYYY-MM-DD/HH:mm / Unix 秒级;日期单独使用
    时自动补到 00:00:00~23:59:59,避免漏消息
  - apiServer 单例支持动态启停,启动失败返回 friendlyMessage(把
    EADDRINUSE 翻译成"端口已被占用"的中文错误并附 4 次重试)

数据库根目录与自服务入口
  - Wcdb4Client 接受 accountRoot 时会自动解析:父目录下找最新含
    db_storage 的 wxid 子目录;设置面板"测试连接"成功后回写解析后的
    精确路径
  - 抽 chat-service.ts:IPC 和 HTTP 共享 listContacts / listMessages /
    getGroupSnapshot / searchMessages / getSelfAccountInfo / testConnection
    / reopenWithRoot
  - WechatDb 接受可选 accountRoot;设置面板新增"应用并重新初始化"
    按钮,改完 dbRoot 立即生效
  - 新增 settings-store.ts,dbRoot / apiEnabled / apiHost / apiPort 落到
    userData/settings.json

主进程健壮性
  - 新增 safe-log.ts 包一层 console.log/warn/error,electron-vite 关闭
    子进程 stderr 后写 EPIPE 不再炸 IPC handler(原 main build 启动时
    即 installSafeConsole)
This commit is contained in:
电摇小子
2026-07-07 13:58:10 +08:00
parent 7f7d673abb
commit 7932ea2f54
14 changed files with 1942 additions and 258 deletions
+382
View File
@@ -0,0 +1,382 @@
import http, { IncomingMessage, ServerResponse, Server } from 'http'
import {
isReady,
listContacts,
listMessages,
getGroupSnapshot,
listRecentChat,
resolveMd5
} from './services/chat-service'
import { exportGroupReport } from './group-report-service'
import { GroupReportExportRequest } from '../shared/group-report'
import { safeError, safeLog, safeWarn } from './safe-log'
export const DEFAULT_HTTP_HOST = '127.0.0.1'
export const DEFAULT_HTTP_PORT = 6131
export interface HttpServerHandle {
host: string
port: number
close(): Promise<void>
}
interface RouteContext {
req: IncomingMessage
res: ServerResponse
url: URL
body?: unknown
}
type RouteHandler = (ctx: RouteContext) => void | Promise<void>
function sendJson(res: ServerResponse, status: number, payload: unknown): void {
const body = JSON.stringify(payload, null, 2)
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(body),
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-store'
})
res.end(body)
}
function sendError(res: ServerResponse, status: number, message: string, extra?: unknown): void {
sendJson(res, status, { error: message, status, ...(extra ? { details: extra } : {}) })
}
function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = []
req.on('data', (chunk: Buffer) => chunks.push(chunk))
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')))
req.on('error', reject)
})
}
function rangeToSec(input: string, endOfUnit = false): number | null {
const m = input.match(/^(\d{4})-(\d{2})-(\d{2})(?:\/(\d{2}):(\d{2}))?$/)
if (!m) return null
const [, y, mo, d, hStr, miStr] = m
const hasTime = hStr !== undefined
let hh: number, mi: number, ss: number, ms: number
if (hasTime) {
hh = Number(hStr)
mi = Number(miStr)
ss = endOfUnit ? 59 : 0
ms = endOfUnit ? 999 : 0
} else if (endOfUnit) {
hh = 23
mi = 59
ss = 59
ms = 999
} else {
hh = 0
mi = 0
ss = 0
ms = 0
}
const date = new Date(Number(y), Number(mo) - 1, Number(d), hh, mi, ss, ms)
return Math.floor(date.getTime() / 1000)
}
function parseTimeRange(value: string | null): { startTime?: number; endTime?: number } {
if (!value) return {}
const trimmed = value.trim()
if (!trimmed) return {}
if (/^\d{10,13}$/.test(trimmed)) {
const n = Number(trimmed)
if (!Number.isFinite(n)) return {}
return { startTime: n > 1e12 ? Math.floor(n / 1000) : Math.floor(n) }
}
if (trimmed.includes('~')) {
const [a, b] = trimmed.split('~').map((s) => s.trim())
const start = rangeToSec(a, false)
const end = rangeToSec(b, true)
return {
startTime: start ?? undefined,
endTime: end ?? undefined
}
}
const start = rangeToSec(trimmed, false)
const end = rangeToSec(trimmed, true)
return {
startTime: start ?? undefined,
endTime: end ?? undefined
}
}
function parseNumeric(value: string | null, fallback: number): number {
if (!value) return fallback
const n = Number(value)
return Number.isFinite(n) ? n : fallback
}
const routes: Record<string, RouteHandler> = {
'/api/v1/health': ({ res }) => {
sendJson(res, 200, {
ok: true,
ready: isReady(),
service: 'WechatExplorer Reader',
version: '1.0.0',
timestamp: new Date().toISOString()
})
},
'/api/v1/current_time': ({ res }) => {
const now = new Date()
sendJson(res, 200, {
time: now.toISOString(),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
timestamp: Math.floor(now.getTime() / 1000),
localDate: `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(
now.getDate()
).padStart(2, '0')}`
})
},
'/api/v1/contact': ({ res, url }) => {
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
const filter = url.searchParams.get('filter') || undefined
const type = url.searchParams.get('type') || undefined
let contacts = listContacts(filter)
if (type === 'user' || type === 'group') {
contacts = contacts.filter((c) => c.type === type)
}
sendJson(res, 200, { count: contacts.length, contacts })
},
'/api/v1/chatroom': ({ res, url }) => {
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
const keyword = url.searchParams.get('keyword') || ''
let groups = listContacts().filter((c) => c.type === 'group')
if (keyword) {
const lower = keyword.toLowerCase()
groups = groups.filter(
(c) => c.m_nsNickName.toLowerCase().includes(lower) || c.m_nsUsrName.toLowerCase().includes(lower)
)
}
sendJson(res, 200, { count: groups.length, chatrooms: groups })
},
'/api/v1/recent_chat': ({ res, url }) => {
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
const limit = parseNumeric(url.searchParams.get('limit'), 50)
const items = listRecentChat(limit)
sendJson(res, 200, { count: items.length, items })
},
'/api/v1/chatlog': ({ res, url }) => {
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
const talker = url.searchParams.get('talker')
if (!talker) return sendError(res, 400, '缺少必要参数 talker')
const resolved = resolveMd5(talker)
if (!resolved) return sendError(res, 404, `未找到会话: ${talker}`)
const timeParam = url.searchParams.get('time')
const startParam = url.searchParams.get('startTime')
const endParam = url.searchParams.get('endTime')
let startTime: number | undefined
let endTime: number | undefined
if (timeParam) {
const range = parseTimeRange(timeParam)
startTime = range.startTime
endTime = range.endTime
} else {
if (startParam) {
const r = parseTimeRange(startParam)
startTime = r.startTime
}
if (endParam) {
const r = parseTimeRange(endParam)
endTime = r.endTime
}
}
const messages = listMessages(resolved.md5, startTime, endTime)
sendJson(res, 200, {
contact: resolved,
query: { talker, time: timeParam, startTime, endTime },
count: messages.length,
messages
})
},
'/api/v1/group_snapshot': ({ res, url }) => {
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
const md5 = url.searchParams.get('md5')
if (!md5) return sendError(res, 400, '缺少必要参数 md5')
const snapshot = getGroupSnapshot(md5)
if (!snapshot) return sendError(res, 404, `未找到群聊: ${md5}`)
sendJson(res, 200, snapshot)
},
'/api/v1/resolve': ({ res, url }) => {
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
const q = url.searchParams.get('q')
if (!q) return sendError(res, 400, '缺少必要参数 q')
const contact = resolveMd5(q)
if (!contact) return sendError(res, 404, `未匹配到联系人: ${q}`)
sendJson(res, 200, contact)
},
'/api/v1/report': async ({ req, res, body }) => {
if (req.method !== 'POST') return sendError(res, 405, '需要 POST 请求')
if (!isReady()) return sendError(res, 503, 'WechatExplorer 数据库未初始化')
if (typeof body !== 'string' || !body.trim()) {
return sendError(res, 400, '请求体为空,需 POST GroupReportExportRequest JSON')
}
let request: GroupReportExportRequest
try {
request = JSON.parse(body) as GroupReportExportRequest
} catch (error) {
return sendError(res, 400, '请求体 JSON 解析失败', error instanceof Error ? error.message : String(error))
}
if (!request?.report || !request?.metadata) {
return sendError(res, 400, '请求体需包含 report 和 metadata 字段')
}
const result = await exportGroupReport(request)
sendJson(res, result.success ? 200 : 500, result)
}
}
export function startHttpServer(
host: string = DEFAULT_HTTP_HOST,
port: number = DEFAULT_HTTP_PORT
): Promise<HttpServerHandle> {
return new Promise((resolve, reject) => {
const server: Server = http.createServer(async (req, res) => {
try {
const url = new URL(req.url || '/', `http://${host}:${port}`)
if (req.method === 'OPTIONS') {
res.writeHead(204, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': '*'
})
return res.end()
}
const handler = routes[url.pathname]
if (!handler) {
return sendError(res, 404, `端点不存在: ${url.pathname}`)
}
let body: string | undefined
if (req.method && req.method !== 'GET' && req.method !== 'HEAD') {
body = await readBody(req)
}
const ctx: RouteContext = { req, res, url, body }
await handler(ctx)
} catch (error) {
safeError('[HttpServer] 请求处理失败:', error)
if (!res.headersSent) {
sendError(res, 500, error instanceof Error ? error.message : String(error))
}
}
})
server.once('error', (error: NodeJS.ErrnoException) => {
const message =
error.code === 'EADDRINUSE'
? `端口 ${port} 已被占用,请关闭占用进程或在设置中更换端口`
: error.message
reject(Object.assign(error, { friendlyMessage: message }))
})
server.listen(port, host, () => {
server.off('error', () => undefined)
const actualPort = (server.address() as { port: number } | null)?.port ?? port
safeLog(`[HttpServer] Listening on http://${host}:${actualPort}`)
resolve({
host,
port: actualPort,
close: () =>
new Promise<void>((res) => {
server.close(() => res())
})
})
})
})
}
export interface ApiServerState {
running: boolean
host: string
port: number
error?: string
}
let singleton: HttpServerHandle | null = null
let singletonState: ApiServerState = { running: false, host: DEFAULT_HTTP_HOST, port: DEFAULT_HTTP_PORT }
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
export const apiServer = {
isRunning(): boolean {
return singleton !== null
},
getState(): ApiServerState {
return { ...singletonState }
},
async start(host: string = DEFAULT_HTTP_HOST, port: number = DEFAULT_HTTP_PORT): Promise<ApiServerState> {
if (singleton) {
return this.getState()
}
const maxAttempts = 4
let lastError: (NodeJS.ErrnoException & { friendlyMessage?: string }) | null = null
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
singleton = await startHttpServer(host, port)
singletonState = {
running: true,
host: singleton.host,
port: singleton.port
}
safeLog(`[ApiServer] started on http://${singleton.host}:${singleton.port}`)
return { ...singletonState }
} catch (error) {
lastError = error as NodeJS.ErrnoException & { friendlyMessage?: string }
if ((error as NodeJS.ErrnoException).code !== 'EADDRINUSE' || attempt === maxAttempts) break
// Brief wait to let the OS release the port (TIME_WAIT / concurrent dev session).
await sleep(400 * attempt)
}
}
const message =
lastError?.friendlyMessage ||
(lastError instanceof Error ? lastError.message : String(lastError)) ||
'API 启动失败'
singletonState = {
running: false,
host,
port,
error: message
}
safeError('[ApiServer] start failed:', message)
return { ...singletonState }
},
async stop(): Promise<ApiServerState> {
if (!singleton) {
return this.getState()
}
try {
await singleton.close()
} catch (error) {
safeWarn('[ApiServer] close failed:', error)
}
singleton = null
singletonState = { ...singletonState, running: false }
safeLog('[ApiServer] stopped')
return { ...singletonState }
}
}
+151 -244
View File
@@ -1,61 +1,47 @@
import { app, shell, BrowserWindow, ipcMain, nativeImage, clipboard } from 'electron'
import { app, shell, BrowserWindow, ipcMain, nativeImage, clipboard, Menu, Tray } from 'electron'
import { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'
import { WechatDb, Contact, WechatMessage } from './wechat-db'
import { WechatDb } from './wechat-db'
import { VoiceService } from './voice-service'
import { StickerService } from './sticker-service'
import {
parseImageDatNameFromRow,
parseMessageContent,
parseStickerMessageFromRow
} from './message-parser'
import { parseMessageContent } from './message-parser'
import { ImageDecryptService } from './image-decrypt-service'
import { exportGroupReport } from './group-report-service'
import { GroupReportExportRequest } from '../shared/group-report'
import { DatabaseKeyStore } from './database-key-store'
import { KeyServiceMac } from './key-service-mac'
import * as chat from './services/chat-service'
import {
apiServer
} from './http-server'
import {
loadSettings,
saveSettings,
getSettingsPath,
AppSettings
} from './services/settings-store'
import { installSafeConsole } from './safe-log'
// electron-vite can close the child's stdout/stderr after spawning Electron.
// Plain console.error then throws EPIPE on a closed pipe and crashes the IPC
// handler. Wrap console.* before any other module logs anything.
installSafeConsole()
let wechatDb: WechatDb | null = null
let voiceService: VoiceService | null = null
let imageDecryptService: ImageDecryptService | null = null
let stickerService: StickerService | null = null
const databaseKeyStore = new DatabaseKeyStore()
const keyServiceMac = new KeyServiceMac()
const BUILD_MARK = 'wechat4-open-account-continues-after-init-1000'
let tray: Tray | null = null
const BUILD_MARK = 'wechat4-local-http-api-2026-07-03'
const TRAY_MODE =
process.argv.includes('--tray') || (process.env['WXE_TRAY'] || '').toString() === '1'
// WechatExplorer's WCDB native library runs InitProtection before wcdb_init.
// In dev, matching the host app name avoids failing the native protection gate.
app.setName('WechatExplorer')
const MSG_TYPE_DICT: Record<number, string> = {
1: '普通文本',
3: '图片',
34: '语音',
42: '名片',
43: '视频',
47: '表情包',
48: '位置',
49: '分享消息',
50: '通话',
10000: '系统消息'
}
function normalizeMsgType(value: string | number | undefined): number {
const raw = String(value ?? '').trim()
if (!raw) return 0
try {
const parsed = BigInt(raw)
const low32 = Number(parsed & 0xffffffffn)
return low32 || Number(parsed)
} catch {
const parsed = Number(raw)
if (!Number.isFinite(parsed)) return 0
return parsed > 0xffffffff ? parsed >>> 0 : parsed
}
}
function createWindow(): void {
// 创建浏览器窗口
const mainWindow = new BrowserWindow({
@@ -90,7 +76,7 @@ function createWindow(): void {
// 当 Electron 完成初始化并准备好创建浏览器窗口时,将调用此方法
// 某些 API 只能在此事件发生后使用
app.whenReady().then(() => {
app.whenReady().then(async () => {
console.log(`WechatExplorer main build: ${BUILD_MARK}`)
// 为窗口设置应用程序用户模型 ID
electronApp.setAppUserModelId('com.electron')
@@ -110,8 +96,7 @@ app.whenReady().then(() => {
const trimmedKey = String(key || '').trim()
console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`)
const nextWechatDb = new WechatDb(key)
wechatDb?.close()
wechatDb = nextWechatDb
chat.setChatDb(nextWechatDb)
const wcdb4Client = nextWechatDb.getWcdb4Client()
voiceService = new VoiceService(wcdb4Client)
stickerService = new StickerService(wcdb4Client)
@@ -151,208 +136,15 @@ app.whenReady().then(() => {
}
})
ipcMain.handle('db:getContacts', (_, filter?: string) => {
if (!wechatDb) return []
ipcMain.handle('db:getContacts', (_, filter?: string) => chat.listContacts(filter))
const contacts: Contact[] = []
const groupContacts = wechatDb.getAllGroupContacts()
const userList = wechatDb.getUserList(filter)
const existingMd5s = new Set<string>()
ipcMain.handle('db:getMessages', (_, userMd5: string, startTime?: number, endTime?: number) =>
chat.listMessages(userMd5, startTime, endTime)
)
// 1. 处理普通联系人
for (const user of userList) {
const md5 = wechatDb.md5(user.m_nsUsrName)
const isGroup = user.m_nsUsrName.endsWith('@chatroom')
existingMd5s.add(md5)
contacts.push({
m_nsUsrName: user.m_nsUsrName,
m_nsNickName: user.nickname || '未知用户',
md5: md5,
type: isGroup ? 'group' : 'user',
avatar: typeof user.avatar === 'string' ? user.avatar : undefined
})
}
ipcMain.handle('db:getGroupSnapshot', (_, userMd5: string) => chat.getGroupSnapshot(userMd5))
// 2. 处理聊天表
const chatTables = wechatDb.getAllChatTables()
for (const table of chatTables) {
if (!table.name.startsWith('Chat_')) continue
const md5 = table.name.substring(5)
if (!existingMd5s.has(md5)) {
if (groupContacts[md5]) {
contacts.push({
m_nsUsrName: `Group_${md5}`,
m_nsNickName: groupContacts[md5],
md5: md5,
type: 'group'
})
} else {
contacts.push({
m_nsUsrName: `Unknown_${md5}`,
m_nsNickName: `Chat_${md5}`,
md5: md5,
type: 'user'
})
}
}
}
return contacts
})
ipcMain.handle('db:getMessages', (_, userMd5: string, startTime?: number, endTime?: number) => {
if (!wechatDb) return []
const wcdb4Client = wechatDb.getWcdb4Client()
const username = wcdb4Client.getUsernameByMd5(userMd5)
const rawMessages = wechatDb.getUserMessages(userMd5, startTime, endTime)
const groupMembers = wechatDb.getGroupMembersForChat(userMd5)
const myAvatar = wechatDb.getMyAvatarUrl()
const myGroupNickname = username?.endsWith('@chatroom')
? wcdb4Client.getMyGroupNickname(username)
: undefined
return rawMessages.map((msg: WechatMessage) => {
const rawMsgType = parseInt(msg.messageType)
const msgType = normalizeMsgType(msg.messageType)
const createTime = parseInt(msg.msgCreateTime)
const date = new Date(createTime * 1000)
const isMine = msg.mesDes !== 1
const localId = parseInt(msg.mesLocalID) || 0
let content = msg.msgContent
let img = ''
let name = ''
if (isMine) {
if (myAvatar) img = myAvatar
name = myGroupNickname || (typeof msg.senderNickname === 'string' ? msg.senderNickname : '')
} else {
if (typeof msg.senderAvatar === 'string') img = msg.senderAvatar
if (typeof msg.senderNickname === 'string') name = msg.senderNickname
}
// 检查内容是否以 wxid 开头并包含冒号
// 示例: wxid_xxxx:\nContent 或 wxid_xxxx:Content
if (content && typeof content === 'string') {
const colonIndex = content.indexOf(':')
if (colonIndex > 0) {
const potentialWxid = content.substring(0, colonIndex)
if (potentialWxid.startsWith('wxid_')) {
// 尝试获取头像
if (wechatDb) {
const member = wechatDb.getGroupMember(potentialWxid)
if (member) {
img = member.m_nsHeadImgUrl
}
}
if (groupMembers[potentialWxid]) {
const nickname = groupMembers[potentialWxid]
name = nickname
content = content.substring(colonIndex + 1) // +1 to skip the colon
}
}
}
}
// 解析富媒体消息内容
let contentData: ReturnType<typeof parseMessageContent> | undefined = undefined
let displayType = MSG_TYPE_DICT[msgType] || msg.messageType
const inferredMsgType =
typeof content === 'string' &&
/<appmsg\b|<refermsg\b|&lt;appmsg\b|&lt;refermsg\b/i.test(content)
? 49
: msgType
if ([3, 42, 47, 48, 49, 50, 10000, 10002].includes(inferredMsgType)) {
try {
const parsed =
inferredMsgType === 47
? parseStickerMessageFromRow(msg, content)
: parseMessageContent(content, inferredMsgType)
if (parsed.type === 'system') {
content = parsed.content
contentData = parsed
} else if (parsed.type !== 'unknown') {
content = ''
}
if (parsed.type === 'image') {
const imageDatName = parseImageDatNameFromRow(msg)
contentData = { ...parsed, datName: parsed.datName || imageDatName }
} else if (parsed.type !== 'system') {
if (parsed.type === 'sticker' && !parsed.url && parsed.md5) {
parsed.url = wcdb4Client.resolveEmoticonCdnUrl(parsed.md5)
}
contentData = parsed
}
if (inferredMsgType !== msgType || rawMsgType !== msgType) {
displayType = MSG_TYPE_DICT[inferredMsgType] || displayType
}
} catch {
// ignore parse errors
}
}
if (
!contentData &&
typeof content === 'string' &&
/^[0-9a-fA-F]{64,}$/.test(content.trim())
) {
const parsed = parseStickerMessageFromRow(msg, content)
if (parsed.type === 'sticker') {
if (!parsed.url && parsed.md5) {
parsed.url = wcdb4Client.resolveEmoticonCdnUrl(parsed.md5)
}
content = ''
contentData = parsed
displayType = '表情包'
}
}
if (msgType === 34) {
content = '[语音消息]'
}
return {
id: msg.mesLocalID || Math.random().toString(),
from: contentData?.type === 'system' ? 'system' : isMine ? 'assistant' : 'user',
type: displayType,
datetime: date.toLocaleString('zh-CN', { hour12: false }),
content: content,
img: img,
name: name,
sessionId: username,
localId: localId,
createTime: createTime,
contentData: contentData
}
})
})
ipcMain.handle('db:getGroupSnapshot', (_, userMd5: string) => {
if (!wechatDb) return null
const wcdb4Client = wechatDb.getWcdb4Client()
const roomId = wcdb4Client.getUsernameByMd5(userMd5)
if (!roomId || !roomId.endsWith('@chatroom')) return null
const members = wcdb4Client
.getGroupMembers(roomId)
.filter((member) => member?.m_nsUsrName)
.map((member) => ({
wxid: member.m_nsUsrName,
nickname: member.nickname || '',
avatar: member.m_nsHeadImgUrl || ''
}))
return {
roomId,
memberCount: members.length,
members
}
})
ipcMain.handle('db:search', (_, keyword: string) => {
if (!wechatDb) return null
return wechatDb.searchAllMessages(keyword)
})
ipcMain.handle('db:search', (_, keyword: string) => chat.searchMessages(keyword))
ipcMain.handle(
'ai:chat',
@@ -441,7 +233,7 @@ app.whenReady().then(() => {
if (!aesKey) {
return { success: false, error: '未配置图片解密密钥' }
}
imageDecryptService = new ImageDecryptService(xorKey, aesKey, wechatDb?.getWcdb4Client())
imageDecryptService = new ImageDecryptService(xorKey, aesKey, chat.getChatDb()?.getWcdb4Client())
}
const imageDatName = typeof imageDatNameOrThumb === 'string' ? imageDatNameOrThumb : undefined
@@ -461,13 +253,75 @@ app.whenReady().then(() => {
ipcMain.handle('db:getSticker', async (_, cdnUrl?: string, md5?: string) => {
if (!stickerService) {
stickerService = new StickerService(wechatDb?.getWcdb4Client())
stickerService = new StickerService(chat.getChatDb()?.getWcdb4Client())
}
return stickerService.resolveSticker(cdnUrl, md5)
})
// -------- Settings & API service --------
ipcMain.handle('settings:get', () => ({
settings: loadSettings(),
settingsPath: getSettingsPath()
}))
ipcMain.handle('settings:set', (_, patch: Partial<AppSettings>) => {
const merged = saveSettings({ ...loadSettings(), ...patch })
return { settings: merged, settingsPath: getSettingsPath() }
})
ipcMain.handle('settings:getSelf', () => {
const info = chat.getSelfAccountInfo()
if (!info) return { ready: false }
return { ready: true, info }
})
ipcMain.handle('db:testConnection', (_, key: string, accountRoot?: string) => {
return chat.testConnection(key, accountRoot)
})
ipcMain.handle('db:reopenWithRoot', (_, accountRoot: string) => {
const ok = chat.reopenWithRoot(accountRoot)
if (!ok) return { success: false, error: '数据库未初始化或重新打开失败' }
const info = chat.getSelfAccountInfo()
return { success: true, info }
})
ipcMain.handle('api:getStatus', () => apiServer.getState())
ipcMain.handle('api:start', async (_, host?: string, port?: number) => {
const settings = loadSettings()
const target = {
host: host || settings.apiHost,
port: port || settings.apiPort
}
if (host || port) saveSettings({ ...settings, ...target })
return apiServer.start(target.host, target.port)
})
ipcMain.handle('api:stop', async () => apiServer.stop())
ipcMain.handle('api:toggle', async (_, enabled: boolean) => {
const settings = saveSettings({ ...loadSettings(), apiEnabled: enabled })
if (enabled) {
return apiServer.start(settings.apiHost, settings.apiPort)
}
return apiServer.stop()
})
createWindow()
// 启动本地 HTTP API(根据 settings.apiEnabled 控制)
const settings = loadSettings()
if (settings.apiEnabled) {
await apiServer.start(settings.apiHost, settings.apiPort)
}
if (TRAY_MODE) {
app.dock?.hide()
setupTray()
}
app.on('activate', function () {
// 在 macOS 上,当点击 dock 图标且没有其他窗口打开时,
// 通常会在应用程序中重新创建一个窗口。
@@ -479,12 +333,65 @@ app.whenReady().then(() => {
// 应用程序及其菜单栏通常会保持活动状态,直到用户
// 显式使用 Cmd + Q 退出。
app.on('window-all-closed', () => {
if (TRAY_MODE) return
if (process.platform !== 'darwin') {
app.quit()
}
})
app.on('before-quit', () => {
wechatDb?.close()
wechatDb = null
app.on('before-quit', async () => {
chat.setChatDb(null)
await apiServer.stop().catch(() => undefined)
if (tray) {
tray.destroy()
tray = null
}
})
function showMainWindow(): void {
if (TRAY_MODE) app.dock?.show().catch(() => undefined)
const wins = BrowserWindow.getAllWindows()
if (wins.length === 0) {
createWindow()
return
}
const win = wins[0]
if (win.isMinimized()) win.restore()
win.show()
win.focus()
}
function buildTrayMenu(): Menu {
return Menu.buildFromTemplate([
{
label: '打开主窗口',
click: () => showMainWindow()
},
{
label: 'API 状态',
click: () => showMainWindow()
},
{ type: 'separator' },
{
label: '退出 WechatExplorer',
click: () => {
tray?.destroy()
tray = null
app.quit()
}
}
])
}
function setupTray(): void {
if (tray) return
try {
const image = nativeImage.createFromPath(join(__dirname, '../../resources/icon.png'))
tray = new Tray(image.isEmpty() ? nativeImage.createEmpty() : image)
tray.setToolTip('WechatExplorer')
tray.setContextMenu(buildTrayMenu())
tray.on('click', () => showMainWindow())
} catch (error) {
console.warn('[Tray] Failed to create tray:', error)
}
}
+25
View File
@@ -0,0 +1,25 @@
// Safe logging for Electron child processes whose stdout/stderr pipes can be
// closed by the parent (electron-vite). Plain console.error throws EPIPE
// against a closed pipe, which crashes the IPC handler. Wrap writes so any
// pipe error is swallowed.
type SafeConsoleMethod = (...args: unknown[]) => void
function makeSafe(method: SafeConsoleMethod): SafeConsoleMethod {
return (...args: unknown[]) => {
try {
method(...args)
} catch {
// Swallow EPIPE / ERR_STREAM_DESTROYED; logging must never crash the app.
}
}
}
export const safeLog = makeSafe(console.log.bind(console))
export const safeWarn = makeSafe(console.warn.bind(console))
export const safeError = makeSafe(console.error.bind(console))
export function installSafeConsole(): void {
console.log = safeLog
console.warn = safeWarn
console.error = safeError
}
+393
View File
@@ -0,0 +1,393 @@
import { WechatDb, WechatMessage } from '../wechat-db'
import {
parseImageDatNameFromRow,
parseMessageContent,
parseStickerMessageFromRow
} from '../message-parser'
export function getCurrentKey(): string {
if (!dbRef) return ''
try {
return dbRef.getWcdb4Client().getKey()
} catch {
return ''
}
}
export interface FormattedContact {
m_nsUsrName: string
m_nsNickName: string
md5: string
type: 'user' | 'group'
avatar?: string
}
export interface FormattedMessage {
id: string
from: string
type: string
datetime: string
content: string
isSender: boolean
img?: string
name?: string
contentData?: ReturnType<typeof parseMessageContent>
voiceDataUrl?: string
voiceDuration?: number
localId?: number
createTime?: number
sessionId?: string
}
export interface GroupSnapshot {
roomId: string
memberCount: number
members: { wxid: string; nickname: string; avatar: string }[]
}
const MSG_TYPE_DICT: Record<number, string> = {
1: '普通文本',
3: '图片',
34: '语音',
42: '名片',
43: '视频',
47: '表情包',
48: '位置',
49: '分享消息',
50: '通话',
10000: '系统消息'
}
function normalizeMsgType(value: string | number | undefined): number {
const raw = String(value ?? '').trim()
if (!raw) return 0
try {
const parsed = BigInt(raw)
const low32 = Number(parsed & 0xffffffffn)
return low32 || Number(parsed)
} catch {
const parsed = Number(raw)
if (!Number.isFinite(parsed)) return 0
return parsed > 0xffffffff ? parsed >>> 0 : parsed
}
}
let dbRef: WechatDb | null = null
export function setChatDb(db: WechatDb | null): void {
dbRef?.close()
dbRef = db
}
export function getChatDb(): WechatDb | null {
return dbRef
}
export function isReady(): boolean {
return dbRef !== null
}
export function listContacts(filter?: string): FormattedContact[] {
if (!dbRef) return []
const contacts: FormattedContact[] = []
const groupContacts = dbRef.getAllGroupContacts()
const userList = dbRef.getUserList(filter)
const existingMd5s = new Set<string>()
for (const user of userList) {
const md5 = dbRef.md5(user.m_nsUsrName)
const isGroup = user.m_nsUsrName.endsWith('@chatroom')
existingMd5s.add(md5)
contacts.push({
m_nsUsrName: user.m_nsUsrName,
m_nsNickName: user.nickname || '未知用户',
md5,
type: isGroup ? 'group' : 'user',
avatar: typeof user.avatar === 'string' ? user.avatar : undefined
})
}
const chatTables = dbRef.getAllChatTables()
for (const table of chatTables) {
if (!table.name.startsWith('Chat_')) continue
const md5 = table.name.substring(5)
if (existingMd5s.has(md5)) continue
if (groupContacts[md5]) {
contacts.push({
m_nsUsrName: `Group_${md5}`,
m_nsNickName: groupContacts[md5],
md5,
type: 'group'
})
} else {
contacts.push({
m_nsUsrName: `Unknown_${md5}`,
m_nsNickName: `Chat_${md5}`,
md5,
type: 'user'
})
}
}
return contacts
}
export function listMessages(
userMd5: string,
startTime?: number,
endTime?: number
): FormattedMessage[] {
if (!dbRef) return []
const wcdb4Client = dbRef.getWcdb4Client()
const username = wcdb4Client.getUsernameByMd5(userMd5)
const rawMessages = dbRef.getUserMessages(userMd5, startTime, endTime)
const groupMembers = dbRef.getGroupMembersForChat(userMd5)
const myAvatar = dbRef.getMyAvatarUrl()
const myGroupNickname = username?.endsWith('@chatroom')
? wcdb4Client.getMyGroupNickname(username)
: undefined
return rawMessages.map((msg: WechatMessage) => {
const rawMsgType = parseInt(msg.messageType)
const msgType = normalizeMsgType(msg.messageType)
const createTime = parseInt(msg.msgCreateTime)
const date = new Date(createTime * 1000)
const isMine = msg.mesDes !== 1
const localId = parseInt(msg.mesLocalID) || 0
let content = msg.msgContent
let img = ''
let name = ''
if (isMine) {
if (myAvatar) img = myAvatar
name = myGroupNickname || (typeof msg.senderNickname === 'string' ? msg.senderNickname : '')
} else {
if (typeof msg.senderAvatar === 'string') img = msg.senderAvatar
if (typeof msg.senderNickname === 'string') name = msg.senderNickname
}
if (content && typeof content === 'string') {
const colonIndex = content.indexOf(':')
if (colonIndex > 0) {
const potentialWxid = content.substring(0, colonIndex)
if (potentialWxid.startsWith('wxid_')) {
const member = dbRef!.getGroupMember(potentialWxid)
if (member) img = member.m_nsHeadImgUrl
if (groupMembers[potentialWxid]) {
name = groupMembers[potentialWxid]
content = content.substring(colonIndex + 1)
}
}
}
}
let contentData: ReturnType<typeof parseMessageContent> | undefined
let displayType = MSG_TYPE_DICT[msgType] || msg.messageType
const inferredMsgType =
typeof content === 'string' &&
/<appmsg\b|<refermsg\b|&lt;appmsg\b|&lt;refermsg\b/i.test(content)
? 49
: msgType
if ([3, 42, 47, 48, 49, 50, 10000, 10002].includes(inferredMsgType)) {
try {
const parsed =
inferredMsgType === 47
? parseStickerMessageFromRow(msg, content)
: parseMessageContent(content, inferredMsgType)
if (parsed.type === 'system') {
content = parsed.content
contentData = parsed
} else if (parsed.type !== 'unknown') {
content = ''
}
if (parsed.type === 'image') {
const imageDatName = parseImageDatNameFromRow(msg)
contentData = { ...parsed, datName: parsed.datName || imageDatName }
} else if (parsed.type !== 'system') {
if (parsed.type === 'sticker' && !parsed.url && parsed.md5) {
parsed.url = wcdb4Client.resolveEmoticonCdnUrl(parsed.md5)
}
contentData = parsed
}
if (inferredMsgType !== msgType || rawMsgType !== msgType) {
displayType = MSG_TYPE_DICT[inferredMsgType] || displayType
}
} catch {
// ignore parse errors
}
}
if (
!contentData &&
typeof content === 'string' &&
/^[0-9a-fA-F]{64,}$/.test(content.trim())
) {
const parsed = parseStickerMessageFromRow(msg, content)
if (parsed.type === 'sticker') {
if (!parsed.url && parsed.md5) {
parsed.url = wcdb4Client.resolveEmoticonCdnUrl(parsed.md5)
}
content = ''
contentData = parsed
displayType = '表情包'
}
}
if (msgType === 34) content = '[语音消息]'
return {
id: msg.mesLocalID || Math.random().toString(),
from: contentData?.type === 'system' ? 'system' : isMine ? 'assistant' : 'user',
isSender: isMine,
type: displayType,
datetime: date.toLocaleString('zh-CN', { hour12: false }),
content,
img,
name,
sessionId: username,
localId,
createTime,
contentData
}
})
}
export function getGroupSnapshot(userMd5: string): GroupSnapshot | null {
if (!dbRef) return null
const wcdb4Client = dbRef.getWcdb4Client()
const roomId = wcdb4Client.getUsernameByMd5(userMd5)
if (!roomId || !roomId.endsWith('@chatroom')) return null
const members = wcdb4Client
.getGroupMembers(roomId)
.filter((member) => member?.m_nsUsrName)
.map((member) => ({
wxid: member.m_nsUsrName,
nickname: member.nickname || '',
avatar: member.m_nsHeadImgUrl || ''
}))
return { roomId, memberCount: members.length, members }
}
export function searchMessages(keyword: string): string | null {
if (!dbRef) return null
return dbRef.searchAllMessages(keyword)
}
export function listRecentChat(limit = 50): FormattedContact[] {
const contacts = listContacts()
return contacts.slice(0, limit)
}
export function resolveMd5(query: string): FormattedContact | null {
const trimmed = query.trim()
if (!trimmed) return null
const lower = trimmed.toLowerCase()
const contacts = listContacts()
const exact = contacts.find(
(c) =>
c.md5 === trimmed ||
c.m_nsUsrName.toLowerCase() === lower ||
c.m_nsNickName.toLowerCase() === lower
)
if (exact) return exact
const partial = contacts.find(
(c) =>
c.m_nsNickName.toLowerCase().includes(lower) ||
c.m_nsUsrName.toLowerCase().includes(lower)
)
return partial || null
}
export interface SelfAccountInfo {
wxid: string
nickname: string
avatar?: string
accountRoot: string
}
export function getSelfAccountInfo(): SelfAccountInfo | null {
if (!dbRef) return null
const wcdb = dbRef.getWcdb4Client()
const accountRoot = wcdb.getAccountRoot()
const usernameCandidates = wcdb.getMyUsernameCandidates()
const primaryUsername = usernameCandidates[0] ?? ''
const wxid =
primaryUsername && primaryUsername.toLowerCase().startsWith('wxid_')
? primaryUsername
: wcdb.getUsernameByMd5(wcdb.md5(accountRoot.split('/').pop() || '')) || primaryUsername
let nickname = ''
let avatar: string | undefined
try {
avatar = wcdb.getMyAvatarUrl()
} catch {
avatar = undefined
}
if (usernameCandidates.length) {
const contacts = listContacts()
const self = contacts.find(
(c) =>
usernameCandidates.includes(c.m_nsUsrName) ||
(c.type === 'user' && usernameCandidates.some((u) => c.m_nsUsrName.includes(u)))
)
if (self) {
nickname = self.m_nsNickName
avatar = avatar || self.avatar
}
}
return {
wxid: wxid || primaryUsername || '',
nickname: nickname || wxid || '我',
avatar,
accountRoot
}
}
export function testConnection(
key: string,
accountRoot?: string
): { success: boolean; error?: string; accountRoot?: string; wxid?: string } {
try {
const probeKey = key.replace(/^0x/i, '').trim()
if (!probeKey) {
return { success: false, error: '密钥不能为空' }
}
const probe = accountRoot ? new WechatDb(probeKey, accountRoot) : new WechatDb(probeKey)
try {
probe.close()
} catch {
// best effort
}
return {
success: true,
accountRoot: probe.getWcdb4Client().getAccountRoot(),
wxid: (probe.getWcdb4Client().getMyUsernameCandidates?.() ?? [])[0] || ''
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error)
}
}
}
export function reopenWithRoot(accountRoot: string): boolean {
if (!dbRef) return false
const key = getCurrentKey()
if (!key) return false
try {
const next = new WechatDb(key, accountRoot)
setChatDb(next)
return true
} catch (error) {
console.error('[ChatService] reopen with root failed:', error)
return false
}
}
+76
View File
@@ -0,0 +1,76 @@
import { app } from 'electron'
import fs from 'fs-extra'
import path from 'path'
import os from 'os'
export interface AppSettings {
dbRoot: string
apiEnabled: boolean
apiHost: string
apiPort: number
}
const DEFAULT_SETTINGS: AppSettings = {
dbRoot: path.join(
os.homedir(),
'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files'
),
apiEnabled: true,
apiHost: '127.0.0.1',
apiPort: 6131
}
const SETTINGS_FILE = path.join(
process.env['WE_SETTINGS_DIR'] || app.getPath('userData'),
'settings.json'
)
let cache: AppSettings | null = null
function ensureDir(): void {
fs.ensureDirSync(path.dirname(SETTINGS_FILE))
}
export function loadSettings(): AppSettings {
if (cache) return cache
try {
if (fs.existsSync(SETTINGS_FILE)) {
const raw = fs.readJsonSync(SETTINGS_FILE) as Partial<AppSettings>
cache = { ...DEFAULT_SETTINGS, ...raw }
return cache
}
} catch (error) {
console.warn('[Settings] failed to load, fallback to defaults:', error)
}
cache = { ...DEFAULT_SETTINGS }
return cache
}
export function saveSettings(next: AppSettings): AppSettings {
cache = { ...next }
try {
ensureDir()
fs.writeJsonSync(SETTINGS_FILE, cache, { spaces: 2 })
} catch (error) {
console.error('[Settings] failed to save:', error)
}
return cache
}
export function updateSettings(patch: Partial<AppSettings>): AppSettings {
return saveSettings({ ...loadSettings(), ...patch })
}
export function resetSettings(): AppSettings {
cache = null
try {
if (fs.existsSync(SETTINGS_FILE)) fs.unlinkSync(SETTINGS_FILE)
} catch {
// best effort
}
return loadSettings()
}
export function getSettingsPath(): string {
return SETTINGS_FILE
}
+39 -2
View File
@@ -145,7 +145,9 @@ export class Wcdb4Client {
constructor(key: string, accountRoot?: string) {
this.key = key.replace(/^0x/i, '').trim()
this.accountRoot = accountRoot || Wcdb4Client.findLatestAccountRoot()
this.accountRoot = accountRoot
? Wcdb4Client.resolveAccountRoot(accountRoot)
: Wcdb4Client.findLatestAccountRoot()
this.wxid = Wcdb4Client.cleanAccountDirName(path.basename(this.accountRoot))
this.dbStoragePath = path.join(this.accountRoot, 'db_storage')
this.sessionDbPath = this.findSessionDb()
@@ -155,6 +157,37 @@ export class Wcdb4Client {
}
}
static resolveAccountRoot(accountRoot: string): string {
const target = (accountRoot || '').trim().replace(/\/+$/, '')
if (!target) {
throw new Error('微信 4.0 账号目录不能为空')
}
if (fs.existsSync(path.join(target, 'db_storage'))) {
return target
}
if (!fs.existsSync(target)) {
throw new Error(`未找到微信 4.0 数据目录: ${target}`)
}
const candidates = fs
.readdirSync(target)
.map((name) => path.join(target, name))
.filter((candidate) => {
try {
return (
fs.statSync(candidate).isDirectory() &&
fs.existsSync(path.join(candidate, 'db_storage'))
)
} catch {
return false
}
})
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)
if (!candidates[0]) {
throw new Error(`未找到包含 db_storage 的微信 4.0 账号目录: ${target}`)
}
return candidates[0]
}
static findLatestAccountRoot(): string {
const root = Wcdb4Client.defaultRoot
if (!fs.existsSync(root)) {
@@ -714,6 +747,10 @@ export class Wcdb4Client {
return this.accountRoot
}
getKey(): string {
return this.key
}
resolveImageHardlink(md5: string): Wcdb4ImageHardlink | null {
if (!this.wcdbResolveImageHardlink) return null
const normalizedMd5 = String(md5 || '')
@@ -1390,7 +1427,7 @@ export class Wcdb4Client {
return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)))
}
private getMyUsernameCandidates(): string[] {
getMyUsernameCandidates(): string[] {
const rawAccountName = path.basename(this.accountRoot)
return this.uniq([this.wxid, rawAccountName, Wcdb4Client.cleanAccountDirName(rawAccountName)])
}
+2 -2
View File
@@ -34,9 +34,9 @@ export class WechatDb {
private wcdb4Client: Wcdb4Client
private chatMd5ToUsername = new Map<string, string>()
constructor(rawKey: string) {
constructor(rawKey: string, accountRoot?: string) {
console.log(`Initializing WechatDb with key length: ${rawKey.trim().length}`)
const client = new Wcdb4Client(rawKey)
const client = new Wcdb4Client(rawKey, accountRoot)
client.open()
this.wcdb4Client = client
for (const table of client.getChatTables()) {