mirror of
https://github.com/kuaifan/dootask.git
synced 2026-03-17 19:23:26 +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 {onExport} = require("./lib/pdf-export");
|
||||||
const {allowedCalls, isWin, isMac} = require("./lib/other");
|
const {allowedCalls, isWin, isMac} = require("./lib/other");
|
||||||
const webTabManager = require("./lib/web-tab-manager");
|
const webTabManager = require("./lib/web-tab-manager");
|
||||||
|
const faviconCache = require("./lib/favicon-cache");
|
||||||
|
|
||||||
// 实例初始化
|
// 实例初始化
|
||||||
const userConf = new electronConf()
|
const userConf = new electronConf()
|
||||||
@ -531,6 +532,9 @@ if (!getTheLock) {
|
|||||||
isReady = true
|
isReady = true
|
||||||
isWin && app.setAppUserModelId(config.appId)
|
isWin && app.setAppUserModelId(config.appId)
|
||||||
|
|
||||||
|
// 清理过期的 favicon 缓存
|
||||||
|
faviconCache.cleanExpiredCache()
|
||||||
|
|
||||||
// 初始化 webTabManager
|
// 初始化 webTabManager
|
||||||
webTabManager.init({
|
webTabManager.init({
|
||||||
getServerUrl: () => serverUrl,
|
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 utils = require('./utils')
|
||||||
const navigation = require('./navigation')
|
const navigation = require('./navigation')
|
||||||
const { allowedCalls, isMac } = require('./other')
|
const { allowedCalls, isMac } = require('./other')
|
||||||
|
const faviconCache = require('./favicon-cache')
|
||||||
const { renderer } = require('./renderer')
|
const { renderer } = require('./renderer')
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@ -224,6 +225,18 @@ function createWebTabWindow(args) {
|
|||||||
webTabWindow.setTitle(args.title)
|
webTabWindow.setTitle(args.title)
|
||||||
}
|
}
|
||||||
} else {
|
} 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 模式下通知标签栏创建新标签
|
// tab 模式下通知标签栏创建新标签
|
||||||
utils.onDispatchEvent(webTabWindow.webContents, {
|
utils.onDispatchEvent(webTabWindow.webContents, {
|
||||||
event: 'create',
|
event: 'create',
|
||||||
@ -232,6 +245,7 @@ function createWebTabWindow(args) {
|
|||||||
afterId: args.afterId,
|
afterId: args.afterId,
|
||||||
windowId: windowId,
|
windowId: windowId,
|
||||||
title: args.title,
|
title: args.title,
|
||||||
|
favicon: cachedFavicon || '',
|
||||||
}).then(_ => { })
|
}).then(_ => { })
|
||||||
}
|
}
|
||||||
activateWebTabInWindow(windowId, browserView.webContents.id)
|
activateWebTabInWindow(windowId, browserView.webContents.id)
|
||||||
@ -565,9 +579,10 @@ function createWebTabView(windowId, args) {
|
|||||||
|
|
||||||
const tabId = browserView.webContents.id
|
const tabId = browserView.webContents.id
|
||||||
const faviconUrl = favicons[favicons.length - 1] || ''
|
const faviconUrl = favicons[favicons.length - 1] || ''
|
||||||
|
const pageUrl = browserView.webContents.getURL()
|
||||||
|
|
||||||
// 验证并转换 favicon 为 base64
|
// 使用缓存模块获取 favicon(先查缓存,无则下载并缓存)
|
||||||
const base64Favicon = await utils.fetchFaviconAsBase64(faviconUrl)
|
const base64Favicon = await faviconCache.fetchAndCache(faviconUrl, pageUrl)
|
||||||
|
|
||||||
// 保存验证后的 favicon 到视图对象
|
// 保存验证后的 favicon 到视图对象
|
||||||
const viewItem = wd.views.find(v => v.id === tabId)
|
const viewItem = wd.views.find(v => v.id === tabId)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user