mirror of
https://github.com/kuaifan/dootask.git
synced 2026-03-03 16:02:08 +00:00
feat: 添加 favicon 双层缓存机制
实现仿 Chrome 的 favicon 缓存系统: - 第一层:域名缓存 - 导航开始时立即查询,快速显示 favicon - 第二层:URL 缓存 - favicon URL 精确匹配 - 支持内存缓存 + 文件持久化,应用启动时自动清理 30 天过期缓存
This commit is contained in:
parent
925449c66a
commit
0d85174250
4
electron/electron.js
vendored
4
electron/electron.js
vendored
@ -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,
|
||||
|
||||
356
electron/lib/favicon-cache.js
vendored
Normal file
356
electron/lib/favicon-cache.js
vendored
Normal file
@ -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<string|null>} - 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<string|null>}
|
||||
*/
|
||||
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
|
||||
}
|
||||
19
electron/lib/web-tab-manager.js
vendored
19
electron/lib/web-tab-manager.js
vendored
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user