Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ce464a6de | ||
|
|
7df54ea584 | ||
|
|
97d690b239 | ||
|
|
99f91c4f76 | ||
|
|
0b598844c8 | ||
|
|
50eec17043 | ||
|
|
8cf4480dd3 | ||
|
|
da9a3a9b0d | ||
|
|
d92ad2c485 | ||
|
|
d1844cea31 | ||
|
|
38310b688f | ||
|
|
bf91b4f899 | ||
|
|
7017af6949 | ||
|
|
dda9ac388a | ||
|
|
9199dcc784 | ||
|
|
3cc7695b1c | ||
|
|
6d9689521c | ||
|
|
68fb221d51 | ||
|
|
80e27dfcef | ||
|
|
1bd473bf11 | ||
|
|
31ca503ae8 | ||
|
|
95f6054b5c | ||
|
|
5271287f45 | ||
|
|
f0335c4fc4 | ||
|
|
bbed3334c0 | ||
|
|
3fec6dab96 | ||
|
|
70fa0cb9c1 | ||
|
|
f80543fbb6 | ||
|
|
3345c943b3 | ||
|
|
065cb2b396 | ||
|
|
1335fb736e | ||
|
|
545452be40 | ||
|
|
2a6d5271c4 | ||
|
|
d1013cfc42 | ||
|
|
a377e1db27 | ||
|
|
0140103194 | ||
|
|
7850cbf790 | ||
|
|
94636cae36 | ||
|
|
8b2402b14e | ||
|
|
e389d9fc56 | ||
|
|
36ef3969cf | ||
|
|
7dee5da5ed | ||
|
|
00ee7e6592 | ||
|
|
1fb387a32d | ||
|
|
38e8d54129 | ||
|
|
4f6eff4173 | ||
|
|
bec2770f2e | ||
|
|
ca45a98bab | ||
|
|
bf3ef0427c | ||
|
|
c68c2e0efa | ||
|
|
bdd7327292 | ||
|
|
1682b76967 | ||
|
|
b2a96c9027 | ||
|
|
a6d62103c1 | ||
|
|
440b27b1b7 | ||
|
|
c0feccdd2b | ||
|
|
1f74a391b7 | ||
|
|
a57fddb857 | ||
|
|
f9abababdb | ||
|
|
92f9326bb9 | ||
|
|
e80768c70a | ||
|
|
be6539c2c2 | ||
|
|
011d269446 | ||
|
|
06a503eab0 | ||
|
|
77e525b64e | ||
|
|
77198bcb62 | ||
|
|
796d50182e | ||
|
|
95bb7d56f9 | ||
|
|
3019487608 | ||
|
|
74759cae4f | ||
|
|
d56ff757ea | ||
|
|
8babb688a6 | ||
|
|
4e8f821de4 | ||
|
|
130eec751f | ||
|
|
ad5b3cae72 | ||
|
|
ed3a4c0ee3 | ||
|
|
367c9fee96 | ||
|
|
d78b231fe9 | ||
|
|
12d2a235bf | ||
|
|
3966bae9c0 | ||
|
|
8b576adbba | ||
|
|
09e87db44a | ||
|
|
d37b67c320 | ||
|
|
9f9a544b00 | ||
|
|
69cf36da68 | ||
|
|
3183c24ae8 | ||
|
|
4dbd0ea25f | ||
|
|
aba18148a7 | ||
|
|
ef4009b913 | ||
|
|
5a210a1d0a | ||
|
|
1f154e475d | ||
|
|
aba0591c3d | ||
|
|
385e0ab2fa | ||
|
|
18a863a73b | ||
|
|
2e2dc34dd0 | ||
|
|
599d0c6217 | ||
|
|
37a326486b | ||
|
|
f3830e66d4 | ||
|
|
d24429fdfa | ||
|
|
ef7aca33f7 | ||
|
|
36e6eea15b | ||
|
|
a4c5a681d5 | ||
|
|
1c80aedf67 | ||
|
|
337be3e62e | ||
|
|
de7b76b150 | ||
|
|
d37e74e53f | ||
|
|
a1e258a4b8 | ||
|
|
40103d8a05 | ||
|
|
bc0772f292 | ||
|
|
3094b7970f | ||
|
|
e52c4c3ec6 |
No files matched your search
@@ -0,0 +1,2 @@
|
||||
custom: https://xi-xu.me/#sponsorships
|
||||
buy_me_a_coffee: xixu
|
||||
@@ -12,7 +12,7 @@ body:
|
||||
id: version
|
||||
attributes:
|
||||
label: Extension Version
|
||||
description: What version of Xget for Chrome are you using?
|
||||
description: What version of Xget Now are you using?
|
||||
placeholder: "1.0.0"
|
||||
validations:
|
||||
required: true
|
||||
@@ -24,7 +24,7 @@ body:
|
||||
description: Which browser are you using?
|
||||
options:
|
||||
- Chrome
|
||||
- Microsoft Edge
|
||||
- Edge
|
||||
- Brave
|
||||
- Other Chromium-based browser
|
||||
validations:
|
||||
|
||||
@@ -6,7 +6,7 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Have a question about Xget for Chrome? We're here to help!
|
||||
Have a question about Xget Now? We're here to help!
|
||||
|
||||
- type: dropdown
|
||||
id: category
|
||||
|
||||
@@ -1,62 +1,62 @@
|
||||
# Pull Request
|
||||
|
||||
## Description
|
||||
<!-- Provide a brief description of the changes in this PR -->
|
||||
## 描述
|
||||
<!-- 简要描述此 PR 中的更改 -->
|
||||
|
||||
## Type of Change
|
||||
<!-- Mark the relevant option with an "x" -->
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] Documentation update
|
||||
- [ ] Code refactoring (no functional changes)
|
||||
- [ ] Performance improvement
|
||||
## 更改类型
|
||||
<!-- 用 "x" 标记相关选项 -->
|
||||
- [ ] Bug 修复(修复问题的非破坏性更改)
|
||||
- [ ] 新功能(添加功能的非破坏性更改)
|
||||
- [ ] 破坏性更改(会导致现有功能无法正常工作的修复或功能)
|
||||
- [ ] 文档更新
|
||||
- [ ] 代码重构(无功能更改)
|
||||
- [ ] 性能改进
|
||||
|
||||
## Related Issues
|
||||
<!-- Link any related issues using #issue_number -->
|
||||
Fixes #(issue_number)
|
||||
## 相关问题
|
||||
<!-- 使用 #issue_number 链接任何相关问题 -->
|
||||
修复 #(问题编号)
|
||||
|
||||
## Changes Made
|
||||
<!-- Describe the specific changes made in this PR -->
|
||||
## 所做更改
|
||||
<!-- 描述此 PR 中的具体更改 -->
|
||||
-
|
||||
-
|
||||
-
|
||||
|
||||
## Testing
|
||||
<!-- Describe how you tested these changes -->
|
||||
- [ ] Tested on GitHub
|
||||
- [ ] Tested on GitLab
|
||||
- [ ] Tested on Hugging Face
|
||||
- [ ] Tested extension popup/settings
|
||||
- [ ] Tested with multiple file types
|
||||
- [ ] Verified no console errors
|
||||
## 测试
|
||||
<!-- 描述你如何测试这些更改 -->
|
||||
- [ ] 在 GitHub 上测试
|
||||
- [ ] 在 GitLab 上测试
|
||||
- [ ] 在 Hugging Face 上测试
|
||||
- [ ] 测试扩展弹出窗口/设置
|
||||
- [ ] 测试多种文件类型
|
||||
- [ ] 验证无控制台错误
|
||||
|
||||
## Platform Compatibility
|
||||
<!-- Check all platforms where you've tested the changes -->
|
||||
## 平台兼容性
|
||||
<!-- 检查你测试更改的所有平台 -->
|
||||
- [ ] GitHub
|
||||
- [ ] GitLab
|
||||
- [ ] Hugging Face
|
||||
|
||||
## Browser Testing
|
||||
<!-- Check browsers where you've tested -->
|
||||
## 浏览器测试
|
||||
<!-- 检查你测试过的浏览器 -->
|
||||
- [ ] Chrome
|
||||
- [ ] Microsoft Edge
|
||||
- [ ] Edge
|
||||
- [ ] Brave
|
||||
- [ ] Other: ____________
|
||||
- [ ] 其他:____________
|
||||
|
||||
## Screenshots/Videos
|
||||
<!-- Add screenshots or videos if applicable -->
|
||||
## 截图/视频
|
||||
<!-- 如果适用,添加截图或视频 -->
|
||||
|
||||
## Checklist
|
||||
## 检查清单
|
||||
|
||||
- [ ] I have read and followed the project guidelines
|
||||
- [ ] My code follows the existing code style
|
||||
- [ ] I have performed a self-review of my code
|
||||
- [ ] I have commented my code, particularly in hard-to-understand areas
|
||||
- [ ] My changes generate no new console errors or warnings
|
||||
- [ ] I have tested that existing functionality still works
|
||||
- [ ] Any new features include appropriate error handling
|
||||
- [ ] The extension manifest version has been updated if needed
|
||||
- [ ] 我已阅读并遵循项目指南
|
||||
- [ ] 我的代码遵循现有代码风格
|
||||
- [ ] 我已对我的代码进行自我审查
|
||||
- [ ] 我已对我的代码进行注释,特别是在难以理解的地方
|
||||
- [ ] 我的更改不会产生新的控制台错误或警告
|
||||
- [ ] 我已测试现有功能仍然正常工作
|
||||
- [ ] 任何新功能都包含适当的错误处理
|
||||
- [ ] 如果需要,扩展清单版本已更新
|
||||
|
||||
## Additional Notes
|
||||
<!-- Add any additional notes or context for reviewers -->
|
||||
## 附加说明
|
||||
<!-- 为审查者添加任何附加说明或上下文 -->
|
||||
@@ -0,0 +1,129 @@
|
||||
name: 自动合并 Dependabot 更新
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- synchronize
|
||||
- ready_for_review
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
enable-auto-merge:
|
||||
name: 为符合条件的 Dependabot PR 开启自动合并
|
||||
if: github.event.pull_request.user.login == 'dependabot[bot]' && !github.event.pull_request.draft
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: 读取 Dependabot 元数据
|
||||
id: metadata
|
||||
uses: dependabot/fetch-metadata@v3
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: 判断是否允许自动合并
|
||||
id: eligibility
|
||||
env:
|
||||
PACKAGE_ECOSYSTEM: ${{ steps.metadata.outputs.package-ecosystem }}
|
||||
DEPENDENCY_TYPE: ${{ steps.metadata.outputs.dependency-type }}
|
||||
UPDATE_TYPE: ${{ steps.metadata.outputs.update-type }}
|
||||
run: |
|
||||
auto_merge=false
|
||||
reason="该 Dependabot 更新不在自动合并范围内"
|
||||
|
||||
if [ "$PACKAGE_ECOSYSTEM" = "github-actions" ]; then
|
||||
auto_merge=true
|
||||
reason="允许自动合并 GitHub Actions 更新"
|
||||
elif [ "$PACKAGE_ECOSYSTEM" = "npm" ] && [ "$DEPENDENCY_TYPE" = "direct:development" ]; then
|
||||
if [ "$UPDATE_TYPE" = "version-update:semver-patch" ] || [ "$UPDATE_TYPE" = "version-update:semver-minor" ]; then
|
||||
auto_merge=true
|
||||
reason="允许自动合并 npm 开发依赖的 patch/minor 更新"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "auto_merge=$auto_merge" >> "$GITHUB_OUTPUT"
|
||||
echo "reason=$reason" >> "$GITHUB_OUTPUT"
|
||||
echo "$reason"
|
||||
|
||||
- name: 自动批准符合条件的 PR
|
||||
if: steps.eligibility.outputs.auto_merge == 'true'
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const pull_number = context.payload.pull_request.number;
|
||||
|
||||
const reviews = await github.paginate(github.rest.pulls.listReviews, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const alreadyApproved = reviews.some(
|
||||
(review) =>
|
||||
review.user?.login === "github-actions[bot]" &&
|
||||
review.state === "APPROVED"
|
||||
);
|
||||
|
||||
if (alreadyApproved) {
|
||||
core.info("PR 已经有 github-actions[bot] 的批准记录,跳过。");
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.pulls.createReview({
|
||||
owner,
|
||||
repo,
|
||||
pull_number,
|
||||
event: "APPROVE",
|
||||
body: "自动批准符合策略的 Dependabot 依赖更新。",
|
||||
});
|
||||
|
||||
- name: 开启自动合并
|
||||
if: steps.eligibility.outputs.auto_merge == 'true'
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const pullRequestId = context.payload.pull_request.node_id;
|
||||
|
||||
try {
|
||||
await github.graphql(
|
||||
`
|
||||
mutation EnableAutoMerge($pullRequestId: ID!) {
|
||||
enablePullRequestAutoMerge(
|
||||
input: {
|
||||
pullRequestId: $pullRequestId
|
||||
mergeMethod: SQUASH
|
||||
}
|
||||
) {
|
||||
pullRequest {
|
||||
number
|
||||
autoMergeRequest {
|
||||
enabledAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ pullRequestId }
|
||||
);
|
||||
|
||||
core.info("已为该 PR 开启自动合并,等待必需检查和分支保护条件满足。");
|
||||
} catch (error) {
|
||||
if (String(error.message).includes("Pull request is in clean status")) {
|
||||
core.info("PR 已经满足条件并可能已启用自动合并,跳过重复设置。");
|
||||
return;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
- name: 输出跳过原因
|
||||
if: steps.eligibility.outputs.auto_merge != 'true'
|
||||
run: echo "${{ steps.eligibility.outputs.reason }}"
|
||||
@@ -0,0 +1,204 @@
|
||||
name: Dependabot Auto Merge
|
||||
|
||||
on:
|
||||
check_suite:
|
||||
types: [completed]
|
||||
workflow_run:
|
||||
workflows:
|
||||
- "CodeQL"
|
||||
- "Dependabot Updates"
|
||||
- "发布扩展到多个应用商店"
|
||||
- "测试构建"
|
||||
- "自动合并 Dependabot 更新"
|
||||
types:
|
||||
- completed
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.run_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
checks: read
|
||||
statuses: read
|
||||
|
||||
jobs:
|
||||
merge:
|
||||
name: Auto-merge Dependabot PRs
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Merge Dependabot PRs when checks pass
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
const successfulCheckConclusions = new Set(["success", "skipped", "neutral"]);
|
||||
const successfulStatusStates = new Set(["success"]);
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const latestBy = (items, keyOf, timeOf) => {
|
||||
const latest = new Map();
|
||||
for (const item of items) {
|
||||
const key = keyOf(item);
|
||||
const itemTime = new Date(timeOf(item) || 0).getTime();
|
||||
const existing = latest.get(key);
|
||||
const existingTime = existing ? new Date(timeOf(existing) || 0).getTime() : -1;
|
||||
if (!existing || itemTime >= existingTime) {
|
||||
latest.set(key, item);
|
||||
}
|
||||
}
|
||||
return [...latest.values()];
|
||||
};
|
||||
|
||||
const findCandidatePulls = async () => {
|
||||
const pulls = await github.paginate(github.rest.pulls.list, {
|
||||
owner,
|
||||
repo,
|
||||
state: "open",
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
return pulls.filter((pr) => pr.user?.login === "dependabot[bot]");
|
||||
};
|
||||
|
||||
const getMergeablePullRequest = async (pull_number) => {
|
||||
for (let attempt = 1; attempt <= 6; attempt += 1) {
|
||||
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number });
|
||||
if (pr.mergeable !== null) {
|
||||
return pr;
|
||||
}
|
||||
core.info("PR #" + pull_number + " mergeability is still being computed; retry " + attempt + "/6.");
|
||||
await wait(5000);
|
||||
}
|
||||
|
||||
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number });
|
||||
return pr;
|
||||
};
|
||||
|
||||
const getSignalState = async (sha) => {
|
||||
const checkRuns = await github.paginate(github.rest.checks.listForRef, {
|
||||
owner,
|
||||
repo,
|
||||
ref: sha,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const statuses = await github.paginate(github.rest.repos.listCommitStatusesForRef, {
|
||||
owner,
|
||||
repo,
|
||||
ref: sha,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const latestChecks = latestBy(
|
||||
checkRuns.filter((run) => run.name !== "Dependabot Auto Merge"),
|
||||
(run) => (run.app?.slug || "unknown") + ":" + run.name,
|
||||
(run) => run.completed_at || run.started_at || run.created_at,
|
||||
);
|
||||
const latestStatuses = latestBy(statuses, (status) => status.context, (status) => status.updated_at || status.created_at);
|
||||
|
||||
const pendingChecks = latestChecks.filter((run) => run.status !== "completed");
|
||||
const failedChecks = latestChecks.filter(
|
||||
(run) => run.status === "completed" && !successfulCheckConclusions.has(String(run.conclusion || "").toLowerCase()),
|
||||
);
|
||||
const failedStatuses = latestStatuses.filter((status) => !successfulStatusStates.has(String(status.state || "").toLowerCase()));
|
||||
|
||||
return {
|
||||
totalSignals: latestChecks.length + latestStatuses.length,
|
||||
pendingChecks,
|
||||
failedChecks,
|
||||
failedStatuses,
|
||||
};
|
||||
};
|
||||
|
||||
const { data: repository } = await github.rest.repos.get({ owner, repo });
|
||||
const mergeMethods = [];
|
||||
if (repository.allow_merge_commit) mergeMethods.push("merge");
|
||||
if (repository.allow_squash_merge) mergeMethods.push("squash");
|
||||
if (repository.allow_rebase_merge) mergeMethods.push("rebase");
|
||||
|
||||
if (mergeMethods.length === 0) {
|
||||
core.info("This repository has no enabled pull request merge methods.");
|
||||
return;
|
||||
}
|
||||
|
||||
const pulls = await findCandidatePulls();
|
||||
if (pulls.length === 0) {
|
||||
core.info("No open Dependabot PRs to evaluate.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const candidate of pulls) {
|
||||
const pull_number = candidate.number;
|
||||
const pr = await getMergeablePullRequest(pull_number);
|
||||
|
||||
if (pr.user?.login !== "dependabot[bot]") {
|
||||
core.info("PR #" + pull_number + " is no longer a Dependabot PR.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pr.state !== "open" || pr.draft) {
|
||||
core.info("PR #" + pull_number + " is not an open, ready PR.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pr.mergeable !== true) {
|
||||
core.info("PR #" + pull_number + " is not currently mergeable.");
|
||||
continue;
|
||||
}
|
||||
|
||||
const signalState = await getSignalState(pr.head.sha);
|
||||
if (signalState.totalSignals === 0) {
|
||||
core.info("PR #" + pull_number + " has no checks or statuses yet; skipping.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (signalState.pendingChecks.length > 0) {
|
||||
core.info("PR #" + pull_number + " still has pending checks: " + signalState.pendingChecks.map((run) => run.name).join(", ") + ".");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (signalState.failedChecks.length > 0 || signalState.failedStatuses.length > 0) {
|
||||
const failedChecks = signalState.failedChecks.map((run) => run.name + "=" + run.conclusion).join(", ");
|
||||
const failedStatuses = signalState.failedStatuses.map((status) => status.context + "=" + status.state).join(", ");
|
||||
core.info("PR #" + pull_number + " is not green. Checks: " + (failedChecks || "none") + ". Statuses: " + (failedStatuses || "none") + ".");
|
||||
continue;
|
||||
}
|
||||
|
||||
let merged = false;
|
||||
let lastError = null;
|
||||
for (const merge_method of mergeMethods) {
|
||||
try {
|
||||
await github.rest.pulls.merge({ owner, repo, pull_number, merge_method });
|
||||
core.info("Merged Dependabot PR #" + pull_number + " with " + merge_method + ".");
|
||||
merged = true;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (error.status === 403 || error.status === 405 || error.status === 409) {
|
||||
core.info("Cannot merge PR #" + pull_number + " with " + merge_method + ": " + error.message);
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (!merged) {
|
||||
core.info("PR #" + pull_number + " could not be merged: " + (lastError?.message || "unknown error") + ".");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pr.head.repo?.full_name === owner + "/" + repo) {
|
||||
try {
|
||||
await github.rest.git.deleteRef({ owner, repo, ref: "heads/" + pr.head.ref });
|
||||
core.info("Deleted branch " + pr.head.ref + ".");
|
||||
} catch (error) {
|
||||
core.info("Could not delete branch " + pr.head.ref + ": " + error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,230 +1,268 @@
|
||||
name: Publish Extension to Multiple Stores
|
||||
name: 发布扩展到多个应用商店
|
||||
|
||||
# 工作流触发条件:手动触发,允许指定版本号
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version number (e.g., 1.0.0)"
|
||||
description: "版本号 (例如: 1.0.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
# 工作流权限设置
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
contents: write # 创建 GitHub Release 需要
|
||||
packages: write # 发布包需要
|
||||
|
||||
jobs:
|
||||
build-and-publish:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
# 第一步:检出源代码
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
# 第二步:设置 Python 环境用于构建脚本
|
||||
- name: 设置 Python
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
# 第三步:设置 Node.js 环境用于 JSON 验证
|
||||
- name: 设置 Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "18"
|
||||
|
||||
- name: Validate manifest.json
|
||||
run: |
|
||||
# Validate that manifest.json is valid JSON
|
||||
node -e "JSON.parse(require('fs').readFileSync('manifest.json', 'utf8'))"
|
||||
echo "✅ manifest.json is valid"
|
||||
- name: 安装 Node.js 依赖
|
||||
run: npm ci
|
||||
|
||||
- name: Set version from input
|
||||
# 第四步:验证 manifest 文件格式
|
||||
- name: 验证 manifest 文件
|
||||
run: |
|
||||
# 验证 Chrome manifest.json 是否为有效的 JSON
|
||||
node -e "JSON.parse(require('fs').readFileSync('manifest.json', 'utf8'))"
|
||||
echo "[OK] Chrome manifest.json 格式有效"
|
||||
|
||||
# 验证 Firefox manifest 是否为有效的 JSON
|
||||
node -e "JSON.parse(require('fs').readFileSync('manifest-firefox.json', 'utf8'))"
|
||||
echo "[OK] Firefox manifest.json 格式有效"
|
||||
|
||||
# 第五步:设置版本号
|
||||
- name: 从输入设置版本
|
||||
id: version
|
||||
run: |
|
||||
# Use the manually provided version
|
||||
# 从工作流输入获取版本号
|
||||
VERSION="${{ inputs.version }}"
|
||||
if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "版本号格式非法: $VERSION"
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Manually set version: $VERSION"
|
||||
echo "手动设置版本: $VERSION"
|
||||
|
||||
- name: Update manifest version
|
||||
# 第六步:更新所有配置文件中的版本号
|
||||
- name: 更新 manifest 版本
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
# Update version in manifest.json to match the tag
|
||||
# 更新 Chrome manifest.json 中的版本
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const manifest = JSON.parse(fs.readFileSync('manifest.json', 'utf8'));
|
||||
manifest.version = '${{ steps.version.outputs.version }}';
|
||||
manifest.version = process.env.VERSION;
|
||||
fs.writeFileSync('manifest.json', JSON.stringify(manifest, null, 2));
|
||||
console.log('Updated manifest.json version to:', manifest.version);
|
||||
console.log('已更新 Chrome manifest.json 版本为:', manifest.version);
|
||||
"
|
||||
|
||||
- name: Update package.json version
|
||||
run: |
|
||||
# Update version in package.json to match the tag
|
||||
# 更新 Firefox manifest.json 中的版本
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
|
||||
pkg.version = '${{ steps.version.outputs.version }}';
|
||||
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2));
|
||||
console.log('Updated package.json version to:', pkg.version);
|
||||
const manifest = JSON.parse(fs.readFileSync('manifest-firefox.json', 'utf8'));
|
||||
manifest.version = process.env.VERSION;
|
||||
fs.writeFileSync('manifest-firefox.json', JSON.stringify(manifest, null, 2));
|
||||
console.log('已更新 Firefox manifest.json 版本为:', manifest.version);
|
||||
"
|
||||
|
||||
- name: Commit and push version updates
|
||||
# 第七步:同步更新 package.json 版本
|
||||
- name: 更新 package.json 版本
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
# Configure git user for the commit
|
||||
npm version "$VERSION" --no-git-tag-version
|
||||
echo "已同步 package.json 和 package-lock.json 版本为: $VERSION"
|
||||
|
||||
# 第八步:提交版本更改并创建 Git 标签
|
||||
- name: 提交并推送版本更新
|
||||
run: |
|
||||
# 配置 Git 用户信息用于自动提交
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
|
||||
# Add the updated files
|
||||
git add manifest.json package.json
|
||||
# 添加更新的文件
|
||||
git add manifest.json manifest-firefox.json package.json package-lock.json
|
||||
|
||||
# Check if there are changes to commit
|
||||
# 检查是否有需要提交的更改
|
||||
if git diff --staged --quiet; then
|
||||
echo "No version changes to commit"
|
||||
echo "没有版本更改需要提交"
|
||||
else
|
||||
# Commit the version updates
|
||||
git commit -m "chore: update version to ${{ steps.version.outputs.version }} [skip ci]"
|
||||
# 提交版本更新
|
||||
git commit -m "chore: 更新版本到 ${{ steps.version.outputs.version }} [skip ci]"
|
||||
|
||||
# Create and push a new tag for this version
|
||||
# 为此版本创建并推送新标签
|
||||
git tag "v${{ steps.version.outputs.version }}"
|
||||
|
||||
# Push to the main branch with the tag
|
||||
# 推送到主分支并包含标签
|
||||
git push origin HEAD:main
|
||||
git push origin "v${{ steps.version.outputs.version }}"
|
||||
|
||||
echo "✅ Version updates committed and pushed to main branch with tag"
|
||||
echo "[OK] 版本更新已提交并推送到主分支,包含标签"
|
||||
fi
|
||||
|
||||
- name: Create extension packages
|
||||
- name: 构建扩展包
|
||||
run: |
|
||||
# Create Chrome extension zip
|
||||
zip -r chrome-extension.zip . \
|
||||
-x "*.git*" \
|
||||
-x "node_modules/*" \
|
||||
-x ".github/*" \
|
||||
-x "*.md" \
|
||||
-x "LICENSE" \
|
||||
-x "*.zip" \
|
||||
-x "*.crx"
|
||||
echo "开始构建扩展包..."
|
||||
|
||||
# Create Microsoft Edge extension zip (same as Chrome for MV3)
|
||||
cp chrome-extension.zip edge-extension.zip
|
||||
# 运行构建脚本构建所有平台
|
||||
python build.py --platform all --package
|
||||
|
||||
echo "📦 Created extension packages"
|
||||
ls -la *.zip
|
||||
echo "扩展包构建完成"
|
||||
ls -la packages/ || echo "packages 目录不存在"
|
||||
|
||||
- name: Create CRX file for GitHub release
|
||||
- name: 运行测试
|
||||
run: |
|
||||
# Install chrome-extension-packer
|
||||
npm install -g crx3
|
||||
echo "运行基础测试..."
|
||||
python dev.py test
|
||||
npm run test:unit
|
||||
|
||||
# Create private key for CRX signing (for distribution only)
|
||||
openssl genrsa -out key.pem 2048
|
||||
echo "运行代码检查..."
|
||||
python dev.py lint
|
||||
|
||||
# Verify manifest.json exists
|
||||
ls -la manifest.json
|
||||
echo "Current directory: $(pwd)"
|
||||
echo "Contents:"
|
||||
ls -la
|
||||
|
||||
# Create temporary directory for extension files
|
||||
mkdir -p extension-temp
|
||||
|
||||
# Copy extension files to temp directory
|
||||
cp manifest.json extension-temp/
|
||||
cp background.js extension-temp/
|
||||
cp content.js extension-temp/
|
||||
cp popup.html extension-temp/
|
||||
cp popup.js extension-temp/
|
||||
cp -r icons extension-temp/
|
||||
|
||||
# Verify temp directory contents
|
||||
echo "Extension temp directory contents:"
|
||||
ls -la extension-temp/
|
||||
|
||||
# Create CRX file from temp directory
|
||||
cd extension-temp
|
||||
crx3 -o ../extension.crx -p ../key.pem .
|
||||
cd ..
|
||||
|
||||
echo "📦 Created extension.crx"
|
||||
ls -la extension.crx
|
||||
|
||||
# Clean up
|
||||
rm -rf extension-temp
|
||||
|
||||
- name: Publish to Chrome Web Store
|
||||
- name: 发布到 Chrome 应用商店
|
||||
continue-on-error: true
|
||||
id: chrome-publish
|
||||
uses: mnao305/chrome-extension-upload@v5.0.0
|
||||
uses: mnao305/chrome-extension-upload@v6.0.0
|
||||
with:
|
||||
file-path: chrome-extension.zip
|
||||
file-path: packages/Xget-Now_${{ steps.version.outputs.version }}.chromium.zip
|
||||
extension-id: ${{ secrets.CHROME_EXTENSION_ID }}
|
||||
client-id: ${{ secrets.CHROME_CLIENT_ID }}
|
||||
client-secret: ${{ secrets.CHROME_CLIENT_SECRET }}
|
||||
refresh-token: ${{ secrets.CHROME_REFRESH_TOKEN }}
|
||||
publish: true # Set to false if you want to upload without publishing
|
||||
publish: true
|
||||
|
||||
- name: Publish to Microsoft Edge Add-ons
|
||||
- name: 发布到 Edge 加载项
|
||||
continue-on-error: true
|
||||
id: edge-publish
|
||||
uses: wdzeng/edge-addon@v2
|
||||
with:
|
||||
product-id: ${{ secrets.EDGE_PRODUCT_ID }}
|
||||
zip-path: edge-extension.zip
|
||||
zip-path: packages/Xget-Now_${{ steps.version.outputs.version }}.chromium.zip
|
||||
api-key: ${{ secrets.EDGE_API_KEY }}
|
||||
client-id: ${{ secrets.EDGE_CLIENT_ID }}
|
||||
notes-for-certification: "Automatic release v${{ steps.version.outputs.version }} - ${{ github.sha }}"
|
||||
notes-for-certification: "自动发布 v${{ steps.version.outputs.version }} - ${{ github.sha }}"
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
- name: 发布到 Firefox 附加组件
|
||||
continue-on-error: true
|
||||
id: firefox-publish
|
||||
uses: browser-actions/release-firefox-addon@latest
|
||||
with:
|
||||
addon-id: ${{ secrets.FIREFOX_ADDON_ID }}
|
||||
addon-path: packages/Xget-Now_${{ steps.version.outputs.version }}.firefox.xpi
|
||||
auth-api-issuer: ${{ secrets.FIREFOX_AUTH_API_ISSUER }}
|
||||
auth-api-secret: ${{ secrets.FIREFOX_AUTH_API_SECRET }}
|
||||
approval-note: |
|
||||
这是一个下载加速器扩展,支持多个平台的文件下载加速。
|
||||
源代码位于 GitHub: https://github.com/xixu-me/Xget-Now
|
||||
构建说明:直接从源代码打包,无需编译或转译。
|
||||
|
||||
- name: 创建 GitHub 发布
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: v${{ steps.version.outputs.version }}
|
||||
name: Xget Now ${{ steps.version.outputs.version }}
|
||||
files: |
|
||||
chrome-extension.zip
|
||||
edge-extension.zip
|
||||
extension.crx
|
||||
packages/Xget-Now_${{ steps.version.outputs.version }}.chromium.zip
|
||||
packages/Xget-Now_${{ steps.version.outputs.version }}.firefox.xpi
|
||||
packages/Xget-Now_${{ steps.version.outputs.version }}.userscript.user.js
|
||||
generate_release_notes: true
|
||||
body: |
|
||||
## Xget for Chrome v${{ steps.version.outputs.version }}
|
||||
## 扩展包下载
|
||||
|
||||
This release has been automatically published to multiple extension stores.
|
||||
### Chromium
|
||||
- **文件**: `Xget-Now_${{ steps.version.outputs.version }}.chromium.zip`
|
||||
- **支持**: Chrome、Edge 等基于 Chromium 的浏览器
|
||||
- **Manifest**: v3
|
||||
|
||||
### 📦 Installation Options
|
||||
### Firefox
|
||||
- **文件**: `Xget-Now_${{ steps.version.outputs.version }}.firefox.xpi`
|
||||
- **支持**: Firefox 或其他基于 Firefox 的浏览器
|
||||
- **Manifest**: v2
|
||||
|
||||
- **Chrome Web Store**: [Install from Chrome Web Store](https://chromewebstore.google.com/detail/ajiejgobfcifcikbahpijopolfjoodgf)
|
||||
- **Microsoft Edge Add-ons**: [Install from Microsoft Edge Add-ons](https://microsoftedge.microsoft.com/addons/detail/hbfflpongojnfojbgadppjgnkabkpjea)
|
||||
- **Manual Installation**: Download the files below or see the [Installation Guide](https://github.com/xixu-me/Xget-for-Chrome?tab=readme-ov-file#-installation) for detailed instructions
|
||||
### Userscript
|
||||
- **文件**: `Xget-Now_${{ steps.version.outputs.version }}.userscript.user.js`
|
||||
- **支持**: 用户脚本管理器
|
||||
- **能力**: GitHub、GitLab、Gitea、Codeberg、Hugging Face、PyPI、npm、SourceForge 下载加速
|
||||
|
||||
### 📁 Available Files
|
||||
## 安装方法
|
||||
|
||||
- `chrome-extension.zip` - For Chrome browsers
|
||||
- `edge-extension.zip` - For Microsoft Edge
|
||||
- `extension.crx` - Pre-packaged extension file for all Chromium-based browsers
|
||||
详细的安装说明请参考:[手动安装指南](https://github.com/xixu-me/Xget-Now?tab=readme-ov-file#%E6%89%8B%E5%8A%A8%E5%AE%89%E8%A3%85)
|
||||
|
||||
## 更新日志
|
||||
|
||||
详细的更改内容请查看下方的自动生成的发布说明。
|
||||
|
||||
### Changes
|
||||
- See commit history for detailed changes
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Check publication results
|
||||
- name: 检查发布结果
|
||||
run: |
|
||||
echo "📊 Publication Results Summary:"
|
||||
echo "发布结果摘要:"
|
||||
echo "================================"
|
||||
echo "版本: ${{ steps.version.outputs.version }}"
|
||||
echo ""
|
||||
|
||||
if [ "${{ steps.chrome-publish.outcome }}" = "success" ]; then
|
||||
echo "✅ Chrome Web Store: Successfully published"
|
||||
echo "[OK] Chrome 应用商店: 发布成功"
|
||||
elif [ "${{ steps.chrome-publish.outcome }}" = "failure" ]; then
|
||||
echo "❌ Chrome Web Store: Publication failed"
|
||||
echo "[FAIL] Chrome 应用商店: 发布失败"
|
||||
else
|
||||
echo "⚠️ Chrome Web Store: Skipped (missing secrets)"
|
||||
echo "[WARN] Chrome 应用商店: 已跳过(缺少密钥或其他原因)"
|
||||
fi
|
||||
|
||||
if [ "${{ steps.edge-publish.outcome }}" = "success" ]; then
|
||||
echo "✅ Microsoft Edge Add-ons: Successfully published"
|
||||
echo "[OK] Edge 加载项: 发布成功"
|
||||
elif [ "${{ steps.edge-publish.outcome }}" = "failure" ]; then
|
||||
echo "❌ Microsoft Edge Add-ons: Publication failed"
|
||||
echo "[FAIL] Edge 加载项: 发布失败"
|
||||
else
|
||||
echo "⚠️ Microsoft Edge Add-ons: Skipped (missing secrets)"
|
||||
echo "[WARN] Edge 加载项: 已跳过(缺少密钥或其他原因)"
|
||||
fi
|
||||
|
||||
echo "✅ GitHub Release: Created with extension files"
|
||||
if [ "${{ steps.firefox-publish.outcome }}" = "success" ]; then
|
||||
echo "[OK] Firefox 附加组件: 发布成功"
|
||||
elif [ "${{ steps.firefox-publish.outcome }}" = "failure" ]; then
|
||||
echo "[FAIL] Firefox 附加组件: 发布失败"
|
||||
else
|
||||
echo "[WARN] Firefox 附加组件: 已跳过(缺少密钥或其他原因)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "[OK] GitHub 发布: 已创建包含所有扩展文件"
|
||||
echo "================================"
|
||||
|
||||
- name: Notify success
|
||||
- name: 通知成功
|
||||
run: |
|
||||
echo "🎉 Successfully published Xget for Chrome v${{ steps.version.outputs.version }} to multiple stores!"
|
||||
echo "🔗 Chrome Web Store: https://chrome.google.com/webstore/detail/${{ secrets.CHROME_EXTENSION_ID }}"
|
||||
echo "🔗 Microsoft Edge Add-ons: Check the releases page for manual installation files"
|
||||
echo "📦 GitHub Release: https://github.com/${{ github.repository }}/releases/tag/v${{ steps.version.outputs.version }}"
|
||||
echo ""
|
||||
echo "成功发布 Xget Now ${{ steps.version.outputs.version }}"
|
||||
echo "================================"
|
||||
echo "Chrome 应用商店: https://chrome.google.com/webstore/detail/${{ secrets.CHROME_EXTENSION_ID }}"
|
||||
echo "Edge 加载项: 查看发布页面获取最新信息"
|
||||
echo "Firefox 附加组件: 查看 Mozilla Add-ons 获取最新信息"
|
||||
echo "GitHub 发布: https://github.com/${{ github.repository }}/releases/tag/v${{ steps.version.outputs.version }}"
|
||||
echo "安装指南: https://github.com/${{ github.repository }}#installation"
|
||||
echo ""
|
||||
echo "发布的文件:"
|
||||
echo " - Xget-Now_${{ steps.version.outputs.version }}.chromium.zip (Chromium)"
|
||||
echo " - Xget-Now_${{ steps.version.outputs.version }}.firefox.xpi (Firefox)"
|
||||
echo " - Xget-Now_${{ steps.version.outputs.version }}.userscript.user.js (Userscript)"
|
||||
echo "================================"
|
||||
@@ -0,0 +1,262 @@
|
||||
name: 测试构建
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
- "docs/**"
|
||||
- "LICENSE"
|
||||
- ".gitignore"
|
||||
- ".editorconfig"
|
||||
- "images/**"
|
||||
- "screenshots/**"
|
||||
- ".vscode/**"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
- "docs/**"
|
||||
- "LICENSE"
|
||||
- ".gitignore"
|
||||
- ".editorconfig"
|
||||
- "images/**"
|
||||
- "screenshots/**"
|
||||
- ".vscode/**"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test-build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: 设置 Python
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: 设置 Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "18"
|
||||
|
||||
- name: 安装 Node.js 依赖
|
||||
run: npm ci
|
||||
|
||||
- name: 验证 manifest 文件
|
||||
run: |
|
||||
echo "验证 Chrome manifest..."
|
||||
node -e "
|
||||
const manifest = JSON.parse(require('fs').readFileSync('manifest.json', 'utf8'));
|
||||
console.log('Chrome manifest 版本:', manifest.version);
|
||||
console.log('Manifest 版本:', manifest.manifest_version);
|
||||
if (manifest.manifest_version !== 3) {
|
||||
throw new Error('Chrome manifest 应该使用 Manifest V3');
|
||||
}
|
||||
"
|
||||
|
||||
echo "验证 Firefox manifest..."
|
||||
node -e "
|
||||
const manifest = JSON.parse(require('fs').readFileSync('manifest-firefox.json', 'utf8'));
|
||||
console.log('Firefox manifest 版本:', manifest.version);
|
||||
console.log('Manifest 版本:', manifest.manifest_version);
|
||||
if (manifest.manifest_version !== 2) {
|
||||
throw new Error('Firefox manifest 应该使用 Manifest V2');
|
||||
}
|
||||
"
|
||||
|
||||
- name: 检查必需文件
|
||||
run: |
|
||||
echo "检查必需文件是否存在..."
|
||||
|
||||
required_files=(
|
||||
"manifest.json"
|
||||
"manifest-firefox.json"
|
||||
"src/background/index.js"
|
||||
"src/content/index.js"
|
||||
"src/popup/popup.js"
|
||||
"src/popup/index.html"
|
||||
"src/shared/download-core.js"
|
||||
"src/userscript/index.js"
|
||||
"src/shared/compat/webext-compat.js"
|
||||
"src/shared/compat/firefox-compat.js"
|
||||
"src/shared/platform-detector.js"
|
||||
"src/shared/platforms.js"
|
||||
"build.py"
|
||||
"dev.py"
|
||||
)
|
||||
|
||||
for file in "${required_files[@]}"; do
|
||||
if [ -f "$file" ]; then
|
||||
echo "[OK] $file"
|
||||
else
|
||||
echo "[FAIL] $file 缺失"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: 运行基础测试
|
||||
run: |
|
||||
echo "运行基础测试..."
|
||||
python dev.py test
|
||||
npm run test:unit
|
||||
|
||||
- name: 运行代码检查
|
||||
run: |
|
||||
echo "运行代码检查..."
|
||||
python dev.py lint
|
||||
|
||||
- name: 测试 Chrome 构建
|
||||
run: |
|
||||
echo "测试 Chrome 构建..."
|
||||
python build.py --platform chrome
|
||||
|
||||
# 验证构建结果
|
||||
if [ -d "build/chrome" ]; then
|
||||
echo "[OK] Chrome 构建目录已创建"
|
||||
ls -la build/chrome/
|
||||
else
|
||||
echo "[FAIL] Chrome 构建失败"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 测试 Firefox 构建
|
||||
run: |
|
||||
echo "测试 Firefox 构建..."
|
||||
python build.py --platform firefox
|
||||
|
||||
# 验证构建结果
|
||||
if [ -d "build/firefox" ]; then
|
||||
echo "[OK] Firefox 构建目录已创建"
|
||||
ls -la build/firefox/
|
||||
else
|
||||
echo "[FAIL] Firefox 构建失败"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 测试 userscript 构建
|
||||
run: |
|
||||
echo "测试 userscript 构建..."
|
||||
python build.py --platform userscript
|
||||
|
||||
if [ -f "build/userscript/xget-now.user.js" ]; then
|
||||
echo "[OK] userscript 构建文件已生成"
|
||||
ls -la build/userscript/
|
||||
else
|
||||
echo "[FAIL] userscript 构建失败"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 测试扩展包生成
|
||||
run: |
|
||||
echo "测试扩展包生成..."
|
||||
python build.py --platform all --package
|
||||
VERSION=$(node -p 'require("./package.json").version')
|
||||
|
||||
# 验证扩展包
|
||||
if [ -d "packages" ]; then
|
||||
echo "[OK] 扩展包目录已创建"
|
||||
ls -la packages/
|
||||
|
||||
# 检查是否有 Chrome、Firefox 和 userscript 包
|
||||
if [ -f "packages/Xget-Now_${VERSION}.chromium.zip" ]; then
|
||||
echo "[OK] Chrome 扩展包已生成"
|
||||
else
|
||||
echo "[FAIL] Chrome 扩展包生成失败"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -f "packages/Xget-Now_${VERSION}.firefox.xpi" ]; then
|
||||
echo "[OK] Firefox 扩展包已生成"
|
||||
else
|
||||
echo "[FAIL] Firefox 扩展包生成失败"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -f "packages/Xget-Now_${VERSION}.userscript.user.js" ]; then
|
||||
echo "[OK] userscript 包已生成"
|
||||
else
|
||||
echo "[FAIL] userscript 包生成失败"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "[FAIL] 扩展包生成失败"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 验证构建的 manifest 文件
|
||||
run: |
|
||||
echo "验证构建的 manifest 文件..."
|
||||
|
||||
# 检查 Chrome 构建的 manifest
|
||||
echo "检查 Chrome manifest..."
|
||||
node -e "
|
||||
const manifest = JSON.parse(require('fs').readFileSync('build/chrome/manifest.json', 'utf8'));
|
||||
console.log('Chrome 构建版本:', manifest.version);
|
||||
if (manifest.manifest_version !== 3) {
|
||||
throw new Error('Chrome 构建应该使用 Manifest V3');
|
||||
}
|
||||
if (!manifest.action) {
|
||||
throw new Error('Chrome 构建应该有 action 字段');
|
||||
}
|
||||
"
|
||||
|
||||
# 检查 Firefox 构建的 manifest
|
||||
echo "检查 Firefox manifest..."
|
||||
node -e "
|
||||
const manifest = JSON.parse(require('fs').readFileSync('build/firefox/manifest.json', 'utf8'));
|
||||
console.log('Firefox 构建版本:', manifest.version);
|
||||
if (manifest.manifest_version !== 2) {
|
||||
throw new Error('Firefox 构建应该使用 Manifest V2');
|
||||
}
|
||||
if (!manifest.browser_action) {
|
||||
throw new Error('Firefox 构建应该有 browser_action 字段');
|
||||
}
|
||||
if (!manifest.applications && !manifest.browser_specific_settings) {
|
||||
throw new Error('Firefox 构建应该有 applications 或 browser_specific_settings 字段');
|
||||
}
|
||||
"
|
||||
|
||||
echo "检查 userscript 构建..."
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const userscript = fs.readFileSync('build/userscript/xget-now.user.js', 'utf8');
|
||||
if (!userscript.includes('@grant GM_xmlhttpRequest')) {
|
||||
throw new Error('userscript 构建缺少 GM_xmlhttpRequest grant');
|
||||
}
|
||||
if (!userscript.includes('@run-at document-start')) {
|
||||
throw new Error('userscript 构建缺少 document-start 配置');
|
||||
}
|
||||
console.log('Userscript 元数据验证通过');
|
||||
"
|
||||
|
||||
- name: 上传构建结果
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: extension-builds-${{ github.sha }}
|
||||
path: |
|
||||
build/
|
||||
packages/
|
||||
retention-days: 7
|
||||
|
||||
- name: 测试总结
|
||||
run: |
|
||||
echo ""
|
||||
echo "所有测试通过"
|
||||
echo "================================"
|
||||
echo "[OK] Manifest 文件验证"
|
||||
echo "[OK] 必需文件检查"
|
||||
echo "[OK] 基础测试"
|
||||
echo "[OK] 代码检查"
|
||||
echo "[OK] Chrome 构建"
|
||||
echo "[OK] Firefox 构建"
|
||||
echo "[OK] userscript 构建"
|
||||
echo "[OK] 扩展包生成"
|
||||
echo "[OK] 构建结果验证"
|
||||
echo "================================"
|
||||
echo "准备发布"
|
||||
@@ -7,7 +7,9 @@ yarn-error.log*
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
packages/
|
||||
*.zip
|
||||
*.xpi
|
||||
extension.zip
|
||||
|
||||
# OS generated files
|
||||
|
||||
+142
-126
@@ -1,202 +1,218 @@
|
||||
# Privacy Policy for Xget for Chrome
|
||||
# 隐私政策
|
||||
|
||||
## Last updated: July 9, 2025
|
||||
## 生效日期:2025 年 8 月 10 日
|
||||
|
||||
## Overview
|
||||
## 概述
|
||||
|
||||
Xget for Chrome is a browser extension that accelerates downloads by redirecting them through Xget proxy servers. This privacy policy explains how we collect, use, and protect your information when you use our extension.
|
||||
Xget Now 是一个开源浏览器扩展,旨在通过 Xget 代理服务重定向下载链接来加速众多软件开发和研究平台的下载,包括 GitHub、GitLab、Hugging Face、npm、PyPI、Maven、RubyGems、CRAN 等 20+ 个平台。我们承诺保护您的隐私并确保数据处理的透明度。
|
||||
|
||||
## Information We Collect
|
||||
## 我们的隐私承诺
|
||||
|
||||
### 1. User Settings and Configuration Data
|
||||
**我们不收集、存储或传输您的个人数据。**
|
||||
|
||||
We collect and store the following information locally on your device:
|
||||
Xget Now 扩展遵循“隐私优先”的设计原则,所有处理都在您的浏览器本地进行。
|
||||
|
||||
- **Xget Domain Configuration**: The domain you configure for the Xget service (e.g., "xget.xi-xu.me")
|
||||
- **Extension Enable/Disable Status**: Whether the extension is currently enabled
|
||||
- **Platform Preferences**: Your preferences for which platforms (GitHub, GitLab, Hugging Face) to use with the extension
|
||||
## 收集的信息
|
||||
|
||||
### 2. Download Activity Data
|
||||
### 我们收集的信息
|
||||
|
||||
The extension processes the following data during operation:
|
||||
**无**。该扩展不会收集、记录或传输任何个人身份信息、浏览历史记录或用户数据到外部服务器。
|
||||
|
||||
- **Download URLs**: URLs of files you attempt to download from supported platforms
|
||||
- **Referrer Information**: The webpage from which downloads are initiated
|
||||
- **File Information**: Basic file metadata like filenames when available
|
||||
### 我们不收集的信息
|
||||
|
||||
### 3. Browser Interaction Data
|
||||
- 个人身份信息(姓名、电子邮件、地址等)
|
||||
- 浏览历史记录或访问的网站
|
||||
- 下载的文件或下载历史
|
||||
- IP 地址或位置信息
|
||||
- 设备信息或浏览器指纹
|
||||
- 任何形式的分析或使用统计
|
||||
|
||||
- **Tab Information**: Limited tab data to send notifications about redirected downloads
|
||||
- **Click Events**: Detection of clicks on download links to trigger redirection
|
||||
## 本地数据存储
|
||||
|
||||
## How We Use Your Information
|
||||
### 扩展设置
|
||||
|
||||
### Local Processing Only
|
||||
该扩展仅在您的浏览器本地存储以下设置:
|
||||
|
||||
- All settings and preferences are stored locally in your browser using Chrome's storage API
|
||||
- Download URL transformation happens locally within the extension
|
||||
- No personal data is transmitted to our servers
|
||||
- **Xget 域名配置**:您设置的自定义 Xget 服务域名
|
||||
- **扩展启用状态**:扩展是否启用的开关状态
|
||||
- **平台偏好设置**:为支持的各个平台(GitHub、GitLab、Hugging Face、npm、PyPI、Maven、RubyGems、CRAN 等)启用/禁用的选择
|
||||
|
||||
### Download Redirection
|
||||
这些设置:
|
||||
|
||||
- We modify download URLs to redirect through your configured Xget domain
|
||||
- The original download URLs are processed locally to create Xget-compatible URLs
|
||||
- Redirected downloads are handled by the Xget service according to their privacy policy
|
||||
- 仅存储在您的浏览器中
|
||||
- 不会发送到我们的服务器或任何第三方
|
||||
- 可以随时通过扩展设置进行修改或删除
|
||||
- 在卸载扩展时会被完全清除
|
||||
|
||||
### Notifications
|
||||
### 数据同步
|
||||
|
||||
- We display local notifications to inform you when downloads are redirected
|
||||
- These notifications appear only in your browser and are not logged or transmitted
|
||||
如果您已登录 Chrome/Edge 并启用了扩展同步,您的扩展设置可能会在您的设备之间同步。这是由 Google/Microsoft 的 Chrome/Edge 同步服务处理的,不涉及我们的服务器。
|
||||
|
||||
## Data Storage and Security
|
||||
## 数据处理
|
||||
|
||||
### Local Storage
|
||||
### URL 转换处理
|
||||
|
||||
- All extension settings are stored using Chrome's synchronized storage API
|
||||
- Settings may sync across your Chrome browsers if you're signed into Chrome
|
||||
- No sensitive personal information is stored
|
||||
当您点击支持平台上的下载链接时,扩展会:
|
||||
|
||||
### Data Retention
|
||||
1. **在本地检测**下载链接
|
||||
2. **在您的浏览器中转换** URL 格式以与 Xget 兼容
|
||||
3. **重定向**您到转换后的 Xget URL
|
||||
|
||||
- Settings persist until you uninstall the extension or manually clear them
|
||||
- Download URLs are not stored; they are processed in real-time only
|
||||
- No browsing history or personal files are retained
|
||||
**重要**:此 URL 转换完全在您的浏览器中进行。原始 URL 和转换后的 URL 都不会发送给我们。
|
||||
|
||||
### Security Measures
|
||||
### 与 Xget 服务的交互
|
||||
|
||||
- All data processing occurs locally within your browser
|
||||
- No external communication except for redirected downloads through your configured Xget domain
|
||||
- Extension follows Chrome's security model and permissions system
|
||||
当您使用扩展时:
|
||||
|
||||
## Third-Party Services
|
||||
- 您的下载请求直接发送到您配置的 Xget 服务(如 `xget.xi-xu.me`)
|
||||
- 我们不充当中介或收集有关这些请求的任何信息
|
||||
- Xget 服务有自己的隐私政策,管理其数据处理实践
|
||||
|
||||
### Xget Service
|
||||
## 第三方服务
|
||||
|
||||
- When you download files, they are redirected through the Xget service you configure
|
||||
- The Xget service may have its own privacy policy governing how they handle downloads
|
||||
- We recommend reviewing the privacy policy of your chosen Xget service provider
|
||||
### Xget 代理服务
|
||||
|
||||
### Supported Platforms
|
||||
该扩展将下载重定向到 Xget 代理服务。请注意:
|
||||
|
||||
The extension operates on these platforms:
|
||||
- **默认服务**:`xget.xi-xu.me`(如果使用)
|
||||
- **自定义服务**:您可以配置任何兼容的 Xget 实例
|
||||
- **数据处理**:您的下载请求由 Xget 服务处理,不由此扩展处理
|
||||
|
||||
- **GitHub** (github.com)
|
||||
- **GitLab** (gitlab.com)
|
||||
- **Hugging Face** (huggingface.co)
|
||||
**重要**:请查看您使用的 Xget 服务的隐私政策,因为您的下载请求将由该服务处理。
|
||||
|
||||
We do not collect or store any account information from these platforms.
|
||||
### 受支持的平台
|
||||
|
||||
## Permissions Explained
|
||||
该扩展在以下平台上运行:
|
||||
|
||||
### Why We Need These Permissions
|
||||
- **代码托管平台**:GitHub、GitLab、Gitea、Codeberg、SourceForge、Android Source
|
||||
- **包管理平台**:npm、PyPI、Anaconda、Maven Central、Gradle Plugin Portal、RubyGems、CRAN、CPAN、CTAN、Go Proxy、NuGet、Crates.io、Packagist
|
||||
- **AI/ML 平台**:Hugging Face
|
||||
- **学术平台**:arXiv
|
||||
- **应用分发**:F-Droid
|
||||
|
||||
**Downloads Permission**:
|
||||
扩展不会与这些平台共享任何数据,也不会修改您与这些平台的交互,除了重定向下载链接。
|
||||
|
||||
- Required to intercept and redirect download requests
|
||||
- Allows the extension to cancel original downloads and start redirected ones
|
||||
## 权限说明
|
||||
|
||||
**Storage Permission**:
|
||||
### 请求的权限
|
||||
|
||||
- Used to save your extension settings and preferences locally
|
||||
- Enables settings synchronization across your Chrome browsers
|
||||
该扩展请求以下浏览器权限:
|
||||
|
||||
**Active Tab Permission**:
|
||||
1. **`downloads`**:检测和管理下载以进行重定向
|
||||
2. **`storage`**:在本地存储您的扩展设置
|
||||
3. **`activeTab`**:在当前标签页中显示通知
|
||||
4. **`host_permissions`**:访问受支持的 20+ 个平台以检测下载链接
|
||||
|
||||
- Needed to send notifications about redirected downloads
|
||||
- Does not allow reading page content or personal information
|
||||
### 权限使用
|
||||
|
||||
**Host Permissions** (github.com, gitlab.com, huggingface.co):
|
||||
- **下载权限**:仅用于检测下载并将其重定向到 Xget
|
||||
- **存储权限**:仅用于保存您的扩展设置
|
||||
- **标签页权限**:仅用于显示下载重定向通知
|
||||
- **主机权限**:仅用于在受支持的各个平台上检测下载链接
|
||||
|
||||
- Required to run the extension on supported platforms
|
||||
- Allows detection and redirection of download links
|
||||
我们不使用这些权限来收集数据、跟踪您的活动或任何其他目的。
|
||||
|
||||
**Web Request Permissions**:
|
||||
## 数据安全
|
||||
|
||||
- Used for download interception functionality
|
||||
- Does not monitor general browsing activity
|
||||
### 本地处理
|
||||
|
||||
## Your Privacy Rights
|
||||
- 所有扩展逻辑在您的浏览器中本地运行
|
||||
- 不建立到我们服务器的网络连接
|
||||
- 没有数据传输到外部分析服务
|
||||
|
||||
### Control Over Your Data
|
||||
### 开源透明性
|
||||
|
||||
- You can disable the extension at any time through the popup interface
|
||||
- You can configure which platforms to use with the extension
|
||||
- You can change or remove your Xget domain configuration
|
||||
- Uninstalling the extension removes all stored settings
|
||||
- 扩展是完全开源的
|
||||
- 源代码可在 [GitHub](https://github.com/xixu-me/Xget-Now) 上获得
|
||||
- 您可以审查、验证和审计所有代码
|
||||
|
||||
### Data Portability
|
||||
## 您的权利
|
||||
|
||||
- Settings are stored in standard Chrome storage format
|
||||
- You can export/backup Chrome extension settings through Chrome's sync feature
|
||||
### 数据控制
|
||||
|
||||
### Data Deletion
|
||||
您可以完全控制扩展存储的有限数据:
|
||||
|
||||
- Clear extension data by uninstalling the extension
|
||||
- Reset settings through the extension popup interface
|
||||
- Clear Chrome's extension storage to remove all traces
|
||||
- **查看**:通过扩展弹出窗口查看您的设置
|
||||
- **修改**:随时更改您的设置
|
||||
- **删除**:卸载扩展以删除所有存储的数据
|
||||
|
||||
## Children's Privacy
|
||||
### 选择退出
|
||||
|
||||
This extension is not intended for use by children under 13 years of age. We do not knowingly collect personal information from children under 13.
|
||||
您可以随时:
|
||||
|
||||
## Changes to This Privacy Policy
|
||||
- 在扩展设置中禁用扩展
|
||||
- 为特定平台禁用重定向
|
||||
- 完全卸载扩展
|
||||
|
||||
We may update this privacy policy from time to time. When we do, we will:
|
||||
## 儿童隐私
|
||||
|
||||
- Update the "Last updated" date at the top of this policy
|
||||
- Notify users through the extension interface if there are material changes
|
||||
- Maintain previous versions for reference
|
||||
该扩展不会有意收集 13 岁以下儿童的信息。实际上,该扩展不会收集任何用户的任何个人信息。
|
||||
|
||||
## Compliance
|
||||
## 国际数据传输
|
||||
|
||||
This extension is designed to comply with:
|
||||
由于扩展不收集或传输任何个人数据,因此不涉及国际数据传输。您配置的任何 Xget 服务都将根据其自己的隐私政策处理数据传输。
|
||||
|
||||
- Chrome Web Store Developer Policies
|
||||
- General Data Protection Regulation (GDPR)
|
||||
- California Consumer Privacy Act (CCPA)
|
||||
- Other applicable privacy laws
|
||||
## 政策更新
|
||||
|
||||
## Technical Implementation
|
||||
### 更改通知
|
||||
|
||||
### Data Minimization
|
||||
我们可能会不时更新此隐私政策。任何更改将:
|
||||
|
||||
- We collect only the minimum data necessary for functionality
|
||||
- No analytics, tracking, or telemetry data is collected
|
||||
- No user identification or profiling occurs
|
||||
- 在此页面上发布
|
||||
- 在更新的策略顶部包含新的生效日期
|
||||
- 如果是重大更改,将通过扩展更新或其他方式通知
|
||||
|
||||
### Transparency
|
||||
### 持续使用
|
||||
|
||||
- All source code is available for review
|
||||
- Extension behavior is predictable and documented
|
||||
- No hidden data collection or processing
|
||||
在任何更改后继续使用扩展即表示您接受更新的隐私政策。
|
||||
|
||||
## Contact Information
|
||||
## 联系我们
|
||||
|
||||
If you have any questions about this privacy policy or the extension's data practices:
|
||||
如果您对此隐私政策有任何问题、疑虑或建议,请:
|
||||
|
||||
- **Developer**: Xi Xu
|
||||
- **Repository**: [Xget for Chrome on GitHub](https://github.com/xixu-me/Xget-for-Chrome)
|
||||
- **Issues**: [Report Issues](https://github.com/xixu-me/Xget-for-Chrome/issues)
|
||||
- **GitHub Issues**:在我们的 [GitHub 存储库](https://github.com/xixu-me/Xget-Now/issues) 上打开一个 issue
|
||||
- **电子邮件**:通过我们的 GitHub 配置文件联系维护者
|
||||
|
||||
## Open Source Commitment
|
||||
## 法律合规
|
||||
|
||||
This extension is open source software licensed under GPL-3.0. You can:
|
||||
### 数据保护法律
|
||||
|
||||
- Review the complete source code
|
||||
- Verify our privacy practices
|
||||
- Submit issues or improvements
|
||||
- Fork and modify the code
|
||||
由于该扩展不收集个人数据,因此它符合主要的数据保护法规,包括:
|
||||
|
||||
## Data Protection Summary
|
||||
- **GDPR**(欧盟一般数据保护条例)
|
||||
- **CCPA**(加州消费者隐私法)
|
||||
- **其他地区数据保护法律**
|
||||
|
||||
| Data Type | Collection | Storage | Sharing | Retention |
|
||||
|-----------|------------|---------|---------|-----------|
|
||||
| Extension Settings | Local only | Browser storage | Chrome sync only | Until uninstall |
|
||||
| Download URLs | Processed locally | Not stored | Redirected only | Real-time only |
|
||||
| Personal Information | None | None | None | None |
|
||||
| Browsing History | None | None | None | None |
|
||||
| User Identity | None | None | None | None |
|
||||
### 最小数据实践
|
||||
|
||||
该扩展遵循数据最小化原则:
|
||||
|
||||
- 仅存储功能绝对必要的数据
|
||||
- 不收集可识别或敏感信息
|
||||
- 实施最小权限访问
|
||||
|
||||
## 技术实施
|
||||
|
||||
### 隐私设计
|
||||
|
||||
该扩展实施了以下隐私保护措施:
|
||||
|
||||
- **无网络通信**:除了重定向下载外,不进行任何网络调用
|
||||
- **本地处理**:所有逻辑在浏览器中执行
|
||||
- **无跟踪**:不包含分析、跟踪或遥测代码
|
||||
- **最小权限**:仅请求功能所需的权限
|
||||
|
||||
### 代码审计
|
||||
|
||||
作为开源项目:
|
||||
|
||||
- 所有代码都是可验证的
|
||||
- 社区可以审计隐私实践
|
||||
- 不包含隐藏或混淆的功能
|
||||
|
||||
---
|
||||
|
||||
By using Xget for Chrome, you acknowledge that you have read and understood this privacy policy and agree to the data practices described herein.
|
||||
**最后更新**:2025 年 8 月 10 日
|
||||
**联系方式**:[GitHub Issues](https://github.com/xixu-me/Xget-Now/issues)
|
||||
|
||||
此隐私政策是我们对透明度和用户隐私承诺的一部分。我们相信您有权了解您使用的软件如何处理您的数据,以及在这种情况下,确认它不会处理您的个人数据。
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
# Xget Now
|
||||
|
||||
***[汉语](README.md)***
|
||||
|
||||
A cross-browser extension for Chromium and Firefox that accelerates file downloads by seamlessly forwarding them to an [Xget](https://github.com/xixu-me/Xget) instance.
|
||||
|
||||
> [!NOTE]
|
||||
> Quick start: install the store version, enter your Xget domain such as `xget.xi-xu.me`, and leave the target platform toggles enabled.
|
||||
|
||||
## Overview
|
||||
|
||||
Xget Now detects real download links on supported websites, rewrites them to your Xget domain, and keeps the browser's native download flow intact. You do not need to modify the original site or manually copy download URLs.
|
||||
|
||||
## Features
|
||||
|
||||
- Automatic download acceleration: seamlessly redirect downloads through Xget for faster speeds.
|
||||
- Configurable settings: customize your Xget domain and platform preferences.
|
||||
- Smart notifications: visual feedback when downloads are redirected.
|
||||
- Privacy-first: all processing happens locally in your browser.
|
||||
- Per-platform control: enable or disable acceleration for specific platforms.
|
||||
- Cross-browser support: works on both Chromium and Firefox browsers.
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
- Code hosting: GitHub, GitLab, Gitea, Codeberg, SourceForge, AOSP.
|
||||
- AI / ML: Hugging Face.
|
||||
- Package and distribution platforms: npm, PyPI, PyPI Files, Conda, Conda Community, Maven Central, Apache Downloads, Gradle Plugin Portal, RubyGems, CRAN, CPAN, CTAN, Go Modules, NuGet, Crates.io, Packagist.
|
||||
- Other platforms: arXiv, F-Droid.
|
||||
|
||||
## Installation
|
||||
|
||||
### Store Availability
|
||||
|
||||
- [Chrome Web Store](https://chromewebstore.google.com/detail/ajiejgobfcifcikbahpijopolfjoodgf?hl=en)
|
||||
- [Chrome Web Store Mirror](https://chromewebstore.xi-xu.me/detail/ajiejgobfcifcikbahpijopolfjoodgf)
|
||||
- [Firefox Add-ons](https://addons.mozilla.org/en-US/firefox/addon/xget-now/)
|
||||
- [Edge Add-ons](https://microsoftedge.microsoft.com/addons/detail/jigpfhbegabdenhihpplcjhpfdcgnalc)
|
||||
|
||||
### Manual Installation
|
||||
|
||||
If you prefer manual installation, or if a store listing is unavailable, you can install Xget Now from GitHub releases or directly from source.
|
||||
|
||||
#### Option 1: Install from GitHub Releases
|
||||
|
||||
1. Go to the [Releases page](https://github.com/xixu-me/Xget-Now/releases/latest).
|
||||
2. Download the package for your browser:
|
||||
- `Xget-Now_x.x.x.chromium.zip`: for Chromium-based browsers such as Chrome, Edge, and Opera.
|
||||
- `Xget-Now_x.x.x.firefox.xpi`: for Firefox.
|
||||
3. Install in Chrome:
|
||||
- Extract the Chromium `.zip` package.
|
||||
- Open `chrome://extensions/`.
|
||||
- Enable "Developer mode" in the top-right corner.
|
||||
- Click "Load unpacked" and select the extracted folder.
|
||||
4. Install in Firefox:
|
||||
- Open `about:debugging`.
|
||||
- Click "This Firefox".
|
||||
- Click "Load Temporary Add-on".
|
||||
- Select the Firefox `.xpi` file, or the `manifest.json` file from an extracted package.
|
||||
5. Install in Edge:
|
||||
- Extract the Chromium `.zip` package.
|
||||
- Open `edge://extensions/`.
|
||||
- Enable "Developer mode" in the left sidebar.
|
||||
- Click "Load unpacked" and select the extracted folder.
|
||||
6. Install in other Chromium-based browsers:
|
||||
- Use the Chromium package.
|
||||
- Enable that browser's developer mode first.
|
||||
- Follow the browser-specific extension loading instructions.
|
||||
|
||||
#### Option 2: Install from Source
|
||||
|
||||
1. Clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/xixu-me/Xget-Now.git
|
||||
cd Xget-Now
|
||||
```
|
||||
|
||||
2. Build the extension if needed:
|
||||
|
||||
`build.py` only uses the Python standard library, so Python 3.7+ is enough.
|
||||
|
||||
```bash
|
||||
python build.py --platform chrome
|
||||
python build.py --platform firefox
|
||||
python build.py --platform all
|
||||
python build.py --platform all --package
|
||||
```
|
||||
|
||||
3. Load it in the browser:
|
||||
- Chromium browsers:
|
||||
- Open `chrome://extensions/` or `edge://extensions/`.
|
||||
- Enable "Developer mode".
|
||||
- Click "Load unpacked" and select either the repository root, or `build/chrome/` if you built the packaged version.
|
||||
- Firefox:
|
||||
- Open `about:debugging`.
|
||||
- Click "This Firefox".
|
||||
- Click "Load Temporary Add-on".
|
||||
- Select `manifest-firefox.json` from the repository root, or `build/firefox/manifest.json` from a build output.
|
||||
|
||||
#### Verify Installation
|
||||
|
||||
After installation, you should see the Xget Now icon in the browser toolbar. Click it, configure your Xget domain, and start accelerating downloads.
|
||||
|
||||
## Configuration
|
||||
|
||||
1. Click the extension icon in the browser toolbar.
|
||||
2. Enter your Xget domain, for example `xget.xi-xu.me`.
|
||||
3. Enable the extension and toggle supported platforms on or off as needed.
|
||||
4. Visit any supported platform and click download links as usual.
|
||||
5. Downloads will automatically be routed through Xget.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Detection: the extension monitors download links on supported platforms.
|
||||
2. Transformation: URLs are converted to an Xget-compatible format.
|
||||
3. Redirection: downloads are routed through your configured Xget domain.
|
||||
4. Acceleration: Xget's CDN and optimization features provide faster download speeds.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Browser Support
|
||||
|
||||
- Chrome: 88+.
|
||||
- Edge: 88+.
|
||||
- Opera: 74+.
|
||||
- Other Chromium-based browsers: versions with Manifest V3 support.
|
||||
- Firefox: 109+.
|
||||
- Firefox ESR: 109+.
|
||||
|
||||
### Xget Instance
|
||||
|
||||
Use the pre-deployed instance `xget.xi-xu.me`, or deploy your own by following the [Xget deployment documentation](https://github.com/xixu-me/Xget#-deployment).
|
||||
|
||||
## Privacy and Security
|
||||
|
||||
- Local processing: all URL transformations happen in your browser.
|
||||
- No data collection: the extension does not collect or transmit personal data.
|
||||
- Minimal permissions: it only requests permissions needed for the feature set.
|
||||
- Open source: the full source code is available for inspection.
|
||||
|
||||
See the [Privacy Policy](PRIVACY_POLICY.md) for complete details.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Extension not working?**
|
||||
|
||||
- Ensure you configured a valid Xget domain.
|
||||
- Check whether the extension is enabled in the popup.
|
||||
- Verify that the target platform is enabled.
|
||||
- Refresh the page after changing settings.
|
||||
|
||||
**Downloads not being redirected?**
|
||||
|
||||
- Refresh the page after changing settings.
|
||||
- Check the browser console for error messages (`F12` -> Console).
|
||||
- Ensure the link is a recognized download type.
|
||||
- Confirm that you clicked an actual download link rather than a navigation link.
|
||||
|
||||
**Xget domain issues?**
|
||||
|
||||
- Enter the domain without the `https://` protocol.
|
||||
- Example: `xget.xi-xu.me`, not `https://xget.xi-xu.me`.
|
||||
- Check whether the domain is reachable in your browser.
|
||||
- Try the default pre-deployed instance first: `xget.xi-xu.me`.
|
||||
|
||||
**Performance issues?**
|
||||
|
||||
- Check your network connection.
|
||||
- Try another Xget domain if one is available.
|
||||
- Verify that the target platform is responding normally.
|
||||
- Clear the browser cache and reload the extension.
|
||||
|
||||
**Firefox-specific issues?**
|
||||
|
||||
- Ensure Firefox is version 109 or newer.
|
||||
- Check whether the extension is correctly loaded in `about:debugging`.
|
||||
- Firefox may require a restart to fully apply extension settings.
|
||||
- Review Firefox Privacy & Security settings to make sure they are not blocking extension behavior.
|
||||
|
||||
### Debug Mode
|
||||
|
||||
In Chromium browsers, open DevTools and inspect the Console tab. In Firefox, open `about:debugging`, click "This Firefox", find Xget Now, click "Inspect", and review the console output there.
|
||||
|
||||
Common debug messages:
|
||||
|
||||
- Extension loaded: `Xget Now: Content script loaded`
|
||||
- Download redirect: `Redirecting download: [original] -> [Xget]`
|
||||
- Settings changed: `Settings updated! Click to refresh page`
|
||||
|
||||
## FAQ
|
||||
|
||||
### Is this extension free?
|
||||
|
||||
Yes. The extension is completely free and open source under GPL-3.0.
|
||||
|
||||
### Can I use my own Xget server?
|
||||
|
||||
Yes. You can deploy your own Xget instance from the [Xget repository](https://github.com/xixu-me/Xget) and point the extension to your own domain.
|
||||
|
||||
### Why are some downloads still going through the original server?
|
||||
|
||||
The extension only redirects recognized download links. Navigation links, preview links, and some dynamic content may not be redirected.
|
||||
|
||||
### Will my browsing data be collected?
|
||||
|
||||
No. The extension runs entirely locally, and no browsing data is collected or transmitted. See the [Privacy Policy](PRIVACY_POLICY.md) for details.
|
||||
|
||||
### How much faster are the downloads?
|
||||
|
||||
Speed improvements depend on your location, network conditions, and file size. Typical gains range from roughly 2x to 10x.
|
||||
|
||||
### Can I disable the extension for specific websites?
|
||||
|
||||
Yes. Use the per-platform toggle switches in the popup to disable acceleration for specific supported platforms.
|
||||
|
||||
### Why are there different versions for Chrome and Firefox?
|
||||
|
||||
The two versions are optimized for different browser architectures:
|
||||
|
||||
- Chrome version: uses Manifest V3 and newer extension APIs.
|
||||
- Firefox version: uses Manifest V2 for Firefox compatibility.
|
||||
|
||||
The core behavior remains the same, while the implementation details differ slightly.
|
||||
|
||||
### Can I use it in Chrome and Firefox at the same time?
|
||||
|
||||
Yes. You can install Xget Now in multiple browsers at once, and each browser keeps its own settings.
|
||||
|
||||
## Support
|
||||
|
||||
If Xget Now is useful to you, consider:
|
||||
|
||||
- Starring the repository.
|
||||
- Leaving a review:
|
||||
- [Chrome Web Store](https://chromewebstore.google.com/detail/ajiejgobfcifcikbahpijopolfjoodgf?hl=en)
|
||||
- [Firefox Add-ons](https://addons.mozilla.org/en-US/firefox/addon/xget-now/)
|
||||
- [Edge Add-ons](https://microsoftedge.microsoft.com/addons/detail/jigpfhbegabdenhihpplcjhpfdcgnalc)
|
||||
- Reporting bugs or suggesting features via [GitHub Issues](https://github.com/xixu-me/Xget-Now/issues).
|
||||
- Sharing it with others who might benefit from faster downloads.
|
||||
|
||||
> [!NOTE]
|
||||
> This repository is licensed under GPL-3.0. See [LICENSE](LICENSE) for the full text.
|
||||
@@ -1,257 +1,247 @@
|
||||
# Xget for Chrome
|
||||
# Xget Now
|
||||
|
||||
A powerful Chrome extension that accelerates downloads from GitHub, GitLab, and Hugging Face by automatically redirecting them through the [Xget](https://github.com/xixu-me/Xget) proxy service.
|
||||
***[English](README.en.md)***
|
||||
|
||||
## 🚀 Features
|
||||
一个适用于 Chromium 和 Firefox 的跨浏览器扩展,通过将文件下载无缝转交给 [Xget](https://github.com/xixu-me/Xget) 实例来加速下载。
|
||||
|
||||
- **🎯 Automatic Download Acceleration**: Seamlessly redirects downloads through Xget for faster speeds
|
||||
- **🌐 Multi-Platform Support**: Works with GitHub, GitLab, and Hugging Face
|
||||
- **⚙️ Configurable Settings**: Customize your Xget domain and platform preferences
|
||||
- **🔔 Smart Notifications**: Visual feedback when downloads are redirected
|
||||
- **🛡️ Privacy-First**: All processing happens locally in your browser
|
||||
- **🎛️ Per-Platform Control**: Enable/disable acceleration for specific platforms
|
||||
> [!TIP]
|
||||
> 欢迎加入“Xget 开源与 AI 交流群”,一起交流开源项目、AI 应用、工程实践、效率工具和独立开发;如果你也在做产品、写代码、折腾项目或者对开源和 AI 感兴趣,欢迎[**进群**](https://file.xi-xu.me/QR%20Codes/%E7%BE%A4%E4%BA%8C%E7%BB%B4%E7%A0%81.png)认识更多认真做事、乐于分享的朋友。
|
||||
|
||||
## 📦 Installation
|
||||
> [!NOTE]
|
||||
> 想快速开始:安装商店版本,填入你的 Xget 域名,例如 `xget.xi-xu.me`,保持目标平台开关开启即可。
|
||||
|
||||
### Store Availability
|
||||
## 概览
|
||||
|
||||
| Store | Link |
|
||||
|-------|------|
|
||||
| **Chrome Web Store** | [](https://chromewebstore.google.com/detail/ajiejgobfcifcikbahpijopolfjoodgf) |
|
||||
| **Microsoft Edge Add-ons** | [Available in the Microsoft Edge Add-ons](https://microsoftedge.microsoft.com/addons/detail/hbfflpongojnfojbgadppjgnkabkpjea) |
|
||||
Xget Now 会在受支持的平台上识别真实下载链接,把请求重写到你的 Xget 域名,并继续使用浏览器原生下载流程。你不需要改动原始站点,也不需要手动复制链接。
|
||||
|
||||
### Manual Installation
|
||||
## 功能特性
|
||||
|
||||
If you prefer to install the extension manually or the store versions are not available, you can download and install it directly:
|
||||
- 自动下载加速:无缝重定向下载,通过 Xget 获得更快速度。
|
||||
- 可配置设置:自定义你的 Xget 域名和平台偏好。
|
||||
- 智能通知:下载重定向时提供可视化反馈。
|
||||
- 隐私优先:所有处理都在你的浏览器本地进行。
|
||||
- 按平台控制:为特定平台启用或禁用加速。
|
||||
- 跨浏览器支持:同时支持 Chromium 和 Firefox 浏览器。
|
||||
|
||||
#### Option 1: Install from GitHub Releases (Recommended)
|
||||
## 支持的平台
|
||||
|
||||
1. **Download the Extension**
|
||||
- Go to the [Releases page](https://github.com/xixu-me/Xget-for-Chrome/releases)
|
||||
- Download the latest release file:
|
||||
- `chrome-extension.zip` - For Chrome browsers
|
||||
- `edge-extension.zip` - For Microsoft Edge
|
||||
- `extension.crx` - Pre-packaged extension file for all Chromium-based browsers
|
||||
- 代码托管平台:GitHub、GitLab、Gitea、Codeberg、SourceForge、AOSP。
|
||||
- AI / ML 平台:Hugging Face。
|
||||
- 包管理与分发平台:npm、PyPI、PyPI Files、Conda、Conda Community、Maven Central、Apache Downloads、Gradle Plugin Portal、RubyGems、CRAN、CPAN、CTAN、Go Modules、NuGet、Crates.io、Packagist。
|
||||
- 其他平台:arXiv、F-Droid。
|
||||
|
||||
2. **Install in Chrome**
|
||||
- Open Chrome and go to `chrome://extensions/`
|
||||
- Enable "Developer mode" (toggle in top right)
|
||||
- Click "Load unpacked" and select the extracted folder from the ZIP file
|
||||
- Or drag and drop the `.crx` file directly onto the extensions page
|
||||
## 安装
|
||||
|
||||
3. **Install in Microsoft Edge**
|
||||
- Open Microsoft Edge and go to `edge://extensions/`
|
||||
- Enable "Developer mode" (toggle in left sidebar)
|
||||
- Click "Load unpacked" and select the extracted folder from the ZIP file
|
||||
- Or drag and drop the `.crx` file directly onto the extensions page
|
||||
### 商店安装
|
||||
|
||||
4. **Install in other Chromium-based browsers**
|
||||
- Follow similar steps as Chrome or Microsoft Edge, ensuring you enable "Developer mode" first
|
||||
- Drag and drop the `.crx` file directly onto the extensions page
|
||||
- [Chrome 应用商店](https://chromewebstore.google.com/detail/ajiejgobfcifcikbahpijopolfjoodgf?hl=zh-CN)
|
||||
- [Chrome 应用商店镜像](https://chromewebstore.xi-xu.me/detail/ajiejgobfcifcikbahpijopolfjoodgf)
|
||||
- [Firefox 附加组件](https://addons.mozilla.org/zh-CN/firefox/addon/xget-now/)
|
||||
- [Edge 加载项](https://microsoftedge.microsoft.com/addons/detail/jigpfhbegabdenhihpplcjhpfdcgnalc?hl=zh-CN&gl=CN)
|
||||
|
||||
#### Option 2: Install from Source Code
|
||||
### 手动安装
|
||||
|
||||
1. **Clone the Repository**
|
||||
如果你更喜欢手动安装扩展,或者应用商店版本不可用,可以直接下载发布包或从源码加载。
|
||||
|
||||
#### 方式 1:从 GitHub Releases 安装
|
||||
|
||||
1. 前往 [Releases 页面](https://github.com/xixu-me/Xget-Now/releases/latest)。
|
||||
2. 下载对应浏览器的扩展文件:
|
||||
- `Xget-Now_x.x.x.chromium.zip`:适用于所有基于 Chromium 的浏览器,例如 Chrome、Edge、Opera。
|
||||
- `Xget-Now_x.x.x.firefox.xpi`:适用于 Firefox 浏览器。
|
||||
3. 在 Chrome 中安装:
|
||||
- 解压 Chromium 版本 `.zip` 文件。
|
||||
- 打开 `chrome://extensions/`。
|
||||
- 启用右上角的“开发者模式”。
|
||||
- 点击“加载已解压的扩展程序”,选择解压后的文件夹。
|
||||
4. 在 Firefox 中安装:
|
||||
- 打开 `about:debugging`。
|
||||
- 点击“此 Firefox”。
|
||||
- 点击“临时载入附加组件”。
|
||||
- 选择下载的 Firefox `.xpi` 文件,或解压后目录中的 `manifest.json` 文件。
|
||||
5. 在 Edge 中安装:
|
||||
- 解压 Chromium 版本 `.zip` 文件。
|
||||
- 打开 `edge://extensions/`。
|
||||
- 启用左侧边栏中的“开发人员模式”。
|
||||
- 点击“加载解压缩的扩展”,选择解压后的文件夹。
|
||||
6. 在其他基于 Chromium 的浏览器中安装:
|
||||
- 使用 Chromium 版本的扩展包。
|
||||
- 先启用浏览器的“开发者模式”。
|
||||
- 按照对应浏览器的扩展安装指南完成加载。
|
||||
|
||||
#### 方式 2:从源码安装
|
||||
|
||||
1. 克隆仓库:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/xixu-me/Xget-for-Chrome.git
|
||||
cd Xget-for-Chrome
|
||||
git clone https://github.com/xixu-me/Xget-Now.git
|
||||
cd Xget-Now
|
||||
```
|
||||
|
||||
2. **Load in Browser**
|
||||
- Open your browser's extension management page
|
||||
- Enable "Developer mode"
|
||||
- Click "Load unpacked" and select the cloned folder
|
||||
2. 可选构建:
|
||||
|
||||
#### Verify Installation
|
||||
`build.py` 仅依赖 Python 标准库,安装 Python 3.7+ 后即可运行。
|
||||
|
||||
After installation, you should see the Xget extension icon in your browser toolbar. Click it to configure your Xget domain and start accelerating downloads!
|
||||
```bash
|
||||
python build.py --platform chrome
|
||||
python build.py --platform firefox
|
||||
python build.py --platform all
|
||||
python build.py --platform all --package
|
||||
```
|
||||
|
||||
## ⚙️ Setup
|
||||
3. 在浏览器中加载:
|
||||
- Chromium 浏览器:
|
||||
- 打开 `chrome://extensions/` 或 `edge://extensions/`。
|
||||
- 启用“开发者模式”。
|
||||
- 点击“加载已解压的扩展程序”,选择源码根目录,或选择构建后的 `build/chrome/` 目录。
|
||||
- Firefox 浏览器:
|
||||
- 打开 `about:debugging`。
|
||||
- 点击“此 Firefox”。
|
||||
- 点击“临时载入附加组件”。
|
||||
- 选择源码根目录中的 `manifest-firefox.json`,或构建后的 `build/firefox/manifest.json`。
|
||||
|
||||
1. **Configure Xget Domain**
|
||||
- Click the extension icon in your toolbar
|
||||
- Enter your Xget domain (e.g., `xget.xi-xu.me`)
|
||||
- Enable the extension
|
||||
#### 验证安装
|
||||
|
||||
2. **Choose Platforms** (Optional)
|
||||
- Toggle individual platforms on/off as needed
|
||||
- All platforms (GitHub, GitLab, Hugging Face) are enabled by default
|
||||
安装后,你应该能在浏览器工具栏中看到 Xget Now 图标。点击图标,配置你的 Xget 域名并开始加速下载。
|
||||
|
||||
3. **Start Downloading**
|
||||
- Visit any supported platform
|
||||
- Click download links as usual
|
||||
- Downloads will be automatically accelerated through Xget
|
||||
## 上手配置
|
||||
|
||||
## 🎯 Supported Platforms
|
||||
1. 点击工具栏中的扩展图标。
|
||||
2. 输入你的 Xget 域名,例如 `xget.xi-xu.me`。
|
||||
3. 启用扩展,并按需打开或关闭各个平台的开关。
|
||||
4. 访问任何受支持的平台,像往常一样点击下载链接。
|
||||
5. 下载会自动通过 Xget 转发和加速。
|
||||
|
||||
| Platform | Status | URL Pattern | Extension ID |
|
||||
|----------|---------|-------------|--------------|
|
||||
| **GitHub** | ✅ Supported | `github.com/*` | `gh` |
|
||||
| **GitLab** | ✅ Supported | `gitlab.com/*` | `gl` |
|
||||
| **Hugging Face** | ✅ Supported | `huggingface.co/*` | `hf` |
|
||||
## 工作原理
|
||||
|
||||
### Supported Download Types
|
||||
1. 检测:扩展监控受支持平台上的下载链接。
|
||||
2. 转换:URL 自动转换为 Xget 兼容格式。
|
||||
3. 重定向:下载通过你配置的 Xget 域名路由。
|
||||
4. 加速:Xget 的全球 CDN 和优化能力提供更快的下载速度。
|
||||
|
||||
- **GitHub**: Release assets, repository archives, raw files, Git LFS files
|
||||
- **GitLab**: Project archives, release downloads, repository exports
|
||||
- **Hugging Face**: Model files, dataset files, model cards, tokenizers
|
||||
## 运行要求
|
||||
|
||||
## 🔧 How It Works
|
||||
### 浏览器支持
|
||||
|
||||
1. **Detection**: The extension monitors download links on supported platforms
|
||||
2. **Transformation**: URLs are automatically converted to Xget-compatible format
|
||||
3. **Redirection**: Downloads are routed through your configured Xget domain
|
||||
4. **Acceleration**: Xget's global CDN and optimization features deliver faster download speeds
|
||||
- Chrome:88+。
|
||||
- Edge:88+。
|
||||
- Opera:74+。
|
||||
- 其他基于 Chromium 的浏览器:支持 Manifest V3 的版本。
|
||||
- Firefox:109+。
|
||||
- Firefox ESR:109+。
|
||||
|
||||
### URL Transformation Examples
|
||||
### Xget 实例
|
||||
|
||||
```text
|
||||
Original: https://github.com/user/repo/archive/main.zip
|
||||
Xget: https://xget.xi-xu.me/gh/user/repo/archive/main.zip
|
||||
你可以直接使用预部署实例 `xget.xi-xu.me`,也可以参考 [Xget 部署文档](https://github.com/xixu-me/Xget#-%E9%83%A8%E7%BD%B2) 自行部署。
|
||||
|
||||
Original: https://gitlab.com/user/repo/-/archive/main.zip
|
||||
Xget: https://xget.xi-xu.me/gl/user/repo/-/archive/main.zip
|
||||
## 隐私与安全
|
||||
|
||||
Original: https://huggingface.co/user/model/resolve/main/model.bin
|
||||
Xget: https://xget.xi-xu.me/hf/user/model/resolve/main/model.bin
|
||||
```
|
||||
- 本地处理:所有 URL 转换都在你的浏览器中进行。
|
||||
- 无数据收集:扩展不收集或传输个人数据。
|
||||
- 最小权限:仅请求功能所需的必要权限。
|
||||
- 开源可审计:完整源码可供检查。
|
||||
|
||||
### Why Xget Makes Downloads Faster
|
||||
查看 [隐私政策](PRIVACY_POLICY.md) 了解完整细节。
|
||||
|
||||
- **Edge Caching**: Files are cached at Cloudflare's global edge locations
|
||||
- **HTTP/3 Support**: Uses the latest protocol for improved performance
|
||||
- **Intelligent Routing**: Automatically routes through the fastest available server
|
||||
- **Compression**: Automatic content compression reduces transfer times
|
||||
- **Connection Optimization**: Persistent connections and preconnection features
|
||||
## 故障排除
|
||||
|
||||
## 📈 Performance Benefits
|
||||
### 常见问题
|
||||
|
||||
Real-world performance improvements with Xget:
|
||||
**扩展不工作?**
|
||||
|
||||
- **GitHub releases**: Up to 5x faster download speeds
|
||||
- **Large repositories**: Significant improvement for multi-GB archives
|
||||
- **International users**: Dramatically faster downloads through edge caching
|
||||
- **Unstable connections**: Automatic retry mechanisms handle network issues
|
||||
- **Concurrent downloads**: No rate limiting compared to direct platform downloads
|
||||
- 确保你已配置有效的 Xget 域名。
|
||||
- 检查扩展在弹出窗口中是否已启用。
|
||||
- 验证目标平台是否已启用。
|
||||
- 更改设置后尝试刷新页面。
|
||||
|
||||
### Benchmarks
|
||||
**下载没有被重定向?**
|
||||
|
||||
| File Type | Original Speed | With Xget | Improvement |
|
||||
|-----------|----------------|-----------|-------------|
|
||||
| GitHub Release (100MB) | 2.5 MB/s | 12.8 MB/s | **5.1x faster** |
|
||||
| Repository Archive (50MB) | 1.8 MB/s | 8.2 MB/s | **4.6x faster** |
|
||||
| Hugging Face Model (500MB) | 3.2 MB/s | 15.1 MB/s | **4.7x faster** |
|
||||
- 更改设置后刷新页面。
|
||||
- 检查浏览器控制台的错误消息(`F12` -> “控制台”)。
|
||||
- 确保链接是被识别的下载类型。
|
||||
- 验证你点击的是实际下载链接,而不是导航链接。
|
||||
|
||||
> **Note**: Results may vary based on location, network conditions, and server load
|
||||
**Xget 域名问题?**
|
||||
|
||||
## 📋 Requirements
|
||||
- 域名应该不带 `https://` 协议。
|
||||
- 示例:`xget.xi-xu.me`,而不是 `https://xget.xi-xu.me`。
|
||||
- 检查域名在浏览器中是否可访问。
|
||||
- 可以先尝试默认的预部署实例:`xget.xi-xu.me`。
|
||||
|
||||
- **Chrome Browser**: Version 88+ (Manifest V3 support)
|
||||
- **Xget Service**: Access to an Xget instance
|
||||
- Use the public instance: `xget.xi-xu.me`
|
||||
- Or deploy your own: [Xget Repository](https://github.com/xixu-me/Xget) (Recommended)
|
||||
**性能问题?**
|
||||
|
||||
### About Xget Service
|
||||
- 检查你的网络连接。
|
||||
- 如果可用,尝试其他 Xget 域名。
|
||||
- 验证目标平台的服务器是否响应。
|
||||
- 清除浏览器缓存并重新加载扩展。
|
||||
|
||||
Xget is a high-performance, secure proxy service built on Cloudflare Workers that provides:
|
||||
**Firefox 特定问题?**
|
||||
|
||||
- **Global Edge Distribution**: Faster downloads through Cloudflare's global network
|
||||
- **Multi-Platform Support**: Optimized for GitHub, GitLab, and Hugging Face
|
||||
- **Advanced Features**: HTTP/3 support, intelligent caching, automatic retries
|
||||
- **Security**: Comprehensive security headers and content protection
|
||||
- **Reliability**: Built-in timeout protection and performance monitoring
|
||||
- 确保 Firefox 版本为 109+。
|
||||
- 在 `about:debugging` 中检查扩展是否正确加载。
|
||||
- Firefox 可能需要重启浏览器才能完全应用扩展设置。
|
||||
- 检查 Firefox 的“隐私和安全”设置是否阻止了扩展功能。
|
||||
|
||||
Learn more at [xget.xi-xu.me](https://xget.xi-xu.me) or the [Xget repository](https://github.com/xixu-me/Xget).
|
||||
### 调试模式
|
||||
|
||||
## 🔒 Privacy & Security
|
||||
Chromium 浏览器中,打开开发者工具并查看 Console 选项卡。Firefox 中,打开 `about:debugging`,点击“此 Firefox”,找到 Xget Now 扩展并点击“检查”,然后查看控制台消息。
|
||||
|
||||
- **Local Processing**: All URL transformations happen in your browser
|
||||
- **No Data Collection**: The extension doesn't collect or transmit personal data
|
||||
- **Minimal Permissions**: Only requests necessary permissions for functionality
|
||||
- **Open Source**: Full source code available for inspection
|
||||
常见调试消息:
|
||||
|
||||
See our [Privacy Policy](PRIVACY_POLICY.md) for complete details.
|
||||
- 扩展加载:`Xget Now: Content script loaded`
|
||||
- 下载重定向:`Redirecting download: [original] -> [Xget]`
|
||||
- 设置更改:`Settings updated! Click to refresh page`
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
## 常见问题
|
||||
|
||||
### Common Issues
|
||||
### 这个扩展是免费的吗?
|
||||
|
||||
**Extension not working?**
|
||||
是的,扩展完全免费,并且以 GPL-3.0 许可证开源。
|
||||
|
||||
- Ensure you've configured a valid Xget domain
|
||||
- Check that the extension is enabled in the popup
|
||||
- Verify the target platform is enabled
|
||||
- Try refreshing the page after changing settings
|
||||
### 我可以使用自己的 Xget 服务器吗?
|
||||
|
||||
**Downloads not being redirected?**
|
||||
当然可以。你可以使用 [Xget 存储库](https://github.com/xixu-me/Xget) 部署自己的 Xget 实例,并配置扩展使用你的域名。
|
||||
|
||||
- Refresh the page after changing settings
|
||||
- Check browser console for error messages (F12 → Console)
|
||||
- Ensure the link is a recognized download type
|
||||
- Verify you're clicking actual download links, not navigation links
|
||||
### 为什么有些下载仍然通过原始服务器?
|
||||
|
||||
**Xget domain issues?**
|
||||
扩展只重定向被识别的下载链接。导航链接、预览链接和一些动态内容可能不会被重定向。
|
||||
|
||||
- Domain should be without `https://` protocol
|
||||
- Example: `xget.xi-xu.me` (not `https://xget.xi-xu.me`)
|
||||
- Check that the domain is accessible in your browser
|
||||
- Try using the default public instance: `xget.xi-xu.me`
|
||||
### 我的浏览数据会被收集吗?
|
||||
|
||||
**Performance issues?**
|
||||
不会。扩展完全在本地运行,不收集或传输浏览数据。更多说明见 [隐私政策](PRIVACY_POLICY.md)。
|
||||
|
||||
- Check your network connection
|
||||
- Try a different Xget domain if available
|
||||
- Verify the target platform's servers are responsive
|
||||
- Clear browser cache and reload the extension
|
||||
### 下载能快多少?
|
||||
|
||||
### Debug Mode
|
||||
速度提升取决于你的地理位置、网络状况和文件大小。典型改进范围约为 2 倍到 10 倍。
|
||||
|
||||
Enable Chrome Developer Tools and check the Console tab for debug messages:
|
||||
### 我可以为特定网站禁用扩展吗?
|
||||
|
||||
- Extension loading: "Xget for Chrome: Content script loaded"
|
||||
- Download redirection: "Redirecting download: [original] -> [xget]"
|
||||
- Settings changes: "Settings updated! Click to refresh page"
|
||||
可以。使用扩展弹出窗口中的按平台开关,即可为特定平台禁用加速。
|
||||
|
||||
## 📄 License
|
||||
### 为什么 Chrome 和 Firefox 版本不同?
|
||||
|
||||
This project is licensed under the GPL-3.0 License - see the [LICENSE](LICENSE) file for details.
|
||||
由于浏览器架构差异,我们分别做了适配:
|
||||
|
||||
## ❓ Frequently Asked Questions
|
||||
- Chrome 版本:使用 Manifest V3,支持较新的扩展 API 和安全特性。
|
||||
- Firefox 版本:使用 Manifest V2,以确保与 Firefox 扩展系统的兼容性。
|
||||
|
||||
### Is this extension free?
|
||||
两者的核心功能保持一致,差异主要在底层实现。
|
||||
|
||||
Yes, the extension is completely free and open source under the GPL-3.0 license.
|
||||
### 可以同时在 Chrome 和 Firefox 中使用吗?
|
||||
|
||||
### Does this work with private repositories?
|
||||
可以。你可以在多个浏览器中同时安装和使用 Xget Now,它们会独立工作,并分别保存各自的设置。
|
||||
|
||||
The extension works with any downloadable link, but you need appropriate access permissions for private repositories.
|
||||
## 支持与反馈
|
||||
|
||||
### Can I use my own Xget server?
|
||||
如果你觉得这个扩展有用,可以:
|
||||
|
||||
Absolutely! You can deploy your own Xget instance using the [Xget repository](https://github.com/xixu-me/Xget) and configure the extension to use your domain.
|
||||
- 为仓库点个 Star。
|
||||
- 在应用商店留下评价:
|
||||
- [Chrome 应用商店](https://chromewebstore.google.com/detail/ajiejgobfcifcikbahpijopolfjoodgf?hl=zh-CN)
|
||||
- [Firefox 附加组件](https://addons.mozilla.org/zh-CN/firefox/addon/xget-now/)
|
||||
- [Edge 加载项](https://microsoftedge.microsoft.com/addons/detail/jigpfhbegabdenhihpplcjhpfdcgnalc?hl=zh-CN&gl=CN)
|
||||
- 通过 [GitHub Issues](https://github.com/xixu-me/Xget-Now/issues) 报告错误或建议功能。
|
||||
- 分享给其他可能受益于更快下载的人。
|
||||
|
||||
### Why do some downloads still go through the original servers?
|
||||
|
||||
The extension only redirects recognized download links. Navigation links, preview links, and some dynamic content may not be redirected.
|
||||
|
||||
### Is my browsing data collected?
|
||||
|
||||
No, the extension operates entirely locally. No browsing data is collected or transmitted. See our [Privacy Policy](PRIVACY_POLICY.md) for details.
|
||||
|
||||
### How much faster are downloads?
|
||||
|
||||
Speed improvements vary based on your location, network, and file size. Typical improvements range from 2x to 10x faster.
|
||||
|
||||
### Can I disable the extension for specific sites?
|
||||
|
||||
Yes, use the per-platform toggles in the extension popup to disable acceleration for specific platforms.
|
||||
|
||||
## 🌟 Show Your Support
|
||||
|
||||
If you find this extension helpful, please:
|
||||
|
||||
- ⭐ Star this repository
|
||||
- 📝 Leave a review on the [Chrome Web Store](https://chromewebstore.google.com/detail/ajiejgobfcifcikbahpijopolfjoodgf)
|
||||
- 🐛 Report bugs or suggest features via [GitHub Issues](https://github.com/xixu-me/Xget-for-Chrome/issues)
|
||||
- 📢 Share with others who might benefit from faster downloads
|
||||
> [!NOTE]
|
||||
> 本仓库采用 GPL-3.0 许可证。完整条款见 [LICENSE](LICENSE)。
|
||||
+43
-43
@@ -1,66 +1,66 @@
|
||||
# Security Policy
|
||||
# 安全政策
|
||||
|
||||
## Supported Versions
|
||||
## 支持的版本
|
||||
|
||||
We actively support the latest version of Xget for Chrome. Security updates are provided for:
|
||||
我们积极支持 Xget Now 的最新版本。为以下版本提供安全更新:
|
||||
|
||||
| Version | Supported |
|
||||
| 版本 | 支持状态 |
|
||||
| ------- | ------------------ |
|
||||
| 1.x.x | :white_check_mark: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
## 报告漏洞
|
||||
|
||||
If you discover a security vulnerability in Xget for Chrome, please report it responsibly:
|
||||
如果你在 Xget Now 中发现安全漏洞,请负责任地报告:
|
||||
|
||||
### How to Report
|
||||
### 如何报告
|
||||
|
||||
1. **Do not** create a public GitHub issue for security vulnerabilities
|
||||
2. Email the maintainer directly or create a private security advisory
|
||||
3. Include as much detail as possible:
|
||||
- Description of the vulnerability
|
||||
- Steps to reproduce
|
||||
- Potential impact
|
||||
- Suggested fix (if any)
|
||||
1. **不要** 为安全漏洞创建公开的 GitHub 问题
|
||||
2. 直接发邮件给维护者或创建私人安全咨询
|
||||
3. 包含尽可能多的详细信息:
|
||||
- 漏洞描述
|
||||
- 重现步骤
|
||||
- 潜在影响
|
||||
- 建议修复(如果有)
|
||||
|
||||
### What to Expect
|
||||
### 期望结果
|
||||
|
||||
- **Response Time**: We aim to respond within 48 hours
|
||||
- **Investigation**: We will investigate and assess the severity
|
||||
- **Fix Timeline**: Critical vulnerabilities will be patched within 7 days
|
||||
- **Disclosure**: We will coordinate responsible disclosure with you
|
||||
- **响应时间**:我们目标在 48 小时内响应
|
||||
- **调查**:我们将调查并评估严重性
|
||||
- **修复时间表**:关键漏洞将在 7 天内修补
|
||||
- **披露**:我们将与你协调负责任的披露
|
||||
|
||||
### Security Considerations
|
||||
### 安全考虑
|
||||
|
||||
This extension:
|
||||
此扩展:
|
||||
|
||||
- Processes URLs locally in your browser
|
||||
- Does not collect or transmit personal data
|
||||
- Uses Chrome's standard extension APIs
|
||||
- Redirects downloads through user-configured Xget domains
|
||||
- 在你的浏览器中本地处理 URL
|
||||
- 不收集或传输个人数据
|
||||
- 使用 Chrome 的标准扩展 API
|
||||
- 通过用户配置的 Xget 域名重定向下载
|
||||
|
||||
### Scope
|
||||
### 范围
|
||||
|
||||
Security issues we consider in scope:
|
||||
我们认为在范围内的安全问题:
|
||||
|
||||
- Code injection vulnerabilities
|
||||
- Privilege escalation
|
||||
- Data leakage or privacy violations
|
||||
- Malicious URL handling
|
||||
- Extension permission abuse
|
||||
- 代码注入漏洞
|
||||
- 权限提升
|
||||
- 数据泄露或隐私违规
|
||||
- 恶意 URL 处理
|
||||
- 扩展权限滥用
|
||||
|
||||
Out of scope:
|
||||
超出范围:
|
||||
|
||||
- Issues with third-party Xget servers
|
||||
- Browser-level vulnerabilities
|
||||
- Social engineering attacks
|
||||
- 第三方 Xget 服务器的问题
|
||||
- 浏览器级别的漏洞
|
||||
- 社会工程攻击
|
||||
|
||||
## Security Best Practices
|
||||
## 安全最佳实践
|
||||
|
||||
When using this extension:
|
||||
使用此扩展时:
|
||||
|
||||
- Only configure trusted Xget domains
|
||||
- Keep the extension updated
|
||||
- Review permissions when updating
|
||||
- Report suspicious behavior
|
||||
- 仅配置可信的 Xget 域名
|
||||
- 保持扩展更新
|
||||
- 更新时审查权限
|
||||
- 报告可疑行为
|
||||
|
||||
Thank you for helping keep Xget for Chrome secure!
|
||||
感谢你帮助保持 Xget Now 的安全!
|
||||
-151
@@ -1,151 +0,0 @@
|
||||
// Platform configurations matching the worker.js
|
||||
const PLATFORMS = {
|
||||
gh: {
|
||||
base: "https://github.com",
|
||||
name: "GitHub",
|
||||
transform: (path) => path.replace(/^\/gh\//, "/"),
|
||||
pattern: /^https:\/\/github\.com\//,
|
||||
},
|
||||
gl: {
|
||||
base: "https://gitlab.com",
|
||||
name: "GitLab",
|
||||
transform: (path) => path.replace(/^\/gl\//, "/"),
|
||||
pattern: /^https:\/\/gitlab\.com\//,
|
||||
},
|
||||
hf: {
|
||||
base: "https://huggingface.co",
|
||||
name: "Hugging Face",
|
||||
transform: (path) => path.replace(/^\/hf\//, "/"),
|
||||
pattern: /^https:\/\/huggingface\.co\//,
|
||||
},
|
||||
};
|
||||
|
||||
// Default settings
|
||||
const DEFAULT_SETTINGS = {
|
||||
enabled: true,
|
||||
xgetDomain: "xget.xi-xu.me",
|
||||
enabledPlatforms: {
|
||||
gh: true,
|
||||
gl: true,
|
||||
hf: true,
|
||||
},
|
||||
};
|
||||
|
||||
// Initialize extension
|
||||
chrome.runtime.onInstalled.addListener(async () => {
|
||||
console.log("Xget for Chrome installed");
|
||||
|
||||
// Set default settings if not already set
|
||||
const settings = await chrome.storage.sync.get(DEFAULT_SETTINGS);
|
||||
await chrome.storage.sync.set(settings);
|
||||
});
|
||||
|
||||
// Listen for download events
|
||||
chrome.downloads.onDeterminingFilename.addListener((downloadItem, suggest) => {
|
||||
handleDownload(downloadItem, suggest);
|
||||
});
|
||||
|
||||
async function handleDownload(downloadItem, suggest) {
|
||||
try {
|
||||
const settings = await chrome.storage.sync.get(DEFAULT_SETTINGS);
|
||||
|
||||
// Check if extension is enabled and domain is configured
|
||||
if (!settings.enabled || !settings.xgetDomain) {
|
||||
suggest();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = downloadItem.url;
|
||||
const redirectedUrl = transformUrl(url, settings);
|
||||
|
||||
if (redirectedUrl && redirectedUrl !== url) {
|
||||
console.log("Redirecting download:", url, "->", redirectedUrl);
|
||||
|
||||
// Cancel the original download
|
||||
chrome.downloads.cancel(downloadItem.id);
|
||||
|
||||
// Start new download with redirected URL
|
||||
chrome.downloads.download({
|
||||
url: redirectedUrl,
|
||||
filename: downloadItem.filename || undefined,
|
||||
conflictAction: "uniquify",
|
||||
});
|
||||
|
||||
// Show notification via content script
|
||||
try {
|
||||
await chrome.tabs.sendMessage(downloadItem.tabId, {
|
||||
action: "showNotification",
|
||||
message: "Download redirected through Xget",
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Could not send notification to tab");
|
||||
}
|
||||
} else {
|
||||
suggest();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error handling download:", error);
|
||||
suggest();
|
||||
}
|
||||
}
|
||||
|
||||
function transformUrl(url, settings) {
|
||||
try {
|
||||
// Find matching platform
|
||||
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;
|
||||
|
||||
// Transform the URL using Xget domain (add https:// protocol)
|
||||
const xgetUrl = `https://${settings.xgetDomain}/${platformKey}${path}`;
|
||||
return xgetUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return null; // No transformation needed
|
||||
} catch (error) {
|
||||
console.error("Error transforming URL:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for messages from popup/options
|
||||
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||
if (request.action === "getSettings") {
|
||||
chrome.storage.sync.get(DEFAULT_SETTINGS).then(sendResponse);
|
||||
return true;
|
||||
} else if (request.action === "saveSettings") {
|
||||
chrome.storage.sync.set(request.settings).then(async () => {
|
||||
// Notify relevant tabs to refresh
|
||||
try {
|
||||
const tabs = await chrome.tabs.query({
|
||||
url: [
|
||||
"https://github.com/*",
|
||||
"https://gitlab.com/*",
|
||||
"https://huggingface.co/*",
|
||||
],
|
||||
});
|
||||
|
||||
for (const tab of tabs) {
|
||||
try {
|
||||
await chrome.tabs.sendMessage(tab.id, {
|
||||
action: "showNotification",
|
||||
message: "Settings updated! Click to refresh page",
|
||||
showRefreshButton: true,
|
||||
});
|
||||
} catch (error) {
|
||||
// Tab might not have content script loaded, ignore
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Could not notify tabs about settings update");
|
||||
}
|
||||
|
||||
sendResponse({ success: true });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,450 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
构建脚本,用于生成不同浏览器版本的扩展包
|
||||
|
||||
支持平台:
|
||||
- Chromium(Manifest V3)
|
||||
- Firefox(Manifest V2)
|
||||
|
||||
使用方法:
|
||||
python build.py --platform all --package
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def create_build_directory(platform, clean=True):
|
||||
"""
|
||||
创建构建目录
|
||||
|
||||
Args:
|
||||
platform (str): 目标平台名称 (chrome/firefox)
|
||||
clean (bool): 是否清理已存在的目录
|
||||
|
||||
Returns:
|
||||
Path: 构建目录路径
|
||||
"""
|
||||
build_dir = Path(f"build/{platform}")
|
||||
if clean and build_dir.exists():
|
||||
shutil.rmtree(build_dir)
|
||||
build_dir.mkdir(parents=True, exist_ok=True)
|
||||
return build_dir
|
||||
|
||||
|
||||
def copy_common_files(build_dir):
|
||||
"""
|
||||
复制通用文件到构建目录
|
||||
|
||||
Args:
|
||||
build_dir (Path): 目标构建目录
|
||||
"""
|
||||
# 创建 src 目录结构
|
||||
src_dir = build_dir / "src"
|
||||
|
||||
# 复制源代码目录
|
||||
if os.path.exists("src"):
|
||||
shutil.copytree("src", src_dir, dirs_exist_ok=True)
|
||||
|
||||
# 复制根目录的文档文件
|
||||
doc_files = [
|
||||
"LICENSE",
|
||||
"README.md",
|
||||
"PRIVACY_POLICY.md",
|
||||
"SECURITY.md",
|
||||
]
|
||||
|
||||
for file in doc_files:
|
||||
if os.path.exists(file):
|
||||
shutil.copy2(file, build_dir)
|
||||
|
||||
# 复制图标目录
|
||||
if os.path.exists("icons"):
|
||||
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 版本
|
||||
|
||||
Returns:
|
||||
Path: 构建目录路径
|
||||
"""
|
||||
print("构建 Chrome 版本...")
|
||||
build_dir = create_build_directory("chrome")
|
||||
|
||||
# 复制通用文件
|
||||
copy_common_files(build_dir)
|
||||
|
||||
# 复制 Chrome manifest
|
||||
shutil.copy2("manifest.json", build_dir / "manifest.json")
|
||||
|
||||
# 为 Chrome 创建扁平化的文件结构
|
||||
flatten_for_browser(build_dir, "chrome")
|
||||
|
||||
print(f"Chrome 版本构建完成: {build_dir}")
|
||||
return build_dir
|
||||
|
||||
|
||||
def build_firefox():
|
||||
"""
|
||||
构建 Firefox 版本
|
||||
|
||||
Returns:
|
||||
Path: 构建目录路径
|
||||
"""
|
||||
print("构建 Firefox 版本...")
|
||||
build_dir = create_build_directory("firefox")
|
||||
|
||||
# 复制通用文件
|
||||
copy_common_files(build_dir)
|
||||
|
||||
# 复制 Firefox manifest
|
||||
shutil.copy2("manifest-firefox.json", build_dir / "manifest.json")
|
||||
|
||||
# 为 Firefox 创建扁平化的文件结构
|
||||
flatten_for_browser(build_dir, "firefox")
|
||||
|
||||
print(f"Firefox 版本构建完成: {build_dir}")
|
||||
return build_dir
|
||||
|
||||
|
||||
def flatten_for_browser(build_dir, platform):
|
||||
"""
|
||||
将 src 目录中的文件扁平化到构建目录根
|
||||
|
||||
Args:
|
||||
build_dir (Path): 构建目录路径
|
||||
platform (str): 目标平台 (chrome/firefox)
|
||||
"""
|
||||
src_dir = build_dir / "src"
|
||||
|
||||
if not src_dir.exists():
|
||||
print(f"警告: {src_dir} 不存在")
|
||||
return
|
||||
|
||||
# 将文件从 src 子目录移动到根目录
|
||||
# background/index.js -> background.js
|
||||
bg_file = src_dir / "background" / "index.js"
|
||||
if bg_file.exists():
|
||||
shutil.copy2(bg_file, build_dir / "background.js")
|
||||
|
||||
# content/index.js -> content.js
|
||||
content_file = src_dir / "content" / "index.js"
|
||||
if content_file.exists():
|
||||
shutil.copy2(content_file, build_dir / "content.js")
|
||||
|
||||
# popup/index.html -> popup.html
|
||||
popup_html = src_dir / "popup" / "index.html"
|
||||
if popup_html.exists():
|
||||
shutil.copy2(popup_html, build_dir / "popup.html")
|
||||
|
||||
# popup/popup.js -> popup.js
|
||||
popup_js = src_dir / "popup" / "popup.js"
|
||||
if popup_js.exists():
|
||||
shutil.copy2(popup_js, build_dir / "popup.js")
|
||||
|
||||
# shared/platforms.js -> platforms.js
|
||||
platforms_file = src_dir / "shared" / "platforms.js"
|
||||
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():
|
||||
shutil.copy2(detector_file, build_dir / "platform-detector.js")
|
||||
|
||||
# shared/compat/webext-compat.js -> webext-compat.js
|
||||
webext_compat = src_dir / "shared" / "compat" / "webext-compat.js"
|
||||
if webext_compat.exists():
|
||||
shutil.copy2(webext_compat, build_dir / "webext-compat.js")
|
||||
|
||||
# shared/compat/firefox-compat.js -> firefox-compat.js
|
||||
firefox_compat = src_dir / "shared" / "compat" / "firefox-compat.js"
|
||||
if firefox_compat.exists():
|
||||
shutil.copy2(firefox_compat, build_dir / "firefox-compat.js")
|
||||
|
||||
# 删除 src 目录
|
||||
if src_dir.exists():
|
||||
shutil.rmtree(src_dir)
|
||||
|
||||
# 平台特定优化
|
||||
if platform == "chrome":
|
||||
optimize_for_chrome(build_dir)
|
||||
elif platform == "firefox":
|
||||
optimize_for_firefox(build_dir)
|
||||
|
||||
|
||||
def optimize_for_chrome(build_dir):
|
||||
"""
|
||||
为 Chrome 优化文件
|
||||
|
||||
Args:
|
||||
build_dir (Path): 构建目录路径
|
||||
"""
|
||||
# 移除 background.js 中的 importScripts 调用,因为在 Manifest V3 中不需要
|
||||
bg_file = build_dir / "background.js"
|
||||
if bg_file.exists():
|
||||
content = bg_file.read_text(encoding="utf-8")
|
||||
# 注释掉 importScripts 调用
|
||||
content = content.replace(
|
||||
"importScripts('webext-compat.js');",
|
||||
"// importScripts('webext-compat.js'); // Chrome 不需要显式导入",
|
||||
)
|
||||
bg_file.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
def optimize_for_firefox(build_dir):
|
||||
"""
|
||||
为 Firefox 优化文件
|
||||
|
||||
Args:
|
||||
build_dir (Path): 构建目录路径
|
||||
"""
|
||||
# Firefox 特有的优化可以在这里添加
|
||||
pass
|
||||
|
||||
|
||||
def create_package(build_dir, platform):
|
||||
"""
|
||||
创建扩展包
|
||||
|
||||
Args:
|
||||
build_dir (Path): 构建目录路径
|
||||
platform (str): 目标平台名称 (chrome/firefox)
|
||||
|
||||
Returns:
|
||||
Path: 扩展包文件路径
|
||||
"""
|
||||
package_dir = Path("packages")
|
||||
package_dir.mkdir(exist_ok=True)
|
||||
|
||||
# 读取版本信息
|
||||
manifest_file = build_dir / "manifest.json"
|
||||
with open(manifest_file, encoding="utf-8") as f:
|
||||
manifest = json.load(f)
|
||||
|
||||
version = manifest["version"]
|
||||
|
||||
# 根据平台设置文件扩展名和命名
|
||||
if platform == "chrome":
|
||||
file_ext = "zip"
|
||||
platform_name = "chromium"
|
||||
elif platform == "firefox":
|
||||
file_ext = "xpi"
|
||||
platform_name = "firefox"
|
||||
else:
|
||||
file_ext = "zip"
|
||||
platform_name = platform
|
||||
|
||||
package_name = f"Xget-Now_{version}.{platform_name}.{file_ext}"
|
||||
package_path = package_dir / package_name
|
||||
|
||||
# 创建扩展包
|
||||
compression = zipfile.ZIP_DEFLATED
|
||||
with zipfile.ZipFile(package_path, "w", compression) as zipf:
|
||||
for file_path in build_dir.rglob("*"):
|
||||
if file_path.is_file():
|
||||
arcname = file_path.relative_to(build_dir)
|
||||
zipf.write(file_path, arcname)
|
||||
|
||||
print(f"扩展包已创建: {package_path}")
|
||||
return package_path
|
||||
|
||||
|
||||
def create_userscript_package(script_path):
|
||||
package_dir = Path("packages")
|
||||
package_dir.mkdir(exist_ok=True)
|
||||
|
||||
metadata = read_package_metadata()
|
||||
package_name = f"Xget-Now_{metadata['version']}.userscript.user.js"
|
||||
package_path = package_dir / package_name
|
||||
shutil.copy2(script_path, package_path)
|
||||
print(f"用户脚本包已创建: {package_path}")
|
||||
return package_path
|
||||
|
||||
|
||||
def extract_userscript_matches():
|
||||
source = Path("src/shared/download-core.js").read_text(encoding="utf-8")
|
||||
match = re.search(
|
||||
r"const CORE_USERSCRIPT_MATCHES = \[(.*?)\];",
|
||||
source,
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
if not match:
|
||||
raise ValueError("无法从 download-core.js 中提取 CORE_USERSCRIPT_MATCHES")
|
||||
|
||||
raw_items = match.group(1)
|
||||
return re.findall(r'"([^"]+)"', raw_items)
|
||||
|
||||
|
||||
def build_userscript():
|
||||
print("构建 userscript 版本...")
|
||||
build_dir = create_build_directory("userscript")
|
||||
metadata = read_package_metadata()
|
||||
matches = extract_userscript_matches()
|
||||
|
||||
metadata_lines = [
|
||||
"// ==UserScript==",
|
||||
"// @name Xget Now",
|
||||
f"// @namespace {metadata['homepage']}",
|
||||
f"// @version {metadata['version']}",
|
||||
f"// @description {metadata['description']}",
|
||||
f"// @author {metadata['author']}",
|
||||
"// @license GPL-3.0",
|
||||
]
|
||||
|
||||
metadata_lines.extend(f"// @match {pattern}" for pattern in matches)
|
||||
metadata_lines.extend(
|
||||
[
|
||||
"// @grant GM_getValue",
|
||||
"// @grant GM_setValue",
|
||||
"// @grant GM_deleteValue",
|
||||
"// @grant GM_registerMenuCommand",
|
||||
"// @grant GM_xmlhttpRequest",
|
||||
"// @connect *",
|
||||
"// @run-at document-start",
|
||||
"// ==/UserScript==",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
script_parts = [
|
||||
"\n".join(metadata_lines),
|
||||
Path("src/shared/download-core.js").read_text(encoding="utf-8"),
|
||||
"",
|
||||
Path("src/userscript/index.js").read_text(encoding="utf-8"),
|
||||
"",
|
||||
]
|
||||
|
||||
output_path = build_dir / "xget-now.user.js"
|
||||
output_path.write_text("\n".join(script_parts), encoding="utf-8")
|
||||
|
||||
print(f"Userscript 构建完成: {output_path}")
|
||||
return output_path
|
||||
|
||||
|
||||
def validate_manifest(manifest_path, platform):
|
||||
"""
|
||||
验证 manifest 文件格式和内容
|
||||
|
||||
Args:
|
||||
manifest_path (str): manifest 文件路径
|
||||
platform (str): 目标平台名称 (chrome/firefox)
|
||||
|
||||
Returns:
|
||||
bool: 验证是否通过
|
||||
"""
|
||||
try:
|
||||
with open(manifest_path, encoding="utf-8") as f:
|
||||
manifest = json.load(f)
|
||||
|
||||
if platform == "chrome":
|
||||
# 验证 Manifest V3 要求
|
||||
assert manifest.get("manifest_version") == 3, "Chrome 需要 Manifest V3"
|
||||
assert "action" in manifest, "Chrome 需要 action 字段"
|
||||
assert "service_worker" in manifest.get("background", {}), (
|
||||
"Chrome 需要 service_worker"
|
||||
)
|
||||
|
||||
elif platform == "firefox":
|
||||
# 验证 Manifest V2 要求
|
||||
assert manifest.get("manifest_version") == 2, "Firefox 需要 Manifest V2"
|
||||
assert "browser_action" in manifest, "Firefox 需要 browser_action 字段"
|
||||
assert (
|
||||
"applications" in manifest or "browser_specific_settings" in manifest
|
||||
), "Firefox 需要应用ID"
|
||||
|
||||
print(f"[OK] {platform} manifest 验证通过")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"[FAIL] {platform} manifest 验证失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="构建 Xget Now 扩展")
|
||||
parser.add_argument(
|
||||
"--platform",
|
||||
choices=["chrome", "firefox", "userscript", "all"],
|
||||
default="all",
|
||||
help="要构建的平台",
|
||||
)
|
||||
parser.add_argument("--package", action="store_true", help="创建扩展包")
|
||||
parser.add_argument(
|
||||
"--clean", action="store_true", default=True, help="清理构建目录"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
platforms = []
|
||||
if args.platform == "all":
|
||||
platforms = ["chrome", "firefox", "userscript"]
|
||||
else:
|
||||
platforms = [args.platform]
|
||||
|
||||
build_results = []
|
||||
|
||||
for platform in platforms:
|
||||
try:
|
||||
if platform == "chrome":
|
||||
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"
|
||||
if not validate_manifest(manifest_path, platform):
|
||||
continue
|
||||
|
||||
build_results.append((platform, build_dir))
|
||||
|
||||
# 创建扩展包
|
||||
if args.package:
|
||||
create_package(build_dir, platform)
|
||||
|
||||
except Exception as e:
|
||||
print(f"构建 {platform} 时出错: {e}")
|
||||
|
||||
# 总结
|
||||
print("\n构建总结:")
|
||||
for platform, build_dir in build_results:
|
||||
print(f"[OK] {platform}: {build_dir}")
|
||||
|
||||
if args.package:
|
||||
print("\n构建包位于 packages/ 目录")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
-387
@@ -1,387 +0,0 @@
|
||||
// Content script to inject download interception on supported platforms
|
||||
|
||||
// Platform detection and URL transformation
|
||||
const PLATFORMS = {
|
||||
gh: {
|
||||
base: "https://github.com",
|
||||
name: "GitHub",
|
||||
pattern: /^https:\/\/github\.com\//,
|
||||
},
|
||||
gl: {
|
||||
base: "https://gitlab.com",
|
||||
name: "GitLab",
|
||||
pattern: /^https:\/\/gitlab\.com\//,
|
||||
},
|
||||
hf: {
|
||||
base: "https://huggingface.co",
|
||||
name: "Hugging Face",
|
||||
pattern: /^https:\/\/huggingface\.co\//,
|
||||
},
|
||||
};
|
||||
|
||||
// Initialize content script
|
||||
(async function () {
|
||||
console.log("Xget for Chrome: Content script loaded");
|
||||
|
||||
// Listen for messages from background script
|
||||
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||
if (request.action === "showNotification") {
|
||||
showNotification(request.message, request.showRefreshButton);
|
||||
sendResponse({ success: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Check if extension is enabled and configured
|
||||
const settings = await getSettings();
|
||||
if (!settings.enabled || !settings.xgetDomain) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find current platform
|
||||
const currentPlatform = detectPlatform(window.location.href);
|
||||
if (!currentPlatform || !settings.enabledPlatforms[currentPlatform]) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add download interception
|
||||
interceptDownloadLinks();
|
||||
|
||||
// Monitor for dynamically added content
|
||||
observePageChanges();
|
||||
})();
|
||||
|
||||
async function getSettings() {
|
||||
try {
|
||||
return await new Promise((resolve) => {
|
||||
chrome.runtime.sendMessage({ action: "getSettings" }, resolve);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error getting settings:", 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() {
|
||||
// Intercept clicks on download links
|
||||
document.addEventListener(
|
||||
"click",
|
||||
async (event) => {
|
||||
const link = event.target.closest("a");
|
||||
if (!link || !link.href) return;
|
||||
|
||||
// Check if this is a download link
|
||||
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();
|
||||
|
||||
// First check: explicit download attributes
|
||||
if (link.download || link.hasAttribute("download")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Second check: file extensions that indicate downloadable files
|
||||
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",
|
||||
];
|
||||
|
||||
// Check if URL ends with a file extension
|
||||
if (fileExtensions.some((ext) => pathname.endsWith(ext))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Third check: GitHub-specific patterns
|
||||
const allowedGitHubHosts = ["github.com"];
|
||||
try {
|
||||
const parsedUrl = new URL(href);
|
||||
if (allowedGitHubHosts.includes(parsedUrl.host)) {
|
||||
// GitHub release asset download URLs follow pattern: /releases/download/
|
||||
if (pathname.includes("/releases/download/")) {
|
||||
return true;
|
||||
}
|
||||
// GitHub archive download URLs
|
||||
if (
|
||||
pathname.includes("/archive/") &&
|
||||
(pathname.endsWith(".zip") || pathname.endsWith(".tar.gz"))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// GitHub raw file URLs - NEW: support for raw file links
|
||||
if (pathname.includes("/raw/")) {
|
||||
return true;
|
||||
}
|
||||
// Exclude navigation to releases page (just /releases or /releases/)
|
||||
if (pathname.endsWith("/releases") || pathname.endsWith("/releases/")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Invalid URL:", href, e);
|
||||
}
|
||||
|
||||
// Fourth check: GitLab-specific patterns
|
||||
const allowedGitLabHosts = ["gitlab.com"];
|
||||
try {
|
||||
const parsedUrl = new URL(href);
|
||||
if (allowedGitLabHosts.includes(parsedUrl.host)) {
|
||||
// GitLab archive downloads
|
||||
if (pathname.includes("/-/archive/")) {
|
||||
return true;
|
||||
}
|
||||
// GitLab release downloads
|
||||
if (
|
||||
pathname.includes("/-/releases/") &&
|
||||
pathname.includes("/downloads/")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Invalid URL:", href, e);
|
||||
}
|
||||
|
||||
// Fifth check: Hugging Face file downloads
|
||||
const allowedHuggingFaceHosts = ["huggingface.co"];
|
||||
try {
|
||||
const parsedUrl = new URL(href);
|
||||
if (allowedHuggingFaceHosts.includes(parsedUrl.host)) {
|
||||
// HF file download URLs contain /resolve/
|
||||
if (pathname.includes("/resolve/")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Invalid URL:", href, e);
|
||||
}
|
||||
|
||||
// Seventh check: explicit download text indicators (be more specific)
|
||||
const downloadTextIndicators = ["download", "download file", "get file"];
|
||||
const linkText = link.textContent.toLowerCase().trim();
|
||||
if (
|
||||
downloadTextIndicators.some(
|
||||
(indicator) =>
|
||||
linkText === indicator || linkText.startsWith(indicator + " ")
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Default: not a download link
|
||||
return false;
|
||||
}
|
||||
|
||||
async function handleDownloadLink(url) {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
if (!settings.enabled || !settings.xgetDomain) return;
|
||||
|
||||
const transformedUrl = transformUrl(url, settings);
|
||||
if (transformedUrl) {
|
||||
// Show notification
|
||||
showNotification(`Download redirected through Xget`);
|
||||
|
||||
// Trigger download
|
||||
window.location.href = transformedUrl;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error handling download:", 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("Error transforming URL:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function observePageChanges() {
|
||||
const observer = new MutationObserver(() => {
|
||||
// Monitor for page changes (no additional actions needed)
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
||||
function showNotification(message, showRefreshButton = false) {
|
||||
try {
|
||||
// Remove any existing notifications first
|
||||
const existingNotifications =
|
||||
document.querySelectorAll(".xget-notification");
|
||||
existingNotifications.forEach((notification) => notification.remove());
|
||||
|
||||
// Create a simple notification
|
||||
const notification = document.createElement("div");
|
||||
notification.className = "xget-notification";
|
||||
|
||||
// Create message container
|
||||
const messageDiv = document.createElement("div");
|
||||
messageDiv.textContent = message;
|
||||
messageDiv.style.marginBottom = showRefreshButton ? "8px" : "0";
|
||||
notification.appendChild(messageDiv);
|
||||
|
||||
// Add refresh button if needed
|
||||
if (showRefreshButton) {
|
||||
const refreshButton = document.createElement("button");
|
||||
refreshButton.textContent = "🔄 Refresh Page";
|
||||
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;
|
||||
`;
|
||||
|
||||
// Add animation styles if not already present
|
||||
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);
|
||||
|
||||
// Remove after longer time if has refresh button, shorter if not
|
||||
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 showing notification:", error);
|
||||
// Fallback to console log if DOM manipulation fails
|
||||
console.log("Xget notification:", message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
开发和测试脚本
|
||||
|
||||
提供功能:
|
||||
- 启动开发服务器
|
||||
- 文件变化监控
|
||||
- 代码质量检查
|
||||
- 自动化测试
|
||||
|
||||
使用方法:
|
||||
python dev.py test # 运行测试
|
||||
python dev.py lint # 代码检查
|
||||
python dev.py watch # 监控文件变化
|
||||
python dev.py serve # 启动开发服务器
|
||||
"""
|
||||
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import socketserver
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
|
||||
def start_dev_server(port=8000):
|
||||
"""
|
||||
启动开发服务器用于测试扩展
|
||||
|
||||
Args:
|
||||
port (int): 服务器端口号,默认 8000
|
||||
"""
|
||||
|
||||
class Handler(http.server.SimpleHTTPRequestHandler):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, directory=os.getcwd(), **kwargs)
|
||||
|
||||
def log_message(self, format, *args):
|
||||
# 简化日志输出格式
|
||||
print(f"[DEV] {format % args}")
|
||||
|
||||
with socketserver.TCPServer(("", port), Handler) as httpd:
|
||||
print(f"开发服务器启动在 http://localhost:{port}")
|
||||
print("按 Ctrl+C 停止服务器")
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\n开发服务器已停止")
|
||||
|
||||
|
||||
def watch_files():
|
||||
"""
|
||||
监控文件变化并自动重新构建
|
||||
|
||||
监控的文件包括:
|
||||
- manifest.json / manifest-firefox.json
|
||||
- JavaScript 文件 (src/background/index.js, src/content/index.js, src/popup/popup.js)
|
||||
- HTML 文件 (src/popup/index.html)
|
||||
- 兼容层文件 (src/shared/compat/*.js)
|
||||
- 平台检测文件 (src/shared/platform-detector.js, src/shared/platforms.js)
|
||||
"""
|
||||
import os
|
||||
|
||||
watch_files = [
|
||||
"manifest.json",
|
||||
"manifest-firefox.json",
|
||||
"src/background/index.js",
|
||||
"src/content/index.js",
|
||||
"src/popup/popup.js",
|
||||
"src/popup/index.html",
|
||||
"src/shared/download-core.js",
|
||||
"src/userscript/index.js",
|
||||
"src/shared/compat/webext-compat.js",
|
||||
"src/shared/compat/firefox-compat.js",
|
||||
"src/shared/platform-detector.js",
|
||||
"src/shared/platforms.js",
|
||||
]
|
||||
|
||||
file_times = {}
|
||||
for file in watch_files:
|
||||
if os.path.exists(file):
|
||||
file_times[file] = os.path.getmtime(file)
|
||||
|
||||
print("开始监控文件变化...")
|
||||
print(f"监控文件: {', '.join(watch_files)}")
|
||||
|
||||
while True:
|
||||
try:
|
||||
changed = False
|
||||
for file in watch_files:
|
||||
if os.path.exists(file):
|
||||
current_time = os.path.getmtime(file)
|
||||
if file not in file_times or current_time > file_times[file]:
|
||||
print(f"检测到文件变化: {file}")
|
||||
file_times[file] = current_time
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
print("重新构建...")
|
||||
result = subprocess.run(
|
||||
["python", "build.py", "--platform", "all"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
print("[OK] 构建成功")
|
||||
else:
|
||||
print(f"[FAIL] 构建失败: {result.stderr}")
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n停止文件监控")
|
||||
break
|
||||
|
||||
|
||||
def run_tests():
|
||||
"""
|
||||
运行基础测试套件
|
||||
|
||||
测试内容:
|
||||
- 必需文件存在性检查
|
||||
- Manifest 文件格式和内容验证
|
||||
- 平台特定配置检查
|
||||
|
||||
Returns:
|
||||
bool: 所有测试是否通过
|
||||
"""
|
||||
print("运行基础测试...")
|
||||
|
||||
def test_manifest(file_path, platform):
|
||||
"""
|
||||
测试 manifest 文件
|
||||
|
||||
Args:
|
||||
file_path (str): manifest 文件路径
|
||||
platform (str): 目标平台 (chrome/firefox)
|
||||
|
||||
Returns:
|
||||
bool: 测试是否通过
|
||||
"""
|
||||
try:
|
||||
with open(file_path, encoding="utf-8") as f:
|
||||
manifest = json.load(f)
|
||||
|
||||
# 基础字段检查
|
||||
required_fields = ["name", "version", "manifest_version"]
|
||||
for field in required_fields:
|
||||
assert field in manifest, f"缺少必需字段: {field}"
|
||||
|
||||
# 平台特定检查
|
||||
if platform == "chrome":
|
||||
assert manifest["manifest_version"] == 3, "Chrome 需要 Manifest V3"
|
||||
assert "action" in manifest, "Chrome 需要 action 字段"
|
||||
elif platform == "firefox":
|
||||
assert manifest["manifest_version"] == 2, "Firefox 需要 Manifest V2"
|
||||
assert "browser_action" in manifest, "Firefox 需要 browser_action 字段"
|
||||
|
||||
print(f"[OK] {platform} manifest 测试通过")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"[FAIL] {platform} manifest 测试失败: {e}")
|
||||
return False
|
||||
|
||||
# 测试文件存在性
|
||||
def test_files_exist():
|
||||
required_files = [
|
||||
"manifest.json",
|
||||
"manifest-firefox.json",
|
||||
"src/background/index.js",
|
||||
"src/content/index.js",
|
||||
"src/popup/popup.js",
|
||||
"src/popup/index.html",
|
||||
"src/shared/download-core.js",
|
||||
"src/userscript/index.js",
|
||||
"src/shared/compat/webext-compat.js",
|
||||
"src/shared/compat/firefox-compat.js",
|
||||
"src/shared/platform-detector.js",
|
||||
"src/shared/platforms.js",
|
||||
]
|
||||
|
||||
all_exist = True
|
||||
for file in required_files:
|
||||
if os.path.exists(file):
|
||||
print(f"[OK] {file} 存在")
|
||||
else:
|
||||
print(f"[FAIL] {file} 不存在")
|
||||
all_exist = False
|
||||
|
||||
return all_exist
|
||||
|
||||
# 运行所有测试
|
||||
results = []
|
||||
results.append(test_files_exist())
|
||||
results.append(test_manifest("manifest.json", "chrome"))
|
||||
results.append(test_manifest("manifest-firefox.json", "firefox"))
|
||||
|
||||
# 总结
|
||||
passed = sum(results)
|
||||
total = len(results)
|
||||
print(f"\n测试结果: {passed}/{total} 通过")
|
||||
|
||||
return passed == total
|
||||
|
||||
|
||||
def lint_code():
|
||||
"""
|
||||
执行代码质量检查
|
||||
|
||||
检查内容:
|
||||
- API 使用规范性 (优先使用 webext 兼容层)
|
||||
- 错误处理最佳实践
|
||||
- 接口导出规范性
|
||||
|
||||
特殊处理:
|
||||
- webext-compat.js: 允许直接使用原生 API
|
||||
- firefox-compat.js: 允许直接使用原生 API
|
||||
- platform-detector.js: 允许直接使用原生 API
|
||||
"""
|
||||
print("运行代码检查...")
|
||||
|
||||
js_files = [
|
||||
"src/background/index.js",
|
||||
"src/content/index.js",
|
||||
"src/popup/popup.js",
|
||||
"src/shared/download-core.js",
|
||||
"src/userscript/index.js",
|
||||
"src/shared/compat/webext-compat.js",
|
||||
"src/shared/compat/firefox-compat.js",
|
||||
"src/shared/platform-detector.js",
|
||||
"src/shared/platforms.js",
|
||||
]
|
||||
|
||||
for file in js_files:
|
||||
if os.path.exists(file):
|
||||
print(f"检查 {file}...")
|
||||
|
||||
# 读取文件内容进行检查
|
||||
with open(file, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# 检查常见问题
|
||||
issues = []
|
||||
|
||||
# 底层文件允许直接使用原生 API
|
||||
bottom_layer_files = [
|
||||
"src/shared/compat/webext-compat.js",
|
||||
"src/shared/compat/firefox-compat.js",
|
||||
"src/shared/platform-detector.js",
|
||||
]
|
||||
|
||||
if file not in bottom_layer_files:
|
||||
if "chrome." in content and "webext." not in content:
|
||||
issues.append("发现直接使用 chrome API,应使用 webext 兼容层")
|
||||
elif "webext-compat.js" in file:
|
||||
# 对于 webext-compat.js,检查是否正确导出了 webext 接口
|
||||
if "window.webext" not in content and "module.exports" not in content:
|
||||
issues.append("兼容层未正确导出 webext 接口")
|
||||
elif "firefox-compat.js" in file:
|
||||
# 对于 firefox-compat.js,检查 Firefox 特定的兼容性处理
|
||||
if "browser." not in content and "chrome." not in content:
|
||||
issues.append("Firefox 兼容层应处理 browser/chrome API")
|
||||
elif "platform-detector.js" in file:
|
||||
# 对于 platform-detector.js,检查是否正确导出了平台检测接口
|
||||
if "platformDetector" not in content:
|
||||
issues.append("平台检测器未正确定义 platformDetector 接口")
|
||||
|
||||
# 检查错误处理
|
||||
if "console.log(" in content and "console.error(" not in content:
|
||||
# 对于底层文件和平台定义文件,console.warn 也是可以接受的
|
||||
if (
|
||||
file in bottom_layer_files or "platforms.js" in file
|
||||
) and "console.warn(" in content:
|
||||
pass # 底层文件使用 console.warn 是合理的
|
||||
else:
|
||||
issues.append("建议使用 console.error 处理错误")
|
||||
|
||||
if issues:
|
||||
print(f" [WARN] {file} 发现问题:")
|
||||
for issue in issues:
|
||||
print(f" - {issue}")
|
||||
else:
|
||||
print(f" [OK] {file} 无明显问题")
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Xget Now 开发工具")
|
||||
parser.add_argument(
|
||||
"command", choices=["test", "lint", "watch", "serve"], help="要执行的命令"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port", type=int, default=8000, help="开发服务器端口 (默认: 8000)"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "test":
|
||||
success = run_tests()
|
||||
exit(0 if success else 1)
|
||||
elif args.command == "lint":
|
||||
lint_code()
|
||||
elif args.command == "watch":
|
||||
watch_files()
|
||||
elif args.command == "serve":
|
||||
start_dev_server(args.port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"background": {
|
||||
"persistent": true,
|
||||
"scripts": [
|
||||
"webext-compat.js",
|
||||
"download-core.js",
|
||||
"background.js"
|
||||
]
|
||||
},
|
||||
"browser_action": {
|
||||
"default_icon": {
|
||||
"16": "icons/icon16.png",
|
||||
"32": "icons/icon32.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
},
|
||||
"default_popup": "popup.html",
|
||||
"default_title": "Xget Now"
|
||||
},
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "i@xi-xu.me",
|
||||
"strict_min_version": "109.0"
|
||||
}
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"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/*",
|
||||
"https://sourceforge.net/*",
|
||||
"https://android.googlesource.com/*",
|
||||
"https://huggingface.co/*",
|
||||
"https://registry.npmjs.org/*",
|
||||
"https://pypi.org/*",
|
||||
"https://files.pythonhosted.org/*",
|
||||
"https://repo.anaconda.com/*",
|
||||
"https://conda.anaconda.org/*",
|
||||
"https://repo1.maven.org/*",
|
||||
"https://downloads.apache.org/*",
|
||||
"https://plugins.gradle.org/*",
|
||||
"https://rubygems.org/*",
|
||||
"https://cran.r-project.org/*",
|
||||
"https://www.cpan.org/*",
|
||||
"https://tug.ctan.org/*",
|
||||
"https://proxy.golang.org/*",
|
||||
"https://api.nuget.org/*",
|
||||
"https://crates.io/*",
|
||||
"https://repo.packagist.org/*",
|
||||
"https://arxiv.org/*",
|
||||
"https://f-droid.org/*"
|
||||
],
|
||||
"run_at": "document_end"
|
||||
}
|
||||
],
|
||||
"description": "为 Firefox 浏览器提供的 Xget 下载加速扩展,支持 GitHub、GitLab、npm、PyPI 等众多平台的文件下载加速",
|
||||
"icons": {
|
||||
"16": "icons/icon16.png",
|
||||
"32": "icons/icon32.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
},
|
||||
"manifest_version": 2,
|
||||
"name": "Xget Now",
|
||||
"permissions": [
|
||||
"downloads",
|
||||
"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/*",
|
||||
"https://sourceforge.net/*",
|
||||
"https://android.googlesource.com/*",
|
||||
"https://huggingface.co/*",
|
||||
"https://registry.npmjs.org/*",
|
||||
"https://pypi.org/*",
|
||||
"https://files.pythonhosted.org/*",
|
||||
"https://repo.anaconda.com/*",
|
||||
"https://conda.anaconda.org/*",
|
||||
"https://repo1.maven.org/*",
|
||||
"https://downloads.apache.org/*",
|
||||
"https://plugins.gradle.org/*",
|
||||
"https://rubygems.org/*",
|
||||
"https://cran.r-project.org/*",
|
||||
"https://www.cpan.org/*",
|
||||
"https://tug.ctan.org/*",
|
||||
"https://proxy.golang.org/*",
|
||||
"https://api.nuget.org/*",
|
||||
"https://crates.io/*",
|
||||
"https://repo.packagist.org/*",
|
||||
"https://arxiv.org/*",
|
||||
"https://f-droid.org/*"
|
||||
],
|
||||
"version": "2.1.0",
|
||||
"web_accessible_resources": [
|
||||
"icons/*"
|
||||
]
|
||||
}
|
||||
+58
-6
@@ -7,7 +7,7 @@
|
||||
"128": "icons/icon128.png"
|
||||
},
|
||||
"default_popup": "popup.html",
|
||||
"default_title": "Xget for Chrome"
|
||||
"default_title": "Xget Now"
|
||||
},
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
@@ -15,21 +15,73 @@
|
||||
"content_scripts": [
|
||||
{
|
||||
"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://huggingface.co/*"
|
||||
"https://gitea.com/*",
|
||||
"https://codeberg.org/*",
|
||||
"https://sourceforge.net/*",
|
||||
"https://android.googlesource.com/*",
|
||||
"https://huggingface.co/*",
|
||||
"https://registry.npmjs.org/*",
|
||||
"https://pypi.org/*",
|
||||
"https://files.pythonhosted.org/*",
|
||||
"https://repo.anaconda.com/*",
|
||||
"https://conda.anaconda.org/*",
|
||||
"https://repo1.maven.org/*",
|
||||
"https://downloads.apache.org/*",
|
||||
"https://plugins.gradle.org/*",
|
||||
"https://rubygems.org/*",
|
||||
"https://cran.r-project.org/*",
|
||||
"https://www.cpan.org/*",
|
||||
"https://tug.ctan.org/*",
|
||||
"https://proxy.golang.org/*",
|
||||
"https://api.nuget.org/*",
|
||||
"https://crates.io/*",
|
||||
"https://repo.packagist.org/*",
|
||||
"https://arxiv.org/*",
|
||||
"https://f-droid.org/*"
|
||||
],
|
||||
"run_at": "document_end"
|
||||
}
|
||||
],
|
||||
"description": "Download accelerator that redirects downloads through Xget for faster speeds",
|
||||
"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://huggingface.co/*"
|
||||
"https://gitea.com/*",
|
||||
"https://codeberg.org/*",
|
||||
"https://sourceforge.net/*",
|
||||
"https://android.googlesource.com/*",
|
||||
"https://huggingface.co/*",
|
||||
"https://registry.npmjs.org/*",
|
||||
"https://pypi.org/*",
|
||||
"https://files.pythonhosted.org/*",
|
||||
"https://repo.anaconda.com/*",
|
||||
"https://conda.anaconda.org/*",
|
||||
"https://repo1.maven.org/*",
|
||||
"https://downloads.apache.org/*",
|
||||
"https://plugins.gradle.org/*",
|
||||
"https://rubygems.org/*",
|
||||
"https://cran.r-project.org/*",
|
||||
"https://www.cpan.org/*",
|
||||
"https://tug.ctan.org/*",
|
||||
"https://proxy.golang.org/*",
|
||||
"https://api.nuget.org/*",
|
||||
"https://crates.io/*",
|
||||
"https://repo.packagist.org/*",
|
||||
"https://arxiv.org/*",
|
||||
"https://f-droid.org/*"
|
||||
],
|
||||
"icons": {
|
||||
"16": "icons/icon16.png",
|
||||
@@ -38,13 +90,13 @@
|
||||
"128": "icons/icon128.png"
|
||||
},
|
||||
"manifest_version": 3,
|
||||
"name": "Xget for Chrome",
|
||||
"name": "Xget Now",
|
||||
"permissions": [
|
||||
"downloads",
|
||||
"storage",
|
||||
"activeTab"
|
||||
],
|
||||
"version": "1.1.0",
|
||||
"version": "2.1.0",
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"matches": [
|
||||
|
||||
Generated
+66
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"name": "xget-now",
|
||||
"version": "2.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "xget-now",
|
||||
"version": "2.1.0",
|
||||
"license": "GPL-3.0",
|
||||
"devDependencies": {
|
||||
"playwright": "^1.62.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.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
-22
@@ -1,39 +1,70 @@
|
||||
{
|
||||
"author": "Xi Xu",
|
||||
"browser": "chrome",
|
||||
"bugs": {
|
||||
"url": "https://github.com/xixu-me/Xget-for-Chrome/issues"
|
||||
"url": "https://github.com/xixu-me/Xget-Now/issues"
|
||||
},
|
||||
"dependencies": {},
|
||||
"description": "Chrome extension that accelerates downloads by redirecting them through Xget",
|
||||
"devDependencies": {},
|
||||
"homepage": "https://github.com/xixu-me/Xget-for-Chrome#readme",
|
||||
"description": "为 Chromium 浏览器提供的 Xget 下载加速扩展,支持 GitHub、GitLab、npm、PyPI 等众多平台的文件下载加速",
|
||||
"engines": {
|
||||
"chrome": ">=88"
|
||||
},
|
||||
"homepage": "https://github.com/xixu-me/Xget-Now",
|
||||
"keywords": [
|
||||
"chrome-extension",
|
||||
"download-accelerator",
|
||||
"xget",
|
||||
"proxy",
|
||||
"github",
|
||||
"gitlab",
|
||||
"huggingface"
|
||||
"npm",
|
||||
"pypi",
|
||||
"xget",
|
||||
"download-proxy"
|
||||
],
|
||||
"license": "GPL-3.0",
|
||||
"main": "background.js",
|
||||
"manifest_version": 3,
|
||||
"name": "xget-for-chrome",
|
||||
"manifest": {
|
||||
"platforms": [
|
||||
"github.com",
|
||||
"gitlab.com",
|
||||
"gitea.com",
|
||||
"codeberg.org",
|
||||
"sourceforge.net",
|
||||
"android.googlesource.com",
|
||||
"huggingface.co",
|
||||
"registry.npmjs.org",
|
||||
"pypi.org",
|
||||
"files.pythonhosted.org",
|
||||
"repo.anaconda.com",
|
||||
"conda.anaconda.org",
|
||||
"repo1.maven.org",
|
||||
"downloads.apache.org",
|
||||
"plugins.gradle.org",
|
||||
"rubygems.org",
|
||||
"cran.r-project.org",
|
||||
"www.cpan.org",
|
||||
"tug.ctan.org",
|
||||
"proxy.golang.org",
|
||||
"api.nuget.org",
|
||||
"crates.io",
|
||||
"repo.packagist.org",
|
||||
"arxiv.org",
|
||||
"f-droid.org"
|
||||
],
|
||||
"version": 3
|
||||
},
|
||||
"name": "xget-now",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/xixu-me/Xget-for-Chrome.git"
|
||||
"url": "https://github.com/xixu-me/Xget-Now.git"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "echo 'No build process required - extension runs from source'",
|
||||
"clean": "rm -f *.zip",
|
||||
"dev": "echo 'Open chrome://extensions/ and load unpacked extension'",
|
||||
"lint": "echo 'No linting configured - consider adding ESLint for JavaScript files'",
|
||||
"package": "zip -r extension.zip . -x '*.git*' 'node_modules/*' '.github/*' '*.md' 'LICENSE' '*.zip'",
|
||||
"release": "npm run validate && npm run package",
|
||||
"test": "echo 'Load extension in Chrome Developer Mode to test'",
|
||||
"validate": "node -e \"JSON.parse(require('fs').readFileSync('manifest.json', 'utf8')); console.log('✅ manifest.json is valid')\""
|
||||
"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": "1.1.0"
|
||||
}
|
||||
"version": "2.1.0",
|
||||
"devDependencies": {
|
||||
"playwright": "^1.62.1"
|
||||
}
|
||||
}
|
||||
-302
@@ -1,302 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Xget for Chrome</title>
|
||||
<style>
|
||||
body {
|
||||
width: 380px;
|
||||
min-height: 280px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header .subtitle {
|
||||
margin: 5px 0 0 0;
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.setting-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.setting-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
transition: 0.3s;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: 0.3s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background-color: #4caf50;
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(26px);
|
||||
}
|
||||
|
||||
.domain-input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.domain-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.domain-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.platforms {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.platform-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: 15px;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status.success {
|
||||
background: rgba(76, 175, 80, 0.2);
|
||||
border: 1px solid rgba(76, 175, 80, 0.3);
|
||||
}
|
||||
|
||||
.status.error {
|
||||
background: rgba(244, 67, 54, 0.2);
|
||||
border: 1px solid rgba(244, 67, 54, 0.3);
|
||||
}
|
||||
|
||||
.mini-toggle {
|
||||
width: 32px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.mini-toggle .slider {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.mini-toggle .slider:before {
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
left: 2px;
|
||||
bottom: 2px;
|
||||
}
|
||||
|
||||
.mini-toggle input:checked + .slider:before {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.link-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
color: white;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.link-item:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.link-icon {
|
||||
margin-right: 10px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.link-text {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Xget for Chrome</h1>
|
||||
<div class="subtitle">Download Accelerator</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="setting-group">
|
||||
<label class="setting-label">
|
||||
<div
|
||||
style="
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
"
|
||||
>
|
||||
Enable Extension
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="enabledToggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-group">
|
||||
<label class="setting-label" for="domainInput"
|
||||
>Xget Domain (Required)</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="domainInput"
|
||||
class="domain-input"
|
||||
placeholder="your-xget-domain.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="setting-group">
|
||||
<label class="setting-label">Enabled Platforms</label>
|
||||
<div class="platforms">
|
||||
<div class="platform-item">
|
||||
<span>GitHub</span>
|
||||
<label class="toggle-switch mini-toggle">
|
||||
<input type="checkbox" id="ghToggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="platform-item">
|
||||
<span>GitLab</span>
|
||||
<label class="toggle-switch mini-toggle">
|
||||
<input type="checkbox" id="glToggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="platform-item">
|
||||
<span>Hugging Face</span>
|
||||
<label class="toggle-switch mini-toggle">
|
||||
<input type="checkbox" id="hfToggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-group">
|
||||
<div class="links">
|
||||
<a
|
||||
href="https://github.com/xixu-me/Xget"
|
||||
target="_blank"
|
||||
class="link-item"
|
||||
>
|
||||
<span class="link-icon">🚀</span>
|
||||
<span class="link-text">Deploy Your Own Xget Service</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/xixu-me/Xget-for-Chrome"
|
||||
target="_blank"
|
||||
class="link-item"
|
||||
>
|
||||
<span class="link-icon">📖</span>
|
||||
<span class="link-text">Source Code & Documentation</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="status" class="status" style="display: none"></div>
|
||||
</div>
|
||||
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,205 +0,0 @@
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
// Load current settings
|
||||
await loadSettings();
|
||||
|
||||
// Set up event listeners
|
||||
setupEventListeners();
|
||||
});
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const settings = await chrome.runtime.sendMessage({
|
||||
action: "getSettings",
|
||||
});
|
||||
|
||||
// Update UI with current settings
|
||||
const domainValue = settings.xgetDomain || "";
|
||||
document.getElementById("domainInput").value = domainValue;
|
||||
|
||||
// Only enable the toggle if domain is set
|
||||
const enabledValue = settings.enabled && domainValue.trim() !== "";
|
||||
document.getElementById("enabledToggle").checked = enabledValue;
|
||||
|
||||
// Update platform toggles
|
||||
document.getElementById("ghToggle").checked = settings.enabledPlatforms.gh;
|
||||
document.getElementById("glToggle").checked = settings.enabledPlatforms.gl;
|
||||
document.getElementById("hfToggle").checked = settings.enabledPlatforms.hf;
|
||||
|
||||
// Show status if domain is missing
|
||||
if (!domainValue && settings.enabled) {
|
||||
showStatus("Please configure your Xget domain", "error");
|
||||
} else if (domainValue && enabledValue) {
|
||||
showStatus("Extension is active and ready", "success");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading settings:", error);
|
||||
showStatus("Error loading settings", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function setupEventListeners() {
|
||||
// Enable/disable toggle
|
||||
document
|
||||
.getElementById("enabledToggle")
|
||||
.addEventListener("change", handleEnableToggle);
|
||||
|
||||
// Domain input
|
||||
document
|
||||
.getElementById("domainInput")
|
||||
.addEventListener("input", debounce(handleDomainChange, 500));
|
||||
document
|
||||
.getElementById("domainInput")
|
||||
.addEventListener("blur", validateDomain);
|
||||
|
||||
// Platform toggles
|
||||
document.getElementById("ghToggle").addEventListener("change", saveSettings);
|
||||
document.getElementById("glToggle").addEventListener("change", saveSettings);
|
||||
document.getElementById("hfToggle").addEventListener("change", saveSettings);
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
try {
|
||||
const domainValue = document.getElementById("domainInput").value.trim();
|
||||
const enabledValue = document.getElementById("enabledToggle").checked;
|
||||
|
||||
// Prevent enabling if no domain is set
|
||||
if (enabledValue && !domainValue) {
|
||||
document.getElementById("enabledToggle").checked = false;
|
||||
showStatus("Cannot enable extension without a domain", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = {
|
||||
enabled: document.getElementById("enabledToggle").checked,
|
||||
xgetDomain: domainValue,
|
||||
enabledPlatforms: {
|
||||
gh: document.getElementById("ghToggle").checked,
|
||||
gl: document.getElementById("glToggle").checked,
|
||||
hf: document.getElementById("hfToggle").checked,
|
||||
},
|
||||
};
|
||||
|
||||
// Clean up domain URL
|
||||
if (settings.xgetDomain) {
|
||||
settings.xgetDomain = cleanupDomain(settings.xgetDomain);
|
||||
}
|
||||
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
action: "saveSettings",
|
||||
settings: settings,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
// Show appropriate status
|
||||
if (!settings.xgetDomain && settings.enabled) {
|
||||
showStatus("Please configure your Xget domain", "error");
|
||||
} else if (settings.xgetDomain && settings.enabled) {
|
||||
showStatus(
|
||||
"✅ Settings saved! Check page notifications for refresh button",
|
||||
"success"
|
||||
);
|
||||
} else {
|
||||
showStatus(
|
||||
"✅ Settings saved! Check page notifications for refresh button",
|
||||
"success"
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error saving settings:", error);
|
||||
showStatus("Error saving settings", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function validateDomain() {
|
||||
const domain = document.getElementById("domainInput").value.trim();
|
||||
if (domain && !isValidDomain(domain)) {
|
||||
showStatus("Invalid domain format", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupDomain(domain) {
|
||||
// Remove any protocol if accidentally included
|
||||
domain = domain.replace(/^https?:\/\//, "");
|
||||
|
||||
// Remove trailing slash
|
||||
domain = domain.replace(/\/$/, "");
|
||||
|
||||
return domain;
|
||||
}
|
||||
|
||||
function isValidDomain(domain) {
|
||||
// Remove protocol if present
|
||||
domain = domain.replace(/^https?:\/\//, "");
|
||||
|
||||
// Basic domain validation pattern
|
||||
const domainPattern =
|
||||
/^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
||||
|
||||
return domainPattern.test(domain) && domain.length <= 253;
|
||||
}
|
||||
|
||||
function isValidUrl(string) {
|
||||
try {
|
||||
new URL(string);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function showStatus(message, type) {
|
||||
const statusElement = document.getElementById("status");
|
||||
statusElement.textContent = message;
|
||||
statusElement.className = `status ${type}`;
|
||||
statusElement.style.display = "block";
|
||||
|
||||
// Auto-hide success messages
|
||||
if (type === "success") {
|
||||
setTimeout(hideStatus, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
function hideStatus() {
|
||||
const statusElement = document.getElementById("status");
|
||||
statusElement.style.display = "none";
|
||||
}
|
||||
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
async function handleEnableToggle() {
|
||||
const enabledToggle = document.getElementById("enabledToggle");
|
||||
const domainInput = document.getElementById("domainInput");
|
||||
|
||||
if (enabledToggle.checked && !domainInput.value.trim()) {
|
||||
// Prevent enabling if no domain is set
|
||||
enabledToggle.checked = false;
|
||||
showStatus("Please configure your Xget domain before enabling", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
await saveSettings();
|
||||
}
|
||||
|
||||
async function handleDomainChange() {
|
||||
const domainInput = document.getElementById("domainInput");
|
||||
const enabledToggle = document.getElementById("enabledToggle");
|
||||
|
||||
// If domain is cleared and extension is enabled, disable it
|
||||
if (!domainInput.value.trim() && enabledToggle.checked) {
|
||||
enabledToggle.checked = false;
|
||||
showStatus("Extension disabled: domain cleared", "error");
|
||||
}
|
||||
|
||||
await saveSettings();
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
if (typeof importScripts !== "undefined") {
|
||||
try {
|
||||
importScripts("webext-compat.js", "download-core.js");
|
||||
} catch (error) {
|
||||
console.log("Background dependencies already loaded by the browser");
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof webext === "undefined") {
|
||||
if (typeof browser !== "undefined") {
|
||||
self.webext = browser;
|
||||
self.webextFlavor = { soup: new Set(["firefox"]) };
|
||||
} else if (typeof chrome !== "undefined") {
|
||||
self.webext = chrome;
|
||||
self.webextFlavor = { soup: new Set(["chromium"]) };
|
||||
}
|
||||
}
|
||||
|
||||
const core = self.XgetDownloadCore;
|
||||
const DEFAULT_SETTINGS = core.normalizeSettings(core.DEFAULT_EXTENSION_SETTINGS);
|
||||
|
||||
function getStorageAPI() {
|
||||
const isFirefox =
|
||||
typeof webextFlavor !== "undefined" &&
|
||||
webextFlavor.soup &&
|
||||
webextFlavor.soup.has("firefox");
|
||||
|
||||
return isFirefox ? webext.storage.local : webext.storage.sync || webext.storage.local;
|
||||
}
|
||||
|
||||
async function getSettings() {
|
||||
const storageAPI = getStorageAPI();
|
||||
const rawSettings = await storageAPI.get(DEFAULT_SETTINGS);
|
||||
return core.normalizeSettings(rawSettings);
|
||||
}
|
||||
|
||||
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) {
|
||||
const settings = await getSettings();
|
||||
if (!settings.enabled || !settings.activeDomain) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownload(downloadItem, suggest) {
|
||||
suggest();
|
||||
processDownloadRedirect(downloadItem).catch((error) => {
|
||||
console.error("Failed to redirect browser download", error);
|
||||
});
|
||||
}
|
||||
|
||||
const messageListener = (request, sender, sendResponse) => {
|
||||
if (request.action === "getSettings") {
|
||||
getSettings()
|
||||
.then((settings) => sendResponse(settings))
|
||||
.catch((error) => {
|
||||
console.error("Failed to load settings", error);
|
||||
sendResponse(DEFAULT_SETTINGS);
|
||||
});
|
||||
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;
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
if (typeof webext === "undefined") {
|
||||
console.error("Xget Now content script: compatibility layer missing");
|
||||
}
|
||||
|
||||
const core = globalThis.XgetDownloadCore;
|
||||
let liveSettings = null;
|
||||
|
||||
async function getSettings() {
|
||||
const settings = await new Promise((resolve) => {
|
||||
webext.runtime.sendMessage({ action: "getSettings" }, resolve);
|
||||
});
|
||||
|
||||
return core.normalizeSettings(settings);
|
||||
}
|
||||
|
||||
function shouldHandleCurrentPage(settings) {
|
||||
const currentPlatform = core.detectPlatform(window.location.href);
|
||||
return (
|
||||
Boolean(currentPlatform) &&
|
||||
settings.enabled &&
|
||||
Boolean(settings.activeDomain) &&
|
||||
settings.enabledPlatforms[currentPlatform]
|
||||
);
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,622 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Xget Now</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary-accent-color: #8a2be2;
|
||||
--success-color: #4caf50;
|
||||
--container-bg: rgba(255, 255, 255, 0.1);
|
||||
--container-border: rgba(255, 255, 255, 0.2);
|
||||
--text-color: white;
|
||||
--text-secondary-color: rgba(255, 255, 255, 0.7);
|
||||
--input-bg: rgba(0, 0, 0, 0.2);
|
||||
--hover-bg: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
html {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
overflow: hidden; /* Prevents scrollbars from appearing on the html element */
|
||||
}
|
||||
|
||||
body {
|
||||
width: 380px;
|
||||
min-height: 400px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
sans-serif;
|
||||
color: var(--text-color);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
background-color: rgba(40, 40, 40, 0.3);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
animation: fadeIn 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid var(--container-border);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.header .link-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 10px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s ease;
|
||||
border-radius: 6px;
|
||||
background: var(--container-bg);
|
||||
border: 1px solid var(--container-border);
|
||||
text-decoration: none;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.header .link-item:hover {
|
||||
opacity: 1;
|
||||
background: var(--hover-bg);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.header .link-item .link-icon {
|
||||
margin-right: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.header .link-item .link-text {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 20px;
|
||||
max-height: calc(400px - 70px); /* Fixed height instead of viewport */
|
||||
overflow-y: auto;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Enhanced Custom Scrollbar */
|
||||
.content::-webkit-scrollbar,
|
||||
.platforms::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.content::-webkit-scrollbar-track,
|
||||
.platforms::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.content::-webkit-scrollbar-thumb,
|
||||
.platforms::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.content::-webkit-scrollbar-thumb:hover,
|
||||
.platforms::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.content::-webkit-scrollbar-thumb:active,
|
||||
.platforms::-webkit-scrollbar-thumb:active {
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
/* Scrollbar corner for better appearance */
|
||||
.content::-webkit-scrollbar-corner,
|
||||
.platforms::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Smooth scrolling support for Firefox */
|
||||
.content,
|
||||
.platforms {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
|
||||
}
|
||||
|
||||
/* Auto-hide scrollbar behavior */
|
||||
.content:not(:hover):not(:focus-within),
|
||||
.platforms:not(:hover):not(:focus-within) {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.content:not(:hover):not(:focus-within)::-webkit-scrollbar,
|
||||
.platforms:not(:hover):not(:focus-within)::-webkit-scrollbar {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.setting-group {
|
||||
margin-bottom: 25px;
|
||||
padding: 15px;
|
||||
background: var(--container-bg);
|
||||
border: 1px solid var(--container-border);
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.setting-group.compact {
|
||||
padding: 10px 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.setting-group:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text-secondary-color);
|
||||
}
|
||||
|
||||
.setting-label.compact {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
transition: 0.4s;
|
||||
border-radius: 28px;
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 22px;
|
||||
width: 22px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: 0.4s cubic-bezier(0.68, -0.55, 0.27, 1.55);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background-color: var(--primary-accent-color);
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(22px);
|
||||
}
|
||||
|
||||
.domain-input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: var(--input-bg);
|
||||
color: var(--text-color);
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.domain-input::placeholder {
|
||||
color: var(--text-secondary-color);
|
||||
}
|
||||
|
||||
.domain-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-accent-color);
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
box-shadow: 0 0 0 3px rgba(138, 43, 226, 0.3);
|
||||
}
|
||||
|
||||
.platforms {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
margin-top: 8px;
|
||||
padding: 5px;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.platform-category {
|
||||
padding: 8px 12px 0 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.category-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary-color);
|
||||
margin-bottom: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
.platform-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.platform-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.platform-item:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: 15px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
border: 1px solid transparent;
|
||||
animation: status-pop-in 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes status-pop-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.status.hiding {
|
||||
animation: fadeOut 0.3s ease forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.status.success {
|
||||
background: rgba(76, 175, 80, 0.25);
|
||||
border-color: rgba(76, 175, 80, 0.4);
|
||||
}
|
||||
|
||||
.status.error {
|
||||
background: rgba(244, 67, 54, 0.25);
|
||||
border-color: rgba(244, 67, 54, 0.4);
|
||||
}
|
||||
|
||||
.mini-toggle {
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.mini-toggle .slider {
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.mini-toggle .slider:before {
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
}
|
||||
|
||||
.mini-toggle input:checked + .slider:before {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.link-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 15px;
|
||||
background: var(--container-bg);
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: var(--text-color);
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid var(--container-border);
|
||||
}
|
||||
|
||||
.link-item:hover {
|
||||
background: var(--hover-bg);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
transform: translateY(-2px) scale(1.02);
|
||||
}
|
||||
|
||||
.link-icon {
|
||||
margin-right: 12px;
|
||||
font-size: 18px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.link-text {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<a
|
||||
href="https://xuc.xi-xu.me/"
|
||||
target="_blank"
|
||||
class="link-item"
|
||||
style="margin-bottom: 10px; display: inline-block"
|
||||
>
|
||||
<span class="link-text">URL 转换器</span>
|
||||
</a>
|
||||
<h1>Xget Now</h1>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="setting-group compact">
|
||||
<label class="setting-label compact">
|
||||
<div
|
||||
style="
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 28px;
|
||||
"
|
||||
>
|
||||
<span>启用扩展</span>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="enabledToggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-group">
|
||||
<label class="setting-label" for="domainInput">Xget 域名(必填)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="domainInput"
|
||||
class="domain-input"
|
||||
placeholder="your-xget-domain.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="setting-group">
|
||||
<label class="setting-label">启用的平台</label>
|
||||
<div class="platforms">
|
||||
<div class="platform-category">
|
||||
<div class="category-title">代码托管</div>
|
||||
<div class="platform-grid">
|
||||
<div class="platform-item">
|
||||
<span>GitHub</span>
|
||||
<label class="toggle-switch mini-toggle">
|
||||
<input type="checkbox" id="ghToggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="platform-item">
|
||||
<span>GitLab</span>
|
||||
<label class="toggle-switch mini-toggle">
|
||||
<input type="checkbox" id="glToggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="platform-item">
|
||||
<span>Gitea</span>
|
||||
<label class="toggle-switch mini-toggle">
|
||||
<input type="checkbox" id="giteaToggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="platform-item">
|
||||
<span>Codeberg</span>
|
||||
<label class="toggle-switch mini-toggle">
|
||||
<input type="checkbox" id="codebergToggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="platform-item">
|
||||
<span>SourceForge</span>
|
||||
<label class="toggle-switch mini-toggle">
|
||||
<input type="checkbox" id="sfToggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="platform-item">
|
||||
<span>AOSP</span>
|
||||
<label class="toggle-switch mini-toggle">
|
||||
<input type="checkbox" id="aospToggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="platform-category">
|
||||
<div class="category-title">AI/ML</div>
|
||||
<div class="platform-grid">
|
||||
<div class="platform-item">
|
||||
<span>Hugging Face</span>
|
||||
<label class="toggle-switch mini-toggle">
|
||||
<input type="checkbox" id="hfToggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="platform-category">
|
||||
<div class="category-title">包管理</div>
|
||||
<div class="platform-grid">
|
||||
<div class="platform-item">
|
||||
<span>npm</span>
|
||||
<label class="toggle-switch mini-toggle">
|
||||
<input type="checkbox" id="npmToggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="platform-item">
|
||||
<span>PyPI</span>
|
||||
<label class="toggle-switch mini-toggle">
|
||||
<input type="checkbox" id="pypiToggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="platform-item">
|
||||
<span>Conda</span>
|
||||
<label class="toggle-switch mini-toggle">
|
||||
<input type="checkbox" id="condaToggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="platform-item">
|
||||
<span>Maven</span>
|
||||
<label class="toggle-switch mini-toggle">
|
||||
<input type="checkbox" id="mavenToggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="platform-item">
|
||||
<span>RubyGems</span>
|
||||
<label class="toggle-switch mini-toggle">
|
||||
<input type="checkbox" id="rubygemsToggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="platform-item">
|
||||
<span>Crates.io</span>
|
||||
<label class="toggle-switch mini-toggle">
|
||||
<input type="checkbox" id="cratesToggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="platform-item">
|
||||
<span>NuGet</span>
|
||||
<label class="toggle-switch mini-toggle">
|
||||
<input type="checkbox" id="nugetToggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="platform-item">
|
||||
<span>Go Modules</span>
|
||||
<label class="toggle-switch mini-toggle">
|
||||
<input type="checkbox" id="golangToggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="platform-category">
|
||||
<div class="category-title">其他</div>
|
||||
<div class="platform-grid">
|
||||
<div class="platform-item">
|
||||
<span>arXiv</span>
|
||||
<label class="toggle-switch mini-toggle">
|
||||
<input type="checkbox" id="arxivToggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="platform-item">
|
||||
<span>F-Droid</span>
|
||||
<label class="toggle-switch mini-toggle">
|
||||
<input type="checkbox" id="fdroidToggle" />
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-group">
|
||||
<div class="links">
|
||||
<a
|
||||
href="https://github.com/xixu-me/Xget"
|
||||
target="_blank"
|
||||
class="link-item"
|
||||
>
|
||||
<span class="link-text">Xget 存储库</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/xixu-me/Xget-Now"
|
||||
target="_blank"
|
||||
class="link-item"
|
||||
>
|
||||
<span class="link-text">扩展存储库</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="status" class="status" style="display: none"></div>
|
||||
</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>
|
||||
</html>
|
||||
@@ -0,0 +1,501 @@
|
||||
/**
|
||||
* Xget Now - 弹窗脚本
|
||||
*
|
||||
* 功能:
|
||||
* - 管理扩展设置界面
|
||||
* - 处理用户交互事件
|
||||
* - 同步设置到后台脚本
|
||||
* - 提供平台开关控制
|
||||
*/
|
||||
|
||||
const core = window.XgetDownloadCore;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
// 确保有可用的API
|
||||
if (
|
||||
typeof webext === "undefined" &&
|
||||
typeof browser === "undefined" &&
|
||||
!window.firefoxAPI
|
||||
) {
|
||||
console.error("No compatible extension API found");
|
||||
showStatus("扩展API加载失败", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// 延迟执行以确保background脚本已准备就绪
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
// 加载当前设置
|
||||
await loadSettings();
|
||||
|
||||
// 设置事件监听器
|
||||
setupEventListeners();
|
||||
} catch (error) {
|
||||
console.error("Initialization error:", error);
|
||||
showStatus("初始化失败,请重新打开扩展", "error");
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
/**
|
||||
* 加载并应用当前设置到 UI
|
||||
*/
|
||||
async function loadSettings() {
|
||||
try {
|
||||
console.log("Loading settings...");
|
||||
|
||||
let settings;
|
||||
|
||||
// Firefox 特殊处理
|
||||
if (window.firefoxAPI && typeof browser !== "undefined") {
|
||||
console.log("Using Firefox direct storage API");
|
||||
try {
|
||||
// 首先尝试消息传递
|
||||
settings = await window.firefoxAPI.sendMessage({
|
||||
action: "getSettings",
|
||||
});
|
||||
} catch (msgError) {
|
||||
console.log(
|
||||
"Message passing failed, using direct storage access:",
|
||||
msgError
|
||||
);
|
||||
// 如果消息传递失败,直接从存储读取
|
||||
settings = await window.firefoxAPI.getSettings();
|
||||
}
|
||||
} else if (webext && webext.runtime && webext.runtime.sendMessage) {
|
||||
// 标准 webext API
|
||||
console.log("Using standard webext API");
|
||||
settings = await webext.runtime.sendMessage({
|
||||
action: "getSettings",
|
||||
});
|
||||
} else {
|
||||
throw new Error("No compatible API available");
|
||||
}
|
||||
|
||||
settings = core ? core.normalizeSettings(settings) : settings;
|
||||
console.log("Settings received:", settings);
|
||||
|
||||
if (!settings) {
|
||||
throw new Error("No response from background script");
|
||||
}
|
||||
|
||||
// 使用当前设置更新 UI
|
||||
const domainValue = settings.xgetDomain || "";
|
||||
document.getElementById("domainInput").value = domainValue;
|
||||
|
||||
// 仅在设置了域名时启用切换
|
||||
const enabledValue = settings.enabled && domainValue.trim() !== "";
|
||||
document.getElementById("enabledToggle").checked = enabledValue;
|
||||
|
||||
// 更新平台切换状态
|
||||
const platformToggles = {
|
||||
// 代码托管平台
|
||||
gh: document.getElementById("ghToggle"),
|
||||
gl: document.getElementById("glToggle"),
|
||||
gitea: document.getElementById("giteaToggle"),
|
||||
codeberg: document.getElementById("codebergToggle"),
|
||||
sf: document.getElementById("sfToggle"),
|
||||
aosp: document.getElementById("aospToggle"),
|
||||
|
||||
// AI/ML 平台
|
||||
hf: document.getElementById("hfToggle"),
|
||||
|
||||
// 包管理平台
|
||||
npm: document.getElementById("npmToggle"),
|
||||
pypi: document.getElementById("pypiToggle"),
|
||||
conda: document.getElementById("condaToggle"),
|
||||
maven: document.getElementById("mavenToggle"),
|
||||
rubygems: document.getElementById("rubygemsToggle"),
|
||||
crates: document.getElementById("cratesToggle"),
|
||||
nuget: document.getElementById("nugetToggle"),
|
||||
golang: document.getElementById("golangToggle"),
|
||||
|
||||
// 其他平台
|
||||
arxiv: document.getElementById("arxivToggle"),
|
||||
fdroid: document.getElementById("fdroidToggle"),
|
||||
};
|
||||
|
||||
// 设置平台切换状态
|
||||
Object.entries(platformToggles).forEach(([platform, toggle]) => {
|
||||
if (
|
||||
toggle &&
|
||||
settings.enabledPlatforms &&
|
||||
settings.enabledPlatforms[platform] !== undefined
|
||||
) {
|
||||
toggle.checked = settings.enabledPlatforms[platform];
|
||||
}
|
||||
});
|
||||
|
||||
// 如果缺少域名则显示状态
|
||||
if (!domainValue && settings.enabled) {
|
||||
showStatus("请配置你的 Xget 域名", "error");
|
||||
} else if (domainValue && enabledValue) {
|
||||
showStatus("扩展已激活并准备就绪", "success");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("加载设置时出错:", error);
|
||||
showStatus("加载设置时出错", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function setupEventListeners() {
|
||||
// 启用/禁用切换
|
||||
document
|
||||
.getElementById("enabledToggle")
|
||||
.addEventListener("change", handleEnableToggle);
|
||||
|
||||
// 域名输入
|
||||
document
|
||||
.getElementById("domainInput")
|
||||
.addEventListener("input", debounce(handleDomainChange, 500));
|
||||
document
|
||||
.getElementById("domainInput")
|
||||
.addEventListener("blur", validateDomain);
|
||||
|
||||
// 平台切换
|
||||
const platformToggles = [
|
||||
"ghToggle",
|
||||
"glToggle",
|
||||
"giteaToggle",
|
||||
"codebergToggle",
|
||||
"sfToggle",
|
||||
"aospToggle",
|
||||
"hfToggle",
|
||||
"npmToggle",
|
||||
"pypiToggle",
|
||||
"condaToggle",
|
||||
"mavenToggle",
|
||||
"rubygemsToggle",
|
||||
"cratesToggle",
|
||||
"nugetToggle",
|
||||
"golangToggle",
|
||||
"arxivToggle",
|
||||
"fdroidToggle",
|
||||
];
|
||||
|
||||
platformToggles.forEach((toggleId) => {
|
||||
const element = document.getElementById(toggleId);
|
||||
if (element) {
|
||||
element.addEventListener("change", saveSettings);
|
||||
}
|
||||
});
|
||||
|
||||
// 滚动优化逻辑
|
||||
setupScrollOptimization();
|
||||
}
|
||||
|
||||
function setupScrollOptimization() {
|
||||
const contentElement = document.querySelector(".content");
|
||||
const platformsElement = document.querySelector(".platforms");
|
||||
|
||||
// 为主内容区域添加滚动优化
|
||||
if (contentElement) {
|
||||
let scrollTimeout;
|
||||
|
||||
contentElement.addEventListener("scroll", () => {
|
||||
// 添加滚动中的视觉反馈
|
||||
contentElement.style.scrollbarWidth = "auto";
|
||||
|
||||
clearTimeout(scrollTimeout);
|
||||
scrollTimeout = setTimeout(() => {
|
||||
// 滚动结束后隐藏滚动条(仅在不悬停时)
|
||||
if (!contentElement.matches(":hover")) {
|
||||
contentElement.style.scrollbarWidth = "thin";
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
// 鼠标悬停时显示滚动条
|
||||
contentElement.addEventListener("mouseenter", () => {
|
||||
contentElement.style.scrollbarWidth = "auto";
|
||||
});
|
||||
|
||||
contentElement.addEventListener("mouseleave", () => {
|
||||
contentElement.style.scrollbarWidth = "thin";
|
||||
});
|
||||
}
|
||||
|
||||
// 为平台列表添加滚动优化
|
||||
if (platformsElement) {
|
||||
let scrollTimeout;
|
||||
|
||||
platformsElement.addEventListener("scroll", () => {
|
||||
platformsElement.style.scrollbarWidth = "auto";
|
||||
|
||||
clearTimeout(scrollTimeout);
|
||||
scrollTimeout = setTimeout(() => {
|
||||
if (!platformsElement.matches(":hover")) {
|
||||
platformsElement.style.scrollbarWidth = "thin";
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
platformsElement.addEventListener("mouseenter", () => {
|
||||
platformsElement.style.scrollbarWidth = "auto";
|
||||
});
|
||||
|
||||
platformsElement.addEventListener("mouseleave", () => {
|
||||
platformsElement.style.scrollbarWidth = "thin";
|
||||
});
|
||||
}
|
||||
|
||||
// 添加键盘导航支持
|
||||
document.addEventListener("keydown", (e) => {
|
||||
const activeElement = document.activeElement;
|
||||
const isInScrollableArea =
|
||||
activeElement &&
|
||||
(contentElement.contains(activeElement) ||
|
||||
platformsElement.contains(activeElement));
|
||||
|
||||
if (isInScrollableArea) {
|
||||
switch (e.key) {
|
||||
case "ArrowUp":
|
||||
if (e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
const scrollable = contentElement.contains(activeElement)
|
||||
? contentElement
|
||||
: platformsElement;
|
||||
scrollable.scrollBy({ top: -50, behavior: "smooth" });
|
||||
}
|
||||
break;
|
||||
case "ArrowDown":
|
||||
if (e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
const scrollable = contentElement.contains(activeElement)
|
||||
? contentElement
|
||||
: platformsElement;
|
||||
scrollable.scrollBy({ top: 50, behavior: "smooth" });
|
||||
}
|
||||
break;
|
||||
case "Home":
|
||||
if (e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
const scrollable = contentElement.contains(activeElement)
|
||||
? contentElement
|
||||
: platformsElement;
|
||||
scrollable.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
break;
|
||||
case "End":
|
||||
if (e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
const scrollable = contentElement.contains(activeElement)
|
||||
? contentElement
|
||||
: platformsElement;
|
||||
scrollable.scrollTo({
|
||||
top: scrollable.scrollHeight,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
try {
|
||||
const domainValue = document.getElementById("domainInput").value.trim();
|
||||
const enabledValue = document.getElementById("enabledToggle").checked;
|
||||
|
||||
// 如果没有设置域名则防止启用
|
||||
if (enabledValue && !domainValue) {
|
||||
document.getElementById("enabledToggle").checked = false;
|
||||
showStatus("没有域名不能启用扩展", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = {
|
||||
enabled: document.getElementById("enabledToggle").checked,
|
||||
xgetDomain: domainValue,
|
||||
enabledPlatforms: {
|
||||
// 代码托管平台
|
||||
gh: document.getElementById("ghToggle")?.checked || false,
|
||||
gl: document.getElementById("glToggle")?.checked || false,
|
||||
gitea: document.getElementById("giteaToggle")?.checked || false,
|
||||
codeberg: document.getElementById("codebergToggle")?.checked || false,
|
||||
sf: document.getElementById("sfToggle")?.checked || false,
|
||||
aosp: document.getElementById("aospToggle")?.checked || false,
|
||||
|
||||
// AI/ML 平台
|
||||
hf: document.getElementById("hfToggle")?.checked || false,
|
||||
|
||||
// 包管理平台
|
||||
npm: document.getElementById("npmToggle")?.checked || false,
|
||||
pypi: document.getElementById("pypiToggle")?.checked || false,
|
||||
"pypi-files": true, // 默认开启
|
||||
conda: document.getElementById("condaToggle")?.checked || false,
|
||||
"conda-community": true, // 默认开启
|
||||
maven: document.getElementById("mavenToggle")?.checked || false,
|
||||
apache: true, // 默认开启
|
||||
gradle: true, // 默认开启
|
||||
rubygems: document.getElementById("rubygemsToggle")?.checked || false,
|
||||
cran: true, // 默认开启
|
||||
cpan: true, // 默认开启
|
||||
ctan: true, // 默认开启
|
||||
golang: document.getElementById("golangToggle")?.checked || false,
|
||||
nuget: document.getElementById("nugetToggle")?.checked || false,
|
||||
crates: document.getElementById("cratesToggle")?.checked || false,
|
||||
packagist: true, // 默认开启
|
||||
|
||||
// 其他平台
|
||||
arxiv: document.getElementById("arxivToggle")?.checked || false,
|
||||
fdroid: document.getElementById("fdroidToggle")?.checked || false,
|
||||
},
|
||||
};
|
||||
|
||||
// 清理域名 URL
|
||||
if (settings.xgetDomain) {
|
||||
settings.xgetDomain = cleanupDomain(settings.xgetDomain);
|
||||
}
|
||||
|
||||
let response;
|
||||
|
||||
// Firefox 特殊处理
|
||||
if (window.firefoxAPI && typeof browser !== "undefined") {
|
||||
console.log("Saving settings using Firefox API");
|
||||
try {
|
||||
// 首先尝试消息传递
|
||||
response = await window.firefoxAPI.sendMessage({
|
||||
action: "saveSettings",
|
||||
settings: settings,
|
||||
});
|
||||
} catch (msgError) {
|
||||
console.log("Message passing failed, using direct storage:", msgError);
|
||||
// 如果消息传递失败,直接写入存储
|
||||
response = await window.firefoxAPI.saveSettings(settings);
|
||||
}
|
||||
} else if (webext && webext.runtime && webext.runtime.sendMessage) {
|
||||
// 标准 webext API
|
||||
console.log("Saving settings using standard webext API");
|
||||
response = await webext.runtime.sendMessage({
|
||||
action: "saveSettings",
|
||||
settings: settings,
|
||||
});
|
||||
} else {
|
||||
throw new Error("No compatible API available for saving settings");
|
||||
}
|
||||
|
||||
if (response && response.success) {
|
||||
// 显示适当的状态
|
||||
if (!settings.xgetDomain && settings.enabled) {
|
||||
showStatus("请配置你的 Xget 域名", "error");
|
||||
} else if (settings.xgetDomain && settings.enabled) {
|
||||
showStatus("设置已保存。查看页面通知中的刷新按钮。", "success");
|
||||
} else {
|
||||
showStatus("设置已保存。查看页面通知中的刷新按钮。", "success");
|
||||
}
|
||||
} else if (response && response.error) {
|
||||
console.error("保存设置失败:", response.error);
|
||||
showStatus("保存设置时出错: " + response.error, "error");
|
||||
} else {
|
||||
// 如果没有响应,可能是消息传递失败
|
||||
console.error("未收到后台脚本的响应");
|
||||
showStatus("保存设置时出错:无法与后台脚本通信", "error");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("保存设置时出错:", error);
|
||||
showStatus("保存设置时出错", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function validateDomain() {
|
||||
const domain = document.getElementById("domainInput").value.trim();
|
||||
if (domain && !isValidDomain(domain)) {
|
||||
showStatus("域名格式无效", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupDomain(domain) {
|
||||
if (core) {
|
||||
return core.sanitizeDomain(domain);
|
||||
}
|
||||
|
||||
return String(domain || "")
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function isValidDomain(domain) {
|
||||
domain = cleanupDomain(domain);
|
||||
|
||||
// 基本域名验证模式
|
||||
const domainPattern =
|
||||
/^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
||||
|
||||
return domainPattern.test(domain) && domain.length <= 253;
|
||||
}
|
||||
|
||||
function isValidUrl(string) {
|
||||
try {
|
||||
new URL(string);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function showStatus(message, type) {
|
||||
const statusElement = document.getElementById("status");
|
||||
statusElement.textContent = message;
|
||||
statusElement.className = `status ${type}`;
|
||||
statusElement.style.display = "block";
|
||||
statusElement.classList.remove("hiding");
|
||||
|
||||
// 自动隐藏成功消息
|
||||
if (type === "success") {
|
||||
setTimeout(hideStatus, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
function hideStatus() {
|
||||
const statusElement = document.getElementById("status");
|
||||
statusElement.classList.add("hiding");
|
||||
statusElement.addEventListener(
|
||||
"animationend",
|
||||
() => {
|
||||
statusElement.style.display = "none";
|
||||
statusElement.classList.remove("hiding");
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
}
|
||||
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
async function handleEnableToggle() {
|
||||
const enabledToggle = document.getElementById("enabledToggle");
|
||||
const domainInput = document.getElementById("domainInput");
|
||||
|
||||
if (enabledToggle.checked && !domainInput.value.trim()) {
|
||||
// 如果没有设置域名则防止启用
|
||||
enabledToggle.checked = false;
|
||||
showStatus("启用前请配置你的 Xget 域名", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
await saveSettings();
|
||||
}
|
||||
|
||||
async function handleDomainChange() {
|
||||
const domainInput = document.getElementById("domainInput");
|
||||
const enabledToggle = document.getElementById("enabledToggle");
|
||||
|
||||
// 如果域名被清空且扩展已启用,则禁用它
|
||||
if (!domainInput.value.trim() && enabledToggle.checked) {
|
||||
enabledToggle.checked = false;
|
||||
showStatus("扩展已禁用:域名已清空", "error");
|
||||
}
|
||||
|
||||
await saveSettings();
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Firefox 特定兼容性修复
|
||||
* 直接使用 browser API 处理 Firefox 中的消息传递问题
|
||||
*/
|
||||
|
||||
// 在 Firefox 中,直接使用 browser API
|
||||
if (
|
||||
typeof browser !== "undefined" &&
|
||||
browser.runtime &&
|
||||
browser.runtime.sendMessage
|
||||
) {
|
||||
console.log("Firefox environment detected, using native browser API");
|
||||
|
||||
// 为popup.js提供一个简化的API
|
||||
window.firefoxAPI = {
|
||||
sendMessage: function (message) {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
browser.runtime.sendMessage(message, (response) => {
|
||||
if (browser.runtime.lastError) {
|
||||
console.error(
|
||||
"Firefox sendMessage error:",
|
||||
browser.runtime.lastError
|
||||
);
|
||||
reject(browser.runtime.lastError);
|
||||
} else {
|
||||
resolve(response);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Firefox sendMessage exception:", error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
getSettings: async function () {
|
||||
try {
|
||||
return await browser.storage.local.get({
|
||||
enabled: true,
|
||||
xgetDomain: "xget.xi-xu.me",
|
||||
enabledPlatforms: {
|
||||
gh: true,
|
||||
gl: true,
|
||||
gitea: true,
|
||||
codeberg: true,
|
||||
sf: true,
|
||||
aosp: true,
|
||||
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,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to get settings from storage:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
saveSettings: async function (settings) {
|
||||
try {
|
||||
await browser.storage.local.set(settings);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Failed to save settings to storage:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
/**
|
||||
* WebExtension API 兼容层
|
||||
*
|
||||
* 提供跨浏览器的统一 API 接口,解决不同浏览器间的差异:
|
||||
* - Firefox 使用 browser API,返回 Promise
|
||||
* - Chromium 使用 chrome API,使用回调函数
|
||||
* - 不同版本的 Manifest API 差异 (V2 vs V3)
|
||||
*
|
||||
* 功能:
|
||||
* - 自动检测当前浏览器环境
|
||||
* - 提供统一的 Promise 化 API 接口
|
||||
* - 处理 API 调用失败的情况
|
||||
* - 兼容不同浏览器的特性差异
|
||||
*/
|
||||
|
||||
/**
|
||||
* 检测当前浏览器环境
|
||||
*
|
||||
* @returns {Object} 浏览器特性信息
|
||||
* @property {number} major - 浏览器主版本号
|
||||
* @property {Set<string>} soup - 浏览器特性标识集合
|
||||
*/
|
||||
const detectBrowser = () => {
|
||||
const ua = navigator.userAgent;
|
||||
let manifest;
|
||||
|
||||
try {
|
||||
manifest = chrome.runtime.getManifest();
|
||||
} catch (error) {
|
||||
console.warn("Could not get manifest in current context:", error);
|
||||
manifest = { version: "0.0.0" }; // 默认版本
|
||||
}
|
||||
|
||||
const flavor = {
|
||||
major: 0,
|
||||
soup: new Set(["webext"]),
|
||||
};
|
||||
|
||||
// 检测 Firefox
|
||||
if (
|
||||
typeof browser !== "undefined" &&
|
||||
browser.runtime &&
|
||||
browser.runtime.getURL("").startsWith("moz-extension://")
|
||||
) {
|
||||
flavor.soup.add("firefox");
|
||||
const match = /Firefox\/(\d+)/.exec(ua);
|
||||
flavor.major = match ? parseInt(match[1], 10) : 115;
|
||||
|
||||
// Firefox 特性检测
|
||||
if (CSS.supports("selector(a:has(b))")) {
|
||||
flavor.soup.add("native_css_has");
|
||||
}
|
||||
}
|
||||
// 检测基于 Chromium 的浏览器
|
||||
else {
|
||||
const match = /\bChrom(?:e|ium)\/(\d+)/.exec(ua);
|
||||
if (match !== null) {
|
||||
flavor.soup.add("chromium");
|
||||
}
|
||||
flavor.major = match ? parseInt(match[1], 10) : 120;
|
||||
|
||||
// 检测具体浏览器
|
||||
if (/\bEdg\/\b/.test(ua)) {
|
||||
flavor.soup.add("edge");
|
||||
} else if (/\bOPR\/\b/.test(ua)) {
|
||||
flavor.soup.add("opera");
|
||||
} else {
|
||||
flavor.soup.add("chrome");
|
||||
}
|
||||
}
|
||||
|
||||
// 检测移动端
|
||||
if (/\bMobile\b/.test(ua)) {
|
||||
flavor.soup.add("mobile");
|
||||
}
|
||||
|
||||
// 检测开发版本
|
||||
if (/^\d+\.\d+\.\d+\D/.test(manifest.version)) {
|
||||
flavor.soup.add("devbuild");
|
||||
}
|
||||
|
||||
return flavor;
|
||||
};
|
||||
|
||||
// 浏览器特性检测
|
||||
const webextFlavor = detectBrowser();
|
||||
|
||||
// 统一的 WebExtension API
|
||||
const webext = (() => {
|
||||
// Firefox 使用 browser API,Chrome 使用 chrome API
|
||||
let api;
|
||||
try {
|
||||
api = typeof browser !== "undefined" && browser.runtime ? browser : chrome;
|
||||
if (!api || !api.runtime) {
|
||||
throw new Error("No WebExtension API available");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize WebExtension API:", error);
|
||||
// 创建一个最小的 API 存根
|
||||
api = {
|
||||
runtime: { getManifest: () => ({ version: "0.0.0" }) },
|
||||
storage: { local: {}, sync: {} },
|
||||
tabs: {},
|
||||
downloads: {},
|
||||
};
|
||||
}
|
||||
|
||||
// Promise 化函数的包装器
|
||||
const promisify = (context, methodName) => {
|
||||
const method = context[methodName];
|
||||
if (!method) {
|
||||
throw new Error(`Method ${methodName} not found`);
|
||||
}
|
||||
|
||||
return function (...args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
method.call(context, ...args, (result) => {
|
||||
if (api.runtime.lastError) {
|
||||
reject(new Error(api.runtime.lastError.message));
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
// 无失败的 Promise 包装器
|
||||
const promisifyNoFail = (context, methodName, defaultValue = undefined) => {
|
||||
try {
|
||||
const promisified = promisify(context, methodName);
|
||||
return async function (...args) {
|
||||
try {
|
||||
return await promisified(...args);
|
||||
} catch (error) {
|
||||
console.warn(`WebExt API call failed: ${methodName}`, error);
|
||||
return defaultValue;
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
// 如果方法不存在,返回空函数
|
||||
return async function () {
|
||||
console.warn(`WebExt API method not available: ${methodName}`);
|
||||
return defaultValue;
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 专门的 sendMessage 包装器
|
||||
const createSendMessage = (runtimeAPI) => {
|
||||
if (!runtimeAPI || !runtimeAPI.sendMessage) {
|
||||
return async () => {
|
||||
console.warn("runtime.sendMessage not available");
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
return function (...args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
if (args.length === 1) {
|
||||
// sendMessage(message)
|
||||
runtimeAPI.sendMessage(args[0], (response) => {
|
||||
if (api.runtime.lastError) {
|
||||
console.warn(
|
||||
"SendMessage failed:",
|
||||
api.runtime.lastError.message
|
||||
);
|
||||
resolve(null);
|
||||
} else {
|
||||
resolve(response);
|
||||
}
|
||||
});
|
||||
} else if (args.length === 2) {
|
||||
// 检查第二个参数是否是函数(回调)
|
||||
if (typeof args[1] === "function") {
|
||||
// sendMessage(message, callback) - 直接调用回调,不使用 Promise
|
||||
runtimeAPI.sendMessage(args[0], args[1]);
|
||||
resolve(undefined); // 立即解析 Promise
|
||||
} else if (typeof args[0] === "string" && args[0].length > 10) {
|
||||
// sendMessage(extensionId, message)
|
||||
runtimeAPI.sendMessage(args[0], args[1], (response) => {
|
||||
if (api.runtime.lastError) {
|
||||
console.warn(
|
||||
"SendMessage failed:",
|
||||
api.runtime.lastError.message
|
||||
);
|
||||
resolve(null);
|
||||
} else {
|
||||
resolve(response);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// sendMessage(message, options)
|
||||
runtimeAPI.sendMessage(args[0], args[1], (response) => {
|
||||
if (api.runtime.lastError) {
|
||||
console.warn(
|
||||
"SendMessage failed:",
|
||||
api.runtime.lastError.message
|
||||
);
|
||||
resolve(null);
|
||||
} else {
|
||||
resolve(response);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (args.length === 3) {
|
||||
// 检查第三个参数是否是函数(回调)
|
||||
if (typeof args[2] === "function") {
|
||||
// sendMessage(extensionId, message, callback) 或 sendMessage(message, options, callback)
|
||||
runtimeAPI.sendMessage(args[0], args[1], args[2]);
|
||||
resolve(undefined); // 立即解析 Promise
|
||||
} else {
|
||||
// sendMessage(extensionId, message, options)
|
||||
runtimeAPI.sendMessage(args[0], args[1], args[2], (response) => {
|
||||
if (api.runtime.lastError) {
|
||||
console.warn(
|
||||
"SendMessage failed:",
|
||||
api.runtime.lastError.message
|
||||
);
|
||||
resolve(null);
|
||||
} else {
|
||||
resolve(response);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (args.length === 4 && typeof args[3] === "function") {
|
||||
// sendMessage(extensionId, message, options, callback)
|
||||
runtimeAPI.sendMessage(args[0], args[1], args[2], args[3]);
|
||||
resolve(undefined); // 立即解析 Promise
|
||||
} else {
|
||||
console.warn("Invalid arguments for sendMessage:", args);
|
||||
resolve(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("SendMessage error:", error);
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
// 专门的 tabs.sendMessage 包装器
|
||||
const createTabsSendMessage = (tabsAPI) => {
|
||||
if (!tabsAPI || !tabsAPI.sendMessage) {
|
||||
return async () => {
|
||||
console.warn("tabs.sendMessage not available");
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
return function (tabId, message, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
if (arguments.length === 2) {
|
||||
// sendMessage(tabId, message)
|
||||
tabsAPI.sendMessage(tabId, message, (response) => {
|
||||
if (api.runtime.lastError) {
|
||||
console.warn(
|
||||
"Tabs sendMessage failed:",
|
||||
api.runtime.lastError.message
|
||||
);
|
||||
resolve(null);
|
||||
} else {
|
||||
resolve(response);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// sendMessage(tabId, message, options)
|
||||
tabsAPI.sendMessage(tabId, message, options, (response) => {
|
||||
if (api.runtime.lastError) {
|
||||
console.warn(
|
||||
"Tabs sendMessage failed:",
|
||||
api.runtime.lastError.message
|
||||
);
|
||||
resolve(null);
|
||||
} else {
|
||||
resolve(response);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Tabs sendMessage error:", error);
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
// Runtime API
|
||||
runtime: {
|
||||
getManifest: () => api.runtime.getManifest(),
|
||||
getURL: (path) => api.runtime.getURL(path),
|
||||
sendMessage: createSendMessage(api.runtime),
|
||||
connect: (connectInfo) => api.runtime.connect(connectInfo),
|
||||
onMessage: api.runtime.onMessage,
|
||||
onConnect: api.runtime.onConnect,
|
||||
onInstalled: api.runtime.onInstalled,
|
||||
onStartup: api.runtime.onStartup,
|
||||
lastError: api.runtime.lastError,
|
||||
id: api.runtime.id,
|
||||
},
|
||||
|
||||
// Tabs API
|
||||
tabs: api.tabs
|
||||
? {
|
||||
query: promisifyNoFail(api.tabs, "query", []),
|
||||
get: promisifyNoFail(api.tabs, "get"),
|
||||
create: promisifyNoFail(api.tabs, "create"),
|
||||
update: promisifyNoFail(api.tabs, "update"),
|
||||
remove: promisifyNoFail(api.tabs, "remove"),
|
||||
sendMessage: createTabsSendMessage(api.tabs),
|
||||
executeScript: promisifyNoFail(api.tabs, "executeScript"),
|
||||
insertCSS: promisifyNoFail(api.tabs, "insertCSS"),
|
||||
removeCSS: promisifyNoFail(api.tabs, "removeCSS"),
|
||||
onUpdated: api.tabs.onUpdated,
|
||||
onActivated: api.tabs.onActivated,
|
||||
onRemoved: api.tabs.onRemoved,
|
||||
}
|
||||
: {
|
||||
query: async () => [],
|
||||
get: async () => null,
|
||||
create: async () => null,
|
||||
update: async () => null,
|
||||
remove: async () => null,
|
||||
sendMessage: async () => null,
|
||||
executeScript: async () => null,
|
||||
insertCSS: async () => null,
|
||||
removeCSS: async () => null,
|
||||
onUpdated: { addListener: () => {}, removeListener: () => {} },
|
||||
onActivated: { addListener: () => {}, removeListener: () => {} },
|
||||
onRemoved: { addListener: () => {}, removeListener: () => {} },
|
||||
},
|
||||
|
||||
// Storage API
|
||||
storage: {
|
||||
local: {
|
||||
get: promisifyNoFail(api.storage.local, "get", {}),
|
||||
set: promisifyNoFail(api.storage.local, "set"),
|
||||
remove: promisifyNoFail(api.storage.local, "remove"),
|
||||
clear: promisifyNoFail(api.storage.local, "clear"),
|
||||
getBytesInUse: promisifyNoFail(api.storage.local, "getBytesInUse", 0),
|
||||
},
|
||||
sync: api.storage.sync
|
||||
? {
|
||||
get: promisifyNoFail(api.storage.sync, "get", {}),
|
||||
set: promisifyNoFail(api.storage.sync, "set"),
|
||||
remove: promisifyNoFail(api.storage.sync, "remove"),
|
||||
clear: promisifyNoFail(api.storage.sync, "clear"),
|
||||
getBytesInUse: promisifyNoFail(
|
||||
api.storage.sync,
|
||||
"getBytesInUse",
|
||||
0
|
||||
),
|
||||
}
|
||||
: null,
|
||||
},
|
||||
|
||||
// Downloads API
|
||||
downloads: api.downloads
|
||||
? {
|
||||
download: promisifyNoFail(api.downloads, "download"),
|
||||
search: promisifyNoFail(api.downloads, "search", []),
|
||||
cancel: promisifyNoFail(api.downloads, "cancel"),
|
||||
pause: promisifyNoFail(api.downloads, "pause"),
|
||||
resume: promisifyNoFail(api.downloads, "resume"),
|
||||
erase: promisifyNoFail(api.downloads, "erase"),
|
||||
removeFile: promisifyNoFail(api.downloads, "removeFile"),
|
||||
show: promisifyNoFail(api.downloads, "show"),
|
||||
showDefaultFolder: promisifyNoFail(
|
||||
api.downloads,
|
||||
"showDefaultFolder"
|
||||
),
|
||||
onCreated: api.downloads.onCreated,
|
||||
onChanged: api.downloads.onChanged,
|
||||
onErased: api.downloads.onErased,
|
||||
onDeterminingFilename: api.downloads.onDeterminingFilename,
|
||||
}
|
||||
: {
|
||||
download: async () => null,
|
||||
search: async () => [],
|
||||
cancel: async () => null,
|
||||
pause: async () => null,
|
||||
resume: async () => null,
|
||||
erase: async () => null,
|
||||
removeFile: async () => null,
|
||||
show: async () => null,
|
||||
showDefaultFolder: async () => null,
|
||||
onCreated: { addListener: () => {}, removeListener: () => {} },
|
||||
onChanged: { addListener: () => {}, removeListener: () => {} },
|
||||
onErased: { addListener: () => {}, removeListener: () => {} },
|
||||
onDeterminingFilename: {
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
},
|
||||
},
|
||||
|
||||
// Browser Action / Action API (兼容不同版本)
|
||||
action: (() => {
|
||||
// Manifest V3 使用 action,V2 使用 browserAction
|
||||
const actionAPI = api.action || api.browserAction;
|
||||
if (!actionAPI) return null;
|
||||
|
||||
return {
|
||||
setTitle: promisifyNoFail(actionAPI, "setTitle"),
|
||||
getTitle: promisifyNoFail(actionAPI, "getTitle"),
|
||||
setIcon: promisifyNoFail(actionAPI, "setIcon"),
|
||||
setPopup: promisifyNoFail(actionAPI, "setPopup"),
|
||||
getPopup: promisifyNoFail(actionAPI, "getPopup"),
|
||||
setBadgeText: promisifyNoFail(actionAPI, "setBadgeText"),
|
||||
getBadgeText: promisifyNoFail(actionAPI, "getBadgeText"),
|
||||
setBadgeBackgroundColor: promisifyNoFail(
|
||||
actionAPI,
|
||||
"setBadgeBackgroundColor"
|
||||
),
|
||||
getBadgeBackgroundColor: promisifyNoFail(
|
||||
actionAPI,
|
||||
"getBadgeBackgroundColor"
|
||||
),
|
||||
setBadgeTextColor: promisifyNoFail(actionAPI, "setBadgeTextColor"),
|
||||
getBadgeTextColor: promisifyNoFail(actionAPI, "getBadgeTextColor"),
|
||||
enable: promisifyNoFail(actionAPI, "enable"),
|
||||
disable: promisifyNoFail(actionAPI, "disable"),
|
||||
onClicked: actionAPI.onClicked,
|
||||
};
|
||||
})(),
|
||||
|
||||
// Permissions API
|
||||
permissions: {
|
||||
contains: promisifyNoFail(api.permissions, "contains", false),
|
||||
getAll: promisifyNoFail(api.permissions, "getAll", {
|
||||
origins: [],
|
||||
permissions: [],
|
||||
}),
|
||||
request: promisifyNoFail(api.permissions, "request", false),
|
||||
remove: promisifyNoFail(api.permissions, "remove", false),
|
||||
onAdded: api.permissions?.onAdded,
|
||||
onRemoved: api.permissions?.onRemoved,
|
||||
},
|
||||
|
||||
// i18n API
|
||||
i18n: {
|
||||
getMessage: (messageName, substitutions) =>
|
||||
api.i18n.getMessage(messageName, substitutions),
|
||||
getAcceptLanguages: promisifyNoFail(api.i18n, "getAcceptLanguages", []),
|
||||
getUILanguage: () => api.i18n.getUILanguage(),
|
||||
detectLanguage: promisifyNoFail(api.i18n, "detectLanguage"),
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
// 导出接口
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
// Node.js 环境
|
||||
module.exports = { webext, webextFlavor };
|
||||
} else if (typeof window !== "undefined") {
|
||||
// 浏览器环境(内容脚本、弹出窗口等)
|
||||
window.webext = webext;
|
||||
window.webextFlavor = webextFlavor;
|
||||
console.log("WebExt compatibility layer loaded in browser environment");
|
||||
} else if (typeof self !== "undefined") {
|
||||
// 服务工作器环境
|
||||
self.webext = webext;
|
||||
self.webextFlavor = webextFlavor;
|
||||
console.log(
|
||||
"WebExt compatibility layer loaded in service worker environment"
|
||||
);
|
||||
} else {
|
||||
// 全局环境
|
||||
this.webext = webext;
|
||||
this.webextFlavor = webextFlavor;
|
||||
console.log("WebExt compatibility layer loaded in global environment");
|
||||
}
|
||||
@@ -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,164 @@
|
||||
/**
|
||||
* 平台特定的配置和检测
|
||||
*
|
||||
* 提供浏览器平台检测和特性识别功能:
|
||||
* - 检测当前运行的浏览器类型和版本
|
||||
* - 识别 Manifest 版本 (V2/V3)
|
||||
* - 检测移动端环境
|
||||
* - 识别开发版本
|
||||
*/
|
||||
|
||||
/**
|
||||
* 平台检测器
|
||||
* 提供浏览器环境检测的核心功能
|
||||
*/
|
||||
const platformDetector = {
|
||||
/**
|
||||
* 检测当前浏览器平台
|
||||
*
|
||||
* @returns {Object} 平台检测结果
|
||||
* @property {string} platform - 浏览器类型 (firefox/chrome/edge/opera/safari/unknown)
|
||||
* @property {number} version - 浏览器主版本号
|
||||
* @property {number} manifestVersion - Manifest 版本号
|
||||
* @property {boolean} isMobile - 是否为移动端
|
||||
* @property {boolean} isDevBuild - 是否为开发版本
|
||||
*/
|
||||
detect() {
|
||||
const manifest =
|
||||
typeof browser !== "undefined" && browser.runtime
|
||||
? browser.runtime.getManifest()
|
||||
: chrome.runtime.getManifest();
|
||||
|
||||
const ua = navigator.userAgent;
|
||||
const extURL =
|
||||
typeof browser !== "undefined" && browser.runtime
|
||||
? browser.runtime.getURL("")
|
||||
: chrome.runtime.getURL("");
|
||||
|
||||
let platform = "unknown";
|
||||
let version = 0;
|
||||
|
||||
// 根据扩展 URL 前缀检测浏览器类型
|
||||
if (extURL.startsWith("moz-extension://")) {
|
||||
platform = "firefox";
|
||||
const match = /Firefox\/(\d+)/.exec(ua);
|
||||
version = match ? parseInt(match[1], 10) : 115;
|
||||
} else if (extURL.startsWith("safari-web-extension://")) {
|
||||
platform = "safari";
|
||||
const match = /Version\/(\d+)/.exec(ua);
|
||||
version = match ? parseInt(match[1], 10) : 15;
|
||||
} else if (/\bEdg\/\b/.test(ua)) {
|
||||
platform = "edge";
|
||||
const match = /Edg\/(\d+)/.exec(ua);
|
||||
version = match ? parseInt(match[1], 10) : 120;
|
||||
} else if (/\bOPR\/\b/.test(ua)) {
|
||||
platform = "opera";
|
||||
const match = /OPR\/(\d+)/.exec(ua);
|
||||
version = match ? parseInt(match[1], 10) : 120;
|
||||
} else {
|
||||
platform = "chrome";
|
||||
const match = /Chrome\/(\d+)/.exec(ua);
|
||||
version = match ? parseInt(match[1], 10) : 120;
|
||||
}
|
||||
|
||||
return {
|
||||
platform,
|
||||
version,
|
||||
manifestVersion: manifest.manifest_version,
|
||||
isMobile: /\bMobile\b/.test(ua),
|
||||
isDevBuild: /^\d+\.\d+\.\d+\D/.test(manifest.version),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取平台特定的能力
|
||||
*/
|
||||
getCapabilities(platformInfo = null) {
|
||||
const info = platformInfo || this.detect();
|
||||
|
||||
const capabilities = {
|
||||
// 基础能力
|
||||
downloads: true,
|
||||
storage: true,
|
||||
tabs: true,
|
||||
|
||||
// 高级能力
|
||||
declarativeNetRequest: false,
|
||||
offscreen: false,
|
||||
scripting: false,
|
||||
|
||||
// 特殊能力
|
||||
popupResize: true,
|
||||
contextMenus: true,
|
||||
notifications: true,
|
||||
};
|
||||
|
||||
// Firefox 特定能力
|
||||
if (info.platform === "firefox") {
|
||||
capabilities.webRequest = true;
|
||||
capabilities.contentSecurityPolicy = false; // 在 Manifest V2 中有限制
|
||||
capabilities.popupResize = false; // Firefox 不支持动态调整弹窗大小
|
||||
}
|
||||
|
||||
// Chromium 特定能力
|
||||
if (
|
||||
info.platform === "chrome" ||
|
||||
info.platform === "edge" ||
|
||||
info.platform === "opera"
|
||||
) {
|
||||
if (info.manifestVersion === 3) {
|
||||
capabilities.declarativeNetRequest = true;
|
||||
capabilities.offscreen = info.version >= 109;
|
||||
capabilities.scripting = true;
|
||||
capabilities.webRequest = false; // MV3 中 webRequest 被限制
|
||||
} else {
|
||||
capabilities.webRequest = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Safari 特定能力
|
||||
if (info.platform === "safari") {
|
||||
capabilities.notifications = false; // Safari 扩展通知支持有限
|
||||
capabilities.contextMenus = false; // Safari 扩展上下文菜单支持有限
|
||||
}
|
||||
|
||||
return capabilities;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取平台特定的限制
|
||||
*/
|
||||
getLimitations(platformInfo = null) {
|
||||
const info = platformInfo || this.detect();
|
||||
|
||||
const limitations = {
|
||||
maxStorageSize: Infinity,
|
||||
maxDownloadConcurrency: 10,
|
||||
requiresUserGesture: false,
|
||||
contentScriptLimitations: [],
|
||||
};
|
||||
|
||||
if (info.platform === "firefox") {
|
||||
limitations.maxStorageSize = 5 * 1024 * 1024; // 5MB for local storage
|
||||
limitations.contentScriptLimitations = [
|
||||
"limited-eval", // eval() 受限
|
||||
"no-dynamic-import", // 动态导入受限
|
||||
];
|
||||
}
|
||||
|
||||
if (info.platform === "safari") {
|
||||
limitations.maxStorageSize = 10 * 1024 * 1024; // 10MB
|
||||
limitations.requiresUserGesture = true; // 许多操作需要用户手势
|
||||
limitations.maxDownloadConcurrency = 3; // 下载并发限制更严格
|
||||
}
|
||||
|
||||
return limitations;
|
||||
},
|
||||
};
|
||||
|
||||
// 导出
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = { platformDetector };
|
||||
} else {
|
||||
window.platformDetector = platformDetector;
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* 平台配置对象,包含各个平台的基础URL和名称
|
||||
* @type {Object.<string, {base: string, name: string, pattern: RegExp}>}
|
||||
*/
|
||||
export 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\//,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 统一的路径转换函数
|
||||
* @param {string} path - 原始路径
|
||||
* @param {string} platformKey - 平台键值
|
||||
* @returns {string} - 转换后的路径
|
||||
*/
|
||||
export function transformPath(path, platformKey) {
|
||||
if (!PLATFORMS[platformKey]) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const prefix = `/${platformKey.replace(/-/g, "/")}/`;
|
||||
let transformedPath = path.replace(
|
||||
new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`),
|
||||
"/"
|
||||
);
|
||||
|
||||
// 特殊处理 crates.io API 路径
|
||||
if (platformKey === "crates") {
|
||||
// 转换路径以包含 API 前缀
|
||||
if (transformedPath.startsWith("/")) {
|
||||
// 处理不同的 API 端点:
|
||||
// /serde/1.0.0/download -> /api/v1/crates/serde/1.0.0/download
|
||||
// /serde -> /api/v1/crates/serde
|
||||
// /?q=query -> /api/v1/crates?q=query
|
||||
if (transformedPath === "/" || transformedPath.startsWith("/?")) {
|
||||
// 搜索端点
|
||||
transformedPath = transformedPath.replace("/", "/api/v1/crates");
|
||||
} else {
|
||||
// 特定 crate 端点
|
||||
transformedPath = `/api/v1/crates${transformedPath}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return transformedPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据URL检测平台
|
||||
* @param {string} url - 要检测的URL
|
||||
* @returns {string|null} - 平台键值或null
|
||||
*/
|
||||
export function detectPlatform(url) {
|
||||
for (const [key, platform] of Object.entries(PLATFORMS)) {
|
||||
if (platform.pattern.test(url)) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换URL为Xget格式
|
||||
* @param {string} url - 原始URL
|
||||
* @param {string} xgetDomain - Xget域名
|
||||
* @param {Object} enabledPlatforms - 启用的平台配置
|
||||
* @returns {string|null} - 转换后的URL或null
|
||||
*/
|
||||
export 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;
|
||||
const transformedPath = transformPath(path, platform);
|
||||
|
||||
return `https://${xgetDomain}/${platform}${transformedPath}`;
|
||||
} catch (error) {
|
||||
console.error("转换 URL 时出错:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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