下载模块 Init

This commit is contained in:
zhongluofeng
2026-07-20 18:28:34 +08:00
parent e84958e0fc
commit 548b022426
37 changed files with 4795 additions and 65 deletions
@@ -0,0 +1,192 @@
/**
* Thing Extension - 后台 Service Worker
*
* 职责:
* 1. 接管浏览器下载,转发到 aria2
* 2. 与本应用(可选)通过 JSON-RPC 通信
* 3. 提供右键菜单"使用 aria2 下载链接"
* 4. 预留资源嗅探能力(webRequest 监听)
*
* 注:Service Worker 是短生命周期的,配置需持久化到 chrome.storage
*/
// ===== 默认配置 =====
const DEFAULT_CONFIG = {
rpcUrl: 'http://127.0.0.1:6800/jsonrpc',
rpcSecret: '',
// 是否拦截浏览器原生下载
interceptDownload: true,
// 文件大小阈值(字节),超过才转 aria2。0 = 全部转
minSize: 0,
// 排除的域名(这些域名的下载走浏览器原生)
excludeDomains: [],
// 是否显示桌面通知
showNotifications: true
}
// ===== 配置读取 =====
async function getConfig() {
const stored = await chrome.storage.local.get('config')
return { ...DEFAULT_CONFIG, ...(stored.config || {}) }
}
async function saveConfig(config) {
await chrome.storage.local.set({ config })
}
// ===== aria2 JSON-RPC 调用 =====
async function aria2Call(method, params = []) {
const config = await getConfig()
const rpcParams = []
if (config.rpcSecret) {
rpcParams.push(`token:${config.rpcSecret}`)
}
rpcParams.push(...params)
const resp = await fetch(config.rpcUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: Date.now().toString(),
method,
params: rpcParams
})
})
if (!resp.ok) {
throw new Error(`aria2 RPC HTTP ${resp.status}`)
}
const data = await resp.json()
if (data.error) {
throw new Error(`aria2 RPC error: ${data.error.message} (${data.error.code})`)
}
return data.result
}
/**
* 添加 URI 下载任务到 aria2
* @returns {string} gid
*/
async function aria2AddUri(url, filename, referer, cookies) {
const options = {}
if (filename) options.out = filename
if (referer) options.referer = referer
if (cookies) options.header = [`Cookie: ${cookies}`]
// User-Agent 用浏览器默认值更兼容
options['user-agent'] = navigator.userAgent
return aria2Call('aria2.addUri', [[url], options])
}
// ===== 下载拦截 =====
async function shouldIntercept(downloadItem) {
const config = await getConfig()
if (!config.interceptDownload) return false
// 大小阈值
if (config.minSize > 0 && downloadItem.fileSize > 0 && downloadItem.fileSize < config.minSize) {
return false
}
// 域名排除
try {
const url = new URL(downloadItem.finalUrl || downloadItem.url)
if (config.excludeDomains.some(d => url.hostname.includes(d))) {
return false
}
} catch { /* ignore */ }
return true
}
async function handleDownloadCreated(downloadItem) {
if (!await shouldIntercept(downloadItem)) return
// 立即取消浏览器原生下载
try {
await chrome.downloads.cancel(downloadItem.id)
await chrome.downloads.erase({ id: downloadItem.id })
} catch { /* ignore */ }
const url = downloadItem.finalUrl || downloadItem.url
const filename = downloadItem.filename || ''
try {
const gid = await aria2AddUri(url, filename, downloadItem.referrer, '')
await notify('已添加到 aria2', `${filename || url}\nGID: ${gid}`)
} catch (e) {
await notify('aria2 添加失败', `${filename || url}\n${e.message}`)
// 失败时把 URL 重新交给浏览器下载
try { await chrome.downloads.download({ url }) } catch { /* ignore */ }
}
}
// ===== 通知 =====
async function notify(title, message) {
const config = await getConfig()
if (!config.showNotifications) return
try {
await chrome.notifications.create({
type: 'basic',
iconUrl: 'icons/icon-128.png',
title,
message
})
} catch { /* ignore */ }
}
// ===== 右键菜单 =====
chrome.runtime.onInstalled.addListener(() => {
chrome.contextMenus.create({
id: 'thing-download-link',
title: '使用 aria2 下载此链接',
contexts: ['link']
})
chrome.contextMenus.create({
id: 'thing-download-page',
title: '使用 aria2 下载当前页面资源',
contexts: ['page']
})
})
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
if (info.menuItemId === 'thing-download-link') {
const url = info.linkUrl
const filename = url.split('/').pop()?.split('?')[0] || ''
try {
const gid = await aria2AddUri(url, filename, info.pageUrl, '')
await notify('已添加到 aria2', `${filename || url}\nGID: ${gid}`)
} catch (e) {
await notify('aria2 添加失败', `${e.message}`)
}
}
})
// ===== 下载事件监听 =====
chrome.downloads.onCreated.addListener(handleDownloadCreated)
// ===== 资源嗅探(预留,仅日志,不拦截)=====
// 后续可启用:监听页面媒体资源,提供"嗅探到的资源"列表
chrome.webRequest.onBeforeRequest.addListener(
(details) => {
// 预留:识别视频/音频流等可下载资源
// 当前不处理,仅保留权限和入口
return undefined
},
{ urls: ['<all_urls>'] },
[]
)
// ===== 来自 popup 的消息 =====
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg.type === 'getConfig') {
getConfig().then(sendResponse)
return true
}
if (msg.type === 'saveConfig') {
saveConfig(msg.config).then(() => sendResponse({ ok: true })).catch(e => sendResponse({ ok: false, error: e.message }))
return true
}
if (msg.type === 'testConnection') {
aria2Call('aria2.getVersion', [])
.then(res => sendResponse({ ok: true, version: res.version }))
.catch(e => sendResponse({ ok: false, error: e.message }))
return true
}
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

@@ -0,0 +1,41 @@
{
"manifest_version": 3,
"name": "Thing Extension",
"version": "0.1.0",
"description": "接管浏览器下载,将任务发送到 aria2 内核。支持后续资源嗅探能力扩展。",
"icons": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
},
"permissions": [
"downloads",
"storage",
"notifications",
"webRequest",
"webNavigation",
"contextMenus"
],
"host_permissions": [
"<all_urls>"
],
"background": {
"service_worker": "background.js"
},
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
},
"default_title": "Thing Extension"
},
"options_ui": {
"page": "popup.html",
"open_in_tab": false
},
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'"
}
}
@@ -0,0 +1,138 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
width: 340px;
font-family: system-ui, -apple-system, 'Segoe UI', 'Microsoft YaHei', sans-serif;
font-size: 13px;
color: #1f2937;
background: #ffffff;
}
.container {
padding: 14px;
}
header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.logo {
width: 32px;
height: 32px;
border-radius: 6px;
}
.title h1 {
font-size: 14px;
font-weight: 600;
color: #111827;
}
.subtitle {
font-size: 11px;
color: #6b7280;
}
.status {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 6px;
background: #f3f4f6;
margin-bottom: 12px;
font-size: 12px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #9ca3af;
}
.dot.ok {
background: #10b981;
}
.dot.fail {
background: #ef4444;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
}
.field span {
font-size: 11px;
color: #4b5563;
font-weight: 500;
}
.field input {
padding: 6px 8px;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 12px;
font-family: 'JetBrains Mono', 'Consolas', monospace;
}
.field input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
}
.checkbox {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
cursor: pointer;
user-select: none;
}
.checkbox input {
cursor: pointer;
}
.actions {
display: flex;
gap: 8px;
margin-top: 6px;
}
.actions button {
flex: 1;
padding: 7px 10px;
border: 1px solid #d1d5db;
background: #ffffff;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.actions button:hover {
background: #f9fafb;
}
.actions button.primary {
background: #111827;
color: #ffffff;
border-color: #111827;
}
.actions button.primary:hover {
background: #1f2937;
}
@@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Thing Extension</title>
<link rel="stylesheet" href="popup.css" />
</head>
<body>
<div class="container">
<header>
<img src="icons/icon-48.png" alt="Thing" class="logo" />
<div class="title">
<h1>Thing Extension</h1>
<span class="subtitle">aria2 下载接管</span>
</div>
</header>
<section class="status" id="statusBox">
<span class="dot" id="statusDot"></span>
<span id="statusText">检测中...</span>
</section>
<form id="configForm">
<label class="field">
<span>RPC 地址</span>
<input type="text" id="rpcUrl" placeholder="http://127.0.0.1:6800/jsonrpc" />
</label>
<label class="field">
<span>RPC 密钥</span>
<input type="password" id="rpcSecret" placeholder="未设置时留空" />
</label>
<label class="checkbox">
<input type="checkbox" id="interceptDownload" />
<span>接管浏览器下载</span>
</label>
<label class="checkbox">
<input type="checkbox" id="showNotifications" />
<span>显示桌面通知</span>
</label>
<label class="field">
<span>最小文件大小(字节,0=全部)</span>
<input type="number" id="minSize" min="0" placeholder="0" />
</label>
<label class="field">
<span>排除域名(逗号分隔)</span>
<input type="text" id="excludeDomains" placeholder="例如:example.com,another.com" />
</label>
<div class="actions">
<button type="button" id="testBtn">测试连接</button>
<button type="submit" class="primary">保存</button>
</div>
</form>
</div>
<script src="popup.js"></script>
</body>
</html>
@@ -0,0 +1,96 @@
/**
* Thing Extension - Popup 脚本
* 配置 RPC 地址、密钥等参数,存储到 chrome.storage.local
*/
const $ = (id) => document.getElementById(id)
const DEFAULT_CONFIG = {
rpcUrl: 'http://127.0.0.1:6800/jsonrpc',
rpcSecret: '',
interceptDownload: true,
minSize: 0,
excludeDomains: [],
showNotifications: true
}
function setStatus(state, text) {
const dot = $('statusDot')
const txt = $('statusText')
dot.className = 'dot ' + (state === 'ok' ? 'ok' : state === 'fail' ? 'fail' : '')
txt.textContent = text
}
async function loadConfig() {
return new Promise((resolve) => {
chrome.runtime.sendMessage({ type: 'getConfig' }, (config) => {
resolve(config || { ...DEFAULT_CONFIG })
})
})
}
function fillForm(config) {
$('rpcUrl').value = config.rpcUrl || DEFAULT_CONFIG.rpcUrl
$('rpcSecret').value = config.rpcSecret || ''
$('interceptDownload').checked = config.interceptDownload !== false
$('showNotifications').checked = config.showNotifications !== false
$('minSize').value = config.minSize || 0
$('excludeDomains').value = (config.excludeDomains || []).join(',')
}
function readForm() {
return {
rpcUrl: $('rpcUrl').value.trim() || DEFAULT_CONFIG.rpcUrl,
rpcSecret: $('rpcSecret').value.trim(),
interceptDownload: $('interceptDownload').checked,
showNotifications: $('showNotifications').checked,
minSize: parseInt($('minSize').value, 10) || 0,
excludeDomains: $('excludeDomains').value
.split(',')
.map(s => s.trim())
.filter(Boolean)
}
}
document.getElementById('configForm').addEventListener('submit', async (e) => {
e.preventDefault()
const config = readForm()
chrome.runtime.sendMessage({ type: 'saveConfig', config }, (res) => {
if (res && res.ok) {
setStatus('', '已保存')
setTimeout(() => window.close(), 500)
} else {
setStatus('fail', '保存失败:' + (res?.error || '未知错误'))
}
})
})
$('testBtn').addEventListener('click', () => {
setStatus('', '测试中...')
// 先保存当前表单值,再测试
const config = readForm()
chrome.runtime.sendMessage({ type: 'saveConfig', config }, () => {
chrome.runtime.sendMessage({ type: 'testConnection' }, (res) => {
if (res && res.ok) {
setStatus('ok', `已连接 · aria2 ${res.version}`)
} else {
setStatus('fail', '连接失败:' + (res?.error || '未知错误'))
}
})
})
})
// 初始化
;(async () => {
const config = await loadConfig()
fillForm(config)
// 自动测试一次连接
setStatus('', '检测中...')
chrome.runtime.sendMessage({ type: 'testConnection' }, (res) => {
if (res && res.ok) {
setStatus('ok', `已连接 · aria2 ${res.version}`)
} else {
setStatus('fail', '未连接')
}
})
})()