diff --git a/electron/main.ts b/electron/main.ts index 5b3a16e..73da5f6 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -375,7 +375,34 @@ let isAppQuitting = false let shutdownPromise: Promise | null = null let tray: Tray | null = null let isClosePromptVisible = false -const chatHistoryPayloadStore = new Map() + +interface ChatHistoryPayloadEntry { + sessionId: string + title?: string + recordList: any[] + createdAt: number + lastAccessedAt: number +} + +const chatHistoryPayloadStore = new Map() +const chatHistoryPayloadTtlMs = 10 * 60 * 1000 +const chatHistoryPayloadMaxEntries = 20 + +const pruneChatHistoryPayloadStore = (): void => { + const now = Date.now() + + for (const [payloadId, payload] of chatHistoryPayloadStore.entries()) { + if (now - payload.createdAt > chatHistoryPayloadTtlMs) { + chatHistoryPayloadStore.delete(payloadId) + } + } + + while (chatHistoryPayloadStore.size > chatHistoryPayloadMaxEntries) { + const oldestPayloadId = chatHistoryPayloadStore.keys().next().value as string | undefined + if (!oldestPayloadId) break + chatHistoryPayloadStore.delete(oldestPayloadId) + } +} type WindowCloseBehavior = 'ask' | 'tray' | 'quit' @@ -659,6 +686,62 @@ const setupCustomTitleBarWindow = (win: BrowserWindow): void => { win.webContents.on('did-finish-load', emitMaximizeState) } +let notificationNavigateHandlerRegistered = false +const focusMainWindowAndNavigate = (sessionId: string): void => { + const targetWindow = mainWindow + if (!targetWindow || targetWindow.isDestroyed()) return + if (targetWindow.isMinimized()) targetWindow.restore() + targetWindow.show() + targetWindow.focus() + targetWindow.webContents.send('navigate-to-session', sessionId) +} + +const ensureNotificationNavigateHandlerRegistered = (): void => { + if (notificationNavigateHandlerRegistered) return + notificationNavigateHandlerRegistered = true + ipcMain.on('notification-clicked', (_event, sessionId) => { + focusMainWindowAndNavigate(String(sessionId || '')) + }) + setNotificationNavigateHandler((sessionId: string) => { + focusMainWindowAndNavigate(String(sessionId || '')) + }) +} + +let wechatRequestHeaderInterceptorRegistered = false +const ensureWeChatRequestHeaderInterceptor = (): void => { + if (wechatRequestHeaderInterceptorRegistered) return + wechatRequestHeaderInterceptorRegistered = true + + session.defaultSession.webRequest.onBeforeSendHeaders( + { + urls: [ + '*://*.qpic.cn/*', + '*://*.qlogo.cn/*', + '*://*.wechat.com/*', + '*://*.weixin.qq.com/*', + '*://*.wx.qq.com/*' + ] + }, + (details, callback) => { + details.requestHeaders['User-Agent'] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351" + details.requestHeaders['Accept'] = "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8" + details.requestHeaders['Accept-Encoding'] = "gzip, deflate, br" + details.requestHeaders['Accept-Language'] = "zh-CN,zh;q=0.9" + details.requestHeaders['Connection'] = "keep-alive" + details.requestHeaders['Range'] = "bytes=0-" + + let host = '' + try { + host = new URL(details.url).hostname.toLowerCase() + } catch {} + const isWxQQ = host === 'wx.qq.com' || host.endsWith('.wx.qq.com') + details.requestHeaders['Referer'] = isWxQQ ? 'https://wx.qq.com/' : 'https://servicewechat.com/' + + callback({ cancel: false, requestHeaders: details.requestHeaders }) + } + ) +} + const getWindowCloseBehavior = (): WindowCloseBehavior => { const behavior = configService?.get('windowCloseBehavior') return behavior === 'tray' || behavior === 'quit' ? behavior : 'ask' @@ -734,44 +817,6 @@ function createWindow(options: { autoShow?: boolean } = {}) { win.loadFile(join(__dirname, '../dist/index.html')) } - // Handle notification click navigation - ipcMain.on('notification-clicked', (_, sessionId) => { - if (win.isMinimized()) win.restore() - win.show() - win.focus() - win.webContents.send('navigate-to-session', sessionId) - }) - - // 设置用于D-Bus通知的Linux通知导航处理程序 - setNotificationNavigateHandler((sessionId: string) => { - if (win.isMinimized()) win.restore() - win.show() - win.focus() - win.webContents.send('navigate-to-session', sessionId) - }) - - // 拦截请求,修改 Referer 和 User-Agent 以通过微信 CDN 鉴权 - session.defaultSession.webRequest.onBeforeSendHeaders( - { - urls: [ - '*://*.qpic.cn/*', - '*://*.qlogo.cn/*', - '*://*.wechat.com/*', - '*://*.weixin.qq.com/*' - ] - }, - (details, callback) => { - details.requestHeaders['User-Agent'] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351" - details.requestHeaders['Accept'] = "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8" - details.requestHeaders['Accept-Encoding'] = "gzip, deflate, br" - details.requestHeaders['Accept-Language'] = "zh-CN,zh;q=0.9" - details.requestHeaders['Referer'] = "https://servicewechat.com/" - details.requestHeaders['Connection'] = "keep-alive" - details.requestHeaders['Range'] = "bytes=0-" - callback({ cancel: false, requestHeaders: details.requestHeaders }) - } - ) - // 忽略微信 CDN 域名的证书错误(部分节点证书配置不正确) win.webContents.on('certificate-error', (event, url, _error, _cert, callback) => { const trusted = ['.qq.com', '.qpic.cn', '.weixin.qq.com', '.wechat.com'] @@ -1179,7 +1224,11 @@ function createChatHistoryWindow(sessionId: string, messageId: number) { } function createChatHistoryPayloadWindow(payloadId: string) { - return createChatHistoryRouteWindow(`/chat-history-inline/${payloadId}`) + const win = createChatHistoryRouteWindow(`/chat-history-inline/${payloadId}`) + win.on('closed', () => { + chatHistoryPayloadStore.delete(payloadId) + }) + return win } function createChatHistoryRouteWindow(route: string) { @@ -1612,6 +1661,7 @@ const runLegacySnsCacheMigration = async ( // 注册 IPC 处理器 function registerIpcHandlers() { registerNotificationHandlers() + ensureNotificationNavigateHandlerRegistered() bizService.registerHandlers() // 配置相关 ipcMain.handle('config:get', async (_, key: string) => { @@ -1989,19 +2039,38 @@ function registerIpcHandlers() { ipcMain.handle('window:openChatHistoryPayloadWindow', (_, payload: { sessionId: string; title?: string; recordList: any[] }) => { const payloadId = randomUUID() + pruneChatHistoryPayloadStore() + const now = Date.now() chatHistoryPayloadStore.set(payloadId, { sessionId: String(payload?.sessionId || '').trim(), title: String(payload?.title || '').trim() || '聊天记录', - recordList: Array.isArray(payload?.recordList) ? payload.recordList : [] + recordList: Array.isArray(payload?.recordList) ? payload.recordList : [], + createdAt: now, + lastAccessedAt: now }) + pruneChatHistoryPayloadStore() createChatHistoryPayloadWindow(payloadId) return true }) ipcMain.handle('window:getChatHistoryPayload', (_, payloadId: string) => { - const payload = chatHistoryPayloadStore.get(String(payloadId || '').trim()) + pruneChatHistoryPayloadStore() + const normalizedPayloadId = String(payloadId || '').trim() + const payload = chatHistoryPayloadStore.get(normalizedPayloadId) if (!payload) return { success: false, error: '聊天记录载荷不存在或已失效' } - return { success: true, payload } + const nextPayload: ChatHistoryPayloadEntry = { + ...payload, + lastAccessedAt: Date.now() + } + chatHistoryPayloadStore.set(normalizedPayloadId, nextPayload) + return { + success: true, + payload: { + sessionId: nextPayload.sessionId, + title: nextPayload.title, + recordList: nextPayload.recordList + } + } }) // 打开会话聊天窗口(同会话仅保留一个窗口并聚焦) @@ -3052,6 +3121,7 @@ function registerIpcHandlers() { ipcMain.handle('cache:clearImages', async () => { const imageResult = await imageDecryptService.clearCache() const emojiResult = chatService.clearCaches({ includeMessages: false, includeContacts: false, includeEmojis: true }) + snsService.clearMemoryCache() const errors = [imageResult, emojiResult] .filter((result) => !result.success) .map((result) => result.error) @@ -3068,6 +3138,7 @@ function registerIpcHandlers() { imageDecryptService.clearCache() ]) const chatResult = chatService.clearCaches() + snsService.clearMemoryCache() const errors = [analyticsResult, imageResult, chatResult] .filter((result) => !result.success) .map((result) => result.error) @@ -3790,6 +3861,7 @@ app.whenReady().then(async () => { // 创建主窗口(不显示,由启动流程统一控制) updateSplashProgress(70, '正在准备主窗口...') + ensureWeChatRequestHeaderInterceptor() mainWindow = createWindow({ autoShow: false }) let iconName = 'icon.ico'; @@ -3849,17 +3921,6 @@ app.whenReady().then(async () => { console.warn('[Tray] Failed to create tray icon:', e) } - // 配置网络服务 - session.defaultSession.webRequest.onBeforeSendHeaders( - { - urls: ['*://*.qpic.cn/*', '*://*.wx.qq.com/*'] - }, - (details, callback) => { - details.requestHeaders['Referer'] = 'https://wx.qq.com/' - callback({ requestHeaders: details.requestHeaders }) - } - ) - // 等待主窗口加载完成(真正耗时阶段,进度条末端呼吸光点) updateSplashProgress(70, '正在准备主窗口...', true) await new Promise((resolve) => { diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 5f305c1..bedb464 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -347,6 +347,7 @@ class ChatService { private messageCursors: Map = new Map() private messageCursorMutex: boolean = false private readonly messageBatchDefault = 50 + private readonly messageCursorSessionLimit = 8 private avatarCache: Map private readonly avatarCacheTtlMs = 10 * 60 * 1000 private readonly defaultV1AesKey = 'cfcd208495d565ef' @@ -671,6 +672,27 @@ class ChatService { /** * 关闭数据库连接 */ + private async closeMessageCursorBySession(sessionId: string): Promise { + const state = this.messageCursors.get(sessionId) + if (!state) return + try { + await wcdbService.closeMessageCursor(state.cursor) + } catch (error) { + console.warn(`[ChatService] 关闭消息游标失败: ${sessionId}`, error) + } finally { + this.messageCursors.delete(sessionId) + } + } + + private async trimMessageCursorStates(activeSessionId: string): Promise { + if (this.messageCursors.size <= this.messageCursorSessionLimit) return + for (const [sessionId] of this.messageCursors) { + if (this.messageCursors.size <= this.messageCursorSessionLimit) break + if (sessionId === activeSessionId) continue + await this.closeMessageCursorBySession(sessionId) + } + } + close(): void { try { for (const state of this.messageCursors.values()) { @@ -1956,6 +1978,11 @@ class ChatService { } let state = this.messageCursors.get(sessionId) + if (state) { + // refresh insertion order so Map iteration approximates LRU + this.messageCursors.delete(sessionId) + this.messageCursors.set(sessionId, state) + } // 只在以下情况重新创建游标: // 1. 没有游标状态 @@ -1974,7 +2001,7 @@ class ChatService { // 关闭旧游标 if (state) { try { - await wcdbService.closeMessageCursor(state.cursor) + await this.closeMessageCursorBySession(sessionId) } catch (e) { console.warn('[ChatService] 关闭旧游标失败:', e) } @@ -1992,6 +2019,7 @@ class ChatService { state = { cursor: cursorResult.cursor, fetched: 0, batchSize, startTime, endTime, ascending } this.messageCursors.set(sessionId, state) + await this.trimMessageCursorStates(sessionId) // 如果需要跳过消息(offset > 0),逐批获取但不返回 // 注意:仅在 offset === 0 时重建游标最安全; @@ -2062,6 +2090,8 @@ class ChatService { const filtered = collected.messages || [] const hasMore = collected.hasMore === true state.fetched += rawRowsConsumed + this.messageCursors.delete(sessionId) + this.messageCursors.set(sessionId, state) releaseMessageCursorMutex?.() this.messageCacheService.set(sessionId, filtered) diff --git a/electron/services/httpService.ts b/electron/services/httpService.ts index 195d263..c4df57e 100644 --- a/electron/services/httpService.ts +++ b/electron/services/httpService.ts @@ -496,11 +496,20 @@ class HttpService { const contentType = mimeTypes[ext] || 'application/octet-stream' try { - const fileBuffer = fs.readFileSync(fullPath) + const stat = fs.statSync(fullPath) res.setHeader('Content-Type', contentType) - res.setHeader('Content-Length', fileBuffer.length) + res.setHeader('Content-Length', stat.size) res.writeHead(200) - res.end(fileBuffer) + + const stream = fs.createReadStream(fullPath) + stream.on('error', () => { + if (!res.headersSent) { + this.sendError(res, 500, 'Failed to read media file') + } else { + try { res.destroy() } catch {} + } + }) + stream.pipe(res) } catch (e) { this.sendError(res, 500, 'Failed to read media file') } diff --git a/electron/services/messageCacheService.ts b/electron/services/messageCacheService.ts index 091e5f4..9d3079a 100644 --- a/electron/services/messageCacheService.ts +++ b/electron/services/messageCacheService.ts @@ -12,6 +12,7 @@ export class MessageCacheService { private readonly cacheFilePath: string private cache: Record = {} private readonly sessionLimit = 150 + private readonly maxSessionEntries = 48 constructor(cacheBasePath?: string) { const basePath = cacheBasePath && cacheBasePath.trim().length > 0 @@ -36,6 +37,7 @@ export class MessageCacheService { const parsed = JSON.parse(raw) if (parsed && typeof parsed === 'object') { this.cache = parsed + this.pruneSessionEntries() } } catch (error) { console.error('MessageCacheService: 载入缓存失败', error) @@ -43,6 +45,19 @@ export class MessageCacheService { } } + private pruneSessionEntries(): void { + const entries = Object.entries(this.cache || {}) + if (entries.length <= this.maxSessionEntries) return + + entries.sort((left, right) => { + const leftAt = Number(left[1]?.updatedAt || 0) + const rightAt = Number(right[1]?.updatedAt || 0) + return rightAt - leftAt + }) + + this.cache = Object.fromEntries(entries.slice(0, this.maxSessionEntries)) + } + get(sessionId: string): SessionMessageCacheEntry | undefined { return this.cache[sessionId] } @@ -56,6 +71,7 @@ export class MessageCacheService { updatedAt: Date.now(), messages: trimmed } + this.pruneSessionEntries() this.persist() } diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index 69f5841..6b5af11 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -324,6 +324,9 @@ class SnsService { private configService: ConfigService private contactCache: ContactCacheService private imageCache = new Map() + private imageCacheMeta = new Map() + private readonly imageCacheTtlMs = 15 * 60 * 1000 + private readonly imageCacheMaxEntries = 120 private exportStatsCache: { totalPosts: number; totalFriends: number; myPosts: number | null; updatedAt: number } | null = null private userPostCountsCache: { counts: Record; updatedAt: number } | null = null private readonly exportStatsCacheTtlMs = 5 * 60 * 1000 @@ -336,6 +339,38 @@ class SnsService { this.contactCache = new ContactCacheService(this.configService.get('cachePath') as string) } + clearMemoryCache(): void { + this.imageCache.clear() + this.imageCacheMeta.clear() + } + + private pruneImageCache(now: number = Date.now()): void { + for (const [key, updatedAt] of this.imageCacheMeta.entries()) { + if (now - updatedAt > this.imageCacheTtlMs) { + this.imageCacheMeta.delete(key) + this.imageCache.delete(key) + } + } + + while (this.imageCache.size > this.imageCacheMaxEntries) { + const oldestKey = this.imageCache.keys().next().value as string | undefined + if (!oldestKey) break + this.imageCache.delete(oldestKey) + this.imageCacheMeta.delete(oldestKey) + } + } + + private rememberImageCache(cacheKey: string, dataUrl: string): void { + if (!cacheKey || !dataUrl) return + const now = Date.now() + if (this.imageCache.has(cacheKey)) { + this.imageCache.delete(cacheKey) + } + this.imageCache.set(cacheKey, dataUrl) + this.imageCacheMeta.set(cacheKey, now) + this.pruneImageCache(now) + } + private toOptionalString(value: unknown): string | undefined { if (typeof value !== 'string') return undefined const trimmed = value.trim() @@ -1239,20 +1274,27 @@ class SnsService { if (!url) return { success: false, error: 'url 不能为空' } const cacheKey = `${url}|${key ?? ''}` - if (this.imageCache.has(cacheKey)) { - const cachedDataUrl = this.imageCache.get(cacheKey) || '' - const base64Part = cachedDataUrl.split(',')[1] || '' - if (base64Part) { - try { - const cachedBuf = Buffer.from(base64Part, 'base64') - if (detectImageMime(cachedBuf, '').startsWith('image/')) { - return { success: true, dataUrl: cachedDataUrl } + const cachedDataUrl = this.imageCache.get(cacheKey) || '' + if (cachedDataUrl) { + const cachedAt = this.imageCacheMeta.get(cacheKey) || 0 + if (cachedAt > 0 && Date.now() - cachedAt <= this.imageCacheTtlMs) { + const base64Part = cachedDataUrl.split(',')[1] || '' + if (base64Part) { + try { + const cachedBuf = Buffer.from(base64Part, 'base64') + if (detectImageMime(cachedBuf, '').startsWith('image/')) { + this.imageCache.delete(cacheKey) + this.imageCache.set(cacheKey, cachedDataUrl) + this.imageCacheMeta.set(cacheKey, Date.now()) + return { success: true, dataUrl: cachedDataUrl } + } + } catch { + // ignore and fall through to refetch } - } catch { - // ignore and fall through to refetch } } this.imageCache.delete(cacheKey) + this.imageCacheMeta.delete(cacheKey) } const result = await this.fetchAndDecryptImage(url, key) @@ -1269,7 +1311,7 @@ class SnsService { return { success: false, error: '无效图片数据(可能密钥不匹配或缓存损坏)' } } const dataUrl = `data:${result.contentType};base64,${result.data.toString('base64')}` - this.imageCache.set(cacheKey, dataUrl) + this.rememberImageCache(cacheKey, dataUrl) return { success: true, dataUrl } } } diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx index 69020f1..304a50a 100644 --- a/src/components/Avatar.tsx +++ b/src/components/Avatar.tsx @@ -5,6 +5,21 @@ import './Avatar.scss' // 全局缓存已成功加载过的头像 URL,用于控制后续是否显示动画 const loadedAvatarCache = new Set() +const MAX_LOADED_AVATAR_CACHE_SIZE = 3000 + +const rememberLoadedAvatar = (src: string): void => { + if (!src) return + if (loadedAvatarCache.has(src)) { + loadedAvatarCache.delete(src) + } + loadedAvatarCache.add(src) + + while (loadedAvatarCache.size > MAX_LOADED_AVATAR_CACHE_SIZE) { + const oldest = loadedAvatarCache.values().next().value as string | undefined + if (!oldest) break + loadedAvatarCache.delete(oldest) + } +} interface AvatarProps { src?: string @@ -123,7 +138,7 @@ export const Avatar = React.memo(function Avatar({ onLoad={() => { if (src) { avatarLoadQueue.clearFailed(src) - loadedAvatarCache.add(src) + rememberLoadedAvatar(src) } setImageLoaded(true) setImageError(false)