diff --git a/README.md b/README.md index d9bb797..2aa2797 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,74 @@ MAC系统 获取微信聊天记录 AI一键生成群聊总结 1. 从 WeFlow/Chatlog 设置中导出 2. 使用内存扫描工具从微信进程中自动提取(待实现) +## 🤖 AI 集成(本地 HTTP API) + +WechatExplorer 内置了一个本地 HTTP API 服务,默认监听 `127.0.0.1:6131`(纯本地,无鉴权),让你能够从 **Claude Desktop / Claude Code / Codex / curl / 任何脚本** 读取已经解锁的微信聊天记录。 + +### 启用本地 API + +API 服务在 WechatExplorer 启动时自动启用,**不需要任何配置**。只需要: +1. 安装并启动 WechatExplorer +2. 完成首次密钥配置(主窗口第一步),解锁 WCDB 数据库 +3. API 即在 `http://127.0.0.1:6131` 可用 + +### 7×24 提供 API(菜单栏常驻模式) + +默认情况下,关闭主窗口时 macOS 会让 app 继续运行,但 Windows / Linux 会退出。如果希望主窗口关闭后 API 服务仍可用,启用菜单栏模式: + +```bash +# 任选一种方式 +WXE_TRAY=1 open /Applications/WechatExplorer.app +/Applications/WechatExplorer.app/Contents/MacOS/WechatExplorer --tray +``` + +启用后: +- macOS dock 图标自动隐藏 +- 菜单栏出现 WechatExplorer 图标(可点击重新打开主窗口、查看 API 状态) +- 主窗口关闭后 API 服务继续运行 + +### API 端点一览 + +| 端点 | 说明 | +|------|------| +| `GET /api/v1/health` | 健康检查 | +| `GET /api/v1/current_time` | 获取当前本地时间(用于"今天/昨天"换算) | +| `GET /api/v1/contact?filter=xxx` | 联系人 / 群聊列表 | +| `GET /api/v1/chatroom?keyword=xxx` | 搜索群聊 | +| `GET /api/v1/chatlog?talker=xxx&time=2026-07-03` | 聊天记录 | +| `GET /api/v1/group_snapshot?md5=xxx` | 群成员快照 | +| `GET /api/v1/resolve?q=群昵称` | 把昵称/wxid/md5 解析成 md5 | + +详细参数、返回结构、时间格式见 [`docs/skill/wechatexplorer-reader/SKILL.md`](./docs/skill/wechatexplorer-reader/SKILL.md)。 + +### 让 Claude 自动总结你的群聊 + +复制 [`docs/skill/wechatexplorer-reader/SKILL.md`](./docs/skill/wechatexplorer-reader/SKILL.md) 到 `~/.claude/skills/`,然后在 Claude Desktop 里说: + +> "今天 技术交流群 聊了啥?" + +Claude 会自动: +1. 调 `current_time` 拿到今天日期 +2. 调 `chatroom` 找到目标群 +3. 调 `chatlog` 拿 JSON 聊天记录 +4. 自己用 LLM 生成总结报告 + +### curl 示例 + +```bash +# 健康检查 +curl http://127.0.0.1:6131/api/v1/health + +# 今天 摸鱼交流群 的聊天记录 +curl -G "http://127.0.0.1:6131/api/v1/chatlog" \ + --data-urlencode "talker=摸鱼交流群" \ + --data-urlencode "time=$(date +%Y-%m-%d)" + +# 把群昵称解析成 md5 +curl -G "http://127.0.0.1:6131/api/v1/resolve" \ + --data-urlencode "q=摸鱼交流群" +``` + ## ⚠️ 免责声明 本项目仅供学习和研究使用。请勿用于非法用途。开发者不对使用本项目造成的任何后果负责。请遵守相关法律法规和微信使用协议。 diff --git a/src/main/http-server.ts b/src/main/http-server.ts new file mode 100644 index 0000000..4d10c62 --- /dev/null +++ b/src/main/http-server.ts @@ -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 +} + +interface RouteContext { + req: IncomingMessage + res: ServerResponse + url: URL + body?: unknown +} + +type RouteHandler = (ctx: RouteContext) => void | Promise + +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 { + 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 = { + '/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 { + 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((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 { + 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 { + 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 { + 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 } + } +} diff --git a/src/main/index.ts b/src/main/index.ts index 1c36e33..2b56de5 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 = { - 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() + 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 | undefined = undefined - let displayType = MSG_TYPE_DICT[msgType] || msg.messageType - const inferredMsgType = - typeof content === '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) => { + 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) + } +} diff --git a/src/main/safe-log.ts b/src/main/safe-log.ts new file mode 100644 index 0000000..5843915 --- /dev/null +++ b/src/main/safe-log.ts @@ -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 +} diff --git a/src/main/services/chat-service.ts b/src/main/services/chat-service.ts new file mode 100644 index 0000000..ff004ba --- /dev/null +++ b/src/main/services/chat-service.ts @@ -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 + 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 = { + 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() + + 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 | undefined + let displayType = MSG_TYPE_DICT[msgType] || msg.messageType + const inferredMsgType = + typeof content === 'string' && + / 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 + } +} diff --git a/src/main/services/settings-store.ts b/src/main/services/settings-store.ts new file mode 100644 index 0000000..349580e --- /dev/null +++ b/src/main/services/settings-store.ts @@ -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 + 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 { + 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 +} \ No newline at end of file diff --git a/src/main/wcdb4-client.ts b/src/main/wcdb4-client.ts index 2aa8ef7..b47c3be 100644 --- a/src/main/wcdb4-client.ts +++ b/src/main/wcdb4-client.ts @@ -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)]) } diff --git a/src/main/wechat-db.ts b/src/main/wechat-db.ts index 8a282f9..8b26f35 100644 --- a/src/main/wechat-db.ts +++ b/src/main/wechat-db.ts @@ -34,9 +34,9 @@ export class WechatDb { private wcdb4Client: Wcdb4Client private chatMd5ToUsername = new Map() - 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()) { diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 5db59cb..987a831 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -81,6 +81,67 @@ declare global { clearSavedDbKey: () => Promise<{ success: boolean; error?: string }> onWcdbChange: (callback: (payload: { type: string; json: string }) => void) => () => void onDbKeyStatus: (callback: (payload: { message: string }) => void) => () => void + getSettings: () => Promise<{ + settings: { + dbRoot: string + apiEnabled: boolean + apiHost: string + apiPort: number + } + settingsPath: string + }> + setSettings: (patch: Partial<{ + dbRoot: string + apiEnabled: boolean + apiHost: string + apiPort: number + }>) => Promise<{ + settings: { + dbRoot: string + apiEnabled: boolean + apiHost: string + apiPort: number + } + settingsPath: string + }> + getSelf: () => Promise< + | { + ready: true + info: { wxid: string; nickname: string; avatar?: string; accountRoot: string } + } + | { ready: false } + > + testConnection: ( + key: string, + accountRoot?: string + ) => Promise<{ + success: boolean + error?: string + accountRoot?: string + wxid?: string + }> + reopenWithRoot: (accountRoot: string) => Promise<{ + success: boolean + error?: string + info?: { wxid: string; nickname: string; avatar?: string; accountRoot: string } + }> + apiStatus: () => Promise<{ + running: boolean + host: string + port: number + error?: string + }> + apiStart: ( + host?: string, + port?: number + ) => Promise<{ running: boolean; host: string; port: number; error?: string }> + apiStop: () => Promise<{ running: boolean; host: string; port: number; error?: string }> + apiToggle: (enabled: boolean) => Promise<{ + running: boolean + host: string + port: number + error?: string + }> } } } diff --git a/src/preload/index.ts b/src/preload/index.ts index b70d05e..07d3164 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -42,7 +42,17 @@ const api = { callback(payload) ipcRenderer.on('key:dbKeyStatus', listener) return () => ipcRenderer.removeListener('key:dbKeyStatus', listener) - } + }, + getSettings: () => ipcRenderer.invoke('settings:get'), + setSettings: (patch) => ipcRenderer.invoke('settings:set', patch), + getSelf: () => ipcRenderer.invoke('settings:getSelf'), + testConnection: (key: string, accountRoot?: string) => + ipcRenderer.invoke('db:testConnection', key, accountRoot), + reopenWithRoot: (accountRoot: string) => ipcRenderer.invoke('db:reopenWithRoot', accountRoot), + apiStatus: () => ipcRenderer.invoke('api:getStatus'), + apiStart: (host?: string, port?: number) => ipcRenderer.invoke('api:start', host, port), + apiStop: () => ipcRenderer.invoke('api:stop'), + apiToggle: (enabled: boolean) => ipcRenderer.invoke('api:toggle', enabled) } if (process.contextIsolated) { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 64b37be..dc0cd5a 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,8 +1,16 @@ import React, { useState } from 'react' import { Sidebar } from './components/Sidebar' import ChatWindow from './components/ChatWindow' +import { SettingsPanel } from './components/SettingsPanel' import { Contact, Message } from '../../shared/types' +interface SelfInfo { + wxid: string + nickname: string + avatar?: string + accountRoot: string +} + const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md' const MESSAGE_MONITOR_DEBOUNCE_MS = 250 @@ -96,6 +104,8 @@ function App(): React.ReactElement { const [dbKeyStatusKind, setDbKeyStatusKind] = useState<'normal' | 'success' | 'error'>('normal') const [showDbKey, setShowDbKey] = useState(false) const [showMacKeyFaq, setShowMacKeyFaq] = useState(false) + const [showSettings, setShowSettings] = useState(false) + const [selfInfo, setSelfInfo] = useState(null) const [isNativeMonitorActive, setIsNativeMonitorActive] = useState(false) const currentGroupSnapshotRef = React.useRef(null) const syntheticGroupMessagesRef = React.useRef>({}) @@ -137,6 +147,7 @@ function App(): React.ReactElement { setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true) setIsAuthenticated(true) loadContacts() + void refreshSelfInfo() } else { const error = typeof result === 'boolean' ? '' : result.error alert(`Failed to open database.${error ? `\n\n${error}` : '\nCheck your key.'}`) @@ -147,6 +158,20 @@ function App(): React.ReactElement { } } + const refreshSelfInfo = async (): Promise => { + try { + const result = await window.api.getSelf() + if (result.ready) { + setSelfInfo(result.info) + } else { + setSelfInfo(null) + } + } catch (error) { + console.warn('[SelfInfo] 加载失败:', error) + setSelfInfo(null) + } + } + const logGroupSnapshot = React.useCallback( async (contact: Contact | null, reason: string): Promise => { if (!contact || contact.type !== 'group') return null @@ -476,6 +501,9 @@ function App(): React.ReactElement { width={sidebarWidth} dateRange={dateRange} onDateRangeChange={handleDateRangeChange} + selfInfo={selfInfo} + dbReady={isAuthenticated} + onOpenSettings={() => setShowSettings(true)} />
selectedContact && handleSelectContact(selectedContact)} onRefreshData={loadContacts} /> + setShowSettings(false)} + onDbKeyChange={setDbKey} + onDbRootChanged={() => { + void refreshSelfInfo() + void loadContacts() + }} + />
) } diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index e94f387..fb231f8 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1204,3 +1204,359 @@ body { .voip-status { font-size: 13px; } + +/* Sidebar 自助卡片 + 入口 */ +.sidebar-footer { + padding: 10px 12px; + border-top: 1px solid var(--border-color); + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + background-color: #f3f4f5; + transition: background-color 0.15s ease; + user-select: none; +} + +.sidebar-footer:hover { + background-color: #e6e9eb; +} + +.sidebar-self-avatar { + width: 36px; + height: 36px; + border-radius: 6px; + background-color: #07c160; + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: 500; + overflow: hidden; + flex-shrink: 0; +} + +.sidebar-self-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.sidebar-self-info { + flex: 1; + min-width: 0; + overflow: hidden; +} + +.sidebar-self-nickname { + font-size: 13px; + font-weight: 500; + color: #1f2429; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.sidebar-self-wxid { + font-size: 11px; + color: #8a9298; + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.sidebar-self-arrow { + color: #8a9298; + font-size: 18px; + flex-shrink: 0; +} + +/* 设置面板 */ +.settings-overlay { + position: fixed; + inset: 0; + background: rgba(15, 21, 26, 0.45); + backdrop-filter: blur(2px); + z-index: 1100; + display: flex; + align-items: center; + justify-content: center; + animation: settings-fade-in 0.16s ease-out; +} + +@keyframes settings-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.settings-modal { + width: min(560px, 92vw); + max-height: 84vh; + background: #fff; + border-radius: 12px; + box-shadow: 0 24px 60px rgba(15, 21, 26, 0.28); + display: flex; + flex-direction: column; + overflow: hidden; + animation: settings-pop-in 0.18s ease-out; +} + +@keyframes settings-pop-in { + from { + transform: translateY(8px) scale(0.98); + opacity: 0; + } + to { + transform: translateY(0) scale(1); + opacity: 1; + } +} + +.settings-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 22px; + border-bottom: 1px solid #ececec; +} + +.settings-header h2 { + margin: 0; + font-size: 17px; + font-weight: 600; + color: #1f2429; +} + +.settings-close { + background: transparent; + border: 0; + color: #7d858a; + font-size: 26px; + line-height: 1; + width: 32px; + height: 32px; + border-radius: 50%; + cursor: pointer; + transition: background-color 0.15s ease; +} + +.settings-close:hover { + background: #f0f2f4; + color: #1f2429; +} + +.settings-body { + padding: 12px 22px 22px; + overflow-y: auto; +} + +.settings-section { + margin-top: 14px; + padding: 14px 16px; + border-radius: 10px; + background: #fafbfc; + border: 1px solid #ececec; +} + +.settings-section:first-child { + margin-top: 6px; +} + +.settings-section-title { + font-size: 12px; + font-weight: 600; + color: #6f767c; + letter-spacing: 0.5px; + text-transform: uppercase; + margin-bottom: 10px; +} + +.settings-row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + margin-top: 8px; +} + +.settings-row:first-child { + margin-top: 0; +} + +.settings-input { + flex: 1; + min-width: 0; + padding: 8px 11px; + border: 1px solid #d4d9dc; + border-radius: 6px; + background: #fff; + font-size: 13px; + color: #1f2429; + font-family: 'SF Mono', Menlo, Consolas, monospace; + outline: none; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.settings-input:focus { + border-color: #07c160; + box-shadow: 0 0 0 3px rgba(7, 193, 96, 0.12); +} + +.settings-input-half { + flex: 0 1 140px; +} + +.settings-input-quarter { + flex: 0 1 90px; +} + +.settings-btn { + padding: 7px 14px; + border: 1px solid #d4d9dc; + border-radius: 6px; + background: #fff; + color: #30383d; + cursor: pointer; + font-size: 13px; + transition: border-color 0.15s ease, background-color 0.15s ease, color 0.15s ease; +} + +.settings-btn:hover:not(:disabled) { + border-color: #07c160; + color: #078f49; +} + +.settings-btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.settings-btn-primary { + background: #07c160; + color: #fff; + border-color: #07c160; +} + +.settings-btn-primary:hover:not(:disabled) { + background: #06ad56; + color: #fff; + border-color: #06ad56; +} + +.settings-hint { + margin-top: 10px; + font-size: 11px; + color: #8a9298; + line-height: 1.6; +} + +.settings-hint code { + background: #eef0f2; + padding: 1px 5px; + border-radius: 3px; + font-size: 10.5px; +} + +.settings-status { + font-size: 12px; + color: #59636a; +} + +.settings-status.ok { + color: #078f49; +} + +.settings-status.fail { + color: #c73737; +} + +.settings-toggle { + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 13px; + color: #30383d; +} + +.settings-toggle input { + width: 16px; + height: 16px; + accent-color: #07c160; + cursor: pointer; +} + +.settings-self { + display: flex; + align-items: center; + gap: 12px; +} + +.settings-self-avatar { + width: 56px; + height: 56px; + border-radius: 10px; + background: #07c160; + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-size: 22px; + font-weight: 500; + overflow: hidden; + flex-shrink: 0; +} + +.settings-self-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.settings-self-info { + flex: 1; + min-width: 0; +} + +.settings-self-nickname { + font-size: 15px; + font-weight: 600; + color: #1f2429; +} + +.settings-self-wxid { + font-size: 12px; + color: #6f767c; + margin-top: 2px; + font-family: 'SF Mono', Menlo, Consolas, monospace; +} + +.settings-self-account { + font-size: 11px; + color: #8a9298; + margin-top: 2px; + word-break: break-all; +} + +.settings-self-empty { + font-size: 13px; + color: #8a9298; +} + +.settings-path { + display: inline-block; + font-family: 'SF Mono', Menlo, Consolas, monospace; + font-size: 11px; + background: #eef0f2; + padding: 3px 7px; + border-radius: 4px; + color: #30383d; + word-break: break-all; + max-width: 100%; +} diff --git a/src/renderer/src/components/SettingsPanel.tsx b/src/renderer/src/components/SettingsPanel.tsx new file mode 100644 index 0000000..89798e8 --- /dev/null +++ b/src/renderer/src/components/SettingsPanel.tsx @@ -0,0 +1,306 @@ +import React, { useEffect, useState } from 'react' + +interface SelfInfo { + wxid: string + nickname: string + avatar?: string + accountRoot: string +} + +interface AppSettings { + dbRoot: string + apiEnabled: boolean + apiHost: string + apiPort: number +} + +interface ApiState { + running: boolean + host: string + port: number + error?: string +} + +interface SettingsPanelProps { + open: boolean + selfInfo: SelfInfo | null + dbReady: boolean + dbKey: string + onClose: () => void + onDbKeyChange: (key: string) => void + onDbRootChanged: () => void +} + +export const SettingsPanel: React.FC = ({ + open, + selfInfo, + dbReady, + dbKey, + onClose, + onDbKeyChange, + onDbRootChanged +}) => { + const [settings, setSettings] = useState(null) + const [settingsPath, setSettingsPath] = useState('') + const [apiState, setApiState] = useState(null) + const [testStatus, setTestStatus] = useState< + { kind: 'idle' | 'ok' | 'fail'; message: string; wxid?: string; accountRoot?: string } + >({ kind: 'idle', message: '' }) + const [reopenStatus, setReopenStatus] = useState('') + const [busy, setBusy] = useState(false) + + useEffect(() => { + if (!open) return + void refresh() + }, [open]) + + async function refresh(): Promise { + const [{ settings, settingsPath }, api] = await Promise.all([ + window.api.getSettings(), + window.api.apiStatus() + ]) + setSettings(settings) + setSettingsPath(settingsPath) + setApiState(api) + } + + if (!open) return null + + async function handleSave(patch: Partial): Promise { + if (!settings) return + setBusy(true) + const next = await window.api.setSettings(patch) + setSettings(next.settings) + setBusy(false) + } + + async function handleTest(): Promise { + setTestStatus({ kind: 'idle', message: '测试中...' }) + setBusy(true) + try { + const result = await window.api.testConnection(dbKey, settings?.dbRoot) + if (result.success) { + setTestStatus({ + kind: 'ok', + message: '连接成功', + wxid: result.wxid, + accountRoot: result.accountRoot + }) + if (result.accountRoot && settings && result.accountRoot !== settings.dbRoot) { + const next = await window.api.setSettings({ dbRoot: result.accountRoot }) + setSettings(next.settings) + } + } else { + setTestStatus({ kind: 'fail', message: result.error || '连接失败' }) + } + } catch (error) { + setTestStatus({ kind: 'fail', message: error instanceof Error ? error.message : String(error) }) + } finally { + setBusy(false) + } + } + + async function handleReopen(): Promise { + if (!settings) return + setBusy(true) + setReopenStatus('重新初始化中...') + try { + const result = await window.api.reopenWithRoot(settings.dbRoot) + if (result.success) { + setReopenStatus(`已重新打开:${result.info?.wxid || '未知'}`) + onDbRootChanged() + } else { + setReopenStatus(result.error || '重新打开失败') + } + } catch (error) { + setReopenStatus(error instanceof Error ? error.message : String(error)) + } finally { + setBusy(false) + } + } + + async function handleApiToggle(enabled: boolean): Promise { + setBusy(true) + await handleSave({ apiEnabled: enabled }) + const state = await window.api.apiToggle(enabled) + setApiState(state) + setBusy(false) + } + + async function handleApiRestart(): Promise { + if (!settings) return + setBusy(true) + await window.api.apiStop() + const state = await window.api.apiStart(settings.apiHost, settings.apiPort) + setApiState(state) + setBusy(false) + } + + return ( +
+
e.stopPropagation()}> +
+

设置

+ +
+ +
+ {/* 自我信息卡片 */} +
+
账号信息
+ {dbReady && selfInfo ? ( +
+
+ {selfInfo.avatar ? ( + {selfInfo.nickname} + ) : ( + (selfInfo.nickname || selfInfo.wxid || '?').charAt(0) + )} +
+
+
{selfInfo.nickname}
+
{selfInfo.wxid}
+
{selfInfo.accountRoot}
+
+
+ ) : ( +
尚未连接数据库
+ )} +
+ + {/* 测试连接 */} +
+
连接测试
+
+ + {testStatus.kind !== 'idle' && ( + + {testStatus.kind === 'ok' ? '✓' : '✗'} {testStatus.message} + {testStatus.wxid ? ` · ${testStatus.wxid}` : ''} + {testStatus.accountRoot ? ` · ${testStatus.accountRoot}` : ''} + + )} +
+
+ 使用当前密钥 + 下方配置的根目录尝试打开数据库,只校验不持久化。 +
+
+ + {/* 解密密钥 */} +
+
解密密钥
+
+ onDbKeyChange(e.target.value)} + placeholder="64 位 hex 密钥,如 0x..." + spellCheck={false} + /> +
+
+ 密钥保存在本机 macOS Keychain(safeStorage 加密),不会上传任何服务器。 +
+
+ + {/* 数据库根目录 */} +
+
数据库根目录
+
+ setSettings(settings ? { ...settings, dbRoot: e.target.value } : null)} + onBlur={(e) => handleSave({ dbRoot: e.target.value })} + placeholder="~/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files" + spellCheck={false} + /> +
+
+ + {reopenStatus && {reopenStatus}} +
+
+ 指向 xwechat_files 目录,内部包含 db_storage/。修改后需重新初始化才能生效。 +
+
+ + {/* API 服务 */} +
+
本地 HTTP API
+
+ + {apiState && ( + + {apiState.running ? '运行中' : '已停止'} + {apiState.error ? ` · ${apiState.error}` : ''} + + )} +
+
+ setSettings(settings ? { ...settings, apiHost: e.target.value } : null)} + onBlur={(e) => handleSave({ apiHost: e.target.value })} + placeholder="host" + spellCheck={false} + /> + + setSettings(settings ? { ...settings, apiPort: Number(e.target.value) || 6131 } : null) + } + onBlur={(e) => handleSave({ apiPort: Number(e.target.value) || 6131 })} + placeholder="port" + /> + +
+
+ API 仅本机访问,无鉴权。关闭后 Claude / Codex 等客户端无法读取聊天数据。 +
+ 配置文档:docs/skill/wechatexplorer-reader/SKILL.md +
+
+ + {/* 配置文件位置 */} +
+
配置文件
+
+ {settingsPath} +
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx index e5f521a..4ced1fd 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -1,6 +1,13 @@ import React, { useState } from 'react' import { Contact } from '../../../shared/types' +interface SelfInfo { + wxid: string + nickname: string + avatar?: string + accountRoot: string +} + interface SidebarProps { contacts: Contact[] selectedContact: Contact | null @@ -10,6 +17,9 @@ interface SidebarProps { width: number dateRange: string onDateRangeChange: (range: string) => void + selfInfo: SelfInfo | null + dbReady: boolean + onOpenSettings: () => void } export const Sidebar: React.FC = ({ @@ -20,7 +30,10 @@ export const Sidebar: React.FC = ({ onContentFilter, width, dateRange, - onDateRangeChange + onDateRangeChange, + selfInfo, + dbReady, + onOpenSettings }) => { const [searchTerm, setSearchTerm] = useState('') const [contentFilter, setContentFilter] = useState('') @@ -115,14 +128,24 @@ export const Sidebar: React.FC = ({
暂无联系人
)} - {/*
-
window.location.reload()}> - ↪️ 退出 -
-
- ✅ 已获得 -
-
*/} +
+
+ {selfInfo?.avatar ? ( + {selfInfo.nickname} + ) : ( + ((selfInfo?.nickname || selfInfo?.wxid || '我').charAt(0)) + )} +
+
+
+ {dbReady && selfInfo ? selfInfo.nickname || selfInfo.wxid || '我' : '未连接'} +
+
+ {dbReady && selfInfo ? selfInfo.wxid : '点击设置 →'} +
+
+
+
) }