mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-10 02:20:28 +00:00
Compare commits
319
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfda439b82 | ||
|
|
c2ada7dbaa | ||
|
|
6f0cfdcd91 | ||
|
|
fdfc6902a7 | ||
|
|
9fb3758203 | ||
|
|
e2ac8ce64b | ||
|
|
c01d11305c | ||
|
|
def54f8998 | ||
|
|
a205c0b8d7 | ||
|
|
ae70dc240c | ||
|
|
d7f6c3b206 | ||
|
|
a83e38dac4 | ||
|
|
d46c06f08d | ||
|
|
a61cdbed67 | ||
|
|
daa8c39dad | ||
|
|
a5cd53f94c | ||
|
|
847e88881d | ||
|
|
3d20e818e5 | ||
|
|
09f7c88ed3 | ||
|
|
f7b26753c1 | ||
|
|
9704d4af27 | ||
|
|
1907f404ac | ||
|
|
72c5ba7d20 | ||
|
|
5b0254f7c0 | ||
|
|
d36b345f92 | ||
|
|
bc5c818eae | ||
|
|
98bd3305b7 | ||
|
|
3f38848051 | ||
|
|
82d7f8ec81 | ||
|
|
a6981d58e9 | ||
|
|
1b1631b41a | ||
|
|
3eb6df55df | ||
|
|
6de63c3f63 | ||
|
|
969615f353 | ||
|
|
5a88b98314 | ||
|
|
2674b1c39e | ||
|
|
4c90b38247 | ||
|
|
93088b22f1 | ||
|
|
fe90c1d261 | ||
|
|
640e798d70 | ||
|
|
ba2df0ecff | ||
|
|
d61da67456 | ||
|
|
7c696ee6e0 | ||
|
|
7b840b4261 | ||
|
|
a3c1f84c2c | ||
|
|
8d33e1a5b3 | ||
|
|
bad7f1d06c | ||
|
|
af1c8b2719 | ||
|
|
d90af0b9d1 | ||
|
|
6016ee1d24 | ||
|
|
e275fd58bd | ||
|
|
c9626cb389 | ||
|
|
f86a379f93 | ||
|
|
a9eee26be7 | ||
|
|
abd16d5326 | ||
|
|
6756d82ffa | ||
|
|
71c9e6947a | ||
|
|
24db9bb58b | ||
|
|
de0afb1481 | ||
|
|
cb6331c20d | ||
|
|
b0e133b554 | ||
|
|
dbe37befa3 | ||
|
|
0d5c4e8e7f | ||
|
|
5515a1c48c | ||
|
|
3abf6ed933 | ||
|
|
af40e8aa63 | ||
|
|
64c5ece02c | ||
|
|
4ccbc3ebc5 | ||
|
|
14d9642d3c | ||
|
|
2f5fbfb17c | ||
|
|
49b4d99b5e | ||
|
|
9d9f1caaaa | ||
|
|
5bb8541385 | ||
|
|
f7189668fd | ||
|
|
1b36ed1807 | ||
|
|
07dc196c3c | ||
|
|
76bec67ab2 | ||
|
|
a2a9e3dc2a | ||
|
|
9a5680fb45 | ||
|
|
75a8a3a9ac | ||
|
|
3e8c85b945 | ||
|
|
90f70d1bf6 | ||
|
|
20a70d7f91 | ||
|
|
d33546a4b5 | ||
|
|
93614d46a8 | ||
|
|
04b0e691eb | ||
|
|
7f08fa0b23 | ||
|
|
df6a51d23c | ||
|
|
46c379d556 | ||
|
|
46cd9f7a3e | ||
|
|
5c2f66f4b6 | ||
|
|
cfdcb586ee | ||
|
|
55e6bdc4f7 | ||
|
|
b31b42301e | ||
|
|
7ce0d876b0 | ||
|
|
2fd2596aa0 | ||
|
|
e0973e4a34 | ||
|
|
4f7b8a05ad | ||
|
|
2927d1ef2b | ||
|
|
3451f577ab | ||
|
|
06671be0d8 | ||
|
|
8ae9363f0f | ||
|
|
5d84445f8f | ||
|
|
7c541e4047 | ||
|
|
3262e5a11b | ||
|
|
ff43a373cd | ||
|
|
7394d948e4 | ||
|
|
5afb3aa43b | ||
|
|
7705af6e68 | ||
|
|
684e060ef3 | ||
|
|
0c67792441 | ||
|
|
b0ef2c2a87 | ||
|
|
1977c2a0fc | ||
|
|
d289708f9e | ||
|
|
cd5fd45667 | ||
|
|
50bc9ab129 | ||
|
|
bfad91dcab | ||
|
|
61a959adda | ||
|
|
f4b839fd11 | ||
|
|
969aeb5e0a | ||
|
|
68d42eb397 | ||
|
|
08603f9510 | ||
|
|
b450bf67bd | ||
|
|
03e26dbea8 | ||
|
|
046a3ac9f9 | ||
|
|
9c5aebaf36 | ||
|
|
a094715d69 | ||
|
|
69bb875172 | ||
|
|
589f10c7ef | ||
|
|
654e57e946 | ||
|
|
b94b99ae09 | ||
|
|
fba990a056 | ||
|
|
cb07ce58bc | ||
|
|
50beef7b08 | ||
|
|
bd39e4110f | ||
|
|
86efcb98c5 | ||
|
|
f3d9873395 | ||
|
|
ef228c1722 | ||
|
|
6863317e9d | ||
|
|
2b83a735cb | ||
|
|
447f599783 | ||
|
|
f986f6253d | ||
|
|
2e1b621250 | ||
|
|
d50947b2e6 | ||
|
|
520a72415c | ||
|
|
a372b67edb | ||
|
|
2d86b5c132 | ||
|
|
63b3144633 | ||
|
|
1b0178fed9 | ||
|
|
83febe0a00 | ||
|
|
987580d400 | ||
|
|
0919936050 | ||
|
|
acc7ed8f6b | ||
|
|
ccb3267fba | ||
|
|
1249493a8a | ||
|
|
107141bbe7 | ||
|
|
b329fee107 | ||
|
|
3ce95f21fd | ||
|
|
f54ed5df5a | ||
|
|
727b7edb84 | ||
|
|
f5891ee03d | ||
|
|
724276ac69 | ||
|
|
a3f559c8e5 | ||
|
|
edd189a8a8 | ||
|
|
c970378fd7 | ||
|
|
8b61343aef | ||
|
|
2cb93a03e2 | ||
|
|
81dfc073a6 | ||
|
|
427921ccab | ||
|
|
f4876c8a0e | ||
|
|
35a3625b11 | ||
|
|
dc9b501e35 | ||
|
|
85fe92a378 | ||
|
|
983d9ad09e | ||
|
|
d9f757aaf0 | ||
|
|
cd409fcfb9 | ||
|
|
366244fa5d | ||
|
|
7a2e9b4fd1 | ||
|
|
203385d3f8 | ||
|
|
aaf73dec24 | ||
|
|
2523282a89 | ||
|
|
3384f5197a | ||
|
|
cd77665f02 | ||
|
|
a22b431150 | ||
|
|
eb7c9ad7f2 | ||
|
|
ea11fc38f1 | ||
|
|
1be16e9f2c | ||
|
|
1d86d14a20 | ||
|
|
5ad15cc4ac | ||
|
|
49604966fd | ||
|
|
bdb8ed177f | ||
|
|
2dc7048cba | ||
|
|
d3750464aa | ||
|
|
5c9bcd3731 | ||
|
|
bdda550b2c | ||
|
|
3308d966f0 | ||
|
|
18041ed9ec | ||
|
|
56b85e545e | ||
|
|
c5af916952 | ||
|
|
a1981328aa | ||
|
|
51244133e0 | ||
|
|
0c17093b1a | ||
|
|
6538b29b62 | ||
|
|
81e90e6b89 | ||
|
|
4f39296664 | ||
|
|
ece5b91dc2 | ||
|
|
d9f04ae928 | ||
|
|
e3ccecbd9c | ||
|
|
fb1c2cb060 | ||
|
|
9bff75464d | ||
|
|
863f759248 | ||
|
|
f89fb5e438 | ||
|
|
7ca1cc8c64 | ||
|
|
1f791c66e6 | ||
|
|
4d60c47d39 | ||
|
|
3fd3dd49d2 | ||
|
|
d119de7b98 | ||
|
|
69b359a9c9 | ||
|
|
4fbe13a1f0 | ||
|
|
3b50465708 | ||
|
|
473c58796c | ||
|
|
fab8f135f3 | ||
|
|
0fca7b6bc7 | ||
|
|
4700d0ba28 | ||
|
|
6e31ea089f | ||
|
|
4a2088f6fb | ||
|
|
17c8fb7e4d | ||
|
|
8c99bbc847 | ||
|
|
06c7fe7a22 | ||
|
|
8b3b5d4394 | ||
|
|
2ee73ab619 | ||
|
|
15413dbc95 | ||
|
|
c7c43fac80 | ||
|
|
4283bdfa5a | ||
|
|
328ba8592c | ||
|
|
a191a4140d | ||
|
|
3521837964 | ||
|
|
b30cd78f86 | ||
|
|
0583e4aaa6 | ||
|
|
a89c55b5c4 | ||
|
|
68c40d7f3f | ||
|
|
42a0b89d95 | ||
|
|
1860f534cd | ||
|
|
5c2d2ad7b4 | ||
|
|
db6352ada8 | ||
|
|
d1a5ed8b3d | ||
|
|
be9583be0f | ||
|
|
d2c70b077b | ||
|
|
53b3eea4fc | ||
|
|
54347f41b6 | ||
|
|
cea454b015 | ||
|
|
070db64ca9 | ||
|
|
6a8956b04d | ||
|
|
057504bb81 | ||
|
|
470b488824 | ||
|
|
c8c9df1ae8 | ||
|
|
33d787c74e | ||
|
|
67338a5e83 | ||
|
|
723697192d | ||
|
|
4294d07d6a | ||
|
|
3936fd786f | ||
|
|
45197e9a3b | ||
|
|
c9692e76e5 | ||
|
|
0a9e39ed75 | ||
|
|
1971f582b5 | ||
|
|
c62d6be3da | ||
|
|
7d5e503894 | ||
|
|
6c4066fde4 | ||
|
|
ffdf3e717f | ||
|
|
f66814b4ae | ||
|
|
0c3e201a51 | ||
|
|
c32a5d750f | ||
|
|
207c287c97 | ||
|
|
56ef25065e | ||
|
|
bd6d6be71f | ||
|
|
1d3a028bae | ||
|
|
27011fb96b | ||
|
|
c696b59c4b | ||
|
|
78b5b8a78b | ||
|
|
c81ca7f5cd | ||
|
|
e39d50fe09 | ||
|
|
086269f8bd | ||
|
|
00d2183961 | ||
|
|
5f914dc4c4 | ||
|
|
9bf204cf04 | ||
|
|
a816b9ccc8 | ||
|
|
d60d370463 | ||
|
|
84d054b494 | ||
|
|
1aa96e147f | ||
|
|
8c01e45780 | ||
|
|
8ad571aa08 | ||
|
|
39316e9b8e | ||
|
|
f778596390 | ||
|
|
c2cbc14492 | ||
|
|
62c22bc6b4 | ||
|
|
b89c7bc608 | ||
|
|
f2cd686563 | ||
|
|
ef5ee99f04 | ||
|
|
6d136604db | ||
|
|
38ff711ea9 | ||
|
|
a1a8abc25f | ||
|
|
abccbabc78 | ||
|
|
c26eb26b8f | ||
|
|
74beea6044 | ||
|
|
e548d7e0c9 | ||
|
|
a996d57914 | ||
|
|
f7dc60ad50 | ||
|
|
8425a96ac9 | ||
|
|
99f4e8c895 | ||
|
|
cc0f61b527 | ||
|
|
922ef969a6 | ||
|
|
3a542054fc | ||
|
|
895a8d2ee4 | ||
|
|
29d0f5ebcf | ||
|
|
31228e3edf | ||
|
|
31a32d7e14 | ||
|
|
65fd6fb489 | ||
|
|
953b878a89 | ||
|
|
ae4ca3da58 |
No files matched your search
@@ -0,0 +1,16 @@
|
||||
.git
|
||||
|
||||
*.data
|
||||
*.log
|
||||
|
||||
node_modules
|
||||
**/node_modules
|
||||
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
|
||||
.github
|
||||
_docs
|
||||
_examples
|
||||
bin
|
||||
ui
|
||||
@@ -0,0 +1 @@
|
||||
* @monkeyWie
|
||||
+1
-1
@@ -12,4 +12,4 @@
|
||||
#custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
# github: monkeyWie
|
||||
ko_fi: gopeed
|
||||
custom: https://docs.gopeed.com/donate.html
|
||||
custom: https://gopeed.com/docs/donate
|
||||
+173
-27
@@ -1,43 +1,189 @@
|
||||
name-template: 'v$NEXT_PATCH_VERSION 🌈'
|
||||
tag-template: 'v$NEXT_PATCH_VERSION'
|
||||
name-template: "v$NEXT_PATCH_VERSION 🌈"
|
||||
tag-template: "v$NEXT_PATCH_VERSION"
|
||||
categories:
|
||||
- title: '🆕 Features'
|
||||
- title: "🆕 Features"
|
||||
labels:
|
||||
- 'feat'
|
||||
- 'feature'
|
||||
- 'enhancement'
|
||||
- title: '🐛 Bug Fixes'
|
||||
- "feat"
|
||||
- "feature"
|
||||
- "enhancement"
|
||||
- title: "🐛 Bug Fixes"
|
||||
labels:
|
||||
- 'fix'
|
||||
- 'bugfix'
|
||||
- 'bug'
|
||||
- title: '🧪 Tests'
|
||||
label: 'test'
|
||||
- title: '🧰 Maintenance'
|
||||
label: 'chore'
|
||||
- title: '📖 Document'
|
||||
label: 'doc'
|
||||
- title: '🚀 CI/CD'
|
||||
label: 'ci'
|
||||
- title: '🌎 Internationalization'
|
||||
label: 'translation'
|
||||
change-template: '- $TITLE @$AUTHOR (#$NUMBER)'
|
||||
- "fix"
|
||||
- "bugfix"
|
||||
- "bug"
|
||||
- title: "🔧 Performance Improvements"
|
||||
labels:
|
||||
- "perf"
|
||||
- title: "🧪 Tests"
|
||||
label: "test"
|
||||
- title: "🧰 Maintenance"
|
||||
label: "chore"
|
||||
- title: "📖 Document"
|
||||
label: "docs"
|
||||
- title: "🚀 CI/CD"
|
||||
label: "ci"
|
||||
- title: "🌎 Internationalization"
|
||||
label: "translation"
|
||||
change-template: "- $TITLE @$AUTHOR (#$NUMBER)"
|
||||
version-resolver:
|
||||
major:
|
||||
labels:
|
||||
- 'major'
|
||||
- "major"
|
||||
minor:
|
||||
labels:
|
||||
- 'minor'
|
||||
- "minor"
|
||||
patch:
|
||||
labels:
|
||||
- 'patch'
|
||||
- "patch"
|
||||
default: patch
|
||||
template: |
|
||||
# Changes
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td rowspan="4">🪟 Windows</td>
|
||||
<td rowspan="2"><code>EXE</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-windows-amd64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-windows-arm64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="2"><code>Portable</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-windows-amd64-portable.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-windows-arm64-portable.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="3">🍎 MacOS</td>
|
||||
<td rowspan="3"><code>DMG</code></td>
|
||||
<td>universal</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-macos.dmg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-macos-amd64.dmg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-macos-arm64.dmg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="6">🐧 Linux</td>
|
||||
<td><code>Flathub</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://flathub.org/apps/com.gopeed.Gopeed">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>SNAP</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://snapcraft.io/gopeed">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="2"><code>DEB</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-linux-amd64.deb">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-linux-arm64.deb">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="2"><code>AppImage</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-linux-amd64.AppImage">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-linux-arm64.AppImage">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="4">🤖 Android</td>
|
||||
<td rowspan="4"><code>APK</code></td>
|
||||
<td>universal</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-android.apk">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>armeabi-v7a</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-android-armeabi-v7a.apk">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64-v8a</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-android-arm64-v8a.apk">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>x86_64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-android-x86_64.apk">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>📱 iOS</td>
|
||||
<td><code>IPA</code></td>
|
||||
<td>universal</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-ios.ipa">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>🐳 Docker</td>
|
||||
<td>-</td>
|
||||
<td>universal</td>
|
||||
<td><a href="https://hub.docker.com/r/liwei2633/gopeed">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="2">💾 Qnap</td>
|
||||
<td rowspan="2"><code>QPKG</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-v$NEXT_PATCH_VERSION-qnap-amd64.qpkg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-v$NEXT_PATCH_VERSION-qnap-arm64.qpkg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="8">🌐 Web</td>
|
||||
<td rowspan="3"><code>Windows</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-v$NEXT_PATCH_VERSION-windows-amd64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-v$NEXT_PATCH_VERSION-windows-arm64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>386</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-v$NEXT_PATCH_VERSION-windows-386.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="2"><code>MacOS</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-v$NEXT_PATCH_VERSION-macos-amd64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-v$NEXT_PATCH_VERSION-macos-arm64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="3"><code>Linux</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-v$NEXT_PATCH_VERSION-linux-amd64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-v$NEXT_PATCH_VERSION-linux-arm64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>386</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-v$NEXT_PATCH_VERSION-linux-386.zip">📥</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
# Release notes
|
||||
|
||||
$CHANGES
|
||||
|
||||
|
||||
# 更新日志
|
||||
|
||||
$CHANGES
|
||||
$CHANGES
|
||||
+424
-221
@@ -4,17 +4,19 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
platform:
|
||||
description: 'Build platform'
|
||||
description: "Build platform"
|
||||
required: true
|
||||
default: 'all'
|
||||
channel:
|
||||
description: 'Snap channel'
|
||||
default: "all"
|
||||
test:
|
||||
description: "Test mode"
|
||||
required: true
|
||||
default: 'stable'
|
||||
default: "false"
|
||||
|
||||
env:
|
||||
GO_VERSION: "1.19"
|
||||
FLUTTER_VERSION: "3.16.1"
|
||||
GO_VERSION: "1.25"
|
||||
FLUTTER_VERSION: "3.41.6"
|
||||
GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }}
|
||||
GA4_API_SECRET: ${{ secrets.GA4_API_SECRET }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -23,57 +25,113 @@ jobs:
|
||||
get-release:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tag_name: ${{ steps.get-release.outputs.tag_name }}
|
||||
version: ${{ steps.get-release.outputs.version }}
|
||||
upload_url: ${{ steps.get-release.outputs.upload_url }}
|
||||
|
||||
steps:
|
||||
- uses: monkeyWie/get-latest-release@v1
|
||||
- uses: monkeyWie/get-latest-release@v2.1
|
||||
id: get-release
|
||||
with:
|
||||
myToken: ${{ github.token }}
|
||||
|
||||
build-windows:
|
||||
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows' }}
|
||||
runs-on: windows-latest
|
||||
needs: [ get-release ]
|
||||
needs: [get-release]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: arm64
|
||||
os: windows-11-arm
|
||||
llvm_ver: "20251202" # Only ARM64
|
||||
flutter_channel: "main"
|
||||
flutter_version: "7e1c8868"
|
||||
- arch: amd64
|
||||
os: windows-2022
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
- name: Enable long paths for flutter main branch checks
|
||||
run: |
|
||||
git config --global core.longpaths true
|
||||
|
||||
# Install LLVM environment only on ARM64
|
||||
- name: Install llvm-mingw-ucrt-aarch64 (ARM64 only)
|
||||
if: matrix.arch == 'arm64'
|
||||
run: |
|
||||
$ver = "${{ matrix.llvm_ver }}"
|
||||
$url = "https://github.com/mstorsjo/llvm-mingw/releases/download/$ver/llvm-mingw-$ver-ucrt-aarch64.zip"
|
||||
$zip = "$env:RUNNER_TEMP\\llvm.zip"
|
||||
$extract = "$env:RUNNER_TEMP\\extract"
|
||||
$target = "C:\\clangarm64"
|
||||
|
||||
curl -L $url -o $zip
|
||||
rm -r -fo $extract,$target -ea Ignore
|
||||
mkdir $extract | Out-Null
|
||||
|
||||
tar -xf $zip -C $extract
|
||||
mv (Get-ChildItem $extract)[0].FullName $target
|
||||
|
||||
$b = "$target\\bin"
|
||||
"CC=$b\\clang.exe" >> $env:GITHUB_ENV
|
||||
"CXX=$b\\clang++.exe" >> $env:GITHUB_ENV
|
||||
"CLANGARM64_BIN=$b" >> $env:GITHUB_ENV
|
||||
"CGO_ENABLED=1" >> $env:GITHUB_ENV
|
||||
"CLANGARM64_ROOT=$target" >> $env:GITHUB_ENV
|
||||
$b >> $env:GITHUB_PATH
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.20"
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
channel: ${{ matrix.flutter_channel || 'stable' }}
|
||||
flutter-version: ${{ matrix.flutter_version || env.FLUTTER_VERSION }}
|
||||
- name: Build
|
||||
env:
|
||||
VERSION: ${{ needs.get-release.outputs.tag_name }}
|
||||
VERSION: ${{ needs.get-release.outputs.version }}
|
||||
GOARCH: ${{ matrix.arch }}
|
||||
run: |
|
||||
Invoke-WebRequest -Uri "https://github.com/jrsoftware/issrc/raw/main/Files/Languages/Unofficial/ChineseSimplified.isl" -OutFile "ChineseSimplified.isl"
|
||||
mv ChineseSimplified.isl "C:\Program Files (x86)\Inno Setup 6\Languages\"
|
||||
|
||||
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/windows/libgopeed.dll github.com/GopeedLab/gopeed/bind/desktop
|
||||
go build -tags nosqlite -ldflags="-w -s -X github.com/GopeedLab/gopeed/pkg/base.Version=$env:VERSION" -buildmode=c-shared -o ui/flutter/windows/libgopeed.dll github.com/GopeedLab/gopeed/bind/desktop
|
||||
go build -ldflags="-w -s" -o ui/flutter/assets/exec/host.exe github.com/GopeedLab/gopeed/cmd/host
|
||||
go build -ldflags="-w -s" -o ui/flutter/assets/exec/updater.exe github.com/GopeedLab/gopeed/cmd/updater
|
||||
cd ui/flutter
|
||||
flutter build windows
|
||||
$mingw = "C:\Program Files\Git\mingw64\bin"
|
||||
$TAG = "v$env:VERSION"
|
||||
flutter build windows --dart-define="UPDATE_CHANNEL=windowsPortable" --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}"
|
||||
$system = "C:\Windows\System32"
|
||||
$release = "build\windows\x64\runner\Release\"
|
||||
cp $mingw\libstdc++-6.dll $release
|
||||
cp $mingw\libgcc_s_seh-1.dll $release
|
||||
if ("${{ matrix.arch }}" -eq "amd64") {
|
||||
# for amd64
|
||||
$release = "build\windows\x64\runner\Release\"
|
||||
$mingw = "C:\Program Files\Git\mingw64\bin"
|
||||
cp $mingw\libstdc++-6.dll $release
|
||||
cp $mingw\libgcc_s_seh-1.dll $release
|
||||
} else {
|
||||
# for ARM64
|
||||
$release = "build\windows\arm64\runner\Release\"
|
||||
$mingw = "$env:CLANGARM64_ROOT\aarch64-w64-mingw32\bin"
|
||||
cp $mingw\libc++.dll $release
|
||||
cp $mingw\libunwind.dll $release
|
||||
}
|
||||
cp $mingw\libwinpthread-1.dll $release
|
||||
cp $system\msvcp140.dll $release
|
||||
cp $system\vcruntime140.dll $release
|
||||
cp $system\vcruntime140_1.dll $release
|
||||
|
||||
New-Item -Path build\windows\Output -ItemType Directory
|
||||
Compress-Archive -Path "$release*" -DestinationPath "build\windows\Output\Gopeed-$env:VERSION-windows-amd64-portable.zip"
|
||||
Compress-Archive -Path "$release*" -DestinationPath "build\windows\Output\Gopeed-$TAG-windows-${{ matrix.arch }}-portable.zip"
|
||||
|
||||
flutter build windows --dart-define="UPDATE_CHANNEL=windowsInstaller" --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}"
|
||||
cd build/windows
|
||||
echo @"
|
||||
; Script generated by the Inno Setup Script Wizard.
|
||||
; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!
|
||||
|
||||
#define MyAppName "Gopeed"
|
||||
#define MyAppVersion "$($env:VERSION.substring(1))"
|
||||
#define MyAppVersion "$env:VERSION"
|
||||
#define MyAppPublisher "monkeyWie"
|
||||
#define MyAppURL "https://gopeed.com"
|
||||
#define MyAppExeName "gopeed.exe"
|
||||
@@ -96,11 +154,13 @@ jobs:
|
||||
PrivilegesRequired=lowest
|
||||
OutputBaseFilename=gopeed
|
||||
SetupIconFile=..\..\assets\icon\icon.ico
|
||||
UninstallDisplayIcon={app}\{#MyAppExeName}
|
||||
Compression=lzma
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
LanguageDetectionMethod=uilanguage
|
||||
ShowLanguageDialog=yes
|
||||
CloseApplications=force
|
||||
|
||||
[Languages]
|
||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
@@ -110,7 +170,7 @@ jobs:
|
||||
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
|
||||
|
||||
[Files]
|
||||
Source: ".\x64\runner\Release\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs
|
||||
Source: ".\${{ matrix.arch == 'amd64' && 'x64' || 'arm64' }}\runner\Release\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs;
|
||||
; NOTE: Don't use "Flags: ignoreversion" on any shared system files
|
||||
|
||||
[UninstallDelete]
|
||||
@@ -124,40 +184,47 @@ jobs:
|
||||
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
|
||||
"@ > setup.iss
|
||||
iscc.exe setup.iss
|
||||
mv "Output\gopeed.exe" "Output\Gopeed-$env:VERSION-windows-amd64.exe"
|
||||
Compress-Archive -Path "Output\Gopeed-$env:VERSION-windows-amd64.exe" -DestinationPath "Output\Gopeed-$env:VERSION-windows-amd64.zip"
|
||||
Remove-Item -Path "Output\Gopeed-$env:VERSION-windows-amd64.exe"
|
||||
mv "Output\gopeed.exe" "Output\Gopeed-$TAG-windows-${{ matrix.arch }}.exe"
|
||||
Compress-Archive -Path "Output\Gopeed-$TAG-windows-${{ matrix.arch }}.exe" -DestinationPath "Output\Gopeed-$TAG-windows-${{ matrix.arch }}.zip"
|
||||
- name: Upload
|
||||
uses: shogo82148/actions-upload-release-asset@v1
|
||||
uses: shogo82148/actions-upload-release-asset@v1.9.2
|
||||
with:
|
||||
upload_url: ${{ needs.get-release.outputs.upload_url }}
|
||||
asset_path: ui/flutter/build/windows/Output/*
|
||||
overwrite: true
|
||||
build-macos-arm64-lib:
|
||||
build-macos-amd64-lib:
|
||||
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ get-release ]
|
||||
runs-on: macos-15-intel
|
||||
needs: [get-release]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- name: Build
|
||||
env:
|
||||
VERSION: ${{ needs.get-release.outputs.version }}
|
||||
run: |
|
||||
go install src.techknowlogick.com/xgo@latest
|
||||
xgo -go go-$GO_VERSION.x --targets=darwin/arm64 -tags="nosqlite" -ldflags="-w -s" -buildmode=c-shared -pkg=bind/desktop -out=libgopeed .
|
||||
mv libgopeed-*.dylib libgopeed.dylib
|
||||
- uses: actions/upload-artifact@v3
|
||||
go build -tags nosqlite -ldflags="-w -s -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION" -buildmode=c-shared -o libgopeed.dylib github.com/GopeedLab/gopeed/bind/desktop
|
||||
go build -ldflags="-w -s" github.com/GopeedLab/gopeed/cmd/host
|
||||
go build -ldflags="-w -s" github.com/GopeedLab/gopeed/cmd/updater
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-arm64-lib
|
||||
path: libgopeed.dylib
|
||||
name: macos-amd64-lib
|
||||
path: |
|
||||
libgopeed.dylib
|
||||
host
|
||||
updater
|
||||
build-macos:
|
||||
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos' }}
|
||||
runs-on: macos-latest
|
||||
needs: [ get-release, build-macos-arm64-lib ]
|
||||
strategy:
|
||||
matrix:
|
||||
channel: [arm64, amd64, universal]
|
||||
needs: [get-release, build-macos-amd64-lib]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- uses: actions/setup-node@v3
|
||||
@@ -168,29 +235,51 @@ jobs:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
- name: Install appdmg
|
||||
run: |
|
||||
python3 -m pip install setuptools
|
||||
python3 -m pip install setuptools --break-system-packages
|
||||
npm install -g appdmg
|
||||
- uses: actions/download-artifact@v3
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: macos-arm64-lib
|
||||
path: ui/flutter/lib-arm64
|
||||
- name: Build
|
||||
env:
|
||||
VERSION: ${{ needs.get-release.outputs.tag_name }}
|
||||
name: macos-amd64-lib
|
||||
path: ui/flutter/lib-amd64
|
||||
- if: ${{ matrix.channel == 'arm64' }}
|
||||
name: Build Arm64 Libraries
|
||||
run: |
|
||||
PROJECT_DIR=$(pwd)
|
||||
go build -tags nosqlite -ldflags="-w -s -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION" -buildmode=c-shared -o ui/flutter/macos/Frameworks/libgopeed.dylib github.com/GopeedLab/gopeed/bind/desktop
|
||||
go build -ldflags="-w -s" -o ui/flutter/assets/exec/host github.com/GopeedLab/gopeed/cmd/host
|
||||
go build -ldflags="-w -s" -o ui/flutter/assets/exec/updater github.com/GopeedLab/gopeed/cmd/updater
|
||||
- if: ${{ matrix.channel == 'amd64' }}
|
||||
name: Build Amd64 Libraries
|
||||
run: |
|
||||
mkdir -p ui/flutter/macos/Frameworks
|
||||
|
||||
cp ui/flutter/lib-amd64/libgopeed.dylib ui/flutter/macos/Frameworks/libgopeed.dylib
|
||||
cp ui/flutter/lib-amd64/host ui/flutter/assets/exec/host
|
||||
cp ui/flutter/lib-amd64/updater ui/flutter/assets/exec/updater
|
||||
- if: ${{ matrix.channel == 'universal' }}
|
||||
name: Build Universal Libraries
|
||||
run: |
|
||||
mkdir -p ui/flutter/macos/Frameworks
|
||||
|
||||
# arm64 lib
|
||||
go build -tags nosqlite -ldflags="-w -s -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION" -buildmode=c-shared -o ui/flutter/.temp/libgopeed.dylib.arm64 github.com/GopeedLab/gopeed/bind/desktop
|
||||
go build -ldflags="-w -s" -o ui/flutter/.temp/host.arm64 github.com/GopeedLab/gopeed/cmd/host
|
||||
go build -ldflags="-w -s" -o ui/flutter/.temp/updater.arm64 github.com/GopeedLab/gopeed/cmd/updater
|
||||
|
||||
# amd64 lib
|
||||
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/lib-amd64/libgopeed.dylib github.com/GopeedLab/gopeed/bind/desktop
|
||||
# universal binary
|
||||
mkdir -p ui/flutter/macos/Frameworks
|
||||
cp ui/flutter/lib-amd64/libgopeed.dylib ui/flutter/macos/Frameworks/amd64-lib
|
||||
cp ui/flutter/lib-arm64/libgopeed.dylib ui/flutter/macos/Frameworks/arm64-lib
|
||||
cd ui/flutter/macos/Frameworks
|
||||
lipo -create -output libgopeed.dylib amd64-lib arm64-lib
|
||||
cp ui/flutter/lib-amd64/libgopeed.dylib ui/flutter/.temp/libgopeed.dylib.amd64
|
||||
cp ui/flutter/lib-amd64/host ui/flutter/.temp/host.amd64
|
||||
cp ui/flutter/lib-amd64/updater ui/flutter/.temp/updater.amd64
|
||||
|
||||
cd $PROJECT_DIR/ui/flutter
|
||||
flutter build macos
|
||||
# universal lib
|
||||
lipo -create -output ui/flutter/macos/Frameworks/libgopeed.dylib ui/flutter/.temp/libgopeed.dylib.arm64 ui/flutter/.temp/libgopeed.dylib.amd64
|
||||
lipo -create -output ui/flutter/assets/exec/host ui/flutter/.temp/host.arm64 ui/flutter/.temp/host.amd64
|
||||
lipo -create -output ui/flutter/assets/exec/updater ui/flutter/.temp/updater.arm64 ui/flutter/.temp/updater.amd64
|
||||
- name: Build
|
||||
env:
|
||||
VERSION: ${{ needs.get-release.outputs.version }}
|
||||
run: |
|
||||
cd ui/flutter
|
||||
flutter build macos --dart-define="UPDATE_CHANNEL=macosDmg" --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}"
|
||||
cd build/macos/Build/Products/Release
|
||||
cat>appdmg.json<<EOF
|
||||
{
|
||||
@@ -203,7 +292,14 @@ jobs:
|
||||
}
|
||||
EOF
|
||||
mkdir dist
|
||||
appdmg appdmg.json dist/Gopeed-$VERSION-macos.dmg
|
||||
appdmg appdmg.json dist/Gopeed-v$VERSION-macos.dmg
|
||||
|
||||
if [[ "${{ matrix.channel }}" == "arm64" ]]; then
|
||||
mv dist/Gopeed-v$VERSION-macos.dmg dist/Gopeed-v$VERSION-macos-arm64.dmg
|
||||
fi
|
||||
if [[ "${{ matrix.channel }}" == "amd64" ]]; then
|
||||
mv dist/Gopeed-v$VERSION-macos.dmg dist/Gopeed-v$VERSION-macos-amd64.dmg
|
||||
fi
|
||||
- name: Upload
|
||||
uses: shogo82148/actions-upload-release-asset@v1
|
||||
with:
|
||||
@@ -212,230 +308,258 @@ jobs:
|
||||
overwrite: true
|
||||
build-linux:
|
||||
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux' }}
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [ get-release ]
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-22.04, ubuntu-22.04-arm]
|
||||
needs: [get-release]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
channel: master
|
||||
- run: |
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y ninja-build libgtk-3-dev libappindicator3-dev
|
||||
sudo apt update -y
|
||||
sudo apt install -y ninja-build libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-1 libayatana-appindicator3-dev rpm patchelf libfuse2 locate libkeybinder-3.0-dev
|
||||
arch=$(uname -m)
|
||||
wget -O appimagetool "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${arch}.AppImage"
|
||||
chmod +x appimagetool
|
||||
sudo mv appimagetool /usr/local/bin/
|
||||
- name: Build
|
||||
env:
|
||||
VERSION: ${{ needs.get-release.outputs.tag_name }}
|
||||
VERSION: ${{ needs.get-release.outputs.version }}
|
||||
run: |
|
||||
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/linux/bundle/lib/libgopeed.so github.com/GopeedLab/gopeed/bind/desktop
|
||||
go build -tags nosqlite -ldflags="-w -s -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION" -buildmode=c-shared -o ui/flutter/linux/bundle/lib/libgopeed.so github.com/GopeedLab/gopeed/bind/desktop
|
||||
go build -ldflags="-w -s" -o ui/flutter/assets/exec/host github.com/GopeedLab/gopeed/cmd/host
|
||||
go build -ldflags="-w -s" -o ui/flutter/assets/exec/updater github.com/GopeedLab/gopeed/cmd/updater
|
||||
|
||||
cd ui/flutter
|
||||
flutter build linux
|
||||
mkdir -p build/dist
|
||||
|
||||
# Build portable
|
||||
tar -C build/linux/x64/release/bundle -czaf build/dist/Gopeed-$VERSION-linux-amd64-portable.tar.gz ./
|
||||
|
||||
# Build deb package
|
||||
mkdir -p debian/gopeed/opt/gopeed
|
||||
mkdir -p debian/gopeed/usr/share/applications
|
||||
mkdir -p debian/gopeed/usr/share/icons/hicolor/scalable/apps
|
||||
mkdir -p debian/gopeed/DEBIAN
|
||||
|
||||
cp -r build/linux/x64/release/bundle/* debian/gopeed/opt/gopeed/
|
||||
cp assets/icon/icon.svg debian/gopeed/usr/share/icons/hicolor/scalable/apps/com.gopeed.Gopeed.svg
|
||||
|
||||
cat>debian/gopeed/DEBIAN/postinst<<EOF
|
||||
#!/bin/bash
|
||||
ln -sf /opt/gopeed/gopeed /usr/bin/gopeed
|
||||
RPM_ARCH=$(uname -m)
|
||||
mkdir -p linux/packaging/rpm
|
||||
cat << EOF > linux/packaging/rpm/make_config.yaml
|
||||
display_name: Gopeed
|
||||
icon: assets/icon/icon.svg
|
||||
summary: High speed downloader.
|
||||
description: A high speed downloader that supports all platforms.
|
||||
group: Applications/Internet
|
||||
license: GPL-3.0
|
||||
url: https://github.com/GopeedLab/gopeed
|
||||
vendor: GopeedLab
|
||||
maintainer: GopeedLab <[email protected]>
|
||||
build_arch: $RPM_ARCH
|
||||
EOF
|
||||
|
||||
cat>debian/gopeed/DEBIAN/postrm<<EOF
|
||||
#!/bin/bash
|
||||
rm -rf /usr/bin/gopeed
|
||||
cat << EOF > distribute_options.yaml
|
||||
output: dist/
|
||||
releases:
|
||||
- name: linux
|
||||
jobs:
|
||||
- name: appimage
|
||||
package:
|
||||
platform: linux
|
||||
target: appimage
|
||||
build_args:
|
||||
dart-define:
|
||||
UPDATE_CHANNEL: linuxAppImage
|
||||
GA4_MEASUREMENT_ID: ${{ env.GA4_MEASUREMENT_ID }}
|
||||
GA4_API_SECRET: ${{ env.GA4_API_SECRET }}
|
||||
- name: deb
|
||||
package:
|
||||
platform: linux
|
||||
target: deb
|
||||
build_args:
|
||||
dart-define:
|
||||
UPDATE_CHANNEL: linuxDeb
|
||||
GA4_MEASUREMENT_ID: ${{ env.GA4_MEASUREMENT_ID }}
|
||||
GA4_API_SECRET: ${{ env.GA4_API_SECRET }}
|
||||
- name: rpm
|
||||
package:
|
||||
platform: linux
|
||||
target: rpm
|
||||
build_args:
|
||||
dart-define:
|
||||
UPDATE_CHANNEL: linuxRpm
|
||||
GA4_MEASUREMENT_ID: ${{ env.GA4_MEASUREMENT_ID }}
|
||||
GA4_API_SECRET: ${{ env.GA4_API_SECRET }}
|
||||
EOF
|
||||
dart pub global activate -sgit https://github.com/GopeedLab/flutter_distributor.git --git-path packages/flutter_distributor
|
||||
flutter_distributor release --name linux
|
||||
cd dist/*
|
||||
|
||||
chmod 0755 debian/gopeed/DEBIAN/postinst
|
||||
chmod 0755 debian/gopeed/DEBIAN/postrm
|
||||
|
||||
cat>debian/gopeed/DEBIAN/control<<EOF
|
||||
Package: gopeed
|
||||
Version: ${VERSION:1}
|
||||
Architecture: amd64
|
||||
Essential: no
|
||||
Priority: optional
|
||||
Depends:
|
||||
Maintainer: monkeyWie
|
||||
Description: High speed downloader that supports all platforms.
|
||||
EOF
|
||||
|
||||
cp linux/assets/com.gopeed.Gopeed.desktop debian/gopeed/usr/share/applications/gopeed.desktop
|
||||
|
||||
dpkg-deb --build debian/gopeed
|
||||
mv debian/gopeed.deb build/dist/Gopeed-$VERSION-linux-amd64.deb
|
||||
|
||||
# Build AppImage
|
||||
sudo apt-get -y install libfuse2
|
||||
wget -O appimage-builder https://github.com/AppImageCrafters/appimage-builder/releases/download/v1.1.0/appimage-builder-1.1.0-x86_64.AppImage
|
||||
chmod +x appimage-builder
|
||||
mkdir AppDir
|
||||
cp -r build/linux/x64/release/bundle/* AppDir
|
||||
mkdir -p AppDir/usr/share/icons/hicolor/scalable/apps/
|
||||
cp assets/icon/icon.svg AppDir/usr/share/icons/hicolor/scalable/apps/gopeed.svg
|
||||
cat>AppImageBuilder.yml<<EOF
|
||||
version: 1
|
||||
AppDir:
|
||||
path: ${PWD}/AppDir
|
||||
app_info:
|
||||
id: com.gopeed.gopeed
|
||||
name: Gopeed
|
||||
icon: gopeed
|
||||
version: ${VERSION:1}
|
||||
exec: gopeed
|
||||
exec_args: \$@
|
||||
apt:
|
||||
arch: amd64
|
||||
allow_unauthenticated: true
|
||||
sources:
|
||||
- sourceline: deb https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ jammy main restricted
|
||||
universe multiverse
|
||||
- sourceline: deb https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ jammy-updates main
|
||||
restricted universe multiverse
|
||||
- sourceline: deb https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ jammy-backports
|
||||
main restricted universe multiverse
|
||||
- sourceline: deb http://security.ubuntu.com/ubuntu/ jammy-security main restricted
|
||||
universe multiverse
|
||||
include: []
|
||||
files:
|
||||
include: []
|
||||
exclude:
|
||||
- usr/share/man
|
||||
- usr/share/doc/*/README.*
|
||||
- usr/share/doc/*/changelog.*
|
||||
- usr/share/doc/*/NEWS.*
|
||||
- usr/share/doc/*/TODO.*
|
||||
test:
|
||||
fedora-30:
|
||||
image: appimagecrafters/tests-env:fedora-30
|
||||
command: ./AppRun
|
||||
debian-stable:
|
||||
image: appimagecrafters/tests-env:debian-stable
|
||||
command: ./AppRun
|
||||
archlinux-latest:
|
||||
image: appimagecrafters/tests-env:archlinux-latest
|
||||
command: ./AppRun
|
||||
centos-7:
|
||||
image: appimagecrafters/tests-env:centos-7
|
||||
command: ./AppRun
|
||||
ubuntu-xenial:
|
||||
image: appimagecrafters/tests-env:ubuntu-xenial
|
||||
command: ./AppRun
|
||||
AppImage:
|
||||
arch: x86_64
|
||||
update-information: guess
|
||||
EOF
|
||||
./appimage-builder --skip-tests --recipe AppImageBuilder.yml
|
||||
mv Gopeed-*-x86_64.AppImage build/dist/Gopeed-$VERSION-linux-amd64.AppImage
|
||||
ARCH="amd64"
|
||||
if [[ "${{ matrix.os }}" == *-arm ]]; then
|
||||
ARCH="arm64"
|
||||
fi
|
||||
|
||||
mv gopeed-*-linux.AppImage Gopeed-v${VERSION}-linux-${ARCH}.AppImage
|
||||
mv gopeed-*-linux.deb Gopeed-v${VERSION}-linux-${ARCH}.deb
|
||||
mv gopeed-*-linux.rpm Gopeed-v${VERSION}-linux-${ARCH}.rpm
|
||||
- name: Upload
|
||||
uses: shogo82148/actions-upload-release-asset@v1
|
||||
with:
|
||||
upload_url: ${{ needs.get-release.outputs.upload_url }}
|
||||
asset_path: ui/flutter/build/dist/*
|
||||
asset_path: ui/flutter/dist/*/*
|
||||
overwrite: true
|
||||
build-snap:
|
||||
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'snap' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ get-release ]
|
||||
needs: [get-release]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
- name: Setup LXD
|
||||
uses: canonical/[email protected]
|
||||
with:
|
||||
channel: latest/stable
|
||||
- name: Build
|
||||
env:
|
||||
VERSION: ${{ needs.get-release.outputs.tag_name }}
|
||||
VERSION: ${{ needs.get-release.outputs.version }}
|
||||
run: |
|
||||
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/linux/bundle/lib/libgopeed.so github.com/GopeedLab/gopeed/bind/desktop
|
||||
go build -tags nosqlite -ldflags="-w -s -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION" -buildmode=c-shared -o ui/flutter/linux/bundle/lib/libgopeed.so github.com/GopeedLab/gopeed/bind/desktop
|
||||
go build -ldflags="-w -s" -o ui/flutter/assets/exec/host github.com/GopeedLab/gopeed/cmd/host
|
||||
go build -ldflags="-w -s" -o ui/flutter/assets/exec/updater github.com/GopeedLab/gopeed/cmd/updater
|
||||
cd ui/flutter
|
||||
|
||||
|
||||
sudo snap install snapcraft --classic
|
||||
mkdir -p snap/gui
|
||||
cp assets/icon/icon.svg snap/gui/gopeed.svg
|
||||
|
||||
|
||||
cat>snap/snapcraft.yaml<<EOF
|
||||
name: gopeed
|
||||
version: ${VERSION:1}
|
||||
version: $VERSION
|
||||
summary: A modern download manager
|
||||
description: High speed downloader that supports all platforms.
|
||||
|
||||
|
||||
confinement: strict
|
||||
base: core22
|
||||
base: core24
|
||||
grade: stable
|
||||
|
||||
architectures:
|
||||
- build-on: amd64
|
||||
|
||||
platforms:
|
||||
amd64:
|
||||
build-on: amd64
|
||||
build-for: amd64
|
||||
|
||||
|
||||
slots:
|
||||
dbus-gopeed:
|
||||
interface: dbus
|
||||
bus: session
|
||||
name: com.gopeed.gopeed
|
||||
|
||||
|
||||
apps:
|
||||
gopeed:
|
||||
command: gopeed
|
||||
command-chain:
|
||||
- bin/gpu-2404-wrapper
|
||||
command: bin/gopeed
|
||||
extensions: [gnome]
|
||||
common-id: com.gopeed.Gopeed
|
||||
plugs:
|
||||
- network
|
||||
slots:
|
||||
- dbus-gopeed
|
||||
- home
|
||||
parts:
|
||||
gopeed:
|
||||
source: .
|
||||
plugin: flutter
|
||||
stage-packages:
|
||||
flutter-git:
|
||||
source: https://github.com/flutter/flutter.git
|
||||
source-tag: $FLUTTER_VERSION
|
||||
source-depth: 1
|
||||
plugin: nil
|
||||
override-build: |
|
||||
mkdir -p \$CRAFT_PART_INSTALL/usr/bin
|
||||
mkdir -p \$CRAFT_PART_INSTALL/usr/libexec
|
||||
cp -r \$CRAFT_PART_SRC \$CRAFT_PART_INSTALL/usr/libexec/flutter
|
||||
ln -s \$CRAFT_PART_INSTALL/usr/libexec/flutter/bin/flutter \$CRAFT_PART_INSTALL/usr/bin/flutter
|
||||
ln -s \$SNAPCRAFT_PART_INSTALL/usr/libexec/flutter/bin/dart \$SNAPCRAFT_PART_INSTALL/usr/bin/dart
|
||||
\$CRAFT_PART_INSTALL/usr/bin/flutter doctor
|
||||
build-packages:
|
||||
- clang
|
||||
- cmake
|
||||
- curl
|
||||
- libgtk-3-dev
|
||||
- libwebkit2gtk-4.1-dev
|
||||
- libkeybinder-3.0-dev
|
||||
- ninja-build
|
||||
- unzip
|
||||
- xz-utils
|
||||
- zip
|
||||
override-prime: ''
|
||||
zenity:
|
||||
plugin: nil
|
||||
stage-packages:
|
||||
- zenity
|
||||
gopeed:
|
||||
after: [flutter-git,zenity]
|
||||
source: .
|
||||
plugin: nil
|
||||
stage-packages:
|
||||
- libwebkit2gtk-4.1-0
|
||||
- libgtk-3-dev
|
||||
- libkeybinder-3.0-dev
|
||||
- libappindicator3-dev
|
||||
flutter-target: lib/main.dart
|
||||
override-build: |
|
||||
# work around pub get stack overflow # https://github.com/dart-lang/sdk/issues/51068#issuecomment-1396588253
|
||||
set +e
|
||||
dart pub get
|
||||
set -eux
|
||||
flutter build linux --dart-define="UPDATE_CHANNEL=linuxSnap" --dart-define="GA4_MEASUREMENT_ID=$GA4_MEASUREMENT_ID" --dart-define="GA4_API_SECRET=$GA4_API_SECRET"
|
||||
mkdir -p \$CRAFT_PART_INSTALL/bin/
|
||||
cp -r build/linux/*/release/bundle/* \$CRAFT_PART_INSTALL/bin/
|
||||
gpu-2404:
|
||||
after: [gopeed]
|
||||
source: https://github.com/canonical/gpu-snap.git
|
||||
plugin: dump
|
||||
override-prime: |
|
||||
craftctl default
|
||||
\${CRAFT_PART_SRC}/bin/gpu-2404-cleanup mesa-2404
|
||||
prime:
|
||||
- bin/gpu-2404-wrapper
|
||||
|
||||
plugs:
|
||||
gpu-2404:
|
||||
interface: content
|
||||
target: \$SNAP/gpu-2404
|
||||
default-provider: mesa-2404
|
||||
|
||||
layout:
|
||||
/usr/share/libdrm:
|
||||
bind: \$SNAP/gpu-2404/libdrm
|
||||
/usr/share/drirc.d:
|
||||
symlink: \$SNAP/gpu-2404/drirc.d
|
||||
/usr/share/X11/XErrorDB:
|
||||
symlink: \$SNAP/gpu-2404/X11/XErrorDB
|
||||
EOF
|
||||
|
||||
|
||||
cp linux/assets/com.gopeed.Gopeed.desktop snap/gui/gopeed.desktop
|
||||
sed -i 's/Icon=com.gopeed.Gopeed/Icon=\${SNAP}\/meta\/gui\/gopeed.svg/g' snap/gui/gopeed.desktop
|
||||
|
||||
|
||||
snapcraft --use-lxd
|
||||
|
||||
# Snapcraft login
|
||||
export SNAPCRAFT_STORE_CREDENTIALS=${{ secrets.SNAP_STORE_LOGIN }}
|
||||
snapcraft upload --release=${{ github.event.inputs.channel }} gopeed_${VERSION:1}_amd64.snap
|
||||
snapcraft upload --release=${{ github.event.inputs.test == 'true' && 'edge' || 'stable' }} gopeed_${VERSION}_amd64.snap
|
||||
build-android:
|
||||
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'android' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ get-release ]
|
||||
needs: [get-release]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- uses: actions/setup-java@v3
|
||||
with:
|
||||
distribution: "zulu"
|
||||
java-version: "11"
|
||||
java-version: "17"
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
- name: Build
|
||||
env:
|
||||
VERSION: ${{ needs.get-release.outputs.tag_name }}
|
||||
VERSION: ${{ needs.get-release.outputs.version }}
|
||||
APK_KEYSTORE: ${{ secrets.APK_KEYSTORE }}
|
||||
APK_KEY_PASSWORD: ${{ secrets.APK_KEY_PASSWORD }}
|
||||
APK_STORE_PASSWORD: ${{ secrets.APK_STORE_PASSWORD }}
|
||||
@@ -443,12 +567,16 @@ jobs:
|
||||
go install golang.org/x/mobile/cmd/gomobile@latest
|
||||
go get golang.org/x/mobile/bind
|
||||
gomobile init
|
||||
gomobile bind -tags nosqlite -ldflags="-w -s" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 19 -javapkg=com.gopeed github.com/GopeedLab/gopeed/bind/mobile
|
||||
gomobile bind -tags nosqlite -ldflags="-w -s -checklinkname=0 -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 21 -javapkg=com.gopeed github.com/GopeedLab/gopeed/bind/mobile
|
||||
cd ui/flutter
|
||||
echo $APK_KEYSTORE | base64 -di > android/app/upload-keystore.jks
|
||||
flutter build apk
|
||||
flutter build apk --dart-define="UPDATE_CHANNEL=androidApk" --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}"
|
||||
flutter build apk --split-per-abi --dart-define="UPDATE_CHANNEL=androidApk" --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}"
|
||||
mkdir dist
|
||||
cp build/app/outputs/flutter-apk/app-release.apk dist/Gopeed-$VERSION-android.apk
|
||||
cp build/app/outputs/flutter-apk/app-release.apk dist/Gopeed-v$VERSION-android.apk
|
||||
cp build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk dist/Gopeed-v$VERSION-android-armeabi-v7a.apk
|
||||
cp build/app/outputs/flutter-apk/app-arm64-v8a-release.apk dist/Gopeed-v$VERSION-android-arm64-v8a.apk
|
||||
cp build/app/outputs/flutter-apk/app-x86_64-release.apk dist/Gopeed-v$VERSION-android-x86_64.apk
|
||||
- name: Upload
|
||||
uses: shogo82148/actions-upload-release-asset@v1
|
||||
with:
|
||||
@@ -457,11 +585,11 @@ jobs:
|
||||
overwrite: true
|
||||
build-ios:
|
||||
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios' }}
|
||||
runs-on: macos-latest
|
||||
needs: [ get-release ]
|
||||
runs-on: macos-14
|
||||
needs: [get-release]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- uses: subosito/flutter-action@v2
|
||||
@@ -469,19 +597,19 @@ jobs:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
- name: Build
|
||||
env:
|
||||
VERSION: ${{ needs.get-release.outputs.tag_name }}
|
||||
VERSION: ${{ needs.get-release.outputs.version }}
|
||||
run: |
|
||||
go install golang.org/x/mobile/cmd/gomobile@latest
|
||||
go get golang.org/x/mobile/bind
|
||||
gomobile init
|
||||
gomobile bind -tags nosqlite -ldflags="-w -s" -o ui/flutter/ios/Frameworks/Libgopeed.xcframework -target=ios github.com/GopeedLab/gopeed/bind/mobile
|
||||
gomobile bind -tags nosqlite -ldflags="-w -s -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION" -o ui/flutter/ios/Frameworks/Libgopeed.xcframework -target=ios github.com/GopeedLab/gopeed/bind/mobile
|
||||
cd ui/flutter
|
||||
flutter build ios --no-codesign
|
||||
flutter build ios --no-codesign --dart-define="UPDATE_CHANNEL=iosIpa" --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}"
|
||||
mkdir Payload
|
||||
cp -r build/ios/iphoneos/Runner.app Payload
|
||||
zip -r -y Payload.zip Payload/Runner.app
|
||||
mkdir dist
|
||||
mv Payload.zip dist/Gopeed-$VERSION-ios.ipa
|
||||
mv Payload.zip dist/Gopeed-v$VERSION-ios.ipa
|
||||
- name: Upload
|
||||
uses: shogo82148/actions-upload-release-asset@v1
|
||||
with:
|
||||
@@ -489,12 +617,12 @@ jobs:
|
||||
asset_path: ui/flutter/dist/*
|
||||
overwrite: true
|
||||
build-web:
|
||||
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'web' }}
|
||||
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'web' || github.event.inputs.platform == 'qnap' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ get-release ]
|
||||
needs: [get-release]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- uses: subosito/flutter-action@v2
|
||||
@@ -502,13 +630,15 @@ jobs:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
- name: Build
|
||||
env:
|
||||
VERSION: ${{ needs.get-release.outputs.tag_name }}
|
||||
VERSION: ${{ needs.get-release.outputs.version }}
|
||||
run: |
|
||||
cd ui/flutter
|
||||
flutter build web --web-renderer html
|
||||
flutter build web --no-web-resources-cdn --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}"
|
||||
dart ../../.github/workflows/scripts/flutter_local_font.dart
|
||||
cd ../../
|
||||
cp -r ui/flutter/build/web cmd/web/dist
|
||||
mkdir -p dist/zip
|
||||
|
||||
goos_arr=(windows darwin linux)
|
||||
goarch_arr=(386 amd64 arm64)
|
||||
export CGO_ENABLED=0
|
||||
@@ -518,9 +648,9 @@ jobs:
|
||||
if [ $goos = "darwin" ]; then
|
||||
goos_name="macos"
|
||||
fi
|
||||
name=gopeed-web-$VERSION-$goos_name-$goarch
|
||||
name=gopeed-web-v$VERSION-$goos_name-$goarch
|
||||
dir="dist/$name/"
|
||||
(GOOS=$goos GOARCH=$goarch go build -tags nosqlite,web -ldflags="-s -w" -o $dir github.com/GopeedLab/gopeed/cmd/web \
|
||||
(GOOS=$goos GOARCH=$goarch go build -tags nosqlite,web -ldflags="-s -w -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION" -o $dir github.com/GopeedLab/gopeed/cmd/web \
|
||||
&& cd $dir \
|
||||
&& file=$(ls -AU | head -1) \
|
||||
&& mkdir $name \
|
||||
@@ -530,21 +660,83 @@ jobs:
|
||||
|| true
|
||||
done
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: web-dist
|
||||
path: dist/zip/
|
||||
- name: Upload
|
||||
uses: shogo82148/actions-upload-release-asset@v1
|
||||
with:
|
||||
upload_url: ${{ needs.get-release.outputs.upload_url }}
|
||||
asset_path: dist/zip/*
|
||||
overwrite: true
|
||||
build-qnap:
|
||||
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'qnap' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: [get-release, build-web]
|
||||
steps:
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.8.18"
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: web-dist
|
||||
path: dist/zip/
|
||||
- name: Build
|
||||
env:
|
||||
VERSION: ${{ needs.get-release.outputs.version }}
|
||||
run: |
|
||||
sudo apt update -y
|
||||
sudo apt install -y pv bsdmainutils
|
||||
wget -O qdk2_0.32.bionic_amd64.deb "https://github.com/qnap-dev/qdk2/releases/download/v0.32/qdk2_0.32.bionic_amd64.deb"
|
||||
dpkg -X qdk2_0.32.bionic_amd64.deb qdk2 # Direct installs will fail due to missing dependencies!
|
||||
[[ -d qdk2 ]] || exit 1
|
||||
|
||||
export PATH=$(pwd)/qdk2/usr/bin:$(pwd)/qdk2/usr/share/qdk2/QDK/bin:${PATH}
|
||||
wget -O Gopeed.template.tar.gz "https://github.com/GopeedLab/QpkgBuild/raw/refs/heads/master/template/Gopeed.template.tar.gz"
|
||||
tar -zxf Gopeed.template.tar.gz
|
||||
[[ -d Gopeed ]] || exit 1
|
||||
|
||||
goos=linux
|
||||
goarch_arr=(amd64 arm64)
|
||||
for goarch in "${goarch_arr[@]}"; do
|
||||
qarch=x86_64
|
||||
[[ "${goarch}" == "arm64" ]] && qarch=arm_64
|
||||
name=gopeed-web-v${VERSION}-${goos}-${goarch}
|
||||
unzip dist/zip/${name}.zip -d dist/${name}
|
||||
cp dist/${name}/${name}/* Gopeed/${qarch}/
|
||||
done
|
||||
cd Gopeed
|
||||
sed -i -e 's/__QPKG_VER__/${VERSION}/g' qpkg.cfg
|
||||
qbuild || exit 1
|
||||
|
||||
mkdir -p ../dist/qnap
|
||||
goos=qnap
|
||||
for goarch in "${goarch_arr[@]}"; do
|
||||
qarch=x86_64
|
||||
[[ "${goarch}" == "arm64" ]] && qarch=arm_64
|
||||
sname=Gopeed_${VERSION}_${qarch}.qpkg
|
||||
dname=gopeed-v${VERSION}-${goos}-${goarch}.qpkg
|
||||
[[ -f build/${sname} ]] && cp -ra build/${sname} ../dist/qnap/${dname}
|
||||
done
|
||||
- name: Upload
|
||||
uses: shogo82148/actions-upload-release-asset@v1
|
||||
with:
|
||||
upload_url: ${{ needs.get-release.outputs.upload_url }}
|
||||
asset_path: dist/qnap/*
|
||||
overwrite: true
|
||||
build-docker:
|
||||
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'docker' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ get-release ]
|
||||
needs: [get-release]
|
||||
steps:
|
||||
- name: Remove unnecessary files
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet
|
||||
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
@@ -555,18 +747,29 @@ jobs:
|
||||
with:
|
||||
username: liwei2633
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Build flutter web
|
||||
run: |
|
||||
cd ui/flutter
|
||||
flutter build web --no-web-resources-cdn --dart-define="UPDATE_CHANNEL=docker" --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}"
|
||||
dart ../../.github/workflows/scripts/flutter_local_font.dart
|
||||
cd ../../
|
||||
rm -rf cmd/web/dist
|
||||
cp -r ui/flutter/build/web cmd/web/dist
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v2
|
||||
env:
|
||||
VERSION: ${{ needs.get-release.outputs.tag_name }}
|
||||
VERSION: ${{ needs.get-release.outputs.version }}
|
||||
with:
|
||||
push: true
|
||||
context: .
|
||||
push: ${{ github.event.inputs.test == 'true' && 'false' || 'true' }}
|
||||
build-args: |
|
||||
VERSION=${{ env.VERSION }}
|
||||
IN_DOCKER=true
|
||||
platforms: |
|
||||
linux/386
|
||||
linux/amd64
|
||||
linux/arm64
|
||||
linux/arm/v6
|
||||
linux/arm/v7
|
||||
tags: |
|
||||
liwei2633/gopeed:latest
|
||||
liwei2633/gopeed:${{ env.VERSION }}
|
||||
liwei2633/gopeed:v${{ env.VERSION }}
|
||||
@@ -17,7 +17,7 @@ on:
|
||||
- "Dockerfile"
|
||||
|
||||
env:
|
||||
GO_VERSION: "1.19"
|
||||
GO_VERSION: "1.25"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- uses: release-drafter/release-drafter@v5
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
// Localize external Google Fonts (fonts.gstatic.com) references in Flutter Web build output.
|
||||
//
|
||||
// Usage (post-build):
|
||||
// # Run AFTER: flutter build web --no-web-resources-cdn
|
||||
// dart ../../.github/workflows/scripts/flutter_local_font.dart
|
||||
//
|
||||
// What it does:
|
||||
// In main.dart.js:
|
||||
// - Find any referenced resources under: https://fonts.gstatic.com/s/
|
||||
// (including the common pattern where Flutter concatenates the base URL with a relative path)
|
||||
// - Download them into: build/web/assets/gstatic/<path>
|
||||
// - Rewrite "https://fonts.gstatic.com/s/" -> "assets/gstatic/" so runtime loads locally
|
||||
// - Only download font subsets needed by supported locales (parsed from message.dart)
|
||||
//
|
||||
// Notes:
|
||||
// - This script DOES NOT call `flutter build web`. It only patches the output.
|
||||
// - This is a best-effort post-process. Always verify in browser devtools.
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
/// Get font families required for a specific locale based on language/script.
|
||||
/// Uses language code prefix to automatically detect the script system.
|
||||
/// Returns empty set if base fonts (Latin/Cyrillic/Greek) are sufficient.
|
||||
Set<String> _getFontFamiliesForLocale(String locale) {
|
||||
final lang = locale.toLowerCase().split('_').first;
|
||||
|
||||
// CJK languages - need specific large font files
|
||||
if (lang == 'zh') {
|
||||
// Simplified vs Traditional Chinese
|
||||
if (locale.contains('cn') || locale.contains('sg')) {
|
||||
return {'notosanssc'}; // Simplified Chinese
|
||||
}
|
||||
return {'notosanstc', 'notosanshk'}; // Traditional Chinese (TW, HK, MO)
|
||||
}
|
||||
if (lang == 'ja') return {'notosansjp'}; // Japanese
|
||||
if (lang == 'ko') return {'notosanskr'}; // Korean
|
||||
|
||||
// Arabic script languages
|
||||
if (lang == 'ar') return {'notosansarabic'}; // Arabic
|
||||
if (lang == 'fa') return {'notosansarabic'}; // Persian/Farsi
|
||||
if (lang == 'ur') return {'notosansarabic'}; // Urdu
|
||||
if (lang == 'ps') return {'notosansarabic'}; // Pashto
|
||||
if (lang == 'ku') return {'notosansarabic'}; // Kurdish (Arabic script)
|
||||
|
||||
// Hebrew script
|
||||
if (lang == 'he' || lang == 'yi') return {'notosanshebrew'};
|
||||
|
||||
// South Asian scripts
|
||||
if (lang == 'hi' || lang == 'mr' || lang == 'ne' || lang == 'sa') {
|
||||
return {'notosansdevanagari'}; // Hindi, Marathi, Nepali, Sanskrit
|
||||
}
|
||||
if (lang == 'bn' || lang == 'as')
|
||||
return {'notosansbengali'}; // Bengali, Assamese
|
||||
if (lang == 'ta')
|
||||
return {'notosanstamil', 'notosanstamilsupplement'}; // Tamil
|
||||
if (lang == 'te') return {'notosanstelugu'}; // Telugu
|
||||
if (lang == 'kn') return {'notosanskannada'}; // Kannada
|
||||
if (lang == 'ml') return {'notosansmalayalam'}; // Malayalam
|
||||
if (lang == 'gu') return {'notosansgujarati'}; // Gujarati
|
||||
if (lang == 'pa') return {'notosansgurmukhi'}; // Punjabi (Gurmukhi)
|
||||
if (lang == 'or') return {'notosansoriya'}; // Odia/Oriya
|
||||
if (lang == 'si') return {'notosanssinhala'}; // Sinhala
|
||||
|
||||
// Southeast Asian scripts
|
||||
if (lang == 'th') return {'notosansthai'}; // Thai
|
||||
if (lang == 'lo') return {'notosanslao'}; // Lao
|
||||
if (lang == 'my') return {'notosansmyanmar'}; // Myanmar/Burmese
|
||||
if (lang == 'km') return {'notosanskhmer'}; // Khmer/Cambodian
|
||||
if (lang == 'jv') return {'notosansjavanese'}; // Javanese
|
||||
|
||||
// Other scripts
|
||||
if (lang == 'ka') return {'notosansgeorgian'}; // Georgian
|
||||
if (lang == 'hy') return {'notosansarmenian'}; // Armenian
|
||||
if (lang == 'am' || lang == 'ti')
|
||||
return {'notosansethiopic'}; // Amharic, Tigrinya
|
||||
if (lang == 'mn') return {'notosansmongolian'}; // Mongolian
|
||||
|
||||
// Latin/Cyrillic/Greek based languages - base notosans is sufficient
|
||||
// Includes: English, German, French, Spanish, Italian, Portuguese,
|
||||
// Russian, Ukrainian, Polish, Turkish, Vietnamese, Greek, etc.
|
||||
return {};
|
||||
}
|
||||
|
||||
/// Base font families that are always included regardless of locale.
|
||||
/// These provide core functionality and are relatively small.
|
||||
const _baseFontFamilies = <String>{
|
||||
'notosans', // Base Latin/Cyrillic/Greek/Vietnamese
|
||||
'roboto', // Material Design default font
|
||||
'notosanssymbols', // Common symbols
|
||||
'notosanssymbols2', // Additional symbols
|
||||
'notosansmath', // Math symbols
|
||||
'notomusic', // Music notation symbols
|
||||
// Note: notocoloremoji (~24MB) and notoemoji (~860KB) are excluded
|
||||
// to reduce bundle size. Add them back if emoji support is needed.
|
||||
};
|
||||
|
||||
/// Parse supported locales from message.dart file.
|
||||
/// Reads the import statements and extracts locale codes like 'zh_cn', 'en_us', etc.
|
||||
Set<String> _parseSupportedLocales(File messageFile) {
|
||||
final locales = <String>{};
|
||||
|
||||
if (!messageFile.existsSync()) {
|
||||
_fail('Warning: message.dart not found, only base fonts will be used');
|
||||
}
|
||||
|
||||
final content = messageFile.readAsStringSync();
|
||||
|
||||
// Match import statements like: import 'langs/zh_cn.dart';
|
||||
final importRegex = RegExp(r"import\s+'langs/(\w+)\.dart'");
|
||||
for (final match in importRegex.allMatches(content)) {
|
||||
final locale = match.group(1);
|
||||
if (locale != null) {
|
||||
locales.add(locale);
|
||||
}
|
||||
}
|
||||
|
||||
if (locales.isEmpty) {
|
||||
_fail(
|
||||
'Warning: No locales found in message.dart, only base fonts will be used');
|
||||
}
|
||||
|
||||
return locales;
|
||||
}
|
||||
|
||||
/// Get required font families for the given locales.
|
||||
/// Returns a set of font family names that should be included.
|
||||
/// Automatically detects required fonts based on language code prefix.
|
||||
Set<String> _getRequiredFontFamilies(Set<String> locales) {
|
||||
final families = <String>{..._baseFontFamilies};
|
||||
for (final locale in locales) {
|
||||
final localeFamilies = _getFontFamiliesForLocale(locale);
|
||||
families.addAll(localeFamilies);
|
||||
}
|
||||
return families;
|
||||
}
|
||||
|
||||
/// Check if a font file path should be included based on required font families.
|
||||
/// Font paths have the format: "fontfamily/version/filename.ext"
|
||||
/// Example: "notosanssc/v36/xxx.ttf" -> font family is "notosanssc"
|
||||
bool _fontMatchesRequiredFamilies(
|
||||
String fontPath, Set<String> requiredFamilies) {
|
||||
// Extract font family from path (first segment before '/')
|
||||
final slashIndex = fontPath.indexOf('/');
|
||||
if (slashIndex == -1) {
|
||||
// No slash found, include by default (unusual path format)
|
||||
return true;
|
||||
}
|
||||
|
||||
final fontFamily = fontPath.substring(0, slashIndex).toLowerCase();
|
||||
return requiredFamilies.contains(fontFamily);
|
||||
}
|
||||
|
||||
Future<void> main(List<String> args) async {
|
||||
try {
|
||||
// This script is intentionally argument-free for CI convenience.
|
||||
// It assumes it is executed from the Flutter project directory (ui/flutter),
|
||||
// but will also try to locate pubspec.yaml by walking up.
|
||||
final flutterDir = _findFlutterProjectRoot(Directory.current);
|
||||
|
||||
// Parse supported locales from message.dart
|
||||
final messageFile =
|
||||
File(_join(flutterDir.path, 'lib', 'i18n', 'message.dart'));
|
||||
final supportedLocales = _parseSupportedLocales(messageFile);
|
||||
stdout.writeln('Supported locales: ${supportedLocales.join(', ')}');
|
||||
|
||||
// Get required font families based on supported locales
|
||||
final requiredFamilies = _getRequiredFontFamilies(supportedLocales);
|
||||
stdout.writeln('Required font families: ${requiredFamilies.join(', ')}');
|
||||
|
||||
final webDir = Directory(_join(flutterDir.path, 'build', 'web'));
|
||||
if (!webDir.existsSync()) {
|
||||
_fail(
|
||||
'Web build directory not found: ${webDir.path}\n'
|
||||
'Did you run: flutter build web --no-web-resources-cdn ?',
|
||||
);
|
||||
}
|
||||
|
||||
final mainJs = File(_join(webDir.path, 'main.dart.js'));
|
||||
if (!mainJs.existsSync()) {
|
||||
_fail('main.dart.js not found at: ${mainJs.path}');
|
||||
}
|
||||
|
||||
final gstaticRoot = Directory(_join(webDir.path, 'assets', 'gstatic'));
|
||||
if (!gstaticRoot.existsSync()) gstaticRoot.createSync(recursive: true);
|
||||
|
||||
const gstaticSPrefix = 'https://fonts.gstatic.com/s/';
|
||||
final original = mainJs.readAsStringSync();
|
||||
|
||||
// Relative font asset paths which Flutter's loader typically concatenates with:
|
||||
// https://fonts.gstatic.com/s/
|
||||
// Keep this as a best-effort heuristic to cover the common concatenation pattern.
|
||||
// Some gstatic assets include extra dot segments like: notocoloremoji/v32/Yq6P-KqIXTD0t4D9z1ESnKM3-HpFabsE4tq3luCC7p-aXxcn.0.woff2
|
||||
// Allow dots in the path, while still restricting to font-like extensions.
|
||||
final relAssetRegex = RegExp(
|
||||
r"""["']([a-zA-Z0-9/_\.-]+\.(?:woff2|woff|ttf|otf|eot|svg))["']""",
|
||||
caseSensitive: false,
|
||||
);
|
||||
final relAssetsUnderS = <String>{};
|
||||
for (final m in relAssetRegex.allMatches(original)) {
|
||||
final p = m.group(1);
|
||||
if (p == null || p.isEmpty) continue;
|
||||
if (p.contains('://') || p.startsWith('data:')) continue;
|
||||
if (p.startsWith('/') ||
|
||||
p.startsWith('assets/') ||
|
||||
p.startsWith('packages/')) continue;
|
||||
if (p.contains('..')) continue;
|
||||
relAssetsUnderS.add(p);
|
||||
}
|
||||
|
||||
if (relAssetsUnderS.isEmpty) {
|
||||
_fail('No fonts.gstatic.com assets found in main.dart.js.');
|
||||
}
|
||||
|
||||
// Build a download plan: dest relative path (under assets/gstatic/) -> URL.
|
||||
// Filter fonts based on required font families to reduce package size.
|
||||
final downloads = <String, Uri>{};
|
||||
final skippedFonts = <String, Set<String>>{};
|
||||
|
||||
for (final rel in relAssetsUnderS) {
|
||||
if (_fontMatchesRequiredFamilies(rel, requiredFamilies)) {
|
||||
downloads[rel] = Uri.parse('$gstaticSPrefix$rel');
|
||||
} else {
|
||||
// Track skipped fonts and their paths for fallback generation
|
||||
final slashIndex = rel.indexOf('/');
|
||||
final family = slashIndex > 0 ? rel.substring(0, slashIndex) : rel;
|
||||
skippedFonts.putIfAbsent(family, () => <String>{}).add(rel);
|
||||
}
|
||||
}
|
||||
|
||||
if (skippedFonts.isNotEmpty) {
|
||||
stdout.writeln(
|
||||
'Skipped ${skippedFonts.length} font families not required by supported locales:',
|
||||
);
|
||||
stdout.writeln(' ${skippedFonts.keys.join(', ')}');
|
||||
}
|
||||
|
||||
if (downloads.isNotEmpty) {
|
||||
stdout.writeln(
|
||||
'Found ${downloads.length} fonts.gstatic.com assets to download...',
|
||||
);
|
||||
|
||||
// Reuse a single HttpClient to avoid creating hundreds of short-lived
|
||||
// connections (can be flaky on some environments).
|
||||
final httpClient = HttpClient()
|
||||
..connectionTimeout = const Duration(seconds: 30)
|
||||
..idleTimeout = const Duration(seconds: 30)
|
||||
..maxConnectionsPerHost = 6;
|
||||
try {
|
||||
for (final entry in downloads.entries) {
|
||||
final relPath = entry.key;
|
||||
final url = entry.value;
|
||||
final destPath = relPath.replaceAll('/', Platform.pathSeparator);
|
||||
final dest = File(_join(gstaticRoot.path, destPath));
|
||||
await _downloadIfMissing(httpClient, url, dest);
|
||||
}
|
||||
} finally {
|
||||
httpClient.close(force: true);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate empty fallback font files for skipped fonts to prevent infinite loading
|
||||
// Empty files will cause browsers to fail fast instead of retrying indefinitely
|
||||
if (skippedFonts.isNotEmpty) {
|
||||
var fallbackCount = 0;
|
||||
for (final entry in skippedFonts.entries) {
|
||||
for (final relPath in entry.value) {
|
||||
final destPath = relPath.replaceAll('/', Platform.pathSeparator);
|
||||
final dest = File(_join(gstaticRoot.path, destPath));
|
||||
if (!dest.existsSync()) {
|
||||
dest.parent.createSync(recursive: true);
|
||||
// Create an empty file - browsers will recognize it as invalid and skip
|
||||
dest.writeAsBytesSync([]);
|
||||
fallbackCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (fallbackCount > 0) {
|
||||
stdout.writeln(
|
||||
'Generated $fallbackCount empty fallback font files to prevent infinite loading',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Rewrite remote prefix so requests become:
|
||||
// <origin>/assets/gstatic/<...>.woff2
|
||||
final replacedGstatic = original.replaceAll(
|
||||
gstaticSPrefix,
|
||||
'assets/gstatic/',
|
||||
);
|
||||
|
||||
if (replacedGstatic == original && downloads.isEmpty) {
|
||||
stdout.writeln(
|
||||
'No changes applied (no fonts.gstatic.com references found).',
|
||||
);
|
||||
exitCode = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
mainJs.writeAsStringSync(replacedGstatic);
|
||||
|
||||
stdout.writeln('Patched: ${mainJs.path}');
|
||||
if (downloads.isNotEmpty) {
|
||||
stdout.writeln(
|
||||
' - downloaded ${downloads.length} files into: ${gstaticRoot.path}',
|
||||
);
|
||||
}
|
||||
stdout.writeln(' - fonts.gstatic.com rewritten -> assets/gstatic/');
|
||||
} catch (e, st) {
|
||||
stderr.writeln('ERROR: $e');
|
||||
stderr.writeln(st);
|
||||
exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _downloadIfMissing(HttpClient client, Uri url, File dest) async {
|
||||
if (dest.existsSync() && dest.lengthSync() > 0) return;
|
||||
|
||||
dest.parent.createSync(recursive: true);
|
||||
|
||||
// Network can be flaky in CI. Retry a few times on transient errors.
|
||||
const maxAttempts = 4;
|
||||
|
||||
for (var attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
final tmp = File('${dest.path}.tmp');
|
||||
try {
|
||||
try {
|
||||
final req = await client.getUrl(url);
|
||||
|
||||
final resp = await req.close().timeout(const Duration(seconds: 60));
|
||||
if (resp.statusCode != 200) {
|
||||
// 404 is not transient in practice - fail fast with a clear message.
|
||||
if (resp.statusCode == 404) {
|
||||
_fail('Missing gstatic asset (404): $url');
|
||||
}
|
||||
throw HttpException('HTTP ${resp.statusCode}', uri: url);
|
||||
}
|
||||
|
||||
// Stream to temp file first, then rename to avoid leaving partial files.
|
||||
if (tmp.existsSync()) tmp.deleteSync();
|
||||
final sink = tmp.openWrite();
|
||||
await resp.pipe(sink).timeout(const Duration(seconds: 120));
|
||||
|
||||
if (!tmp.existsSync() || tmp.lengthSync() == 0) {
|
||||
throw const FormatException('Downloaded asset is empty');
|
||||
}
|
||||
if (dest.existsSync()) dest.deleteSync();
|
||||
tmp.renameSync(dest.path);
|
||||
return;
|
||||
} finally {
|
||||
// no-op: HttpClient lifecycle managed by the caller
|
||||
}
|
||||
} on TimeoutException {
|
||||
if (tmp.existsSync()) tmp.deleteSync();
|
||||
if (attempt == maxAttempts) {
|
||||
_fail('Timeout downloading asset: $url');
|
||||
}
|
||||
} on SocketException catch (e) {
|
||||
if (tmp.existsSync()) tmp.deleteSync();
|
||||
if (attempt == maxAttempts) {
|
||||
_fail('Socket error downloading asset: $url ($e)');
|
||||
}
|
||||
} on HttpException catch (e) {
|
||||
if (tmp.existsSync()) tmp.deleteSync();
|
||||
if (attempt == maxAttempts) {
|
||||
_fail('HTTP error downloading asset: $url ($e)');
|
||||
}
|
||||
} on FileSystemException catch (e) {
|
||||
if (tmp.existsSync()) tmp.deleteSync();
|
||||
_fail('File write error downloading asset: $url ($e)');
|
||||
} catch (e) {
|
||||
if (tmp.existsSync()) tmp.deleteSync();
|
||||
if (attempt == maxAttempts) {
|
||||
_fail('Unexpected error downloading asset: $url ($e)');
|
||||
}
|
||||
}
|
||||
|
||||
// Backoff before retrying.
|
||||
final backoffSeconds = 1 << (attempt - 1);
|
||||
stdout.writeln(
|
||||
'Retrying (${attempt + 1}/$maxAttempts) for: $url (wait ${backoffSeconds}s)',
|
||||
);
|
||||
await Future.delayed(Duration(seconds: backoffSeconds));
|
||||
}
|
||||
}
|
||||
|
||||
Directory _findFlutterProjectRoot(Directory start) {
|
||||
var dir = start;
|
||||
for (var i = 0; i < 10; i++) {
|
||||
final pubspec = File(_join(dir.path, 'pubspec.yaml'));
|
||||
if (pubspec.existsSync()) return dir;
|
||||
final parent = dir.parent;
|
||||
if (parent.path == dir.path) break;
|
||||
dir = parent;
|
||||
}
|
||||
// Fallback: use current directory, error messages will explain what is missing.
|
||||
return start;
|
||||
}
|
||||
|
||||
Never _fail(String msg) {
|
||||
throw FormatException(msg);
|
||||
}
|
||||
|
||||
String _join(String a, [String? b, String? c, String? d]) {
|
||||
final parts = <String>[
|
||||
a,
|
||||
if (b != null) b,
|
||||
if (c != null) c,
|
||||
if (d != null) d,
|
||||
];
|
||||
return parts.join(Platform.pathSeparator);
|
||||
}
|
||||
+202
-15
@@ -25,10 +25,11 @@ on:
|
||||
- ".github/workflows/test.yml"
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
GO_VERSION: "1.19"
|
||||
FLUTTER_VERSION: "3.16.1"
|
||||
GO_VERSION: "1.25"
|
||||
FLUTTER_VERSION: "3.41.6"
|
||||
|
||||
jobs:
|
||||
# lint:
|
||||
@@ -49,32 +50,218 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- name: Install webview dependencies
|
||||
run: |
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev xvfb
|
||||
- name: Run Unit tests.
|
||||
run: go test $(go list ./... | grep -v /cmd/ | grep -v /bind/ ) -v -covermode=count -coverprofile=coverage.txt
|
||||
- uses: codecov/codecov-action@v3
|
||||
run: |
|
||||
# Get packages for testing and coverage (including cmd/web for flags_test.go)
|
||||
PACKAGES=$(go list ./... | awk '!/\/bind\// && (!/\/cmd\// || /\/cmd\/web$/)')
|
||||
env CGO_ENABLED=1 xvfb-run -a go test -tags webview -v -coverpkg=$(echo "$PACKAGES" | paste -sd "," -) -covermode=count -coverprofile=coverage.txt $PACKAGES
|
||||
# Filter out main.go from coverage report to avoid lowering coverage percentage
|
||||
grep -v "main.go" coverage.txt > coverage_filtered.txt || true
|
||||
mv coverage_filtered.txt coverage.txt
|
||||
- uses: codecov/codecov-action@v4
|
||||
with:
|
||||
files: ./coverage.txt
|
||||
build:
|
||||
name: Build check
|
||||
runs-on: ubuntu-latest
|
||||
# needs: [lint, test]
|
||||
needs: [test]
|
||||
token: ${{ secrets.CODECOV_UPLOAD_TOKEN }}
|
||||
test-webview-integration:
|
||||
name: Test webview integration (${{ matrix.name }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: Windows
|
||||
os: windows-latest
|
||||
- name: macOS
|
||||
os: macos-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- uses: subosito/flutter-action@v2
|
||||
if: runner.os == 'macOS'
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
- name: Test webview
|
||||
if: runner.os == 'Windows'
|
||||
run: go test -tags webview -v -timeout 300s ./internal/webview/goprovider/
|
||||
env:
|
||||
CGO_ENABLED: 1
|
||||
- name: Test webview
|
||||
if: runner.os == 'macOS'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SOCKET_PATH="$HOME/Library/Application Support/com.gopeed.gopeed/gopeed_webview.sock"
|
||||
LOG_PATH="$RUNNER_TEMP/gopeed-webview-rpc.log"
|
||||
rm -f "$SOCKET_PATH"
|
||||
|
||||
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/macos/Frameworks/libgopeed.dylib github.com/GopeedLab/gopeed/bind/desktop
|
||||
|
||||
cd ui/flutter
|
||||
flutter pub get
|
||||
flutter run -d macos >"$LOG_PATH" 2>&1 &
|
||||
FLUTTER_PID=$!
|
||||
cd ../..
|
||||
|
||||
cleanup() {
|
||||
kill "$FLUTTER_PID" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
if ! go test -tags webview -v -timeout 600s ./internal/webview/rpcprovider/ -args -webview-rpc-network unix -webview-rpc-address "$SOCKET_PATH"; then
|
||||
cat "$LOG_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build-desktop:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: windows-2022
|
||||
- os: windows-11-arm
|
||||
llvm_ver: "20251202" # Only ARM64
|
||||
flutter_channel: "main"
|
||||
flutter_version: "7e1c8868"
|
||||
- os: macos-latest
|
||||
- os: ubuntu-22.04
|
||||
- os: ubuntu-22.04-arm
|
||||
flutter_channel: "main"
|
||||
name: Build desktop check (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
needs: [ test ]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Enable long paths for flutter main branch checks
|
||||
run: |
|
||||
git config --global core.longpaths true
|
||||
- name: Install llvm-mingw-ucrt-aarch64 (ARM64 only)
|
||||
if: matrix.os == 'windows-11-arm'
|
||||
run: |
|
||||
$ver = "${{ matrix.llvm_ver }}"
|
||||
$url = "https://github.com/mstorsjo/llvm-mingw/releases/download/$ver/llvm-mingw-$ver-ucrt-aarch64.zip"
|
||||
$zip = "$env:RUNNER_TEMP\\llvm.zip"
|
||||
$extract = "$env:RUNNER_TEMP\\extract"
|
||||
$target = "C:\\clangarm64"
|
||||
|
||||
curl -L $url -o $zip
|
||||
rm -r -fo $extract,$target -ea Ignore
|
||||
mkdir $extract | Out-Null
|
||||
|
||||
tar -xf $zip -C $extract
|
||||
mv (Get-ChildItem $extract)[0].FullName $target
|
||||
|
||||
$b = "$target\\bin"
|
||||
"CC=$b\\clang.exe" >> $env:GITHUB_ENV
|
||||
"CXX=$b\\clang++.exe" >> $env:GITHUB_ENV
|
||||
"CLANGARM64_BIN=$b" >> $env:GITHUB_ENV
|
||||
"CGO_ENABLED=1" >> $env:GITHUB_ENV
|
||||
"CLANGARM64_ROOT=$target" >> $env:GITHUB_ENV
|
||||
$b >> $env:GITHUB_PATH
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: ${{ matrix.flutter_channel || 'stable' }}
|
||||
flutter-version: ${{ matrix.flutter_version || env.FLUTTER_VERSION }}
|
||||
- if: runner.os == 'Windows'
|
||||
run: |
|
||||
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/windows/libgopeed.dll github.com/GopeedLab/gopeed/bind/desktop
|
||||
cd ui/flutter
|
||||
flutter build windows
|
||||
- if: runner.os == 'macOS'
|
||||
run: |
|
||||
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/macos/Frameworks/libgopeed.dylib github.com/GopeedLab/gopeed/bind/desktop
|
||||
cd ui/flutter
|
||||
flutter build macos
|
||||
- if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y ninja-build libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev libkeybinder-3.0-dev
|
||||
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/linux/bundle/lib/libgopeed.so github.com/GopeedLab/gopeed/bind/desktop
|
||||
cd ui/flutter
|
||||
flutter build linux
|
||||
build-mobile:
|
||||
name: Build mobile check
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ macos-14, ubuntu-latest ]
|
||||
needs: [ test ]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
- run: |
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y ninja-build libgtk-3-dev libayatana-appindicator3-dev
|
||||
go install golang.org/x/mobile/cmd/gomobile@latest
|
||||
go get golang.org/x/mobile/bind
|
||||
gomobile init
|
||||
- if: runner.os == 'macOS'
|
||||
run: |
|
||||
gomobile bind -tags nosqlite -ldflags="-w -s" -o ui/flutter/ios/Frameworks/Libgopeed.xcframework -target=ios github.com/GopeedLab/gopeed/bind/mobile
|
||||
cd ui/flutter
|
||||
flutter build ipa --no-codesign
|
||||
- if: runner.os == 'Linux'
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
distribution: "zulu"
|
||||
java-version: "17"
|
||||
- if: runner.os == 'Linux'
|
||||
run: |
|
||||
gomobile bind -tags nosqlite -ldflags="-w -s -checklinkname=0" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 21 -javapkg=com.gopeed github.com/GopeedLab/gopeed/bind/mobile
|
||||
cd ui/flutter
|
||||
flutter build apk
|
||||
|
||||
build-web:
|
||||
name: Build web check
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ test ]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
- name: Build
|
||||
run: |
|
||||
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/linux/bundle/lib/libgopeed.so github.com/GopeedLab/gopeed/bind/desktop
|
||||
cd ui/flutter
|
||||
flutter build linux
|
||||
flutter build web --no-web-resources-cdn
|
||||
dart ../../.github/workflows/scripts/flutter_local_font.dart
|
||||
cd ../../
|
||||
rm -rf cmd/web/dist
|
||||
cp -r ui/flutter/build/web cmd/web/dist
|
||||
go build -tags nosqlite,web -ldflags="-s -w" github.com/GopeedLab/gopeed/cmd/web
|
||||
build-docker:
|
||||
name: Build docker check
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ test ]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
- name: Build
|
||||
run: |
|
||||
cd ui/flutter
|
||||
flutter build web --no-web-resources-cdn
|
||||
cd ../../
|
||||
rm -rf cmd/web/dist
|
||||
cp -r ui/flutter/build/web cmd/web/dist
|
||||
docker build -t gopeed .
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
ui/flutter/dist/
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ The internationalization files of Gopeed are located in the `ui/flutter/lib/i18n
|
||||
You only need to add the corresponding language file in this directory.
|
||||
|
||||
Please refer to `en_us.dart` for translation.
|
||||
words prefixed with `@` are not meant to be translated.
|
||||
|
||||
## flutter development
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Hướng dẫn đóng góp cho Gopeed
|
||||
|
||||
Trước tiên, cảm ơn bạn đã quan tâm đến việc đóng góp cho Gopeed. Hướng dẫn này sẽ giúp bạn tham gia
|
||||
phát triển Gopeed một cách tốt hơn.
|
||||
|
||||
## Mô tả nhánh
|
||||
|
||||
Dự án này chỉ có một nhánh chính duy nhất, đó là nhánh `main`. Nếu bạn muốn tham gia vào
|
||||
phát triển Gopeed, hãy fork dự án này trước, sau đó phát triển trong dự án fork của bạn. Sau khi
|
||||
hoàn thành phát triển, gửi một PR đến dự án này và merge vào nhánh `main`.
|
||||
|
||||
## Phát triển cục bộ
|
||||
|
||||
Đề nghị phát triển và gỡ lỗi thông qua web. Đầu tiên, khởi động dịch vụ backend bằng cách chạy
|
||||
lệnh `go run cmd/api/main.go` trong dòng lệnh, cổng mặc định của dịch vụ là `9999`, sau đó
|
||||
khởi động dự án flutter frontend trong chế độ `debug` để chạy.
|
||||
|
||||
## Dịch thuật
|
||||
|
||||
Các tệp quốc tế hóa của Gopeed được đặt trong thư mục `ui/flutter/lib/i18n/langs`.
|
||||
Bạn chỉ cần thêm tệp ngôn ngữ tương ứng trong thư mục này.
|
||||
|
||||
Vui lòng tham khảo `en_us.dart` để biết cách dịch thuật.
|
||||
|
||||
## Phát triển flutter
|
||||
|
||||
Đừng quên chạy `dart format ./ui/flutter` trước khi commit để giữ mã của bạn theo định dạng dart chuẩn.
|
||||
|
||||
Bật build_runner watcher nếu bạn muốn chỉnh sửa api/models:
|
||||
+13
-12
@@ -1,22 +1,23 @@
|
||||
FROM instrumentisto/flutter:3.16.1 AS flutter
|
||||
WORKDIR /app
|
||||
COPY ./ui/flutter/pubspec.yaml ./ui/flutter/pubspec.lock ./
|
||||
RUN flutter pub get
|
||||
COPY ./ui/flutter ./
|
||||
RUN flutter build web --web-renderer html
|
||||
|
||||
FROM golang:1.19.3 AS go
|
||||
FROM golang:1.25.4-alpine3.22 AS go
|
||||
WORKDIR /app
|
||||
COPY ./go.mod ./go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
COPY --from=flutter /app/build/web ./cmd/web/dist
|
||||
RUN CGO_ENABLED=0 go build -tags nosqlite,web -ldflags="-s -w" -o dist/gopeed github.com/GopeedLab/gopeed/cmd/web
|
||||
ARG VERSION=dev
|
||||
RUN CGO_ENABLED=0 go build -tags nosqlite,web \
|
||||
-ldflags="-s -w -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION -X github.com/GopeedLab/gopeed/pkg/base.InDocker=true" \
|
||||
-o dist/gopeed github.com/GopeedLab/gopeed/cmd/web
|
||||
|
||||
FROM alpine:3.14.2
|
||||
FROM alpine:3.23
|
||||
LABEL maintainer="monkeyWie"
|
||||
WORKDIR /app
|
||||
COPY --from=go /app/dist/gopeed ./
|
||||
COPY entrypoint.sh ./entrypoint.sh
|
||||
RUN apk update && \
|
||||
apk add --no-cache su-exec ; \
|
||||
chmod +x ./entrypoint.sh && \
|
||||
rm -rf /var/cache/apk/*
|
||||
VOLUME ["/app/storage"]
|
||||
ENV PUID=0 PGID=0 UMASK=022
|
||||
EXPOSE 9999
|
||||
ENTRYPOINT ["./gopeed"]
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
@@ -1,40 +1,173 @@
|
||||
# [](https://gopeed.com)
|
||||
|
||||
[](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
|
||||
[](https://codecov.io/gh/GopeedLab/gopeed)
|
||||
[](https://github.com/GopeedLab/gopeed/releases)
|
||||
[](https://github.com/GopeedLab/gopeed/releases)
|
||||
[](https://docs.gopeed.com/donate.html)
|
||||
[](https://gopeed.com/docs/donate)
|
||||
[](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
|
||||
[](https://discord.gg/ZUJqJrwCGB)
|
||||
|
||||

|
||||
<a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
[](https://ko-fi.com/R6R6IJGN6)
|
||||
|
||||
[English](/README.md) | [中文](/README_zh-CN.md) | [日本語](/README_ja-JP.md) | [正體中文](/README_zh-TW.md)
|
||||
[English](/README.md) | [中文](/README_zh-CN.md) | [日本語](/README_ja-JP.md) | [正體中文](/README_zh-TW.md) | [Tiếng Việt](/README_vi-VN.md)
|
||||
|
||||
## Introduction
|
||||
## 🚀 Introduction
|
||||
|
||||
Gopeed (full name Go Speed), a high-speed downloader developed by `Golang` + `Flutter`, supports (HTTP, BitTorrent, Magnet) protocol, and supports all platforms. In addition to basic download functions, Gopeed is also a highly customizable downloader that supports implementing more features through integration with [APIs](https://docs.gopeed.com/dev-api.html) or installation and development of [extensions](https://docs.gopeed.com/dev-extension.html).
|
||||
Gopeed (full name Go Speed), a high-speed downloader developed by `Golang` + `Flutter`, supports (HTTP, BitTorrent, Magnet, ED2K) protocol, and supports all platforms. In addition to basic download functions, Gopeed is also a highly customizable downloader that supports implementing more features through integration with [APIs](https://gopeed.com/docs/dev-api) or installation and development of [extensions](https://gopeed.com/docs/dev-extension).
|
||||
|
||||
Visit ✈ [Official Website](https://gopeed.com) | 📖 [Official Docs](https://gopeed.com/docs)
|
||||
|
||||
Visit ✈ [Official Website](https://gopeed.com) | 📖 [Official Docs](https://docs.gopeed.com)
|
||||
## ⬇️ Download
|
||||
|
||||
## Install
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td rowspan="4">🪟 Windows</td>
|
||||
<td rowspan="2"><code>EXE</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-windows-amd64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-windows-arm64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="2"><code>Portable</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-windows-amd64-portable.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-windows-arm64-portable.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="3">🍎 MacOS</td>
|
||||
<td rowspan="3"><code>DMG</code></td>
|
||||
<td>universal</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos.dmg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos-amd64.dmg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos-arm64.dmg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="6">🐧 Linux</td>
|
||||
<td><code>Flathub</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://flathub.org/apps/com.gopeed.Gopeed">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>SNAP</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://snapcraft.io/gopeed">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="2"><code>DEB</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-linux-amd64.deb">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-linux-arm64.deb">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="2"><code>AppImage</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-linux-amd64.AppImage">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-linux-arm64.AppImage">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="4">🤖 Android</td>
|
||||
<td rowspan="4"><code>APK</code></td>
|
||||
<td>universal</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android.apk">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>armeabi-v7a</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android-armeabi-v7a.apk">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64-v8a</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android-arm64-v8a.apk">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>x86_64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android-x86_64.apk">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>📱 iOS</td>
|
||||
<td><code>IPA</code></td>
|
||||
<td>universal</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-ios.ipa">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>🐳 Docker</td>
|
||||
<td>-</td>
|
||||
<td>universal</td>
|
||||
<td><a href="https://hub.docker.com/r/liwei2633/gopeed">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="2">💾 Qnap</td>
|
||||
<td rowspan="2"><code>QPKG</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-$version-qnap-amd64.qpkg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-$version-qnap-arm64.qpkg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="8">🌐 Web</td>
|
||||
<td rowspan="3"><code>Windows</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-$version-windows-amd64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-$version-windows-arm64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>386</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-$version-windows-386.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="2"><code>MacOS</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-$version-macos-amd64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-$version-macos-arm64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="3"><code>Linux</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-$version-linux-amd64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-$version-linux-arm64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>386</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-$version-linux-386.zip">📥</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
**Supported platforms**
|
||||
More about installation, please refer to [Installation](https://gopeed.com/docs/install)
|
||||
|
||||
- [x] windows
|
||||
- [x] macos
|
||||
- [x] linux
|
||||
- [x] android
|
||||
- [x] ios
|
||||
- [x] web
|
||||
- [x] docker
|
||||
|
||||
[Download](https://github.com/GopeedLab/gopeed/releases/latest)
|
||||
|
||||
> Tips: If the macos open failed, please execute the `xattr -d com.apple.quarantine /Applications/Gopeed.app` command in the terminal
|
||||
|
||||
### Command tool
|
||||
### 🛠️ Command tool
|
||||
|
||||
use `go install`:
|
||||
|
||||
@@ -42,56 +175,46 @@ use `go install`:
|
||||
go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
|
||||
```
|
||||
|
||||
### Docker
|
||||
## 🔌 Browser Extension
|
||||
|
||||
#### Start directly
|
||||
Gopeed also provides a browser extension to take over browser downloads, supporting browsers such as Chrome, Edge, Firefox, etc., please refer to: [https://github.com/GopeedLab/browser-extension](https://github.com/GopeedLab/browser-extension)
|
||||
|
||||
```bash
|
||||
docker run -d -p 9999:9999 -v /path/to/download:/root/Downloads -v /path/to/storage:/app/storage liwei2633/gopeed
|
||||
```
|
||||
## 📱 WeChat Official Account
|
||||
|
||||
[More details for docker](https://docs.gopeed.com/install.html#docker-installation)
|
||||
Follow our WeChat Official Account to get the latest updates and news.
|
||||
|
||||
#### Using Docker Compose
|
||||
<img src="_docs/img/weixin.png" width="200" />
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
## 💝 Donate
|
||||
|
||||
#### Access Gopeed
|
||||
If you like this project, please consider [donating](https://gopeed.com/docs/donate) to support the development of this project, thank you!
|
||||
|
||||
When the docker container is running, you can access the web page through `http://localhost:9999`.
|
||||
|
||||
## Donate
|
||||
|
||||
If you like this project, please consider [donating](https://docs.gopeed.com/donate.html) to support the development of this project, thank you!
|
||||
|
||||
## Showcase
|
||||
## 🖼️ Showcase
|
||||
|
||||

|
||||
|
||||
## Development
|
||||
## 👨💻 Development
|
||||
|
||||
This project is divided into two parts, the front end uses `flutter`, the back end uses `Golang`, and the two sides communicate through the `http` protocol. On the unix system, `unix socket` is used, and on the windows system, `tcp` protocol is used.
|
||||
|
||||
> The front code is located in the `ui/flutter` directory.
|
||||
|
||||
### Environment
|
||||
### 🌍 Environment
|
||||
|
||||
1. Golang 1.19+
|
||||
2. Flutter 3.10+
|
||||
1. Golang 1.25+
|
||||
2. Flutter 3.38+
|
||||
|
||||
### Clone
|
||||
### 📋 Clone
|
||||
|
||||
```bash
|
||||
git clone [email protected]:GopeedLab/gopeed.git
|
||||
```
|
||||
|
||||
### Contributing
|
||||
### 🤝 Contributing
|
||||
|
||||
Please refer to [CONTRIBUTING.md](/CONTRIBUTING.md)
|
||||
|
||||
### Build
|
||||
### 🏗️ Build
|
||||
|
||||
#### Desktop
|
||||
|
||||
@@ -138,7 +261,7 @@ command:
|
||||
- android
|
||||
|
||||
```bash
|
||||
gomobile bind -tags nosqlite -ldflags="-w -s" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 19 -javapkg=com.gopeed github.com/GopeedLab/gopeed/bind/mobile
|
||||
gomobile bind -tags nosqlite -ldflags="-w -s -checklinkname=0" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 21 -javapkg="com.gopeed" github.com/GopeedLab/gopeed/bind/mobile
|
||||
cd ui/flutter
|
||||
flutter build apk
|
||||
```
|
||||
@@ -164,18 +287,18 @@ cp -r ui/flutter/build/web cmd/web/dist
|
||||
go build -tags nosqlite,web -ldflags="-s -w" -o bin/ github.com/GopeedLab/gopeed/cmd/web
|
||||
```
|
||||
|
||||
## Credits
|
||||
## ❤️ Credits
|
||||
|
||||
### Contributors
|
||||
### 👥 Contributors
|
||||
|
||||
<a href="https://github.com/GopeedLab/gopeed/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=GopeedLab/gopeed" />
|
||||
</a>
|
||||
|
||||
### JetBrains
|
||||
### 🏢 JetBrains
|
||||
|
||||
[](https://www.jetbrains.com/?from=gopeed)
|
||||
|
||||
## License
|
||||
## 📄 License
|
||||
|
||||
[GPLv3](LICENSE)
|
||||
+161
-56
@@ -1,96 +1,201 @@
|
||||
# [](https://gopeed.com)
|
||||
|
||||
[](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
|
||||
[](https://codecov.io/gh/GopeedLab/gopeed)
|
||||
[](https://github.com/GopeedLab/gopeed/releases)
|
||||
[](https://github.com/GopeedLab/gopeed/releases)
|
||||
[](https://docs.gopeed.com/donate.html)
|
||||
[](https://gopeed.com/docs/donate)
|
||||
[](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
|
||||
[](https://discord.gg/ZUJqJrwCGB)
|
||||
|
||||

|
||||
<a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
[](https://ko-fi.com/R6R6IJGN6)
|
||||
|
||||
[English](/README.md) | [中文](/README_zh-CN.md) | [日本語](/README_ja-JP.md) | [正體中文](/README_zh-TW.md)
|
||||
[English](/README.md) | [中文](/README_zh-CN.md) | [日本語](/README_ja-JP.md) | [正體中文](/README_zh-TW.md) | [Tiếng Việt](/README_vi-VN.md)
|
||||
|
||||
## はじめに
|
||||
## 🚀 はじめに
|
||||
|
||||
Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された高速ダウンローダーで、(HTTP、BitTorrent、Magnet) プロトコルをサポートし、すべてのプラットフォームをサポートします。基本的なダウンロード機能に加え、[APIs](https://docs.gopeed.com/dev-api.html)との連動や[拡張機能](https://docs.gopeed.com/dev-extension.html)のインストール・開発による追加機能にも対応した、カスタマイズ性の高いダウンローダーです。
|
||||
Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された高速ダウンローダーで、(HTTP、BitTorrent、Magnet、ED2K) プロトコルをサポートし、すべてのプラットフォームをサポートします。基本的なダウンロード機能に加え、[APIs](https://gopeed.com/docs/dev-api)との連動や[拡張機能](https://gopeed.com/docs/dev-extension)のインストール・開発による追加機能にも対応した、カスタマイズ性の高いダウンローダーです。
|
||||
|
||||
見て下さい ✈ [公式ウェブサイト](https://gopeed.com) | 📖 [開発ドキュメント](https://docs.gopeed.com)
|
||||
見て下さい ✈ [公式ウェブサイト](https://gopeed.com) | 📖 [開発ドキュメント](https://gopeed.com/docs)
|
||||
|
||||
## インストール
|
||||
## ⬇️ インストール
|
||||
|
||||
**サポートするプラットフォーム**
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td rowspan="2">🪟 Windows</td>
|
||||
<td><code>EXE</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-windows-amd64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Portable</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-windows-amd64-portable.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="3">🍎 MacOS</td>
|
||||
<td rowspan="3"><code>DMG</code></td>
|
||||
<td>universal</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos.dmg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos-amd64.dmg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos-arm64.dmg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="6">🐧 Linux</td>
|
||||
<td><code>Flathub</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://flathub.org/apps/com.gopeed.Gopeed">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>SNAP</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://snapcraft.io/gopeed">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="2"><code>DEB</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-linux-amd64.deb">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-linux-arm64.deb">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="2"><code>AppImage</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-linux-amd64.AppImage">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-linux-arm64.AppImage">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="4">🤖 Android</td>
|
||||
<td rowspan="4"><code>APK</code></td>
|
||||
<td>universal</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android.apk">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>armeabi-v7a</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android-armeabi-v7a.apk">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64-v8a</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android-arm64-v8a.apk">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>x86_64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android-x86_64.apk">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>📱 iOS</td>
|
||||
<td><code>IPA</code></td>
|
||||
<td>universal</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-ios.ipa">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>🐳 Docker</td>
|
||||
<td>-</td>
|
||||
<td>universal</td>
|
||||
<td><a href="https://hub.docker.com/r/liwei2633/gopeed">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="2">💾 Qnap</td>
|
||||
<td rowspan="2"><code>QPKG</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-$version-qnap-amd64.qpkg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-$version-qnap-arm64.qpkg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="8">🌐 Web</td>
|
||||
<td rowspan="3"><code>Windows</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-$version-windows-amd64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-$version-windows-arm64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>386</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-$version-windows-386.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="2"><code>MacOS</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-$version-macos-amd64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-$version-macos-arm64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="3"><code>Linux</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-$version-linux-amd64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-$version-linux-arm64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>386</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-$version-linux-386.zip">📥</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
インストールについての詳細は、[インストール](https://gopeed.com/docs/install)を参照してください。
|
||||
|
||||
- [x] windows
|
||||
- [x] macos
|
||||
- [x] linux
|
||||
- [x] android
|
||||
- [x] ios
|
||||
- [x] web
|
||||
- [x] docker
|
||||
### 🛠️ コマンドツール
|
||||
|
||||
[ダウンロード](https://github.com/GopeedLab/gopeed/releases/latest)
|
||||
## 📱 WeChat 公式アカウント
|
||||
|
||||
> ヒント: macos を開くのに失敗した場合は、ターミナルで `xattr -d com.apple.quarantine /Applications/Gopeed.app` コマンドを実行してください
|
||||
公式アカウントをフォローして、最新のアップデートやニュースを入手してください。
|
||||
|
||||
### コマンドツール
|
||||
<img src="_docs/img/weixin.png" width="200" />
|
||||
|
||||
`go install` を使用:
|
||||
## 💝 寄付
|
||||
|
||||
```bash
|
||||
go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
|
||||
```
|
||||
もしこのプロジェクトがお気に召しましたら、このプロジェクトの発展を支援するために[寄付](https://gopeed.com/docs/donate)をご検討ください!
|
||||
|
||||
### Docker
|
||||
|
||||
#### 直接始める
|
||||
|
||||
```bash
|
||||
docker run -d -p 9999:9999 -v /path/to/download:/root/Downloads -v /path/to/storage:/app/storage liwei2633/gopeed
|
||||
```
|
||||
|
||||
[Dockerの使用については](https://docs.gopeed.com/install.html#docker-installation)
|
||||
|
||||
#### Docker Compose の使用
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
#### Gopeed へのアクセス
|
||||
|
||||
docker コンテナが起動しているときは、`http://localhost:9999` からウェブページにアクセスできます。
|
||||
|
||||
## 寄付
|
||||
|
||||
もしこのプロジェクトがお気に召しましたら、このプロジェクトの発展を支援するために[寄付](/.donate/index.md#donate)をご検討ください!
|
||||
|
||||
## ショーケース
|
||||
## 🖼️ ショーケース
|
||||
|
||||

|
||||
|
||||
## 開発
|
||||
## 👨💻 開発
|
||||
|
||||
このプロジェクトは二つの部分に分かれており、フロントエンドでは `flutter` を、バックエンドでは `Golang` を使用し、両者は `http` プロトコルで通信する。ユニックスシステムでは `unix socket` を、ウィンドウズシステムでは `tcp` プロトコルを使用します。
|
||||
|
||||
> フロントコードは `ui/flutter` ディレクトリにあります。
|
||||
|
||||
### 環境
|
||||
### 🌍 環境
|
||||
|
||||
1. Go 言語 1.19+
|
||||
2. Flutter 3.10+
|
||||
1. Go 言語 1.25+
|
||||
2. Flutter 3.38+
|
||||
|
||||
### クローン
|
||||
### 📋 クローン
|
||||
|
||||
```bash
|
||||
git clone [email protected]:GopeedLab/gopeed.git
|
||||
```
|
||||
|
||||
### コントリビュート
|
||||
### 🤝 コントリビュート
|
||||
|
||||
[CONTRIBUTING.md](/CONTRIBUTING_ja-JP.md) をご参照ください
|
||||
|
||||
### ビルド
|
||||
### 🏗️ ビルド
|
||||
|
||||
#### デスクトップ
|
||||
|
||||
@@ -137,7 +242,7 @@ gomobile init
|
||||
- android
|
||||
|
||||
```bash
|
||||
gomobile bind -tags nosqlite -ldflags="-w -s" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 19 -javapkg=com.gopeed github.com/GopeedLab/gopeed/bind/mobile
|
||||
gomobile bind -tags nosqlite -ldflags="-w -s -checklinkname=0" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 21 -javapkg="com.gopeed" github.com/GopeedLab/gopeed/bind/mobile
|
||||
cd ui/flutter
|
||||
flutter build apk
|
||||
```
|
||||
@@ -163,7 +268,7 @@ cp -r ui/flutter/build/web cmd/web/dist
|
||||
go build -tags nosqlite,web -ldflags="-s -w" -o bin/ github.com/GopeedLab/gopeed/cmd/web
|
||||
```
|
||||
|
||||
## 感謝
|
||||
## ❤️ 感謝
|
||||
|
||||
### コントリビューター
|
||||
|
||||
|
||||
+291
@@ -0,0 +1,291 @@
|
||||
# [](https://gopeed.com)
|
||||
|
||||
[](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
|
||||
[](https://codecov.io/gh/GopeedLab/gopeed)
|
||||
[](https://github.com/GopeedLab/gopeed/releases)
|
||||
[](https://github.com/GopeedLab/gopeed/releases)
|
||||
[](https://gopeed.com/docs/donate)
|
||||
[](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
|
||||
[](https://discord.gg/ZUJqJrwCGB)
|
||||
|
||||
<a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
[](https://ko-fi.com/R6R6IJGN6)
|
||||
|
||||
[English](/README.md) | [中文](/README_zh-CN.md) | [日本語](/README_ja-JP.md) | [正體中文](/README_zh-TW.md) | [Tiếng Việt](/README_vi-VN.md)
|
||||
|
||||
## 🚀 Giới thiệu
|
||||
|
||||
Gopeed (tên đầy đủ Go Speed), một công cụ tải xuống tốc độ cao được phát triển bởi `Golang` + `Flutter`, hỗ trợ giao thức (HTTP, BitTorrent, Magnet, ED2K) và hỗ trợ tất cả các nền tảng. Ngoài các chức năng tải xuống cơ bản, Gopeed còn là một công cụ tải xuống có thể tùy chỉnh cao cho phép triển khai thêm tính năng thông qua việc tích hợp với [APIs](https://gopeed.com/docs/dev-api) hoặc cài đặt và phát triển các [tiện ích mở rộng](https://gopeed.com/docs/dev-extension).
|
||||
|
||||
Truy cập ✈ [Trang web chính thức](https://gopeed.com) | 📖 [Tài liệu chính thức](https://gopeed.com/docs)
|
||||
|
||||
## ⬇️ Tải về
|
||||
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td rowspan="2">🪟 Windows</td>
|
||||
<td><code>EXE</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-windows-amd64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Portable</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-windows-amd64-portable.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="3">🍎 MacOS</td>
|
||||
<td rowspan="3"><code>DMG</code></td>
|
||||
<td>universal</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos.dmg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos-amd64.dmg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos-arm64.dmg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="6">🐧 Linux</td>
|
||||
<td><code>Flathub</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://flathub.org/apps/com.gopeed.Gopeed">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>SNAP</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://snapcraft.io/gopeed">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="2"><code>DEB</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-linux-amd64.deb">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-linux-arm64.deb">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="2"><code>AppImage</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-linux-amd64.AppImage">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-linux-arm64.AppImage">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="4">🤖 Android</td>
|
||||
<td rowspan="4"><code>APK</code></td>
|
||||
<td>universal</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android.apk">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>armeabi-v7a</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android-armeabi-v7a.apk">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64-v8a</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android-arm64-v8a.apk">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>x86_64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android-x86_64.apk">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>📱 iOS</td>
|
||||
<td><code>IPA</code></td>
|
||||
<td>universal</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-ios.ipa">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>🐳 Docker</td>
|
||||
<td>-</td>
|
||||
<td>universal</td>
|
||||
<td><a href="https://hub.docker.com/r/liwei2633/gopeed">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="2">💾 Qnap</td>
|
||||
<td rowspan="2"><code>QPKG</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-$version-qnap-amd64.qpkg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-$version-qnap-arm64.qpkg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="8">🌐 Web</td>
|
||||
<td rowspan="3"><code>Windows</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-$version-windows-amd64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-$version-windows-arm64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>386</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-$version-windows-386.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="2"><code>MacOS</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-$version-macos-amd64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-$version-macos-arm64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="3"><code>Linux</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-$version-linux-amd64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-$version-linux-arm64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>386</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-$version-linux-386.zip">📥</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
Thêm thông tin về cài đặt, vui lòng tham khảo [Cài đặt](https://gopeed.com/docs/install)
|
||||
|
||||
### 🛠️ Công cụ lệnh
|
||||
|
||||
Sử dụng `go install`:
|
||||
|
||||
```bash
|
||||
go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
|
||||
```
|
||||
|
||||
## 📱 WeChat Official Account
|
||||
|
||||
Theo dõi tài khoản chính thức để nhận các cập nhật và tin tức mới nhất.
|
||||
|
||||
<img src="_docs/img/weixin.png" width="200" />
|
||||
|
||||
## 💝 Quyên góp
|
||||
|
||||
Nếu bạn thích dự án này, xin vui lòng xem xét [quyên góp](https://gopeed.com/docs/donate) để hỗ trợ phát triển dự án này, cảm ơn bạn!
|
||||
|
||||
## 🖼️ Trưng bày
|
||||
|
||||

|
||||
|
||||
## 👨💻 Development
|
||||
|
||||
Dự án này được chia thành hai phần, phần giao diện sử dụng `flutter`, phần backend sử dụng `Golang`, và hai phía giao tiếp thông qua giao thức `http`. Trên hệ thống unix, sử dụng `unix socket`, và trên hệ thống windows, sử dụng giao thức `tcp`.
|
||||
|
||||
> Mã giao diện nằm trong thư mục `ui/flutter`.
|
||||
|
||||
### 🌍 Environment
|
||||
|
||||
1. Golang 1.25+
|
||||
2. Flutter 3.38+
|
||||
|
||||
### 📋 Clone
|
||||
|
||||
```bash
|
||||
git clone [email protected]:GopeedLab/gopeed.git
|
||||
```
|
||||
|
||||
### 🤝 Đóng góp
|
||||
|
||||
Vui lòng tham khảo [CONTRIBUTING_vi-VN.md](/CONTRIBUTING_vi-VN.md)
|
||||
|
||||
### 🏗️ Xây dựng
|
||||
|
||||
#### Desktop
|
||||
|
||||
Trước tiên, bạn cần cấu hình môi trường theo tài liệu chính thức của [Tài liệu trang web máy tính để bàn Flutter](https://docs.flutter.dev/development/platform-integration/desktop), sau đó bạn cần đảm bảo môi trường cgo được thiết lập đúng. Để biết hướng dẫn chi tiết về cách thiết lập môi trường cgo, vui lòng tham khảo các tài liệu tương ứng có sẵn trực tuyến.
|
||||
|
||||
command:
|
||||
|
||||
- windows
|
||||
|
||||
```bash
|
||||
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/windows/libgopeed.dll github.com/GopeedLab/gopeed/bind/desktop
|
||||
cd ui/flutter
|
||||
flutter build windows
|
||||
```
|
||||
|
||||
- macos
|
||||
|
||||
```bash
|
||||
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/macos/Frameworks/libgopeed.dylib github.com/GopeedLab/gopeed/bind/desktop
|
||||
cd ui/flutter
|
||||
flutter build macos
|
||||
```
|
||||
|
||||
- linux
|
||||
|
||||
```bash
|
||||
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/linux/bundle/lib/libgopeed.so github.com/GopeedLab/gopeed/bind/desktop
|
||||
cd ui/flutter
|
||||
flutter build linux
|
||||
```
|
||||
|
||||
#### Mobile
|
||||
|
||||
Giống như trước đây, bạn cũng cần chuẩn bị môi trường `cgo` và sau đó cài đặt `gomobile`:
|
||||
|
||||
```bash
|
||||
go install golang.org/x/mobile/cmd/gomobile@latest
|
||||
go get golang.org/x/mobile/bind
|
||||
gomobile init
|
||||
```
|
||||
|
||||
command:
|
||||
|
||||
- android
|
||||
|
||||
```bash
|
||||
gomobile bind -tags nosqlite -ldflags="-w -s -checklinkname=0" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 21 -javapkg="com.gopeed" github.com/GopeedLab/gopeed/bind/mobile
|
||||
cd ui/flutter
|
||||
flutter build apk
|
||||
```
|
||||
|
||||
- ios
|
||||
|
||||
```bash
|
||||
gomobile bind -tags nosqlite -ldflags="-w -s" -o ui/flutter/ios/Frameworks/Libgopeed.xcframework -target=ios github.com/GopeedLab/gopeed/bind/mobile
|
||||
cd ui/flutter
|
||||
flutter build ios --no-codesign
|
||||
```
|
||||
|
||||
#### Web
|
||||
|
||||
command:
|
||||
|
||||
```bash
|
||||
cd ui/flutter
|
||||
flutter build web
|
||||
cd ../../
|
||||
rm -rf cmd/web/dist
|
||||
cp -r ui/flutter/build/web cmd/web/dist
|
||||
go build -tags nosqlite,web -ldflags="-s -w" -o bin/ github.com/GopeedLab/gopeed/cmd/web
|
||||
```
|
||||
|
||||
## ❤️ Tín dụng
|
||||
|
||||
### Người đóng góp
|
||||
|
||||
<a href="https://github.com/GopeedLab/gopeed/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=GopeedLab/gopeed" />
|
||||
</a>
|
||||
|
||||
### JetBrains
|
||||
|
||||
[](https://www.jetbrains.com/?from=gopeed)
|
||||
|
||||
## Giấy phép
|
||||
|
||||
[GPLv3](LICENSE)
|
||||
+163
-48
@@ -1,39 +1,164 @@
|
||||
# [](https://gopeed.com)
|
||||
|
||||
[](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
|
||||
[](https://codecov.io/gh/GopeedLab/gopeed)
|
||||
[](https://github.com/GopeedLab/gopeed/releases)
|
||||
[](https://github.com/GopeedLab/gopeed/releases)
|
||||
[](https://docs.gopeed.com/zh/donate.html)
|
||||
[](https://gopeed.com/docs/donate)
|
||||
[](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
|
||||
[](https://discord.gg/ZUJqJrwCGB)
|
||||
|
||||

|
||||
<a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
[](https://ko-fi.com/R6R6IJGN6)
|
||||
|
||||
[English](/README.md) | [中文](/README_zh-CN.md) | [日本語](/README_ja-JP.md) | [正體中文](/README_zh-TW.md)
|
||||
[English](/README.md) | [中文](/README_zh-CN.md) | [日本語](/README_ja-JP.md) | [正體中文](/README_zh-TW.md) | [Tiếng Việt](/README_vi-VN.md)
|
||||
|
||||
## 介绍
|
||||
## 🚀 介绍
|
||||
|
||||
Gopeed(全称 Go Speed),直译过来中文名叫做`够快下载器`(不是狗屁下载器!),是一款由`Golang`+`Flutter`开发的高速下载器,支持(HTTP、BitTorrent、Magnet)协议下载,并且支持全平台使用。除了基本的下载功能外,Gopeed 还是一款高度可定制化的下载器,支持通过对接[APIs](https://docs.gopeed.com/zh/dev-api.html)或者安装和开发[扩展](https://docs.gopeed.com/zh/dev-extension.html)来实现更多的功能。
|
||||
Gopeed(全称 Go Speed),直译过来中文名叫做`够快下载器`(不是狗屁下载器!),是一款由`Golang`+`Flutter`开发的高速下载器,支持(HTTP、BitTorrent、Magnet、ED2K)协议下载,并且支持全平台使用。除了基本的下载功能外,Gopeed 还是一款高度可定制化的下载器,支持通过对接[APIs](https://gopeed.com/docs/dev-api)或者安装和开发[扩展](https://gopeed.com/docs/dev-extension)来实现更多的功能。
|
||||
|
||||
访问 ✈ [官方网站](https://gopeed.com/zh-CN) | 📖 [官方文档](https://docs.gopeed.com/zh/)
|
||||
访问 ✈ [官方网站](https://gopeed.com/zh-CN) | 📖 [官方文档](https://gopeed.com/docs)
|
||||
|
||||
## 安装
|
||||
## ⬇️ 下载
|
||||
|
||||
**已支持平台**
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td rowspan="2">🪟 Windows</td>
|
||||
<td><code>EXE</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-windows-amd64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Portable</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-windows-amd64-portable.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="3">🍎 MacOS</td>
|
||||
<td rowspan="3"><code>DMG</code></td>
|
||||
<td>universal</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos.dmg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos-amd64.dmg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos-arm64.dmg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="6">🐧 Linux</td>
|
||||
<td><code>Flathub</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://flathub.org/apps/com.gopeed.Gopeed">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>SNAP</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://snapcraft.io/gopeed">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="2"><code>DEB</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-linux-amd64.deb">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-linux-arm64.deb">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="2"><code>AppImage</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-linux-amd64.AppImage">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-linux-arm64.AppImage">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="4">🤖 Android</td>
|
||||
<td rowspan="4"><code>APK</code></td>
|
||||
<td>universal</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android.apk">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>armeabi-v7a</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android-armeabi-v7a.apk">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64-v8a</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android-arm64-v8a.apk">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>x86_64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android-x86_64.apk">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>📱 iOS</td>
|
||||
<td><code>IPA</code></td>
|
||||
<td>universal</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-ios.ipa">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>🐳 Docker</td>
|
||||
<td>-</td>
|
||||
<td>universal</td>
|
||||
<td><a href="https://hub.docker.com/r/liwei2633/gopeed">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="2">💾 Qnap</td>
|
||||
<td rowspan="2"><code>QPKG</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-$version-qnap-amd64.qpkg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-$version-qnap-arm64.qpkg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="8">🌐 Web</td>
|
||||
<td rowspan="3"><code>Windows</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-$version-windows-amd64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-$version-windows-arm64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>386</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-$version-windows-386.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="2"><code>MacOS</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-$version-macos-amd64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-$version-macos-arm64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="3"><code>Linux</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-$version-linux-amd64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-$version-linux-arm64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>386</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-$version-linux-386.zip">📥</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
更多关于安装的内容请参考[安装文档](https://gopeed.com/docs/install)
|
||||
|
||||
- [x] windows
|
||||
- [x] macos
|
||||
- [x] linux
|
||||
- [x] android
|
||||
- [x] ios
|
||||
- [x] web
|
||||
- [x] docker
|
||||
|
||||
[前往下载](https://github.com/GopeedLab/gopeed/releases/latest)
|
||||
|
||||
> 注:macos 版本运行如果提示损坏,请在终端执行 `xattr -d com.apple.quarantine /Applications/Gopeed.app` 命令
|
||||
|
||||
### 命令行工具
|
||||
### 🛠️ 命令行工具
|
||||
|
||||
使用`go install`安装:
|
||||
|
||||
@@ -41,56 +166,46 @@ Gopeed(全称 Go Speed),直译过来中文名叫做`够快下载器`(不
|
||||
go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
|
||||
```
|
||||
|
||||
### Docker
|
||||
## 🔌 浏览器扩展
|
||||
|
||||
#### 直接运行
|
||||
Gopeed 还提供了浏览器扩展用于接管浏览器下载,支持 Chrome、Edge、Firefox 等浏览器,具体请参考:[https://github.com/GopeedLab/browser-extension](https://github.com/GopeedLab/browser-extension)
|
||||
|
||||
```bash
|
||||
docker run -d -p 9999:9999 -v /path/to/download:/root/Downloads -v /path/to/storage:/app/storage liwei2633/gopeed
|
||||
```
|
||||
## 📱 微信公众号
|
||||
|
||||
[更多关于 Docker 的使用](https://docs.gopeed.com/zh/install.html#docker-%E5%AE%89%E8%A3%85)
|
||||
关注公众号获取项目最新动态和资讯。
|
||||
|
||||
#### 使用 Docker Compose
|
||||
<img src="_docs/img/weixin.png" width="200" />
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
## 💝 赞助
|
||||
|
||||
#### 访问服务
|
||||
如果觉得项目对你有帮助,请考虑[赞助](https://gopeed.com/docs/donate)以支持这个项目的发展,非常感谢!
|
||||
|
||||
当 docker 容器运行时,可以通过 `http://localhost:9999` 访问 web 页面。
|
||||
|
||||
## 赞助
|
||||
|
||||
如果觉得项目对你有帮助,请考虑[赞助](https://docs.gopeed.com/zh/donate)以支持这个项目的发展,非常感谢!
|
||||
|
||||
## 界面展示
|
||||
## 🖼️ 界面展示
|
||||
|
||||

|
||||
|
||||
## 开发
|
||||
## 👨💻 开发
|
||||
|
||||
本项目分为前端和后端两个部分,前端使用`flutter`,后端使用`Golang`,两边通过`http`协议进行通讯,在 unix 系统下,使用的是`unix socket`,在 windows 系统下,使用的是`tcp`协议。
|
||||
|
||||
> 前端代码位于`ui/flutter`目录下。
|
||||
|
||||
### 环境要求
|
||||
### 🌍 环境要求
|
||||
|
||||
1. Golang 1.19+
|
||||
2. Flutter 3.10+
|
||||
1. Golang 1.25+
|
||||
2. Flutter 3.38+
|
||||
|
||||
### 克隆项目
|
||||
### 📋 克隆项目
|
||||
|
||||
```bash
|
||||
git clone [email protected]:GopeedLab/gopeed.git
|
||||
```
|
||||
|
||||
### 贡献代码
|
||||
### 🤝 贡献代码
|
||||
|
||||
请参考[贡献指南](CONTRIBUTING_zh-CN.md)
|
||||
|
||||
### 编译
|
||||
### 🏗️ 编译
|
||||
|
||||
#### 桌面端
|
||||
|
||||
@@ -137,7 +252,7 @@ gomobile init
|
||||
- android
|
||||
|
||||
```bash
|
||||
gomobile bind -tags nosqlite -ldflags="-w -s" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 19 -javapkg=com.gopeed github.com/GopeedLab/gopeed/bind/mobile
|
||||
gomobile bind -tags nosqlite -ldflags="-w -s -checklinkname=0" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 21 -javapkg="com.gopeed" github.com/GopeedLab/gopeed/bind/mobile
|
||||
cd ui/flutter
|
||||
flutter build apk
|
||||
```
|
||||
@@ -163,7 +278,7 @@ cp -r ui/flutter/build/web cmd/web/dist
|
||||
go build -tags nosqlite,web -ldflags="-s -w" -o bin/ github.com/GopeedLab/gopeed/cmd/web
|
||||
```
|
||||
|
||||
## 感谢
|
||||
## ❤️ 感谢
|
||||
|
||||
### 贡献者
|
||||
|
||||
|
||||
+161
-49
@@ -1,39 +1,165 @@
|
||||
# [](https://gopeed.com)
|
||||
|
||||
[](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
|
||||
[](https://codecov.io/gh/GopeedLab/gopeed)
|
||||
[](https://github.com/GopeedLab/gopeed/releases)
|
||||
[](https://github.com/GopeedLab/gopeed/releases)
|
||||
[](https://docs.gopeed.com/donate.html)
|
||||
[](https://gopeed.com/docs/donate)
|
||||
[](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
|
||||
[](https://discord.gg/ZUJqJrwCGB)
|
||||
|
||||

|
||||
<a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
[](https://ko-fi.com/R6R6IJGN6)
|
||||
|
||||
[English](/README.md) | [中文](/README_zh-CN.md) | [日本語](/README_ja-JP.md) | [正體中文](/README_zh-TW.md)
|
||||
[English](/README.md) | [中文](/README_zh-CN.md) | [日本語](/README_ja-JP.md) | [正體中文](/README_zh-TW.md) | [Tiếng Việt](/README_vi-VN.md)
|
||||
|
||||
## 簡介
|
||||
## 🚀 簡介
|
||||
|
||||
Gopeed(全稱 Go Speed),是一款使用`Golang`+`Flutter`編寫的高速下載軟體,支援(HTTP、BitTorrent、Magnet)協定,同時支援所有的平台。
|
||||
Gopeed(全稱 Go Speed),是一款使用`Golang`+`Flutter`編寫的高速下載軟體,支援(HTTP、BitTorrent、Magnet、ED2K)協定,同時支援所有的平台。
|
||||
|
||||
前往 ✈ [主頁](https://gopeed.com/zh-CN) | 📖 [文檔](https://docs.gopeed.com/zh/)
|
||||
前往 ✈ [主頁](https://gopeed.com/zh-CN) | 📖 [文檔](https://gopeed.com/docs)
|
||||
|
||||
## 安裝
|
||||
## ⬇️ 下載
|
||||
|
||||
**已支援的平台**
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td rowspan="2">🪟 Windows</td>
|
||||
<td><code>EXE</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-windows-amd64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Portable</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-windows-amd64-portable.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="3">🍎 MacOS</td>
|
||||
<td rowspan="3"><code>DMG</code></td>
|
||||
<td>universal</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos.dmg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos-amd64.dmg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos-arm64.dmg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="6">🐧 Linux</td>
|
||||
<td><code>Flathub</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://flathub.org/apps/com.gopeed.Gopeed">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>SNAP</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://snapcraft.io/gopeed">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="2"><code>DEB</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-linux-amd64.deb">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-linux-arm64.deb">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="2"><code>AppImage</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-linux-amd64.AppImage">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-linux-arm64.AppImage">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="4">🤖 Android</td>
|
||||
<td rowspan="4"><code>APK</code></td>
|
||||
<td>universal</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android.apk">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>armeabi-v7a</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android-armeabi-v7a.apk">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64-v8a</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android-arm64-v8a.apk">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>x86_64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android-x86_64.apk">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>📱 iOS</td>
|
||||
<td><code>IPA</code></td>
|
||||
<td>universal</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-ios.ipa">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>🐳 Docker</td>
|
||||
<td>-</td>
|
||||
<td>universal</td>
|
||||
<td><a href="https://hub.docker.com/r/liwei2633/gopeed">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="2">💾 Qnap</td>
|
||||
<td rowspan="2"><code>QPKG</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-$version-qnap-amd64.qpkg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-$version-qnap-arm64.qpkg">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="8">🌐 Web</td>
|
||||
<td rowspan="3"><code>Windows</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-$version-windows-amd64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-$version-windows-arm64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>386</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-$version-windows-386.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="2"><code>MacOS</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-$version-macos-amd64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-$version-macos-arm64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="3"><code>Linux</code></td>
|
||||
<td>amd64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-$version-linux-amd64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>arm64</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-$version-linux-arm64.zip">📥</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>386</td>
|
||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-$version-linux-386.zip">📥</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
- [x] Windows
|
||||
- [x] MacOS
|
||||
- [x] Linux
|
||||
- [x] Android
|
||||
- [x] iOS
|
||||
- [x] Web
|
||||
- [x] Docker
|
||||
更多關於安裝的內容請參考[安裝文檔](https://gopeed.com/docs/install)
|
||||
|
||||
[下載](https://github.com/GopeedLab/gopeed/releases/latest)
|
||||
|
||||
> 註:MacOS 版運行時若提示損壞,請在終端中執行 `xattr -d com.apple.quarantine /Applications/Gopeed.app`
|
||||
|
||||
### 使用CLI安裝
|
||||
### 🛠️ 使用 CLI 安裝
|
||||
|
||||
使用`go install`安裝:
|
||||
|
||||
@@ -41,56 +167,42 @@ Gopeed(全稱 Go Speed),是一款使用`Golang`+`Flutter`編寫的高速
|
||||
go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
|
||||
```
|
||||
|
||||
### Docker
|
||||
## 📱 微信公眾號
|
||||
|
||||
#### 直接執行
|
||||
關注公眾號獲取項目最新動態和資訊。
|
||||
|
||||
```bash
|
||||
docker run -d -p 9999:9999 -v /path/to/download:/root/Downloads -v /path/to/storage:/app/storage liwei2633/gopeed
|
||||
```
|
||||
<img src="_docs/img/weixin.png" width="200" />
|
||||
|
||||
[更多關於 Docker 的使用](https://docs.gopeed.com/zh/install.html#docker-%E5%AE%89%E8%A3%85)
|
||||
## 💝 贊助
|
||||
|
||||
#### 使用 Docker Compose
|
||||
如果你認為該項目對你有所幫助,請考慮[贊助](https://gopeed.com/docs/donate)以支持該項目的持續發展,謝謝!
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
#### 訪問服務
|
||||
|
||||
當 docker 容器運作時,可以通過 `http://localhost:9999` 訪問 web 頁面。
|
||||
|
||||
## 贊助
|
||||
|
||||
如果你認為該項目對你有所幫助,請考慮[贊助](https://docs.gopeed.com/zh/donate)以支持該項目的持續發展,謝謝!
|
||||
|
||||
## 軟體介面
|
||||
## 🖼️ 軟體介面
|
||||
|
||||

|
||||
|
||||
## 開發
|
||||
## 👨💻 開發
|
||||
|
||||
該項目分為前端與後端,前端使用`flutter`編寫,後端使用`Golang`編寫,兩邊通過`http`協定進行通訊,在 unix 系統下,則使用`unix socket`,在 windows 系統下,則使用`tcp`協定。
|
||||
|
||||
> 前端代碼位於`ui/flutter`目錄內。
|
||||
|
||||
### 開發環境
|
||||
### 🌍 開發環境
|
||||
|
||||
1. Golang 1.19+
|
||||
2. Flutter 3.10+
|
||||
1. Golang 1.25+
|
||||
2. Flutter 3.38+
|
||||
|
||||
### 克隆項目
|
||||
### 📋 克隆項目
|
||||
|
||||
```bash
|
||||
git clone [email protected]:GopeedLab/gopeed.git
|
||||
```
|
||||
|
||||
### 協助開發
|
||||
### 🤝 協助開發
|
||||
|
||||
請參考[協助指南](CONTRIBUTING_zh-TW.md)
|
||||
|
||||
### 編譯
|
||||
### 🏗️ 編譯
|
||||
|
||||
#### 桌面端
|
||||
|
||||
@@ -137,7 +249,7 @@ gomobile init
|
||||
- android
|
||||
|
||||
```bash
|
||||
gomobile bind -tags nosqlite -ldflags="-w -s" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 19 -javapkg=com.gopeed github.com/GopeedLab/gopeed/bind/mobile
|
||||
gomobile bind -tags nosqlite -ldflags="-w -s -checklinkname=0" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 21 -javapkg="com.gopeed" github.com/GopeedLab/gopeed/bind/mobile
|
||||
cd ui/flutter
|
||||
flutter build apk
|
||||
```
|
||||
@@ -163,7 +275,7 @@ cp -r ui/flutter/build/web cmd/web/dist
|
||||
go build -tags nosqlite,web -ldflags="-s -w" -o bin/ github.com/GopeedLab/gopeed/cmd/web
|
||||
```
|
||||
|
||||
## 感謝
|
||||
## ❤️ 感謝
|
||||
|
||||
### 貢獻者
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,54 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 400" width="1200" height="400">
|
||||
<rect width="1200" height="400" fill="#040605"/>
|
||||
<g fill="#5dc76c">
|
||||
<rect x="-100" y="50" width="150" height="2" opacity="0.6">
|
||||
<animate attributeName="x" from="-200" to="1300" dur="0.8s" repeatCount="indefinite"/>
|
||||
</rect>
|
||||
<rect x="-200" y="150" width="200" height="1" opacity="0.4">
|
||||
<animate attributeName="x" from="-300" to="1300" dur="1.2s" repeatCount="indefinite"/>
|
||||
</rect>
|
||||
<rect x="-50" y="250" width="180" height="3" opacity="0.8">
|
||||
<animate attributeName="x" from="-200" to="1300" dur="0.6s" repeatCount="indefinite"/>
|
||||
</rect>
|
||||
<rect x="-150" y="350" width="220" height="2" opacity="0.5">
|
||||
<animate attributeName="x" from="-300" to="1300" dur="1s" repeatCount="indefinite"/>
|
||||
</rect>
|
||||
<rect x="-100" y="100" width="100" height="1" opacity="0.7">
|
||||
<animate attributeName="x" from="-150" to="1300" dur="0.9s" repeatCount="indefinite"/>
|
||||
</rect>
|
||||
<rect x="-250" y="300" width="250" height="2" opacity="0.3">
|
||||
<animate attributeName="x" from="-400" to="1300" dur="1.5s" repeatCount="indefinite"/>
|
||||
</rect>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="logoGlow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="12" result="blur" />
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||
</filter>
|
||||
</defs>
|
||||
<g transform="translate(600, 160)">
|
||||
<!-- Floating animation -->
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="translate" values="0,-8; 0,8; 0,-8" dur="3s" repeatCount="indefinite" />
|
||||
|
||||
<!-- Pulsing glow behind logo -->
|
||||
<circle cx="0" cy="0" r="45" fill="#5dc76c" filter="url(#logoGlow)" opacity="0.3">
|
||||
<animate attributeName="opacity" values="0.1; 0.6; 0.1" dur="2s" repeatCount="indefinite" />
|
||||
<animate attributeName="r" values="40; 65; 40" dur="2s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
|
||||
<!-- Breathing scale animation -->
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="scale" values="0.95; 1.05; 0.95" dur="2s" repeatCount="indefinite" />
|
||||
<g transform="scale(0.12) translate(-512, -512)">
|
||||
<path style="opacity:0.998" fill="#5dc76c" d="M 951.5,763.5 C 940.739,755.276 929.405,747.776 917.5,741C 908.477,736.319 899.143,732.485 889.5,729.5C 885.844,625.779 840.844,545.613 754.5,489C 674.802,441.453 590.135,430.12 500.5,455C 419.755,480.605 359.588,530.771 320,605.5C 278.585,565.816 230.752,555.649 176.5,575C 131.948,594.935 106.448,629.102 100,677.5C 99.3333,685.833 99.3333,694.167 100,702.5C 102.081,714.824 105.081,726.824 109,738.5C 95.4882,745.492 82.9882,753.826 71.5,763.5C 37.7121,705.639 16.5454,643.306 8,576.5C -9.41698,416.759 35.9164,278.093 144,160.5C 244.661,58.2827 367.161,5.94939 511.5,3.5C 678.975,7.48829 814.142,74.8216 917,205.5C 1011.33,336.426 1040,480.759 1003,638.5C 991.564,682.671 974.397,724.337 951.5,763.5 Z"/>
|
||||
<path style="opacity:0.999" fill="#fefefe" d="M 951.5,763.5 C 949.28,768.272 946.78,772.938 944,777.5C 938.305,786.913 932.138,795.913 925.5,804.5C 915.954,796.888 905.288,791.055 893.5,787C 878.282,782.958 863.115,778.791 848,774.5C 854.013,716.891 841.346,663.891 810,615.5C 768.341,553.228 711.174,512.395 638.5,493C 560.311,479.566 490.311,497.566 428.5,547C 411.963,560.536 397.13,575.703 384,592.5C 366.745,616.923 348.911,640.923 330.5,664.5C 310.135,651.302 290.469,637.135 271.5,622C 238.448,602.307 206.782,604.307 176.5,628C 159.375,642.752 148.875,661.252 145,683.5C 143.968,692.716 144.968,701.716 148,710.5C 154.334,725.339 160.167,740.339 165.5,755.5C 138.118,764.688 114.451,779.688 94.5,800.5C 85.653,788.968 77.9863,776.635 71.5,763.5C 82.9882,753.826 95.4882,745.492 109,738.5C 105.081,726.824 102.081,714.824 100,702.5C 99.3333,694.167 99.3333,685.833 100,677.5C 106.448,629.102 131.948,594.935 176.5,575C 230.752,555.649 278.585,565.816 320,605.5C 359.588,530.771 419.755,480.605 500.5,455C 590.135,430.12 674.802,441.453 754.5,489C 840.844,545.613 885.844,625.779 889.5,729.5C 899.143,732.485 908.477,736.319 917.5,741C 929.405,747.776 940.739,755.276 951.5,763.5 Z"/>
|
||||
<path style="opacity:0.998" fill="#5dc76c" d="M 925.5,804.5 C 850.791,908.608 750.791,975.775 625.5,1006C 609.308,1009.7 592.975,1012.53 576.5,1014.5C 615.708,969.938 655.042,925.438 694.5,881C 664.168,880.5 633.835,880.333 603.5,880.5C 603.5,811.167 603.5,741.833 603.5,672.5C 542.167,672.5 480.833,672.5 419.5,672.5C 419.5,741.5 419.5,810.5 419.5,879.5C 388.493,879.167 357.493,879.5 326.5,880.5C 326.624,881.107 326.957,881.44 327.5,881.5C 366.805,925.808 406.138,970.141 445.5,1014.5C 318.63,996.317 212.796,939.651 128,844.5C 115.85,830.525 104.683,815.859 94.5,800.5C 114.451,779.688 138.118,764.688 165.5,755.5C 160.167,740.339 154.334,725.339 148,710.5C 144.968,701.716 143.968,692.716 145,683.5C 148.875,661.252 159.375,642.752 176.5,628C 206.782,604.307 238.448,602.307 271.5,622C 290.469,637.135 310.135,651.302 330.5,664.5C 348.911,640.923 366.745,616.923 384,592.5C 397.13,575.703 411.963,560.536 428.5,547C 490.311,497.566 560.311,479.566 638.5,493C 711.174,512.395 768.341,553.228 810,615.5C 841.346,663.891 854.013,716.891 848,774.5C 863.115,778.791 878.282,782.958 893.5,787C 905.288,791.055 915.954,796.888 925.5,804.5 Z"/>
|
||||
<path style="opacity:0.999" fill="#fefefe" d="M 576.5,1014.5 C 532.836,1019.82 489.169,1019.82 445.5,1014.5C 406.138,970.141 366.805,925.808 327.5,881.5C 358.167,880.833 388.833,880.167 419.5,879.5C 419.5,810.5 419.5,741.5 419.5,672.5C 480.833,672.5 542.167,672.5 603.5,672.5C 603.5,741.833 603.5,811.167 603.5,880.5C 633.835,880.333 664.168,880.5 694.5,881C 655.042,925.438 615.708,969.938 576.5,1014.5 Z"/>
|
||||
<path style="opacity:1" fill="#ade2b5" d="M 419.5,879.5 C 388.833,880.167 358.167,880.833 327.5,881.5C 326.957,881.44 326.624,881.107 326.5,880.5C 357.493,879.5 388.493,879.167 419.5,879.5 Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<text x="600" y="300" font-family="sans-serif" font-size="72" font-style="italic" font-weight="900" text-anchor="middle" fill="#fff" letter-spacing="8">GOPEED</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.0 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 1.2 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/download"
|
||||
"github.com/GopeedLab/gopeed/pkg/protocol/http"
|
||||
|
||||
@@ -16,6 +16,7 @@ func Start(cfg *C.char) (int, *C.char) {
|
||||
return 0, C.CString(err.Error())
|
||||
}
|
||||
config.ProductionMode = true
|
||||
applyWebViewProvider(&config)
|
||||
realPort, err := rest.Start(&config)
|
||||
if err != nil {
|
||||
return 0, C.CString(err.Error())
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/GopeedLab/gopeed/internal/webview/rpcprovider"
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||
)
|
||||
|
||||
func applyWebViewProvider(config *model.StartConfig) {
|
||||
if config == nil {
|
||||
return
|
||||
}
|
||||
config.WebViewProvider = newDesktopWebViewProvider(config.WebViewRPCConfig)
|
||||
}
|
||||
|
||||
func newDesktopWebViewProvider(rpcCfg *enginewebview.RPCConfig) enginewebview.Provider {
|
||||
if rpcCfg != nil && rpcCfg.Enabled() {
|
||||
return rpcprovider.New(*rpcCfg)
|
||||
}
|
||||
return enginewebview.NewUnavailableProvider()
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//go:build !darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/GopeedLab/gopeed/internal/webview/goprovider"
|
||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||
)
|
||||
|
||||
func applyWebViewProvider(config *model.StartConfig) {
|
||||
if config == nil {
|
||||
return
|
||||
}
|
||||
config.WebViewProvider = goprovider.New()
|
||||
}
|
||||
@@ -4,6 +4,7 @@ package libgopeed
|
||||
import "C"
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/GopeedLab/gopeed/internal/webview/rpcprovider"
|
||||
"github.com/GopeedLab/gopeed/pkg/rest"
|
||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||
)
|
||||
@@ -14,9 +15,17 @@ func Start(cfg string) (int, error) {
|
||||
return 0, err
|
||||
}
|
||||
config.ProductionMode = true
|
||||
applyWebViewProvider(&config)
|
||||
return rest.Start(&config)
|
||||
}
|
||||
|
||||
func Stop() {
|
||||
rest.Stop()
|
||||
}
|
||||
|
||||
func applyWebViewProvider(config *model.StartConfig) {
|
||||
if config == nil || config.WebViewRPCConfig == nil || !config.WebViewRPCConfig.Enabled() {
|
||||
return
|
||||
}
|
||||
config.WebViewProvider = rpcprovider.New(*config.WebViewRPCConfig)
|
||||
}
|
||||
@@ -0,0 +1,723 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/webview/goprovider"
|
||||
"github.com/GopeedLab/gopeed/internal/webview/rpcprovider"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/download"
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
)
|
||||
|
||||
type kvFlag map[string]string
|
||||
|
||||
type webViewConfig struct {
|
||||
provider string
|
||||
network string
|
||||
address string
|
||||
token string
|
||||
}
|
||||
|
||||
func (l *kvFlag) String() string {
|
||||
if l == nil {
|
||||
return ""
|
||||
}
|
||||
data, _ := json.Marshal(map[string]string(*l))
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func (l *kvFlag) Set(value string) error {
|
||||
if *l == nil {
|
||||
*l = make(map[string]string)
|
||||
}
|
||||
idx := -1
|
||||
for i := 0; i < len(value); i++ {
|
||||
if value[i] == '=' {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx <= 0 {
|
||||
return fmt.Errorf("invalid label %q, expected key=value", value)
|
||||
}
|
||||
(*l)[value[:idx]] = value[idx+1:]
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
os.Exit(goprovider.RunMainThreadLoop(run))
|
||||
}
|
||||
|
||||
func run() int {
|
||||
if len(os.Args) < 2 {
|
||||
usage()
|
||||
return 2
|
||||
}
|
||||
|
||||
switch os.Args[1] {
|
||||
case "resolve":
|
||||
if err := runResolve(os.Args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
case "download":
|
||||
if err := runDownload(os.Args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
case "eval":
|
||||
if err := runEval(os.Args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
case "-h", "--help", "help":
|
||||
usage()
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown subcommand %q\n", os.Args[1])
|
||||
usage()
|
||||
return 2
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintf(os.Stderr, `gopeed extmock CLI
|
||||
|
||||
Usage:
|
||||
extmock resolve [flags] <extension-dir> <url>
|
||||
extmock download [flags] <extension-dir> <url> <output-dir>
|
||||
extmock eval [flags] [script-file]
|
||||
|
||||
Flags:
|
||||
-label key=value Request label, repeatable
|
||||
-setting key=value Extension setting override, repeatable
|
||||
-webview-provider WebView provider: go|rpc
|
||||
-webview-network RPC network when provider=rpc: unix|tcp
|
||||
-webview-address RPC address when provider=rpc
|
||||
-webview-token RPC bearer token when provider=rpc
|
||||
-verb Print download progress while running
|
||||
-pretty Pretty-print JSON output
|
||||
|
||||
Examples:
|
||||
extmock resolve ./ https://www.google.com
|
||||
extmock download ./ https://example.com/video /tmp/extmock-download
|
||||
extmock resolve ./ https://example.com/video -label env=test -setting token=abc
|
||||
extmock eval ./test.js
|
||||
extmock eval -e "new Blob(['hello']).size"
|
||||
`)
|
||||
}
|
||||
|
||||
func runResolve(args []string) error {
|
||||
fs := flag.NewFlagSet("resolve", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
var labels kvFlag
|
||||
var settings kvFlag
|
||||
var pretty bool
|
||||
cfg := webViewConfig{}
|
||||
fs.Var(&labels, "label", "Request label in key=value form (repeatable)")
|
||||
fs.Var(&settings, "setting", "Extension setting override in key=value form (repeatable)")
|
||||
fs.StringVar(&cfg.provider, "webview-provider", "", "WebView provider: go|rpc")
|
||||
fs.StringVar(&cfg.network, "webview-network", "", "WebView RPC network: unix|tcp")
|
||||
fs.StringVar(&cfg.address, "webview-address", "", "WebView RPC address")
|
||||
fs.StringVar(&cfg.token, "webview-token", "", "WebView RPC bearer token")
|
||||
fs.BoolVar(&pretty, "pretty", false, "Pretty-print JSON output")
|
||||
|
||||
flagArgs, positionalArgs, err := splitResolveArgs(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fs.Parse(flagArgs); err != nil {
|
||||
return err
|
||||
}
|
||||
if fs.NArg() != 0 {
|
||||
return fmt.Errorf("unexpected trailing args: %v", fs.Args())
|
||||
}
|
||||
if len(positionalArgs) != 2 {
|
||||
return fmt.Errorf("resolve expects exactly 2 args: <extension-dir> <url>")
|
||||
}
|
||||
|
||||
extDir, err := filepath.Abs(positionalArgs[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
url := positionalArgs[1]
|
||||
|
||||
d, cleanup, err := newExtMockDownloader(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
ext, err := d.InstallExtensionByFolder(extDir, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(settings) > 0 {
|
||||
if err := d.UpdateExtensionSettings(ext.Identity, toAnyMap(settings)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
result, err := d.Resolve(&base.Request{
|
||||
URL: url,
|
||||
Labels: map[string]string(labels),
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printJSON(result, pretty)
|
||||
}
|
||||
|
||||
type downloadResult struct {
|
||||
Resolve *download.ResolveResult `json:"resolve"`
|
||||
OutputDir string `json:"outputDir"`
|
||||
Files []*downloadedFileResult `json:"files"`
|
||||
}
|
||||
|
||||
type downloadedFileResult struct {
|
||||
Index int `json:"index"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
TaskID string `json:"taskId"`
|
||||
Status base.Status `json:"status"`
|
||||
Size int64 `json:"size"`
|
||||
Req *base.Request `json:"req,omitempty"`
|
||||
}
|
||||
|
||||
func runDownload(args []string) error {
|
||||
fs := flag.NewFlagSet("download", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
var labels kvFlag
|
||||
var settings kvFlag
|
||||
var pretty bool
|
||||
var verbose bool
|
||||
cfg := webViewConfig{}
|
||||
fs.Var(&labels, "label", "Request label in key=value form (repeatable)")
|
||||
fs.Var(&settings, "setting", "Extension setting override in key=value form (repeatable)")
|
||||
fs.StringVar(&cfg.provider, "webview-provider", "", "WebView provider: go|rpc")
|
||||
fs.StringVar(&cfg.network, "webview-network", "", "WebView RPC network: unix|tcp")
|
||||
fs.StringVar(&cfg.address, "webview-address", "", "WebView RPC address")
|
||||
fs.StringVar(&cfg.token, "webview-token", "", "WebView RPC bearer token")
|
||||
fs.BoolVar(&verbose, "verb", false, "Print download progress while running")
|
||||
fs.BoolVar(&verbose, "verbose", false, "Print download progress while running")
|
||||
fs.BoolVar(&pretty, "pretty", false, "Pretty-print JSON output")
|
||||
|
||||
flagArgs, positionalArgs, err := splitDownloadArgs(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fs.Parse(flagArgs); err != nil {
|
||||
return err
|
||||
}
|
||||
if fs.NArg() != 0 {
|
||||
return fmt.Errorf("unexpected trailing args: %v", fs.Args())
|
||||
}
|
||||
if len(positionalArgs) != 3 {
|
||||
return fmt.Errorf("download expects exactly 3 args: <extension-dir> <url> <output-dir>")
|
||||
}
|
||||
|
||||
extDir, err := filepath.Abs(positionalArgs[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
url := positionalArgs[1]
|
||||
outputDir, err := filepath.Abs(positionalArgs[2])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
d, cleanup, err := newExtMockDownloader(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
ext, err := d.InstallExtensionByFolder(extDir, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(settings) > 0 {
|
||||
if err := d.UpdateExtensionSettings(ext.Identity, toAnyMap(settings)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
result, err := d.Resolve(&base.Request{
|
||||
URL: url,
|
||||
Labels: map[string]string(labels),
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result == nil || result.Res == nil {
|
||||
return fmt.Errorf("resolve returned empty resource")
|
||||
}
|
||||
if len(result.Res.Files) == 0 {
|
||||
return fmt.Errorf("resolve returned no files")
|
||||
}
|
||||
|
||||
resourceOutputDir := outputDir
|
||||
if result.Res.Name != "" {
|
||||
resourceOutputDir = filepath.Join(outputDir, result.Res.Name)
|
||||
}
|
||||
if err := os.MkdirAll(resourceOutputDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
waiter := newTaskTerminalWaiter(d)
|
||||
summary := &downloadResult{
|
||||
Resolve: result,
|
||||
OutputDir: resourceOutputDir,
|
||||
Files: make([]*downloadedFileResult, 0, len(result.Res.Files)),
|
||||
}
|
||||
|
||||
for idx, file := range result.Res.Files {
|
||||
if file == nil {
|
||||
return fmt.Errorf("file %d is nil", idx)
|
||||
}
|
||||
if file.Req == nil {
|
||||
return fmt.Errorf("file %d request is nil", idx)
|
||||
}
|
||||
|
||||
targetDir := resourceOutputDir
|
||||
if file.Path != "" {
|
||||
targetDir = filepath.Join(resourceOutputDir, file.Path)
|
||||
}
|
||||
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
taskID, err := d.CreateDirect(file.Req, &base.Options{
|
||||
Path: targetDir,
|
||||
Name: file.Name,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create task for %q failed: %w", file.Name, err)
|
||||
}
|
||||
waiter.Register(taskID)
|
||||
|
||||
summary.Files = append(summary.Files, &downloadedFileResult{
|
||||
Index: idx,
|
||||
Name: file.Name,
|
||||
Path: filepath.Join(targetDir, file.Name),
|
||||
TaskID: taskID,
|
||||
Req: file.Req,
|
||||
})
|
||||
}
|
||||
|
||||
var stopVerbose func()
|
||||
if verbose {
|
||||
stopVerbose = startVerboseTaskListener(d, summary.Files)
|
||||
defer stopVerbose()
|
||||
}
|
||||
|
||||
for _, file := range summary.Files {
|
||||
task, err := waiter.Wait(file.TaskID, 5*time.Minute)
|
||||
if err != nil {
|
||||
return fmt.Errorf("download %q failed: %w", file.Name, err)
|
||||
}
|
||||
file.Status = task.Status
|
||||
if info, statErr := os.Stat(file.Path); statErr == nil {
|
||||
file.Size = info.Size()
|
||||
}
|
||||
}
|
||||
|
||||
return printJSON(summary, pretty)
|
||||
}
|
||||
|
||||
func runEval(args []string) error {
|
||||
fs := flag.NewFlagSet("eval", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
var (
|
||||
script string
|
||||
pretty bool
|
||||
)
|
||||
cfg := webViewConfig{}
|
||||
fs.StringVar(&script, "e", "", "Inline JavaScript to execute")
|
||||
fs.StringVar(&script, "eval", "", "Inline JavaScript to execute")
|
||||
fs.StringVar(&cfg.provider, "webview-provider", "", "WebView provider: go|rpc")
|
||||
fs.StringVar(&cfg.network, "webview-network", "", "WebView RPC network: unix|tcp")
|
||||
fs.StringVar(&cfg.address, "webview-address", "", "WebView RPC address")
|
||||
fs.StringVar(&cfg.token, "webview-token", "", "WebView RPC bearer token")
|
||||
fs.BoolVar(&pretty, "pretty", false, "Pretty-print JSON output")
|
||||
|
||||
flagArgs, positionalArgs, err := splitEvalArgs(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fs.Parse(flagArgs); err != nil {
|
||||
return err
|
||||
}
|
||||
if fs.NArg() != 0 {
|
||||
return fmt.Errorf("unexpected trailing args: %v", fs.Args())
|
||||
}
|
||||
if script == "" && len(positionalArgs) == 0 {
|
||||
return fmt.Errorf("eval expects inline code via -e/--eval or a script file path")
|
||||
}
|
||||
if script != "" && len(positionalArgs) > 0 {
|
||||
return fmt.Errorf("eval accepts either inline code or a script file path, not both")
|
||||
}
|
||||
if len(positionalArgs) > 1 {
|
||||
return fmt.Errorf("eval expects at most 1 script file path")
|
||||
}
|
||||
|
||||
d, cleanup, err := newExtMockDownloader(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
runtime, err := d.NewExtensionEngine(newExtMockExtension(), map[string]any{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer runtime.Close()
|
||||
|
||||
var value any
|
||||
if script != "" {
|
||||
value, err = runtime.Eval(script)
|
||||
} else {
|
||||
scriptPath, pathErr := filepath.Abs(positionalArgs[0])
|
||||
if pathErr != nil {
|
||||
return pathErr
|
||||
}
|
||||
value, err = runtime.EvalFile(scriptPath)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printJSON(value, pretty)
|
||||
}
|
||||
|
||||
func splitResolveArgs(args []string) ([]string, []string, error) {
|
||||
var flagArgs []string
|
||||
var positionalArgs []string
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
switch {
|
||||
case arg == "-pretty":
|
||||
flagArgs = append(flagArgs, arg)
|
||||
case arg == "-webview-provider", arg == "-webview-network", arg == "-webview-address", arg == "-webview-token":
|
||||
if i+1 >= len(args) {
|
||||
return nil, nil, fmt.Errorf("flag %q requires a value", arg)
|
||||
}
|
||||
flagArgs = append(flagArgs, arg, args[i+1])
|
||||
i++
|
||||
case arg == "-label":
|
||||
if i+1 >= len(args) {
|
||||
return nil, nil, fmt.Errorf("flag %q requires a value", arg)
|
||||
}
|
||||
flagArgs = append(flagArgs, arg, args[i+1])
|
||||
i++
|
||||
case arg == "-setting":
|
||||
if i+1 >= len(args) {
|
||||
return nil, nil, fmt.Errorf("flag %q requires a value", arg)
|
||||
}
|
||||
flagArgs = append(flagArgs, arg, args[i+1])
|
||||
i++
|
||||
case len(arg) > len("-label=") && arg[:len("-label=")] == "-label=":
|
||||
flagArgs = append(flagArgs, arg)
|
||||
case len(arg) > len("-setting=") && arg[:len("-setting=")] == "-setting=":
|
||||
flagArgs = append(flagArgs, arg)
|
||||
case hasFlagValue(arg, "-webview-provider"), hasFlagValue(arg, "-webview-network"),
|
||||
hasFlagValue(arg, "-webview-address"), hasFlagValue(arg, "-webview-token"):
|
||||
flagArgs = append(flagArgs, arg)
|
||||
case len(arg) > 0 && arg[0] == '-':
|
||||
return nil, nil, fmt.Errorf("unknown flag %q", arg)
|
||||
default:
|
||||
positionalArgs = append(positionalArgs, arg)
|
||||
}
|
||||
}
|
||||
return flagArgs, positionalArgs, nil
|
||||
}
|
||||
|
||||
func splitDownloadArgs(args []string) ([]string, []string, error) {
|
||||
var flagArgs []string
|
||||
var positionalArgs []string
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
switch {
|
||||
case arg == "-pretty", arg == "-verb", arg == "-verbose":
|
||||
flagArgs = append(flagArgs, arg)
|
||||
case arg == "-webview-provider", arg == "-webview-network", arg == "-webview-address", arg == "-webview-token":
|
||||
if i+1 >= len(args) {
|
||||
return nil, nil, fmt.Errorf("flag %q requires a value", arg)
|
||||
}
|
||||
flagArgs = append(flagArgs, arg, args[i+1])
|
||||
i++
|
||||
case arg == "-label":
|
||||
if i+1 >= len(args) {
|
||||
return nil, nil, fmt.Errorf("flag %q requires a value", arg)
|
||||
}
|
||||
flagArgs = append(flagArgs, arg, args[i+1])
|
||||
i++
|
||||
case arg == "-setting":
|
||||
if i+1 >= len(args) {
|
||||
return nil, nil, fmt.Errorf("flag %q requires a value", arg)
|
||||
}
|
||||
flagArgs = append(flagArgs, arg, args[i+1])
|
||||
i++
|
||||
case len(arg) > len("-label=") && arg[:len("-label=")] == "-label=":
|
||||
flagArgs = append(flagArgs, arg)
|
||||
case len(arg) > len("-setting=") && arg[:len("-setting=")] == "-setting=":
|
||||
flagArgs = append(flagArgs, arg)
|
||||
case hasFlagValue(arg, "-webview-provider"), hasFlagValue(arg, "-webview-network"),
|
||||
hasFlagValue(arg, "-webview-address"), hasFlagValue(arg, "-webview-token"):
|
||||
flagArgs = append(flagArgs, arg)
|
||||
case len(arg) > 0 && arg[0] == '-':
|
||||
return nil, nil, fmt.Errorf("unknown flag %q", arg)
|
||||
default:
|
||||
positionalArgs = append(positionalArgs, arg)
|
||||
}
|
||||
}
|
||||
return flagArgs, positionalArgs, nil
|
||||
}
|
||||
|
||||
func splitEvalArgs(args []string) ([]string, []string, error) {
|
||||
var flagArgs []string
|
||||
var positionalArgs []string
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
switch {
|
||||
case arg == "-pretty":
|
||||
flagArgs = append(flagArgs, arg)
|
||||
case arg == "-webview-provider", arg == "-webview-network", arg == "-webview-address", arg == "-webview-token":
|
||||
if i+1 >= len(args) {
|
||||
return nil, nil, fmt.Errorf("flag %q requires a value", arg)
|
||||
}
|
||||
flagArgs = append(flagArgs, arg, args[i+1])
|
||||
i++
|
||||
case arg == "-e", arg == "--eval":
|
||||
if i+1 >= len(args) {
|
||||
return nil, nil, fmt.Errorf("flag %q requires a value", arg)
|
||||
}
|
||||
flagArgs = append(flagArgs, arg, args[i+1])
|
||||
i++
|
||||
case hasFlagValue(arg, "-e"), hasFlagValue(arg, "--eval"),
|
||||
hasFlagValue(arg, "-webview-provider"), hasFlagValue(arg, "-webview-network"),
|
||||
hasFlagValue(arg, "-webview-address"), hasFlagValue(arg, "-webview-token"):
|
||||
flagArgs = append(flagArgs, arg)
|
||||
case len(arg) > 0 && arg[0] == '-':
|
||||
return nil, nil, fmt.Errorf("unknown flag %q", arg)
|
||||
default:
|
||||
positionalArgs = append(positionalArgs, arg)
|
||||
}
|
||||
}
|
||||
return flagArgs, positionalArgs, nil
|
||||
}
|
||||
|
||||
func hasFlagValue(arg string, prefix string) bool {
|
||||
return len(arg) > len(prefix)+1 && arg[:len(prefix)+1] == prefix+"="
|
||||
}
|
||||
|
||||
func toAnyMap(values kvFlag) map[string]any {
|
||||
result := make(map[string]any, len(values))
|
||||
for key, value := range values {
|
||||
result[key] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func newExtMockDownloader(cfg webViewConfig) (*download.Downloader, func(), error) {
|
||||
webViewProvider, err := newExtMockWebViewProvider(cfg)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
d := download.NewDownloader(&download.DownloaderConfig{
|
||||
Storage: download.NewMemStorage(),
|
||||
WebViewProvider: webViewProvider,
|
||||
})
|
||||
if err := d.Setup(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
cleanup := func() {
|
||||
_ = d.Clear()
|
||||
}
|
||||
return d, cleanup, nil
|
||||
}
|
||||
|
||||
func newExtMockExtension() *download.Extension {
|
||||
return &download.Extension{
|
||||
Name: "extmock",
|
||||
Author: "gopeed",
|
||||
Title: "Gopeed ExtMock Runtime",
|
||||
Version: "0.0.0",
|
||||
DevMode: true,
|
||||
}
|
||||
}
|
||||
|
||||
func newExtMockWebViewProvider(cfg webViewConfig) (enginewebview.Provider, error) {
|
||||
switch cfg.provider {
|
||||
case "", "go":
|
||||
return goprovider.New(), nil
|
||||
case "rpc":
|
||||
rpcCfg, err := resolveRPCConfig(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rpcprovider.New(rpcCfg), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported webview provider %q", cfg.provider)
|
||||
}
|
||||
}
|
||||
|
||||
func resolveRPCConfig(cfg webViewConfig) (enginewebview.RPCConfig, error) {
|
||||
network := cfg.network
|
||||
address := cfg.address
|
||||
if network == "" && address == "" {
|
||||
defaultAddress, err := defaultWebViewRPCUnixSocket()
|
||||
if err != nil {
|
||||
return enginewebview.RPCConfig{}, err
|
||||
}
|
||||
network = "unix"
|
||||
address = defaultAddress
|
||||
}
|
||||
if network == "" || address == "" {
|
||||
return enginewebview.RPCConfig{}, fmt.Errorf("webview rpc requires both network and address")
|
||||
}
|
||||
return enginewebview.RPCConfig{
|
||||
Network: network,
|
||||
Address: address,
|
||||
Token: cfg.token,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func defaultWebViewRPCUnixSocket() (string, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(homeDir, "gopeed_webview.sock"), nil
|
||||
}
|
||||
|
||||
type taskTerminalWaiter struct {
|
||||
downloader *download.Downloader
|
||||
}
|
||||
|
||||
func newTaskTerminalWaiter(d *download.Downloader) *taskTerminalWaiter {
|
||||
return &taskTerminalWaiter{downloader: d}
|
||||
}
|
||||
|
||||
func (w *taskTerminalWaiter) Register(id string) {
|
||||
}
|
||||
|
||||
func (w *taskTerminalWaiter) Wait(id string, timeout time.Duration) (*download.Task, error) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if task, ok := w.currentTerminalTask(id); ok {
|
||||
return task, w.taskError(task)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
return nil, fmt.Errorf("timeout waiting for task %s", id)
|
||||
}
|
||||
|
||||
func (w *taskTerminalWaiter) currentTerminalTask(id string) (*download.Task, bool) {
|
||||
task := w.downloader.GetTask(id)
|
||||
if task == nil {
|
||||
return nil, false
|
||||
}
|
||||
if task.Status != base.DownloadStatusDone && task.Status != base.DownloadStatusError {
|
||||
return nil, false
|
||||
}
|
||||
return task, true
|
||||
}
|
||||
|
||||
func (w *taskTerminalWaiter) taskError(task *download.Task) error {
|
||||
if task == nil {
|
||||
return fmt.Errorf("task is nil")
|
||||
}
|
||||
if task.Status == base.DownloadStatusError {
|
||||
return fmt.Errorf("task %s finished with status %s", task.ID, task.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func startVerboseTaskListener(d *download.Downloader, files []*downloadedFileResult) func() {
|
||||
tracked := make(map[string]string, len(files))
|
||||
for _, file := range files {
|
||||
tracked[file.TaskID] = file.Name
|
||||
}
|
||||
d.Listener(func(event *download.Event) {
|
||||
if event == nil || event.Task == nil {
|
||||
return
|
||||
}
|
||||
name, ok := tracked[event.Task.ID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
switch event.Key {
|
||||
case download.EventKeyStart, download.EventKeyProgress, download.EventKeyDone, download.EventKeyError:
|
||||
printVerboseTaskEvent(event, name)
|
||||
}
|
||||
})
|
||||
return func() {}
|
||||
}
|
||||
|
||||
func printVerboseTaskEvent(event *download.Event, name string) {
|
||||
task := event.Task
|
||||
ts := time.Now().Format("15:04:05.000")
|
||||
if task == nil {
|
||||
return
|
||||
}
|
||||
total := int64(0)
|
||||
if task.Meta != nil && task.Meta.Res != nil {
|
||||
total = task.Meta.Res.Size
|
||||
}
|
||||
downloaded := int64(0)
|
||||
speed := int64(0)
|
||||
if task.Progress != nil {
|
||||
downloaded = task.Progress.Downloaded
|
||||
speed = task.Progress.Speed
|
||||
}
|
||||
if event.Key == download.EventKeyError && event.Err != nil {
|
||||
fmt.Fprintf(
|
||||
os.Stderr,
|
||||
"[%s] event=%s task=%s status=%s downloaded=%d total=%d speed=%d name=%q err=%v\n",
|
||||
ts,
|
||||
event.Key,
|
||||
task.ID,
|
||||
task.Status,
|
||||
downloaded,
|
||||
total,
|
||||
speed,
|
||||
name,
|
||||
event.Err,
|
||||
)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(
|
||||
os.Stderr,
|
||||
"[%s] event=%s task=%s status=%s downloaded=%d total=%d speed=%d name=%q\n",
|
||||
ts,
|
||||
event.Key,
|
||||
task.ID,
|
||||
task.Status,
|
||||
downloaded,
|
||||
total,
|
||||
speed,
|
||||
name,
|
||||
)
|
||||
}
|
||||
|
||||
func printJSON(v any, pretty bool) error {
|
||||
var (
|
||||
data []byte
|
||||
err error
|
||||
)
|
||||
if pretty {
|
||||
data, err = json.MarshalIndent(v, "", " ")
|
||||
} else {
|
||||
data, err = json.Marshal(v)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
return nil
|
||||
}
|
||||
+6
-2
@@ -2,13 +2,14 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/download"
|
||||
"github.com/GopeedLab/gopeed/pkg/protocol/http"
|
||||
"github.com/GopeedLab/gopeed/pkg/util"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const progressWidth = 20
|
||||
@@ -65,6 +66,9 @@ var (
|
||||
|
||||
func printProgress(task *download.Task, title string) {
|
||||
var rate float64
|
||||
if task.Meta.Res == nil {
|
||||
task = emptyTask
|
||||
}
|
||||
if task.Meta.Res.Size <= 0 {
|
||||
rate = 0
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func Dial() (net.Conn, error) {
|
||||
// Get binary path
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return net.Dial("unix", filepath.Join(filepath.Dir(exe), "gopeed_host.sock"))
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net"
|
||||
|
||||
"github.com/Microsoft/go-winio"
|
||||
)
|
||||
|
||||
func Dial() (net.Conn, error) {
|
||||
return winio.DialPipe(`\\.\pipe\gopeed_host`, nil)
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/browser"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
Method string `json:"method"`
|
||||
Meta map[string]any `json:"meta"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Code int `json:"code"`
|
||||
Data any `json:"data,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
func check() (data bool, err error) {
|
||||
conn, err := Dial()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer conn.Close()
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func wakeup(hidden bool) error {
|
||||
running, _ := check()
|
||||
if running {
|
||||
return nil
|
||||
}
|
||||
|
||||
uri := "gopeed:"
|
||||
if hidden {
|
||||
uri = uri + "?hidden=true"
|
||||
}
|
||||
if err := browser.OpenURL(uri); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
if ok, _ := check(); ok {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
return fmt.Errorf("start gopeed failed")
|
||||
}
|
||||
|
||||
// postToFlutter sends a POST request to Flutter RPC server
|
||||
func postToFlutter(path string, body []byte, headers map[string]string, timeout time.Duration) (*http.Response, error) {
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return Dial()
|
||||
},
|
||||
},
|
||||
Timeout: timeout,
|
||||
}
|
||||
req, err := http.NewRequest("POST", "http://127.0.0.1"+path, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
return client.Do(req)
|
||||
}
|
||||
|
||||
var apiMap = map[string]func(message *Message) (data any, err error){
|
||||
"ping": func(message *Message) (data any, err error) {
|
||||
return check()
|
||||
},
|
||||
"wakeup": func(message *Message) (data any, err error) {
|
||||
silent := false
|
||||
if v, ok := message.Meta["silent"]; ok {
|
||||
silent, _ = v.(bool)
|
||||
}
|
||||
err = wakeup(silent)
|
||||
return
|
||||
},
|
||||
"create": func(message *Message) (data any, err error) {
|
||||
buf, err := message.Params.MarshalJSON()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
silent := false
|
||||
if v, ok := message.Meta["silent"]; ok {
|
||||
silent, _ = v.(bool)
|
||||
}
|
||||
|
||||
if err := wakeup(silent); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
headers := make(map[string]string)
|
||||
if message.Meta != nil {
|
||||
metaJson, _ := json.Marshal(message.Meta)
|
||||
headers["X-Gopeed-Host-Meta"] = string(metaJson)
|
||||
}
|
||||
_, err = postToFlutter("/create", buf, headers, 10*time.Second)
|
||||
return
|
||||
},
|
||||
"forward": func(message *Message) (data any, err error) {
|
||||
buf, err := message.Params.MarshalJSON()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := postToFlutter("/forward", buf, nil, 60*time.Second)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var respData map[string]json.RawMessage
|
||||
if err := json.Unmarshal(respBody, &respData); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return respData, nil
|
||||
},
|
||||
}
|
||||
|
||||
// go build -ldflags="-s -w" -o ui/flutter/assets/exec/ github.com/GopeedLab/gopeed/cmd/host
|
||||
|
||||
func main() {
|
||||
for {
|
||||
// Read message length (first 4 bytes)
|
||||
var length uint32
|
||||
if err := binary.Read(os.Stdin, binary.NativeEndian, &length); err != nil {
|
||||
if err == io.EOF {
|
||||
return
|
||||
}
|
||||
sendError("Failed to read message length: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Read the message
|
||||
input := make([]byte, length)
|
||||
if _, err := io.ReadFull(os.Stdin, input); err != nil {
|
||||
sendError("Failed to read message: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Parse message
|
||||
var message Message
|
||||
if err := json.Unmarshal(input, &message); err != nil {
|
||||
sendError("Failed to parse message: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Handle request
|
||||
var data any
|
||||
var err error
|
||||
if handler, ok := apiMap[message.Method]; ok {
|
||||
data, err = handler(&message)
|
||||
} else {
|
||||
err = errors.New("Unknown method: " + message.Method)
|
||||
}
|
||||
if err != nil {
|
||||
sendError(err.Error())
|
||||
continue
|
||||
}
|
||||
sendResponse(0, data, "")
|
||||
}
|
||||
}
|
||||
|
||||
func sendResponse(code int, data interface{}, message string) {
|
||||
response := Response{
|
||||
Code: code,
|
||||
Data: data,
|
||||
Message: message,
|
||||
}
|
||||
|
||||
// Encode response
|
||||
responseBytes, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
sendError("Failed to encode response: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Write message length
|
||||
binary.Write(os.Stdout, binary.NativeEndian, uint32(len(responseBytes)))
|
||||
// Write message
|
||||
os.Stdout.Write(responseBytes)
|
||||
}
|
||||
|
||||
func sendError(msg string) {
|
||||
sendResponse(1, nil, msg)
|
||||
}
|
||||
+38
-6
@@ -3,11 +3,14 @@ package cmd
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/rest"
|
||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
//go:embed banner.txt
|
||||
@@ -24,17 +27,46 @@ func Start(cfg *model.StartConfig) {
|
||||
panic(err)
|
||||
}
|
||||
if downloadCfg.FirstLoad {
|
||||
// Set default download dir to user download dir
|
||||
userDir, err := os.UserHomeDir()
|
||||
if err == nil {
|
||||
downloadDir := filepath.Join(userDir, "Downloads")
|
||||
downloadCfg.DownloadDir = downloadDir
|
||||
rest.Downloader.PutConfig(downloadCfg)
|
||||
// Set default download config
|
||||
if cfg.DownloadConfig != nil {
|
||||
cfg.DownloadConfig.Merge(downloadCfg)
|
||||
// TODO Use PatchConfig
|
||||
rest.Downloader.PutConfig(cfg.DownloadConfig)
|
||||
downloadCfg = cfg.DownloadConfig
|
||||
}
|
||||
|
||||
downloadDir := downloadCfg.DownloadDir
|
||||
// Set default download dir, in docker, it will be ${exe}/Downloads, else it will be ${user}/Downloads
|
||||
if downloadDir == "" {
|
||||
if base.InDocker == "true" {
|
||||
downloadDir = filepath.Join(filepath.Dir(cfg.StorageDir), "Downloads")
|
||||
} else {
|
||||
userDir, err := os.UserHomeDir()
|
||||
if err == nil {
|
||||
downloadDir = filepath.Join(userDir, "Downloads")
|
||||
}
|
||||
}
|
||||
if downloadDir != "" {
|
||||
downloadCfg.DownloadDir = downloadDir
|
||||
rest.Downloader.PutConfig(downloadCfg)
|
||||
}
|
||||
}
|
||||
}
|
||||
watchExit()
|
||||
|
||||
fmt.Printf("Server start success on http://%s\n", listener.Addr().String())
|
||||
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func watchExit() {
|
||||
sigs := make(chan os.Signal, 1)
|
||||
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
sig := <-sigs
|
||||
fmt.Printf("Server is shutting down due to signal: %s\n", sig)
|
||||
rest.Downloader.Close()
|
||||
os.Exit(0)
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/browser"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// go build -ldflags="-s -w" -o ui/flutter/assets/exec/ github.com/GopeedLab/gopeed/cmd/updater
|
||||
|
||||
func main() {
|
||||
pid := flag.Int("pid", 0, "PID of the process to update")
|
||||
updateChannel := flag.String("channel", "", "Update channel")
|
||||
packagePath := flag.String("asset", "", "Path to the package asset")
|
||||
exeDir := flag.String("exeDir", "", "Directory of the entry executable")
|
||||
logPath := flag.String("log", "", "Log file path")
|
||||
flag.Parse()
|
||||
|
||||
if *pid == 0 {
|
||||
log.Println("Invalid PID")
|
||||
os.Exit(1)
|
||||
}
|
||||
if *updateChannel == "" {
|
||||
log.Println("Invalid update channel")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if *logPath != "" {
|
||||
logFile, err := os.OpenFile(*logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0666)
|
||||
if err == nil {
|
||||
defer logFile.Close()
|
||||
log.SetOutput(logFile)
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
restart bool
|
||||
err error
|
||||
)
|
||||
if restart, err = update(*pid, *updateChannel, *packagePath, *exeDir); err != nil {
|
||||
log.Printf("Update failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Restart the application
|
||||
if restart {
|
||||
browser.OpenURL("gopeed:///")
|
||||
}
|
||||
|
||||
// Delete package asset
|
||||
if *packagePath != "" {
|
||||
os.Remove(*packagePath)
|
||||
}
|
||||
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func update(pid int, updateChannel, packagePath, exeDir string) (restart bool, err error) {
|
||||
killSignalChan := make(chan any, 1)
|
||||
|
||||
go func() {
|
||||
<-killSignalChan
|
||||
|
||||
if err = killProcess(pid); err != nil {
|
||||
log.Printf("Failed to kill process: %v\n", err)
|
||||
}
|
||||
|
||||
if err = waitForProcessExit(pid); err != nil {
|
||||
log.Printf("Failed to wait for process exit: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
log.Printf("Updating process updateChannel=%s packagePath=%s exeDir=%s\n", updateChannel, packagePath, exeDir)
|
||||
if restart, err = install(killSignalChan, updateChannel, packagePath, exeDir); err != nil {
|
||||
return false, errors.Wrap(err, "failed to install package")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func waitForProcessExit(pid int) error {
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
process, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
// On some systems, error is returned if process doesn't exist
|
||||
return nil
|
||||
}
|
||||
|
||||
// Send null signal to test if process exists
|
||||
err = process.Signal(syscall.Signal(0))
|
||||
if err != nil {
|
||||
// If error occurs, the process no longer exists
|
||||
return nil
|
||||
}
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("process %d still running after timeout", pid)
|
||||
}
|
||||
|
||||
func killProcess(pid int) error {
|
||||
process, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return process.Kill()
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func install(killSignalChan chan<- any, updateChannel, packagePath, destDir string) (bool, error) {
|
||||
return true, installByDmg(killSignalChan, packagePath, destDir)
|
||||
}
|
||||
|
||||
// installByDmg handles macOS dmg package installation
|
||||
func installByDmg(killSignalChan chan<- any, packagePath, destDir string) error {
|
||||
// /Applications/Gopeed.app/Contents/MacOS -> /Applications
|
||||
appPath := getParentDir(getParentDir(getParentDir(destDir)))
|
||||
output, err := exec.Command("hdiutil", "attach", packagePath, "-nobrowse").Output()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mountPoint := ""
|
||||
for _, line := range strings.Split(string(output), "\n") {
|
||||
if strings.Contains(line, "/Volumes/") {
|
||||
// Find the /Volumes/ path in the line
|
||||
// hdiutil output format: /dev/disk4s1 Apple_HFS /Volumes/Gopeed
|
||||
// or with sequence number: /dev/disk4s1 Apple_HFS /Volumes/Gopeed 1
|
||||
idx := strings.Index(line, "/Volumes/")
|
||||
if idx != -1 {
|
||||
// Extract everything from /Volumes/ onwards and trim whitespace
|
||||
mountPoint = strings.TrimSpace(line[idx:])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if mountPoint == "" {
|
||||
return fmt.Errorf("failed to get mount point from hdiutil output: %s", string(output))
|
||||
}
|
||||
|
||||
// Detach the mounted DMG
|
||||
defer exec.Command("hdiutil", "detach", mountPoint, "-quiet").Run()
|
||||
|
||||
matches, err := filepath.Glob(filepath.Join(mountPoint, "*.app"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
return fmt.Errorf("no .app found in dmg, mountPoint: %s", mountPoint)
|
||||
}
|
||||
|
||||
killSignalChan <- nil
|
||||
|
||||
// Copy the new app to the destination
|
||||
// cp -Rf /Volumes/GoPeed/GoPeed.app /Applications
|
||||
if err := exec.Command("cp", "-Rf", matches[0], appPath).Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get parent directory safely, handling trailing separators
|
||||
func getParentDir(path string) string {
|
||||
// Remove trailing separators if they exist
|
||||
path = strings.TrimRight(path, string(filepath.Separator))
|
||||
// Now get the parent directory
|
||||
return filepath.Dir(path)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
func install(killSignalChan chan<- any, updateChannel, packagePath, destDir string) (bool, error) {
|
||||
killSignalChan <- nil
|
||||
switch updateChannel {
|
||||
case "linuxDeb":
|
||||
return true, installByDeb(packagePath)
|
||||
case "linuxFlathub":
|
||||
return true, installByFlathub()
|
||||
case "linuxSnap":
|
||||
return true, installBySnap()
|
||||
default:
|
||||
return false, fmt.Errorf("unsupported update channel for Linux: %s", updateChannel)
|
||||
}
|
||||
}
|
||||
|
||||
// executeInTerminal tries to execute a command in one of several common terminal emulators
|
||||
func executeInTerminal(command string) error {
|
||||
terminals := []string{
|
||||
"gnome-terminal", // GNOME
|
||||
"konsole", // KDE
|
||||
"xfce4-terminal", // XFCE
|
||||
"xterm", // X11
|
||||
}
|
||||
|
||||
command = fmt.Sprintf(`echo "Starting update..." && echo "[CMD] %s" && %s`, command, command)
|
||||
|
||||
for _, term := range terminals {
|
||||
if _, err := exec.LookPath(term); err == nil {
|
||||
var cmd *exec.Cmd
|
||||
switch term {
|
||||
case "gnome-terminal", "xfce4-terminal":
|
||||
cmd = exec.Command(term, "--", "bash", "-c", command)
|
||||
case "konsole":
|
||||
cmd = exec.Command(term, "-e", "bash", "-c", command)
|
||||
case "xterm":
|
||||
cmd = exec.Command(term, "-e", command)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("no suitable terminal emulator found. Please install gnome-terminal, konsole, xfce4-terminal or xterm")
|
||||
}
|
||||
|
||||
// installByDeb installs the .deb package
|
||||
func installByDeb(packagePath string) error {
|
||||
command := fmt.Sprintf(`sudo dpkg -i "%s"`, packagePath)
|
||||
return executeInTerminal(command)
|
||||
}
|
||||
|
||||
// installByFlathub updates the application via Flathub
|
||||
func installByFlathub() error {
|
||||
command := "flatpak update com.gopeed.Gopeed -y"
|
||||
return executeInTerminal(command)
|
||||
}
|
||||
|
||||
// installBySnap updates the application via Snap
|
||||
func installBySnap() error {
|
||||
command := "sudo snap refresh gopeed"
|
||||
return executeInTerminal(command)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func install(killSignalChan chan<- any, updateChannel, packagePath, destDir string) (bool, error) {
|
||||
switch updateChannel {
|
||||
case "windowsInstaller":
|
||||
return false, installByInstaller(killSignalChan, packagePath, destDir)
|
||||
default:
|
||||
return true, installByPortable(killSignalChan, packagePath, destDir)
|
||||
}
|
||||
}
|
||||
|
||||
// installByInstaller extracts the installer from the zip file and runs it
|
||||
func installByInstaller(killSignalChan chan<- any, packagePath, destDir string) error {
|
||||
// Create a temp directory for extraction
|
||||
tempDir, err := os.MkdirTemp("", "gopeed_update")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Extract the zip file
|
||||
reader, err := zip.OpenReader(packagePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
// Find the installer file
|
||||
var installerPath string
|
||||
for _, file := range reader.File {
|
||||
if file.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract file
|
||||
path := filepath.Join(tempDir, file.Name)
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dstFile, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
srcFile, err := file.Open()
|
||||
if err != nil {
|
||||
dstFile.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = io.Copy(dstFile, srcFile)
|
||||
srcFile.Close()
|
||||
dstFile.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If this is likely an installer (.exe, .msi), save its path
|
||||
ext := strings.ToLower(filepath.Ext(file.Name))
|
||||
if ext == ".exe" || ext == ".msi" {
|
||||
installerPath = path
|
||||
}
|
||||
}
|
||||
|
||||
if installerPath == "" {
|
||||
return fmt.Errorf("no installer found in the update package")
|
||||
}
|
||||
|
||||
// Run the installer
|
||||
cmd := exec.Command(installerPath)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
killSignalChan <- nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// installByPortable extracts the portable version to the destination directory
|
||||
func installByPortable(killSignalChan chan<- any, packagePath, destDir string) error {
|
||||
killSignalChan <- nil
|
||||
|
||||
reader, err := zip.OpenReader(packagePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
for _, file := range reader.File {
|
||||
path := filepath.Join(destDir, file.Name)
|
||||
|
||||
if file.FileInfo().IsDir() {
|
||||
os.MkdirAll(path, file.Mode())
|
||||
continue
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dstFile, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
srcFile, err := file.Open()
|
||||
if err != nil {
|
||||
dstFile.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = io.Copy(dstFile, srcFile)
|
||||
srcFile.Close()
|
||||
dstFile.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+171
-47
@@ -3,73 +3,197 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
)
|
||||
|
||||
type args struct {
|
||||
Address *string `json:"address"`
|
||||
Port *int `json:"port"`
|
||||
Username *string `json:"username"`
|
||||
Password *string `json:"password"`
|
||||
ApiToken *string `json:"apiToken"`
|
||||
Address *string `json:"address"`
|
||||
Port *int `json:"port"`
|
||||
Username *string `json:"username"`
|
||||
Password *string `json:"password"`
|
||||
ApiToken *string `json:"apiToken"`
|
||||
StorageDir *string `json:"storageDir"`
|
||||
WhiteDownloadDirs []string `json:"whiteDownloadDirs"`
|
||||
// DownloadConfig when the first time to start the server, it will be configured as initial value
|
||||
DownloadConfig *base.DownloaderStoreConfig `json:"downloadConfig"`
|
||||
|
||||
configPath *string
|
||||
}
|
||||
|
||||
func parse() *args {
|
||||
var cliArgs args
|
||||
cliArgs.Address = flag.String("A", "0.0.0.0", "Bind Address")
|
||||
cliArgs.Port = flag.Int("P", 9999, "Bind Port")
|
||||
cliArgs.Username = flag.String("u", "gopeed", "HTTP Basic Auth Username")
|
||||
cliArgs.Password = flag.String("p", "", "HTTP Basic Auth Pwd")
|
||||
cliArgs.ApiToken = flag.String("T", "", "API token, that can only be used when basic authentication is enabled.")
|
||||
cliArgs.configPath = flag.String("c", "./config.json", "Config file path")
|
||||
cfg := &args{}
|
||||
|
||||
cliConfig := loadCliArgs()
|
||||
loadConfigFile(cfg, *cliConfig.configPath)
|
||||
loadEnvVars(cfg)
|
||||
// override with non-default command line arguments
|
||||
overrideWithCliArgs(cfg, cliConfig)
|
||||
// set default values
|
||||
setDefaults(cfg, cliConfig)
|
||||
return cfg
|
||||
}
|
||||
|
||||
// loadCliArgs parses command line arguments and returns initial config
|
||||
func loadCliArgs() *args {
|
||||
cfg := &args{}
|
||||
cfg.Address = flag.String("A", "0.0.0.0", "Bind Address")
|
||||
cfg.Port = flag.Int("P", 9999, "Bind Port")
|
||||
cfg.Username = flag.String("u", "gopeed", "Web Authentication Username")
|
||||
cfg.Password = flag.String("p", "", "Web Authentication Password, if no password is set, web authentication will not be enabled")
|
||||
cfg.ApiToken = flag.String("T", "", "API token, it must be configured when using HTTP API in the case of enabling web authentication")
|
||||
cfg.StorageDir = flag.String("d", "", "Storage directory")
|
||||
whiteDownloadDirs := flag.String("w", "", "White download directories, comma-separated")
|
||||
cfg.configPath = flag.String("c", "./config.json", "Config file path")
|
||||
flag.Parse()
|
||||
|
||||
// args priority: config file > cli args
|
||||
cfgArgs := loadConfig(*cliArgs.configPath)
|
||||
if cfgArgs.Address == nil {
|
||||
cfgArgs.Address = cliArgs.Address
|
||||
// Parse white download directories from comma-separated string
|
||||
if whiteDownloadDirs != nil && *whiteDownloadDirs != "" {
|
||||
dirs := strings.Split(*whiteDownloadDirs, ",")
|
||||
for i := range dirs {
|
||||
dirs[i] = strings.TrimSpace(dirs[i])
|
||||
}
|
||||
cfg.WhiteDownloadDirs = dirs
|
||||
}
|
||||
if cfgArgs.Port == nil {
|
||||
cfgArgs.Port = cliArgs.Port
|
||||
}
|
||||
if cfgArgs.Username == nil {
|
||||
cfgArgs.Username = cliArgs.Username
|
||||
}
|
||||
if cfgArgs.Password == nil {
|
||||
cfgArgs.Password = cliArgs.Password
|
||||
}
|
||||
if cfgArgs.ApiToken == nil {
|
||||
cfgArgs.ApiToken = cliArgs.ApiToken
|
||||
}
|
||||
return cfgArgs
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
func loadConfig(path string) *args {
|
||||
var args args
|
||||
|
||||
if !filepath.IsAbs(path) {
|
||||
dir, err := filepath.Abs(filepath.Dir(os.Args[0]))
|
||||
if err != nil {
|
||||
return &args
|
||||
// overrideWithCliArgs overrides config with non-empty command line arguments
|
||||
func overrideWithCliArgs(cfg *args, cliConfig *args) {
|
||||
flag.Visit(func(f *flag.Flag) {
|
||||
switch f.Name {
|
||||
case "A":
|
||||
cfg.Address = cliConfig.Address
|
||||
case "P":
|
||||
cfg.Port = cliConfig.Port
|
||||
case "u":
|
||||
cfg.Username = cliConfig.Username
|
||||
case "p":
|
||||
cfg.Password = cliConfig.Password
|
||||
case "T":
|
||||
cfg.ApiToken = cliConfig.ApiToken
|
||||
case "d":
|
||||
cfg.StorageDir = cliConfig.StorageDir
|
||||
case "w":
|
||||
cfg.WhiteDownloadDirs = cliConfig.WhiteDownloadDirs
|
||||
case "c":
|
||||
cfg.configPath = cliConfig.configPath
|
||||
}
|
||||
path = filepath.Join(dir, path)
|
||||
})
|
||||
}
|
||||
|
||||
// setDefaults sets default values for any unset configuration fields
|
||||
func setDefaults(cfg *args, cliConfig *args) {
|
||||
if cfg.Address == nil {
|
||||
cfg.Address = cliConfig.Address
|
||||
}
|
||||
file, err := os.ReadFile(path)
|
||||
if cfg.Port == nil {
|
||||
cfg.Port = cliConfig.Port
|
||||
}
|
||||
if cfg.Username == nil {
|
||||
cfg.Username = cliConfig.Username
|
||||
}
|
||||
if cfg.Password == nil {
|
||||
cfg.Password = cliConfig.Password
|
||||
}
|
||||
if cfg.ApiToken == nil {
|
||||
cfg.ApiToken = cliConfig.ApiToken
|
||||
}
|
||||
if cfg.StorageDir == nil {
|
||||
cfg.StorageDir = cliConfig.StorageDir
|
||||
}
|
||||
}
|
||||
|
||||
// loadConfigFile loads configuration from file
|
||||
func loadConfigFile(cfg *args, configPath string) {
|
||||
if !filepath.IsAbs(configPath) {
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
configPath = filepath.Join(dir, configPath)
|
||||
}
|
||||
|
||||
file, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &args
|
||||
return
|
||||
}
|
||||
fmt.Println("config file read failed, reason:" + err.Error())
|
||||
return &args
|
||||
return
|
||||
}
|
||||
if err = json.Unmarshal(file, &args); err != nil {
|
||||
fmt.Println("config file parse failed, reason:" + err.Error())
|
||||
return &args
|
||||
|
||||
if err = json.Unmarshal(file, cfg); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// loadEnvVars loads configuration from environment variables with prefix GOPEED_
|
||||
func loadEnvVars(cfg *args) {
|
||||
v := reflect.ValueOf(cfg).Elem()
|
||||
t := reflect.TypeOf(cfg).Elem()
|
||||
|
||||
for i := 0; i < v.NumField(); i++ {
|
||||
field := v.Field(i)
|
||||
fieldType := t.Field(i)
|
||||
|
||||
// Get json tag as environment variable suffix
|
||||
jsonTag := fieldType.Tag.Get("json")
|
||||
if jsonTag == "" || jsonTag == "-" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Remove options like omitempty
|
||||
if commaIdx := strings.Index(jsonTag, ","); commaIdx != -1 {
|
||||
jsonTag = jsonTag[:commaIdx]
|
||||
}
|
||||
|
||||
// Convert to uppercase and add GOPEED_ prefix
|
||||
envKey := "GOPEED_" + strings.ToUpper(jsonTag)
|
||||
envValue := os.Getenv(envKey)
|
||||
|
||||
if envValue == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Set value based on field type
|
||||
if field.Kind() == reflect.Ptr {
|
||||
if field.IsNil() {
|
||||
// Create new pointer instance
|
||||
newVal := reflect.New(field.Type().Elem())
|
||||
field.Set(newVal)
|
||||
}
|
||||
|
||||
switch field.Type().Elem().Kind() {
|
||||
case reflect.String:
|
||||
field.Elem().SetString(envValue)
|
||||
case reflect.Int:
|
||||
if intVal, err := strconv.Atoi(envValue); err == nil {
|
||||
field.Elem().SetInt(int64(intVal))
|
||||
}
|
||||
default:
|
||||
// For complex types like DownloadConfig, try JSON unmarshaling
|
||||
if field.Type().Elem() == reflect.TypeOf(base.DownloaderStoreConfig{}) {
|
||||
var config base.DownloaderStoreConfig
|
||||
if err := json.Unmarshal([]byte(envValue), &config); err == nil {
|
||||
field.Set(reflect.ValueOf(&config))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if field.Kind() == reflect.Slice {
|
||||
// Handle non-pointer slice types (like []string for WhiteDownloadDirs)
|
||||
if field.Type().Elem().Kind() == reflect.String {
|
||||
dirs := strings.Split(envValue, ",")
|
||||
for i := range dirs {
|
||||
dirs[i] = strings.TrimSpace(dirs[i])
|
||||
}
|
||||
field.Set(reflect.ValueOf(dirs))
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Printf("config file loaded: %s\n", path)
|
||||
return &args
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+25
-17
@@ -6,11 +6,12 @@ package main
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"github.com/GopeedLab/gopeed/cmd"
|
||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/GopeedLab/gopeed/cmd"
|
||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||
)
|
||||
|
||||
//go:embed dist/*
|
||||
@@ -23,30 +24,37 @@ func main() {
|
||||
}
|
||||
|
||||
args := parse()
|
||||
var webBasicAuth *model.WebBasicAuth
|
||||
var webAuth *model.WebAuth
|
||||
if isNotBlank(args.Username) && isNotBlank(args.Password) {
|
||||
webBasicAuth = &model.WebBasicAuth{
|
||||
webAuth = &model.WebAuth{
|
||||
Username: *args.Username,
|
||||
Password: *args.Password,
|
||||
}
|
||||
}
|
||||
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
var storageDir string
|
||||
if args.StorageDir != nil && *args.StorageDir != "" {
|
||||
storageDir = *args.StorageDir
|
||||
} else {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
storageDir = filepath.Join(filepath.Dir(exe), "storage")
|
||||
}
|
||||
dir := filepath.Dir(exe)
|
||||
|
||||
cfg := &model.StartConfig{
|
||||
Network: "tcp",
|
||||
Address: fmt.Sprintf("%s:%d", *args.Address, *args.Port),
|
||||
Storage: model.StorageBolt,
|
||||
StorageDir: filepath.Join(dir, "storage"),
|
||||
ApiToken: *args.ApiToken,
|
||||
ProductionMode: true,
|
||||
WebEnable: true,
|
||||
WebFS: sub,
|
||||
WebBasicAuth: webBasicAuth,
|
||||
Network: "tcp",
|
||||
Address: fmt.Sprintf("%s:%d", *args.Address, *args.Port),
|
||||
Storage: model.StorageBolt,
|
||||
StorageDir: storageDir,
|
||||
WhiteDownloadDirs: args.WhiteDownloadDirs,
|
||||
ApiToken: *args.ApiToken,
|
||||
DownloadConfig: args.DownloadConfig,
|
||||
ProductionMode: true,
|
||||
WebEnable: true,
|
||||
WebFS: sub,
|
||||
WebAuth: webAuth,
|
||||
}
|
||||
cmd.Start(cfg)
|
||||
}
|
||||
|
||||
+14
-11
@@ -1,12 +1,15 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
gopeed:
|
||||
container_name: gopeed
|
||||
ports:
|
||||
- "9999:9999" # HTTP port (host:container)
|
||||
image: liwei2633/gopeed
|
||||
volumes:
|
||||
- ~/gopeed/Downloads:/root/Downloads # mount download path
|
||||
#- ~/gopeed/storage:/app/storage # if you need to mount storage path, uncomment this line
|
||||
restart: unless-stopped
|
||||
gopeed:
|
||||
container_name: gopeed
|
||||
ports:
|
||||
- 9999:9999 # HTTP port (host:container)
|
||||
environment:
|
||||
- PUID=0
|
||||
- PGID=0
|
||||
- UMASK=022
|
||||
volumes:
|
||||
- ~/gopeed/Downloads:/app/Downloads # mount download path
|
||||
#- ~/gopeed/storage:/app/storage # if you need to mount storage path, uncomment this line
|
||||
restart: unless-stopped
|
||||
image: liwei2633/gopeed
|
||||
# command: -u Username -p Password # optional authentication
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/bin/sh
|
||||
|
||||
chown -R ${PUID}:${PGID} /app
|
||||
|
||||
umask ${UMASK}
|
||||
|
||||
exec su-exec ${PUID}:${PGID} ./gopeed "$@"
|
||||
@@ -1,108 +1,164 @@
|
||||
module github.com/GopeedLab/gopeed
|
||||
|
||||
go 1.19
|
||||
go 1.25.4
|
||||
|
||||
require (
|
||||
github.com/anacrolix/missinggo/v2 v2.7.2-0.20230527121029-a582b4f397b9
|
||||
github.com/anacrolix/torrent v1.52.5
|
||||
github.com/GopeedLab/webview_go v0.0.0-20260423085439-7a2f88b6e9b5
|
||||
github.com/anacrolix/torrent v1.60.1-0.20251217073903-486bcbe758e0
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
|
||||
github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d
|
||||
github.com/dop251/goja_nodejs v0.0.0-20231122114759-e84d9a924c5c
|
||||
github.com/bodgit/sevenzip v1.6.1
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
|
||||
github.com/dop251/goja_nodejs v0.0.0-20240728170619-29b559befffc
|
||||
github.com/go-git/go-git/v5 v5.8.1
|
||||
github.com/gorilla/handlers v1.5.1
|
||||
github.com/gorilla/mux v1.8.0
|
||||
github.com/imroc/req/v3 v3.52.2
|
||||
github.com/matoous/go-nanoid/v2 v2.0.0
|
||||
github.com/mattn/go-ieproxy v0.0.12
|
||||
github.com/mholt/archives v0.1.5
|
||||
github.com/monkeyWie/goed2k v0.0.0-20260317100435-7a7575cf2447
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529
|
||||
github.com/rs/zerolog v1.31.0
|
||||
github.com/virtuald/go-paniclog v0.0.0-20190812204905-43a7fa316459
|
||||
go.etcd.io/bbolt v1.3.8
|
||||
golang.org/x/exp v0.0.0-20220613132600-b0d781184e0d
|
||||
golang.org/x/sync v0.3.0
|
||||
github.com/xiaoqidun/setft v0.0.0-20220310121541-be86327699ad
|
||||
go.etcd.io/bbolt v1.4.3
|
||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/STARRY-S/zip v0.2.3 // indirect
|
||||
github.com/anacrolix/btree v0.1.1 // indirect
|
||||
github.com/anacrolix/missinggo/v2 v2.10.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/bodgit/plumbing v1.3.0 // indirect
|
||||
github.com/bodgit/windows v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
|
||||
github.com/felixge/fgprof v0.9.5 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/icholy/digest v1.1.0 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/pgzip v1.2.6 // indirect
|
||||
github.com/mikelolasagasti/xz v1.0.1 // indirect
|
||||
github.com/minio/minlz v1.0.1 // indirect
|
||||
github.com/nwaples/rardecode/v2 v2.2.0 // indirect
|
||||
github.com/onsi/ginkgo/v2 v2.23.4 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.22 // indirect
|
||||
github.com/pion/transport/v4 v4.0.1 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.51.0 // indirect
|
||||
github.com/refraction-networking/utls v1.6.7 // indirect
|
||||
github.com/sorairolake/lzip-go v0.3.8 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/ulikunitz/xz v0.5.15 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||
go.uber.org/mock v0.5.1 // indirect
|
||||
go4.org v0.0.0-20230225012048-214862532bf5 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
crawshaw.io/sqlite v0.3.3-0.20220618202545-d1964889ea3c // indirect
|
||||
dario.cat/mergo v1.0.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.1 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.1
|
||||
github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95 // indirect
|
||||
github.com/RoaringBitmap/roaring v1.2.3 // indirect
|
||||
github.com/RoaringBitmap/roaring v1.9.4 // indirect
|
||||
github.com/acomagu/bufpipe v1.0.4 // indirect
|
||||
github.com/ajwerner/btree v0.0.0-20211221152037-f427b3e689c0 // indirect
|
||||
github.com/alecthomas/atomic v0.1.0-alpha2 // indirect
|
||||
github.com/anacrolix/chansync v0.3.0 // indirect
|
||||
github.com/anacrolix/dht/v2 v2.19.2-0.20221121215055-066ad8494444 // indirect
|
||||
github.com/anacrolix/envpprof v1.2.1 // indirect
|
||||
github.com/anacrolix/generics v0.0.0-20230428105757-683593396d68 // indirect
|
||||
github.com/anacrolix/go-libutp v1.3.1 // indirect
|
||||
github.com/anacrolix/log v0.14.0 // indirect
|
||||
github.com/anacrolix/chansync v0.7.0 // indirect
|
||||
github.com/anacrolix/dht/v2 v2.23.0 // indirect
|
||||
github.com/anacrolix/envpprof v1.5.0 // indirect
|
||||
github.com/anacrolix/generics v0.2.0 // indirect
|
||||
github.com/anacrolix/go-libutp v1.3.2 // indirect
|
||||
github.com/anacrolix/log v0.17.1-0.20251118025802-918f1157b7bb // indirect
|
||||
github.com/anacrolix/missinggo v1.3.0 // indirect
|
||||
github.com/anacrolix/missinggo/perf v1.0.0 // indirect
|
||||
github.com/anacrolix/mmsg v1.0.0 // indirect
|
||||
github.com/anacrolix/multiless v0.3.0 // indirect
|
||||
github.com/anacrolix/stm v0.4.0 // indirect
|
||||
github.com/anacrolix/sync v0.4.0 // indirect
|
||||
github.com/anacrolix/upnp v0.1.3-0.20220123035249-922794e51c96 // indirect
|
||||
github.com/anacrolix/utp v0.1.0 // indirect
|
||||
github.com/anacrolix/mmsg v1.1.1 // indirect
|
||||
github.com/anacrolix/multiless v0.4.0 // indirect
|
||||
github.com/anacrolix/stm v0.5.0 // indirect
|
||||
github.com/anacrolix/sync v0.6.0 // indirect
|
||||
github.com/anacrolix/upnp v0.1.4 // indirect
|
||||
github.com/anacrolix/utp v0.2.0 // indirect
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/benbjohnson/immutable v0.3.0 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.2.2 // indirect
|
||||
github.com/benbjohnson/immutable v0.4.3 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect
|
||||
github.com/cloudflare/circl v1.3.3 // indirect
|
||||
github.com/cespare/xxhash v1.1.0 // indirect
|
||||
github.com/cloudflare/circl v1.6.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.10.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.0 // indirect
|
||||
github.com/edsrzf/mmap-go v1.1.0 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/edsrzf/mmap-go v1.2.0 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.1 // indirect
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.4.1 // indirect
|
||||
github.com/go-logr/logr v1.2.3 // indirect
|
||||
github.com/go-llsqlite/adapter v0.2.0 // indirect
|
||||
github.com/go-llsqlite/crawshaw v0.6.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/google/btree v1.1.2 // indirect
|
||||
github.com/google/pprof v0.0.0-20231101202521-4ca4178f5c7a // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.0 // indirect
|
||||
github.com/huandu/xstrings v1.3.2 // indirect
|
||||
github.com/google/btree v1.1.3 // indirect
|
||||
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/huandu/xstrings v1.5.0 // indirect
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
||||
github.com/lispad/go-generics-tools v1.1.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/minio/sha256-simd v1.0.1 // indirect
|
||||
github.com/mr-tron/base58 v1.2.0 // indirect
|
||||
github.com/mschoch/smat v0.2.0 // indirect
|
||||
github.com/pion/datachannel v1.5.2 // indirect
|
||||
github.com/pion/dtls/v2 v2.2.4 // indirect
|
||||
github.com/pion/ice/v2 v2.2.6 // indirect
|
||||
github.com/pion/interceptor v0.1.11 // indirect
|
||||
github.com/pion/logging v0.2.2 // indirect
|
||||
github.com/pion/mdns v0.0.5 // indirect
|
||||
github.com/multiformats/go-multihash v0.2.3 // indirect
|
||||
github.com/multiformats/go-varint v0.1.0 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pion/datachannel v1.6.0 // indirect
|
||||
github.com/pion/dtls/v3 v3.0.10 // indirect
|
||||
github.com/pion/ice/v4 v4.2.0 // indirect
|
||||
github.com/pion/interceptor v0.1.42 // indirect
|
||||
github.com/pion/logging v0.2.4 // indirect
|
||||
github.com/pion/mdns/v2 v2.1.0 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/rtcp v1.2.9 // indirect
|
||||
github.com/pion/rtp v1.7.13 // indirect
|
||||
github.com/pion/sctp v1.8.2 // indirect
|
||||
github.com/pion/sdp/v3 v3.0.5 // indirect
|
||||
github.com/pion/srtp/v2 v2.0.9 // indirect
|
||||
github.com/pion/stun v0.3.5 // indirect
|
||||
github.com/pion/transport v0.13.1 // indirect
|
||||
github.com/pion/transport/v2 v2.0.0 // indirect
|
||||
github.com/pion/turn/v2 v2.0.8 // indirect
|
||||
github.com/pion/udp v0.1.4 // indirect
|
||||
github.com/pion/webrtc/v3 v3.1.42 // indirect
|
||||
github.com/pion/rtcp v1.2.16 // indirect
|
||||
github.com/pion/rtp v1.10.0 // indirect
|
||||
github.com/pion/sctp v1.9.1 // indirect
|
||||
github.com/pion/sdp/v3 v3.0.17 // indirect
|
||||
github.com/pion/srtp/v3 v3.0.10 // indirect
|
||||
github.com/pion/stun/v3 v3.1.1 // indirect
|
||||
github.com/pion/turn/v4 v4.1.4 // indirect
|
||||
github.com/pion/webrtc/v4 v4.2.2-0.20260109001657-a5962f314db7 // indirect
|
||||
github.com/pjbgf/sha1cd v0.3.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/rs/dnscache v0.0.0-20211102005908-e0241e321417 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
|
||||
github.com/protolambda/ctxlock v0.1.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/sergi/go-diff v1.1.0 // indirect
|
||||
github.com/skeema/knownhosts v1.2.0 // indirect
|
||||
github.com/tidwall/btree v1.6.0 // indirect
|
||||
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||
github.com/tidwall/btree v1.8.1 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||
go.opentelemetry.io/otel v1.8.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.8.0 // indirect
|
||||
golang.org/x/crypto v0.14.0 // indirect
|
||||
golang.org/x/mod v0.8.0 // indirect
|
||||
golang.org/x/net v0.17.0 // indirect
|
||||
golang.org/x/sys v0.14.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
golang.org/x/time v0.0.0-20220609170525-579cf78fd858 // indirect
|
||||
golang.org/x/tools v0.6.0 // indirect
|
||||
go.opentelemetry.io/otel v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
||||
golang.org/x/crypto v0.46.0 // indirect
|
||||
golang.org/x/mod v0.31.0 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/tools v0.40.0 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
lukechampine.com/blake3 v1.4.1 // indirect
|
||||
modernc.org/libc v1.67.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.43.0 // indirect
|
||||
zombiezen.com/go/sqlite v1.4.2 // indirect
|
||||
)
|
||||
@@ -0,0 +1,677 @@
|
||||
package blob
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const urlPathPrefix = "/__blob/"
|
||||
|
||||
const rangeSourceFailureLimit = 2
|
||||
|
||||
// unclaimedSourceTTL bounds how long a session-backed source may keep its
|
||||
// engine alive without ever being claimed by a download task.
|
||||
var unclaimedSourceTTL = 10 * time.Minute
|
||||
|
||||
var (
|
||||
ErrInvalidURL = errors.New("invalid blob url")
|
||||
ErrInvalidOptions = errors.New("invalid blob options")
|
||||
ErrSourceNotFound = errors.New("blob source not found")
|
||||
ErrSourceRevoked = errors.New("blob source revoked")
|
||||
ErrSourceClosed = errors.New("blob source closed")
|
||||
ErrRangeNotAllowed = errors.New("blob range not allowed")
|
||||
)
|
||||
|
||||
type SessionRef interface {
|
||||
Retain()
|
||||
Release()
|
||||
}
|
||||
|
||||
type OpenRequest struct {
|
||||
Offset int64
|
||||
End int64
|
||||
}
|
||||
|
||||
type OpenFunc func(ctx context.Context, req OpenRequest) (io.ReadCloser, error)
|
||||
|
||||
type CreateOptions struct {
|
||||
ContentType string
|
||||
Size int64
|
||||
Range bool
|
||||
Session SessionRef
|
||||
}
|
||||
|
||||
type Metadata struct {
|
||||
ContentType string
|
||||
Size int64
|
||||
Range bool
|
||||
}
|
||||
|
||||
type Source struct {
|
||||
ID string
|
||||
URL string
|
||||
ContentType string
|
||||
|
||||
mu sync.Mutex
|
||||
size int64
|
||||
rangeEnabled bool
|
||||
revoked bool
|
||||
taskRefs int
|
||||
readErr error
|
||||
rangeFailures int
|
||||
session SessionRef
|
||||
open OpenFunc
|
||||
unclaimed *time.Timer
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
dir string
|
||||
|
||||
mu sync.RWMutex
|
||||
sources map[string]*Source
|
||||
|
||||
serverMu sync.Mutex
|
||||
listener net.Listener
|
||||
server *http.Server
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func NewRegistry(dir string) *Registry {
|
||||
return &Registry{
|
||||
dir: dir,
|
||||
sources: make(map[string]*Source),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Registry) Dir() string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
return r.dir
|
||||
}
|
||||
|
||||
func (r *Registry) IsURL(raw string) bool {
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
src, err := r.get(raw)
|
||||
return err == nil && src != nil
|
||||
}
|
||||
|
||||
func (r *Registry) CreateBlob(data []byte, contentType string) (string, error) {
|
||||
buf := append([]byte(nil), data...)
|
||||
return r.CreateOpener(func(ctx context.Context, req OpenRequest) (io.ReadCloser, error) {
|
||||
if req.Offset < 0 || req.Offset > int64(len(buf)) {
|
||||
return nil, ErrSourceNotFound
|
||||
}
|
||||
end := int64(len(buf))
|
||||
if req.End >= req.Offset && req.End+1 < end {
|
||||
end = req.End + 1
|
||||
}
|
||||
return io.NopCloser(bytes.NewReader(buf[req.Offset:end])), nil
|
||||
}, &CreateOptions{
|
||||
ContentType: contentType,
|
||||
Size: int64(len(buf)),
|
||||
Range: len(buf) > 0,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Registry) CreateOpener(open OpenFunc, opts *CreateOptions) (string, error) {
|
||||
if r == nil {
|
||||
return "", ErrSourceNotFound
|
||||
}
|
||||
if open == nil {
|
||||
return "", ErrInvalidOptions
|
||||
}
|
||||
if opts == nil {
|
||||
opts = &CreateOptions{}
|
||||
}
|
||||
if opts.Size < 0 {
|
||||
opts.Size = 0
|
||||
}
|
||||
if opts.Range && opts.Size <= 0 {
|
||||
return "", fmt.Errorf("%w: range requires positive size", ErrInvalidOptions)
|
||||
}
|
||||
// Keep server generation selection and source insertion atomic with Close.
|
||||
r.serverMu.Lock()
|
||||
baseURL, err := r.ensureServerLocked()
|
||||
if err != nil {
|
||||
r.serverMu.Unlock()
|
||||
return "", err
|
||||
}
|
||||
id, err := randomID(18)
|
||||
if err != nil {
|
||||
r.serverMu.Unlock()
|
||||
return "", err
|
||||
}
|
||||
if opts.Session != nil {
|
||||
opts.Session.Retain()
|
||||
}
|
||||
srcURL := fmt.Sprintf("%s%s", baseURL, id)
|
||||
src := &Source{
|
||||
ID: id,
|
||||
URL: srcURL,
|
||||
ContentType: opts.ContentType,
|
||||
size: opts.Size,
|
||||
rangeEnabled: opts.Range,
|
||||
session: opts.Session,
|
||||
open: open,
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.sources[id] = src
|
||||
r.mu.Unlock()
|
||||
r.serverMu.Unlock()
|
||||
if opts.Session != nil && unclaimedSourceTTL > 0 {
|
||||
src.mu.Lock()
|
||||
if !src.revoked {
|
||||
src.unclaimed = time.AfterFunc(unclaimedSourceTTL, func() {
|
||||
r.expireUnclaimed(src)
|
||||
})
|
||||
}
|
||||
src.mu.Unlock()
|
||||
}
|
||||
return src.URL, nil
|
||||
}
|
||||
|
||||
func (r *Registry) Metadata(raw string) (Metadata, error) {
|
||||
src, err := r.get(raw)
|
||||
if err != nil {
|
||||
return Metadata{}, err
|
||||
}
|
||||
src.mu.Lock()
|
||||
defer src.mu.Unlock()
|
||||
return Metadata{
|
||||
ContentType: src.ContentType,
|
||||
Size: src.size,
|
||||
Range: src.rangeEnabled,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Acquire retains a source for a task. A source remains available until every
|
||||
// task that acquired it calls Release, unless it is explicitly revoked.
|
||||
func (r *Registry) Acquire(raw string) error {
|
||||
src, err := r.get(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return src.acquireTask()
|
||||
}
|
||||
|
||||
// Release releases a task's reference to a source. Releasing the final task
|
||||
// reference revokes the source. Sources that have never been acquired remain
|
||||
// registered until Revoke or Registry.Close is called; session-backed sources
|
||||
// are also bounded by the unclaimed-source TTL.
|
||||
func (r *Registry) Release(raw string) error {
|
||||
src, err := r.get(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
session, last, err := src.releaseTask()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !last {
|
||||
return nil
|
||||
}
|
||||
r.deleteSource(src)
|
||||
if session != nil {
|
||||
session.Release()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SourceError returns the first error encountered while opening or reading a
|
||||
// source. Invalid and missing sources have no source error. HTTP response write
|
||||
// errors are deliberately not recorded here.
|
||||
func (r *Registry) SourceError(raw string) error {
|
||||
src, err := r.get(raw)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return src.sourceError()
|
||||
}
|
||||
|
||||
func (r *Registry) Revoke(raw string) error {
|
||||
src, err := r.get(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.removeSource(src)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Registry) Close() error {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
r.serverMu.Lock()
|
||||
server := r.server
|
||||
listener := r.listener
|
||||
r.server = nil
|
||||
r.listener = nil
|
||||
r.baseURL = ""
|
||||
r.mu.Lock()
|
||||
sources := make([]*Source, 0, len(r.sources))
|
||||
for _, src := range r.sources {
|
||||
sources = append(sources, src)
|
||||
}
|
||||
r.sources = make(map[string]*Source)
|
||||
r.mu.Unlock()
|
||||
r.serverMu.Unlock()
|
||||
if server != nil {
|
||||
_ = server.Close()
|
||||
}
|
||||
if listener != nil {
|
||||
_ = listener.Close()
|
||||
}
|
||||
for _, src := range sources {
|
||||
src.close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureServerLocked returns the current server URL or starts a new server.
|
||||
// The caller must hold serverMu.
|
||||
func (r *Registry) ensureServerLocked() (string, error) {
|
||||
if r.baseURL != "" {
|
||||
return r.baseURL, nil
|
||||
}
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
r.listener = listener
|
||||
r.baseURL = "http://" + listener.Addr().String() + urlPathPrefix
|
||||
server := &http.Server{Handler: r}
|
||||
r.server = server
|
||||
go func() {
|
||||
if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
_ = r.Close()
|
||||
}
|
||||
}()
|
||||
return r.baseURL, nil
|
||||
}
|
||||
|
||||
func (r *Registry) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
w.Header().Set("Allow", "GET")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
id := parseRequest(req)
|
||||
if id == "" {
|
||||
http.NotFound(w, req)
|
||||
return
|
||||
}
|
||||
src, err := r.getByID(id)
|
||||
if err != nil {
|
||||
http.NotFound(w, req)
|
||||
return
|
||||
}
|
||||
meta, open, session := src.acquireOpen()
|
||||
if open == nil {
|
||||
http.NotFound(w, req)
|
||||
return
|
||||
}
|
||||
if session != nil {
|
||||
defer session.Release()
|
||||
}
|
||||
if src.sourceError() != nil {
|
||||
http.Error(w, "blob source unavailable", http.StatusGone)
|
||||
return
|
||||
}
|
||||
start, end, ranged, err := parseRange(req.Header.Get("Range"), meta.Size, meta.Range)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusRequestedRangeNotSatisfiable)
|
||||
return
|
||||
}
|
||||
if !meta.Range {
|
||||
start, end, ranged = 0, -1, false
|
||||
}
|
||||
reader, err := open(req.Context(), OpenRequest{
|
||||
Offset: start,
|
||||
End: end,
|
||||
})
|
||||
if err != nil {
|
||||
if req.Context().Err() == nil || !errors.Is(err, req.Context().Err()) {
|
||||
src.recordSourceError(err, meta.Range)
|
||||
}
|
||||
http.Error(w, "blob source unavailable", http.StatusGone)
|
||||
return
|
||||
}
|
||||
if reader == nil {
|
||||
err = fmt.Errorf("%w: opener returned a nil reader", ErrSourceClosed)
|
||||
src.recordSourceError(err, meta.Range)
|
||||
http.Error(w, "blob source unavailable", http.StatusGone)
|
||||
return
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
writeHeaders(w, meta, start, end, ranged)
|
||||
if ranged {
|
||||
w.WriteHeader(http.StatusPartialContent)
|
||||
}
|
||||
limit := int64(-1)
|
||||
if end >= start {
|
||||
limit = end - start + 1
|
||||
} else if meta.Size > 0 {
|
||||
limit = meta.Size
|
||||
}
|
||||
_, readErr, writeErr := copyWithFlush(w, reader, limit)
|
||||
if readErr != nil {
|
||||
if ctxErr := req.Context().Err(); ctxErr == nil || !errors.Is(readErr, ctxErr) {
|
||||
src.recordSourceError(readErr, meta.Range)
|
||||
}
|
||||
} else if writeErr == nil && req.Context().Err() == nil {
|
||||
src.recordSourceSuccess(meta.Range)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Source) acquireOpen() (Metadata, OpenFunc, SessionRef) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.revoked {
|
||||
return Metadata{}, nil, nil
|
||||
}
|
||||
if s.session != nil {
|
||||
// The source's own retain guarantees that the session cannot become
|
||||
// closed while this additional active-reader retain is acquired.
|
||||
s.session.Retain()
|
||||
}
|
||||
return Metadata{
|
||||
ContentType: s.ContentType,
|
||||
Size: s.size,
|
||||
Range: s.rangeEnabled,
|
||||
}, s.open, s.session
|
||||
}
|
||||
|
||||
func (s *Source) acquireTask() error {
|
||||
s.mu.Lock()
|
||||
if s.revoked {
|
||||
s.mu.Unlock()
|
||||
return ErrSourceRevoked
|
||||
}
|
||||
s.taskRefs++
|
||||
timer := s.unclaimed
|
||||
s.unclaimed = nil
|
||||
s.mu.Unlock()
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Source) releaseTask() (session SessionRef, last bool, err error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.revoked {
|
||||
return nil, false, ErrSourceRevoked
|
||||
}
|
||||
if s.taskRefs == 0 {
|
||||
return nil, false, ErrSourceClosed
|
||||
}
|
||||
s.taskRefs--
|
||||
if s.taskRefs > 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
s.revoked = true
|
||||
s.open = nil
|
||||
session = s.session
|
||||
s.session = nil
|
||||
return session, true, nil
|
||||
}
|
||||
|
||||
func (s *Source) sourceError() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.readErr
|
||||
}
|
||||
|
||||
func (s *Source) recordSourceError(err error, ranged bool) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
if s.readErr == nil && ranged {
|
||||
s.rangeFailures++
|
||||
if s.rangeFailures < rangeSourceFailureLimit {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
}
|
||||
if s.readErr == nil {
|
||||
s.readErr = err
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Source) recordSourceSuccess(ranged bool) {
|
||||
if !ranged {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
if s.readErr == nil {
|
||||
s.rangeFailures = 0
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Source) close() {
|
||||
s.mu.Lock()
|
||||
alreadyRevoked := s.revoked
|
||||
s.revoked = true
|
||||
s.open = nil
|
||||
session := s.session
|
||||
s.session = nil
|
||||
timer := s.unclaimed
|
||||
s.unclaimed = nil
|
||||
s.mu.Unlock()
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
if !alreadyRevoked && session != nil {
|
||||
session.Release()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Source) expireUnclaimed() (SessionRef, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.unclaimed = nil
|
||||
if s.revoked || s.taskRefs > 0 {
|
||||
return nil, false
|
||||
}
|
||||
s.revoked = true
|
||||
s.open = nil
|
||||
session := s.session
|
||||
s.session = nil
|
||||
return session, true
|
||||
}
|
||||
|
||||
func (r *Registry) expireUnclaimed(src *Source) {
|
||||
session, expired := src.expireUnclaimed()
|
||||
if !expired {
|
||||
return
|
||||
}
|
||||
r.deleteSource(src)
|
||||
if session != nil {
|
||||
session.Release()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Registry) removeSource(src *Source) {
|
||||
src.close()
|
||||
r.deleteSource(src)
|
||||
}
|
||||
|
||||
func (r *Registry) deleteSource(src *Source) {
|
||||
r.mu.Lock()
|
||||
if current := r.sources[src.ID]; current == src {
|
||||
delete(r.sources, src.ID)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func writeHeaders(w http.ResponseWriter, meta Metadata, start, end int64, ranged bool) {
|
||||
if meta.ContentType != "" {
|
||||
w.Header().Set("Content-Type", meta.ContentType)
|
||||
}
|
||||
if meta.Range {
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
}
|
||||
if meta.Size > 0 {
|
||||
if ranged {
|
||||
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, meta.Size))
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(end-start+1, 10))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(meta.Size, 10))
|
||||
}
|
||||
}
|
||||
|
||||
func copyWithFlush(w http.ResponseWriter, reader io.Reader, limit int64) (written int64, readErr error, writeErr error) {
|
||||
if limit >= 0 {
|
||||
reader = io.LimitReader(reader, limit)
|
||||
}
|
||||
buf := make([]byte, 32*1024)
|
||||
flusher, canFlush := w.(http.Flusher)
|
||||
for {
|
||||
nr, er := reader.Read(buf)
|
||||
if er != nil && !errors.Is(er, io.EOF) {
|
||||
readErr = er
|
||||
}
|
||||
if nr > 0 {
|
||||
nw, ew := w.Write(buf[:nr])
|
||||
if nw > 0 {
|
||||
written += int64(nw)
|
||||
if canFlush {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
if ew != nil {
|
||||
return written, readErr, ew
|
||||
}
|
||||
if nr != nw {
|
||||
return written, readErr, io.ErrShortWrite
|
||||
}
|
||||
}
|
||||
if er != nil {
|
||||
if errors.Is(er, io.EOF) {
|
||||
if limit >= 0 && written < limit {
|
||||
return written, io.ErrUnexpectedEOF, nil
|
||||
}
|
||||
return written, nil, nil
|
||||
}
|
||||
return written, readErr, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseRange(header string, size int64, rangeEnabled bool) (start int64, end int64, ranged bool, err error) {
|
||||
if header == "" || !rangeEnabled {
|
||||
return 0, -1, false, nil
|
||||
}
|
||||
if size <= 0 {
|
||||
return 0, 0, false, ErrRangeNotAllowed
|
||||
}
|
||||
if !strings.HasPrefix(header, "bytes=") {
|
||||
return 0, 0, false, fmt.Errorf("unsupported range")
|
||||
}
|
||||
parts := strings.SplitN(strings.TrimPrefix(header, "bytes="), "-", 2)
|
||||
if len(parts) != 2 || parts[0] == "" {
|
||||
return 0, 0, false, fmt.Errorf("unsupported range")
|
||||
}
|
||||
start, err = strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil || start < 0 {
|
||||
return 0, 0, false, fmt.Errorf("invalid range")
|
||||
}
|
||||
if start >= size {
|
||||
return 0, 0, false, fmt.Errorf("range out of bounds")
|
||||
}
|
||||
end = size - 1
|
||||
if parts[1] != "" {
|
||||
end, err = strconv.ParseInt(parts[1], 10, 64)
|
||||
if err != nil || end < start {
|
||||
return 0, 0, false, fmt.Errorf("invalid range")
|
||||
}
|
||||
if end >= size {
|
||||
end = size - 1
|
||||
}
|
||||
}
|
||||
return start, end, true, nil
|
||||
}
|
||||
|
||||
func (r *Registry) get(raw string) (*Source, error) {
|
||||
id, ok := r.parseURL(raw)
|
||||
if !ok {
|
||||
return nil, ErrInvalidURL
|
||||
}
|
||||
src, err := r.getByID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
src.mu.Lock()
|
||||
revoked := src.revoked
|
||||
src.mu.Unlock()
|
||||
if revoked {
|
||||
return nil, ErrSourceRevoked
|
||||
}
|
||||
return src, nil
|
||||
}
|
||||
|
||||
func (r *Registry) getByID(id string) (*Source, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
src := r.sources[id]
|
||||
if src == nil {
|
||||
return nil, ErrSourceNotFound
|
||||
}
|
||||
return src, nil
|
||||
}
|
||||
|
||||
func (r *Registry) parseURL(raw string) (id string, ok bool) {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Scheme != "http" || !strings.HasPrefix(u.Path, urlPathPrefix) {
|
||||
return "", false
|
||||
}
|
||||
r.serverMu.Lock()
|
||||
baseURL := r.baseURL
|
||||
r.serverMu.Unlock()
|
||||
if baseURL == "" || !strings.HasPrefix(raw, baseURL) {
|
||||
return "", false
|
||||
}
|
||||
id = strings.TrimPrefix(u.Path, urlPathPrefix)
|
||||
if id == "" || path.Base(id) != id {
|
||||
return "", false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func parseRequest(req *http.Request) string {
|
||||
id := strings.TrimPrefix(req.URL.Path, urlPathPrefix)
|
||||
if id == "" || id == req.URL.Path || path.Base(id) != id {
|
||||
return ""
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func randomID(size int) (string, error) {
|
||||
buf := make([]byte, size)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(buf), nil
|
||||
}
|
||||
@@ -0,0 +1,898 @@
|
||||
package blob
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type terminalErrorReader struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *terminalErrorReader) Read([]byte) (int, error) {
|
||||
return 0, r.err
|
||||
}
|
||||
|
||||
type contextErrorReader struct {
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (r *contextErrorReader) Read([]byte) (int, error) {
|
||||
<-r.ctx.Done()
|
||||
return 0, r.ctx.Err()
|
||||
}
|
||||
|
||||
func (r *contextErrorReader) Close() error { return nil }
|
||||
|
||||
type failingResponseWriter struct {
|
||||
header http.Header
|
||||
err error
|
||||
}
|
||||
|
||||
func (w *failingResponseWriter) Header() http.Header {
|
||||
return w.header
|
||||
}
|
||||
|
||||
func (w *failingResponseWriter) Write([]byte) (int, error) {
|
||||
return 0, w.err
|
||||
}
|
||||
|
||||
func (w *failingResponseWriter) WriteHeader(int) {}
|
||||
|
||||
type countingSession struct {
|
||||
mu sync.Mutex
|
||||
refs int
|
||||
zero chan struct{}
|
||||
zeroOnce sync.Once
|
||||
}
|
||||
|
||||
func newCountingSession() *countingSession {
|
||||
return &countingSession{zero: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (s *countingSession) Retain() {
|
||||
s.mu.Lock()
|
||||
s.refs++
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *countingSession) Release() {
|
||||
s.mu.Lock()
|
||||
s.refs--
|
||||
refs := s.refs
|
||||
s.mu.Unlock()
|
||||
if refs == 0 {
|
||||
s.zeroOnce.Do(func() { close(s.zero) })
|
||||
}
|
||||
}
|
||||
|
||||
func (s *countingSession) RefCount() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.refs
|
||||
}
|
||||
|
||||
func setUnclaimedSourceTTLForTest(t *testing.T, ttl time.Duration) {
|
||||
t.Helper()
|
||||
previous := unclaimedSourceTTL
|
||||
unclaimedSourceTTL = ttl
|
||||
t.Cleanup(func() {
|
||||
unclaimedSourceTTL = previous
|
||||
})
|
||||
}
|
||||
|
||||
func TestRegistryBlobHTTPGetAndRange(t *testing.T) {
|
||||
registry := NewRegistry(t.TempDir())
|
||||
defer registry.Close()
|
||||
|
||||
url, err := registry.CreateBlob([]byte("hello world"), "text/plain")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !registry.IsURL(url) {
|
||||
t.Fatal("expected registry to recognize created url")
|
||||
}
|
||||
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("unexpected GET status: %d", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Content-Length"); got != "11" {
|
||||
t.Fatalf("unexpected content length: %q", got)
|
||||
}
|
||||
if got := resp.Header.Get("Accept-Ranges"); got != "bytes" {
|
||||
t.Fatalf("unexpected accept ranges: %q", got)
|
||||
}
|
||||
if got := resp.Header.Get("Content-Type"); !strings.HasPrefix(got, "text/plain") {
|
||||
t.Fatalf("unexpected content type: %q", got)
|
||||
}
|
||||
if string(body) != "hello world" {
|
||||
t.Fatalf("unexpected GET body: %q", string(body))
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Range", "bytes=6-10")
|
||||
resp, err = http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err = io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusPartialContent {
|
||||
t.Fatalf("unexpected range status: %d", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Content-Range"); got != "bytes 6-10/11" {
|
||||
t.Fatalf("unexpected content range: %q", got)
|
||||
}
|
||||
if string(body) != "world" {
|
||||
t.Fatalf("unexpected range body: %q", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryEmptyBlob(t *testing.T) {
|
||||
registry := NewRegistry(t.TempDir())
|
||||
defer registry.Close()
|
||||
|
||||
url, err := registry.CreateBlob(nil, "application/octet-stream")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta, err := registry.Metadata(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if meta.Size != 0 || meta.Range {
|
||||
t.Fatalf("unexpected empty blob metadata: %#v", meta)
|
||||
}
|
||||
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d", resp.StatusCode)
|
||||
}
|
||||
if len(body) != 0 {
|
||||
t.Fatalf("unexpected body: %q", body)
|
||||
}
|
||||
if got := resp.Header.Get("Content-Length"); got != "0" {
|
||||
t.Fatalf("unexpected content length: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryOpenerSizeWithoutRange(t *testing.T) {
|
||||
registry := NewRegistry(t.TempDir())
|
||||
defer registry.Close()
|
||||
|
||||
payload := []byte("hello opener")
|
||||
url, err := registry.CreateOpener(func(ctx context.Context, req OpenRequest) (io.ReadCloser, error) {
|
||||
if req.Offset != 0 || req.End != -1 {
|
||||
t.Fatalf("unexpected non-range request: %#v", req)
|
||||
}
|
||||
return io.NopCloser(bytes.NewReader(payload)), nil
|
||||
}, &CreateOptions{
|
||||
ContentType: "text/plain",
|
||||
Size: int64(len(payload)),
|
||||
Range: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Range", "bytes=6-11")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Content-Length"); got != "12" {
|
||||
t.Fatalf("unexpected content length: %q", got)
|
||||
}
|
||||
if got := resp.Header.Get("Accept-Ranges"); got != "" {
|
||||
t.Fatalf("unexpected accept ranges: %q", got)
|
||||
}
|
||||
if string(body) != string(payload) {
|
||||
t.Fatalf("unexpected body: %q", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryOpenerRangeConcurrentRequests(t *testing.T) {
|
||||
registry := NewRegistry(t.TempDir())
|
||||
defer registry.Close()
|
||||
|
||||
payload := []byte("abcdefghijklmnopqrstuvwxyz")
|
||||
var mu sync.Mutex
|
||||
var calls []OpenRequest
|
||||
url, err := registry.CreateOpener(func(ctx context.Context, req OpenRequest) (io.ReadCloser, error) {
|
||||
mu.Lock()
|
||||
calls = append(calls, req)
|
||||
mu.Unlock()
|
||||
|
||||
start := int(req.Offset)
|
||||
end := len(payload)
|
||||
if req.End >= 0 {
|
||||
end = int(req.End) + 1
|
||||
}
|
||||
if start < 0 || start > end || end > len(payload) {
|
||||
return nil, errors.New("bad test range")
|
||||
}
|
||||
return io.NopCloser(bytes.NewReader(payload[start:end])), nil
|
||||
}, &CreateOptions{
|
||||
Size: int64(len(payload)),
|
||||
Range: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ranges := []struct {
|
||||
header string
|
||||
want string
|
||||
}{
|
||||
{header: "bytes=0-4", want: "abcde"},
|
||||
{header: "bytes=10-15", want: "klmnop"},
|
||||
{header: "bytes=20-25", want: "uvwxyz"},
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for _, item := range ranges {
|
||||
item := item
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Range", item.header)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
if resp.StatusCode != http.StatusPartialContent {
|
||||
t.Errorf("unexpected status for %s: %d", item.header, resp.StatusCode)
|
||||
}
|
||||
if string(body) != item.want {
|
||||
t.Errorf("unexpected body for %s: %q", item.header, string(body))
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(calls) != len(ranges) {
|
||||
t.Fatalf("unexpected opener call count: %d", len(calls))
|
||||
}
|
||||
seen := map[OpenRequest]bool{}
|
||||
for _, call := range calls {
|
||||
seen[call] = true
|
||||
}
|
||||
for _, want := range []OpenRequest{{Offset: 0, End: 4}, {Offset: 10, End: 15}, {Offset: 20, End: 25}} {
|
||||
if !seen[want] {
|
||||
t.Fatalf("missing opener request %#v, got %#v", want, calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryRejectsRangeWithoutSize(t *testing.T) {
|
||||
registry := NewRegistry(t.TempDir())
|
||||
defer registry.Close()
|
||||
|
||||
_, err := registry.CreateOpener(func(ctx context.Context, req OpenRequest) (io.ReadCloser, error) {
|
||||
return io.NopCloser(strings.NewReader("")), nil
|
||||
}, &CreateOptions{Range: true})
|
||||
if !errors.Is(err, ErrInvalidOptions) {
|
||||
t.Fatalf("expected invalid options error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryTracksSourceErrors(t *testing.T) {
|
||||
t.Run("reader error", func(t *testing.T) {
|
||||
registry := NewRegistry(t.TempDir())
|
||||
defer registry.Close()
|
||||
|
||||
boom := errors.New("reader failed")
|
||||
url, err := registry.CreateOpener(func(context.Context, OpenRequest) (io.ReadCloser, error) {
|
||||
return io.NopCloser(io.MultiReader(strings.NewReader("partial"), &terminalErrorReader{err: boom})), nil
|
||||
}, &CreateOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if readErr != nil {
|
||||
t.Fatalf("the HTTP transport should end normally, got %v", readErr)
|
||||
}
|
||||
if string(body) != "partial" {
|
||||
t.Fatalf("unexpected partial body: %q", body)
|
||||
}
|
||||
if got := registry.SourceError(url); !errors.Is(got, boom) {
|
||||
t.Fatalf("expected source error %v, got %v", boom, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("short known-size reader", func(t *testing.T) {
|
||||
registry := NewRegistry(t.TempDir())
|
||||
defer registry.Close()
|
||||
|
||||
url, err := registry.CreateOpener(func(context.Context, OpenRequest) (io.ReadCloser, error) {
|
||||
return io.NopCloser(strings.NewReader("short")), nil
|
||||
}, &CreateOptions{Size: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if got := registry.SourceError(url); !errors.Is(got, io.ErrUnexpectedEOF) {
|
||||
t.Fatalf("expected unexpected EOF, got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("range reader error becomes terminal", func(t *testing.T) {
|
||||
registry := NewRegistry(t.TempDir())
|
||||
defer registry.Close()
|
||||
|
||||
boom := errors.New("range reader failed")
|
||||
url, err := registry.CreateOpener(func(context.Context, OpenRequest) (io.ReadCloser, error) {
|
||||
return io.NopCloser(io.MultiReader(strings.NewReader("partial"), &terminalErrorReader{err: boom})), nil
|
||||
}, &CreateOptions{Size: 10, Range: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for attempt := 0; attempt < rangeSourceFailureLimit; attempt++ {
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Range", "bytes=0-9")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
if got := registry.SourceError(url); !errors.Is(got, boom) {
|
||||
t.Fatalf("expected range source error %v, got %v", boom, got)
|
||||
}
|
||||
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusGone {
|
||||
t.Fatalf("expected terminal source status %d, got %d", http.StatusGone, resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("short range reader", func(t *testing.T) {
|
||||
registry := NewRegistry(t.TempDir())
|
||||
defer registry.Close()
|
||||
|
||||
url, err := registry.CreateOpener(func(context.Context, OpenRequest) (io.ReadCloser, error) {
|
||||
return io.NopCloser(strings.NewReader("short")), nil
|
||||
}, &CreateOptions{Size: 10, Range: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for attempt := 0; attempt < rangeSourceFailureLimit; attempt++ {
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Range", "bytes=0-9")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
if got := registry.SourceError(url); !errors.Is(got, io.ErrUnexpectedEOF) {
|
||||
t.Fatalf("expected range unexpected EOF, got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("successful range retry clears transient failure", func(t *testing.T) {
|
||||
registry := NewRegistry(t.TempDir())
|
||||
defer registry.Close()
|
||||
|
||||
attempt := 0
|
||||
boom := errors.New("transient range failure")
|
||||
url, err := registry.CreateOpener(func(context.Context, OpenRequest) (io.ReadCloser, error) {
|
||||
attempt++
|
||||
if attempt == 1 {
|
||||
return io.NopCloser(&terminalErrorReader{err: boom}), nil
|
||||
}
|
||||
return io.NopCloser(strings.NewReader("0123456789")), nil
|
||||
}, &CreateOptions{Size: 10, Range: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Range", "bytes=0-9")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
if got := registry.SourceError(url); got != nil {
|
||||
t.Fatalf("successful retry left transient source error: %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("request cancellation is not a source error", func(t *testing.T) {
|
||||
registry := NewRegistry(t.TempDir())
|
||||
defer registry.Close()
|
||||
|
||||
opened := make(chan struct{})
|
||||
url, err := registry.CreateOpener(func(ctx context.Context, _ OpenRequest) (io.ReadCloser, error) {
|
||||
close(opened)
|
||||
return &contextErrorReader{ctx: ctx}, nil
|
||||
}, &CreateOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
resp, _ := http.DefaultClient.Do(req)
|
||||
if resp != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
<-opened
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("cancelled request did not finish")
|
||||
}
|
||||
if got := registry.SourceError(url); got != nil {
|
||||
t.Fatalf("request cancellation was recorded as source error: %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil reader", func(t *testing.T) {
|
||||
registry := NewRegistry(t.TempDir())
|
||||
defer registry.Close()
|
||||
|
||||
url, err := registry.CreateOpener(func(context.Context, OpenRequest) (io.ReadCloser, error) {
|
||||
return nil, nil
|
||||
}, &CreateOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusGone {
|
||||
t.Fatalf("expected nil reader status %d, got %d", http.StatusGone, resp.StatusCode)
|
||||
}
|
||||
if got := registry.SourceError(url); !errors.Is(got, ErrSourceClosed) {
|
||||
t.Fatalf("expected closed source error, got %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRegistryDoesNotRecordResponseWriteError(t *testing.T) {
|
||||
registry := NewRegistry(t.TempDir())
|
||||
defer registry.Close()
|
||||
|
||||
url, err := registry.CreateOpener(func(context.Context, OpenRequest) (io.ReadCloser, error) {
|
||||
return io.NopCloser(strings.NewReader("payload")), nil
|
||||
}, &CreateOptions{Size: 7})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writerErr := errors.New("client connection closed")
|
||||
registry.ServeHTTP(&failingResponseWriter{header: make(http.Header), err: writerErr}, req)
|
||||
if got := registry.SourceError(url); got != nil {
|
||||
t.Fatalf("response write error was recorded as a source error: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryTaskReferencesShareSource(t *testing.T) {
|
||||
registry := NewRegistry(t.TempDir())
|
||||
defer registry.Close()
|
||||
|
||||
url, err := registry.CreateBlob([]byte("shared"), "text/plain")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := registry.Acquire(url); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := registry.Acquire(url); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := registry.Release(url); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !registry.IsURL(url) {
|
||||
t.Fatal("the first task release revoked a source still used by another task")
|
||||
}
|
||||
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if readErr != nil || string(body) != "shared" {
|
||||
t.Fatalf("unexpected shared response body %q, error %v", body, readErr)
|
||||
}
|
||||
|
||||
if err := registry.Release(url); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if registry.IsURL(url) {
|
||||
t.Fatal("the final task release did not revoke the source")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryUnclaimedSessionSourceExpires(t *testing.T) {
|
||||
setUnclaimedSourceTTLForTest(t, 25*time.Millisecond)
|
||||
registry := NewRegistry(t.TempDir())
|
||||
defer registry.Close()
|
||||
|
||||
session := newCountingSession()
|
||||
url, err := registry.CreateOpener(func(context.Context, OpenRequest) (io.ReadCloser, error) {
|
||||
return io.NopCloser(strings.NewReader("unused")), nil
|
||||
}, &CreateOptions{Size: 6, Session: session})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := session.RefCount(); got != 1 {
|
||||
t.Fatalf("unexpected initial session ref count: %d", got)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-session.zero:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("unclaimed source did not expire")
|
||||
}
|
||||
if registry.IsURL(url) {
|
||||
t.Fatal("expired source is still registered")
|
||||
}
|
||||
if got := session.RefCount(); got != 0 {
|
||||
t.Fatalf("expired source retained its session: %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryAcquireStopsUnclaimedSourceExpiration(t *testing.T) {
|
||||
const ttl = 25 * time.Millisecond
|
||||
setUnclaimedSourceTTLForTest(t, ttl)
|
||||
registry := NewRegistry(t.TempDir())
|
||||
defer registry.Close()
|
||||
|
||||
session := newCountingSession()
|
||||
url, err := registry.CreateOpener(func(context.Context, OpenRequest) (io.ReadCloser, error) {
|
||||
return io.NopCloser(strings.NewReader("claimed")), nil
|
||||
}, &CreateOptions{Size: 7, Session: session})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := registry.Acquire(url); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-session.zero:
|
||||
t.Fatal("claimed source expired")
|
||||
case <-time.After(4 * ttl):
|
||||
}
|
||||
if !registry.IsURL(url) {
|
||||
t.Fatal("claimed source was removed")
|
||||
}
|
||||
if err := registry.Release(url); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case <-session.zero:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("final task release did not release the session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryUnclaimedExpirationKeepsActiveReaderSession(t *testing.T) {
|
||||
setUnclaimedSourceTTLForTest(t, 100*time.Millisecond)
|
||||
registry := NewRegistry(t.TempDir())
|
||||
defer registry.Close()
|
||||
|
||||
session := newCountingSession()
|
||||
reader, writer := io.Pipe()
|
||||
opened := make(chan struct{})
|
||||
url, err := registry.CreateOpener(func(context.Context, OpenRequest) (io.ReadCloser, error) {
|
||||
close(opened)
|
||||
return reader, nil
|
||||
}, &CreateOptions{Size: 7, Session: session})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
type result struct {
|
||||
body []byte
|
||||
err error
|
||||
}
|
||||
resultCh := make(chan result, 1)
|
||||
go func() {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
resultCh <- result{err: err}
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
resultCh <- result{body: body, err: err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-opened:
|
||||
case got := <-resultCh:
|
||||
t.Fatalf("request ended before the opener ran: body %q, error %v", got.body, got.err)
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("active request did not open")
|
||||
}
|
||||
deadline := time.NewTimer(2 * time.Second)
|
||||
ticker := time.NewTicker(5 * time.Millisecond)
|
||||
defer deadline.Stop()
|
||||
defer ticker.Stop()
|
||||
for registry.IsURL(url) {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
case <-deadline.C:
|
||||
t.Fatal("unclaimed source did not expire while its reader was active")
|
||||
}
|
||||
}
|
||||
if got := session.RefCount(); got != 1 {
|
||||
t.Fatalf("expiration released the active-reader session ref: %d", got)
|
||||
}
|
||||
if _, err := writer.Write([]byte("payload")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = writer.Close()
|
||||
|
||||
select {
|
||||
case got := <-resultCh:
|
||||
if got.err != nil || string(got.body) != "payload" {
|
||||
t.Fatalf("active response was interrupted: body %q, error %v", got.body, got.err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("active response did not finish after source expiration")
|
||||
}
|
||||
select {
|
||||
case <-session.zero:
|
||||
if got := session.RefCount(); got != 0 {
|
||||
t.Fatalf("unexpected final session ref count: %d", got)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("active-reader session ref was not released")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryCloseCancelsUnclaimedExpiration(t *testing.T) {
|
||||
const ttl = 25 * time.Millisecond
|
||||
setUnclaimedSourceTTLForTest(t, ttl)
|
||||
registry := NewRegistry(t.TempDir())
|
||||
|
||||
session := newCountingSession()
|
||||
_, err := registry.CreateOpener(func(context.Context, OpenRequest) (io.ReadCloser, error) {
|
||||
return io.NopCloser(strings.NewReader("unused")), nil
|
||||
}, &CreateOptions{Size: 6, Session: session})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := registry.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case <-session.zero:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("registry close did not release the session")
|
||||
}
|
||||
<-time.After(4 * ttl)
|
||||
if got := session.RefCount(); got != 0 {
|
||||
t.Fatalf("expiration released the session again after close: %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryRevokeKeepsActiveReaderSession(t *testing.T) {
|
||||
registry := NewRegistry(t.TempDir())
|
||||
defer registry.Close()
|
||||
|
||||
session := newCountingSession()
|
||||
reader, writer := io.Pipe()
|
||||
opened := make(chan struct{})
|
||||
url, err := registry.CreateOpener(func(context.Context, OpenRequest) (io.ReadCloser, error) {
|
||||
close(opened)
|
||||
return reader, nil
|
||||
}, &CreateOptions{Size: 7, Session: session})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
type result struct {
|
||||
body []byte
|
||||
err error
|
||||
}
|
||||
resultCh := make(chan result, 1)
|
||||
go func() {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
resultCh <- result{err: err}
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
resultCh <- result{body: body, err: err}
|
||||
}()
|
||||
|
||||
<-opened
|
||||
if got := session.RefCount(); got != 2 {
|
||||
t.Fatalf("expected source and active-reader session refs, got %d", got)
|
||||
}
|
||||
if err := registry.Revoke(url); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := session.RefCount(); got != 1 {
|
||||
t.Fatalf("revoke released the active-reader session ref: %d", got)
|
||||
}
|
||||
if _, err := writer.Write([]byte("payload")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = writer.Close()
|
||||
|
||||
select {
|
||||
case got := <-resultCh:
|
||||
if got.err != nil || string(got.body) != "payload" {
|
||||
t.Fatalf("active response was interrupted: body %q, error %v", got.body, got.err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("active response did not finish after revoke")
|
||||
}
|
||||
select {
|
||||
case <-session.zero:
|
||||
if got := session.RefCount(); got != 0 {
|
||||
t.Fatalf("unexpected final session ref count: %d", got)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("active-reader session ref was not released")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryHTTPErrorStatuses(t *testing.T) {
|
||||
registry := NewRegistry(t.TempDir())
|
||||
defer registry.Close()
|
||||
|
||||
url, err := registry.CreateBlob([]byte("secret"), "text/plain")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
badURL := url + "-missing"
|
||||
resp, err := http.Get(badURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("unexpected missing source status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodHead, url, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err = http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("unexpected method status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
req, err = http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Range", "bytes=99-100")
|
||||
resp, err = http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusRequestedRangeNotSatisfiable {
|
||||
t.Fatalf("unexpected invalid range status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if err := registry.Revoke(url); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err = http.Get(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("unexpected revoked source status: %d", resp.StatusCode)
|
||||
}
|
||||
if registry.IsURL(url) {
|
||||
t.Fatal("revoked source should not be usable")
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type Controller struct {
|
||||
GetConfig func(v any) bool
|
||||
ProxyUrl *url.URL
|
||||
GetConfig func(v any)
|
||||
GetProxy func(requestProxy *base.RequestProxy) func(*http.Request) (*url.URL, error)
|
||||
FileController
|
||||
//ContextDialer() (proxy.Dialer, error)
|
||||
}
|
||||
@@ -22,10 +24,11 @@ type DefaultFileController struct {
|
||||
|
||||
func NewController() *Controller {
|
||||
return &Controller{
|
||||
FileController: &DefaultFileController{},
|
||||
GetConfig: func(v any) bool {
|
||||
return false
|
||||
GetConfig: func(v any) {},
|
||||
GetProxy: func(requestProxy *base.RequestProxy) func(*http.Request) (*url.URL, error) {
|
||||
return requestProxy.ToHandler()
|
||||
},
|
||||
FileController: &DefaultFileController{},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+76
-13
@@ -1,26 +1,28 @@
|
||||
package fetcher
|
||||
|
||||
import (
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/controller"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"path"
|
||||
)
|
||||
|
||||
// Fetcher defines the interface for a download protocol.
|
||||
// One fetcher for each download task
|
||||
// Each download task will have a corresponding Fetcher instance for the management of the download task
|
||||
type Fetcher interface {
|
||||
// Name return the name of the protocol.
|
||||
Name() string
|
||||
|
||||
Setup(ctl *controller.Controller)
|
||||
// Resolve resource info from request
|
||||
Resolve(req *base.Request) error
|
||||
// Create ready to download, but not started
|
||||
Create(opts *base.Options) error
|
||||
Resolve(req *base.Request, opts *base.Options) error
|
||||
Start() error
|
||||
// Patch modifies task-specific data based on the protocol.
|
||||
// For HTTP: can modify Request info (URL, headers, etc.)
|
||||
// For BT: can modify SelectFiles (via opts.SelectFiles)
|
||||
Patch(req *base.Request, opts *base.Options) error
|
||||
Pause() error
|
||||
Close() error
|
||||
|
||||
// Stats refreshes health statistics and returns the latest information
|
||||
Stats() any
|
||||
// Meta returns the meta information of the download.
|
||||
Meta() *FetcherMeta
|
||||
// Progress returns the progress of the download.
|
||||
@@ -29,6 +31,12 @@ type Fetcher interface {
|
||||
Wait() error
|
||||
}
|
||||
|
||||
type Uploader interface {
|
||||
Upload() error
|
||||
UploadedBytes() int64
|
||||
WaitUpload() error
|
||||
}
|
||||
|
||||
// FetcherMeta defines the meta information of a fetcher.
|
||||
type FetcherMeta struct {
|
||||
Req *base.Request `json:"req"`
|
||||
@@ -66,17 +74,72 @@ func (m *FetcherMeta) RootDirPath() string {
|
||||
}
|
||||
}
|
||||
|
||||
// FetcherBuilder defines the interface for a fetcher builder.
|
||||
type FetcherBuilder interface {
|
||||
// Schemes returns the schemes supported by the fetcher.
|
||||
Schemes() []string
|
||||
type FilterType int
|
||||
|
||||
const (
|
||||
// FilterTypeUrl url type, pattern is the scheme, e.g. http://github.com -> http
|
||||
FilterTypeUrl FilterType = iota
|
||||
// FilterTypeFile file type, pattern is the file extension name, e.g. test.torrent -> torrent
|
||||
FilterTypeFile
|
||||
// FilterTypeBase64 base64 data type, pattern is the data mime type, e.g. data:application/x-bittorrent;base64 -> application/x-bittorrent
|
||||
FilterTypeBase64
|
||||
)
|
||||
|
||||
type SchemeFilter struct {
|
||||
Type FilterType
|
||||
Pattern string
|
||||
}
|
||||
|
||||
func (s *SchemeFilter) Match(uri string) bool {
|
||||
uriUpper := strings.ToUpper(uri)
|
||||
patternUpper := strings.ToUpper(s.Pattern)
|
||||
switch s.Type {
|
||||
case FilterTypeUrl:
|
||||
return strings.HasPrefix(uriUpper, patternUpper+":")
|
||||
case FilterTypeFile:
|
||||
return strings.HasSuffix(uriUpper, "."+patternUpper)
|
||||
case FilterTypeBase64:
|
||||
return strings.HasPrefix(uriUpper, "DATA:"+patternUpper+";BASE64,")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// FetcherManager manage and control the fetcher
|
||||
type FetcherManager interface {
|
||||
// Name return the name of the protocol.
|
||||
Name() string
|
||||
// Filters registers the supported schemes.
|
||||
Filters() []*SchemeFilter
|
||||
// Build returns a new fetcher.
|
||||
Build() Fetcher
|
||||
// ParseName name displayed when the task is not yet resolved, parsed from the request URL
|
||||
ParseName(u string) string
|
||||
// AutoRename returns whether the fetcher need renaming the download file when has the same name file.
|
||||
AutoRename() bool
|
||||
|
||||
// DefaultConfig returns the default configuration of the protocol.
|
||||
DefaultConfig() any
|
||||
// Store fetcher
|
||||
Store(fetcher Fetcher) (any, error)
|
||||
// Restore fetcher
|
||||
Restore() (v any, f func(meta *FetcherMeta, v any) Fetcher)
|
||||
// Close the fetcher manager, release resources.
|
||||
Close() error
|
||||
}
|
||||
|
||||
// StatefulFetcherManager is an optional extension for protocols that keep
|
||||
// shared client state outside individual task fetchers.
|
||||
type StatefulFetcherManager interface {
|
||||
SetStateStore(store ProtocolStateStore)
|
||||
}
|
||||
|
||||
// ProtocolStateStore persists shared protocol state for a fetcher manager.
|
||||
// Downloader provides the concrete storage backend, while the protocol decides
|
||||
// when state should be loaded or flushed.
|
||||
type ProtocolStateStore interface {
|
||||
Load(v any) (bool, error)
|
||||
Save(v any) error
|
||||
Delete() error
|
||||
}
|
||||
|
||||
type DefaultFetcher struct {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package fetcher
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSchemeFilter_Match(t *testing.T) {
|
||||
type fields struct {
|
||||
Type FilterType
|
||||
Pattern string
|
||||
}
|
||||
type args struct {
|
||||
uri string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
fields fields
|
||||
args args
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "url match",
|
||||
fields: fields{
|
||||
Type: FilterTypeUrl,
|
||||
Pattern: "https",
|
||||
},
|
||||
args: args{
|
||||
uri: "https://github.com",
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "url not match",
|
||||
fields: fields{
|
||||
Type: FilterTypeUrl,
|
||||
Pattern: "https",
|
||||
},
|
||||
args: args{
|
||||
uri: "ftp://github.com",
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "file match",
|
||||
fields: fields{
|
||||
Type: FilterTypeFile,
|
||||
Pattern: "torrent",
|
||||
},
|
||||
args: args{
|
||||
uri: "d:/temp/test.torrent",
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "file not match",
|
||||
fields: fields{
|
||||
Type: FilterTypeFile,
|
||||
Pattern: "torrent",
|
||||
},
|
||||
args: args{
|
||||
uri: "d:/temp/test.txt",
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "base64 match",
|
||||
fields: fields{
|
||||
Type: FilterTypeBase64,
|
||||
Pattern: "application/x-bittorrent",
|
||||
},
|
||||
args: args{
|
||||
uri: "data:application/x-bittorrent;base64,xxx",
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "base64 not match",
|
||||
fields: fields{
|
||||
Type: FilterTypeBase64,
|
||||
Pattern: "application/x-bittorrent",
|
||||
},
|
||||
args: args{
|
||||
uri: "data:application/javascript;base64,xxx",
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s := &SchemeFilter{
|
||||
Type: tt.fields.Type,
|
||||
Pattern: tt.fields.Pattern,
|
||||
}
|
||||
if got := s.Match(tt.args.uri); got != tt.want {
|
||||
t.Errorf("Match() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,4 +3,10 @@ package bt
|
||||
type config struct {
|
||||
ListenPort int `json:"listenPort"`
|
||||
Trackers []string `json:"trackers"`
|
||||
// SeedKeep is always keep seeding after downloading is complete, unless manually stopped.
|
||||
SeedKeep bool `json:"seedKeep"`
|
||||
// SeedRatio is the ratio of uploaded data to downloaded data to seed.
|
||||
SeedRatio float64 `json:"seedRatio"`
|
||||
// SeedTime is the time in seconds to seed after downloading is complete.
|
||||
SeedTime int64 `json:"seedTime"`
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package bt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/rs/dnscache"
|
||||
)
|
||||
|
||||
// DnsCacheResolver resolves DNS requests for an HTTP client using an in-memory cache.
|
||||
type DnsCacheResolver struct {
|
||||
RefreshTimeout time.Duration
|
||||
|
||||
resolver dnscache.Resolver
|
||||
}
|
||||
|
||||
func (r *DnsCacheResolver) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ips, err := r.resolver.LookupHost(ctx, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var conn net.Conn
|
||||
for _, ip := range ips {
|
||||
var dialer net.Dialer
|
||||
conn, err = dialer.DialContext(ctx, network, net.JoinHostPort(ip, port))
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
return conn, err
|
||||
}
|
||||
|
||||
func (r *DnsCacheResolver) Run(ctx context.Context) {
|
||||
ticker := time.NewTicker(r.RefreshTimeout)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
r.resolver.Refresh(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
+458
-138
@@ -2,6 +2,17 @@ package bt
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/controller"
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
@@ -9,18 +20,15 @@ import (
|
||||
"github.com/GopeedLab/gopeed/pkg/util"
|
||||
"github.com/anacrolix/torrent"
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"github.com/anacrolix/torrent/storage"
|
||||
)
|
||||
|
||||
var (
|
||||
client *torrent.Client
|
||||
lock sync.Mutex
|
||||
torrentDirMap = make(map[string]string)
|
||||
ftMap = make(map[string]*fileTorrentImpl)
|
||||
cfg *torrent.ClientConfig
|
||||
client *torrent.Client
|
||||
lock sync.Mutex
|
||||
closeCtx context.Context
|
||||
closeFunc func()
|
||||
)
|
||||
|
||||
type Fetcher struct {
|
||||
@@ -29,14 +37,13 @@ type Fetcher struct {
|
||||
|
||||
torrent *torrent.Torrent
|
||||
meta *fetcher.FetcherMeta
|
||||
data *fetcherData
|
||||
|
||||
torrentReady atomic.Bool
|
||||
create atomic.Bool
|
||||
progress fetcher.Progress
|
||||
}
|
||||
|
||||
func (f *Fetcher) Name() string {
|
||||
return "bt"
|
||||
torrentReady atomic.Bool
|
||||
torrentUpload atomic.Bool
|
||||
torrentDropCtx context.Context
|
||||
torrentDropFunc func()
|
||||
uploadDoneCh chan any
|
||||
}
|
||||
|
||||
func (f *Fetcher) Setup(ctl *controller.Controller) {
|
||||
@@ -44,13 +51,12 @@ func (f *Fetcher) Setup(ctl *controller.Controller) {
|
||||
if f.meta == nil {
|
||||
f.meta = &fetcher.FetcherMeta{}
|
||||
}
|
||||
exist := f.ctl.GetConfig(&f.config)
|
||||
if !exist {
|
||||
f.config = &config{
|
||||
ListenPort: 0,
|
||||
Trackers: []string{},
|
||||
}
|
||||
if f.data == nil {
|
||||
f.data = &fetcherData{}
|
||||
}
|
||||
f.uploadDoneCh = make(chan any, 1)
|
||||
f.torrentDropCtx, f.torrentDropFunc = context.WithCancel(context.Background())
|
||||
f.ctl.GetConfig(&f.config)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -61,65 +67,61 @@ func (f *Fetcher) initClient() (err error) {
|
||||
if client != nil {
|
||||
return
|
||||
}
|
||||
|
||||
cfg := torrent.NewDefaultClientConfig()
|
||||
cfg.ListenPort = f.config.ListenPort
|
||||
if f.ctl.ProxyUrl != nil {
|
||||
cfg.HTTPProxy = http.ProxyURL(f.ctl.ProxyUrl)
|
||||
if closeCtx == nil {
|
||||
closeCtx, closeFunc = context.WithCancel(context.Background())
|
||||
}
|
||||
cfg.DefaultStorage = newFileOpts(newFileClientOpts{
|
||||
ClientBaseDir: cfg.DataDir,
|
||||
HandleFileTorrent: func(infoHash metainfo.Hash, ft *fileTorrentImpl) {
|
||||
if dir, ok := torrentDirMap[infoHash.String()]; ok {
|
||||
ft.setTorrentDir(dir)
|
||||
}
|
||||
ftMap[infoHash.String()] = ft
|
||||
},
|
||||
})
|
||||
|
||||
cfg = torrent.NewDefaultClientConfig()
|
||||
cfg.Seed = true
|
||||
cfg.Bep20 = fmt.Sprintf("-GP%s-", parseBep20())
|
||||
cfg.ExtendedHandshakeClientVersion = fmt.Sprintf("Gopeed %s", base.Version)
|
||||
cfg.ListenPort = f.config.ListenPort
|
||||
cfg.HTTPProxy = f.ctl.GetProxy(f.meta.Req.Proxy)
|
||||
dnsResolver := &DnsCacheResolver{RefreshTimeout: 5 * time.Minute}
|
||||
cfg.TrackerDialContext = dnsResolver.DialContext
|
||||
client, err = torrent.NewClient(cfg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
closeCtx, closeFunc = context.WithCancel(context.Background())
|
||||
go func() {
|
||||
dnsResolver.Run(closeCtx)
|
||||
}()
|
||||
return
|
||||
}
|
||||
|
||||
func (f *Fetcher) Resolve(req *base.Request) error {
|
||||
if err := base.ParseReqExtra[bt.ReqExtra](req); err != nil {
|
||||
return err
|
||||
func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
|
||||
f.meta.Req = req
|
||||
f.meta.Opts = opts
|
||||
if f.meta.Opts == nil {
|
||||
f.meta.Opts = &base.Options{}
|
||||
}
|
||||
|
||||
if err := f.addTorrent(req); err != nil {
|
||||
if err := f.addTorrent(req, false); err != nil {
|
||||
return err
|
||||
}
|
||||
f.updateRes()
|
||||
f.meta.Req = req
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) Create(opts *base.Options) (err error) {
|
||||
f.create.Store(true)
|
||||
f.meta.Opts = opts
|
||||
if f.meta.Res != nil {
|
||||
torrentDirMap[f.meta.Res.Hash] = f.meta.FolderPath()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) Start() (err error) {
|
||||
if !f.torrentReady.Load() {
|
||||
if err = f.addTorrent(f.meta.Req); err != nil {
|
||||
if err = f.addTorrent(f.meta.Req, false); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if ft, ok := ftMap[f.meta.Res.Hash]; ok {
|
||||
ft.setTorrentDir(f.meta.FolderPath())
|
||||
}
|
||||
|
||||
files := f.torrent.Files()
|
||||
// If the user does not specify the file to download, all files will be downloaded by default
|
||||
if len(f.meta.Opts.SelectFiles) == 0 {
|
||||
f.meta.Opts.SelectFiles = make([]int, len(files))
|
||||
for i := range files {
|
||||
f.meta.Opts.SelectFiles[i] = i
|
||||
if f.data.Progress == nil {
|
||||
if len(f.meta.Opts.SelectFiles) == 0 {
|
||||
f.meta.Opts.SelectFiles = make([]int, len(files))
|
||||
for i := range files {
|
||||
f.meta.Opts.SelectFiles[i] = i
|
||||
}
|
||||
}
|
||||
f.data.Progress = make(fetcher.Progress, len(f.meta.Opts.SelectFiles))
|
||||
}
|
||||
f.progress = make(fetcher.Progress, len(f.meta.Opts.SelectFiles))
|
||||
if len(f.meta.Opts.SelectFiles) == len(files) {
|
||||
f.torrent.DownloadAll()
|
||||
} else {
|
||||
@@ -128,17 +130,22 @@ func (f *Fetcher) Start() (err error) {
|
||||
file.Download()
|
||||
}
|
||||
}
|
||||
f.torrent.AllowDataDownload()
|
||||
return
|
||||
}
|
||||
|
||||
func (f *Fetcher) Pause() (err error) {
|
||||
f.torrentReady.Store(false)
|
||||
f.safeDrop()
|
||||
f.torrent.DisallowDataDownload()
|
||||
return
|
||||
}
|
||||
|
||||
func (f *Fetcher) Close() (err error) {
|
||||
f.safeDrop()
|
||||
f.torrentDropFunc()
|
||||
f.uploadDoneCh <- nil
|
||||
if len(client.Torrents()) == 0 {
|
||||
err = closeClient()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -151,66 +158,147 @@ func (f *Fetcher) safeDrop() {
|
||||
f.torrent.Drop()
|
||||
}
|
||||
|
||||
func (f *Fetcher) Wait() (err error) {
|
||||
for {
|
||||
if f.torrentReady.Load() && len(f.meta.Opts.SelectFiles) > 0 {
|
||||
done := true
|
||||
for _, selectIndex := range f.meta.Opts.SelectFiles {
|
||||
file := f.torrent.Files()[selectIndex]
|
||||
if file.BytesCompleted() < file.Length() {
|
||||
done = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if done {
|
||||
// remove unselected files
|
||||
for i, file := range f.torrent.Files() {
|
||||
selected := false
|
||||
for _, selectIndex := range f.meta.Opts.SelectFiles {
|
||||
if i == selectIndex {
|
||||
selected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !selected {
|
||||
util.SafeRemove(filepath.Join(f.meta.Opts.Path, f.meta.Res.Name, file.Path()))
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(time.Millisecond * 500)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) Meta() *fetcher.FetcherMeta {
|
||||
return f.meta
|
||||
}
|
||||
|
||||
func (f *Fetcher) Stats() any {
|
||||
var stats torrent.TorrentStats
|
||||
if f.torrent != nil {
|
||||
stats = f.torrent.Stats()
|
||||
} else {
|
||||
stats = torrent.TorrentStats{}
|
||||
}
|
||||
return &bt.Stats{
|
||||
TotalPeers: stats.TotalPeers,
|
||||
ActivePeers: stats.ActivePeers,
|
||||
ConnectedSeeders: stats.ConnectedSeeders,
|
||||
SeedBytes: f.data.SeedBytes,
|
||||
SeedRatio: f.seedRadio(),
|
||||
SeedTime: f.data.SeedTime,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Fetcher) Progress() fetcher.Progress {
|
||||
if !f.torrentReady.Load() {
|
||||
return f.progress
|
||||
return f.data.Progress
|
||||
}
|
||||
for i := range f.progress {
|
||||
for i := range f.data.Progress {
|
||||
selectIndex := f.meta.Opts.SelectFiles[i]
|
||||
file := f.torrent.Files()[selectIndex]
|
||||
f.progress[i] = file.BytesCompleted()
|
||||
f.data.Progress[i] = file.BytesCompleted()
|
||||
}
|
||||
return f.progress
|
||||
return f.data.Progress
|
||||
}
|
||||
|
||||
func (f *Fetcher) Wait() (err error) {
|
||||
for {
|
||||
select {
|
||||
case <-f.torrentDropCtx.Done():
|
||||
return
|
||||
case <-time.After(time.Second):
|
||||
if f.torrentReady.Load() && len(f.meta.Opts.SelectFiles) > 0 {
|
||||
if f.isDone() {
|
||||
// remove unselected files
|
||||
for i, file := range f.torrent.Files() {
|
||||
selected := false
|
||||
for _, selectIndex := range f.meta.Opts.SelectFiles {
|
||||
if i == selectIndex {
|
||||
selected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !selected {
|
||||
util.SafeRemove(filepath.Join(f.meta.Opts.Path, f.meta.Res.Name, file.Path()))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Fetcher) isDone() bool {
|
||||
if f.meta.Opts == nil {
|
||||
return false
|
||||
}
|
||||
for _, selectIndex := range f.meta.Opts.SelectFiles {
|
||||
file := f.torrent.Files()[selectIndex]
|
||||
if file.BytesCompleted() < file.Length() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Patch modifies the BT task settings.
|
||||
// Invalid file indices are silently ignored.
|
||||
func (f *Fetcher) Patch(req *base.Request, opts *base.Options) error {
|
||||
if opts == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if opts.SelectFiles != nil {
|
||||
selectFiles := opts.SelectFiles
|
||||
|
||||
// Get file count from resource metadata
|
||||
fileCount := 0
|
||||
if f.meta.Res != nil {
|
||||
fileCount = len(f.meta.Res.Files)
|
||||
}
|
||||
|
||||
// Filter out invalid indices (silently ignore)
|
||||
validSelectFiles := make([]int, 0, len(selectFiles))
|
||||
for _, idx := range selectFiles {
|
||||
if idx >= 0 && idx < fileCount {
|
||||
validSelectFiles = append(validSelectFiles, idx)
|
||||
}
|
||||
}
|
||||
|
||||
if f.torrent != nil {
|
||||
files := f.torrent.Files()
|
||||
|
||||
// Cancel all current file downloads first
|
||||
f.torrent.CancelPieces(0, f.torrent.NumPieces())
|
||||
|
||||
// Apply new file selection
|
||||
if len(validSelectFiles) == len(files) {
|
||||
f.torrent.DownloadAll()
|
||||
} else {
|
||||
for _, selectIndex := range validSelectFiles {
|
||||
file := files[selectIndex]
|
||||
file.Download()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
f.meta.Opts.SelectFiles = validSelectFiles
|
||||
// Recalculate the resource size based on new selection
|
||||
if f.meta.Res != nil {
|
||||
f.meta.Res.CalcSize(validSelectFiles)
|
||||
}
|
||||
// Reset progress tracking for new file selection
|
||||
f.data.Progress = make(fetcher.Progress, len(validSelectFiles))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) updateRes() {
|
||||
res := &base.Resource{
|
||||
Name: f.torrent.Name(),
|
||||
Range: true,
|
||||
Files: make([]*base.FileInfo, len(f.torrent.Files())),
|
||||
Hash: f.torrent.InfoHash().String(),
|
||||
}
|
||||
// Directory torrent
|
||||
if f.torrent.Info().Length == 0 {
|
||||
res.Name = f.torrent.Name()
|
||||
}
|
||||
for i, file := range f.torrent.Files() {
|
||||
res.Files[i] = &base.FileInfo{
|
||||
Name: filepath.Base(file.DisplayPath()),
|
||||
Path: util.Dir(file.Path()),
|
||||
Path: util.Dir(file.DisplayPath()),
|
||||
Size: file.Length(),
|
||||
}
|
||||
}
|
||||
@@ -221,77 +309,309 @@ func (f *Fetcher) updateRes() {
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Fetcher) addTorrent(req *base.Request) (err error) {
|
||||
func (f *Fetcher) Upload() (err error) {
|
||||
return f.addTorrent(f.meta.Req, true)
|
||||
}
|
||||
|
||||
func (f *Fetcher) doUpload(fromUpload bool) {
|
||||
if !f.torrentUpload.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check and update seed data
|
||||
lastData := &fetcherData{
|
||||
SeedBytes: f.data.SeedBytes,
|
||||
SeedTime: f.data.SeedTime,
|
||||
}
|
||||
var doneTime int64 = 0
|
||||
for {
|
||||
select {
|
||||
case <-f.torrentDropCtx.Done():
|
||||
return
|
||||
case <-time.After(time.Second):
|
||||
if !f.torrentReady.Load() {
|
||||
continue
|
||||
}
|
||||
|
||||
stats := f.torrentStats()
|
||||
f.data.SeedBytes = lastData.SeedBytes + stats.BytesWrittenData.Int64()
|
||||
|
||||
// Check is download complete, if not don't check and stop seeding
|
||||
if !fromUpload && !f.isDone() {
|
||||
continue
|
||||
}
|
||||
if doneTime == 0 {
|
||||
doneTime = time.Now().Unix()
|
||||
}
|
||||
f.data.SeedTime = lastData.SeedTime + time.Now().Unix() - doneTime
|
||||
|
||||
// If the seed forever is true, keep seeding
|
||||
if f.config.SeedKeep {
|
||||
continue
|
||||
}
|
||||
|
||||
// If the seed ratio is reached, stop seeding
|
||||
if f.config.SeedRatio > 0 {
|
||||
seedRadio := f.seedRadio()
|
||||
if seedRadio >= f.config.SeedRatio {
|
||||
f.Close()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If the seed time is reached, stop seeding
|
||||
if f.config.SeedTime > 0 {
|
||||
if f.data.SeedTime >= f.config.SeedTime {
|
||||
f.Close()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get torrent stats maybe panic, see https://github.com/anacrolix/torrent/issues/972
|
||||
func (f *Fetcher) torrentStats() torrent.TorrentStats {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// ignore panic
|
||||
}
|
||||
}()
|
||||
|
||||
return f.torrent.Stats()
|
||||
}
|
||||
|
||||
func (f *Fetcher) UploadedBytes() int64 {
|
||||
return f.data.SeedBytes
|
||||
}
|
||||
|
||||
func (f *Fetcher) WaitUpload() (err error) {
|
||||
<-f.uploadDoneCh
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) addTorrent(req *base.Request, fromUpload bool) (err error) {
|
||||
if err = base.ParseReqExtra[bt.ReqExtra](req); err != nil {
|
||||
return
|
||||
}
|
||||
if err = f.initClient(); err != nil {
|
||||
return
|
||||
}
|
||||
schema := util.ParseSchema(req.URL)
|
||||
privateTorrent := false
|
||||
var spec *torrent.TorrentSpec
|
||||
if schema == "MAGNET" {
|
||||
f.torrent, err = client.AddMagnet(req.URL)
|
||||
} else if schema == "APPLICATION/X-BITTORRENT" {
|
||||
_, data := util.ParseDataUri(req.URL)
|
||||
buf := bytes.NewBuffer(data)
|
||||
var metaInfo *metainfo.MetaInfo
|
||||
metaInfo, err = metainfo.Load(buf)
|
||||
spec, err = torrent.TorrentSpecFromMagnetUri(req.URL)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
var reader io.Reader
|
||||
if schema == "FILE" {
|
||||
fileUrl, _ := url.Parse(req.URL)
|
||||
filePath := fileUrl.Path[1:]
|
||||
reader, err = os.Open(filePath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
} else if schema == "DATA" {
|
||||
_, data := util.ParseDataUri(req.URL)
|
||||
reader = bytes.NewBuffer(data)
|
||||
} else {
|
||||
reader, err = os.Open(req.URL)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer reader.(io.Closer).Close()
|
||||
}
|
||||
|
||||
var metaInfo *metainfo.MetaInfo
|
||||
metaInfo, err = metainfo.Load(reader)
|
||||
// Hotfix for https://github.com/anacrolix/torrent/issues/992, ignore "expected EOF" error
|
||||
// TODO remove this after the issue is fixed
|
||||
if err != nil && !strings.Contains(err.Error(), "expected EOF") {
|
||||
return err
|
||||
}
|
||||
f.torrent, err = client.AddTorrent(metaInfo)
|
||||
} else {
|
||||
f.torrent, err = client.AddTorrentFromFile(req.URL)
|
||||
|
||||
info, er := metaInfo.UnmarshalInfo()
|
||||
if er != nil {
|
||||
return er
|
||||
}
|
||||
|
||||
if info.Private != nil && *info.Private {
|
||||
privateTorrent = true
|
||||
}
|
||||
spec, err = torrent.TorrentSpecFromMetaInfoErr(metaInfo)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
spec.Storage = storage.NewFileOpts(storage.NewFileClientOpts{
|
||||
ClientBaseDir: cfg.DataDir,
|
||||
TorrentDirMaker: func(baseDir string, info *metainfo.Info, infoHash metainfo.Hash) string {
|
||||
return f.meta.Opts.Path
|
||||
},
|
||||
})
|
||||
f.torrent, _, err = client.AddTorrentSpec(spec)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// use map to deduplicate
|
||||
trackers := make(map[string]bool)
|
||||
if req.Extra != nil {
|
||||
extra := req.Extra.(*bt.ReqExtra)
|
||||
if len(extra.Trackers) > 0 {
|
||||
for _, tracker := range extra.Trackers {
|
||||
// Do not add external tracker to a private torrent.
|
||||
if !privateTorrent {
|
||||
// use map to deduplicate
|
||||
trackers := make(map[string]bool)
|
||||
if req.Extra != nil {
|
||||
extra := req.Extra.(*bt.ReqExtra)
|
||||
if len(extra.Trackers) > 0 {
|
||||
for _, tracker := range extra.Trackers {
|
||||
trackers[tracker] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(f.config.Trackers) > 0 {
|
||||
for _, tracker := range f.config.Trackers {
|
||||
trackers[tracker] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(f.config.Trackers) > 0 {
|
||||
for _, tracker := range f.config.Trackers {
|
||||
trackers[tracker] = true
|
||||
if len(trackers) > 0 {
|
||||
announceList := make([][]string, 0)
|
||||
for tracker := range trackers {
|
||||
announceList = append(announceList, []string{tracker})
|
||||
}
|
||||
f.torrent.AddTrackers(announceList)
|
||||
}
|
||||
}
|
||||
if len(trackers) > 0 {
|
||||
announceList := make([][]string, 0)
|
||||
for tracker := range trackers {
|
||||
announceList = append(announceList, []string{tracker})
|
||||
}
|
||||
f.torrent.AddTrackers(announceList)
|
||||
}
|
||||
<-f.torrent.GotInfo()
|
||||
f.torrentReady.Store(true)
|
||||
|
||||
go f.doUpload(fromUpload)
|
||||
return
|
||||
}
|
||||
|
||||
type FetcherBuilder struct {
|
||||
func (f *Fetcher) seedRadio() float64 {
|
||||
var bytesRead int64
|
||||
if f.Meta().Res != nil {
|
||||
bytesRead = f.Meta().Res.Size
|
||||
} else {
|
||||
bytesRead = 0
|
||||
}
|
||||
if bytesRead <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
return float64(f.data.SeedBytes) / float64(bytesRead)
|
||||
}
|
||||
|
||||
var schemes = []string{"FILE", "MAGNET", "APPLICATION/X-BITTORRENT"}
|
||||
|
||||
func (fb *FetcherBuilder) Schemes() []string {
|
||||
return schemes
|
||||
type fetcherData struct {
|
||||
Progress fetcher.Progress
|
||||
SeedBytes int64
|
||||
// SeedTime is the time in seconds to seed after downloading is complete.
|
||||
SeedTime int64
|
||||
}
|
||||
|
||||
func (fb *FetcherBuilder) Build() fetcher.Fetcher {
|
||||
func closeClient() error {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
if closeFunc != nil {
|
||||
closeFunc()
|
||||
}
|
||||
if client != nil {
|
||||
errs := client.Close()
|
||||
if len(errs) > 0 {
|
||||
return errs[0]
|
||||
}
|
||||
client = nil
|
||||
closeCtx = nil
|
||||
closeFunc = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type FetcherManager struct {
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) Name() string {
|
||||
return "bt"
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) Filters() []*fetcher.SchemeFilter {
|
||||
return []*fetcher.SchemeFilter{
|
||||
{
|
||||
Type: fetcher.FilterTypeUrl,
|
||||
Pattern: "MAGNET",
|
||||
},
|
||||
{
|
||||
Type: fetcher.FilterTypeFile,
|
||||
Pattern: "TORRENT",
|
||||
},
|
||||
{
|
||||
Type: fetcher.FilterTypeBase64,
|
||||
Pattern: "APPLICATION/X-BITTORRENT",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) Build() fetcher.Fetcher {
|
||||
return &Fetcher{}
|
||||
}
|
||||
|
||||
func (fb *FetcherBuilder) Store(f fetcher.Fetcher) (data any, err error) {
|
||||
return nil, nil
|
||||
func (fm *FetcherManager) ParseName(u string) string {
|
||||
var name string
|
||||
url, err := url.Parse(u)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
params := url.Query()
|
||||
if params.Get("dn") != "" {
|
||||
return params.Get("dn")
|
||||
}
|
||||
if params.Get("xt") != "" {
|
||||
xt := strings.Split(params.Get("xt"), ":")
|
||||
return xt[len(xt)-1]
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func (fb *FetcherBuilder) Restore() (v any, f func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher) {
|
||||
return nil, func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher {
|
||||
func (fm *FetcherManager) AutoRename() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) DefaultConfig() any {
|
||||
return &config{
|
||||
ListenPort: 0,
|
||||
Trackers: []string{},
|
||||
SeedKeep: false,
|
||||
SeedRatio: 1.0,
|
||||
SeedTime: 120 * 60,
|
||||
}
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) Store(f fetcher.Fetcher) (data any, err error) {
|
||||
_f := f.(*Fetcher)
|
||||
return _f.data, nil
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) Restore() (v any, f func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher) {
|
||||
return &fetcherData{}, func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher {
|
||||
return &Fetcher{
|
||||
meta: meta,
|
||||
data: v.(*fetcherData),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) Close() error {
|
||||
return closeClient()
|
||||
}
|
||||
|
||||
// parse version to bep20 format, fixed length 4, if not enough, fill 0
|
||||
func parseBep20() string {
|
||||
s := strings.ReplaceAll(base.Version, ".", "")
|
||||
if len(s) < 4 {
|
||||
s += strings.Repeat("0", 4-len(s))
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -3,16 +3,18 @@ package bt
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
gohttp "net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/controller"
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/internal/test"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/protocol/bt"
|
||||
"github.com/GopeedLab/gopeed/pkg/util"
|
||||
"net/url"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFetcher_Resolve_Torrent(t *testing.T) {
|
||||
@@ -29,13 +31,12 @@ func TestFetcher_Resolve_DataUri_Torrent(t *testing.T) {
|
||||
dataUri := "data:application/x-bittorrent;base64," + base64.StdEncoding.EncodeToString(buf)
|
||||
err = fetcher.Resolve(&base.Request{
|
||||
URL: dataUri,
|
||||
})
|
||||
}, nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
want := &base.Resource{
|
||||
Name: "ubuntu-22.04-live-server-amd64.iso",
|
||||
Size: 1466714112,
|
||||
Range: true,
|
||||
Files: []*base.FileInfo{
|
||||
@@ -60,61 +61,308 @@ func TestFetcher_ResolveWithProxy(t *testing.T) {
|
||||
proxyListener := test.StartSocks5Server(usr, pwd)
|
||||
defer proxyListener.Close()
|
||||
|
||||
doResolve(t, buildConfigFetcher(util.BuildProxyUrl("socks5", proxyListener.Addr().String(), usr, pwd)))
|
||||
doResolve(t, buildConfigFetcher(&base.DownloaderProxyConfig{
|
||||
Enable: true,
|
||||
System: false,
|
||||
Scheme: "socks5",
|
||||
Host: proxyListener.Addr().String(),
|
||||
Usr: usr,
|
||||
Pwd: pwd,
|
||||
}))
|
||||
}
|
||||
|
||||
func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "./testdata/ubuntu-22.04-live-server-amd64.iso.torrent",
|
||||
Extra: bt.ReqExtra{
|
||||
Trackers: []string{
|
||||
"udp://tracker.birkenwald.de:6969/announce",
|
||||
"udp://tracker.bitsearch.to:1337/announce",
|
||||
t.Run("Resolve Single File", func(t *testing.T) {
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "./testdata/ubuntu-22.04-live-server-amd64.iso.torrent",
|
||||
Extra: bt.ReqExtra{
|
||||
Trackers: []string{
|
||||
"udp://tracker.birkenwald.de:6969/announce",
|
||||
"udp://tracker.bitsearch.to:1337/announce",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}, nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
want := &base.Resource{
|
||||
Name: "ubuntu-22.04-live-server-amd64.iso",
|
||||
Size: 1466714112,
|
||||
Range: true,
|
||||
Files: []*base.FileInfo{
|
||||
{
|
||||
Name: "ubuntu-22.04-live-server-amd64.iso",
|
||||
Size: 1466714112,
|
||||
want := &base.Resource{
|
||||
Size: 1466714112,
|
||||
Range: true,
|
||||
Files: []*base.FileInfo{
|
||||
{
|
||||
Name: "ubuntu-22.04-live-server-amd64.iso",
|
||||
Size: 1466714112,
|
||||
},
|
||||
},
|
||||
},
|
||||
Hash: "8a55cfbd5ca5d11507364765936c4f9e55b253ed",
|
||||
Hash: "8a55cfbd5ca5d11507364765936c4f9e55b253ed",
|
||||
}
|
||||
if !reflect.DeepEqual(want, fetcher.Meta().Res) {
|
||||
t.Errorf("Resolve Single File Resolve() got = %v, want %v", fetcher.Meta().Res, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Resolve Multi Files", func(t *testing.T) {
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "./testdata/test.torrent",
|
||||
Extra: bt.ReqExtra{
|
||||
Trackers: []string{
|
||||
"udp://tracker.birkenwald.de:6969/announce",
|
||||
"udp://tracker.bitsearch.to:1337/announce",
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
want := &base.Resource{
|
||||
Name: "test",
|
||||
Size: 107484864,
|
||||
Range: true,
|
||||
Files: []*base.FileInfo{
|
||||
{
|
||||
Name: "c.txt",
|
||||
Path: "path",
|
||||
Size: 98501754,
|
||||
},
|
||||
{
|
||||
Name: "b.txt",
|
||||
Size: 8904996,
|
||||
},
|
||||
{
|
||||
Name: "a.txt",
|
||||
Size: 78114,
|
||||
},
|
||||
},
|
||||
Hash: "ccbc92b0cd8deec16a2ef4be242a8c9243b1cedb",
|
||||
}
|
||||
if !reflect.DeepEqual(want, fetcher.Meta().Res) {
|
||||
t.Errorf("Resolve Multi Files Resolve() got = %v, want %v", fetcher.Meta().Res, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Resolve Unclean Torrent", func(t *testing.T) {
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "./testdata/test.unclean.torrent",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Errorf("Resolve Unclean Torrent Resolve() got = %v, want nil", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Resolve file scheme Torrent", func(t *testing.T) {
|
||||
file, _ := filepath.Abs("./testdata/test.unclean.torrent")
|
||||
uri := "file:///" + file
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: uri,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Errorf("Resolve file scheme Torrent Resolve() got = %v, want nil", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFetcherManager_ParseName(t *testing.T) {
|
||||
type args struct {
|
||||
u string
|
||||
}
|
||||
if !reflect.DeepEqual(want, fetcher.Meta().Res) {
|
||||
t.Errorf("Resolve() got = %v, want %v", fetcher.Meta().Res, want)
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "broken url",
|
||||
args: args{
|
||||
u: "magnet://!@#%github.com",
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "dn",
|
||||
args: args{
|
||||
u: "magnet:?xt=urn:btih:8a55cfbd5ca5d11507364765936c4f9e55b253ed&dn=ubuntu-22.04-live-server-amd64.iso",
|
||||
},
|
||||
want: "ubuntu-22.04-live-server-amd64.iso",
|
||||
},
|
||||
{
|
||||
name: "no dn",
|
||||
args: args{
|
||||
u: "magnet:?xt=urn:btih:8a55cfbd5ca5d11507364765936c4f9e55b253ed",
|
||||
},
|
||||
want: "8a55cfbd5ca5d11507364765936c4f9e55b253ed",
|
||||
},
|
||||
{
|
||||
name: "non standard magnet",
|
||||
args: args{
|
||||
u: "magnet:?xxt=abcd",
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fm := &FetcherManager{}
|
||||
if got := fm.ParseName(tt.args.u); got != tt.want {
|
||||
t.Errorf("ParseName() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func buildFetcher() fetcher.Fetcher {
|
||||
fetcher := new(FetcherBuilder).Build()
|
||||
fetcher.Setup(controller.NewController())
|
||||
fb := new(FetcherManager)
|
||||
fetcher := fb.Build()
|
||||
newController := controller.NewController()
|
||||
newController.GetConfig = func(v any) {
|
||||
json.Unmarshal([]byte(test.ToJson(fb.DefaultConfig())), v)
|
||||
}
|
||||
fetcher.Setup(newController)
|
||||
return fetcher
|
||||
}
|
||||
|
||||
func buildConfigFetcher(proxyUrl *url.URL) fetcher.Fetcher {
|
||||
fetcher := new(FetcherBuilder).Build()
|
||||
func buildConfigFetcher(proxyConfig *base.DownloaderProxyConfig) fetcher.Fetcher {
|
||||
fetcher := new(FetcherManager).Build()
|
||||
newController := controller.NewController()
|
||||
mockCfg := config{
|
||||
Trackers: []string{
|
||||
"udp://tracker.birkenwald.de:6969/announce",
|
||||
"udp://tracker.bitsearch.to:1337/announce",
|
||||
}}
|
||||
newController.GetConfig = func(v any) bool {
|
||||
if err := json.Unmarshal([]byte(test.ToJson(mockCfg)), v); err != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
newController.GetConfig = func(v any) {
|
||||
json.Unmarshal([]byte(test.ToJson(mockCfg)), v)
|
||||
}
|
||||
newController.GetProxy = func(requestProxy *base.RequestProxy) func(*gohttp.Request) (*url.URL, error) {
|
||||
return proxyConfig.ToHandler()
|
||||
}
|
||||
newController.ProxyUrl = proxyUrl
|
||||
fetcher.Setup(newController)
|
||||
return fetcher
|
||||
}
|
||||
|
||||
// TestFetcher_Patch tests the Patch functionality for BT fetcher.
|
||||
// It tests modifying selected files after Resolve (without downloading).
|
||||
func TestFetcher_Patch(t *testing.T) {
|
||||
f := buildFetcher()
|
||||
|
||||
// Resolve a multi-file torrent
|
||||
err := f.Resolve(&base.Request{
|
||||
URL: "./testdata/test.torrent",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify initial state: 3 files, all selected by default
|
||||
meta := f.Meta()
|
||||
if len(meta.Res.Files) != 3 {
|
||||
t.Fatalf("Expected 3 files, got %d", len(meta.Res.Files))
|
||||
}
|
||||
if len(meta.Opts.SelectFiles) != 3 {
|
||||
t.Fatalf("Expected 3 selected files, got %d", len(meta.Opts.SelectFiles))
|
||||
}
|
||||
|
||||
// Total size of all files: 107484864
|
||||
totalSize := int64(107484864)
|
||||
if meta.Res.Size != totalSize {
|
||||
t.Fatalf("Expected total size %d, got %d", totalSize, meta.Res.Size)
|
||||
}
|
||||
|
||||
t.Run("Patch with valid indices", func(t *testing.T) {
|
||||
// Select only file 0 and 2 (c.txt and a.txt)
|
||||
err := f.Patch(nil, &base.Options{
|
||||
SelectFiles: []int{0, 2},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Patch failed: %v", err)
|
||||
}
|
||||
|
||||
meta := f.Meta()
|
||||
if !reflect.DeepEqual(meta.Opts.SelectFiles, []int{0, 2}) {
|
||||
t.Errorf("Expected SelectFiles [0, 2], got %v", meta.Opts.SelectFiles)
|
||||
}
|
||||
|
||||
// Size should be recalculated: c.txt (98501754) + a.txt (78114) = 98579868
|
||||
expectedSize := int64(98501754 + 78114)
|
||||
if meta.Res.Size != expectedSize {
|
||||
t.Errorf("Expected size %d, got %d", expectedSize, meta.Res.Size)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Patch with invalid indices are silently ignored", func(t *testing.T) {
|
||||
// Mix of valid (0, 1) and invalid (-1, 5, 100) indices
|
||||
err := f.Patch(nil, &base.Options{
|
||||
SelectFiles: []int{-1, 0, 5, 1, 100},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Patch should not return error for invalid indices: %v", err)
|
||||
}
|
||||
|
||||
meta := f.Meta()
|
||||
// Only valid indices 0 and 1 should remain
|
||||
if !reflect.DeepEqual(meta.Opts.SelectFiles, []int{0, 1}) {
|
||||
t.Errorf("Expected SelectFiles [0, 1], got %v", meta.Opts.SelectFiles)
|
||||
}
|
||||
|
||||
// Size should be: c.txt (98501754) + b.txt (8904996) = 107406750
|
||||
expectedSize := int64(98501754 + 8904996)
|
||||
if meta.Res.Size != expectedSize {
|
||||
t.Errorf("Expected size %d, got %d", expectedSize, meta.Res.Size)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Patch with all invalid indices results in empty selection", func(t *testing.T) {
|
||||
err := f.Patch(nil, &base.Options{
|
||||
SelectFiles: []int{-5, 10, 999},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Patch should not return error: %v", err)
|
||||
}
|
||||
|
||||
meta := f.Meta()
|
||||
if len(meta.Opts.SelectFiles) != 0 {
|
||||
t.Errorf("Expected empty SelectFiles, got %v", meta.Opts.SelectFiles)
|
||||
}
|
||||
|
||||
// Note: CalcSize with empty selectFiles calculates total size of all files
|
||||
// This is by design - empty selection in CalcSize means "all files"
|
||||
// But SelectFiles being empty means no files are selected for download
|
||||
if meta.Res.Size != totalSize {
|
||||
t.Errorf("Expected size %d (CalcSize with empty slice = all files), got %d", totalSize, meta.Res.Size)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Patch with nil opts does nothing", func(t *testing.T) {
|
||||
// First set a known state
|
||||
f.Patch(nil, &base.Options{SelectFiles: []int{1}})
|
||||
prevSelectFiles := f.Meta().Opts.SelectFiles
|
||||
|
||||
// Patch with nil opts
|
||||
err := f.Patch(nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Patch with nil opts should not fail: %v", err)
|
||||
}
|
||||
|
||||
// Should remain unchanged
|
||||
if !reflect.DeepEqual(f.Meta().Opts.SelectFiles, prevSelectFiles) {
|
||||
t.Errorf("SelectFiles should remain unchanged after nil opts Patch")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Patch progress array is resized", func(t *testing.T) {
|
||||
btFetcher := f.(*Fetcher)
|
||||
// Initialize progress array
|
||||
btFetcher.data.Progress = make(fetcher.Progress, 3)
|
||||
|
||||
err := f.Patch(nil, &base.Options{
|
||||
SelectFiles: []int{0, 2},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Patch failed: %v", err)
|
||||
}
|
||||
|
||||
if len(btFetcher.data.Progress) != 2 {
|
||||
t.Errorf("Expected Progress length 2, got %d", len(btFetcher.data.Progress))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
// modify from github.com/anacrolix/torrent/storage/file.go
|
||||
|
||||
package bt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/anacrolix/torrent/storage"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/anacrolix/missinggo/v2"
|
||||
|
||||
"github.com/anacrolix/torrent/common"
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
"github.com/anacrolix/torrent/segments"
|
||||
)
|
||||
|
||||
// File-based storage for torrents, that isn't yet bound to a particular torrent.
|
||||
type fileClientImpl struct {
|
||||
opts newFileClientOpts
|
||||
}
|
||||
|
||||
type newFileClientOpts struct {
|
||||
// The base directory for all downloads.
|
||||
ClientBaseDir string
|
||||
HandleFileTorrent func(infoHash metainfo.Hash, ft *fileTorrentImpl)
|
||||
FilePathMaker storage.FilePathMaker
|
||||
PieceCompletion storage.PieceCompletion
|
||||
}
|
||||
|
||||
func newFileOpts(opts newFileClientOpts) storage.ClientImplCloser {
|
||||
if opts.FilePathMaker == nil {
|
||||
opts.FilePathMaker = func(opts storage.FilePathMakerOpts) string {
|
||||
var parts []string
|
||||
if opts.Info.Name != metainfo.NoName {
|
||||
parts = append(parts, opts.Info.Name)
|
||||
}
|
||||
return filepath.Join(append(parts, opts.File.Path...)...)
|
||||
}
|
||||
}
|
||||
if opts.PieceCompletion == nil {
|
||||
ret, err := storage.NewDefaultPieceCompletionForDir(opts.ClientBaseDir)
|
||||
if err != nil {
|
||||
ret = storage.NewMapPieceCompletion()
|
||||
}
|
||||
opts.PieceCompletion = ret
|
||||
}
|
||||
return fileClientImpl{opts}
|
||||
}
|
||||
|
||||
func (fs fileClientImpl) Close() error {
|
||||
return fs.opts.PieceCompletion.Close()
|
||||
}
|
||||
|
||||
func (fs fileClientImpl) OpenTorrent(info *metainfo.Info, infoHash metainfo.Hash) (_ storage.TorrentImpl, err error) {
|
||||
upvertedFiles := info.UpvertedFiles()
|
||||
files := make([]file, 0, len(upvertedFiles))
|
||||
for _, fileInfo := range upvertedFiles {
|
||||
filePath := filepath.Join("", fs.opts.FilePathMaker(storage.FilePathMakerOpts{
|
||||
Info: info,
|
||||
File: &fileInfo,
|
||||
}))
|
||||
f := file{
|
||||
rawPath: filePath,
|
||||
path: filePath,
|
||||
length: fileInfo.Length,
|
||||
}
|
||||
if f.length == 0 {
|
||||
err = CreateNativeZeroLengthFile(f.path)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("creating zero length file: %w", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
files = append(files, f)
|
||||
}
|
||||
t := &fileTorrentImpl{
|
||||
files,
|
||||
segments.NewIndex(common.LengthIterFromUpvertedFiles(upvertedFiles)),
|
||||
infoHash,
|
||||
fs.opts.PieceCompletion,
|
||||
}
|
||||
fs.opts.HandleFileTorrent(infoHash, t)
|
||||
return storage.TorrentImpl{
|
||||
Piece: t.Piece,
|
||||
Close: t.Close,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type file struct {
|
||||
// The safe, OS-local file path.
|
||||
rawPath string
|
||||
path string
|
||||
length int64
|
||||
}
|
||||
|
||||
type fileTorrentImpl struct {
|
||||
files []file
|
||||
segmentLocater segments.Index
|
||||
infoHash metainfo.Hash
|
||||
completion storage.PieceCompletion
|
||||
}
|
||||
|
||||
func (fts *fileTorrentImpl) Piece(p metainfo.Piece) storage.PieceImpl {
|
||||
// Create a view onto the file-based torrent storage.
|
||||
_io := fileTorrentImplIO{fts}
|
||||
// Return the appropriate segments of this.
|
||||
return &filePieceImpl{
|
||||
fts,
|
||||
p,
|
||||
missinggo.NewSectionWriter(_io, p.Offset(), p.Length()),
|
||||
io.NewSectionReader(_io, p.Offset(), p.Length()),
|
||||
}
|
||||
}
|
||||
|
||||
func (fts *fileTorrentImpl) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fts *fileTorrentImpl) setTorrentDir(dir string) {
|
||||
for i, f := range fts.files {
|
||||
fts.files[i].path = filepath.Join(dir, f.rawPath)
|
||||
}
|
||||
}
|
||||
|
||||
// A helper to create zero-length files which won't appear for file-orientated storage since no
|
||||
// writes will ever occur to them (no torrent data is associated with a zero-length file). The
|
||||
// caller should make sure the file name provided is safe/sanitized.
|
||||
func CreateNativeZeroLengthFile(name string) error {
|
||||
os.MkdirAll(filepath.Dir(name), 0o777)
|
||||
var f io.Closer
|
||||
f, err := os.Create(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
// Exposes file-based storage of a torrent, as one big ReadWriterAt.
|
||||
type fileTorrentImplIO struct {
|
||||
fts *fileTorrentImpl
|
||||
}
|
||||
|
||||
// Returns EOF on short or missing file.
|
||||
func (fst *fileTorrentImplIO) readFileAt(file file, b []byte, off int64) (n int, err error) {
|
||||
f, err := os.Open(file.path)
|
||||
if os.IsNotExist(err) {
|
||||
// File missing is treated the same as a short file.
|
||||
err = io.EOF
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
// Limit the read to within the expected bounds of this file.
|
||||
if int64(len(b)) > file.length-off {
|
||||
b = b[:file.length-off]
|
||||
}
|
||||
for off < file.length && len(b) != 0 {
|
||||
n1, err1 := f.ReadAt(b, off)
|
||||
b = b[n1:]
|
||||
n += n1
|
||||
off += int64(n1)
|
||||
if n1 == 0 {
|
||||
err = err1
|
||||
break
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Only returns EOF at the end of the torrent. Premature EOF is ErrUnexpectedEOF.
|
||||
func (fst fileTorrentImplIO) ReadAt(b []byte, off int64) (n int, err error) {
|
||||
fst.fts.segmentLocater.Locate(segments.Extent{off, int64(len(b))}, func(i int, e segments.Extent) bool {
|
||||
n1, err1 := fst.readFileAt(fst.fts.files[i], b[:e.Length], e.Start)
|
||||
n += n1
|
||||
b = b[n1:]
|
||||
err = err1
|
||||
return err == nil // && int64(n1) == e.Length
|
||||
})
|
||||
if len(b) != 0 && err == nil {
|
||||
err = io.EOF
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (fst fileTorrentImplIO) WriteAt(p []byte, off int64) (n int, err error) {
|
||||
// log.Printf("write at %v: %v bytes", off, len(p))
|
||||
fst.fts.segmentLocater.Locate(segments.Extent{off, int64(len(p))}, func(i int, e segments.Extent) bool {
|
||||
name := fst.fts.files[i].path
|
||||
os.MkdirAll(filepath.Dir(name), 0o777)
|
||||
var f *os.File
|
||||
f, err = os.OpenFile(name, os.O_WRONLY|os.O_CREATE, 0o666)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
var n1 int
|
||||
n1, err = f.WriteAt(p[:e.Length], e.Start)
|
||||
// log.Printf("%v %v wrote %v: %v", i, e, n1, err)
|
||||
closeErr := f.Close()
|
||||
n += n1
|
||||
p = p[n1:]
|
||||
if err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err == nil && int64(n1) != e.Length {
|
||||
err = io.ErrShortWrite
|
||||
}
|
||||
return err == nil
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
// modify from github.com/anacrolix/torrent/storage/file_misc.go
|
||||
|
||||
package bt
|
||||
|
||||
import "github.com/anacrolix/torrent/metainfo"
|
||||
|
||||
type requiredLength struct {
|
||||
fileIndex int
|
||||
length int64
|
||||
}
|
||||
|
||||
func extentCompleteRequiredLengths(info *metainfo.Info, off, n int64) (ret []requiredLength) {
|
||||
if n == 0 {
|
||||
return
|
||||
}
|
||||
for i, fi := range info.UpvertedFiles() {
|
||||
if off >= fi.Length {
|
||||
off -= fi.Length
|
||||
continue
|
||||
}
|
||||
n1 := n
|
||||
if off+n1 > fi.Length {
|
||||
n1 = fi.Length - off
|
||||
}
|
||||
ret = append(ret, requiredLength{
|
||||
fileIndex: i,
|
||||
length: off + n1,
|
||||
})
|
||||
n -= n1
|
||||
if n == 0 {
|
||||
return
|
||||
}
|
||||
off = 0
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
// modify from github.com/anacrolix/torrent/storage/file_piece.go
|
||||
|
||||
package bt
|
||||
|
||||
import (
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
"github.com/anacrolix/torrent/storage"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
type filePieceImpl struct {
|
||||
*fileTorrentImpl
|
||||
p metainfo.Piece
|
||||
io.WriterAt
|
||||
io.ReaderAt
|
||||
}
|
||||
|
||||
var _ storage.PieceImpl = (*filePieceImpl)(nil)
|
||||
|
||||
func (fs *filePieceImpl) pieceKey() metainfo.PieceKey {
|
||||
return metainfo.PieceKey{InfoHash: fs.infoHash, Index: fs.p.Index()}
|
||||
}
|
||||
|
||||
func (fs *filePieceImpl) Completion() storage.Completion {
|
||||
c, err := fs.completion.Get(fs.pieceKey())
|
||||
if err != nil {
|
||||
log.Printf("error getting piece completion: %s", err)
|
||||
c.Ok = false
|
||||
return c
|
||||
}
|
||||
|
||||
verified := true
|
||||
if c.Complete {
|
||||
// If it's allegedly complete, check that its constituent files have the necessary length.
|
||||
for _, fi := range extentCompleteRequiredLengths(fs.p.Info, fs.p.Offset(), fs.p.Length()) {
|
||||
s, err := os.Stat(fs.files[fi.fileIndex].path)
|
||||
if err != nil || s.Size() < fi.length {
|
||||
verified = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !verified {
|
||||
// The completion was wrong, fix it.
|
||||
c.Complete = false
|
||||
fs.completion.Set(fs.pieceKey(), false)
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func (fs *filePieceImpl) MarkComplete() error {
|
||||
return fs.completion.Set(fs.pieceKey(), true)
|
||||
}
|
||||
|
||||
func (fs *filePieceImpl) MarkNotComplete() error {
|
||||
return fs.completion.Set(fs.pieceKey(), false)
|
||||
}
|
||||
BIN
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,15 @@
|
||||
package ed2k
|
||||
|
||||
const (
|
||||
defaultServerList = "45.82.80.155:5687,176.123.5.89:4725,85.121.5.137:4232,176.123.2.239:4232,145.239.2.134:4661,91.208.162.87:4232,37.15.61.236:4232"
|
||||
defaultServerMet = "ed2k://|serverlist|http://upd.emule-security.org/server.met|/"
|
||||
defaultNodesDat = "https://upd.emule-security.org/nodes.dat"
|
||||
)
|
||||
|
||||
type config struct {
|
||||
ListenPort int `json:"listenPort"`
|
||||
UDPPort int `json:"udpPort"`
|
||||
ServerAddr string `json:"serverAddr"`
|
||||
ServerMet string `json:"serverMet"`
|
||||
NodesDat string `json:"nodesDat"`
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
package ed2k
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/controller"
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
ped2k "github.com/GopeedLab/gopeed/pkg/protocol/ed2k"
|
||||
"github.com/monkeyWie/goed2k"
|
||||
gprotocol "github.com/monkeyWie/goed2k/protocol"
|
||||
)
|
||||
|
||||
type clientStateStore struct {
|
||||
store fetcher.ProtocolStateStore
|
||||
}
|
||||
|
||||
func (s *clientStateStore) Load() (*goed2k.ClientState, error) {
|
||||
if s == nil || s.store == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var state goed2k.ClientState
|
||||
exist, err := s.store.Load(&state)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exist {
|
||||
return nil, nil
|
||||
}
|
||||
return &state, nil
|
||||
}
|
||||
|
||||
func (s *clientStateStore) Save(state *goed2k.ClientState) error {
|
||||
if s == nil || s.store == nil {
|
||||
return nil
|
||||
}
|
||||
if state == nil {
|
||||
return s.store.Delete()
|
||||
}
|
||||
return s.store.Save(state)
|
||||
}
|
||||
|
||||
type Fetcher struct {
|
||||
ctl *controller.Controller
|
||||
config *config
|
||||
|
||||
manager *FetcherManager
|
||||
meta *fetcher.FetcherMeta
|
||||
handle goed2k.TransferHandle
|
||||
|
||||
waitCtx context.Context
|
||||
waitCancel context.CancelFunc
|
||||
}
|
||||
|
||||
func (f *Fetcher) Setup(ctl *controller.Controller) {
|
||||
f.ctl = ctl
|
||||
if f.meta == nil {
|
||||
f.meta = &fetcher.FetcherMeta{}
|
||||
}
|
||||
f.waitCtx, f.waitCancel = context.WithCancel(context.Background())
|
||||
f.ctl.GetConfig(&f.config)
|
||||
}
|
||||
|
||||
func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
|
||||
link, err := parseLink(req.URL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f.meta.Req = req
|
||||
f.meta.Opts = opts
|
||||
if f.meta.Opts == nil {
|
||||
f.meta.Opts = &base.Options{}
|
||||
}
|
||||
f.meta.Res = buildResource(link)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) Start() error {
|
||||
link, err := parseLink(f.meta.Req.URL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if f.meta.Res == nil {
|
||||
f.meta.Res = buildResource(link)
|
||||
}
|
||||
|
||||
client, err := f.getClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
targetPath := f.meta.SingleFilepath()
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
handle := client.FindTransfer(link.Hash)
|
||||
if handle.IsValid() {
|
||||
f.handle = handle
|
||||
if handle.IsPaused() {
|
||||
if err := client.ResumeTransfer(handle.GetHash()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
atp := goed2k.AddTransferParams{
|
||||
Hash: link.Hash,
|
||||
CreateTime: time.Now().UnixMilli(),
|
||||
Size: link.NumberValue,
|
||||
FilePath: targetPath,
|
||||
}
|
||||
handle, err = client.AddTransfer(atp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f.handle = handle
|
||||
if handle.IsValid() && handle.IsPaused() {
|
||||
if err := client.ResumeTransfer(handle.GetHash()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) Patch(req *base.Request, opts *base.Options) error {
|
||||
handle := f.currentHandle()
|
||||
|
||||
if opts != nil && (opts.Name != "" || opts.Path != "") && handle.IsValid() {
|
||||
return errors.New("cannot change ed2k target path after transfer started")
|
||||
}
|
||||
if req != nil && req.URL != "" && handle.IsValid() {
|
||||
return errors.New("cannot change ed2k link after transfer started")
|
||||
}
|
||||
|
||||
if req != nil {
|
||||
if req.URL != "" {
|
||||
link, err := parseLink(req.URL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f.meta.Req.URL = req.URL
|
||||
f.meta.Res = buildResource(link)
|
||||
}
|
||||
if req.Labels != nil {
|
||||
if f.meta.Req.Labels == nil {
|
||||
f.meta.Req.Labels = make(map[string]string)
|
||||
}
|
||||
for k, v := range req.Labels {
|
||||
f.meta.Req.Labels[k] = v
|
||||
}
|
||||
}
|
||||
if req.Proxy != nil {
|
||||
f.meta.Req.Proxy = req.Proxy
|
||||
}
|
||||
}
|
||||
|
||||
if opts != nil {
|
||||
if opts.Name != "" {
|
||||
f.meta.Opts.Name = opts.Name
|
||||
}
|
||||
if opts.Path != "" {
|
||||
f.meta.Opts.Path = opts.Path
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) Pause() error {
|
||||
handle := f.currentHandle()
|
||||
if !handle.IsValid() {
|
||||
return nil
|
||||
}
|
||||
client, err := f.getClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.PauseTransfer(handle.GetHash()); err != nil {
|
||||
return err
|
||||
}
|
||||
f.handle = handle
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) Close() error {
|
||||
if f.waitCancel != nil {
|
||||
f.waitCancel()
|
||||
}
|
||||
handle := f.currentHandle()
|
||||
if !handle.IsValid() {
|
||||
return nil
|
||||
}
|
||||
client, err := f.getClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f.handle = handle
|
||||
return client.RemoveTransfer(handle.GetHash(), false)
|
||||
}
|
||||
|
||||
func (f *Fetcher) Meta() *fetcher.FetcherMeta {
|
||||
return f.meta
|
||||
}
|
||||
|
||||
func (f *Fetcher) Stats() any {
|
||||
handle := f.currentHandle()
|
||||
if !handle.IsValid() {
|
||||
return &ped2k.Stats{}
|
||||
}
|
||||
status := handle.GetStatus()
|
||||
return &ped2k.Stats{
|
||||
State: string(status.State),
|
||||
ActivePeers: handle.ActiveConnections(),
|
||||
TotalPeers: status.NumPeers,
|
||||
DownloadRate: status.DownloadRate,
|
||||
Upload: status.Upload,
|
||||
UploadRate: status.UploadRate,
|
||||
TotalDone: status.TotalDone,
|
||||
TotalReceived: status.TotalReceived,
|
||||
TotalWanted: status.TotalWanted,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Fetcher) Progress() fetcher.Progress {
|
||||
handle := f.currentHandle()
|
||||
if !handle.IsValid() {
|
||||
return fetcher.Progress{0}
|
||||
}
|
||||
return fetcher.Progress{handle.GetStatus().TotalReceived}
|
||||
}
|
||||
|
||||
func (f *Fetcher) Wait() error {
|
||||
client, err := f.getClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hash, err := f.hash()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
handle := f.currentHandle()
|
||||
if handle.IsValid() && handle.IsFinished() {
|
||||
return nil
|
||||
}
|
||||
|
||||
progressCh, cancel := client.SubscribeTransferProgress()
|
||||
defer cancel()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-f.waitCtx.Done():
|
||||
return nil
|
||||
case event, ok := <-progressCh:
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for _, transfer := range event.Transfers {
|
||||
if transfer.Hash.Compare(hash) != 0 {
|
||||
continue
|
||||
}
|
||||
// Removal can happen during task deletion or client shutdown, both of
|
||||
// which should unblock Wait without treating it as a download failure.
|
||||
if transfer.Removed || transfer.State == goed2k.Finished {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Fetcher) getClient() (*goed2k.Client, error) {
|
||||
if f.manager == nil {
|
||||
f.manager = &FetcherManager{}
|
||||
}
|
||||
return f.manager.initClient(f.config)
|
||||
}
|
||||
|
||||
func (f *Fetcher) currentHandle() goed2k.TransferHandle {
|
||||
if f.handle.IsValid() {
|
||||
return f.handle
|
||||
}
|
||||
if f.manager == nil {
|
||||
return f.handle
|
||||
}
|
||||
client := f.manager.currentClient()
|
||||
if client == nil {
|
||||
return f.handle
|
||||
}
|
||||
hash, err := f.hash()
|
||||
if err != nil {
|
||||
return f.handle
|
||||
}
|
||||
handle := client.FindTransfer(hash)
|
||||
if handle.IsValid() {
|
||||
f.handle = handle
|
||||
}
|
||||
return f.handle
|
||||
}
|
||||
|
||||
func (f *Fetcher) hash() (gprotocol.Hash, error) {
|
||||
if f.meta == nil || f.meta.Req == nil {
|
||||
return gprotocol.Invalid, errors.New("ed2k link is empty")
|
||||
}
|
||||
link, err := parseLink(f.meta.Req.URL)
|
||||
if err != nil {
|
||||
return gprotocol.Invalid, err
|
||||
}
|
||||
return link.Hash, nil
|
||||
}
|
||||
|
||||
type FetcherManager struct {
|
||||
mu sync.Mutex
|
||||
client *goed2k.Client
|
||||
stateStore *clientStateStore
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) SetStateStore(store fetcher.ProtocolStateStore) {
|
||||
fm.mu.Lock()
|
||||
defer fm.mu.Unlock()
|
||||
|
||||
if fm.stateStore == nil {
|
||||
fm.stateStore = &clientStateStore{}
|
||||
}
|
||||
fm.stateStore.store = store
|
||||
if fm.client != nil {
|
||||
fm.client.SetStateStore(fm.stateStore)
|
||||
}
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) Name() string {
|
||||
return "ed2k"
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) Filters() []*fetcher.SchemeFilter {
|
||||
return []*fetcher.SchemeFilter{
|
||||
{
|
||||
Type: fetcher.FilterTypeUrl,
|
||||
Pattern: "ED2K",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) Build() fetcher.Fetcher {
|
||||
return &Fetcher{manager: fm}
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) ParseName(u string) string {
|
||||
link, err := parseLink(u)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return link.StringValue
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) AutoRename() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) DefaultConfig() any {
|
||||
return &config{
|
||||
ListenPort: 0,
|
||||
UDPPort: 0,
|
||||
ServerAddr: defaultServerList,
|
||||
ServerMet: defaultServerMet,
|
||||
NodesDat: defaultNodesDat,
|
||||
}
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) Store(f fetcher.Fetcher) (any, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) Restore() (v any, f func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher) {
|
||||
return nil, func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher {
|
||||
return &Fetcher{
|
||||
manager: fm,
|
||||
meta: meta,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) Close() error {
|
||||
fm.mu.Lock()
|
||||
defer fm.mu.Unlock()
|
||||
|
||||
if fm.client != nil {
|
||||
fm.client.Close()
|
||||
fm.client = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseLink(raw string) (goed2k.EMuleLink, error) {
|
||||
link, err := goed2k.ParseEMuleLink(raw)
|
||||
if err != nil {
|
||||
return goed2k.EMuleLink{}, err
|
||||
}
|
||||
if link.Type != goed2k.LinkFile {
|
||||
return goed2k.EMuleLink{}, errors.New("unsupported ed2k link type")
|
||||
}
|
||||
return link, nil
|
||||
}
|
||||
|
||||
func buildResource(link goed2k.EMuleLink) *base.Resource {
|
||||
return &base.Resource{
|
||||
Size: link.NumberValue,
|
||||
Range: false,
|
||||
Hash: link.Hash.String(),
|
||||
Files: []*base.FileInfo{
|
||||
{
|
||||
Name: link.StringValue,
|
||||
Size: link.NumberValue,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func splitCommaList(value string) []string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(value, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) getStateStoreLocked() *clientStateStore {
|
||||
if fm.stateStore == nil {
|
||||
fm.stateStore = &clientStateStore{}
|
||||
}
|
||||
return fm.stateStore
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) currentClient() *goed2k.Client {
|
||||
fm.mu.Lock()
|
||||
defer fm.mu.Unlock()
|
||||
return fm.client
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) initClient(cfg *config) (*goed2k.Client, error) {
|
||||
fm.mu.Lock()
|
||||
defer fm.mu.Unlock()
|
||||
|
||||
if fm.client != nil {
|
||||
return fm.client, nil
|
||||
}
|
||||
if cfg == nil {
|
||||
cfg = fm.DefaultConfig().(*config)
|
||||
}
|
||||
|
||||
settings := goed2k.NewSettings()
|
||||
settings.ListenPort = cfg.ListenPort
|
||||
settings.UDPPort = cfg.UDPPort
|
||||
settings.EnableDHT = true
|
||||
settings.EnableUPnP = true
|
||||
settings.ReconnectToServer = true
|
||||
|
||||
client := goed2k.NewClient(settings)
|
||||
client.SetStateStore(fm.getStateStoreLocked())
|
||||
if err := client.LoadState(""); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := client.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fm.client = client
|
||||
// Bootstrap is best-effort: downloads can still proceed later even if
|
||||
// server list or DHT initialization fails during startup.
|
||||
bootstrapClient(client, cfg)
|
||||
return fm.client, nil
|
||||
}
|
||||
|
||||
func bootstrapClient(client *goed2k.Client, cfg *config) {
|
||||
for _, serverAddr := range splitCommaList(cfg.ServerAddr) {
|
||||
go func(serverAddr string) {
|
||||
_ = client.Connect(serverAddr)
|
||||
}(serverAddr)
|
||||
}
|
||||
for _, source := range splitCommaList(cfg.ServerMet) {
|
||||
go func(source string) {
|
||||
_ = client.ConnectServerMet(source)
|
||||
}(source)
|
||||
}
|
||||
if cfg.NodesDat != "" {
|
||||
go func() {
|
||||
_ = client.LoadDHTNodesDat(cfg.NodesDat)
|
||||
}()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package ed2k
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/controller"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
)
|
||||
|
||||
const testLink = "ed2k://|file|cn_windows_10_multi-edition_vl_version_1709_updated_sept_2017_x64_dvd_100090774.iso|4630972416|8867C5E54405FF9452225B66EFEE690A|/"
|
||||
|
||||
func TestFetcher_Resolve(t *testing.T) {
|
||||
f := (&FetcherManager{}).Build()
|
||||
f.Setup(controller.NewController())
|
||||
|
||||
err := f.Resolve(&base.Request{URL: testLink}, &base.Options{Path: t.TempDir()})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
|
||||
meta := f.Meta()
|
||||
if meta.Res == nil {
|
||||
t.Fatal("Resolve() resource is nil")
|
||||
}
|
||||
if got, want := meta.Res.Hash, "8867C5E54405FF9452225B66EFEE690A"; got != want {
|
||||
t.Fatalf("Resolve() hash = %s, want %s", got, want)
|
||||
}
|
||||
if got, want := meta.Res.Size, int64(4630972416); got != want {
|
||||
t.Fatalf("Resolve() size = %d, want %d", got, want)
|
||||
}
|
||||
if got, want := meta.Res.Files[0].Name, "cn_windows_10_multi-edition_vl_version_1709_updated_sept_2017_x64_dvd_100090774.iso"; got != want {
|
||||
t.Fatalf("Resolve() name = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetcherManager_ParseName(t *testing.T) {
|
||||
got := (&FetcherManager{}).ParseName(testLink)
|
||||
want := "cn_windows_10_multi-edition_vl_version_1709_updated_sept_2017_x64_dvd_100090774.iso"
|
||||
if got != want {
|
||||
t.Fatalf("ParseName() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package http
|
||||
|
||||
type config struct {
|
||||
UserAgent string `json:"userAgent"`
|
||||
Connections int `json:"connections"`
|
||||
UserAgent string `json:"userAgent"`
|
||||
Connections int `json:"connections"`
|
||||
UseServerCtime bool `json:"useServerCtime"`
|
||||
}
|
||||
+1608
-330
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,105 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"path"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
fhttp "github.com/GopeedLab/gopeed/pkg/protocol/http"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Fetcher Data (for Store/Restore)
|
||||
// ============================================================================
|
||||
|
||||
type fetcherData struct {
|
||||
Connections []*connection
|
||||
RedirectURL string // Saved redirect URL for resume
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Fetcher Manager
|
||||
// ============================================================================
|
||||
|
||||
type FetcherManager struct {
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) Name() string {
|
||||
return "http"
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) Filters() []*fetcher.SchemeFilter {
|
||||
return []*fetcher.SchemeFilter{
|
||||
{
|
||||
Type: fetcher.FilterTypeUrl,
|
||||
Pattern: "HTTP",
|
||||
},
|
||||
{
|
||||
Type: fetcher.FilterTypeUrl,
|
||||
Pattern: "HTTPS",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) Build() fetcher.Fetcher {
|
||||
return &Fetcher{}
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) ParseName(u string) string {
|
||||
var name string
|
||||
url, err := url.Parse(u)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
name = path.Base(url.Path)
|
||||
if name == "" || name == "/" || name == "." {
|
||||
name = url.Hostname()
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) AutoRename() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) DefaultConfig() any {
|
||||
return &config{
|
||||
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36",
|
||||
Connections: 16,
|
||||
}
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) Store(f fetcher.Fetcher) (data any, err error) {
|
||||
_f := f.(*Fetcher)
|
||||
_f.redirectLock.Lock()
|
||||
redirectURL := _f.redirectURL
|
||||
_f.redirectLock.Unlock()
|
||||
return &fetcherData{
|
||||
Connections: _f.connections,
|
||||
RedirectURL: redirectURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) Restore() (v any, f func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher) {
|
||||
return &fetcherData{}, func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher {
|
||||
fd := v.(*fetcherData)
|
||||
fb := &FetcherManager{}
|
||||
fetcher := fb.Build().(*Fetcher)
|
||||
fetcher.meta = meta
|
||||
base.ParseReqExtra[fhttp.ReqExtra](fetcher.meta.Req)
|
||||
base.ParseOptExtra[fhttp.OptsExtra](fetcher.meta.Opts)
|
||||
if len(fd.Connections) > 0 {
|
||||
fetcher.connections = fd.Connections
|
||||
}
|
||||
// Restore redirect URL for resume
|
||||
if fd.RedirectURL != "" {
|
||||
fetcher.redirectURL = fd.RedirectURL
|
||||
}
|
||||
return fetcher
|
||||
}
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) Close() error {
|
||||
return nil
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,138 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestParseFilenameWithAmpersand tests the fix for filenames containing & character
|
||||
// Issue: filenames with & are HTML-encoded as & and then truncated at the semicolon
|
||||
func TestParseFilenameWithAmpersand(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
disposition string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "quoted filename with &",
|
||||
disposition: `attachment; filename="查询处理&优化.pptx"`,
|
||||
want: "查询处理&优化.pptx",
|
||||
},
|
||||
{
|
||||
name: "unquoted filename with &",
|
||||
disposition: `attachment; filename=test&file.txt`,
|
||||
want: "test&file.txt",
|
||||
},
|
||||
{
|
||||
name: "quoted filename with & and extra params",
|
||||
disposition: `attachment; filename="test&file.txt"; charset=utf-8`,
|
||||
want: "test&file.txt",
|
||||
},
|
||||
{
|
||||
name: "filename with multiple HTML entities",
|
||||
disposition: `attachment; filename="test&<>.txt"`,
|
||||
want: "test&<>.txt",
|
||||
},
|
||||
{
|
||||
name: "normal filename without entities",
|
||||
disposition: `attachment; filename="normal.txt"`,
|
||||
want: "normal.txt",
|
||||
},
|
||||
{
|
||||
name: "filename with actual ampersand (no encoding)",
|
||||
disposition: `attachment; filename="test&file.txt"`,
|
||||
want: "test&file.txt",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := parseFilename(tt.disposition)
|
||||
if got != tt.want {
|
||||
t.Errorf("parseFilename() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindParamValueEnd tests the helper function for finding parameter value boundaries
|
||||
func TestFindParamValueEnd(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
want int
|
||||
}{
|
||||
{
|
||||
name: "quoted value with semicolon inside",
|
||||
value: `"test&file.txt"; charset=utf-8`,
|
||||
want: 19, // Position of semicolon after closing quote
|
||||
},
|
||||
{
|
||||
name: "quoted value without semicolon after",
|
||||
value: `"test.txt"`,
|
||||
want: -1,
|
||||
},
|
||||
{
|
||||
name: "unquoted value with semicolon",
|
||||
value: `test.txt; charset=utf-8`,
|
||||
want: 8,
|
||||
},
|
||||
{
|
||||
name: "unquoted value without semicolon",
|
||||
value: `test.txt`,
|
||||
want: -1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := findParamValueEnd(tt.value)
|
||||
if got != tt.want {
|
||||
t.Errorf("findParamValueEnd() = %d, want %d", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnescapeHTMLEntities tests HTML entity unescaping
|
||||
func TestUnescapeHTMLEntities(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "ampersand",
|
||||
in: "test&file.txt",
|
||||
want: "test&file.txt",
|
||||
},
|
||||
{
|
||||
name: "less than and greater than",
|
||||
in: "<test>.txt",
|
||||
want: "<test>.txt",
|
||||
},
|
||||
{
|
||||
name: "quote",
|
||||
in: "test"file.txt",
|
||||
want: "test\"file.txt",
|
||||
},
|
||||
{
|
||||
name: "multiple entities",
|
||||
in: "a&b<c>d"e",
|
||||
want: "a&b<c>d\"e",
|
||||
},
|
||||
{
|
||||
name: "no entities",
|
||||
in: "normal.txt",
|
||||
want: "normal.txt",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := unescapeHTMLEntities(tt.in)
|
||||
if got != tt.want {
|
||||
t.Errorf("unescapeHTMLEntities() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
fhttp "github.com/GopeedLab/gopeed/pkg/protocol/http"
|
||||
"github.com/GopeedLab/gopeed/pkg/util"
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
)
|
||||
|
||||
type RequestError struct {
|
||||
Code int
|
||||
}
|
||||
|
||||
func NewRequestError(code int) *RequestError {
|
||||
return &RequestError{Code: code}
|
||||
}
|
||||
|
||||
func (re *RequestError) Error() string {
|
||||
return fmt.Sprintf("http request fail, code:%d", re.Code)
|
||||
}
|
||||
|
||||
func isFailureExemptHTTPCode(code int) bool {
|
||||
if code >= 500 && code <= 599 {
|
||||
return true
|
||||
}
|
||||
|
||||
switch code {
|
||||
case 429, 408, 440, 499:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func shouldCountHTTPFailure(err error) bool {
|
||||
var re *RequestError
|
||||
if !errors.As(err, &re) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !isFailureExemptHTTPCode(re.Code)
|
||||
}
|
||||
|
||||
func extractRequestError(err error) *RequestError {
|
||||
var re *RequestError
|
||||
if errors.As(err, &re) {
|
||||
return re
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildRequest creates an HTTP request using the redirect URL if available.
|
||||
func (f *Fetcher) buildRequest(ctx context.Context, req *base.Request) (httpReq *http.Request, err error) {
|
||||
return f.buildRequestWithURL(ctx, req, true)
|
||||
}
|
||||
|
||||
// buildRequestWithOriginalURL creates an HTTP request using the original URL.
|
||||
// This is used for retrying when the redirect URL has expired.
|
||||
func (f *Fetcher) buildRequestWithOriginalURL(ctx context.Context, req *base.Request) (httpReq *http.Request, err error) {
|
||||
return f.buildRequestWithURL(ctx, req, false)
|
||||
}
|
||||
|
||||
// buildRequestWithURL creates an HTTP request.
|
||||
// If useRedirect is true and a redirect URL exists, it will be used; otherwise the original URL is used.
|
||||
func (f *Fetcher) buildRequestWithURL(ctx context.Context, req *base.Request, useRedirect bool) (httpReq *http.Request, err error) {
|
||||
var reqUrl string
|
||||
f.redirectLock.Lock()
|
||||
if useRedirect && f.redirectURL != "" {
|
||||
reqUrl = f.redirectURL
|
||||
} else {
|
||||
reqUrl = req.URL
|
||||
}
|
||||
f.redirectLock.Unlock()
|
||||
|
||||
var (
|
||||
method string
|
||||
body io.Reader
|
||||
)
|
||||
headers := http.Header{}
|
||||
if req.Extra == nil {
|
||||
method = http.MethodGet
|
||||
} else {
|
||||
extra := req.Extra.(*fhttp.ReqExtra)
|
||||
if extra.Method != "" {
|
||||
method = extra.Method
|
||||
} else {
|
||||
method = http.MethodGet
|
||||
}
|
||||
if len(extra.Header) > 0 {
|
||||
for k, v := range extra.Header {
|
||||
headers.Set(k, strings.TrimSpace(v))
|
||||
}
|
||||
}
|
||||
if extra.Body != "" {
|
||||
body = bytes.NewBufferString(extra.Body)
|
||||
}
|
||||
}
|
||||
if _, ok := headers[base.HttpHeaderUserAgent]; !ok {
|
||||
headers.Set(base.HttpHeaderUserAgent, strings.TrimSpace(f.config.UserAgent))
|
||||
}
|
||||
|
||||
if ctx != nil {
|
||||
httpReq, err = http.NewRequestWithContext(ctx, method, reqUrl, body)
|
||||
} else {
|
||||
httpReq, err = http.NewRequest(method, reqUrl, body)
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
httpReq.Header = headers
|
||||
if host := headers.Get(base.HttpHeaderHost); host != "" {
|
||||
httpReq.Host = host
|
||||
}
|
||||
return httpReq, nil
|
||||
}
|
||||
|
||||
// updateRedirectURL updates the redirect URL from the response.
|
||||
// This is called when a request using the original URL succeeds after the redirect URL expired.
|
||||
func (f *Fetcher) updateRedirectURL(url string) {
|
||||
f.redirectLock.Lock()
|
||||
f.redirectURL = url
|
||||
f.redirectLock.Unlock()
|
||||
}
|
||||
|
||||
// hasRedirectURL checks if a redirect URL exists and is different from the original URL.
|
||||
func (f *Fetcher) hasRedirectURL() bool {
|
||||
f.redirectLock.Lock()
|
||||
defer f.redirectLock.Unlock()
|
||||
return f.redirectURL != "" && f.redirectURL != f.meta.Req.URL
|
||||
}
|
||||
|
||||
// isRedirectExpiredError checks if the error indicates that the redirect URL may have expired.
|
||||
// This includes 403 (Forbidden), 401 (Unauthorized), 410 (Gone), and network errors.
|
||||
func isRedirectExpiredError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for specific HTTP error codes that might indicate URL expiration
|
||||
if re := extractRequestError(err); re != nil {
|
||||
switch re.Code {
|
||||
case 401, 403, 404, 410:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// tryFallbackToOriginalURL attempts to make a request using the original URL
|
||||
// when the redirect URL has expired. Returns the response if successful.
|
||||
func (f *Fetcher) tryFallbackToOriginalURL(ctx context.Context, client *http.Client, rangeStart, rangeEnd int64) (*http.Response, error) {
|
||||
httpReq, err := f.buildRequestWithOriginalURL(ctx, f.meta.Req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if f.meta.Res.Range && rangeEnd > 0 {
|
||||
httpReq.Header.Set(base.HttpHeaderRange,
|
||||
fmt.Sprintf(base.HttpHeaderRangeFormat, rangeStart, rangeEnd))
|
||||
}
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != base.HttpCodeOK && resp.StatusCode != base.HttpCodePartialContent {
|
||||
resp.Body.Close()
|
||||
return nil, NewRequestError(resp.StatusCode)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// buildClient creates an HTTP client with the default connection timeout.
|
||||
// Used for resolve phase where we don't have connection time data yet.
|
||||
func (f *Fetcher) buildClient() *http.Client {
|
||||
return f.buildClientWithTimeout(connectTimeout)
|
||||
}
|
||||
|
||||
// buildFastFailClient creates an HTTP client with fast-fail timeout.
|
||||
// Uses max(minFastFailTimeout, maxConnTime) for fast-fail retry during download phase.
|
||||
func (f *Fetcher) buildFastFailClient() *http.Client {
|
||||
maxConn := f.maxConnTime.Load()
|
||||
if maxConn == 0 {
|
||||
// No successful connection yet, use default timeout
|
||||
return f.buildClientWithTimeout(connectTimeout)
|
||||
}
|
||||
|
||||
timeout := maxConn
|
||||
if timeout >= minFastFailTimeout {
|
||||
// If greater than minFastFailTimeout, increase by 50% for safety margin
|
||||
timeout = int64(float64(timeout) * 1.5)
|
||||
} else {
|
||||
timeout = minFastFailTimeout
|
||||
}
|
||||
return f.buildClientWithTimeout(time.Duration(timeout))
|
||||
}
|
||||
|
||||
// buildClientWithTimeout creates an HTTP client with the specified connection timeout.
|
||||
func (f *Fetcher) buildClientWithTimeout(timeout time.Duration) *http.Client {
|
||||
transport := &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: timeout,
|
||||
}).DialContext,
|
||||
Proxy: f.ctl.GetProxy(f.meta.Req.Proxy),
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: f.meta.Req.SkipVerifyCert,
|
||||
},
|
||||
TLSHandshakeTimeout: timeout,
|
||||
}
|
||||
jar, _ := cookiejar.New(nil)
|
||||
return &http.Client{
|
||||
Transport: transport,
|
||||
Jar: jar,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Filename Parsing
|
||||
// ============================================================================
|
||||
|
||||
// parseFilename extracts filename from Content-Disposition header
|
||||
func parseFilename(contentDisposition string) string {
|
||||
// Try RFC 5987 extended notation first (filename*=)
|
||||
if filename := parseFilenameExtended(contentDisposition); filename != "" {
|
||||
return filename
|
||||
}
|
||||
|
||||
// Try standard MIME parsing
|
||||
_, params, err := mime.ParseMediaType(contentDisposition)
|
||||
if err == nil {
|
||||
if filename := params["filename"]; filename != "" {
|
||||
return decodeFilenameParam(filename)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to manual parsing
|
||||
return parseFilenameFallback(contentDisposition)
|
||||
}
|
||||
|
||||
// parseFilenameExtended handles RFC 5987 extended notation (filename*=)
|
||||
// Format: filename*=charset'language'value (e.g., UTF-8”%E6%B5%8B%E8%AF%95.zip)
|
||||
func parseFilenameExtended(cd string) string {
|
||||
lower := strings.ToLower(cd)
|
||||
idx := strings.Index(lower, "filename*=")
|
||||
if idx == -1 {
|
||||
return ""
|
||||
}
|
||||
|
||||
value := cd[idx+len("filename*="):]
|
||||
|
||||
// Find the end of the value using proper quote handling
|
||||
endIdx := findParamValueEnd(value)
|
||||
if endIdx != -1 {
|
||||
value = value[:endIdx]
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
|
||||
// Try charset''encoded format (e.g., UTF-8''%E4%B8%AD%E6%96%87.txt)
|
||||
parts := strings.SplitN(value, "''", 2)
|
||||
if len(parts) == 2 {
|
||||
// Use PathUnescape to handle %2B correctly (should decode to +, not space)
|
||||
decoded, err := url.PathUnescape(parts[1])
|
||||
if err == nil {
|
||||
return decoded
|
||||
}
|
||||
}
|
||||
|
||||
// Try charset'language'encoded format
|
||||
parts = strings.SplitN(value, "'", 3)
|
||||
if len(parts) >= 3 {
|
||||
// Use PathUnescape to handle %2B correctly (should decode to +, not space)
|
||||
decoded, err := url.PathUnescape(parts[2])
|
||||
if err == nil {
|
||||
return decoded
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// decodeFilenameParam decodes filename parameter value
|
||||
// Handles HTML entities, MIME encoded-word, URL encoding, and GBK encoding fallback
|
||||
func decodeFilenameParam(filename string) string {
|
||||
// First, unescape HTML entities (e.g., & -> &, < -> <, > -> >)
|
||||
// This must be done before other decoding to handle cases where servers
|
||||
// HTML-encode special characters in filenames
|
||||
filename = unescapeHTMLEntities(filename)
|
||||
|
||||
// Handle RFC 2047 encoded word (=?charset?encoding?text?=)
|
||||
if strings.HasPrefix(filename, "=?") {
|
||||
decoder := new(mime.WordDecoder)
|
||||
normalizedFilename := strings.Replace(filename, "UTF8", "UTF-8", 1)
|
||||
if decoded, err := decoder.Decode(normalizedFilename); err == nil {
|
||||
return decoded
|
||||
}
|
||||
}
|
||||
|
||||
// Try URL decoding - use PathUnescape for filenames to handle %2B correctly
|
||||
decoded := util.TryUrlPathUnescape(filename)
|
||||
|
||||
// If not valid UTF-8, try GBK decoding (common for Chinese websites)
|
||||
if !utf8.ValidString(decoded) {
|
||||
if gbkDecoded := tryDecodeGBK(decoded); gbkDecoded != "" {
|
||||
return gbkDecoded
|
||||
}
|
||||
}
|
||||
|
||||
return decoded
|
||||
}
|
||||
|
||||
// unescapeHTMLEntities unescapes common HTML entities in filenames
|
||||
// This handles cases where servers HTML-encode special characters like & to &
|
||||
func unescapeHTMLEntities(s string) string {
|
||||
// Common HTML entities that might appear in filenames
|
||||
replacements := map[string]string{
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
""": "\"",
|
||||
"'": "'",
|
||||
"'": "'",
|
||||
}
|
||||
|
||||
result := s
|
||||
for entity, char := range replacements {
|
||||
result = strings.ReplaceAll(result, entity, char)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// tryDecodeGBK attempts to decode string as GBK encoding
|
||||
func tryDecodeGBK(s string) string {
|
||||
if len(s) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
decoder := simplifiedchinese.GBK.NewDecoder()
|
||||
decoded, err := decoder.Bytes([]byte(s))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
result := string(decoded)
|
||||
if utf8.ValidString(result) {
|
||||
return result
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseFilenameFallback is a fallback parser for non-standard Content-Disposition
|
||||
func parseFilenameFallback(cd string) string {
|
||||
lower := strings.ToLower(cd)
|
||||
idx := strings.Index(lower, "filename=")
|
||||
if idx == -1 {
|
||||
return ""
|
||||
}
|
||||
|
||||
value := cd[idx+len("filename="):]
|
||||
|
||||
// Find the end of the value using proper quote handling
|
||||
endIdx := findParamValueEnd(value)
|
||||
if endIdx != -1 {
|
||||
value = value[:endIdx]
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
|
||||
// Remove surrounding quotes
|
||||
if len(value) >= 2 {
|
||||
if (value[0] == '"' && value[len(value)-1] == '"') ||
|
||||
(value[0] == '\'' && value[len(value)-1] == '\'') {
|
||||
value = value[1 : len(value)-1]
|
||||
}
|
||||
}
|
||||
|
||||
return decodeFilenameParam(value)
|
||||
}
|
||||
|
||||
// findParamValueEnd finds the end position of a parameter value in a Content-Disposition header.
|
||||
// It correctly handles quoted values where semicolons inside quotes should not be treated as delimiters.
|
||||
// It also handles HTML entities in unquoted values (e.g., & should not be split at the semicolon).
|
||||
// Returns the end index (exclusive) of the value, or -1 if it extends to the end of the string.
|
||||
func findParamValueEnd(value string) int {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// If the value starts with a quote, find the matching closing quote
|
||||
if value[0] == '"' || value[0] == '\'' {
|
||||
quote := value[0]
|
||||
// Find the closing quote, handling escaped quotes
|
||||
for i := 1; i < len(value); i++ {
|
||||
if value[i] == quote {
|
||||
// Check if it's escaped
|
||||
if i > 0 && value[i-1] == '\\' {
|
||||
continue
|
||||
}
|
||||
// Found closing quote, now look for ; after it
|
||||
remaining := value[i+1:]
|
||||
if semiIdx := strings.Index(remaining, ";"); semiIdx != -1 {
|
||||
return i + 1 + semiIdx
|
||||
}
|
||||
return -1 // No semicolon after closing quote
|
||||
}
|
||||
}
|
||||
// No closing quote found, treat rest of string as value
|
||||
return -1
|
||||
}
|
||||
|
||||
// Unquoted value - find the next semicolon that's not part of an HTML entity
|
||||
// HTML entities have the pattern &...; (e.g., & < > " ')
|
||||
for i := 0; i < len(value); i++ {
|
||||
if value[i] == ';' {
|
||||
// Check if this semicolon is part of an HTML entity
|
||||
// Look backwards for an & character
|
||||
isEntity := false
|
||||
if i > 0 {
|
||||
// Look for & before this semicolon (within reasonable distance, max 10 chars)
|
||||
for j := i - 1; j >= 0 && j >= i-10; j-- {
|
||||
if value[j] == '&' {
|
||||
// Found &, this semicolon might be part of an HTML entity
|
||||
// Check if there are only alphanumeric or # between & and ;
|
||||
entityChars := value[j+1 : i]
|
||||
if len(entityChars) > 0 && isValidHTMLEntityChars(entityChars) {
|
||||
isEntity = true
|
||||
}
|
||||
break
|
||||
}
|
||||
// If we hit whitespace or another special char, stop looking
|
||||
if value[j] == ' ' || value[j] == '"' || value[j] == '\'' {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !isEntity {
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1 // No semicolon, extends to end
|
||||
}
|
||||
|
||||
// isValidHTMLEntityChars checks if a string contains only valid HTML entity characters
|
||||
// (alphanumeric and #, typically for entities like & < ' etc.)
|
||||
func isValidHTMLEntityChars(s string) bool {
|
||||
if len(s) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, c := range s {
|
||||
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '#') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TimeoutReader struct {
|
||||
reader io.Reader
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
func NewTimeoutReader(r io.Reader, timeout time.Duration) *TimeoutReader {
|
||||
return &TimeoutReader{
|
||||
reader: r,
|
||||
timeout: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
func (tr *TimeoutReader) Read(p []byte) (n int, err error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), tr.timeout)
|
||||
defer cancel()
|
||||
|
||||
done := make(chan struct{})
|
||||
var readErr error
|
||||
var bytesRead int
|
||||
|
||||
go func() {
|
||||
bytesRead, readErr = tr.reader.Read(p)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
return bytesRead, readErr
|
||||
case <-ctx.Done():
|
||||
return 0, ctx.Err()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTimeoutReader_Read(t *testing.T) {
|
||||
data := []byte("Hello, World!")
|
||||
reader := bytes.NewReader(data)
|
||||
timeoutReader := NewTimeoutReader(reader, 1*time.Second)
|
||||
|
||||
buf := make([]byte, len(data))
|
||||
n, err := timeoutReader.Read(buf)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if n != len(data) {
|
||||
t.Fatalf("expected to read %d bytes, read %d", len(data), n)
|
||||
}
|
||||
if !bytes.Equal(buf, data) {
|
||||
t.Fatalf("expected %s, got %s", data, buf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeoutReader_ReadTimeout(t *testing.T) {
|
||||
reader := &slowReader{delay: 2 * time.Second}
|
||||
timeoutReader := NewTimeoutReader(reader, 1*time.Second)
|
||||
|
||||
buf := make([]byte, 8192)
|
||||
_, err := timeoutReader.Read(buf)
|
||||
if err == nil {
|
||||
t.Fatal("expected timeout error, got nil")
|
||||
}
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("expected %v, got %v", context.DeadlineExceeded, err)
|
||||
}
|
||||
}
|
||||
|
||||
type slowReader struct {
|
||||
delay time.Duration
|
||||
}
|
||||
|
||||
func (sr *slowReader) Read(p []byte) (n int, err error) {
|
||||
time.Sleep(sr.delay)
|
||||
return 0, io.EOF
|
||||
}
|
||||
+713
-79
@@ -2,18 +2,23 @@ package test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/armon/go-socks5"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/armon/go-socks5"
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -22,14 +27,23 @@ const (
|
||||
Dir = "./"
|
||||
BuildFile = Dir + BuildName
|
||||
|
||||
ExternalDownloadUrl = "https://raw.githubusercontent.com/GopeedLab/gopeed/v1.5.6/_docs/img/banner.png"
|
||||
ExternalDownloadName = "banner.png"
|
||||
ExternalDownloadSize = 26416
|
||||
//ExternalDownloadMd5 = "c67c6e3cae79a95342485676571e8a5c"
|
||||
|
||||
DownloadName = "download.data"
|
||||
DownloadRename = "download (1).data"
|
||||
DownloadFile = Dir + DownloadName
|
||||
DownloadRenameFile = Dir + DownloadRename
|
||||
|
||||
// TestChineseFileName is a common test filename with Chinese characters
|
||||
// Used to test Content-Disposition parsing with various encodings
|
||||
TestChineseFileName = "测试.zip"
|
||||
)
|
||||
|
||||
func StartTestFileServer() net.Listener {
|
||||
return startTestServer(func() http.Handler {
|
||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||
return http.FileServer(http.Dir(Dir))
|
||||
})
|
||||
}
|
||||
@@ -45,7 +59,7 @@ func (s *SlowFileServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func StartTestSlowFileServer(delay time.Duration) net.Listener {
|
||||
return startTestServer(func() http.Handler {
|
||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||
return &SlowFileServer{
|
||||
delay: delay,
|
||||
handler: http.FileServer(http.Dir(Dir)),
|
||||
@@ -54,8 +68,11 @@ func StartTestSlowFileServer(delay time.Duration) net.Listener {
|
||||
}
|
||||
|
||||
func StartTestCustomServer() net.Listener {
|
||||
return startTestServer(func() http.Handler {
|
||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.WriteHeader(200)
|
||||
})
|
||||
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
|
||||
file, err := os.Open(BuildFile)
|
||||
if err != nil {
|
||||
@@ -75,13 +92,168 @@ func StartTestCustomServer() net.Listener {
|
||||
defer file.Close()
|
||||
io.Copy(writer, file)
|
||||
})
|
||||
mux.HandleFunc("/encoded-word", func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.Header().Set("Content-Disposition", "attachment; filename=\"=?UTF8?B?5rWL6K+VLnppcA==?=\"")
|
||||
writer.Header().Set("Content-Type", "application/octet-stream")
|
||||
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
|
||||
file, err := os.Open(BuildFile)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer file.Close()
|
||||
io.Copy(writer, file)
|
||||
})
|
||||
mux.HandleFunc("/%E6%B5%8B%E8%AF%95.zip", func(writer http.ResponseWriter, request *http.Request) {
|
||||
file, err := os.Open(BuildFile)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer file.Close()
|
||||
io.Copy(writer, file)
|
||||
})
|
||||
mux.HandleFunc("/no-encode", func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.Header().Set("Content-Disposition", "attachment; filename="+TestChineseFileName)
|
||||
writer.Header().Set("Content-Type", "application/octet-stream")
|
||||
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
|
||||
file, err := os.Open(BuildFile)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer file.Close()
|
||||
io.Copy(writer, file)
|
||||
})
|
||||
// Test endpoint for mixed encoding: filename= with garbled characters (including special chars)
|
||||
// and filename*= with proper UTF-8. This tests the case where mime.ParseMediaType fails
|
||||
// due to invalid characters like <a> tags in the filename.
|
||||
mux.HandleFunc("/mixed-encoding", func(writer http.ResponseWriter, request *http.Request) {
|
||||
// This simulates a server that sends a garbled filename= with special chars that cause
|
||||
// mime.ParseMediaType to fail, plus a proper filename*=UTF-8''...
|
||||
// The filename*= should be preferred and correctly parsed.
|
||||
writer.Header().Set("Content-Disposition", `attachment;filename="garbled<invalid>chars.zip";filename*=UTF-8''%E6%B5%8B%E8%AF%95.zip`)
|
||||
writer.Header().Set("Content-Type", "application/octet-stream")
|
||||
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
|
||||
file, err := os.Open(BuildFile)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer file.Close()
|
||||
io.Copy(writer, file)
|
||||
})
|
||||
// Test endpoint for filename*= only (RFC 5987 format)
|
||||
mux.HandleFunc("/filename-star", func(writer http.ResponseWriter, request *http.Request) {
|
||||
// URL-encoded TestChineseFileName: 测试.zip -> %E6%B5%8B%E8%AF%95.zip
|
||||
writer.Header().Set("Content-Disposition", `attachment; filename*=UTF-8''%E6%B5%8B%E8%AF%95.zip`)
|
||||
writer.Header().Set("Content-Type", "application/octet-stream")
|
||||
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
|
||||
file, err := os.Open(BuildFile)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer file.Close()
|
||||
io.Copy(writer, file)
|
||||
})
|
||||
// Test endpoint for GBK-encoded filename (common on Chinese Windows servers)
|
||||
// This simulates the case where Chinese characters are sent as GBK bytes
|
||||
// which appear as garbled characters when interpreted as UTF-8.
|
||||
// For example, "测试" in GBK is [B2 E2 CA D4] which is invalid UTF-8.
|
||||
// Our fix detects invalid UTF-8 and attempts GBK decoding.
|
||||
mux.HandleFunc("/gbk-encoded", func(writer http.ResponseWriter, request *http.Request) {
|
||||
// Encode TestChineseFileName as GBK
|
||||
gbkEncoder := simplifiedchinese.GBK.NewEncoder()
|
||||
gbkBytes, _ := gbkEncoder.Bytes([]byte(TestChineseFileName))
|
||||
// Send GBK bytes directly in filename (simulating broken server behavior)
|
||||
writer.Header().Set("Content-Disposition", `attachment; filename="`+string(gbkBytes)+`"`)
|
||||
writer.Header().Set("Content-Type", "application/octet-stream")
|
||||
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
|
||||
file, err := os.Open(BuildFile)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer file.Close()
|
||||
io.Copy(writer, file)
|
||||
})
|
||||
// Test endpoint for filenames with plus signs (C++ files, etc.)
|
||||
// This tests that %2B decodes to + not space
|
||||
mux.HandleFunc("/plus-sign-encoded", func(writer http.ResponseWriter, request *http.Request) {
|
||||
// Use filename*= format with %2B encoding for plus signs
|
||||
writer.Header().Set("Content-Disposition", `attachment; filename*=UTF-8''C%2B%2B%20%20Primer%20%20Plus.mobi`)
|
||||
writer.Header().Set("Content-Type", "application/octet-stream")
|
||||
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
|
||||
file, err := os.Open(BuildFile)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer file.Close()
|
||||
io.Copy(writer, file)
|
||||
})
|
||||
// Test endpoint for plus sign in URL path
|
||||
mux.HandleFunc("/C%2B%2B%20Primer.txt", func(writer http.ResponseWriter, request *http.Request) {
|
||||
file, err := os.Open(BuildFile)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer file.Close()
|
||||
io.Copy(writer, file)
|
||||
})
|
||||
// Test endpoint for filename with HTML-encoded ampersand (&)
|
||||
// This tests the case from the bug report where filenames containing & are
|
||||
// HTML-encoded as & by the server, causing truncation at the semicolon.
|
||||
// Example: "查询处理&优化.pptx" -> "查询处理&优化.pptx"
|
||||
mux.HandleFunc("/ampersand-encoded", func(writer http.ResponseWriter, request *http.Request) {
|
||||
// Simulate server sending filename with HTML-encoded ampersand
|
||||
writer.Header().Set("Content-Disposition", `attachment; filename="查询处理&优化.pptx"`)
|
||||
writer.Header().Set("Content-Type", "application/octet-stream")
|
||||
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
|
||||
file, err := os.Open(BuildFile)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer file.Close()
|
||||
io.Copy(writer, file)
|
||||
})
|
||||
// Test endpoint for unquoted filename with HTML-encoded ampersand
|
||||
// Some servers might send unquoted filenames with HTML entities
|
||||
mux.HandleFunc("/ampersand-unquoted", func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.Header().Set("Content-Disposition", `attachment; filename=test&file.txt`)
|
||||
writer.Header().Set("Content-Type", "application/octet-stream")
|
||||
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
|
||||
file, err := os.Open(BuildFile)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer file.Close()
|
||||
io.Copy(writer, file)
|
||||
})
|
||||
// Test 403 Forbidden endpoint
|
||||
mux.HandleFunc("/forbidden", func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.WriteHeader(403)
|
||||
})
|
||||
return mux
|
||||
})
|
||||
}
|
||||
|
||||
// StartTestHostHeaderServer starts a server that validates the Host header
|
||||
// Returns 400 Bad Request if the Host header value equals "test"
|
||||
func StartTestHostHeaderServer() net.Listener {
|
||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) {
|
||||
// If the Host header is "test", return 400 (simulating server that validates Host)
|
||||
if request.Host == "test" {
|
||||
writer.WriteHeader(400)
|
||||
writer.Write([]byte("Bad Request: Invalid Host header"))
|
||||
return
|
||||
}
|
||||
writer.WriteHeader(200)
|
||||
writer.Write([]byte("OK"))
|
||||
})
|
||||
return mux
|
||||
})
|
||||
}
|
||||
|
||||
func StartTestRetryServer() net.Listener {
|
||||
counter := 0
|
||||
return startTestServer(func() http.Handler {
|
||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
|
||||
counter++
|
||||
@@ -101,7 +273,7 @@ func StartTestRetryServer() net.Listener {
|
||||
}
|
||||
|
||||
func StartTestPostServer() net.Listener {
|
||||
return startTestServer(func() http.Handler {
|
||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.Method == "POST" && request.Header.Get("Authorization") != "" {
|
||||
@@ -124,25 +296,198 @@ func StartTestPostServer() net.Listener {
|
||||
}
|
||||
|
||||
func StartTestErrorServer() net.Listener {
|
||||
counter := 0
|
||||
return startTestServer(func() http.Handler {
|
||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
|
||||
counter++
|
||||
if counter != 1 {
|
||||
writer.WriteHeader(500)
|
||||
// Always return 404 error to test error handling
|
||||
writer.WriteHeader(404)
|
||||
return
|
||||
})
|
||||
return mux
|
||||
})
|
||||
}
|
||||
|
||||
// StartTestOneTimeServer creates a server where the URL can only be downloaded once
|
||||
// The first non-probe request succeeds, all subsequent requests return 404
|
||||
// This simulates one-time download URLs (e.g., signed URLs that expire after first use)
|
||||
func StartTestOneTimeServer() net.Listener {
|
||||
var accessed atomic.Bool
|
||||
|
||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
|
||||
// First full download request succeeds, subsequent requests fail
|
||||
if accessed.Swap(true) {
|
||||
writer.WriteHeader(404)
|
||||
return
|
||||
}
|
||||
|
||||
// First request - return full file without Range support
|
||||
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
|
||||
file, err := os.Open(BuildFile)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
io.Copy(writer, file)
|
||||
})
|
||||
return mux
|
||||
})
|
||||
}
|
||||
|
||||
// StartTestNoRangeSlowServer creates a server that always returns the full file
|
||||
// with Content-Length but does not support Range requests.
|
||||
func StartTestNoRangeSlowServer(delayPerChunk time.Duration) net.Listener {
|
||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
|
||||
|
||||
file, err := os.Open(BuildFile)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
buf := make([]byte, 256*1024)
|
||||
for !sl.isShutdown {
|
||||
n, readErr := file.Read(buf)
|
||||
if n > 0 {
|
||||
if _, writeErr := writer.Write(buf[:n]); writeErr != nil {
|
||||
return
|
||||
}
|
||||
if flusher, ok := writer.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
if delayPerChunk > 0 {
|
||||
time.Sleep(delayPerChunk)
|
||||
}
|
||||
}
|
||||
if readErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
return mux
|
||||
})
|
||||
}
|
||||
|
||||
// StartTestLimitServer connections limit server
|
||||
func StartTestLimitServer(maxConnections int32, delay int64) net.Listener {
|
||||
// StartTestExpiringRedirectServer creates a server that simulates expiring redirect URLs.
|
||||
// The original URL redirects to a temporary URL that expires after a specified number of requests.
|
||||
// When the temporary URL expires (returns 403), the client should retry with the original URL
|
||||
// to get a new redirect URL.
|
||||
// Parameters:
|
||||
// - requestsBeforeExpire: number of requests the temporary URL accepts before expiring
|
||||
// - delayPerByte: optional delay per byte for slow transfer (use 0 for no delay)
|
||||
func StartTestExpiringRedirectServer(requestsBeforeExpire int32, delayPerByte time.Duration) net.Listener {
|
||||
var redirectVersion atomic.Int32
|
||||
var requestCount atomic.Int32
|
||||
redirectVersion.Store(1)
|
||||
|
||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
path := request.URL.Path
|
||||
|
||||
// Original URL - redirects to current version of temporary URL
|
||||
if path == "/"+BuildName {
|
||||
// Reset request count when original URL is accessed
|
||||
requestCount.Store(0)
|
||||
// Redirect to versioned temporary URL
|
||||
version := redirectVersion.Load()
|
||||
redirectURL := fmt.Sprintf("/redirect-v%d/%s", version, BuildName)
|
||||
http.Redirect(writer, request, redirectURL, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Temporary URL handler - matches /redirect-v{N}/... pattern
|
||||
if strings.HasPrefix(path, "/redirect-v") {
|
||||
// Check if the redirect has expired
|
||||
count := requestCount.Add(1)
|
||||
if count > requestsBeforeExpire {
|
||||
// Redirect expired - increment version for next redirect
|
||||
redirectVersion.Add(1)
|
||||
writer.WriteHeader(403)
|
||||
writer.Write([]byte("Redirect URL expired"))
|
||||
return
|
||||
}
|
||||
|
||||
// Serve the file with range support
|
||||
rangeFileHandle(
|
||||
writer,
|
||||
request,
|
||||
nil,
|
||||
func(file *os.File, n int64) {
|
||||
if delayPerByte > 0 {
|
||||
slowCopyNWithDelay(sl, writer, file, n, delayPerByte)
|
||||
} else {
|
||||
io.CopyN(writer, file, n)
|
||||
}
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Not found
|
||||
writer.WriteHeader(404)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// StartTestLowSpeedServer creates a server with configurable delay per request
|
||||
func StartTestLowSpeedServer(delayPerByte time.Duration) net.Listener {
|
||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
|
||||
rangeFileHandle(
|
||||
writer,
|
||||
request,
|
||||
nil,
|
||||
func(file *os.File, n int64) {
|
||||
slowCopyNWithDelay(sl, writer, file, n, delayPerByte)
|
||||
},
|
||||
)
|
||||
})
|
||||
return mux
|
||||
})
|
||||
}
|
||||
|
||||
// slowCopyNWithDelay copies n bytes from src to dst with a delay per byte
|
||||
func slowCopyNWithDelay(sl *shutdownListener, dst io.Writer, src io.Reader, n int64, delayPerByte time.Duration) {
|
||||
buf := make([]byte, 32*1024)
|
||||
remaining := n
|
||||
for remaining > 0 {
|
||||
if sl.isShutdown {
|
||||
return
|
||||
}
|
||||
toRead := int64(len(buf))
|
||||
if toRead > remaining {
|
||||
toRead = remaining
|
||||
}
|
||||
nr, er := src.Read(buf[:toRead])
|
||||
if nr > 0 {
|
||||
nw, ew := dst.Write(buf[0:nr])
|
||||
if nw > 0 {
|
||||
remaining -= int64(nw)
|
||||
// Add delay based on bytes written
|
||||
if delayPerByte > 0 {
|
||||
time.Sleep(delayPerByte * time.Duration(nw))
|
||||
}
|
||||
}
|
||||
if ew != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if er != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StartTestConLimitServer connections limit server
|
||||
func StartTestConLimitServer(maxConnections int32) net.Listener {
|
||||
var connections atomic.Int32
|
||||
|
||||
return startTestServer(func() http.Handler {
|
||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
|
||||
defer func() {
|
||||
@@ -153,68 +498,254 @@ func StartTestLimitServer(maxConnections int32, delay int64) net.Listener {
|
||||
writer.WriteHeader(403)
|
||||
return
|
||||
}
|
||||
|
||||
r := request.Header.Get("Range")
|
||||
if r == "" {
|
||||
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
|
||||
writer.WriteHeader(200)
|
||||
file, err := os.Open(BuildFile)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer file.Close()
|
||||
slowCopy(writer, file, delay)
|
||||
} else {
|
||||
// split range
|
||||
s := strings.Split(r, "=")
|
||||
if len(s) != 2 {
|
||||
writer.WriteHeader(400)
|
||||
return
|
||||
}
|
||||
s = strings.Split(s[1], "-")
|
||||
if len(s) != 2 {
|
||||
writer.WriteHeader(400)
|
||||
return
|
||||
}
|
||||
start, err := strconv.ParseInt(s[0], 10, 64)
|
||||
if err != nil {
|
||||
writer.WriteHeader(400)
|
||||
return
|
||||
}
|
||||
end, err := strconv.ParseInt(s[1], 10, 64)
|
||||
if err != nil {
|
||||
writer.WriteHeader(400)
|
||||
return
|
||||
}
|
||||
if start < 0 || end < 0 || start > end {
|
||||
writer.WriteHeader(400)
|
||||
return
|
||||
}
|
||||
if end >= BuildSize {
|
||||
end = BuildSize - 1
|
||||
}
|
||||
writer.Header().Set("Content-Length", fmt.Sprintf("%d", end-start+1))
|
||||
writer.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, BuildSize))
|
||||
writer.Header().Set("Accept-Ranges", "bytes")
|
||||
writer.WriteHeader(206)
|
||||
file, err := os.Open(BuildFile)
|
||||
if err != nil {
|
||||
writer.WriteHeader(500)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
file.Seek(start, 0)
|
||||
slowCopyN(writer, file, end-start+1, delay)
|
||||
}
|
||||
rangeFileHandle(
|
||||
writer,
|
||||
request,
|
||||
nil,
|
||||
func(file *os.File, n int64) {
|
||||
slowCopyN(sl, writer, file, n, 0)
|
||||
},
|
||||
)
|
||||
})
|
||||
return mux
|
||||
})
|
||||
}
|
||||
|
||||
// StartTestTimeoutOnceServer creates a server that times out on first request, then works normally
|
||||
func StartTestTimeoutOnceServer(delay int64) net.Listener {
|
||||
var timeoutOnce atomic.Bool
|
||||
|
||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
|
||||
// First request delays to trigger timeout, subsequent requests respond normally
|
||||
useInitialDelay := delay > 0 && !timeoutOnce.Swap(true)
|
||||
rangeFileHandle(
|
||||
writer,
|
||||
request,
|
||||
nil,
|
||||
func(file *os.File, n int64) {
|
||||
if useInitialDelay {
|
||||
slowCopyAfterDelay(sl, writer, file, n, delay)
|
||||
return
|
||||
}
|
||||
slowCopyN(sl, writer, file, n, 0)
|
||||
},
|
||||
)
|
||||
})
|
||||
return mux
|
||||
})
|
||||
}
|
||||
|
||||
// StartTestTemporary500Server creates a server that returns 500 for a duration, then recovers
|
||||
// Uses slow transfer to ensure the file isn't fully downloaded during resolve phase
|
||||
func StartTestTemporary500Server(errorDuration time.Duration) net.Listener {
|
||||
startTime := time.Now()
|
||||
var requestCount atomic.Int32
|
||||
|
||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
|
||||
reqNum := requestCount.Add(1)
|
||||
|
||||
// First request (Resolve) succeeds with slow transfer to prevent full download
|
||||
// Subsequent requests return 500 for the specified duration, then recover
|
||||
if reqNum == 1 {
|
||||
// Slow transfer for resolve: 50 microsecond per byte (~20MB/s)
|
||||
// This ensures resolve doesn't complete the full 200MB file
|
||||
rangeFileHandle(
|
||||
writer,
|
||||
request,
|
||||
nil,
|
||||
func(file *os.File, n int64) {
|
||||
slowCopyNWithDelay(sl, writer, file, n, 50*time.Microsecond)
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Subsequent requests: return 500 for errorDuration, then normal
|
||||
if time.Since(startTime) < errorDuration {
|
||||
writer.WriteHeader(500)
|
||||
writer.Write([]byte("Internal Server Error"))
|
||||
return
|
||||
}
|
||||
|
||||
rangeFileHandle(
|
||||
writer,
|
||||
request,
|
||||
nil,
|
||||
func(file *os.File, n int64) {
|
||||
slowCopyN(sl, writer, file, n, 0)
|
||||
},
|
||||
)
|
||||
})
|
||||
return mux
|
||||
})
|
||||
}
|
||||
|
||||
// StartTestFailThenRecoverServer creates a server that fails all connections initially,
|
||||
// then recovers after a specified number of failed requests.
|
||||
// This tests the retry functionality when calling Start() again after a download fails.
|
||||
// Parameters:
|
||||
// - failedRequestsBeforeRecover: number of requests that will fail before server recovers
|
||||
//
|
||||
// Note: Uses 416 (Range Not Satisfiable) instead of 500 because 5xx errors are exempt
|
||||
// from failure counting and will retry indefinitely. 416 is counted as a failure.
|
||||
func StartTestFailThenRecoverServer(failedRequestsBeforeRecover int32) net.Listener {
|
||||
var requestCount atomic.Int32
|
||||
|
||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
|
||||
reqNum := requestCount.Add(1)
|
||||
|
||||
// First request (Resolve) always succeeds
|
||||
if reqNum == 1 {
|
||||
rangeFileHandle(
|
||||
writer,
|
||||
request,
|
||||
nil,
|
||||
func(file *os.File, n int64) {
|
||||
io.CopyN(writer, file, n)
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Subsequent requests fail until we've had enough failures
|
||||
// Use 416 Range Not Satisfiable - this error is counted towards failure limit
|
||||
if reqNum <= failedRequestsBeforeRecover+1 { // +1 because first request is resolve
|
||||
writer.WriteHeader(416)
|
||||
writer.Write([]byte("Range Not Satisfiable"))
|
||||
return
|
||||
}
|
||||
|
||||
// After enough failures, server recovers
|
||||
rangeFileHandle(
|
||||
writer,
|
||||
request,
|
||||
nil,
|
||||
func(file *os.File, n int64) {
|
||||
io.CopyN(writer, file, n)
|
||||
},
|
||||
)
|
||||
})
|
||||
return mux
|
||||
})
|
||||
}
|
||||
|
||||
// StartTestRangeBugServer simulate bug server:
|
||||
// Don't follow Range request rules, always return more data than range, e.g. Range: bytes=0-100, return 150 bytes
|
||||
func StartTestRangeBugServer() net.Listener {
|
||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
|
||||
rangeFileHandle(
|
||||
writer,
|
||||
request,
|
||||
func(end int64) int64 {
|
||||
var bugEnd = end
|
||||
if end != 0 {
|
||||
bugEnd = end + 50
|
||||
if bugEnd >= BuildSize {
|
||||
bugEnd = end
|
||||
}
|
||||
}
|
||||
return bugEnd
|
||||
},
|
||||
func(file *os.File, n int64) {
|
||||
io.CopyN(writer, file, n)
|
||||
},
|
||||
)
|
||||
})
|
||||
return mux
|
||||
})
|
||||
}
|
||||
|
||||
func rangeFileHandle(writer http.ResponseWriter, request *http.Request, modifyEnd func(end int64) int64, iocpN func(file *os.File, n int64)) {
|
||||
r := request.Header.Get("Range")
|
||||
|
||||
// If no Range header, return full file with Accept-Ranges header
|
||||
if r == "" {
|
||||
// Open file first to ensure it exists
|
||||
file, err := os.Open(BuildFile)
|
||||
if err != nil {
|
||||
writer.WriteHeader(500)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
|
||||
writer.Header().Set("Accept-Ranges", "bytes")
|
||||
writer.WriteHeader(200)
|
||||
(writer.(http.Flusher)).Flush()
|
||||
|
||||
iocpN(file, BuildSize)
|
||||
return
|
||||
}
|
||||
|
||||
// split range
|
||||
s := strings.Split(r, "=")
|
||||
if len(s) != 2 {
|
||||
writer.WriteHeader(400)
|
||||
return
|
||||
}
|
||||
s = strings.Split(s[1], "-")
|
||||
if len(s) != 2 {
|
||||
writer.WriteHeader(400)
|
||||
return
|
||||
}
|
||||
start, err := strconv.ParseInt(s[0], 10, 64)
|
||||
if err != nil {
|
||||
writer.WriteHeader(400)
|
||||
return
|
||||
}
|
||||
end := int64(BuildSize - 1)
|
||||
if s[1] != "" {
|
||||
end, err = strconv.ParseInt(s[1], 10, 64)
|
||||
if err != nil {
|
||||
writer.WriteHeader(400)
|
||||
return
|
||||
}
|
||||
}
|
||||
if start < 0 || end < 0 || start > end {
|
||||
writer.WriteHeader(400)
|
||||
return
|
||||
}
|
||||
if end >= BuildSize {
|
||||
end = BuildSize - 1
|
||||
}
|
||||
|
||||
if modifyEnd != nil {
|
||||
end = modifyEnd(end)
|
||||
}
|
||||
|
||||
// Open file before sending headers to ensure it exists
|
||||
file, err := os.Open(BuildFile)
|
||||
if err != nil {
|
||||
writer.WriteHeader(500)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
file.Seek(start, 0)
|
||||
|
||||
writer.Header().Set("Content-Length", fmt.Sprintf("%d", end-start+1))
|
||||
writer.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, BuildSize))
|
||||
writer.Header().Set("Accept-Ranges", "bytes")
|
||||
writer.WriteHeader(206)
|
||||
(writer.(http.Flusher)).Flush()
|
||||
|
||||
iocpN(file, end-start+1)
|
||||
}
|
||||
|
||||
// slowCopyN copies n bytes from src to dst, speed limit is bytes per second
|
||||
func slowCopy(dst io.Writer, src io.Reader, delay int64) (written int64, err error) {
|
||||
func slowCopy(sl *shutdownListener, dst io.Writer, src io.Reader, delay int64) (written int64, err error) {
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
if sl.isShutdown {
|
||||
return 0, errors.New("server shutdown")
|
||||
}
|
||||
nr, er := src.Read(buf)
|
||||
if nr > 0 {
|
||||
nw, ew := dst.Write(buf[0:nr])
|
||||
@@ -243,8 +774,8 @@ func slowCopy(dst io.Writer, src io.Reader, delay int64) (written int64, err err
|
||||
return written, err
|
||||
}
|
||||
|
||||
func slowCopyN(dst io.Writer, src io.Reader, n int64, delay int64) (written int64, err error) {
|
||||
written, err = slowCopy(dst, io.LimitReader(src, n), delay)
|
||||
func slowCopyN(sl *shutdownListener, dst io.Writer, src io.Reader, n int64, delay int64) (written int64, err error) {
|
||||
written, err = slowCopy(sl, dst, io.LimitReader(src, n), delay)
|
||||
if written == n {
|
||||
return n, nil
|
||||
}
|
||||
@@ -255,7 +786,15 @@ func slowCopyN(dst io.Writer, src io.Reader, n int64, delay int64) (written int6
|
||||
return
|
||||
}
|
||||
|
||||
func startTestServer(serverHandle func() http.Handler) net.Listener {
|
||||
// slowCopyAfterDelay sleeps once before performing a normal copy to simulate a single timeout.
|
||||
func slowCopyAfterDelay(sl *shutdownListener, dst io.Writer, src io.Reader, n int64, delay int64) (written int64, err error) {
|
||||
if delay > 0 {
|
||||
time.Sleep(time.Millisecond * time.Duration(delay))
|
||||
}
|
||||
return slowCopyN(sl, dst, src, n, 0)
|
||||
}
|
||||
|
||||
func startTestServer(serverHandle func(sl *shutdownListener) http.Handler) net.Listener {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
@@ -265,7 +804,7 @@ func startTestServer(serverHandle func() http.Handler) net.Listener {
|
||||
panic(err)
|
||||
}
|
||||
defer file.Close()
|
||||
// 随机生成一个文件
|
||||
// Write random data
|
||||
l := int64(8192)
|
||||
buf := make([]byte, l)
|
||||
size := int64(0)
|
||||
@@ -282,22 +821,26 @@ func startTestServer(serverHandle func() http.Handler) net.Listener {
|
||||
size += l
|
||||
}
|
||||
server := &http.Server{}
|
||||
server.Handler = serverHandle()
|
||||
go server.Serve(listener)
|
||||
|
||||
return &shutdownListener{
|
||||
sl := &shutdownListener{
|
||||
server: server,
|
||||
Listener: listener,
|
||||
}
|
||||
server.Handler = serverHandle(sl)
|
||||
go server.Serve(listener)
|
||||
|
||||
return sl
|
||||
}
|
||||
|
||||
type shutdownListener struct {
|
||||
server *http.Server
|
||||
server *http.Server
|
||||
isShutdown bool
|
||||
net.Listener
|
||||
}
|
||||
|
||||
func (c *shutdownListener) Close() error {
|
||||
// Shutdown server first (waits for in-flight requests), then set isShutdown
|
||||
closeErr := c.server.Shutdown(context.Background())
|
||||
c.isShutdown = true
|
||||
if err := ifExistAndRemove(BuildFile); err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
@@ -330,10 +873,101 @@ func StartSocks5Server(usr, pwd string) net.Listener {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0") // 你可以根据需要更改监听地址
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
go server.Serve(listener)
|
||||
return listener
|
||||
}
|
||||
|
||||
// StartTestPatchURLServer creates a server with two endpoints:
|
||||
// - /bad-url: always returns 404 (simulates a broken download link)
|
||||
// - /good-url: returns the file successfully (simulates a working download link)
|
||||
// This is used to test the Patch functionality where a failed download URL can be
|
||||
// replaced with a working one.
|
||||
func StartTestPatchURLServer() net.Listener {
|
||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
// Bad URL - always fails with 404
|
||||
mux.HandleFunc("/bad-url", func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.WriteHeader(404)
|
||||
writer.Write([]byte("Not Found"))
|
||||
})
|
||||
// Good URL - returns the file successfully with range support
|
||||
mux.HandleFunc("/good-url", func(writer http.ResponseWriter, request *http.Request) {
|
||||
rangeFileHandle(
|
||||
writer,
|
||||
request,
|
||||
nil,
|
||||
func(file *os.File, n int64) {
|
||||
io.CopyN(writer, file, n)
|
||||
},
|
||||
)
|
||||
})
|
||||
return mux
|
||||
})
|
||||
}
|
||||
|
||||
// StartTestCookieExpiringServer creates a server that simulates cookie expiration during download.
|
||||
// - First request (Resolve): accepts "session=old_token" and succeeds
|
||||
// - Subsequent requests: "session=old_token" is expired (returns 401), only "session=new_token" works
|
||||
// This is used to test the Patch functionality where a cookie expires mid-download,
|
||||
// requiring the user to patch with a new cookie to resume.
|
||||
func StartTestCookieExpiringServer() net.Listener {
|
||||
var requestCount atomic.Int32
|
||||
|
||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
|
||||
reqNum := requestCount.Add(1)
|
||||
cookie := request.Header.Get("Cookie")
|
||||
|
||||
// First request (Resolve): accept old_token
|
||||
if reqNum == 1 {
|
||||
if !strings.Contains(cookie, "session=old_token") {
|
||||
writer.WriteHeader(401)
|
||||
writer.Write([]byte("Unauthorized: Invalid cookie"))
|
||||
return
|
||||
}
|
||||
// Return file info for resolve (with range support)
|
||||
rangeFileHandle(
|
||||
writer,
|
||||
request,
|
||||
nil,
|
||||
func(file *os.File, n int64) {
|
||||
io.CopyN(writer, file, n)
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Subsequent requests: old_token is expired, only new_token works
|
||||
if strings.Contains(cookie, "session=new_token") {
|
||||
// New token is valid - return the file with range support
|
||||
rangeFileHandle(
|
||||
writer,
|
||||
request,
|
||||
nil,
|
||||
func(file *os.File, n int64) {
|
||||
io.CopyN(writer, file, n)
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Old token or invalid token - return 401
|
||||
writer.WriteHeader(401)
|
||||
writer.Write([]byte("Unauthorized: Cookie expired"))
|
||||
})
|
||||
return mux
|
||||
})
|
||||
}
|
||||
|
||||
func AssertResourceEqual(want, got *base.Resource) bool {
|
||||
// Ignore ctime
|
||||
if got != nil && len(got.Files) > 0 {
|
||||
got.Files[0].Ctime = nil
|
||||
}
|
||||
return reflect.DeepEqual(want, got)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//go:build darwin
|
||||
|
||||
package goprovider
|
||||
|
||||
var mainThreadTasks chan func()
|
||||
|
||||
func RunMainThreadLoop(run func() int) int {
|
||||
if mainThreadTasks != nil {
|
||||
return run()
|
||||
}
|
||||
|
||||
mainThreadTasks = make(chan func())
|
||||
resultCh := make(chan int, 1)
|
||||
|
||||
go func() {
|
||||
resultCh <- run()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case task := <-mainThreadTasks:
|
||||
task()
|
||||
case code := <-resultCh:
|
||||
mainThreadTasks = nil
|
||||
return code
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func postMainThreadTask(task func()) bool {
|
||||
if mainThreadTasks == nil {
|
||||
return false
|
||||
}
|
||||
mainThreadTasks <- task
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//go:build !darwin
|
||||
|
||||
package goprovider
|
||||
|
||||
func RunMainThreadLoop(run func() int) int {
|
||||
return run()
|
||||
}
|
||||
|
||||
func postMainThreadTask(func()) bool {
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,608 @@
|
||||
//go:build cgo
|
||||
|
||||
package goprovider
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
runtimepkg "runtime"
|
||||
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
webview "github.com/GopeedLab/webview_go"
|
||||
)
|
||||
|
||||
type Provider struct{}
|
||||
|
||||
func New() enginewebview.Provider {
|
||||
return &Provider{}
|
||||
}
|
||||
|
||||
func (p *Provider) IsAvailable() bool {
|
||||
return webview.IsAvailable()
|
||||
}
|
||||
|
||||
func (p *Provider) Open(opts enginewebview.OpenOptions) (enginewebview.Page, error) {
|
||||
pw := newPageWrapper(opts)
|
||||
if err := pw.start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pw, nil
|
||||
}
|
||||
|
||||
type pageWrapper struct {
|
||||
opts enginewebview.OpenOptions
|
||||
|
||||
callbackName string
|
||||
readyID string
|
||||
|
||||
view webview.WebView
|
||||
|
||||
mu sync.Mutex
|
||||
pending map[string]chan evalResult
|
||||
url string
|
||||
closed bool
|
||||
|
||||
ready chan error
|
||||
done chan struct{}
|
||||
loads chan string
|
||||
}
|
||||
|
||||
type evalMessage struct {
|
||||
ID string `json:"id"`
|
||||
Ready bool `json:"ready,omitempty"`
|
||||
Load bool `json:"load,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
OK bool `json:"ok,omitempty"`
|
||||
Value any `json:"value,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type evalResult struct {
|
||||
value any
|
||||
err error
|
||||
}
|
||||
|
||||
type nativeCookieJSON struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Domain string `json:"domain"`
|
||||
Path string `json:"path"`
|
||||
Expires string `json:"expires"`
|
||||
Secure bool `json:"secure"`
|
||||
HTTPOnly bool `json:"httpOnly"`
|
||||
}
|
||||
|
||||
var providerSeq atomic.Uint64
|
||||
|
||||
func newPageWrapper(opts enginewebview.OpenOptions) *pageWrapper {
|
||||
seq := providerSeq.Add(1)
|
||||
return &pageWrapper{
|
||||
opts: opts,
|
||||
callbackName: fmt.Sprintf("__gopeedWebViewCallback_%d", seq),
|
||||
readyID: fmt.Sprintf("__ready__%d", seq),
|
||||
pending: make(map[string]chan evalResult),
|
||||
ready: make(chan error, 1),
|
||||
done: make(chan struct{}),
|
||||
loads: make(chan string, 8),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *pageWrapper) start() error {
|
||||
startedAt := time.Now()
|
||||
run := func() {
|
||||
runtimepkg.LockOSThread()
|
||||
defer runtimepkg.UnlockOSThread()
|
||||
|
||||
var w webview.WebView
|
||||
if p.opts.Headless {
|
||||
w = webview.NewHeadless(p.opts.Debug)
|
||||
} else {
|
||||
w = webview.New(p.opts.Debug)
|
||||
}
|
||||
p.view = w
|
||||
w.SetTitle(firstNonEmpty(p.opts.Title, "Gopeed WebView"))
|
||||
w.SetSize(defaultWindowDimension(p.opts.Width, 1280), defaultWindowDimension(p.opts.Height, 800), webview.HintNone)
|
||||
|
||||
if p.opts.UserAgent != "" {
|
||||
type userAgentSetter interface {
|
||||
SetUserAgent(userAgent string)
|
||||
}
|
||||
if setter, ok := w.(userAgentSetter); ok {
|
||||
setter.SetUserAgent(p.opts.UserAgent)
|
||||
}
|
||||
}
|
||||
w.Init(buildLoadNotifyScript(p.callbackName))
|
||||
|
||||
if err := w.Bind(p.callbackName, func(payload string) error {
|
||||
return p.handleCallback(payload)
|
||||
}); err != nil {
|
||||
p.ready <- err
|
||||
return
|
||||
}
|
||||
|
||||
applyWindowOptions(w, p.opts)
|
||||
w.SetHtml(buildBootstrapHTML(p.callbackName, p.readyID))
|
||||
w.Run()
|
||||
w.Destroy()
|
||||
close(p.done)
|
||||
}
|
||||
|
||||
if !postMainThreadTask(run) {
|
||||
go run()
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-p.ready:
|
||||
p.tracef("open %dms", time.Since(startedAt).Milliseconds())
|
||||
return err
|
||||
case <-time.After(10 * time.Second):
|
||||
p.tracef("open timeout %dms", time.Since(startedAt).Milliseconds())
|
||||
return fmt.Errorf("webview startup timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func (p *pageWrapper) AddInitScript(script string) error {
|
||||
return p.dispatch(func(w webview.WebView) error {
|
||||
w.Init(script)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (p *pageWrapper) Goto(url string, opts enginewebview.GotoOptions) error {
|
||||
startedAt := time.Now()
|
||||
p.drainLoads()
|
||||
if err := p.dispatch(func(w webview.WebView) error {
|
||||
p.mu.Lock()
|
||||
p.url = url
|
||||
p.mu.Unlock()
|
||||
w.Navigate(url)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
timeout := 30 * time.Second
|
||||
if opts.TimeoutMS > 0 {
|
||||
timeout = time.Duration(opts.TimeoutMS) * time.Millisecond
|
||||
}
|
||||
waitUntil := opts.WaitUntil
|
||||
if waitUntil == "" {
|
||||
waitUntil = "load"
|
||||
}
|
||||
err := p.waitForNavigation(url, timeout, waitUntil)
|
||||
if err != nil {
|
||||
p.tracef("goto failed %dms url=%s err=%v", time.Since(startedAt).Milliseconds(), url, err)
|
||||
return err
|
||||
}
|
||||
p.tracef("goto %dms url=%s", time.Since(startedAt).Milliseconds(), url)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *pageWrapper) Execute(expression string, args ...any) (any, error) {
|
||||
startedAt := time.Now()
|
||||
if args == nil {
|
||||
args = []any{}
|
||||
}
|
||||
argsJSON, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqID := fmt.Sprintf("req-%d", providerSeq.Add(1))
|
||||
ch := make(chan evalResult, 1)
|
||||
|
||||
p.mu.Lock()
|
||||
if p.closed {
|
||||
p.mu.Unlock()
|
||||
return nil, fmt.Errorf("webview page is closed")
|
||||
}
|
||||
p.pending[reqID] = ch
|
||||
p.mu.Unlock()
|
||||
|
||||
script := buildExecuteScript(p.callbackName, reqID, expression, string(argsJSON))
|
||||
if err := p.dispatch(func(w webview.WebView) error {
|
||||
w.Eval(script)
|
||||
return nil
|
||||
}); err != nil {
|
||||
p.mu.Lock()
|
||||
delete(p.pending, reqID)
|
||||
p.mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := <-ch
|
||||
if result.err != nil {
|
||||
p.tracef("execute failed %dms err=%v", time.Since(startedAt).Milliseconds(), result.err)
|
||||
return result.value, result.err
|
||||
}
|
||||
p.tracef("execute %dms", time.Since(startedAt).Milliseconds())
|
||||
return result.value, nil
|
||||
}
|
||||
|
||||
func (p *pageWrapper) GetCookies() ([]enginewebview.Cookie, error) {
|
||||
cookies, err := p.nativeCookies()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]enginewebview.Cookie, 0, len(cookies))
|
||||
for _, cookie := range cookies {
|
||||
item := enginewebview.Cookie{
|
||||
Name: cookie.Name,
|
||||
Value: cookie.Value,
|
||||
Domain: cookie.Domain,
|
||||
Path: cookie.Path,
|
||||
Secure: cookie.Secure,
|
||||
HTTPOnly: cookie.HTTPOnly,
|
||||
}
|
||||
if cookie.Expires != "" {
|
||||
if parsed, err := time.Parse(time.RFC3339Nano, cookie.Expires); err == nil {
|
||||
item.Expires = parsed
|
||||
}
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *pageWrapper) SetCookie(cookie enginewebview.Cookie) error {
|
||||
type cookieSetter interface {
|
||||
SetCookie(cookie webview.Cookie) error
|
||||
}
|
||||
return p.dispatch(func(w webview.WebView) error {
|
||||
setter, ok := w.(cookieSetter)
|
||||
if !ok {
|
||||
return fmt.Errorf("native cookie access is unavailable")
|
||||
}
|
||||
return setter.SetCookie(webview.Cookie{
|
||||
Name: cookie.Name,
|
||||
Value: cookie.Value,
|
||||
Domain: cookie.Domain,
|
||||
Path: cookie.Path,
|
||||
Expires: cookie.Expires,
|
||||
Secure: cookie.Secure,
|
||||
HTTPOnly: cookie.HTTPOnly,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (p *pageWrapper) DeleteCookie(cookie enginewebview.Cookie) error {
|
||||
type cookieDeleter interface {
|
||||
DeleteCookie(name string, domain string, path string) error
|
||||
}
|
||||
return p.dispatch(func(w webview.WebView) error {
|
||||
deleter, ok := w.(cookieDeleter)
|
||||
if !ok {
|
||||
return fmt.Errorf("native cookie access is unavailable")
|
||||
}
|
||||
return deleter.DeleteCookie(cookie.Name, cookie.Domain, cookie.Path)
|
||||
})
|
||||
}
|
||||
|
||||
func (p *pageWrapper) ClearCookies() error {
|
||||
type cookieClearer interface {
|
||||
ClearCookies() error
|
||||
}
|
||||
return p.dispatch(func(w webview.WebView) error {
|
||||
clearer, ok := w.(cookieClearer)
|
||||
if !ok {
|
||||
return fmt.Errorf("native cookie access is unavailable")
|
||||
}
|
||||
return clearer.ClearCookies()
|
||||
})
|
||||
}
|
||||
|
||||
func (p *pageWrapper) Close() error {
|
||||
p.mu.Lock()
|
||||
if p.closed {
|
||||
p.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
p.closed = true
|
||||
w := p.view
|
||||
wait := p.done
|
||||
p.mu.Unlock()
|
||||
|
||||
if w != nil {
|
||||
// PostQuitMessage (used by the Windows WebView2 terminate_impl)
|
||||
// only takes effect on the thread that owns the message loop.
|
||||
// Dispatch the termination to the webview's UI thread so that
|
||||
// the quit message lands on the correct queue.
|
||||
terminateDone := make(chan struct{})
|
||||
w.Dispatch(func() {
|
||||
w.Terminate()
|
||||
close(terminateDone)
|
||||
})
|
||||
<-terminateDone
|
||||
}
|
||||
<-wait
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *pageWrapper) tracef(format string, args ...any) {
|
||||
if !strings.Contains(p.opts.Title, "youtube-sabr") {
|
||||
return
|
||||
}
|
||||
fmt.Printf("goprovider "+format+"\n", args...)
|
||||
}
|
||||
|
||||
func (p *pageWrapper) waitForNavigation(targetURL string, timeout time.Duration, waitUntil string) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
ticker := time.NewTicker(100 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-p.loads:
|
||||
if waitUntil == "load" {
|
||||
return nil
|
||||
}
|
||||
case <-ticker.C:
|
||||
state, err := p.navigationState()
|
||||
if err == nil && state.ready(targetURL, waitUntil) {
|
||||
return nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("webview navigation timeout")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type navigationState struct {
|
||||
URL string `json:"url"`
|
||||
ReadyState string `json:"readyState"`
|
||||
}
|
||||
|
||||
func (s navigationState) ready(targetURL string, waitUntil string) bool {
|
||||
if s.URL == "" || s.URL == "about:blank" {
|
||||
return false
|
||||
}
|
||||
switch waitUntil {
|
||||
case "domcontentloaded":
|
||||
if s.ReadyState == "loading" || s.ReadyState == "" {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
if s.ReadyState != "complete" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if targetURL == "" {
|
||||
return true
|
||||
}
|
||||
if s.URL == targetURL {
|
||||
return true
|
||||
}
|
||||
target, err := parseURL(targetURL)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
current, err := parseURL(s.URL)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
if current.Scheme == target.Scheme && current.Host == target.Host && current.Path == target.Path {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseURL(raw string) (*url.URL, error) {
|
||||
return url.Parse(raw)
|
||||
}
|
||||
|
||||
func (p *pageWrapper) navigationState() (navigationState, error) {
|
||||
value, err := p.Execute(`() => ({
|
||||
url: String(location.href || ""),
|
||||
readyState: document.readyState || "",
|
||||
})`)
|
||||
if err != nil {
|
||||
return navigationState{}, err
|
||||
}
|
||||
stateMap, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return navigationState{}, fmt.Errorf("invalid navigation state")
|
||||
}
|
||||
state := navigationState{}
|
||||
if urlValue, ok := stateMap["url"].(string); ok {
|
||||
state.URL = urlValue
|
||||
}
|
||||
if readyValue, ok := stateMap["readyState"].(string); ok {
|
||||
state.ReadyState = readyValue
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (p *pageWrapper) dispatch(fn func(w webview.WebView) error) error {
|
||||
p.mu.Lock()
|
||||
if p.closed {
|
||||
p.mu.Unlock()
|
||||
return fmt.Errorf("webview page is closed")
|
||||
}
|
||||
w := p.view
|
||||
p.mu.Unlock()
|
||||
if w == nil {
|
||||
return fmt.Errorf("webview is not initialized")
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
w.Dispatch(func() {
|
||||
done <- fn(w)
|
||||
})
|
||||
return <-done
|
||||
}
|
||||
|
||||
func (p *pageWrapper) nativeCookies() ([]nativeCookieJSON, error) {
|
||||
type cookieGetter interface {
|
||||
GetCookies(url string) ([]webview.Cookie, error)
|
||||
}
|
||||
|
||||
var cookies []webview.Cookie
|
||||
err := p.dispatch(func(w webview.WebView) error {
|
||||
getter, ok := w.(cookieGetter)
|
||||
if !ok {
|
||||
return fmt.Errorf("native cookie access is unavailable")
|
||||
}
|
||||
p.mu.Lock()
|
||||
url := p.url
|
||||
p.mu.Unlock()
|
||||
if url == "" {
|
||||
url = "about:blank"
|
||||
}
|
||||
var err error
|
||||
cookies, err = getter.GetCookies(url)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]nativeCookieJSON, 0, len(cookies))
|
||||
for _, cookie := range cookies {
|
||||
item := nativeCookieJSON{
|
||||
Name: cookie.Name,
|
||||
Value: cookie.Value,
|
||||
Domain: cookie.Domain,
|
||||
Path: cookie.Path,
|
||||
Secure: cookie.Secure,
|
||||
HTTPOnly: cookie.HTTPOnly,
|
||||
}
|
||||
if !cookie.Expires.IsZero() {
|
||||
item.Expires = cookie.Expires.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *pageWrapper) handleCallback(payload string) error {
|
||||
var msg evalMessage
|
||||
if err := json.Unmarshal([]byte(payload), &msg); err != nil {
|
||||
return err
|
||||
}
|
||||
if msg.Ready && msg.ID == p.readyID {
|
||||
select {
|
||||
case p.ready <- nil:
|
||||
default:
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if msg.Load {
|
||||
select {
|
||||
case p.loads <- msg.URL:
|
||||
default:
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
ch := p.pending[msg.ID]
|
||||
delete(p.pending, msg.ID)
|
||||
p.mu.Unlock()
|
||||
if ch == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !msg.OK {
|
||||
ch <- evalResult{err: errors.New(strings.TrimSpace(msg.Error))}
|
||||
return nil
|
||||
}
|
||||
ch <- evalResult{value: msg.Value}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *pageWrapper) drainLoads() {
|
||||
for {
|
||||
select {
|
||||
case <-p.loads:
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildBootstrapHTML(callbackName string, readyID string) string {
|
||||
return fmt.Sprintf(`<!doctype html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body>
|
||||
<script>
|
||||
window.addEventListener("load", () => {
|
||||
const callback = globalThis[%q];
|
||||
if (typeof callback === "function") {
|
||||
callback(JSON.stringify({ id: %q, ready: true }));
|
||||
}
|
||||
}, { once: true });
|
||||
</script>
|
||||
</body>
|
||||
</html>`, callbackName, readyID)
|
||||
}
|
||||
|
||||
func buildExecuteScript(callbackName string, reqID string, expression string, argsJSON string) string {
|
||||
callbackJSON, _ := json.Marshal(callbackName)
|
||||
reqJSON, _ := json.Marshal(reqID)
|
||||
exprJSON, _ := json.Marshal(expression)
|
||||
argsLiteral := argsJSON
|
||||
if argsLiteral == "" || argsLiteral == "null" {
|
||||
argsLiteral = "[]"
|
||||
}
|
||||
return fmt.Sprintf(`(() => {
|
||||
const __cb = globalThis[%s];
|
||||
const __id = %s;
|
||||
const __expression = %s;
|
||||
const __args = %s;
|
||||
Promise.resolve()
|
||||
.then(async () => {
|
||||
const __target = (0, eval)(__expression);
|
||||
const __value = await (typeof __target === "function" ? __target(...__args) : __target);
|
||||
return { id: __id, ok: true, value: __value ?? null };
|
||||
})
|
||||
.catch((error) => ({
|
||||
id: __id,
|
||||
ok: false,
|
||||
error: [
|
||||
error && error.name ? error.name : "Error",
|
||||
error && error.message ? error.message : String(error),
|
||||
error && error.stack ? error.stack : "",
|
||||
].filter(Boolean).join(": ").replace(": @", "\n@"),
|
||||
}))
|
||||
.then((message) => __cb(JSON.stringify(message)));
|
||||
})();`, string(callbackJSON), string(reqJSON), string(exprJSON), argsLiteral)
|
||||
}
|
||||
|
||||
func buildLoadNotifyScript(callbackName string) string {
|
||||
callbackJSON, _ := json.Marshal(callbackName)
|
||||
return fmt.Sprintf(`(() => {
|
||||
window.addEventListener("load", () => {
|
||||
const __cb = globalThis[%s];
|
||||
if (typeof __cb !== "function") {
|
||||
return;
|
||||
}
|
||||
__cb(JSON.stringify({
|
||||
load: true,
|
||||
url: String(location.href || ""),
|
||||
}));
|
||||
}, { once: true });
|
||||
})();`, string(callbackJSON))
|
||||
}
|
||||
|
||||
func defaultWindowDimension(value int, fallback int) int {
|
||||
if value > 0 {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//go:build cgo && webview && !darwin
|
||||
|
||||
package goprovider
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/webview/integrationtest"
|
||||
)
|
||||
|
||||
func TestProviderContract(t *testing.T) {
|
||||
integrationtest.RunProviderContract(t, New(), integrationtest.ContractOptions{
|
||||
CookieDomainMode: integrationtest.CookieDomainModeRequired,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !cgo
|
||||
|
||||
package goprovider
|
||||
|
||||
import enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
|
||||
func New() enginewebview.Provider {
|
||||
return enginewebview.NewUnavailableProvider()
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//go:build cgo && darwin
|
||||
|
||||
package goprovider
|
||||
|
||||
/*
|
||||
#cgo darwin CFLAGS: -x objective-c
|
||||
#cgo darwin LDFLAGS: -framework Cocoa
|
||||
|
||||
#include <Cocoa/Cocoa.h>
|
||||
|
||||
static void gopeedApplyHeadlessWindowOptions(void *windowPtr, int headless) {
|
||||
if (windowPtr == NULL) {
|
||||
return;
|
||||
}
|
||||
|
||||
NSWindow *window = (__bridge NSWindow *)windowPtr;
|
||||
if (headless) {
|
||||
[window orderOut:nil];
|
||||
}
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
webview "github.com/GopeedLab/webview_go"
|
||||
)
|
||||
|
||||
func applyWindowOptions(w webview.WebView, opts enginewebview.OpenOptions) {
|
||||
C.gopeedApplyHeadlessWindowOptions(w.Window(), C.int(boolToInt(opts.Headless)))
|
||||
}
|
||||
|
||||
func boolToInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build cgo && !darwin
|
||||
|
||||
package goprovider
|
||||
|
||||
import (
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
webview "github.com/GopeedLab/webview_go"
|
||||
)
|
||||
|
||||
func applyWindowOptions(_ webview.WebView, _ enginewebview.OpenOptions) {}
|
||||
@@ -0,0 +1,378 @@
|
||||
package integrationtest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
)
|
||||
|
||||
const TestUserAgent = "GopeedWebViewIntegration/1.0"
|
||||
|
||||
type ContractOptions struct {
|
||||
AvailabilityTimeout time.Duration
|
||||
CookieDomainMode CookieDomainMode
|
||||
CookieTestURL string
|
||||
}
|
||||
|
||||
type CookieDomainMode int
|
||||
|
||||
const (
|
||||
CookieDomainModeRequired CookieDomainMode = iota
|
||||
CookieDomainModeOmit
|
||||
)
|
||||
|
||||
func RunProviderContract(t *testing.T, provider enginewebview.Provider, opts ContractOptions) {
|
||||
t.Helper()
|
||||
|
||||
timeout := opts.AvailabilityTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
waitForProviderAvailable(t, provider, timeout)
|
||||
|
||||
page, server, testURL := openTestPage(t, provider, enginewebview.OpenOptions{
|
||||
Headless: true,
|
||||
Title: "Gopeed WebView Integration",
|
||||
Width: 640,
|
||||
Height: 480,
|
||||
UserAgent: TestUserAgent,
|
||||
})
|
||||
|
||||
if err := page.Goto(testURL, map[string]any{"timeoutMs": 10000}); err != nil {
|
||||
t.Fatalf("goto failed: %v", err)
|
||||
}
|
||||
|
||||
ua, err := page.Execute(`() => navigator.userAgent`)
|
||||
if err != nil {
|
||||
t.Fatalf("navigator.userAgent execute failed: %v", err)
|
||||
}
|
||||
if ua != TestUserAgent {
|
||||
t.Fatalf("unexpected user agent: got %v want %q", ua, TestUserAgent)
|
||||
}
|
||||
|
||||
if err := page.Focus("#name"); err != nil {
|
||||
t.Fatalf("focus failed: %v", err)
|
||||
}
|
||||
activeElement, err := page.Execute(`() => document.activeElement && document.activeElement.id`)
|
||||
if err != nil {
|
||||
t.Fatalf("activeElement execute failed: %v", err)
|
||||
}
|
||||
if activeElement != "name" {
|
||||
t.Fatalf("expected active element to be #name, got %v", activeElement)
|
||||
}
|
||||
|
||||
if err := page.Type("#name", "Gopeed", map[string]any{"delay": 5}); err != nil {
|
||||
t.Fatalf("type failed: %v", err)
|
||||
}
|
||||
typeState, err := page.Execute(`() => ({
|
||||
value: document.querySelector('#name').value,
|
||||
mirror: document.querySelector('#name-value').textContent,
|
||||
inputCount: window.__inputEvents.length
|
||||
})`)
|
||||
if err != nil {
|
||||
t.Fatalf("type state execute failed: %v", err)
|
||||
}
|
||||
stateMap, ok := typeState.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected type state payload: %T", typeState)
|
||||
}
|
||||
if stateMap["value"] != "Gopeed" || stateMap["mirror"] != "Gopeed" {
|
||||
t.Fatalf("unexpected type state: %#v", stateMap)
|
||||
}
|
||||
if stateMap["inputCount"] != float64(len("Gopeed")) {
|
||||
t.Fatalf("unexpected input event count: %#v", stateMap["inputCount"])
|
||||
}
|
||||
|
||||
if err := page.Click("#clicker"); err != nil {
|
||||
t.Fatalf("click failed: %v", err)
|
||||
}
|
||||
clickCount, err := page.Execute(`() => document.querySelector('#click-count').textContent`)
|
||||
if err != nil {
|
||||
t.Fatalf("click count execute failed: %v", err)
|
||||
}
|
||||
if clickCount != "1" {
|
||||
t.Fatalf("expected click count to be 1, got %v", clickCount)
|
||||
}
|
||||
|
||||
found, err := page.WaitForSelector("#delayed", map[string]any{
|
||||
"timeoutMs": 5000,
|
||||
"pollIntervalMs": 25,
|
||||
"visible": true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("waitForSelector failed: %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected delayed selector to appear")
|
||||
}
|
||||
|
||||
value, err := page.WaitForFunction(`() => window.__readyValue`, map[string]any{
|
||||
"timeoutMs": 5000,
|
||||
"pollIntervalMs": 25,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("waitForFunction failed: %v", err)
|
||||
}
|
||||
readyValue, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected waitForFunction value: %T", value)
|
||||
}
|
||||
if readyValue["status"] != "ready" {
|
||||
t.Fatalf("unexpected waitForFunction payload: %#v", readyValue)
|
||||
}
|
||||
|
||||
found, err = page.WaitForSelector("#missing", map[string]any{
|
||||
"timeoutMs": 200,
|
||||
"pollIntervalMs": 25,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("waitForSelector timeout case failed: %v", err)
|
||||
}
|
||||
if found {
|
||||
t.Fatal("expected missing selector wait to time out")
|
||||
}
|
||||
|
||||
cookieTestURL := opts.CookieTestURL
|
||||
if cookieTestURL == "" {
|
||||
cookieTestURL = testURL
|
||||
}
|
||||
runCookieLifecycleAssertions(t, page, server, cookieTestURL, opts.CookieDomainMode)
|
||||
}
|
||||
|
||||
func waitForProviderAvailable(t *testing.T, provider enginewebview.Provider, timeout time.Duration) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if provider != nil && provider.IsAvailable() {
|
||||
return
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("provider did not become available within %s", timeout)
|
||||
}
|
||||
|
||||
func openTestPage(t *testing.T, provider enginewebview.Provider, opts enginewebview.OpenOptions) (*enginewebview.PageHandle, *httptest.Server, string) {
|
||||
t.Helper()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
switch r.URL.Path {
|
||||
case "/next":
|
||||
fmt.Fprint(w, `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Next Page</title></head>
|
||||
<body>
|
||||
<h1 id="next-page">Navigation complete</h1>
|
||||
</body>
|
||||
</html>`)
|
||||
default:
|
||||
fmt.Fprint(w, `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Test Page</title></head>
|
||||
<body>
|
||||
<input id="name" type="text" />
|
||||
<div id="name-value"></div>
|
||||
<button id="clicker" type="button">Click me</button>
|
||||
<div id="click-count">0</div>
|
||||
<button id="nav-btn" type="button">Navigate</button>
|
||||
<script>
|
||||
window.__inputEvents = [];
|
||||
window.__readyValue = null;
|
||||
const nameInput = document.getElementById('name');
|
||||
const nameValue = document.getElementById('name-value');
|
||||
nameInput.addEventListener('input', () => {
|
||||
nameValue.textContent = nameInput.value;
|
||||
window.__inputEvents.push(nameInput.value);
|
||||
});
|
||||
document.getElementById('clicker').addEventListener('click', () => {
|
||||
const counter = document.getElementById('click-count');
|
||||
counter.textContent = String(Number(counter.textContent || '0') + 1);
|
||||
});
|
||||
document.getElementById('nav-btn').addEventListener('click', () => {
|
||||
setTimeout(() => {
|
||||
window.location.href = '/next';
|
||||
}, 50);
|
||||
});
|
||||
setTimeout(() => {
|
||||
const marker = document.createElement('div');
|
||||
marker.id = 'delayed';
|
||||
marker.textContent = 'Loaded later';
|
||||
document.body.appendChild(marker);
|
||||
}, 150);
|
||||
setTimeout(() => {
|
||||
window.__readyValue = { status: 'ready', source: 'timer' };
|
||||
}, 200);
|
||||
</script>
|
||||
</body>
|
||||
</html>`)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
runtime := enginewebview.NewRuntime(provider, true)
|
||||
page, err := runtime.Open(map[string]any{
|
||||
"headless": opts.Headless,
|
||||
"debug": opts.Debug,
|
||||
"title": opts.Title,
|
||||
"width": opts.Width,
|
||||
"height": opts.Height,
|
||||
"userAgent": opts.UserAgent,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open webview: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = runtime.Close()
|
||||
})
|
||||
|
||||
return page, server, server.URL
|
||||
}
|
||||
|
||||
func runCookieLifecycleAssertions(t *testing.T, page *enginewebview.PageHandle, _ *httptest.Server, testURL string, domainMode CookieDomainMode) {
|
||||
t.Helper()
|
||||
|
||||
parsedURL, err := url.Parse(testURL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse cookie test url failed: %v", err)
|
||||
}
|
||||
cookieDomain := parsedURL.Hostname()
|
||||
|
||||
if err := page.ClearCookies(); err != nil {
|
||||
t.Fatalf("clearCookies before cookie assertions failed: %v", err)
|
||||
}
|
||||
|
||||
visibleCookie := map[string]any{
|
||||
"name": "visible",
|
||||
"value": "1",
|
||||
"path": "/",
|
||||
}
|
||||
secretCookie := map[string]any{
|
||||
"name": "secret",
|
||||
"value": "2",
|
||||
"path": "/",
|
||||
"httpOnly": true,
|
||||
"expires": time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
deleteCookie := map[string]any{
|
||||
"name": "visible",
|
||||
"path": "/",
|
||||
}
|
||||
if domainMode == CookieDomainModeRequired {
|
||||
visibleCookie["domain"] = cookieDomain
|
||||
secretCookie["domain"] = cookieDomain
|
||||
deleteCookie["domain"] = cookieDomain
|
||||
}
|
||||
|
||||
if err := page.SetCookie(visibleCookie); err != nil {
|
||||
t.Fatalf("set visible cookie failed: %v", err)
|
||||
}
|
||||
if err := page.SetCookie(secretCookie); err != nil {
|
||||
t.Fatalf("set secret cookie failed: %v", err)
|
||||
}
|
||||
|
||||
if err := page.Goto(testURL, map[string]any{"timeoutMs": 10000}); err != nil {
|
||||
t.Fatalf("goto after setCookie failed: %v", err)
|
||||
}
|
||||
|
||||
documentCookie, err := page.Execute(`() => document.cookie`)
|
||||
if err != nil {
|
||||
t.Fatalf("document.cookie execute failed: %v", err)
|
||||
}
|
||||
documentCookieText, ok := documentCookie.(string)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected document.cookie payload: %T", documentCookie)
|
||||
}
|
||||
if !strings.Contains(documentCookieText, "visible=1") {
|
||||
t.Fatalf("expected visible cookie in document.cookie, got %q", documentCookieText)
|
||||
}
|
||||
if strings.Contains(documentCookieText, "secret=2") {
|
||||
t.Fatalf("expected httpOnly cookie to be hidden from document.cookie, got %q", documentCookieText)
|
||||
}
|
||||
|
||||
cookies, err := page.GetCookies()
|
||||
if err != nil {
|
||||
t.Fatalf("getCookies failed: %v", err)
|
||||
}
|
||||
if len(cookies) < 2 {
|
||||
t.Fatalf("expected at least 2 cookies, got %#v", cookies)
|
||||
}
|
||||
if !hasCookie(cookies, "visible", "1") {
|
||||
t.Fatalf("expected visible cookie in native cookie store, got %#v", cookies)
|
||||
}
|
||||
if !hasCookie(cookies, "secret", "2") {
|
||||
t.Fatalf("expected secret cookie in native cookie store, got %#v", cookies)
|
||||
}
|
||||
|
||||
if err := page.DeleteCookie(deleteCookie); err != nil {
|
||||
t.Fatalf("deleteCookie failed: %v", err)
|
||||
}
|
||||
if err := page.Goto(testURL, map[string]any{"timeoutMs": 10000}); err != nil {
|
||||
t.Fatalf("goto after deleteCookie failed: %v", err)
|
||||
}
|
||||
|
||||
documentCookie, err = page.Execute(`() => document.cookie`)
|
||||
if err != nil {
|
||||
t.Fatalf("document.cookie execute after delete failed: %v", err)
|
||||
}
|
||||
documentCookieText, ok = documentCookie.(string)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected document.cookie payload after delete: %T", documentCookie)
|
||||
}
|
||||
if strings.Contains(documentCookieText, "visible=1") {
|
||||
t.Fatalf("expected visible cookie to be deleted, got %q", documentCookieText)
|
||||
}
|
||||
if strings.Contains(documentCookieText, "secret=2") {
|
||||
t.Fatalf("expected httpOnly cookie to remain hidden after delete, got %q", documentCookieText)
|
||||
}
|
||||
|
||||
cookies, err = page.GetCookies()
|
||||
if err != nil {
|
||||
t.Fatalf("getCookies after delete failed: %v", err)
|
||||
}
|
||||
if hasCookie(cookies, "visible", "1") {
|
||||
t.Fatalf("expected visible cookie to be removed from native store, got %#v", cookies)
|
||||
}
|
||||
if !hasCookie(cookies, "secret", "2") {
|
||||
t.Fatalf("expected secret cookie to remain in native store after delete, got %#v", cookies)
|
||||
}
|
||||
|
||||
if err := page.ClearCookies(); err != nil {
|
||||
t.Fatalf("clearCookies failed: %v", err)
|
||||
}
|
||||
if err := page.Goto(testURL, map[string]any{"timeoutMs": 10000}); err != nil {
|
||||
t.Fatalf("goto after clearCookies failed: %v", err)
|
||||
}
|
||||
documentCookie, err = page.Execute(`() => document.cookie`)
|
||||
if err != nil {
|
||||
t.Fatalf("document.cookie execute after clear failed: %v", err)
|
||||
}
|
||||
documentCookieText, ok = documentCookie.(string)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected document.cookie payload after clear: %T", documentCookie)
|
||||
}
|
||||
if documentCookieText != "" {
|
||||
t.Fatalf("expected document.cookie to be empty after clear, got %q", documentCookieText)
|
||||
}
|
||||
cookies, err = page.GetCookies()
|
||||
if err != nil {
|
||||
t.Fatalf("getCookies after clear failed: %v", err)
|
||||
}
|
||||
if len(cookies) != 0 {
|
||||
t.Fatalf("expected cookie store to be empty after clear, got %#v", cookies)
|
||||
}
|
||||
}
|
||||
|
||||
func hasCookie(cookies []enginewebview.Cookie, name string, value string) bool {
|
||||
for _, cookie := range cookies {
|
||||
if cookie.Name == name && cookie.Value == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
# rpcprovider
|
||||
|
||||
`rpcprovider` is the RPC-backed implementation of Gopeed's `webview.Provider` interface.
|
||||
|
||||
It is intended for environments where WebView capability is owned by another process, most notably mobile hosts. This package does not implement a WebView itself. Instead, it translates the `pkg/download/engine/webview` page API into single-request `POST + JSON` calls sent to a local host-side RPC service.
|
||||
|
||||
## When to use it
|
||||
|
||||
Use `rpcprovider` for entrypoints such as `bind/mobile`, where the Go process cannot directly own a native WebView.
|
||||
|
||||
Do not use it for desktop or `cmd/web` local WebView integration. Those entrypoints should use `internal/webview/goprovider` instead.
|
||||
|
||||
## Configuration
|
||||
|
||||
The provider is configured through `pkg/download/engine/webview.RPCConfig`:
|
||||
|
||||
```go
|
||||
type RPCConfig struct {
|
||||
Network string `json:"network"`
|
||||
Address string `json:"address"`
|
||||
Token string `json:"token,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `Network` currently supports `tcp` and `unix`.
|
||||
- `Address` examples:
|
||||
- `127.0.0.1:38765`
|
||||
- `/path/to/webview.sock`
|
||||
- `Token` is optional. When set, the client sends `Authorization: Bearer <token>`.
|
||||
|
||||
Example:
|
||||
|
||||
```go
|
||||
provider := rpcprovider.New(webview.RPCConfig{
|
||||
Network: "tcp",
|
||||
Address: "127.0.0.1:38765",
|
||||
Token: "secret-token",
|
||||
})
|
||||
```
|
||||
|
||||
## Transport
|
||||
|
||||
- Endpoint: `POST /webview`
|
||||
- Content type: `application/json`
|
||||
- One request, one response
|
||||
- No `id` field yet
|
||||
- The envelope is intentionally close to JSON-RPC so it can evolve later without renaming the methods or reshaping the payloads
|
||||
|
||||
Request example:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "page.execute",
|
||||
"params": {
|
||||
"pageId": "page-1",
|
||||
"expression": "document.title",
|
||||
"args": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Successful response:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {
|
||||
"title": "Example"
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
Failure response:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": null,
|
||||
"error": {
|
||||
"code": "PAGE_NOT_FOUND",
|
||||
"message": "page not found"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Supported methods
|
||||
|
||||
- `webview.isAvailable`
|
||||
- `page.open`
|
||||
- `page.addInitScript`
|
||||
- `page.goto`
|
||||
- `page.execute`
|
||||
- `page.getCookies`
|
||||
- `page.setCookie`
|
||||
- `page.deleteCookie`
|
||||
- `page.clearCookies`
|
||||
- `page.close`
|
||||
|
||||
The canonical wire models for these methods are defined in:
|
||||
|
||||
- [pkg/download/engine/webview/rpc.go](../../../pkg/download/engine/webview/rpc.go)
|
||||
- [pkg/download/engine/webview/runtime.go](../../../pkg/download/engine/webview/runtime.go)
|
||||
|
||||
The README is only a guide. The Go source above is the authoritative protocol reference.
|
||||
|
||||
## Method reference
|
||||
|
||||
Each method below uses the common envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "<method-name>",
|
||||
"params": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
and returns:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": ...,
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
or:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": null,
|
||||
"error": {
|
||||
"code": "<ERROR_CODE>",
|
||||
"message": "<message>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `webview.isAvailable`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "webview.isAvailable",
|
||||
"params": {}
|
||||
}
|
||||
```
|
||||
|
||||
Success:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {
|
||||
"available": true
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
### `page.open`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "page.open",
|
||||
"params": {
|
||||
"headless": true,
|
||||
"debug": false,
|
||||
"title": "Gopeed WebView",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"userAgent": "Mozilla/5.0 ..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Success:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {
|
||||
"pageId": "page-1"
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
### `page.addInitScript`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "page.addInitScript",
|
||||
"params": {
|
||||
"pageId": "page-1",
|
||||
"script": "window.__READY__ = true;"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Success:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
### `page.goto`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "page.goto",
|
||||
"params": {
|
||||
"pageId": "page-1",
|
||||
"url": "https://example.com",
|
||||
"timeoutMs": 15000,
|
||||
"waitUntil": "domcontentloaded"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Success:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
`waitUntil` currently supports `load` and `domcontentloaded`. The default is `load`.
|
||||
|
||||
### `page.execute`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "page.execute",
|
||||
"params": {
|
||||
"pageId": "page-1",
|
||||
"expression": "document.title",
|
||||
"args": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Success:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": "Example Domain",
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
### `page.getCookies`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "page.getCookies",
|
||||
"params": {
|
||||
"pageId": "page-1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Success:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": [
|
||||
{
|
||||
"name": "session",
|
||||
"value": "abc",
|
||||
"domain": ".example.com",
|
||||
"path": "/",
|
||||
"expires": "2026-03-26T12:00:00Z",
|
||||
"secure": true,
|
||||
"httpOnly": true
|
||||
}
|
||||
],
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
### `page.setCookie`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "page.setCookie",
|
||||
"params": {
|
||||
"pageId": "page-1",
|
||||
"cookie": {
|
||||
"name": "session",
|
||||
"value": "abc",
|
||||
"domain": ".example.com",
|
||||
"path": "/",
|
||||
"expires": "2026-03-26T12:00:00Z",
|
||||
"secure": true,
|
||||
"httpOnly": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Success:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
### `page.deleteCookie`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "page.deleteCookie",
|
||||
"params": {
|
||||
"pageId": "page-1",
|
||||
"cookie": {
|
||||
"name": "session",
|
||||
"domain": ".example.com",
|
||||
"path": "/"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Success:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
### `page.clearCookies`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "page.clearCookies",
|
||||
"params": {
|
||||
"pageId": "page-1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Success:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
### `page.close`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "page.close",
|
||||
"params": {
|
||||
"pageId": "page-1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Success:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": {},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
## Method semantics
|
||||
|
||||
- `page.open`
|
||||
- Creates a page session
|
||||
- Returns `pageId`
|
||||
- `page.addInitScript`
|
||||
- Registers a page initialization script
|
||||
- `page.goto`
|
||||
- Goes to the target URL
|
||||
- The server should return only after navigation completes or fails
|
||||
- `page.execute`
|
||||
- Executes an expression or serialized function body
|
||||
- The returned value must be JSON-serializable
|
||||
- `page.getCookies`
|
||||
- Returns cookies from the underlying native WebView cookie store
|
||||
- `page.setCookie`
|
||||
- Inserts or updates a cookie
|
||||
- `page.deleteCookie`
|
||||
- Deletes a cookie using `name/domain/path`
|
||||
- `page.clearCookies`
|
||||
- Clears the current cookie store
|
||||
- `page.close`
|
||||
- Closes the page and releases related resources
|
||||
|
||||
## Cookie model
|
||||
|
||||
Cookies use `runtime/webview.Cookie`.
|
||||
|
||||
Example payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "session",
|
||||
"value": "abc",
|
||||
"domain": ".example.com",
|
||||
"path": "/",
|
||||
"expires": "2026-03-26T12:00:00Z",
|
||||
"secure": true,
|
||||
"httpOnly": true
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `expires` uses RFC3339 / RFC3339Nano string form.
|
||||
- If the host platform supports a full native cookie store, it should return fields such as `httpOnly` and `secure`.
|
||||
- `setCookie -> goto` is a valid pattern for preloading authenticated session state.
|
||||
|
||||
## Error codes
|
||||
|
||||
The current error code set is defined in `pkg/download/engine/webview/rpc.go`:
|
||||
|
||||
- `INVALID_REQUEST`
|
||||
- `UNKNOWN_METHOD`
|
||||
- `UNAVAILABLE`
|
||||
- `BROWSER_NOT_FOUND`
|
||||
- `PAGE_NOT_FOUND`
|
||||
- `NAVIGATION_FAILED`
|
||||
- `EVALUATION_FAILED`
|
||||
- `TIMEOUT`
|
||||
- `INTERNAL_ERROR`
|
||||
|
||||
`BROWSER_NOT_FOUND` is a historical leftover from an earlier shape of the protocol. The current protocol no longer has a separate browser model, so this can be cleaned up later if the protocol is tightened further.
|
||||
|
||||
## Current protocol boundary
|
||||
|
||||
This protocol is intentionally limited:
|
||||
|
||||
- Single-direction request/response only
|
||||
- Not REST-style resource routing
|
||||
- No bidirectional events
|
||||
- No streaming event subscriptions
|
||||
- No host-initiated push channel
|
||||
|
||||
If the protocol needs to evolve toward full JSON-RPC later, the intended migration path is to keep the existing `method/params/result/error` structure and add:
|
||||
|
||||
- `id`
|
||||
- notifications
|
||||
- a bidirectional transport model
|
||||
|
||||
## Host implementation guidance
|
||||
|
||||
A host implementation should usually:
|
||||
|
||||
- Listen on a local `tcp` address or `unix socket`
|
||||
- Expose `POST /webview`
|
||||
- Validate the bearer token if configured
|
||||
- Maintain `pageId -> native webview/session`
|
||||
- Own page lifecycle, cleanup, and timeout handling
|
||||
|
||||
With that split, Gopeed stays a pure RPC client and does not need to know how the host's native WebView is implemented.
|
||||
@@ -0,0 +1,195 @@
|
||||
package rpcprovider
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
)
|
||||
|
||||
type Provider struct {
|
||||
client *Client
|
||||
}
|
||||
|
||||
func New(cfg enginewebview.RPCConfig) enginewebview.Provider {
|
||||
return &Provider{
|
||||
client: NewClient(cfg),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) IsAvailable() bool {
|
||||
if p == nil || p.client == nil || !p.client.Enabled() {
|
||||
return false
|
||||
}
|
||||
var result enginewebview.IsAvailableResult
|
||||
if err := p.client.Call(enginewebview.MethodIsAvailable, enginewebview.IsAvailableParams{}, &result); err != nil {
|
||||
return false
|
||||
}
|
||||
return result.Available
|
||||
}
|
||||
|
||||
func (p *Provider) Open(opts enginewebview.OpenOptions) (enginewebview.Page, error) {
|
||||
if p == nil || p.client == nil || !p.IsAvailable() {
|
||||
return nil, enginewebview.ErrUnavailable
|
||||
}
|
||||
var result enginewebview.PageOpenResult
|
||||
if err := p.client.Call(enginewebview.MethodPageOpen, enginewebview.NewPageOpenParams(opts), &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &page{
|
||||
client: p.client,
|
||||
id: result.PageID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type page struct {
|
||||
client *Client
|
||||
id string
|
||||
}
|
||||
|
||||
func (p *page) AddInitScript(script string) error {
|
||||
return p.client.Call(enginewebview.MethodPageAddInitScript, enginewebview.PageAddInitScriptParams{
|
||||
PageID: p.id,
|
||||
Script: script,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func (p *page) Goto(url string, opts enginewebview.GotoOptions) error {
|
||||
return p.client.Call(enginewebview.MethodPageGoto, enginewebview.NewPageGotoParams(p.id, url, opts), nil)
|
||||
}
|
||||
|
||||
func (p *page) Execute(expression string, args ...any) (any, error) {
|
||||
var result any
|
||||
if err := p.client.Call(enginewebview.MethodPageExecute, enginewebview.PageExecuteParams{
|
||||
PageID: p.id,
|
||||
Expression: expression,
|
||||
Args: args,
|
||||
}, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *page) GetCookies() ([]enginewebview.Cookie, error) {
|
||||
var result []enginewebview.Cookie
|
||||
if err := p.client.Call(enginewebview.MethodPageGetCookies, enginewebview.PageGetCookiesParams{
|
||||
PageID: p.id,
|
||||
}, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result == nil {
|
||||
return []enginewebview.Cookie{}, nil
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *page) SetCookie(cookie enginewebview.Cookie) error {
|
||||
return p.client.Call(enginewebview.MethodPageSetCookie, enginewebview.PageSetCookieParams{
|
||||
PageID: p.id,
|
||||
Cookie: cookie,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func (p *page) DeleteCookie(cookie enginewebview.Cookie) error {
|
||||
return p.client.Call(enginewebview.MethodPageDeleteCookie, enginewebview.PageDeleteCookieParams{
|
||||
PageID: p.id,
|
||||
Cookie: cookie,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func (p *page) ClearCookies() error {
|
||||
return p.client.Call(enginewebview.MethodPageClearCookies, enginewebview.PageClearCookiesParams{
|
||||
PageID: p.id,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func (p *page) Close() error {
|
||||
return p.client.Call(enginewebview.MethodPageClose, enginewebview.PageCloseParams{
|
||||
PageID: p.id,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
endpoint string
|
||||
token string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func NewClient(cfg enginewebview.RPCConfig) *Client {
|
||||
client := &Client{token: cfg.Token}
|
||||
if !cfg.Enabled() {
|
||||
return client
|
||||
}
|
||||
switch strings.ToLower(cfg.Network) {
|
||||
case "unix":
|
||||
client.endpoint = "http://unix" + enginewebview.RPCEndpointPath
|
||||
client.http = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, "unix", cfg.Address)
|
||||
},
|
||||
},
|
||||
}
|
||||
default:
|
||||
client.endpoint = "http://" + cfg.Address + enginewebview.RPCEndpointPath
|
||||
client.http = &http.Client{}
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
func (c *Client) Enabled() bool {
|
||||
return c != nil && c.endpoint != "" && c.http != nil
|
||||
}
|
||||
|
||||
func (c *Client) Call(method string, params any, result any) error {
|
||||
if !c.Enabled() {
|
||||
return enginewebview.ErrUnavailable
|
||||
}
|
||||
payload, err := json.Marshal(enginewebview.RPCRequest{
|
||||
Method: method,
|
||||
Params: params,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, c.endpoint, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("webview rpc status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var body enginewebview.RPCResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return err
|
||||
}
|
||||
if body.Error != nil {
|
||||
return body.Error
|
||||
}
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
if len(body.Result) == 0 || string(body.Result) == "null" {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(body.Result, result)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//go:build webview && darwin
|
||||
|
||||
package rpcprovider
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/webview/integrationtest"
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
)
|
||||
|
||||
var (
|
||||
webViewRPCNetwork = flag.String("webview-rpc-network", "unix", "WebView RPC network for integration tests")
|
||||
webViewRPCAddress = flag.String("webview-rpc-address", defaultWebViewRPCAddress(), "WebView RPC address for integration tests")
|
||||
webViewRPCToken = flag.String("webview-rpc-token", "", "WebView RPC token for integration tests")
|
||||
)
|
||||
|
||||
func TestProviderContract(t *testing.T) {
|
||||
provider := New(enginewebview.RPCConfig{
|
||||
Network: *webViewRPCNetwork,
|
||||
Address: *webViewRPCAddress,
|
||||
Token: *webViewRPCToken,
|
||||
})
|
||||
integrationtest.RunProviderContract(t, provider, integrationtest.ContractOptions{
|
||||
AvailabilityTimeout: 5 * time.Minute,
|
||||
CookieDomainMode: integrationtest.CookieDomainModeRequired,
|
||||
CookieTestURL: "https://example.com/",
|
||||
})
|
||||
}
|
||||
|
||||
func defaultWebViewRPCAddress() string {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(homeDir, "Library", "Application Support", "com.gopeed.gopeed", "gopeed_webview.sock")
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package rpcprovider
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
)
|
||||
|
||||
func TestProviderIsAvailableOverUnixSocket(t *testing.T) {
|
||||
socketPath := filepath.Join("/tmp", "gopeed-rpcwebview-"+t.Name()+".sock")
|
||||
listener, err := net.Listen("unix", socketPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
defer os.Remove(socketPath)
|
||||
|
||||
server := &http.Server{
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req enginewebview.RPCRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if req.Method != enginewebview.MethodIsAvailable {
|
||||
t.Fatalf("unexpected unix method: %s", req.Method)
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||
"result": enginewebview.IsAvailableResult{Available: true},
|
||||
"error": nil,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}),
|
||||
}
|
||||
defer server.Close()
|
||||
go server.Serve(listener)
|
||||
|
||||
provider := New(enginewebview.RPCConfig{
|
||||
Network: "unix",
|
||||
Address: socketPath,
|
||||
})
|
||||
if !provider.IsAvailable() {
|
||||
t.Fatal("expected unix provider to be available")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package rpcprovider
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
)
|
||||
|
||||
func TestProviderLifecycleOverHTTP(t *testing.T) {
|
||||
var calls []enginewebview.RPCRequest
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("unexpected method: %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != enginewebview.RPCEndpointPath {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer secret-token" {
|
||||
t.Fatalf("unexpected auth header: %q", got)
|
||||
}
|
||||
var req enginewebview.RPCRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
calls = append(calls, req)
|
||||
|
||||
var body any
|
||||
switch req.Method {
|
||||
case enginewebview.MethodIsAvailable:
|
||||
body = map[string]any{
|
||||
"result": enginewebview.IsAvailableResult{Available: true},
|
||||
"error": nil,
|
||||
}
|
||||
case enginewebview.MethodPageOpen:
|
||||
body = map[string]any{
|
||||
"result": enginewebview.PageOpenResult{PageID: "page-open-1"},
|
||||
"error": nil,
|
||||
}
|
||||
case enginewebview.MethodPageAddInitScript, enginewebview.MethodPageGoto:
|
||||
body = map[string]any{
|
||||
"result": enginewebview.EmptyResult{},
|
||||
"error": nil,
|
||||
}
|
||||
case enginewebview.MethodPageExecute:
|
||||
body = map[string]any{
|
||||
"result": map[string]any{"title": "Example"},
|
||||
"error": nil,
|
||||
}
|
||||
case enginewebview.MethodPageClose:
|
||||
body = map[string]any{
|
||||
"result": enginewebview.EmptyResult{},
|
||||
"error": nil,
|
||||
}
|
||||
default:
|
||||
t.Fatalf("unexpected method: %s", req.Method)
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := enginewebview.RPCConfig{
|
||||
Network: "tcp",
|
||||
Address: server.Listener.Addr().String(),
|
||||
Token: "secret-token",
|
||||
}
|
||||
provider := New(cfg)
|
||||
if !provider.IsAvailable() {
|
||||
t.Fatal("expected provider to be available")
|
||||
}
|
||||
|
||||
opener, ok := provider.(enginewebview.Opener)
|
||||
if !ok {
|
||||
t.Fatal("expected provider to implement runtime webview opener")
|
||||
}
|
||||
opened, err := opener.Open(enginewebview.OpenOptions{
|
||||
Headless: true,
|
||||
Width: 1280,
|
||||
Height: 720,
|
||||
Title: "Gopeed",
|
||||
UserAgent: "UA",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := opened.AddInitScript("window.__TEST__ = true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := opened.Goto("https://example.com", enginewebview.GotoOptions{
|
||||
TimeoutMS: 3210,
|
||||
WaitUntil: "domcontentloaded",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value, err := opened.Execute("document.title")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, ok := value.(map[string]any)
|
||||
if !ok || got["title"] != "Example" {
|
||||
t.Fatalf("unexpected evaluate result: %#v", value)
|
||||
}
|
||||
if err := opened.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expectedMethods := []string{
|
||||
enginewebview.MethodIsAvailable,
|
||||
enginewebview.MethodIsAvailable,
|
||||
enginewebview.MethodPageOpen,
|
||||
enginewebview.MethodPageAddInitScript,
|
||||
enginewebview.MethodPageGoto,
|
||||
enginewebview.MethodPageExecute,
|
||||
enginewebview.MethodPageClose,
|
||||
}
|
||||
if len(calls) != len(expectedMethods) {
|
||||
t.Fatalf("unexpected call count: got %d want %d", len(calls), len(expectedMethods))
|
||||
}
|
||||
for i, method := range expectedMethods {
|
||||
if calls[i].Method != method {
|
||||
t.Fatalf("unexpected method at %d: got %s want %s", i, calls[i].Method, method)
|
||||
}
|
||||
}
|
||||
gotoParamsJSON, err := json.Marshal(calls[4].Params)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var gotoParams enginewebview.PageGotoParams
|
||||
if err := json.Unmarshal(gotoParamsJSON, &gotoParams); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotoParams.WaitUntil != "domcontentloaded" {
|
||||
t.Fatalf("unexpected waitUntil: %q", gotoParams.WaitUntil)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderLaunchUnavailableWithoutEndpoint(t *testing.T) {
|
||||
provider := New(enginewebview.RPCConfig{})
|
||||
if provider.IsAvailable() {
|
||||
t.Fatal("expected empty provider to be unavailable")
|
||||
}
|
||||
opener, ok := provider.(enginewebview.Opener)
|
||||
if !ok {
|
||||
t.Fatal("expected provider to implement runtime webview opener")
|
||||
}
|
||||
if _, err := opener.Open(enginewebview.OpenOptions{}); err == nil || err.Error() != enginewebview.ErrUnavailable.Error() {
|
||||
t.Fatalf("unexpected launch error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageSetCookieParamsOmitsZeroExpires(t *testing.T) {
|
||||
params := enginewebview.PageSetCookieParams{
|
||||
PageID: "page-1",
|
||||
Cookie: enginewebview.Cookie{
|
||||
Name: "visible",
|
||||
Value: "1",
|
||||
Domain: "example.com",
|
||||
Path: "/",
|
||||
},
|
||||
}
|
||||
payload, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(payload) == "" {
|
||||
t.Fatal("expected non-empty json payload")
|
||||
}
|
||||
if got := string(payload); strings.Contains(got, `"expires"`) {
|
||||
t.Fatalf("unexpected zero expires in payload: %s", got)
|
||||
}
|
||||
|
||||
params.Cookie.Expires = time.Now().UTC().Truncate(time.Second)
|
||||
payload, err = json.Marshal(params)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := string(payload); !strings.Contains(got, `"expires"`) {
|
||||
t.Fatalf("expected expires in payload: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -15,11 +15,15 @@ const (
|
||||
HttpCodeOK = 200
|
||||
HttpCodePartialContent = 206
|
||||
|
||||
HttpHeaderHost = "Host"
|
||||
HttpHeaderRange = "Range"
|
||||
HttpHeaderAcceptRanges = "Accept-Ranges"
|
||||
HttpHeaderContentLength = "Content-Length"
|
||||
HttpHeaderContentRange = "Content-Range"
|
||||
HttpHeaderContentDisposition = "Content-Disposition"
|
||||
HttpHeaderUserAgent = "Usr-Agent"
|
||||
HttpHeaderUserAgent = "User-Agent"
|
||||
HttpHeaderLastModified = "Last-Modified"
|
||||
|
||||
HttpHeaderBytes = "bytes"
|
||||
HttpHeaderRangeFormat = "bytes=%d-%d"
|
||||
)
|
||||
@@ -1,8 +0,0 @@
|
||||
package base
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
NotFound = errors.New("not found")
|
||||
BadParams = errors.New("bad params")
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
package base
|
||||
|
||||
// Version is the build version, set at build time, using `go build -ldflags "-X github.com/GopeedLab/gopeed/pkg/base.Version=1.0.0"`.
|
||||
var Version string
|
||||
var InDocker string
|
||||
|
||||
func init() {
|
||||
if Version == "" {
|
||||
Version = "dev"
|
||||
}
|
||||
}
|
||||
+258
-14
@@ -2,16 +2,45 @@ package base
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/util"
|
||||
"github.com/mattn/go-ieproxy"
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
// Request download request
|
||||
type Request struct {
|
||||
URL string `json:"url"`
|
||||
Extra any `json:"extra"`
|
||||
RawURL string `json:"rawUrl"`
|
||||
URL string `json:"url"`
|
||||
Extra any `json:"extra"`
|
||||
// Labels is used to mark the download task
|
||||
Labels map[string]string `json:"labels"`
|
||||
// Proxy is special proxy config for request
|
||||
Proxy *RequestProxy `json:"proxy"`
|
||||
// SkipVerifyCert is the flag that skip verify cert
|
||||
SkipVerifyCert bool `json:"skipVerifyCert"`
|
||||
}
|
||||
|
||||
// SetLabels replaces all labels on the request.
|
||||
func (r *Request) SetLabels(labels map[string]string) {
|
||||
r.Labels = labels
|
||||
}
|
||||
|
||||
// PutLabel sets a label on the request.
|
||||
func (r *Request) PutLabel(key, value string) {
|
||||
if r.Labels == nil {
|
||||
r.Labels = make(map[string]string)
|
||||
}
|
||||
r.Labels[key] = value
|
||||
}
|
||||
|
||||
// DelLabel deletes a label from the request.
|
||||
func (r *Request) DelLabel(key string) {
|
||||
delete(r.Labels, key)
|
||||
}
|
||||
|
||||
func (r *Request) Validate() error {
|
||||
@@ -21,6 +50,37 @@ func (r *Request) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type RequestProxyMode string
|
||||
|
||||
const (
|
||||
// RequestProxyModeFollow follow setting proxy
|
||||
RequestProxyModeFollow RequestProxyMode = "follow"
|
||||
// RequestProxyModeNone not use proxy
|
||||
RequestProxyModeNone RequestProxyMode = "none"
|
||||
// RequestProxyModeCustom custom proxy
|
||||
RequestProxyModeCustom RequestProxyMode = "custom"
|
||||
)
|
||||
|
||||
type RequestProxy struct {
|
||||
Mode RequestProxyMode `json:"mode"`
|
||||
Scheme string `json:"scheme"`
|
||||
Host string `json:"host"`
|
||||
Usr string `json:"usr"`
|
||||
Pwd string `json:"pwd"`
|
||||
}
|
||||
|
||||
func (p *RequestProxy) ToHandler() func(r *http.Request) (*url.URL, error) {
|
||||
if p == nil || p.Mode != RequestProxyModeCustom {
|
||||
return nil
|
||||
}
|
||||
|
||||
if p.Scheme == "" || p.Host == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return http.ProxyURL(util.BuildProxyUrl(p.Scheme, p.Host, p.Usr, p.Pwd))
|
||||
}
|
||||
|
||||
// Resource download resource
|
||||
type Resource struct {
|
||||
// if name is not empty, the resource is a folder and the name is the folder name
|
||||
@@ -37,7 +97,7 @@ func (r *Resource) Validate() error {
|
||||
if r.Name == "" {
|
||||
return fmt.Errorf("invalid resource name")
|
||||
}
|
||||
if r.Files == nil || len(r.Files) == 0 {
|
||||
if len(r.Files) == 0 {
|
||||
return fmt.Errorf("invalid resource files")
|
||||
}
|
||||
for _, file := range r.Files {
|
||||
@@ -59,9 +119,10 @@ func (r *Resource) CalcSize(selectFiles []int) {
|
||||
}
|
||||
|
||||
type FileInfo struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
Ctime *time.Time `json:"ctime"`
|
||||
|
||||
Req *Request `json:"req"`
|
||||
}
|
||||
@@ -82,19 +143,14 @@ func (o *Options) InitSelectFiles(fileSize int) {
|
||||
// if selectFiles is empty, select all files
|
||||
if len(o.SelectFiles) == 0 {
|
||||
o.SelectFiles = make([]int, fileSize)
|
||||
for i := 0; i < fileSize; i++ {
|
||||
for i := range fileSize {
|
||||
o.SelectFiles[i] = i
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (o *Options) Clone() *Options {
|
||||
return &Options{
|
||||
Name: o.Name,
|
||||
Path: o.Path,
|
||||
SelectFiles: o.SelectFiles,
|
||||
Extra: o.Extra,
|
||||
}
|
||||
return util.DeepClone(o)
|
||||
}
|
||||
|
||||
func ParseReqExtra[E any](req *Request) error {
|
||||
@@ -112,7 +168,7 @@ func ParseReqExtra[E any](req *Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func ParseOptsExtra[E any](opts *Options) error {
|
||||
func ParseOptExtra[E any](opts *Options) error {
|
||||
if opts.Extra == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -126,3 +182,191 @@ func ParseOptsExtra[E any](opts *Options) error {
|
||||
opts.Extra = &t
|
||||
return nil
|
||||
}
|
||||
|
||||
type CreateTaskBatch struct {
|
||||
Reqs []*CreateTaskBatchItem `json:"reqs"`
|
||||
Opts *Options `json:"opts"`
|
||||
}
|
||||
|
||||
type CreateTaskBatchItem struct {
|
||||
Req *Request `json:"req"`
|
||||
Opts *Options `json:"opts"`
|
||||
}
|
||||
|
||||
// DownloaderStoreConfig is the config that can restore the downloader.
|
||||
type DownloaderStoreConfig struct {
|
||||
FirstLoad bool `json:"-"` // FirstLoad is the flag that the config is first time init and not from store
|
||||
|
||||
DownloadDir string `json:"downloadDir"` // DownloadDir is the default directory to save the downloaded files
|
||||
MaxRunning int `json:"maxRunning"` // MaxRunning is the max running download count
|
||||
ProtocolConfig map[string]any `json:"protocolConfig"` // ProtocolConfig is special config for each protocol
|
||||
Extra map[string]any `json:"extra"`
|
||||
Proxy *DownloaderProxyConfig `json:"proxy"`
|
||||
Webhook *WebhookConfig `json:"webhook"` // Webhook is the webhook configuration
|
||||
Script *ScriptConfig `json:"script"` // Script is the script execution configuration
|
||||
AutoTorrent *AutoTorrentConfig `json:"autoTorrent"` // AutoTorrent is the auto torrent task creation configuration
|
||||
Archive *ArchiveConfig `json:"archive"` // Archive is the archive extraction configuration
|
||||
AutoDeleteMissingFileTasks bool `json:"autoDeleteMissingFileTasks"` // AutoDeleteMissingFileTasks enables automatic deletion of tasks with missing files
|
||||
}
|
||||
|
||||
func (cfg *DownloaderStoreConfig) Init() *DownloaderStoreConfig {
|
||||
if cfg.MaxRunning == 0 {
|
||||
cfg.MaxRunning = 5
|
||||
}
|
||||
if cfg.ProtocolConfig == nil {
|
||||
cfg.ProtocolConfig = make(map[string]any)
|
||||
}
|
||||
if cfg.Proxy == nil {
|
||||
cfg.Proxy = &DownloaderProxyConfig{}
|
||||
}
|
||||
if cfg.Webhook == nil {
|
||||
cfg.Webhook = &WebhookConfig{}
|
||||
}
|
||||
if cfg.Script == nil {
|
||||
cfg.Script = &ScriptConfig{}
|
||||
}
|
||||
if cfg.AutoTorrent == nil {
|
||||
cfg.AutoTorrent = &AutoTorrentConfig{
|
||||
Enable: false,
|
||||
DeleteAfterDownload: false,
|
||||
}
|
||||
}
|
||||
if cfg.Archive == nil {
|
||||
cfg.Archive = &ArchiveConfig{
|
||||
AutoExtract: false,
|
||||
DeleteAfterExtract: false,
|
||||
}
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func (cfg *DownloaderStoreConfig) Merge(beforeCfg *DownloaderStoreConfig) *DownloaderStoreConfig {
|
||||
if beforeCfg == nil {
|
||||
return cfg
|
||||
}
|
||||
if cfg.DownloadDir == "" {
|
||||
cfg.DownloadDir = beforeCfg.DownloadDir
|
||||
}
|
||||
if cfg.MaxRunning == 0 {
|
||||
cfg.MaxRunning = beforeCfg.MaxRunning
|
||||
}
|
||||
if cfg.ProtocolConfig == nil {
|
||||
cfg.ProtocolConfig = beforeCfg.ProtocolConfig
|
||||
}
|
||||
if cfg.Extra == nil {
|
||||
cfg.Extra = beforeCfg.Extra
|
||||
}
|
||||
if cfg.Proxy == nil {
|
||||
cfg.Proxy = beforeCfg.Proxy
|
||||
}
|
||||
if cfg.Webhook == nil {
|
||||
cfg.Webhook = beforeCfg.Webhook
|
||||
}
|
||||
if cfg.Script == nil {
|
||||
cfg.Script = beforeCfg.Script
|
||||
}
|
||||
if cfg.AutoTorrent == nil {
|
||||
cfg.AutoTorrent = beforeCfg.AutoTorrent
|
||||
}
|
||||
if cfg.Archive == nil {
|
||||
cfg.Archive = beforeCfg.Archive
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// WebhookConfig is the webhook configuration
|
||||
type WebhookConfig struct {
|
||||
Enable bool `json:"enable"` // Enable is the flag to enable/disable webhooks
|
||||
URLs []string `json:"urls"` // URLs is the list of webhook URLs
|
||||
}
|
||||
|
||||
// ScriptConfig is the script execution configuration
|
||||
type ScriptConfig struct {
|
||||
Enable bool `json:"enable"` // Enable is the flag to enable/disable script execution
|
||||
Paths []string `json:"paths"` // Paths is the list of script paths to execute
|
||||
}
|
||||
|
||||
// AutoTorrentConfig is the auto torrent task creation configuration
|
||||
type AutoTorrentConfig struct {
|
||||
Enable bool `json:"enable"` // Enable enables automatic BT task creation when downloading .torrent files
|
||||
DeleteAfterDownload bool `json:"deleteAfterDownload"` // DeleteAfterDownload deletes the .torrent file after BT task creation
|
||||
}
|
||||
|
||||
// ArchiveConfig is the archive extraction configuration
|
||||
type ArchiveConfig struct {
|
||||
AutoExtract bool `json:"autoExtract"` // AutoExtract enables automatic extraction of archives after download
|
||||
DeleteAfterExtract bool `json:"deleteAfterExtract"` // DeleteAfterExtract deletes the archive after successful extraction
|
||||
}
|
||||
|
||||
type DownloaderProxyConfig struct {
|
||||
Enable bool `json:"enable"`
|
||||
// System is the flag that use system proxy
|
||||
System bool `json:"system"`
|
||||
Scheme string `json:"scheme"`
|
||||
Host string `json:"host"`
|
||||
Usr string `json:"usr"`
|
||||
Pwd string `json:"pwd"`
|
||||
}
|
||||
|
||||
func (cfg *DownloaderProxyConfig) ToHandler() func(r *http.Request) (*url.URL, error) {
|
||||
if cfg == nil || cfg.Enable == false {
|
||||
return nil
|
||||
}
|
||||
if cfg.System {
|
||||
safeProxyReloadConf()
|
||||
return ieproxy.GetProxyFunc()
|
||||
}
|
||||
if cfg.Scheme == "" || cfg.Host == "" {
|
||||
return nil
|
||||
}
|
||||
return http.ProxyURL(util.BuildProxyUrl(cfg.Scheme, cfg.Host, cfg.Usr, cfg.Pwd))
|
||||
}
|
||||
|
||||
// ToUrl returns the proxy url, just for git clone
|
||||
func (cfg *DownloaderProxyConfig) ToUrl() *url.URL {
|
||||
if cfg == nil || cfg.Enable == false {
|
||||
return nil
|
||||
}
|
||||
if cfg.System {
|
||||
safeProxyReloadConf()
|
||||
static := ieproxy.GetConf().Static
|
||||
if static.Active && len(static.Protocols) > 0 {
|
||||
// If only one protocol, use it
|
||||
if len(static.Protocols) == 1 {
|
||||
for _, v := range static.Protocols {
|
||||
return parseUrlSafe(v)
|
||||
}
|
||||
}
|
||||
// Check https
|
||||
if v, ok := static.Protocols["https"]; ok {
|
||||
return parseUrlSafe(v)
|
||||
}
|
||||
// Check http
|
||||
if v, ok := static.Protocols["http"]; ok {
|
||||
return parseUrlSafe(v)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if cfg.Scheme == "" || cfg.Host == "" {
|
||||
return nil
|
||||
}
|
||||
return util.BuildProxyUrl(cfg.Scheme, cfg.Host, cfg.Usr, cfg.Pwd)
|
||||
}
|
||||
|
||||
var prcLock sync.Mutex
|
||||
|
||||
func safeProxyReloadConf() {
|
||||
prcLock.Lock()
|
||||
defer prcLock.Unlock()
|
||||
|
||||
ieproxy.ReloadConf()
|
||||
}
|
||||
|
||||
func parseUrlSafe(rawUrl string) *url.URL {
|
||||
u, err := url.Parse(rawUrl)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return u
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
package base
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDownloaderStoreConfig_Init(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fields *DownloaderStoreConfig
|
||||
want *DownloaderStoreConfig
|
||||
}{
|
||||
{
|
||||
"Init",
|
||||
&DownloaderStoreConfig{},
|
||||
&DownloaderStoreConfig{
|
||||
MaxRunning: 5,
|
||||
ProtocolConfig: map[string]any{},
|
||||
Proxy: &DownloaderProxyConfig{},
|
||||
Webhook: &WebhookConfig{},
|
||||
Script: &ScriptConfig{},
|
||||
AutoTorrent: &AutoTorrentConfig{
|
||||
Enable: false,
|
||||
DeleteAfterDownload: false,
|
||||
},
|
||||
Archive: &ArchiveConfig{
|
||||
AutoExtract: false,
|
||||
DeleteAfterExtract: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"Init MaxRunning",
|
||||
&DownloaderStoreConfig{
|
||||
MaxRunning: 10,
|
||||
},
|
||||
&DownloaderStoreConfig{
|
||||
MaxRunning: 10,
|
||||
ProtocolConfig: map[string]any{},
|
||||
Proxy: &DownloaderProxyConfig{},
|
||||
Webhook: &WebhookConfig{},
|
||||
Script: &ScriptConfig{},
|
||||
AutoTorrent: &AutoTorrentConfig{
|
||||
Enable: false,
|
||||
DeleteAfterDownload: false,
|
||||
},
|
||||
Archive: &ArchiveConfig{
|
||||
AutoExtract: false,
|
||||
DeleteAfterExtract: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"Init ProtocolConfig",
|
||||
&DownloaderStoreConfig{
|
||||
ProtocolConfig: map[string]any{
|
||||
"key": "value",
|
||||
},
|
||||
},
|
||||
&DownloaderStoreConfig{
|
||||
MaxRunning: 5,
|
||||
ProtocolConfig: map[string]any{
|
||||
"key": "value",
|
||||
},
|
||||
Proxy: &DownloaderProxyConfig{},
|
||||
Webhook: &WebhookConfig{},
|
||||
Script: &ScriptConfig{},
|
||||
AutoTorrent: &AutoTorrentConfig{
|
||||
Enable: false,
|
||||
DeleteAfterDownload: false,
|
||||
},
|
||||
Archive: &ArchiveConfig{
|
||||
AutoExtract: false,
|
||||
DeleteAfterExtract: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"Init Proxy",
|
||||
&DownloaderStoreConfig{
|
||||
Proxy: &DownloaderProxyConfig{
|
||||
Enable: true,
|
||||
},
|
||||
},
|
||||
&DownloaderStoreConfig{
|
||||
MaxRunning: 5,
|
||||
ProtocolConfig: map[string]any{},
|
||||
Proxy: &DownloaderProxyConfig{
|
||||
Enable: true,
|
||||
},
|
||||
Webhook: &WebhookConfig{},
|
||||
Script: &ScriptConfig{},
|
||||
AutoTorrent: &AutoTorrentConfig{
|
||||
Enable: false,
|
||||
DeleteAfterDownload: false,
|
||||
},
|
||||
Archive: &ArchiveConfig{
|
||||
AutoExtract: false,
|
||||
DeleteAfterExtract: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"Init AutoTorrent",
|
||||
&DownloaderStoreConfig{
|
||||
AutoTorrent: &AutoTorrentConfig{
|
||||
Enable: true,
|
||||
DeleteAfterDownload: true,
|
||||
},
|
||||
},
|
||||
&DownloaderStoreConfig{
|
||||
MaxRunning: 5,
|
||||
ProtocolConfig: map[string]any{},
|
||||
Proxy: &DownloaderProxyConfig{},
|
||||
Webhook: &WebhookConfig{},
|
||||
Script: &ScriptConfig{},
|
||||
AutoTorrent: &AutoTorrentConfig{
|
||||
Enable: true,
|
||||
DeleteAfterDownload: true,
|
||||
},
|
||||
Archive: &ArchiveConfig{
|
||||
AutoExtract: false,
|
||||
DeleteAfterExtract: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"Init Archive",
|
||||
&DownloaderStoreConfig{
|
||||
Archive: &ArchiveConfig{
|
||||
AutoExtract: true,
|
||||
DeleteAfterExtract: false,
|
||||
},
|
||||
},
|
||||
&DownloaderStoreConfig{
|
||||
MaxRunning: 5,
|
||||
ProtocolConfig: map[string]any{},
|
||||
Proxy: &DownloaderProxyConfig{},
|
||||
Webhook: &WebhookConfig{},
|
||||
Script: &ScriptConfig{},
|
||||
AutoTorrent: &AutoTorrentConfig{
|
||||
Enable: false,
|
||||
DeleteAfterDownload: false,
|
||||
},
|
||||
Archive: &ArchiveConfig{
|
||||
AutoExtract: true,
|
||||
DeleteAfterExtract: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := &DownloaderStoreConfig{
|
||||
FirstLoad: tt.fields.FirstLoad,
|
||||
DownloadDir: tt.fields.DownloadDir,
|
||||
MaxRunning: tt.fields.MaxRunning,
|
||||
ProtocolConfig: tt.fields.ProtocolConfig,
|
||||
Extra: tt.fields.Extra,
|
||||
Proxy: tt.fields.Proxy,
|
||||
Webhook: tt.fields.Webhook,
|
||||
Script: tt.fields.Script,
|
||||
AutoTorrent: tt.fields.AutoTorrent,
|
||||
Archive: tt.fields.Archive,
|
||||
}
|
||||
if got := cfg.Init(); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("Init() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloaderStoreConfig_Merge(t *testing.T) {
|
||||
type args struct {
|
||||
beforeCfg *DownloaderStoreConfig
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
fields *DownloaderStoreConfig
|
||||
args args
|
||||
want *DownloaderStoreConfig
|
||||
}{
|
||||
{
|
||||
"Merge Nil",
|
||||
&DownloaderStoreConfig{},
|
||||
args{
|
||||
beforeCfg: nil,
|
||||
},
|
||||
&DownloaderStoreConfig{},
|
||||
},
|
||||
{
|
||||
"Merge DownloadDir No Override",
|
||||
&DownloaderStoreConfig{
|
||||
DownloadDir: "before",
|
||||
},
|
||||
args{
|
||||
beforeCfg: &DownloaderStoreConfig{
|
||||
DownloadDir: "after",
|
||||
},
|
||||
},
|
||||
&DownloaderStoreConfig{
|
||||
DownloadDir: "before",
|
||||
},
|
||||
},
|
||||
{
|
||||
"Merge DownloadDir Override",
|
||||
&DownloaderStoreConfig{},
|
||||
args{
|
||||
beforeCfg: &DownloaderStoreConfig{
|
||||
DownloadDir: "after",
|
||||
},
|
||||
},
|
||||
&DownloaderStoreConfig{
|
||||
DownloadDir: "after",
|
||||
},
|
||||
},
|
||||
{
|
||||
"Merge MaxRunning No Override",
|
||||
&DownloaderStoreConfig{
|
||||
MaxRunning: 1,
|
||||
},
|
||||
args{
|
||||
beforeCfg: &DownloaderStoreConfig{
|
||||
MaxRunning: 10,
|
||||
},
|
||||
},
|
||||
&DownloaderStoreConfig{
|
||||
MaxRunning: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
"Merge MaxRunning Override",
|
||||
&DownloaderStoreConfig{},
|
||||
args{
|
||||
beforeCfg: &DownloaderStoreConfig{
|
||||
MaxRunning: 10,
|
||||
},
|
||||
},
|
||||
&DownloaderStoreConfig{
|
||||
MaxRunning: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
"Merge ProtocolConfig No Override",
|
||||
&DownloaderStoreConfig{
|
||||
ProtocolConfig: map[string]any{},
|
||||
},
|
||||
args{
|
||||
beforeCfg: &DownloaderStoreConfig{
|
||||
ProtocolConfig: map[string]any{
|
||||
"key": "after",
|
||||
},
|
||||
},
|
||||
},
|
||||
&DownloaderStoreConfig{
|
||||
ProtocolConfig: map[string]any{},
|
||||
},
|
||||
},
|
||||
{
|
||||
"Merge ProtocolConfig Override",
|
||||
&DownloaderStoreConfig{},
|
||||
args{
|
||||
beforeCfg: &DownloaderStoreConfig{
|
||||
ProtocolConfig: map[string]any{
|
||||
"key": "after",
|
||||
},
|
||||
},
|
||||
},
|
||||
&DownloaderStoreConfig{
|
||||
ProtocolConfig: map[string]any{
|
||||
"key": "after",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"Merge Extra No Override",
|
||||
&DownloaderStoreConfig{
|
||||
Extra: map[string]any{},
|
||||
},
|
||||
args{
|
||||
beforeCfg: &DownloaderStoreConfig{
|
||||
Extra: map[string]any{
|
||||
"key": "after",
|
||||
},
|
||||
},
|
||||
},
|
||||
&DownloaderStoreConfig{
|
||||
Extra: map[string]any{},
|
||||
},
|
||||
},
|
||||
{
|
||||
"Merge Extra Override",
|
||||
&DownloaderStoreConfig{},
|
||||
args{
|
||||
beforeCfg: &DownloaderStoreConfig{
|
||||
Extra: map[string]any{
|
||||
"key": "after",
|
||||
},
|
||||
},
|
||||
},
|
||||
&DownloaderStoreConfig{
|
||||
Extra: map[string]any{
|
||||
"key": "after",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"Merge Proxy No Override",
|
||||
&DownloaderStoreConfig{
|
||||
Proxy: &DownloaderProxyConfig{},
|
||||
},
|
||||
args{
|
||||
beforeCfg: &DownloaderStoreConfig{
|
||||
Proxy: &DownloaderProxyConfig{
|
||||
Scheme: "http",
|
||||
},
|
||||
},
|
||||
},
|
||||
&DownloaderStoreConfig{
|
||||
Proxy: &DownloaderProxyConfig{},
|
||||
},
|
||||
},
|
||||
{
|
||||
"Merge Proxy Override",
|
||||
&DownloaderStoreConfig{},
|
||||
args{
|
||||
beforeCfg: &DownloaderStoreConfig{
|
||||
Proxy: &DownloaderProxyConfig{
|
||||
Scheme: "http",
|
||||
},
|
||||
},
|
||||
},
|
||||
&DownloaderStoreConfig{
|
||||
Proxy: &DownloaderProxyConfig{
|
||||
Scheme: "http",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"Merge AutoTorrent No Override",
|
||||
&DownloaderStoreConfig{
|
||||
AutoTorrent: &AutoTorrentConfig{
|
||||
Enable: true,
|
||||
},
|
||||
},
|
||||
args{
|
||||
beforeCfg: &DownloaderStoreConfig{
|
||||
AutoTorrent: &AutoTorrentConfig{
|
||||
Enable: false,
|
||||
DeleteAfterDownload: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
&DownloaderStoreConfig{
|
||||
AutoTorrent: &AutoTorrentConfig{
|
||||
Enable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"Merge AutoTorrent Override",
|
||||
&DownloaderStoreConfig{},
|
||||
args{
|
||||
beforeCfg: &DownloaderStoreConfig{
|
||||
AutoTorrent: &AutoTorrentConfig{
|
||||
Enable: true,
|
||||
DeleteAfterDownload: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
&DownloaderStoreConfig{
|
||||
AutoTorrent: &AutoTorrentConfig{
|
||||
Enable: true,
|
||||
DeleteAfterDownload: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"Merge Archive No Override",
|
||||
&DownloaderStoreConfig{
|
||||
Archive: &ArchiveConfig{
|
||||
AutoExtract: true,
|
||||
},
|
||||
},
|
||||
args{
|
||||
beforeCfg: &DownloaderStoreConfig{
|
||||
Archive: &ArchiveConfig{
|
||||
AutoExtract: false,
|
||||
DeleteAfterExtract: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
&DownloaderStoreConfig{
|
||||
Archive: &ArchiveConfig{
|
||||
AutoExtract: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"Merge Archive Override",
|
||||
&DownloaderStoreConfig{},
|
||||
args{
|
||||
beforeCfg: &DownloaderStoreConfig{
|
||||
Archive: &ArchiveConfig{
|
||||
AutoExtract: false,
|
||||
DeleteAfterExtract: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
&DownloaderStoreConfig{
|
||||
Archive: &ArchiveConfig{
|
||||
AutoExtract: false,
|
||||
DeleteAfterExtract: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := &DownloaderStoreConfig{
|
||||
FirstLoad: tt.fields.FirstLoad,
|
||||
DownloadDir: tt.fields.DownloadDir,
|
||||
MaxRunning: tt.fields.MaxRunning,
|
||||
ProtocolConfig: tt.fields.ProtocolConfig,
|
||||
Extra: tt.fields.Extra,
|
||||
Proxy: tt.fields.Proxy,
|
||||
Webhook: tt.fields.Webhook,
|
||||
AutoTorrent: tt.fields.AutoTorrent,
|
||||
Archive: tt.fields.Archive,
|
||||
}
|
||||
if got := cfg.Merge(tt.args.beforeCfg); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("Merge() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+1595
-286
File diff suppressed because it is too large.
Load diff
+3123
-57
File diff suppressed because it is too large.
Load diff
+143
-83
@@ -3,16 +3,19 @@ package engine
|
||||
import (
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
gojaerror "github.com/GopeedLab/gopeed/pkg/download/engine/inject/error"
|
||||
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/file"
|
||||
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/formdata"
|
||||
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/stream"
|
||||
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/vm"
|
||||
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/xhr"
|
||||
"github.com/dop251/goja"
|
||||
"github.com/dop251/goja_nodejs/eventloop"
|
||||
gojaurl "github.com/dop251/goja_nodejs/url"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed polyfill/out/index.js
|
||||
@@ -24,93 +27,118 @@ type Engine struct {
|
||||
Runtime *goja.Runtime
|
||||
}
|
||||
|
||||
type JSFunction func(goja.FunctionCall) goja.Value
|
||||
|
||||
// RunString executes the script and returns the go type value
|
||||
// if script result is promise, it will be resolved
|
||||
func (e *Engine) RunString(script string) (value any, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = r.(error)
|
||||
}
|
||||
}()
|
||||
|
||||
var result goja.Value
|
||||
e.loop.Run(func(runtime *goja.Runtime) {
|
||||
result, err = runtime.RunString(script)
|
||||
if err == nil {
|
||||
go e.await(result)
|
||||
}
|
||||
return e.runOnLoop(func(runtime *goja.Runtime) (goja.Value, error) {
|
||||
return runtime.RunString(script)
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return resolveResult(result)
|
||||
}
|
||||
|
||||
// CallFunction calls the function and returns the go type value
|
||||
// if function result is promise, it will be resolved
|
||||
func (e *Engine) CallFunction(fn goja.Callable, args ...any) (value any, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = r.(error)
|
||||
func (e *Engine) CallFunction(fn any, args ...any) (value any, err error) {
|
||||
return e.runOnLoop(func(runtime *goja.Runtime) (goja.Value, error) {
|
||||
var jsArgs []goja.Value
|
||||
for _, arg := range args {
|
||||
jsArgs = append(jsArgs, runtime.ToValue(arg))
|
||||
}
|
||||
}()
|
||||
|
||||
var result goja.Value
|
||||
e.loop.Run(func(runtime *goja.Runtime) {
|
||||
if args == nil {
|
||||
result, err = fn(nil)
|
||||
} else {
|
||||
var jsArgs []goja.Value
|
||||
for _, arg := range args {
|
||||
jsArgs = append(jsArgs, runtime.ToValue(arg))
|
||||
switch f := fn.(type) {
|
||||
case goja.Callable:
|
||||
if args == nil {
|
||||
return f(nil)
|
||||
}
|
||||
result, err = fn(nil, jsArgs...)
|
||||
}
|
||||
if err == nil {
|
||||
go e.await(result)
|
||||
return f(nil, jsArgs...)
|
||||
case JSFunction:
|
||||
return callExportedFunction(func(call goja.FunctionCall) goja.Value {
|
||||
return f(call)
|
||||
}, jsArgs...)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported function type: %T", fn)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return resolveResult(result)
|
||||
}
|
||||
|
||||
// loop.Run will hang if the script result has a non-stop code, such as setInterval.
|
||||
// This method will stop the event loop when the promise result is resolved.
|
||||
func (e *Engine) await(value any) {
|
||||
if value == nil {
|
||||
return
|
||||
func (e *Engine) runOnLoop(fn func(runtime *goja.Runtime) (goja.Value, error)) (any, error) {
|
||||
type result struct {
|
||||
value any
|
||||
err error
|
||||
}
|
||||
|
||||
if v, ok := value.(goja.Value); ok {
|
||||
// if result is promise, wait for it to be resolved
|
||||
if p, ok := v.Export().(*goja.Promise); ok {
|
||||
if p.State() != goja.PromiseStatePending {
|
||||
ch := make(chan result, 1)
|
||||
ok := e.loop.RunOnLoop(func(runtime *goja.Runtime) {
|
||||
var once sync.Once
|
||||
sendResult := func(res result) {
|
||||
once.Do(func() {
|
||||
ch <- res
|
||||
})
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
switch v := r.(type) {
|
||||
case error:
|
||||
sendResult(result{err: v})
|
||||
case goja.Value:
|
||||
sendResult(result{err: exportJSError(v)})
|
||||
default:
|
||||
sendResult(result{err: fmt.Errorf("panic: %v", r)})
|
||||
}
|
||||
}
|
||||
}()
|
||||
value, err := fn(runtime)
|
||||
if err != nil {
|
||||
sendResult(result{err: err})
|
||||
return
|
||||
}
|
||||
if p, ok := value.Export().(*goja.Promise); ok {
|
||||
switch p.State() {
|
||||
case goja.PromiseStateFulfilled:
|
||||
sendResult(result{value: exportJSValue(p.Result())})
|
||||
return
|
||||
case goja.PromiseStateRejected:
|
||||
sendResult(result{err: exportJSError(p.Result())})
|
||||
return
|
||||
}
|
||||
|
||||
// check promise state every 100 milliseconds, until it is resolved
|
||||
for {
|
||||
time.Sleep(time.Millisecond * 100)
|
||||
if p.State() == goja.PromiseStatePending {
|
||||
continue
|
||||
}
|
||||
break
|
||||
promiseObj := value.ToObject(runtime)
|
||||
thenVal := promiseObj.Get("then")
|
||||
thenFn, ok := goja.AssertFunction(thenVal)
|
||||
if !ok {
|
||||
sendResult(result{err: errors.New("promise.then is not callable")})
|
||||
return
|
||||
}
|
||||
|
||||
// stop the event loop
|
||||
e.loop.StopNoWait()
|
||||
onFulfilled := runtime.ToValue(func(call goja.FunctionCall) goja.Value {
|
||||
sendResult(result{value: exportJSValue(call.Argument(0))})
|
||||
return goja.Undefined()
|
||||
})
|
||||
onRejected := runtime.ToValue(func(call goja.FunctionCall) goja.Value {
|
||||
sendResult(result{err: exportJSError(call.Argument(0))})
|
||||
return goja.Undefined()
|
||||
})
|
||||
if _, err := thenFn(promiseObj, onFulfilled, onRejected); err != nil {
|
||||
sendResult(result{err: err})
|
||||
}
|
||||
return
|
||||
}
|
||||
sendResult(result{value: exportJSValue(value)})
|
||||
})
|
||||
if !ok {
|
||||
return nil, errors.New("engine loop terminated")
|
||||
}
|
||||
res := <-ch
|
||||
if res.err != nil {
|
||||
return nil, res.err
|
||||
}
|
||||
return res.value, nil
|
||||
}
|
||||
|
||||
func (e *Engine) Close() {
|
||||
e.loop.Stop()
|
||||
e.loop.Terminate()
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
ProxyURL *url.URL
|
||||
ProxyConfig *base.DownloaderProxyConfig
|
||||
StreamConfig *stream.Config
|
||||
}
|
||||
|
||||
func NewEngine(cfg *Config) *Engine {
|
||||
@@ -121,7 +149,10 @@ func NewEngine(cfg *Config) *Engine {
|
||||
engine := &Engine{
|
||||
loop: loop,
|
||||
}
|
||||
loop.Run(func(runtime *goja.Runtime) {
|
||||
loop.Start()
|
||||
done := make(chan struct{})
|
||||
loop.RunOnLoop(func(runtime *goja.Runtime) {
|
||||
defer close(done)
|
||||
engine.Runtime = runtime
|
||||
runtime.SetFieldNameMapper(goja.TagFieldNameMapper("json", true))
|
||||
vm.Enable(runtime)
|
||||
@@ -135,7 +166,7 @@ func NewEngine(cfg *Config) *Engine {
|
||||
if err := formdata.Enable(runtime); err != nil {
|
||||
return
|
||||
}
|
||||
if err := xhr.Enable(runtime, cfg.ProxyURL); err != nil {
|
||||
if err := xhr.Enable(runtime, cfg.ProxyConfig.ToHandler()); err != nil {
|
||||
return
|
||||
}
|
||||
if _, err := runtime.RunString(polyfillScript); err != nil {
|
||||
@@ -145,12 +176,20 @@ func NewEngine(cfg *Config) *Engine {
|
||||
if err := runtime.Set("global", runtime.GlobalObject()); err != nil {
|
||||
return
|
||||
}
|
||||
// polyfill window
|
||||
if err := runtime.Set("window", runtime.GlobalObject()); err != nil {
|
||||
return
|
||||
}
|
||||
// polyfill window.location
|
||||
if _, err := runtime.RunString("global.location = new URL('http://localhost');"); err != nil {
|
||||
return
|
||||
}
|
||||
if err := stream.Enable(runtime, loop, cfg.StreamConfig); err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
})
|
||||
<-done
|
||||
return engine
|
||||
}
|
||||
|
||||
@@ -159,24 +198,45 @@ func Run(script string) (value any, err error) {
|
||||
return engine.RunString(script)
|
||||
}
|
||||
|
||||
// if the value is Promise, it will be resolved and return the result.
|
||||
func resolveResult(value goja.Value) (any, error) {
|
||||
export := value.Export()
|
||||
switch export.(type) {
|
||||
case *goja.Promise:
|
||||
p := export.(*goja.Promise)
|
||||
switch p.State() {
|
||||
case goja.PromiseStatePending:
|
||||
return nil, nil
|
||||
case goja.PromiseStateFulfilled:
|
||||
return p.Result().Export(), nil
|
||||
case goja.PromiseStateRejected:
|
||||
if err, ok := p.Result().Export().(error); ok {
|
||||
return nil, err
|
||||
} else {
|
||||
return nil, errors.New(p.Result().String())
|
||||
}
|
||||
func exportJSValue(value goja.Value) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return value.Export()
|
||||
}
|
||||
|
||||
func exportJSError(value goja.Value) error {
|
||||
if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
|
||||
return errors.New("promise rejected")
|
||||
}
|
||||
if err, ok := value.Export().(error); ok {
|
||||
return err
|
||||
}
|
||||
stack := value.String()
|
||||
if ro, ok := value.(*goja.Object); ok {
|
||||
stackVal := ro.Get("stack")
|
||||
if stackVal != nil && stackVal.String() != "" {
|
||||
stack = stackVal.String()
|
||||
}
|
||||
}
|
||||
return export, nil
|
||||
return errors.New(stack)
|
||||
}
|
||||
|
||||
func callExportedFunction(fn func(goja.FunctionCall) goja.Value, args ...goja.Value) (ret goja.Value, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
switch v := r.(type) {
|
||||
case error:
|
||||
err = v
|
||||
case goja.Value:
|
||||
err = exportJSError(v)
|
||||
default:
|
||||
err = fmt.Errorf("panic: %v", r)
|
||||
}
|
||||
}
|
||||
}()
|
||||
return fn(goja.FunctionCall{
|
||||
This: nil,
|
||||
Arguments: args,
|
||||
}), nil
|
||||
}
|
||||
@@ -6,12 +6,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/GopeedLab/gopeed/internal/test"
|
||||
gojaerror "github.com/GopeedLab/gopeed/pkg/download/engine/inject/error"
|
||||
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/file"
|
||||
gojautil "github.com/GopeedLab/gopeed/pkg/download/engine/util"
|
||||
"github.com/GopeedLab/gopeed/pkg/util"
|
||||
"github.com/dop251/goja"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -19,13 +13,26 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/test"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
gojaerror "github.com/GopeedLab/gopeed/pkg/download/engine/inject/error"
|
||||
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/file"
|
||||
gojautil "github.com/GopeedLab/gopeed/pkg/download/engine/util"
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func TestPolyfill(t *testing.T) {
|
||||
doTestPolyfill(t, "MessageError")
|
||||
doTestPolyfill(t, "XMLHttpRequest")
|
||||
doTestPolyfill(t, "Blob")
|
||||
doTestPolyfill(t, "Event")
|
||||
doTestPolyfill(t, "EventTarget")
|
||||
doTestPolyfill(t, "FormData")
|
||||
doTestPolyfill(t, "AbortController")
|
||||
doTestPolyfill(t, "AbortSignal")
|
||||
doTestPolyfill(t, "TextDecoder")
|
||||
doTestPolyfill(t, "TextEncoder")
|
||||
doTestPolyfill(t, "fetch")
|
||||
doTestPolyfill(t, "__gopeed_create_vm")
|
||||
}
|
||||
@@ -33,13 +40,75 @@ func TestPolyfill(t *testing.T) {
|
||||
func TestError(t *testing.T) {
|
||||
engine := NewEngine(nil)
|
||||
_, err := engine.RunString(`
|
||||
throw new MessageError('test');
|
||||
throw new MessageError('test');
|
||||
`)
|
||||
if me, ok := gojautil.AssertError[*gojaerror.MessageError](err); !ok {
|
||||
t.Fatalf("expect MessageError, but got %v", me)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallFunction_DetachedAsyncWorkDoesNotBlockReturn(t *testing.T) {
|
||||
engine := NewEngine(nil)
|
||||
defer engine.Close()
|
||||
if _, err := engine.RunString(`
|
||||
globalThis.__done = false;
|
||||
async function testDetached() {
|
||||
(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
globalThis.__done = true;
|
||||
})();
|
||||
return "ok";
|
||||
}
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fn, ok := goja.AssertFunction(engine.Runtime.Get("testDetached"))
|
||||
if !ok {
|
||||
t.Fatal("testDetached is not callable")
|
||||
}
|
||||
startedAt := time.Now()
|
||||
value, err := engine.CallFunction(fn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
elapsed := time.Since(startedAt)
|
||||
if elapsed > 200*time.Millisecond {
|
||||
t.Fatalf("detached async work blocked return for %s", elapsed)
|
||||
}
|
||||
if value != "ok" {
|
||||
t.Fatalf("unexpected return value: %#v", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallFunction_AwaitedPromiseBlocksUntilSettled(t *testing.T) {
|
||||
engine := NewEngine(nil)
|
||||
defer engine.Close()
|
||||
if _, err := engine.RunString(`
|
||||
async function testAwaited() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
return "ok";
|
||||
}
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fn, ok := goja.AssertFunction(engine.Runtime.Get("testAwaited"))
|
||||
if !ok {
|
||||
t.Fatal("testAwaited is not callable")
|
||||
}
|
||||
startedAt := time.Now()
|
||||
value, err := engine.CallFunction(fn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
elapsed := time.Since(startedAt)
|
||||
if elapsed < 100*time.Millisecond {
|
||||
t.Fatalf("awaited promise returned too early: %s", elapsed)
|
||||
}
|
||||
if value != "ok" {
|
||||
t.Fatalf("unexpected return value: %#v", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetch(t *testing.T) {
|
||||
server := startServer()
|
||||
defer server.Close()
|
||||
@@ -61,6 +130,50 @@ async function testText(){
|
||||
return await resp.text();
|
||||
}
|
||||
|
||||
async function testResponseBodyGetReader() {
|
||||
const resp = await fetch(host+'/text', {
|
||||
method: 'POST',
|
||||
body: 'stream-body'
|
||||
});
|
||||
if (!resp.body || typeof resp.body.getReader !== 'function') {
|
||||
throw new Error('response.body.getReader is not available');
|
||||
}
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let result = '';
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
result += decoder.decode(value, { stream: true });
|
||||
}
|
||||
result += decoder.decode();
|
||||
return result;
|
||||
}
|
||||
|
||||
async function testStreamingFetchResponseBody() {
|
||||
const startedAt = Date.now();
|
||||
const resp = await fetch(host+'/stream');
|
||||
const resolvedAt = Date.now() - startedAt;
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
const first = await reader.read();
|
||||
const firstAt = Date.now() - startedAt;
|
||||
const second = await reader.read();
|
||||
const secondAt = Date.now() - startedAt;
|
||||
let result = '';
|
||||
result += decoder.decode(first.value, { stream: true });
|
||||
result += decoder.decode(second.value, { stream: true });
|
||||
result += decoder.decode();
|
||||
return {
|
||||
resolvedAt,
|
||||
firstAt,
|
||||
secondAt,
|
||||
text: result
|
||||
};
|
||||
}
|
||||
|
||||
async function testOctetStream(file){
|
||||
const resp = await fetch(host+'/octetStream',{
|
||||
method: 'POST',
|
||||
@@ -69,6 +182,44 @@ async function testOctetStream(file){
|
||||
return await resp.text();
|
||||
}
|
||||
|
||||
async function testRedirect() {
|
||||
const url = host + '/redirect?num=3'
|
||||
return await new Promise((resolve, reject) => {
|
||||
fetch(url, {
|
||||
method: 'HEAD',
|
||||
redirect: 'error',
|
||||
}).then(()=>reject())
|
||||
|
||||
fetch(url, {
|
||||
method: 'HEAD',
|
||||
redirect: 'follow',
|
||||
}).then((res) =>res.headers.has('location') && reject()).catch(() => reject())
|
||||
|
||||
fetch(url, {
|
||||
method: 'HEAD',
|
||||
redirect: 'manual',
|
||||
}).then((res) => {
|
||||
const location = res.headers.get('location');
|
||||
location ? resolve(location) : reject()
|
||||
}).catch(() => reject())
|
||||
})
|
||||
}
|
||||
|
||||
async function testResponseUrl() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', host+'/redirect?num=3');
|
||||
xhr.onload = function(){
|
||||
if (xhr.responseURL.includes('/redirect?num=0')){
|
||||
resolve();
|
||||
}else{
|
||||
reject();
|
||||
}
|
||||
};
|
||||
xhr.send();
|
||||
});
|
||||
}
|
||||
|
||||
async function testFormData(file){
|
||||
const formData = new FormData();
|
||||
formData.append('name', 'test');
|
||||
@@ -80,6 +231,30 @@ async function testFormData(file){
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
function testHeader(){
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', host+'/header');
|
||||
xhr.setRequestHeader('X-Gopeed-Test', 'test1');
|
||||
xhr.setRequestHeader('x-gopeed-test', 'test2');
|
||||
xhr.setRequestHeader('x-Gopeed-test', 'test3');
|
||||
xhr.onload = function(){
|
||||
const testHeader1 = xhr.getResponseHeader("X-Gopeed-Test");
|
||||
const testHeader2 = xhr.getResponseHeader("x-gopeed-test");
|
||||
const testHeader3 = xhr.getResponseHeader("x-Gopeed-test");
|
||||
const expect = 'test1, test2, test3';
|
||||
const all = xhr.getAllResponseHeaders();
|
||||
if(testHeader1 === expect && testHeader2 === expect && testHeader3 === expect
|
||||
&& all.includes('X-Gopeed-Test: '+expect)){
|
||||
resolve();
|
||||
}else{
|
||||
reject();
|
||||
}
|
||||
};
|
||||
xhr.send();
|
||||
});
|
||||
}
|
||||
|
||||
function testProgress(){
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
@@ -140,6 +315,31 @@ function testTimeout(){
|
||||
xhr.send();
|
||||
});
|
||||
}
|
||||
|
||||
async function testFingerprint(fingerprint,ua){
|
||||
__gopeed_setFingerprint(fingerprint);
|
||||
const resp = await fetch(host+'/ua');
|
||||
const data = await resp.json();
|
||||
if(!data.user_agent.includes(ua)){
|
||||
throw new Error('fingerprint test failed, user agent: ' + data.user_agent);
|
||||
}
|
||||
}
|
||||
|
||||
async function testFingerprintDefault(){
|
||||
await testFingerprint('none', 'Go')
|
||||
}
|
||||
|
||||
async function testFingerprintChrome(){
|
||||
await testFingerprint('chrome', 'Chrome')
|
||||
}
|
||||
|
||||
async function testFingerprintFirefox(){
|
||||
await testFingerprint('firefox', 'Firefox')
|
||||
}
|
||||
|
||||
async function testFingerprintSafari(){
|
||||
await testFingerprint('safari', 'Safari')
|
||||
}
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -161,6 +361,32 @@ function testTimeout(){
|
||||
t.Fatalf("testText failed, want %s, got %s", "test", result)
|
||||
}
|
||||
|
||||
result, err = callTestFun(engine, "testResponseBodyGetReader")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result != "stream-body" {
|
||||
t.Fatalf("testResponseBodyGetReader failed, want %s, got %s", "stream-body", result)
|
||||
}
|
||||
|
||||
result, err = callTestFun(engine, "testStreamingFetchResponseBody")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
streaming := result.(map[string]any)
|
||||
if streaming["text"] != "chunk-1chunk-2" {
|
||||
t.Fatalf("testStreamingFetchResponseBody failed, want %s, got %v", "chunk-1chunk-2", streaming["text"])
|
||||
}
|
||||
if int(streaming["resolvedAt"].(int64)) >= 120 {
|
||||
t.Fatalf("fetch resolved too late, got %dms", streaming["resolvedAt"].(int64))
|
||||
}
|
||||
if int(streaming["firstAt"].(int64)) >= 120 {
|
||||
t.Fatalf("first chunk arrived too late, got %dms", streaming["firstAt"].(int64))
|
||||
}
|
||||
if int(streaming["secondAt"].(int64)) < 120 {
|
||||
t.Fatalf("second chunk arrived too early, got %dms", streaming["secondAt"].(int64))
|
||||
}
|
||||
|
||||
func() {
|
||||
jsFile, _, md5 := buildFile(t, engine.Runtime)
|
||||
result, err = callTestFun(engine, "testOctetStream", jsFile)
|
||||
@@ -172,6 +398,20 @@ function testTimeout(){
|
||||
}
|
||||
}()
|
||||
|
||||
t.Run("testRedirect", func(t *testing.T) {
|
||||
_, err := callTestFun(engine, "testRedirect")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("testResponseUrl", func(t *testing.T) {
|
||||
_, err = callTestFun(engine, "testResponseUrl")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
func() {
|
||||
jsFile, goFile, md5 := buildFile(t, engine.Runtime)
|
||||
result, err = callTestFun(engine, "testFormData", jsFile)
|
||||
@@ -190,20 +430,38 @@ function testTimeout(){
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = callTestFun(engine, "testHeader")
|
||||
if err != nil {
|
||||
t.Fatal("header test failed", err)
|
||||
}
|
||||
|
||||
_, err = callTestFun(engine, "testProgress")
|
||||
if err != nil {
|
||||
t.Fatal("progress test failed")
|
||||
t.Fatal("progress test failed", err)
|
||||
}
|
||||
|
||||
_, err = callTestFun(engine, "testAbort")
|
||||
if err != nil {
|
||||
t.Fatal("abort test failed")
|
||||
t.Fatal("abort test failed", err)
|
||||
}
|
||||
|
||||
_, err = callTestFun(engine, "testTimeout")
|
||||
if err == nil || err.Error() != "timeout" {
|
||||
t.Fatalf("timeout test failed, want %s, got %s", "timeout", err)
|
||||
}
|
||||
|
||||
_, err = callTestFun(engine, "testFingerprintChrome")
|
||||
if err != nil {
|
||||
t.Fatal("testFingerprintChrome test failed", err)
|
||||
}
|
||||
_, err = callTestFun(engine, "testFingerprintFirefox")
|
||||
if err != nil {
|
||||
t.Fatal("testFingerprintFirefox test failed", err)
|
||||
}
|
||||
_, err = callTestFun(engine, "testFingerprintSafari")
|
||||
if err != nil {
|
||||
t.Fatal("testFingerprintSafari test failed", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchWithProxy(t *testing.T) {
|
||||
@@ -217,8 +475,16 @@ func doTestFetchWithProxy(t *testing.T, usr, pwd string) {
|
||||
|
||||
proxyListener := test.StartSocks5Server(usr, pwd)
|
||||
defer proxyListener.Close()
|
||||
|
||||
engine := NewEngine(&Config{ProxyURL: util.BuildProxyUrl("socks5", proxyListener.Addr().String(), usr, pwd)})
|
||||
engine := NewEngine(&Config{
|
||||
ProxyConfig: &base.DownloaderProxyConfig{
|
||||
Enable: true,
|
||||
System: false,
|
||||
Scheme: "socks5",
|
||||
Host: proxyListener.Addr().String(),
|
||||
Usr: usr,
|
||||
Pwd: pwd,
|
||||
},
|
||||
})
|
||||
|
||||
if _, err := engine.RunString(fmt.Sprintf("var host = 'http://%s';", httpListener.Addr().String())); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -320,11 +586,32 @@ func startServer() net.Listener {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
mux.HandleFunc("/header", func(w http.ResponseWriter, r *http.Request) {
|
||||
for k, v := range r.Header {
|
||||
if strings.HasPrefix(k, "X-Gopeed") {
|
||||
w.Header().Set(k, strings.Join(v, ", "))
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
mux.HandleFunc("/text", func(w http.ResponseWriter, r *http.Request) {
|
||||
buf, _ := io.ReadAll(r.Body)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(buf)
|
||||
})
|
||||
mux.HandleFunc("/stream", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
_, _ = w.Write([]byte("chunk-1"))
|
||||
flusher.Flush()
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
_, _ = w.Write([]byte("chunk-2"))
|
||||
flusher.Flush()
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte("chunk-1chunk-2"))
|
||||
})
|
||||
mux.HandleFunc("/octetStream", func(w http.ResponseWriter, r *http.Request) {
|
||||
md5 := calcMd5(r.Body)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -359,6 +646,25 @@ func startServer() net.Listener {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
mux.HandleFunc("/redirect", func(w http.ResponseWriter, r *http.Request) {
|
||||
num := r.URL.Query().Get("num")
|
||||
n, _ := strconv.Atoi(num)
|
||||
if n > 0 {
|
||||
http.Redirect(w, r, fmt.Sprintf("/redirect?num=%d", n-1), http.StatusFound)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("ok"))
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("/ua", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
data := map[string]any{
|
||||
"user_agent": r.UserAgent(),
|
||||
}
|
||||
buf, _ := json.Marshal(data)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(buf)
|
||||
})
|
||||
server.Handler = mux
|
||||
go server.Serve(listener)
|
||||
return listener
|
||||
|
||||
@@ -0,0 +1,990 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/file"
|
||||
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/formdata"
|
||||
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/xhr"
|
||||
"github.com/GopeedLab/gopeed/pkg/download/engine/util"
|
||||
"github.com/dop251/goja"
|
||||
"github.com/dop251/goja_nodejs/eventloop"
|
||||
"github.com/imroc/req/v3"
|
||||
)
|
||||
|
||||
//go:embed stream.js
|
||||
var script string
|
||||
|
||||
type Config struct {
|
||||
CreateObjectURL func(opts *ObjectURLOptions, open ObjectURLOpener) (string, error)
|
||||
RevokeObjectURL func(url string) error
|
||||
ProxyHandler func(r *http.Request) (*url.URL, error)
|
||||
RegisterCleanup func(cleanup func())
|
||||
}
|
||||
|
||||
type ObjectURLOptions struct {
|
||||
ContentType string
|
||||
Size int64
|
||||
Range bool
|
||||
}
|
||||
|
||||
type ObjectURLOpenRequest struct {
|
||||
Offset int64
|
||||
End int64
|
||||
}
|
||||
|
||||
type ObjectURLOpener func(ctx context.Context, req ObjectURLOpenRequest) (io.ReadCloser, error)
|
||||
|
||||
func Enable(runtime *goja.Runtime, loop *eventloop.EventLoop, cfg *Config) error {
|
||||
if cfg == nil {
|
||||
cfg = &Config{}
|
||||
}
|
||||
blobPipes := newBlobPipeRegistry()
|
||||
if err := runtime.Set("__gopeed_create_blob_object_url", func(call goja.FunctionCall) goja.Value {
|
||||
if cfg.CreateObjectURL == nil {
|
||||
panic(runtime.NewGoError(fmt.Errorf("blob object url handler not configured")))
|
||||
}
|
||||
openValue := call.Argument(0)
|
||||
if _, ok := goja.AssertFunction(openValue); !ok {
|
||||
panic(runtime.NewGoError(fmt.Errorf("blob opener must be callable")))
|
||||
}
|
||||
optsObj := call.Argument(1).ToObject(runtime)
|
||||
size := int64(0)
|
||||
contentType := ""
|
||||
rangeEnabled := false
|
||||
if optsObj != nil {
|
||||
size = optsObj.Get("size").ToInteger()
|
||||
contentType = optsObj.Get("contentType").String()
|
||||
rangeEnabled = optsObj.Get("range").ToBoolean()
|
||||
}
|
||||
if size < 0 {
|
||||
size = 0
|
||||
}
|
||||
opts := &ObjectURLOptions{
|
||||
ContentType: contentType,
|
||||
Size: size,
|
||||
Range: rangeEnabled,
|
||||
}
|
||||
url, err := cfg.CreateObjectURL(opts, func(ctx context.Context, req ObjectURLOpenRequest) (io.ReadCloser, error) {
|
||||
return openBlobObjectURLReader(ctx, loop, blobPipes, openValue, req)
|
||||
})
|
||||
if err != nil {
|
||||
panic(runtime.NewGoError(err))
|
||||
}
|
||||
return runtime.ToValue(url)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runtime.Set("__gopeed_revoke_object_url", func(url string) {
|
||||
if cfg.RevokeObjectURL == nil {
|
||||
panic(runtime.NewGoError(fmt.Errorf("blob revoke handler not configured")))
|
||||
}
|
||||
if err := cfg.RevokeObjectURL(url); err != nil {
|
||||
panic(runtime.NewGoError(err))
|
||||
}
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
fetchRegistry := newFetchRegistry()
|
||||
if cfg.RegisterCleanup != nil {
|
||||
cfg.RegisterCleanup(fetchRegistry.CloseAll)
|
||||
}
|
||||
if err := runtime.Set("__gopeed_fetch_open", func(call goja.FunctionCall) goja.Value {
|
||||
reqMeta, err := exportFetchRequest(runtime, call.Argument(0))
|
||||
if err != nil {
|
||||
panic(runtime.NewGoError(err))
|
||||
}
|
||||
fingerprint := util.SafeGet[string](runtime, xhr.FingerprintMagicKey)
|
||||
promise, resolve, reject := runtime.NewPromise()
|
||||
go func() {
|
||||
meta, err := fetchRegistry.Open(fingerprint, cfg.ProxyHandler, reqMeta)
|
||||
ok := loop.RunOnLoop(func(runtime *goja.Runtime) {
|
||||
if err != nil {
|
||||
reject(runtime.NewGoError(err))
|
||||
return
|
||||
}
|
||||
resolve(runtime.ToValue(meta))
|
||||
})
|
||||
if !ok && meta != nil {
|
||||
fetchRegistry.Close(meta.ID)
|
||||
}
|
||||
}()
|
||||
return runtime.ToValue(promise)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runtime.Set("__gopeed_fetch_read", func(call goja.FunctionCall) goja.Value {
|
||||
id := call.Argument(0).String()
|
||||
chunkSize := int(call.Argument(1).ToInteger())
|
||||
promise, resolve, reject := runtime.NewPromise()
|
||||
go func() {
|
||||
chunk, done, err := fetchRegistry.Read(id, chunkSize)
|
||||
ok := loop.RunOnLoop(func(runtime *goja.Runtime) {
|
||||
if err != nil {
|
||||
reject(runtime.NewGoError(err))
|
||||
return
|
||||
}
|
||||
if done {
|
||||
resolve(goja.Null())
|
||||
return
|
||||
}
|
||||
resolve(runtime.ToValue(runtime.NewArrayBuffer(chunk)))
|
||||
})
|
||||
if !ok {
|
||||
fetchRegistry.Close(id)
|
||||
}
|
||||
}()
|
||||
return runtime.ToValue(promise)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runtime.Set("__gopeed_fetch_close", func(id string) {
|
||||
fetchRegistry.Close(id)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runtime.Set("__gopeed_fetch_abort", func(id string, reason string) {
|
||||
fetchRegistry.Abort(id, reason)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runtime.Set("__gopeed_blob_pipe_chunk", func(call goja.FunctionCall) goja.Value {
|
||||
id := call.Argument(0).String()
|
||||
ok := blobPipes.Push(id, exportBytes(call.Argument(1)))
|
||||
return runtime.ToValue(ok)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runtime.Set("__gopeed_blob_pipe_close", func(id string) {
|
||||
blobPipes.Close(id, nil)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runtime.Set("__gopeed_blob_pipe_error", func(id string, message string) {
|
||||
blobPipes.Close(id, errors.New(message))
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := runtime.RunString(script)
|
||||
return err
|
||||
}
|
||||
|
||||
func openBlobObjectURLReader(ctx context.Context, loop *eventloop.EventLoop, pipes *blobPipeRegistry, openValue goja.Value, req ObjectURLOpenRequest) (io.ReadCloser, error) {
|
||||
if pipes == nil {
|
||||
return nil, fmt.Errorf("blob pipe registry not configured")
|
||||
}
|
||||
pipe := newBlobPipe(ctx, loop, pipes)
|
||||
pipes.Add(pipe)
|
||||
_, err := runOnLoop(loop, func(runtime *goja.Runtime) (goja.Value, error) {
|
||||
fnVal := runtime.Get("__gopeed_blob_pipe_source")
|
||||
fn, ok := goja.AssertFunction(fnVal)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("blob pipe helper is not callable")
|
||||
}
|
||||
request := map[string]any{
|
||||
"offset": req.Offset,
|
||||
"end": req.End,
|
||||
}
|
||||
return fn(nil, openValue, runtime.ToValue(request), runtime.ToValue(pipe.id))
|
||||
})
|
||||
if err != nil {
|
||||
pipe.Close()
|
||||
return nil, err
|
||||
}
|
||||
return pipe, nil
|
||||
}
|
||||
|
||||
type blobPipeRegistry struct {
|
||||
mu sync.Mutex
|
||||
pipes map[string]*blobPipe
|
||||
next atomic.Uint64
|
||||
}
|
||||
|
||||
func newBlobPipeRegistry() *blobPipeRegistry {
|
||||
return &blobPipeRegistry{
|
||||
pipes: map[string]*blobPipe{},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *blobPipeRegistry) Add(pipe *blobPipe) {
|
||||
r.mu.Lock()
|
||||
r.pipes[pipe.id] = pipe
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *blobPipeRegistry) Remove(id string) {
|
||||
r.mu.Lock()
|
||||
delete(r.pipes, id)
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *blobPipeRegistry) Push(id string, chunk []byte) bool {
|
||||
r.mu.Lock()
|
||||
pipe := r.pipes[id]
|
||||
r.mu.Unlock()
|
||||
if pipe == nil {
|
||||
return false
|
||||
}
|
||||
return pipe.Push(chunk)
|
||||
}
|
||||
|
||||
func (r *blobPipeRegistry) Close(id string, err error) {
|
||||
r.mu.Lock()
|
||||
pipe := r.pipes[id]
|
||||
r.mu.Unlock()
|
||||
if pipe != nil {
|
||||
pipe.CloseWithError(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *blobPipeRegistry) NewID() string {
|
||||
return fmt.Sprintf("pipe-%d", r.next.Add(1))
|
||||
}
|
||||
|
||||
type blobPipeItem struct {
|
||||
chunk []byte
|
||||
err error
|
||||
done bool
|
||||
}
|
||||
|
||||
type blobPipe struct {
|
||||
id string
|
||||
loop *eventloop.EventLoop
|
||||
registry *blobPipeRegistry
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
ch chan blobPipeItem
|
||||
|
||||
mu sync.Mutex
|
||||
buf []byte
|
||||
closed bool
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func newBlobPipe(ctx context.Context, loop *eventloop.EventLoop, registry *blobPipeRegistry) *blobPipe {
|
||||
pipeCtx, cancel := context.WithCancel(ctx)
|
||||
return &blobPipe{
|
||||
id: registry.NewID(),
|
||||
loop: loop,
|
||||
registry: registry,
|
||||
ctx: pipeCtx,
|
||||
cancel: cancel,
|
||||
ch: make(chan blobPipeItem, 8),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *blobPipe) Push(chunk []byte) bool {
|
||||
if len(chunk) == 0 {
|
||||
return true
|
||||
}
|
||||
buf := append([]byte(nil), chunk...)
|
||||
select {
|
||||
case p.ch <- blobPipeItem{chunk: buf}:
|
||||
return true
|
||||
case <-p.ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (p *blobPipe) CloseWithError(err error) {
|
||||
p.once.Do(func() {
|
||||
p.registry.Remove(p.id)
|
||||
select {
|
||||
case p.ch <- blobPipeItem{done: true, err: err}:
|
||||
case <-p.ctx.Done():
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (p *blobPipe) Read(dst []byte) (int, error) {
|
||||
p.mu.Lock()
|
||||
if len(p.buf) > 0 {
|
||||
n := copy(dst, p.buf)
|
||||
p.buf = p.buf[n:]
|
||||
p.mu.Unlock()
|
||||
return n, nil
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
select {
|
||||
case item := <-p.ch:
|
||||
if item.done {
|
||||
if item.err != nil {
|
||||
return 0, item.err
|
||||
}
|
||||
return 0, io.EOF
|
||||
}
|
||||
n := copy(dst, item.chunk)
|
||||
if n < len(item.chunk) {
|
||||
p.mu.Lock()
|
||||
p.buf = append(p.buf, item.chunk[n:]...)
|
||||
p.mu.Unlock()
|
||||
}
|
||||
return n, nil
|
||||
case <-p.ctx.Done():
|
||||
return 0, p.ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *blobPipe) Close() error {
|
||||
p.mu.Lock()
|
||||
if p.closed {
|
||||
p.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
p.closed = true
|
||||
p.mu.Unlock()
|
||||
p.registry.Remove(p.id)
|
||||
p.cancel()
|
||||
p.cancelSourceReader()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *blobPipe) cancelSourceReader() {
|
||||
if p.loop == nil {
|
||||
return
|
||||
}
|
||||
// Invoke the JavaScript cancellation before returning from Close. The
|
||||
// source session may be released immediately after the HTTP handler closes
|
||||
// this reader, so merely queueing the callback could let the engine stop
|
||||
// before reader.cancel() is called.
|
||||
_, _ = runOnLoop(p.loop, func(runtime *goja.Runtime) (goja.Value, error) {
|
||||
fnVal := runtime.Get("__gopeed_blob_cancel_pipe_source")
|
||||
fn, ok := goja.AssertFunction(fnVal)
|
||||
if !ok {
|
||||
return goja.Undefined(), nil
|
||||
}
|
||||
_, err := fn(nil, runtime.ToValue(p.id), runtime.ToValue("blob request closed"))
|
||||
return goja.Undefined(), err
|
||||
})
|
||||
}
|
||||
|
||||
func exportBytes(value goja.Value) []byte {
|
||||
if value == nil || goja.IsNull(value) || goja.IsUndefined(value) {
|
||||
return nil
|
||||
}
|
||||
if ab, ok := value.Export().(goja.ArrayBuffer); ok {
|
||||
return ab.Bytes()
|
||||
}
|
||||
if b, ok := value.Export().([]byte); ok {
|
||||
return b
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type blobObjectURLReader struct {
|
||||
ctx context.Context
|
||||
loop *eventloop.EventLoop
|
||||
id string
|
||||
|
||||
mu sync.Mutex
|
||||
buf []byte
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (r *blobObjectURLReader) Read(p []byte) (int, error) {
|
||||
r.mu.Lock()
|
||||
if len(r.buf) > 0 {
|
||||
n := copy(p, r.buf)
|
||||
r.buf = r.buf[n:]
|
||||
r.mu.Unlock()
|
||||
return n, nil
|
||||
}
|
||||
closed := r.closed
|
||||
r.mu.Unlock()
|
||||
if closed {
|
||||
return 0, io.EOF
|
||||
}
|
||||
if err := r.ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
chunkSize := len(p)
|
||||
if chunkSize <= 0 {
|
||||
chunkSize = 32 * 1024
|
||||
}
|
||||
value, err := runOnLoop(r.loop, func(runtime *goja.Runtime) (goja.Value, error) {
|
||||
fnVal := runtime.Get("__gopeed_blob_read_source")
|
||||
fn, ok := goja.AssertFunction(fnVal)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("blob read helper is not callable")
|
||||
}
|
||||
return fn(nil, runtime.ToValue(r.id), runtime.ToValue(chunkSize))
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if goja.IsNull(value) || goja.IsUndefined(value) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
var chunk []byte
|
||||
if ab, ok := value.Export().(goja.ArrayBuffer); ok {
|
||||
chunk = ab.Bytes()
|
||||
} else if b, ok := value.Export().([]byte); ok {
|
||||
chunk = b
|
||||
} else {
|
||||
return 0, fmt.Errorf("blob read helper returned %T", value.Export())
|
||||
}
|
||||
if len(chunk) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
n := copy(p, chunk)
|
||||
if n < len(chunk) {
|
||||
r.mu.Lock()
|
||||
r.buf = append(r.buf, chunk[n:]...)
|
||||
r.mu.Unlock()
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (r *blobObjectURLReader) Close() error {
|
||||
r.mu.Lock()
|
||||
if r.closed {
|
||||
r.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
r.closed = true
|
||||
r.mu.Unlock()
|
||||
_, err := runOnLoop(r.loop, func(runtime *goja.Runtime) (goja.Value, error) {
|
||||
fnVal := runtime.Get("__gopeed_blob_close_source")
|
||||
fn, ok := goja.AssertFunction(fnVal)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return fn(nil, runtime.ToValue(r.id))
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func runOnLoop(loop *eventloop.EventLoop, fn func(runtime *goja.Runtime) (goja.Value, error)) (goja.Value, error) {
|
||||
type result struct {
|
||||
value goja.Value
|
||||
err error
|
||||
}
|
||||
ch := make(chan result, 1)
|
||||
ok := loop.RunOnLoop(func(runtime *goja.Runtime) {
|
||||
send := func(value goja.Value, err error) {
|
||||
select {
|
||||
case ch <- result{value: value, err: err}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
switch v := r.(type) {
|
||||
case error:
|
||||
send(nil, v)
|
||||
case goja.Value:
|
||||
send(nil, exportJSError(v))
|
||||
default:
|
||||
send(nil, fmt.Errorf("panic: %v", r))
|
||||
}
|
||||
}
|
||||
}()
|
||||
value, err := fn(runtime)
|
||||
if err != nil {
|
||||
send(nil, err)
|
||||
return
|
||||
}
|
||||
if promise, ok := value.Export().(*goja.Promise); ok {
|
||||
switch promise.State() {
|
||||
case goja.PromiseStateFulfilled:
|
||||
send(promise.Result(), nil)
|
||||
return
|
||||
case goja.PromiseStateRejected:
|
||||
send(nil, exportJSError(promise.Result()))
|
||||
return
|
||||
default:
|
||||
thenVal := value.ToObject(runtime).Get("then")
|
||||
thenFn, ok := goja.AssertFunction(thenVal)
|
||||
if !ok {
|
||||
send(nil, errors.New("promise.then is not callable"))
|
||||
return
|
||||
}
|
||||
onFulfilled := runtime.ToValue(func(call goja.FunctionCall) goja.Value {
|
||||
send(call.Argument(0), nil)
|
||||
return goja.Undefined()
|
||||
})
|
||||
onRejected := runtime.ToValue(func(call goja.FunctionCall) goja.Value {
|
||||
send(nil, exportJSError(call.Argument(0)))
|
||||
return goja.Undefined()
|
||||
})
|
||||
if _, err := thenFn(value, onFulfilled, onRejected); err != nil {
|
||||
send(nil, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
send(value, nil)
|
||||
})
|
||||
if !ok {
|
||||
return nil, errors.New("engine loop terminated")
|
||||
}
|
||||
res := <-ch
|
||||
return res.value, res.err
|
||||
}
|
||||
|
||||
func exportJSError(value goja.Value) error {
|
||||
if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
|
||||
return errors.New("promise rejected")
|
||||
}
|
||||
if err, ok := value.Export().(error); ok {
|
||||
return err
|
||||
}
|
||||
stack := value.String()
|
||||
if ro, ok := value.(*goja.Object); ok {
|
||||
stackVal := ro.Get("stack")
|
||||
if stackVal != nil && stackVal.String() != "" {
|
||||
stack = stackVal.String()
|
||||
}
|
||||
}
|
||||
return errors.New(stack)
|
||||
}
|
||||
|
||||
type fetchRegistry struct {
|
||||
mu sync.Mutex
|
||||
streams map[string]*fetchStream
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
closed bool
|
||||
}
|
||||
|
||||
type fetchStream struct {
|
||||
body io.ReadCloser
|
||||
cancel context.CancelFunc
|
||||
closeOnce sync.Once
|
||||
ctx context.Context
|
||||
ch chan fetchChunk
|
||||
readMu sync.Mutex
|
||||
pending []byte
|
||||
}
|
||||
|
||||
type fetchChunk struct {
|
||||
data []byte
|
||||
err error
|
||||
}
|
||||
|
||||
type fetchRequest struct {
|
||||
URL string
|
||||
Method string
|
||||
Headers [][2]string
|
||||
Body any
|
||||
Redirect string
|
||||
Credentials string
|
||||
}
|
||||
|
||||
type fetchFormDataEntry struct {
|
||||
name string
|
||||
value any
|
||||
}
|
||||
|
||||
type fetchFormDataSnapshot struct {
|
||||
entries []fetchFormDataEntry
|
||||
}
|
||||
|
||||
type fetchOpenMeta struct {
|
||||
ID string `json:"id"`
|
||||
Status int `json:"status"`
|
||||
StatusText string `json:"statusText"`
|
||||
URL string `json:"url"`
|
||||
Headers [][2]string `json:"headers"`
|
||||
}
|
||||
|
||||
func newFetchRegistry() *fetchRegistry {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &fetchRegistry{
|
||||
streams: make(map[string]*fetchStream),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
func exportFetchRequest(runtime *goja.Runtime, value goja.Value) (*fetchRequest, error) {
|
||||
obj := value.ToObject(runtime)
|
||||
if obj == nil {
|
||||
return nil, fmt.Errorf("invalid fetch request")
|
||||
}
|
||||
meta := &fetchRequest{
|
||||
URL: obj.Get("url").String(),
|
||||
Method: obj.Get("method").String(),
|
||||
Redirect: obj.Get("redirect").String(),
|
||||
Credentials: obj.Get("credentials").String(),
|
||||
}
|
||||
if meta.Method == "" {
|
||||
meta.Method = http.MethodGet
|
||||
}
|
||||
if headersVal := obj.Get("headers"); headersVal != nil && !goja.IsUndefined(headersVal) && !goja.IsNull(headersVal) {
|
||||
if exported, ok := headersVal.Export().([]any); ok {
|
||||
for _, item := range exported {
|
||||
pair, ok := item.([]any)
|
||||
if !ok || len(pair) != 2 {
|
||||
continue
|
||||
}
|
||||
meta.Headers = append(meta.Headers, [2]string{fmt.Sprint(pair[0]), fmt.Sprint(pair[1])})
|
||||
}
|
||||
}
|
||||
}
|
||||
bodyVal := obj.Get("body")
|
||||
if bodyVal != nil && !goja.IsUndefined(bodyVal) && !goja.IsNull(bodyVal) {
|
||||
body, err := snapshotFetchBody(bodyVal.Export())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta.Body = body
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
// snapshotFetchBody runs on the JavaScript event loop before fetch work is
|
||||
// handed to a background goroutine. Values exported by goja may still share
|
||||
// mutable storage with JavaScript (notably ArrayBuffer and FormData), so the
|
||||
// background request must only retain immutable Go-owned data.
|
||||
func snapshotFetchBody(body any) (any, error) {
|
||||
switch v := body.(type) {
|
||||
case nil, string:
|
||||
return v, nil
|
||||
case []byte:
|
||||
return append([]byte(nil), v...), nil
|
||||
case goja.ArrayBuffer:
|
||||
return append([]byte(nil), v.Bytes()...), nil
|
||||
case *file.File:
|
||||
return snapshotFetchFile(v), nil
|
||||
case *formdata.FormData:
|
||||
entries := v.Entries()
|
||||
snapshot := &fetchFormDataSnapshot{
|
||||
entries: make([]fetchFormDataEntry, 0, len(entries)),
|
||||
}
|
||||
for _, entry := range entries {
|
||||
pair, ok := entry.([]any)
|
||||
if !ok || len(pair) != 2 {
|
||||
return nil, fmt.Errorf("invalid FormData entry %T", entry)
|
||||
}
|
||||
name, ok := pair[0].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid FormData field name %T", pair[0])
|
||||
}
|
||||
value := pair[1]
|
||||
if formFile, ok := value.(*file.File); ok {
|
||||
value = snapshotFetchFile(formFile)
|
||||
}
|
||||
snapshot.entries = append(snapshot.entries, fetchFormDataEntry{
|
||||
name: name,
|
||||
value: value,
|
||||
})
|
||||
}
|
||||
return snapshot, nil
|
||||
default:
|
||||
if typed, ok := v.(interface{ Bytes() []byte }); ok {
|
||||
return append([]byte(nil), typed.Bytes()...), nil
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
|
||||
func snapshotFetchFile(src *file.File) *file.File {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
return &file.File{
|
||||
Reader: src.Reader,
|
||||
Closer: src.Closer,
|
||||
Name: src.Name,
|
||||
Size: src.Size,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *fetchRegistry) Open(fingerprint string, proxyHandler func(r *http.Request) (*url.URL, error), reqMeta *fetchRequest) (*fetchOpenMeta, error) {
|
||||
r.mu.Lock()
|
||||
if r.closed {
|
||||
r.mu.Unlock()
|
||||
return nil, context.Canceled
|
||||
}
|
||||
registryCtx := r.ctx
|
||||
r.mu.Unlock()
|
||||
|
||||
client := req.C()
|
||||
if proxyHandler != nil {
|
||||
client.SetProxy(proxyHandler)
|
||||
}
|
||||
setFetchFingerprint(client, fingerprint)
|
||||
contentType, body, err := buildFetchBody(reqMeta.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx, cancel := context.WithCancel(registryCtx)
|
||||
keepCancel := false
|
||||
defer func() {
|
||||
if !keepCancel {
|
||||
cancel()
|
||||
}
|
||||
}()
|
||||
reqBuilder := client.R()
|
||||
reqBuilder.SetContext(ctx)
|
||||
reqBuilder.DisableAutoReadResponse()
|
||||
for _, header := range reqMeta.Headers {
|
||||
reqBuilder.SetHeader(header[0], header[1])
|
||||
}
|
||||
if body != nil && reqMeta.Method != http.MethodGet && reqMeta.Method != http.MethodHead {
|
||||
reqBuilder.SetBody(body)
|
||||
if contentType != "" && !hasHeader(reqMeta.Headers, "Content-Type") {
|
||||
reqBuilder.SetHeader("Content-Type", contentType)
|
||||
}
|
||||
}
|
||||
client.SetRedirectPolicy(func(req *http.Request, via []*http.Request) error {
|
||||
switch reqMeta.Redirect {
|
||||
case "manual":
|
||||
return http.ErrUseLastResponse
|
||||
case "error":
|
||||
return fmt.Errorf("redirect failed")
|
||||
default:
|
||||
if len(via) > 20 {
|
||||
return fmt.Errorf("too many redirects")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
})
|
||||
resp, err := reqBuilder.Send(reqMeta.Method, reqMeta.URL)
|
||||
if err != nil {
|
||||
var ne net.Error
|
||||
if errorsAsTimeout(err, &ne) {
|
||||
return nil, fmt.Errorf("Network request timed out")
|
||||
}
|
||||
return nil, fmt.Errorf("Network request failed: %w", err)
|
||||
}
|
||||
id := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
meta := &fetchOpenMeta{
|
||||
ID: id,
|
||||
Status: resp.StatusCode,
|
||||
StatusText: resp.Status,
|
||||
URL: reqMeta.URL,
|
||||
}
|
||||
if resp.Response != nil && resp.Response.Request != nil && resp.Response.Request.URL != nil {
|
||||
responseURL := *resp.Response.Request.URL
|
||||
responseURL.Fragment = ""
|
||||
meta.URL = responseURL.String()
|
||||
}
|
||||
for key, values := range resp.Header {
|
||||
meta.Headers = append(meta.Headers, [2]string{key, strings.Join(values, ", ")})
|
||||
}
|
||||
bodyCloser := io.NopCloser(strings.NewReader(""))
|
||||
if resp.Response != nil && resp.Response.Body != nil {
|
||||
bodyCloser = resp.Response.Body
|
||||
}
|
||||
stream := &fetchStream{
|
||||
body: bodyCloser,
|
||||
cancel: cancel,
|
||||
ctx: ctx,
|
||||
ch: make(chan fetchChunk, 8),
|
||||
}
|
||||
r.mu.Lock()
|
||||
if r.closed {
|
||||
r.mu.Unlock()
|
||||
closeFetchStream(stream)
|
||||
return nil, context.Canceled
|
||||
}
|
||||
r.streams[id] = stream
|
||||
r.mu.Unlock()
|
||||
keepCancel = true
|
||||
go stream.readLoop()
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
func (r *fetchRegistry) Read(id string, chunkSize int) ([]byte, bool, error) {
|
||||
stream := r.get(id)
|
||||
if stream == nil {
|
||||
return nil, true, nil
|
||||
}
|
||||
stream.readMu.Lock()
|
||||
defer stream.readMu.Unlock()
|
||||
if len(stream.pending) > 0 {
|
||||
n := len(stream.pending)
|
||||
if chunkSize > 0 && n > chunkSize {
|
||||
n = chunkSize
|
||||
}
|
||||
chunk := append([]byte(nil), stream.pending[:n]...)
|
||||
stream.pending = stream.pending[n:]
|
||||
return chunk, false, nil
|
||||
}
|
||||
select {
|
||||
case item, ok := <-stream.ch:
|
||||
if !ok {
|
||||
r.Close(id)
|
||||
return nil, true, nil
|
||||
}
|
||||
if item.err != nil {
|
||||
r.Close(id)
|
||||
return nil, false, item.err
|
||||
}
|
||||
if chunkSize > 0 && len(item.data) > chunkSize {
|
||||
head := append([]byte(nil), item.data[:chunkSize]...)
|
||||
stream.pending = append(stream.pending[:0], item.data[chunkSize:]...)
|
||||
return head, false, nil
|
||||
}
|
||||
return item.data, false, nil
|
||||
case <-stream.ctx.Done():
|
||||
return nil, false, stream.ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fetchStream) readLoop() {
|
||||
defer close(s.ch)
|
||||
buf := make([]byte, 64*1024)
|
||||
for {
|
||||
n, err := s.body.Read(buf)
|
||||
if n > 0 {
|
||||
chunk := append([]byte(nil), buf[:n]...)
|
||||
select {
|
||||
case s.ch <- fetchChunk{data: chunk}:
|
||||
case <-s.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
select {
|
||||
case s.ch <- fetchChunk{err: err}:
|
||||
case <-s.ctx.Done():
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *fetchRegistry) Close(id string) {
|
||||
r.mu.Lock()
|
||||
stream := r.streams[id]
|
||||
delete(r.streams, id)
|
||||
r.mu.Unlock()
|
||||
closeFetchStream(stream)
|
||||
}
|
||||
|
||||
// CloseAll permanently closes the registry and cancels both active and
|
||||
// in-flight fetches. It is safe to call more than once.
|
||||
func (r *fetchRegistry) CloseAll() {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
if r.closed {
|
||||
r.mu.Unlock()
|
||||
return
|
||||
}
|
||||
r.closed = true
|
||||
if r.cancel != nil {
|
||||
r.cancel()
|
||||
}
|
||||
streams := r.streams
|
||||
r.streams = make(map[string]*fetchStream)
|
||||
r.mu.Unlock()
|
||||
|
||||
for _, stream := range streams {
|
||||
closeFetchStream(stream)
|
||||
}
|
||||
}
|
||||
|
||||
func closeFetchStream(stream *fetchStream) {
|
||||
if stream == nil {
|
||||
return
|
||||
}
|
||||
stream.closeOnce.Do(func() {
|
||||
if stream.cancel != nil {
|
||||
stream.cancel()
|
||||
}
|
||||
_ = stream.body.Close()
|
||||
})
|
||||
}
|
||||
|
||||
func (r *fetchRegistry) Abort(id string, _ string) {
|
||||
r.Close(id)
|
||||
}
|
||||
|
||||
func (r *fetchRegistry) get(id string) *fetchStream {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.streams[id]
|
||||
}
|
||||
|
||||
func buildFetchBody(body any) (string, any, error) {
|
||||
switch v := body.(type) {
|
||||
case nil:
|
||||
return "", nil, nil
|
||||
case string:
|
||||
return "text/plain;charset=UTF-8", v, nil
|
||||
case []byte:
|
||||
return "application/octet-stream", v, nil
|
||||
case goja.ArrayBuffer:
|
||||
return "application/octet-stream", v.Bytes(), nil
|
||||
case *file.File:
|
||||
return "application/octet-stream", v.Reader, nil
|
||||
case *fetchFormDataSnapshot:
|
||||
pr, pw := io.Pipe()
|
||||
mw := xhr.NewMultipart(pw)
|
||||
for _, entry := range v.entries {
|
||||
switch vv := entry.value.(type) {
|
||||
case string:
|
||||
if err := mw.WriteField(entry.name, vv); err != nil {
|
||||
_ = pw.CloseWithError(err)
|
||||
return "", nil, err
|
||||
}
|
||||
case *file.File:
|
||||
if err := mw.WriteFile(entry.name, vv); err != nil {
|
||||
_ = pw.CloseWithError(err)
|
||||
return "", nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
go func() {
|
||||
if err := mw.Send(); err != nil {
|
||||
_ = pw.CloseWithError(err)
|
||||
return
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
_ = pw.CloseWithError(err)
|
||||
return
|
||||
}
|
||||
_ = pw.Close()
|
||||
}()
|
||||
return mw.FormDataContentType(), pr, nil
|
||||
default:
|
||||
if typed, ok := v.(interface{ Bytes() []byte }); ok {
|
||||
return "application/octet-stream", typed.Bytes(), nil
|
||||
}
|
||||
return "", fmt.Sprint(v), nil
|
||||
}
|
||||
}
|
||||
|
||||
func hasHeader(headers [][2]string, key string) bool {
|
||||
for _, header := range headers {
|
||||
if strings.EqualFold(header[0], key) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func setFetchFingerprint(client *req.Client, fingerprint string) {
|
||||
switch fingerprint {
|
||||
case "chrome":
|
||||
client.ImpersonateChrome()
|
||||
case "firefox":
|
||||
client.ImpersonateFirefox()
|
||||
case "safari":
|
||||
client.ImpersonateSafari()
|
||||
}
|
||||
}
|
||||
|
||||
func errorsAsTimeout(err error, target *net.Error) bool {
|
||||
return errors.As(err, target) && (*target).Timeout()
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package stream_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/download/engine"
|
||||
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/stream"
|
||||
)
|
||||
|
||||
type createdObjectURL struct {
|
||||
opts *stream.ObjectURLOptions
|
||||
open stream.ObjectURLOpener
|
||||
}
|
||||
|
||||
func TestResponseBlobObjectURLReadsResponseStream(t *testing.T) {
|
||||
const payload = "response blob payload"
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
_, _ = io.WriteString(w, payload[:8])
|
||||
w.(http.Flusher).Flush() // force an unknown-length/chunked response
|
||||
_, _ = io.WriteString(w, payload[8:])
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
created := make(chan createdObjectURL, 1)
|
||||
engine, cleanup := newStreamTestEngine(t, created)
|
||||
defer func() {
|
||||
cleanup()
|
||||
engine.Close()
|
||||
}()
|
||||
|
||||
value, err := engine.RunString(fmt.Sprintf(`
|
||||
(async () => {
|
||||
const response = await fetch(%q);
|
||||
const blob = await response.blob();
|
||||
return __gopeed_blob_create_object_url(blob);
|
||||
})()
|
||||
`, server.URL))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value != "blob:test-1" {
|
||||
t.Fatalf("unexpected object URL: %#v", value)
|
||||
}
|
||||
|
||||
object := <-created
|
||||
if object.opts.Size != int64(len(payload)) || object.opts.ContentType != "text/plain" {
|
||||
t.Fatalf("unexpected object URL options: %#v", object.opts)
|
||||
}
|
||||
if !object.opts.Range {
|
||||
t.Fatal("materialized response Blob should advertise range support")
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
reader, err := object.open(context.Background(), stream.ObjectURLOpenRequest{Offset: 0, End: -1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, readErr := io.ReadAll(reader)
|
||||
_ = reader.Close()
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
if got := string(data); got != payload {
|
||||
t.Fatalf("unexpected response Blob data on open %d: %q", i+1, got)
|
||||
}
|
||||
}
|
||||
rangeReader, err := object.open(context.Background(), stream.ObjectURLOpenRequest{Offset: 9, End: 12})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rangeData, readErr := io.ReadAll(rangeReader)
|
||||
_ = rangeReader.Close()
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
if got, want := string(rangeData), payload[9:13]; got != want {
|
||||
t.Fatalf("unexpected materialized Blob range: got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseBlobRejectsTruncatedResponse(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Length", "64")
|
||||
_, _ = io.WriteString(w, "partial")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
created := make(chan createdObjectURL, 1)
|
||||
engine, cleanup := newStreamTestEngine(t, created)
|
||||
defer func() {
|
||||
cleanup()
|
||||
engine.Close()
|
||||
}()
|
||||
|
||||
value, err := engine.RunString(fmt.Sprintf(`
|
||||
(async () => {
|
||||
const response = await fetch(%q);
|
||||
try {
|
||||
await response.blob();
|
||||
return "resolved";
|
||||
} catch (_) {
|
||||
return "rejected";
|
||||
}
|
||||
})()
|
||||
`, server.URL))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value != "rejected" {
|
||||
t.Fatalf("truncated response unexpectedly materialized a Blob: %#v", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyBlobObjectURL(t *testing.T) {
|
||||
created := make(chan createdObjectURL, 1)
|
||||
engine, cleanup := newStreamTestEngine(t, created)
|
||||
defer func() {
|
||||
cleanup()
|
||||
engine.Close()
|
||||
}()
|
||||
|
||||
value, err := engine.RunString(`
|
||||
__gopeed_blob_create_object_url(new Blob([], { type: "application/empty" }))
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value != "blob:test-1" {
|
||||
t.Fatalf("unexpected object URL: %#v", value)
|
||||
}
|
||||
|
||||
object := <-created
|
||||
if object.opts.Size != 0 || object.opts.Range {
|
||||
t.Fatalf("unexpected empty Blob options: %#v", object.opts)
|
||||
}
|
||||
reader, err := object.open(context.Background(), stream.ObjectURLOpenRequest{Offset: 0, End: -1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer reader.Close()
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(data) != 0 {
|
||||
t.Fatalf("expected empty Blob, got %q", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlobObjectURLReaderCloseCancelsPendingJSReader(t *testing.T) {
|
||||
created := make(chan createdObjectURL, 1)
|
||||
engine, cleanup := newStreamTestEngine(t, created)
|
||||
defer func() {
|
||||
cleanup()
|
||||
engine.Close()
|
||||
}()
|
||||
|
||||
value, err := engine.RunString(`
|
||||
globalThis.__blobPipeCancelState = {
|
||||
readCalls: 0,
|
||||
cancelCalls: 0,
|
||||
releaseCalls: 0,
|
||||
cancelReason: "",
|
||||
resolveRead: null,
|
||||
};
|
||||
const pendingSource = {
|
||||
getReader() {
|
||||
return {
|
||||
read() {
|
||||
__blobPipeCancelState.readCalls++;
|
||||
return new Promise((resolve) => {
|
||||
__blobPipeCancelState.resolveRead = resolve;
|
||||
});
|
||||
},
|
||||
cancel(reason) {
|
||||
__blobPipeCancelState.cancelCalls++;
|
||||
__blobPipeCancelState.cancelReason = String(reason);
|
||||
if (__blobPipeCancelState.resolveRead) {
|
||||
__blobPipeCancelState.resolveRead({ done: true, value: undefined });
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
releaseLock() {
|
||||
__blobPipeCancelState.releaseCalls++;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
__gopeed_blob_create_object_url(async () => pendingSource);
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value != "blob:test-1" {
|
||||
t.Fatalf("unexpected object URL: %#v", value)
|
||||
}
|
||||
|
||||
object := <-created
|
||||
reader, err := object.open(context.Background(), stream.ObjectURLOpenRequest{Offset: 0, End: -1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
readDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, readErr := reader.Read(make([]byte, 1))
|
||||
readDone <- readErr
|
||||
}()
|
||||
|
||||
waitForJSValue(t, engine, `String(__blobPipeCancelState.readCalls)`, "1")
|
||||
if err := reader.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := reader.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
select {
|
||||
case readErr := <-readDone:
|
||||
if !errors.Is(readErr, context.Canceled) {
|
||||
t.Fatalf("unexpected pending read result: %v", readErr)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("pending Go read was not released by Close")
|
||||
}
|
||||
waitForJSValue(t, engine, `JSON.stringify({
|
||||
cancelCalls: __blobPipeCancelState.cancelCalls,
|
||||
releaseCalls: __blobPipeCancelState.releaseCalls,
|
||||
cancelReason: __blobPipeCancelState.cancelReason,
|
||||
})`, `{"cancelCalls":1,"releaseCalls":1,"cancelReason":"blob request closed"}`)
|
||||
}
|
||||
|
||||
func waitForJSValue(t *testing.T, engine *engine.Engine, expression, want string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
var got any
|
||||
for time.Now().Before(deadline) {
|
||||
var err error
|
||||
got, err = engine.RunString(expression)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got == want {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("timed out waiting for JavaScript value %q, got %#v", want, got)
|
||||
}
|
||||
|
||||
func newStreamTestEngine(t *testing.T, created chan<- createdObjectURL) (*engine.Engine, func()) {
|
||||
t.Helper()
|
||||
var next atomic.Int64
|
||||
var cleanup func()
|
||||
e := engine.NewEngine(&engine.Config{
|
||||
StreamConfig: &stream.Config{
|
||||
CreateObjectURL: func(opts *stream.ObjectURLOptions, open stream.ObjectURLOpener) (string, error) {
|
||||
created <- createdObjectURL{opts: opts, open: open}
|
||||
return fmt.Sprintf("blob:test-%d", next.Add(1)), nil
|
||||
},
|
||||
RevokeObjectURL: func(string) error { return nil },
|
||||
RegisterCleanup: func(fn func()) {
|
||||
cleanup = fn
|
||||
},
|
||||
},
|
||||
})
|
||||
if cleanup == nil {
|
||||
t.Fatal("stream cleanup was not registered")
|
||||
}
|
||||
return e, cleanup
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/file"
|
||||
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/formdata"
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func TestExportFetchRequestSnapshotsArrayBuffer(t *testing.T) {
|
||||
runtime := goja.New()
|
||||
buffer := runtime.NewArrayBuffer([]byte("before"))
|
||||
request := runtime.NewObject()
|
||||
setFetchRequestDefaults(t, request)
|
||||
if err := request.Set("body", buffer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
meta, err := exportFetchRequest(runtime, request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
buffer.Bytes()[0] = 'X'
|
||||
|
||||
body, ok := meta.Body.([]byte)
|
||||
if !ok {
|
||||
t.Fatalf("expected byte snapshot, got %T", meta.Body)
|
||||
}
|
||||
if got := string(body); got != "before" {
|
||||
t.Fatalf("ArrayBuffer snapshot changed after export: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportFetchRequestSnapshotsFormData(t *testing.T) {
|
||||
runtime := goja.New()
|
||||
runtime.SetFieldNameMapper(goja.TagFieldNameMapper("json", true))
|
||||
if err := formdata.Enable(runtime); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value, err := runtime.RunString(`
|
||||
globalThis.form = new FormData();
|
||||
form.append("field", "before");
|
||||
form;
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := runtime.NewObject()
|
||||
setFetchRequestDefaults(t, request)
|
||||
if err := request.Set("body", value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
meta, err := exportFetchRequest(runtime, request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := runtime.RunString(`form.set("field", "after")`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
body, ok := meta.Body.(*fetchFormDataSnapshot)
|
||||
if !ok {
|
||||
t.Fatalf("expected FormData snapshot, got %T", meta.Body)
|
||||
}
|
||||
if len(body.entries) != 1 {
|
||||
t.Fatalf("expected one FormData entry, got %d", len(body.entries))
|
||||
}
|
||||
if got := body.entries[0]; got.name != "field" || got.value != "before" {
|
||||
t.Fatalf("unexpected FormData snapshot: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func setFetchRequestDefaults(t *testing.T, request *goja.Object) {
|
||||
t.Helper()
|
||||
for key, value := range map[string]any{
|
||||
"url": "https://example.test",
|
||||
"method": "POST",
|
||||
"redirect": "follow",
|
||||
"credentials": "same-origin",
|
||||
} {
|
||||
if err := request.Set(key, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotFetchBodyCopiesFileMetadata(t *testing.T) {
|
||||
source := &file.File{
|
||||
Reader: strings.NewReader("payload"),
|
||||
Name: "before.txt",
|
||||
Size: 7,
|
||||
}
|
||||
|
||||
value, err := snapshotFetchBody(source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot, ok := value.(*file.File)
|
||||
if !ok {
|
||||
t.Fatalf("expected file snapshot, got %T", value)
|
||||
}
|
||||
source.Name = "after.txt"
|
||||
source.Size = 99
|
||||
|
||||
if snapshot.Name != "before.txt" || snapshot.Size != 7 {
|
||||
t.Fatalf("file metadata was not snapshotted: %#v", snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRegistryCloseAll(t *testing.T) {
|
||||
registry := newFetchRegistry()
|
||||
streamCtx, cancel := context.WithCancel(context.Background())
|
||||
body := &trackingReadCloser{}
|
||||
registry.streams["active"] = &fetchStream{
|
||||
body: body,
|
||||
cancel: cancel,
|
||||
ctx: streamCtx,
|
||||
ch: make(chan fetchChunk),
|
||||
}
|
||||
|
||||
registry.CloseAll()
|
||||
registry.CloseAll()
|
||||
|
||||
select {
|
||||
case <-streamCtx.Done():
|
||||
default:
|
||||
t.Fatal("active fetch context was not canceled")
|
||||
}
|
||||
if got := body.closeCalls.Load(); got != 1 {
|
||||
t.Fatalf("expected body to close once, got %d", got)
|
||||
}
|
||||
registry.mu.Lock()
|
||||
closed := registry.closed
|
||||
remaining := len(registry.streams)
|
||||
registry.mu.Unlock()
|
||||
if !closed || remaining != 0 {
|
||||
t.Fatalf("registry not fully closed: closed=%v remaining=%d", closed, remaining)
|
||||
}
|
||||
if _, err := registry.Open("", nil, &fetchRequest{URL: "https://example.test"}); err == nil {
|
||||
t.Fatal("expected a closed registry to reject new fetches")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRegistryReadPreservesChunkOrder(t *testing.T) {
|
||||
registry := newFetchRegistry()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
stream := &fetchStream{
|
||||
body: io.NopCloser(strings.NewReader("")),
|
||||
cancel: cancel,
|
||||
ctx: ctx,
|
||||
ch: make(chan fetchChunk, 2),
|
||||
}
|
||||
stream.ch <- fetchChunk{data: []byte("abc")}
|
||||
stream.ch <- fetchChunk{data: []byte("def")}
|
||||
registry.streams["ordered"] = stream
|
||||
defer registry.CloseAll()
|
||||
|
||||
var got strings.Builder
|
||||
for range 4 {
|
||||
chunk, done, err := registry.Read("ordered", 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if done {
|
||||
t.Fatal("stream ended before buffered chunks were read")
|
||||
}
|
||||
got.Write(chunk)
|
||||
}
|
||||
if got.String() != "abcdef" {
|
||||
t.Fatalf("fetch chunks were reordered: %q", got.String())
|
||||
}
|
||||
}
|
||||
|
||||
type trackingReadCloser struct {
|
||||
closeCalls atomic.Int32
|
||||
}
|
||||
|
||||
func (r *trackingReadCloser) Read([]byte) (int, error) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
func (r *trackingReadCloser) Close() error {
|
||||
r.closeCalls.Add(1)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,964 @@
|
||||
(function () {
|
||||
const createBlobObjectURL = globalThis.__gopeed_create_blob_object_url;
|
||||
const revokeObjectURL = globalThis.__gopeed_revoke_object_url;
|
||||
const fetchOpen = globalThis.__gopeed_fetch_open;
|
||||
const fetchRead = globalThis.__gopeed_fetch_read;
|
||||
const fetchClose = globalThis.__gopeed_fetch_close;
|
||||
const fetchAbort = globalThis.__gopeed_fetch_abort;
|
||||
|
||||
if (typeof globalThis.ReadableStream === "undefined") {
|
||||
class ReadableStreamDefaultController {
|
||||
constructor(stream) {
|
||||
this._stream = stream;
|
||||
}
|
||||
|
||||
enqueue(chunk) {
|
||||
this._stream._enqueue(chunk);
|
||||
}
|
||||
|
||||
close() {
|
||||
this._stream._close();
|
||||
}
|
||||
|
||||
error(err) {
|
||||
this._stream._error(err);
|
||||
}
|
||||
}
|
||||
|
||||
class ReadableStreamDefaultReader {
|
||||
constructor(stream) {
|
||||
this._stream = stream;
|
||||
}
|
||||
|
||||
read() {
|
||||
return this._stream._read();
|
||||
}
|
||||
|
||||
cancel(reason) {
|
||||
if (!this._stream) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return this._stream.cancel(reason);
|
||||
}
|
||||
|
||||
releaseLock() {
|
||||
if (this._stream) {
|
||||
this._stream._reader = null;
|
||||
this._stream.locked = false;
|
||||
this._stream = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ReadableStream {
|
||||
constructor(source = {}) {
|
||||
this._source = source;
|
||||
this._queue = [];
|
||||
this._waiters = [];
|
||||
this._closed = false;
|
||||
this._errored = null;
|
||||
this._reader = null;
|
||||
this._pulling = false;
|
||||
this.locked = false;
|
||||
this._controller = new ReadableStreamDefaultController(this);
|
||||
if (typeof source.start === "function") {
|
||||
source.start(this._controller);
|
||||
}
|
||||
}
|
||||
|
||||
_enqueue(chunk) {
|
||||
if (this._closed || this._errored) {
|
||||
return;
|
||||
}
|
||||
if (this._waiters.length > 0) {
|
||||
const waiter = this._waiters.shift();
|
||||
waiter.resolve({ done: false, value: chunk });
|
||||
return;
|
||||
}
|
||||
this._queue.push(chunk);
|
||||
}
|
||||
|
||||
_close() {
|
||||
this._closed = true;
|
||||
while (this._waiters.length > 0) {
|
||||
this._waiters.shift().resolve({ done: true, value: undefined });
|
||||
}
|
||||
}
|
||||
|
||||
_error(err) {
|
||||
this._errored = err || new Error("ReadableStream error");
|
||||
while (this._waiters.length > 0) {
|
||||
this._waiters.shift().reject(this._errored);
|
||||
}
|
||||
}
|
||||
|
||||
_read() {
|
||||
if (this._errored) {
|
||||
return Promise.reject(this._errored);
|
||||
}
|
||||
this._markBodyUsed();
|
||||
if (this._queue.length > 0) {
|
||||
return Promise.resolve({ done: false, value: this._queue.shift() });
|
||||
}
|
||||
if (this._closed) {
|
||||
return Promise.resolve({ done: true, value: undefined });
|
||||
}
|
||||
this._maybePull();
|
||||
if (this._queue.length > 0) {
|
||||
return Promise.resolve({ done: false, value: this._queue.shift() });
|
||||
}
|
||||
if (this._closed) {
|
||||
return Promise.resolve({ done: true, value: undefined });
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
this._waiters.push({ resolve, reject });
|
||||
this._maybePull();
|
||||
});
|
||||
}
|
||||
|
||||
_maybePull() {
|
||||
if (this._closed || this._errored || this._pulling) {
|
||||
return;
|
||||
}
|
||||
if (this._queue.length > 0) {
|
||||
return;
|
||||
}
|
||||
if (!this._source || typeof this._source.pull !== "function") {
|
||||
return;
|
||||
}
|
||||
this._pulling = true;
|
||||
Promise.resolve(this._source.pull(this._controller))
|
||||
.catch((err) => {
|
||||
this._error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
this._pulling = false;
|
||||
if (!this._closed && !this._errored && this._queue.length === 0 && this._waiters.length > 0) {
|
||||
this._maybePull();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_markBodyUsed() {
|
||||
if (typeof this.__gopeedMarkBodyUsed === "function") {
|
||||
this.__gopeedMarkBodyUsed();
|
||||
}
|
||||
}
|
||||
|
||||
getReader() {
|
||||
if (this.locked) {
|
||||
throw new TypeError("ReadableStream is locked");
|
||||
}
|
||||
this.locked = true;
|
||||
this._reader = new ReadableStreamDefaultReader(this);
|
||||
return this._reader;
|
||||
}
|
||||
|
||||
cancel(reason) {
|
||||
this._markBodyUsed();
|
||||
if (this._source && typeof this._source.cancel === "function") {
|
||||
return Promise.resolve(this._source.cancel(reason)).then(() => {
|
||||
this._close();
|
||||
});
|
||||
}
|
||||
this._close();
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async pipeTo(dest) {
|
||||
const reader = this.getReader();
|
||||
const writer = dest.getWriter();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
await writer.close();
|
||||
break;
|
||||
}
|
||||
await writer.write(value);
|
||||
}
|
||||
} catch (err) {
|
||||
await writer.abort(err);
|
||||
throw err;
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
writer.releaseLock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.ReadableStream = ReadableStream;
|
||||
}
|
||||
|
||||
if (typeof globalThis.WritableStream === "undefined") {
|
||||
class WritableStreamDefaultWriter {
|
||||
constructor(stream) {
|
||||
if (stream.locked) {
|
||||
throw new TypeError("WritableStream is locked");
|
||||
}
|
||||
this._stream = stream;
|
||||
this._stream.locked = true;
|
||||
}
|
||||
|
||||
write(chunk) {
|
||||
return this._stream._write(chunk);
|
||||
}
|
||||
|
||||
close() {
|
||||
return this._stream._close();
|
||||
}
|
||||
|
||||
abort(reason) {
|
||||
return this._stream._abort(reason);
|
||||
}
|
||||
|
||||
releaseLock() {
|
||||
if (this._stream) {
|
||||
this._stream.locked = false;
|
||||
this._stream = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class WritableStream {
|
||||
constructor(sink = {}) {
|
||||
this._sink = sink;
|
||||
this._observers = [];
|
||||
this._state = "writable";
|
||||
this.locked = false;
|
||||
}
|
||||
|
||||
_targets() {
|
||||
return [this._sink, ...this._observers];
|
||||
}
|
||||
|
||||
_write(chunk) {
|
||||
if (this._state !== "writable") {
|
||||
return Promise.reject(new TypeError("WritableStream is not writable"));
|
||||
}
|
||||
let chain = Promise.resolve();
|
||||
for (const target of this._targets()) {
|
||||
if (target && typeof target.write === "function") {
|
||||
chain = chain.then(() => target.write(chunk));
|
||||
}
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
_close() {
|
||||
if (this._state !== "writable") {
|
||||
return Promise.reject(new TypeError("WritableStream is not writable"));
|
||||
}
|
||||
this._state = "closed";
|
||||
let chain = Promise.resolve();
|
||||
for (const target of this._targets()) {
|
||||
if (target && typeof target.close === "function") {
|
||||
chain = chain.then(() => target.close());
|
||||
}
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
_abort(reason) {
|
||||
if (this._state === "errored") {
|
||||
return Promise.resolve();
|
||||
}
|
||||
this._state = "errored";
|
||||
let chain = Promise.resolve();
|
||||
for (const target of this._targets()) {
|
||||
if (target && typeof target.abort === "function") {
|
||||
chain = chain.then(() => target.abort(reason));
|
||||
}
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
_addObserver(observer) {
|
||||
this._observers.push(observer);
|
||||
}
|
||||
|
||||
getWriter() {
|
||||
return new WritableStreamDefaultWriter(this);
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.WritableStream = WritableStream;
|
||||
}
|
||||
|
||||
if (typeof globalThis.TransformStream === "undefined") {
|
||||
class TransformStream {
|
||||
constructor(transformer = {}) {
|
||||
let readableController;
|
||||
this.readable = new ReadableStream({
|
||||
start(controller) {
|
||||
readableController = controller;
|
||||
}
|
||||
});
|
||||
this.writable = new WritableStream({
|
||||
async write(chunk) {
|
||||
if (typeof transformer.transform === "function") {
|
||||
await transformer.transform(chunk, readableController);
|
||||
return;
|
||||
}
|
||||
readableController.enqueue(chunk);
|
||||
},
|
||||
async close() {
|
||||
if (typeof transformer.flush === "function") {
|
||||
await transformer.flush(readableController);
|
||||
}
|
||||
readableController.close();
|
||||
},
|
||||
async abort(reason) {
|
||||
if (typeof transformer.abort === "function") {
|
||||
await transformer.abort(reason);
|
||||
}
|
||||
readableController.error(reason || new Error("TransformStream aborted"));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.TransformStream = TransformStream;
|
||||
}
|
||||
|
||||
if (typeof globalThis.ByteLengthQueuingStrategy === "undefined") {
|
||||
globalThis.ByteLengthQueuingStrategy = class ByteLengthQueuingStrategy {
|
||||
constructor({ highWaterMark }) {
|
||||
this.highWaterMark = highWaterMark;
|
||||
}
|
||||
|
||||
size(chunk) {
|
||||
if (typeof chunk === "string") {
|
||||
return chunk.length;
|
||||
}
|
||||
if (chunk && typeof chunk.byteLength === "number") {
|
||||
return chunk.byteLength;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof globalThis.CountQueuingStrategy === "undefined") {
|
||||
globalThis.CountQueuingStrategy = class CountQueuingStrategy {
|
||||
constructor({ highWaterMark }) {
|
||||
this.highWaterMark = highWaterMark;
|
||||
}
|
||||
|
||||
size() {
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function toUint8Array(chunk) {
|
||||
if (chunk == null) {
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
if (chunk instanceof Uint8Array) {
|
||||
return chunk;
|
||||
}
|
||||
if (typeof chunk === "string") {
|
||||
return new TextEncoder().encode(chunk);
|
||||
}
|
||||
if (chunk instanceof ArrayBuffer) {
|
||||
return new Uint8Array(chunk);
|
||||
}
|
||||
if (typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView && ArrayBuffer.isView(chunk)) {
|
||||
return new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
||||
}
|
||||
if (typeof Blob !== "undefined" && chunk instanceof Blob) {
|
||||
if (chunk._buffer instanceof Uint8Array) {
|
||||
return chunk._buffer;
|
||||
}
|
||||
}
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
|
||||
function createBodyReadableStream(owner) {
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
Promise.resolve().then(async () => {
|
||||
owner.bodyUsed = true;
|
||||
if (owner._noBody) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
if (owner._bodyArrayBuffer) {
|
||||
controller.enqueue(toUint8Array(owner._bodyArrayBuffer));
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
if (owner._bodyBlob) {
|
||||
const data = await owner._bodyBlob.arrayBuffer();
|
||||
controller.enqueue(new Uint8Array(data));
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
if (owner._bodyText != null) {
|
||||
controller.enqueue(toUint8Array(owner._bodyText));
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
if (owner._bodyInit != null) {
|
||||
controller.enqueue(toUint8Array(owner._bodyInit));
|
||||
}
|
||||
controller.close();
|
||||
}).catch((err) => {
|
||||
controller.error(err);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function readAllFromStream(stream, asText) {
|
||||
if (!stream) {
|
||||
return asText ? "" : new Uint8Array(0);
|
||||
}
|
||||
const reader = stream.getReader();
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
const chunk = toUint8Array(value);
|
||||
chunks.push(chunk);
|
||||
total += chunk.byteLength;
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
const merged = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
merged.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
if (asText) {
|
||||
return new TextDecoder().decode(merged);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function attachResponseStreaming(response, stream) {
|
||||
response.__gopeedBodyStream = stream;
|
||||
response.__gopeedBodyConsumed = false;
|
||||
const ensureUnused = () => {
|
||||
if (response.__gopeedBodyConsumed) {
|
||||
throw new TypeError("Already read");
|
||||
}
|
||||
};
|
||||
const markBodyUsed = () => {
|
||||
ensureUnused();
|
||||
response.__gopeedBodyConsumed = true;
|
||||
response.bodyUsed = true;
|
||||
};
|
||||
if (stream) {
|
||||
stream.__gopeedMarkBodyUsed = () => {
|
||||
if (!response.__gopeedBodyConsumed) {
|
||||
response.__gopeedBodyConsumed = true;
|
||||
response.bodyUsed = true;
|
||||
}
|
||||
};
|
||||
}
|
||||
response.text = async function () {
|
||||
ensureUnused();
|
||||
markBodyUsed();
|
||||
return readAllFromStream(stream, true);
|
||||
};
|
||||
response.arrayBuffer = async function () {
|
||||
ensureUnused();
|
||||
markBodyUsed();
|
||||
const bytes = await readAllFromStream(stream, false);
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||||
};
|
||||
response.blob = async function () {
|
||||
ensureUnused();
|
||||
markBodyUsed();
|
||||
const contentType = this.headers && this.headers.get ? (this.headers.get("content-type") || "") : "";
|
||||
// Response.blob() is a materializing body consumer. Keeping the live
|
||||
// response stream behind an empty one-shot Blob makes retry, range and a
|
||||
// second object-URL reader silently produce an empty file.
|
||||
const bytes = await readAllFromStream(stream, false);
|
||||
return new Blob([bytes], { type: contentType });
|
||||
};
|
||||
response.json = async function () {
|
||||
const text = await this.text();
|
||||
return JSON.parse(text);
|
||||
};
|
||||
return response;
|
||||
}
|
||||
|
||||
if (typeof globalThis.Response === "function") {
|
||||
const responseProto = globalThis.Response.prototype;
|
||||
const bodyDescriptor = Object.getOwnPropertyDescriptor(responseProto, "body");
|
||||
if (!bodyDescriptor || typeof bodyDescriptor.get !== "function") {
|
||||
Object.defineProperty(responseProto, "body", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get() {
|
||||
if (this.__gopeedBodyStream) {
|
||||
return this.__gopeedBodyStream;
|
||||
}
|
||||
if (!this.__gopeedBodyStream) {
|
||||
this.__gopeedBodyStream = createBodyReadableStream(this);
|
||||
}
|
||||
return this.__gopeedBodyStream;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const blobReaders = new Map();
|
||||
|
||||
function getValueTypeName(value) {
|
||||
if (value === null) {
|
||||
return "null";
|
||||
}
|
||||
if (value === undefined) {
|
||||
return "undefined";
|
||||
}
|
||||
if (value && value.constructor && typeof value.constructor.name === "string" && value.constructor.name) {
|
||||
return value.constructor.name;
|
||||
}
|
||||
return typeof value;
|
||||
}
|
||||
|
||||
function toReadableStreamReader(value, sourceLabel) {
|
||||
if (value && typeof value.getReader === "function") {
|
||||
return value.getReader();
|
||||
}
|
||||
throw new TypeError(sourceLabel + " must return a ReadableStream, got " + getValueTypeName(value));
|
||||
}
|
||||
|
||||
async function openBlobReadable(blob, request) {
|
||||
const { offset, end } = request;
|
||||
const sliced = blob.slice(offset, end >= offset ? end + 1 : undefined);
|
||||
if (sliced && typeof sliced.stream === "function") {
|
||||
try {
|
||||
const stream = sliced.stream();
|
||||
if (stream && typeof stream.getReader === "function") {
|
||||
return stream;
|
||||
}
|
||||
} catch (_) {
|
||||
}
|
||||
}
|
||||
if (sliced && typeof sliced.arrayBuffer === "function") {
|
||||
const buffer = await sliced.arrayBuffer();
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(buffer));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
throw new TypeError("Blob source cannot be converted to a ReadableStream");
|
||||
}
|
||||
|
||||
function describeObjectURLValue(value) {
|
||||
if (value instanceof Blob) {
|
||||
return {
|
||||
kind: "blob",
|
||||
value,
|
||||
};
|
||||
}
|
||||
if (typeof value === "function") {
|
||||
return {
|
||||
kind: "opener",
|
||||
openReadable: value,
|
||||
sourceLabel: "gopeed.runtime.blob.createObjectURL opener function",
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "other",
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
function releaseReader(reader, reason) {
|
||||
if (!reader) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (reason !== undefined && typeof reader.cancel === "function") {
|
||||
const result = reader.cancel(reason);
|
||||
if (result && typeof result.catch === "function") {
|
||||
result.catch(function () {});
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
}
|
||||
if (typeof reader.releaseLock === "function") {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch (_) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBlobObjectURLOptions(value, options, defaultRange) {
|
||||
const normalized = {
|
||||
contentType: "",
|
||||
size: 0,
|
||||
range: !!defaultRange,
|
||||
};
|
||||
if (options && typeof options === "object") {
|
||||
if (typeof options.contentType === "string") {
|
||||
normalized.contentType = options.contentType;
|
||||
}
|
||||
if (Number.isFinite(Number(options.size)) && Number(options.size) > 0) {
|
||||
normalized.size = Number(options.size);
|
||||
}
|
||||
if (typeof options.range === "boolean") {
|
||||
normalized.range = options.range;
|
||||
}
|
||||
}
|
||||
if (!normalized.contentType && value && typeof value.type === "string") {
|
||||
normalized.contentType = value.type;
|
||||
}
|
||||
if (!normalized.size && value) {
|
||||
if (Number.isFinite(Number(value.size)) && Number(value.size) > 0) {
|
||||
normalized.size = Number(value.size);
|
||||
} else if (value._buffer && Number.isFinite(Number(value._buffer.byteLength))) {
|
||||
normalized.size = Number(value._buffer.byteLength);
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function validateBlobObjectURLOptions(options) {
|
||||
if (options.range && !(Number.isFinite(Number(options.size)) && Number(options.size) > 0)) {
|
||||
throw new TypeError("gopeed.runtime.blob.createObjectURL options.range requires a positive options.size");
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOpenRequest(request) {
|
||||
const offset = Math.max(0, Number(request && request.offset) || 0);
|
||||
const endValue = Number(request && request.end);
|
||||
const end = Number.isFinite(endValue) ? endValue : -1;
|
||||
return { offset, end };
|
||||
}
|
||||
|
||||
function yieldBlobPipeTask() {
|
||||
return new Promise((resolve) => {
|
||||
if (typeof setTimeout === "function") {
|
||||
setTimeout(resolve, 0);
|
||||
} else {
|
||||
Promise.resolve().then(resolve);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createPendingBlobReader(openReadable, request) {
|
||||
let activeReader;
|
||||
let ready;
|
||||
function ensureReader() {
|
||||
if (ready) {
|
||||
return ready;
|
||||
}
|
||||
ready = (async function () {
|
||||
const source = await openReadable(normalizeOpenRequest(request));
|
||||
activeReader = toReadableStreamReader(source, "gopeed.runtime.blob.createObjectURL opener function");
|
||||
return activeReader;
|
||||
})();
|
||||
return ready;
|
||||
}
|
||||
return {
|
||||
async read() {
|
||||
const reader = await ensureReader();
|
||||
return reader.read();
|
||||
},
|
||||
cancel(reason) {
|
||||
return ensureReader().then(function (reader) {
|
||||
return reader.cancel(reason);
|
||||
}, function () {});
|
||||
},
|
||||
releaseLock() {
|
||||
if (!ready) {
|
||||
return;
|
||||
}
|
||||
return ready.then(function (reader) {
|
||||
return reader.releaseLock();
|
||||
}, function () {});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
globalThis.__gopeed_blob_open_source = function (openReadable, request) {
|
||||
if (typeof openReadable !== "function") {
|
||||
throw new TypeError("blob opener must be callable");
|
||||
}
|
||||
const id = crypto.randomUUID ? crypto.randomUUID() : String(Date.now()) + "-" + String(Math.random());
|
||||
blobReaders.set(id, createPendingBlobReader(openReadable, request));
|
||||
return id;
|
||||
};
|
||||
|
||||
globalThis.__gopeed_blob_read_source = async function (id, chunkSize) {
|
||||
const reader = blobReaders.get(id);
|
||||
if (!reader) {
|
||||
return null;
|
||||
}
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
blobReaders.delete(id);
|
||||
releaseReader(reader);
|
||||
return null;
|
||||
}
|
||||
const chunk = value instanceof Uint8Array ? value : new Uint8Array(value);
|
||||
const size = Math.max(1, Number(chunkSize) || chunk.byteLength);
|
||||
if (chunk.byteLength <= size) {
|
||||
return chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength);
|
||||
}
|
||||
const head = chunk.slice(0, size);
|
||||
let offset = size;
|
||||
blobReaders.set(id, {
|
||||
read() {
|
||||
if (offset >= chunk.byteLength) {
|
||||
return reader.read();
|
||||
}
|
||||
const next = chunk.slice(offset, Math.min(offset + size, chunk.byteLength));
|
||||
offset += next.byteLength;
|
||||
return Promise.resolve({ done: false, value: next });
|
||||
},
|
||||
cancel(reason) {
|
||||
return reader.cancel(reason);
|
||||
},
|
||||
releaseLock() {
|
||||
return reader.releaseLock();
|
||||
}
|
||||
});
|
||||
return head.buffer.slice(head.byteOffset, head.byteOffset + head.byteLength);
|
||||
};
|
||||
|
||||
globalThis.__gopeed_blob_close_source = function (id) {
|
||||
const reader = blobReaders.get(id);
|
||||
if (!reader) {
|
||||
return;
|
||||
}
|
||||
blobReaders.delete(id);
|
||||
releaseReader(reader, "blob request closed");
|
||||
};
|
||||
|
||||
const blobPipeReaderStates = new Map();
|
||||
|
||||
function releaseBlobPipeReader(state) {
|
||||
if (!state || state.released) {
|
||||
return;
|
||||
}
|
||||
state.released = true;
|
||||
if (state.reader && typeof state.reader.releaseLock === "function") {
|
||||
try {
|
||||
state.reader.releaseLock();
|
||||
} catch (_) {
|
||||
}
|
||||
}
|
||||
if (blobPipeReaderStates.get(state.pipeId) === state) {
|
||||
blobPipeReaderStates.delete(state.pipeId);
|
||||
}
|
||||
}
|
||||
|
||||
function finishBlobPipeReader(state, cancel, reason) {
|
||||
if (!state) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (cancel) {
|
||||
state.cancelRequested = true;
|
||||
if (reason !== undefined) {
|
||||
state.cancelReason = reason;
|
||||
}
|
||||
}
|
||||
if (state.cleanupPromise) {
|
||||
return state.cleanupPromise;
|
||||
}
|
||||
if (!state.reader) {
|
||||
// A cancellation may arrive while the asynchronous opener is pending.
|
||||
// Keep the state until the reader exists so it can still be canceled.
|
||||
if (!state.cancelRequested || state.openSettled) {
|
||||
releaseBlobPipeReader(state);
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
state.cleanupPromise = (async function () {
|
||||
try {
|
||||
if (state.cancelRequested && typeof state.reader.cancel === "function") {
|
||||
await state.reader.cancel(state.cancelReason);
|
||||
}
|
||||
} catch (_) {
|
||||
} finally {
|
||||
releaseBlobPipeReader(state);
|
||||
}
|
||||
})();
|
||||
return state.cleanupPromise;
|
||||
}
|
||||
|
||||
globalThis.__gopeed_blob_cancel_pipe_source = function (pipeId, reason) {
|
||||
const state = blobPipeReaderStates.get(pipeId);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
// finishBlobPipeReader invokes reader.cancel() synchronously up to its
|
||||
// first await. Do not return the cleanup Promise: Go only needs to ensure
|
||||
// cancellation has started before releasing the source session.
|
||||
finishBlobPipeReader(state, true, reason).catch(function () {});
|
||||
};
|
||||
|
||||
globalThis.__gopeed_blob_pipe_source = function (openReadable, request, pipeId) {
|
||||
if (typeof openReadable !== "function") {
|
||||
throw new TypeError("blob opener must be callable");
|
||||
}
|
||||
const state = {
|
||||
pipeId,
|
||||
reader: null,
|
||||
cancelRequested: false,
|
||||
cancelReason: "blob pipe closed",
|
||||
cleanupPromise: null,
|
||||
openSettled: false,
|
||||
released: false,
|
||||
};
|
||||
blobPipeReaderStates.set(pipeId, state);
|
||||
const startPipe = async function () {
|
||||
try {
|
||||
const source = await openReadable(normalizeOpenRequest(request));
|
||||
state.openSettled = true;
|
||||
state.reader = toReadableStreamReader(source, "gopeed.runtime.blob.createObjectURL opener function");
|
||||
if (state.cancelRequested) {
|
||||
await finishBlobPipeReader(state, true, state.cancelReason);
|
||||
return;
|
||||
}
|
||||
while (true) {
|
||||
const { done, value } = await state.reader.read();
|
||||
if (done) {
|
||||
globalThis.__gopeed_blob_pipe_close(pipeId);
|
||||
return;
|
||||
}
|
||||
const chunk = value instanceof Uint8Array ? value : new Uint8Array(value);
|
||||
const buffer = chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength);
|
||||
if (!globalThis.__gopeed_blob_pipe_chunk(pipeId, buffer)) {
|
||||
state.cancelRequested = true;
|
||||
state.cancelReason = "blob pipe closed";
|
||||
return;
|
||||
}
|
||||
await yieldBlobPipeTask();
|
||||
}
|
||||
} catch (error) {
|
||||
if (!state.cancelRequested) {
|
||||
globalThis.__gopeed_blob_pipe_error(pipeId, error && error.stack ? error.stack : String(error));
|
||||
}
|
||||
} finally {
|
||||
state.openSettled = true;
|
||||
await finishBlobPipeReader(state, state.cancelRequested, state.cancelReason);
|
||||
}
|
||||
};
|
||||
if (typeof setTimeout === "function") {
|
||||
setTimeout(startPipe, 0);
|
||||
} else {
|
||||
Promise.resolve().then(startPipe);
|
||||
}
|
||||
};
|
||||
|
||||
globalThis.__gopeed_blob_create_object_url = function (value, options) {
|
||||
const described = describeObjectURLValue(value);
|
||||
if (described.kind === "blob") {
|
||||
const blob = described.value;
|
||||
const normalized = normalizeBlobObjectURLOptions(blob, options, Number(blob.size) > 0);
|
||||
validateBlobObjectURLOptions(normalized);
|
||||
const opener = async (request) => openBlobReadable(blob, request);
|
||||
return createBlobObjectURL(opener, normalized);
|
||||
}
|
||||
if (described.kind === "opener") {
|
||||
const normalized = normalizeBlobObjectURLOptions(null, options, false);
|
||||
validateBlobObjectURLOptions(normalized);
|
||||
return createBlobObjectURL(described.openReadable, normalized);
|
||||
}
|
||||
throw new TypeError("Unsupported object type for gopeed.runtime.blob.createObjectURL: " + getValueTypeName(described.value) + ". Expected Blob or opener function");
|
||||
};
|
||||
|
||||
globalThis.__gopeed_blob_revoke_object_url = function (url) {
|
||||
if (typeof url === "string") {
|
||||
revokeObjectURL(url);
|
||||
}
|
||||
};
|
||||
|
||||
const originalFetch = typeof globalThis.fetch === "function"
|
||||
? globalThis.fetch.bind(globalThis)
|
||||
: null;
|
||||
|
||||
if (typeof fetchOpen === "function") {
|
||||
globalThis.fetch = async function (input, init) {
|
||||
const request = new Request(input, init);
|
||||
if (originalFetch && (request.method === "HEAD" || request.redirect === "manual" || request.redirect === "error")) {
|
||||
return originalFetch(input, init);
|
||||
}
|
||||
let body = null;
|
||||
if (request._bodyFormData) {
|
||||
body = request._bodyFormData;
|
||||
} else if (request._bodyArrayBuffer) {
|
||||
body = request._bodyArrayBuffer;
|
||||
} else if (request._bodyBlob) {
|
||||
body = await request._bodyBlob.arrayBuffer();
|
||||
} else if (request._bodyInit != null && typeof request._bodyInit === "object") {
|
||||
body = request._bodyInit;
|
||||
} else if (request._bodyText != null) {
|
||||
body = request._bodyText;
|
||||
} else if (request._bodyInit != null) {
|
||||
body = request._bodyInit;
|
||||
}
|
||||
const headers = [];
|
||||
request.headers.forEach((value, key) => {
|
||||
headers.push([key, value]);
|
||||
});
|
||||
let meta;
|
||||
try {
|
||||
meta = await fetchOpen({
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
headers,
|
||||
body,
|
||||
redirect: request.redirect,
|
||||
credentials: request.credentials
|
||||
});
|
||||
} catch (error) {
|
||||
throw error instanceof Error ? error : new TypeError(String(error));
|
||||
}
|
||||
const stream = new ReadableStream({
|
||||
async pull(controller) {
|
||||
let chunk;
|
||||
try {
|
||||
chunk = await fetchRead(meta.id, 64 * 1024);
|
||||
} catch (error) {
|
||||
fetchClose(meta.id);
|
||||
controller.error(error instanceof Error ? error : new TypeError(String(error)));
|
||||
return;
|
||||
}
|
||||
if (chunk == null) {
|
||||
fetchClose(meta.id);
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
const bytes = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk);
|
||||
if (bytes.byteLength === 0) {
|
||||
fetchClose(meta.id);
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
controller.enqueue(bytes);
|
||||
},
|
||||
cancel(reason) {
|
||||
fetchAbort(meta.id, reason == null ? "" : String(reason));
|
||||
}
|
||||
});
|
||||
const response = new Response(null, {
|
||||
status: meta.status,
|
||||
statusText: meta.statusText,
|
||||
headers: meta.headers,
|
||||
url: meta.url
|
||||
});
|
||||
return attachResponseStreaming(response, stream);
|
||||
};
|
||||
globalThis.fetch.__gopeedOriginalFetch = originalFetch;
|
||||
}
|
||||
})();
|
||||
@@ -1,6 +1,8 @@
|
||||
package vm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/dop251/goja_nodejs/eventloop"
|
||||
)
|
||||
@@ -25,7 +27,12 @@ func (vm *Vm) Get(name string) (value any) {
|
||||
func (vm *Vm) RunString(script string) (value any, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = r.(error)
|
||||
switch v := r.(type) {
|
||||
case error:
|
||||
err = v
|
||||
default:
|
||||
err = fmt.Errorf("panic: %v", r)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
@@ -2,16 +2,20 @@ package xhr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/file"
|
||||
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/formdata"
|
||||
"github.com/GopeedLab/gopeed/pkg/download/engine/util"
|
||||
"github.com/dop251/goja"
|
||||
"errors"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/file"
|
||||
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/formdata"
|
||||
"github.com/GopeedLab/gopeed/pkg/download/engine/util"
|
||||
"github.com/dop251/goja"
|
||||
"github.com/imroc/req/v3"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -23,6 +27,12 @@ const (
|
||||
eventTimeout = "timeout"
|
||||
)
|
||||
|
||||
const (
|
||||
redirectError = "error"
|
||||
redirectFollow = "follow"
|
||||
redirectManual = "manual"
|
||||
)
|
||||
|
||||
type ProgressEvent struct {
|
||||
Type string `json:"type"`
|
||||
LengthComputable bool `json:"lengthComputable"`
|
||||
@@ -117,18 +127,27 @@ type XMLHttpRequestUpload struct {
|
||||
type XMLHttpRequest struct {
|
||||
method string
|
||||
url string
|
||||
requestHeaders map[string]string
|
||||
responseHeaders map[string]string
|
||||
requestHeaders http.Header
|
||||
responseHeaders http.Header
|
||||
aborted bool
|
||||
proxyUrl *url.URL
|
||||
client *req.Client
|
||||
fingerprint string
|
||||
|
||||
Upload *XMLHttpRequestUpload `json:"upload"`
|
||||
Timeout int `json:"timeout"`
|
||||
ReadyState int `json:"readyState"`
|
||||
Status int `json:"status"`
|
||||
StatusText string `json:"statusText"`
|
||||
Response string `json:"response"`
|
||||
ResponseText string `json:"responseText"`
|
||||
WithCredentials bool `json:"withCredentials"`
|
||||
Upload *XMLHttpRequestUpload `json:"upload"`
|
||||
Timeout int `json:"timeout"`
|
||||
ReadyState int `json:"readyState"`
|
||||
Status int `json:"status"`
|
||||
StatusText string `json:"statusText"`
|
||||
Response string `json:"response"`
|
||||
ResponseText string `json:"responseText"`
|
||||
// https://developer.mozilla.org/zh-CN/docs/Web/API/XMLHttpRequest/responseURL
|
||||
// https://xhr.spec.whatwg.org/#the-responseurl-attribute
|
||||
ResponseUrl string `json:"responseURL"`
|
||||
// extend fetch redirect
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/RequestInit#redirect
|
||||
// https://fetch.spec.whatwg.org/#concept-request-redirect-mode
|
||||
Redirect string `json:"redirect"`
|
||||
*EventProp
|
||||
Onreadystatechange func(event *ProgressEvent) `json:"onreadystatechange"`
|
||||
}
|
||||
@@ -136,47 +155,63 @@ type XMLHttpRequest struct {
|
||||
func (xhr *XMLHttpRequest) Open(method, url string) {
|
||||
xhr.method = method
|
||||
xhr.url = url
|
||||
xhr.requestHeaders = make(map[string]string)
|
||||
xhr.responseHeaders = make(map[string]string)
|
||||
xhr.requestHeaders = make(http.Header)
|
||||
xhr.responseHeaders = make(http.Header)
|
||||
xhr.doReadystatechange(1)
|
||||
}
|
||||
|
||||
func (xhr *XMLHttpRequest) SetRequestHeader(key, value string) {
|
||||
xhr.requestHeaders[key] = value
|
||||
xhr.requestHeaders.Add(key, value)
|
||||
}
|
||||
|
||||
func (xhr *XMLHttpRequest) Send(data goja.Value) {
|
||||
var req *http.Request
|
||||
var err error
|
||||
setFingerprint(xhr.client, xhr.fingerprint)
|
||||
|
||||
d := xhr.parseData(data)
|
||||
var (
|
||||
contentType string
|
||||
contentLength int64
|
||||
isStringBody bool
|
||||
)
|
||||
if d == nil || xhr.method == "GET" || xhr.method == "HEAD" {
|
||||
req, err = http.NewRequest(xhr.method, xhr.url, nil)
|
||||
} else {
|
||||
switch d.(type) {
|
||||
|
||||
// Create request using req library
|
||||
reqBuilder := xhr.client.R()
|
||||
|
||||
// Set headers first
|
||||
if xhr.requestHeaders != nil {
|
||||
for key, values := range xhr.requestHeaders {
|
||||
if len(values) > 0 {
|
||||
// Merge multiple values with comma separator (HTTP standard)
|
||||
mergedValue := strings.Join(values, ", ")
|
||||
reqBuilder.SetHeader(key, mergedValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle request body
|
||||
if d != nil && xhr.method != "GET" && xhr.method != "HEAD" {
|
||||
switch v := d.(type) {
|
||||
case string:
|
||||
req, err = http.NewRequest(xhr.method, xhr.url, bytes.NewBufferString(d.(string)))
|
||||
reqBuilder.SetBody(v)
|
||||
contentType = "text/plain;charset=UTF-8"
|
||||
contentLength = int64(len(d.(string)))
|
||||
contentLength = int64(len(v))
|
||||
isStringBody = true
|
||||
case *file.File:
|
||||
req, err = http.NewRequest(xhr.method, xhr.url, d.(*file.File).Reader)
|
||||
reqBuilder.SetBody(v.Reader)
|
||||
contentType = "application/octet-stream"
|
||||
contentLength = d.(*file.File).Size
|
||||
contentLength = v.Size
|
||||
case *formdata.FormData:
|
||||
pr, pw := io.Pipe()
|
||||
mw := NewMultipart(pw)
|
||||
for _, e := range d.(*formdata.FormData).Entries() {
|
||||
for _, e := range v.Entries() {
|
||||
arr := e.([]any)
|
||||
k := arr[0].(string)
|
||||
v := arr[1]
|
||||
switch v.(type) {
|
||||
switch v := v.(type) {
|
||||
case string:
|
||||
mw.WriteField(k, v.(string))
|
||||
mw.WriteField(k, v)
|
||||
case *file.File:
|
||||
mw.WriteFile(k, v.(*file.File))
|
||||
mw.WriteFile(k, v)
|
||||
}
|
||||
}
|
||||
go func() {
|
||||
@@ -184,36 +219,42 @@ func (xhr *XMLHttpRequest) Send(data goja.Value) {
|
||||
defer mw.Close()
|
||||
mw.Send()
|
||||
}()
|
||||
req, err = http.NewRequest(xhr.method, xhr.url, pr)
|
||||
reqBuilder.SetBody(pr)
|
||||
contentType = mw.FormDataContentType()
|
||||
contentLength = mw.Size()
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
xhr.callOnerror()
|
||||
return
|
||||
|
||||
// Only string body can specify Content-Type header by user
|
||||
if contentType != "" && (!isStringBody || xhr.requestHeaders.Get("Content-Type") == "") {
|
||||
reqBuilder.SetHeader("Content-Type", contentType)
|
||||
}
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
|
||||
// Set timeout
|
||||
if xhr.Timeout > 0 {
|
||||
xhr.client.SetTimeout(time.Duration(xhr.Timeout) * time.Millisecond)
|
||||
}
|
||||
if contentLength > 0 {
|
||||
req.ContentLength = contentLength
|
||||
}
|
||||
for k, v := range xhr.requestHeaders {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
transport := &http.Transport{}
|
||||
if xhr.proxyUrl != nil {
|
||||
transport.Proxy = http.ProxyURL(xhr.proxyUrl)
|
||||
}
|
||||
client := &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: time.Duration(xhr.Timeout) * time.Millisecond,
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
|
||||
// Configure redirect behavior
|
||||
xhr.client.SetRedirectPolicy(func(req *http.Request, via []*http.Request) error {
|
||||
if xhr.Redirect == redirectManual {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
if xhr.Redirect == redirectError {
|
||||
return errors.New("redirect failed")
|
||||
}
|
||||
if len(via) > 20 {
|
||||
return errors.New("too many redirects")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// Execute request
|
||||
resp, err := reqBuilder.Send(xhr.method, xhr.url)
|
||||
if err != nil {
|
||||
// handle timeout error
|
||||
if err, ok := err.(net.Error); ok && err.Timeout() {
|
||||
var ne net.Error
|
||||
if errors.As(err, &ne) && ne.Timeout() {
|
||||
if xhr.Timeout > 0 {
|
||||
xhr.Upload.callOntimeout()
|
||||
xhr.callOntimeout()
|
||||
@@ -224,32 +265,37 @@ func (xhr *XMLHttpRequest) Send(data goja.Value) {
|
||||
xhr.callOnerror()
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
xhr.Upload.callOnprogress(contentLength, contentLength)
|
||||
if !xhr.aborted {
|
||||
xhr.Upload.callOnload()
|
||||
}
|
||||
for k, v := range resp.Header {
|
||||
xhr.responseHeaders[k] = v[0]
|
||||
|
||||
// Set response URL (final URL after redirects)
|
||||
if resp.Response != nil && resp.Response.Request != nil && resp.Response.Request.URL != nil {
|
||||
responseUrl := resp.Response.Request.URL
|
||||
responseUrl.Fragment = ""
|
||||
xhr.ResponseUrl = responseUrl.String()
|
||||
} else {
|
||||
xhr.ResponseUrl = xhr.url
|
||||
}
|
||||
|
||||
// Set response headers
|
||||
xhr.responseHeaders = resp.Header
|
||||
xhr.Status = resp.StatusCode
|
||||
xhr.StatusText = resp.Status
|
||||
xhr.doReadystatechange(2)
|
||||
buf, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
xhr.callOnerror()
|
||||
return
|
||||
}
|
||||
|
||||
bodyBytes := resp.Bytes()
|
||||
xhr.doReadystatechange(3)
|
||||
xhr.Response = string(buf)
|
||||
xhr.Response = string(bodyBytes)
|
||||
xhr.ResponseText = xhr.Response
|
||||
xhr.doReadystatechange(4)
|
||||
respBodyLen := int64(len(buf))
|
||||
respBodyLen := int64(len(bodyBytes))
|
||||
xhr.callOnprogress(respBodyLen, respBodyLen)
|
||||
if !xhr.aborted {
|
||||
xhr.callOnload()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (xhr *XMLHttpRequest) Abort() {
|
||||
@@ -260,7 +306,10 @@ func (xhr *XMLHttpRequest) Abort() {
|
||||
}
|
||||
|
||||
func (xhr *XMLHttpRequest) GetResponseHeader(key string) string {
|
||||
return xhr.responseHeaders[key]
|
||||
if xhr.responseHeaders == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(xhr.responseHeaders.Values(key), ", ")
|
||||
}
|
||||
|
||||
func (xhr *XMLHttpRequest) GetAllResponseHeaders() string {
|
||||
@@ -268,7 +317,7 @@ func (xhr *XMLHttpRequest) GetAllResponseHeaders() string {
|
||||
for k, v := range xhr.responseHeaders {
|
||||
buf.WriteString(k)
|
||||
buf.WriteString(": ")
|
||||
buf.WriteString(v)
|
||||
buf.WriteString(strings.Join(v, ", "))
|
||||
buf.WriteString("\r\n")
|
||||
}
|
||||
return buf.String()
|
||||
@@ -313,7 +362,7 @@ func (xhr *XMLHttpRequest) parseData(data goja.Value) any {
|
||||
return data.String()
|
||||
}
|
||||
|
||||
func Enable(runtime *goja.Runtime, proxyUrl *url.URL) error {
|
||||
func Enable(runtime *goja.Runtime, proxyHandler func(r *http.Request) (*url.URL, error)) error {
|
||||
progressEvent := runtime.ToValue(func(call goja.ConstructorCall) *goja.Object {
|
||||
if len(call.Arguments) < 1 {
|
||||
util.ThrowTypeError(runtime, "Failed to construct 'ProgressEvent': 1 argument required, but only 0 present.")
|
||||
@@ -326,8 +375,15 @@ func Enable(runtime *goja.Runtime, proxyUrl *url.URL) error {
|
||||
return instanceValue
|
||||
})
|
||||
xhr := runtime.ToValue(func(call goja.ConstructorCall) *goja.Object {
|
||||
// Create req client with proxy support
|
||||
client := req.C()
|
||||
if proxyHandler != nil {
|
||||
client.SetProxy(proxyHandler)
|
||||
}
|
||||
|
||||
instance := &XMLHttpRequest{
|
||||
proxyUrl: proxyUrl,
|
||||
client: client,
|
||||
fingerprint: util.SafeGet[string](runtime, FingerprintMagicKey),
|
||||
Upload: &XMLHttpRequestUpload{
|
||||
EventProp: &EventProp{
|
||||
eventListeners: make(map[string]func(event *ProgressEvent)),
|
||||
@@ -341,6 +397,9 @@ func Enable(runtime *goja.Runtime, proxyUrl *url.URL) error {
|
||||
instanceValue.SetPrototype(call.This.Prototype())
|
||||
return instanceValue
|
||||
})
|
||||
runtime.Set("__gopeed_setFingerprint", func(fingerprint string) {
|
||||
runtime.Set(FingerprintMagicKey, fingerprint)
|
||||
})
|
||||
if err := runtime.Set("ProgressEvent", progressEvent); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -386,10 +445,9 @@ func (w *multipartWrapper) Size() int64 {
|
||||
w.statWriter.Close()
|
||||
size := int64(w.statBuffer.Len())
|
||||
for _, v := range w.fields {
|
||||
switch v.(type) {
|
||||
switch v := v.(type) {
|
||||
case *file.File:
|
||||
f := v.(*file.File)
|
||||
size += f.Size
|
||||
size += v.Size
|
||||
}
|
||||
}
|
||||
return size
|
||||
@@ -397,18 +455,17 @@ func (w *multipartWrapper) Size() int64 {
|
||||
|
||||
func (w *multipartWrapper) Send() error {
|
||||
for k, v := range w.fields {
|
||||
switch v.(type) {
|
||||
switch v := v.(type) {
|
||||
case string:
|
||||
if err := w.writer.WriteField(k, v.(string)); err != nil {
|
||||
if err := w.writer.WriteField(k, v); err != nil {
|
||||
return err
|
||||
}
|
||||
case *file.File:
|
||||
f := v.(*file.File)
|
||||
fw, err := w.writer.CreateFormFile(k, f.Name)
|
||||
fw, err := w.writer.CreateFormFile(k, v.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = io.Copy(fw, f); err != nil {
|
||||
if _, err = io.Copy(fw, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package xhr
|
||||
|
||||
import "github.com/imroc/req/v3"
|
||||
|
||||
type Fingerprint string
|
||||
|
||||
const (
|
||||
FingerprintMagicKey = "__gopeed_xhr_fingerprint"
|
||||
|
||||
fingerprintChrome = "chrome"
|
||||
fingerprintFirefox = "firefox"
|
||||
fingerprintSafari = "safari"
|
||||
)
|
||||
|
||||
func setFingerprint(client *req.Client, fingerprint string) {
|
||||
switch fingerprint {
|
||||
case fingerprintChrome:
|
||||
client.ImpersonateChrome()
|
||||
case fingerprintFirefox:
|
||||
client.ImpersonateFirefox()
|
||||
case fingerprintSafari:
|
||||
client.ImpersonateSafari()
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
(()=>{var t={725:function(t,e,r){var o,n,i;i="undefined"!=typeof self&&self||"undefined"!=typeof window&&window||void 0!==r.g&&r.g||this,o=function(t){"use strict";var e=i.BlobBuilder||i.WebKitBlobBuilder||i.MSBlobBuilder||i.MozBlobBuilder,r=i.URL||i.webkitURL||function(t,e){return(e=document.createElement("a")).href=t,e},o=i.Blob,n=r.createObjectURL,a=r.revokeObjectURL,s=i.Symbol&&i.Symbol.toStringTag,u=!1,f=!1,l=e&&e.prototype.append&&e.prototype.getBlob;try{u=2===new Blob(["ä"]).size,f=2===new Blob([new Uint8Array([1,2])]).size}catch(t){}function c(t){return t.map((function(t){if(t.buffer instanceof ArrayBuffer){var e=t.buffer;if(t.byteLength!==e.byteLength){var r=new Uint8Array(t.byteLength);r.set(new Uint8Array(e,t.byteOffset,t.byteLength)),e=r.buffer}return e}return t}))}function h(t,r){r=r||{};var o=new e;return c(t).forEach((function(t){o.append(t)})),r.type?o.getBlob(r.type):o.getBlob()}function p(t,e){return new o(c(t),e||{})}i.Blob&&(h.prototype=Blob.prototype,p.prototype=Blob.prototype);var y="function"==typeof TextEncoder?TextEncoder.prototype.encode.bind(new TextEncoder):function(t){for(var e=0,r=t.length,o=i.Uint8Array||Array,n=0,a=Math.max(32,r+(r>>1)+7),s=new o(a>>3<<3);e<r;){var u=t.charCodeAt(e++);if(u>=55296&&u<=56319){if(e<r){var f=t.charCodeAt(e);56320==(64512&f)&&(++e,u=((1023&u)<<10)+(1023&f)+65536)}if(u>=55296&&u<=56319)continue}if(n+4>s.length){a+=8,a=(a*=1+e/t.length*2)>>3<<3;var l=new Uint8Array(a);l.set(s),s=l}if(0!=(4294967168&u)){if(0==(4294965248&u))s[n++]=u>>6&31|192;else if(0==(4294901760&u))s[n++]=u>>12&15|224,s[n++]=u>>6&63|128;else{if(0!=(4292870144&u))continue;s[n++]=u>>18&7|240,s[n++]=u>>12&63|128,s[n++]=u>>6&63|128}s[n++]=63&u|128}else s[n++]=u}return s.slice(0,n)},d="function"==typeof TextDecoder?TextDecoder.prototype.decode.bind(new TextDecoder):function(t){for(var e=t.length,r=[],o=0;o<e;){var n,i,a,s,u=t[o],f=null,l=u>239?4:u>223?3:u>191?2:1;if(o+l<=e)switch(l){case 1:u<128&&(f=u);break;case 2:128==(192&(n=t[o+1]))&&(s=(31&u)<<6|63&n)>127&&(f=s);break;case 3:n=t[o+1],i=t[o+2],128==(192&n)&&128==(192&i)&&(s=(15&u)<<12|(63&n)<<6|63&i)>2047&&(s<55296||s>57343)&&(f=s);break;case 4:n=t[o+1],i=t[o+2],a=t[o+3],128==(192&n)&&128==(192&i)&&128==(192&a)&&(s=(15&u)<<18|(63&n)<<12|(63&i)<<6|63&a)>65535&&s<1114112&&(f=s)}null===f?(f=65533,l=1):f>65535&&(f-=65536,r.push(f>>>10&1023|55296),f=56320|1023&f),r.push(f),o+=l}for(var c=r.length,h="",p=0;p<c;)h+=String.fromCharCode.apply(String,r.slice(p,p+=4096));return h};function b(){var e=!!i.ActiveXObject||"-ms-scroll-limit"in document.documentElement.style&&"-ms-ime-align"in document.documentElement.style,r=i.XMLHttpRequest&&i.XMLHttpRequest.prototype.send;e&&r&&(XMLHttpRequest.prototype.send=function(t){t instanceof Blob?(this.setRequestHeader("Content-Type",t.type),r.call(this,t)):r.call(this,t)});try{new File([],""),t.File=i.File,t.FileReader=i.FileReader}catch(e){try{t.File=new Function('class File extends Blob {constructor(chunks, name, opts) {opts = opts || {};super(chunks, opts || {});this.name = name.replace(/\\//g, ":");this.lastModifiedDate = opts.lastModified ? new Date(opts.lastModified) : new Date();this.lastModified = +this.lastModifiedDate;}};return new File([], ""), File')()}catch(e){t.File=function(t,e,r){var o=new Blob(t,r),n=r&&void 0!==r.lastModified?new Date(r.lastModified):new Date;return o.name=e.replace(/\//g,":"),o.lastModifiedDate=n,o.lastModified=+n,o.toString=function(){return"[object File]"},s&&(o[s]="File"),o}}}}u?(b(),t.Blob=f?i.Blob:p):l?(b(),t.Blob=h):function(){function e(t){for(var e=new Array(t.byteLength),r=new Uint8Array(t),o=e.length;o--;)e[o]=r[o];return e}function o(t){for(var e="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",r=[],o=0;o<t.length;o+=3){var n=t[o],i=o+1<t.length,a=i?t[o+1]:0,s=o+2<t.length,u=s?t[o+2]:0,f=n>>2,l=(3&n)<<4|a>>4,c=(15&a)<<2|u>>6,h=63&u;s||(h=64,i||(c=64)),r.push(e[f],e[l],e[c],e[h])}return r.join("")}var s=Object.create||function(t){function e(){}return e.prototype=t,new e};function u(t){return Object.prototype.toString.call(t).slice(8,-1)}function f(t,e){return"object"==typeof t&&Object.prototype.isPrototypeOf.call(t.prototype,e)}var l=["Int8Array","Uint8Array","Uint8ClampedArray","Int16Array","Uint16Array","Int32Array","Uint32Array","Float32Array","Float64Array","ArrayBuffer"];function c(t){return e=l,r=u(t),-1!==e.indexOf(r)||f(i.ArrayBuffer,t);var e,r}function h(t,r){r=null==r?{}:r;for(var o=0,n=(t=t?t.slice():[]).length;o<n;o++){var a=t[o];a instanceof h?t[o]=a._buffer:"string"==typeof a?t[o]=y(a):"DataView"===u(s=a)||f(i.DataView,s)?t[o]=e(a.buffer):c(a)?t[o]=e(a):t[o]=y(String(a))}var s;this._buffer=i.Uint8Array?function(t){for(var e=0,r=t.length;r--;)e+=t[r].length;for(var o=new Uint8Array(e),n=0,i=0;i<t.length;i++){var a=t[i];o.set(a,n),n+=a.byteLength||a.length}return o}(t):[].concat.apply([],t),this.size=this._buffer.length,this.type=r.type||"",/[^\u0020-\u007E]/.test(this.type)?this.type="":this.type=this.type.toLowerCase()}funcLine truncated
|
||||
(()=>{var e={847(e,t,r){var n,o;n=function(){"use strict";function e(e,t){(null==t||t>e.length)&&(t=e.length);for(var r=0,n=Array(t);r<t;r++)n[r]=e[r];return n}function t(e,t,r){return t=l(t),function(e,t){if(t&&("object"==typeof t||"function"==typeof t))return t;if(void 0!==t)throw new TypeError("Derived constructors may only return object or undefined");return function(e){if(void 0===e)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return e}(e)}(e,c()?Reflect.construct(t,r||[],l(e).constructor):t.apply(e,r))}function n(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function o(e,t){for(var r=0;r<t.length;r++){var n=t[r];n.enumerable=n.enumerable||!1,n.configurable=!0,"value"in n&&(n.writable=!0),Object.defineProperty(e,f(n.key),n)}}function i(e,t,r){return t&&o(e.prototype,t),r&&o(e,r),Object.defineProperty(e,"prototype",{writable:!1}),e}function a(t,r){var n="undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(!n){if(Array.isArray(t)||(n=function(t,r){if(t){if("string"==typeof t)return e(t,r);var n={}.toString.call(t).slice(8,-1);return"Object"===n&&t.constructor&&(n=t.constructor.name),"Map"===n||"Set"===n?Array.from(t):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?e(t,r):void 0}}(t))||r&&t&&"number"==typeof t.length){n&&(t=n);var o=0,i=function(){};return{s:i,n:function(){return o>=t.length?{done:!0}:{done:!1,value:t[o++]}},e:function(e){throw e},f:i}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var a,s=!0,l=!1;return{s:function(){n=n.call(t)},n:function(){var e=n.next();return s=e.done,e},e:function(e){l=!0,a=e},f:function(){try{s||null==n.return||n.return()}finally{if(l)throw a}}}}function s(){return s="undefined"!=typeof Reflect&&Reflect.get?Reflect.get.bind():function(e,t,r){var n=function(e,t){for(;!{}.hasOwnProperty.call(e,t)&&null!==(e=l(e)););return e}(e,t);if(n){var o=Object.getOwnPropertyDescriptor(n,t);return o.get?o.get.call(arguments.length<3?e:r):o.value}},s.apply(null,arguments)}function l(e){return l=Object.setPrototypeOf?Object.getPrototypeOf.bind():function(e){return e.__proto__||Object.getPrototypeOf(e)},l(e)}function c(){try{var e=!Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],function(){}))}catch(e){}return(c=function(){return!!e})()}function u(e,t){return u=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(e,t){return e.__proto__=t,e},u(e,t)}function f(e){var t=function(e,t){if("object"!=typeof e||!e)return e;var r=e[Symbol.toPrimitive];if(void 0!==r){var n=r.call(e,t||"default");if("object"!=typeof n)return n;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===t?String:Number)(e)}(e,"string");return"symbol"==typeof t?t:t+""}!function(e){e.AbortSignal,e.AbortController}("undefined"!=typeof self?self:r.g);var p=function(){return i(function e(){n(this,e),Object.defineProperty(this,"listeners",{value:{},writable:!0,configurable:!0})},[{key:"addEventListener",value:function(e,t,r){e in this.listeners||(this.listeners[e]=[]),this.listeners[e].push({callback:t,options:r})}},{key:"removeEventListener",value:function(e,t){if(e in this.listeners)for(var r=this.listeners[e],n=0,o=r.length;n<o;n++)if(r[n].callback===t)return void r.splice(n,1)}},{key:"dispatchEvent",value:function(e){var t=this;if(e.type in this.listeners){for(var r=this.listeners[e.type].slice(),n=function(){var n=r[o];try{n.callback.call(t,e)}catch(e){Promise.resolve().then(function(){throw e})}n.options&&n.options.once&&t.removeEventListener(e.type,n.callback)},o=0,i=r.length;o<i;o++)n();return!e.defaultPrevented}}}])}(),d=function(e){function r(){var e;return n(this,r),(e=t(this,r)).listeners||p.call(e),Object.defineProperty(e,"aborted",{value:!1,writable:!0,configurable:!0}),Object.defineProperty(e,"onabort",{value:null,writable:!0,configurable:!0}),Object.defineProperty(e,"reason",{value:void 0,writable:!0,configurable:!0}),e}return function(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Super expression must either be null or a function");e.prototype=Object.create(t&&t.prototype,{constructor:{value:e,writable:!0,configurable:!0}}),Object.defineProperty(e,"prototype",{writable:!1}),t&&u(e,t)}(r,e),i(r,[{key:"toString",value:function(){return"[object AbortSignal]"}},{key:"dispatchEvent",value:function(e){var t,n,o,i,a;"abort"===e.type&&(this.aborted=!0,"function"==typeof this.onabort&&this.onabort.call(this,e)),(t=r,n="dispatchEvent",o=this,a=s(l(1&(i=3)?t.prototype:t),n,o),2&i&&"function"==typeof a?function(e){return a.apply(o,e)}:a)([e])}},{key:"throwIfAborted",value:function(){var e=this.aborted,t=this.reason;if(e)throw void 0===t?"Aborted":t}}],[{key:"timeout",value:function(e){var t=new h;return setTimeout(function(){return t.abort(new DOMException("This signal is timeout in ".concat(e,"ms"),"TimeoutError"))},e),t.signal}},{keLine truncated
|
||||
+1319
-1400
File diff suppressed because it is too large.
Load diff
Loaded 100 of 366 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user