浏览器下载插件
This commit is contained in:
@@ -3,9 +3,8 @@
|
|||||||
*
|
*
|
||||||
* 职责:
|
* 职责:
|
||||||
* 1. 接管浏览器下载,转发到 aria2
|
* 1. 接管浏览器下载,转发到 aria2
|
||||||
* 2. 与本应用(可选)通过 JSON-RPC 通信
|
* 2. 资源嗅探:识别视频/音频/图片/压缩包/安装包等,存储到内存供 popup 查看
|
||||||
* 3. 提供右键菜单"使用 aria2 下载链接"
|
* 3. 右键菜单"使用 aria2 下载"
|
||||||
* 4. 预留资源嗅探能力(webRequest 监听)
|
|
||||||
*
|
*
|
||||||
* 注:Service Worker 是短生命周期的,配置需持久化到 chrome.storage
|
* 注:Service Worker 是短生命周期的,配置需持久化到 chrome.storage
|
||||||
*/
|
*/
|
||||||
@@ -14,14 +13,282 @@
|
|||||||
const DEFAULT_CONFIG = {
|
const DEFAULT_CONFIG = {
|
||||||
rpcUrl: 'http://127.0.0.1:6800/jsonrpc',
|
rpcUrl: 'http://127.0.0.1:6800/jsonrpc',
|
||||||
rpcSecret: '',
|
rpcSecret: '',
|
||||||
// 是否拦截浏览器原生下载
|
|
||||||
interceptDownload: true,
|
interceptDownload: true,
|
||||||
// 文件大小阈值(字节),超过才转 aria2。0 = 全部转
|
|
||||||
minSize: 0,
|
minSize: 0,
|
||||||
// 排除的域名(这些域名的下载走浏览器原生)
|
|
||||||
excludeDomains: [],
|
excludeDomains: [],
|
||||||
// 是否显示桌面通知
|
showNotifications: true,
|
||||||
showNotifications: true
|
// 嗅探开关
|
||||||
|
sniffEnabled: true,
|
||||||
|
// 嗅探的资源类型:只保留视频/音频/图片/压缩包/种子/安装包
|
||||||
|
// 其他类型(文档、字幕、json、js、css 等)一律不嗅探
|
||||||
|
sniffTypes: ['video', 'audio', 'image', 'archive', 'torrent', 'installer'],
|
||||||
|
// 嗅探记录最大数量(避免无限增长)
|
||||||
|
sniffMaxItems: 200,
|
||||||
|
// 嗅探最小文件大小(字节,0=全部)。仅对有 Content-Length 的请求生效
|
||||||
|
sniffMinSize: 1024 * 100, // 100KB
|
||||||
|
// 嗅探排除的域名(CDN、统计等噪声)
|
||||||
|
sniffExcludeDomains: ['google-analytics.com', 'doubleclick.net', 'facebook.net', 'googletagmanager.com']
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 非资源扩展名黑名单 =====
|
||||||
|
// 这些扩展名即使出现在 URL 中也不是可下载资源(接口、前端资源、数据文件等)
|
||||||
|
const NON_RESOURCE_EXTS = new Set([
|
||||||
|
// 数据/配置
|
||||||
|
'json', 'xml', 'yaml', 'yml', 'toml', 'ini', 'conf', 'env',
|
||||||
|
// 前端资源
|
||||||
|
'js', 'mjs', 'cjs', 'css', 'scss', 'sass', 'less', 'html', 'htm', 'vue', 'jsx', 'tsx',
|
||||||
|
// 字体
|
||||||
|
'woff', 'woff2', 'ttf', 'otf', 'eot',
|
||||||
|
// 网页元数据
|
||||||
|
'ico', 'webmanifest', 'sitemap', 'rss', 'atom',
|
||||||
|
// 接口常见后缀(部分 API 返回这些格式)
|
||||||
|
'jsonp', 'graphql',
|
||||||
|
// 地图/数据
|
||||||
|
'map', 'geojson', 'protobuf', 'pb',
|
||||||
|
// 模板/源码
|
||||||
|
'tpl', 'pug', 'md', 'rst',
|
||||||
|
])
|
||||||
|
|
||||||
|
// ===== API 路径关键词黑名单 =====
|
||||||
|
// URL 路径包含这些关键词的视为接口请求,不嗅探
|
||||||
|
const API_PATH_KEYWORDS = [
|
||||||
|
'/api/', '/api/', '/v1/', '/v2/', '/v3/',
|
||||||
|
'/init', '/send', '/track', '/log', '/beacon', '/analytics',
|
||||||
|
'/collect', '/report', '/event', '/events', '/metrics',
|
||||||
|
'/stat', '/stats', '/count', '/click', '/redirect',
|
||||||
|
'/auth', '/login', '/logout', '/token', '/session',
|
||||||
|
'/webhook', '/callback', '/notify',
|
||||||
|
'/sentry', '/rum', '/monitor', '/perf',
|
||||||
|
'/rpc', '/jsonrpc', '/graphql'
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断 URL 是否是 API/接口请求(应排除)
|
||||||
|
*/
|
||||||
|
function isApiRequest(url) {
|
||||||
|
let path = ''
|
||||||
|
try {
|
||||||
|
path = new URL(url).pathname.toLowerCase()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// 路径无扩展名且包含 api 关键词
|
||||||
|
if (API_PATH_KEYWORDS.some(kw => path.includes(kw))) return true
|
||||||
|
// 路径以 /api 开头
|
||||||
|
if (path.startsWith('/api') || path.startsWith('/rpc')) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 资源类型识别规则 =====
|
||||||
|
// 按扩展名 + MIME 类型识别
|
||||||
|
// 只保留用户需要的 6 类:视频/音频/图片/压缩包/种子/安装包
|
||||||
|
const RESOURCE_RULES = [
|
||||||
|
{
|
||||||
|
type: 'video',
|
||||||
|
label: '视频',
|
||||||
|
color: '#ef4444',
|
||||||
|
exts: ['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v', 'mpg', 'mpeg', 'ts', 'm3u8', 'rmvb', 'rm', 'm4s', 'f4v', '3gp', 'ogv'],
|
||||||
|
mimes: ['video/']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'audio',
|
||||||
|
label: '音频',
|
||||||
|
color: '#f59e0b',
|
||||||
|
exts: ['mp3', 'flac', 'ape', 'wav', 'aac', 'ogg', 'm4a', 'wma', 'opus', 'm3u'],
|
||||||
|
mimes: ['audio/']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'image',
|
||||||
|
label: '图片',
|
||||||
|
color: '#10b981',
|
||||||
|
exts: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'tiff', 'heic', 'avif'],
|
||||||
|
mimes: ['image/']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'archive',
|
||||||
|
label: '压缩包',
|
||||||
|
color: '#8b5cf6',
|
||||||
|
exts: ['zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'iso', 'cab', 'tgz', 'tbz2'],
|
||||||
|
mimes: ['application/zip', 'application/x-rar', 'application/x-7z', 'application/x-tar', 'application/gzip', 'application/x-iso9660-image']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'installer',
|
||||||
|
label: '安装包',
|
||||||
|
color: '#3b82f6',
|
||||||
|
exts: ['exe', 'msi', 'dmg', 'pkg', 'deb', 'rpm', 'appimage', 'apk', 'ipa'],
|
||||||
|
mimes: ['application/x-msdownload', 'application/x-msi', 'application/vnd.android.package-archive']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'torrent',
|
||||||
|
label: '种子',
|
||||||
|
color: '#14b8a6',
|
||||||
|
exts: ['torrent'],
|
||||||
|
mimes: ['application/x-bittorrent']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// ===== 流媒体 URL 路径关键词 =====
|
||||||
|
// B站等流媒体通过 xhr 加载 .m4s 分片,URL 路径包含这些关键词且 MIME 为二进制流时识别为视频
|
||||||
|
const STREAM_PATH_KEYWORDS = [
|
||||||
|
'/upgcxcode/', // B站视频分片
|
||||||
|
'/video/', // 通用视频路径
|
||||||
|
'/media/', // 通用媒体路径
|
||||||
|
'/stream/', // 流媒体路径
|
||||||
|
'/play/', // 播放路径
|
||||||
|
'/dash/', // DASH 流
|
||||||
|
'/hls/', // HLS 流
|
||||||
|
'/video/', // 视频流
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 识别资源类型
|
||||||
|
* @param {string} url
|
||||||
|
* @param {string} [mimeType]
|
||||||
|
* @returns {{type:string,label:string,color:string}|null} 匹配失败返回 null(不嗅探)
|
||||||
|
*/
|
||||||
|
function identifyResource(url, mimeType) {
|
||||||
|
let path = url
|
||||||
|
let pathname = ''
|
||||||
|
try {
|
||||||
|
const u = new URL(url)
|
||||||
|
path = u.pathname
|
||||||
|
pathname = u.pathname.toLowerCase()
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
// 去掉查询参数后取扩展名
|
||||||
|
const ext = path.split('?')[0].split('/').pop()?.split('.').pop()?.toLowerCase() || ''
|
||||||
|
|
||||||
|
// 非资源扩展名黑名单(json/js/css 等)直接排除
|
||||||
|
if (ext && NON_RESOURCE_EXTS.has(ext)) return null
|
||||||
|
|
||||||
|
// API 接口请求排除
|
||||||
|
if (isApiRequest(url)) return null
|
||||||
|
|
||||||
|
// 先按扩展名 + MIME 匹配规则
|
||||||
|
for (const rule of RESOURCE_RULES) {
|
||||||
|
if (ext && rule.exts.includes(ext)) return rule
|
||||||
|
if (mimeType && rule.mimes.some(m => mimeType.toLowerCase().startsWith(m))) return rule
|
||||||
|
}
|
||||||
|
|
||||||
|
// 流媒体特殊识别:MIME 为二进制流且 URL 路径含视频关键词,识别为视频
|
||||||
|
// 这能抓到 B站等没有扩展名但通过路径关键词标识的流媒体分片
|
||||||
|
if (mimeType && (mimeType === 'application/octet-stream' || mimeType === 'application/vnd.apple.mpegurl' || mimeType === 'video/mp2t')) {
|
||||||
|
if (STREAM_PATH_KEYWORDS.some(kw => pathname.includes(kw))) {
|
||||||
|
return RESOURCE_RULES[0] // video
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未命中任何规则,不视为资源(避免 json/js 等被误判为"其他")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 URL 提取文件名
|
||||||
|
*/
|
||||||
|
function extractFilename(url, contentDisposition) {
|
||||||
|
// 优先从 Content-Disposition 提取
|
||||||
|
if (contentDisposition) {
|
||||||
|
const m = contentDisposition.match(/filename\*?=(?:UTF-8'')?["']?([^"';]+)/i)
|
||||||
|
if (m && m[1]) {
|
||||||
|
try { return decodeURIComponent(m[1]) } catch { return m[1] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 从 URL 提取
|
||||||
|
try {
|
||||||
|
const u = new URL(url)
|
||||||
|
const name = u.pathname.split('/').pop()
|
||||||
|
if (name) return decodeURIComponent(name)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化文件大小
|
||||||
|
*/
|
||||||
|
function formatSize(bytes) {
|
||||||
|
if (!bytes || bytes <= 0) return '—'
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||||
|
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 资源嗅探存储 =====
|
||||||
|
// 使用 chrome.storage.local 持久化(Service Worker 可能随时重启)
|
||||||
|
const SNIFF_STORAGE_KEY = 'sniffedResources'
|
||||||
|
// 按 tabId 分组:{ [tabId]: { url, title, resources: [] } }
|
||||||
|
// 同时保留一个全局合并视图
|
||||||
|
|
||||||
|
async function getSniffedAll() {
|
||||||
|
const data = await chrome.storage.local.get(SNIFF_STORAGE_KEY)
|
||||||
|
return data[SNIFF_STORAGE_KEY] || {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSniffedAll(all) {
|
||||||
|
await chrome.storage.local.set({ [SNIFF_STORAGE_KEY]: all })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加一条嗅探记录
|
||||||
|
*/
|
||||||
|
async function addSniffedItem(tabId, tabUrl, tabTitle, item) {
|
||||||
|
const config = await getConfig()
|
||||||
|
if (!config.sniffEnabled) return
|
||||||
|
if (!config.sniffTypes.includes(item.type)) return
|
||||||
|
|
||||||
|
// 排除域名
|
||||||
|
try {
|
||||||
|
const host = new URL(item.url).hostname
|
||||||
|
if (config.sniffExcludeDomains.some(d => host.includes(d))) return
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
const all = await getSniffedAll()
|
||||||
|
if (!all[tabId]) {
|
||||||
|
all[tabId] = { url: tabUrl, title: tabTitle, resources: [], updatedAt: Date.now() }
|
||||||
|
}
|
||||||
|
const tabData = all[tabId]
|
||||||
|
tabData.url = tabUrl
|
||||||
|
tabData.title = tabTitle
|
||||||
|
tabData.updatedAt = Date.now()
|
||||||
|
|
||||||
|
// 去重(同 URL 不重复添加,但更新 size/filename)
|
||||||
|
const existing = tabData.resources.find(r => r.url === item.url)
|
||||||
|
if (existing) {
|
||||||
|
if (item.size && !existing.size) existing.size = item.size
|
||||||
|
if (item.filename && !existing.filename) existing.filename = item.filename
|
||||||
|
existing.lastSeen = Date.now()
|
||||||
|
} else {
|
||||||
|
tabData.resources.push(item)
|
||||||
|
// 限制单 tab 资源数
|
||||||
|
if (tabData.resources.length > 100) {
|
||||||
|
tabData.resources = tabData.resources.slice(-100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制总 tab 数
|
||||||
|
const tabIds = Object.keys(all)
|
||||||
|
if (tabIds.length > 50) {
|
||||||
|
// 删除最久未更新的
|
||||||
|
tabIds.sort((a, b) => (all[a].updatedAt || 0) - (all[b].updatedAt || 0))
|
||||||
|
for (let i = 0; i < tabIds.length - 50; i++) delete all[tabIds[i]]
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveSniffedAll(all)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空所有嗅探记录
|
||||||
|
*/
|
||||||
|
async function clearSniffedAll() {
|
||||||
|
await saveSniffedAll({})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空指定 tab 的嗅探记录
|
||||||
|
*/
|
||||||
|
async function clearSniffedByTab(tabId) {
|
||||||
|
const all = await getSniffedAll()
|
||||||
|
delete all[tabId]
|
||||||
|
await saveSniffedAll(all)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 配置读取 =====
|
// ===== 配置读取 =====
|
||||||
@@ -65,14 +332,15 @@ async function aria2Call(method, params = []) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 添加 URI 下载任务到 aria2
|
* 添加 URI 下载任务到 aria2
|
||||||
* @returns {string} gid
|
|
||||||
*/
|
*/
|
||||||
async function aria2AddUri(url, filename, referer, cookies) {
|
async function aria2AddUri(url, filename, referer, cookies, headers) {
|
||||||
const options = {}
|
const options = {}
|
||||||
if (filename) options.out = filename
|
if (filename) options.out = filename
|
||||||
if (referer) options.referer = referer
|
if (referer) options.referer = referer
|
||||||
if (cookies) options.header = [`Cookie: ${cookies}`]
|
const headerList = []
|
||||||
// User-Agent 用浏览器默认值更兼容
|
if (cookies) headerList.push(`Cookie: ${cookies}`)
|
||||||
|
if (headers && headers.length) headerList.push(...headers)
|
||||||
|
if (headerList.length) options.header = headerList
|
||||||
options['user-agent'] = navigator.userAgent
|
options['user-agent'] = navigator.userAgent
|
||||||
return aria2Call('aria2.addUri', [[url], options])
|
return aria2Call('aria2.addUri', [[url], options])
|
||||||
}
|
}
|
||||||
@@ -81,11 +349,9 @@ async function aria2AddUri(url, filename, referer, cookies) {
|
|||||||
async function shouldIntercept(downloadItem) {
|
async function shouldIntercept(downloadItem) {
|
||||||
const config = await getConfig()
|
const config = await getConfig()
|
||||||
if (!config.interceptDownload) return false
|
if (!config.interceptDownload) return false
|
||||||
// 大小阈值
|
|
||||||
if (config.minSize > 0 && downloadItem.fileSize > 0 && downloadItem.fileSize < config.minSize) {
|
if (config.minSize > 0 && downloadItem.fileSize > 0 && downloadItem.fileSize < config.minSize) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// 域名排除
|
|
||||||
try {
|
try {
|
||||||
const url = new URL(downloadItem.finalUrl || downloadItem.url)
|
const url = new URL(downloadItem.finalUrl || downloadItem.url)
|
||||||
if (config.excludeDomains.some(d => url.hostname.includes(d))) {
|
if (config.excludeDomains.some(d => url.hostname.includes(d))) {
|
||||||
@@ -98,7 +364,6 @@ async function shouldIntercept(downloadItem) {
|
|||||||
async function handleDownloadCreated(downloadItem) {
|
async function handleDownloadCreated(downloadItem) {
|
||||||
if (!await shouldIntercept(downloadItem)) return
|
if (!await shouldIntercept(downloadItem)) return
|
||||||
|
|
||||||
// 立即取消浏览器原生下载
|
|
||||||
try {
|
try {
|
||||||
await chrome.downloads.cancel(downloadItem.id)
|
await chrome.downloads.cancel(downloadItem.id)
|
||||||
await chrome.downloads.erase({ id: downloadItem.id })
|
await chrome.downloads.erase({ id: downloadItem.id })
|
||||||
@@ -112,7 +377,6 @@ async function handleDownloadCreated(downloadItem) {
|
|||||||
await notify('已添加到 aria2', `${filename || url}\nGID: ${gid}`)
|
await notify('已添加到 aria2', `${filename || url}\nGID: ${gid}`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
await notify('aria2 添加失败', `${filename || url}\n${e.message}`)
|
await notify('aria2 添加失败', `${filename || url}\n${e.message}`)
|
||||||
// 失败时把 URL 重新交给浏览器下载
|
|
||||||
try { await chrome.downloads.download({ url }) } catch { /* ignore */ }
|
try { await chrome.downloads.download({ url }) } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,9 +403,9 @@ chrome.runtime.onInstalled.addListener(() => {
|
|||||||
contexts: ['link']
|
contexts: ['link']
|
||||||
})
|
})
|
||||||
chrome.contextMenus.create({
|
chrome.contextMenus.create({
|
||||||
id: 'thing-download-page',
|
id: 'thing-download-sniffed',
|
||||||
title: '使用 aria2 下载当前页面资源',
|
title: '查看嗅探到的资源',
|
||||||
contexts: ['page']
|
contexts: ['action']
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -161,18 +425,173 @@ chrome.contextMenus.onClicked.addListener(async (info, tab) => {
|
|||||||
// ===== 下载事件监听 =====
|
// ===== 下载事件监听 =====
|
||||||
chrome.downloads.onCreated.addListener(handleDownloadCreated)
|
chrome.downloads.onCreated.addListener(handleDownloadCreated)
|
||||||
|
|
||||||
// ===== 资源嗅探(预留,仅日志,不拦截)=====
|
// ===== 资源嗅探:webRequest 监听 =====
|
||||||
// 后续可启用:监听页面媒体资源,提供"嗅探到的资源"列表
|
// 监听所有请求的响应头,由 identifyResource 统一识别
|
||||||
chrome.webRequest.onBeforeRequest.addListener(
|
// 不再按请求类型过滤(B站等流媒体通过 xhr 加载 .m4s 分片)
|
||||||
(details) => {
|
chrome.webRequest.onHeadersReceived.addListener(
|
||||||
// 预留:识别视频/音频流等可下载资源
|
async (details) => {
|
||||||
// 当前不处理,仅保留权限和入口
|
// 跳过扩展自身的请求
|
||||||
return undefined
|
if (details.initiator && details.initiator.startsWith('chrome-extension://')) return
|
||||||
|
|
||||||
|
const config = await getConfig()
|
||||||
|
if (!config.sniffEnabled) return
|
||||||
|
|
||||||
|
// 提取 MIME 和大小
|
||||||
|
let mimeType = ''
|
||||||
|
let contentLength = 0
|
||||||
|
let contentDisposition = ''
|
||||||
|
for (const h of (details.responseHeaders || [])) {
|
||||||
|
const name = h.name.toLowerCase()
|
||||||
|
if (name === 'content-type') mimeType = (h.value || '').split(';')[0].trim()
|
||||||
|
else if (name === 'content-length') contentLength = parseInt(h.value, 10) || 0
|
||||||
|
else if (name === 'content-disposition') contentDisposition = h.value || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 识别资源类型(内部已排除 API 请求和 json/js 等非资源扩展名)
|
||||||
|
const matched = identifyResource(details.url, mimeType)
|
||||||
|
if (!matched) return
|
||||||
|
|
||||||
|
// 按 sniffTypes 配置过滤
|
||||||
|
if (!config.sniffTypes.includes(matched.type)) return
|
||||||
|
|
||||||
|
// 大小阈值过滤(仅对有 Content-Length 的生效)
|
||||||
|
// 注意:流媒体分片的 Content-Length 是单个分片大小,不是完整视频大小
|
||||||
|
// 但仍记录,至少能让用户看到单个请求大小
|
||||||
|
if (config.sniffMinSize > 0 && contentLength > 0 && contentLength < config.sniffMinSize) return
|
||||||
|
|
||||||
|
// 获取 tab 信息
|
||||||
|
let tabUrl = '', tabTitle = ''
|
||||||
|
if (details.tabId && details.tabId > 0) {
|
||||||
|
try {
|
||||||
|
const tab = await chrome.tabs.get(details.tabId)
|
||||||
|
tabUrl = tab.url || ''
|
||||||
|
tabTitle = tab.title || ''
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = extractFilename(details.url, contentDisposition)
|
||||||
|
const item = {
|
||||||
|
url: details.url,
|
||||||
|
filename,
|
||||||
|
type: matched.type,
|
||||||
|
typeLabel: matched.label,
|
||||||
|
color: matched.color,
|
||||||
|
mimeType,
|
||||||
|
size: contentLength,
|
||||||
|
sizeText: formatSize(contentLength),
|
||||||
|
tabId: details.tabId,
|
||||||
|
tabUrl,
|
||||||
|
tabTitle,
|
||||||
|
firstSeen: Date.now(),
|
||||||
|
lastSeen: Date.now()
|
||||||
|
}
|
||||||
|
await addSniffedItem(String(details.tabId || '0'), tabUrl, tabTitle, item)
|
||||||
},
|
},
|
||||||
{ urls: ['<all_urls>'] },
|
{ urls: ['<all_urls>'] },
|
||||||
[]
|
['responseHeaders']
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 额外嗅探:通过 chrome.scripting 注入到页面,扫描 <video>/<audio>/<img><a href> 等
|
||||||
|
* 这能抓到 webRequest 抓不到的静态资源(如 src 直接写在 HTML 里的)
|
||||||
|
* 还能抓到 blob: URL(MSE 流媒体,B站等常用)
|
||||||
|
*/
|
||||||
|
async function sniffTabResources(tabId) {
|
||||||
|
const config = await getConfig()
|
||||||
|
if (!config.sniffEnabled) return
|
||||||
|
try {
|
||||||
|
const results = await chrome.scripting.executeScript({
|
||||||
|
target: { tabId },
|
||||||
|
func: () => {
|
||||||
|
const found = []
|
||||||
|
// video/audio/source - 包括 currentSrc(可能指向 blob:)
|
||||||
|
document.querySelectorAll('video, audio, source').forEach(el => {
|
||||||
|
const src = el.src || el.getAttribute('src') || el.currentSrc
|
||||||
|
if (src) {
|
||||||
|
found.push({ url: src, tag: el.tagName.toLowerCase(), isBlob: src.startsWith('blob:') })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// img
|
||||||
|
document.querySelectorAll('img[src]').forEach(el => {
|
||||||
|
const src = el.src || el.getAttribute('src')
|
||||||
|
if (src) found.push({ url: src, tag: 'img', isBlob: false })
|
||||||
|
})
|
||||||
|
// a[href] 指向资源文件的
|
||||||
|
document.querySelectorAll('a[href]').forEach(el => {
|
||||||
|
const href = el.href
|
||||||
|
if (!href) return
|
||||||
|
const ext = href.split('?')[0].split('.').pop()?.toLowerCase() || ''
|
||||||
|
const resourceExts = ['mp4','mkv','avi','mov','mp3','flac','wav','zip','rar','7z','exe','msi','dmg','pkg','torrent','iso','apk','ipa','webm','m4v','m4a','ogg','opus','webp','jpg','jpeg','png','gif','bmp','svg','m4s','ts','flv']
|
||||||
|
if (resourceExts.includes(ext)) {
|
||||||
|
found.push({ url: href, tag: 'a', isBlob: false })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!results || !results[0]) return
|
||||||
|
const items = results[0].result || []
|
||||||
|
let tab
|
||||||
|
try { tab = await chrome.tabs.get(tabId) } catch { tab = null }
|
||||||
|
for (const it of items) {
|
||||||
|
// blob: URL 无法直接下载(MSE 流),但记录下来让用户知道存在
|
||||||
|
if (it.isBlob) {
|
||||||
|
const tagType = it.tag === 'video' ? 'video' : it.tag === 'audio' ? 'audio' : 'other'
|
||||||
|
if (!config.sniffTypes.includes(tagType)) continue
|
||||||
|
await addSniffedItem(String(tabId), tab?.url || '', tab?.title || '', {
|
||||||
|
url: it.url,
|
||||||
|
filename: `(流媒体 blob) ${tab?.title || ''}`,
|
||||||
|
type: tagType,
|
||||||
|
typeLabel: tagType === 'video' ? '视频' : '音频',
|
||||||
|
color: tagType === 'video' ? '#ef4444' : '#f59e0b',
|
||||||
|
mimeType: '',
|
||||||
|
size: 0,
|
||||||
|
sizeText: 'blob',
|
||||||
|
tabId,
|
||||||
|
tabUrl: tab?.url || '',
|
||||||
|
tabTitle: tab?.title || '',
|
||||||
|
firstSeen: Date.now(),
|
||||||
|
lastSeen: Date.now()
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const matched = identifyResource(it.url, '')
|
||||||
|
if (!matched) continue
|
||||||
|
if (!config.sniffTypes.includes(matched.type)) continue
|
||||||
|
const filename = extractFilename(it.url, '')
|
||||||
|
await addSniffedItem(String(tabId), tab?.url || '', tab?.title || '', {
|
||||||
|
url: it.url,
|
||||||
|
filename,
|
||||||
|
type: matched.type,
|
||||||
|
typeLabel: matched.label,
|
||||||
|
color: matched.color,
|
||||||
|
mimeType: '',
|
||||||
|
size: 0,
|
||||||
|
sizeText: '—',
|
||||||
|
tabId,
|
||||||
|
tabUrl: tab?.url || '',
|
||||||
|
tabTitle: tab?.title || '',
|
||||||
|
firstSeen: Date.now(),
|
||||||
|
lastSeen: Date.now()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch { /* ignore: 可能是无权限页面(chrome:// 等)*/ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面完成加载时触发一次脚本嗅探
|
||||||
|
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
|
||||||
|
if (changeInfo.status === 'complete' && tab.url && /^https?:/.test(tab.url)) {
|
||||||
|
sniffTabResources(tabId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 标签关闭时清理对应的嗅探记录(可选,避免存储膨胀)
|
||||||
|
chrome.tabs.onRemoved.addListener(async (tabId) => {
|
||||||
|
// 不自动清理,让用户可在 popup 查看历史。仅当数量超限才在 addSniffedItem 中淘汰
|
||||||
|
// 如需自动清理,取消下方注释:
|
||||||
|
// await clearSniffedByTab(String(tabId))
|
||||||
|
})
|
||||||
|
|
||||||
// ===== 来自 popup 的消息 =====
|
// ===== 来自 popup 的消息 =====
|
||||||
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
||||||
if (msg.type === 'getConfig') {
|
if (msg.type === 'getConfig') {
|
||||||
@@ -189,4 +608,30 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
|||||||
.catch(e => sendResponse({ ok: false, error: e.message }))
|
.catch(e => sendResponse({ ok: false, error: e.message }))
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if (msg.type === 'getSniffed') {
|
||||||
|
getSniffedAll().then(sendResponse)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (msg.type === 'clearSniffed') {
|
||||||
|
clearSniffedAll().then(() => sendResponse({ ok: true }))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (msg.type === 'sniffCurrentTab') {
|
||||||
|
chrome.tabs.query({ active: true, currentWindow: true }).then(async (tabs) => {
|
||||||
|
if (tabs[0]) {
|
||||||
|
await sniffTabResources(tabs[0].id)
|
||||||
|
sendResponse({ ok: true })
|
||||||
|
} else {
|
||||||
|
sendResponse({ ok: false, error: '无活动标签' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (msg.type === 'downloadSniffed') {
|
||||||
|
const { url, filename, referer } = msg
|
||||||
|
aria2AddUri(url, filename, referer || '', '')
|
||||||
|
.then(gid => sendResponse({ ok: true, gid }))
|
||||||
|
.catch(e => sendResponse({ ok: false, error: e.message }))
|
||||||
|
return true
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Thing Extension",
|
"name": "Thing Extension",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "接管浏览器下载,将任务发送到 aria2 内核。支持后续资源嗅探能力扩展。",
|
"description": "发送浏览器下载到Thing Downloader(aria2),嗅探网页资源。",
|
||||||
"icons": {
|
"icons": {
|
||||||
"16": "icons/icon-16.png",
|
"16": "icons/icon-16.png",
|
||||||
"48": "icons/icon-48.png",
|
"48": "icons/icon-48.png",
|
||||||
@@ -14,7 +14,9 @@
|
|||||||
"notifications",
|
"notifications",
|
||||||
"webRequest",
|
"webRequest",
|
||||||
"webNavigation",
|
"webNavigation",
|
||||||
"contextMenus"
|
"contextMenus",
|
||||||
|
"tabs",
|
||||||
|
"scripting"
|
||||||
],
|
],
|
||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
"<all_urls>"
|
"<all_urls>"
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
width: 340px;
|
width: 420px;
|
||||||
|
max-height: 560px;
|
||||||
font-family: system-ui, -apple-system, 'Segoe UI', 'Microsoft YaHei', sans-serif;
|
font-family: system-ui, -apple-system, 'Segoe UI', 'Microsoft YaHei', sans-serif;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
@@ -13,14 +14,17 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
padding: 14px;
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 560px;
|
||||||
}
|
}
|
||||||
|
|
||||||
header {
|
header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
@@ -40,6 +44,48 @@ header {
|
|||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tab 切换 */
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
flex: 1;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #6b7280;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
color: #111827;
|
||||||
|
border-bottom-color: #111827;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 状态条 */
|
||||||
.status {
|
.status {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -47,7 +93,7 @@ header {
|
|||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: #f3f4f6;
|
background: #f3f4f6;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,19 +104,15 @@ header {
|
|||||||
background: #9ca3af;
|
background: #9ca3af;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dot.ok {
|
.dot.ok { background: #10b981; }
|
||||||
background: #10b981;
|
.dot.fail { background: #ef4444; }
|
||||||
}
|
|
||||||
|
|
||||||
.dot.fail {
|
|
||||||
background: #ef4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/* 表单 */
|
||||||
.field {
|
.field {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field span {
|
.field span {
|
||||||
@@ -97,14 +139,12 @@ header {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox input {
|
.checkbox input { cursor: pointer; }
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -123,9 +163,7 @@ header {
|
|||||||
transition: background 0.15s, border-color 0.15s;
|
transition: background 0.15s, border-color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions button:hover {
|
.actions button:hover { background: #f9fafb; }
|
||||||
background: #f9fafb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions button.primary {
|
.actions button.primary {
|
||||||
background: #111827;
|
background: #111827;
|
||||||
@@ -133,6 +171,284 @@ header {
|
|||||||
border-color: #111827;
|
border-color: #111827;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions button.primary:hover {
|
.actions button.primary:hover { background: #1f2937; }
|
||||||
background: #1f2937;
|
|
||||||
|
/* 嗅探面板 */
|
||||||
|
.sniff-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sniff-toolbar button,
|
||||||
|
.sniff-toolbar select {
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sniff-toolbar button:hover { background: #f9fafb; }
|
||||||
|
|
||||||
|
/* 资源类型子 Tab */
|
||||||
|
.sniff-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sniff-tab {
|
||||||
|
flex: 1;
|
||||||
|
padding: 5px 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #6b7280;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sniff-tab:hover {
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sniff-tab.active {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #111827;
|
||||||
|
font-weight: 500;
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sniff-tab-count {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #e5e7eb;
|
||||||
|
color: #6b7280;
|
||||||
|
min-width: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sniff-tab.active .sniff-tab-count {
|
||||||
|
background: #111827;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sniff-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fafafa;
|
||||||
|
max-height: 380px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sniff-list .empty {
|
||||||
|
padding: 24px 10px;
|
||||||
|
text-align: center;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sniff-item {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sniff-item:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sniff-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sniff-item-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sniff-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sniff-filename {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1f2937;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sniff-size {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #6b7280;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sniff-url {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sniff-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sniff-actions button {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sniff-actions button:hover { background: #f3f4f6; }
|
||||||
|
|
||||||
|
.sniff-actions button.dl {
|
||||||
|
background: #111827;
|
||||||
|
color: #ffffff;
|
||||||
|
border-color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sniff-actions button.dl:hover { background: #1f2937; }
|
||||||
|
|
||||||
|
.sniff-stats {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #6b7280;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 嗅探项:图片缩略图 */
|
||||||
|
.sniff-thumb {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 120px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sniff-preview-btn {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sniff-preview-btn:hover { background: #f3f4f6; }
|
||||||
|
|
||||||
|
/* 预览浮层 */
|
||||||
|
.preview-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
z-index: 1000;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-overlay.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-content {
|
||||||
|
position: relative;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-close {
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
right: -8px;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ffffff;
|
||||||
|
border: none;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-close:hover { background: #f3f4f6; }
|
||||||
|
|
||||||
|
.preview-body {
|
||||||
|
max-width: 380px;
|
||||||
|
max-height: 440px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-body img {
|
||||||
|
max-width: 380px;
|
||||||
|
max-height: 440px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-body video {
|
||||||
|
max-width: 380px;
|
||||||
|
max-height: 440px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-info {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 380px;
|
||||||
|
word-break: break-all;
|
||||||
|
background: rgba(0,0,0,0.5);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,55 @@
|
|||||||
<img src="icons/icon-48.png" alt="Thing" class="logo" />
|
<img src="icons/icon-48.png" alt="Thing" class="logo" />
|
||||||
<div class="title">
|
<div class="title">
|
||||||
<h1>Thing Extension</h1>
|
<h1>Thing Extension</h1>
|
||||||
<span class="subtitle">aria2 下载接管</span>
|
<span class="subtitle">aria2 下载接管 · 资源嗅探</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- Tab 切换 -->
|
||||||
|
<nav class="tabs">
|
||||||
|
<button class="tab active" data-tab="sniff">资源嗅探</button>
|
||||||
|
<button class="tab" data-tab="config">设置</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- ===== 嗅探面板 ===== -->
|
||||||
|
<section class="panel" id="panel-sniff">
|
||||||
|
<!-- 资源类型子 Tab -->
|
||||||
|
<nav class="sniff-tabs">
|
||||||
|
<button class="sniff-tab active" data-type="video">
|
||||||
|
视频 <span class="sniff-tab-count" id="count-video">0</span>
|
||||||
|
</button>
|
||||||
|
<button class="sniff-tab" data-type="audio">
|
||||||
|
音频 <span class="sniff-tab-count" id="count-audio">0</span>
|
||||||
|
</button>
|
||||||
|
<button class="sniff-tab" data-type="image">
|
||||||
|
图片 <span class="sniff-tab-count" id="count-image">0</span>
|
||||||
|
</button>
|
||||||
|
<button class="sniff-tab" data-type="other">
|
||||||
|
其他 <span class="sniff-tab-count" id="count-other">0</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="sniff-toolbar">
|
||||||
|
<button id="sniffRefreshBtn" title="重新嗅探当前页">嗅探本页</button>
|
||||||
|
<button id="sniffClearBtn" title="清空所有嗅探记录">清空</button>
|
||||||
|
</div>
|
||||||
|
<div class="sniff-list" id="sniffList">
|
||||||
|
<div class="empty">暂无嗅探记录</div>
|
||||||
|
</div>
|
||||||
|
<div class="sniff-stats" id="sniffStats"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ===== 预览浮层 ===== -->
|
||||||
|
<div class="preview-overlay" id="previewOverlay">
|
||||||
|
<div class="preview-content">
|
||||||
|
<button class="preview-close" id="previewClose" title="关闭">×</button>
|
||||||
|
<div class="preview-body" id="previewBody"></div>
|
||||||
|
<div class="preview-info" id="previewInfo"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== 配置面板 ===== -->
|
||||||
|
<section class="panel hidden" id="panel-config">
|
||||||
<section class="status" id="statusBox">
|
<section class="status" id="statusBox">
|
||||||
<span class="dot" id="statusDot"></span>
|
<span class="dot" id="statusDot"></span>
|
||||||
<span id="statusText">检测中...</span>
|
<span id="statusText">检测中...</span>
|
||||||
@@ -37,18 +82,28 @@
|
|||||||
<span>接管浏览器下载</span>
|
<span>接管浏览器下载</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" id="sniffEnabled" />
|
||||||
|
<span>启用资源嗅探</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
<label class="checkbox">
|
<label class="checkbox">
|
||||||
<input type="checkbox" id="showNotifications" />
|
<input type="checkbox" id="showNotifications" />
|
||||||
<span>显示桌面通知</span>
|
<span>显示桌面通知</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>最小文件大小(字节,0=全部)</span>
|
<span>下载最小文件大小(字节,0=全部)</span>
|
||||||
<input type="number" id="minSize" min="0" placeholder="0" />
|
<input type="number" id="minSize" min="0" placeholder="0" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>排除域名(逗号分隔)</span>
|
<span>嗅探最小文件大小(字节,0=全部)</span>
|
||||||
|
<input type="number" id="sniffMinSize" min="0" placeholder="102400" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>下载排除域名(逗号分隔)</span>
|
||||||
<input type="text" id="excludeDomains" placeholder="例如:example.com,another.com" />
|
<input type="text" id="excludeDomains" placeholder="例如:example.com,another.com" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -57,6 +112,7 @@
|
|||||||
<button type="submit" class="primary">保存</button>
|
<button type="submit" class="primary">保存</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<script src="popup.js"></script>
|
<script src="popup.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Thing Extension - Popup 脚本
|
* Thing Extension - Popup 脚本
|
||||||
* 配置 RPC 地址、密钥等参数,存储到 chrome.storage.local
|
* 1. RPC 配置(存储到 chrome.storage.local)
|
||||||
|
* 2. 资源嗅探列表查看与下载
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id)
|
const $ = (id) => document.getElementById(id)
|
||||||
@@ -11,9 +12,34 @@ const DEFAULT_CONFIG = {
|
|||||||
interceptDownload: true,
|
interceptDownload: true,
|
||||||
minSize: 0,
|
minSize: 0,
|
||||||
excludeDomains: [],
|
excludeDomains: [],
|
||||||
showNotifications: true
|
showNotifications: true,
|
||||||
|
sniffEnabled: true,
|
||||||
|
sniffTypes: ['video', 'audio', 'image', 'archive', 'torrent', 'installer'],
|
||||||
|
sniffMaxItems: 200,
|
||||||
|
sniffMinSize: 1024 * 100,
|
||||||
|
sniffExcludeDomains: []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function send(type, payload) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
chrome.runtime.sendMessage({ type, ...payload }, resolve)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Tab 切换 =====
|
||||||
|
document.querySelectorAll('.tab').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.tab').forEach(b => b.classList.remove('active'))
|
||||||
|
document.querySelectorAll('.panel').forEach(p => p.classList.add('hidden'))
|
||||||
|
btn.classList.add('active')
|
||||||
|
$('panel-' + btn.dataset.tab).classList.remove('hidden')
|
||||||
|
if (btn.dataset.tab === 'sniff') {
|
||||||
|
refreshSniffList()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===== 状态指示 =====
|
||||||
function setStatus(state, text) {
|
function setStatus(state, text) {
|
||||||
const dot = $('statusDot')
|
const dot = $('statusDot')
|
||||||
const txt = $('statusText')
|
const txt = $('statusText')
|
||||||
@@ -21,20 +47,19 @@ function setStatus(state, text) {
|
|||||||
txt.textContent = text
|
txt.textContent = text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 配置表单 =====
|
||||||
async function loadConfig() {
|
async function loadConfig() {
|
||||||
return new Promise((resolve) => {
|
return send('getConfig', {})
|
||||||
chrome.runtime.sendMessage({ type: 'getConfig' }, (config) => {
|
|
||||||
resolve(config || { ...DEFAULT_CONFIG })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillForm(config) {
|
function fillForm(config) {
|
||||||
$('rpcUrl').value = config.rpcUrl || DEFAULT_CONFIG.rpcUrl
|
$('rpcUrl').value = config.rpcUrl || DEFAULT_CONFIG.rpcUrl
|
||||||
$('rpcSecret').value = config.rpcSecret || ''
|
$('rpcSecret').value = config.rpcSecret || ''
|
||||||
$('interceptDownload').checked = config.interceptDownload !== false
|
$('interceptDownload').checked = config.interceptDownload !== false
|
||||||
|
$('sniffEnabled').checked = config.sniffEnabled !== false
|
||||||
$('showNotifications').checked = config.showNotifications !== false
|
$('showNotifications').checked = config.showNotifications !== false
|
||||||
$('minSize').value = config.minSize || 0
|
$('minSize').value = config.minSize || 0
|
||||||
|
$('sniffMinSize').value = config.sniffMinSize ?? DEFAULT_CONFIG.sniffMinSize
|
||||||
$('excludeDomains').value = (config.excludeDomains || []).join(',')
|
$('excludeDomains').value = (config.excludeDomains || []).join(',')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,8 +68,10 @@ function readForm() {
|
|||||||
rpcUrl: $('rpcUrl').value.trim() || DEFAULT_CONFIG.rpcUrl,
|
rpcUrl: $('rpcUrl').value.trim() || DEFAULT_CONFIG.rpcUrl,
|
||||||
rpcSecret: $('rpcSecret').value.trim(),
|
rpcSecret: $('rpcSecret').value.trim(),
|
||||||
interceptDownload: $('interceptDownload').checked,
|
interceptDownload: $('interceptDownload').checked,
|
||||||
|
sniffEnabled: $('sniffEnabled').checked,
|
||||||
showNotifications: $('showNotifications').checked,
|
showNotifications: $('showNotifications').checked,
|
||||||
minSize: parseInt($('minSize').value, 10) || 0,
|
minSize: parseInt($('minSize').value, 10) || 0,
|
||||||
|
sniffMinSize: parseInt($('sniffMinSize').value, 10) || 0,
|
||||||
excludeDomains: $('excludeDomains').value
|
excludeDomains: $('excludeDomains').value
|
||||||
.split(',')
|
.split(',')
|
||||||
.map(s => s.trim())
|
.map(s => s.trim())
|
||||||
@@ -52,45 +79,243 @@ function readForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('configForm').addEventListener('submit', async (e) => {
|
$('configForm').addEventListener('submit', async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const config = readForm()
|
const config = readForm()
|
||||||
chrome.runtime.sendMessage({ type: 'saveConfig', config }, (res) => {
|
const res = await send('saveConfig', { config })
|
||||||
if (res && res.ok) {
|
if (res && res.ok) {
|
||||||
setStatus('', '已保存')
|
setStatus('', '已保存')
|
||||||
setTimeout(() => window.close(), 500)
|
setTimeout(() => setStatus('', '配置已保存'), 1500)
|
||||||
} else {
|
} else {
|
||||||
setStatus('fail', '保存失败:' + (res?.error || '未知错误'))
|
setStatus('fail', '保存失败:' + (res?.error || '未知错误'))
|
||||||
}
|
}
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
$('testBtn').addEventListener('click', () => {
|
$('testBtn').addEventListener('click', async () => {
|
||||||
setStatus('', '测试中...')
|
setStatus('', '测试中...')
|
||||||
// 先保存当前表单值,再测试
|
|
||||||
const config = readForm()
|
const config = readForm()
|
||||||
chrome.runtime.sendMessage({ type: 'saveConfig', config }, () => {
|
await send('saveConfig', { config })
|
||||||
chrome.runtime.sendMessage({ type: 'testConnection' }, (res) => {
|
const res = await send('testConnection', {})
|
||||||
if (res && res.ok) {
|
if (res && res.ok) {
|
||||||
setStatus('ok', `已连接 · aria2 ${res.version}`)
|
setStatus('ok', `已连接 · aria2 ${res.version}`)
|
||||||
} else {
|
} else {
|
||||||
setStatus('fail', '连接失败:' + (res?.error || '未知错误'))
|
setStatus('fail', '连接失败:' + (res?.error || '未知错误'))
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===== 嗅探列表 =====
|
||||||
|
let allSniffed = {}
|
||||||
|
let currentSniffTab = 'video' // 当前选中的资源类型 tab
|
||||||
|
|
||||||
|
// "其他"包含:archive/torrent/installer
|
||||||
|
function getCategory(type) {
|
||||||
|
if (type === 'video' || type === 'audio' || type === 'image') return type
|
||||||
|
return 'other'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSniffList() {
|
||||||
|
allSniffed = await send('getSniffed', {})
|
||||||
|
updateTabCounts()
|
||||||
|
renderSniffList()
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTabCounts() {
|
||||||
|
const counts = { video: 0, audio: 0, image: 0, other: 0 }
|
||||||
|
for (const tabId of Object.keys(allSniffed)) {
|
||||||
|
const tabData = allSniffed[tabId]
|
||||||
|
if (!tabData || !tabData.resources) continue
|
||||||
|
for (const r of tabData.resources) {
|
||||||
|
counts[getCategory(r.type)]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$('count-video').textContent = counts.video
|
||||||
|
$('count-audio').textContent = counts.audio
|
||||||
|
$('count-image').textContent = counts.image
|
||||||
|
$('count-other').textContent = counts.other
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFilteredResources() {
|
||||||
|
const all = []
|
||||||
|
for (const tabId of Object.keys(allSniffed)) {
|
||||||
|
const tabData = allSniffed[tabId]
|
||||||
|
if (!tabData || !tabData.resources) continue
|
||||||
|
for (const r of tabData.resources) {
|
||||||
|
if (getCategory(r.type) !== currentSniffTab) continue
|
||||||
|
all.push({ ...r, tabTitle: tabData.title || r.tabTitle || '' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 按时间倒序
|
||||||
|
all.sort((a, b) => (b.lastSeen || 0) - (a.lastSeen || 0))
|
||||||
|
return all
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSniffList() {
|
||||||
|
const list = $('sniffList')
|
||||||
|
const items = getFilteredResources()
|
||||||
|
if (items.length === 0) {
|
||||||
|
list.innerHTML = '<div class="empty">暂无嗅探记录</div>'
|
||||||
|
$('sniffStats').textContent = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list.innerHTML = items.map(r => {
|
||||||
|
// 图片:显示缩略图(可点击预览)
|
||||||
|
const isImage = r.type === 'image'
|
||||||
|
const isVideo = r.type === 'video'
|
||||||
|
const isPreviewable = isImage || isVideo
|
||||||
|
const thumbHtml = isImage
|
||||||
|
? `<img class="sniff-thumb" src="${escapeAttr(r.url)}" alt="" loading="lazy" data-preview="image" data-url="${escapeAttr(r.url)}" data-title="${escapeAttr(r.filename || '')}" onerror="this.style.display='none'">`
|
||||||
|
: ''
|
||||||
|
const previewBtn = isPreviewable
|
||||||
|
? `<button class="sniff-preview-btn" data-preview="${isVideo ? 'video' : 'image'}" data-url="${escapeAttr(r.url)}" data-title="${escapeAttr(r.filename || '')}">预览</button>`
|
||||||
|
: ''
|
||||||
|
return `
|
||||||
|
<div class="sniff-item">
|
||||||
|
<div class="sniff-item-head">
|
||||||
|
<span class="sniff-badge" style="background:${r.color || '#6b7280'}">${r.typeLabel || r.type}</span>
|
||||||
|
<span class="sniff-filename" title="${escapeHtml(r.filename || r.url)}">${escapeHtml(r.filename || '(未命名)')}</span>
|
||||||
|
<span class="sniff-size">${r.sizeText || '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="sniff-url" title="${escapeHtml(r.url)}">${escapeHtml(r.url)}</div>
|
||||||
|
${thumbHtml}
|
||||||
|
<div class="sniff-actions">
|
||||||
|
<button class="dl" data-url="${escapeAttr(r.url)}" data-filename="${escapeAttr(r.filename || '')}" data-referer="${escapeAttr(r.tabUrl || '')}">下载</button>
|
||||||
|
${previewBtn}
|
||||||
|
<button class="copy" data-url="${escapeAttr(r.url)}">复制</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}).join('')
|
||||||
|
$('sniffStats').textContent = `共 ${items.length} 条`
|
||||||
|
|
||||||
|
// 绑定下载按钮
|
||||||
|
list.querySelectorAll('.sniff-actions .dl').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const url = btn.dataset.url
|
||||||
|
const filename = btn.dataset.filename
|
||||||
|
const referer = btn.dataset.referer
|
||||||
|
btn.disabled = true
|
||||||
|
btn.textContent = '发送中...'
|
||||||
|
const res = await send('downloadSniffed', { url, filename, referer })
|
||||||
|
btn.disabled = false
|
||||||
|
btn.textContent = '下载'
|
||||||
|
if (res && res.ok) {
|
||||||
|
btn.textContent = '已发送 ✓'
|
||||||
|
setTimeout(() => { btn.textContent = '下载' }, 1500)
|
||||||
|
} else {
|
||||||
|
alert('下载失败:' + (res?.error || '未知错误'))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
// 绑定复制按钮
|
||||||
|
list.querySelectorAll('.sniff-actions .copy').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(btn.dataset.url)
|
||||||
|
btn.textContent = '已复制 ✓'
|
||||||
|
setTimeout(() => { btn.textContent = '复制' }, 1200)
|
||||||
|
} catch {
|
||||||
|
alert('复制失败')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
// 绑定预览按钮(图片缩略图和预览按钮共用)
|
||||||
|
list.querySelectorAll('[data-preview]').forEach(el => {
|
||||||
|
el.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const type = el.dataset.preview
|
||||||
|
const url = el.dataset.url
|
||||||
|
const title = el.dataset.title
|
||||||
|
openPreview(type, url, title)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 预览浮层 =====
|
||||||
|
function openPreview(type, url, title) {
|
||||||
|
const overlay = $('previewOverlay')
|
||||||
|
const body = $('previewBody')
|
||||||
|
const info = $('previewInfo')
|
||||||
|
if (type === 'image') {
|
||||||
|
body.innerHTML = `<img src="${escapeAttr(url)}" alt="${escapeAttr(title)}">`
|
||||||
|
} else if (type === 'video') {
|
||||||
|
body.innerHTML = `<video src="${escapeAttr(url)}" controls autoplay></video>`
|
||||||
|
}
|
||||||
|
info.textContent = `${title || ''} · ${url}`
|
||||||
|
overlay.classList.add('active')
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePreview() {
|
||||||
|
const overlay = $('previewOverlay')
|
||||||
|
const body = $('previewBody')
|
||||||
|
// 释放视频资源
|
||||||
|
const video = body.querySelector('video')
|
||||||
|
if (video) video.pause()
|
||||||
|
body.innerHTML = ''
|
||||||
|
overlay.classList.remove('active')
|
||||||
|
}
|
||||||
|
|
||||||
|
$('previewClose').addEventListener('click', closePreview)
|
||||||
|
$('previewOverlay').addEventListener('click', (e) => {
|
||||||
|
// 点击遮罩关闭
|
||||||
|
if (e.target.id === 'previewOverlay') closePreview()
|
||||||
|
})
|
||||||
|
// ESC 关闭
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && $('previewOverlay').classList.contains('active')) {
|
||||||
|
closePreview()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s || '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeAttr(s) {
|
||||||
|
return escapeHtml(s).replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
|
$('sniffRefreshBtn').addEventListener('click', async () => {
|
||||||
|
const btn = $('sniffRefreshBtn')
|
||||||
|
btn.disabled = true
|
||||||
|
btn.textContent = '嗅探中...'
|
||||||
|
await send('sniffCurrentTab', {})
|
||||||
|
await refreshSniffList()
|
||||||
|
btn.disabled = false
|
||||||
|
btn.textContent = '嗅探本页'
|
||||||
|
})
|
||||||
|
|
||||||
|
$('sniffClearBtn').addEventListener('click', async () => {
|
||||||
|
if (!confirm('清空所有嗅探记录?')) return
|
||||||
|
await send('clearSniffed', {})
|
||||||
|
await refreshSniffList()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 子 Tab 切换
|
||||||
|
document.querySelectorAll('.sniff-tab').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.sniff-tab').forEach(b => b.classList.remove('active'))
|
||||||
|
btn.classList.add('active')
|
||||||
|
currentSniffTab = btn.dataset.type
|
||||||
|
renderSniffList()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// 初始化
|
// ===== 初始化 =====
|
||||||
;(async () => {
|
;(async () => {
|
||||||
const config = await loadConfig()
|
const config = await loadConfig()
|
||||||
fillForm(config)
|
fillForm(config)
|
||||||
// 自动测试一次连接
|
// 自动测试连接
|
||||||
setStatus('', '检测中...')
|
setStatus('', '检测中...')
|
||||||
chrome.runtime.sendMessage({ type: 'testConnection' }, (res) => {
|
const res = await send('testConnection', {})
|
||||||
if (res && res.ok) {
|
if (res && res.ok) {
|
||||||
setStatus('ok', `已连接 · aria2 ${res.version}`)
|
setStatus('ok', `已连接 · aria2 ${res.version}`)
|
||||||
} else {
|
} else {
|
||||||
setStatus('fail', '未连接')
|
setStatus('fail', '未连接')
|
||||||
}
|
}
|
||||||
})
|
// 加载嗅探列表
|
||||||
|
await refreshSniffList()
|
||||||
})()
|
})()
|
||||||
|
|||||||
Binary file not shown.
@@ -758,9 +758,10 @@ impl Aria2Manager {
|
|||||||
Err(_) => return,
|
Err(_) => return,
|
||||||
};
|
};
|
||||||
let _ = rt.block_on(async {
|
let _ = rt.block_on(async {
|
||||||
// 给一个短超时,避免退出卡住
|
// 给 2 秒超时,让 aria2 有足够时间保存 session
|
||||||
|
// (aria2 保存 session 可能涉及磁盘 I/O,500ms 太短)
|
||||||
let _ = tokio::time::timeout(
|
let _ = tokio::time::timeout(
|
||||||
std::time::Duration::from_millis(500),
|
std::time::Duration::from_millis(2000),
|
||||||
self.rpc_call("aria2.shutdown", vec![]),
|
self.rpc_call("aria2.shutdown", vec![]),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|||||||
+24
-5
@@ -38,14 +38,16 @@ fn quit_app(
|
|||||||
state: tauri::State<'_, ProcessManager>,
|
state: tauri::State<'_, ProcessManager>,
|
||||||
mihomo: tauri::State<'_, MihomoManager>,
|
mihomo: tauri::State<'_, MihomoManager>,
|
||||||
aria2: tauri::State<'_, Aria2Manager>,
|
aria2: tauri::State<'_, Aria2Manager>,
|
||||||
|
app: tauri::AppHandle,
|
||||||
) {
|
) {
|
||||||
// 退出前清理系统代理,避免遗留导致网络问题
|
// 退出前清理系统代理,避免遗留导致网络问题
|
||||||
mihomo.cleanup_on_exit();
|
mihomo.cleanup_on_exit();
|
||||||
// 退出前让 aria2 优雅关闭(保存 session)
|
// 退出前让 aria2 优雅关闭(保存 session,2s 超时)
|
||||||
aria2.cleanup_on_exit();
|
aria2.cleanup_on_exit();
|
||||||
// 停止所有子进程
|
// 停止所有子进程(同步 kill + 带超时的 wait,确保进程真正终止)
|
||||||
state.stop_all();
|
state.stop_all();
|
||||||
std::process::exit(0);
|
// 通过 app.exit 触发 RunEvent::ExitRequested,统一退出路径
|
||||||
|
app.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
@@ -154,6 +156,7 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
"quit" => {
|
"quit" => {
|
||||||
// 退出前清理系统代理 + 优雅关闭 aria2 + 停止所有子进程
|
// 退出前清理系统代理 + 优雅关闭 aria2 + 停止所有子进程
|
||||||
|
// 直接调用 cleanup + stop_all + exit(quit_app 命令是给前端用的)
|
||||||
if let Some(mihomo) = app.try_state::<MihomoManager>() {
|
if let Some(mihomo) = app.try_state::<MihomoManager>() {
|
||||||
mihomo.cleanup_on_exit();
|
mihomo.cleanup_on_exit();
|
||||||
}
|
}
|
||||||
@@ -207,6 +210,22 @@ pub fn run() {
|
|||||||
api.prevent_close();
|
api.prevent_close();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.run(tauri::generate_context!())
|
.build(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while building tauri application")
|
||||||
|
.run(|app, event| {
|
||||||
|
// 退出请求兜底:捕获所有退出路径(app.exit、窗口全部关闭、系统信号等)
|
||||||
|
// 确保 mihomo/aria2 子进程在任何情况下都被清理
|
||||||
|
// 注:quit_app 命令和托盘菜单已主动调用 cleanup,这里作为二次保险
|
||||||
|
if let tauri::RunEvent::ExitRequested { .. } = event {
|
||||||
|
if let Some(mihomo) = app.try_state::<MihomoManager>() {
|
||||||
|
mihomo.cleanup_on_exit();
|
||||||
|
}
|
||||||
|
if let Some(aria2) = app.try_state::<Aria2Manager>() {
|
||||||
|
aria2.cleanup_on_exit();
|
||||||
|
}
|
||||||
|
if let Some(pm) = app.try_state::<ProcessManager>() {
|
||||||
|
pm.stop_all();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,101 @@ use tauri::{AppHandle, Emitter, Manager};
|
|||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub const CREATE_NO_WINDOW: u32 = 0x08000000;
|
pub const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
|
|
||||||
|
// Windows Job Object 相关常量,用于异常退出时自动清理子进程
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[allow(non_snake_case, non_upper_case_globals, non_camel_case_types)]
|
||||||
|
mod winapi {
|
||||||
|
pub const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE: u32 = 0x2000;
|
||||||
|
pub type HANDLE = *mut std::ffi::c_void;
|
||||||
|
pub type BOOL = i32;
|
||||||
|
pub type DWORD = u32;
|
||||||
|
pub type ULONG_PTR = usize;
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct IO_COUNTERS {
|
||||||
|
pub ReadOperationCount: ULONG_PTR,
|
||||||
|
pub WriteOperationCount: ULONG_PTR,
|
||||||
|
pub OtherOperationCount: ULONG_PTR,
|
||||||
|
pub ReadTransferCount: ULONG_PTR,
|
||||||
|
pub WriteTransferCount: ULONG_PTR,
|
||||||
|
pub OtherTransferCount: ULONG_PTR,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION {
|
||||||
|
pub BasicLimitInformation: JOBOBJECT_BASIC_LIMIT_INFORMATION,
|
||||||
|
pub IoInfo: IO_COUNTERS,
|
||||||
|
pub ProcessMemoryLimit: ULONG_PTR,
|
||||||
|
pub JobMemoryLimit: ULONG_PTR,
|
||||||
|
pub PeakProcessMemoryUsed: ULONG_PTR,
|
||||||
|
pub PeakJobMemoryUsed: ULONG_PTR,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct JOBOBJECT_BASIC_LIMIT_INFORMATION {
|
||||||
|
pub PerProcessUserTimeLimit: i64,
|
||||||
|
pub PerJobUserTimeLimit: i64,
|
||||||
|
pub LimitFlags: DWORD,
|
||||||
|
pub MinimumWorkingSetSize: ULONG_PTR,
|
||||||
|
pub MaximumWorkingSetSize: ULONG_PTR,
|
||||||
|
pub ActiveProcessLimit: DWORD,
|
||||||
|
pub Affinity: ULONG_PTR,
|
||||||
|
pub PriorityClass: DWORD,
|
||||||
|
pub SchedulingClass: DWORD,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const JobObjectExtendedLimitInformation: DWORD = 9;
|
||||||
|
|
||||||
|
extern "system" {
|
||||||
|
pub fn CreateJobObjectW(lpJobAttributes: *mut std::ffi::c_void, lpName: *const u16) -> HANDLE;
|
||||||
|
pub fn SetInformationJobObject(
|
||||||
|
hJob: HANDLE,
|
||||||
|
JobObjectInformationClass: DWORD,
|
||||||
|
lpJobObjectInformation: *mut std::ffi::c_void,
|
||||||
|
cbJobObjectInformationLength: DWORD,
|
||||||
|
) -> BOOL;
|
||||||
|
pub fn AssignProcessToJobObject(hJob: HANDLE, hProcess: HANDLE) -> BOOL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 全局 Job Object 句柄(lazy 初始化,所有子进程都加入此 job)
|
||||||
|
/// 当主进程退出(包括崩溃)时,OS 自动终止 job 内所有子进程
|
||||||
|
/// 用 usize 存储指针以绕过 Send 约束(句柄本身是进程级资源,线程间共享安全)
|
||||||
|
#[cfg(windows)]
|
||||||
|
static JOB_HANDLE: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn get_job_handle() -> Option<winapi::HANDLE> {
|
||||||
|
let addr = *JOB_HANDLE.get_or_init(|| {
|
||||||
|
unsafe {
|
||||||
|
let h = winapi::CreateJobObjectW(std::ptr::null_mut(), std::ptr::null());
|
||||||
|
if h.is_null() {
|
||||||
|
eprintln!("[ProcessManager] CreateJobObjectW 失败,异常退出时子进程可能残留");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// 设置 KILL_ON_JOB_CLOSE:主进程退出时自动终止所有子进程
|
||||||
|
let mut info: winapi::JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed();
|
||||||
|
info.BasicLimitInformation.LimitFlags = winapi::JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||||
|
let ok = winapi::SetInformationJobObject(
|
||||||
|
h,
|
||||||
|
winapi::JobObjectExtendedLimitInformation,
|
||||||
|
&mut info as *mut _ as *mut std::ffi::c_void,
|
||||||
|
std::mem::size_of::<winapi::JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
|
||||||
|
);
|
||||||
|
if ok == 0 {
|
||||||
|
eprintln!("[ProcessManager] SetInformationJobObject 失败");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
h as usize
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if addr == 0 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(addr as winapi::HANDLE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 为 Command 设置平台特定的创建标志(Windows 上隐藏控制台窗口)
|
/// 为 Command 设置平台特定的创建标志(Windows 上隐藏控制台窗口)
|
||||||
/// 公开以便其他模块(如 mihomo_manager 调用 mihomo -v 查询版本)复用
|
/// 公开以便其他模块(如 mihomo_manager 调用 mihomo -v 查询版本)复用
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
@@ -24,6 +119,22 @@ pub fn setup_creation_flags(_cmd: &mut Command) {
|
|||||||
// 非 Windows 平台无需处理
|
// 非 Windows 平台无需处理
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 将已启动的子进程加入 Job Object(异常退出时自动清理)
|
||||||
|
/// 在 Windows 上调用,非 Windows 平台为空操作
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn assign_to_job(child: &Child) {
|
||||||
|
use std::os::windows::io::AsRawHandle;
|
||||||
|
if let Some(job) = get_job_handle() {
|
||||||
|
let child_handle = child.as_raw_handle() as winapi::HANDLE;
|
||||||
|
unsafe {
|
||||||
|
let _ = winapi::AssignProcessToJobObject(job, child_handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn assign_to_job(_child: &Child) {}
|
||||||
|
|
||||||
/// 进程状态枚举
|
/// 进程状态枚举
|
||||||
#[derive(Serialize, Clone, Debug)]
|
#[derive(Serialize, Clone, Debug)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
@@ -119,6 +230,8 @@ impl ProcessManager {
|
|||||||
.spawn()
|
.spawn()
|
||||||
.map_err(|e| format!("启动进程 '{}' 失败: {}", params.id, e))?;
|
.map_err(|e| format!("启动进程 '{}' 失败: {}", params.id, e))?;
|
||||||
let pid = child.id();
|
let pid = child.id();
|
||||||
|
// 将子进程加入 Job Object,主进程异常退出时由 OS 自动清理
|
||||||
|
assign_to_job(&child);
|
||||||
|
|
||||||
let entry = ProcessEntry {
|
let entry = ProcessEntry {
|
||||||
child,
|
child,
|
||||||
@@ -165,11 +278,27 @@ impl ProcessManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 停止所有进程(应用退出时调用)
|
/// 停止所有进程(应用退出时调用)
|
||||||
|
/// 同步 kill + 带超时的 wait,确保子进程在主进程退出前真正终止
|
||||||
pub fn stop_all(&self) {
|
pub fn stop_all(&self) {
|
||||||
if let Ok(mut processes) = self.processes.lock() {
|
if let Ok(mut processes) = self.processes.lock() {
|
||||||
for (id, mut entry) in processes.drain() {
|
for (id, mut entry) in processes.drain() {
|
||||||
let _ = entry.child.kill();
|
let _ = entry.child.kill();
|
||||||
let _ = entry.child.wait();
|
// 带超时的 wait,避免子进程卡住导致主进程无法退出
|
||||||
|
// 最长等 3 秒,超时则放弃等待(Job Object 兜底会清理)
|
||||||
|
let deadline = std::time::Instant::now() + Duration::from_secs(3);
|
||||||
|
loop {
|
||||||
|
match entry.child.try_wait() {
|
||||||
|
Ok(Some(_)) => break,
|
||||||
|
Ok(None) => {
|
||||||
|
if std::time::Instant::now() >= deadline {
|
||||||
|
println!("[ProcessManager] 进程 {} 等待退出超时(3s),放弃等待", id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
println!("[ProcessManager] 已停止进程: {}", id);
|
println!("[ProcessManager] 已停止进程: {}", id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user