diff --git a/build.py b/build.py index 2cab302..d117ba8 100644 --- a/build.py +++ b/build.py @@ -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__": diff --git a/manifest-firefox.json b/manifest-firefox.json index 23c3cc0..78f8d6e 100644 --- a/manifest-firefox.json +++ b/manifest-firefox.json @@ -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/*" ] -} \ No newline at end of file +} diff --git a/manifest.json b/manifest.json index 93cb01f..8978af3 100644 --- a/manifest.json +++ b/manifest.json @@ -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 @@ ] } ] -} \ No newline at end of file +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0a496b9 --- /dev/null +++ b/package-lock.json @@ -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" + } + } + } +} diff --git a/package.json b/package.json index c245f23..13774b0 100644 --- a/package.json +++ b/package.json @@ -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" -} \ No newline at end of file + "version": "2.0.5", + "devDependencies": { + "playwright": "^1.59.1" + } +} diff --git a/src/background/index.js b/src/background/index.js index ef7d70c..233fa1b 100644 --- a/src/background/index.js +++ b/src/background/index.js @@ -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); } diff --git a/src/content/index.js b/src/content/index.js index 936e51f..449137a 100644 --- a/src/content/index.js +++ b/src/content/index.js @@ -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); - } -} diff --git a/src/popup/index.html b/src/popup/index.html index beb6d0e..c0ad8a6 100644 --- a/src/popup/index.html +++ b/src/popup/index.html @@ -618,6 +618,7 @@ + diff --git a/src/popup/popup.js b/src/popup/popup.js index 752e6c8..9e1ec92 100644 --- a/src/popup/popup.js +++ b/src/popup/popup.js @@ -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 = diff --git a/src/shared/download-core.js b/src/shared/download-core.js new file mode 100644 index 0000000..9428d73 --- /dev/null +++ b/src/shared/download-core.js @@ -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, + }; +}); diff --git a/src/userscript/index.js b/src/userscript/index.js new file mode 100644 index 0000000..97230f0 --- /dev/null +++ b/src/userscript/index.js @@ -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); + } +})(); diff --git a/tests/e2e/userscript.e2e.test.js b/tests/e2e/userscript.e2e.test.js new file mode 100644 index 0000000..9af954b --- /dev/null +++ b/tests/e2e/userscript.e2e.test.js @@ -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: "ok", + }); + }); + + 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: "original", + }); + } + ); + 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: "original fallback", + }); + } + ); + 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(); + } +}); diff --git a/tests/unit/download-core.test.js b/tests/unit/download-core.test.js new file mode 100644 index 0000000..42b1b97 --- /dev/null +++ b/tests/unit/download-core.test.js @@ -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); +});