feat(userscript): add shared download core
This commit is contained in:
1 parent
3fec6dab96
commit
bbed3334c0
13 files changed
+2298
-1021
No files matched your search
@@ -13,6 +13,7 @@
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
@@ -67,6 +68,19 @@ def copy_common_files(build_dir):
|
||||
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():
|
||||
"""
|
||||
构建 Chromium 版本
|
||||
@@ -153,6 +167,11 @@ def flatten_for_browser(build_dir, platform):
|
||||
if platforms_file.exists():
|
||||
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
|
||||
detector_file = src_dir / "shared" / "platform-detector.js"
|
||||
if detector_file.exists():
|
||||
@@ -256,6 +275,79 @@ def create_package(build_dir, platform):
|
||||
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):
|
||||
"""
|
||||
验证 manifest 文件格式和内容
|
||||
@@ -299,7 +391,7 @@ def main():
|
||||
parser = argparse.ArgumentParser(description="构建 Xget Now 扩展")
|
||||
parser.add_argument(
|
||||
"--platform",
|
||||
choices=["chrome", "firefox", "all"],
|
||||
choices=["chrome", "firefox", "userscript", "all"],
|
||||
default="all",
|
||||
help="要构建的平台",
|
||||
)
|
||||
@@ -312,7 +404,7 @@ def main():
|
||||
|
||||
platforms = []
|
||||
if args.platform == "all":
|
||||
platforms = ["chrome", "firefox"]
|
||||
platforms = ["chrome", "firefox", "userscript"]
|
||||
else:
|
||||
platforms = [args.platform]
|
||||
|
||||
@@ -324,6 +416,12 @@ def main():
|
||||
build_dir = build_chrome()
|
||||
elif platform == "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_path = build_dir / "manifest.json"
|
||||
@@ -345,7 +443,7 @@ def main():
|
||||
print(f"✓ {platform}: {build_dir}")
|
||||
|
||||
if args.package:
|
||||
print("\n扩展包位于 packages/ 目录")
|
||||
print("\n构建包位于 packages/ 目录")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"persistent": true,
|
||||
"scripts": [
|
||||
"webext-compat.js",
|
||||
"download-core.js",
|
||||
"background.js"
|
||||
]
|
||||
},
|
||||
@@ -26,10 +27,14 @@
|
||||
{
|
||||
"js": [
|
||||
"webext-compat.js",
|
||||
"download-core.js",
|
||||
"content.js"
|
||||
],
|
||||
"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/*",
|
||||
@@ -72,6 +77,9 @@
|
||||
"storage",
|
||||
"activeTab",
|
||||
"https://github.com/*",
|
||||
"https://raw.githubusercontent.com/*",
|
||||
"https://codeload.github.com/*",
|
||||
"https://objects.githubusercontent.com/*",
|
||||
"https://gitlab.com/*",
|
||||
"https://gitea.com/*",
|
||||
"https://codeberg.org/*",
|
||||
@@ -101,4 +109,4 @@
|
||||
"web_accessible_resources": [
|
||||
"icons/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -16,10 +16,14 @@
|
||||
{
|
||||
"js": [
|
||||
"webext-compat.js",
|
||||
"download-core.js",
|
||||
"content.js"
|
||||
],
|
||||
"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/*",
|
||||
@@ -51,6 +55,9 @@
|
||||
"description": "为 Chromium 浏览器提供的 Xget 下载加速扩展,支持 GitHub、GitLab、npm、PyPI 等众多平台的文件下载加速",
|
||||
"host_permissions": [
|
||||
"https://github.com/*",
|
||||
"https://raw.githubusercontent.com/*",
|
||||
"https://codeload.github.com/*",
|
||||
"https://objects.githubusercontent.com/*",
|
||||
"https://gitlab.com/*",
|
||||
"https://gitea.com/*",
|
||||
"https://codeberg.org/*",
|
||||
@@ -100,4 +107,4 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Generated
+66
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-4
@@ -56,8 +56,15 @@
|
||||
"url": "https://github.com/xixu-me/Xget-Now.git"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "echo 'No build process needed for Chrome extension'",
|
||||
"test": "echo 'No tests specified'"
|
||||
"build": "python build.py --platform all",
|
||||
"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
@@ -1,445 +1,148 @@
|
||||
/**
|
||||
* Xget Now - 扩展后台脚本
|
||||
*
|
||||
* 功能:
|
||||
* - 处理扩展设置管理
|
||||
* - 监听标签页更新事件
|
||||
* - 处理来自内容脚本的消息
|
||||
* - 管理扩展状态和配置
|
||||
*/
|
||||
|
||||
// 对于Firefox Manifest V2,webext-compat.js 通过manifest加载
|
||||
// 对于Chrome Manifest V3,通过importScripts加载
|
||||
if (typeof importScripts !== "undefined") {
|
||||
try {
|
||||
importScripts("webext-compat.js");
|
||||
} catch (e) {
|
||||
console.log("Running in Firefox/Manifest V2 environment");
|
||||
importScripts("webext-compat.js", "download-core.js");
|
||||
} catch (error) {
|
||||
console.log("Background dependencies already loaded by the browser");
|
||||
}
|
||||
}
|
||||
|
||||
// 确保兼容层可用
|
||||
if (typeof webext === "undefined") {
|
||||
console.error("WebExt compatibility layer not found");
|
||||
// 尝试使用全局的browser或chrome API
|
||||
if (typeof browser !== "undefined") {
|
||||
console.log("Using Firefox browser API directly");
|
||||
self.webext = browser;
|
||||
self.webextFlavor = { soup: new Set(["firefox"]) };
|
||||
} else if (typeof chrome !== "undefined") {
|
||||
console.log("Using Chrome API directly");
|
||||
self.webext = chrome;
|
||||
self.webextFlavor = { soup: new Set(["chromium"]) };
|
||||
}
|
||||
}
|
||||
|
||||
// 确认API可用性
|
||||
console.log("Background script initializing...");
|
||||
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");
|
||||
const core = self.XgetDownloadCore;
|
||||
const DEFAULT_SETTINGS = core.normalizeSettings(core.DEFAULT_EXTENSION_SETTINGS);
|
||||
|
||||
/**
|
||||
* 平台配置定义
|
||||
* 支持的下载加速平台列表
|
||||
*/
|
||||
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可能不可用或有限制
|
||||
function getStorageAPI() {
|
||||
const isFirefox =
|
||||
webextFlavor && webextFlavor.soup && webextFlavor.soup.has("firefox");
|
||||
const storageAPI = isFirefox
|
||||
? webext.storage.local
|
||||
: webext.storage.sync || webext.storage.local;
|
||||
typeof webextFlavor !== "undefined" &&
|
||||
webextFlavor.soup &&
|
||||
webextFlavor.soup.has("firefox");
|
||||
|
||||
try {
|
||||
// 如果尚未设置,则设置默认设置
|
||||
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;
|
||||
return isFirefox ? webext.storage.local : webext.storage.sync || webext.storage.local;
|
||||
}
|
||||
|
||||
function handleDownload(downloadItem, suggest) {
|
||||
// 立即调用 suggest 来防止多次调用错误
|
||||
suggest();
|
||||
async function getSettings() {
|
||||
const storageAPI = getStorageAPI();
|
||||
const rawSettings = await storageAPI.get(DEFAULT_SETTINGS);
|
||||
return core.normalizeSettings(rawSettings);
|
||||
}
|
||||
|
||||
// 然后异步处理重定向逻辑
|
||||
processDownloadRedirect(downloadItem);
|
||||
async function setSettings(nextSettings) {
|
||||
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) {
|
||||
try {
|
||||
// 对于Firefox,优先使用local存储
|
||||
const isFirefox =
|
||||
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 settings = await getSettings();
|
||||
if (!settings.enabled || !settings.activeDomain) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查扩展是否已启用并配置了域名
|
||||
if (!settings.enabled || !settings.xgetDomain) {
|
||||
return;
|
||||
const redirectedUrl = core.transformDownloadUrl(downloadItem.url, settings);
|
||||
if (!redirectedUrl || redirectedUrl === downloadItem.url) {
|
||||
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) {
|
||||
try {
|
||||
// 找到匹配的平台
|
||||
for (const [platformKey, platform] of Object.entries(PLATFORMS)) {
|
||||
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;
|
||||
}
|
||||
function handleDownload(downloadItem, suggest) {
|
||||
suggest();
|
||||
processDownloadRedirect(downloadItem).catch((error) => {
|
||||
console.error("Failed to redirect browser download", error);
|
||||
});
|
||||
}
|
||||
|
||||
// 监听来自弹出窗口/选项的消息
|
||||
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") {
|
||||
// 使用Promise处理异步操作
|
||||
(async () => {
|
||||
try {
|
||||
const settings = await storageAPI.get(DEFAULT_SETTINGS);
|
||||
console.log("Settings retrieved:", settings);
|
||||
sendResponse(settings || DEFAULT_SETTINGS);
|
||||
} catch (error) {
|
||||
console.error("获取设置时出错:", error);
|
||||
getSettings()
|
||||
.then((settings) => sendResponse(settings))
|
||||
.catch((error) => {
|
||||
console.error("Failed to load settings", error);
|
||||
sendResponse(DEFAULT_SETTINGS);
|
||||
}
|
||||
})();
|
||||
return true; // 保持消息通道开放
|
||||
} else if (request.action === "saveSettings") {
|
||||
// 确保设置对象有效
|
||||
const settingsToSave = {
|
||||
...DEFAULT_SETTINGS,
|
||||
...request.settings,
|
||||
};
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
await storageAPI.set(settingsToSave);
|
||||
console.log("Settings saved successfully");
|
||||
|
||||
// 通知相关标签页刷新
|
||||
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 true;
|
||||
}
|
||||
|
||||
if (request.action === "saveSettings") {
|
||||
setSettings(request.settings || {})
|
||||
.then(async (settings) => {
|
||||
await notifyMatchingTabs("Settings updated. Refresh the page to use them.", true);
|
||||
sendResponse({ success: true, settings });
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to save settings", error);
|
||||
sendResponse({ success: false, error: error.message });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// 未知消息
|
||||
return false;
|
||||
};
|
||||
|
||||
// 添加消息监听器
|
||||
if (webext && webext.runtime && webext.runtime.onMessage) {
|
||||
webext.runtime.onMessage.addListener(messageListener);
|
||||
console.log("Message listener registered");
|
||||
} else {
|
||||
console.error("Cannot register message listener - runtime API not available");
|
||||
webext.runtime.onInstalled.addListener(async () => {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
await setSettings(settings);
|
||||
} catch (error) {
|
||||
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
@@ -1,614 +1,160 @@
|
||||
/**
|
||||
* Xget Now - 内容脚本
|
||||
*
|
||||
* 功能:
|
||||
* - 在支持的平台页面上注入下载拦截功能
|
||||
* - 监听页面上的下载链接点击事件
|
||||
* - 将下载请求重定向到加速服务
|
||||
* - 处理不同平台的特殊下载逻辑
|
||||
*/
|
||||
|
||||
// 确保兼容层可用
|
||||
if (typeof webext === "undefined") {
|
||||
console.error("WebExt compatibility layer not found in content script");
|
||||
console.error("Xget Now content script: compatibility layer missing");
|
||||
}
|
||||
|
||||
/**
|
||||
* 平台配置定义
|
||||
* 与后台脚本保持同步的平台列表
|
||||
*/
|
||||
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\//,
|
||||
},
|
||||
const core = globalThis.XgetDownloadCore;
|
||||
let liveSettings = null;
|
||||
|
||||
// AI/ML 平台
|
||||
hf: {
|
||||
base: "https://huggingface.co",
|
||||
name: "Hugging Face",
|
||||
pattern: /^https:\/\/huggingface\.co\//,
|
||||
},
|
||||
async function getSettings() {
|
||||
const settings = await new Promise((resolve) => {
|
||||
webext.runtime.sendMessage({ action: "getSettings" }, resolve);
|
||||
});
|
||||
|
||||
// 包管理平台
|
||||
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\//,
|
||||
},
|
||||
return core.normalizeSettings(settings);
|
||||
}
|
||||
|
||||
// 其他平台
|
||||
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\//,
|
||||
},
|
||||
};
|
||||
function shouldHandleCurrentPage(settings) {
|
||||
const currentPlatform = core.detectPlatform(window.location.href);
|
||||
return (
|
||||
Boolean(currentPlatform) &&
|
||||
settings.enabled &&
|
||||
Boolean(settings.activeDomain) &&
|
||||
settings.enabledPlatforms[currentPlatform]
|
||||
);
|
||||
}
|
||||
|
||||
// 初始化内容脚本
|
||||
(async function () {
|
||||
console.log("Xget Now:内容脚本已加载");
|
||||
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();
|
||||
}
|
||||
|
||||
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) => {
|
||||
if (request.action === "showNotification") {
|
||||
showNotification(request.message, request.showRefreshButton);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -618,6 +618,7 @@
|
||||
</div>
|
||||
|
||||
<script src="webext-compat.js"></script>
|
||||
<script src="download-core.js"></script>
|
||||
<script src="firefox-compat.js"></script>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
|
||||
+10
-8
@@ -8,6 +8,8 @@
|
||||
* - 提供平台开关控制
|
||||
*/
|
||||
|
||||
const core = window.XgetDownloadCore;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
// 确保有可用的API
|
||||
if (
|
||||
@@ -70,6 +72,7 @@ async function loadSettings() {
|
||||
throw new Error("No compatible API available");
|
||||
}
|
||||
|
||||
settings = core ? core.normalizeSettings(settings) : settings;
|
||||
console.log("Settings received:", settings);
|
||||
|
||||
if (!settings) {
|
||||
@@ -404,18 +407,17 @@ function validateDomain() {
|
||||
}
|
||||
|
||||
function cleanupDomain(domain) {
|
||||
// 如果意外包含协议,则删除它
|
||||
domain = domain.replace(/^https?:\/\//, "");
|
||||
if (core) {
|
||||
return core.sanitizeDomain(domain);
|
||||
}
|
||||
|
||||
// 删除末尾斜杠
|
||||
domain = domain.replace(/\/$/, "");
|
||||
|
||||
return domain;
|
||||
return String(domain || "")
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function isValidDomain(domain) {
|
||||
// 如果存在协议则删除它
|
||||
domain = domain.replace(/^https?:\/\//, "");
|
||||
domain = cleanupDomain(domain);
|
||||
|
||||
// 基本域名验证模式
|
||||
const domainPattern =
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user