fix: 将通知头像存储在缓存文件中,通过LRU缓存维护头像缓存数量,点击通知后可以跳转到对应的会话窗口(fixes #654)

This commit is contained in:
fatfathao
2026-04-07 23:25:36 +08:00
parent 902d2c9c74
commit 7a51d8cf64
7 changed files with 283 additions and 97 deletions

View File

@@ -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)
}
},

View File

@@ -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<string, Promise<string | null>> =
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<void> {
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<void> {
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<void> {
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<string | null> {
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<string | null>((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<string | null> {
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<void> {
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<number> {
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();

View File

@@ -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<number, Notification> = new Map();
const closeTimers: Map<number, NodeJS.Timeout> = new Map();
// 头像缓存url->localFilePath
const avatarCache: Map<string, string> = 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<string> {
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<string | null> {
// 检查缓存
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<void>((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<void> {
const caps = await getCapabilities();
console.log("[LinuxNotification] Service initialized with native API:", caps);
}
export async function shutdownLinuxNotificationService(): Promise<void> {
// 清理所有活动的通知
for (const [id, notification] of activeNotifications) {
try {
notification.close();
} catch {}
clearNotificationState(id);
}
// 清理头像文件缓存
try {
await avatarFileCache.clearCache();
} catch {}
console.log("[LinuxNotification] Service shutdown complete");
}

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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<string>