diff --git a/electron/electron.js b/electron/electron.js index 793d2bca2..38a59399a 100644 --- a/electron/electron.js +++ b/electron/electron.js @@ -46,6 +46,7 @@ const {onRenderer, renderer} = require("./lib/renderer"); const {onExport} = require("./lib/pdf-export"); const {allowedCalls, isWin, isMac} = require("./lib/other"); const webTabManager = require("./lib/web-tab-manager"); +const faviconCache = require("./lib/favicon-cache"); // 实例初始化 const userConf = new electronConf() @@ -531,6 +532,9 @@ if (!getTheLock) { isReady = true isWin && app.setAppUserModelId(config.appId) + // 清理过期的 favicon 缓存 + faviconCache.cleanExpiredCache() + // 初始化 webTabManager webTabManager.init({ getServerUrl: () => serverUrl, diff --git a/electron/lib/favicon-cache.js b/electron/lib/favicon-cache.js new file mode 100644 index 000000000..be11629bb --- /dev/null +++ b/electron/lib/favicon-cache.js @@ -0,0 +1,356 @@ +/** + * Favicon 缓存模块 + * + * 实现双层缓存机制(仿 Chrome): + * - 第一层:域名缓存 - 快速响应,在导航开始时立即查询 + * - 第二层:URL 缓存 - 精确匹配,在获取到 favicon URL 后查询 + */ + +const fs = require('fs') +const path = require('path') +const { app, net } = require('electron') + +// 缓存目录 +let cacheDir = null + +// 内存缓存(加速读取) +const memoryCache = { + domains: new Map(), // domain -> base64 + urls: new Map() // url -> base64 +} + +/** + * 初始化缓存目录 + */ +function ensureCacheDir() { + if (cacheDir) return cacheDir + + cacheDir = path.join(app.getPath('userData'), 'favicon-cache') + + // 确保缓存目录存在 + if (!fs.existsSync(cacheDir)) { + fs.mkdirSync(cacheDir, { recursive: true }) + } + + return cacheDir +} + +/** + * 将 URL 或域名转换为安全的文件名 + * @param {string} str - URL 或域名 + * @param {string} prefix - 前缀 ('domain_' 或 'url_') + * @returns {string} 安全的文件名 + */ +function toSafeFileName(str, prefix) { + // 移除协议 + let safe = str.replace(/^https?:\/\//, '') + // 将特殊字符替换为下划线 + safe = safe.replace(/[^a-zA-Z0-9.-]/g, '_') + // 移除连续的下划线 + safe = safe.replace(/_+/g, '_') + // 移除首尾下划线 + safe = safe.replace(/^_|_$/g, '') + // 限制长度(避免文件名过长) + if (safe.length > 200) { + safe = safe.substring(0, 200) + } + return prefix + safe +} + +/** + * 从 URL 提取域名 + * @param {string} url + * @returns {string|null} + */ +function extractDomain(url) { + if (!url) return null + try { + const urlObj = new URL(url) + return urlObj.hostname + } catch { + return null + } +} + +/** + * 获取域名缓存的文件路径 + * @param {string} domain + * @returns {string} + */ +function getDomainCachePath(domain) { + ensureCacheDir() + return path.join(cacheDir, toSafeFileName(domain, 'domain_')) +} + +/** + * 获取 URL 缓存的文件路径 + * @param {string} url + * @returns {string} + */ +function getUrlCachePath(url) { + ensureCacheDir() + return path.join(cacheDir, toSafeFileName(url, 'url_')) +} + +/** + * 根据域名获取缓存的 favicon(第一层缓存) + * @param {string} domain - 域名 + * @returns {string|null} - base64 数据或 null + */ +function getByDomain(domain) { + if (!domain) return null + + // 先查内存缓存 + if (memoryCache.domains.has(domain)) { + return memoryCache.domains.get(domain) + } + + // 再查文件缓存 + const filePath = getDomainCachePath(domain) + try { + if (fs.existsSync(filePath)) { + const data = fs.readFileSync(filePath, 'utf8') + // 写入内存缓存 + memoryCache.domains.set(domain, data) + return data + } + } catch { + // 忽略读取错误 + } + + return null +} + +/** + * 根据 URL 获取缓存的 favicon(第二层缓存) + * @param {string} url - favicon URL + * @returns {string|null} - base64 数据或 null + */ +function getByUrl(url) { + if (!url) return null + + // 先查内存缓存 + if (memoryCache.urls.has(url)) { + return memoryCache.urls.get(url) + } + + // 再查文件缓存 + const filePath = getUrlCachePath(url) + try { + if (fs.existsSync(filePath)) { + const data = fs.readFileSync(filePath, 'utf8') + // 写入内存缓存 + memoryCache.urls.set(url, data) + return data + } + } catch { + // 忽略读取错误 + } + + return null +} + +/** + * 保存 favicon 到缓存 + * @param {string} faviconUrl - favicon 的 URL + * @param {string} pageUrl - 页面的 URL(用于提取域名) + * @param {string} base64Data - base64 编码的 favicon 数据 + */ +function save(faviconUrl, pageUrl, base64Data) { + if (!base64Data) return + + const domain = extractDomain(pageUrl) + + // 保存到 URL 缓存 + if (faviconUrl) { + const urlPath = getUrlCachePath(faviconUrl) + memoryCache.urls.set(faviconUrl, base64Data) + fs.writeFile(urlPath, base64Data, 'utf8', () => {}) + } + + // 保存到域名缓存 + if (domain) { + const domainPath = getDomainCachePath(domain) + memoryCache.domains.set(domain, base64Data) + fs.writeFile(domainPath, base64Data, 'utf8', () => {}) + } +} + +/** + * 获取并缓存 favicon + * 先查缓存,无缓存则下载并保存 + * @param {string} faviconUrl - favicon URL + * @param {string} pageUrl - 页面 URL + * @param {number} timeout - 超时时间(毫秒) + * @returns {Promise} - base64 数据或 null + */ +async function fetchAndCache(faviconUrl, pageUrl, timeout = 5000) { + if (!faviconUrl || typeof faviconUrl !== 'string') { + return null + } + + // 如果已经是 base64,直接保存并返回 + if (faviconUrl.startsWith('data:')) { + save(null, pageUrl, faviconUrl) + return faviconUrl + } + + // 先查 URL 缓存 + const cached = getByUrl(faviconUrl) + if (cached) { + // 更新域名缓存(确保域名缓存是最新的) + const domain = extractDomain(pageUrl) + if (domain && !memoryCache.domains.has(domain)) { + save(null, pageUrl, cached) + } + return cached + } + + // 下载 favicon + const base64Data = await downloadFavicon(faviconUrl, timeout) + + if (base64Data) { + // 保存到缓存 + save(faviconUrl, pageUrl, base64Data) + } + + return base64Data +} + +/** + * 下载 favicon 并转换为 base64 + * @param {string} faviconUrl + * @param {number} timeout + * @returns {Promise} + */ +function downloadFavicon(faviconUrl, timeout = 5000) { + return new Promise((resolve) => { + try { + const request = net.request(faviconUrl) + + const timeoutId = setTimeout(() => { + request.abort() + resolve(null) + }, timeout) + + const chunks = [] + + request.on('response', (response) => { + const contentType = response.headers['content-type'] + const isImage = contentType && ( + contentType.includes('image/') || + contentType.includes('icon') + ) + + if (response.statusCode !== 200 || !isImage) { + clearTimeout(timeoutId) + resolve(null) + return + } + + response.on('data', (chunk) => { + chunks.push(chunk) + }) + + response.on('end', () => { + clearTimeout(timeoutId) + try { + const buffer = Buffer.concat(chunks) + if (buffer.length < 10) { + resolve(null) + return + } + let mimeType = 'image/png' + if (contentType) { + const match = contentType.match(/^([^;]+)/) + if (match) { + mimeType = match[1].trim() + } + } + const base64 = buffer.toString('base64') + resolve(`data:${mimeType};base64,${base64}`) + } catch { + resolve(null) + } + }) + + response.on('error', () => { + clearTimeout(timeoutId) + resolve(null) + }) + }) + + request.on('error', () => { + clearTimeout(timeoutId) + resolve(null) + }) + + request.end() + } catch { + resolve(null) + } + }) +} + +/** + * 清空缓存 + */ +function clearCache() { + memoryCache.domains.clear() + memoryCache.urls.clear() + + if (cacheDir && fs.existsSync(cacheDir)) { + try { + const files = fs.readdirSync(cacheDir) + for (const file of files) { + fs.unlinkSync(path.join(cacheDir, file)) + } + } catch { + // 忽略错误 + } + } +} + +/** + * 清理过期的缓存文件 + * 默认清理 30 天未访问的文件 + * @param {number} maxAgeDays - 最大保留天数,默认 30 + */ +function cleanExpiredCache(maxAgeDays = 30) { + ensureCacheDir() + + if (!cacheDir || !fs.existsSync(cacheDir)) { + return + } + + const now = Date.now() + const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000 + + try { + const files = fs.readdirSync(cacheDir) + for (const file of files) { + const filePath = path.join(cacheDir, file) + try { + const stats = fs.statSync(filePath) + // 使用访问时间(atime)判断是否过期 + if (now - stats.atimeMs > maxAgeMs) { + fs.unlinkSync(filePath) + } + } catch { + // 忽略单个文件的错误 + } + } + } catch { + // 忽略错误 + } +} + +module.exports = { + extractDomain, + getByDomain, + getByUrl, + save, + fetchAndCache, + clearCache, + cleanExpiredCache +} diff --git a/electron/lib/web-tab-manager.js b/electron/lib/web-tab-manager.js index b7a5c41c0..d6ebddd83 100644 --- a/electron/lib/web-tab-manager.js +++ b/electron/lib/web-tab-manager.js @@ -21,6 +21,7 @@ const { const utils = require('./utils') const navigation = require('./navigation') const { allowedCalls, isMac } = require('./other') +const faviconCache = require('./favicon-cache') const { renderer } = require('./renderer') // ============================================================ @@ -224,6 +225,18 @@ function createWebTabWindow(args) { webTabWindow.setTitle(args.title) } } else { + // 从域名缓存获取 favicon(快速响应) + const domain = faviconCache.extractDomain(args.url) + const cachedFavicon = domain ? faviconCache.getByDomain(domain) : null + + // 如果有缓存,保存到视图对象 + if (cachedFavicon) { + const viewItem = windowData.views.find(v => v.id === browserView.webContents.id) + if (viewItem) { + viewItem.favicon = cachedFavicon + } + } + // tab 模式下通知标签栏创建新标签 utils.onDispatchEvent(webTabWindow.webContents, { event: 'create', @@ -232,6 +245,7 @@ function createWebTabWindow(args) { afterId: args.afterId, windowId: windowId, title: args.title, + favicon: cachedFavicon || '', }).then(_ => { }) } activateWebTabInWindow(windowId, browserView.webContents.id) @@ -565,9 +579,10 @@ function createWebTabView(windowId, args) { const tabId = browserView.webContents.id const faviconUrl = favicons[favicons.length - 1] || '' + const pageUrl = browserView.webContents.getURL() - // 验证并转换 favicon 为 base64 - const base64Favicon = await utils.fetchFaviconAsBase64(faviconUrl) + // 使用缓存模块获取 favicon(先查缓存,无则下载并缓存) + const base64Favicon = await faviconCache.fetchAndCache(faviconUrl, pageUrl) // 保存验证后的 favicon 到视图对象 const viewItem = wd.views.find(v => v.id === tabId)