feat(userscript): add shared download core

This commit is contained in:
xixu-me committed 2026-04-12 00:15:54 +08:00
1 parent 3fec6dab96
commit bbed3334c0
13 files changed
+2295 -1018

No files matched your search

+101 -3
View File
@@ -13,6 +13,7 @@
import argparse import argparse
import json import json
import os import os
import re
import shutil import shutil
import zipfile import zipfile
from pathlib import Path from pathlib import Path
@@ -67,6 +68,19 @@ def copy_common_files(build_dir):
shutil.copytree("icons", build_dir / "icons", dirs_exist_ok=True) shutil.copytree("icons", build_dir / "icons", dirs_exist_ok=True)
def read_package_metadata():
with open("package.json", encoding="utf-8") as f:
package = json.load(f)
return {
"name": package["name"],
"version": package["version"],
"description": package["description"],
"author": package["author"],
"homepage": package["homepage"],
}
def build_chrome(): def build_chrome():
""" """
构建 Chromium 版本 构建 Chromium 版本
@@ -153,6 +167,11 @@ def flatten_for_browser(build_dir, platform):
if platforms_file.exists(): if platforms_file.exists():
shutil.copy2(platforms_file, build_dir / "platforms.js") shutil.copy2(platforms_file, build_dir / "platforms.js")
# shared/download-core.js -> download-core.js
download_core_file = src_dir / "shared" / "download-core.js"
if download_core_file.exists():
shutil.copy2(download_core_file, build_dir / "download-core.js")
# shared/platform-detector.js -> platform-detector.js # shared/platform-detector.js -> platform-detector.js
detector_file = src_dir / "shared" / "platform-detector.js" detector_file = src_dir / "shared" / "platform-detector.js"
if detector_file.exists(): if detector_file.exists():
@@ -256,6 +275,79 @@ def create_package(build_dir, platform):
return package_path return package_path
def create_userscript_package(script_path):
package_dir = Path("packages")
package_dir.mkdir(exist_ok=True)
metadata = read_package_metadata()
package_name = f"Xget-Now_{metadata['version']}.userscript.user.js"
package_path = package_dir / package_name
shutil.copy2(script_path, package_path)
print(f"用户脚本包已创建: {package_path}")
return package_path
def extract_userscript_matches():
source = Path("src/shared/download-core.js").read_text(encoding="utf-8")
match = re.search(
r"const CORE_USERSCRIPT_MATCHES = \[(.*?)\];",
source,
re.DOTALL,
)
if not match:
raise ValueError("无法从 download-core.js 中提取 CORE_USERSCRIPT_MATCHES")
raw_items = match.group(1)
return re.findall(r'"([^"]+)"', raw_items)
def build_userscript():
print("构建 userscript 版本...")
build_dir = create_build_directory("userscript")
metadata = read_package_metadata()
matches = extract_userscript_matches()
metadata_lines = [
"// ==UserScript==",
"// @name Xget Now",
f"// @namespace {metadata['homepage']}",
f"// @version {metadata['version']}",
f"// @description {metadata['description']}",
f"// @author {metadata['author']}",
"// @license GPL-3.0",
]
metadata_lines.extend(f"// @match {pattern}" for pattern in matches)
metadata_lines.extend(
[
"// @grant GM_getValue",
"// @grant GM_setValue",
"// @grant GM_deleteValue",
"// @grant GM_registerMenuCommand",
"// @grant GM_xmlhttpRequest",
"// @connect *",
"// @run-at document-start",
"// ==/UserScript==",
"",
]
)
script_parts = [
"\n".join(metadata_lines),
Path("src/shared/download-core.js").read_text(encoding="utf-8"),
"",
Path("src/userscript/index.js").read_text(encoding="utf-8"),
"",
]
output_path = build_dir / "xget-now.user.js"
output_path.write_text("\n".join(script_parts), encoding="utf-8")
print(f"Userscript 构建完成: {output_path}")
return output_path
def validate_manifest(manifest_path, platform): def validate_manifest(manifest_path, platform):
""" """
验证 manifest 文件格式和内容 验证 manifest 文件格式和内容
@@ -299,7 +391,7 @@ def main():
parser = argparse.ArgumentParser(description="构建 Xget Now 扩展") parser = argparse.ArgumentParser(description="构建 Xget Now 扩展")
parser.add_argument( parser.add_argument(
"--platform", "--platform",
choices=["chrome", "firefox", "all"], choices=["chrome", "firefox", "userscript", "all"],
default="all", default="all",
help="要构建的平台", help="要构建的平台",
) )
@@ -312,7 +404,7 @@ def main():
platforms = [] platforms = []
if args.platform == "all": if args.platform == "all":
platforms = ["chrome", "firefox"] platforms = ["chrome", "firefox", "userscript"]
else: else:
platforms = [args.platform] platforms = [args.platform]
@@ -324,6 +416,12 @@ def main():
build_dir = build_chrome() build_dir = build_chrome()
elif platform == "firefox": elif platform == "firefox":
build_dir = build_firefox() build_dir = build_firefox()
elif platform == "userscript":
userscript_path = build_userscript()
build_results.append((platform, userscript_path))
if args.package:
create_userscript_package(userscript_path)
continue
# 验证 manifest # 验证 manifest
manifest_path = build_dir / "manifest.json" manifest_path = build_dir / "manifest.json"
@@ -345,7 +443,7 @@ def main():
print(f"✓ {platform}: {build_dir}") print(f"✓ {platform}: {build_dir}")
if args.package: if args.package:
print("\n扩展包位于 packages/ 目录") print("\n构建包位于 packages/ 目录")
if __name__ == "__main__": if __name__ == "__main__":
+8
View File
@@ -3,6 +3,7 @@
"persistent": true, "persistent": true,
"scripts": [ "scripts": [
"webext-compat.js", "webext-compat.js",
"download-core.js",
"background.js" "background.js"
] ]
}, },
@@ -26,10 +27,14 @@
{ {
"js": [ "js": [
"webext-compat.js", "webext-compat.js",
"download-core.js",
"content.js" "content.js"
], ],
"matches": [ "matches": [
"https://github.com/*", "https://github.com/*",
"https://raw.githubusercontent.com/*",
"https://codeload.github.com/*",
"https://objects.githubusercontent.com/*",
"https://gitlab.com/*", "https://gitlab.com/*",
"https://gitea.com/*", "https://gitea.com/*",
"https://codeberg.org/*", "https://codeberg.org/*",
@@ -72,6 +77,9 @@
"storage", "storage",
"activeTab", "activeTab",
"https://github.com/*", "https://github.com/*",
"https://raw.githubusercontent.com/*",
"https://codeload.github.com/*",
"https://objects.githubusercontent.com/*",
"https://gitlab.com/*", "https://gitlab.com/*",
"https://gitea.com/*", "https://gitea.com/*",
"https://codeberg.org/*", "https://codeberg.org/*",
+7
View File
@@ -16,10 +16,14 @@
{ {
"js": [ "js": [
"webext-compat.js", "webext-compat.js",
"download-core.js",
"content.js" "content.js"
], ],
"matches": [ "matches": [
"https://github.com/*", "https://github.com/*",
"https://raw.githubusercontent.com/*",
"https://codeload.github.com/*",
"https://objects.githubusercontent.com/*",
"https://gitlab.com/*", "https://gitlab.com/*",
"https://gitea.com/*", "https://gitea.com/*",
"https://codeberg.org/*", "https://codeberg.org/*",
@@ -51,6 +55,9 @@
"description": "为 Chromium 浏览器提供的 Xget 下载加速扩展,支持 GitHub、GitLab、npm、PyPI 等众多平台的文件下载加速", "description": "为 Chromium 浏览器提供的 Xget 下载加速扩展,支持 GitHub、GitLab、npm、PyPI 等众多平台的文件下载加速",
"host_permissions": [ "host_permissions": [
"https://github.com/*", "https://github.com/*",
"https://raw.githubusercontent.com/*",
"https://codeload.github.com/*",
"https://objects.githubusercontent.com/*",
"https://gitlab.com/*", "https://gitlab.com/*",
"https://gitea.com/*", "https://gitea.com/*",
"https://codeberg.org/*", "https://codeberg.org/*",
+66
View File
@@ -0,0 +1,66 @@
{
"name": "xget-now",
"version": "2.0.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "xget-now",
"version": "2.0.5",
"license": "GPL-3.0",
"devDependencies": {
"playwright": "^1.59.1"
},
"engines": {
"chrome": ">=88"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}
+10 -3
View File
@@ -56,8 +56,15 @@
"url": "https://github.com/xixu-me/Xget-Now.git" "url": "https://github.com/xixu-me/Xget-Now.git"
}, },
"scripts": { "scripts": {
"build": "echo 'No build process needed for Chrome extension'", "build": "python build.py --platform all",
"test": "echo 'No tests specified'" "build:package": "python build.py --platform all --package",
"build:userscript": "python build.py --platform userscript --package",
"test": "node --test",
"test:unit": "node --test tests/unit/download-core.test.js",
"test:e2e": "node --test tests/e2e/userscript.e2e.test.js"
}, },
"version": "2.0.5" "version": "2.0.5",
"devDependencies": {
"playwright": "^1.59.1"
}
} }
+108 -405
View File
@@ -1,445 +1,148 @@
/**
* Xget Now - 扩展后台脚本
*
* 功能:
* - 处理扩展设置管理
* - 监听标签页更新事件
* - 处理来自内容脚本的消息
* - 管理扩展状态和配置
*/
// 对于Firefox Manifest V2,webext-compat.js 通过manifest加载
// 对于Chrome Manifest V3,通过importScripts加载
if (typeof importScripts !== "undefined") { if (typeof importScripts !== "undefined") {
try { try {
importScripts("webext-compat.js"); importScripts("webext-compat.js", "download-core.js");
} catch (e) { } catch (error) {
console.log("Running in Firefox/Manifest V2 environment"); console.log("Background dependencies already loaded by the browser");
} }
} }
// 确保兼容层可用
if (typeof webext === "undefined") { if (typeof webext === "undefined") {
console.error("WebExt compatibility layer not found");
// 尝试使用全局的browser或chrome API
if (typeof browser !== "undefined") { if (typeof browser !== "undefined") {
console.log("Using Firefox browser API directly");
self.webext = browser; self.webext = browser;
self.webextFlavor = { soup: new Set(["firefox"]) }; self.webextFlavor = { soup: new Set(["firefox"]) };
} else if (typeof chrome !== "undefined") { } else if (typeof chrome !== "undefined") {
console.log("Using Chrome API directly");
self.webext = chrome; self.webext = chrome;
self.webextFlavor = { soup: new Set(["chromium"]) }; self.webextFlavor = { soup: new Set(["chromium"]) };
} }
} }
// 确认API可用性 const core = self.XgetDownloadCore;
console.log("Background script initializing..."); const DEFAULT_SETTINGS = core.normalizeSettings(core.DEFAULT_EXTENSION_SETTINGS);
console.log("WebExt API available:", typeof webext !== "undefined");
console.log("Storage API available:", webext && webext.storage ? "yes" : "no");
console.log("Runtime API available:", webext && webext.runtime ? "yes" : "no");
/** function getStorageAPI() {
* 平台配置定义
* 支持的下载加速平台列表
*/
const PLATFORMS = {
// 代码托管平台
gh: {
base: "https://github.com",
name: "GitHub",
pattern: /^https:\/\/github\.com\//,
},
gl: {
base: "https://gitlab.com",
name: "GitLab",
pattern: /^https:\/\/gitlab\.com\//,
},
gitea: {
base: "https://gitea.com",
name: "Gitea",
pattern: /^https:\/\/gitea\.com\//,
},
codeberg: {
base: "https://codeberg.org",
name: "Codeberg",
pattern: /^https:\/\/codeberg\.org\//,
},
sf: {
base: "https://sourceforge.net",
name: "SourceForge",
pattern: /^https:\/\/sourceforge\.net\//,
},
aosp: {
base: "https://android.googlesource.com",
name: "AOSP",
pattern: /^https:\/\/android\.googlesource\.com\//,
},
// AI/ML 平台
hf: {
base: "https://huggingface.co",
name: "Hugging Face",
pattern: /^https:\/\/huggingface\.co\//,
},
// 包管理平台
npm: {
base: "https://registry.npmjs.org",
name: "npm",
pattern: /^https:\/\/registry\.npmjs\.org\//,
},
pypi: {
base: "https://pypi.org",
name: "PyPI",
pattern: /^https:\/\/pypi\.org\//,
},
"pypi-files": {
base: "https://files.pythonhosted.org",
name: "PyPI Files",
pattern: /^https:\/\/files\.pythonhosted\.org\//,
},
conda: {
base: "https://repo.anaconda.com",
name: "Conda",
pattern: /^https:\/\/repo\.anaconda\.com\//,
},
"conda-community": {
base: "https://conda.anaconda.org",
name: "Conda Community",
pattern: /^https:\/\/conda\.anaconda\.org\//,
},
maven: {
base: "https://repo1.maven.org",
name: "Maven",
pattern: /^https:\/\/repo1\.maven\.org\//,
},
apache: {
base: "https://downloads.apache.org",
name: "Apache",
pattern: /^https:\/\/downloads\.apache\.org\//,
},
gradle: {
base: "https://plugins.gradle.org",
name: "Gradle",
pattern: /^https:\/\/plugins\.gradle\.org\//,
},
rubygems: {
base: "https://rubygems.org",
name: "RubyGems",
pattern: /^https:\/\/rubygems\.org\//,
},
cran: {
base: "https://cran.r-project.org",
name: "CRAN",
pattern: /^https:\/\/cran\.r-project\.org\//,
},
cpan: {
base: "https://www.cpan.org",
name: "CPAN",
pattern: /^https:\/\/www\.cpan\.org\//,
},
ctan: {
base: "https://tug.ctan.org",
name: "CTAN",
pattern: /^https:\/\/tug\.ctan\.org\//,
},
golang: {
base: "https://proxy.golang.org",
name: "Go Modules",
pattern: /^https:\/\/proxy\.golang\.org\//,
},
nuget: {
base: "https://api.nuget.org",
name: "NuGet",
pattern: /^https:\/\/api\.nuget\.org\//,
},
crates: {
base: "https://crates.io",
name: "Crates.io",
pattern: /^https:\/\/crates\.io\//,
},
packagist: {
base: "https://repo.packagist.org",
name: "Packagist",
pattern: /^https:\/\/repo\.packagist\.org\//,
},
// 其他平台
arxiv: {
base: "https://arxiv.org",
name: "arXiv",
pattern: /^https:\/\/arxiv\.org\//,
},
fdroid: {
base: "https://f-droid.org",
name: "F-Droid",
pattern: /^https:\/\/f-droid\.org\//,
},
};
// 转换URL为Xget格式
function transformUrl(url, xgetDomain, enabledPlatforms) {
try {
const platform = detectPlatform(url);
if (!platform || !enabledPlatforms[platform]) {
return null;
}
const urlObj = new URL(url);
const path = urlObj.pathname + urlObj.search + urlObj.hash;
return `https://${xgetDomain}/${platform}${path}`;
} catch (error) {
console.error("转换 URL 时出错:", error);
return null;
}
}
// 检测平台
function detectPlatform(url) {
for (const [key, platform] of Object.entries(PLATFORMS)) {
if (platform.pattern.test(url)) {
return key;
}
}
return null;
}
const DEFAULT_SETTINGS = {
enabled: true,
xgetDomain: "xget.xi-xu.me",
enabledPlatforms: {
// 代码托管平台
gh: true,
gl: true,
gitea: true,
codeberg: true,
sf: true,
aosp: true,
// AI/ML 平台
hf: true,
// 包管理平台
npm: true,
pypi: true,
"pypi-files": true,
conda: true,
"conda-community": true,
maven: true,
apache: true,
gradle: true,
rubygems: true,
cran: true,
cpan: true,
ctan: true,
golang: true,
nuget: true,
crates: true,
packagist: true,
// 其他平台
arxiv: true,
fdroid: true,
},
};
// 初始化扩展
webext.runtime.onInstalled.addListener(async () => {
console.log("Xget Now 已安装");
// 对于Firefox,优先使用local存储,因为sync可能不可用或有限制
const isFirefox = const isFirefox =
webextFlavor && webextFlavor.soup && webextFlavor.soup.has("firefox"); typeof webextFlavor !== "undefined" &&
const storageAPI = isFirefox webextFlavor.soup &&
? webext.storage.local webextFlavor.soup.has("firefox");
: webext.storage.sync || webext.storage.local;
try { return isFirefox ? webext.storage.local : webext.storage.sync || webext.storage.local;
// 如果尚未设置,则设置默认设置
const settings = await storageAPI.get(DEFAULT_SETTINGS);
await storageAPI.set(settings);
} catch (error) {
console.error("初始化设置时出错:", error);
// 尝试使用local storage作为后备
try {
await webext.storage.local.set(DEFAULT_SETTINGS);
} catch (localError) {
console.error("使用本地存储初始化设置时出错:", localError);
}
}
});
// 防止监听器被多次注册
let downloadListenerAdded = false;
// 监听下载事件
if (!downloadListenerAdded && webext.downloads.onDeterminingFilename) {
webext.downloads.onDeterminingFilename.addListener(handleDownload);
downloadListenerAdded = true;
} }
function handleDownload(downloadItem, suggest) { async function getSettings() {
// 立即调用 suggest 来防止多次调用错误 const storageAPI = getStorageAPI();
suggest(); const rawSettings = await storageAPI.get(DEFAULT_SETTINGS);
return core.normalizeSettings(rawSettings);
}
// 然后异步处理重定向逻辑 async function setSettings(nextSettings) {
processDownloadRedirect(downloadItem); const storageAPI = getStorageAPI();
const normalized = core.normalizeSettings(nextSettings);
await storageAPI.set(normalized);
return normalized;
}
async function notifyMatchingTabs(message, showRefreshButton) {
try {
const tabs = await webext.tabs.query({
url: core.getMatchPatterns(),
});
await Promise.all(
tabs.map(async (tab) => {
try {
await webext.tabs.sendMessage(tab.id, {
action: "showNotification",
message,
showRefreshButton,
});
} catch (error) {
return null;
}
})
);
} catch (error) {
console.log("Unable to notify tabs about settings changes");
}
} }
async function processDownloadRedirect(downloadItem) { async function processDownloadRedirect(downloadItem) {
try { const settings = await getSettings();
// 对于Firefox,优先使用local存储 if (!settings.enabled || !settings.activeDomain) {
const isFirefox = return;
webextFlavor && webextFlavor.soup && webextFlavor.soup.has("firefox"); }
const storageAPI = isFirefox
? webext.storage.local
: webext.storage.sync || webext.storage.local;
const settings = await storageAPI.get(DEFAULT_SETTINGS);
// 检查扩展是否已启用并配置了域名 const redirectedUrl = core.transformDownloadUrl(downloadItem.url, settings);
if (!settings.enabled || !settings.xgetDomain) { if (!redirectedUrl || redirectedUrl === downloadItem.url) {
return; return;
}
await webext.downloads.cancel(downloadItem.id);
await webext.downloads.download({
url: redirectedUrl,
filename: downloadItem.filename || undefined,
conflictAction: "uniquify",
});
if (typeof downloadItem.tabId === "number") {
try {
await webext.tabs.sendMessage(downloadItem.tabId, {
action: "showNotification",
message: "Download redirected via Xget",
showRefreshButton: false,
});
} catch (error) {
return null;
} }
const url = downloadItem.url;
const redirectedUrl = transformUrl(
url,
settings.xgetDomain,
settings.enabledPlatforms
);
if (redirectedUrl && redirectedUrl !== url) {
console.log("重定向下载:", url, "->", redirectedUrl);
try {
// 取消原始下载
await webext.downloads.cancel(downloadItem.id);
// 使用重定向的 URL 开始新下载
await webext.downloads.download({
url: redirectedUrl,
filename: downloadItem.filename || undefined,
conflictAction: "uniquify",
});
// 通过内容脚本显示通知
try {
await webext.tabs.sendMessage(downloadItem.tabId, {
action: "showNotification",
message: "下载已通过 Xget 重定向",
});
} catch (error) {
console.log("无法向标签页发送通知");
}
} catch (error) {
console.error("重定向下载时出错:", error);
}
}
} catch (error) {
console.error("处理下载重定向时出错:", error);
} }
} }
function transformUrlLegacy(url, settings) { function handleDownload(downloadItem, suggest) {
try { suggest();
// 找到匹配的平台 processDownloadRedirect(downloadItem).catch((error) => {
for (const [platformKey, platform] of Object.entries(PLATFORMS)) { console.error("Failed to redirect browser download", error);
if (!settings.enabledPlatforms[platformKey]) continue; });
if (platform.pattern.test(url)) {
const urlObj = new URL(url);
const path = urlObj.pathname + urlObj.search + urlObj.hash;
// 使用 Xget 域名转换 URL(添加 https:// 协议)
const xgetUrl = `https://${settings.xgetDomain}/${platformKey}${path}`;
return xgetUrl;
}
}
return null; // 不需要转换
} catch (error) {
console.error("转换 URL 时出错:", error);
return null;
}
} }
// 监听来自弹出窗口/选项的消息
const messageListener = (request, sender, sendResponse) => { const messageListener = (request, sender, sendResponse) => {
console.log("Received message:", request.action);
// 对于Firefox,优先使用local存储,因为sync可能不可用或有限制
const isFirefox =
webextFlavor && webextFlavor.soup && webextFlavor.soup.has("firefox");
const storageAPI = isFirefox
? webext.storage.local
: webext.storage.sync || webext.storage.local;
if (request.action === "getSettings") { if (request.action === "getSettings") {
// 使用Promise处理异步操作 getSettings()
(async () => { .then((settings) => sendResponse(settings))
try { .catch((error) => {
const settings = await storageAPI.get(DEFAULT_SETTINGS); console.error("Failed to load settings", error);
console.log("Settings retrieved:", settings);
sendResponse(settings || DEFAULT_SETTINGS);
} catch (error) {
console.error("获取设置时出错:", error);
sendResponse(DEFAULT_SETTINGS); sendResponse(DEFAULT_SETTINGS);
} });
})(); return true;
return true; // 保持消息通道开放 }
} else if (request.action === "saveSettings") {
// 确保设置对象有效 if (request.action === "saveSettings") {
const settingsToSave = { setSettings(request.settings || {})
...DEFAULT_SETTINGS, .then(async (settings) => {
...request.settings, await notifyMatchingTabs("Settings updated. Refresh the page to use them.", true);
}; sendResponse({ success: true, settings });
})
(async () => { .catch((error) => {
try { console.error("Failed to save settings", error);
await storageAPI.set(settingsToSave); sendResponse({ success: false, error: error.message });
console.log("Settings saved successfully"); });
return true;
// 通知相关标签页刷新
try {
const tabs = await webext.tabs.query({
url: Object.values(PLATFORMS).map(
(platform) => platform.base + "/*"
),
});
for (const tab of tabs) {
try {
await webext.tabs.sendMessage(tab.id, {
action: "showNotification",
message: "设置已更新!点击刷新页面",
showRefreshButton: true,
});
} catch (error) {
// 标签页可能没有加载内容脚本,忽略
}
}
} catch (error) {
console.log("无法通知标签页设置更新");
}
sendResponse({ success: true });
} catch (error) {
console.error("保存设置时出错:", error);
sendResponse({ success: false, error: error.message });
}
})();
return true; // 保持消息通道开放
} }
// 未知消息
return false; return false;
}; };
// 添加消息监听器 webext.runtime.onInstalled.addListener(async () => {
if (webext && webext.runtime && webext.runtime.onMessage) { try {
webext.runtime.onMessage.addListener(messageListener); const settings = await getSettings();
console.log("Message listener registered"); await setSettings(settings);
} else { } catch (error) {
console.error("Cannot register message listener - runtime API not available"); console.error("Failed to initialize extension settings", error);
}
});
if (webext.downloads && webext.downloads.onDeterminingFilename) {
webext.downloads.onDeterminingFilename.addListener(handleDownload);
}
if (webext.runtime && webext.runtime.onMessage) {
webext.runtime.onMessage.addListener(messageListener);
} }
+145 -599
View File
@@ -1,614 +1,160 @@
/**
* Xget Now - 内容脚本
*
* 功能:
* - 在支持的平台页面上注入下载拦截功能
* - 监听页面上的下载链接点击事件
* - 将下载请求重定向到加速服务
* - 处理不同平台的特殊下载逻辑
*/
// 确保兼容层可用
if (typeof webext === "undefined") { if (typeof webext === "undefined") {
console.error("WebExt compatibility layer not found in content script"); console.error("Xget Now content script: compatibility layer missing");
} }
/** const core = globalThis.XgetDownloadCore;
* 平台配置定义 let liveSettings = null;
* 与后台脚本保持同步的平台列表
*/
const PLATFORMS = {
// 代码托管平台
gh: {
base: "https://github.com",
name: "GitHub",
pattern: /^https:\/\/github\.com\//,
},
gl: {
base: "https://gitlab.com",
name: "GitLab",
pattern: /^https:\/\/gitlab\.com\//,
},
gitea: {
base: "https://gitea.com",
name: "Gitea",
pattern: /^https:\/\/gitea\.com\//,
},
codeberg: {
base: "https://codeberg.org",
name: "Codeberg",
pattern: /^https:\/\/codeberg\.org\//,
},
sf: {
base: "https://sourceforge.net",
name: "SourceForge",
pattern: /^https:\/\/sourceforge\.net\//,
},
aosp: {
base: "https://android.googlesource.com",
name: "AOSP",
pattern: /^https:\/\/android\.googlesource\.com\//,
},
// AI/ML 平台 async function getSettings() {
hf: { const settings = await new Promise((resolve) => {
base: "https://huggingface.co", webext.runtime.sendMessage({ action: "getSettings" }, resolve);
name: "Hugging Face", });
pattern: /^https:\/\/huggingface\.co\//,
},
// 包管理平台 return core.normalizeSettings(settings);
npm: { }
base: "https://registry.npmjs.org",
name: "npm",
pattern: /^https:\/\/registry\.npmjs\.org\//,
},
pypi: {
base: "https://pypi.org",
name: "PyPI",
pattern: /^https:\/\/pypi\.org\//,
},
"pypi-files": {
base: "https://files.pythonhosted.org",
name: "PyPI Files",
pattern: /^https:\/\/files\.pythonhosted\.org\//,
},
conda: {
base: "https://repo.anaconda.com",
name: "Conda",
pattern: /^https:\/\/repo\.anaconda\.com\//,
},
"conda-community": {
base: "https://conda.anaconda.org",
name: "Conda Community",
pattern: /^https:\/\/conda\.anaconda\.org\//,
},
maven: {
base: "https://repo1.maven.org",
name: "Maven",
pattern: /^https:\/\/repo1\.maven\.org\//,
},
apache: {
base: "https://downloads.apache.org",
name: "Apache",
pattern: /^https:\/\/downloads\.apache\.org\//,
},
gradle: {
base: "https://plugins.gradle.org",
name: "Gradle",
pattern: /^https:\/\/plugins\.gradle\.org\//,
},
rubygems: {
base: "https://rubygems.org",
name: "RubyGems",
pattern: /^https:\/\/rubygems\.org\//,
},
cran: {
base: "https://cran.r-project.org",
name: "CRAN",
pattern: /^https:\/\/cran\.r-project\.org\//,
},
cpan: {
base: "https://www.cpan.org",
name: "CPAN",
pattern: /^https:\/\/www\.cpan\.org\//,
},
ctan: {
base: "https://tug.ctan.org",
name: "CTAN",
pattern: /^https:\/\/tug\.ctan\.org\//,
},
golang: {
base: "https://proxy.golang.org",
name: "Go Modules",
pattern: /^https:\/\/proxy\.golang\.org\//,
},
nuget: {
base: "https://api.nuget.org",
name: "NuGet",
pattern: /^https:\/\/api\.nuget\.org\//,
},
crates: {
base: "https://crates.io",
name: "Crates.io",
pattern: /^https:\/\/crates\.io\//,
},
packagist: {
base: "https://repo.packagist.org",
name: "Packagist",
pattern: /^https:\/\/repo\.packagist\.org\//,
},
// 其他平台 function shouldHandleCurrentPage(settings) {
arxiv: { const currentPlatform = core.detectPlatform(window.location.href);
base: "https://arxiv.org", return (
name: "arXiv", Boolean(currentPlatform) &&
pattern: /^https:\/\/arxiv\.org\//, settings.enabled &&
}, Boolean(settings.activeDomain) &&
fdroid: { settings.enabledPlatforms[currentPlatform]
base: "https://f-droid.org", );
name: "F-Droid", }
pattern: /^https:\/\/f-droid\.org\//,
},
};
// 初始化内容脚本 function createSyntheticLink(url, sourceLink) {
(async function () { const link = document.createElement("a");
console.log("Xget Now:内容脚本已加载"); link.href = url;
link.rel = "noopener noreferrer";
link.target = sourceLink && sourceLink.target ? sourceLink.target : "_blank";
if (sourceLink && (sourceLink.download || sourceLink.hasAttribute("download"))) {
link.download = sourceLink.download || "";
}
link.style.display = "none";
return link;
}
function openViaSyntheticLink(url, sourceLink) {
const link = createSyntheticLink(url, sourceLink);
document.body.appendChild(link);
link.click();
link.remove();
}
function showNotification(message, showRefreshButton) {
const existing = document.querySelectorAll(".xget-notification");
existing.forEach((node) => node.remove());
const notification = document.createElement("div");
notification.className = "xget-notification";
notification.style.cssText = [
"position: fixed",
"top: 20px",
"right: 20px",
"z-index: 2147483647",
"max-width: 280px",
"padding: 12px 14px",
"border-radius: 10px",
"box-shadow: 0 10px 24px rgba(15, 23, 42, 0.2)",
"background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%)",
"color: #eff6ff",
"font: 13px/1.4 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
].join("; ");
const text = document.createElement("div");
text.textContent = String(message);
notification.appendChild(text);
if (showRefreshButton) {
const button = document.createElement("button");
button.type = "button";
button.textContent = "Refresh";
button.style.cssText = [
"margin-top: 8px",
"border: 0",
"border-radius: 8px",
"padding: 6px 8px",
"background: rgba(255, 255, 255, 0.18)",
"color: inherit",
"cursor: pointer",
].join("; ");
button.addEventListener("click", () => window.location.reload());
notification.appendChild(button);
}
document.documentElement.appendChild(notification);
setTimeout(() => {
notification.remove();
}, showRefreshButton ? 8000 : 3000);
}
async function handleClick(event) {
const link = event.target && event.target.closest
? event.target.closest("a[href]")
: null;
if (!link) {
return;
}
const settings = liveSettings || (await getSettings());
liveSettings = settings;
if (!shouldHandleCurrentPage(settings)) {
return;
}
const platformKey = core.detectPlatform(link.href);
if (!platformKey || !settings.enabledPlatforms[platformKey]) {
return;
}
if (core.shouldBypassClick(event, link)) {
return;
}
if (
!core.isDownloadCandidate(link.href, {
hasDownloadAttribute: Boolean(link.download || link.hasAttribute("download")),
linkText: link.textContent || "",
})
) {
return;
}
event.preventDefault();
event.stopPropagation();
if (typeof event.stopImmediatePropagation === "function") {
event.stopImmediatePropagation();
}
const redirectedUrl = core.transformDownloadUrl(link.href, settings);
if (!redirectedUrl || redirectedUrl === link.href) {
showNotification("Unable to rewrite this URL. Opening the original link.");
openViaSyntheticLink(link.href, link);
return;
}
showNotification("Download redirected via Xget");
openViaSyntheticLink(redirectedUrl, link);
}
(async function init() {
liveSettings = await getSettings();
if (!shouldHandleCurrentPage(liveSettings)) {
return;
}
document.addEventListener("click", handleClick, true);
// 监听来自后台脚本的消息
webext.runtime.onMessage.addListener((request, sender, sendResponse) => { webext.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === "showNotification") { if (request.action === "showNotification") {
showNotification(request.message, request.showRefreshButton); showNotification(request.message, request.showRefreshButton);
sendResponse({ success: true }); sendResponse({ success: true });
} }
}); });
// 检查扩展是否已启用并配置
const settings = await getSettings();
if (!settings.enabled || !settings.xgetDomain) {
return;
}
// 找到当前平台
const currentPlatform = detectPlatform(window.location.href);
if (!currentPlatform || !settings.enabledPlatforms[currentPlatform]) {
return;
}
// 添加下载拦截
interceptDownloadLinks();
// 监控动态添加的内容
observePageChanges();
})(); })();
async function getSettings() {
try {
return await new Promise((resolve) => {
webext.runtime.sendMessage({ action: "getSettings" }, resolve);
});
} catch (error) {
console.error("获取设置时出错:", error);
return { enabled: false };
}
}
function detectPlatform(url) {
for (const [key, platform] of Object.entries(PLATFORMS)) {
if (platform.pattern.test(url)) {
return key;
}
}
return null;
}
function interceptDownloadLinks() {
// 拦截下载链接的点击事件
document.addEventListener(
"click",
async (event) => {
const link = event.target.closest("a");
if (!link || !link.href) return;
// 检查这是否是下载链接
if (isDownloadLink(link)) {
event.preventDefault();
event.stopPropagation();
await handleDownloadLink(link.href);
}
},
true
);
}
function isDownloadLink(link) {
const href = link.href.toLowerCase();
const url = new URL(link.href);
const pathname = url.pathname.toLowerCase();
// 第一检查:明确的下载属性
if (link.download || link.hasAttribute("download")) {
return true;
}
// 第二检查:表示可下载文件的文件扩展名
const fileExtensions = [
".zip",
".tar.gz",
".tar.bz2",
".tar.xz",
".7z",
".rar",
".gz",
".bz2",
".exe",
".msi",
".dmg",
".pkg",
".deb",
".rpm",
".apk",
".jar",
".war",
".ear",
".iso",
".img",
".pdf",
".doc",
".docx",
".xls",
".xlsx",
".ppt",
".pptx",
".mp4",
".avi",
".mkv",
".mov",
".wmv",
".flv",
".mp3",
".wav",
".flac",
".ogg",
".jpg",
".jpeg",
".png",
".gif",
".bmp",
".tiff",
".svg",
".whl",
".egg",
".gem",
".nupkg",
];
// 检查 URL 是否以文件扩展名结尾
if (fileExtensions.some((ext) => pathname.endsWith(ext))) {
return true;
}
// 第三检查:GitHub 特定模式
const allowedGitHubHosts = ["github.com"];
try {
const parsedUrl = new URL(href);
if (allowedGitHubHosts.includes(parsedUrl.host)) {
// GitHub 发布资源下载 URL 遵循模式:/releases/download/
if (pathname.includes("/releases/download/")) {
return true;
}
// GitHub 存档下载 URL
if (
pathname.includes("/archive/") &&
(pathname.endsWith(".zip") || pathname.endsWith(".tar.gz"))
) {
return true;
}
// GitHub 原始文件 URL - 新增:支持原始文件链接
if (pathname.includes("/raw/")) {
return true;
}
// 排除导航到发布页面(仅 /releases 或 /releases/)
if (pathname.endsWith("/releases") || pathname.endsWith("/releases/")) {
return false;
}
}
} catch (e) {
console.error("无效的 URL:", href, e);
}
// 第四检查:GitLab 特定模式
const allowedGitLabHosts = ["gitlab.com"];
try {
const parsedUrl = new URL(href);
if (allowedGitLabHosts.includes(parsedUrl.host)) {
// GitLab 存档下载
if (pathname.includes("/-/archive/")) {
return true;
}
// GitLab 发布下载
if (
pathname.includes("/-/releases/") &&
pathname.includes("/downloads/")
) {
return true;
}
}
} catch (e) {
console.error("无效的 URL:", href, e);
}
// 第五检查:Hugging Face 文件下载
const allowedHuggingFaceHosts = ["huggingface.co"];
try {
const parsedUrl = new URL(href);
if (allowedHuggingFaceHosts.includes(parsedUrl.host)) {
// HF 文件下载 URL 包含 /resolve/
if (pathname.includes("/resolve/")) {
return true;
}
}
} catch (e) {
console.error("无效的 URL:", href, e);
}
// 第六检查:npm 包下载
const allowedNpmHosts = ["registry.npmjs.org"];
try {
const parsedUrl = new URL(href);
if (allowedNpmHosts.includes(parsedUrl.host)) {
// npm tarball URL 包含 /-/
if (pathname.includes("/-/") && pathname.endsWith(".tgz")) {
return true;
}
}
} catch (e) {
console.error("无效的 URL:", href, e);
}
// 第七检查:PyPI 包下载
const allowedPypiHosts = ["pypi.org", "files.pythonhosted.org"];
try {
const parsedUrl = new URL(href);
if (allowedPypiHosts.includes(parsedUrl.host)) {
// PyPI 包文件下载
if (
pathname.includes("/packages/") &&
(pathname.endsWith(".tar.gz") ||
pathname.endsWith(".whl") ||
pathname.endsWith(".egg"))
) {
return true;
}
}
} catch (e) {
console.error("无效的 URL:", href, e);
}
// 第八检查:SourceForge 下载
const allowedSourceForgeHosts = ["sourceforge.net"];
try {
const parsedUrl = new URL(href);
if (allowedSourceForgeHosts.includes(parsedUrl.host)) {
// SourceForge 下载 URL 包含 /download
if (
pathname.includes("/download") ||
url.searchParams.get("use_mirror")
) {
return true;
}
}
} catch (e) {
console.error("无效的 URL:", href, e);
}
// 第九检查:Conda 包下载
const allowedCondaHosts = ["repo.anaconda.com", "conda.anaconda.org"];
try {
const parsedUrl = new URL(href);
if (allowedCondaHosts.includes(parsedUrl.host)) {
// Conda 包文件
if (pathname.endsWith(".conda") || pathname.endsWith(".tar.bz2")) {
return true;
}
}
} catch (e) {
console.error("无效的 URL:", href, e);
}
// 第十检查:其他包管理平台
const packageManagerHosts = [
"rubygems.org",
"cran.r-project.org",
"crates.io",
"repo.packagist.org",
"api.nuget.org",
"proxy.golang.org",
];
try {
const parsedUrl = new URL(href);
if (packageManagerHosts.includes(parsedUrl.host)) {
// 各种包管理器的下载文件
const packageExtensions = [
".gem",
".tar.gz",
".crate",
".zip",
".nupkg",
".tgz",
".tar.bz2",
];
if (packageExtensions.some((ext) => pathname.endsWith(ext))) {
return true;
}
}
} catch (e) {
console.error("无效的 URL:", href, e);
}
// 第十一检查:明确的下载文本指示器(更具体)
const downloadTextIndicators = ["download", "download file", "get file"];
const linkText = link.textContent.toLowerCase().trim();
if (
downloadTextIndicators.some(
(indicator) =>
linkText === indicator || linkText.startsWith(indicator + " ")
)
) {
return true;
}
// 默认:不是下载链接
return false;
}
async function handleDownloadLink(url) {
try {
const settings = await getSettings();
if (!settings.enabled || !settings.xgetDomain) return;
const transformedUrl = transformUrl(url, settings);
if (transformedUrl) {
// 显示通知
showNotification(`下载已通过 Xget 重定向`);
// 触发下载
window.location.href = transformedUrl;
}
} catch (error) {
console.error("处理下载时出错:", error);
}
}
function transformUrl(url, settings) {
try {
const platform = detectPlatform(url);
if (!platform || !settings.enabledPlatforms[platform]) {
return null;
}
const urlObj = new URL(url);
const path = urlObj.pathname + urlObj.search + urlObj.hash;
return `https://${settings.xgetDomain}/${platform}${path}`;
} catch (error) {
console.error("转换 URL 时出错:", error);
return null;
}
}
function observePageChanges() {
const observer = new MutationObserver(() => {
// 监控页面变化(无需额外操作)
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
}
function showNotification(message, showRefreshButton = false) {
try {
// 首先删除所有现有通知
const existingNotifications =
document.querySelectorAll(".xget-notification");
existingNotifications.forEach((notification) => notification.remove());
// 创建一个简单的通知
const notification = document.createElement("div");
notification.className = "xget-notification";
// 创建消息容器
const messageDiv = document.createElement("div");
messageDiv.textContent = message;
messageDiv.style.marginBottom = showRefreshButton ? "8px" : "0";
notification.appendChild(messageDiv);
// 如果需要,添加刷新按钮
if (showRefreshButton) {
const refreshButton = document.createElement("button");
refreshButton.textContent = "🔄 刷新页面";
refreshButton.style.cssText = `
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
margin-top: 4px;
width: 100%;
transition: background 0.2s;
`;
refreshButton.addEventListener("mouseenter", () => {
refreshButton.style.background = "rgba(255, 255, 255, 0.3)";
});
refreshButton.addEventListener("mouseleave", () => {
refreshButton.style.background = "rgba(255, 255, 255, 0.2)";
});
refreshButton.addEventListener("click", () => {
window.location.reload();
});
notification.appendChild(refreshButton);
}
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 12px 16px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
z-index: 2147483647;
animation: xgetSlideIn 0.3s ease-out;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 280px;
word-wrap: break-word;
`;
// 如果尚未存在则添加动画样式
if (!document.getElementById("xget-notification-styles")) {
const style = document.createElement("style");
style.id = "xget-notification-styles";
style.textContent = `
@keyframes xgetSlideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes xgetSlideOut {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
`;
document.head.appendChild(style);
}
document.body.appendChild(notification);
// 如果有刷新按钮则在较长时间后删除,否则在较短时间后删除
const removeDelay = showRefreshButton ? 8000 : 4000;
setTimeout(() => {
notification.style.animation = "xgetSlideOut 0.3s ease-in";
setTimeout(() => {
if (notification.parentNode) {
notification.remove();
}
}, 300);
}, removeDelay);
} catch (error) {
console.error("显示通知时出错:", error);
// 如果 DOM 操作失败则回退到控制台日志
console.log("Xget 通知:", message);
}
}
+1
View File
@@ -618,6 +618,7 @@
</div> </div>
<script src="webext-compat.js"></script> <script src="webext-compat.js"></script>
<script src="download-core.js"></script>
<script src="firefox-compat.js"></script> <script src="firefox-compat.js"></script>
<script src="popup.js"></script> <script src="popup.js"></script>
</body> </body>
+10 -8
View File
@@ -8,6 +8,8 @@
* - 提供平台开关控制 * - 提供平台开关控制
*/ */
const core = window.XgetDownloadCore;
document.addEventListener("DOMContentLoaded", async () => { document.addEventListener("DOMContentLoaded", async () => {
// 确保有可用的API // 确保有可用的API
if ( if (
@@ -70,6 +72,7 @@ async function loadSettings() {
throw new Error("No compatible API available"); throw new Error("No compatible API available");
} }
settings = core ? core.normalizeSettings(settings) : settings;
console.log("Settings received:", settings); console.log("Settings received:", settings);
if (!settings) { if (!settings) {
@@ -404,18 +407,17 @@ function validateDomain() {
} }
function cleanupDomain(domain) { function cleanupDomain(domain) {
// 如果意外包含协议,则删除它 if (core) {
domain = domain.replace(/^https?:\/\//, ""); return core.sanitizeDomain(domain);
}
// 删除末尾斜杠 return String(domain || "")
domain = domain.replace(/\/$/, ""); .replace(/^https?:\/\//, "")
.replace(/\/$/, "");
return domain;
} }
function isValidDomain(domain) { function isValidDomain(domain) {
// 如果存在协议则删除它 domain = cleanupDomain(domain);
domain = domain.replace(/^https?:\/\//, "");
// 基本域名验证模式 // 基本域名验证模式
const domainPattern = const domainPattern =
+600
View File
@@ -0,0 +1,600 @@
(function (root, factory) {
const api = factory();
if (typeof module !== "undefined" && module.exports) {
module.exports = api;
}
root.XgetDownloadCore = api;
})(typeof globalThis !== "undefined" ? globalThis : this, function () {
const PLATFORM_DEFINITIONS = {
gh: {
base: "https://github.com",
name: "GitHub",
hosts: [
"github.com",
"raw.githubusercontent.com",
"codeload.github.com",
"objects.githubusercontent.com",
],
downloadPatterns: [
/\/releases\/download\//i,
/\/archive\/.*\.(zip|tar\.gz|tar)$/i,
/\/raw\//i,
/\/zip\//i,
/\/tar(\.gz)?\//i,
],
},
gl: {
base: "https://gitlab.com",
name: "GitLab",
hosts: ["gitlab.com"],
downloadPatterns: [/\/-\/archive\//i, /\/-\/releases\/.+\/downloads\//i],
},
gitea: {
base: "https://gitea.com",
name: "Gitea",
hosts: ["gitea.com"],
downloadPatterns: [/\/archive\//i, /\/raw\//i, /\/releases\/download\//i],
},
codeberg: {
base: "https://codeberg.org",
name: "Codeberg",
hosts: ["codeberg.org"],
downloadPatterns: [/\/archive\//i, /\/raw\//i, /\/releases\/download\//i],
},
sf: {
base: "https://sourceforge.net",
name: "SourceForge",
hosts: ["sourceforge.net"],
downloadPatterns: [/\/download$/i, /\/downloads\//i, /\/files\//i],
},
aosp: {
base: "https://android.googlesource.com",
name: "AOSP",
hosts: ["android.googlesource.com"],
downloadPatterns: [/\/\+archive\//i],
},
hf: {
base: "https://huggingface.co",
name: "Hugging Face",
hosts: ["huggingface.co"],
downloadPatterns: [/\/resolve\//i],
},
npm: {
base: "https://registry.npmjs.org",
name: "npm",
hosts: ["registry.npmjs.org"],
downloadPatterns: [/\/-\/.+\.tgz$/i],
},
pypi: {
base: "https://pypi.org",
name: "PyPI",
hosts: ["pypi.org"],
downloadPatterns: [/\/packages\//i],
},
"pypi-files": {
base: "https://files.pythonhosted.org",
name: "PyPI Files",
hosts: ["files.pythonhosted.org"],
downloadPatterns: [/\/packages\//i],
},
conda: {
base: "https://repo.anaconda.com",
name: "Conda",
hosts: ["repo.anaconda.com"],
downloadPatterns: [/\.(conda|tar\.bz2)$/i],
},
"conda-community": {
base: "https://conda.anaconda.org",
name: "Conda Community",
hosts: ["conda.anaconda.org"],
downloadPatterns: [/\.(conda|tar\.bz2)$/i],
},
maven: {
base: "https://repo1.maven.org",
name: "Maven Central",
hosts: ["repo1.maven.org"],
downloadPatterns: [/\.(jar|war|ear|pom|zip|tar\.gz)$/i],
},
apache: {
base: "https://downloads.apache.org",
name: "Apache Downloads",
hosts: ["downloads.apache.org"],
downloadPatterns: [/\.(zip|tar\.gz|tgz|bz2|xz)$/i],
},
gradle: {
base: "https://plugins.gradle.org",
name: "Gradle Plugin Portal",
hosts: ["plugins.gradle.org"],
downloadPatterns: [/\.(jar|zip)$/i],
},
rubygems: {
base: "https://rubygems.org",
name: "RubyGems",
hosts: ["rubygems.org"],
downloadPatterns: [/\.gem$/i],
},
cran: {
base: "https://cran.r-project.org",
name: "CRAN",
hosts: ["cran.r-project.org"],
downloadPatterns: [/\.(tar\.gz|zip)$/i],
},
cpan: {
base: "https://www.cpan.org",
name: "CPAN",
hosts: ["www.cpan.org"],
downloadPatterns: [/\.(tar\.gz|zip)$/i],
},
ctan: {
base: "https://tug.ctan.org",
name: "CTAN",
hosts: ["tug.ctan.org"],
downloadPatterns: [/\.(zip|tar\.xz|tar\.gz)$/i],
},
golang: {
base: "https://proxy.golang.org",
name: "Go Modules",
hosts: ["proxy.golang.org"],
downloadPatterns: [/\/@v\/.+\.(mod|zip|info)$/i],
},
nuget: {
base: "https://api.nuget.org",
name: "NuGet",
hosts: ["api.nuget.org"],
downloadPatterns: [/\.nupkg$/i],
},
crates: {
base: "https://crates.io",
name: "Crates.io",
hosts: ["crates.io"],
downloadPatterns: [/\/download$/i, /\.crate$/i],
},
packagist: {
base: "https://repo.packagist.org",
name: "Packagist",
hosts: ["repo.packagist.org"],
downloadPatterns: [/\.zip$/i],
},
arxiv: {
base: "https://arxiv.org",
name: "arXiv",
hosts: ["arxiv.org"],
downloadPatterns: [/\/pdf\//i, /\/e-print\//i],
},
fdroid: {
base: "https://f-droid.org",
name: "F-Droid",
hosts: ["f-droid.org"],
downloadPatterns: [/\.apk$/i, /\/FDroid\.apk$/i],
},
};
const HOST_TO_PLATFORM = Object.entries(PLATFORM_DEFINITIONS).reduce(
(map, [key, platform]) => {
platform.hosts.forEach((host) => {
map[host] = key;
});
return map;
},
{}
);
const CORE_USERSCRIPT_MATCHES = [
"https://github.com/*",
"https://raw.githubusercontent.com/*",
"https://codeload.github.com/*",
"https://objects.githubusercontent.com/*",
"https://gitlab.com/*",
"https://gitea.com/*",
"https://codeberg.org/*",
"https://sourceforge.net/*",
"https://huggingface.co/*",
"https://pypi.org/*",
"https://files.pythonhosted.org/*",
"https://registry.npmjs.org/*",
];
const CORE_USERSCRIPT_PLATFORM_KEYS = [
"gh",
"gl",
"gitea",
"codeberg",
"sf",
"hf",
"pypi",
"pypi-files",
"npm",
];
const DOWNLOADABLE_EXTENSIONS = [
".zip",
".tar.gz",
".tar.bz2",
".tar.xz",
".tgz",
".7z",
".rar",
".gz",
".bz2",
".xz",
".exe",
".msi",
".dmg",
".pkg",
".deb",
".rpm",
".apk",
".jar",
".war",
".ear",
".iso",
".img",
".pdf",
".doc",
".docx",
".xls",
".xlsx",
".ppt",
".pptx",
".mp4",
".avi",
".mkv",
".mov",
".wmv",
".mp3",
".wav",
".flac",
".jpg",
".jpeg",
".png",
".gif",
".bmp",
".svg",
".whl",
".egg",
".gem",
".nupkg",
".conda",
".crate",
".safetensors",
".bin",
".pt",
".pth",
".onnx",
".msgpack",
".tflite",
".h5",
];
const DEFAULT_EXTENSION_SETTINGS = {
enabled: true,
xgetDomain: "xget.xi-xu.me",
enabledPlatforms: Object.keys(PLATFORM_DEFINITIONS).reduce((acc, key) => {
acc[key] = true;
return acc;
}, {}),
};
const DEFAULT_USERSCRIPT_SETTINGS = {
enabled: true,
activeProfileId: "default",
profiles: [
{ id: "default", name: "Xget Official", domain: "xget.xi-xu.me" },
],
enabledPlatforms: Object.keys(PLATFORM_DEFINITIONS).reduce((acc, key) => {
acc[key] = CORE_USERSCRIPT_PLATFORM_KEYS.includes(key);
return acc;
}, {}),
showNotifications: true,
autoProbe: true,
};
function sanitizeDomain(domain) {
return String(domain || "")
.trim()
.replace(/^https?:\/\//i, "")
.replace(/\/+$/g, "");
}
function getUrlObject(url) {
try {
return new URL(url);
} catch (error) {
return null;
}
}
function detectPlatform(url) {
const urlObject = getUrlObject(url);
if (!urlObject) {
return null;
}
return HOST_TO_PLATFORM[urlObject.hostname] || null;
}
function transformPath(path, platformKey) {
if (platformKey !== "crates") {
return path;
}
if (path === "/" || path.startsWith("/?")) {
return path.replace("/", "/api/v1/crates");
}
return `/api/v1/crates${path}`;
}
function isKnownDownloadPattern(urlObject, platformKey) {
const platform = PLATFORM_DEFINITIONS[platformKey];
if (!platform) {
return false;
}
if (platformKey === "gh" && urlObject.hostname !== "github.com") {
return true;
}
return platform.downloadPatterns.some((pattern) =>
pattern.test(urlObject.pathname)
);
}
function hasDownloadableExtension(pathname) {
const lowerPath = String(pathname || "").toLowerCase();
return DOWNLOADABLE_EXTENSIONS.some((extension) =>
lowerPath.endsWith(extension)
);
}
function looksLikeExplicitDownloadText(linkText) {
const normalized = String(linkText || "").trim().toLowerCase();
return (
normalized === "download" ||
normalized === "download file" ||
normalized === "get file" ||
normalized.startsWith("download ")
);
}
function isDownloadCandidate(url, context) {
const details = context || {};
const urlObject = getUrlObject(url);
if (!urlObject) {
return false;
}
const platformKey = detectPlatform(urlObject.href);
if (!platformKey) {
return false;
}
if (details.hasDownloadAttribute || details.downloadAttribute) {
return true;
}
if (isKnownDownloadPattern(urlObject, platformKey)) {
return true;
}
if (hasDownloadableExtension(urlObject.pathname)) {
return true;
}
if (
platformKey === "sf" &&
(urlObject.searchParams.has("use_mirror") ||
String(details.linkText || "").toLowerCase().includes("download"))
) {
return true;
}
return looksLikeExplicitDownloadText(details.linkText);
}
function resolveActiveDomain(settings) {
if (settings.activeDomain) {
return sanitizeDomain(settings.activeDomain);
}
if (settings.xgetDomain) {
return sanitizeDomain(settings.xgetDomain);
}
const profile = (settings.profiles || []).find(
(item) => item.id === settings.activeProfileId
);
if (profile && profile.domain) {
return sanitizeDomain(profile.domain);
}
const fallbackProfile = (settings.profiles || [])[0];
return sanitizeDomain(fallbackProfile && fallbackProfile.domain);
}
function transformDownloadUrl(url, rawSettings) {
const settings = normalizeSettings(rawSettings);
const platformKey = detectPlatform(url);
if (!platformKey || !settings.enabledPlatforms[platformKey]) {
return null;
}
const activeDomain = resolveActiveDomain(settings);
if (!activeDomain) {
return null;
}
const urlObject = getUrlObject(url);
if (!urlObject) {
return null;
}
const transformedPath = transformPath(
urlObject.pathname + urlObject.search + urlObject.hash,
platformKey
);
return `https://${activeDomain}/${platformKey}${transformedPath}`;
}
function shouldBypassClick(event, link) {
if (!event || !link || !link.href) {
return true;
}
if (event.defaultPrevented) {
return true;
}
if (event.button !== 0) {
return true;
}
if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
return true;
}
const currentWindowName =
typeof window !== "undefined" ? String(window.name || "") : "";
if (
link.target &&
link.target !== "_self" &&
link.target !== "" &&
link.target !== currentWindowName
) {
return true;
}
return false;
}
function normalizeProfiles(profiles) {
const list = Array.isArray(profiles) ? profiles : [];
const normalized = list
.map((profile, index) => ({
id: String(profile && profile.id ? profile.id : `profile-${index + 1}`),
name: String(
profile && profile.name ? profile.name : `Profile ${index + 1}`
).trim(),
domain: sanitizeDomain(profile && profile.domain),
}))
.filter((profile) => profile.domain);
if (normalized.length > 0) {
return normalized;
}
return DEFAULT_USERSCRIPT_SETTINGS.profiles.map((profile) => ({ ...profile }));
}
function normalizeEnabledPlatforms(enabledPlatforms, mode) {
const source = enabledPlatforms || {};
const defaults =
mode === "userscript"
? DEFAULT_USERSCRIPT_SETTINGS.enabledPlatforms
: DEFAULT_EXTENSION_SETTINGS.enabledPlatforms;
return Object.keys(DEFAULT_EXTENSION_SETTINGS.enabledPlatforms).reduce(
(acc, key) => {
acc[key] =
typeof source[key] === "boolean" ? source[key] : defaults[key];
return acc;
},
{}
);
}
function normalizeSettings(rawSettings, options) {
const opts = options || {};
const input = rawSettings || {};
const mode =
opts.mode ||
(Array.isArray(input.profiles) || Object.prototype.hasOwnProperty.call(input, "activeProfileId")
? "userscript"
: "extension");
if (mode === "userscript") {
const profiles = normalizeProfiles(input.profiles);
const activeProfileId =
profiles.find((profile) => profile.id === input.activeProfileId)?.id ||
profiles[0].id;
const activeDomain = sanitizeDomain(
input.activeDomain ||
profiles.find((profile) => profile.id === activeProfileId)?.domain
);
return {
enabled:
typeof input.enabled === "boolean"
? input.enabled
: DEFAULT_USERSCRIPT_SETTINGS.enabled,
activeProfileId,
profiles,
enabledPlatforms: normalizeEnabledPlatforms(
input.enabledPlatforms,
"userscript"
),
showNotifications:
typeof input.showNotifications === "boolean"
? input.showNotifications
: DEFAULT_USERSCRIPT_SETTINGS.showNotifications,
autoProbe:
typeof input.autoProbe === "boolean"
? input.autoProbe
: DEFAULT_USERSCRIPT_SETTINGS.autoProbe,
activeDomain,
};
}
const xgetDomain = sanitizeDomain(input.xgetDomain);
return {
enabled:
typeof input.enabled === "boolean"
? input.enabled
: DEFAULT_EXTENSION_SETTINGS.enabled,
xgetDomain: xgetDomain || DEFAULT_EXTENSION_SETTINGS.xgetDomain,
enabledPlatforms: normalizeEnabledPlatforms(
input.enabledPlatforms,
"extension"
),
activeDomain: xgetDomain || DEFAULT_EXTENSION_SETTINGS.xgetDomain,
};
}
function getMatchPatterns(platformKeys) {
const allowed = Array.isArray(platformKeys) && platformKeys.length > 0
? new Set(platformKeys)
: null;
return Object.entries(PLATFORM_DEFINITIONS).flatMap(([key, platform]) => {
if (allowed && !allowed.has(key)) {
return [];
}
return platform.hosts.map((host) => `https://${host}/*`);
});
}
return {
CORE_USERSCRIPT_MATCHES,
CORE_USERSCRIPT_PLATFORM_KEYS,
DEFAULT_EXTENSION_SETTINGS,
DEFAULT_USERSCRIPT_SETTINGS,
DOWNLOADABLE_EXTENSIONS,
PLATFORM_DEFINITIONS,
detectPlatform,
getMatchPatterns,
hasDownloadableExtension,
isDownloadCandidate,
normalizeSettings,
sanitizeDomain,
shouldBypassClick,
transformDownloadUrl,
transformPath,
};
});
+593
View File
@@ -0,0 +1,593 @@
(function () {
const core = globalThis.XgetDownloadCore;
if (!core) {
console.error("Xget Now userscript: shared core missing");
return;
}
const STORAGE_KEY = "xget_now_userscript_settings";
const PROBE_CACHE_MS = 5 * 60 * 1000;
const state = {
settings: loadSettings(),
probeCache: {},
toastTimer: null,
};
registerMenuCommands();
document.addEventListener("click", onDocumentClick, true);
function loadSettings() {
return core.normalizeSettings(
typeof GM_getValue === "function" ? GM_getValue(STORAGE_KEY, null) : null,
{ mode: "userscript" }
);
}
function saveSettings(nextSettings) {
state.settings = core.normalizeSettings(nextSettings, { mode: "userscript" });
GM_setValue(STORAGE_KEY, state.settings);
return state.settings;
}
function registerMenuCommands() {
if (typeof GM_registerMenuCommand !== "function") {
return;
}
GM_registerMenuCommand(
state.settings.enabled ? "Disable Xget Now" : "Enable Xget Now",
() => {
const settings = saveSettings({
...state.settings,
enabled: !state.settings.enabled,
});
showToast(
settings.enabled ? "Xget Now userscript enabled" : "Xget Now userscript disabled"
);
}
);
GM_registerMenuCommand("Open Xget Now Settings", () => {
openSettingsModal();
});
GM_registerMenuCommand("Test Current Xget Node", async () => {
const activeProfile = getActiveProfile(state.settings);
if (!activeProfile) {
showToast("No active Xget profile configured", "error");
return;
}
showToast(`Testing ${activeProfile.domain}...`, "info");
const available = await probeDomain(activeProfile.domain, true);
showToast(
available
? `${activeProfile.domain} is reachable`
: `${activeProfile.domain} is unavailable`,
available ? "success" : "error"
);
});
}
function getActiveProfile(settings) {
return (settings.profiles || []).find(
(profile) => profile.id === settings.activeProfileId
);
}
async function probeDomain(domain, force) {
const normalizedDomain = core.sanitizeDomain(domain);
if (!normalizedDomain) {
return false;
}
const cached = state.probeCache[normalizedDomain];
if (
!force &&
cached &&
Date.now() - cached.checkedAt < PROBE_CACHE_MS
) {
return cached.available;
}
const available = await new Promise((resolve) => {
let settled = false;
const finish = (value) => {
if (!settled) {
settled = true;
resolve(value);
}
};
try {
GM_xmlhttpRequest({
method: "GET",
url: `https://${normalizedDomain}/gh/robots.txt`,
headers: {
Range: "bytes=0-0",
"Cache-Control": "no-cache",
},
timeout: 3000,
onload: (response) => {
finish(
response.status === 206 ||
(response.status >= 200 && response.status < 400)
);
},
onerror: () => finish(false),
ontimeout: () => finish(false),
});
} catch (error) {
finish(false);
}
});
state.probeCache[normalizedDomain] = {
available,
checkedAt: Date.now(),
};
return available;
}
function createSyntheticLink(url, sourceLink) {
const link = document.createElement("a");
link.href = url;
link.rel = "noopener noreferrer";
link.target = sourceLink && sourceLink.target ? sourceLink.target : "_blank";
if (sourceLink && (sourceLink.download || sourceLink.hasAttribute("download"))) {
link.download = sourceLink.download || "";
}
link.style.display = "none";
return link;
}
function openViaSyntheticLink(url, sourceLink) {
const link = createSyntheticLink(url, sourceLink);
document.body.appendChild(link);
link.click();
link.remove();
}
async function onDocumentClick(event) {
const link = event.target && event.target.closest
? event.target.closest("a[href]")
: null;
if (!link) {
return;
}
const settings = state.settings;
if (!settings.enabled) {
return;
}
const platformKey = core.detectPlatform(link.href);
if (!platformKey || !settings.enabledPlatforms[platformKey]) {
return;
}
if (core.shouldBypassClick(event, link)) {
return;
}
if (
!core.isDownloadCandidate(link.href, {
hasDownloadAttribute: Boolean(link.download || link.hasAttribute("download")),
linkText: link.textContent || "",
})
) {
return;
}
event.preventDefault();
event.stopPropagation();
if (typeof event.stopImmediatePropagation === "function") {
event.stopImmediatePropagation();
}
const activeProfile = getActiveProfile(settings);
if (!activeProfile) {
showToast("No active Xget profile configured", "error");
openViaSyntheticLink(link.href, link);
return;
}
if (settings.autoProbe) {
const available = await probeDomain(activeProfile.domain);
if (!available) {
showToast(
`${activeProfile.domain} is unavailable. Falling back to the original link.`,
"error"
);
openViaSyntheticLink(link.href, link);
return;
}
}
const acceleratedUrl = core.transformDownloadUrl(link.href, settings);
if (!acceleratedUrl || acceleratedUrl === link.href) {
showToast("Unable to rewrite this link. Falling back to the original URL.", "error");
openViaSyntheticLink(link.href, link);
return;
}
if (settings.showNotifications) {
showToast(`Redirecting download via ${activeProfile.domain}`, "success");
}
openViaSyntheticLink(acceleratedUrl, link);
}
function ensureToastContainer() {
let container = document.getElementById("xget-now-userscript-toast");
if (container) {
return container;
}
container = document.createElement("div");
container.id = "xget-now-userscript-toast";
container.style.cssText = [
"position: fixed",
"top: 20px",
"right: 20px",
"z-index: 2147483647",
"max-width: 320px",
"padding: 12px 14px",
"border-radius: 10px",
"box-shadow: 0 12px 30px rgba(15, 23, 42, 0.18)",
"font: 13px/1.4 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
"background: #0f172a",
"color: #f8fafc",
"opacity: 0",
"transform: translateY(-8px)",
"transition: opacity 120ms ease, transform 120ms ease",
"pointer-events: none",
].join("; ");
document.documentElement.appendChild(container);
return container;
}
function showToast(message, type) {
if (!state.settings.showNotifications && type !== "error" && type !== "info") {
return;
}
const container = ensureToastContainer();
container.textContent = String(message);
const palette = {
success: { background: "#0f766e", color: "#f0fdfa" },
error: { background: "#991b1b", color: "#fef2f2" },
info: { background: "#1d4ed8", color: "#eff6ff" },
};
const colors = palette[type] || palette.success;
container.style.background = colors.background;
container.style.color = colors.color;
container.style.opacity = "1";
container.style.transform = "translateY(0)";
if (state.toastTimer) {
clearTimeout(state.toastTimer);
}
state.toastTimer = setTimeout(() => {
container.style.opacity = "0";
container.style.transform = "translateY(-8px)";
}, type === "error" ? 5000 : 2500);
}
function createSwitch(label, checked) {
const wrapper = document.createElement("label");
wrapper.style.cssText =
"display:flex; align-items:center; justify-content:space-between; gap:12px; padding:10px 0; cursor:pointer;";
const text = document.createElement("span");
text.textContent = label;
text.style.cssText = "font-size:13px; color:#0f172a;";
const input = document.createElement("input");
input.type = "checkbox";
input.checked = checked;
wrapper.append(text, input);
return { wrapper, input };
}
function createProfileRow(profile, isActive) {
const row = document.createElement("div");
row.className = "xget-profile-row";
row.dataset.profileId = profile.id;
row.style.cssText =
"display:grid; grid-template-columns: 18px minmax(0, 1fr) minmax(0, 1fr) auto; gap:10px; align-items:center; margin-bottom:8px;";
const radio = document.createElement("input");
radio.type = "radio";
radio.name = "xget-active-profile";
radio.checked = isActive;
radio.value = profile.id;
const nameInput = document.createElement("input");
nameInput.type = "text";
nameInput.value = profile.name;
nameInput.placeholder = "Profile name";
nameInput.style.cssText = profileInputStyles();
nameInput.dataset.field = "name";
const domainInput = document.createElement("input");
domainInput.type = "text";
domainInput.value = profile.domain;
domainInput.placeholder = "xget.example.com";
domainInput.style.cssText = profileInputStyles();
domainInput.dataset.field = "domain";
const deleteButton = document.createElement("button");
deleteButton.type = "button";
deleteButton.textContent = "Delete";
deleteButton.style.cssText =
"border:0; border-radius:8px; background:#fee2e2; color:#991b1b; padding:8px 10px; cursor:pointer;";
deleteButton.addEventListener("click", () => {
const list = row.parentElement;
row.remove();
const remainingRows = list.querySelectorAll(".xget-profile-row");
if (remainingRows.length > 0 && !list.querySelector('input[type="radio"]:checked')) {
remainingRows[0].querySelector('input[type="radio"]').checked = true;
}
});
row.append(radio, nameInput, domainInput, deleteButton);
return row;
}
function profileInputStyles() {
return [
"width:100%",
"border:1px solid #cbd5e1",
"border-radius:8px",
"padding:8px 10px",
"font-size:13px",
"box-sizing:border-box",
].join("; ");
}
function createSectionTitle(text) {
const title = document.createElement("h3");
title.textContent = text;
title.style.cssText =
"margin:0 0 12px; font-size:14px; font-weight:700; color:#0f172a;";
return title;
}
function createPlatformGrid(settings) {
const grid = document.createElement("div");
grid.style.cssText =
"display:grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap:8px;";
core.CORE_USERSCRIPT_PLATFORM_KEYS.forEach((platformKey) => {
const platform = core.PLATFORM_DEFINITIONS[platformKey];
const switchView = createSwitch(
platform ? platform.name : platformKey,
Boolean(settings.enabledPlatforms[platformKey])
);
switchView.input.dataset.platformKey = platformKey;
switchView.wrapper.style.borderBottom = "1px solid #e2e8f0";
grid.appendChild(switchView.wrapper);
});
return grid;
}
function collectProfiles(profileList) {
return Array.from(profileList.querySelectorAll(".xget-profile-row"))
.map((row) => {
const radio = row.querySelector('input[type="radio"]');
const name = row.querySelector('input[data-field="name"]').value;
const domain = row.querySelector('input[data-field="domain"]').value;
const id = row.dataset.profileId || crypto.randomUUID();
return {
id,
name: String(name || "").trim() || "Unnamed profile",
domain: core.sanitizeDomain(domain),
active: Boolean(radio && radio.checked),
};
})
.filter((profile) => profile.domain);
}
function openSettingsModal() {
const existing = document.getElementById("xget-now-userscript-modal");
if (existing) {
existing.remove();
}
const overlay = document.createElement("div");
overlay.id = "xget-now-userscript-modal";
overlay.style.cssText = [
"position: fixed",
"inset: 0",
"z-index: 2147483646",
"background: rgba(15, 23, 42, 0.45)",
"display: flex",
"justify-content: center",
"align-items: center",
"padding: 20px",
].join("; ");
const panel = document.createElement("div");
panel.style.cssText = [
"width: min(900px, 100%)",
"max-height: min(90vh, 840px)",
"overflow: auto",
"background: #ffffff",
"border-radius: 16px",
"padding: 20px",
"box-shadow: 0 30px 80px rgba(15, 23, 42, 0.25)",
"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
].join("; ");
const header = document.createElement("div");
header.style.cssText =
"display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:18px;";
const title = document.createElement("h2");
title.textContent = "Xget Now Userscript Settings";
title.style.cssText = "margin:0; font-size:18px; color:#0f172a;";
const close = document.createElement("button");
close.type = "button";
close.textContent = "Close";
close.style.cssText =
"border:0; border-radius:8px; background:#e2e8f0; color:#0f172a; padding:10px 12px; cursor:pointer;";
close.addEventListener("click", () => overlay.remove());
header.append(title, close);
panel.appendChild(header);
const togglesSection = document.createElement("section");
togglesSection.style.marginBottom = "18px";
togglesSection.appendChild(createSectionTitle("General"));
const enabledSwitch = createSwitch("Enable Xget Now", state.settings.enabled);
const notificationSwitch = createSwitch(
"Show notifications",
state.settings.showNotifications
);
const autoProbeSwitch = createSwitch(
"Probe node before redirecting downloads",
state.settings.autoProbe
);
togglesSection.append(
enabledSwitch.wrapper,
notificationSwitch.wrapper,
autoProbeSwitch.wrapper
);
panel.appendChild(togglesSection);
const profilesSection = document.createElement("section");
profilesSection.style.marginBottom = "18px";
profilesSection.appendChild(createSectionTitle("Xget Nodes"));
const profileList = document.createElement("div");
state.settings.profiles.forEach((profile) => {
profileList.appendChild(
createProfileRow(profile, profile.id === state.settings.activeProfileId)
);
});
profilesSection.appendChild(profileList);
const addProfile = document.createElement("button");
addProfile.type = "button";
addProfile.textContent = "Add node";
addProfile.style.cssText =
"border:0; border-radius:8px; background:#dbeafe; color:#1d4ed8; padding:10px 12px; cursor:pointer;";
addProfile.addEventListener("click", () => {
const profile = {
id:
typeof crypto !== "undefined" && crypto.randomUUID
? crypto.randomUUID()
: `profile-${Date.now()}`,
name: "",
domain: "",
};
profileList.appendChild(
createProfileRow(profile, profileList.children.length === 0)
);
});
profilesSection.appendChild(addProfile);
panel.appendChild(profilesSection);
const probeRow = document.createElement("div");
probeRow.style.cssText =
"display:flex; align-items:center; gap:12px; margin: 14px 0 20px;";
const probeButton = document.createElement("button");
probeButton.type = "button";
probeButton.textContent = "Test selected node";
probeButton.style.cssText =
"border:0; border-radius:8px; background:#0f766e; color:#f0fdfa; padding:10px 12px; cursor:pointer;";
const probeResult = document.createElement("span");
probeResult.style.cssText = "font-size:13px; color:#334155;";
probeButton.addEventListener("click", async () => {
const profiles = collectProfiles(profileList);
const active = profiles.find((profile) => profile.active);
if (!active) {
probeResult.textContent = "Select a node first";
return;
}
probeResult.textContent = `Testing ${active.domain}...`;
const available = await probeDomain(active.domain, true);
probeResult.textContent = available
? `${active.domain} is reachable`
: `${active.domain} is unavailable`;
});
probeRow.append(probeButton, probeResult);
panel.appendChild(probeRow);
const platformsSection = document.createElement("section");
platformsSection.style.marginBottom = "20px";
platformsSection.appendChild(createSectionTitle("Platforms"));
platformsSection.appendChild(createPlatformGrid(state.settings));
panel.appendChild(platformsSection);
const footer = document.createElement("div");
footer.style.cssText =
"display:flex; justify-content:flex-end; gap:12px; border-top:1px solid #e2e8f0; padding-top:16px;";
const cancelButton = document.createElement("button");
cancelButton.type = "button";
cancelButton.textContent = "Cancel";
cancelButton.style.cssText =
"border:0; border-radius:8px; background:#e2e8f0; color:#0f172a; padding:10px 12px; cursor:pointer;";
cancelButton.addEventListener("click", () => overlay.remove());
const saveButton = document.createElement("button");
saveButton.type = "button";
saveButton.textContent = "Save";
saveButton.style.cssText =
"border:0; border-radius:8px; background:#1d4ed8; color:#eff6ff; padding:10px 12px; cursor:pointer;";
saveButton.addEventListener("click", () => {
const profiles = collectProfiles(profileList);
if (profiles.length === 0) {
showToast("Add at least one valid Xget node before saving.", "error");
return;
}
const activeProfile = profiles.find((profile) => profile.active) || profiles[0];
const enabledPlatforms = { ...state.settings.enabledPlatforms };
panel
.querySelectorAll("input[data-platform-key]")
.forEach((input) => {
enabledPlatforms[input.dataset.platformKey] = input.checked;
});
saveSettings({
enabled: enabledSwitch.input.checked,
showNotifications: notificationSwitch.input.checked,
autoProbe: autoProbeSwitch.input.checked,
activeProfileId: activeProfile.id,
profiles: profiles.map(({ active, ...profile }) => profile),
enabledPlatforms,
});
overlay.remove();
showToast("Xget Now userscript settings saved", "success");
});
footer.append(cancelButton, saveButton);
panel.appendChild(footer);
overlay.appendChild(panel);
overlay.addEventListener("click", (event) => {
if (event.target === overlay) {
overlay.remove();
}
});
document.documentElement.appendChild(overlay);
}
})();
+451
View File
@@ -0,0 +1,451 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { chromium } = require("playwright");
const REPO_ROOT = path.resolve(__dirname, "..", "..");
const USER_SCRIPT_PATH = path.join(
REPO_ROOT,
"build",
"userscript",
"xget-now.user.js"
);
function buildUserscript() {
execFileSync("python", ["build.py", "--platform", "userscript"], {
cwd: REPO_ROOT,
stdio: "pipe",
});
assert.equal(
fs.existsSync(USER_SCRIPT_PATH),
true,
`Expected userscript artifact at ${USER_SCRIPT_PATH}`
);
return fs.readFileSync(USER_SCRIPT_PATH, "utf8");
}
function createUserscriptSettings(overrides = {}) {
return {
enabled: true,
activeProfileId: "test-profile",
profiles: [
{ id: "default", name: "Default", domain: "xget.xi-xu.me" },
{ id: "test-profile", name: "Test Profile", domain: "xget.test" },
],
enabledPlatforms: {
gh: true,
gl: true,
gitea: true,
codeberg: true,
sf: true,
hf: true,
pypi: true,
"pypi-files": true,
npm: true,
},
showNotifications: true,
autoProbe: true,
...overrides,
};
}
async function createHarness(storeOverrides = {}, xgetAvailable = true) {
const userscriptSource = buildUserscript();
const browser = await chromium.launch({
channel: "chromium",
headless: false,
});
const context = await browser.newContext({
viewport: { width: 1440, height: 1800 },
});
const seenRequests = [];
const probeRequests = [];
await context.exposeBinding("__xgetGMRequest", async (_source, request) => {
if (request.url === "https://xget.test/gh/robots.txt") {
probeRequests.push(request.url);
if (!xgetAvailable) {
return {
ok: false,
error: "Xget probe failed",
};
}
return {
ok: true,
status: 204,
responseText: "",
finalUrl: request.url,
};
}
const controller = new AbortController();
const timeoutMs = Number(request.timeout || 0);
let timeoutId = null;
if (timeoutMs > 0) {
timeoutId = setTimeout(() => controller.abort(), timeoutMs);
}
try {
const response = await fetch(request.url, {
method: request.method || "GET",
headers: request.headers || {},
signal: controller.signal,
});
return {
ok: true,
status: response.status,
responseText: await response.text(),
finalUrl: request.url,
};
} catch (error) {
return {
ok: false,
error:
error && typeof error === "object" && "message" in error
? String(error.message)
: String(error),
};
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
});
await context.route("https://xget.test/**", async (route) => {
const request = route.request();
seenRequests.push(request.url());
if (!xgetAvailable) {
await route.abort("failed");
return;
}
await route.fulfill({
status: 200,
contentType: "text/html",
headers: {
"access-control-allow-origin": "*",
},
body: "<html><body>ok</body></html>",
});
});
await context.addInitScript(
({ initialStore }) => {
const store = { ...initialStore };
globalThis.GM_getValue = (key, fallback) =>
Object.prototype.hasOwnProperty.call(store, key) ? store[key] : fallback;
globalThis.GM_setValue = (key, value) => {
store[key] = value;
};
globalThis.GM_deleteValue = (key) => {
delete store[key];
};
globalThis.GM_registerMenuCommand = () => {};
globalThis.__XGET_TEST_STORE__ = store;
globalThis.GM_xmlhttpRequest = (options) => {
let aborted = false;
const request = {
url: options.url,
method: options.method || "GET",
headers: options.headers || {},
timeout: options.timeout || 0,
};
globalThis.__xgetGMRequest(request)
.then((result) => {
if (aborted) {
return;
}
if (result.ok) {
options.onload?.({
status: result.status,
responseText: result.responseText,
finalUrl: result.finalUrl,
});
return;
}
options.onerror?.(new Error(result.error || "GM_xmlhttpRequest failed"));
})
.catch((error) => {
if (!aborted) {
options.onerror?.(error);
}
});
return {
abort() {
aborted = true;
},
};
};
},
{
initialStore: {
xget_now_userscript_settings: createUserscriptSettings(storeOverrides),
},
}
);
await context.addInitScript({ content: userscriptSource });
return { browser, context, probeRequests, seenRequests };
}
async function clickRoleAndWaitForRequest(page, seenRequests, roleName, expectedUrl) {
const locatorOptions =
typeof roleName === "string"
? { name: roleName, exact: true }
: { name: roleName };
await page.getByRole("link", locatorOptions).click();
await waitForRequest(seenRequests, expectedUrl);
}
async function clickSelectorAndWaitForRequest(page, seenRequests, selector, expectedUrl) {
await page.locator(selector).click();
await waitForRequest(seenRequests, expectedUrl);
}
async function waitForRequest(seenRequests, expectedUrl, timeoutMs = 15000) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (seenRequests.includes(expectedUrl)) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
assert.fail(`Timed out waiting for request: ${expectedUrl}`);
}
test("GitHub release asset clicks rewrite to Xget", async () => {
const harness = await createHarness();
const { browser, context, seenRequests } = harness;
try {
const page = await context.newPage();
await page.goto("https://github.com/BurntSushi/ripgrep/releases/tag/14.1.1", {
waitUntil: "domcontentloaded",
});
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await page.waitForTimeout(5000);
const expectedUrl =
"https://xget.test/gh/BurntSushi/ripgrep/releases/download/14.1.1/ripgrep-14.1.1-aarch64-apple-darwin.tar.gz";
await clickRoleAndWaitForRequest(
page,
seenRequests,
"ripgrep-14.1.1-aarch64-apple-darwin.tar.gz",
expectedUrl
);
assert.equal(
seenRequests.includes(expectedUrl),
true
);
} finally {
await browser.close();
}
});
test("GitHub source archive clicks rewrite to Xget", async () => {
const harness = await createHarness();
const { browser, context, seenRequests } = harness;
try {
const page = await context.newPage();
await page.goto("https://github.com/BurntSushi/ripgrep/releases/tag/14.1.1", {
waitUntil: "domcontentloaded",
});
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await page.waitForTimeout(5000);
const expectedUrl =
"https://xget.test/gh/BurntSushi/ripgrep/archive/refs/tags/14.1.1.zip";
await clickRoleAndWaitForRequest(page, seenRequests, "Source code (zip)", expectedUrl);
assert.equal(
seenRequests.includes(expectedUrl),
true
);
} finally {
await browser.close();
}
});
test("PyPI wheel clicks rewrite to Xget", async () => {
const harness = await createHarness();
const { browser, context, seenRequests } = harness;
try {
const page = await context.newPage();
await page.goto("https://pypi.org/project/requests/2.32.3/#files", {
waitUntil: "domcontentloaded",
});
await page.waitForTimeout(3000);
const expectedUrl =
"https://xget.test/pypi-files/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl";
await clickRoleAndWaitForRequest(
page,
seenRequests,
"requests-2.32.3-py3-none-any.whl",
expectedUrl
);
assert.equal(
seenRequests.includes(expectedUrl),
true
);
} finally {
await browser.close();
}
});
test("Hugging Face file clicks rewrite to Xget", async () => {
const harness = await createHarness();
const { browser, context, seenRequests } = harness;
try {
const page = await context.newPage();
await page.goto("https://huggingface.co/openai-community/gpt2/tree/main", {
waitUntil: "domcontentloaded",
});
await page.waitForTimeout(5000);
const expectedUrl =
"https://xget.test/hf/openai-community/gpt2/resolve/main/config.json?download=true";
await clickSelectorAndWaitForRequest(
page,
seenRequests,
'a[href="/openai-community/gpt2/resolve/main/config.json?download=true"]',
expectedUrl
);
assert.equal(
seenRequests.includes(expectedUrl),
true
);
} finally {
await browser.close();
}
});
test("SourceForge download clicks rewrite to Xget", async () => {
const harness = await createHarness();
const { browser, context, seenRequests } = harness;
try {
const page = await context.newPage();
await page.goto(
"https://sourceforge.net/projects/sevenzip/files/7-Zip/24.09/",
{ waitUntil: "domcontentloaded" }
);
await page.waitForTimeout(5000);
const expectedUrl =
"https://xget.test/sf/projects/sevenzip/files/7-Zip/24.09/7z2409-x64.exe/download";
await clickRoleAndWaitForRequest(
page,
seenRequests,
/7z2409-x64\.exe/i,
expectedUrl
);
assert.equal(
seenRequests.includes(expectedUrl),
true
);
} finally {
await browser.close();
}
});
test("modifier clicks bypass userscript interception", async () => {
const harness = await createHarness();
const { browser, context, seenRequests } = harness;
try {
const page = await context.newPage();
await context.route(
"https://github.com/BurntSushi/ripgrep/archive/refs/tags/14.1.1.zip",
async (route) => {
await route.fulfill({
status: 200,
contentType: "text/html",
body: "<html><body>original</body></html>",
});
}
);
await page.goto("https://github.com/BurntSushi/ripgrep/releases/tag/14.1.1", {
waitUntil: "domcontentloaded",
});
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await page.waitForTimeout(5000);
await page.getByRole("link", { name: "Source code (zip)" }).click({
modifiers: ["Control"],
});
await page.waitForTimeout(2000);
assert.equal(
seenRequests.some((url) =>
url.includes("xget.test/gh/BurntSushi/ripgrep/archive/refs/tags/14.1.1.zip")
),
false
);
} finally {
await browser.close();
}
});
test("falls back to the original URL when the Xget node is unavailable", async () => {
const harness = await createHarness({}, false);
const { browser, context, probeRequests, seenRequests } = harness;
try {
const page = await context.newPage();
let originalFallbackRequested = false;
await context.route(
"https://github.com/BurntSushi/ripgrep/archive/refs/tags/14.1.1.zip",
async (route) => {
originalFallbackRequested = true;
await route.fulfill({
status: 200,
contentType: "text/html",
body: "<html><body>original fallback</body></html>",
});
}
);
await page.goto("https://github.com/BurntSushi/ripgrep/releases/tag/14.1.1", {
waitUntil: "domcontentloaded",
});
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await page.waitForTimeout(5000);
await page.getByRole("link", { name: "Source code (zip)" }).click();
await page.waitForTimeout(2000);
assert.equal(
probeRequests.includes("https://xget.test/gh/robots.txt"),
true
);
assert.equal(
seenRequests.some((url) =>
url.includes("xget.test/gh/BurntSushi/ripgrep/archive/refs/tags/14.1.1.zip")
),
false
);
assert.equal(originalFallbackRequested, true);
assert.equal(
page.url(),
"https://github.com/BurntSushi/ripgrep/releases/tag/14.1.1"
);
} finally {
await browser.close();
}
});
+195
View File
@@ -0,0 +1,195 @@
const test = require("node:test");
const assert = require("node:assert/strict");
function loadCore() {
try {
return require("../../src/shared/download-core.js");
} catch (error) {
assert.fail(`download-core.js is not ready: ${error.message}`);
}
}
test("detectPlatform recognizes GitHub helper hosts", () => {
const core = loadCore();
assert.equal(
core.detectPlatform(
"https://objects.githubusercontent.com/github-production-release-asset-2e65be/123/file.zip"
),
"gh"
);
assert.equal(
core.detectPlatform("https://codeload.github.com/owner/repo/zip/refs/tags/v1.0.0"),
"gh"
);
assert.equal(
core.detectPlatform("https://raw.githubusercontent.com/owner/repo/main/file.txt"),
"gh"
);
});
test("isDownloadCandidate recognizes supported core platform downloads", () => {
const core = loadCore();
assert.equal(
core.isDownloadCandidate(
"https://github.com/owner/repo/releases/download/v1.0.0/app.zip",
{ linkText: "app.zip" }
),
true
);
assert.equal(
core.isDownloadCandidate(
"https://github.com/owner/repo/archive/refs/tags/v1.0.0.tar.gz",
{ linkText: "Source code (tar.gz)" }
),
true
);
assert.equal(
core.isDownloadCandidate(
"https://gitlab.com/owner/repo/-/archive/v1.0.0/repo-v1.0.0.zip",
{ linkText: "Download archive" }
),
true
);
assert.equal(
core.isDownloadCandidate(
"https://codeberg.org/owner/repo/releases/download/v1.0.0/app.tar.gz",
{ linkText: "Download" }
),
true
);
assert.equal(
core.isDownloadCandidate(
"https://huggingface.co/openai-community/gpt2/resolve/main/model.safetensors?download=true",
{ linkText: "548 MB" }
),
true
);
assert.equal(
core.isDownloadCandidate(
"https://files.pythonhosted.org/packages/hash/app-1.0.0-py3-none-any.whl",
{ linkText: "app-1.0.0-py3-none-any.whl" }
),
true
);
assert.equal(
core.isDownloadCandidate(
"https://registry.npmjs.org/react/-/react-19.0.0.tgz",
{ linkText: "react-19.0.0.tgz" }
),
true
);
assert.equal(
core.isDownloadCandidate(
"https://sourceforge.net/projects/sevenzip/files/7-Zip/24.09/7z2409-x64.exe/download",
{ linkText: "Download Latest Version" }
),
true
);
});
test("isDownloadCandidate rejects obvious non-download links", () => {
const core = loadCore();
assert.equal(
core.isDownloadCandidate("https://github.com/owner/repo/releases", {
linkText: "Releases",
}),
false
);
assert.equal(
core.isDownloadCandidate("https://pypi.org/project/requests/2.32.3/", {
linkText: "requests 2.32.3",
}),
false
);
});
test("transformDownloadUrl rewrites supported URLs to the active Xget domain", () => {
const core = loadCore();
const settings = core.normalizeSettings({
xgetDomain: "xget.example.com",
enabledPlatforms: { gh: true, pypi: true, "pypi-files": true, crates: true },
});
assert.equal(
core.transformDownloadUrl(
"https://github.com/owner/repo/releases/download/v1.0.0/app.zip",
settings
),
"https://xget.example.com/gh/owner/repo/releases/download/v1.0.0/app.zip"
);
assert.equal(
core.transformDownloadUrl(
"https://files.pythonhosted.org/packages/hash/app-1.0.0.tar.gz",
settings
),
"https://xget.example.com/pypi-files/packages/hash/app-1.0.0.tar.gz"
);
assert.equal(
core.transformDownloadUrl(
"https://crates.io/serde/1.0.0/download",
settings
),
"https://xget.example.com/crates/api/v1/crates/serde/1.0.0/download"
);
});
test("shouldBypassClick preserves modifier and non-left-click behavior", () => {
const core = loadCore();
const link = { href: "https://github.com/owner/repo/releases/download/v1.0.0/app.zip" };
assert.equal(
core.shouldBypassClick(
{ button: 1, defaultPrevented: false, ctrlKey: false, metaKey: false, shiftKey: false, altKey: false },
link
),
true
);
assert.equal(
core.shouldBypassClick(
{ button: 0, defaultPrevented: false, ctrlKey: true, metaKey: false, shiftKey: false, altKey: false },
link
),
true
);
assert.equal(
core.shouldBypassClick(
{ button: 0, defaultPrevented: false, ctrlKey: false, metaKey: false, shiftKey: false, altKey: false },
link
),
false
);
});
test("normalizeSettings supports extension and userscript shapes", () => {
const core = loadCore();
const extensionSettings = core.normalizeSettings({
xgetDomain: "https://xget.example.com/",
enabledPlatforms: { gh: true, gl: false },
});
assert.equal(extensionSettings.xgetDomain, "xget.example.com");
assert.equal(extensionSettings.enabledPlatforms.gh, true);
assert.equal(extensionSettings.enabledPlatforms.gl, false);
assert.equal(extensionSettings.activeDomain, "xget.example.com");
const userscriptSettings = core.normalizeSettings(
{
activeProfileId: "custom",
profiles: [
{ id: "default", name: "Default", domain: "xget.xi-xu.me" },
{ id: "custom", name: "Custom", domain: "https://xget.alt.example/" },
],
enabledPlatforms: { gh: true, hf: true },
autoProbe: false,
},
{ mode: "userscript" }
);
assert.equal(userscriptSettings.activeProfileId, "custom");
assert.equal(userscriptSettings.activeDomain, "xget.alt.example");
assert.equal(userscriptSettings.enabledPlatforms.hf, true);
assert.equal(userscriptSettings.autoProbe, false);
});