diff --git a/electron/preload.ts b/electron/preload.ts index e90a078..d13f938 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -19,6 +19,11 @@ contextBridge.exposeInMainWorld('electronAPI', { onShow: (callback: (event: any, data: any) => void) => { ipcRenderer.on('notification:show', callback) return () => ipcRenderer.removeAllListeners('notification:show') + }, // 监听原本发送出来的navigate-to-session事件,跳转到具体的会话 + onNavigateToSession: (callback: (sessionId: string) => void) => { + const listener = (_: any, sessionId: string) => callback(sessionId) + ipcRenderer.on('navigate-to-session', listener) + return () => ipcRenderer.removeListener('navigate-to-session', listener) } }, diff --git a/electron/services/avatarFileCacheService.ts b/electron/services/avatarFileCacheService.ts new file mode 100644 index 0000000..7216154 --- /dev/null +++ b/electron/services/avatarFileCacheService.ts @@ -0,0 +1,219 @@ +import https from "https"; +import http, { IncomingMessage } from "http"; +import { promises as fs } from "fs"; +import { join } from "path"; +import { ConfigService } from "./config"; + +// 头像文件缓存服务 - 复用项目已有的缓存目录结构 +export class AvatarFileCacheService { + private static instance: AvatarFileCacheService | null = null; + + // 头像文件缓存目录 + private readonly cacheDir: string; + // 头像URL -> 本地文件路径的内存缓存(仅追踪正在下载的) + private readonly pendingDownloads: Map> = + new Map(); + // LRU 追踪:文件路径->最后访问时间 + private readonly lruOrder: string[] = []; + private readonly maxCacheFiles = 100; + + private constructor() { + const basePath = ConfigService.getInstance().getCacheBasePath(); + this.cacheDir = join(basePath, "avatar-files"); + this.ensureCacheDir(); + this.loadLruOrder(); + } + + public static getInstance(): AvatarFileCacheService { + if (!AvatarFileCacheService.instance) { + AvatarFileCacheService.instance = new AvatarFileCacheService(); + } + return AvatarFileCacheService.instance; + } + + private ensureCacheDir(): void { + // 同步确保目录存在(构造函数调用) + try { + fs.mkdir(this.cacheDir, { recursive: true }).catch(() => {}); + } catch {} + } + + private async ensureCacheDirAsync(): Promise { + try { + await fs.mkdir(this.cacheDir, { recursive: true }); + } catch {} + } + + private getFilePath(url: string): string { + // 使用URL的hash作为文件名,避免特殊字符问题 + const hash = this.hashString(url); + return join(this.cacheDir, `avatar_${hash}.png`); + } + + private hashString(str: string): string { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // 转换为32位整数 + } + return Math.abs(hash).toString(16); + } + + private async loadLruOrder(): Promise { + try { + const entries = await fs.readdir(this.cacheDir); + // 按修改时间排序(旧的在前) + const filesWithTime: { file: string; mtime: number }[] = []; + for (const entry of entries) { + if (!entry.startsWith("avatar_") || !entry.endsWith(".png")) continue; + try { + const stat = await fs.stat(join(this.cacheDir, entry)); + filesWithTime.push({ file: entry, mtime: stat.mtimeMs }); + } catch {} + } + filesWithTime.sort((a, b) => a.mtime - b.mtime); + this.lruOrder.length = 0; + this.lruOrder.push(...filesWithTime.map((f) => f.file)); + } catch {} + } + + private updateLru(fileName: string): void { + const index = this.lruOrder.indexOf(fileName); + if (index > -1) { + this.lruOrder.splice(index, 1); + } + this.lruOrder.push(fileName); + } + + private async evictIfNeeded(): Promise { + while (this.lruOrder.length >= this.maxCacheFiles) { + const oldest = this.lruOrder.shift(); + if (oldest) { + try { + await fs.rm(join(this.cacheDir, oldest)); + console.log(`[AvatarFileCache] Evicted: ${oldest}`); + } catch {} + } + } + } + + private async downloadAvatar(url: string): Promise { + const localPath = this.getFilePath(url); + + // 检查文件是否已存在 + try { + await fs.access(localPath); + const fileName = localPath.split("/").pop()!; + this.updateLru(fileName); + return localPath; + } catch {} + + await this.ensureCacheDirAsync(); + await this.evictIfNeeded(); + + return new Promise((resolve) => { + const options = { + headers: { + "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", + Referer: "https://servicewechat.com/", + Accept: + "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", + "Accept-Encoding": "gzip, deflate, br", + "Accept-Language": "zh-CN,zh;q=0.9", + Connection: "keep-alive", + }, + }; + + const callback = (res: IncomingMessage) => { + if (res.statusCode !== 200) { + resolve(null); + return; + } + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("end", async () => { + try { + const buffer = Buffer.concat(chunks); + await fs.writeFile(localPath, buffer); + const fileName = localPath.split("/").pop()!; + this.updateLru(fileName); + console.log( + `[AvatarFileCache] Downloaded: ${url.substring(0, 50)}... -> ${localPath}`, + ); + resolve(localPath); + } catch { + resolve(null); + } + }); + res.on("error", () => resolve(null)); + }; + + const req = url.startsWith("https") + ? https.get(url, options, callback) + : http.get(url, options, callback); + + req.on("error", () => resolve(null)); + req.setTimeout(10000, () => { + req.destroy(); + resolve(null); + }); + }); + } + + /** + * 获取头像本地文件路径,如果需要会下载 + * 同一URL并发调用会复用同一个下载任务 + */ + async getAvatarPath(url: string): Promise { + if (!url) return null; + + // 检查是否有正在进行的下载 + const pending = this.pendingDownloads.get(url); + if (pending) { + return pending; + } + + // 发起新下载 + const downloadPromise = this.downloadAvatar(url); + this.pendingDownloads.set(url, downloadPromise); + + try { + const result = await downloadPromise; + return result; + } finally { + this.pendingDownloads.delete(url); + } + } + + // 清理所有缓存文件(App退出时调用) + async clearCache(): Promise { + try { + const entries = await fs.readdir(this.cacheDir); + for (const entry of entries) { + if (entry.startsWith("avatar_") && entry.endsWith(".png")) { + try { + await fs.rm(join(this.cacheDir, entry)); + } catch {} + } + } + this.lruOrder.length = 0; + console.log("[AvatarFileCache] Cache cleared"); + } catch {} + } + + // 获取当前缓存的文件数量 + async getCacheCount(): Promise { + try { + const entries = await fs.readdir(this.cacheDir); + return entries.filter( + (e) => e.startsWith("avatar_") && e.endsWith(".png"), + ).length; + } catch { + return 0; + } + } +} + +export const avatarFileCache = AvatarFileCacheService.getInstance(); diff --git a/electron/services/linuxNotificationService.ts b/electron/services/linuxNotificationService.ts index 8ce8238..111626c 100644 --- a/electron/services/linuxNotificationService.ts +++ b/electron/services/linuxNotificationService.ts @@ -1,8 +1,5 @@ -import https from "https"; -import http, { IncomingMessage } from "http"; -import { promises as fs } from "fs"; -import { join } from "path"; -import { app, Notification } from "electron"; +import { Notification } from "electron"; +import { avatarFileCache, AvatarFileCacheService } from "./avatarFileCacheService"; export interface LinuxNotificationData { sessionId?: string; @@ -19,11 +16,6 @@ let notificationCounter = 1; const activeNotifications: Map = new Map(); const closeTimers: Map = new Map(); -// 头像缓存:url->localFilePath -const avatarCache: Map = new Map(); -// 缓存目录 -let avatarCacheDir: string | null = null; - function nextNotificationId(): number { const id = notificationCounter; notificationCounter += 1; @@ -39,91 +31,6 @@ function clearNotificationState(notificationId: number): void { } } -// 确保缓存目录存在 -async function ensureCacheDir(): Promise { - if (!avatarCacheDir) { - avatarCacheDir = join(app.getPath("temp"), "weflow-avatars"); - try { - await fs.mkdir(avatarCacheDir, { recursive: true }); - } catch (error) { - console.error( - "[LinuxNotification] Failed to create avatar cache dir:", - error, - ); - } - } - return avatarCacheDir; -} - -// 下载头像到本地临时文件 -async function downloadAvatarToLocal(url: string): Promise { - // 检查缓存 - if (avatarCache.has(url)) { - return avatarCache.get(url) || null; - } - - try { - const cacheDir = await ensureCacheDir(); - // 生成唯一文件名 - const fileName = `avatar_${Date.now()}_${Math.random().toString(36).substring(2, 8)}.png`; - const localPath = join(cacheDir, fileName); - - await new Promise((resolve, reject) => { - // 微信 CDN 需要特殊的请求头才能下载图片 - const options = { - headers: { - "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", - Referer: "https://servicewechat.com/", - Accept: - "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", - "Accept-Encoding": "gzip, deflate, br", - "Accept-Language": "zh-CN,zh;q=0.9", - Connection: "keep-alive", - }, - }; - - const callback = (res: IncomingMessage) => { - if (res.statusCode !== 200) { - reject(new Error(`HTTP ${res.statusCode}`)); - return; - } - const chunks: Buffer[] = []; - res.on("data", (chunk: Buffer) => chunks.push(chunk)); - res.on("end", async () => { - try { - const buffer = Buffer.concat(chunks); - await fs.writeFile(localPath, buffer); - avatarCache.set(url, localPath); - resolve(); - } catch (err) { - reject(err); - } - }); - res.on("error", reject); - }; - - const req = url.startsWith("https") - ? https.get(url, options, callback) - : http.get(url, options, callback); - - req.on("error", reject); - req.setTimeout(10000, () => { - req.destroy(); - reject(new Error("Download timeout")); - }); - }); - - console.log( - `[LinuxNotification] Avatar downloaded: ${url} -> ${localPath}`, - ); - return localPath; - } catch (error) { - console.error("[LinuxNotification] Failed to download avatar:", error); - return null; - } -} - function triggerNotificationCallback(sessionId: string): void { for (const callback of notificationCallbacks) { try { @@ -149,7 +56,7 @@ export async function showLinuxNotification( try { let iconPath: string | undefined; if (data.avatarUrl) { - iconPath = (await downloadAvatarToLocal(data.avatarUrl)) || undefined; + iconPath = (await avatarFileCache.getAvatarPath(data.avatarUrl)) || undefined; } const notification = new Notification({ @@ -248,3 +155,20 @@ export async function initLinuxNotificationService(): Promise { const caps = await getCapabilities(); console.log("[LinuxNotification] Service initialized with native API:", caps); } + +export async function shutdownLinuxNotificationService(): Promise { + // 清理所有活动的通知 + for (const [id, notification] of activeNotifications) { + try { + notification.close(); + } catch {} + clearNotificationState(id); + } + + // 清理头像文件缓存 + try { + await avatarFileCache.clearCache(); + } catch {} + + console.log("[LinuxNotification] Service shutdown complete"); +} diff --git a/electron/windows/notificationWindow.ts b/electron/windows/notificationWindow.ts index 587f43e..f3c8eca 100644 --- a/electron/windows/notificationWindow.ts +++ b/electron/windows/notificationWindow.ts @@ -27,6 +27,14 @@ export function destroyNotificationWindow() { } lastNotificationData = null; + // Linux:关闭通知服务并清理缓存(fire-and-forget,不阻塞退出) + if (isLinux && linuxNotificationService) { + linuxNotificationService.shutdownLinuxNotificationService().catch((error) => { + console.warn("[NotificationWindow] Failed to shutdown Linux notification service:", error); + }); + linuxNotificationService = null; + } + if (!notificationWindow || notificationWindow.isDestroyed()) { notificationWindow = null; return; diff --git a/src/App.tsx b/src/App.tsx index f54442d..2ccf779 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -339,6 +339,21 @@ function App() { } }, [setUpdateInfo, setDownloadProgress, setShowUpdateDialog, isNotificationWindow]) + // 监听通知点击导航事件 + useEffect(() => { + if (isNotificationWindow) return + + const removeListener = window.electronAPI?.notification?.onNavigateToSession?.((sessionId: string) => { + if (!sessionId) return + // 导航到聊天页面,通过URL参数让ChatPage接收sessionId + navigate(`/chat?sessionId=${encodeURIComponent(sessionId)}`, { replace: true }) + }) + + return () => { + removeListener?.() + } + }, [navigate, isNotificationWindow]) + // 解锁后显示暂存的更新弹窗 useEffect(() => { if (!isLocked && updateInfo?.hasUpdate && !showUpdateDialog && !isDownloading) { diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 5e86cc5..4da71be 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture, Newspaper } from 'lucide-react' -import { useNavigate } from 'react-router-dom' +import { useNavigate, useLocation } from 'react-router-dom' import { createPortal } from 'react-dom' import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso' import { useShallow } from 'zustand/react/shallow' @@ -1142,6 +1142,7 @@ function ChatPage(props: ChatPageProps) { const normalizedStandaloneInitialContactType = useMemo(() => String(standaloneInitialContactType || '').trim().toLowerCase(), [standaloneInitialContactType]) const shouldHideStandaloneDetailButton = standaloneSessionWindow && normalizedStandaloneSource === 'export' const navigate = useNavigate() + const location = useLocation() const { isConnected, @@ -5350,6 +5351,19 @@ function ChatPage(props: ChatPageProps) { selectSessionById ]) + // 监听URL参数中的sessionId,用于通知点击导航 + useEffect(() => { + if (standaloneSessionWindow) return // standalone模式由上面的useEffect处理 + const params = new URLSearchParams(location.search) + const urlSessionId = params.get('sessionId') + if (!urlSessionId) return + if (!isConnected || isConnecting) return + if (currentSessionId === urlSessionId) return + selectSessionById(urlSessionId) + // 选中后清除URL参数,避免影响后续用户手动切换会话 + navigate('/chat', { replace: true }) + }, [standaloneSessionWindow, location.search, isConnected, isConnecting, currentSessionId, selectSessionById, navigate]) + useEffect(() => { if (!standaloneSessionWindow || !normalizedInitialSessionId) return if (!isConnected || isConnecting) { diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 9de0e7b..8741799 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -78,6 +78,7 @@ export interface ElectronAPI { ready: () => void resize: (width: number, height: number) => void onShow: (callback: (event: any, data: any) => void) => () => void + onNavigateToSession: (callback: (sessionId: string) => void) => () => void } log: { getPath: () => Promise