Compare commits

...
539 Commits
Author SHA1 Message Date
Levi 4c2321d686 Merge branch 'main' into ci/test 2026-03-25 13:58:42 +08:00
Jaime Muñoz Martín daa8c39dad feat(lang): update Catalan language strings (#1322) 2026-03-23 11:01:59 +08:00
Levi 78e23217f9 FIX 2026-03-18 21:56:43 +08:00
Levi 61f62ec39a fix 2026-03-18 21:44:51 +08:00
Levi 5d76d062ad fix 2026-03-18 21:27:30 +08:00
Levi fc489b6b97 ci test 2026-03-18 21:16:03 +08:00
Levi a5cd53f94c release: v1.9.3 2026-03-18 20:00:59 +08:00
Levi 847e88881d fix: non-range HTTP downloaded bytes exceeding total file size (#1317) 2026-03-18 18:53:49 +08:00
Wang YiJia 3d20e818e5 fix: add IsCreated flag to reliably track task creation state (#1314) 2026-03-18 18:35:39 +08:00
Levi 09f7c88ed3 feat: ed2k protocol support (#1316) 2026-03-17 19:42:16 +08:00
Copilot f7b26753c1 docs: migrate docs.gopeed.com links to gopeed.com/docs (#1311) 2026-03-12 22:44:17 +08:00
Levi 9704d4af27 fix: update RPM icon path (#1309) 2026-03-12 21:33:31 +08:00
Jaime Muñoz Martín 1907f404ac feat(lang): Catalan language added (#1303) 2026-03-11 10:26:55 +08:00
Tuna Dinçer 72c5ba7d20 feat(lang): update Turkish translation (#1304) 2026-03-10 13:38:02 +08:00
Levi 5b0254f7c0 release: v1.9.2 2026-03-08 19:52:32 +08:00
Levi d36b345f92 feat: extension store (#1301) 2026-03-06 21:53:19 +08:00
Locon213 bc5c818eae feat(desktop): implement native desktop notifications for task completion and errors (#1293) 2026-03-01 11:12:40 +08:00
Levi 98bd3305b7 feat: update Flutter version to 3.41.2 (#1290) 2026-02-26 20:42:34 +08:00
Tuna Dinçer 3f38848051 feat: Turkish localization improvements 2026-02-25 09:54:15 +08:00
Levi 82d7f8ec81 feat: update debug extension IDs retrieval to use environment (#1281) 2026-02-14 17:25:46 +08:00
Levi a6981d58e9 release: v1.9.1 2026-02-08 18:40:59 +08:00
Copilot 1b1631b41a feat: support post-task script hooks (#1265) 2026-02-08 18:40:06 +08:00
Levi 3eb6df55df feat: implement task patching functionality (#1267) 2026-02-06 23:42:57 +08:00
Levi 6de63c3f63 fix: low FPS on Windows (#1279) 2026-02-06 22:38:31 +08:00
Inori 969615f353 ci: docker image optimization (#1274) 2026-02-05 16:25:59 +08:00
Levi 5a88b98314 fix: add missing lib to Windows ARM64 bundle (#1273) 2026-02-04 18:41:32 +08:00
Levi 2674b1c39e ci: upgrade flutter version on windows arm (#1269) 2026-02-03 21:13:25 +08:00
Copilot 4c90b38247 fix: gbk filename encoding in ZIP extraction (#1260) 2026-01-29 22:37:12 +08:00
Levi 93088b22f1 fix: improve retry handling for expired HTTP redirect url (#1263) 2026-01-29 22:13:42 +08:00
Levi fe90c1d261 fix: deep link hidden handling on MacOS (#1262) 2026-01-28 18:57:09 +08:00
Levi 640e798d70 release: v1.9.0 2026-01-23 22:38:11 +08:00
Levi ba2df0ecff docs: update windows arm download link 2026-01-23 22:33:00 +08:00
Levi d61da67456 feat: add anonymous analytics (#1254) 2026-01-23 22:29:15 +08:00
Levi 7c696ee6e0 feat: upgrade Flutter version to 3.38 (#1256) 2026-01-23 21:28:13 +08:00
Copilot 7b840b4261 feat: add config for auto-torrent (#1219) 2026-01-22 22:18:34 +08:00
Copilot a3c1f84c2c feat: register as android download handler for browser integration (#1251) 2026-01-22 18:33:23 +08:00
Locon213 8d33e1a5b3 feat: add automatic cleanup options for completed torrents and missing files (#1243) 2026-01-22 09:32:14 +08:00
Copilot bad7f1d06c fix: filesystem error when creating long filename (#1240) 2026-01-21 23:12:38 +08:00
Levi af1c8b2719 feat: enhance deep link handling for install extensions (#1249) 2026-01-21 20:23:04 +08:00
Levi d90af0b9d1 feat: support calling Gopeed API via host RPC (#1247) 2026-01-21 13:21:20 +08:00
Levi 6016ee1d24 feat: localize google fonts in flutter web (#1241) 2026-01-15 23:59:41 +08:00
Levi e275fd58bd fix: update goja to avoid some special cases stack overflow (#1242) 2026-01-15 18:49:19 +08:00
Levi c9626cb389 perf: slow down DHT announcing on errors (#1186) 2026-01-14 20:04:21 +08:00
Locon213 f86a379f93 feat(ui): add download percentage and ETA display (#1238) 2026-01-14 09:47:45 +08:00
Levi a9eee26be7 refactor: fethcer interface & download flow (#1229) 2026-01-08 21:50:47 +08:00
Copilot abd16d5326 fix: filename truncation for HTML-encoded ampersands in Content-Disposition (#1215) 2026-01-05 22:56:04 +08:00
Copilot 6756d82ffa fix: use PathUnescape for filename URL decoding (#1214) 2026-01-05 21:46:50 +08:00
Copilot 71c9e6947a feat: add auto-start incomplete tasks on application startup (#1203) 2026-01-05 21:11:42 +08:00
Matteo Pietro Dazzi 24db9bb58b feat: queue extractions (#1227) 2026-01-05 15:53:19 +08:00
Matteo Pietro Dazzi de0afb1481 feat: multipart-archives (#1222) 2026-01-01 19:09:22 +08:00
awe123343 cb6331c20d feat(ui): support running as menubar app (#1216) 2025-12-31 09:57:16 +08:00
Matteo Pietro Dazzi b0e133b554 feat: show extraction progress (#1217) 2025-12-31 09:25:21 +08:00
r6SHsRLF dbe37befa3 ci(windows): keep installer exe in release assets (#1211) 2025-12-29 17:36:27 +08:00
Matteo Pietro Dazzi 0d5c4e8e7f feat: auto extract archives (#1212) 2025-12-29 17:27:49 +08:00
Copilot 5515a1c48c fix: ByteFmt panic on negative int64 values (#1200) 2025-12-28 21:57:40 +08:00
Mine 3abf6ed933 feat: add support for Windows ARM architecture in build and release configurations (#1206) 2025-12-28 21:45:47 +08:00
Levi af40e8aa63 release: v1.8.3 2025-11-30 21:11:04 +08:00
Levi 64c5ece02c docs: add WeChat Official Account qr (#1192) 2025-11-29 13:19:37 +08:00
Levi 4ccbc3ebc5 feat: support custom GitHub mirror config (#1191) 2025-11-29 12:39:24 +08:00
BerryMC 14d9642d3c feat: add RPM package build (#1190) 2025-11-29 11:35:32 +08:00
Copilot 2f5fbfb17c fix: HTTP filename parsing for mixed encodings and GBK-encoded (#1189) 2025-11-29 11:34:50 +08:00
Copilot 49b4d99b5e feat: add download directory placeholders, category support (#1188) 2025-11-27 18:39:08 +08:00
Copilot 9d9f1caaaa feat: support webhook (#1187) 2025-11-25 23:34:45 +08:00
Copilot 5bb8541385 fix: proxy username and password not persisting in settings (#1183) 2025-11-24 19:29:49 +08:00
Levi f7189668fd ci: pin windows runner image to windows-2022 (#1184) 2025-11-24 18:32:56 +08:00
Little-King 1b36ed1807 fix: defect in extension process during rename duplicate file (#1158) 2025-11-14 18:49:21 +08:00
Levi 07dc196c3c feat: add support for Waterfox browser detection (#1146) 2025-10-15 20:44:41 +08:00
Levi 76bec67ab2 ci: fix windows runner 2025-10-12 22:45:43 +08:00
Levi a2a9e3dc2a release: v1.8.2 2025-10-12 21:17:40 +08:00
Levi 9a5680fb45 feat: refactor browser extension communication to use IPC (#1130) 2025-10-12 21:17:07 +08:00
Levi 75a8a3a9ac ci: downgrade to Ubuntu 22.04 (#1128) 2025-10-09 21:01:06 +08:00
Levi 3e8c85b945 feat: update GitHub mirrors (#1137) 2025-10-09 20:59:56 +08:00
Levi 90f70d1bf6 feat: isolate TLS fingerprint for each extension lifecycle (#1135) 2025-10-09 20:37:21 +08:00
Levi 20a70d7f91 fix: certain special single-file torrent downloads (#1136) 2025-09-29 22:52:06 +08:00
Levi d33546a4b5 fix: update deleteAll method to accept force parameter (#1127) 2025-09-29 19:41:48 +08:00
Levi 93614d46a8 release: v1.8.1 2025-09-14 10:10:30 +08:00
Levi 04b0e691eb fix: add timeout to server shutdown (#1116) 2025-09-13 21:19:28 +08:00
铺盖崽 7f08fa0b23 ci: update linux runner 24.04 (#1117) 2025-09-13 18:44:00 +08:00
Levi df6a51d23c feat: add notify when new version option (#1115) 2025-09-12 18:32:45 +08:00
Levi 46c379d556 fix: resolve incorrect download path for single file torrent (#1111) 2025-09-10 07:59:10 +08:00
Levi 46cd9f7a3e fix: windows tray disappearing after sleep/wake cycles (#1108) 2025-09-09 22:44:09 +08:00
Levi 5c2f66f4b6 fix: web auth config (#1094) 2025-09-09 18:35:04 +08:00
Krau cfdcb586ee fix: decode URL-encoded filename from contentDisposition header (#1099) 2025-09-04 18:22:30 +08:00
Levi 55e6bdc4f7 release: v1.8.0 2025-08-28 18:43:44 +08:00
Levi b31b42301e feat: update storage directory path for Windows (#1087) 2025-08-28 08:44:18 +08:00
Levi 7ce0d876b0 feat: add environment variables configuration support (#1088) 2025-08-28 08:21:02 +08:00
Levi 2fd2596aa0 ci: update macOS version to macos-14 (#1086) 2025-08-26 10:07:33 +08:00
Local-Gay-Communist e0973e4a34 feat: add german language support (#1084) 2025-08-22 09:26:37 +08:00
Levi 4f7b8a05ad feat: web login page (#1058) 2025-07-16 01:02:25 +08:00
Levi 2927d1ef2b feat(extension): TLS fingerprinting support in xhr (#1054) 2025-07-15 23:54:51 +08:00
Levi 3451f577ab fix: remove invalid characters in HTTP headers (#1053) 2025-07-15 22:34:10 +08:00
Levi 06671be0d8 release: v1.7.1 2025-07-08 09:13:39 +08:00
Thiago Sabaia 8ae9363f0f feat: Translation into Portuguese (Brazil) (#1047) 2025-06-17 09:51:51 +08:00
unifan 5d84445f8f feat: Open in Folder with support for 3rd file manager (#1045) 2025-06-07 11:14:58 +08:00
Levi 7c541e4047 feat: web support relative base path (#1001) 2025-05-15 22:07:26 +08:00
Charles Huang 3262e5a11b fix: do not add external tracker to a private torrent (#1011) 2025-04-23 22:53:15 +08:00
Charles Huang ff43a373cd fix: json syntax error in bt config (#1014) 2025-04-23 13:48:46 +08:00
Levi 7394d948e4 ci: workaround for app_indicator_new is deprecated (#986) 2025-04-09 22:20:16 +08:00
Levi 5afb3aa43b ci: fix go 1.24 code coverage (#988) 2025-04-09 22:19:50 +08:00
Levi 7705af6e68 release: v1.7.0 2025-04-08 22:22:09 +08:00
Levi 684e060ef3 chore: upgrade go to 1.24 (#984) 2025-04-08 22:18:18 +08:00
Levi 0c67792441 ci: build separate macos packages (#967) 2025-04-08 19:43:30 +08:00
Levi b0ef2c2a87 fix: bt seed cause crash (#985) 2025-04-08 18:38:17 +08:00
mak7im01 1977c2a0fc feat: update ru_ru.dart (#982) 2025-04-08 09:21:29 +08:00
Levi d289708f9e feat: use batch download api for create multitasks (#978) 2025-04-05 22:57:17 +08:00
Levi cd5fd45667 fix: resolve http non encode attachment filename (#975) 2025-04-05 22:37:19 +08:00
Levi 50bc9ab129 feat: improve http download strategy to prevent speed drops near completion (#969) 2025-04-05 22:20:35 +08:00
Levi bfad91dcab release: v1.6.11 2025-03-14 23:03:48 +08:00
Levi 61a959adda ci: apk build split abi (#962) 2025-03-14 22:58:28 +08:00
Levi f4b839fd11 fix: http download size exceeds file size (#959) 2025-03-14 20:07:34 +08:00
Levi 969aeb5e0a fix: : web auth priority when api token and basic auth coexist (#960) 2025-03-14 19:22:31 +08:00
Levi 68d42eb397 feat: web http file server caching and gzip support (#961) 2025-03-14 18:31:05 +08:00
Levi 08603f9510 feat: auto updater (#933) 2025-03-13 18:31:38 +08:00
Internetezoo b450bf67bd feat: Add Hungarian languages (#940) 2025-03-10 11:59:44 +08:00
Levi 03e26dbea8 feat: improve http download retry strategy (#951) 2025-03-07 12:01:52 +08:00
Levi 046a3ac9f9 fix: can not open url encode torrent file path (#945) 2025-02-27 00:00:25 +08:00
Levi 9c5aebaf36 fix: path url encode filename parse (#939) 2025-02-18 19:37:38 +08:00
Xiangxin a094715d69 fix: windows title bar theme switch (#936) 2025-02-17 19:32:09 +08:00
铺盖崽 69bb875172 ci: fix flathub build error (#937) 2025-02-17 09:15:42 +08:00
铺盖崽 589f10c7ef ci: add linux arm64 build (#935) 2025-02-16 10:54:25 +08:00
Levi 654e57e946 release: v1.6.10 2025-02-13 21:56:27 +08:00
Levi b94b99ae09 fix: gopeed scheme safe url params (#932) 2025-02-13 21:55:37 +08:00
Levi fba990a056 fix: http MIME encoded word filename parse (#931) 2025-02-13 21:54:13 +08:00
𝗦𝗵𝗟𝗲𝗿𝗣 cb07ce58bc feat: improve Turkish Locales (#917) 2025-02-07 08:05:38 +08:00
Levi 50beef7b08 docs: improve download links (#913) 2025-02-06 12:19:27 +08:00
Levi bd39e4110f feat: add ukraine language (#909) 2025-02-05 10:51:58 +08:00
Levi 86efcb98c5 fix: web white screen (#908) 2025-02-05 10:51:33 +08:00
Levi f3d9873395 release: v1.6.9 2025-02-03 18:50:25 +08:00
Levi ef228c1722 feat: update android target sdk to 34 (#902) 2025-02-03 16:15:13 +08:00
Levi 6863317e9d feat: support tcp protocol on android (#903) 2025-02-03 13:49:46 +08:00
Levi 2b83a735cb fix: advanced options used by default when creating task (#901) 2025-02-03 13:42:50 +08:00
Levi 447f599783 fix: firefox native host integration (#895) 2025-02-01 18:14:59 +08:00
liwei f986f6253d ci: fix download-artifact version 2025-01-31 20:00:45 +08:00
liwei 2e1b621250 ci: fix 2025-01-31 20:00:14 +08:00
Levi d50947b2e6 release: v1.6.8 2025-01-31 19:15:01 +08:00
Levi 520a72415c feat: browser extensions native messaging host (#890) 2025-01-31 17:57:34 +08:00
Levi a372b67edb ci: fix linux package (#882) 2025-01-23 12:45:44 +08:00
Levi 2d86b5c132 fix: handle share links (#883) 2025-01-23 12:17:25 +08:00
Levi 63b3144633 ci: qnap separate build (#877) 2025-01-17 23:03:55 +08:00
Levi 1b0178fed9 release: v1.6.7 2025-01-17 08:25:24 +08:00
Levi 83febe0a00 feat: support creating tasks using gopeed scheme (#876) 2025-01-16 18:49:32 +08:00
Levi 987580d400 fix: windows uninstall icon (#875) 2025-01-16 18:48:46 +08:00
Levi 0919936050 feat: support custom http request headers (#868) 2025-01-16 00:19:50 +08:00
Levi acc7ed8f6b feat: torrent scheme deep link and file associations (#866) 2025-01-16 00:02:00 +08:00
Levi ccb3267fba feat: update flutter to v3.24 (#865) 2025-01-06 18:34:50 +08:00
Levi 1249493a8a feat: improve file_tree_view display on mobile (#862) 2024-12-30 20:35:39 +08:00
Levi 107141bbe7 fix: not disabling the context menu on the browser (#861) 2024-12-30 20:17:33 +08:00
Levi b329fee107 release: v1.6.6 2024-12-27 19:11:18 +08:00
Levi 3ce95f21fd fix: some torrents can not resovle (#857) 2024-12-27 19:06:17 +08:00
Levi f54ed5df5a feat: batch operation task (#860) 2024-12-27 18:52:23 +08:00
Levi 727b7edb84 feat: file tree view responsive support (#856) 2024-12-27 11:34:56 +08:00
Levi f5891ee03d feat: add batch create tips (#858) 2024-12-27 11:17:17 +08:00
Levi 724276ac69 release: v1.6.5 2024-12-25 22:56:09 +08:00
Levi a3f559c8e5 feat: improve file select tree view (#853) 2024-12-25 22:55:33 +08:00
Levi edd189a8a8 fix: failed to delete a running task (#852) 2024-12-24 22:23:20 +08:00
Levi c970378fd7 fix: http download retries too many times (#849) 2024-12-24 20:28:04 +08:00
Levi 8b61343aef fix: extension task missing fields (#850) 2024-12-23 01:58:29 +08:00
Levi 2cb93a03e2 perf: extension onStart hook will block task create (#844) 2024-12-21 09:58:33 +08:00
Levi 81dfc073a6 release: v1.6.4 2024-12-09 20:30:33 +08:00
Levi 427921ccab perf: write to disk only when progress updated (#839) 2024-12-09 20:29:50 +08:00
Levi f4876c8a0e fix: HTTP fetch crash when modified request extra via extension (#832) 2024-12-08 12:07:08 +08:00
Navy.Wang 35a3625b11 feat: add QNAP platform build (#828) 2024-11-29 13:38:17 +08:00
ilkei dc9b501e35 feat: optimize docker build (#827) 2024-11-27 13:40:28 +08:00
Levi 85fe92a378 release: v1.6.3 2024-11-18 18:54:46 +08:00
Levi 983d9ad09e fix: windows location fix patch not effect (#819) 2024-11-18 18:54:15 +08:00
Levi d9f757aaf0 fix: revert flutter version to v3.16.8 (#821) 2024-11-18 18:31:25 +08:00
Levi cd409fcfb9 ci: add build tests for all platforms (#812) 2024-11-15 12:06:51 +08:00
Levi 366244fa5d fix: android build on go 1.23 (#813) 2024-11-15 11:43:55 +08:00
Levi 7a2e9b4fd1 release: v1.6.2 2024-11-14 23:45:08 +08:00
Levi 203385d3f8 fix: use golang 1.23 standard library to collect crash logs (#807) 2024-11-14 00:54:34 +08:00
Levi aaf73dec24 fix: proxy config reload panic (#808) 2024-11-14 00:36:21 +08:00
Ricky 2523282a89 feat(http): add option to skip SSL certificate verification (#801) 2024-11-12 10:30:21 +08:00
Levi 3384f5197a fix: download path in docker-compose.yml (#798) 2024-11-05 09:55:25 +08:00
Levi cd77665f02 fix: avoid request locatoin permisson on windows (#787) 2024-10-19 16:55:23 +08:00
Levi a22b431150 feat: upgrade flutter to v3.24.3 (#791) 2024-10-19 12:12:41 +08:00
Levi eb7c9ad7f2 release: v1.6.1 2024-10-16 18:31:56 +08:00
Levi ea11fc38f1 ci: fix windows build version (#785) 2024-10-16 18:30:41 +08:00
towermom9 1be16e9f2c feat: Updated the indonesian translation (#777) 2024-10-10 13:18:11 +08:00
Ayase 1d86d14a20 fix: open files on linux (#765) 2024-10-03 21:40:49 +08:00
𝗦𝗵𝗟𝗲𝗿𝗣 5ad15cc4ac feat: Update tr_tr.dart (#754) 2024-09-23 09:19:45 +08:00
Levi 49604966fd release: v1.6.0 2024-09-21 11:00:47 +08:00
Levi bdb8ed177f feat: supports batch operation of tasks by filter (#753) 2024-09-21 10:19:52 +08:00
Levi 2dc7048cba feat: support download directory white list (#752) 2024-09-20 21:32:21 +08:00
Levi d3750464aa fix: torrent stats panic (#737) 2024-09-20 12:28:24 +08:00
Levi 5c9bcd3731 fix: DotAll flag not working with group (#750) 2024-09-19 21:28:46 +08:00
Aman Raj Singh Mourya bdda550b2c feat: avoid using the depreciated background (#746) 2024-09-18 14:39:01 +08:00
liwei 3308d966f0 docs: fix banner transparent 2024-09-13 18:40:58 +08:00
Levi 18041ed9ec docs: improve banner image (#740) 2024-09-11 19:02:39 +08:00
Levi 56b85e545e release: v1.5.9 2024-09-07 19:20:12 +08:00
Levi c5af916952 feat: support default direct download config (#735) 2024-09-07 14:08:17 +08:00
Levi a1981328aa fix: avoid HTTP zero connections (#729) 2024-09-07 13:49:32 +08:00
Levi 51244133e0 feat: support extension onDone event (#728) 2024-09-07 10:10:39 +08:00
Levi 0c17093b1a feat: task separate proxy (#727) 2024-09-06 18:35:23 +08:00
Levi 6538b29b62 feat: macos round icon (#719) 2024-09-03 18:47:49 +08:00
Levi 81e90e6b89 fix: try request grant external storage on Android (#660) 2024-09-03 18:47:06 +08:00
JuanQ 4f39296664 feat: Add spanish language (#667) 2024-08-27 08:59:18 +08:00
Levi ece5b91dc2 fix: torrent redundant directory (#649) 2024-08-14 21:54:59 +08:00
Levi d9f04ae928 fix: tasks block when paused while not ready (#648) 2024-08-14 21:22:23 +08:00
Levi e3ccecbd9c fix: task directory open (#647) 2024-08-14 21:21:41 +08:00
Levi fb1c2cb060 fix: downgrade minSdkVersion (#642) 2024-08-14 21:21:09 +08:00
Levi 9bff75464d ci: docker flutter build on host (#637) 2024-08-09 18:28:44 +08:00
Levi 863f759248 release: v1.5.8 2024-08-09 12:57:11 +08:00
Levi f89fb5e438 fix: web config file not loaded when is relative path (#636) 2024-08-09 12:50:20 +08:00
Levi 7ca1cc8c64 fix: batch create tasks with duplicate names (#635) 2024-08-09 12:41:03 +08:00
Levi 1f791c66e6 perf: excessive torrent tracker dns resolve (#634) 2024-08-08 18:55:52 +08:00
Levi 4d60c47d39 feat: task detail view (#633) 2024-08-08 18:34:00 +08:00
Levi 3fd3dd49d2 feat(extension): get exception stacks on promise error (#630) 2024-08-07 12:04:45 +08:00
foxxorcat d119de7b98 feat: enhanced extension engine (#629) 2024-08-05 10:44:17 +08:00
madoka773 69b359a9c9 fix: tray icon not shown on linux flatpak or snap (#625) 2024-08-02 12:36:25 +08:00
Levi 4fbe13a1f0 fix: clear UPNP port mapping when exist app (#624) 2024-07-30 18:43:54 +08:00
Levi 3b50465708 feat: torrent seed config (#599) 2024-07-30 12:50:06 +08:00
Levi 473c58796c fix: handle empty string params (#620) 2024-07-24 11:51:31 +08:00
abc0922001 fab8f135f3 feat: Update Traditional Chinese translation (#611) 2024-07-17 18:42:42 +08:00
liwei 0fca7b6bc7 fix: fr_fr import 2024-07-15 09:36:36 +08:00
torik988 4700d0ba28 feat: Create fr_fr.dart (#608) 2024-07-15 09:28:03 +08:00
towermom9 6e31ea089f feat: Indonesian Translation/Localization (#606) 2024-07-15 09:21:49 +08:00
Levi 4a2088f6fb fix: xhr string request body can specify Content-Type (#596) 2024-07-09 18:40:02 +08:00
𝗪𝗜𝗡𝗭𝗢𝗥𝗧 17c8fb7e4d feat: update tr_tr.dart (#593) 2024-07-03 09:52:01 +08:00
Levi 8c99bbc847 ci: create CODEOWNERS 2024-06-26 17:17:27 +08:00
Levi 06c7fe7a22 fix: avoid hanging the main thread on repeat start (#584) 2024-06-20 18:59:50 +08:00
Levi 8b3b5d4394 fix: javascript engine xhr polyfill (#581) 2024-06-15 11:40:12 +08:00
liwei 2ee73ab619 fix: it_it translate 2024-06-11 14:37:39 +08:00
albanobattistella 15413dbc95 feat: Create it_it.dart (#576) 2024-06-11 14:23:24 +08:00
Levi c7c43fac80 fix: snap dir pick depend (#558) 2024-06-11 12:35:57 +08:00
247b 4283bdfa5a feat :tamil translation is added (#563) 2024-05-27 09:24:45 +08:00
liwei 328ba8592c build: update go-ieproxy depend 2024-05-23 08:57:02 +08:00
er2de2 a191a4140d feat: Update pl_pl.dart (#554) 2024-05-23 08:53:48 +08:00
Levi 3521837964 release: v1.5.7 2024-05-17 19:59:31 +08:00
Levi b30cd78f86 ci: use special flutter version on snap (#543) 2024-05-17 19:57:40 +08:00
Levi 0583e4aaa6 feat: remember delete task option (#542) 2024-05-17 18:43:21 +08:00
Levi a89c55b5c4 feat: improve Android download directory choose (#541) 2024-05-17 18:38:35 +08:00
Levi 68c40d7f3f feat: add batch create and server info APIs (#540) 2024-05-17 15:19:10 +08:00
Levi 42a0b89d95 fix: bt request extra type convert (#535) 2024-05-16 13:02:40 +08:00
liwei 1860f534cd fix: pl_pl i18n 2024-05-16 12:58:05 +08:00
er2de2 5c2d2ad7b4 feat: add Polish translation (#534) 2024-05-16 12:55:33 +08:00
Levi db6352ada8 feat: add show item on system tray (#533) 2024-05-15 20:41:57 +08:00
Levi d1a5ed8b3d feat: support launch at startup (#532) 2024-05-14 19:23:29 +08:00
Levi be9583be0f ci: fix ios framework build (#525) 2024-05-08 18:06:58 +08:00
Levi d2c70b077b feat: check API port is in use (#524) 2024-05-07 19:19:57 +08:00
Levi 53b3eea4fc feat: add extension store links for edge and firefox (#520) 2024-05-06 18:36:53 +08:00
Levi 54347f41b6 feat: support double click to open task file (#516) 2024-05-02 13:22:57 +08:00
Levi cea454b015 feat: support system proxy (#515) 2024-05-01 18:53:12 +08:00
Levi 070db64ca9 fix: http request cookie incremented indefinitely on retries (#514) 2024-05-01 17:45:59 +08:00
Levi 6a8956b04d docs: fix linux download link 2024-05-01 04:16:56 +00:00
Deepansh Singh Arya 057504bb81 feat: updated iOS icons (#512) 2024-05-01 10:48:35 +08:00
madoka773 470b488824 ci: build gopeed with libayatana-appindicator (#508) 2024-04-26 15:41:47 +08:00
madoka773 c8c9df1ae8 ci: build linux with ubuntu latest (#501) 2024-04-23 08:57:23 +08:00
invictus1032 33d787c74e docs: fix Linux download links (#465)
Fix filename discrepancies for .deb and .AppImage
2024-04-12 14:45:09 +08:00
liwei 67338a5e83 docs: add browser extension link 2024-04-03 17:52:37 +08:00
Levi 723697192d fix: command panic before resolve resource (#451) 2024-04-03 16:56:20 +08:00
Zack 4294d07d6a fix: Fix the issue of installing apk files(Android) (#448) (#449) 2024-04-03 09:23:05 +08:00
WINZORT 3936fd786f feat: Update tr_tr.dart (#445) 2024-04-01 09:07:44 +08:00
ilkeiii 45197e9a3b fix: docker entrypoint_sh (#434) 2024-03-30 12:35:00 +08:00
liwei c9692e76e5 fix: build flags type 2024-03-30 11:10:34 +08:00
liwei 0a9e39ed75 ci: fix Dockerfile InDocker flag 2024-03-30 11:00:14 +08:00
liwei 1971f582b5 ci: fix Dockerfile build 2024-03-30 10:50:53 +08:00
liwei c62d6be3da ci: fix Dockerfile build args 2024-03-30 10:39:46 +08:00
liwei 7d5e503894 fix: bump rounded_loading_button 2024-03-30 10:21:19 +08:00
liwei 6c4066fde4 ci: fix Dockerfile 2024-03-30 10:05:45 +08:00
liwei ffdf3e717f ci: fix docker build args format 2024-03-30 09:57:21 +08:00
liwei f66814b4ae ci: fix docker build args 2024-03-30 09:50:41 +08:00
Levi 0c3e201a51 release: v1.5.6 2024-03-30 09:35:20 +08:00
liwei c32a5d750f ci: update change log template 2024-03-30 09:32:43 +08:00
Levi 207c287c97 feat: add browser extension link (#433) 2024-03-30 09:12:24 +08:00
Levi 56ef25065e fix: task lock by unreachable torrent (#431) 2024-03-29 22:11:21 +08:00
Levi bd6d6be71f feat: web config add stroage dir item (#428) 2024-03-29 17:10:25 +08:00
Zack 1d3a028bae docs: Optimize compile commands (#420) 2024-03-27 22:18:52 +08:00
Zack 27011fb96b feat: Support Via Download task, require via version 5.5.0 or above(Android) (#412) (#419) 2024-03-27 21:58:16 +08:00
ilkeiii c696b59c4b feat: add docker permission related settings (#380) 2024-03-25 08:51:07 +08:00
WINZORT 78b5b8a78b feat: Create tr_tr.dart (#402) 2024-03-12 13:57:11 +08:00
Levi c81ca7f5cd feat: docker download dir move to /app/Download (#398) 2024-03-12 13:39:56 +08:00
liwei e39d50fe09 docs: add Flathub link 2024-02-23 08:53:20 +08:00
David Nguyen 086269f8bd feat: add Vietnamese translate (#389) 2024-02-22 15:54:35 +08:00
liwei 00d2183961 docs: fix download link 2024-02-18 20:45:07 +08:00
liwei 5f914dc4c4 docs: fix mac download link 2024-02-18 12:47:27 +08:00
MonoYan 9bf204cf04 Update README_zh-CN.md (#381)
Fixed MacOS download link
2024-02-18 01:14:56 +08:00
Levi a816b9ccc8 fix: create task with default select files (#374) 2024-02-02 09:44:00 +08:00
liwei d60d370463 docs: remove linux portable link 2024-02-01 15:11:41 +08:00
Levi 84d054b494 fix: start duplicate server on mobile when the app is closed by swiping (#370) 2024-01-31 18:45:09 +08:00
Elliot 1aa96e147f feat: improve zh-TW translation (#369) 2024-01-31 18:23:25 +08:00
Levi 8c01e45780 release: v1.5.5 2024-01-30 14:50:29 +08:00
Levi 8ad571aa08 fix: config init fail (#365) 2024-01-30 14:49:26 +08:00
Levi 39316e9b8e fix: windows default install dir (#366) 2024-01-30 14:41:22 +08:00
liwei f778596390 release: v1.5.4 2024-01-30 10:57:08 +08:00
liwei c2cbc14492 docs: improve download guide 2024-01-30 10:37:27 +08:00
Levi 62c22bc6b4 feat: support use server time for file creation (#359) 2024-01-30 09:08:01 +08:00
Levi b89c7bc608 feat: migrate shared_preferences to hive (#343) 2024-01-24 15:49:51 +08:00
Tracey Lesley f2cd686563 fix: missing pb size format (#342) 2024-01-24 14:33:18 +08:00
Levi ef5ee99f04 feat: custom torrent client version (#341) 2024-01-23 17:44:44 +08:00
Tracey Lesley 6d136604db feat: downloader task stats (#339) 2024-01-22 11:43:12 +08:00
Levi 38ff711ea9 feat: add batch delete tasks api (#338) 2024-01-19 18:04:54 +08:00
liwei a1a8abc25f fix: ios download path 2024-01-19 15:45:24 +08:00
madoka773 abccbabc78 fix: wrong debian installed size (#335) 2024-01-19 11:17:50 +08:00
Levi c26eb26b8f fix: default download path on mobile (#337) 2024-01-19 11:12:27 +08:00
Levi 74beea6044 feat: auto create torrent task for http download (#334) 2024-01-18 18:38:44 +08:00
Levi e548d7e0c9 feat: save window size and resize on startup (#332) 2024-01-18 15:00:05 +08:00
madoka773 a996d57914 ci: fix debian 12 depends (#329) 2024-01-18 11:16:50 +08:00
Levi f7dc60ad50 fix: proxy setting only numeric input (#328) 2024-01-18 09:17:06 +08:00
Levi 8425a96ac9 feat: add mirror.ghproxy.com trackers subscribe cdn (#330) 2024-01-17 22:09:37 +08:00
Tracey Lesley 99f4e8c895 feat: add download stats info (#323) 2024-01-17 21:59:01 +08:00
Elliot cc0f61b527 feat: improve zh-TW translation (#327) 2024-01-17 18:16:40 +08:00
Levi 922ef969a6 feat: add thanks item for contributors (#325) 2024-01-16 19:02:20 +08:00
Tracey Lesley 3a542054fc fix: compatible with not follow the http range response standard 2024-01-14 09:58:14 +08:00
Levi 895a8d2ee4 fix: foreground task scope (#314) 2024-01-05 21:49:21 +08:00
madoka773 29d0f5ebcf ci: use flutter distributor (#313) 2024-01-04 12:46:16 +08:00
Levi 31228e3edf release: v1.5.3 2023-12-15 19:23:16 +08:00
Levi 31a32d7e14 fix: handle api panic (#302) 2023-12-15 19:20:56 +08:00
Levi 65fd6fb489 fix: http pause handle (#303) 2023-12-15 19:15:38 +08:00
Levi 953b878a89 fix: adapt mobile proxy settings ui (#301) 2023-12-15 19:09:48 +08:00
Elliot ae4ca3da58 feat: improve zh-TW translation (#300) 2023-12-14 18:06:49 +08:00
Levi d09bd180af release: v1.5.2 2023-12-13 09:38:34 +08:00
liwei 3d24e353c5 docs: docker data path mounted 2023-12-13 08:57:57 +08:00
Levi ce7489b0c0 feat: support volume storage dir on docker (#291) 2023-12-13 08:51:22 +08:00
Levi 2b5091ba21 feat: improve extension engine (#298)
1. support onError event
2. add built-in MessageError
2023-12-12 19:00:42 +08:00
Levi ff32971aca fix: repair invalid filename (#296) 2023-12-12 18:40:36 +08:00
Levi f2af61f9aa fix: sort of completed tasks (#297) 2023-12-12 18:36:40 +08:00
Elliot d935b9d631 feat: improve zh_TW translate (#287) 2023-12-04 23:01:49 +08:00
liwei ae66d1d83d docs: fix contributing 2023-12-04 13:41:27 +08:00
madoka773 aa91ca1d50 ci: linux build use libappindicator (#286) 2023-12-04 13:36:27 +08:00
liwei 2d4bdea5cb fix: android file open 2023-12-02 22:51:44 +08:00
liwei 2f2393ac20 fix: i18n word 2023-12-02 19:22:37 +08:00
Levi 5a761e244a release: v1.5.1 2023-12-02 18:47:07 +08:00
Levi 6538766780 fix: incorrect file name in task files view (#285) 2023-12-02 18:45:16 +08:00
Levi 16b7a3d5b3 feat: support proxy (#284) 2023-12-02 18:36:52 +08:00
Levi ef97528114 ci: fix window 2023-11-29 21:12:06 +08:00
Levi b58669bb46 ci: fix windows build 2023-11-29 20:35:20 +08:00
Levi 51d28c5e53 release: v1.5.0 2023-11-29 20:18:08 +08:00
Levi db15650664 feat: support browse files on web (#283) 2023-11-29 18:24:01 +08:00
Levi ee7da179c1 chore: upgrade flutter to v3.16.1 (#282) 2023-11-29 13:32:07 +08:00
Levi 6bb32372ab feat: support more configurations (#281) 2023-11-29 12:52:51 +08:00
Levi 7d6428c967 fix: task can not completed (#280) 2023-11-28 18:27:36 +08:00
Levi bba5107dd5 fix: crash on android arm-32 (#279) 2023-11-27 11:10:50 +08:00
liwei ddfe76b635 fix: script engine event loop stop 2023-11-25 00:43:40 +08:00
Levi aad7395dde feat: enhanced extension script engine (#277)
1. update goja version
2. add node vm polyfill
3. non-stop event loop handle
4 improve logger print
2023-11-24 22:41:16 +08:00
Levi 6f541715df fix: extension download path (#274) 2023-11-24 18:16:03 +08:00
neovali 848797b2fd fix: .deb build (#269) 2023-11-15 21:01:28 +08:00
Levi 0a05bf41e7 fix: incorrect storage path in linux (#270) 2023-11-15 16:26:29 +08:00
liwei 485656d1d4 ci: fix linux desktop entry 2023-11-13 16:23:26 +08:00
liwei c074e0c2bc chore: generate locales 2023-11-13 15:55:02 +08:00
yuloury 0db0a2c987 ci: fix deb desktop entry (#268) 2023-11-11 09:23:17 +08:00
Elliot b06a79b5c7 feat: improve zh_TW translate 2023-11-10 09:11:50 +08:00
liwei 4840c4b8a4 ci: fix build env 2023-11-09 18:49:35 +08:00
Levi e72b097aa2 release: v1.4.4 2023-11-09 17:09:15 +08:00
Levi af52c4ce7e feat: support create task directly (#262) 2023-11-09 16:52:02 +08:00
Levi aae79c2449 feat: running foreground service on mobile (#261) 2023-11-07 17:52:10 +08:00
Levi 83838afce9 feat: support more linux distributions (#254) 2023-11-01 14:54:24 +08:00
Levi 4690107e32 fix: gopeed cli BT download (#258) 2023-10-31 10:16:28 +08:00
liwei 74b08b3631 chore: build locales.g.dart 2023-10-31 09:24:55 +08:00
Kiber_kotan 953d348c0c feat: improve Russian translation (#257) 2023-10-30 11:27:43 +08:00
Levi 00cea68b6e feat: add extension intro links (#252) 2023-10-26 18:35:22 +08:00
Levi daa9d4c707 release: v1.4.3 2023-10-24 16:17:48 +08:00
liwei 6bc587752a chore: update introduction 2023-10-24 16:16:16 +08:00
Levi 11befc09bd refractor: downloader task action dispatch (#249) 2023-10-24 16:02:39 +08:00
liwei ff6c0da262 chore: locales build 2023-10-23 13:55:13 +08:00
Levi aab4a37a7e feat: adapt mobile extension list (#242) 2023-10-21 16:36:22 +08:00
Levi 74c9491893 fix: linux system tray tooltip (#243) 2023-10-21 16:29:15 +08:00
Levi 256faf2cbd feat: add extension setting tooltip (#247) 2023-10-21 16:16:46 +08:00
Om Jogani 32e3620f5e feat: Implemented History Panel for reusing previously used Magnet & Torrent Links (#245) 2023-10-21 15:52:34 +08:00
Levi 39202b44a1 fix: http retry strategy (#240) 2023-10-20 12:15:21 +08:00
Levi 457d9e85d6 release: v1.4.2 2023-10-19 18:36:46 +08:00
Levi 858ad74339 feat: extension on start event (#239) 2023-10-19 18:08:27 +08:00
Levi 755d7ce814 fix: add system tray tooltip (#237) 2023-10-19 14:00:15 +08:00
Levi 854f600d3f test: add extension script error case (#238) 2023-10-19 13:59:59 +08:00
Levi 28da89f08c release: v1.4.1 2023-10-18 11:10:04 +08:00
Levi dddefb68a2 feat: support storage state on extension (#235) 2023-10-18 10:59:51 +08:00
liwei e882b663a8 ci: docker build free disk 2023-10-17 11:58:24 +08:00
liwei 6f8e9fc68f ci: fix go version on Windows 2023-10-17 10:52:19 +08:00
liwei 7799a4032c ci: fix windows build 2023-10-17 10:41:49 +08:00
liwei 6316e9986a ci: fix go version 2023-10-17 10:26:21 +08:00
liwei 39d1028ee9 fix: web start 2023-10-17 09:45:19 +08:00
liwei 107f90649b release: v1.4.0 2023-10-17 09:19:47 +08:00
liwei c8b66501b7 chore: generate zh_TW 2023-10-17 09:19:08 +08:00
Elliot 398e3f7adc feat: update zh_TW.json (#225) 2023-10-16 23:20:20 +08:00
Levi 158018c248 ci: support arm/v6 and arm/v7 on docker (#234) 2023-10-16 16:19:12 +08:00
liwei c56447ad5f chore: update donate link 2023-10-16 12:49:59 +08:00
Levi c073fc0a67 feat: extension support (#78) 2023-10-16 09:47:31 +08:00
liwei 82d316af83 ci: fix go version env 2023-10-08 16:42:33 +08:00
liwei 150953a033 chore: upgrade go version to 1.20 2023-10-08 16:40:53 +08:00
liwei f30ad6641f ci: fix xgo version 2023-10-08 16:04:24 +08:00
liwei 7019f18635 release: v1.3.13 2023-10-08 15:36:13 +08:00
Levi c0338c3d8e feat: auto recognize magnet uri (#230) 2023-10-08 15:29:19 +08:00
Levi fe55f1bd54 feat: adapts to Android 14 (#229) 2023-10-06 11:51:12 +08:00
liwei 48f8c60672 ci: disable auto translate 2023-09-22 15:34:45 +08:00
Elliot cc97cedf0f feat: improve zh-TW translate (#224) 2023-09-21 22:08:19 +08:00
Levi a7d855f65a release: v1.3.12 2023-09-20 16:33:47 +08:00
Levi f0f16b3c80 ci: disable translate 2023-09-20 16:32:02 +08:00
Levi 3459b474db feat: file open on mobile (#223) 2023-09-20 16:29:08 +08:00
Tracey Lesley d28fcef0a7 feat: default http User-Agent header (#26) 2023-09-13 10:03:26 +08:00
Elliot d694c08635 feat: add zh-TW.json translate (#209) 2023-09-08 09:19:55 +08:00
liwei a7c61a668e chore: update donate doc 2023-09-07 09:32:33 +08:00
Levi 09aea55b49 release: v1.3.11 2023-09-04 13:57:47 +08:00
Levi c0cd2d7255 feat: task file manage (#196)
1. support view task file list
2. support share file on android and iOS
2023-09-04 13:51:30 +08:00
Levi b00f4a1ed0 feat: ios support (#194) 2023-09-04 11:28:07 +08:00
Levi 06766eea56 feat: auto get create url from clipboard (#193) 2023-09-02 11:32:28 +08:00
Levi 0f0c8f27bd feat: adjust size of resource dialog (#192) 2023-09-02 11:26:26 +08:00
liwei 45cf62f88d release: v1.3.10 2023-08-26 09:17:22 +08:00
Levi 6e9d3499d9 fix: drop file crash on Windows (#189) 2023-08-26 08:58:10 +08:00
Levi b6d60f6bdf fix: http download timeout (#188) 2023-08-25 18:42:10 +08:00
Levi e1684e755a fix: go install fail (#184) 2023-08-16 17:05:20 +08:00
Tracey Lesley e63cfca82a fix:current writes map (#182) 2023-08-12 13:45:01 +08:00
Levi 85ef8d3be4 release: v1.3.9 2023-08-01 18:57:15 +08:00
Levi bf2ccdb5a7 fix: torrent file path resolve (#173) 2023-08-01 18:56:39 +08:00
Levi a75bfd6118 release: v1.3.8 2023-07-28 21:50:26 +08:00
Levi a5d8713ff7 feat: torrent file support on web (#171) 2023-07-28 21:48:20 +08:00
Levi 81518d794f feat: web support http basic authorization (#170) 2023-07-27 21:02:33 +08:00
Levi 7c2b81caf7 release: v1.3.7 2023-07-23 10:00:47 +08:00
Levi 2221332243 fix: bt file real time speed (#163) 2023-07-23 09:58:42 +08:00
Levi dc407434d0 fix: file_picker crash on windows (#164) 2023-07-23 00:43:22 +08:00
Levi 1c2e86a991 fix: flutter docker image 2023-07-19 23:35:02 +08:00
Levi c78dc5f18f fix: flutter image 2023-07-19 23:26:53 +08:00
Levi 8ea5abb5c3 fix: Dockerfile flutter version 2023-07-19 23:19:24 +08:00
Levi d1a4067dfa release: v1.3.6 2023-07-19 23:05:01 +08:00
Levi a1928d202f fix: high dpi display (#158) 2023-07-19 23:01:25 +08:00
Levi 7376e85895 feat: create task with advanced options (#157) 2023-07-19 22:52:51 +08:00
Li Wei eb79dd5ac5 chore: update docker command 2023-07-19 06:11:02 +00:00
Li Wei 5b945b16c1 ci: auto translate 2023-07-18 09:17:07 +00:00
Levi 80e60f4190 feat: improve tracker cdn (#155) 2023-07-15 17:01:52 +08:00
Levi 3087796ef5 feat: support flutter 3.10 (#154) 2023-07-15 14:52:59 +08:00
Liam Segura 99de0aaf9b chore: Update README.md (#144) 2023-07-07 20:23:41 +08:00
liwei 6bea61cf0a release: v1.3.5 2023-07-07 16:23:31 +08:00
liwei 6c3553805e fix: include Japanese 2023-07-07 16:16:43 +08:00
liwei adc7c7766d feat: include Japanese 2023-07-07 16:10:50 +08:00
Ikko Eltociear Ashimine 15d0296307 feat: add Japanese support (#139) 2023-07-07 09:55:23 +08:00
Li Wei 32d2e46118 feat: bt fetcher reuse (#142) 2023-07-06 17:21:34 +08:00
Li Wei abe3cec13d fix: client log is not recorded (#141) 2023-07-06 17:15:48 +08:00
liwei 9249613601 release: v1.3.4 2023-07-05 21:56:25 +08:00
Li Wei 5bafb40599 fix: torrent udp tracker panic (#138) 2023-07-05 21:32:50 +08:00
Li Wei 5968d3c14a feat: panic log (#137) 2023-07-05 21:12:55 +08:00
Li Wei fcb54d3acc fix: api token not saved (#135) 2023-07-04 12:18:46 +08:00
Li Wei e2c4567ed0 fix: macos directory open (#127) 2023-07-03 19:02:08 +08:00
Li Wei c3b28c7034 fix: dio timeout don't throw exception (#133) 2023-07-03 18:52:17 +08:00
Li Wei 32061200a5 chore: add download total badge 2023-06-30 04:12:02 +00:00
Li Wei 19aad9bc9c fix: android build 2023-06-28 15:33:50 +08:00
Li Wei 2d0cc1c877 release: v1.3.3 2023-06-28 15:21:06 +08:00
Li Wei dc94a1004c build: fix gomobile 2023-06-28 06:38:34 +00:00
Li Wei 1385f62fb0 ci: fix gomobile android build 2023-06-28 03:09:05 +00:00
Li Wei 34fc886662 fix: http filename resolve (#124) 2023-06-28 10:22:45 +08:00
Li Wei c651263747 chore: update clone git url 2023-06-27 03:41:49 +00:00
Li Wei 96a4526bc8 feat: improve web build (#123) 2023-06-27 10:04:15 +08:00
Shane 97cf8ff0c5 fix: remove cli test args (#122) 2023-06-16 09:09:14 +08:00
liwei 70c16195ef release: v1.3.2 2023-06-09 22:17:27 +08:00
Li Wei eb9d966855 feat: support start and pause all tasks (#121) 2023-06-09 22:15:59 +08:00
liwei 0e3ca912a7 fix: add mac tray template icon 2023-06-08 14:10:55 +08:00
Li Wei b3e372326f feat: add desktop system tray support (#120) 2023-06-07 21:50:40 +08:00
Li Wei 0100f0131d feat: android deeplink and file selector (#118) 2023-06-06 20:39:07 +08:00
Levi e4c3b449a4 chore: update readme 2023-05-23 18:23:51 +08:00
liwei aaa8cd6406 fix: flutter downgrade to v3.7.12 2023-05-11 18:06:25 +08:00
Li Wei 328422b3b8 feat: flutter upgrade to v3.10.0 (#113) 2023-05-11 11:42:55 +08:00
Li Wei 17cce89ef1 release: v1.3.1 2023-05-11 09:19:56 +08:00
Li Wei d9c5ca339f feat: support max running tasks config (#112) 2023-05-10 18:00:58 +08:00
Li Wei 1e08ecd8db fix: file explorer and support select file (#111) 2023-05-04 14:32:37 +08:00
Li Wei 34bcbd9571 fix: gopeed cli negative WaitGroup counter error (#110) 2023-05-04 10:05:32 +08:00
liwei 09bdb5584a ci: fix mac build 2023-04-01 21:29:57 +08:00
liwei 36f5c9569e ci: debug 2023-04-01 21:21:09 +08:00
liwei 3d9edc5133 ci: debug 2023-04-01 21:10:41 +08:00
liwei ed08c05532 ci: fix 2023-04-01 20:58:33 +08:00
liwei 9bd8befcb8 fix: mac universal binary build 2023-04-01 20:49:51 +08:00
liwei 7ba87e48fc fix: mac build 2023-04-01 20:31:55 +08:00
Li Wei af5f739630 ci: macos universal binaries build (#103) 2023-04-01 20:13:08 +08:00
liwei 5558cac263 fix: task view ui 2023-04-01 19:43:47 +08:00
liwei 4128e718e9 ci: fix web build 2023-04-01 17:05:51 +08:00
liwei 6d017b18f5 fix: version check 2023-04-01 16:53:43 +08:00
liwei 35eb089178 ci: fix web build 2023-04-01 16:37:42 +08:00
liwei b5f78f0fbe ci: fix docker build 2023-04-01 16:32:57 +08:00
liwei 628d73f90d ci: fix android build 2023-04-01 16:15:34 +08:00
liwei 7d2d84d844 fix: home page menu icon 2023-04-01 16:09:36 +08:00
liwei 1cbe58d760 ci: fix macos arm build 2023-04-01 15:56:07 +08:00
liwei c77a4094a3 fix: logger file output 2023-04-01 15:52:12 +08:00
Li Wei 33f6325bf7 feat: new version check (#102) 2023-04-01 15:33:58 +08:00
Li Wei 701ea59035 feat: smooth speed calculation (#99) 2023-03-09 10:35:10 +08:00
pharaoh2012 e77441f0de docs: correct the official website link (#98) 2023-03-07 11:01:18 +08:00
Li Wei dd46e7a3eb fix: crash caused by non-resumable http download resources (#97) 2023-03-06 21:09:31 +08:00
liwei f42838810a refactor: common message dialog method 2023-03-06 21:02:24 +08:00
liwei 7fdba12376 fix: tracker cdn retry issue 2023-03-06 17:07:00 +08:00
liwei 20130ae6d0 fix: error code 2023-03-05 15:37:04 +08:00
liwei 4598e9b351 ci: fix release action permissions 2023-03-05 12:10:32 +08:00
Li Wei 2e639e0fef feat: api result add code (#96) 2023-03-05 11:54:39 +08:00
liwei dbbbc204fb doc: add official website link 2023-03-04 13:37:20 +08:00
liwei caab121cc2 ci: fix flutter linux build tool 2023-03-02 14:05:22 +08:00
Li Wei c637b36094 ci: flutter build check (#95) 2023-03-02 13:57:00 +08:00
Neginegiandwuke2022 38ba901956 fix: fluent system theme mode (#94)
* Fix: fluent theme mode fix

---------

Co-authored-by: wuke2022 <[email protected]>
2023-02-28 01:53:37 +08:00
Neginegiandwuke2022 8bae1a5850 feat: complete tree view (#93)
* feat: complete tree view

---------

Co-authored-by: wuke2022 <[email protected]>
2023-02-23 21:34:39 +08:00
Neginegiandwuke2022 56375c0edb fix: treeview,style,file picker (#91)
* fix: tree view,style,file picker

Co-authored-by: wuke2022 <[email protected]>
2023-02-22 20:06:28 +08:00
Neginegiandwuke2022 5b69472ac8 fix: style, performance ,loading bug (#90)
* refactor project in get-cli standard

* use local trackers when github is not connectable
upgrade stylish_bottom_bar to 1.0.0
responsive view for home_view
treeview for file_list_vie
fluent ui integrating

* improve responsive view.
treeView add light theme.fix overflow bugs

* responsive view,treeView,file select
downloaded and downloading pages
file extension icons
fix some minor bugs

* use local trackers when github is not connectable
upgrade stylish_bottom_bar to 1.0.0
responsive view for home_view
treeview for file_list_vie
fluent ui integrating

* improve responsive view.
treeView add light theme.fix overflow bugs

* bottom bar index fix

* refactor project in get-cli standard

* fix:locale fix

* fix: task list stack progress style
fix: translation
fix: tree view
fix: confirm button loading
remove: stylish_bottom_bar(dark mode not supported)

* fix: task list stack progress style
fix: translation
fix: tree view
fix: confirm button loading
remove: stylish_bottom_bar(dark mode not supported)

---------

Co-authored-by: wuke2022 <[email protected]>
2023-02-21 20:17:17 +08:00
Neginegiandwuke2022 dbc9c4c121 fix docs and locales (#86)
* refactor project in get-cli standard

* use local trackers when github is not connectable
upgrade stylish_bottom_bar to 1.0.0
responsive view for home_view
treeview for file_list_vie
fluent ui integrating

* improve responsive view.
treeView add light theme.fix overflow bugs

* responsive view,treeView,file select
downloaded and downloading pages
file extension icons
fix some minor bugs

* use local trackers when github is not connectable
upgrade stylish_bottom_bar to 1.0.0
responsive view for home_view
treeview for file_list_vie
fluent ui integrating

* improve responsive view.
treeView add light theme.fix overflow bugs

* bottom bar index fix

* refactor project in get-cli standard

* fix:locale fix

* docs: add detailed bash for build and dev

---------

Co-authored-by: wuke2022 <[email protected]>
2023-02-19 23:47:28 +08:00
Neginegiandwuke2022 c0e24ce869 fix locale in get-cli standard (#81)
* refactor project in get-cli standard

* use local trackers when github is not connectable
upgrade stylish_bottom_bar to 1.0.0
responsive view for home_view
treeview for file_list_vie
fluent ui integrating

* improve responsive view.
treeView add light theme.fix overflow bugs

* responsive view,treeView,file select
downloaded and downloading pages
file extension icons
fix some minor bugs

* use local trackers when github is not connectable
upgrade stylish_bottom_bar to 1.0.0
responsive view for home_view
treeview for file_list_vie
fluent ui integrating

* improve responsive view.
treeView add light theme.fix overflow bugs

* bottom bar index fix

* refactor project in get-cli standard

---------

Co-authored-by: wuke2022 <[email protected]>
2023-02-19 21:22:20 +08:00
Neginegiandwuke2022 74c24ad860 Refactor project in get-cli standard (#80)
* refactor project in get-cli standard

* use local trackers when github is not connectable
upgrade stylish_bottom_bar to 1.0.0
responsive view for home_view
treeview for file_list_vie
fluent ui integrating

* improve responsive view.
treeView add light theme.fix overflow bugs

* responsive view,treeView,file select
downloaded and downloading pages
file extension icons
fix some minor bugs

* use local trackers when github is not connectable
upgrade stylish_bottom_bar to 1.0.0
responsive view for home_view
treeview for file_list_vie
fluent ui integrating

* improve responsive view.
treeView add light theme.fix overflow bugs

* bottom bar index fix

* refactor project in get-cli standard

---------

Co-authored-by: wuke2022 <[email protected]>
2023-02-19 20:06:28 +08:00
wuke2022 9b1ed15fea feat: ui improvement
flutter 3.7 compatibility
responsive view
beautiful progress bar
select file in treeview
downlading and downloaded page
2023-02-16 11:34:42 +08:00
liwei a875a9258e refactor: change repo url 2023-02-11 21:37:05 +08:00
liwei 0d2ac10af8 fix: rename logic 2023-02-10 21:40:16 +08:00
liwei 978b8bc7cb docs: add full name 2023-02-10 16:20:55 +08:00
liwei 50c3713c90 docs: donate link hash 2023-02-10 16:16:00 +08:00
liwei 2639b6bc65 docs: badge sort 2023-02-10 16:09:01 +08:00
liwei 8b29ceb151 docs: add donate badge 2023-02-10 16:07:43 +08:00
liwei e1c14ad9f8 docs: add donate info 2023-02-10 15:42:29 +08:00
liwei e4f86097e8 fix: bt resolve 2023-02-10 15:27:15 +08:00
Li Wei 0515ce6081 feat: auto rename on duplicate (#76) 2023-02-10 15:18:31 +08:00
liwei 048554df4d ci: fix 2023-02-08 17:29:23 +08:00
Li Wei 2d02486ca6 ci: windows setup add chinese (#75) 2023-02-08 17:18:39 +08:00
liwei 3523f5855a fix: api ok nil response 2023-02-08 14:59:59 +08:00
liwei aa9577a2e0 fix: ignore broken task data 2023-02-08 14:53:49 +08:00
liwei efc9dcca0b fix: download dir config 2023-02-07 23:53:50 +08:00
liwei b062336c09 docs: update CONTRIBUTING.md 2023-02-07 23:30:26 +08:00
liwei baf88a5cb7 ci: build only manual trigger 2023-02-07 18:28:14 +08:00
liwei e208014d34 ci: fix windows 2023-02-07 18:24:03 +08:00
liwei 3ecedd60fd ci: fix 2023-02-07 15:33:02 +08:00
liwei 6fd68e4752 ci: fix input 2023-02-07 15:13:51 +08:00
Li Wei c2910b0e8c ci: get last release include draft (#74) 2023-02-07 15:10:31 +08:00
Li Wei 8ed51787ad feat: add cdn for tracker subscribe urls (#73) 2023-02-07 14:18:59 +08:00
liwei 2e5fd3017c fix: bt fetcher restore 2023-02-06 22:21:25 +08:00
Li Wei 027d29e834 fix: windows version (#72) 2023-02-06 21:06:56 +08:00
Li Wei 1a3c8c026b feat: reuse fetcher (#71) 2023-02-06 20:58:13 +08:00
Persian Prince 17cb59c606 feat: add farsi languages (#70) 2023-02-03 21:35:23 +08:00
liwei e3828c2982 ci: fix 2022-12-17 13:09:15 +08:00
liwei 8cef0bb727 ci: fix android sign 2022-12-17 10:58:32 +08:00
Li Wei 21ec1e4539 fix: follow android 11 storage permissions (#66) 2022-12-16 21:43:06 +08:00
levi dda44cc770 fix: docker tag 2022-12-16 15:24:41 +08:00
levi 78911b2aa9 ci: fix docker build 2022-12-16 15:10:34 +08:00
Li Wei 4935dcd0e6 ci: fix 2022-12-16 15:02:33 +08:00
Li Wei e8fe6ad550 ci: docker supports more platforms (#64) 2022-12-16 14:56:56 +08:00
Li Wei db4003ad4f fix: speed calculate (#63) 2022-12-16 14:53:07 +08:00
Li Wei 0a4be165a5 fix: http download fail (#61) 2022-12-14 12:12:39 +08:00
levi c62867e3ef chore: fix docker-compose command 2022-12-13 15:00:32 +08:00
levi 6df4959845 ci: fix 2022-12-13 14:49:53 +08:00
levi e0b18013be chore: release v1.2.0 2022-12-13 14:48:01 +08:00
levi d205e3b372 feat: enable ru_RU 2022-12-13 14:47:08 +08:00
azatzin30 719daf054b feat: Translation to Russian language (#60) 2022-12-13 14:42:08 +08:00
levi a5f72a22c3 fix: i18n 2022-12-13 14:39:51 +08:00
levi bf5c7655f2 test: remove fail code 2022-12-12 16:06:00 +08:00
Li Wei 691c2f496e feat: support custom tracker url (#59) 2022-12-12 15:58:11 +08:00
gesneriana 9ef3405e43 fix: missing single quotes (#57) 2022-11-16 13:34:15 +08:00
azatzin30 215faa0bf0 feat: Added Russian language (#55) 2022-11-11 22:17:38 +08:00
cgcel 695da7ea87 feat: support docker compose (#54) 2022-11-10 14:13:34 +08:00
liwei 7c6c361d30 fix: docker dist copy 2022-11-08 22:55:56 +08:00
liwei 872ba0ba87 doc: fix docker run command 2022-11-08 22:36:47 +08:00
monkeyWie 93c14dcee5 fix: ip bind (#50) 2022-11-08 22:29:33 +08:00
liwei 1af0b2175c ci: path include Dockerfile 2022-11-08 21:50:45 +08:00
monkeyWie ee81ebffef fix: docker executable (#49) 2022-11-08 21:49:02 +08:00
levi dc00fd2543 doc: fix 2022-11-08 15:02:58 +08:00
438 changed files with 53809 additions and 4515 deletions

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
.git
*.data
*.log
node_modules
**/node_modules
Dockerfile
.dockerignore
.github
_docs
_examples
bin
ui
+1
View File
@@ -0,0 +1 @@
* @monkeyWie
+15
View File
@@ -0,0 +1,15 @@
# These are supported funding model platforms
#github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
#patreon: # Replace with a single Patreon username
#open_collective: # Replace with a single Open Collective username
#ko_fi: # Replace with a single Ko-fi username
#tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
#community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
#liberapay: # Replace with a single Liberapay username
#issuehunt: # Replace with a single IssueHunt username
#otechie: # Replace with a single Otechie username
#custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
# github: monkeyWie
ko_fi: gopeed
custom: https://gopeed.com/docs/donate
+6 -6
View File
@@ -1,8 +1,8 @@
<!--
请根据以下模版提issue
Please use the following template to create an issue.
-->
### 问题描述(必要)
### 版本号(必要)
### 操作系统(必要)
### 相关截图
### 相关日志
### Description(required)
### App Version(required)
### OS Version(required)
### Snapshots
### Log
+176 -22
View File
@@ -1,35 +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: '🧰 Maintenance'
label: 'chore'
- title: '📖 Document'
label: 'doc'
- title: '🚀 CI/CD'
label: 'ci'
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>
$CHANGES
# Release notes
$CHANGES
# 更新日志
$CHANGES
+567 -130
View File
@@ -2,56 +2,136 @@ name: build
on:
workflow_dispatch:
release:
types: [published]
inputs:
platform:
description: "Build platform"
required: true
default: "all"
test:
description: "Test mode"
required: true
default: "false"
env:
GO_VERSION: "1.24"
FLUTTER_VERSION: "3.41.2"
GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }}
GA4_API_SECRET: ${{ secrets.GA4_API_SECRET }}
permissions:
contents: write
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: actions/checkout@v3
- uses: thebritican/[email protected]
- uses: monkeyWie/[email protected]
id: get-release
with:
myToken: ${{ github.token }}
build-windows:
runs-on: windows-latest
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows' }}
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@v3
- 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@v4
with:
go-version: "^1.19"
cache: true
go-version: ${{ env.GO_VERSION }}
- uses: subosito/flutter-action@v2
with:
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: |
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/windows/libgopeed.dll github.com/monkeyWie/gopeed/bind/desktop
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 -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:\ProgramData\Chocolatey\lib\mingw\tools\install\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\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.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"
@@ -74,20 +154,28 @@ 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"
Name: "chinesesimplified"; MessagesFile: "compiler:Languages\ChineseSimplified.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Files]
Source: ".\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]
Type: filesandordirs; Name: "{app}"
[Icons]
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
@@ -96,70 +184,102 @@ 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-install.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:
runs-on: ubuntu-latest
build-macos-amd64-lib:
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos' }}
runs-on: macos-15-intel
needs: [get-release]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
- uses: actions/setup-go@v4
with:
go-version: "^1.19"
cache: true
- run: mkdir -p ~/image-cache
- id: image-cache
uses: actions/cache@v3
with:
path: ~/image-cache
key: image-cache-xgo-v1.19
- if: steps.image-cache.outputs.cache-hit != 'true'
run: |
docker pull techknowlogick/xgo:go-1.19.x
docker save -o ~/image-cache/xgo.tar techknowlogick/xgo:go-1.19.x
- if: steps.image-cache.outputs.cache-hit == 'true'
run: docker load -i ~/image-cache/xgo.tar
go-version: ${{ env.GO_VERSION }}
- name: Build
env:
VERSION: ${{ needs.get-release.outputs.version }}
run: |
go install src.techknowlogick.com/xgo@latest
xgo --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@v3
- uses: actions/setup-go@v4
with:
go-version: "^1.19"
cache: true
go-version: ${{ env.GO_VERSION }}
- uses: actions/setup-node@v3
with:
node-version: 16
- uses: subosito/flutter-action@v2
- name: Install appdmg
run: npm install -g appdmg
- uses: actions/download-artifact@v3
with:
name: macos-arm64-lib
path: ui/flutter/macos/Frameworks
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Install appdmg
run: |
python3 -m pip install setuptools --break-system-packages
npm install -g appdmg
- uses: actions/download-artifact@v4
with:
name: macos-amd64-lib
path: ui/flutter/lib-amd64
- if: ${{ matrix.channel == 'arm64' }}
name: Build Arm64 Libraries
run: |
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
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
# 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.tag_name }}
VERSION: ${{ needs.get-release.outputs.version }}
run: |
PROJECT_DIR=$(pwd)
cd ui/flutter
flutter build macos
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
{
@@ -172,14 +292,14 @@ jobs:
}
EOF
mkdir dist
appdmg appdmg.json dist/Gopeed-$VERSION-macos-arm64.dmg
appdmg appdmg.json dist/Gopeed-v$VERSION-macos.dmg
# amd64
cd $PROJECT_DIR
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o bin/libgopeed.dylib github.com/monkeyWie/gopeed/bind/desktop
cp bin/libgopeed.dylib ui/flutter/build/macos/Build/Products/Release/Gopeed.app/Contents/Frameworks/
cd ui/flutter/build/macos/Build/Products/Release
appdmg appdmg.json dist/Gopeed-$VERSION-macos-amd64.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:
@@ -187,90 +307,307 @@ jobs:
asset_path: ui/flutter/build/macos/Build/Products/Release/dist/*
overwrite: true
build-linux:
runs-on: ubuntu-latest
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux' }}
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@v3
- uses: actions/setup-go@v4
with:
go-version: "^1.19"
cache: true
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
sudo apt update -y
sudo apt install -y ninja-build libgtk-3-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/monkeyWie/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 debian/gui
cp assets/icon/icon_1024.png debian/gui/gopeed.png
cat>debian/debian.yaml<<EOF
flutter_app:
command: gopeed
arch: x64
parent: /usr/local/lib
control:
Package: gopeed
Version: ${VERSION:1}
Architecture: amd64
Essential: no
Priority: optional
Depends:
Maintainer: monkeyWie
Description: High speed downloader that supports all platforms.
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/gui/gopeed.desktop<<EOF
[Desktop Entry]
Version=${VERSION:1}
Name=Gopeed
GenericName=Gopeed
Comment=High speed downloader that supports all platforms.
Terminal=false
Type=Application
Categories=Utility
Keywords=Flutter;Downloader;
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/*
dart pub global activate flutter_to_debian
flutter_to_debian
mv debian/packages/gopeed*.deb debian/packages/Gopeed-$VERSION-linux-amd64.deb
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/debian/packages/*
asset_path: ui/flutter/dist/*/*
overwrite: true
build-android:
build-snap:
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'snap' }}
runs-on: ubuntu-latest
needs: [get-release]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
- uses: actions/setup-go@v4
with:
go-version: "^1.19"
cache: true
go-version: ${{ env.GO_VERSION }}
- name: Setup LXD
uses: canonical/[email protected]
with:
channel: latest/stable
- name: Build
env:
VERSION: ${{ needs.get-release.outputs.version }}
run: |
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
summary: A modern download manager
description: High speed downloader that supports all platforms.
confinement: strict
base: core24
grade: stable
platforms:
amd64:
build-on: amd64
build-for: amd64
slots:
dbus-gopeed:
interface: dbus
bus: session
name: com.gopeed.gopeed
apps:
gopeed:
command-chain:
- bin/gpu-2404-wrapper
command: bin/gopeed
extensions: [gnome]
common-id: com.gopeed.Gopeed
plugs:
- network
- home
parts:
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
- 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:
- libgtk-3-dev
- libkeybinder-3.0-dev
- libappindicator3-dev
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.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]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
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 }}
run: |
mkdir ui/flutter/android/app/libs
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/monkeyWie/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
flutter build apk
echo $APK_KEYSTORE | base64 -di > android/app/upload-keystore.jks
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:
upload_url: ${{ needs.get-release.outputs.upload_url }}
asset_path: ui/flutter/dist/*
overwrite: true
build-ios:
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios' }}
runs-on: macos-14
needs: [get-release]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
with:
go-version: ${{ env.GO_VERSION }}
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Build
env:
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 -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 --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-v$VERSION-ios.ipa
- name: Upload
uses: shogo82148/actions-upload-release-asset@v1
with:
@@ -278,32 +615,40 @@ jobs:
asset_path: ui/flutter/dist/*
overwrite: true
build-web:
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'web' || github.event.inputs.platform == 'qnap' }}
runs-on: ubuntu-latest
needs: [get-release]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
- uses: actions/setup-go@v4
with:
go-version: "^1.19"
cache: true
go-version: ${{ env.GO_VERSION }}
- 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 }}
run: |
cd ui/flutter
flutter build web
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
for goos in "${goos_arr[@]}"; do
for goarch in "${goarch_arr[@]}"; do
name=gopeed-web-$VERSION-$goos-$goarch
goos_name=$goos
if [ $goos = "darwin" ]; then
goos_name="macos"
fi
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/monkeyWie/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 \
@@ -313,24 +658,116 @@ 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]
steps:
- uses: actions/checkout@v3
- name: Build
env:
VERSION: ${{ needs.get-release.outputs.tag_name }}
- name: Remove unnecessary files
run: |
tag=liwei2633/gopeed:${VERSION:1}
docker build -t $tag .
docker login -u liwei2633 -p ${{ secrets.DOCKER_PASSWORD }}
docker push $tag
docker tag $tag liwei2633/gopeed:latest
docker push liwei2633/gopeed:latest
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
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
uses: docker/login-action@v1
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.version }}
with:
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/v7
tags: |
liwei2633/gopeed:latest
liwei2633/gopeed:v${{ env.VERSION }}
+10 -2
View File
@@ -11,8 +11,16 @@ on:
- "pkg/**"
- "ui/**"
- ".github/workflows/release.yml"
- ".github/release-drafter.yml"
- "go.mod"
- "go.sum"
- "Dockerfile"
env:
GO_VERSION: "1.24"
permissions:
contents: write
jobs:
release:
@@ -23,9 +31,9 @@ jobs:
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
- uses: actions/setup-go@v4
with:
go-version: "^1.19"
go-version: ${{ env.GO_VERSION }}
- uses: release-drafter/release-drafter@v5
id: create_release
env:
@@ -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);
}
+156 -15
View File
@@ -25,6 +25,11 @@ on:
- ".github/workflows/test.yml"
- "go.mod"
- "go.sum"
workflow_dispatch:
env:
GO_VERSION: "1.24"
FLUTTER_VERSION: "3.41.2"
jobs:
# lint:
@@ -41,30 +46,166 @@ jobs:
# go get -u golang.org/x/lint/golint
# golint -set_exit_status ./...
test:
name: Test
name: Test check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
- uses: actions/setup-go@v4
with:
go-version: "^1.19"
cache: true
go-version: ${{ env.GO_VERSION }}
- 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 ./... | grep -v /bind/ | grep -v -E '/cmd/(?!web)')
go test -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
fail_ci_if_error: true
build:
name: Build
runs-on: ubuntu-latest
# needs: [lint, test]
token: ${{ secrets.CODECOV_UPLOAD_TOKEN }}
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
- uses: actions/setup-go@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@v4
with:
go-version: "^1.19"
cache: true
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 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@v4
with:
go-version: ${{ env.GO_VERSION }}
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
- run: |
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@v4
with:
go-version: ${{ env.GO_VERSION }}
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Build
run: go build github.com/monkeyWie/gopeed/cmd/gopeed
run: |
cd ui/flutter
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@v4
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 .
+30
View File
@@ -0,0 +1,30 @@
# name: 'translator'
# on:
# issues:
# types: [opened, edited]
# issue_comment:
# types: [created, edited]
# discussion:
# types: [created, edited]
# discussion_comment:
# types: [created, edited]
# pull_request_target:
# types: [opened, edited]
# pull_request_review_comment:
# types: [created, edited]
# jobs:
# translate:
# permissions:
# issues: write
# discussions: write
# pull-requests: write
# runs-on: ubuntu-latest
# steps:
# - uses: lizheming/[email protected]
# env:
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# with:
# IS_MODIFY_TITLE: true
# APPEND_TRANSLATION: true
+7 -2
View File
@@ -4,7 +4,7 @@
*.dll
*.so
*.dylib
ui/flutter/dist/
# Test binary, built with `go test -c`
*.test
@@ -21,7 +21,12 @@ bin/
*.data
*.db
*.log
.DS_Store
dist/
node_modules/
cmd/web/dist/
.test_storage/
.test_download/
/extensions
+34
View File
@@ -0,0 +1,34 @@
# Gopeed contributors guide
Firstly, thank you for your interest in contributing to Gopeed. This guide will help you better
participate in the development of Gopeed.
## Branch description
This project only has one main branch, namely the `main` branch. If you want to participate in the
development of Gopeed, please fork this project first, and then develop in your fork project. After
development is completed, submit a PR to this project and merge it into the `main` branch.
## Local development
It is recommended to develop and debug through the web. First, start the backend service, and start
it by the command line `go run cmd/api/main.go`, the default port of the service is `9999`, and then
start the front-end flutter project in `debug` mode to run.
## Translation
The internationalization files of Gopeed are located in the `ui/flutter/lib/i18n/langs` directory.
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
Don't forget to run`dart format ./ui/flutter`before you commit to keep your code in standard dart format
Turn on build_runner watcher if you want to edit api/models:
```
flutter pub run build_runner watch
```
+33
View File
@@ -0,0 +1,33 @@
# Gopeed コントリビューターガイド
まず最初に、Gopeed への貢献に興味を持っていただきありがとうございます。このガイドは、あなたが Gopeed の
開発に参加するための手助けとなるでしょう。
## ブランチの説明
このプロジェクトのメインブランチは `main` ブランチのみです。Gopeed の開発に参加したい場合は、
まずこのプロジェクトをフォークし、フォークしたプロジェクトで開発を行ってください。開発が完了したら、
このプロジェクトに PR を提出し、`main` ブランチにマージしてください。
## ローカル開発
開発およびデバッグはウェブ上で行うことを推奨する。まずバックエンドのサービスを起動し、
コマンドライン `go run cmd/api/main.go` で起動する。サービスのデフォルトポートは `9999` で、
次にフロントエンドの flutter プロジェクトを `debug` モードで起動して実行します。
## 翻訳
Gopeed の国際化ファイルは `ui/flutter/lib/i18n/langs` ディレクトリにあります。
このディレクトリに対応する言語ファイルを追加するだけでよいです。
翻訳については `en_us.dart` を参照してください。
## flutter での開発
コミットする前に `dart format ./ui/flutter` を実行し、コードを標準の dart フォーマットにしておくことを忘れないでください
api/models を編集したい場合は build_runner watcher をオンにします:
```
flutter pub run build_runner watch
```
+29
View File
@@ -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:
+30
View File
@@ -0,0 +1,30 @@
# Gopeed 贡献指南
首先感谢您对贡献代码感兴趣,这份指南将帮助您更好的参与到 Gopeed 的开发中来。
## 分支说明
本项目只有一个主分支,即 `main` 分支,如果您想要参与到 Gopeed 的开发中来,请先 fork 本项目,然后在您的 fork 项目中进行开发,开发完成后再向本项目提交
PR,合并到 `main` 分支。
## 本地开发
建议通过 web 端进行开发调试,首先启动后端服务,通过命令行 `go run cmd/api/main.go` 启动 ,服务启动默认端口为 `9999`,然后以 `debug` 模式启动前端
flutter 项目即可运行。
## 翻译
Gopeed 的国际化文件位于 `ui/flutter/lib/i18n/langs` 目录下,只需要在该目录下添加对应的语言文件即可。
请注意以 `en_us.dart` 为参照进行翻译。
## flutter开发
每次提交前请务必`dart format ./ui/flutter`
如果要编辑api/models,请打开build_runner watcher:
```
flutter pub run build_runner watch
```
+28
View File
@@ -0,0 +1,28 @@
# Gopeed 協助指南
首先感謝您願意幫助我們改進並優化該項目,這份指南將會幫助您更好的參與 Gopeed 的開發。
## 分支說明
本項目只有一個分支,即 `main` 分支,如果您想要參與 Gopeed 的開發,請先 fork 該項目,再在您自己的 fork 中進行開發,開發完成後再開啟PR,以合併至 `main` 分支。
## 離線開發
建議使用 web 端進行開發與調試,首先啟動服務,使用指令 `go run cmd/api/main.go` 啟動 ,該服務默認連接埠為 `9999`,接著以 `debug` 模式啟動前端 flutter 項目即可。
## 翻譯
Gopeed 的翻譯文件位於 `ui/flutter/lib/i18n/langs` 目錄中,只需要修改或新建翻譯文件即可。
請以 `en_us.dart` 作為參照。
## flutter開發
每次提交PR前請務必執行 `dart format ./ui/flutter`
如果需要編輯 api/models,請打開build_runner watcher:
```
flutter pub run build_runner watch
```
+14 -12
View File
@@ -1,21 +1,23 @@
FROM cirrusci/flutter:3.3.7 AS flutter
WORKDIR /app
COPY ./ui/flutter/pubspec.yaml ./ui/flutter/pubspec.lock ./
RUN flutter pub get
COPY ./ui/flutter ./
RUN flutter build web
FROM golang:1.19.3 AS go
FROM golang:1.24.11-alpine3.23 AS go
WORKDIR /app
COPY ./go.mod ./go.sum ./
RUN go mod download
COPY . .
COPY --from=flutter /app/build/web/* ./cmd/web/dist/
RUN go build -tags nosqlite,web -ldflags="-s -w" -o dist/gopeed github.com/monkeyWie/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"]
+213 -44
View File
@@ -1,82 +1,231 @@
[![Test Status](https://github.com/monkeyWie/gopeed/workflows/test/badge.svg)](https://github.com/monkeyWie/gopeed/actions?query=workflow%3Atest)
[![Codecov](https://codecov.io/gh/monkeyWie/gopeed/branch/main/graph/badge.svg)](https://codecov.io/gh/monkeyWie/gopeed)
[![Release](https://img.shields.io/github/release/monkeyWie/gopeed.svg?style=flat-square)](https://github.com/monkeyWie/gopeed/releases)
# [![](_docs/img/banner.svg)](https://gopeed.com)
[![Test Status](https://github.com/GopeedLab/gopeed/workflows/test/badge.svg)](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
[![Codecov](https://codecov.io/gh/GopeedLab/gopeed/branch/main/graph/badge.svg)](https://codecov.io/gh/GopeedLab/gopeed)
[![Release](https://img.shields.io/github/release/GopeedLab/gopeed.svg)](https://github.com/GopeedLab/gopeed/releases)
[![Download](https://img.shields.io/github/downloads/GopeedLab/gopeed/total.svg)](https://github.com/GopeedLab/gopeed/releases)
[![Donate](https://img.shields.io/badge/%24-donate-ff69b4.svg)](https://gopeed.com/docs/donate)
[![WeChat](https://img.shields.io/badge/WeChat%20Official%20Account-07C160?logo=wechat&logoColor=white)](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
[![Discord](https://img.shields.io/discord/1037992631881449472?label=Discord&logo=discord&style=social)](https://discord.gg/ZUJqJrwCGB)
![](_docs/img/banner.png)
<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>
[English](/README.md) | [中文](/README_zh-CN.md)
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/R6R6IJGN6)
## Introduction
[English](/README.md) | [中文](/README_zh-CN.md) | [日本語](/README_ja-JP.md) | [正體中文](/README_zh-TW.md) | [Tiếng Việt](/README_vi-VN.md)
Gopeed is a high-speed downloader developed by `Golang`+`Flutter`, which supports (HTTP, BitTorrent, Magnet) protocol downloads and supports all platforms.
## 🚀 Introduction
## Installation
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).
**Supported platforms**
Visit ✈ [Official Website](https://gopeed.com) | 📖 [Official Docs](https://gopeed.com/docs)
- [x] windows
- [x] macos
- [x] linux
- [x] android
- [ ] ios
- [x] web
- [x] docker
## ⬇️ Download
[To Release](https://github.com/monkeyWie/gopeed/releases/latest)
<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>
> Tips: If the macos open failed, please execute the `xattr -d com.apple.quarantine /Applications/gopeed.app` command in the terminal
More about installation, please refer to [Installation](https://gopeed.com/docs/install)
### Command tool
### 🛠️ Command tool
use `go install`:
```bash
go install github.com/monkeyWie/gopeed/cmd/gopeed
go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
```
### Docker
## 🔌 Browser Extension
```bash
docker run liwei2633/gopeed:latest -v /path/to/download:/download -p 9999:9999
```
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)
When the docker container is running, you can access the web page through `http://localhost:9999`.
> Tip: Modify the download path to `/download` on the setting page to access the downloaded files on the host.
## 📱 WeChat Official Account
## Showcase
Follow our WeChat Official Account to get the latest updates and news.
<img src="_docs/img/weixin.png" width="200" />
## 💝 Donate
If you like this project, please consider [donating](https://gopeed.com/docs/donate) to support the development of this project, thank you!
## 🖼️ Showcase
![](_docs/img/ui-demo.png)
## 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.0+
1. Golang 1.24+
2. Flutter 3.38+
### Clone
### 📋 Clone
```bash
git clone [email protected]:monkeyWie/gopeed.git
git clone [email protected]:GopeedLab/gopeed.git
```
### Build
### 🤝 Contributing
Please refer to [CONTRIBUTING.md](/CONTRIBUTING.md)
### 🏗️ Build
#### Desktop
First, you need to configure the environment according to the [flutter desktop official website document](https://docs.flutter.dev/development/platform-integration/desktop), and then you need to prepare the `cgo` environment, which can be searched for yourself.
First, you need to configure the environment according to the official [Flutter desktop website documention](https://docs.flutter.dev/development/platform-integration/desktop), then you will need to ensure the cgo environment is set up accordingly. For detailed instructions on setting up the cgo environment, please refer to relevant resources available online.
command:
- windows
```bash
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/windows/libgopeed.dll github.com/monkeyWie/gopeed/bind/desktop
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
```
@@ -84,7 +233,7 @@ flutter build windows
- macos
```bash
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/macos/Frameworks/libgopeed.dylib github.com/monkeyWie/gopeed/bind/desktop
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
```
@@ -92,7 +241,7 @@ 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/monkeyWie/gopeed/bind/desktop
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
```
@@ -103,6 +252,7 @@ Same as before, you also need to prepare the `cgo` environment, and then install
```bash
go install golang.org/x/mobile/cmd/gomobile@latest
go get golang.org/x/mobile/bind
gomobile init
```
@@ -111,14 +261,20 @@ 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/monkeyWie/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
```
#### Web
- ios
Web platform communicates directly with the backend http server, no additional environment is required.
```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:
@@ -126,10 +282,23 @@ command:
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/monkeyWie/gopeed/cmd/web
````
go build -tags nosqlite,web -ldflags="-s -w" -o bin/ github.com/GopeedLab/gopeed/cmd/web
```
## License
## ❤️ Credits
### 👥 Contributors
<a href="https://github.com/GopeedLab/gopeed/graphs/contributors">
<img src="https://contrib.rocks/image?repo=GopeedLab/gopeed" />
</a>
### 🏢 JetBrains
[![goland](_docs/img/goland.svg)](https://www.jetbrains.com/?from=gopeed)
## 📄 License
[GPLv3](LICENSE)
+285
View File
@@ -0,0 +1,285 @@
# [![](_docs/img/banner.svg)](https://gopeed.com)
[![Test Status](https://github.com/GopeedLab/gopeed/workflows/test/badge.svg)](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
[![Codecov](https://codecov.io/gh/GopeedLab/gopeed/branch/main/graph/badge.svg)](https://codecov.io/gh/GopeedLab/gopeed)
[![Release](https://img.shields.io/github/release/GopeedLab/gopeed.svg)](https://github.com/GopeedLab/gopeed/releases)
[![Download](https://img.shields.io/github/downloads/GopeedLab/gopeed/total.svg)](https://github.com/GopeedLab/gopeed/releases)
[![Donate](https://img.shields.io/badge/%24-donate-ff69b4.svg)](https://gopeed.com/docs/donate)
[![WeChat](https://img.shields.io/badge/WeChat%20Official%20Account-07C160?logo=wechat&logoColor=white)](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
[![Discord](https://img.shields.io/discord/1037992631881449472?label=Discord&logo=discord&style=social)](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>
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](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)
## 🚀 はじめに
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://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)を参照してください。
### 🛠️ コマンドツール
## 📱 WeChat 公式アカウント
公式アカウントをフォローして、最新のアップデートやニュースを入手してください。
<img src="_docs/img/weixin.png" width="200" />
## 💝 寄付
もしこのプロジェクトがお気に召しましたら、このプロジェクトの発展を支援するために[寄付](https://gopeed.com/docs/donate)をご検討ください!
## 🖼️ ショーケース
![](_docs/img/ui-demo.png)
## 👨‍💻 開発
このプロジェクトは二つの部分に分かれており、フロントエンドでは `flutter` を、バックエンドでは `Golang` を使用し、両者は `http` プロトコルで通信する。ユニックスシステムでは `unix socket` を、ウィンドウズシステムでは `tcp` プロトコルを使用します。
> フロントコードは `ui/flutter` ディレクトリにあります。
### 🌍 環境
1. Go 言語 1.24+
2. Flutter 3.38+
### 📋 クローン
```bash
git clone [email protected]:GopeedLab/gopeed.git
```
### 🤝 コントリビュート
[CONTRIBUTING.md](/CONTRIBUTING_ja-JP.md) をご参照ください
### 🏗️ ビルド
#### デスクトップ
まず、[flutter デスクトップ公式サイトドキュメント](https://docs.flutter.dev/development/platform-integration/desktop)に従って環境を設定し、自分で検索できる `cgo` 環境を用意します。
コマンド:
- 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
```
#### モバイル
先ほどと同じように、`cgo` 環境を準備し、`gomobile` をインストールする必要があります:
```bash
go install golang.org/x/mobile/cmd/gomobile@latest
go get golang.org/x/mobile/bind
gomobile init
```
コマンド:
- 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
コマンド:
```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
```
## ❤️ 感謝
### コントリビューター
<a href="https://github.com/GopeedLab/gopeed/graphs/contributors">
<img src="https://contrib.rocks/image?repo=GopeedLab/gopeed" />
</a>
### JetBrains
[![goland](_docs/img/goland.svg)](https://www.jetbrains.com/?from=gopeed)
## ライセンス
[GPLv3](LICENSE)
+291
View File
@@ -0,0 +1,291 @@
# [![](_docs/img/banner.svg)](https://gopeed.com)
[![Trạng thái kiểm tra](https://github.com/GopeedLab/gopeed/workflows/test/badge.svg)](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
[![Codecov](https://codecov.io/gh/GopeedLab/gopeed/branch/main/graph/badge.svg)](https://codecov.io/gh/GopeedLab/gopeed)
[![Phiên bản](https://img.shields.io/github/release/GopeedLab/gopeed.svg)](https://github.com/GopeedLab/gopeed/releases)
[![Tải về](https://img.shields.io/github/downloads/GopeedLab/gopeed/total.svg)](https://github.com/GopeedLab/gopeed/releases)
[![Ủng hộ](https://img.shields.io/badge/%24-ủng%20hộ-ff69b4.svg)](https://gopeed.com/docs/donate)
[![WeChat](https://img.shields.io/badge/WeChat%20Official%20Account-07C160?logo=wechat&logoColor=white)](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
[![Discord](https://img.shields.io/discord/1037992631881449472?label=Discord&logo=discord&style=social)](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>
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](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
![](_docs/img/ui-demo.png)
## 👨‍💻 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.24+
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
[![goland](_docs/img/goland.svg)](https://www.jetbrains.com/?from=gopeed)
## Giấy phép
[GPLv3](LICENSE)
+202 -42
View File
@@ -1,71 +1,211 @@
[![Test Status](https://github.com/monkeyWie/gopeed/workflows/test/badge.svg)](https://github.com/monkeyWie/gopeed/actions?query=workflow%3Atest)
[![Codecov](https://codecov.io/gh/monkeyWie/gopeed/branch/main/graph/badge.svg)](https://codecov.io/gh/monkeyWie/gopeed)
[![Release](https://img.shields.io/github/release/monkeyWie/gopeed.svg?style=flat-square)](https://github.com/monkeyWie/gopeed/releases)
# [![](_docs/img/banner.svg)](https://gopeed.com)
[![Test Status](https://github.com/GopeedLab/gopeed/workflows/test/badge.svg)](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
[![Codecov](https://codecov.io/gh/GopeedLab/gopeed/branch/main/graph/badge.svg)](https://codecov.io/gh/GopeedLab/gopeed)
[![Release](https://img.shields.io/github/release/GopeedLab/gopeed.svg)](https://github.com/GopeedLab/gopeed/releases)
[![Download](https://img.shields.io/github/downloads/GopeedLab/gopeed/total.svg)](https://github.com/GopeedLab/gopeed/releases)
[![Donate](https://img.shields.io/badge/%24-donate-ff69b4.svg)](https://gopeed.com/docs/donate)
[![WeChat](https://img.shields.io/badge/%E5%BE%AE%E4%BF%A1%E5%85%AC%E4%BC%97%E5%8F%B7-07C160?logo=wechat&logoColor=white)](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
[![Discord](https://img.shields.io/discord/1037992631881449472?label=Discord&logo=discord&style=social)](https://discord.gg/ZUJqJrwCGB)
![](_docs/img/banner.png)
<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>
[English](/README.md) | [中文](/README_zh-CN.md)
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](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)
Gopeed 是一款由`Golang`+`Flutter`开发的高速下载器,支持(HTTP、BitTorrent、Magnet)协议下载,并且支持全平台使用。
## 🚀 介绍
## 下载
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://gopeed.com/docs)
- [x] windows
- [x] macos
- [x] linux
- [x] android
- [ ] ios
- [x] web
- [x] docker
## ⬇️ 下载
[点击前往](https://github.com/monkeyWie/gopeed/releases/latest)
<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)
> 注:macos 版本运行如果提示损坏,请在终端执行 `xattr -d com.apple.quarantine /Applications/gopeed.app` 命令
### 命令行工具
### 🛠️ 命令行工具
使用`go install`安装:
```bash
go install github.com/monkeyWie/gopeed/cmd/gopeed
go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
```
### Docker
## 🔌 浏览器扩展
```bash
docker run liwei2633/gopeed:latest -v /path/to/download:/download -p 9999:9999
```
Gopeed 还提供了浏览器扩展用于接管浏览器下载,支持 Chrome、Edge、Firefox 等浏览器,具体请参考:[https://github.com/GopeedLab/browser-extension](https://github.com/GopeedLab/browser-extension)
当 docker 容器运行时,可以通过 `http://localhost:9999` 访问 web 页面。
> 提示:在设置页面把下载路径修改为 `/download` 以便在宿主机访问下载完的文件。
## 📱 微信公众号
## 界面展示
关注公众号获取项目最新动态和资讯。
<img src="_docs/img/weixin.png" width="200" />
## 💝 赞助
如果觉得项目对你有帮助,请考虑[赞助](https://gopeed.com/docs/donate)以支持这个项目的发展,非常感谢!
## 🖼️ 界面展示
![](_docs/img/ui-demo.png)
## 开发
## 👨‍💻 开发
本项目分为前端和后端两个部分,前端使用`flutter`,后端使用`Golang`,两边通过`http`协议进行通讯,在 unix 系统下,使用的是`unix socket`,在 windows 系统下,使用的是`tcp`协议。
> 前端代码位于`ui/flutter`目录下。
### 环境要求
### 🌍 环境要求
1. Golang 1.19+
2. Flutter 3.0+
1. Golang 1.24+
2. Flutter 3.38+
### 克隆项目
### 📋 克隆项目
```bash
git clone [email protected]:monkeyWie/gopeed.git
git clone [email protected]:GopeedLab/gopeed.git
```
### 编译
### 🤝 贡献代码
请参考[贡献指南](CONTRIBUTING_zh-CN.md)
### 🏗️ 编译
#### 桌面端
@@ -76,7 +216,7 @@ git clone [email protected]:monkeyWie/gopeed.git
- windows
```bash
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/windows/libgopeed.dll github.com/monkeyWie/gopeed/bind/desktop
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
```
@@ -84,7 +224,7 @@ flutter build windows
- macos
```bash
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/macos/Frameworks/libgopeed.dylib github.com/monkeyWie/gopeed/bind/desktop
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
```
@@ -92,7 +232,7 @@ 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/monkeyWie/gopeed/bind/desktop
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
```
@@ -103,6 +243,7 @@ flutter build linux
```bash
go install golang.org/x/mobile/cmd/gomobile@latest
go get golang.org/x/mobile/bind
gomobile init
```
@@ -111,14 +252,20 @@ 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/monkeyWie/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
```
#### Web 端(推荐本地调试使用)
- ios
Web 端直接与后端 http 服务通讯,不需要额外准备环境。
```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 端
构建命令:
@@ -126,10 +273,23 @@ Web 端直接与后端 http 服务通讯,不需要额外准备环境。
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/monkeyWie/gopeed/cmd/web
go build -tags nosqlite,web -ldflags="-s -w" -o bin/ github.com/GopeedLab/gopeed/cmd/web
```
## ❤️ 感谢
### 贡献者
<a href="https://github.com/GopeedLab/gopeed/graphs/contributors">
<img src="https://contrib.rocks/image?repo=GopeedLab/gopeed" />
</a>
### JetBrains
[![goland](_docs/img/goland.svg)](https://www.jetbrains.com/?from=gopeed)
## 开源许可
基于 [GPLv3](LICENSE) 协议开源。
+292
View File
@@ -0,0 +1,292 @@
# [![](_docs/img/banner.svg)](https://gopeed.com)
[![Test Status](https://github.com/GopeedLab/gopeed/workflows/test/badge.svg)](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
[![Codecov](https://codecov.io/gh/GopeedLab/gopeed/branch/main/graph/badge.svg)](https://codecov.io/gh/GopeedLab/gopeed)
[![Release](https://img.shields.io/github/release/GopeedLab/gopeed.svg)](https://github.com/GopeedLab/gopeed/releases)
[![Download](https://img.shields.io/github/downloads/GopeedLab/gopeed/total.svg)](https://github.com/GopeedLab/gopeed/releases)
[![Donate](https://img.shields.io/badge/%24-donate-ff69b4.svg)](https://gopeed.com/docs/donate)
[![WeChat](https://img.shields.io/badge/%E5%BE%AE%E4%BF%A1%E5%85%AC%E4%BC%97%E5%8F%B7-07C160?logo=wechat&logoColor=white)](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
[![Discord](https://img.shields.io/discord/1037992631881449472?label=Discord&logo=discord&style=social)](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>
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](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)
## 🚀 簡介
Gopeed(全稱 Go Speed),是一款使用`Golang`+`Flutter`編寫的高速下載軟體,支援(HTTP、BitTorrent、Magnet、ED2K)協定,同時支援所有的平台。
前往 ✈ [主頁](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)
### 🛠️ 使用 CLI 安裝
使用`go install`安裝:
```bash
go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
```
## 📱 微信公眾號
關注公眾號獲取項目最新動態和資訊。
<img src="_docs/img/weixin.png" width="200" />
## 💝 贊助
如果你認為該項目對你有所幫助,請考慮[贊助](https://gopeed.com/docs/donate)以支持該項目的持續發展,謝謝!
## 🖼️ 軟體介面
![](_docs/img/ui-demo.png)
## 👨‍💻 開發
該項目分為前端與後端,前端使用`flutter`編寫,後端使用`Golang`編寫,兩邊通過`http`協定進行通訊,在 unix 系統下,則使用`unix socket`,在 windows 系統下,則使用`tcp`協定。
> 前端代碼位於`ui/flutter`目錄內。
### 🌍 開發環境
1. Golang 1.24+
2. Flutter 3.38+
### 📋 克隆項目
```bash
git clone [email protected]:GopeedLab/gopeed.git
```
### 🤝 協助開發
請參考[協助指南](CONTRIBUTING_zh-TW.md)
### 🏗️ 編譯
#### 桌面端
首先需要按照[flutter desktop 官方文檔](https://docs.flutter.dev/development/platform-integration/desktop)配置開發環境,並準備好`cgo`環境,具體方法可以自行搜索。
組建指令:
- 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
```
#### 移動設備
需要`cgo`環境,並安裝`gomobile`:
```bash
go install golang.org/x/mobile/cmd/gomobile@latest
go get golang.org/x/mobile/bind
gomobile init
```
組建指令:
- 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
```
#### 網頁端
組建指令:
```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
```
## ❤️ 感謝
### 貢獻者
<a href="https://github.com/GopeedLab/gopeed/graphs/contributors">
<img src="https://contrib.rocks/image?repo=GopeedLab/gopeed" />
</a>
### JetBrains
[![goland](_docs/img/goland.svg)](https://www.jetbrains.com/?from=gopeed)
## 軟體許可
該軟體遵循 [GPLv3](LICENSE) 。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

+54
View File
@@ -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

+1
View File
@@ -0,0 +1 @@
<svg height="105" viewBox="0 0 105 105" width="105" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><linearGradient id="a" gradientUnits="userSpaceOnUse" x1="99.03" x2="61.54" y1="59.18" y2="90.91"><stop offset="0" stop-color="#087cfa"/><stop offset=".02" stop-color="#0d7bfa"/><stop offset=".37" stop-color="#5566f9"/><stop offset=".66" stop-color="#8a57f8"/><stop offset=".88" stop-color="#ab4ef7"/><stop offset="1" stop-color="#b74af7"/></linearGradient><linearGradient id="b" x1="37.54" x2="60.33" xlink:href="#a" y1="34.26" y2="8.33"/><linearGradient id="c" gradientUnits="userSpaceOnUse" x1="17.84" x2="87.09" y1="88.36" y2="43.86"><stop offset="0" stop-color="#087cfa"/><stop offset=".1" stop-color="#1598d3"/><stop offset=".22" stop-color="#23b6aa"/><stop offset=".35" stop-color="#2dcc8b"/><stop offset=".46" stop-color="#35dd74"/><stop offset=".57" stop-color="#39e767"/><stop offset=".67" stop-color="#3bea62"/></linearGradient><path d="m89.06 41.63 11.44 25.37-19.58 33.5-12.06-29.28z" fill="url(#a)"/><path d="m68.86 71.22 12.06 29.28-30.34-10.43z" fill="#b74af7"/><path d="m72.13 31.61-6.22-27.11h-34.49l-26.92 41.18 7.79 18.92-7.79 17.27 55.75-47.43z" fill="url(#b)"/><path d="m100.5 24.88-40.25 9.56-55.75 47.43 35.96 18.63 28.4-29.28z" fill="url(#c)"/><path d="m22.5 22.5h60v60h-60z"/><g fill="#fff"><path d="m28.13 71.25h22.5v3.75h-22.5z"/><path d="m26.44 42.81v-.07a12.7 12.7 0 0 1 12.97-12.74 13.62 13.62 0 0 1 9.73 3.39l-3.43 4.14a9.13 9.13 0 0 0 -6.47-2.53c-4 0-7.11 3.5-7.11 7.7v.07c0 4.53 3.11 7.85 7.5 7.85a8.67 8.67 0 0 0 5.12-1.49v-3.56h-5.48v-4.7h10.73v10.74a16.07 16.07 0 0 1 -10.57 3.93c-7.69 0-12.99-5.41-12.99-12.73z"/><path d="m52.3 42.81v-.07a12.79 12.79 0 0 1 13.19-12.74c7.63 0 13.11 5.69 13.11 12.72v.07a12.79 12.79 0 0 1 -13.18 12.8c-7.64-.05-13.12-5.74-13.12-12.78zm20.61 0v-.07a7.51 7.51 0 0 0 -7.49-7.74 7.4 7.4 0 0 0 -7.42 7.67v.07a7.52 7.52 0 0 0 7.5 7.78 7.4 7.4 0 0 0 7.41-7.71z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.9 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

+7 -3
View File
@@ -2,8 +2,10 @@ package main
import (
"fmt"
"github.com/monkeyWie/gopeed/pkg/base"
"github.com/monkeyWie/gopeed/pkg/download"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/download"
"github.com/GopeedLab/gopeed/pkg/protocol/http"
)
func main() {
@@ -16,7 +18,9 @@ func main() {
}
}).
Create(&base.Options{
Connections: 8,
Extra: http.OptsExtra{
Connections: 8,
},
})
if err != nil {
panic(err)
+3 -2
View File
@@ -3,8 +3,8 @@ package main
import "C"
import (
"encoding/json"
"github.com/monkeyWie/gopeed/pkg/rest"
"github.com/monkeyWie/gopeed/pkg/rest/model"
"github.com/GopeedLab/gopeed/pkg/rest"
"github.com/GopeedLab/gopeed/pkg/rest/model"
)
func main() {}
@@ -15,6 +15,7 @@ func Start(cfg *C.char) (int, *C.char) {
if err := json.Unmarshal([]byte(C.GoString(cfg)), &config); err != nil {
return 0, C.CString(err.Error())
}
config.ProductionMode = true
realPort, err := rest.Start(&config)
if err != nil {
return 0, C.CString(err.Error())
+3 -2
View File
@@ -4,8 +4,8 @@ package libgopeed
import "C"
import (
"encoding/json"
"github.com/monkeyWie/gopeed/pkg/rest"
"github.com/monkeyWie/gopeed/pkg/rest/model"
"github.com/GopeedLab/gopeed/pkg/rest"
"github.com/GopeedLab/gopeed/pkg/rest/model"
)
func Start(cfg string) (int, error) {
@@ -13,6 +13,7 @@ func Start(cfg string) (int, error) {
if err := json.Unmarshal([]byte(cfg), &config); err != nil {
return 0, err
}
config.ProductionMode = true
return rest.Start(&config)
}
+17
View File
@@ -0,0 +1,17 @@
package main
import (
"github.com/GopeedLab/gopeed/cmd"
"github.com/GopeedLab/gopeed/pkg/rest/model"
)
// only for local development
func main() {
cfg := &model.StartConfig{
Network: "tcp",
Address: "127.0.0.1:9999",
Storage: model.StorageBolt,
WebEnable: true,
}
cmd.Start(cfg)
}
File renamed without changes.
+1 -1
View File
@@ -25,7 +25,7 @@ func parse() *args {
if len(t) > 0 {
args.url = t[0]
} else {
gPrintln("missing url parameter")
gPrintln("missing url parameter, for example: gopeed https://www.google.com or gopeed bt.torrent or gopeed magnet:?xt=urn:btih:...")
gPrintln("try 'gopeed -h' for more information")
os.Exit(1)
}
+26 -8
View File
@@ -2,11 +2,14 @@ package main
import (
"fmt"
"github.com/monkeyWie/gopeed/pkg/base"
"github.com/monkeyWie/gopeed/pkg/download"
"github.com/monkeyWie/gopeed/pkg/util"
"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"
)
const progressWidth = 20
@@ -15,6 +18,7 @@ func main() {
args := parse()
var wg sync.WaitGroup
wg.Add(1)
_, err := download.Boot().
URL(args.url).
Listener(func(event *download.Event) {
@@ -39,26 +43,40 @@ func main() {
}
}).
Create(&base.Options{
Path: *args.dir,
Connections: *args.connections,
Path: *args.dir,
Extra: http.OptsExtra{Connections: *args.connections},
})
if err != nil {
panic(err)
}
wg.Add(1)
printProgress(emptyTask, "downloading...")
wg.Wait()
}
var (
lastLineLen = 0
sb = new(strings.Builder)
emptyTask = &download.Task{
Progress: &download.Progress{},
Meta: &fetcher.FetcherMeta{
Res: &base.Resource{},
},
}
)
func printProgress(task *download.Task, title string) {
rate := float64(task.Progress.Downloaded) / float64(task.Res.Size)
var rate float64
if task.Meta.Res == nil {
task = emptyTask
}
if task.Meta.Res.Size <= 0 {
rate = 0
} else {
rate = float64(task.Progress.Downloaded) / float64(task.Meta.Res.Size)
}
completeWidth := int(progressWidth * rate)
speed := util.ByteFmt(task.Progress.Speed)
totalSize := util.ByteFmt(task.Res.Size)
totalSize := util.ByteFmt(task.Meta.Res.Size)
sb.WriteString(fmt.Sprintf("\r%s [", title))
for i := 0; i < progressWidth; i++ {
if i < completeWidth {
+19
View File
@@ -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"))
}
+11
View File
@@ -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)
}
+210
View File
@@ -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)
}
+72
View File
@@ -0,0 +1,72 @@
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
var banner string
func Start(cfg *model.StartConfig) {
fmt.Println(banner)
srv, listener, err := rest.BuildServer(cfg)
if err != nil {
panic(err)
}
downloadCfg, err := rest.Downloader.GetConfig()
if err != nil {
panic(err)
}
if downloadCfg.FirstLoad {
// 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)
}()
}
+115
View File
@@ -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()
}
+72
View File
@@ -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)
}
+72
View File
@@ -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)
}
+134
View File
@@ -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
}
+199
View File
@@ -0,0 +1,199 @@
package main
import (
"encoding/json"
"flag"
"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"`
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 {
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()
// 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
}
return cfg
}
// 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
}
})
}
// setDefaults sets default values for any unset configuration fields
func setDefaults(cfg *args, cliConfig *args) {
if cfg.Address == nil {
cfg.Address = cliConfig.Address
}
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
}
return
}
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))
}
}
}
}
File diff suppressed because it is too large. Load diff
+42 -20
View File
@@ -6,14 +6,13 @@ package main
import (
"embed"
"fmt"
"github.com/monkeyWie/gopeed/pkg/rest"
"github.com/monkeyWie/gopeed/pkg/rest/model"
"io/fs"
"net/http"
)
"os"
"path/filepath"
//go:embed banner.txt
var banner string
"github.com/GopeedLab/gopeed/cmd"
"github.com/GopeedLab/gopeed/pkg/rest/model"
)
//go:embed dist/*
var dist embed.FS
@@ -23,20 +22,43 @@ func main() {
if err != nil {
panic(err)
}
args := parse()
var webAuth *model.WebAuth
if isNotBlank(args.Username) && isNotBlank(args.Password) {
webAuth = &model.WebAuth{
Username: *args.Username,
Password: *args.Password,
}
}
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")
}
cfg := &model.StartConfig{
Network: "tcp",
Address: "127.0.0.1:9999",
Storage: model.StorageBolt,
WebEnable: true,
WebFS: sub,
}
fmt.Println(banner)
srv, listener, err := rest.BuildServer(cfg)
if err != nil {
panic(err)
}
fmt.Printf("Server start success on http://%s\n", listener.Addr().String())
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
panic(err)
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)
}
func isNotBlank(str *string) bool {
return str != nil && *str != ""
}
+15
View File
@@ -0,0 +1,15 @@
services:
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
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
chown -R ${PUID}:${PGID} /app
umask ${UMASK}
exec su-exec ${PUID}:${PGID} ./gopeed "$@"
+148 -59
View File
@@ -1,76 +1,165 @@
module github.com/monkeyWie/gopeed
module github.com/GopeedLab/gopeed
go 1.19
go 1.24.9
toolchain go1.24.11
require (
github.com/anacrolix/torrent v1.46.0
github.com/anacrolix/torrent v1.60.1-0.20251217073903-486bcbe758e0
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
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
go.etcd.io/bbolt v1.3.6
golang.org/x/sync v0.0.0-20220819030929-7fc1605a5dde
github.com/mattn/go-ieproxy v0.0.12
github.com/mholt/archives v0.1.5
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/xiaoqidun/setft v0.0.0-20220310121541-be86327699ad
go.etcd.io/bbolt v1.4.3
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93
)
require (
crawshaw.io/sqlite v0.3.3-0.20210127221821-98b1f83c5508 // indirect
github.com/RoaringBitmap/roaring v1.0.1-0.20220510143707-3f418c4f42a4 // indirect
github.com/ajwerner/btree v0.0.0-20211221152037-f427b3e689c0 // indirect
github.com/anacrolix/chansync v0.3.0 // indirect
github.com/anacrolix/dht/v2 v2.16.2-0.20220311024416-dd658f18fd51 // indirect
github.com/anacrolix/envpprof v1.2.1 // indirect
github.com/anacrolix/generics v0.0.0-20220618083756-f99e35403a60 // indirect
github.com/anacrolix/go-libutp v1.2.0 // indirect
github.com/anacrolix/log v0.13.1 // indirect
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/monkeyWie/goed2k v0.0.0-20260317100435-7a7575cf2447 // 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 (
dario.cat/mergo v1.0.0 // 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.9.4 // indirect
github.com/acomagu/bufpipe v1.0.4 // indirect
github.com/alecthomas/atomic v0.1.0-alpha2 // 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/missinggo/v2 v2.7.0 // indirect
github.com/anacrolix/mmsg v1.0.0 // indirect
github.com/anacrolix/multiless v0.2.1-0.20211218050420-533661eef5dc // indirect
github.com/anacrolix/stm v0.3.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.0 // 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/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/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/google/btree v1.0.1 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/gorilla/handlers v1.5.1 // indirect
github.com/gorilla/websocket v1.4.2 // indirect
github.com/huandu/xstrings v1.3.2 // indirect
github.com/lispad/go-generics-tools v1.0.0 // 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-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.4+incompatible // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // 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/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/mattn/go-colorable v0.1.13 // 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.1.2 // indirect
github.com/pion/ice/v2 v2.1.20 // indirect
github.com/pion/interceptor v0.1.7 // 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.4 // indirect
github.com/pion/sctp v1.8.2 // indirect
github.com/pion/sdp/v3 v3.0.4 // indirect
github.com/pion/srtp/v2 v2.0.5 // indirect
github.com/pion/stun v0.3.5 // indirect
github.com/pion/transport v0.13.0 // indirect
github.com/pion/turn/v2 v2.0.6 // indirect
github.com/pion/udp v0.1.1 // indirect
github.com/pion/webrtc/v3 v3.1.24-0.20220208053747-94262c1b2b38 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rs/dnscache v0.0.0-20210201191234-295bba877686 // indirect
github.com/tidwall/btree v1.3.1 // indirect
golang.org/x/crypto v0.0.0-20220131195533-30dcbda58838 // indirect
golang.org/x/exp v0.0.0-20220428152302-39d4317da171 // indirect
golang.org/x/mobile v0.0.0-20220928052126-fa6bcb076835 // indirect
golang.org/x/mod v0.6.0-dev.0.20211013180041-c96bc1413d57 // indirect
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd // indirect
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e // indirect
golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac // indirect
golang.org/x/tools v0.1.8-0.20211029000441-d6a9af8af023 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // 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/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/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.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
)
+584 -168
View File
File diff suppressed because it is too large. Load diff
+22 -8
View File
@@ -1,30 +1,44 @@
package controller
import (
"errors"
"github.com/GopeedLab/gopeed/pkg/base"
"net/http"
"net/url"
"os"
"path/filepath"
)
type Controller interface {
Touch(name string, size int64) (file *os.File, err error)
type Controller struct {
GetConfig func(v any)
GetProxy func(requestProxy *base.RequestProxy) func(*http.Request) (*url.URL, error)
FileController
//ContextDialer() (proxy.Dialer, error)
}
type DefaultController struct {
type FileController interface {
Touch(name string, size int64) (file *os.File, err error)
}
func NewController() *DefaultController {
return &DefaultController{}
type DefaultFileController struct {
}
func (c *DefaultController) Touch(name string, size int64) (file *os.File, err error) {
func NewController() *Controller {
return &Controller{
GetConfig: func(v any) {},
GetProxy: func(requestProxy *base.RequestProxy) func(*http.Request) (*url.URL, error) {
return requestProxy.ToHandler()
},
FileController: &DefaultFileController{},
}
}
func (c *DefaultFileController) Touch(name string, size int64) (file *os.File, err error) {
dir := filepath.Dir(name)
if err = os.MkdirAll(dir, os.ModePerm); err != nil {
return
}
file, err = os.Create(name)
if err != nil && !errors.Is(err, os.ErrExist) {
if err != nil {
return
}
if size > 0 {
+135 -25
View File
@@ -1,44 +1,154 @@
package fetcher
import (
"github.com/monkeyWie/gopeed/internal/controller"
"github.com/monkeyWie/gopeed/pkg/base"
"path"
"strings"
"github.com/GopeedLab/gopeed/internal/controller"
"github.com/GopeedLab/gopeed/pkg/base"
)
// 对应协议的下载支持
// Fetcher defines the interface for a download protocol.
// Each download task will have a corresponding Fetcher instance for the management of the download task
type Fetcher interface {
Setup(ctl controller.Controller) error
// 解析请求
Resolve(req *base.Request) (res *base.Resource, err error)
// 创建任务
Create(res *base.Resource, opts *base.Options) (err error)
Start() (err error)
Pause() (err error)
Continue() (err error)
Close() (err error)
Setup(ctl *controller.Controller)
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.
Progress() Progress
// 该方法会一直阻塞,直到任务下载结束
Wait() (err error)
// Wait for the download to complete, this method will block until the download is done.
Wait() error
}
type FetcherBuilder interface {
Schemes() []string
Build() Fetcher
type Uploader interface {
Upload() error
UploadedBytes() int64
WaitUpload() error
}
// Store 存储任务
// FetcherMeta defines the meta information of a fetcher.
type FetcherMeta struct {
Req *base.Request `json:"req"`
Res *base.Resource `json:"res"`
Opts *base.Options `json:"opts"`
}
// FolderPath return the folder path of the meta info.
func (m *FetcherMeta) FolderPath() string {
// check if rename folder
folder := m.Res.Name
if m.Opts.Name != "" {
folder = m.Opts.Name
}
return path.Join(m.Opts.Path, folder)
}
// SingleFilepath return the single file path of the meta info.
func (m *FetcherMeta) SingleFilepath() string {
// check if rename file
file := m.Res.Files[0]
fileName := file.Name
if m.Opts.Name != "" {
fileName = m.Opts.Name
}
return path.Join(m.Opts.Path, file.Path, fileName)
}
// RootDirPath return the root dir path of the task file.
func (m *FetcherMeta) RootDirPath() string {
if m.Res.Name != "" {
return m.FolderPath()
} else {
return m.Opts.Path
}
}
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 恢复任务
Restore() (v any, f func(res *base.Resource, opts *base.Options, v any) Fetcher)
// 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 {
Ctl controller.Controller
Ctl *controller.Controller
Meta *FetcherMeta
DoneCh chan error
}
func (f *DefaultFetcher) Setup(ctl controller.Controller) (err error) {
func (f *DefaultFetcher) Setup(ctl *controller.Controller) (err error) {
f.Ctl = ctl
f.DoneCh = make(chan error, 1)
return
@@ -48,10 +158,10 @@ func (f *DefaultFetcher) Wait() (err error) {
return <-f.DoneCh
}
// 获取任务中各个文件的已下载字节数
// Progress is a map of the progress of each file in the torrent.
type Progress []int64
// TotalDownloaded 获取任务总下载字节数
// TotalDownloaded returns the total downloaded bytes.
func (p Progress) TotalDownloaded() int64 {
total := int64(0)
for _, d := range p {
+97
View File
@@ -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)
}
})
}
}
+52
View File
@@ -0,0 +1,52 @@
package logger
import (
"github.com/GopeedLab/gopeed/pkg/util"
"github.com/rs/zerolog"
"io"
"os"
"path/filepath"
)
type Logger struct {
zerolog.Logger
logFile *os.File
}
func (l *Logger) CLose() {
l.logFile.Close()
}
// NewLogger create a new logger
func NewLogger(logFile bool, logPath string) *Logger {
var out io.Writer
if logFile {
// log to file
logDir := filepath.Dir(logPath)
if err := util.CreateDirIfNotExist(logDir); err != nil {
panic(err)
}
var (
logfile *os.File
err error
)
logfile, err = os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
panic(err)
}
out = logfile
} else {
out = os.Stdout
}
logger := &Logger{}
if logFile {
logger.logFile = out.(*os.File)
}
logger.Logger = zerolog.New(zerolog.ConsoleWriter{
NoColor: true,
Out: out,
TimeFormat: "2006-01-02 15:04:05",
}).With().Timestamp().Logger()
return logger
}
+35
View File
@@ -0,0 +1,35 @@
package logger
import (
"os"
"testing"
)
func TestNewLogger(t *testing.T) {
logger := NewLogger(false, "")
logger.Info().Msg("test")
}
func TestNewLoggerFile(t *testing.T) {
logPath := "./testdata/test.log"
logger := NewLogger(true, logPath)
defer func() {
logger.CLose()
os.Remove(logPath)
}()
logger.Info().Msg("test")
// read log file, if not exist or empty, test fail.
file, err := os.Open(logPath)
if err != nil {
t.Fatal(err)
}
defer file.Close()
stat, err := file.Stat()
if err != nil {
t.Fatal(err)
}
if stat.Size() == 0 {
t.Fatal("log file is empty")
}
}
+12
View File
@@ -0,0 +1,12 @@
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)
}
}
}
+544 -149
View File
@@ -1,222 +1,617 @@
package bt
import (
"github.com/anacrolix/torrent"
"github.com/anacrolix/torrent/metainfo"
"github.com/anacrolix/torrent/storage"
"github.com/monkeyWie/gopeed/internal/controller"
"github.com/monkeyWie/gopeed/internal/fetcher"
"github.com/monkeyWie/gopeed/pkg/base"
"github.com/monkeyWie/gopeed/pkg/util"
"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"
"github.com/GopeedLab/gopeed/pkg/protocol/bt"
"github.com/GopeedLab/gopeed/pkg/util"
"github.com/anacrolix/torrent"
"github.com/anacrolix/torrent/metainfo"
"github.com/anacrolix/torrent/storage"
)
var client *torrent.Client
var (
cfg *torrent.ClientConfig
client *torrent.Client
lock sync.Mutex
closeCtx context.Context
closeFunc func()
)
type Fetcher struct {
Ctl controller.Controller
ctl *controller.Controller
config *config
torrent *torrent.Torrent
res *base.Resource
opts *base.Options
meta *fetcher.FetcherMeta
data *fetcherData
ready atomic.Bool
progress fetcher.Progress
torrentPaths map[string]string
torrentReady atomic.Bool
torrentUpload atomic.Bool
torrentDropCtx context.Context
torrentDropFunc func()
uploadDoneCh chan any
}
func (f *Fetcher) Setup(ctl controller.Controller) (err error) {
f.Ctl = ctl
var once sync.Once
once.Do(func() {
cfg := torrent.NewDefaultClientConfig()
cfg.ListenPort = 0
// Support custom download path for each torrent
pieceCompletion, err := storage.NewDefaultPieceCompletionForDir(cfg.DataDir)
if err != nil {
pieceCompletion = storage.NewMapPieceCompletion()
}
clientImplCloser := storage.NewFileOpts(storage.NewFileClientOpts{
ClientBaseDir: cfg.DataDir,
TorrentDirMaker: func(baseDir string, info *metainfo.Info, infoHash metainfo.Hash) string {
if dir, ok := f.torrentPaths[infoHash.String()]; ok {
return dir
}
return baseDir
},
PieceCompletion: pieceCompletion,
})
cfg.DefaultStorage = clientImplCloser
client, err = torrent.NewClient(cfg)
if err != nil {
return
}
f.torrentPaths = make(map[string]string)
})
func (f *Fetcher) Setup(ctl *controller.Controller) {
f.ctl = ctl
if f.meta == nil {
f.meta = &fetcher.FetcherMeta{}
}
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
}
func (f *Fetcher) Wait() (err error) {
for {
if f.ready.Load() {
done := true
for _, selectIndex := range f.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.opts.SelectFiles {
if i == selectIndex {
selected = true
break
}
}
if !selected {
util.SafeRemove(filepath.Join(f.opts.Path, file.Path()))
}
}
break
}
}
time.Sleep(time.Millisecond * 500)
}
return nil
}
func (f *Fetcher) initClient() (err error) {
lock.Lock()
defer lock.Unlock()
func (f *Fetcher) Resolve(req *base.Request) (res *base.Resource, err error) {
if err = f.addTorrent(req.URL, true); err != nil {
if client != nil {
return
}
defer f.torrent.Drop()
res = &base.Resource{
Req: req,
Name: f.torrent.Name(),
Range: true,
Files: make([]*base.FileInfo, len(f.torrent.Files())),
Extra: map[string]any{
"infoHash": f.torrent.InfoHash().String(),
},
if closeCtx == nil {
closeCtx, closeFunc = context.WithCancel(context.Background())
}
for i, file := range f.torrent.Files() {
res.Files[i] = &base.FileInfo{
Name: filepath.Base(file.DisplayPath()),
Path: util.Dir(file.Path()),
Size: file.Length(),
}
res.Size += file.Length()
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) Create(res *base.Resource, opts *base.Options) (err error) {
f.res = res
f.opts = opts
if len(f.opts.SelectFiles) == 0 {
f.opts.SelectFiles = make([]int, 0)
for i := range f.torrent.Files() {
f.opts.SelectFiles = append(f.opts.SelectFiles, i)
}
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 f.opts.Path != "" {
if extra, ok := res.Extra.(map[string]any); ok {
f.torrentPaths[extra["infoHash"].(string)] = f.opts.Path
}
if err := f.addTorrent(req, false); err != nil {
return err
}
f.progress = make(fetcher.Progress, len(f.opts.SelectFiles))
f.ready.Store(false)
f.updateRes()
return nil
}
func (f *Fetcher) Start() (err error) {
if err = f.addTorrent(f.res.Req.URL, false); err != nil {
return
if !f.torrentReady.Load() {
if err = f.addTorrent(f.meta.Req, false); err != nil {
return
}
}
files := f.torrent.Files()
if len(f.opts.SelectFiles) == len(files) {
// If the user does not specify the file to download, all files will be downloaded by default
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))
}
if len(f.meta.Opts.SelectFiles) == len(files) {
f.torrent.DownloadAll()
} else {
for _, selectIndex := range f.opts.SelectFiles {
for _, selectIndex := range f.meta.Opts.SelectFiles {
file := files[selectIndex]
file.Download()
}
}
f.torrent.AllowDataDownload()
return
}
func (f *Fetcher) Pause() (err error) {
f.ready.Store(false)
f.torrent.Drop()
f.torrent.DisallowDataDownload()
return
}
func (f *Fetcher) Continue() (err error) {
return f.Start()
}
func (f *Fetcher) Close() (err error) {
f.torrent.Drop()
f.safeDrop()
f.torrentDropFunc()
f.uploadDoneCh <- nil
if len(client.Torrents()) == 0 {
err = closeClient()
}
return nil
}
func (f *Fetcher) Progress() fetcher.Progress {
if !f.ready.Load() {
return f.progress
}
for i := range f.progress {
selectIndex := f.opts.SelectFiles[i]
file := f.torrent.Files()[selectIndex]
f.progress[i] = file.BytesCompleted()
}
return f.progress
func (f *Fetcher) safeDrop() {
defer func() {
// ignore panic
_ = recover()
}()
f.torrent.Drop()
}
func (f *Fetcher) addTorrent(url string, resolve bool) (err error) {
schema := util.ParseSchema(url)
if schema == "MAGNET" {
f.torrent, err = client.AddMagnet(url)
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 {
f.torrent, err = client.AddTorrentFromFile(url)
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.data.Progress
}
for i := range f.data.Progress {
selectIndex := f.meta.Opts.SelectFiles[i]
file := f.torrent.Files()[selectIndex]
f.data.Progress[i] = file.BytesCompleted()
}
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{
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.DisplayPath()),
Size: file.Length(),
}
}
res.CalcSize(nil)
f.meta.Res = res
if f.meta.Opts != nil {
f.meta.Opts.InitSelectFiles(len(res.Files))
}
}
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" {
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
}
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
}
<-f.torrent.GotInfo()
if resolve {
return
// 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(trackers) > 0 {
announceList := make([][]string, 0)
for tracker := range trackers {
announceList = append(announceList, []string{tracker})
}
f.torrent.AddTrackers(announceList)
}
}
f.ready.Store(true)
<-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"}
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(res *base.Resource, opts *base.Options, v any) fetcher.Fetcher) {
return nil, nil
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
}
+338 -14
View File
@@ -1,25 +1,42 @@
package bt
import (
"github.com/monkeyWie/gopeed/internal/controller"
"github.com/monkeyWie/gopeed/internal/fetcher"
"github.com/monkeyWie/gopeed/pkg/base"
"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"
)
func TestFetcher_Resolve_Torrent(t *testing.T) {
doResolve(t, buildFetcher())
}
func TestFetcher_Resolve_DataUri_Torrent(t *testing.T) {
fetcher := buildFetcher()
res, err := fetcher.Resolve(&base.Request{
URL: "./testdata/ubuntu-22.04-live-server-amd64.iso.torrent",
})
buf, err := os.ReadFile("./testdata/ubuntu-22.04-live-server-amd64.iso.torrent")
if err != nil {
t.Fatal(err)
}
// convert to data uri
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{
Req: res.Req,
Name: "ubuntu-22.04-live-server-amd64.iso",
Size: 1466714112,
Range: true,
Files: []*base.FileInfo{
@@ -28,17 +45,324 @@ func TestFetcher_Resolve_Torrent(t *testing.T) {
Size: 1466714112,
},
},
Extra: map[string]any{
"infoHash": "8a55cfbd5ca5d11507364765936c4f9e55b253ed",
Hash: "8a55cfbd5ca5d11507364765936c4f9e55b253ed",
}
if !reflect.DeepEqual(want, fetcher.Meta().Res) {
t.Errorf("Resolve() got = %v, want %v", fetcher.Meta().Res, want)
}
}
func TestFetcher_Config(t *testing.T) {
doResolve(t, buildConfigFetcher(nil))
}
func TestFetcher_ResolveWithProxy(t *testing.T) {
usr, pwd := "admin", "123"
proxyListener := test.StartSocks5Server(usr, pwd)
defer proxyListener.Close()
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) {
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",
},
},
}, nil)
if err != nil {
panic(err)
}
want := &base.Resource{
Size: 1466714112,
Range: true,
Files: []*base.FileInfo{
{
Name: "ubuntu-22.04-live-server-amd64.iso",
Size: 1466714112,
},
},
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
}
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: "",
},
}
if !reflect.DeepEqual(want, res) {
t.Errorf("Resolve() got = %v, want %v", res, 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(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) {
json.Unmarshal([]byte(test.ToJson(mockCfg)), v)
}
newController.GetProxy = func(requestProxy *base.RequestProxy) func(*gohttp.Request) (*url.URL, error) {
return proxyConfig.ToHandler()
}
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))
}
})
}
Binary file not shown.
Binary file not shown.
+15
View File
@@ -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"`
}
+505
View File
@@ -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)
}()
}
}
+42
View File
@@ -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)
}
}
+7
View File
@@ -0,0 +1,7 @@
package http
type config struct {
UserAgent string `json:"userAgent"`
Connections int `json:"connections"`
UseServerCtime bool `json:"useServerCtime"`
}
File diff suppressed because it is too large. Load diff
+105
View File
@@ -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 &amp; and then truncated at the semicolon
func TestParseFilenameWithAmpersand(t *testing.T) {
tests := []struct {
name string
disposition string
want string
}{
{
name: "quoted filename with &amp;",
disposition: `attachment; filename="查询处理&amp;优化.pptx"`,
want: "查询处理&优化.pptx",
},
{
name: "unquoted filename with &amp;",
disposition: `attachment; filename=test&amp;file.txt`,
want: "test&file.txt",
},
{
name: "quoted filename with &amp; and extra params",
disposition: `attachment; filename="test&amp;file.txt"; charset=utf-8`,
want: "test&file.txt",
},
{
name: "filename with multiple HTML entities",
disposition: `attachment; filename="test&amp;&lt;&gt;.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&amp;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&amp;file.txt",
want: "test&file.txt",
},
{
name: "less than and greater than",
in: "&lt;test&gt;.txt",
want: "<test>.txt",
},
{
name: "quote",
in: "test&quot;file.txt",
want: "test\"file.txt",
},
{
name: "multiple entities",
in: "a&amp;b&lt;c&gt;d&quot;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)
}
})
}
}
+474
View File
@@ -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., &amp; -> &, &lt; -> <, &gt; -> >)
// 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 &amp;
func unescapeHTMLEntities(s string) string {
// Common HTML entities that might appear in filenames
replacements := map[string]string{
"&amp;": "&",
"&lt;": "<",
"&gt;": ">",
"&quot;": "\"",
"&#39;": "'",
"&apos;": "'",
}
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., &amp; 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., &amp; &lt; &gt; &quot; &#39;)
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 &amp; &lt; &#39; 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
}
-24
View File
@@ -1,24 +0,0 @@
package http
import "github.com/monkeyWie/gopeed/pkg/base"
type chunk struct {
Status base.Status
Begin int64
End int64
Downloaded int64
}
func newChunk(begin int64, end int64) *chunk {
return &chunk{
Status: base.DownloadStatusReady,
Begin: begin,
End: end,
}
}
type extra struct {
Method string
Header map[string]string
Body string
}
+40
View File
@@ -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
}
+834 -35
View File
@@ -1,14 +1,24 @@
package test
import (
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"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 (
@@ -17,12 +27,23 @@ const (
Dir = "./"
BuildFile = Dir + BuildName
DownloadName = "download.data"
DownloadFile = Dir + DownloadName
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))
})
}
@@ -38,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)),
@@ -46,9 +67,12 @@ func StartTestSlowFileServer(delay time.Duration) net.Listener {
})
}
func StartTestChunkedServer() net.Listener {
return startTestServer(func() http.Handler {
func StartTestCustomServer() net.Listener {
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 {
@@ -57,17 +81,183 @@ func StartTestChunkedServer() net.Listener {
defer file.Close()
io.Copy(writer, file)
})
mux.HandleFunc("/disposition", func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Content-Disposition", "attachment; filename=\""+BuildName+"\"")
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("/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 (&amp;)
// This tests the case from the bug report where filenames containing & are
// HTML-encoded as &amp; by the server, causing truncation at the semicolon.
// Example: "查询处理&优化.pptx" -> "查询处理&amp;优化.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="查询处理&amp;优化.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&amp;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++
if counter != 1 && counter < 5 {
if counter != 1 && counter < 2 {
writer.WriteHeader(500)
return
}
@@ -83,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") != "" {
@@ -106,21 +296,512 @@ 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
})
}
func startTestServer(serverHandle func() http.Handler) 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)
})
})
}
// StartTestSlowStartServer creates a server with configurable delay per request
// This allows testing slow-start connection expansion to reach max connections
func StartTestSlowStartServer(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
}
}
}
// StartTestLimitServer connections limit server
func StartTestLimitServer(maxConnections int32, delay int64) net.Listener {
var connections atomic.Int32
var slowOnce atomic.Bool
return startTestServer(func(sl *shutdownListener) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
defer func() {
connections.Add(-1)
}()
connections.Add(1)
if maxConnections != 0 && connections.Load() > maxConnections {
writer.WriteHeader(403)
return
}
// First request intentionally delays the first write to trigger a single read timeout,
// subsequent requests respond at normal speed.
useInitialDelay := delay > 0 && !slowOnce.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
})
}
// 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, 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(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])
if nw > 0 {
written += int64(nw)
}
if ew != nil {
err = ew
break
}
if nr != nw {
err = io.ErrShortWrite
break
}
}
if er != nil {
if er != io.EOF {
err = er
}
break
}
if delay > 0 {
time.Sleep(time.Millisecond * time.Duration(delay))
}
}
return written, err
}
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
}
if written < n && err == nil {
// src stopped early; must have been EOF.
err = io.EOF
}
return
}
// 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)
@@ -129,7 +810,8 @@ func startTestServer(serverHandle func() http.Handler) net.Listener {
if err != nil {
panic(err)
}
// 随机生成一个文件
defer file.Close()
// Write random data
l := int64(8192)
buf := make([]byte, l)
size := int64(0)
@@ -146,31 +828,36 @@ func startTestServer(serverHandle func() http.Handler) net.Listener {
size += l
}
server := &http.Server{}
server.Handler = serverHandle()
go server.Serve(listener)
return &delFileListener{
File: file,
sl := &shutdownListener{
server: server,
Listener: listener,
}
server.Handler = serverHandle(sl)
go server.Serve(listener)
return sl
}
type delFileListener struct {
*os.File
type shutdownListener struct {
server *http.Server
isShutdown bool
net.Listener
}
func (c *delFileListener) Close() error {
defer func() {
c.File.Close()
if err := ifExistAndRemove(c.File.Name()); err != nil {
fmt.Println(err)
}
if err := ifExistAndRemove(DownloadFile); err != nil {
fmt.Println(err)
}
}()
return c.Listener.Close()
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)
}
if err := ifExistAndRemove(DownloadFile); err != nil {
fmt.Println(err)
}
if err := ifExistAndRemove(DownloadRenameFile); err != nil {
fmt.Println(err)
}
return closeErr
}
func ifExistAndRemove(name string) error {
@@ -179,3 +866,115 @@ func ifExistAndRemove(name string) error {
}
return nil
}
func StartSocks5Server(usr, pwd string) net.Listener {
conf := &socks5.Config{}
if usr != "" && pwd != "" {
conf.Credentials = socks5.StaticCredentials{
usr: pwd,
}
}
server, err := socks5.New(conf)
if err != nil {
panic(err)
}
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)
}
+4
View File
@@ -49,3 +49,7 @@ func ToJson(v interface{}) string {
buf, _ := json.Marshal(v)
return string(buf)
}
func JsonEqual(v1 any, v2 any) bool {
return ToJson(v1) == ToJson(v2)
}
+7 -1
View File
@@ -3,9 +3,10 @@ package base
type Status string
const (
DownloadStatusReady Status = "ready"
DownloadStatusReady Status = "ready" // task create but not start
DownloadStatusRunning Status = "running"
DownloadStatusPause Status = "pause"
DownloadStatusWait Status = "wait" // task is wait for running
DownloadStatusError Status = "error"
DownloadStatusDone Status = "done"
)
@@ -14,10 +15,15 @@ const (
HttpCodeOK = 200
HttpCodePartialContent = 206
HttpHeaderHost = "Host"
HttpHeaderRange = "Range"
HttpHeaderAcceptRanges = "Accept-Ranges"
HttpHeaderContentLength = "Content-Length"
HttpHeaderContentRange = "Content-Range"
HttpHeaderContentDisposition = "Content-Disposition"
HttpHeaderUserAgent = "User-Agent"
HttpHeaderLastModified = "Last-Modified"
HttpHeaderBytes = "bytes"
HttpHeaderRangeFormat = "bytes=%d-%d"
)
+11
View File
@@ -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"
}
}
+334 -23
View File
@@ -1,42 +1,353 @@
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 interface{} `json:"extra"`
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"`
}
// 资源信息
func (r *Request) Validate() error {
if r.URL == "" {
return fmt.Errorf("invalid request url")
}
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 {
Req *Request `json:"req"`
// 资源名称
// if name is not empty, the resource is a folder and the name is the folder name
Name string `json:"name"`
// 资源总大小
Size int64 `json:"size"`
// 是否支持断点下载
Size int64 `json:"size"`
// is support range download
Range bool `json:"range"`
// 资源所包含的文件列表
// file list
Files []*FileInfo `json:"files"`
// 附加信息
Extra interface{} `json:"extra"`
Hash string `json:"hash"`
}
func (r *Resource) Validate() error {
if r.Name == "" {
return fmt.Errorf("invalid resource name")
}
if len(r.Files) == 0 {
return fmt.Errorf("invalid resource files")
}
for _, file := range r.Files {
if file.Name == "" {
return fmt.Errorf("invalid resource file name")
}
}
return nil
}
func (r *Resource) CalcSize(selectFiles []int) {
var size int64
for i, file := range r.Files {
if len(selectFiles) == 0 || slices.Contains(selectFiles, i) {
size += file.Size
}
}
r.Size = size
}
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"`
}
// 下载选项
// Options for download
type Options struct {
// 保存文件名
// Download file name
Name string `json:"name"`
// 保存目录
// Download file path
Path string `json:"path"`
// 并发连接数
Connections int `json:"connections"`
// 选择下载的文件下标列表
// Select file indexes to download
SelectFiles []int `json:"selectFiles"`
// Extra info for specific fetcher
Extra any `json:"extra"`
}
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 := range fileSize {
o.SelectFiles[i] = i
}
}
}
func (o *Options) Clone() *Options {
return util.DeepClone(o)
}
func ParseReqExtra[E any](req *Request) error {
if req.Extra == nil {
return nil
}
if _, ok := req.Extra.(*E); ok {
return nil
}
var t E
if err := util.MapToStruct(req.Extra, &t); err != nil {
return err
}
req.Extra = &t
return nil
}
func ParseOptExtra[E any](opts *Options) error {
if opts.Extra == nil {
return nil
}
if _, ok := opts.Extra.(*E); ok {
return nil
}
var t E
if err := util.MapToStruct(opts.Extra, &t); err != nil {
return err
}
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
}
+437
View File
@@ -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)
}
})
}
}
+1608 -292
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+194
View File
@@ -0,0 +1,194 @@
package engine
import (
_ "embed"
"errors"
"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/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"
"time"
)
//go:embed polyfill/out/index.js
var polyfillScript string
type Engine struct {
loop *eventloop.EventLoop
Runtime *goja.Runtime
}
// 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)
}
})
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)
}
}()
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))
}
result, err = fn(nil, jsArgs...)
}
if err == nil {
go e.await(result)
}
})
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
}
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 {
return
}
// check promise state every 100 milliseconds, until it is resolved
for {
time.Sleep(time.Millisecond * 100)
if p.State() == goja.PromiseStatePending {
continue
}
break
}
// stop the event loop
e.loop.StopNoWait()
}
}
}
func (e *Engine) Close() {
e.loop.StopNoWait()
}
type Config struct {
ProxyConfig *base.DownloaderProxyConfig
}
func NewEngine(cfg *Config) *Engine {
if cfg == nil {
cfg = &Config{}
}
loop := eventloop.NewEventLoop()
engine := &Engine{
loop: loop,
}
loop.Run(func(runtime *goja.Runtime) {
engine.Runtime = runtime
runtime.SetFieldNameMapper(goja.TagFieldNameMapper("json", true))
vm.Enable(runtime)
gojaurl.Enable(runtime)
if err := gojaerror.Enable(runtime); err != nil {
return
}
if err := file.Enable(runtime); err != nil {
return
}
if err := formdata.Enable(runtime); err != nil {
return
}
if err := xhr.Enable(runtime, cfg.ProxyConfig.ToHandler()); err != nil {
return
}
if _, err := runtime.RunString(polyfillScript); err != nil {
return
}
// polyfill global
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
}
return
})
return engine
}
func Run(script string) (value any, err error) {
engine := NewEngine(nil)
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 {
stack := p.Result().String()
result := p.Result()
if ro, ok := result.(*goja.Object); ok {
stackVal := ro.Get("stack")
if stackVal != nil && stackVal.String() != "" {
stack = stackVal.String()
}
}
return nil, errors.New(stack)
}
}
}
return export, nil
}
+555
View File
@@ -0,0 +1,555 @@
package engine
import (
"crypto/md5"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"strconv"
"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, "FormData")
doTestPolyfill(t, "TextDecoder")
doTestPolyfill(t, "TextEncoder")
doTestPolyfill(t, "fetch")
doTestPolyfill(t, "__gopeed_create_vm")
}
func TestError(t *testing.T) {
engine := NewEngine(nil)
_, err := engine.RunString(`
throw new MessageError('test');
`)
if me, ok := gojautil.AssertError[*gojaerror.MessageError](err); !ok {
t.Fatalf("expect MessageError, but got %v", me)
}
}
func TestFetch(t *testing.T) {
server := startServer()
defer server.Close()
engine := NewEngine(nil)
if _, err := engine.RunString(fmt.Sprintf("var host = 'http://%s';", server.Addr().String())); err != nil {
t.Fatal(err)
}
_, err := engine.RunString(`
async function testGet(){
const resp = await fetch(host+'/get');
return resp.status;
}
async function testText(){
const resp = await fetch(host+'/text',{
method: 'POST',
body: 'test'
});
return await resp.text();
}
async function testOctetStream(file){
const resp = await fetch(host+'/octetStream',{
method: 'POST',
body: 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');
formData.append('f', file);
const resp = await fetch(host+'/formData',{
method: 'POST',
body: formData
});
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();
xhr.open('GET', host+'/get');
const xhrUploadPromise = new Promise((resolve, reject) => {
xhr.upload.onprogress = function(e){
if(e.loaded === e.total){
resolve();
}
}
});
const xhrPromise = new Promise((resolve, reject) => {
xhr.onprogress = function(e){
if(e.loaded === e.total){
resolve();
}
}
});
Promise.all([xhrUploadPromise, xhrPromise]).then(() => {
resolve();
});
xhr.send();
setTimeout(() => {
reject('timeout');
}, 1000);
});
}
function testAbort(){
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', host+'/timeout?duration=500');
xhr.onabort = function() {
resolve();
};
xhr.send();
setTimeout(() => {
xhr.abort();
}, 200);
setTimeout(() => {
reject('timeout');
}, 1000);
});
}
function testTimeout(){
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const t = 500;
xhr.open('GET', host+'/timeout?duration='+t);
xhr.timeout = t - 200;
xhr.onload = function() {
resolve();
};
xhr.ontimeout = function() {
reject('timeout');
};
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)
}
result, err := callTestFun(engine, "testGet")
if err != nil {
t.Fatal(err)
}
if result != int64(200) {
t.Fatalf("testGet failed, want %d, got %d", 200, result)
}
result, err = callTestFun(engine, "testText")
if err != nil {
t.Fatal(err)
}
if result != "test" {
t.Fatalf("testText failed, want %s, got %s", "test", result)
}
func() {
jsFile, _, md5 := buildFile(t, engine.Runtime)
result, err = callTestFun(engine, "testOctetStream", jsFile)
if err != nil {
t.Fatal(err)
}
if result != md5 {
t.Fatalf("testOctetStream failed, want %s, got %s", md5, result)
}
}()
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)
if err != nil {
t.Fatal(err)
}
want := map[string]any{
"name": "test",
"f": map[string]string{
"filename": goFile.Name,
"md5": md5,
},
}
if !test.JsonEqual(result, want) {
t.Fatalf("testFormData failed, want %v, got %v", want, result)
}
}()
_, 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", err)
}
_, err = callTestFun(engine, "testAbort")
if err != nil {
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) {
doTestFetchWithProxy(t, "", "")
doTestFetchWithProxy(t, "admin", "123")
}
func doTestFetchWithProxy(t *testing.T, usr, pwd string) {
httpListener := startServer()
defer httpListener.Close()
proxyListener := test.StartSocks5Server(usr, pwd)
defer proxyListener.Close()
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)
}
respCode, err := engine.RunString(`
(async function(){
const resp = await fetch(host+'/get');
return resp.status;
})()
`)
if err != nil {
t.Fatal(err)
}
if respCode != int64(200) {
t.Fatalf("fetch with proxy failed, want %d, got %d", 200, respCode)
}
}
func TestVm(t *testing.T) {
engine := NewEngine(nil)
value, err := engine.RunString(`
const vm = __gopeed_create_vm()
vm.set('a', 1)
vm.set('b', 2)
const result = vm.runString('a=a+1;b=b+1;a+b;')
const out = {
"a": vm.get('a'),
"b": vm.get('b'),
"result": result
}
out
`)
if err != nil {
t.Fatal(err)
}
want := map[string]any{
"a": 2,
"b": 3,
"result": 5,
}
if !test.JsonEqual(value, want) {
t.Fatalf("vm test failed, want %v, got %v", want, value)
}
}
func TestNonStopLoop(t *testing.T) {
engine := NewEngine(nil)
_, err := engine.RunString(`
function leak(){
setInterval(() => {
},500)
}
function test(){
leak()
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve('done')
}, 1000)
})
}
`)
if err != nil {
t.Fatal(err)
}
val, err := callTestFun(engine, "test")
if err != nil {
panic(err)
}
if val != "done" {
t.Fatalf("infinite loop test failed, want %s, got %s", "done", val)
}
}
func doTestPolyfill(t *testing.T, module string) {
value, err := Run(fmt.Sprintf(`
!!globalThis['%s']
`, module))
if err != nil {
t.Fatal(err)
}
if !value.(bool) {
t.Fatalf("module %s not polyfilled", module)
}
}
func startServer() net.Listener {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
panic(err)
}
server := &http.Server{}
mux := http.NewServeMux()
mux.HandleFunc("/get", func(w http.ResponseWriter, r *http.Request) {
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("/octetStream", func(w http.ResponseWriter, r *http.Request) {
md5 := calcMd5(r.Body)
w.WriteHeader(http.StatusOK)
w.Write([]byte(md5))
})
mux.HandleFunc("/formData", func(w http.ResponseWriter, r *http.Request) {
err := r.ParseMultipartForm(1024 * 1024 * 30)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(err.Error()))
return
}
result := make(map[string]any)
for k, v := range r.MultipartForm.Value {
result[k] = v[0]
}
for k, v := range r.MultipartForm.File {
f, _ := v[0].Open()
result[k] = map[string]string{
"filename": v[0].Filename,
"md5": calcMd5(f),
}
}
w.WriteHeader(http.StatusOK)
buf, _ := json.Marshal(result)
w.Write(buf)
})
mux.HandleFunc("/timeout", func(w http.ResponseWriter, r *http.Request) {
duration := r.URL.Query().Get("duration")
t, _ := strconv.Atoi(duration)
time.Sleep(time.Duration(t) * time.Millisecond)
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
}
func buildFile(t *testing.T, runtime *goja.Runtime) (goja.Value, *file.File, string) {
jsFile, err := file.NewJsFile(runtime)
if err != nil {
t.Fatal(err)
}
f := jsFile.Export().(*file.File)
data := "test"
f.Reader = strings.NewReader(data)
f.Name = "test.txt"
f.Size = int64(len(data))
return jsFile, f, calcMd5(strings.NewReader(data))
}
func callTestFun(engine *Engine, fun string, args ...any) (any, error) {
test, ok := goja.AssertFunction(engine.Runtime.Get(fun))
if !ok {
return nil, errors.New("function not found:" + fun)
}
return engine.CallFunction(test, args...)
}
func calcMd5(reader io.Reader) string {
// Open a new hash interface to write to
hash := md5.New()
// Copy the file in the hash interface and check for any error
if _, err := io.Copy(hash, reader); err != nil {
return ""
}
return hex.EncodeToString(hash.Sum(nil))
}
@@ -0,0 +1,29 @@
package error
import (
"github.com/dop251/goja"
)
type MessageError struct {
Message string `json:"message"`
}
func (e *MessageError) Error() string {
return e.Message
}
func Enable(runtime *goja.Runtime) error {
messageError := runtime.ToValue(func(call goja.ConstructorCall) *goja.Object {
var message string
if len(call.Arguments) > 0 {
message = call.Arguments[0].String()
}
instance := &MessageError{
Message: message,
}
instanceValue := runtime.ToValue(instance).(*goja.Object)
instanceValue.SetPrototype(call.This.Prototype())
return instanceValue
})
return runtime.Set("MessageError", messageError)
}
+32
View File
@@ -0,0 +1,32 @@
package file
import (
"errors"
"github.com/dop251/goja"
"io"
)
type File struct {
io.Reader `json:""`
io.Closer `json:""`
Name string `json:"name"`
Size int64 `json:"size"`
}
func NewJsFile(runtime *goja.Runtime) (goja.Value, error) {
fileCtor, ok := goja.AssertConstructor(runtime.Get("File"))
if !ok {
return nil, errors.New("file is not defined")
}
return fileCtor(nil)
}
func Enable(runtime *goja.Runtime) error {
file := runtime.ToValue(func(call goja.ConstructorCall) *goja.Object {
instance := &File{}
instanceValue := runtime.ToValue(instance).(*goja.Object)
instanceValue.SetPrototype(call.This.Prototype())
return instanceValue
})
return runtime.Set("File", file)
}
@@ -0,0 +1,68 @@
package formdata
import "github.com/dop251/goja"
type FormData struct {
data map[string]any
}
func (fd *FormData) Append(name string, value any) {
fd.data[name] = value
}
func (fd *FormData) Delete(name string) {
delete(fd.data, name)
}
func (fd *FormData) Entries() []any {
var entries []any
for k, v := range fd.data {
entries = append(entries, []any{k, v})
}
return entries
}
func (fd *FormData) Get(name string) any {
return fd.data[name]
}
func (fd *FormData) GetAll(name string) []any {
return []any{fd.data[name]}
}
func (fd *FormData) Has(name string) bool {
_, ok := fd.data[name]
return ok
}
func (fd *FormData) Keys() []string {
var keys []string
for k := range fd.data {
keys = append(keys, k)
}
return keys
}
func (fd *FormData) Set(name string, value any) {
fd.data[name] = value
}
func (fd *FormData) Values() []any {
var values []any
for _, v := range fd.data {
values = append(values, v)
}
return values
}
func Enable(runtime *goja.Runtime) error {
file := runtime.ToValue(func(call goja.ConstructorCall) *goja.Object {
instance := &FormData{
data: make(map[string]any),
}
instanceValue := runtime.ToValue(instance).(*goja.Object)
instanceValue.SetPrototype(call.This.Prototype())
return instanceValue
})
return runtime.Set("FormData", file)
}
+44
View File
@@ -0,0 +1,44 @@
package vm
import (
"github.com/dop251/goja"
"github.com/dop251/goja_nodejs/eventloop"
)
type Vm struct {
loop *eventloop.EventLoop
}
func (vm *Vm) Set(name string, value any) {
vm.loop.Run(func(runtime *goja.Runtime) {
runtime.Set(name, value)
})
}
func (vm *Vm) Get(name string) (value any) {
vm.loop.Run(func(runtime *goja.Runtime) {
value = runtime.Get(name)
})
return
}
func (vm *Vm) RunString(script string) (value any, err error) {
defer func() {
if r := recover(); r != nil {
err = r.(error)
}
}()
vm.loop.Run(func(runtime *goja.Runtime) {
value, err = runtime.RunString(script)
})
return
}
func Enable(runtime *goja.Runtime) error {
return runtime.Set("__gopeed_create_vm", func(call goja.FunctionCall) goja.Value {
return runtime.ToValue(&Vm{
loop: eventloop.NewEventLoop(),
})
})
}
+482
View File
@@ -0,0 +1,482 @@
package xhr
import (
"bytes"
"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 (
eventLoad = "load"
eventReadystatechange = "readystatechange"
eventProgress = "progress"
eventAbort = "abort"
eventError = "error"
eventTimeout = "timeout"
)
const (
redirectError = "error"
redirectFollow = "follow"
redirectManual = "manual"
)
type ProgressEvent struct {
Type string `json:"type"`
LengthComputable bool `json:"lengthComputable"`
Loaded int64 `json:"loaded"`
Total int64 `json:"total"`
}
type EventProp struct {
eventListeners map[string]func(event *ProgressEvent)
Onload func(event *ProgressEvent) `json:"onload"`
Onprogress func(event *ProgressEvent) `json:"onprogress"`
Onabort func(event *ProgressEvent) `json:"onabort"`
Onerror func(event *ProgressEvent) `json:"onerror"`
Ontimeout func(event *ProgressEvent) `json:"ontimeout"`
}
func (ep *EventProp) AddEventListener(event string, cb func(event *ProgressEvent)) {
ep.eventListeners[event] = cb
}
func (ep *EventProp) RemoveEventListener(event string) {
delete(ep.eventListeners, event)
}
func (ep *EventProp) callOnload() {
event := &ProgressEvent{
Type: eventLoad,
LengthComputable: false,
}
if ep.Onload != nil {
ep.Onload(event)
}
ep.callEventListener(event)
}
func (ep *EventProp) callOnprogress(loaded, total int64) {
event := &ProgressEvent{
Type: eventProgress,
LengthComputable: true,
Loaded: loaded,
Total: total,
}
if ep.Onprogress != nil {
ep.Onprogress(event)
}
ep.callEventListener(event)
}
func (ep *EventProp) callOnabort() {
event := &ProgressEvent{
Type: eventAbort,
LengthComputable: false,
}
if ep.Onabort != nil {
ep.Onabort(event)
}
ep.callEventListener(event)
}
func (ep *EventProp) callOnerror() {
event := &ProgressEvent{
Type: eventError,
LengthComputable: false,
}
if ep.Onerror != nil {
ep.Onerror(event)
}
ep.callEventListener(event)
}
func (ep *EventProp) callOntimeout() {
event := &ProgressEvent{
Type: eventTimeout,
LengthComputable: false,
}
if ep.Ontimeout != nil {
ep.Ontimeout(event)
}
ep.callEventListener(event)
}
func (ep *EventProp) callEventListener(event *ProgressEvent) {
if cb, ok := ep.eventListeners[event.Type]; ok {
cb(event)
}
}
type XMLHttpRequestUpload struct {
*EventProp
}
type XMLHttpRequest struct {
method string
url string
requestHeaders http.Header
responseHeaders http.Header
aborted bool
client *req.Client
fingerprint string
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"`
}
func (xhr *XMLHttpRequest) Open(method, url string) {
xhr.method = method
xhr.url = url
xhr.requestHeaders = make(http.Header)
xhr.responseHeaders = make(http.Header)
xhr.doReadystatechange(1)
}
func (xhr *XMLHttpRequest) SetRequestHeader(key, value string) {
xhr.requestHeaders.Add(key, value)
}
func (xhr *XMLHttpRequest) Send(data goja.Value) {
setFingerprint(xhr.client, xhr.fingerprint)
d := xhr.parseData(data)
var (
contentType string
contentLength int64
isStringBody bool
)
// 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:
reqBuilder.SetBody(v)
contentType = "text/plain;charset=UTF-8"
contentLength = int64(len(v))
isStringBody = true
case *file.File:
reqBuilder.SetBody(v.Reader)
contentType = "application/octet-stream"
contentLength = v.Size
case *formdata.FormData:
pr, pw := io.Pipe()
mw := NewMultipart(pw)
for _, e := range v.Entries() {
arr := e.([]any)
k := arr[0].(string)
v := arr[1]
switch v := v.(type) {
case string:
mw.WriteField(k, v)
case *file.File:
mw.WriteFile(k, v)
}
}
go func() {
defer pw.Close()
defer mw.Close()
mw.Send()
}()
reqBuilder.SetBody(pr)
contentType = mw.FormDataContentType()
contentLength = mw.Size()
}
}
// Only string body can specify Content-Type header by user
if contentType != "" && (!isStringBody || xhr.requestHeaders.Get("Content-Type") == "") {
reqBuilder.SetHeader("Content-Type", contentType)
}
// Set timeout
if xhr.Timeout > 0 {
xhr.client.SetTimeout(time.Duration(xhr.Timeout) * time.Millisecond)
}
// 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
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() {
if xhr.Timeout > 0 {
xhr.Upload.callOntimeout()
xhr.callOntimeout()
}
return
}
xhr.Upload.callOnerror()
xhr.callOnerror()
return
}
xhr.Upload.callOnprogress(contentLength, contentLength)
if !xhr.aborted {
xhr.Upload.callOnload()
}
// 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)
bodyBytes := resp.Bytes()
xhr.doReadystatechange(3)
xhr.Response = string(bodyBytes)
xhr.ResponseText = xhr.Response
xhr.doReadystatechange(4)
respBodyLen := int64(len(bodyBytes))
xhr.callOnprogress(respBodyLen, respBodyLen)
if !xhr.aborted {
xhr.callOnload()
}
}
func (xhr *XMLHttpRequest) Abort() {
xhr.doReadystatechange(0)
xhr.aborted = true
xhr.Upload.callOnabort()
xhr.callOnabort()
}
func (xhr *XMLHttpRequest) GetResponseHeader(key string) string {
if xhr.responseHeaders == nil {
return ""
}
return strings.Join(xhr.responseHeaders.Values(key), ", ")
}
func (xhr *XMLHttpRequest) GetAllResponseHeaders() string {
var buf bytes.Buffer
for k, v := range xhr.responseHeaders {
buf.WriteString(k)
buf.WriteString(": ")
buf.WriteString(strings.Join(v, ", "))
buf.WriteString("\r\n")
}
return buf.String()
}
func (xhr *XMLHttpRequest) callOnreadystatechange() {
event := &ProgressEvent{
Type: eventReadystatechange,
LengthComputable: false,
}
if xhr.Onreadystatechange != nil {
xhr.Onreadystatechange(event)
}
xhr.callEventListener(event)
}
func (xhr *XMLHttpRequest) doReadystatechange(state int) {
if xhr.aborted {
return
}
xhr.ReadyState = state
xhr.callOnreadystatechange()
}
// parse js data to go struct
func (xhr *XMLHttpRequest) parseData(data goja.Value) any {
// check if data is null or undefined
if data == nil || goja.IsNull(data) || goja.IsUndefined(data) || goja.IsNaN(data) {
return nil
}
// check if data is File
f, ok := data.Export().(*file.File)
if ok {
return f
}
// check if data is FormData
fd, ok := data.Export().(*formdata.FormData)
if ok {
return fd
}
// otherwise, return data as string
return data.String()
}
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.")
}
instance := &ProgressEvent{
Type: call.Argument(0).String(),
}
instanceValue := runtime.ToValue(instance).(*goja.Object)
instanceValue.SetPrototype(call.This.Prototype())
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{
client: client,
fingerprint: util.SafeGet[string](runtime, FingerprintMagicKey),
Upload: &XMLHttpRequestUpload{
EventProp: &EventProp{
eventListeners: make(map[string]func(event *ProgressEvent)),
},
},
EventProp: &EventProp{
eventListeners: make(map[string]func(event *ProgressEvent)),
},
}
instanceValue := runtime.ToValue(instance).(*goja.Object)
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
}
if err := runtime.Set("XMLHttpRequest", xhr); err != nil {
return err
}
return nil
}
// Wrap multipart.Writer and stat content length
type multipartWrapper struct {
statBuffer *bytes.Buffer
statWriter *multipart.Writer
writer *multipart.Writer
fields map[string]any
}
func NewMultipart(w io.Writer) *multipartWrapper {
var buf bytes.Buffer
return &multipartWrapper{
statBuffer: &buf,
statWriter: multipart.NewWriter(&buf),
writer: multipart.NewWriter(w),
fields: make(map[string]any),
}
}
func (w *multipartWrapper) WriteField(fieldname string, value string) error {
w.fields[fieldname] = value
return w.statWriter.WriteField(fieldname, value)
}
func (w *multipartWrapper) WriteFile(fieldname string, file *file.File) error {
w.fields[fieldname] = file
_, err := w.statWriter.CreateFormFile(fieldname, file.Name)
if err != nil {
return err
}
return nil
}
func (w *multipartWrapper) Size() int64 {
w.statWriter.Close()
size := int64(w.statBuffer.Len())
for _, v := range w.fields {
switch v := v.(type) {
case *file.File:
size += v.Size
}
}
return size
}
func (w *multipartWrapper) Send() error {
for k, v := range w.fields {
switch v := v.(type) {
case string:
if err := w.writer.WriteField(k, v); err != nil {
return err
}
case *file.File:
fw, err := w.writer.CreateFormFile(k, v.Name)
if err != nil {
return err
}
if _, err = io.Copy(fw, v); err != nil {
return err
}
}
}
return nil
}
func (w *multipartWrapper) FormDataContentType() string {
return w.writer.FormDataContentType()
}
func (w *multipartWrapper) Close() error {
return w.writer.Close()
}
@@ -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()
}
}
@@ -0,0 +1 @@
(()=>{var e={725:function(e,t,r){var o,n,i;i="undefined"!=typeof self&&self||"undefined"!=typeof window&&window||void 0!==r.g&&r.g||this,o=function(e){"use strict";var t=i.BlobBuilder||i.WebKitBlobBuilder||i.MSBlobBuilder||i.MozBlobBuilder,r=i.URL||i.webkitURL||function(e,t){return(t=document.createElement("a")).href=e,t},o=i.Blob,n=r.createObjectURL,a=r.revokeObjectURL,s=i.Symbol&&i.Symbol.toStringTag,u=!1,f=!1,l=t&&t.prototype.append&&t.prototype.getBlob;try{u=2===new Blob(["ä"]).size,f=2===new Blob([new Uint8Array([1,2])]).size}catch(e){}function c(e){return e.map((function(e){if(e.buffer instanceof ArrayBuffer){var t=e.buffer;if(e.byteLength!==t.byteLength){var r=new Uint8Array(e.byteLength);r.set(new Uint8Array(t,e.byteOffset,e.byteLength)),t=r.buffer}return t}return e}))}function h(e,r){r=r||{};var o=new t;return c(e).forEach((function(e){o.append(e)})),r.type?o.getBlob(r.type):o.getBlob()}function d(e,t){return new o(c(e),t||{})}i.Blob&&(h.prototype=Blob.prototype,d.prototype=Blob.prototype);var p="function"==typeof TextEncoder?TextEncoder.prototype.encode.bind(new TextEncoder):function(e){for(var t=0,r=e.length,o=i.Uint8Array||Array,n=0,a=Math.max(32,r+(r>>1)+7),s=new o(a>>3<<3);t<r;){var u=e.charCodeAt(t++);if(u>=55296&&u<=56319){if(t<r){var f=e.charCodeAt(t);56320==(64512&f)&&(++t,u=((1023&u)<<10)+(1023&f)+65536)}if(u>=55296&&u<=56319)continue}if(n+4>s.length){a+=8,a=(a*=1+t/e.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)},y="function"==typeof TextDecoder?TextDecoder.prototype.decode.bind(new TextDecoder):function(e){for(var t=e.length,r=[],o=0;o<t;){var n,i,a,s,u=e[o],f=null,l=u>239?4:u>223?3:u>191?2:1;if(o+l<=t)switch(l){case 1:u<128&&(f=u);break;case 2:128==(192&(n=e[o+1]))&&(s=(31&u)<<6|63&n)>127&&(f=s);break;case 3:n=e[o+1],i=e[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=e[o+1],i=e[o+2],a=e[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="",d=0;d<c;)h+=String.fromCharCode.apply(String,r.slice(d,d+=4096));return h};function b(){var t=!!i.ActiveXObject||"-ms-scroll-limit"in document.documentElement.style&&"-ms-ime-align"in document.documentElement.style,r=i.XMLHttpRequest&&i.XMLHttpRequest.prototype.send;t&&r&&(XMLHttpRequest.prototype.send=function(e){e instanceof Blob?(this.setRequestHeader("Content-Type",e.type),r.call(this,e)):r.call(this,e)});try{new File([],""),e.File=i.File,e.FileReader=i.FileReader}catch(t){try{e.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(t){e.File=function(e,t,r){var o=new Blob(e,r),n=r&&void 0!==r.lastModified?new Date(r.lastModified):new Date;return o.name=t.replace(/\//g,":"),o.lastModifiedDate=n,o.lastModified=+n,o.toString=function(){return"[object File]"},s&&(o[s]="File"),o}}}}u?(b(),e.Blob=f?i.Blob:d):l?(b(),e.Blob=h):function(){function t(e){for(var t=new Array(e.byteLength),r=new Uint8Array(e),o=t.length;o--;)t[o]=r[o];return t}function o(e){for(var t="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",r=[],o=0;o<e.length;o+=3){var n=e[o],i=o+1<e.length,a=i?e[o+1]:0,s=o+2<e.length,u=s?e[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(t[f],t[l],t[c],t[h])}return r.join("")}var s=Object.create||function(e){function t(){}return t.prototype=e,new t};function u(e){return Object.prototype.toString.call(e).slice(8,-1)}function f(e,t){return"object"==typeof e&&Object.prototype.isPrototypeOf.call(e.prototype,t)}var l=["Int8Array","Uint8Array","Uint8ClampedArray","Int16Array","Uint16Array","Int32Array","Uint32Array","Float32Array","Float64Array","ArrayBuffer"];function c(e){return t=l,r=u(e),-1!==t.indexOf(r)||f(i.ArrayBuffer,e);var t,r}function h(e,r){r=null==r?{}:r;for(var o=0,n=(e=e?e.slice():[]).length;o<n;o++){var a=e[o];a instanceof h?e[o]=a._buffer:"string"==typeof a?e[o]=p(a):"DataView"===u(s=a)||f(i.DataView,s)?e[o]=t(a.buffer):c(a)?e[o]=t(a):e[o]=p(String(a))}var s;this._buffer=i.Uint8Array?function(e){for(var t=0,r=e.length;r--;)t+=e[r].length;for(var o=new Uint8Array(t),n=0,i=0;i<e.length;i++){var a=e[i];o.set(a,n),n+=a.byteLength||a.length}return o}(e):[].concat.apply([],e),this.size=this._buffer.length,this.type=r.type||"",/[^\u0020-\u007E]/.test(this.type)?this.type="":this.type=this.type.toLowerCase()}funcLine truncated
File diff suppressed because it is too large. Load diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "polyfill",
"version": "1.0.0",
"description": "",
"main": "index.js",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "webpack --mode production --watch",
"build": "webpack --mode production",
"postinstall": "patch-package"
},
"author": "",
"license": "ISC",
"devDependencies": {
"patch-package": "^8.0.0",
"webpack": "^5.75.0",
"webpack-cli": "^5.0.1"
},
"dependencies": {
"blob-polyfill": "^7.0.20220408",
"fastestsmallesttextencoderdecoder": "^1.0.22",
"whatwg-fetch": "^3.6.20"
}
}
@@ -0,0 +1,46 @@
diff --git a/node_modules/whatwg-fetch/dist/fetch.umd.js b/node_modules/whatwg-fetch/dist/fetch.umd.js
index 7a0d852..604691e 100644
--- a/node_modules/whatwg-fetch/dist/fetch.umd.js
+++ b/node_modules/whatwg-fetch/dist/fetch.umd.js
@@ -394,6 +394,7 @@
}
}());
this.referrer = null;
+ this.redirect = options.redirect || 'follow'
if ((this.method === 'GET' || this.method === 'HEAD') && body) {
throw new TypeError('Body not allowed for GET or HEAD requests')
@@ -606,6 +607,10 @@
}
}
+ if ('redirect' in xhr) {
+ xhr.redirect = request.redirect
+ }
+
if (init && typeof init.headers === 'object' && !(init.headers instanceof Headers || (g.Headers && init.headers instanceof g.Headers))) {
var names = [];
Object.getOwnPropertyNames(init.headers).forEach(function(name) {
diff --git a/node_modules/whatwg-fetch/fetch.js b/node_modules/whatwg-fetch/fetch.js
index f39a983..d1fc903 100644
--- a/node_modules/whatwg-fetch/fetch.js
+++ b/node_modules/whatwg-fetch/fetch.js
@@ -388,6 +388,7 @@ export function Request(input, options) {
}
}());
this.referrer = null
+ this.redirect = options.redirect || 'follow'
if ((this.method === 'GET' || this.method === 'HEAD') && body) {
throw new TypeError('Body not allowed for GET or HEAD requests')
@@ -600,6 +601,10 @@ export function fetch(input, init) {
}
}
+ if ('redirect' in xhr) {
+ xhr.redirect = request.redirect
+ }
+
if (init && typeof init.headers === 'object' && !(init.headers instanceof Headers || (g.Headers && init.headers instanceof g.Headers))) {
var names = [];
Object.getOwnPropertyNames(init.headers).forEach(function(name) {
@@ -0,0 +1,3 @@
import { Blob } from "blob-polyfill";
globalThis.Blob = Blob;
@@ -0,0 +1,13 @@
globalThis.crypto = {
getRandomValues(arr) {
for (let i = 0, len = arr.length; i < len; i++) {
arr[i] = Math.floor(Math.random() * 256);
}
return arr;
},
randomUUID() {
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c) => {
return (c ^ (this.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16)
})
}
}
@@ -0,0 +1 @@
import 'whatwg-fetch'
@@ -0,0 +1,5 @@
import "./blob/index.js"
import "./crypto/index.js"
// polyfill TextEncoder
import 'fastestsmallesttextencoderdecoder';
import "./fetch/index.js"
@@ -0,0 +1,12 @@
import path from "path";
import { fileURLToPath } from "url";
const __dirname = fileURLToPath(import.meta.url);
export default {
entry: "./src/index.js",
output: {
filename: "index.js",
path: path.resolve(__dirname, "../out"),
},
};
+37
View File
@@ -0,0 +1,37 @@
package util
import (
"github.com/dop251/goja"
)
func ThrowTypeError(vm *goja.Runtime, msg string) {
panic(vm.NewTypeError(msg))
}
func AssertError[T error](err error) (t T, r bool) {
if err == nil {
return
}
if e, ok := err.(T); ok {
return e, true
}
if e, ok := err.(*goja.Exception); ok {
if ee, okk := e.Value().Export().(T); okk {
return ee, true
}
}
return
}
func SafeGet[T any](vm *goja.Runtime, name string) T {
v := vm.Get(name)
if v == nil {
var init T
return init
}
if e, ok := v.Export().(T); ok {
return e
}
var init T
return init
}
-1
View File
@@ -5,7 +5,6 @@ type EventKey string
const (
EventKeyStart = "start"
EventKeyPause = "pause"
EventKeyContinue = "continue"
EventKeyProgress = "progress"
EventKeyError = "error"
EventKeyDelete = "delete"
+821
View File
@@ -0,0 +1,821 @@
package download
import (
"encoding/json"
"fmt"
"io"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/GopeedLab/gopeed/internal/logger"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/download/engine"
gojaerror "github.com/GopeedLab/gopeed/pkg/download/engine/inject/error"
gojautil "github.com/GopeedLab/gopeed/pkg/download/engine/util"
"github.com/GopeedLab/gopeed/pkg/util"
"github.com/dop251/goja"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/transport"
)
var (
gitSuffix = ".git"
tempExtensionsDir = ".extensions"
extensionsDir = "extensions"
extensionIgnoreDirs = []string{gitSuffix, "node_modules"}
ErrExtensionNoManifest = fmt.Errorf("manifest.json not found")
ErrExtensionNotFound = fmt.Errorf("extension not found")
)
type ActivationEvent string
const (
EventOnResolve ActivationEvent = "onResolve"
EventOnStart ActivationEvent = "onStart"
EventOnError ActivationEvent = "onError"
EventOnDone ActivationEvent = "onDone"
)
func (d *Downloader) InstallExtensionByGit(url string) (*Extension, error) {
return d.fetchExtensionByGit(url, d.InstallExtensionByFolder)
}
func (d *Downloader) InstallExtensionByFolder(path string, devMode bool) (*Extension, error) {
ext, err := d.parseExtensionByPath(path)
if err != nil {
return nil, err
}
// if dev mode, don't copy to the extensions' directory
if devMode {
ext.DevMode = true
ext.DevPath, _ = filepath.Abs(path)
} else {
if err = util.CopyDir(path, d.ExtensionPath(ext), extensionIgnoreDirs...); err != nil {
return nil, err
}
}
// if extension is not installed, add it to the list, otherwise update it
installedExt := d.getExtension(ext.Identity)
if installedExt == nil {
ext.CreatedAt = time.Now()
ext.UpdatedAt = ext.CreatedAt
installedExt = ext
d.extensions = append(d.extensions, installedExt)
} else {
installedExt.update(ext)
}
if err = d.storage.Put(bucketExtension, installedExt.Identity, installedExt); err != nil {
return nil, err
}
return installedExt, nil
}
// UpgradeCheckExtension Check if there is a new version for the extension.
func (d *Downloader) UpgradeCheckExtension(identity string) (newVersion string, err error) {
ext, err := d.GetExtension(identity)
if err != nil {
return
}
installUrl := ext.buildInstallUrl()
if installUrl == "" {
return
}
_, err = d.fetchExtensionByGit(installUrl, func(tempExtPath string, devMode bool) (*Extension, error) {
tempExt, err := d.parseExtensionByPath(tempExtPath)
if err != nil {
return nil, err
}
if tempExt.Version != ext.Version {
newVersion = tempExt.Version
}
return tempExt, nil
})
return
}
func (d *Downloader) UpgradeExtension(identity string) error {
ext, err := d.GetExtension(identity)
if err != nil {
return err
}
installUrl := ext.buildInstallUrl()
if installUrl == "" {
return nil
}
if _, err := d.InstallExtensionByGit(installUrl); err != nil {
return err
}
return nil
}
func (d *Downloader) UpdateExtensionSettings(identity string, settings map[string]any) error {
ext, err := d.GetExtension(identity)
if err != nil {
return err
}
for _, setting := range ext.Settings {
if value, ok := settings[setting.Name]; ok {
setting.Value = tryParse(value, setting.Type)
}
}
return d.storage.Put(bucketExtension, ext.Identity, ext)
}
func (d *Downloader) SwitchExtension(identity string, status bool) error {
ext, err := d.GetExtension(identity)
if err != nil {
return err
}
ext.Disabled = !status
return d.storage.Put(bucketExtension, ext.Identity, ext)
}
func (d *Downloader) DeleteExtension(identity string) error {
ext, err := d.GetExtension(identity)
if err != nil {
return err
}
// remove from disk
if !ext.DevMode {
if err := os.RemoveAll(d.ExtensionPath(ext)); err != nil {
return err
}
}
// remove from extensions
for i, e := range d.extensions {
if e.Identity == identity {
d.extensions = append(d.extensions[:i], d.extensions[i+1:]...)
break
}
}
if err = d.storage.Delete(bucketExtension, identity); err != nil {
return err
}
if err = d.storage.Delete(bucketExtensionStorage, identity); err != nil {
return err
}
return nil
}
func (d *Downloader) GetExtensions() []*Extension {
return d.extensions
}
func (d *Downloader) GetExtension(identity string) (*Extension, error) {
extension := d.getExtension(identity)
if extension == nil {
return nil, ErrExtensionNotFound
}
return extension, nil
}
func (d *Downloader) getExtension(identity string) *Extension {
for _, ext := range d.extensions {
if ext.Identity == identity {
return ext
}
}
return nil
}
func (d *Downloader) fetchExtensionByGit(url string, handler func(tempExtPath string, devMode bool) (*Extension, error)) (*Extension, error) {
if !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "http://") {
url = "https://" + url
}
// resolve project path
parentPath, projectPath := path.Split(url)
// resolve project name and sub path
pathArr := strings.SplitN(projectPath, "#", 2)
projectPath = strings.TrimSuffix(pathArr[0], gitSuffix)
subPath := ""
if len(pathArr) > 1 {
subPath = pathArr[1]
}
// Use unique suffix to avoid concurrent conflicts when multiple git operations
// target the same extension (e.g., install and update check happening simultaneously)
tempExtDir := filepath.Join(d.cfg.StorageDir, tempExtensionsDir, fmt.Sprintf("%s_%d", projectPath, time.Now().UnixNano()))
if err := os.MkdirAll(tempExtDir, 0755); err != nil {
return nil, err
}
defer os.RemoveAll(tempExtDir)
proxyOptions := transport.ProxyOptions{}
proxyUrl := d.cfg.DownloaderStoreConfig.Proxy.ToUrl()
if proxyUrl != nil {
proxyOptions.URL = proxyUrl.Scheme + "://" + proxyUrl.Host
proxyOptions.Username = proxyUrl.User.Username()
proxyOptions.Password, _ = proxyUrl.User.Password()
}
// clone project to extension temp dir
gitUrl := parentPath + projectPath + gitSuffix
if _, err := git.PlainClone(tempExtDir, false, &git.CloneOptions{
URL: gitUrl,
Depth: 1,
ProxyOptions: proxyOptions,
}); err != nil {
return nil, err
}
return handler(filepath.Join(tempExtDir, subPath), false)
}
func (d *Downloader) parseExtensionByPath(path string) (*Extension, error) {
// resolve extension manifest
manifestTempPath := filepath.Join(path, "manifest.json")
if _, err := os.Stat(manifestTempPath); os.IsNotExist(err) {
return nil, ErrExtensionNoManifest
}
file, err := os.ReadFile(manifestTempPath)
if err != nil {
return nil, err
}
var ext Extension
if err = json.Unmarshal(file, &ext); err != nil {
return nil, err
}
if err = ext.validate(); err != nil {
return nil, err
}
ext.Identity = ext.buildIdentity()
return &ext, nil
}
func (d *Downloader) triggerOnResolve(req *base.Request) (res *base.Resource, err error) {
err = doTrigger(d,
EventOnResolve,
req,
&OnResolveContext{
Req: req,
},
func(ext *Extension, gopeed *Instance, ctx *OnResolveContext) {
// Validate resource structure
if ctx.Res != nil && len(ctx.Res.Files) > 0 {
if err := ctx.Res.Validate(); err != nil {
gopeed.Logger.logger.Warn().Err(err).Msgf("[%s] resource invalid", ext.buildIdentity())
return
}
ctx.Res.Name = util.SafeFilename(ctx.Res.Name)
for _, file := range ctx.Res.Files {
file.Name = util.SafeFilename(file.Name)
}
ctx.Res.CalcSize(nil)
}
res = ctx.Res
},
)
return
}
func (d *Downloader) triggerOnStart(task *Task) {
doTrigger(d,
EventOnStart,
task.Meta.Req,
&OnStartContext{
Task: NewExtensionTask(d, task),
},
func(ext *Extension, gopeed *Instance, ctx *OnStartContext) {
// Validate request structure
if ctx.Task.Meta.Req != nil {
if err := ctx.Task.Meta.Req.Validate(); err != nil {
gopeed.Logger.logger.Warn().Err(err).Msgf("[%s] request invalid", ext.buildIdentity())
return
}
}
},
)
return
}
func (d *Downloader) triggerOnError(task *Task, err error) {
doTrigger(d,
EventOnError,
task.Meta.Req,
&OnErrorContext{
Task: NewExtensionTask(d, task),
Error: err,
},
nil,
)
}
func (d *Downloader) triggerOnDone(task *Task) {
doTrigger(d,
EventOnDone,
task.Meta.Req,
&OnErrorContext{
Task: NewExtensionTask(d, task),
},
nil,
)
}
func doTrigger[T any](d *Downloader, event ActivationEvent, req *base.Request, ctx T, handler func(ext *Extension, gopeed *Instance, ctx T)) error {
// init extension global object
gopeed := &Instance{
Events: make(InstanceEvents),
}
var err error
for _, ext := range d.extensions {
if ext.Disabled {
continue
}
for _, script := range ext.Scripts {
if script.match(event, req) {
gopeed.Info = NewExtensionInfo(ext)
gopeed.Logger = newInstanceLogger(ext, d.ExtensionLogger)
gopeed.Settings = parseSettings(ext.Settings)
gopeed.Storage = &ContextStorage{
storage: d.storage,
identity: ext.buildIdentity(),
}
scriptFilePath := filepath.Join(d.ExtensionPath(ext), script.Entry)
if _, err = os.Stat(scriptFilePath); os.IsNotExist(err) {
gopeed.Logger.logger.Error().Err(err).Msgf("[%s] script file not exist", ext.buildIdentity())
continue
}
func() {
var scriptFile *os.File
scriptFile, err = os.Open(scriptFilePath)
if err != nil {
gopeed.Logger.logger.Error().Err(err).Msgf("[%s] open script file failed", ext.buildIdentity())
return
}
defer scriptFile.Close()
var scriptBuf []byte
scriptBuf, err = io.ReadAll(scriptFile)
if err != nil {
gopeed.Logger.logger.Error().Err(err).Msgf("[%s] read script file failed", ext.buildIdentity())
return
}
// Init request labels
if req.Labels == nil {
req.Labels = make(map[string]string)
}
engine := engine.NewEngine(&engine.Config{
ProxyConfig: d.cfg.Proxy,
})
defer engine.Close()
err = engine.Runtime.Set("gopeed", gopeed)
if err != nil {
gopeed.Logger.logger.Error().Err(err).Msgf("[%s] engine inject failed", ext.buildIdentity())
return
}
_, err = engine.RunString(string(scriptBuf))
if err != nil {
gopeed.Logger.logger.Error().Err(err).Msgf("[%s] run script failed", ext.buildIdentity())
return
}
if fn, ok := gopeed.Events[event]; ok {
_, err = engine.CallFunction(fn, ctx)
if err != nil {
gopeed.Logger.logger.Error().Err(err).Msgf("[%s] call function failed: %s", ext.buildIdentity(), event)
return
}
if handler != nil {
handler(ext, gopeed, ctx)
}
}
}()
}
}
}
// Only return MessageError
if me, ok := gojautil.AssertError[*gojaerror.MessageError](err); ok {
return me
}
return nil
}
func (d *Downloader) ExtensionPath(ext *Extension) string {
if ext.DevMode {
return ext.DevPath
}
return filepath.Join(d.cfg.StorageDir, extensionsDir, ext.Identity)
}
type Extension struct {
// Identity is global unique for an extension, it's a combination of author and name
Identity string `json:"identity"`
Name string `json:"name"`
Author string `json:"author"`
Title string `json:"title"`
Description string `json:"description"`
Icon string `json:"icon"`
// Version semantic version string, like: 1.0.0
Version string `json:"version"`
// Homepage homepage url
Homepage string `json:"homepage"`
// Repository git repository info
Repository *Repository `json:"repository"`
Scripts []*Script `json:"scripts"`
Settings []*Setting `json:"settings"`
// Disabled if true, this extension will be ignored
Disabled bool `json:"disabled"`
DevMode bool `json:"devMode"`
// DevPath is the local path of extension source code
DevPath string `json:"devPath"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (e *Extension) validate() error {
if e.Name == "" {
return fmt.Errorf("extension name is required")
}
if e.Title == "" {
return fmt.Errorf("extension title is required")
}
if e.Version == "" {
return fmt.Errorf("extension version is required")
}
return nil
}
func (e *Extension) buildIdentity() string {
if e.Author == "" {
return e.Name
}
return e.Author + "@" + e.Name
}
func (e *Extension) buildInstallUrl() string {
if e.Repository == nil || e.Repository.Url == "" {
return ""
}
repoUrl := e.Repository.Url
if e.Repository.Directory != "" {
if strings.HasSuffix(repoUrl, "/") {
repoUrl = repoUrl[:len(repoUrl)-1]
}
dir := e.Repository.Directory
if strings.HasPrefix(dir, "/") {
dir = dir[1:]
}
repoUrl = repoUrl + "#" + dir
}
return repoUrl
}
func (e *Extension) update(newExt *Extension) error {
e.Title = newExt.Title
e.Description = newExt.Description
e.Icon = newExt.Icon
e.Version = newExt.Version
e.Homepage = newExt.Homepage
e.Repository = newExt.Repository
e.Scripts = newExt.Scripts
// merge settings
// if new setting not exist in old settings, append it
for _, newSetting := range newExt.Settings {
var exist bool
for _, setting := range e.Settings {
if setting.Name == newSetting.Name {
exist = true
break
}
}
if !exist {
e.Settings = append(e.Settings, newSetting)
}
}
// if old setting not exist in new settings, remove it
for i := 0; i < len(e.Settings); i++ {
var exist bool
for _, setting := range newExt.Settings {
if setting.Name == e.Settings[i].Name {
exist = true
break
}
}
if !exist {
e.Settings = append(e.Settings[:i], e.Settings[i+1:]...)
i--
}
}
// if new setting exist in old settings, update it
for _, newSetting := range newExt.Settings {
for _, setting := range e.Settings {
if setting.Name == newSetting.Name {
setting.Title = newSetting.Title
setting.Description = newSetting.Description
setting.Options = newSetting.Options
// if type changed, reset value
if setting.Type != newSetting.Type {
setting.Type = newSetting.Type
setting.Value = newSetting.Value
}
break
}
}
}
e.UpdatedAt = time.Now()
return nil
}
type Repository struct {
Url string `json:"url"`
Directory string `json:"directory"`
}
type Script struct {
// Event active event name
Event string `json:"event"`
// Match rules
Match *Match `json:"match"`
// Entry js script file path
Entry string `json:"entry"`
}
func (s *Script) match(event ActivationEvent, req *base.Request) bool {
if s.Event == "" {
return false
}
if s.Event != string(event) {
return false
}
if s.Match == nil || (len(s.Match.Urls) == 0 && len(s.Match.Labels) == 0) {
return false
}
// match url
for _, url := range s.Match.Urls {
if util.Match(url, req.URL) {
return true
}
}
// match label
for _, label := range s.Match.Labels {
if _, ok := req.Labels[label]; ok {
return true
}
}
return false
}
type Match struct {
// Urls match expression, refer to https://developer.chrome.com/docs/extensions/mv3/match_patterns/
Urls []string `json:"urls"`
// Labels match request labels
Labels []string `json:"labels"`
}
type SettingType string
const (
SettingTypeString SettingType = "string"
SettingTypeNumber SettingType = "number"
SettingTypeBoolean SettingType = "boolean"
)
type Setting struct {
Name string `json:"name"`
Title string `json:"title"`
Description string `json:"description"`
Required bool `json:"required"`
// setting type
Type SettingType `json:"type"`
// setting value
Value any `json:"value"`
//Multiple bool `json:"multiple"`
Options []*Option `json:"options"`
}
type Option struct {
Label string `json:"label"`
Value any `json:"value"`
}
// Instance inject to js context when extension script is activated
type Instance struct {
Events InstanceEvents `json:"events"`
Info *ExtensionInfo `json:"info"`
Logger *InstanceLogger `json:"logger"`
Settings map[string]any `json:"settings"`
Storage *ContextStorage `json:"storage"`
}
type InstanceEvents map[ActivationEvent]goja.Callable
func (h InstanceEvents) register(name ActivationEvent, fn goja.Callable) {
h[name] = fn
}
func (h InstanceEvents) OnResolve(fn goja.Callable) {
h.register(EventOnResolve, fn)
}
func (h InstanceEvents) OnStart(fn goja.Callable) {
h.register(EventOnStart, fn)
}
func (h InstanceEvents) OnError(fn goja.Callable) {
h.register(EventOnError, fn)
}
func (h InstanceEvents) OnDone(fn goja.Callable) {
h.register(EventOnDone, fn)
}
type ExtensionInfo struct {
Identity string `json:"identity"`
Name string `json:"name"`
Author string `json:"author"`
Title string `json:"title"`
Version string `json:"version"`
}
func NewExtensionInfo(ext *Extension) *ExtensionInfo {
return &ExtensionInfo{
Identity: ext.buildIdentity(),
Name: ext.Name,
Author: ext.Author,
Title: ext.Title,
Version: ext.Version,
}
}
type InstanceLogger struct {
identity string
devMode bool
logger *logger.Logger
}
func (l *InstanceLogger) Debug(msg ...goja.Value) {
if l.devMode {
l.logger.Debug().Msg(l.append(msg...))
}
}
func (l *InstanceLogger) Info(msg ...goja.Value) {
l.logger.Info().Msg(l.append(msg...))
}
func (l *InstanceLogger) Warn(msg ...goja.Value) {
l.logger.Warn().Msg(l.append(msg...))
}
func (l *InstanceLogger) Error(msg ...goja.Value) {
l.logger.Error().Msg(l.append(msg...))
}
func (l *InstanceLogger) append(msg ...goja.Value) string {
strMsg := make([]string, len(msg))
for i, m := range msg {
strMsg[i] = m.String()
}
return fmt.Sprintf("[%s] %s", l.identity, strings.Join(strMsg, " "))
}
func newInstanceLogger(extension *Extension, logger *logger.Logger) *InstanceLogger {
return &InstanceLogger{
identity: extension.buildIdentity(),
devMode: extension.DevMode,
logger: logger,
}
}
type OnResolveContext struct {
Req *base.Request `json:"req"`
Res *base.Resource `json:"res"`
}
type OnStartContext struct {
Task *ExtensionTask `json:"task"`
}
type OnErrorContext struct {
Task *ExtensionTask `json:"task"`
Error error `json:"error"`
}
type OnDoneContext struct {
Task *Task `json:"task"`
}
// ExtensionTask is a wrapper of Task, it's used to interact with extension scripts.
// Avoid extension scripts modifying task directly, use ExtensionTask to encapsulate task,
// only some fields can be modified, such as request info.
type ExtensionTask struct {
download *Downloader
*Task
}
func NewExtensionTask(download *Downloader, task *Task) *ExtensionTask {
// restricts extension scripts to only modify request info
newTask := task.clone()
newTask.Meta.Req = task.Meta.Req
return &ExtensionTask{
download: download,
Task: newTask,
}
}
func (t *ExtensionTask) Continue() error {
return t.download.Continue(&TaskFilter{
IDs: []string{t.ID},
})
}
func (t *ExtensionTask) Pause() error {
return t.download.Pause(&TaskFilter{
IDs: []string{t.ID},
})
}
func parseSettings(settings []*Setting) map[string]any {
m := make(map[string]any)
for _, s := range settings {
var val any
if s.Value != nil {
val = s.Value
}
m[s.Name] = tryParse(val, s.Type)
}
return m
}
func tryParse(val any, settingType SettingType) any {
if val == nil {
return nil
}
switch settingType {
case SettingTypeString:
return fmt.Sprint(val)
case SettingTypeNumber:
vv, err := strconv.ParseFloat(fmt.Sprint(val), 64)
if err != nil {
return nil
}
return vv
case SettingTypeBoolean:
vv, err := strconv.ParseBool(fmt.Sprint(val))
if err != nil {
return nil
}
return vv
default:
return nil
}
}
type ContextStorage struct {
storage Storage
identity string
}
func (s *ContextStorage) Get(key string) any {
raw := s.getRawData()
if v, ok := raw[key]; ok {
return v
}
return nil
}
func (s *ContextStorage) Set(key string, value string) {
raw := s.getRawData()
raw[key] = value
s.storage.Put(bucketExtensionStorage, s.identity, raw)
}
func (s *ContextStorage) Remove(key string) {
raw := s.getRawData()
delete(raw, key)
s.storage.Put(bucketExtensionStorage, s.identity, raw)
}
func (s *ContextStorage) Keys() []string {
raw := s.getRawData()
keys := make([]string, 0)
for k := range raw {
keys = append(keys, k)
}
return keys
}
func (s *ContextStorage) Clear() {
s.storage.Delete(bucketExtensionStorage, s.identity)
}
func (s *ContextStorage) getRawData() map[string]string {
var data map[string]string
s.storage.Get(bucketExtensionStorage, s.identity, &data)
if data == nil {
data = make(map[string]string)
}
return data
}
Loaded 100 of 438 files, more files were not shown because too many files have changed in this diff. Show more