Files
WechatExplorer/src/main/index.ts
T

846 lines
30 KiB
TypeScript

import './preload-env'
import {
app,
shell,
BrowserWindow,
ipcMain,
nativeImage,
clipboard,
Menu,
Tray,
dialog
} from 'electron'
import { join } from 'path'
import { existsSync } from 'fs'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'
import { WechatDb } from './wechat-db'
import { bootstrapWcdbNative } from './wcdb4-client'
import { VoiceService } from './voice-service'
import { StickerService } from './sticker-service'
import { parseMessageContent } from './message-parser'
import { ImageDecryptService } from './image-decrypt-service'
import { exportGroupReport } from './group-report-service'
import {
deleteGeneratedReport,
listGeneratedReports,
saveGeneratedReport
} from './report-history-service'
import type { GroupReportExportRequest } from '../shared/group-report'
import type { SaveGeneratedReportRequest } from '../shared/report-history'
import type {
AIChatRequestOptions,
AIProviderConfig,
AIVisionTestRequest,
LegacyAIConfig
} from '../shared/ai-provider'
import { DatabaseKeyStore } from './database-key-store'
import { ImageKeyConfigService } from './services/image-key-config-service'
import { AIProviderService } from './services/ai-provider-service'
import { imageInsightService } from './services/image-insight-service'
import type {
ImageAnalysisRequest,
ImageAnalysisResponse,
ImageCandidate,
ImageCandidateQuery,
ImageInsight
} from '../shared/image-insight'
import { KeyServiceMac } from './key-service-mac'
import { KeyService as KeyServiceWin } from './key-service-win'
import * as chat from './services/chat-service'
import { apiServer } from './http-server'
import { skillResourceService } from './services/skill-resource-service'
import { testLocalApiRequest } from './services/local-api-test-service'
import { isWindowsWechatRunning } from './services/wechat-process-status'
import {
inspectImageDecryptionStatus,
testImageDecryption
} from './services/image-decryption-status-service'
import type { SaveImageKeyRequest, TestImageDecryptionRequest } from '../shared/image-decryption'
import { loadSettings, saveSettings, getSettingsPath, AppSettings } from './services/settings-store'
import {
getBootstrapCache,
getCachedMessages,
mergeBootstrapAvatars,
mergeCachedContactAvatars,
saveBootstrapContacts,
saveBootstrapSelf,
saveCachedMessages
} from './services/bootstrap-cache'
import { installSafeConsole } from './safe-log'
import { agentHubService } from './services/agent-hub-service'
import { appLogger } from './app-logger'
import type { AppLogEntry } from '../shared/app-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 voiceService: VoiceService | null = null
let imageDecryptService: ImageDecryptService | null = null
let stickerService: StickerService | null = null
const databaseKeyStore = new DatabaseKeyStore()
const imageKeyConfigService = new ImageKeyConfigService()
const aiProviderService = new AIProviderService()
const keyServiceMac = new KeyServiceMac()
const keyServiceWin = new KeyServiceWin()
let tray: Tray | null = null
const packagedIconPath = join(process.resourcesPath, 'resources', 'icon.png')
const appIconPath = existsSync(packagedIconPath) ? packagedIconPath : icon
// WCDB's Windows runtime checks the host application name during wcdb_init.
// Mirroring WeFlow's name unblocks the -1006 init failure on Windows.
app.setName(process.platform === 'win32' ? 'WeFlow' : 'WechatExplorer')
let dbInitInFlight: Promise<{ success: boolean; monitoring?: boolean; error?: string }> | 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'
function getConfiguredImageKeys(): { xorKey: string; aesKey: string } {
const config = imageKeyConfigService.getConfig()
return {
xorKey: config.xorKey || '0x40',
aesKey: config.aesKey || ''
}
}
function createWindow(): void {
// 鍒涘缓娴忚鍣ㄧ獥鍙?
const mainWindow = new BrowserWindow({
width: 1400,
height: 800,
show: false,
autoHideMenuBar: true,
icon: appIconPath,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false
}
})
mainWindow.on('ready-to-show', () => {
mainWindow.show()
})
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
// 鍩轰簬 electron-vite cli 鐨勬覆鏌撳櫒 HMR
// 鍔犺浇寮€鍙戠幆澧冪殑杩滅▼ URL 鎴栫敓浜х幆澧冪殑鏈湴 html 鏂囦欢
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
}
// 褰?Electron 瀹屾垚鍒濆鍖栧苟鍑嗗濂藉垱寤烘祻瑙堝櫒绐楀彛鏃讹紝灏嗚皟鐢ㄦ鏂规硶
// 鏌愪簺 API 鍙兘鍦ㄦ浜嬩欢鍙戠敓鍚庝娇鐢?
app.whenReady().then(async () => {
console.log(`WechatExplorer main build: ${BUILD_MARK}`)
appLogger.write({
level: 'info',
scope: 'lifecycle',
message: 'WechatExplorer 启动',
details: { build: BUILD_MARK, platform: process.platform, version: app.getVersion() }
})
process.on('uncaughtException', (error) => {
appLogger.write({
level: 'error',
scope: 'main-process',
message: error.message,
details: { stack: error.stack }
})
})
process.on('unhandledRejection', (reason) => {
appLogger.write({
level: 'error',
scope: 'main-process',
message: reason instanceof Error ? reason.message : 'Promise 未处理拒绝',
details: {
stack: reason instanceof Error ? reason.stack : undefined,
reason: reason instanceof Error ? undefined : String(reason)
}
})
})
// WCDB's Windows runtime returns -1006 if wcdb_init is called more than once
// per process. Bootstrap native once here so any later Wcdb4Client instance
// reuses the already-initialized library and skips wcdb_init.
try {
bootstrapWcdbNative()
console.log('[WCDB4] bootstrap complete at whenReady top')
} catch (bootstrapError) {
console.error('[WCDB4] bootstrap failed at whenReady top:', bootstrapError)
}
// 涓虹獥鍙h缃簲鐢ㄧ▼搴忕敤鎴锋ā鍨?ID
electronApp.setAppUserModelId('com.wechatexplorer.app')
if (process.platform === 'darwin') app.dock?.setIcon(appIconPath)
// 鍦ㄥ紑鍙戠幆澧冧腑榛樿鎸?F12 鎵撳紑鎴栧叧闂?DevTools
// 鍦ㄧ敓浜х幆澧冧腑蹇界暐 CommandOrControl + R
// 鍙傝 https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
// IPC test
ipcMain.on('ping', () => console.log('pong'))
ipcMain.handle('app-log:write', (_, entry: AppLogEntry) => appLogger.write(entry))
ipcMain.handle('app-log:getPath', () => appLogger.logPath)
ipcMain.handle('app-log:reveal', () => appLogger.reveal())
ipcMain.handle('db:init', async (_, key: string) => {
if (dbInitInFlight) return dbInitInFlight
dbInitInFlight = (async () => {
try {
const trimmedKey = String(key || '').trim()
console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`)
const settings = loadSettings()
if (
chat.isReady() &&
chat.getCurrentKey().replace(/^0x/i, '').trim() === trimmedKey.replace(/^0x/i, '') &&
(!settings.dbRoot || chat.getCurrentAccountRoot() === settings.dbRoot)
) {
console.log('[WCDB4] db:init reuse current connection')
return { success: true, monitoring: true }
}
const nextWechatDb = await WechatDb.create(key, settings.dbRoot)
const resolvedRoot = nextWechatDb.getWcdb4Client().getAccountRoot()
if (resolvedRoot && resolvedRoot !== settings.dbRoot) {
saveSettings({ ...settings, dbRoot: resolvedRoot })
}
chat.setChatDb(nextWechatDb)
const wcdb4Client = nextWechatDb.getWcdb4Client()
voiceService = new VoiceService(wcdb4Client)
stickerService = new StickerService(wcdb4Client)
const monitoring = wcdb4Client.startMonitor((type, json) => {
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json })
}
})
imageDecryptService = null
return { success: true, monitoring }
} catch (error) {
console.error('Failed to init DB:', error)
return { success: false, error: error instanceof Error ? error.message : String(error) }
} finally {
dbInitInFlight = null
}
})()
return dbInitInFlight
})
ipcMain.handle('key:getSavedDbKey', async () => databaseKeyStore.load())
ipcMain.handle('key:getEnvironment', async () => {
const storage = await databaseKeyStore.getStatus()
const self = chat.getSelfAccountInfo()
return {
platform: process.platform,
autoDetectSupported: process.platform === 'win32',
wechatRunning: await isWindowsWechatRunning(),
accountIdentified: Boolean(self?.wxid),
dbConnected: chat.isReady(),
encryptionAvailable: storage.encryptionAvailable
}
})
ipcMain.handle('key:readClipboardDbKey', () => {
try {
return { success: true, value: clipboard.readText().trim() }
} catch {
return { success: false, error: '无法读取剪贴板' }
}
})
ipcMain.handle('key:pasteAndSaveDbKey', async () => {
const clipboardKey = clipboard.readText().trim()
return databaseKeyStore.save(clipboardKey)
})
ipcMain.handle('key:saveDbKey', async (_, key: string) =>
databaseKeyStore.save(String(key || ''))
)
ipcMain.handle('key:clearSavedDbKey', async () => databaseKeyStore.clear())
ipcMain.handle('key:autoGetDbKey', async (event, options?: { save?: boolean }) => {
const onStatus = (message: string): void => {
if (!event.sender.isDestroyed()) event.sender.send('key:dbKeyStatus', { message })
}
const result =
process.platform === 'win32'
? await keyServiceWin.autoGetDbKey(60_000, onStatus)
: await keyServiceMac.autoGetDbKey(onStatus)
if (!result.success || !result.key) return result
if (options?.save === false) return result
const saved = await databaseKeyStore.save(result.key)
return {
...result,
saved: saved.success,
warning: saved.success ? undefined : saved.error
}
})
ipcMain.handle('key:autoGetImageKey', async (event, options?: { save?: boolean }) => {
const settings = loadSettings()
const self = chat.getSelfAccountInfo()
const accountRoot = settings.imageKeyRoot || self?.accountRoot || settings.dbRoot
const wxid = self?.wxid
const onStatus = (message: string): void => {
if (!event.sender.isDestroyed()) event.sender.send('key:imageKeyStatus', { message })
}
const result =
process.platform === 'win32'
? await keyServiceWin.autoGetImageKeyByMemoryScan(accountRoot, onStatus)
: await keyServiceMac.autoGetImageKey(accountRoot, onStatus, wxid)
if (!result.success || !result.aesKey) return result
if (process.platform === 'win32') {
onStatus('发现候选密钥,图片模板验证通过')
}
const imageXorKey = `0x${Number(result.xorKey ?? 0x40)
.toString(16)
.toUpperCase()
.padStart(2, '0')}`
const verified = result.verified ?? process.platform === 'win32'
if (options?.save === false) {
return { ...result, verified, imageXorKey, imageAesKey: result.aesKey }
}
const saved = imageKeyConfigService.save({
resourceRoot: accountRoot,
xorKey: imageXorKey,
aesKey: result.aesKey
})
if (saved.success) imageDecryptService = null
return {
...result,
success: saved.success,
error: saved.success ? undefined : saved.error,
verified,
imageXorKey,
imageAesKey: result.aesKey,
settings: imageKeyConfigService.getLegacySettingsView()
}
})
ipcMain.handle('image:getConfig', () => imageKeyConfigService.getConfig())
ipcMain.handle('image:getStatus', async () =>
inspectImageDecryptionStatus(imageKeyConfigService.getConfig())
)
ipcMain.handle('image:saveConfig', (_, request: SaveImageKeyRequest) => {
const result = imageKeyConfigService.save(request)
if (result.success) imageDecryptService = null
return result
})
ipcMain.handle('image:testConfig', (_, request: TestImageDecryptionRequest) =>
testImageDecryption(request)
)
ipcMain.handle('image:clearConfig', () => {
const result = imageKeyConfigService.clear()
if (result.success) imageDecryptService = null
return result
})
ipcMain.handle('db:getBootstrapCache', () => {
if (!chat.isReady()) return null
return getBootstrapCache(chat.getCurrentAccountRoot())
})
ipcMain.handle(
'db:getCachedMessages',
(_, userMd5: string, startTime?: number, endTime?: number) => {
if (!chat.isReady()) return []
return getCachedMessages(chat.getCurrentAccountRoot(), userMd5, startTime, endTime)
}
)
ipcMain.handle('db:getContacts', (_, filter?: string) => {
const accountRoot = chat.getCurrentAccountRoot()
const contacts = accountRoot
? mergeCachedContactAvatars(accountRoot, chat.listContacts(filter))
: chat.listContacts(filter)
if (!filter && chat.isReady() && accountRoot) {
saveBootstrapContacts(accountRoot, contacts)
}
return contacts
})
ipcMain.handle('db:getContactAvatars', (_, usernames: string[]) => {
const avatars = chat.getContactAvatars(usernames)
if (chat.isReady()) mergeBootstrapAvatars(chat.getCurrentAccountRoot(), avatars)
return avatars
})
ipcMain.handle(
'db:getMessages',
(_, userMd5: string, startTime?: number, endTime?: number, options?: { limit?: number }) => {
const messages = chat.listMessages(userMd5, startTime, endTime, options)
if (chat.isReady()) {
saveCachedMessages(chat.getCurrentAccountRoot(), userMd5, startTime, endTime, messages)
}
return messages
}
)
ipcMain.handle('db:getGroupSnapshot', (_, userMd5: string) => chat.getGroupSnapshot(userMd5))
ipcMain.handle('db:search', (_, keyword: string) => chat.searchMessages(keyword))
ipcMain.handle(
'ai:chat',
async (_, messages: { role: string; content: string }[], options?: AIChatRequestOptions) =>
aiProviderService.chat(messages, options)
)
ipcMain.handle('ai:listProviders', () => aiProviderService.list())
ipcMain.handle('ai:getRuntimeConfig', () => aiProviderService.getRuntimeConfig())
ipcMain.handle('ai:saveProvider', (_, provider: AIProviderConfig) =>
aiProviderService.save(provider)
)
ipcMain.handle('ai:deleteProvider', (_, providerId: string) =>
aiProviderService.delete(providerId)
)
ipcMain.handle('ai:setDefaultProvider', (_, providerId: string) =>
aiProviderService.setDefault(providerId)
)
ipcMain.handle('ai:testProvider', (_, providerId: string) => aiProviderService.test(providerId))
ipcMain.handle('ai:testVision', (_, request: AIVisionTestRequest) =>
aiProviderService.testVision(request)
)
ipcMain.handle('ai:migrateLegacy', (_, config: LegacyAIConfig) =>
aiProviderService.migrateLegacy(config)
)
ipcMain.handle('copy-image', async (_, base64String) => {
try {
const image = nativeImage.createFromDataURL(base64String)
clipboard.writeImage(image)
return { success: true }
} catch (error: unknown) {
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }
}
})
ipcMain.handle('report:export', async (_, request: GroupReportExportRequest) => {
return exportGroupReport(request)
})
ipcMain.handle('report:listGenerated', async () => {
return listGeneratedReports()
})
ipcMain.handle('report:saveGenerated', async (_, request: SaveGeneratedReportRequest) => {
return saveGeneratedReport(request)
})
ipcMain.handle('report:deleteGenerated', async (_, reportId: string) => {
return deleteGeneratedReport(reportId)
})
ipcMain.handle('report:reveal', async (_, filePath: string) => {
try {
shell.showItemInFolder(filePath)
return { success: true }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
})
ipcMain.handle(
'db:getVoiceData',
async (_, sessionId: string, localId: number, createTime: number, svrId?: string | number) => {
if (!voiceService) {
return { success: false, error: 'VoiceService 未初始化' }
}
return voiceService.resolveVoice(sessionId, localId, createTime, svrId)
}
)
ipcMain.handle('db:parseMessage', async (_, content: string, messageType: number) => {
return parseMessageContent(content, messageType)
})
ipcMain.handle(
'db:getImage',
async (
_,
imageMd5?: string,
imageDatNameOrThumb?: string | boolean,
_sessionId?: string,
options?: { force?: boolean }
) => {
void _sessionId
if (!imageDecryptService) {
const { xorKey, aesKey } = getConfiguredImageKeys()
if (!aesKey) {
return { success: false, error: '未配置图片解密密钥' }
}
imageDecryptService = new ImageDecryptService(
xorKey,
aesKey,
chat.getChatDb()?.getWcdb4Client()
)
}
const imageDatName = typeof imageDatNameOrThumb === 'string' ? imageDatNameOrThumb : undefined
const force = options?.force === true
let filePath = force
? imageDecryptService.findImageFile(imageMd5, imageDatName, { allowThumbnail: false })
: null
if (!filePath) {
filePath = imageDecryptService.findImageFile(imageMd5, imageDatName, {
allowThumbnail: true
})
}
if (!filePath) {
return { success: false, error: force ? '未找到原图或缩略图文件' : '未找到图片文件' }
}
const decrypted = imageDecryptService.decryptImageToBase64WithFallback(filePath, true)
if (!decrypted) {
return { success: false, error: '图片解密失败' }
}
return {
success: true,
data: decrypted.data,
isThumb: imageDecryptService.isThumbnailFile(decrypted.filePath),
filePath: decrypted.filePath
}
}
)
// ============================================================
// AI 图片理解基础设施(ImageInsightService)
// ============================================================
// 注入依赖(用闭包捕获当前 db:getImage 已经初始化过的 imageDecryptService)
// 同时把 imageDecryptService 暴露到 globalThis,供 group-report-service 渲染时按 imageHash 取图
;(globalThis as { __imageDecrypt?: typeof imageDecryptService }).__imageDecrypt =
imageDecryptService
imageInsightService.bind({
providerService: aiProviderService,
decryptService: {
findImageFile: (md5, datName, opts) =>
imageDecryptService?.findImageFile(md5, datName, opts) ?? null,
decryptImageToBase64: (filePath) =>
imageDecryptService?.decryptImageToBase64(filePath) ?? null
}
})
/** 日报入口:取会话 Top N 热点图片 + 已缓存的 Insight */
ipcMain.handle(
'image:listCandidates',
async (
_,
query: ImageCandidateQuery
): Promise<{ success: boolean; candidates: ImageCandidate[]; error?: string }> => {
console.log('[IPC] image:listCandidates query=%j', query)
try {
const inputs = (query as ImageCandidateQuery & { inputs?: unknown[] }).inputs || []
console.log('[IPC] image:listCandidates received %d inputs', inputs.length)
const candidates = await imageInsightService.listTopHotImages(query, inputs as never)
console.log('[IPC] image:listCandidates returned %d candidates', candidates.length)
return { success: true, candidates }
} catch (error) {
console.warn('[IPC] image:listCandidates failed:', error)
return {
success: false,
candidates: [],
error: error instanceof Error ? error.message : String(error)
}
}
}
)
/** 单图分析:缓存命中即返回,未命中调 AI;失败不抛 */
ipcMain.handle(
'image:analyze',
async (_, request: ImageAnalysisRequest): Promise<ImageAnalysisResponse> => {
console.log('[IPC] image:analyze hash=%s messageId=%s', request.imageHash, request.messageId)
// 校验 provider 是否支持 vision
const runtime = aiProviderService.getRuntimeConfig()
if (!runtime.configured) {
return { success: false, error: '尚未配置 AI Provider' }
}
const list = aiProviderService.list()
const provider = list.providers.find((p) => p.id === runtime.providerId)
const model = provider?.models.find((m) => m.id === runtime.model)
if (!provider || !model) {
return { success: false, error: '当前 AI 模型不存在' }
}
// Capability metadata is stored per machine. A model verified on macOS
// may still be unmarked on Windows, so do not reject before making the
// real multimodal request. The provider response remains authoritative.
// request 来自 renderer,imageHash 是 md5(优先)或 sha256(...),dataUrl 在内部算出
// 这里直接调 service,dataUrl 由 renderer 通过 window.api.getImage 拿到再传进来
return imageInsightService.analyze(request)
}
)
/** 单图查询缓存 */
ipcMain.handle(
'image:getInsight',
async (_, imageHash: string): Promise<{ success: boolean; insight?: ImageInsight }> => {
const insight = imageInsightService.getInsight(imageHash)
return { success: true, insight: insight || undefined }
}
)
/** 列出某会话所有已分析的 insights */
ipcMain.handle(
'image:listInsights',
async (
_,
sessionId: string,
limit?: number
): Promise<{ success: boolean; insights: ImageInsight[] }> => {
return { success: true, insights: imageInsightService.listBySession(sessionId, limit) }
}
)
ipcMain.handle('db:getSticker', async (_, cdnUrl?: string, md5?: string) => {
if (!stickerService) {
stickerService = new StickerService(chat.getChatDb()?.getWcdb4Client())
}
return stickerService.resolveSticker(cdnUrl, md5)
})
// -------- Settings & API service --------
ipcMain.handle('settings:get', () => ({
settings: imageKeyConfigService.getLegacySettingsView(),
settingsPath: getSettingsPath()
}))
ipcMain.handle('settings:set', (_, patch: Partial<AppSettings>) => {
const before = loadSettings()
const current = imageKeyConfigService.getConfig()
const resourceRoot = patch.imageKeyRoot ?? before.imageKeyRoot
const xorKey = patch.imageXorKey ?? current.xorKey ?? '0x40'
const aesKey = patch.imageAesKey ?? current.aesKey ?? ''
const includesImageKey = 'imageXorKey' in patch || 'imageAesKey' in patch
if (includesImageKey) {
saveSettings({ ...before, ...patch, imageXorKey: '', imageAesKey: '' })
if (aesKey) imageKeyConfigService.save({ resourceRoot, xorKey, aesKey })
else imageKeyConfigService.clear()
imageDecryptService = null
} else {
saveSettings({ ...before, ...patch, imageXorKey: '', imageAesKey: '' })
}
return {
settings: imageKeyConfigService.getLegacySettingsView(),
settingsPath: getSettingsPath()
}
})
ipcMain.handle('settings:getSelf', () => {
const info = chat.getSelfAccountInfo()
if (!info) return { ready: false }
if (chat.isReady()) saveBootstrapSelf(chat.getCurrentAccountRoot(), info)
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('settings:selectDbRoot', async (event) => {
const result = await dialog.showOpenDialog(BrowserWindow.fromWebContents(event.sender)!, {
title: '选择微信数据库目录',
defaultPath: loadSettings().dbRoot || undefined,
properties: ['openDirectory']
})
return result.canceled ? { canceled: true } : { canceled: false, path: result.filePaths[0] }
})
ipcMain.handle('settings:openAccountRoot', async () => {
const accountRoot = chat.getCurrentAccountRoot()
if (!accountRoot) return { success: false, error: '当前没有可打开的账号目录' }
const error = await shell.openPath(accountRoot)
return error ? { success: false, error } : { success: true }
})
ipcMain.handle('db:disconnect', (_, options?: { closeNative?: boolean }) => {
// 断开操作保持幂等:渲染进程可能已标记断开,或主进程连接已先行失效。
// 即使当前未就绪,也应让用户正常返回登录页。
if (options?.closeNative !== false && chat.isReady()) chat.setChatDb(null)
return { success: true }
})
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()
})
ipcMain.handle('api:skillStatus', () => skillResourceService.getStatus())
ipcMain.handle('api:readSkill', () => skillResourceService.read())
ipcMain.handle('api:revealSkill', () => skillResourceService.reveal())
ipcMain.handle('api:openSkillGithub', () => skillResourceService.openGithub())
ipcMain.handle('api:testLocalRequest', (_, request) => testLocalApiRequest(request))
ipcMain.handle('api:copyText', (_, text: unknown) => {
if (typeof text !== 'string' || text.length > 1024 * 1024) {
return { success: false, error: '复制内容无效或过大' }
}
try {
clipboard.writeText(text)
return { success: true }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
})
ipcMain.handle('agent-hub:getStatus', () => agentHubService.getStatus())
ipcMain.handle('agent-hub:getLogs', () => agentHubService.getLogs())
ipcMain.handle('agent-hub:clearLogs', () => agentHubService.clearLogs())
ipcMain.handle('agent-hub:startLogin', () => agentHubService.startLogin())
ipcMain.handle('agent-hub:cancelLogin', () => agentHubService.cancelLogin())
ipcMain.handle('agent-hub:reconnect', () => agentHubService.reconnect())
ipcMain.handle('agent-hub:disconnect', () => agentHubService.disconnect())
ipcMain.handle('agent-hub:selectTestImage', async (event) => {
const window = BrowserWindow.fromWebContents(event.sender)
const result = await dialog.showOpenDialog(window!, {
title: '选择要测试发送的图片',
properties: ['openFile'],
filters: [
{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'] },
{ name: '所有文件', extensions: ['*'] }
]
})
return result.canceled ? { canceled: true } : { canceled: false, path: result.filePaths[0] }
})
createWindow()
// 鍚姩鏈湴 HTTP API(鏍规嵁 settings.apiEnabled 鎺у埗)
const settings = loadSettings()
if (settings.apiEnabled) {
await apiServer.start(settings.apiHost, settings.apiPort)
}
await agentHubService.start(settings)
if (TRAY_MODE) {
app.dock?.hide()
setupTray()
}
app.on('activate', function () {
// 鍦?macOS 涓婏紝褰撶偣鍑?dock 鍥炬爣涓旀病鏈夊叾浠栫獥鍙f墦寮€鏃讹紝
// 閫氬父浼氬湪搴旂敤绋嬪簭涓噸鏂板垱寤轰竴涓獥鍙c€?
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
// 褰撴墍鏈夌獥鍙e叧闂椂閫€鍑猴紝闄や簡 macOS銆傚湪閭i噷锛?
// 搴旂敤绋嬪簭鍙婂叾鑿滃崟鏍忛€氬父浼氫繚鎸佹椿鍔ㄧ姸鎬侊紝鐩村埌鐢ㄦ埛
// 鏄惧紡浣跨敤 Cmd + Q 閫€鍑恒€?
app.on('window-all-closed', () => {
if (TRAY_MODE) return
if (process.platform !== 'darwin') {
app.quit()
}
})
app.on('before-quit', async () => {
agentHubService.stop()
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(appIconPath)
const traySize = process.platform === 'darwin' ? 20 : 24
const trayImage = image.isEmpty()
? nativeImage.createEmpty()
: image.resize({ width: traySize, height: traySize, quality: 'best' })
tray = new Tray(trayImage)
tray.setToolTip('WechatExplorer')
tray.setContextMenu(buildTrayMenu())
tray.on('click', () => showMainWindow())
} catch (error) {
console.warn('[Tray] Failed to create tray:', error)
}
}