Compare commits

..
345 Commits
Author SHA1 Message Date
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
345 changed files with 38612 additions and 8172 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
+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
+484 -111
View File
@@ -2,10 +2,21 @@ name: build
on:
workflow_dispatch:
inputs:
platform:
description: "Build platform"
required: true
default: "all"
test:
description: "Test mode"
required: true
default: "false"
env:
GO_VERSION: "1.19"
FLUTTER_VERSION: "3.10.6"
GO_VERSION: "1.24"
FLUTTER_VERSION: "3.38.7"
GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }}
GA4_API_SECRET: ${{ secrets.GA4_API_SECRET }}
permissions:
contents: write
@@ -14,56 +25,113 @@ jobs:
get-release:
runs-on: ubuntu-latest
outputs:
tag_name: ${{ steps.get-release.outputs.tag_name }}
version: ${{ steps.get-release.outputs.version }}
upload_url: ${{ steps.get-release.outputs.upload_url }}
steps:
- uses: monkeyWie/get-latest-release@v1
- uses: monkeyWie/get-latest-release@v2.1
id: get-release
with:
myToken: ${{ github.token }}
build-windows:
runs-on: windows-latest
needs: [ get-release ]
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
- 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.20"
go-version: ${{ env.GO_VERSION }}
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: ${{ matrix.flutter_channel || 'stable' }}
flutter-version: ${{ matrix.flutter_version || env.FLUTTER_VERSION }}
- name: Build
env:
VERSION: ${{ needs.get-release.outputs.tag_name }}
VERSION: ${{ needs.get-release.outputs.version }}
GOARCH: ${{ matrix.arch }}
run: |
Invoke-WebRequest -Uri "https://github.com/jrsoftware/issrc/raw/main/Files/Languages/Unofficial/ChineseSimplified.isl" -OutFile "ChineseSimplified.isl"
mv ChineseSimplified.isl "C:\Program Files (x86)\Inno Setup 6\Languages\"
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/windows/libgopeed.dll github.com/GopeedLab/gopeed/bind/desktop
go build -tags nosqlite -ldflags="-w -s -X github.com/GopeedLab/gopeed/pkg/base.Version=$env:VERSION" -buildmode=c-shared -o ui/flutter/windows/libgopeed.dll github.com/GopeedLab/gopeed/bind/desktop
go build -ldflags="-w -s" -o ui/flutter/assets/exec/host.exe github.com/GopeedLab/gopeed/cmd/host
go build -ldflags="-w -s" -o ui/flutter/assets/exec/updater.exe github.com/GopeedLab/gopeed/cmd/updater
cd ui/flutter
flutter build windows
$mingw = "C:\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-portable.zip"
Compress-Archive -Path "$release*" -DestinationPath "build\windows\Output\Gopeed-$TAG-windows-${{ matrix.arch }}-portable.zip"
flutter build windows --dart-define="UPDATE_CHANNEL=windowsInstaller" --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}"
cd build/windows
echo @"
; Script generated by the Inno Setup Script Wizard.
; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!
#define MyAppName "Gopeed"
#define MyAppVersion "$($env:VERSION.substring(1))"
#define MyAppVersion "$env:VERSION"
#define MyAppPublisher "monkeyWie"
#define MyAppURL "https://gopeed.com"
#define MyAppExeName "gopeed.exe"
@@ -86,11 +154,13 @@ jobs:
PrivilegesRequired=lowest
OutputBaseFilename=gopeed
SetupIconFile=..\..\assets\icon\icon.ico
UninstallDisplayIcon={app}\{#MyAppExeName}
Compression=lzma
SolidCompression=yes
WizardStyle=modern
LanguageDetectionMethod=uilanguage
ShowLanguageDialog=yes
CloseApplications=force
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
@@ -100,7 +170,7 @@ jobs:
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]
@@ -114,35 +184,44 @@ jobs:
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
"@ > setup.iss
iscc.exe setup.iss
mv "Output\gopeed.exe" "Output\Gopeed-$env:VERSION-windows-amd64.exe"
Compress-Archive -Path "Output\Gopeed-$env:VERSION-windows-amd64.exe" -DestinationPath "Output\Gopeed-$env:VERSION-windows-amd64.zip"
Remove-Item -Path "Output\Gopeed-$env:VERSION-windows-amd64.exe"
mv "Output\gopeed.exe" "Output\Gopeed-$TAG-windows-${{ matrix.arch }}.exe"
Compress-Archive -Path "Output\Gopeed-$TAG-windows-${{ matrix.arch }}.exe" -DestinationPath "Output\Gopeed-$TAG-windows-${{ matrix.arch }}.zip"
- name: Upload
uses: shogo82148/actions-upload-release-asset@v1
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
needs: [ get-release ]
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@v4
with:
go-version: ${{ env.GO_VERSION }}
- name: Build
env:
VERSION: ${{ needs.get-release.outputs.version }}
run: |
go install src.techknowlogick.com/xgo@latest
xgo -go go-1.19.x --targets=darwin/arm64 -tags="nosqlite" -ldflags="-w -s" -buildmode=c-shared -pkg=bind/desktop -out=libgopeed .
mv libgopeed-*.dylib libgopeed.dylib
- uses: actions/upload-artifact@v3
go build -tags nosqlite -ldflags="-w -s -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION" -buildmode=c-shared -o libgopeed.dylib github.com/GopeedLab/gopeed/bind/desktop
go build -ldflags="-w -s" github.com/GopeedLab/gopeed/cmd/host
go build -ldflags="-w -s" github.com/GopeedLab/gopeed/cmd/updater
- uses: actions/upload-artifact@v4
with:
name: macos-arm64-lib
path: libgopeed.dylib
name: macos-amd64-lib
path: |
libgopeed.dylib
host
updater
build-macos:
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos' }}
runs-on: macos-latest
needs: [ get-release, build-macos-arm64-lib ]
strategy:
matrix:
channel: [arm64, amd64, universal]
needs: [get-release, build-macos-amd64-lib]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
@@ -155,28 +234,52 @@ jobs:
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Install appdmg
run: npm install -g appdmg
- uses: actions/download-artifact@v3
with:
name: macos-arm64-lib
path: ui/flutter/lib-arm64
- name: Build
env:
VERSION: ${{ needs.get-release.outputs.tag_name }}
run: |
PROJECT_DIR=$(pwd)
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
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/lib-amd64/libgopeed.dylib github.com/GopeedLab/gopeed/bind/desktop
# universal binary
mkdir -p ui/flutter/macos/Frameworks
cp ui/flutter/lib-amd64/libgopeed.dylib ui/flutter/macos/Frameworks/amd64-lib
cp ui/flutter/lib-arm64/libgopeed.dylib ui/flutter/macos/Frameworks/arm64-lib
cd ui/flutter/macos/Frameworks
lipo -create -output libgopeed.dylib amd64-lib arm64-lib
cp ui/flutter/lib-amd64/libgopeed.dylib ui/flutter/.temp/libgopeed.dylib.amd64
cp ui/flutter/lib-amd64/host ui/flutter/.temp/host.amd64
cp ui/flutter/lib-amd64/updater ui/flutter/.temp/updater.amd64
cd $PROJECT_DIR/ui/flutter
flutter build macos
# universal lib
lipo -create -output ui/flutter/macos/Frameworks/libgopeed.dylib ui/flutter/.temp/libgopeed.dylib.arm64 ui/flutter/.temp/libgopeed.dylib.amd64
lipo -create -output ui/flutter/assets/exec/host ui/flutter/.temp/host.arm64 ui/flutter/.temp/host.amd64
lipo -create -output ui/flutter/assets/exec/updater ui/flutter/.temp/updater.arm64 ui/flutter/.temp/updater.amd64
- name: Build
env:
VERSION: ${{ needs.get-release.outputs.version }}
run: |
cd ui/flutter
flutter build macos --dart-define="UPDATE_CHANNEL=macosDmg" --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}"
cd build/macos/Build/Products/Release
cat>appdmg.json<<EOF
{
@@ -189,7 +292,14 @@ jobs:
}
EOF
mkdir dist
appdmg appdmg.json dist/Gopeed-$VERSION-macos.dmg
appdmg appdmg.json dist/Gopeed-v$VERSION-macos.dmg
if [[ "${{ matrix.channel }}" == "arm64" ]]; then
mv dist/Gopeed-v$VERSION-macos.dmg dist/Gopeed-v$VERSION-macos-arm64.dmg
fi
if [[ "${{ matrix.channel }}" == "amd64" ]]; then
mv dist/Gopeed-v$VERSION-macos.dmg dist/Gopeed-v$VERSION-macos-amd64.dmg
fi
- name: Upload
uses: shogo82148/actions-upload-release-asset@v1
with:
@@ -197,8 +307,12 @@ jobs:
asset_path: ui/flutter/build/macos/Build/Products/Release/dist/*
overwrite: true
build-linux:
runs-on: ubuntu-latest
needs: [ get-release ]
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@v4
@@ -207,58 +321,235 @@ jobs:
- 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 libayatana-appindicator3-dev
sudo apt update -y
sudo apt install -y ninja-build libgtk-3-dev libayatana-appindicator3-1 libayatana-appindicator3-dev rpm patchelf libfuse2 locate imagemagick potrace libkeybinder-3.0-dev
arch=$(uname -m)
wget -O appimagetool "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${arch}.AppImage"
chmod +x appimagetool
sudo mv appimagetool /usr/local/bin/
- name: Build
env:
VERSION: ${{ needs.get-release.outputs.tag_name }}
VERSION: ${{ needs.get-release.outputs.version }}
run: |
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/linux/bundle/lib/libgopeed.so github.com/GopeedLab/gopeed/bind/desktop
go build -tags nosqlite -ldflags="-w -s -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION" -buildmode=c-shared -o ui/flutter/linux/bundle/lib/libgopeed.so github.com/GopeedLab/gopeed/bind/desktop
go build -ldflags="-w -s" -o ui/flutter/assets/exec/host github.com/GopeedLab/gopeed/cmd/host
go build -ldflags="-w -s" -o ui/flutter/assets/exec/updater github.com/GopeedLab/gopeed/cmd/updater
cd ui/flutter
flutter build linux
mkdir -p 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.
ICON_PATH=$(find assets -name "logo.png" -o -name "icon.png" | head -n 1)
[ -z "$ICON_PATH" ] && ICON_PATH="assets/icon.png" && touch assets/icon.png
convert "$ICON_PATH" assets/icon.svg
ICON_PATH="assets/icon.svg"
RPM_ARCH=$(uname -m)
mkdir -p linux/packaging/rpm
cat << EOF > linux/packaging/rpm/make_config.yaml
display_name: Gopeed
icon: $ICON_PATH
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 ]
needs: [get-release]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
with:
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
@@ -267,13 +558,13 @@ jobs:
- 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 }}
@@ -281,12 +572,16 @@ jobs:
go install golang.org/x/mobile/cmd/gomobile@latest
go get golang.org/x/mobile/bind
gomobile init
gomobile bind -tags nosqlite -ldflags="-w -s" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 19 -javapkg=com.gopeed github.com/GopeedLab/gopeed/bind/mobile
gomobile bind -tags nosqlite -ldflags="-w -s -checklinkname=0 -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 21 -javapkg=com.gopeed github.com/GopeedLab/gopeed/bind/mobile
cd ui/flutter
echo $APK_KEYSTORE | base64 -di > android/app/upload-keystore.jks
flutter build apk
flutter build apk --dart-define="UPDATE_CHANNEL=androidApk" --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}"
flutter build apk --split-per-abi --dart-define="UPDATE_CHANNEL=androidApk" --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}"
mkdir dist
cp build/app/outputs/flutter-apk/app-release.apk dist/Gopeed-$VERSION-android.apk
cp build/app/outputs/flutter-apk/app-release.apk dist/Gopeed-v$VERSION-android.apk
cp build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk dist/Gopeed-v$VERSION-android-armeabi-v7a.apk
cp build/app/outputs/flutter-apk/app-arm64-v8a-release.apk dist/Gopeed-v$VERSION-android-arm64-v8a.apk
cp build/app/outputs/flutter-apk/app-x86_64-release.apk dist/Gopeed-v$VERSION-android-x86_64.apk
- name: Upload
uses: shogo82148/actions-upload-release-asset@v1
with:
@@ -294,8 +589,9 @@ jobs:
asset_path: ui/flutter/dist/*
overwrite: true
build-ios:
runs-on: macos-latest
needs: [ get-release ]
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
@@ -306,19 +602,19 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Build
env:
VERSION: ${{ needs.get-release.outputs.tag_name }}
VERSION: ${{ needs.get-release.outputs.version }}
run: |
go install golang.org/x/mobile/cmd/gomobile@latest
go get golang.org/x/mobile/bind
gomobile init
gomobile bind -tags nosqlite -ldflags="-w -s" -o ui/flutter/ios/Frameworks/Libgopeed.xcframework -target=ios github.com/GopeedLab/gopeed/bind/mobile
gomobile bind -tags nosqlite -ldflags="-w -s -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION" -o ui/flutter/ios/Frameworks/Libgopeed.xcframework -target=ios github.com/GopeedLab/gopeed/bind/mobile
cd ui/flutter
flutter build ios --no-codesign
flutter build ios --no-codesign --dart-define="UPDATE_CHANNEL=iosIpa" --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}"
mkdir Payload
cp -r build/ios/iphoneos/Runner.app Payload
zip -r -y Payload.zip Payload/Runner.app
mkdir dist
mv Payload.zip dist/Gopeed-$VERSION-ios.ipa
mv Payload.zip dist/Gopeed-v$VERSION-ios.ipa
- name: Upload
uses: shogo82148/actions-upload-release-asset@v1
with:
@@ -326,8 +622,9 @@ 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 ]
needs: [get-release]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
@@ -338,13 +635,15 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Build
env:
VERSION: ${{ needs.get-release.outputs.tag_name }}
VERSION: ${{ needs.get-release.outputs.version }}
run: |
cd ui/flutter
flutter build web --web-renderer html
flutter build web --no-web-resources-cdn --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}"
dart ../../.github/workflows/scripts/flutter_local_font.dart
cd ../../
cp -r ui/flutter/build/web cmd/web/dist
mkdir -p dist/zip
goos_arr=(windows darwin linux)
goarch_arr=(386 amd64 arm64)
export CGO_ENABLED=0
@@ -354,9 +653,9 @@ jobs:
if [ $goos = "darwin" ]; then
goos_name="macos"
fi
name=gopeed-web-$VERSION-$goos_name-$goarch
name=gopeed-web-v$VERSION-$goos_name-$goarch
dir="dist/$name/"
(GOOS=$goos GOARCH=$goarch go build -tags nosqlite,web -ldflags="-s -w" -o $dir github.com/GopeedLab/gopeed/cmd/web \
(GOOS=$goos GOARCH=$goarch go build -tags nosqlite,web -ldflags="-s -w -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION" -o $dir github.com/GopeedLab/gopeed/cmd/web \
&& cd $dir \
&& file=$(ls -AU | head -1) \
&& mkdir $name \
@@ -366,20 +665,83 @@ jobs:
|| true
done
done
- uses: actions/upload-artifact@v4
with:
name: web-dist
path: dist/zip/
- name: Upload
uses: shogo82148/actions-upload-release-asset@v1
with:
upload_url: ${{ needs.get-release.outputs.upload_url }}
asset_path: dist/zip/*
overwrite: true
build-docker:
build-qnap:
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'qnap' }}
runs-on: ubuntu-latest
needs: [ get-release ]
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:
- name: Remove unnecessary files
run: |
sudo rm -rf /usr/share/dotnet
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
- uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
@@ -390,18 +752,29 @@ jobs:
with:
username: liwei2633
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build flutter web
run: |
cd ui/flutter
flutter build web --no-web-resources-cdn --dart-define="UPDATE_CHANNEL=docker" --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}"
dart ../../.github/workflows/scripts/flutter_local_font.dart
cd ../../
rm -rf cmd/web/dist
cp -r ui/flutter/build/web cmd/web/dist
- name: Build and push
uses: docker/build-push-action@v2
env:
VERSION: ${{ needs.get-release.outputs.tag_name }}
VERSION: ${{ needs.get-release.outputs.version }}
with:
push: true
context: .
push: ${{ github.event.inputs.test == 'true' && 'false' || 'true' }}
build-args: |
VERSION=${{ env.VERSION }}
IN_DOCKER=true
platforms: |
linux/386
linux/amd64
linux/arm64
linux/arm/v6
linux/arm/v7
tags: |
liwei2633/gopeed:latest
liwei2633/gopeed:${{ env.VERSION }}
liwei2633/gopeed:v${{ env.VERSION }}
+1 -1
View File
@@ -17,7 +17,7 @@ on:
- "Dockerfile"
env:
GO_VERSION: "1.19"
GO_VERSION: "1.24"
permissions:
contents: write
@@ -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);
}
+143 -12
View File
@@ -25,10 +25,11 @@ on:
- ".github/workflows/test.yml"
- "go.mod"
- "go.sum"
workflow_dispatch:
env:
GO_VERSION: "1.19"
FLUTTER_VERSION: "3.10.6"
GO_VERSION: "1.24"
FLUTTER_VERSION: "3.38.7"
jobs:
# lint:
@@ -53,14 +54,91 @@ jobs:
with:
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
build:
name: Build check
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
- 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: ${{ 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
@@ -71,10 +149,63 @@ jobs:
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
- run: |
sudo apt-get update -y
sudo apt-get install -y ninja-build libgtk-3-dev libayatana-appindicator3-dev
go install golang.org/x/mobile/cmd/gomobile@latest
go get golang.org/x/mobile/bind
gomobile init
- if: runner.os == 'macOS'
run: |
gomobile bind -tags nosqlite -ldflags="-w -s" -o ui/flutter/ios/Frameworks/Libgopeed.xcframework -target=ios github.com/GopeedLab/gopeed/bind/mobile
cd ui/flutter
flutter build ipa --no-codesign
- if: runner.os == 'Linux'
uses: actions/setup-java@v3
with:
distribution: "zulu"
java-version: "17"
- if: runner.os == 'Linux'
run: |
gomobile bind -tags nosqlite -ldflags="-w -s -checklinkname=0" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 21 -javapkg=com.gopeed github.com/GopeedLab/gopeed/bind/mobile
cd ui/flutter
flutter build apk
build-web:
name: Build web check
runs-on: ubuntu-latest
needs: [test]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
with:
go-version: ${{ env.GO_VERSION }}
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Build
run: |
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/linux/bundle/lib/libgopeed.so github.com/GopeedLab/gopeed/bind/desktop
cd ui/flutter
flutter build linux
flutter build web --no-web-resources-cdn
dart ../../.github/workflows/scripts/flutter_local_font.dart
cd ../../
rm -rf cmd/web/dist
cp -r ui/flutter/build/web cmd/web/dist
go build -tags nosqlite,web -ldflags="-s -w" github.com/GopeedLab/gopeed/cmd/web
build-docker:
name: Build docker check
runs-on: ubuntu-latest
needs: [test]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@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 .
+1 -1
View File
@@ -4,7 +4,7 @@
*.dll
*.so
*.dylib
ui/flutter/dist/
# Test binary, built with `go test -c`
*.test
+3 -23
View File
@@ -17,18 +17,11 @@ start the front-end flutter project in `debug` mode to run.
## Translation
The internationalization files of Gopeed are located in the `ui/flutter/assets/locales` directory.
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.
Generate locales after you edit locales:
```
get generate locales
```
Please refer to `en_us.dart` for translation.
words prefixed with `@` are not meant to be translated.
## flutter development
@@ -38,17 +31,4 @@ Turn on build_runner watcher if you want to edit api/models:
```
flutter pub run build_runner watch
```
get-cli commands usages:
```
create:
controller: Generate controller
page: Use to generate pages
view: Generate view
generate:
locales: Generate translation file from json files
model: generate Class model from json
```
```
+1 -22
View File
@@ -17,17 +17,9 @@
## 翻訳
Gopeed の国際化ファイルは `ui/flutter/assets/locales` ディレクトリにあります。
Gopeed の国際化ファイルは `ui/flutter/lib/i18n/langs` ディレクトリにあります。
このディレクトリに対応する言語ファイルを追加するだけでよいです。
ロケール編集後にロケールを生成:
```
get generate locales
```
翻訳については `en_us.dart` を参照してください。
## flutter での開発
@@ -39,16 +31,3 @@ api/models を編集したい場合は build_runner watcher をオンにしま
```
flutter pub run build_runner watch
```
get-cli コマンドの使用法:
```
create:
controller: Generate controller
page: Use to generate pages
view: Generate view
generate:
locales: Generate translation file from json files
model: generate Class model from json
```
+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:
+2 -20
View File
@@ -14,15 +14,9 @@ flutter 项目即可运行。
## 翻译
Gopeed 的国际化文件位于 `ui/flutter/assets/locales` 目录下,只需要在该目录下添加对应的语言文件即可。
Gopeed 的国际化文件位于 `ui/flutter/lib/i18n/langs` 目录下,只需要在该目录下添加对应的语言文件即可。
编辑locales后请运行以下命令:
```
get generate locales
```
请注意以 `en_US.json` 为参照进行翻译。
请注意以 `en_us.dart` 为参照进行翻译。
## flutter开发
@@ -34,15 +28,3 @@ get generate locales
flutter pub run build_runner watch
```
适用get-cli 命令:
```
create:
controller: Generate controller
page: Use to generate pages
view: Generate view
generate:
locales: Generate translation file from json files
model: generate Class model from json
```
+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 instrumentisto/flutter:3.10.6 AS flutter
WORKDIR /app
COPY ./ui/flutter/pubspec.yaml ./ui/flutter/pubspec.lock ./
RUN flutter pub get
COPY ./ui/flutter ./
RUN flutter build web --web-renderer html
FROM golang:1.19.3 AS go
FROM golang:1.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 CGO_ENABLED=0 go build -tags nosqlite,web -ldflags="-s -w" -o dist/gopeed github.com/GopeedLab/gopeed/cmd/web
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -tags nosqlite,web \
-ldflags="-s -w -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION -X github.com/GopeedLab/gopeed/pkg/base.InDocker=true" \
-o dist/gopeed github.com/GopeedLab/gopeed/cmd/web
FROM alpine:3.14.2
FROM alpine:3.23
LABEL maintainer="monkeyWie"
WORKDIR /app
COPY --from=go /app/dist/gopeed ./
COPY entrypoint.sh ./entrypoint.sh
RUN apk update && \
apk add --no-cache su-exec ; \
chmod +x ./entrypoint.sh && \
rm -rf /var/cache/apk/*
VOLUME ["/app/storage"]
ENV PUID=0 PGID=0 UMASK=022
EXPOSE 9999
ENTRYPOINT ["./gopeed"]
ENTRYPOINT ["./entrypoint.sh"]
+172 -47
View File
@@ -1,39 +1,173 @@
# [![](_docs/img/banner.png)](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://docs.gopeed.com/donate.html)
[![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>
[![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)
[English](/README.md) | [中文](/README_zh-CN.md) | [日本語](/README_ja-JP.md) | [正體中文](/README_zh-TW.md) | [Tiếng Việt](/README_vi-VN.md)
## Introduction
## 🚀 Introduction
Gopeed (full name Go Speed), a high-speed downloader developed by `Golang` + `Flutter`, supports (HTTP, BitTorrent, Magnet) protocol, and supports all platforms.
Gopeed (full name Go Speed), a high-speed downloader developed by `Golang` + `Flutter`, supports (HTTP, BitTorrent, Magnet) protocol, and supports all platforms. In addition to basic download functions, Gopeed is also a highly customizable downloader that supports implementing more features through integration with [APIs](https://docs.gopeed.com/dev-api.html) or installation and development of [extensions](https://docs.gopeed.com/dev-extension.html).
Visit ✈ [Official Website](https://gopeed.com) | 📖 [Develop Docs](https://docs.gopeed.com)
Visit ✈ [Official Website](https://gopeed.com) | 📖 [Official Docs](https://docs.gopeed.com)
## Install
## ⬇️ Download
**Supported platforms**
<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>
- [x] windows
- [x] macos
- [x] linux
- [x] android
- [x] ios
- [x] web
- [x] docker
More about installation, please refer to [Installation](https://docs.gopeed.com/install.html)
[Download](https://github.com/GopeedLab/gopeed/releases/latest)
> Tips: If the macos open failed, please execute the `xattr -d com.apple.quarantine /Applications/Gopeed.app` command in the terminal
### Command tool
### 🛠️ Command tool
use `go install`:
@@ -41,54 +175,46 @@ use `go install`:
go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
```
### Docker
## 🔌 Browser Extension
#### Start directly
Gopeed also provides a browser extension to take over browser downloads, supporting browsers such as Chrome, Edge, Firefox, etc., please refer to: [https://github.com/GopeedLab/browser-extension](https://github.com/GopeedLab/browser-extension)
```bash
docker run -d -p 9999:9999 -v /path/to/download:/root/Downloads liwei2633/gopeed
```
## 📱 WeChat Official Account
#### Using Docker Compose
Follow our WeChat Official Account to get the latest updates and news.
```bash
docker-compose up -d
```
<img src="_docs/img/weixin.png" width="200" />
#### Access Gopeed
When the docker container is running, you can access the web page through `http://localhost:9999`.
## Donate
## 💝 Donate
If you like this project, please consider [donating](https://docs.gopeed.com/donate.html) to support the development of this project, thank you!
## Showcase
## 🖼️ Showcase
![](_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.10+
1. Golang 1.24+
2. Flutter 3.38+
### Clone
### 📋 Clone
```bash
git clone [email protected]:GopeedLab/gopeed.git
```
### Contributing
### 🤝 Contributing
Please refer to [CONTRIBUTING.md](/CONTRIBUTING.md)
### Build
### 🏗️ Build
#### Desktop
@@ -135,7 +261,7 @@ command:
- android
```bash
gomobile bind -tags nosqlite -ldflags="-w -s" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 19 -javapkg=com.gopeed github.com/GopeedLab/gopeed/bind/mobile
gomobile bind -tags nosqlite -ldflags="-w -s -checklinkname=0" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 21 -javapkg="com.gopeed" github.com/GopeedLab/gopeed/bind/mobile
cd ui/flutter
flutter build apk
```
@@ -159,21 +285,20 @@ 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
go run cmd/web/main.go
```
## Credits
## ❤️ Credits
### Contributors
### 👥 Contributors
<a href="https://github.com/GopeedLab/gopeed/graphs/contributors">
<img src="https://contrib.rocks/image?repo=GopeedLab/gopeed" />
</a>
### JetBrains
### 🏢 JetBrains
[![goland](_docs/img/goland.svg)](https://www.jetbrains.com/?from=gopeed)
## License
## 📄 License
[GPLv3](LICENSE)
+159 -53
View File
@@ -1,94 +1,201 @@
# [![](_docs/img/banner.png)](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://docs.gopeed.com/donate.html)
[![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>
[![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)
[English](/README.md) | [中文](/README_zh-CN.md) | [日本語](/README_ja-JP.md) | [正體中文](/README_zh-TW.md) | [Tiếng Việt](/README_vi-VN.md)
## はじめに
## 🚀 はじめに
Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された高速ダウンローダーで、(HTTP、BitTorrent、Magnet) プロトコルをサポートし、すべてのプラットフォームをサポートします。
Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された高速ダウンローダーで、(HTTP、BitTorrent、Magnet) プロトコルをサポートし、すべてのプラットフォームをサポートします。基本的なダウンロード機能に加え、[APIs](https://docs.gopeed.com/dev-api.html)との連動や[拡張機能](https://docs.gopeed.com/dev-extension.html)のインストール・開発による追加機能にも対応した、カスタマイズ性の高いダウンローダーです。
見て下さい ✈ [公式ウェブサイト](https://gopeed.com) | 📖 [開発ドキュメント](https://docs.gopeed.com)
## インストール
## ⬇️ インストール
**サポートするプラットフォーム**
<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://docs.gopeed.com/install.html)を参照してください。
- [x] windows
- [x] macos
- [x] linux
- [x] android
- [x] ios
- [x] web
- [x] docker
### 🛠️ コマンドツール
[ダウンロード](https://github.com/GopeedLab/gopeed/releases/latest)
## 📱 WeChat 公式アカウント
> ヒント: macos を開くのに失敗した場合は、ターミナルで `xattr -d com.apple.quarantine /Applications/Gopeed.app` コマンドを実行してください
公式アカウントをフォローして、最新のアップデートやニュースを入手してください。
### コマンドツール
<img src="_docs/img/weixin.png" width="200" />
`go install` を使用:
## 💝 寄付
```bash
go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
```
もしこのプロジェクトがお気に召しましたら、このプロジェクトの発展を支援するために[寄付](https://docs.gopeed.com/donate.html)をご検討ください!
### Docker
#### 直接始める
```bash
docker run -d -p 9999:9999 -v /path/to/download:/root/Downloads liwei2633/gopeed
```
#### Docker Compose の使用
```bash
docker-compose up -d
```
#### Gopeed へのアクセス
docker コンテナが起動しているときは、`http://localhost:9999` からウェブページにアクセスできます。
## 寄付
もしこのプロジェクトがお気に召しましたら、このプロジェクトの発展を支援するために[寄付](/.donate/index.md#donate)をご検討ください!
## ショーケース
## 🖼️ ショーケース
![](_docs/img/ui-demo.png)
## 開発
## 👨‍💻 開発
このプロジェクトは二つの部分に分かれており、フロントエンドでは `flutter` を、バックエンドでは `Golang` を使用し、両者は `http` プロトコルで通信する。ユニックスシステムでは `unix socket` を、ウィンドウズシステムでは `tcp` プロトコルを使用します。
> フロントコードは `ui/flutter` ディレクトリにあります。
### 環境
### 🌍 環境
1. Go 言語 1.19+
2. Flutter 3.10+
1. Go 言語 1.24+
2. Flutter 3.38+
### クローン
### 📋 クローン
```bash
git clone [email protected]:GopeedLab/gopeed.git
```
### コントリビュート
### 🤝 コントリビュート
[CONTRIBUTING.md](/CONTRIBUTING_ja-JP.md) をご参照ください
### ビルド
### 🏗️ ビルド
#### デスクトップ
@@ -135,7 +242,7 @@ gomobile init
- android
```bash
gomobile bind -tags nosqlite -ldflags="-w -s" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 19 -javapkg=com.gopeed github.com/GopeedLab/gopeed/bind/mobile
gomobile bind -tags nosqlite -ldflags="-w -s -checklinkname=0" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 21 -javapkg="com.gopeed" github.com/GopeedLab/gopeed/bind/mobile
cd ui/flutter
flutter build apk
```
@@ -159,10 +266,9 @@ 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
go run cmd/web/main.go
```
## 感謝
## ❤️ 感謝
### コントリビューター
+291
View File
@@ -0,0 +1,291 @@
# [![](_docs/img/banner.png)](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://docs.gopeed.com/donate.html)
[![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) 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://docs.gopeed.com/dev-api.html) hoặc cài đặt và phát triển các [tiện ích mở rộng](https://docs.gopeed.com/dev-extension.html).
Truy cập ✈ [Trang web chính thức](https://gopeed.com) | 📖 [Tài liệu chính thức](https://docs.gopeed.com)
## ⬇️ 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://docs.gopeed.com/install.html)
### 🛠️ 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://docs.gopeed.com/donate.html) để 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)
+161 -45
View File
@@ -1,39 +1,164 @@
# [![](_docs/img/banner.png)](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://docs.gopeed.com/zh/donate.html)
[![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>
[![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)
[English](/README.md) | [中文](/README_zh-CN.md) | [日本語](/README_ja-JP.md) | [正體中文](/README_zh-TW.md) | [Tiếng Việt](/README_vi-VN.md)
## 介绍
## 🚀 介绍
Gopeed(全称 Go Speed),是一款由`Golang`+`Flutter`开发的高速下载器,支持(HTTP、BitTorrent、Magnet)协议下载,并且支持全平台使用。
Gopeed(全称 Go Speed),直译过来中文名叫做`够快下载器`(不是狗屁下载器!),是一款由`Golang`+`Flutter`开发的高速下载器,支持(HTTP、BitTorrent、Magnet)协议下载,并且支持全平台使用。除了基本的下载功能外,Gopeed 还是一款高度可定制化的下载器,支持通过对接[APIs](https://docs.gopeed.com/zh/dev-api.html)或者安装和开发[扩展](https://docs.gopeed.com/zh/dev-extension.html)来实现更多的功能。
访问 ✈ [官方网站](https://gopeed.com/zh-CN) | 📖 [开发文档](https://docs.gopeed.com/zh/)
访问 ✈ [官方网站](https://gopeed.com/zh-CN) | 📖 [官方文档](https://docs.gopeed.com/zh/)
## 安装
## ⬇️ 下载
**已支持平台**
<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://docs.gopeed.com/zh/install.html)
- [x] windows
- [x] macos
- [x] linux
- [x] android
- [x] ios
- [x] web
- [x] docker
[前往下载](https://github.com/GopeedLab/gopeed/releases/latest)
> 注:macos 版本运行如果提示损坏,请在终端执行 `xattr -d com.apple.quarantine /Applications/Gopeed.app` 命令
### 命令行工具
### 🛠️ 命令行工具
使用`go install`安装:
@@ -41,54 +166,46 @@ Gopeed(全称 Go Speed),是一款由`Golang`+`Flutter`开发的高速下
go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
```
### Docker
## 🔌 浏览器扩展
#### 直接运行
Gopeed 还提供了浏览器扩展用于接管浏览器下载,支持 Chrome、Edge、Firefox 等浏览器,具体请参考:[https://github.com/GopeedLab/browser-extension](https://github.com/GopeedLab/browser-extension)
```bash
docker run -d -p 9999:9999 -v /path/to/download:/root/Downloads liwei2633/gopeed
```
## 📱 微信公众号
#### 使用 Docker Compose
关注公众号获取项目最新动态和资讯。
```bash
docker-compose up -d
```
<img src="_docs/img/weixin.png" width="200" />
#### 访问服务
当 docker 容器运行时,可以通过 `http://localhost:9999` 访问 web 页面。
## 赞助
## 💝 赞助
如果觉得项目对你有帮助,请考虑[赞助](https://docs.gopeed.com/zh/donate)以支持这个项目的发展,非常感谢!
## 界面展示
## 🖼️ 界面展示
![](_docs/img/ui-demo.png)
## 开发
## 👨‍💻 开发
本项目分为前端和后端两个部分,前端使用`flutter`,后端使用`Golang`,两边通过`http`协议进行通讯,在 unix 系统下,使用的是`unix socket`,在 windows 系统下,使用的是`tcp`协议。
> 前端代码位于`ui/flutter`目录下。
### 环境要求
### 🌍 环境要求
1. Golang 1.19+
2. Flutter 3.10+
1. Golang 1.24+
2. Flutter 3.38+
### 克隆项目
### 📋 克隆项目
```bash
git clone [email protected]:GopeedLab/gopeed.git
```
### 贡献代码
### 🤝 贡献代码
请参考[贡献指南](CONTRIBUTING_zh-CN.md)
### 编译
### 🏗️ 编译
#### 桌面端
@@ -135,7 +252,7 @@ gomobile init
- android
```bash
gomobile bind -tags nosqlite -ldflags="-w -s" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 19 -javapkg=com.gopeed github.com/GopeedLab/gopeed/bind/mobile
gomobile bind -tags nosqlite -ldflags="-w -s -checklinkname=0" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 21 -javapkg="com.gopeed" github.com/GopeedLab/gopeed/bind/mobile
cd ui/flutter
flutter build apk
```
@@ -159,10 +276,9 @@ 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
go run cmd/web/main.go
```
## 感谢
## ❤️ 感谢
### 贡献者
+292
View File
@@ -0,0 +1,292 @@
# [![](_docs/img/banner.png)](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://docs.gopeed.com/donate.html)
[![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)協定,同時支援所有的平台。
前往 ✈ [主頁](https://gopeed.com/zh-CN) | 📖 [文檔](https://docs.gopeed.com/zh/)
## ⬇️ 下載
<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://docs.gopeed.com/zh/install.html)
### 🛠️ 使用 CLI 安裝
使用`go install`安裝:
```bash
go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
```
## 📱 微信公眾號
關注公眾號獲取項目最新動態和資訊。
<img src="_docs/img/weixin.png" width="200" />
## 💝 贊助
如果你認為該項目對你有所幫助,請考慮[贊助](https://docs.gopeed.com/zh/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

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

+1
View File
@@ -2,6 +2,7 @@ package main
import (
"fmt"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/download"
"github.com/GopeedLab/gopeed/pkg/protocol/http"
+4 -3
View File
@@ -8,9 +8,10 @@ import (
// only for local development
func main() {
cfg := &model.StartConfig{
Network: "tcp",
Address: "127.0.0.1:9999",
Storage: model.StorageBolt,
Network: "tcp",
Address: "127.0.0.1:9999",
Storage: model.StorageBolt,
WebEnable: true,
}
cmd.Start(cfg)
}
+20 -3
View File
@@ -2,12 +2,14 @@ package main
import (
"fmt"
"strings"
"sync"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/download"
"github.com/GopeedLab/gopeed/pkg/protocol/http"
"github.com/GopeedLab/gopeed/pkg/util"
"strings"
"sync"
)
const progressWidth = 20
@@ -47,16 +49,31 @@ func main() {
if err != nil {
panic(err)
}
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.Meta.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.Meta.Res.Size)
+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)
}
+38 -6
View File
@@ -3,11 +3,14 @@ package cmd
import (
_ "embed"
"fmt"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/rest"
"github.com/GopeedLab/gopeed/pkg/rest/model"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
)
//go:embed banner.txt
@@ -24,17 +27,46 @@ func Start(cfg *model.StartConfig) {
panic(err)
}
if downloadCfg.FirstLoad {
// Set default download dir to user download dir
userDir, err := os.UserHomeDir()
if err == nil {
downloadDir := filepath.Join(userDir, "Downloads")
downloadCfg.DownloadDir = downloadDir
rest.Downloader.PutConfig(downloadCfg)
// Set default download config
if cfg.DownloadConfig != nil {
cfg.DownloadConfig.Merge(downloadCfg)
// TODO Use PatchConfig
rest.Downloader.PutConfig(cfg.DownloadConfig)
downloadCfg = cfg.DownloadConfig
}
downloadDir := downloadCfg.DownloadDir
// Set default download dir, in docker, it will be ${exe}/Downloads, else it will be ${user}/Downloads
if downloadDir == "" {
if base.InDocker == "true" {
downloadDir = filepath.Join(filepath.Dir(cfg.StorageDir), "Downloads")
} else {
userDir, err := os.UserHomeDir()
if err == nil {
downloadDir = filepath.Join(userDir, "Downloads")
}
}
if downloadDir != "" {
downloadCfg.DownloadDir = downloadDir
rest.Downloader.PutConfig(downloadCfg)
}
}
}
watchExit()
fmt.Printf("Server start success on http://%s\n", listener.Addr().String())
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
panic(err)
}
}
func watchExit() {
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-sigs
fmt.Printf("Server is shutting down due to signal: %s\n", sig)
rest.Downloader.Close()
os.Exit(0)
}()
}
+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
}
+171 -47
View File
@@ -3,73 +3,197 @@ package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"github.com/GopeedLab/gopeed/pkg/base"
)
type args struct {
Address *string `json:"address"`
Port *int `json:"port"`
Username *string `json:"username"`
Password *string `json:"password"`
ApiToken *string `json:"apiToken"`
Address *string `json:"address"`
Port *int `json:"port"`
Username *string `json:"username"`
Password *string `json:"password"`
ApiToken *string `json:"apiToken"`
StorageDir *string `json:"storageDir"`
WhiteDownloadDirs []string `json:"whiteDownloadDirs"`
// DownloadConfig when the first time to start the server, it will be configured as initial value
DownloadConfig *base.DownloaderStoreConfig `json:"downloadConfig"`
configPath *string
}
func parse() *args {
var cliArgs args
cliArgs.Address = flag.String("A", "0.0.0.0", "Bind Address")
cliArgs.Port = flag.Int("P", 9999, "Bind Port")
cliArgs.Username = flag.String("u", "gopeed", "HTTP Basic Auth Username")
cliArgs.Password = flag.String("p", "", "HTTP Basic Auth Password")
cliArgs.ApiToken = flag.String("T", "", "API token, that can only be used when basic authentication is enabled.")
cliArgs.configPath = flag.String("c", "./config.json", "Config file path")
cfg := &args{}
cliConfig := loadCliArgs()
loadConfigFile(cfg, *cliConfig.configPath)
loadEnvVars(cfg)
// override with non-default command line arguments
overrideWithCliArgs(cfg, cliConfig)
// set default values
setDefaults(cfg, cliConfig)
return cfg
}
// loadCliArgs parses command line arguments and returns initial config
func loadCliArgs() *args {
cfg := &args{}
cfg.Address = flag.String("A", "0.0.0.0", "Bind Address")
cfg.Port = flag.Int("P", 9999, "Bind Port")
cfg.Username = flag.String("u", "gopeed", "Web Authentication Username")
cfg.Password = flag.String("p", "", "Web Authentication Password, if no password is set, web authentication will not be enabled")
cfg.ApiToken = flag.String("T", "", "API token, it must be configured when using HTTP API in the case of enabling web authentication")
cfg.StorageDir = flag.String("d", "", "Storage directory")
whiteDownloadDirs := flag.String("w", "", "White download directories, comma-separated")
cfg.configPath = flag.String("c", "./config.json", "Config file path")
flag.Parse()
// args priority: config file > cli args
cfgArgs := loadConfig(*cliArgs.configPath)
if cfgArgs.Address == nil {
cfgArgs.Address = cliArgs.Address
// Parse white download directories from comma-separated string
if whiteDownloadDirs != nil && *whiteDownloadDirs != "" {
dirs := strings.Split(*whiteDownloadDirs, ",")
for i := range dirs {
dirs[i] = strings.TrimSpace(dirs[i])
}
cfg.WhiteDownloadDirs = dirs
}
if cfgArgs.Port == nil {
cfgArgs.Port = cliArgs.Port
}
if cfgArgs.Username == nil {
cfgArgs.Username = cliArgs.Username
}
if cfgArgs.Password == nil {
cfgArgs.Password = cliArgs.Password
}
if cfgArgs.ApiToken == nil {
cfgArgs.ApiToken = cliArgs.ApiToken
}
return cfgArgs
return cfg
}
func loadConfig(path string) *args {
var args args
if !filepath.IsAbs(path) {
dir, err := filepath.Abs(filepath.Dir(os.Args[0]))
if err != nil {
return &args
// overrideWithCliArgs overrides config with non-empty command line arguments
func overrideWithCliArgs(cfg *args, cliConfig *args) {
flag.Visit(func(f *flag.Flag) {
switch f.Name {
case "A":
cfg.Address = cliConfig.Address
case "P":
cfg.Port = cliConfig.Port
case "u":
cfg.Username = cliConfig.Username
case "p":
cfg.Password = cliConfig.Password
case "T":
cfg.ApiToken = cliConfig.ApiToken
case "d":
cfg.StorageDir = cliConfig.StorageDir
case "w":
cfg.WhiteDownloadDirs = cliConfig.WhiteDownloadDirs
case "c":
cfg.configPath = cliConfig.configPath
}
path = filepath.Join(dir, path)
})
}
// setDefaults sets default values for any unset configuration fields
func setDefaults(cfg *args, cliConfig *args) {
if cfg.Address == nil {
cfg.Address = cliConfig.Address
}
file, err := os.ReadFile(path)
if cfg.Port == nil {
cfg.Port = cliConfig.Port
}
if cfg.Username == nil {
cfg.Username = cliConfig.Username
}
if cfg.Password == nil {
cfg.Password = cliConfig.Password
}
if cfg.ApiToken == nil {
cfg.ApiToken = cliConfig.ApiToken
}
if cfg.StorageDir == nil {
cfg.StorageDir = cliConfig.StorageDir
}
}
// loadConfigFile loads configuration from file
func loadConfigFile(cfg *args, configPath string) {
if !filepath.IsAbs(configPath) {
dir, err := os.Getwd()
if err != nil {
return
}
configPath = filepath.Join(dir, configPath)
}
file, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
return &args
return
}
fmt.Println("config file read failed, reason:" + err.Error())
return &args
return
}
if err = json.Unmarshal(file, &args); err != nil {
fmt.Println("config file parse failed, reason:" + err.Error())
return &args
if err = json.Unmarshal(file, cfg); err != nil {
return
}
}
// loadEnvVars loads configuration from environment variables with prefix GOPEED_
func loadEnvVars(cfg *args) {
v := reflect.ValueOf(cfg).Elem()
t := reflect.TypeOf(cfg).Elem()
for i := 0; i < v.NumField(); i++ {
field := v.Field(i)
fieldType := t.Field(i)
// Get json tag as environment variable suffix
jsonTag := fieldType.Tag.Get("json")
if jsonTag == "" || jsonTag == "-" {
continue
}
// Remove options like omitempty
if commaIdx := strings.Index(jsonTag, ","); commaIdx != -1 {
jsonTag = jsonTag[:commaIdx]
}
// Convert to uppercase and add GOPEED_ prefix
envKey := "GOPEED_" + strings.ToUpper(jsonTag)
envValue := os.Getenv(envKey)
if envValue == "" {
continue
}
// Set value based on field type
if field.Kind() == reflect.Ptr {
if field.IsNil() {
// Create new pointer instance
newVal := reflect.New(field.Type().Elem())
field.Set(newVal)
}
switch field.Type().Elem().Kind() {
case reflect.String:
field.Elem().SetString(envValue)
case reflect.Int:
if intVal, err := strconv.Atoi(envValue); err == nil {
field.Elem().SetInt(int64(intVal))
}
default:
// For complex types like DownloadConfig, try JSON unmarshaling
if field.Type().Elem() == reflect.TypeOf(base.DownloaderStoreConfig{}) {
var config base.DownloaderStoreConfig
if err := json.Unmarshal([]byte(envValue), &config); err == nil {
field.Set(reflect.ValueOf(&config))
}
}
}
} else if field.Kind() == reflect.Slice {
// Handle non-pointer slice types (like []string for WhiteDownloadDirs)
if field.Type().Elem().Kind() == reflect.String {
dirs := strings.Split(envValue, ",")
for i := range dirs {
dirs[i] = strings.TrimSpace(dirs[i])
}
field.Set(reflect.ValueOf(dirs))
}
}
}
fmt.Printf("config file loaded: %s\n", path)
return &args
}
File diff suppressed because it is too large. Load diff
+28 -11
View File
@@ -6,9 +6,12 @@ package main
import (
"embed"
"fmt"
"io/fs"
"os"
"path/filepath"
"github.com/GopeedLab/gopeed/cmd"
"github.com/GopeedLab/gopeed/pkg/rest/model"
"io/fs"
)
//go:embed dist/*
@@ -21,23 +24,37 @@ func main() {
}
args := parse()
var webBasicAuth *model.WebBasicAuth
var webAuth *model.WebAuth
if isNotBlank(args.Username) && isNotBlank(args.Password) {
webBasicAuth = &model.WebBasicAuth{
webAuth = &model.WebAuth{
Username: *args.Username,
Password: *args.Password,
}
}
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: fmt.Sprintf("%s:%d", *args.Address, *args.Port),
Storage: model.StorageBolt,
ApiToken: *args.ApiToken,
ProductionMode: true,
WebEnable: true,
WebFS: sub,
WebBasicAuth: webBasicAuth,
Network: "tcp",
Address: fmt.Sprintf("%s:%d", *args.Address, *args.Port),
Storage: model.StorageBolt,
StorageDir: storageDir,
WhiteDownloadDirs: args.WhiteDownloadDirs,
ApiToken: *args.ApiToken,
DownloadConfig: args.DownloadConfig,
ProductionMode: true,
WebEnable: true,
WebFS: sub,
WebAuth: webAuth,
}
cmd.Start(cfg)
}
+14 -10
View File
@@ -1,11 +1,15 @@
version: '3'
services:
gopeed:
container_name: gopeed
ports:
- 9999:9999 # HTTP port (host:container)
image: liwei2633/gopeed
volumes:
- ~/Downloads:/root/Downloads # replace with your prefer path
restart: unless-stopped
gopeed:
container_name: gopeed
ports:
- 9999:9999 # HTTP port (host:container)
environment:
- PUID=0
- PGID=0
- UMASK=022
volumes:
- ~/gopeed/Downloads:/app/Downloads # mount download path
#- ~/gopeed/storage:/app/storage # if you need to mount storage path, uncomment this line
restart: unless-stopped
image: liwei2633/gopeed
# command: -u Username -p Password # optional authentication
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
chown -R ${PUID}:${PGID} /app
umask ${UMASK}
exec su-exec ${PUID}:${PGID} ./gopeed "$@"
+127 -70
View File
@@ -1,107 +1,164 @@
module github.com/GopeedLab/gopeed
go 1.19
go 1.24.9
toolchain go1.24.11
require (
github.com/anacrolix/missinggo/v2 v2.7.2-0.20230527121029-a582b4f397b9
github.com/anacrolix/torrent v1.52.5
github.com/dop251/goja v0.0.0-20230828202809-3dbe69dd2b8e
github.com/dop251/goja_nodejs v0.0.0-20230821135201-94e508132562
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
github.com/virtuald/go-paniclog v0.0.0-20190812204905-43a7fa316459
go.etcd.io/bbolt v1.3.7-0.20231005155708-db45d0d6b1f6
golang.org/x/sync v0.3.0
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 (
github.com/STARRY-S/zip v0.2.3 // indirect
github.com/anacrolix/btree v0.1.1 // indirect
github.com/anacrolix/missinggo/v2 v2.10.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/bodgit/plumbing v1.3.0 // indirect
github.com/bodgit/windows v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
github.com/felixge/fgprof v0.9.5 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/icholy/digest v1.1.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/mikelolasagasti/xz v1.0.1 // indirect
github.com/minio/minlz v1.0.1 // indirect
github.com/nwaples/rardecode/v2 v2.2.0 // indirect
github.com/onsi/ginkgo/v2 v2.23.4 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/pion/transport/v4 v4.0.1 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.51.0 // indirect
github.com/refraction-networking/utls v1.6.7 // indirect
github.com/sorairolake/lzip-go v0.3.8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/mock v0.5.1 // indirect
go4.org v0.0.0-20230225012048-214862532bf5 // indirect
golang.org/x/sync v0.19.0 // indirect
)
require (
crawshaw.io/sqlite v0.3.3-0.20220618202545-d1964889ea3c // indirect
dario.cat/mergo v1.0.0 // indirect
github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/Microsoft/go-winio v0.6.1
github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95 // indirect
github.com/RoaringBitmap/roaring v1.2.3 // indirect
github.com/RoaringBitmap/roaring v1.9.4 // indirect
github.com/acomagu/bufpipe v1.0.4 // indirect
github.com/ajwerner/btree v0.0.0-20211221152037-f427b3e689c0 // indirect
github.com/alecthomas/atomic v0.1.0-alpha2 // indirect
github.com/anacrolix/chansync v0.3.0 // indirect
github.com/anacrolix/dht/v2 v2.19.2-0.20221121215055-066ad8494444 // indirect
github.com/anacrolix/envpprof v1.2.1 // indirect
github.com/anacrolix/generics v0.0.0-20230428105757-683593396d68 // indirect
github.com/anacrolix/go-libutp v1.3.1 // indirect
github.com/anacrolix/log v0.14.0 // indirect
github.com/anacrolix/chansync v0.7.0 // indirect
github.com/anacrolix/dht/v2 v2.23.0 // indirect
github.com/anacrolix/envpprof v1.5.0 // indirect
github.com/anacrolix/generics v0.2.0 // indirect
github.com/anacrolix/go-libutp v1.3.2 // indirect
github.com/anacrolix/log v0.17.1-0.20251118025802-918f1157b7bb // indirect
github.com/anacrolix/missinggo v1.3.0 // indirect
github.com/anacrolix/missinggo/perf v1.0.0 // indirect
github.com/anacrolix/mmsg v1.0.0 // indirect
github.com/anacrolix/multiless v0.3.0 // indirect
github.com/anacrolix/stm v0.4.0 // indirect
github.com/anacrolix/sync v0.4.0 // indirect
github.com/anacrolix/upnp v0.1.3-0.20220123035249-922794e51c96 // indirect
github.com/anacrolix/utp v0.1.0 // indirect
github.com/anacrolix/mmsg v1.1.1 // indirect
github.com/anacrolix/multiless v0.4.0 // indirect
github.com/anacrolix/stm v0.5.0 // indirect
github.com/anacrolix/sync v0.6.0 // indirect
github.com/anacrolix/upnp v0.1.4 // indirect
github.com/anacrolix/utp v0.2.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/benbjohnson/immutable v0.3.0 // indirect
github.com/bits-and-blooms/bitset v1.2.2 // indirect
github.com/benbjohnson/immutable v0.4.3 // indirect
github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect
github.com/cloudflare/circl v1.3.3 // indirect
github.com/cespare/xxhash v1.1.0 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dlclark/regexp2 v1.7.0 // indirect
github.com/dustin/go-humanize v1.0.0 // indirect
github.com/edsrzf/mmap-go v1.1.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/edsrzf/mmap-go v1.2.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/felixge/httpsnoop v1.0.1 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.4.1 // indirect
github.com/go-logr/logr v1.2.3 // indirect
github.com/go-llsqlite/adapter v0.2.0 // indirect
github.com/go-llsqlite/crawshaw v0.6.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/google/btree v1.1.2 // indirect
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/huandu/xstrings v1.3.2 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/lispad/go-generics-tools v1.1.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/minio/sha256-simd v1.0.1 // indirect
github.com/mr-tron/base58 v1.2.0 // indirect
github.com/mschoch/smat v0.2.0 // indirect
github.com/pion/datachannel v1.5.2 // indirect
github.com/pion/dtls/v2 v2.2.4 // indirect
github.com/pion/ice/v2 v2.2.6 // indirect
github.com/pion/interceptor v0.1.11 // indirect
github.com/pion/logging v0.2.2 // indirect
github.com/pion/mdns v0.0.5 // indirect
github.com/multiformats/go-multihash v0.2.3 // indirect
github.com/multiformats/go-varint v0.1.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pion/datachannel v1.6.0 // indirect
github.com/pion/dtls/v3 v3.0.10 // indirect
github.com/pion/ice/v4 v4.2.0 // indirect
github.com/pion/interceptor v0.1.42 // indirect
github.com/pion/logging v0.2.4 // indirect
github.com/pion/mdns/v2 v2.1.0 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.9 // indirect
github.com/pion/rtp v1.7.13 // indirect
github.com/pion/sctp v1.8.2 // indirect
github.com/pion/sdp/v3 v3.0.5 // indirect
github.com/pion/srtp/v2 v2.0.9 // indirect
github.com/pion/stun v0.3.5 // indirect
github.com/pion/transport v0.13.1 // indirect
github.com/pion/transport/v2 v2.0.0 // indirect
github.com/pion/turn/v2 v2.0.8 // indirect
github.com/pion/udp v0.1.4 // indirect
github.com/pion/webrtc/v3 v3.1.42 // indirect
github.com/pion/rtcp v1.2.16 // indirect
github.com/pion/rtp v1.10.0 // indirect
github.com/pion/sctp v1.9.1 // indirect
github.com/pion/sdp/v3 v3.0.17 // indirect
github.com/pion/srtp/v3 v3.0.10 // indirect
github.com/pion/stun/v3 v3.1.1 // indirect
github.com/pion/turn/v4 v4.1.4 // indirect
github.com/pion/webrtc/v4 v4.2.2-0.20260109001657-a5962f314db7 // indirect
github.com/pjbgf/sha1cd v0.3.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rs/dnscache v0.0.0-20211102005908-e0241e321417 // indirect
github.com/rs/zerolog v1.31.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/tidwall/btree v1.6.0 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/tidwall/btree v1.8.1 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
go.opentelemetry.io/otel v1.8.0 // indirect
go.opentelemetry.io/otel/trace v1.8.0 // indirect
golang.org/x/crypto v0.11.0 // indirect
golang.org/x/exp v0.0.0-20220613132600-b0d781184e0d // indirect
golang.org/x/mod v0.8.0 // indirect
golang.org/x/net v0.12.0 // indirect
golang.org/x/sys v0.13.0 // indirect
golang.org/x/text v0.11.0 // indirect
golang.org/x/time v0.0.0-20220609170525-579cf78fd858 // indirect
golang.org/x/tools v0.6.0 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.40.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
lukechampine.com/blake3 v1.4.1 // indirect
modernc.org/libc v1.67.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.43.0 // indirect
zombiezen.com/go/sqlite v1.4.2 // indirect
)
+476 -213
View File
File diff suppressed because it is too large. Load diff
+9 -4
View File
@@ -1,12 +1,16 @@
package controller
import (
"github.com/GopeedLab/gopeed/pkg/base"
"net/http"
"net/url"
"os"
"path/filepath"
)
type Controller struct {
GetConfig func(v any) (bool, error)
GetConfig func(v any)
GetProxy func(requestProxy *base.RequestProxy) func(*http.Request) (*url.URL, error)
FileController
//ContextDialer() (proxy.Dialer, error)
}
@@ -20,10 +24,11 @@ type DefaultFileController struct {
func NewController() *Controller {
return &Controller{
FileController: &DefaultFileController{},
GetConfig: func(v any) (bool, error) {
return false, nil
GetConfig: func(v any) {},
GetProxy: func(requestProxy *base.RequestProxy) func(*http.Request) (*url.URL, error) {
return requestProxy.ToHandler()
},
FileController: &DefaultFileController{},
}
}
+70 -13
View File
@@ -1,26 +1,28 @@
package fetcher
import (
"path"
"strings"
"github.com/GopeedLab/gopeed/internal/controller"
"github.com/GopeedLab/gopeed/pkg/base"
"path"
)
// Fetcher defines the interface for a download protocol.
// One fetcher for each download task
// Each download task will have a corresponding Fetcher instance for the management of the download task
type Fetcher interface {
// Name return the name of the protocol.
Name() string
Setup(ctl *controller.Controller)
// Resolve resource info from request
Resolve(req *base.Request) error
// Create ready to download, but not started
Create(opts *base.Options) error
Resolve(req *base.Request, opts *base.Options) error
Start() error
// Patch modifies task-specific data based on the protocol.
// For HTTP: can modify Request info (URL, headers, etc.)
// For BT: can modify SelectFiles (via opts.SelectFiles)
Patch(req *base.Request, opts *base.Options) error
Pause() error
Close() error
// Stats refreshes health statistics and returns the latest information
Stats() any
// Meta returns the meta information of the download.
Meta() *FetcherMeta
// Progress returns the progress of the download.
@@ -29,6 +31,12 @@ type Fetcher interface {
Wait() error
}
type Uploader interface {
Upload() error
UploadedBytes() int64
WaitUpload() error
}
// FetcherMeta defines the meta information of a fetcher.
type FetcherMeta struct {
Req *base.Request `json:"req"`
@@ -57,17 +65,66 @@ func (m *FetcherMeta) SingleFilepath() string {
return path.Join(m.Opts.Path, file.Path, fileName)
}
// FetcherBuilder defines the interface for a fetcher builder.
type FetcherBuilder interface {
// Schemes returns the schemes supported by the fetcher.
Schemes() []string
// 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 fetcher
Restore() (v any, f func(meta *FetcherMeta, v any) Fetcher)
// Close the fetcher manager, release resources.
Close() error
}
type DefaultFetcher struct {
+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)
}
})
}
}
+8 -1
View File
@@ -1,5 +1,12 @@
package bt
type config struct {
Trackers []string `json:"trackers"`
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)
}
}
}
+465 -131
View File
@@ -2,6 +2,17 @@ package bt
import (
"bytes"
"context"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/GopeedLab/gopeed/internal/controller"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/pkg/base"
@@ -9,32 +20,30 @@ import (
"github.com/GopeedLab/gopeed/pkg/util"
"github.com/anacrolix/torrent"
"github.com/anacrolix/torrent/metainfo"
"path/filepath"
"sync"
"sync/atomic"
"time"
"github.com/anacrolix/torrent/storage"
)
var (
client *torrent.Client
lock sync.Mutex
torrentDirMap = make(map[string]string)
ftMap = make(map[string]*fileTorrentImpl)
cfg *torrent.ClientConfig
client *torrent.Client
lock sync.Mutex
closeCtx context.Context
closeFunc func()
)
type Fetcher struct {
ctl *controller.Controller
ctl *controller.Controller
config *config
torrent *torrent.Torrent
meta *fetcher.FetcherMeta
data *fetcherData
torrentReady atomic.Bool
create atomic.Bool
progress fetcher.Progress
}
func (f *Fetcher) Name() string {
return "bt"
torrentReady atomic.Bool
torrentUpload atomic.Bool
torrentDropCtx context.Context
torrentDropFunc func()
uploadDoneCh chan any
}
func (f *Fetcher) Setup(ctl *controller.Controller) {
@@ -42,6 +51,12 @@ func (f *Fetcher) Setup(ctl *controller.Controller) {
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
}
@@ -52,55 +67,61 @@ func (f *Fetcher) initClient() (err error) {
if client != nil {
return
}
if closeCtx == nil {
closeCtx, closeFunc = context.WithCancel(context.Background())
}
cfg := torrent.NewDefaultClientConfig()
cfg.ListenPort = 0
cfg.DefaultStorage = newFileOpts(newFileClientOpts{
ClientBaseDir: cfg.DataDir,
HandleFileTorrent: func(infoHash metainfo.Hash, ft *fileTorrentImpl) {
if dir, ok := torrentDirMap[infoHash.String()]; ok {
ft.setTorrentDir(dir)
}
ftMap[infoHash.String()] = ft
},
})
cfg = torrent.NewDefaultClientConfig()
cfg.Seed = true
cfg.Bep20 = fmt.Sprintf("-GP%s-", parseBep20())
cfg.ExtendedHandshakeClientVersion = fmt.Sprintf("Gopeed %s", base.Version)
cfg.ListenPort = f.config.ListenPort
cfg.HTTPProxy = f.ctl.GetProxy(f.meta.Req.Proxy)
dnsResolver := &DnsCacheResolver{RefreshTimeout: 5 * time.Minute}
cfg.TrackerDialContext = dnsResolver.DialContext
client, err = torrent.NewClient(cfg)
if err != nil {
return
}
closeCtx, closeFunc = context.WithCancel(context.Background())
go func() {
dnsResolver.Run(closeCtx)
}()
return
}
func (f *Fetcher) Resolve(req *base.Request) error {
if err := base.ParseReqExtra[bt.ReqExtra](req); err != nil {
return err
func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
f.meta.Req = req
f.meta.Opts = opts
if f.meta.Opts == nil {
f.meta.Opts = &base.Options{}
}
if err := f.addTorrent(req); err != nil {
if err := f.addTorrent(req, false); err != nil {
return err
}
f.updateRes()
f.meta.Req = req
return nil
}
func (f *Fetcher) Create(opts *base.Options) (err error) {
f.create.Store(true)
f.meta.Opts = opts
if f.meta.Res != nil {
torrentDirMap[f.meta.Res.Hash] = f.meta.FolderPath()
}
f.progress = make(fetcher.Progress, len(f.meta.Opts.SelectFiles))
return nil
}
func (f *Fetcher) Start() (err error) {
if !f.torrentReady.Load() {
if err = f.addTorrent(f.meta.Req); err != nil {
if err = f.addTorrent(f.meta.Req, false); err != nil {
return
}
}
if ft, ok := ftMap[f.meta.Res.Hash]; ok {
ft.setTorrentDir(f.meta.FolderPath())
}
files := f.torrent.Files()
// If the user does not specify the file to download, all files will be downloaded by default
if 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 {
@@ -109,17 +130,22 @@ func (f *Fetcher) Start() (err error) {
file.Download()
}
}
f.torrent.AllowDataDownload()
return
}
func (f *Fetcher) Pause() (err error) {
f.torrentReady.Store(false)
f.safeDrop()
f.torrent.DisallowDataDownload()
return
}
func (f *Fetcher) Close() (err error) {
f.safeDrop()
f.torrentDropFunc()
f.uploadDoneCh <- nil
if len(client.Torrents()) == 0 {
err = closeClient()
}
return nil
}
@@ -132,152 +158,460 @@ func (f *Fetcher) safeDrop() {
f.torrent.Drop()
}
func (f *Fetcher) Wait() (err error) {
for {
if f.torrentReady.Load() {
done := true
for _, selectIndex := range f.meta.Opts.SelectFiles {
file := f.torrent.Files()[selectIndex]
if file.BytesCompleted() < file.Length() {
done = false
break
}
}
if done {
// remove unselected files
for i, file := range f.torrent.Files() {
selected := false
for _, selectIndex := range f.meta.Opts.SelectFiles {
if i == selectIndex {
selected = true
break
}
}
if !selected {
util.SafeRemove(filepath.Join(f.meta.Opts.Path, file.Path()))
}
}
break
}
}
time.Sleep(time.Millisecond * 500)
}
return nil
}
func (f *Fetcher) Meta() *fetcher.FetcherMeta {
return f.meta
}
func (f *Fetcher) Stats() any {
var stats torrent.TorrentStats
if f.torrent != nil {
stats = f.torrent.Stats()
} else {
stats = torrent.TorrentStats{}
}
return &bt.Stats{
TotalPeers: stats.TotalPeers,
ActivePeers: stats.ActivePeers,
ConnectedSeeders: stats.ConnectedSeeders,
SeedBytes: f.data.SeedBytes,
SeedRatio: f.seedRadio(),
SeedTime: f.data.SeedTime,
}
}
func (f *Fetcher) Progress() fetcher.Progress {
if !f.torrentReady.Load() {
return f.progress
return f.data.Progress
}
for i := range f.progress {
for i := range f.data.Progress {
selectIndex := f.meta.Opts.SelectFiles[i]
file := f.torrent.Files()[selectIndex]
f.progress[i] = file.BytesCompleted()
f.data.Progress[i] = file.BytesCompleted()
}
return f.progress
return f.data.Progress
}
func (f *Fetcher) Wait() (err error) {
for {
select {
case <-f.torrentDropCtx.Done():
return
case <-time.After(time.Second):
if f.torrentReady.Load() && len(f.meta.Opts.SelectFiles) > 0 {
if f.isDone() {
// remove unselected files
for i, file := range f.torrent.Files() {
selected := false
for _, selectIndex := range f.meta.Opts.SelectFiles {
if i == selectIndex {
selected = true
break
}
}
if !selected {
util.SafeRemove(filepath.Join(f.meta.Opts.Path, f.meta.Res.Name, file.Path()))
}
}
return
}
}
}
}
}
func (f *Fetcher) isDone() bool {
if f.meta.Opts == nil {
return false
}
for _, selectIndex := range f.meta.Opts.SelectFiles {
file := f.torrent.Files()[selectIndex]
if file.BytesCompleted() < file.Length() {
return false
}
}
return true
}
// Patch modifies the BT task settings.
// Invalid file indices are silently ignored.
func (f *Fetcher) Patch(req *base.Request, opts *base.Options) error {
if opts == nil {
return nil
}
if opts.SelectFiles != nil {
selectFiles := opts.SelectFiles
// Get file count from resource metadata
fileCount := 0
if f.meta.Res != nil {
fileCount = len(f.meta.Res.Files)
}
// Filter out invalid indices (silently ignore)
validSelectFiles := make([]int, 0, len(selectFiles))
for _, idx := range selectFiles {
if idx >= 0 && idx < fileCount {
validSelectFiles = append(validSelectFiles, idx)
}
}
if f.torrent != nil {
files := f.torrent.Files()
// Cancel all current file downloads first
f.torrent.CancelPieces(0, f.torrent.NumPieces())
// Apply new file selection
if len(validSelectFiles) == len(files) {
f.torrent.DownloadAll()
} else {
for _, selectIndex := range validSelectFiles {
file := files[selectIndex]
file.Download()
}
}
}
f.meta.Opts.SelectFiles = validSelectFiles
// Recalculate the resource size based on new selection
if f.meta.Res != nil {
f.meta.Res.CalcSize(validSelectFiles)
}
// Reset progress tracking for new file selection
f.data.Progress = make(fetcher.Progress, len(validSelectFiles))
}
return nil
}
func (f *Fetcher) updateRes() {
res := &base.Resource{
Name: f.torrent.Name(),
Range: true,
Files: make([]*base.FileInfo, len(f.torrent.Files())),
Hash: f.torrent.InfoHash().String(),
}
// Directory torrent
if f.torrent.Info().Length == 0 {
res.Name = f.torrent.Name()
}
for i, file := range f.torrent.Files() {
res.Files[i] = &base.FileInfo{
Name: filepath.Base(file.DisplayPath()),
Path: util.Dir(file.Path()),
Path: util.Dir(file.DisplayPath()),
Size: file.Length(),
}
}
res.CalcSize()
res.CalcSize(nil)
f.meta.Res = res
if f.meta.Opts != nil {
f.meta.Opts.InitSelectFiles(len(res.Files))
}
}
func (f *Fetcher) addTorrent(req *base.Request) (err error) {
func (f *Fetcher) Upload() (err error) {
return f.addTorrent(f.meta.Req, true)
}
func (f *Fetcher) doUpload(fromUpload bool) {
if !f.torrentUpload.CompareAndSwap(false, true) {
return
}
// Check and update seed data
lastData := &fetcherData{
SeedBytes: f.data.SeedBytes,
SeedTime: f.data.SeedTime,
}
var doneTime int64 = 0
for {
select {
case <-f.torrentDropCtx.Done():
return
case <-time.After(time.Second):
if !f.torrentReady.Load() {
continue
}
stats := f.torrentStats()
f.data.SeedBytes = lastData.SeedBytes + stats.BytesWrittenData.Int64()
// Check is download complete, if not don't check and stop seeding
if !fromUpload && !f.isDone() {
continue
}
if doneTime == 0 {
doneTime = time.Now().Unix()
}
f.data.SeedTime = lastData.SeedTime + time.Now().Unix() - doneTime
// If the seed forever is true, keep seeding
if f.config.SeedKeep {
continue
}
// If the seed ratio is reached, stop seeding
if f.config.SeedRatio > 0 {
seedRadio := f.seedRadio()
if seedRadio >= f.config.SeedRatio {
f.Close()
break
}
}
// If the seed time is reached, stop seeding
if f.config.SeedTime > 0 {
if f.data.SeedTime >= f.config.SeedTime {
f.Close()
break
}
}
}
}
}
// Get torrent stats maybe panic, see https://github.com/anacrolix/torrent/issues/972
func (f *Fetcher) torrentStats() torrent.TorrentStats {
defer func() {
if r := recover(); r != nil {
// ignore panic
}
}()
return f.torrent.Stats()
}
func (f *Fetcher) UploadedBytes() int64 {
return f.data.SeedBytes
}
func (f *Fetcher) WaitUpload() (err error) {
<-f.uploadDoneCh
return nil
}
func (f *Fetcher) addTorrent(req *base.Request, fromUpload bool) (err error) {
if err = base.ParseReqExtra[bt.ReqExtra](req); err != nil {
return
}
if err = f.initClient(); err != nil {
return
}
schema := util.ParseSchema(req.URL)
privateTorrent := false
var spec *torrent.TorrentSpec
if schema == "MAGNET" {
f.torrent, err = client.AddMagnet(req.URL)
} else if schema == "APPLICATION/X-BITTORRENT" {
_, data := util.ParseDataUri(req.URL)
buf := bytes.NewBuffer(data)
var metaInfo *metainfo.MetaInfo
metaInfo, err = metainfo.Load(buf)
spec, err = torrent.TorrentSpecFromMagnetUri(req.URL)
if err != nil {
return
}
} else {
var reader io.Reader
if schema == "FILE" {
fileUrl, _ := url.Parse(req.URL)
filePath := fileUrl.Path[1:]
reader, err = os.Open(filePath)
if err != nil {
return
}
} else if schema == "DATA" {
_, data := util.ParseDataUri(req.URL)
reader = bytes.NewBuffer(data)
} else {
reader, err = os.Open(req.URL)
if err != nil {
return
}
defer reader.(io.Closer).Close()
}
var metaInfo *metainfo.MetaInfo
metaInfo, err = metainfo.Load(reader)
// Hotfix for https://github.com/anacrolix/torrent/issues/992, ignore "expected EOF" error
// TODO remove this after the issue is fixed
if err != nil && !strings.Contains(err.Error(), "expected EOF") {
return err
}
f.torrent, err = client.AddTorrent(metaInfo)
} else {
f.torrent, err = client.AddTorrentFromFile(req.URL)
info, er := metaInfo.UnmarshalInfo()
if er != nil {
return er
}
if info.Private != nil && *info.Private {
privateTorrent = true
}
spec, err = torrent.TorrentSpecFromMetaInfoErr(metaInfo)
if err != nil {
return
}
}
if err != nil {
return
}
var cfg config
exist, err := f.ctl.GetConfig(&cfg)
spec.Storage = storage.NewFileOpts(storage.NewFileClientOpts{
ClientBaseDir: cfg.DataDir,
TorrentDirMaker: func(baseDir string, info *metainfo.Info, infoHash metainfo.Hash) string {
return f.meta.Opts.Path
},
})
f.torrent, _, err = client.AddTorrentSpec(spec)
if err != nil {
return
}
// use map to deduplicate
trackers := make(map[string]bool)
if req.Extra != nil {
extra := req.Extra.(*bt.ReqExtra)
if len(extra.Trackers) > 0 {
for _, tracker := range extra.Trackers {
// Do not add external tracker to a private torrent.
if !privateTorrent {
// use map to deduplicate
trackers := make(map[string]bool)
if req.Extra != nil {
extra := req.Extra.(*bt.ReqExtra)
if len(extra.Trackers) > 0 {
for _, tracker := range extra.Trackers {
trackers[tracker] = true
}
}
}
if len(f.config.Trackers) > 0 {
for _, tracker := range f.config.Trackers {
trackers[tracker] = true
}
}
}
if exist && len(cfg.Trackers) > 0 {
for _, tracker := range cfg.Trackers {
trackers[tracker] = true
if len(trackers) > 0 {
announceList := make([][]string, 0)
for tracker := range trackers {
announceList = append(announceList, []string{tracker})
}
f.torrent.AddTrackers(announceList)
}
}
if len(trackers) > 0 {
announceList := make([][]string, 0)
for tracker := range trackers {
announceList = append(announceList, []string{tracker})
}
f.torrent.AddTrackers(announceList)
}
<-f.torrent.GotInfo()
f.torrentReady.Store(true)
go f.doUpload(fromUpload)
return
}
type FetcherBuilder struct {
func (f *Fetcher) seedRadio() float64 {
var bytesRead int64
if f.Meta().Res != nil {
bytesRead = f.Meta().Res.Size
} else {
bytesRead = 0
}
if bytesRead <= 0 {
return 0
}
return float64(f.data.SeedBytes) / float64(bytesRead)
}
var schemes = []string{"FILE", "MAGNET", "APPLICATION/X-BITTORRENT"}
func (fb *FetcherBuilder) Schemes() []string {
return schemes
type fetcherData struct {
Progress fetcher.Progress
SeedBytes int64
// SeedTime is the time in seconds to seed after downloading is complete.
SeedTime int64
}
func (fb *FetcherBuilder) Build() fetcher.Fetcher {
func closeClient() error {
lock.Lock()
defer lock.Unlock()
if closeFunc != nil {
closeFunc()
}
if client != nil {
errs := client.Close()
if len(errs) > 0 {
return errs[0]
}
client = nil
closeCtx = nil
closeFunc = nil
}
return nil
}
type FetcherManager struct {
}
func (fm *FetcherManager) Name() string {
return "bt"
}
func (fm *FetcherManager) Filters() []*fetcher.SchemeFilter {
return []*fetcher.SchemeFilter{
{
Type: fetcher.FilterTypeUrl,
Pattern: "MAGNET",
},
{
Type: fetcher.FilterTypeFile,
Pattern: "TORRENT",
},
{
Type: fetcher.FilterTypeBase64,
Pattern: "APPLICATION/X-BITTORRENT",
},
}
}
func (fm *FetcherManager) Build() fetcher.Fetcher {
return &Fetcher{}
}
func (fb *FetcherBuilder) Store(f fetcher.Fetcher) (data any, err error) {
return nil, nil
func (fm *FetcherManager) ParseName(u string) string {
var name string
url, err := url.Parse(u)
if err != nil {
return ""
}
params := url.Query()
if params.Get("dn") != "" {
return params.Get("dn")
}
if params.Get("xt") != "" {
xt := strings.Split(params.Get("xt"), ":")
return xt[len(xt)-1]
}
return name
}
func (fb *FetcherBuilder) Restore() (v any, f func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher) {
return nil, func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher {
func (fm *FetcherManager) AutoRename() bool {
return false
}
func (fm *FetcherManager) DefaultConfig() any {
return &config{
ListenPort: 0,
Trackers: []string{},
SeedKeep: false,
SeedRatio: 1.0,
SeedTime: 120 * 60,
}
}
func (fm *FetcherManager) Store(f fetcher.Fetcher) (data any, err error) {
_f := f.(*Fetcher)
return _f.data, nil
}
func (fm *FetcherManager) Restore() (v any, f func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher) {
return &fetcherData{}, func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher {
return &Fetcher{
meta: meta,
data: v.(*fetcherData),
}
}
}
func (fm *FetcherManager) Close() error {
return closeClient()
}
// parse version to bep20 format, fixed length 4, if not enough, fill 0
func parseBep20() string {
s := strings.ReplaceAll(base.Version, ".", "")
if len(s) < 4 {
s += strings.Repeat("0", 4-len(s))
}
return s
}
+297 -38
View File
@@ -3,14 +3,18 @@ package bt
import (
"encoding/base64"
"encoding/json"
gohttp "net/http"
"net/url"
"os"
"path/filepath"
"reflect"
"testing"
"github.com/GopeedLab/gopeed/internal/controller"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/internal/test"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/protocol/bt"
"os"
"reflect"
"testing"
)
func TestFetcher_Resolve_Torrent(t *testing.T) {
@@ -27,13 +31,12 @@ func TestFetcher_Resolve_DataUri_Torrent(t *testing.T) {
dataUri := "data:application/x-bittorrent;base64," + base64.StdEncoding.EncodeToString(buf)
err = fetcher.Resolve(&base.Request{
URL: dataUri,
})
}, nil)
if err != nil {
panic(err)
}
want := &base.Resource{
Name: "ubuntu-22.04-live-server-amd64.iso",
Size: 1466714112,
Range: true,
Files: []*base.FileInfo{
@@ -50,60 +53,316 @@ func TestFetcher_Resolve_DataUri_Torrent(t *testing.T) {
}
func TestFetcher_Config(t *testing.T) {
doResolve(t, buildConfigFetcher())
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) {
err := fetcher.Resolve(&base.Request{
URL: "./testdata/ubuntu-22.04-live-server-amd64.iso.torrent",
Extra: bt.ReqExtra{
Trackers: []string{
"udp://tracker.birkenwald.de:6969/announce",
"udp://tracker.bitsearch.to:1337/announce",
t.Run("Resolve Single File", func(t *testing.T) {
err := fetcher.Resolve(&base.Request{
URL: "./testdata/ubuntu-22.04-live-server-amd64.iso.torrent",
Extra: bt.ReqExtra{
Trackers: []string{
"udp://tracker.birkenwald.de:6969/announce",
"udp://tracker.bitsearch.to:1337/announce",
},
},
},
})
if err != nil {
panic(err)
}
}, nil)
if err != nil {
panic(err)
}
want := &base.Resource{
Name: "ubuntu-22.04-live-server-amd64.iso",
Size: 1466714112,
Range: true,
Files: []*base.FileInfo{
{
Name: "ubuntu-22.04-live-server-amd64.iso",
Size: 1466714112,
want := &base.Resource{
Size: 1466714112,
Range: true,
Files: []*base.FileInfo{
{
Name: "ubuntu-22.04-live-server-amd64.iso",
Size: 1466714112,
},
},
},
Hash: "8a55cfbd5ca5d11507364765936c4f9e55b253ed",
Hash: "8a55cfbd5ca5d11507364765936c4f9e55b253ed",
}
if !reflect.DeepEqual(want, fetcher.Meta().Res) {
t.Errorf("Resolve Single File Resolve() got = %v, want %v", fetcher.Meta().Res, want)
}
})
t.Run("Resolve Multi Files", func(t *testing.T) {
err := fetcher.Resolve(&base.Request{
URL: "./testdata/test.torrent",
Extra: bt.ReqExtra{
Trackers: []string{
"udp://tracker.birkenwald.de:6969/announce",
"udp://tracker.bitsearch.to:1337/announce",
},
},
}, nil)
if err != nil {
panic(err)
}
want := &base.Resource{
Name: "test",
Size: 107484864,
Range: true,
Files: []*base.FileInfo{
{
Name: "c.txt",
Path: "path",
Size: 98501754,
},
{
Name: "b.txt",
Size: 8904996,
},
{
Name: "a.txt",
Size: 78114,
},
},
Hash: "ccbc92b0cd8deec16a2ef4be242a8c9243b1cedb",
}
if !reflect.DeepEqual(want, fetcher.Meta().Res) {
t.Errorf("Resolve Multi Files Resolve() got = %v, want %v", fetcher.Meta().Res, want)
}
})
t.Run("Resolve Unclean Torrent", func(t *testing.T) {
err := fetcher.Resolve(&base.Request{
URL: "./testdata/test.unclean.torrent",
}, nil)
if err != nil {
t.Errorf("Resolve Unclean Torrent Resolve() got = %v, want nil", err)
}
})
t.Run("Resolve file scheme Torrent", func(t *testing.T) {
file, _ := filepath.Abs("./testdata/test.unclean.torrent")
uri := "file:///" + file
err := fetcher.Resolve(&base.Request{
URL: uri,
}, nil)
if err != nil {
t.Errorf("Resolve file scheme Torrent Resolve() got = %v, want nil", err)
}
})
}
func TestFetcherManager_ParseName(t *testing.T) {
type args struct {
u string
}
if !reflect.DeepEqual(want, fetcher.Meta().Res) {
t.Errorf("Resolve() got = %v, want %v", fetcher.Meta().Res, want)
tests := []struct {
name string
args args
want string
}{
{
name: "broken url",
args: args{
u: "magnet://!@#%github.com",
},
want: "",
},
{
name: "dn",
args: args{
u: "magnet:?xt=urn:btih:8a55cfbd5ca5d11507364765936c4f9e55b253ed&dn=ubuntu-22.04-live-server-amd64.iso",
},
want: "ubuntu-22.04-live-server-amd64.iso",
},
{
name: "no dn",
args: args{
u: "magnet:?xt=urn:btih:8a55cfbd5ca5d11507364765936c4f9e55b253ed",
},
want: "8a55cfbd5ca5d11507364765936c4f9e55b253ed",
},
{
name: "non standard magnet",
args: args{
u: "magnet:?xxt=abcd",
},
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fm := &FetcherManager{}
if got := fm.ParseName(tt.args.u); got != tt.want {
t.Errorf("ParseName() = %v, want %v", got, tt.want)
}
})
}
}
func buildFetcher() fetcher.Fetcher {
fetcher := new(FetcherBuilder).Build()
fetcher.Setup(controller.NewController())
fb := new(FetcherManager)
fetcher := fb.Build()
newController := controller.NewController()
newController.GetConfig = func(v any) {
json.Unmarshal([]byte(test.ToJson(fb.DefaultConfig())), v)
}
fetcher.Setup(newController)
return fetcher
}
func buildConfigFetcher() fetcher.Fetcher {
fetcher := new(FetcherBuilder).Build()
func buildConfigFetcher(proxyConfig *base.DownloaderProxyConfig) fetcher.Fetcher {
fetcher := new(FetcherManager).Build()
newController := controller.NewController()
mockCfg := config{
Trackers: []string{
"udp://tracker.birkenwald.de:6969/announce",
"udp://tracker.bitsearch.to:1337/announce",
}}
newController.GetConfig = func(v any) (bool, error) {
if err := json.Unmarshal([]byte(test.ToJson(mockCfg)), v); err != nil {
return false, err
}
return true, nil
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))
}
})
}
-214
View File
@@ -1,214 +0,0 @@
// modify from github.com/anacrolix/torrent/storage/file.go
package bt
import (
"fmt"
"github.com/anacrolix/torrent/storage"
"io"
"os"
"path/filepath"
"github.com/anacrolix/missinggo/v2"
"github.com/anacrolix/torrent/common"
"github.com/anacrolix/torrent/metainfo"
"github.com/anacrolix/torrent/segments"
)
// File-based storage for torrents, that isn't yet bound to a particular torrent.
type fileClientImpl struct {
opts newFileClientOpts
}
type newFileClientOpts struct {
// The base directory for all downloads.
ClientBaseDir string
HandleFileTorrent func(infoHash metainfo.Hash, ft *fileTorrentImpl)
FilePathMaker storage.FilePathMaker
PieceCompletion storage.PieceCompletion
}
func newFileOpts(opts newFileClientOpts) storage.ClientImplCloser {
if opts.FilePathMaker == nil {
opts.FilePathMaker = func(opts storage.FilePathMakerOpts) string {
var parts []string
if opts.Info.Name != metainfo.NoName {
parts = append(parts, opts.Info.Name)
}
return filepath.Join(append(parts, opts.File.Path...)...)
}
}
if opts.PieceCompletion == nil {
ret, err := storage.NewDefaultPieceCompletionForDir(opts.ClientBaseDir)
if err != nil {
ret = storage.NewMapPieceCompletion()
}
opts.PieceCompletion = ret
}
return fileClientImpl{opts}
}
func (fs fileClientImpl) Close() error {
return fs.opts.PieceCompletion.Close()
}
func (fs fileClientImpl) OpenTorrent(info *metainfo.Info, infoHash metainfo.Hash) (_ storage.TorrentImpl, err error) {
upvertedFiles := info.UpvertedFiles()
files := make([]file, 0, len(upvertedFiles))
for _, fileInfo := range upvertedFiles {
filePath := filepath.Join("", fs.opts.FilePathMaker(storage.FilePathMakerOpts{
Info: info,
File: &fileInfo,
}))
f := file{
rawPath: filePath,
path: filePath,
length: fileInfo.Length,
}
if f.length == 0 {
err = CreateNativeZeroLengthFile(f.path)
if err != nil {
err = fmt.Errorf("creating zero length file: %w", err)
return
}
}
files = append(files, f)
}
t := &fileTorrentImpl{
files,
segments.NewIndex(common.LengthIterFromUpvertedFiles(upvertedFiles)),
infoHash,
fs.opts.PieceCompletion,
}
fs.opts.HandleFileTorrent(infoHash, t)
return storage.TorrentImpl{
Piece: t.Piece,
Close: t.Close,
}, nil
}
type file struct {
// The safe, OS-local file path.
rawPath string
path string
length int64
}
type fileTorrentImpl struct {
files []file
segmentLocater segments.Index
infoHash metainfo.Hash
completion storage.PieceCompletion
}
func (fts *fileTorrentImpl) Piece(p metainfo.Piece) storage.PieceImpl {
// Create a view onto the file-based torrent storage.
_io := fileTorrentImplIO{fts}
// Return the appropriate segments of this.
return &filePieceImpl{
fts,
p,
missinggo.NewSectionWriter(_io, p.Offset(), p.Length()),
io.NewSectionReader(_io, p.Offset(), p.Length()),
}
}
func (fts *fileTorrentImpl) Close() error {
return nil
}
func (fts *fileTorrentImpl) setTorrentDir(dir string) {
for i, f := range fts.files {
fts.files[i].path = filepath.Join(dir, f.rawPath)
}
}
// A helper to create zero-length files which won't appear for file-orientated storage since no
// writes will ever occur to them (no torrent data is associated with a zero-length file). The
// caller should make sure the file name provided is safe/sanitized.
func CreateNativeZeroLengthFile(name string) error {
os.MkdirAll(filepath.Dir(name), 0o777)
var f io.Closer
f, err := os.Create(name)
if err != nil {
return err
}
return f.Close()
}
// Exposes file-based storage of a torrent, as one big ReadWriterAt.
type fileTorrentImplIO struct {
fts *fileTorrentImpl
}
// Returns EOF on short or missing file.
func (fst *fileTorrentImplIO) readFileAt(file file, b []byte, off int64) (n int, err error) {
f, err := os.Open(file.path)
if os.IsNotExist(err) {
// File missing is treated the same as a short file.
err = io.EOF
return
}
if err != nil {
return
}
defer f.Close()
// Limit the read to within the expected bounds of this file.
if int64(len(b)) > file.length-off {
b = b[:file.length-off]
}
for off < file.length && len(b) != 0 {
n1, err1 := f.ReadAt(b, off)
b = b[n1:]
n += n1
off += int64(n1)
if n1 == 0 {
err = err1
break
}
}
return
}
// Only returns EOF at the end of the torrent. Premature EOF is ErrUnexpectedEOF.
func (fst fileTorrentImplIO) ReadAt(b []byte, off int64) (n int, err error) {
fst.fts.segmentLocater.Locate(segments.Extent{off, int64(len(b))}, func(i int, e segments.Extent) bool {
n1, err1 := fst.readFileAt(fst.fts.files[i], b[:e.Length], e.Start)
n += n1
b = b[n1:]
err = err1
return err == nil // && int64(n1) == e.Length
})
if len(b) != 0 && err == nil {
err = io.EOF
}
return
}
func (fst fileTorrentImplIO) WriteAt(p []byte, off int64) (n int, err error) {
// log.Printf("write at %v: %v bytes", off, len(p))
fst.fts.segmentLocater.Locate(segments.Extent{off, int64(len(p))}, func(i int, e segments.Extent) bool {
name := fst.fts.files[i].path
os.MkdirAll(filepath.Dir(name), 0o777)
var f *os.File
f, err = os.OpenFile(name, os.O_WRONLY|os.O_CREATE, 0o666)
if err != nil {
return false
}
var n1 int
n1, err = f.WriteAt(p[:e.Length], e.Start)
// log.Printf("%v %v wrote %v: %v", i, e, n1, err)
closeErr := f.Close()
n += n1
p = p[n1:]
if err == nil {
err = closeErr
}
if err == nil && int64(n1) != e.Length {
err = io.ErrShortWrite
}
return err == nil
})
return
}
-36
View File
@@ -1,36 +0,0 @@
// modify from github.com/anacrolix/torrent/storage/file_misc.go
package bt
import "github.com/anacrolix/torrent/metainfo"
type requiredLength struct {
fileIndex int
length int64
}
func extentCompleteRequiredLengths(info *metainfo.Info, off, n int64) (ret []requiredLength) {
if n == 0 {
return
}
for i, fi := range info.UpvertedFiles() {
if off >= fi.Length {
off -= fi.Length
continue
}
n1 := n
if off+n1 > fi.Length {
n1 = fi.Length - off
}
ret = append(ret, requiredLength{
fileIndex: i,
length: off + n1,
})
n -= n1
if n == 0 {
return
}
off = 0
}
return
}
-61
View File
@@ -1,61 +0,0 @@
// modify from github.com/anacrolix/torrent/storage/file_piece.go
package bt
import (
"github.com/anacrolix/torrent/metainfo"
"github.com/anacrolix/torrent/storage"
"io"
"log"
"os"
)
type filePieceImpl struct {
*fileTorrentImpl
p metainfo.Piece
io.WriterAt
io.ReaderAt
}
var _ storage.PieceImpl = (*filePieceImpl)(nil)
func (fs *filePieceImpl) pieceKey() metainfo.PieceKey {
return metainfo.PieceKey{InfoHash: fs.infoHash, Index: fs.p.Index()}
}
func (fs *filePieceImpl) Completion() storage.Completion {
c, err := fs.completion.Get(fs.pieceKey())
if err != nil {
log.Printf("error getting piece completion: %s", err)
c.Ok = false
return c
}
verified := true
if c.Complete {
// If it's allegedly complete, check that its constituent files have the necessary length.
for _, fi := range extentCompleteRequiredLengths(fs.p.Info, fs.p.Offset(), fs.p.Length()) {
s, err := os.Stat(fs.files[fi.fileIndex].path)
if err != nil || s.Size() < fi.length {
verified = false
break
}
}
}
if !verified {
// The completion was wrong, fix it.
c.Complete = false
fs.completion.Set(fs.pieceKey(), false)
}
return c
}
func (fs *filePieceImpl) MarkComplete() error {
return fs.completion.Set(fs.pieceKey(), true)
}
func (fs *filePieceImpl) MarkNotComplete() error {
return fs.completion.Set(fs.pieceKey(), false)
}
Binary file not shown.
Binary file not shown.
+3 -1
View File
@@ -1,5 +1,7 @@
package http
type config struct {
Connections int `json:"connections"`
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
}
+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
}
+767 -21
View File
@@ -2,14 +2,23 @@ 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 (
@@ -18,14 +27,23 @@ const (
Dir = "./"
BuildFile = Dir + BuildName
ExternalDownloadUrl = "https://raw.githubusercontent.com/GopeedLab/gopeed/v1.5.6/_docs/img/banner.png"
ExternalDownloadName = "banner.png"
ExternalDownloadSize = 26416
//ExternalDownloadMd5 = "c67c6e3cae79a95342485676571e8a5c"
DownloadName = "download.data"
DownloadRename = "download (1).data"
DownloadFile = Dir + DownloadName
DownloadRenameFile = Dir + DownloadRename
// TestChineseFileName is a common test filename with Chinese characters
// Used to test Content-Disposition parsing with various encodings
TestChineseFileName = "测试.zip"
)
func StartTestFileServer() net.Listener {
return startTestServer(func() http.Handler {
return startTestServer(func(sl *shutdownListener) http.Handler {
return http.FileServer(http.Dir(Dir))
})
}
@@ -41,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)),
@@ -50,8 +68,11 @@ func StartTestSlowFileServer(delay time.Duration) net.Listener {
}
func StartTestCustomServer() net.Listener {
return startTestServer(func() http.Handler {
return startTestServer(func(sl *shutdownListener) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) {
writer.WriteHeader(200)
})
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
file, err := os.Open(BuildFile)
if err != nil {
@@ -71,17 +92,172 @@ func StartTestCustomServer() net.Listener {
defer file.Close()
io.Copy(writer, file)
})
mux.HandleFunc("/encoded-word", func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Content-Disposition", "attachment; filename=\"=?UTF8?B?5rWL6K+VLnppcA==?=\"")
writer.Header().Set("Content-Type", "application/octet-stream")
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
file, err := os.Open(BuildFile)
if err != nil {
panic(err)
}
defer file.Close()
io.Copy(writer, file)
})
mux.HandleFunc("/%E6%B5%8B%E8%AF%95.zip", func(writer http.ResponseWriter, request *http.Request) {
file, err := os.Open(BuildFile)
if err != nil {
panic(err)
}
defer file.Close()
io.Copy(writer, file)
})
mux.HandleFunc("/no-encode", func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Content-Disposition", "attachment; filename="+TestChineseFileName)
writer.Header().Set("Content-Type", "application/octet-stream")
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
file, err := os.Open(BuildFile)
if err != nil {
panic(err)
}
defer file.Close()
io.Copy(writer, file)
})
// Test endpoint for mixed encoding: filename= with garbled characters (including special chars)
// and filename*= with proper UTF-8. This tests the case where mime.ParseMediaType fails
// due to invalid characters like <a> tags in the filename.
mux.HandleFunc("/mixed-encoding", func(writer http.ResponseWriter, request *http.Request) {
// This simulates a server that sends a garbled filename= with special chars that cause
// mime.ParseMediaType to fail, plus a proper filename*=UTF-8''...
// The filename*= should be preferred and correctly parsed.
writer.Header().Set("Content-Disposition", `attachment;filename="garbled<invalid>chars.zip";filename*=UTF-8''%E6%B5%8B%E8%AF%95.zip`)
writer.Header().Set("Content-Type", "application/octet-stream")
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
file, err := os.Open(BuildFile)
if err != nil {
panic(err)
}
defer file.Close()
io.Copy(writer, file)
})
// Test endpoint for filename*= only (RFC 5987 format)
mux.HandleFunc("/filename-star", func(writer http.ResponseWriter, request *http.Request) {
// URL-encoded TestChineseFileName: 测试.zip -> %E6%B5%8B%E8%AF%95.zip
writer.Header().Set("Content-Disposition", `attachment; filename*=UTF-8''%E6%B5%8B%E8%AF%95.zip`)
writer.Header().Set("Content-Type", "application/octet-stream")
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
file, err := os.Open(BuildFile)
if err != nil {
panic(err)
}
defer file.Close()
io.Copy(writer, file)
})
// Test endpoint for GBK-encoded filename (common on Chinese Windows servers)
// This simulates the case where Chinese characters are sent as GBK bytes
// which appear as garbled characters when interpreted as UTF-8.
// For example, "测试" in GBK is [B2 E2 CA D4] which is invalid UTF-8.
// Our fix detects invalid UTF-8 and attempts GBK decoding.
mux.HandleFunc("/gbk-encoded", func(writer http.ResponseWriter, request *http.Request) {
// Encode TestChineseFileName as GBK
gbkEncoder := simplifiedchinese.GBK.NewEncoder()
gbkBytes, _ := gbkEncoder.Bytes([]byte(TestChineseFileName))
// Send GBK bytes directly in filename (simulating broken server behavior)
writer.Header().Set("Content-Disposition", `attachment; filename="`+string(gbkBytes)+`"`)
writer.Header().Set("Content-Type", "application/octet-stream")
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
file, err := os.Open(BuildFile)
if err != nil {
panic(err)
}
defer file.Close()
io.Copy(writer, file)
})
// Test endpoint for filenames with plus signs (C++ files, etc.)
// This tests that %2B decodes to + not space
mux.HandleFunc("/plus-sign-encoded", func(writer http.ResponseWriter, request *http.Request) {
// Use filename*= format with %2B encoding for plus signs
writer.Header().Set("Content-Disposition", `attachment; filename*=UTF-8''C%2B%2B%20%20Primer%20%20Plus.mobi`)
writer.Header().Set("Content-Type", "application/octet-stream")
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
file, err := os.Open(BuildFile)
if err != nil {
panic(err)
}
defer file.Close()
io.Copy(writer, file)
})
// Test endpoint for plus sign in URL path
mux.HandleFunc("/C%2B%2B%20Primer.txt", func(writer http.ResponseWriter, request *http.Request) {
file, err := os.Open(BuildFile)
if err != nil {
panic(err)
}
defer file.Close()
io.Copy(writer, file)
})
// Test endpoint for filename with HTML-encoded ampersand (&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
}
@@ -97,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") != "" {
@@ -120,21 +296,475 @@ 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)
return
}
// Always return 404 error to test error handling
writer.WriteHeader(404)
return
})
return mux
})
}
func startTestServer(serverHandle func() http.Handler) net.Listener {
// 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
})
}
// 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)
@@ -144,7 +774,7 @@ func startTestServer(serverHandle func() http.Handler) net.Listener {
panic(err)
}
defer file.Close()
// 随机生成一个文件
// Write random data
l := int64(8192)
buf := make([]byte, l)
size := int64(0)
@@ -161,22 +791,26 @@ func startTestServer(serverHandle func() http.Handler) net.Listener {
size += l
}
server := &http.Server{}
server.Handler = serverHandle()
go server.Serve(listener)
return &shutdownListener{
sl := &shutdownListener{
server: server,
Listener: listener,
}
server.Handler = serverHandle(sl)
go server.Serve(listener)
return sl
}
type shutdownListener struct {
server *http.Server
server *http.Server
isShutdown bool
net.Listener
}
func (c *shutdownListener) Close() error {
// Shutdown server first (waits for in-flight requests), then set isShutdown
closeErr := c.server.Shutdown(context.Background())
c.isShutdown = true
if err := ifExistAndRemove(BuildFile); err != nil {
fmt.Println(err)
}
@@ -195,3 +829,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 -4
View File
@@ -15,15 +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"
)
const (
AgentName = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36"
)
-8
View File
@@ -1,8 +0,0 @@
package base
import "errors"
var (
NotFound = errors.New("not found")
BadParams = errors.New("bad params")
)
+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"
}
}
+252 -16
View File
@@ -2,13 +2,64 @@ package base
import (
"fmt"
"net/http"
"net/url"
"sync"
"time"
"github.com/GopeedLab/gopeed/pkg/util"
"github.com/mattn/go-ieproxy"
"golang.org/x/exp/slices"
)
// Request download request
type Request struct {
URL string `json:"url"`
Extra any `json:"extra"`
// 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
@@ -27,7 +78,7 @@ func (r *Resource) Validate() error {
if r.Name == "" {
return fmt.Errorf("invalid resource name")
}
if r.Files == nil || len(r.Files) == 0 {
if len(r.Files) == 0 {
return fmt.Errorf("invalid resource files")
}
for _, file := range r.Files {
@@ -36,21 +87,23 @@ func (r *Resource) Validate() error {
}
}
return nil
}
func (r *Resource) CalcSize() {
func (r *Resource) CalcSize(selectFiles []int) {
var size int64
for _, file := range r.Files {
size += file.Size
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"`
}
@@ -71,19 +124,14 @@ func (o *Options) InitSelectFiles(fileSize int) {
// if selectFiles is empty, select all files
if len(o.SelectFiles) == 0 {
o.SelectFiles = make([]int, fileSize)
for i := 0; i < fileSize; i++ {
for i := range fileSize {
o.SelectFiles[i] = i
}
}
}
func (o *Options) Clone() *Options {
return &Options{
Name: o.Name,
Path: o.Path,
SelectFiles: o.SelectFiles,
Extra: o.Extra,
}
return util.DeepClone(o)
}
func ParseReqExtra[E any](req *Request) error {
@@ -101,7 +149,7 @@ func ParseReqExtra[E any](req *Request) error {
return nil
}
func ParseOptsExtra[E any](opts *Options) error {
func ParseOptExtra[E any](opts *Options) error {
if opts.Extra == nil {
return nil
}
@@ -115,3 +163,191 @@ func ParseOptsExtra[E any](opts *Options) error {
opts.Extra = &t
return nil
}
type CreateTaskBatch struct {
Reqs []*CreateTaskBatchItem `json:"reqs"`
Opts *Options `json:"opts"`
}
type CreateTaskBatchItem struct {
Req *Request `json:"req"`
Opts *Options `json:"opts"`
}
// DownloaderStoreConfig is the config that can restore the downloader.
type DownloaderStoreConfig struct {
FirstLoad bool `json:"-"` // FirstLoad is the flag that the config is first time init and not from store
DownloadDir string `json:"downloadDir"` // DownloadDir is the default directory to save the downloaded files
MaxRunning int `json:"maxRunning"` // MaxRunning is the max running download count
ProtocolConfig map[string]any `json:"protocolConfig"` // ProtocolConfig is special config for each protocol
Extra map[string]any `json:"extra"`
Proxy *DownloaderProxyConfig `json:"proxy"`
Webhook *WebhookConfig `json:"webhook"` // Webhook is the webhook configuration
Script *ScriptConfig `json:"script"` // Script is the script execution configuration
AutoTorrent *AutoTorrentConfig `json:"autoTorrent"` // AutoTorrent is the auto torrent task creation configuration
Archive *ArchiveConfig `json:"archive"` // Archive is the archive extraction configuration
AutoDeleteMissingFileTasks bool `json:"autoDeleteMissingFileTasks"` // AutoDeleteMissingFileTasks enables automatic deletion of tasks with missing files
}
func (cfg *DownloaderStoreConfig) Init() *DownloaderStoreConfig {
if cfg.MaxRunning == 0 {
cfg.MaxRunning = 5
}
if cfg.ProtocolConfig == nil {
cfg.ProtocolConfig = make(map[string]any)
}
if cfg.Proxy == nil {
cfg.Proxy = &DownloaderProxyConfig{}
}
if cfg.Webhook == nil {
cfg.Webhook = &WebhookConfig{}
}
if cfg.Script == nil {
cfg.Script = &ScriptConfig{}
}
if cfg.AutoTorrent == nil {
cfg.AutoTorrent = &AutoTorrentConfig{
Enable: false,
DeleteAfterDownload: false,
}
}
if cfg.Archive == nil {
cfg.Archive = &ArchiveConfig{
AutoExtract: false,
DeleteAfterExtract: false,
}
}
return cfg
}
func (cfg *DownloaderStoreConfig) Merge(beforeCfg *DownloaderStoreConfig) *DownloaderStoreConfig {
if beforeCfg == nil {
return cfg
}
if cfg.DownloadDir == "" {
cfg.DownloadDir = beforeCfg.DownloadDir
}
if cfg.MaxRunning == 0 {
cfg.MaxRunning = beforeCfg.MaxRunning
}
if cfg.ProtocolConfig == nil {
cfg.ProtocolConfig = beforeCfg.ProtocolConfig
}
if cfg.Extra == nil {
cfg.Extra = beforeCfg.Extra
}
if cfg.Proxy == nil {
cfg.Proxy = beforeCfg.Proxy
}
if cfg.Webhook == nil {
cfg.Webhook = beforeCfg.Webhook
}
if cfg.Script == nil {
cfg.Script = beforeCfg.Script
}
if cfg.AutoTorrent == nil {
cfg.AutoTorrent = beforeCfg.AutoTorrent
}
if cfg.Archive == nil {
cfg.Archive = beforeCfg.Archive
}
return cfg
}
// WebhookConfig is the webhook configuration
type WebhookConfig struct {
Enable bool `json:"enable"` // Enable is the flag to enable/disable webhooks
URLs []string `json:"urls"` // URLs is the list of webhook URLs
}
// ScriptConfig is the script execution configuration
type ScriptConfig struct {
Enable bool `json:"enable"` // Enable is the flag to enable/disable script execution
Paths []string `json:"paths"` // Paths is the list of script paths to execute
}
// AutoTorrentConfig is the auto torrent task creation configuration
type AutoTorrentConfig struct {
Enable bool `json:"enable"` // Enable enables automatic BT task creation when downloading .torrent files
DeleteAfterDownload bool `json:"deleteAfterDownload"` // DeleteAfterDownload deletes the .torrent file after BT task creation
}
// ArchiveConfig is the archive extraction configuration
type ArchiveConfig struct {
AutoExtract bool `json:"autoExtract"` // AutoExtract enables automatic extraction of archives after download
DeleteAfterExtract bool `json:"deleteAfterExtract"` // DeleteAfterExtract deletes the archive after successful extraction
}
type DownloaderProxyConfig struct {
Enable bool `json:"enable"`
// System is the flag that use system proxy
System bool `json:"system"`
Scheme string `json:"scheme"`
Host string `json:"host"`
Usr string `json:"usr"`
Pwd string `json:"pwd"`
}
func (cfg *DownloaderProxyConfig) ToHandler() func(r *http.Request) (*url.URL, error) {
if cfg == nil || cfg.Enable == false {
return nil
}
if cfg.System {
safeProxyReloadConf()
return ieproxy.GetProxyFunc()
}
if cfg.Scheme == "" || cfg.Host == "" {
return nil
}
return http.ProxyURL(util.BuildProxyUrl(cfg.Scheme, cfg.Host, cfg.Usr, cfg.Pwd))
}
// ToUrl returns the proxy url, just for git clone
func (cfg *DownloaderProxyConfig) ToUrl() *url.URL {
if cfg == nil || cfg.Enable == false {
return nil
}
if cfg.System {
safeProxyReloadConf()
static := ieproxy.GetConf().Static
if static.Active && len(static.Protocols) > 0 {
// If only one protocol, use it
if len(static.Protocols) == 1 {
for _, v := range static.Protocols {
return parseUrlSafe(v)
}
}
// Check https
if v, ok := static.Protocols["https"]; ok {
return parseUrlSafe(v)
}
// Check http
if v, ok := static.Protocols["http"]; ok {
return parseUrlSafe(v)
}
}
return nil
}
if cfg.Scheme == "" || cfg.Host == "" {
return nil
}
return util.BuildProxyUrl(cfg.Scheme, cfg.Host, cfg.Usr, cfg.Pwd)
}
var prcLock sync.Mutex
func safeProxyReloadConf() {
prcLock.Lock()
defer prcLock.Unlock()
ieproxy.ReloadConf()
}
func parseUrlSafe(rawUrl string) *url.URL {
u, err := url.Parse(rawUrl)
if err != nil {
return nil
}
return u
}
+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)
}
})
}
}
+1346 -360
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+83 -14
View File
@@ -3,14 +3,16 @@ package engine
import (
_ "embed"
"errors"
"fmt"
"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"
"github.com/dop251/goja_nodejs/process"
"github.com/dop251/goja_nodejs/url"
gojaurl "github.com/dop251/goja_nodejs/url"
"time"
)
//go:embed polyfill/out/index.js
@@ -27,12 +29,16 @@ type Engine struct {
func (e *Engine) RunString(script string) (value any, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("%v", r)
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
@@ -45,9 +51,10 @@ func (e *Engine) RunString(script string) (value any, err error) {
func (e *Engine) CallFunction(fn goja.Callable, args ...any) (value any, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("%v", r)
err = r.(error)
}
}()
var result goja.Value
e.loop.Run(func(runtime *goja.Runtime) {
if args == nil {
@@ -59,18 +66,57 @@ func (e *Engine) CallFunction(fn goja.Callable, args ...any) (value any, err err
}
result, err = fn(nil, jsArgs...)
}
if err == nil {
go e.await(result)
}
})
if err != nil {
return nil, err
return
}
return resolveResult(result)
}
func (e *Engine) Close() {
e.loop.Stop()
// 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 NewEngine() *Engine {
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,
@@ -78,15 +124,18 @@ func NewEngine() *Engine {
loop.Run(func(runtime *goja.Runtime) {
engine.Runtime = runtime
runtime.SetFieldNameMapper(goja.TagFieldNameMapper("json", true))
url.Enable(runtime)
process.Enable(runtime)
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); err != nil {
if err := xhr.Enable(runtime, cfg.ProxyConfig.ToHandler()); err != nil {
return
}
if _, err := runtime.RunString(polyfillScript); err != nil {
@@ -96,13 +145,21 @@ func NewEngine() *Engine {
if err := runtime.Set("global", runtime.GlobalObject()); err != nil {
return
}
// polyfill window
if err := runtime.Set("window", runtime.GlobalObject()); err != nil {
return
}
// polyfill window.location
if _, err := runtime.RunString("global.location = new URL('http://localhost');"); err != nil {
return
}
return
})
return engine
}
func Run(script string) (value any, err error) {
engine := NewEngine()
engine := NewEngine(nil)
return engine.RunString(script)
}
@@ -118,7 +175,19 @@ func resolveResult(value goja.Value) (any, error) {
case goja.PromiseStateFulfilled:
return p.Result().Export(), nil
case goja.PromiseStateRejected:
return nil, errors.New(p.Result().String())
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
+270 -6
View File
@@ -6,9 +6,6 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/GopeedLab/gopeed/internal/test"
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/file"
"github.com/dop251/goja"
"io"
"net"
"net/http"
@@ -16,19 +13,40 @@ import (
"strings"
"testing"
"time"
"github.com/GopeedLab/gopeed/internal/test"
"github.com/GopeedLab/gopeed/pkg/base"
gojaerror "github.com/GopeedLab/gopeed/pkg/download/engine/inject/error"
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/file"
gojautil "github.com/GopeedLab/gopeed/pkg/download/engine/util"
"github.com/dop251/goja"
)
func TestPolyfill(t *testing.T) {
doTestPolyfill(t, "MessageError")
doTestPolyfill(t, "XMLHttpRequest")
doTestPolyfill(t, "Blob")
doTestPolyfill(t, "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()
engine := NewEngine(nil)
if _, err := engine.RunString(fmt.Sprintf("var host = 'http://%s';", server.Addr().String())); err != nil {
t.Fatal(err)
}
@@ -54,6 +72,44 @@ async function testOctetStream(file){
return await resp.text();
}
async function testRedirect() {
const url = host + '/redirect?num=3'
return await new Promise((resolve, reject) => {
fetch(url, {
method: 'HEAD',
redirect: 'error',
}).then(()=>reject())
fetch(url, {
method: 'HEAD',
redirect: 'follow',
}).then((res) =>res.headers.has('location') && reject()).catch(() => reject())
fetch(url, {
method: 'HEAD',
redirect: 'manual',
}).then((res) => {
const location = res.headers.get('location');
location ? resolve(location) : reject()
}).catch(() => reject())
})
}
async function testResponseUrl() {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', host+'/redirect?num=3');
xhr.onload = function(){
if (xhr.responseURL.includes('/redirect?num=0')){
resolve();
}else{
reject();
}
};
xhr.send();
});
}
async function testFormData(file){
const formData = new FormData();
formData.append('name', 'test');
@@ -65,6 +121,30 @@ async function testFormData(file){
return await resp.json();
}
function testHeader(){
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', host+'/header');
xhr.setRequestHeader('X-Gopeed-Test', 'test1');
xhr.setRequestHeader('x-gopeed-test', 'test2');
xhr.setRequestHeader('x-Gopeed-test', 'test3');
xhr.onload = function(){
const testHeader1 = xhr.getResponseHeader("X-Gopeed-Test");
const testHeader2 = xhr.getResponseHeader("x-gopeed-test");
const testHeader3 = xhr.getResponseHeader("x-Gopeed-test");
const expect = 'test1, test2, test3';
const all = xhr.getAllResponseHeaders();
if(testHeader1 === expect && testHeader2 === expect && testHeader3 === expect
&& all.includes('X-Gopeed-Test: '+expect)){
resolve();
}else{
reject();
}
};
xhr.send();
});
}
function testProgress(){
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
@@ -125,6 +205,31 @@ function testTimeout(){
xhr.send();
});
}
async function testFingerprint(fingerprint,ua){
__gopeed_setFingerprint(fingerprint);
const resp = await fetch(host+'/ua');
const data = await resp.json();
if(!data.user_agent.includes(ua)){
throw new Error('fingerprint test failed, user agent: ' + data.user_agent);
}
}
async function testFingerprintDefault(){
await testFingerprint('none', 'Go')
}
async function testFingerprintChrome(){
await testFingerprint('chrome', 'Chrome')
}
async function testFingerprintFirefox(){
await testFingerprint('firefox', 'Firefox')
}
async function testFingerprintSafari(){
await testFingerprint('safari', 'Safari')
}
`)
if err != nil {
t.Fatal(err)
@@ -157,6 +262,20 @@ function testTimeout(){
}
}()
t.Run("testRedirect", func(t *testing.T) {
_, err := callTestFun(engine, "testRedirect")
if err != nil {
t.Fatal(err)
}
})
t.Run("testResponseUrl", func(t *testing.T) {
_, err = callTestFun(engine, "testResponseUrl")
if err != nil {
t.Fatal(err)
}
})
func() {
jsFile, goFile, md5 := buildFile(t, engine.Runtime)
result, err = callTestFun(engine, "testFormData", jsFile)
@@ -175,20 +294,137 @@ function testTimeout(){
}
}()
_, err = callTestFun(engine, "testHeader")
if err != nil {
t.Fatal("header test failed", err)
}
_, err = callTestFun(engine, "testProgress")
if err != nil {
t.Fatal("progress test failed")
t.Fatal("progress test failed", err)
}
_, err = callTestFun(engine, "testAbort")
if err != nil {
t.Fatal("abort test failed")
t.Fatal("abort test failed", err)
}
_, err = callTestFun(engine, "testTimeout")
if err == nil || err.Error() != "timeout" {
t.Fatalf("timeout test failed, want %s, got %s", "timeout", err)
}
_, err = callTestFun(engine, "testFingerprintChrome")
if err != nil {
t.Fatal("testFingerprintChrome test failed", err)
}
_, err = callTestFun(engine, "testFingerprintFirefox")
if err != nil {
t.Fatal("testFingerprintFirefox test failed", err)
}
_, err = callTestFun(engine, "testFingerprintSafari")
if err != nil {
t.Fatal("testFingerprintSafari test failed", err)
}
}
func TestFetchWithProxy(t *testing.T) {
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) {
@@ -214,6 +450,15 @@ func startServer() net.Listener {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/header", func(w http.ResponseWriter, r *http.Request) {
for k, v := range r.Header {
if strings.HasPrefix(k, "X-Gopeed") {
w.Header().Set(k, strings.Join(v, ", "))
}
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/text", func(w http.ResponseWriter, r *http.Request) {
buf, _ := io.ReadAll(r.Body)
w.WriteHeader(http.StatusOK)
@@ -253,6 +498,25 @@ func startServer() net.Listener {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/redirect", func(w http.ResponseWriter, r *http.Request) {
num := r.URL.Query().Get("num")
n, _ := strconv.Atoi(num)
if n > 0 {
http.Redirect(w, r, fmt.Sprintf("/redirect?num=%d", n-1), http.StatusFound)
} else {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
}
})
mux.HandleFunc("/ua", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
data := map[string]any{
"user_agent": r.UserAgent(),
}
buf, _ := json.Marshal(data)
w.WriteHeader(http.StatusOK)
w.Write(buf)
})
server.Handler = mux
go server.Serve(listener)
return listener
@@ -0,0 +1,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)
}
-9
View File
@@ -1,9 +0,0 @@
package inject
import (
"github.com/dop251/goja"
)
func ThrowTypeError(vm *goja.Runtime, msg string) {
panic(vm.NewTypeError(msg))
}
+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(),
})
})
}
+135 -70
View File
@@ -2,15 +2,20 @@ package xhr
import (
"bytes"
"github.com/GopeedLab/gopeed/pkg/download/engine/inject"
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/file"
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/formdata"
"github.com/dop251/goja"
"errors"
"io"
"mime/multipart"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/file"
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/formdata"
"github.com/GopeedLab/gopeed/pkg/download/engine/util"
"github.com/dop251/goja"
"github.com/imroc/req/v3"
)
const (
@@ -22,6 +27,12 @@ const (
eventTimeout = "timeout"
)
const (
redirectError = "error"
redirectFollow = "follow"
redirectManual = "manual"
)
type ProgressEvent struct {
Type string `json:"type"`
LengthComputable bool `json:"lengthComputable"`
@@ -116,17 +127,27 @@ type XMLHttpRequestUpload struct {
type XMLHttpRequest struct {
method string
url string
requestHeaders map[string]string
responseHeaders map[string]string
requestHeaders http.Header
responseHeaders http.Header
aborted bool
client *req.Client
fingerprint string
Upload *XMLHttpRequestUpload `json:"upload"`
Timeout int `json:"timeout"`
ReadyState int `json:"readyState"`
Status int `json:"status"`
StatusText string `json:"statusText"`
Response string `json:"response"`
ResponseText string `json:"responseText"`
WithCredentials bool `json:"withCredentials"`
Upload *XMLHttpRequestUpload `json:"upload"`
Timeout int `json:"timeout"`
ReadyState int `json:"readyState"`
Status int `json:"status"`
StatusText string `json:"statusText"`
Response string `json:"response"`
ResponseText string `json:"responseText"`
// https://developer.mozilla.org/zh-CN/docs/Web/API/XMLHttpRequest/responseURL
// https://xhr.spec.whatwg.org/#the-responseurl-attribute
ResponseUrl string `json:"responseURL"`
// extend fetch redirect
// https://developer.mozilla.org/en-US/docs/Web/API/RequestInit#redirect
// https://fetch.spec.whatwg.org/#concept-request-redirect-mode
Redirect string `json:"redirect"`
*EventProp
Onreadystatechange func(event *ProgressEvent) `json:"onreadystatechange"`
}
@@ -134,47 +155,63 @@ type XMLHttpRequest struct {
func (xhr *XMLHttpRequest) Open(method, url string) {
xhr.method = method
xhr.url = url
xhr.requestHeaders = make(map[string]string)
xhr.responseHeaders = make(map[string]string)
xhr.requestHeaders = make(http.Header)
xhr.responseHeaders = make(http.Header)
xhr.doReadystatechange(1)
}
func (xhr *XMLHttpRequest) SetRequestHeader(key, value string) {
xhr.requestHeaders[key] = value
xhr.requestHeaders.Add(key, value)
}
func (xhr *XMLHttpRequest) Send(data goja.Value) {
var req *http.Request
var err error
setFingerprint(xhr.client, xhr.fingerprint)
d := xhr.parseData(data)
var (
contentType string
contentLength int64
isStringBody bool
)
if d == nil || xhr.method == "GET" || xhr.method == "HEAD" {
req, err = http.NewRequest(xhr.method, xhr.url, nil)
} else {
switch d.(type) {
// Create request using req library
reqBuilder := xhr.client.R()
// Set headers first
if xhr.requestHeaders != nil {
for key, values := range xhr.requestHeaders {
if len(values) > 0 {
// Merge multiple values with comma separator (HTTP standard)
mergedValue := strings.Join(values, ", ")
reqBuilder.SetHeader(key, mergedValue)
}
}
}
// Handle request body
if d != nil && xhr.method != "GET" && xhr.method != "HEAD" {
switch v := d.(type) {
case string:
req, err = http.NewRequest(xhr.method, xhr.url, bytes.NewBufferString(d.(string)))
reqBuilder.SetBody(v)
contentType = "text/plain;charset=UTF-8"
contentLength = int64(len(d.(string)))
contentLength = int64(len(v))
isStringBody = true
case *file.File:
req, err = http.NewRequest(xhr.method, xhr.url, d.(*file.File).Reader)
reqBuilder.SetBody(v.Reader)
contentType = "application/octet-stream"
contentLength = d.(*file.File).Size
contentLength = v.Size
case *formdata.FormData:
pr, pw := io.Pipe()
mw := NewMultipart(pw)
for _, e := range d.(*formdata.FormData).Entries() {
for _, e := range v.Entries() {
arr := e.([]any)
k := arr[0].(string)
v := arr[1]
switch v.(type) {
switch v := v.(type) {
case string:
mw.WriteField(k, v.(string))
mw.WriteField(k, v)
case *file.File:
mw.WriteFile(k, v.(*file.File))
mw.WriteFile(k, v)
}
}
go func() {
@@ -182,31 +219,42 @@ func (xhr *XMLHttpRequest) Send(data goja.Value) {
defer mw.Close()
mw.Send()
}()
req, err = http.NewRequest(xhr.method, xhr.url, pr)
reqBuilder.SetBody(pr)
contentType = mw.FormDataContentType()
contentLength = mw.Size()
}
}
if err != nil {
xhr.callOnerror()
return
// Only string body can specify Content-Type header by user
if contentType != "" && (!isStringBody || xhr.requestHeaders.Get("Content-Type") == "") {
reqBuilder.SetHeader("Content-Type", contentType)
}
if contentType != "" {
req.Header.Set("Content-Type", contentType)
// Set timeout
if xhr.Timeout > 0 {
xhr.client.SetTimeout(time.Duration(xhr.Timeout) * time.Millisecond)
}
if contentLength > 0 {
req.ContentLength = contentLength
}
for k, v := range xhr.requestHeaders {
req.Header.Set(k, v)
}
client := &http.Client{
Timeout: time.Duration(xhr.Timeout) * time.Millisecond,
}
resp, err := client.Do(req)
// Configure redirect behavior
xhr.client.SetRedirectPolicy(func(req *http.Request, via []*http.Request) error {
if xhr.Redirect == redirectManual {
return http.ErrUseLastResponse
}
if xhr.Redirect == redirectError {
return errors.New("redirect failed")
}
if len(via) > 20 {
return errors.New("too many redirects")
}
return nil
})
// Execute request
resp, err := reqBuilder.Send(xhr.method, xhr.url)
if err != nil {
// handle timeout error
if err, ok := err.(net.Error); ok && err.Timeout() {
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() {
if xhr.Timeout > 0 {
xhr.Upload.callOntimeout()
xhr.callOntimeout()
@@ -217,32 +265,37 @@ func (xhr *XMLHttpRequest) Send(data goja.Value) {
xhr.callOnerror()
return
}
defer resp.Body.Close()
xhr.Upload.callOnprogress(contentLength, contentLength)
if !xhr.aborted {
xhr.Upload.callOnload()
}
for k, v := range resp.Header {
xhr.responseHeaders[k] = v[0]
// Set response URL (final URL after redirects)
if resp.Response != nil && resp.Response.Request != nil && resp.Response.Request.URL != nil {
responseUrl := resp.Response.Request.URL
responseUrl.Fragment = ""
xhr.ResponseUrl = responseUrl.String()
} else {
xhr.ResponseUrl = xhr.url
}
// Set response headers
xhr.responseHeaders = resp.Header
xhr.Status = resp.StatusCode
xhr.StatusText = resp.Status
xhr.doReadystatechange(2)
buf, err := io.ReadAll(resp.Body)
if err != nil {
xhr.callOnerror()
return
}
bodyBytes := resp.Bytes()
xhr.doReadystatechange(3)
xhr.Response = string(buf)
xhr.Response = string(bodyBytes)
xhr.ResponseText = xhr.Response
xhr.doReadystatechange(4)
respBodyLen := int64(len(buf))
respBodyLen := int64(len(bodyBytes))
xhr.callOnprogress(respBodyLen, respBodyLen)
if !xhr.aborted {
xhr.callOnload()
}
return
}
func (xhr *XMLHttpRequest) Abort() {
@@ -253,7 +306,10 @@ func (xhr *XMLHttpRequest) Abort() {
}
func (xhr *XMLHttpRequest) GetResponseHeader(key string) string {
return xhr.responseHeaders[key]
if xhr.responseHeaders == nil {
return ""
}
return strings.Join(xhr.responseHeaders.Values(key), ", ")
}
func (xhr *XMLHttpRequest) GetAllResponseHeaders() string {
@@ -261,7 +317,7 @@ func (xhr *XMLHttpRequest) GetAllResponseHeaders() string {
for k, v := range xhr.responseHeaders {
buf.WriteString(k)
buf.WriteString(": ")
buf.WriteString(v)
buf.WriteString(strings.Join(v, ", "))
buf.WriteString("\r\n")
}
return buf.String()
@@ -306,10 +362,10 @@ func (xhr *XMLHttpRequest) parseData(data goja.Value) any {
return data.String()
}
func Enable(runtime *goja.Runtime) error {
func Enable(runtime *goja.Runtime, proxyHandler func(r *http.Request) (*url.URL, error)) error {
progressEvent := runtime.ToValue(func(call goja.ConstructorCall) *goja.Object {
if len(call.Arguments) < 1 {
inject.ThrowTypeError(runtime, "Failed to construct 'ProgressEvent': 1 argument required, but only 0 present.")
util.ThrowTypeError(runtime, "Failed to construct 'ProgressEvent': 1 argument required, but only 0 present.")
}
instance := &ProgressEvent{
Type: call.Argument(0).String(),
@@ -319,7 +375,15 @@ func Enable(runtime *goja.Runtime) error {
return instanceValue
})
xhr := runtime.ToValue(func(call goja.ConstructorCall) *goja.Object {
// Create req client with proxy support
client := req.C()
if proxyHandler != nil {
client.SetProxy(proxyHandler)
}
instance := &XMLHttpRequest{
client: client,
fingerprint: util.SafeGet[string](runtime, FingerprintMagicKey),
Upload: &XMLHttpRequestUpload{
EventProp: &EventProp{
eventListeners: make(map[string]func(event *ProgressEvent)),
@@ -333,6 +397,9 @@ func Enable(runtime *goja.Runtime) error {
instanceValue.SetPrototype(call.This.Prototype())
return instanceValue
})
runtime.Set("__gopeed_setFingerprint", func(fingerprint string) {
runtime.Set(FingerprintMagicKey, fingerprint)
})
if err := runtime.Set("ProgressEvent", progressEvent); err != nil {
return err
}
@@ -378,10 +445,9 @@ func (w *multipartWrapper) Size() int64 {
w.statWriter.Close()
size := int64(w.statBuffer.Len())
for _, v := range w.fields {
switch v.(type) {
switch v := v.(type) {
case *file.File:
f := v.(*file.File)
size += f.Size
size += v.Size
}
}
return size
@@ -389,18 +455,17 @@ func (w *multipartWrapper) Size() int64 {
func (w *multipartWrapper) Send() error {
for k, v := range w.fields {
switch v.(type) {
switch v := v.(type) {
case string:
if err := w.writer.WriteField(k, v.(string)); err != nil {
if err := w.writer.WriteField(k, v); err != nil {
return err
}
case *file.File:
f := v.(*file.File)
fw, err := w.writer.CreateFormFile(k, f.Name)
fw, err := w.writer.CreateFormFile(k, v.Name)
if err != nil {
return err
}
if _, err = io.Copy(fw, f); err != nil {
if _, err = io.Copy(fw, v); err != nil {
return err
}
}
@@ -0,0 +1,24 @@
package xhr
import "github.com/imroc/req/v3"
type Fingerprint string
const (
FingerprintMagicKey = "__gopeed_xhr_fingerprint"
fingerprintChrome = "chrome"
fingerprintFirefox = "firefox"
fingerprintSafari = "safari"
)
func setFingerprint(client *req.Client, fingerprint string) {
switch fingerprint {
case fingerprintChrome:
client.ImpersonateChrome()
case fingerprintFirefox:
client.ImpersonateFirefox()
case fingerprintSafari:
client.ImpersonateSafari()
}
}
+1 -1
View File
@@ -1 +1 @@
(()=>{var t={725:function(t,e,r){var o,n,i;i="undefined"!=typeof self&&self||"undefined"!=typeof window&&window||void 0!==r.g&&r.g||this,o=function(t){"use strict";var e=i.BlobBuilder||i.WebKitBlobBuilder||i.MSBlobBuilder||i.MozBlobBuilder,r=i.URL||i.webkitURL||function(t,e){return(e=document.createElement("a")).href=t,e},o=i.Blob,n=r.createObjectURL,a=r.revokeObjectURL,s=i.Symbol&&i.Symbol.toStringTag,u=!1,f=!1,l=e&&e.prototype.append&&e.prototype.getBlob;try{u=2===new Blob(["ä"]).size,f=2===new Blob([new Uint8Array([1,2])]).size}catch(t){}function c(t){return t.map((function(t){if(t.buffer instanceof ArrayBuffer){var e=t.buffer;if(t.byteLength!==e.byteLength){var r=new Uint8Array(t.byteLength);r.set(new Uint8Array(e,t.byteOffset,t.byteLength)),e=r.buffer}return e}return t}))}function h(t,r){r=r||{};var o=new e;return c(t).forEach((function(t){o.append(t)})),r.type?o.getBlob(r.type):o.getBlob()}function p(t,e){return new o(c(t),e||{})}i.Blob&&(h.prototype=Blob.prototype,p.prototype=Blob.prototype);var y="function"==typeof TextEncoder?TextEncoder.prototype.encode.bind(new TextEncoder):function(t){for(var e=0,r=t.length,o=i.Uint8Array||Array,n=0,a=Math.max(32,r+(r>>1)+7),s=new o(a>>3<<3);e<r;){var u=t.charCodeAt(e++);if(u>=55296&&u<=56319){if(e<r){var f=t.charCodeAt(e);56320==(64512&f)&&(++e,u=((1023&u)<<10)+(1023&f)+65536)}if(u>=55296&&u<=56319)continue}if(n+4>s.length){a+=8,a=(a*=1+e/t.length*2)>>3<<3;var l=new Uint8Array(a);l.set(s),s=l}if(0!=(4294967168&u)){if(0==(4294965248&u))s[n++]=u>>6&31|192;else if(0==(4294901760&u))s[n++]=u>>12&15|224,s[n++]=u>>6&63|128;else{if(0!=(4292870144&u))continue;s[n++]=u>>18&7|240,s[n++]=u>>12&63|128,s[n++]=u>>6&63|128}s[n++]=63&u|128}else s[n++]=u}return s.slice(0,n)},d="function"==typeof TextDecoder?TextDecoder.prototype.decode.bind(new TextDecoder):function(t){for(var e=t.length,r=[],o=0;o<e;){var n,i,a,s,u=t[o],f=null,l=u>239?4:u>223?3:u>191?2:1;if(o+l<=e)switch(l){case 1:u<128&&(f=u);break;case 2:128==(192&(n=t[o+1]))&&(s=(31&u)<<6|63&n)>127&&(f=s);break;case 3:n=t[o+1],i=t[o+2],128==(192&n)&&128==(192&i)&&(s=(15&u)<<12|(63&n)<<6|63&i)>2047&&(s<55296||s>57343)&&(f=s);break;case 4:n=t[o+1],i=t[o+2],a=t[o+3],128==(192&n)&&128==(192&i)&&128==(192&a)&&(s=(15&u)<<18|(63&n)<<12|(63&i)<<6|63&a)>65535&&s<1114112&&(f=s)}null===f?(f=65533,l=1):f>65535&&(f-=65536,r.push(f>>>10&1023|55296),f=56320|1023&f),r.push(f),o+=l}for(var c=r.length,h="",p=0;p<c;)h+=String.fromCharCode.apply(String,r.slice(p,p+=4096));return h};function b(){var e=!!i.ActiveXObject||"-ms-scroll-limit"in document.documentElement.style&&"-ms-ime-align"in document.documentElement.style,r=i.XMLHttpRequest&&i.XMLHttpRequest.prototype.send;e&&r&&(XMLHttpRequest.prototype.send=function(t){t instanceof Blob?(this.setRequestHeader("Content-Type",t.type),r.call(this,t)):r.call(this,t)});try{new File([],""),t.File=i.File,t.FileReader=i.FileReader}catch(e){try{t.File=new Function('class File extends Blob {constructor(chunks, name, opts) {opts = opts || {};super(chunks, opts || {});this.name = name.replace(/\\//g, ":");this.lastModifiedDate = opts.lastModified ? new Date(opts.lastModified) : new Date();this.lastModified = +this.lastModifiedDate;}};return new File([], ""), File')()}catch(e){t.File=function(t,e,r){var o=new Blob(t,r),n=r&&void 0!==r.lastModified?new Date(r.lastModified):new Date;return o.name=e.replace(/\//g,":"),o.lastModifiedDate=n,o.lastModified=+n,o.toString=function(){return"[object File]"},s&&(o[s]="File"),o}}}}u?(b(),t.Blob=f?i.Blob:p):l?(b(),t.Blob=h):function(){function e(t){for(var e=new Array(t.byteLength),r=new Uint8Array(t),o=e.length;o--;)e[o]=r[o];return e}function o(t){for(var e="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",r=[],o=0;o<t.length;o+=3){var n=t[o],i=o+1<t.length,a=i?t[o+1]:0,s=o+2<t.length,u=s?t[o+2]:0,f=n>>2,l=(3&n)<<4|a>>4,c=(15&a)<<2|u>>6,h=63&u;s||(h=64,i||(c=64)),r.push(e[f],e[l],e[c],e[h])}return r.join("")}var s=Object.create||function(t){function e(){}return e.prototype=t,new e};function u(t){return Object.prototype.toString.call(t).slice(8,-1)}function f(t,e){return"object"==typeof t&&Object.prototype.isPrototypeOf.call(t.prototype,e)}var l=["Int8Array","Uint8Array","Uint8ClampedArray","Int16Array","Uint16Array","Int32Array","Uint32Array","Float32Array","Float64Array","ArrayBuffer"];function c(t){return e=l,r=u(t),-1!==e.indexOf(r)||f(i.ArrayBuffer,t);var e,r}function h(t,r){r=null==r?{}:r;for(var o=0,n=(t=t?t.slice():[]).length;o<n;o++){var a=t[o];a instanceof h?t[o]=a._buffer:"string"==typeof a?t[o]=y(a):"DataView"===u(s=a)||f(i.DataView,s)?t[o]=e(a.buffer):c(a)?t[o]=e(a):t[o]=y(String(a))}var s;this._buffer=i.Uint8Array?function(t){for(var e=0,r=t.length;r--;)e+=t[r].length;for(var o=new Uint8Array(e),n=0,i=0;i<t.length;i++){var a=t[i];o.set(a,n),n+=a.byteLength||a.length}return o}(t):[].concat.apply([],t),this.size=this._buffer.length,this.type=r.type||"",/[^\u0020-\u007E]/.test(this.type)?this.type="":this.type=this.type.toLowerCase()}funcLine truncated
(()=>{var e={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
+5 -2
View File
@@ -7,16 +7,19 @@
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "webpack --mode production --watch",
"build": "webpack --mode production"
"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",
"whatwg-fetch": "^3.6.2"
"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) {
@@ -1,3 +1,5 @@
import "./blob/index.js"
import "./crypto/index.js"
// polyfill TextEncoder
import 'fastestsmallesttextencoderdecoder';
import "./fetch/index.js"
+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
}
+333 -61
View File
@@ -3,12 +3,6 @@ package download
import (
"encoding/json"
"fmt"
"github.com/GopeedLab/gopeed/internal/logger"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/download/engine"
"github.com/GopeedLab/gopeed/pkg/util"
"github.com/dop251/goja"
"github.com/go-git/go-git/v5"
"io"
"os"
"path"
@@ -16,6 +10,16 @@ import (
"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 (
@@ -33,8 +37,9 @@ type ActivationEvent string
const (
EventOnResolve ActivationEvent = "onResolve"
//EventOnError ActivationEvent = "onError"
//EventOnDone ActivationEvent = "onDone"
EventOnStart ActivationEvent = "onStart"
EventOnError ActivationEvent = "onError"
EventOnDone ActivationEvent = "onDone"
)
func (d *Downloader) InstallExtensionByGit(url string) (*Extension, error) {
@@ -47,7 +52,7 @@ func (d *Downloader) InstallExtensionByFolder(path string, devMode bool) (*Exten
return nil, err
}
// if dev mode, don't copy the extension to the extensions' directory
// if dev mode, don't copy to the extensions' directory
if devMode {
ext.DevMode = true
ext.DevPath, _ = filepath.Abs(path)
@@ -151,7 +156,13 @@ func (d *Downloader) DeleteExtension(identity string) error {
break
}
}
return d.storage.Delete(bucketExtension, identity)
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 {
@@ -190,19 +201,30 @@ func (d *Downloader) fetchExtensionByGit(url string, handler func(tempExtPath st
subPath = pathArr[1]
}
tempExtDir := filepath.Join(d.cfg.StorageDir, tempExtensionsDir, projectPath)
if err := util.RmAndMkDirAll(tempExtDir); err != nil {
// 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,
URL: gitUrl,
Depth: 1,
ProxyOptions: proxyOptions,
}); err != nil {
return nil, err
}
defer os.RemoveAll(tempExtDir)
return handler(filepath.Join(tempExtDir, subPath), false)
}
@@ -228,24 +250,97 @@ func (d *Downloader) parseExtensionByPath(path string) (*Extension, error) {
return &ext, nil
}
func (d *Downloader) triggerOnResolve(req *base.Request) (res *base.Resource) {
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),
}
ctx := &Context{
Req: req,
}
var err error
for _, ext := range d.extensions {
if ext.Disabled {
continue
}
for _, script := range ext.Scripts {
if script.match(EventOnResolve, req.URL) {
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() {
@@ -256,14 +351,19 @@ func (d *Downloader) triggerOnResolve(req *base.Request) (res *base.Resource) {
return
}
defer scriptFile.Close()
ctx.Settings = parseSettings(ext.Settings)
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
}
engine := engine.NewEngine()
// 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 {
@@ -275,27 +375,26 @@ func (d *Downloader) triggerOnResolve(req *base.Request) (res *base.Resource) {
gopeed.Logger.logger.Error().Err(err).Msgf("[%s] run script failed", ext.buildIdentity())
return
}
if fn, ok := gopeed.Events[EventOnResolve]; ok {
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", EventOnResolve, ext.buildIdentity())
gopeed.Logger.logger.Error().Err(err).Msgf("[%s] call function failed: %s", ext.buildIdentity(), event)
return
}
// calculate resource total size
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.CalcSize()
if handler != nil {
handler(ext, gopeed, ctx)
}
res = ctx.Res
}
}()
}
}
}
return
// Only return MessageError
if me, ok := gojautil.AssertError[*gojaerror.MessageError](err); ok {
return me
}
return nil
}
func (d *Downloader) ExtensionPath(ext *Extension) string {
@@ -378,8 +477,50 @@ func (e *Extension) update(newExt *Extension) error {
e.Homepage = newExt.Homepage
e.Repository = newExt.Repository
e.Scripts = newExt.Scripts
// don't override settings
//e.Settings = newExt.Settings
// 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
}
@@ -392,30 +533,46 @@ type Repository struct {
type Script struct {
// Event active event name
Event string `json:"event"`
// Matches match request url pattern list
Matches []string `json:"matches"`
// Match rules
Match *Match `json:"match"`
// Entry js script file path
Entry string `json:"entry"`
}
func (s *Script) match(event ActivationEvent, url string) bool {
func (s *Script) match(event ActivationEvent, req *base.Request) bool {
if s.Event == "" {
return false
}
if s.Event != string(event) {
return false
}
if len(s.Matches) == 0 {
if s.Match == nil || (len(s.Match.Urls) == 0 && len(s.Match.Labels) == 0) {
return false
}
for _, match := range s.Matches {
if util.Match(match, url) {
// 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 (
@@ -442,10 +599,13 @@ type Option struct {
Value any `json:"value"`
}
// Instance inject to js context
// Instance inject to js context when extension script is activated
type Instance struct {
Events InstanceEvents `json:"events"`
Logger *InstanceLogger `json:"logger"`
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
@@ -458,13 +618,35 @@ func (h InstanceEvents) OnResolve(fn goja.Callable) {
h.register(EventOnResolve, fn)
}
//func (h InstanceEvents) OnError(fn goja.Callable) {
// h.register(HookEventOnError, fn)
//}
//
//func (h InstanceEvents) OnDone(fn goja.Callable) {
// h.register(HookEventOnDone, 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
@@ -472,28 +654,28 @@ type InstanceLogger struct {
logger *logger.Logger
}
func (l *InstanceLogger) Debug(msg ...any) {
func (l *InstanceLogger) Debug(msg ...goja.Value) {
if l.devMode {
l.logger.Debug().Msg(l.append(msg...))
}
}
func (l *InstanceLogger) Info(msg ...any) {
func (l *InstanceLogger) Info(msg ...goja.Value) {
l.logger.Info().Msg(l.append(msg...))
}
func (l *InstanceLogger) Warn(msg ...any) {
func (l *InstanceLogger) Warn(msg ...goja.Value) {
l.logger.Warn().Msg(l.append(msg...))
}
func (l *InstanceLogger) Error(msg ...any) {
func (l *InstanceLogger) Error(msg ...goja.Value) {
l.logger.Error().Msg(l.append(msg...))
}
func (l *InstanceLogger) append(msg ...any) string {
func (l *InstanceLogger) append(msg ...goja.Value) string {
strMsg := make([]string, len(msg))
for i, m := range msg {
strMsg[i] = fmt.Sprint(m)
strMsg[i] = m.String()
}
return fmt.Sprintf("[%s] %s", l.identity, strings.Join(strMsg, " "))
}
@@ -506,10 +688,53 @@ func newInstanceLogger(extension *Extension, logger *logger.Logger) *InstanceLog
}
}
type Context struct {
Req *base.Request `json:"req"`
Res *base.Resource `json:"res"`
Settings map[string]any `json:"settings"`
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 {
@@ -547,3 +772,50 @@ func tryParse(val any, settingType SettingType) any {
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
}
+248 -33
View File
@@ -1,10 +1,15 @@
package download
import (
"github.com/GopeedLab/gopeed/internal/logger"
"github.com/GopeedLab/gopeed/pkg/base"
"errors"
"os"
"testing"
"time"
"github.com/GopeedLab/gopeed/internal/logger"
"github.com/GopeedLab/gopeed/pkg/base"
gojaerror "github.com/GopeedLab/gopeed/pkg/download/engine/inject/error"
"github.com/dop251/goja"
)
func TestDownloader_InstallExtensionByFolder(t *testing.T) {
@@ -14,7 +19,7 @@ func TestDownloader_InstallExtensionByFolder(t *testing.T) {
}
rr, err := downloader.Resolve(&base.Request{
URL: "https://github.com/test",
})
}, nil)
if err != nil {
t.Fatal(err)
}
@@ -31,7 +36,7 @@ func TestDownloader_InstallExtensionByFolderDevMode(t *testing.T) {
}
rr, err := downloader.Resolve(&base.Request{
URL: "https://github.com/test",
})
}, nil)
if err != nil {
t.Fatal(err)
}
@@ -48,7 +53,7 @@ func TestDownloader_InstallExtensionByGit(t *testing.T) {
}
rr, err := downloader.Resolve(&base.Request{
URL: "https://github.com/GopeedLab/gopeed/releases",
})
}, nil)
if err != nil {
t.Fatal(err)
}
@@ -65,7 +70,7 @@ func TestDownloader_InstallExtensionByGitSimple(t *testing.T) {
}
rr, err := downloader.Resolve(&base.Request{
URL: "https://github.com/GopeedLab/gopeed/releases",
})
}, nil)
if err != nil {
t.Fatal(err)
}
@@ -82,7 +87,7 @@ func TestDownloader_InstallExtensionByGitFull(t *testing.T) {
}
rr, err := downloader.Resolve(&base.Request{
URL: "https://github.com/GopeedLab/gopeed/releases",
})
}, nil)
if err != nil {
t.Fatal(err)
}
@@ -93,6 +98,15 @@ func TestDownloader_InstallExtensionByGitFull(t *testing.T) {
}
func TestDownloader_UpgradeExtension(t *testing.T) {
getSetting := func(settings []*Setting, name string) *Setting {
for _, setting := range settings {
if setting.Name == name {
return setting
}
}
return nil
}
setupDownloader(func(downloader *Downloader) {
installedExt, err := downloader.InstallExtensionByFolder("./testdata/extensions/update", false)
if err != nil {
@@ -120,14 +134,214 @@ func TestDownloader_UpgradeExtension(t *testing.T) {
t.Fatal("extension update fail")
}
// check setting update
s1 := getSetting(upgradeExt.Settings, "s1")
if s1.Title == "S1 old" {
t.Fatal("setting update fail")
}
// check setting type update
s2 := getSetting(upgradeExt.Settings, "s2")
if s2.Type == "number" {
t.Fatal("setting type update fail")
}
// check setting remove
d1 := getSetting(upgradeExt.Settings, "d1")
if d1 != nil {
t.Fatal("setting remove fail")
}
// check setting add
s3 := getSetting(upgradeExt.Settings, "s3")
if s3 == nil {
t.Fatal("setting add fail")
}
rr, err := downloader.Resolve(&base.Request{
URL: "https://github.com/GopeedLab/gopeed/releases",
})
URL: "https://test.com",
}, nil)
if err != nil {
t.Fatal(err)
}
if len(rr.Res.Files) == 1 {
t.Fatal("resolve error")
if rr.Res.Name != "test" {
t.Fatal("script update fail")
}
})
}
func TestDownloader_Extension_OnStart(t *testing.T) {
downloadAndCheck := func(req *base.Request) {
setupDownloader(func(downloader *Downloader) {
if _, err := downloader.InstallExtensionByFolder("./testdata/extensions/on_start", false); err != nil {
t.Fatal(err)
}
errCh := make(chan error, 1)
downloader.Listener(func(event *Event) {
if event.Key == EventKeyFinally {
errCh <- event.Err
}
})
id, err := downloader.CreateDirect(req, nil)
if err != nil {
t.Fatal(err)
}
select {
case err = <-errCh:
break
case <-time.After(time.Second * 30): // Increased timeout for real network requests
err = errors.New("timeout")
}
if err != nil {
panic("extension on start download error: " + err.Error())
}
task := downloader.GetTask(id)
if task.Meta.Req.URL != "https://github.com" {
t.Fatalf("except url: https://github.com, actual: %s", task.Meta.Req.URL)
}
if task.Meta.Req.Labels["modified"] != "true" {
t.Fatalf("except label: modified=true, actual: %s", task.Meta.Req.Labels["modified"])
}
})
}
// url match
downloadAndCheck(&base.Request{
URL: "https://github.com/gopeed/test/404",
})
// label match
downloadAndCheck(&base.Request{
URL: "https://test.com",
Labels: map[string]string{
"test": "true",
},
})
}
func TestDownloader_Extension_OnError(t *testing.T) {
setupDownloader(func(downloader *Downloader) {
if _, err := downloader.InstallExtensionByFolder("./testdata/extensions/on_error", false); err != nil {
t.Fatal(err)
}
errCh := make(chan error, 1)
downloader.Listener(func(event *Event) {
if event.Key == EventKeyFinally {
errCh <- event.Err
}
})
id, err := downloader.CreateDirect(&base.Request{
URL: "https://github.com/gopeed/test/404",
Labels: map[string]string{
"test": "true",
},
}, nil)
if err != nil {
t.Fatal(err)
}
select {
case err = <-errCh:
break
case <-time.After(time.Second * 30): // Increased timeout for real network requests
err = errors.New("timeout")
}
if err != nil {
panic("extension on error download error: " + err.Error())
}
// extension on error modify url and continue download
task := downloader.GetTask(id)
if task.Status != base.DownloadStatusDone {
t.Fatalf("except status is done, actual: %s", task.Status)
}
})
}
func TestDownloader_Extension_OnDone(t *testing.T) {
setupDownloader(func(downloader *Downloader) {
if _, err := downloader.InstallExtensionByFolder("./testdata/extensions/on_done", false); err != nil {
t.Fatal(err)
}
errCh := make(chan error, 1)
downloader.Listener(func(event *Event) {
if event.Key == EventKeyFinally {
errCh <- event.Err
}
})
id, err := downloader.CreateDirect(&base.Request{
URL: "https://github.com",
}, nil)
if err != nil {
t.Fatal(err)
}
select {
case err = <-errCh:
break
case <-time.After(time.Second * 30): // Increased timeout for real network requests
err = errors.New("timeout")
}
// wait for script execution
time.Sleep(time.Millisecond * 3000)
if err != nil {
panic("extension on done download error: " + err.Error())
}
// extension on error modify url and continue download
task := downloader.GetTask(id)
if task.Meta.Req.Labels["modified"] != "true" {
t.Fatalf("except label: modified=true, actual: %s", task.Meta.Req.Labels["modified"])
}
if task.Status != base.DownloadStatusDone {
t.Fatalf("except status is done, actual: %s", task.Status)
}
})
}
func TestDownloader_Extension_Errors(t *testing.T) {
setupDownloader(func(downloader *Downloader) {
if _, err := downloader.InstallExtensionByFolder("./testdata/extensions/script_error", false); err != nil {
t.Fatal(err)
}
rr, err := downloader.Resolve(&base.Request{
URL: "https://github.com/test",
}, nil)
if err != nil {
t.Fatal(err)
}
if len(rr.Res.Files) == 2 {
t.Fatal("script error catch failed")
}
})
setupDownloader(func(downloader *Downloader) {
if _, err := downloader.InstallExtensionByFolder("./testdata/extensions/function_error", false); err != nil {
t.Fatal(err)
}
rr, err := downloader.Resolve(&base.Request{
URL: "https://github.com/test",
}, nil)
if err != nil {
t.Fatal(err)
}
if len(rr.Res.Files) == 2 {
t.Fatal("function error catch failed")
}
})
setupDownloader(func(downloader *Downloader) {
if _, err := downloader.InstallExtensionByFolder("./testdata/extensions/message_error", false); err != nil {
t.Fatal(err)
}
_, err := downloader.Resolve(&base.Request{
URL: "https://github.com/test",
}, nil)
if err == nil {
t.Fatalf("except error, but got nil")
}
me, ok := err.(*gojaerror.MessageError)
if !ok {
t.Fatalf("except MessageError type, but got %s", err)
}
want := "test"
if me.Error() != want {
t.Fatalf("except MessageError message %s, but got %s", want, me.Message)
}
})
}
@@ -139,7 +353,7 @@ func TestDownloader_Extension_Settings(t *testing.T) {
}
rr, err := downloader.Resolve(&base.Request{
URL: "https://github.com/test",
})
}, nil)
if err != nil {
t.Fatal(err)
}
@@ -160,7 +374,7 @@ func TestDownloader_Extension_Settings(t *testing.T) {
})
rr, err := downloader.Resolve(&base.Request{
URL: "https://github.com/test",
})
}, nil)
if err != nil {
t.Fatal(err)
}
@@ -170,6 +384,23 @@ func TestDownloader_Extension_Settings(t *testing.T) {
})
}
func TestDownloader_ExtensionStorage(t *testing.T) {
setupDownloader(func(downloader *Downloader) {
if _, err := downloader.InstallExtensionByFolder("./testdata/extensions/storage", false); err != nil {
t.Fatal(err)
}
rr, err := downloader.Resolve(&base.Request{
URL: "https://github.com/test",
}, nil)
if err != nil {
t.Fatal(err)
}
if len(rr.Res.Files) == 1 {
t.Fatal("resolve error")
}
})
}
func TestDownloader_SwitchExtension(t *testing.T) {
setupDownloader(func(downloader *Downloader) {
installedExt, err := downloader.InstallExtensionByFolder("./testdata/extensions/basic", false)
@@ -205,31 +436,15 @@ func TestDownloader_DeleteExtension(t *testing.T) {
})
}
func TestDownloader_Extension_OnResolve(t *testing.T) {
setupDownloader(func(downloader *Downloader) {
installedExt, err := downloader.InstallExtensionByFolder("./testdata/extensions/settings_all", false)
if err != nil {
t.Fatal(err)
}
if err := downloader.DeleteExtension(installedExt.Identity); err != nil {
t.Fatal(err)
}
extensions := downloader.GetExtensions()
if len(extensions) != 0 {
t.Fatal("extension delete fail")
}
})
}
func TestDownloader_Extension_Logger(t *testing.T) {
logger := logger.NewLogger(false, "")
il := newInstanceLogger(&Extension{
Name: "test",
}, logger)
il.Debug("msg1", "msg2")
il.Info("msg1", "msg2")
il.Warn("msg1", "msg2")
il.Error("msg1", "msg2")
il.Debug(goja.NaN(), goja.Undefined())
il.Info(goja.NaN(), goja.Undefined())
il.Warn(goja.NaN(), goja.Undefined())
il.Error(goja.NaN(), goja.Undefined())
}
func setupDownloader(fn func(downloader *Downloader)) {
+479
View File
@@ -0,0 +1,479 @@
package download
import (
"context"
"io"
"math"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"sync/atomic"
"github.com/mholt/archives"
"golang.org/x/text/encoding/simplifiedchinese"
)
// supportedArchiveExtensions contains file extensions supported by mholt/archives library
var supportedArchiveExtensions = []string{
// Archive formats
".zip",
".tar",
".tar.gz", ".tgz",
".tar.bz2", ".tbz2",
".tar.xz", ".txz",
".tar.lz4", ".tlz4",
".tar.sz", ".tsz",
".tar.zst", ".tzst",
".rar",
".7z",
// Compression formats
".gz",
".bz2",
".xz",
".lz4",
".sz",
".zst",
".br",
".lz",
}
// multiPartArchivePatterns contains regex patterns for multi-part archive detection
// Each pattern should have a capture group for the base name and part number
var multiPartArchivePatterns = []*regexp.Regexp{
// 7z multi-part: file.7z.001, file.7z.002, etc.
regexp.MustCompile(`(?i)^(.+\.7z)\.(\d{3})$`),
// RAR multi-part (new style): file.part01.rar, file.part02.rar, etc.
regexp.MustCompile(`(?i)^(.+)\.part(\d+)\.rar$`),
// RAR multi-part (old style): file.rar, file.r00, file.r01, etc. (first file is .rar)
regexp.MustCompile(`(?i)^(.+)\.r(\d{2})$`),
// ZIP multi-part: file.zip.001, file.zip.002, etc.
regexp.MustCompile(`(?i)^(.+\.zip)\.(\d{3})$`),
// ZIP split: file.z01, file.z02, ... file.zip (last file is .zip)
regexp.MustCompile(`(?i)^(.+)\.z(\d{2})$`),
}
// ArchivePartInfo contains information about a multi-part archive
type ArchivePartInfo struct {
// IsMultiPart indicates if this file is part of a multi-part archive
IsMultiPart bool
// BaseName is the common base name for all parts (without part number extension)
BaseName string
// PartNumber is the part number (1-indexed)
PartNumber int
// FirstPartPath is the path to the first part of the archive
FirstPartPath string
// Pattern indicates which pattern matched (for determining extraction method)
Pattern string
}
// ExtractProgressCallback is called to report extraction progress
type ExtractProgressCallback func(extractedFiles int, totalFiles int, progress int)
// newZipFormat creates a Zip format with proper character encoding support.
// It uses GB18030 encoding to handle Chinese characters in filenames that may
// be encoded with legacy GBK/GB18030 instead of UTF-8.
func newZipFormat() archives.Zip {
return archives.Zip{
// GB18030 is a superset of GBK and handles Chinese characters correctly
TextEncoding: simplifiedchinese.GB18030,
}
}
// isArchiveFile checks if a file is a supported archive format
func isArchiveFile(filename string) bool {
lowerName := strings.ToLower(filename)
// Check for multi-part archive first
if isMultiPartArchive(filename) {
return true
}
return slices.ContainsFunc(supportedArchiveExtensions, func(ext string) bool {
return strings.HasSuffix(lowerName, ext)
})
}
// archiveInfo holds information about an opened archive
type archiveInfo struct {
file *os.File
stat os.FileInfo
format archives.Format
input io.Reader
}
// openArchive opens an archive file and identifies its format
func openArchive(archivePath string, password string) (*archiveInfo, error) {
file, err := os.Open(archivePath)
if err != nil {
return nil, err
}
stat, err := file.Stat()
if err != nil {
file.Close()
return nil, err
}
format, input, err := archives.Identify(context.Background(), archivePath, file)
if err != nil {
file.Close()
return nil, err
}
// Configure format-specific settings
// Handle password-protected archives and character encoding
if password != "" {
if rar, ok := format.(archives.Rar); ok {
rar.Password = password
format = rar
}
if sz, ok := format.(archives.SevenZip); ok {
sz.Password = password
format = sz
}
}
// For ZIP files, configure character encoding to handle non-UTF8 filenames
// This is essential for Chinese characters encoded in GBK/GB18030
if _, ok := format.(archives.Zip); ok {
format = newZipFormat()
}
return &archiveInfo{
file: file,
stat: stat,
format: format,
input: input,
}, nil
}
// createExtractionHandler creates a handler function for extracting files with progress tracking
func createExtractionHandler(destDir string, totalFiles int, progressCallback ExtractProgressCallback) func(ctx context.Context, fileInfo archives.FileInfo) error {
var extractedFiles atomic.Int32
return func(ctx context.Context, fileInfo archives.FileInfo) error {
err := extractFile(ctx, fileInfo, destDir)
if err == nil && !fileInfo.IsDir() {
extracted := int(extractedFiles.Add(1))
if progressCallback != nil && totalFiles > 0 {
progress := int(math.Min(float64((extracted*100)/totalFiles), 100))
progressCallback(extracted, totalFiles, progress)
}
}
return err
}
}
// extractArchive extracts an archive file to a destination directory
func extractArchive(archivePath string, destDir string, password string, progressCallback ExtractProgressCallback) error {
// Open the archive file
info, err := openArchive(archivePath, password)
if err != nil {
return err
}
defer info.file.Close()
// Create destination directory if it doesn't exist
if err := os.MkdirAll(destDir, 0755); err != nil {
return err
}
// Handle extraction based on format type
switch f := info.format.(type) {
case archives.Extractor:
// For archive formats (zip, rar, 7z, tar, etc.)
// First, count total files for progress tracking
totalFiles, err := countArchiveFiles(archivePath, password)
if err != nil {
// If counting fails, proceed without progress reporting
totalFiles = 0
}
return f.Extract(context.Background(), info.input, createExtractionHandler(destDir, totalFiles, progressCallback))
case archives.Decompressor:
// For single-file compression formats (gz, bz2, xz, etc.)
// Decompress to a file without the compression extension
baseName := filepath.Base(archivePath)
lowerBaseName := strings.ToLower(baseName)
for _, ext := range supportedArchiveExtensions {
if strings.HasSuffix(lowerBaseName, ext) {
// Get the actual suffix from the original filename (preserving case)
actualSuffix := baseName[len(baseName)-len(ext):]
baseName = strings.TrimSuffix(baseName, actualSuffix)
break
}
}
destPath := filepath.Join(destDir, baseName)
reader, err := f.OpenReader(info.input)
if err != nil {
return err
}
defer reader.Close()
destFile, err := os.Create(destPath)
if err != nil {
return err
}
defer destFile.Close()
// Report progress at start and end for decompression
if progressCallback != nil {
progressCallback(0, 1, 0)
}
_, err = io.Copy(destFile, reader)
if err == nil && progressCallback != nil {
progressCallback(1, 1, 100)
}
return err
case archives.Archiver:
// This format is an archiver, try to extract using the extractor interface
if ext, ok := info.format.(archives.Extractor); ok {
// Reset file position
if seeker, ok := info.input.(io.Seeker); ok {
seeker.Seek(0, io.SeekStart)
}
// Count total files for progress tracking
totalFiles, err := countArchiveFiles(archivePath, password)
if err != nil {
totalFiles = 0
}
return ext.Extract(context.Background(), io.NewSectionReader(info.file, 0, info.stat.Size()), createExtractionHandler(destDir, totalFiles, progressCallback))
}
}
return nil
}
// createCountingHandler creates a handler function for counting files in an archive
func createCountingHandler(count *int) func(ctx context.Context, fileInfo archives.FileInfo) error {
return func(ctx context.Context, fileInfo archives.FileInfo) error {
if !fileInfo.IsDir() {
(*count)++
}
return nil
}
}
// countArchiveFiles counts the number of files in an archive for progress calculation
func countArchiveFiles(archivePath string, password string) (int, error) {
info, err := openArchive(archivePath, password)
if err != nil {
return 0, err
}
defer info.file.Close()
count := 0
switch f := info.format.(type) {
case archives.Extractor:
err = f.Extract(context.Background(), info.input, createCountingHandler(&count))
case archives.Archiver:
if ext, ok := info.format.(archives.Extractor); ok {
if seeker, ok := info.input.(io.Seeker); ok {
seeker.Seek(0, io.SeekStart)
}
err = ext.Extract(context.Background(), io.NewSectionReader(info.file, 0, info.stat.Size()), createCountingHandler(&count))
}
case archives.Decompressor:
// Single file compression, count as 1
return 1, nil
}
return count, err
}
// extractFile handles extracting a single file from an archive
func extractFile(ctx context.Context, fileInfo archives.FileInfo, destDir string) error {
// Skip directories, they will be created when extracting files
if fileInfo.IsDir() {
destPath := filepath.Join(destDir, fileInfo.NameInArchive)
return os.MkdirAll(destPath, fileInfo.Mode())
}
// Sanitize the path to prevent path traversal attacks
cleanPath := filepath.Clean(fileInfo.NameInArchive)
if strings.HasPrefix(cleanPath, "..") || filepath.IsAbs(cleanPath) {
// Skip files with suspicious paths
return nil
}
destPath := filepath.Join(destDir, cleanPath)
// Create parent directories
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
return err
}
// Open the file from the archive
reader, err := fileInfo.Open()
if err != nil {
return err
}
defer reader.Close()
// Create the destination file
destFile, err := os.Create(destPath)
if err != nil {
return err
}
defer destFile.Close()
// Copy the contents
_, err = io.Copy(destFile, reader)
if err != nil {
return err
}
// Set file permissions
return os.Chmod(destPath, fileInfo.Mode())
}
// isMultiPartArchive checks if a file is part of a multi-part archive
func isMultiPartArchive(filename string) bool {
info := getArchivePartInfo(filename)
return info.IsMultiPart
}
// getArchivePartInfo returns detailed information about a multi-part archive file
func getArchivePartInfo(filename string) ArchivePartInfo {
baseName := filepath.Base(filename)
for _, pattern := range multiPartArchivePatterns {
matches := pattern.FindStringSubmatch(baseName)
if len(matches) >= 3 {
partNum := 0
_, err := parsePartNumber(matches[2], &partNum)
if err != nil {
continue
}
info := ArchivePartInfo{
IsMultiPart: true,
BaseName: matches[1],
PartNumber: partNum,
Pattern: pattern.String(),
}
// Determine the first part path based on pattern
dir := filepath.Dir(filename)
info.FirstPartPath = determineFirstPartPath(dir, info.BaseName, pattern.String())
return info
}
}
// Check for .rar files that might be the first part of old-style multi-part RAR
if strings.HasSuffix(strings.ToLower(baseName), ".rar") && !strings.Contains(strings.ToLower(baseName), ".part") {
// Check if there are .r00, .r01 files in the same directory
dir := filepath.Dir(filename)
nameWithoutExt := strings.TrimSuffix(baseName, filepath.Ext(baseName))
r00Path := filepath.Join(dir, nameWithoutExt+".r00")
if _, err := os.Stat(r00Path); err == nil {
return ArchivePartInfo{
IsMultiPart: true,
BaseName: nameWithoutExt,
PartNumber: 1, // .rar is the first part in old-style
FirstPartPath: filename,
Pattern: "rar-old-style",
}
}
}
return ArchivePartInfo{IsMultiPart: false}
}
// parsePartNumber parses a part number string and stores it in the provided pointer
// Returns the parsed number and nil error on success
func parsePartNumber(s string, partNum *int) (int, error) {
n := 0
for _, c := range s {
if c >= '0' && c <= '9' {
n = n*10 + int(c-'0')
}
}
// For .001, .002 style, part number is the value itself
// For .part01 style, part number is the value itself
// For .r00, .r01 style: r00=2 (since .rar is part 1), r01=3, etc.
// However, for consistency, we return the raw number and handle the offset elsewhere
*partNum = n
// Treat 00 as part 0, let callers handle the semantics
if n == 0 {
*partNum = 1 // For .001 format, 001 should be 1
}
return *partNum, nil
}
// determineFirstPartPath determines the path to the first part of a multi-part archive
func determineFirstPartPath(dir, baseName, pattern string) string {
switch {
case strings.Contains(pattern, `.7z)`):
// 7z multi-part: first part is .7z.001
return filepath.Join(dir, baseName+".001")
case strings.Contains(pattern, `.part`):
// RAR new style: first part is .part01.rar or .part1.rar
// Try both single and double digit formats
if _, err := os.Stat(filepath.Join(dir, baseName+".part1.rar")); err == nil {
return filepath.Join(dir, baseName+".part1.rar")
}
return filepath.Join(dir, baseName+".part01.rar")
case strings.Contains(pattern, `.r(`):
// RAR old style: first part is .rar (not .r00)
return filepath.Join(dir, baseName+".rar")
case strings.Contains(pattern, `.zip)`):
// ZIP multi-part: first part is .zip.001
return filepath.Join(dir, baseName+".001")
case strings.Contains(pattern, `.z(`):
// ZIP split: last part is .zip, but extraction should start from .z01
return filepath.Join(dir, baseName+".z01")
default:
return ""
}
}
// isFirstPart checks if the given file is the first part of a multi-part archive
func isFirstPart(filename string) bool {
info := getArchivePartInfo(filename)
if !info.IsMultiPart {
return false
}
// For most formats, part 1 is the first part
// For old-style RAR, check if this is the .rar file
if info.Pattern == "rar-old-style" {
return strings.HasSuffix(strings.ToLower(filename), ".rar")
}
return info.PartNumber == 1
}
// GetMultiPartArchiveBaseName returns the base name for a multi-part archive
// This is used to group related parts together
func GetMultiPartArchiveBaseName(filename string) string {
info := getArchivePartInfo(filename)
if !info.IsMultiPart {
return ""
}
return filepath.Join(filepath.Dir(filename), info.BaseName)
}
// extractMultiPartArchive extracts a multi-part archive starting from the first part
func extractMultiPartArchive(firstPartPath string, destDir string, password string, progressCallback ExtractProgressCallback) error {
info := getArchivePartInfo(firstPartPath)
// For 7z multi-part archives, the bodgit/sevenzip library handles multi-volume automatically
// when using OpenReader with a .001 file
if strings.Contains(info.Pattern, `\.7z\)`) || strings.HasSuffix(strings.ToLower(firstPartPath), ".7z.001") {
return extractSevenZipMultiPart(firstPartPath, destDir, password, progressCallback)
}
// For RAR multi-part archives, use the archives library with Name and FS fields
if strings.Contains(info.Pattern, "rar") || info.Pattern == "rar-old-style" {
return extractRarMultiPart(firstPartPath, destDir, password, progressCallback)
}
// For ZIP multi-part archives (.zip.001, .zip.002, etc.), concatenate parts and extract
if strings.Contains(info.Pattern, `\.zip\)`) || strings.HasSuffix(strings.ToLower(firstPartPath), ".zip.001") {
return extractZipMultiPart(firstPartPath, destDir, password, progressCallback)
}
// For other formats, try standard extraction (may not work for all multi-part formats)
return extractArchive(firstPartPath, destDir, password, progressCallback)
}
+92
View File
@@ -0,0 +1,92 @@
package download
import (
"io"
"os"
"path/filepath"
"github.com/bodgit/sevenzip"
)
// extractSevenZipMultiPart extracts a multi-part 7z archive using bodgit/sevenzip directly
// The mholt/archives wrapper doesn't properly handle multi-part 7z files because it uses
// io.SectionReader which can only see the first part. The bodgit/sevenzip library's
// OpenReaderWithPassword function handles multi-part files automatically when given the .001 file path.
func extractSevenZipMultiPart(firstPartPath string, destDir string, password string, progressCallback ExtractProgressCallback) error {
// Use bodgit/sevenzip directly - it automatically handles .001, .002, etc. files
var reader *sevenzip.ReadCloser
var err error
if password != "" {
reader, err = sevenzip.OpenReaderWithPassword(firstPartPath, password)
} else {
reader, err = sevenzip.OpenReader(firstPartPath)
}
if err != nil {
return err
}
defer reader.Close()
// Create destination directory
if err := os.MkdirAll(destDir, 0755); err != nil {
return err
}
// Count total files for progress
totalFiles := 0
for _, f := range reader.File {
if !f.FileInfo().IsDir() {
totalFiles++
}
}
// Extract files with progress tracking
extractedFiles := 0
for _, f := range reader.File {
destPath := filepath.Join(destDir, f.Name)
if f.FileInfo().IsDir() {
if err := os.MkdirAll(destPath, f.Mode()); err != nil {
return err
}
continue
}
// Ensure parent directory exists
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
return err
}
// Extract file - open, copy, close immediately (as recommended by bodgit/sevenzip)
if err := extractSevenZipFile(f, destPath); err != nil {
return err
}
extractedFiles++
if progressCallback != nil && totalFiles > 0 {
progress := int(float64(extractedFiles) / float64(totalFiles) * 100)
progressCallback(extractedFiles, totalFiles, progress)
}
}
return nil
}
// extractSevenZipFile extracts a single file from a 7z archive
// This follows the bodgit/sevenzip recommended pattern of closing rc before processing the next file
func extractSevenZipFile(f *sevenzip.File, destPath string) error {
rc, err := f.Open()
if err != nil {
return err
}
defer rc.Close()
outFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
if err != nil {
return err
}
defer outFile.Close()
_, err = io.Copy(outFile, rc)
return err
}
+199
View File
@@ -0,0 +1,199 @@
package download
import (
"sync"
)
// ExtractionJob represents a single extraction job in the queue
type ExtractionJob struct {
// ID is a unique identifier for this job (usually task ID or multi-part base name)
ID string
// Execute is the function that performs the actual extraction
Execute func()
// done channel is signaled when the job has been executed
done chan struct{}
}
// NewExtractionJob creates a new extraction job
func NewExtractionJob(id string, execute func()) *ExtractionJob {
return &ExtractionJob{
ID: id,
Execute: execute,
done: make(chan struct{}),
}
}
// Wait blocks until the job has been executed
func (j *ExtractionJob) Wait() {
<-j.done
}
// ExtractionQueue manages a queue of extraction jobs to prevent resource exhaustion
// by ensuring only one extraction (or one multi-part archive extraction) runs at a time
type ExtractionQueue struct {
mu sync.Mutex
cond *sync.Cond
jobs []*ExtractionJob
running bool
shutdown bool
wg sync.WaitGroup
}
// NewExtractionQueue creates a new extraction queue
func NewExtractionQueue() *ExtractionQueue {
q := &ExtractionQueue{
jobs: make([]*ExtractionJob, 0),
}
q.cond = sync.NewCond(&q.mu)
return q
}
// Start starts the queue worker that processes jobs sequentially
func (q *ExtractionQueue) Start() {
q.mu.Lock()
if q.running {
q.mu.Unlock()
return
}
q.running = true
q.shutdown = false
q.mu.Unlock()
q.wg.Add(1)
go q.worker()
}
// Stop stops the queue worker gracefully
// It waits for the current job to complete but discards pending jobs
func (q *ExtractionQueue) Stop() {
q.mu.Lock()
if !q.running {
q.mu.Unlock()
return
}
q.shutdown = true
q.cond.Signal()
q.mu.Unlock()
q.wg.Wait()
}
// Enqueue adds a new extraction job to the queue
// The job will be executed when its turn comes (FIFO order)
// Returns the job so the caller can wait for completion if needed
func (q *ExtractionQueue) Enqueue(job *ExtractionJob) *ExtractionJob {
q.mu.Lock()
defer q.mu.Unlock()
if q.shutdown {
// Queue is shutting down, signal done immediately without executing
close(job.done)
return job
}
q.jobs = append(q.jobs, job)
q.cond.Signal()
return job
}
// EnqueueAndWait adds a new extraction job to the queue and waits for it to complete
func (q *ExtractionQueue) EnqueueAndWait(job *ExtractionJob) {
q.Enqueue(job)
job.Wait()
}
// QueueLength returns the current number of pending jobs in the queue
func (q *ExtractionQueue) QueueLength() int {
q.mu.Lock()
defer q.mu.Unlock()
return len(q.jobs)
}
// IsRunning returns true if the queue worker is running
func (q *ExtractionQueue) IsRunning() bool {
q.mu.Lock()
defer q.mu.Unlock()
return q.running
}
// HasPendingJob checks if there's a pending job with the given ID
func (q *ExtractionQueue) HasPendingJob(id string) bool {
q.mu.Lock()
defer q.mu.Unlock()
for _, job := range q.jobs {
if job.ID == id {
return true
}
}
return false
}
// RemovePendingJob removes a pending job with the given ID from the queue
// Returns true if a job was removed, false if not found
// Note: This cannot remove a job that is currently being executed
func (q *ExtractionQueue) RemovePendingJob(id string) bool {
q.mu.Lock()
defer q.mu.Unlock()
for i, job := range q.jobs {
if job.ID == id {
// Close the done channel to unblock any waiters
close(job.done)
// Remove from queue
q.jobs = append(q.jobs[:i], q.jobs[i+1:]...)
return true
}
}
return false
}
// worker is the main loop that processes jobs sequentially
func (q *ExtractionQueue) worker() {
defer q.wg.Done()
for {
q.mu.Lock()
// Wait for a job or shutdown signal
for len(q.jobs) == 0 && !q.shutdown {
q.cond.Wait()
}
if q.shutdown {
// Close done channels for all remaining jobs
for _, job := range q.jobs {
close(job.done)
}
q.jobs = nil
q.running = false
q.mu.Unlock()
return
}
// Dequeue the first job
job := q.jobs[0]
q.jobs = q.jobs[1:]
q.mu.Unlock()
// Execute the job outside the lock
if job.Execute != nil {
job.Execute()
}
// Signal that the job is done
close(job.done)
}
}
// Global extraction queue instance
var globalExtractionQueue *ExtractionQueue
var extractionQueueOnce sync.Once
// GetExtractionQueue returns the global extraction queue instance
func GetExtractionQueue() *ExtractionQueue {
extractionQueueOnce.Do(func() {
globalExtractionQueue = NewExtractionQueue()
globalExtractionQueue.Start()
})
return globalExtractionQueue
}
+545
View File
@@ -0,0 +1,545 @@
package download
import (
"sync"
"sync/atomic"
"testing"
"time"
)
func TestNewExtractionQueue(t *testing.T) {
q := NewExtractionQueue()
if q == nil {
t.Fatal("NewExtractionQueue returned nil")
}
if q.jobs == nil {
t.Fatal("jobs slice should be initialized")
}
if len(q.jobs) != 0 {
t.Fatalf("expected empty jobs slice, got %d", len(q.jobs))
}
if q.running {
t.Fatal("queue should not be running initially")
}
if q.cond == nil {
t.Fatal("condition variable should be initialized")
}
}
func TestExtractionQueue_StartStop(t *testing.T) {
q := NewExtractionQueue()
// Test Start
q.Start()
if !q.IsRunning() {
t.Fatal("queue should be running after Start")
}
// Test double Start (should be idempotent)
q.Start()
if !q.IsRunning() {
t.Fatal("queue should still be running after second Start")
}
// Test Stop
q.Stop()
if q.IsRunning() {
t.Fatal("queue should not be running after Stop")
}
// Test double Stop (should be idempotent)
q.Stop()
if q.IsRunning() {
t.Fatal("queue should still not be running after second Stop")
}
}
func TestExtractionQueue_EnqueueSingleJob(t *testing.T) {
q := NewExtractionQueue()
q.Start()
defer q.Stop()
executed := false
job := NewExtractionJob("test-1", func() {
executed = true
})
q.Enqueue(job)
job.Wait()
if !executed {
t.Fatal("job should have been executed")
}
}
func TestExtractionQueue_EnqueueAndWait(t *testing.T) {
q := NewExtractionQueue()
q.Start()
defer q.Stop()
executed := false
job := NewExtractionJob("test-1", func() {
executed = true
})
q.EnqueueAndWait(job)
if !executed {
t.Fatal("job should have been executed after EnqueueAndWait returns")
}
}
func TestExtractionQueue_FIFOOrder(t *testing.T) {
q := NewExtractionQueue()
// Don't start yet - we want to queue multiple jobs first
var executionOrder []string
var mu sync.Mutex
addToOrder := func(id string) {
mu.Lock()
executionOrder = append(executionOrder, id)
mu.Unlock()
}
job1 := NewExtractionJob("job-1", func() {
addToOrder("job-1")
})
job2 := NewExtractionJob("job-2", func() {
addToOrder("job-2")
})
job3 := NewExtractionJob("job-3", func() {
addToOrder("job-3")
})
// Enqueue jobs before starting (they'll wait in queue)
q.mu.Lock()
q.jobs = append(q.jobs, job1, job2, job3)
q.mu.Unlock()
// Now start processing
q.Start()
defer q.Stop()
// Wait for all jobs
job1.Wait()
job2.Wait()
job3.Wait()
// Verify FIFO order
if len(executionOrder) != 3 {
t.Fatalf("expected 3 executions, got %d", len(executionOrder))
}
if executionOrder[0] != "job-1" || executionOrder[1] != "job-2" || executionOrder[2] != "job-3" {
t.Fatalf("expected FIFO order [job-1, job-2, job-3], got %v", executionOrder)
}
}
func TestExtractionQueue_SequentialExecution(t *testing.T) {
q := NewExtractionQueue()
q.Start()
defer q.Stop()
var activeJobs int32
var maxConcurrent int32
var mu sync.Mutex
// Create jobs that take some time to execute
createJob := func(id string) *ExtractionJob {
return NewExtractionJob(id, func() {
current := atomic.AddInt32(&activeJobs, 1)
mu.Lock()
if current > maxConcurrent {
maxConcurrent = current
}
mu.Unlock()
time.Sleep(10 * time.Millisecond)
atomic.AddInt32(&activeJobs, -1)
})
}
jobs := make([]*ExtractionJob, 5)
for i := range jobs {
jobs[i] = q.Enqueue(createJob(string(rune('A' + i))))
}
// Wait for all jobs
for _, job := range jobs {
job.Wait()
}
// Verify only one job ran at a time
if maxConcurrent > 1 {
t.Fatalf("expected max 1 concurrent job, got %d", maxConcurrent)
}
}
func TestExtractionQueue_QueueLength(t *testing.T) {
q := NewExtractionQueue()
if q.QueueLength() != 0 {
t.Fatalf("expected queue length 0, got %d", q.QueueLength())
}
// Add jobs without starting (they'll stay in queue)
blockChan := make(chan struct{})
blockingJob := NewExtractionJob("blocking", func() {
<-blockChan // Block until signaled
})
q.Start()
defer q.Stop()
q.Enqueue(blockingJob)
// Give worker time to pick up the blocking job
time.Sleep(20 * time.Millisecond)
// Queue more jobs while blocking job is running
job2 := q.Enqueue(NewExtractionJob("job-2", func() {}))
job3 := q.Enqueue(NewExtractionJob("job-3", func() {}))
// Should have 2 jobs waiting
if q.QueueLength() != 2 {
t.Fatalf("expected queue length 2, got %d", q.QueueLength())
}
// Unblock and wait
close(blockChan)
blockingJob.Wait()
job2.Wait()
job3.Wait()
if q.QueueLength() != 0 {
t.Fatalf("expected queue length 0 after completion, got %d", q.QueueLength())
}
}
func TestExtractionQueue_HasPendingJob(t *testing.T) {
q := NewExtractionQueue()
blockChan := make(chan struct{})
blockingJob := NewExtractionJob("blocking", func() {
<-blockChan
})
q.Start()
defer q.Stop()
q.Enqueue(blockingJob)
time.Sleep(20 * time.Millisecond) // Let blocking job start
// Queue more jobs
job2 := q.Enqueue(NewExtractionJob("job-2", func() {}))
if !q.HasPendingJob("job-2") {
t.Fatal("expected HasPendingJob to return true for job-2")
}
if q.HasPendingJob("job-99") {
t.Fatal("expected HasPendingJob to return false for non-existent job")
}
// Unblock and wait
close(blockChan)
blockingJob.Wait()
job2.Wait()
if q.HasPendingJob("job-2") {
t.Fatal("expected HasPendingJob to return false after job completion")
}
}
func TestExtractionQueue_RemovePendingJob(t *testing.T) {
q := NewExtractionQueue()
blockChan := make(chan struct{})
blockingJob := NewExtractionJob("blocking", func() {
<-blockChan
})
executed := false
job2 := NewExtractionJob("job-2", func() {
executed = true
})
q.Start()
defer q.Stop()
q.Enqueue(blockingJob)
time.Sleep(20 * time.Millisecond) // Let blocking job start
q.Enqueue(job2)
// Remove job-2 before it executes
removed := q.RemovePendingJob("job-2")
if !removed {
t.Fatal("expected RemovePendingJob to return true")
}
// Try to remove again (should return false)
removed = q.RemovePendingJob("job-2")
if removed {
t.Fatal("expected second RemovePendingJob to return false")
}
// Unblock
close(blockChan)
blockingJob.Wait()
// Wait a bit for any potential execution
time.Sleep(50 * time.Millisecond)
if executed {
t.Fatal("removed job should not have been executed")
}
// The removed job's done channel should be closed
select {
case <-job2.done:
// Expected
default:
t.Fatal("removed job's done channel should be closed")
}
}
func TestExtractionQueue_StopDiscardsPendingJobs(t *testing.T) {
q := NewExtractionQueue()
blockChan := make(chan struct{})
executed1 := false
executed2 := false
blockingJob := NewExtractionJob("blocking", func() {
<-blockChan
executed1 = true
})
pendingJob := NewExtractionJob("pending", func() {
executed2 = true
})
q.Start()
q.Enqueue(blockingJob)
time.Sleep(20 * time.Millisecond) // Let blocking job start
q.Enqueue(pendingJob)
// Stop without unblocking
go func() {
time.Sleep(10 * time.Millisecond)
close(blockChan)
}()
q.Stop()
// Blocking job should have completed, but pending job was discarded
if !executed1 {
t.Log("Note: blocking job may not complete if Stop() won races")
}
if executed2 {
t.Fatal("pending job should not have been executed after Stop")
}
// Pending job's done channel should be closed
select {
case <-pendingJob.done:
// Expected
default:
t.Fatal("pending job's done channel should be closed after Stop")
}
}
func TestExtractionQueue_EnqueueAfterShutdown(t *testing.T) {
q := NewExtractionQueue()
q.Start()
q.Stop()
executed := false
job := NewExtractionJob("after-shutdown", func() {
executed = true
})
q.Enqueue(job)
// Job should be immediately done (without execution)
select {
case <-job.done:
// Expected
case <-time.After(100 * time.Millisecond):
t.Fatal("job should be immediately done after shutdown")
}
if executed {
t.Fatal("job should not be executed after shutdown")
}
}
func TestNewExtractionJob(t *testing.T) {
job := NewExtractionJob("test-id", func() {})
if job.ID != "test-id" {
t.Fatalf("expected ID 'test-id', got '%s'", job.ID)
}
if job.Execute == nil {
t.Fatal("Execute should not be nil")
}
if job.done == nil {
t.Fatal("done channel should be initialized")
}
}
func TestExtractionJob_Wait(t *testing.T) {
job := NewExtractionJob("test", func() {})
done := make(chan struct{})
go func() {
job.Wait()
close(done)
}()
// Wait should block
select {
case <-done:
t.Fatal("Wait should block until job is done")
case <-time.After(50 * time.Millisecond):
// Expected
}
// Close done channel
close(job.done)
// Now Wait should return
select {
case <-done:
// Expected
case <-time.After(100 * time.Millisecond):
t.Fatal("Wait should return after job is done")
}
}
func TestExtractionQueue_NilExecuteFunction(t *testing.T) {
q := NewExtractionQueue()
q.Start()
defer q.Stop()
// Job with nil Execute should not panic
job := &ExtractionJob{
ID: "nil-execute",
Execute: nil,
done: make(chan struct{}),
}
q.Enqueue(job)
job.Wait()
// No panic means success
}
func TestExtractionQueue_ConcurrentEnqueue(t *testing.T) {
q := NewExtractionQueue()
q.Start()
defer q.Stop()
var counter int32
var wg sync.WaitGroup
numGoroutines := 10
jobsPerGoroutine := 10
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(goroutineID int) {
defer wg.Done()
for j := 0; j < jobsPerGoroutine; j++ {
job := NewExtractionJob("", func() {
atomic.AddInt32(&counter, 1)
})
q.EnqueueAndWait(job)
}
}(i)
}
wg.Wait()
expected := int32(numGoroutines * jobsPerGoroutine)
if counter != expected {
t.Fatalf("expected counter %d, got %d", expected, counter)
}
}
func TestExtractionQueue_Restart(t *testing.T) {
q := NewExtractionQueue()
// First run
q.Start()
executed1 := false
job1 := NewExtractionJob("job-1", func() {
executed1 = true
})
q.EnqueueAndWait(job1)
q.Stop()
if !executed1 {
t.Fatal("first job should have been executed")
}
// Restart
q.Start()
executed2 := false
job2 := NewExtractionJob("job-2", func() {
executed2 = true
})
q.EnqueueAndWait(job2)
q.Stop()
if !executed2 {
t.Fatal("second job should have been executed after restart")
}
}
func TestGetExtractionQueue(t *testing.T) {
// Note: This test modifies global state, so it should ideally be run in isolation
// However, the implementation ensures the queue is created only once
queue := GetExtractionQueue()
if queue == nil {
t.Fatal("GetExtractionQueue should not return nil")
}
if !queue.IsRunning() {
t.Fatal("global queue should be running")
}
// Calling again should return the same instance
queue2 := GetExtractionQueue()
if queue != queue2 {
t.Fatal("GetExtractionQueue should return the same instance")
}
}
func TestExtractionQueue_LongRunningJob(t *testing.T) {
q := NewExtractionQueue()
q.Start()
defer q.Stop()
startTime := time.Now()
longJobDuration := 100 * time.Millisecond
longJob := NewExtractionJob("long-job", func() {
time.Sleep(longJobDuration)
})
shortJobExecuted := false
shortJob := NewExtractionJob("short-job", func() {
shortJobExecuted = true
})
q.Enqueue(longJob)
q.Enqueue(shortJob)
shortJob.Wait()
elapsed := time.Since(startTime)
if elapsed < longJobDuration {
t.Fatalf("short job should wait for long job, elapsed: %v", elapsed)
}
if !shortJobExecuted {
t.Fatal("short job should have been executed")
}
}
+46
View File
@@ -0,0 +1,46 @@
package download
import (
"context"
"os"
"path/filepath"
"github.com/mholt/archives"
)
// extractRarMultiPart extracts a multi-part RAR archive
func extractRarMultiPart(firstPartPath string, destDir string, password string, progressCallback ExtractProgressCallback) error {
// For RAR archives, we need to use the Name field in archives.Rar
// to let it automatically find subsequent volumes
dir := filepath.Dir(firstPartPath)
fileName := filepath.Base(firstPartPath)
rar := archives.Rar{
Password: password,
Name: fileName,
FS: os.DirFS(dir),
}
// Create destination directory
if err := os.MkdirAll(destDir, 0755); err != nil {
return err
}
// Count files first for progress
totalFiles := 0
err := rar.Extract(context.Background(), nil, func(ctx context.Context, fileInfo archives.FileInfo) error {
if !fileInfo.IsDir() {
totalFiles++
}
return nil
})
if err != nil {
// If counting fails, proceed without progress
totalFiles = 0
}
// Reset and extract with progress tracking
return rar.Extract(context.Background(), nil, createExtractionHandler(destDir, totalFiles, progressCallback))
}
File diff suppressed because it is too large. Load diff
+191
View File
@@ -0,0 +1,191 @@
package download
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/mholt/archives"
)
// extractZipMultiPart extracts a multi-part ZIP archive (.zip.001, .zip.002, etc.)
// These are created by simply splitting a ZIP file into chunks, so we concatenate them
func extractZipMultiPart(firstPartPath string, destDir string, password string, progressCallback ExtractProgressCallback) error {
// Find all parts
parts, err := findZipMultiParts(firstPartPath)
if err != nil {
return err
}
// Create a multi-part reader that reads across all parts
multiReader := newMultiPartFileReader(parts)
defer multiReader.Close()
// Get total size
totalSize := multiReader.Size()
// Create destination directory
if err := os.MkdirAll(destDir, 0755); err != nil {
return err
}
// First pass: count files for progress
totalFiles := 0
zip := newZipFormat()
err = zip.Extract(context.Background(), io.NewSectionReader(multiReader, 0, totalSize), func(ctx context.Context, fileInfo archives.FileInfo) error {
if !fileInfo.IsDir() {
totalFiles++
}
return nil
})
if err != nil {
// If counting fails, proceed without progress
totalFiles = 0
}
// Reset reader for actual extraction
multiReader.Close()
multiReader = newMultiPartFileReader(parts)
defer multiReader.Close()
// Second pass: extract with progress tracking
return zip.Extract(context.Background(), io.NewSectionReader(multiReader, 0, totalSize), createExtractionHandler(destDir, totalFiles, progressCallback))
}
// findZipMultiParts finds all parts of a multi-part ZIP archive in order
func findZipMultiParts(firstPartPath string) ([]string, error) {
// Extract base name (e.g., "Archive.zip" from "Archive.zip.001")
dir := filepath.Dir(firstPartPath)
baseName := filepath.Base(firstPartPath)
// Remove the .001 suffix to get the base
if idx := strings.LastIndex(baseName, "."); idx > 0 {
baseName = baseName[:idx] // "Archive.zip"
}
var parts []string
partNum := 1
for {
partPath := filepath.Join(dir, baseName+fmt.Sprintf(".%03d", partNum))
if _, err := os.Stat(partPath); os.IsNotExist(err) {
break
}
parts = append(parts, partPath)
partNum++
}
if len(parts) == 0 {
return nil, fmt.Errorf("no parts found for %s", firstPartPath)
}
return parts, nil
}
// multiPartFileReader provides io.ReaderAt over multiple files concatenated
type multiPartFileReader struct {
parts []string
files []*os.File
sizes []int64
offsets []int64 // cumulative offsets for each file
totalSize int64
}
func newMultiPartFileReader(parts []string) *multiPartFileReader {
return &multiPartFileReader{parts: parts}
}
func (m *multiPartFileReader) init() error {
if m.files != nil {
return nil
}
m.files = make([]*os.File, len(m.parts))
m.sizes = make([]int64, len(m.parts))
m.offsets = make([]int64, len(m.parts))
var offset int64
for i, part := range m.parts {
f, err := os.Open(part)
if err != nil {
m.Close()
return err
}
stat, err := f.Stat()
if err != nil {
f.Close()
m.Close()
return err
}
m.files[i] = f
m.sizes[i] = stat.Size()
m.offsets[i] = offset
offset += stat.Size()
}
m.totalSize = offset
return nil
}
func (m *multiPartFileReader) Size() int64 {
if err := m.init(); err != nil {
return 0
}
return m.totalSize
}
func (m *multiPartFileReader) ReadAt(p []byte, off int64) (n int, err error) {
if err := m.init(); err != nil {
return 0, err
}
if off >= m.totalSize {
return 0, io.EOF
}
// Find which file(s) to read from
for i, fileOffset := range m.offsets {
fileEnd := fileOffset + m.sizes[i]
if off >= fileEnd {
continue
}
// Read from this file
localOffset := off - fileOffset
toRead := len(p) - n
if int64(toRead) > fileEnd-off {
toRead = int(fileEnd - off)
}
read, err := m.files[i].ReadAt(p[n:n+toRead], localOffset)
n += read
off += int64(read)
if err != nil && err != io.EOF {
return n, err
}
if n >= len(p) {
return n, nil
}
}
if n == 0 {
return 0, io.EOF
}
return n, nil
}
func (m *multiPartFileReader) Close() error {
for _, f := range m.files {
if f != nil {
f.Close()
}
}
m.files = nil
return nil
}
+95 -44
View File
@@ -1,6 +1,10 @@
package download
import (
"encoding/json"
"sync"
"time"
"github.com/GopeedLab/gopeed/internal/controller"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/internal/protocol/bt"
@@ -8,8 +12,6 @@ import (
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/util"
gonanoid "github.com/matoous/go-nanoid/v2"
"sync"
"time"
)
type ResolveResult struct {
@@ -19,17 +21,21 @@ type ResolveResult struct {
type Task struct {
ID string `json:"id"`
Protocol string `json:"protocol"`
Meta *fetcher.FetcherMeta `json:"meta"`
Status base.Status `json:"status"`
Uploading bool `json:"uploading"`
Progress *Progress `json:"progress"`
Size int64 `json:"size"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
fetcherBuilder fetcher.FetcherBuilder
fetcherManager fetcher.FetcherManager
fetcher fetcher.Fetcher
timer *util.Timer
statusLock *sync.Mutex
lock *sync.Mutex
speedArr []int64
uploadSpeedArr []int64
}
func NewTask() *Task {
@@ -41,53 +47,115 @@ func NewTask() *Task {
ID: id,
Status: base.DownloadStatusReady,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
}
// Name returns the display name of the task.
func (t *Task) Name() string {
// Custom name first
if t.Meta.Opts.Name != "" {
return t.Meta.Opts.Name
}
// Task is not resolved, parse the name from the URL
if t.Meta.Res == nil {
fallbackName := "unknown"
if t.fetcherManager == nil {
return fallbackName
}
parseName := t.fetcherManager.ParseName(t.Meta.Req.URL)
if parseName == "" {
return fallbackName
}
return parseName
}
// Task is a folder
if t.Meta.Res.Name != "" {
return t.Meta.Res.Name
}
// Get the name of the first file
return t.Meta.Res.Files[0].Name
}
func (t *Task) MarshalJSON() ([]byte, error) {
type rawTaskType Task
jsonTask := struct {
rawTaskType
Name string `json:"name"`
}{
rawTaskType(*t),
t.Name(),
}
return json.Marshal(jsonTask)
}
func (t *Task) updateStatus(status base.Status) {
t.UpdatedAt = time.Now()
t.Status = status
}
func (t *Task) clone() *Task {
return &Task{
ID: t.ID,
Meta: t.Meta,
Status: t.Status,
Progress: t.Progress,
Size: t.Size,
CreatedAt: t.CreatedAt,
}
return util.DeepClone(t)
}
func (t *Task) calcSpeed(downloaded int64, usedTime float64) int64 {
t.speedArr = append(t.speedArr, downloaded)
if len(t.speedArr) > 6 {
t.speedArr = t.speedArr[1:]
func (t *Task) updateSpeed(downloaded int64, usedTime float64) int64 {
return calcSpeed(&t.speedArr, downloaded, usedTime)
}
func (t *Task) updateUploadSpeed(downloaded int64, usedTime float64) int64 {
return calcSpeed(&t.uploadSpeedArr, downloaded, usedTime)
}
func calcSpeed(speedArr *[]int64, downloaded int64, usedTime float64) int64 {
*speedArr = append(*speedArr, downloaded)
// Record last 5 seconds of download speed to calculate the average speed
if len(*speedArr) > int(5.0/usedTime) {
*speedArr = (*speedArr)[1:]
}
var total int64
for _, v := range t.speedArr {
for _, v := range *speedArr {
total += v
}
return int64(float64(total) / float64(len(t.speedArr)) / usedTime)
return int64(float64(total) / float64(len(*speedArr)) / usedTime)
}
type TaskFilter struct {
IDs []string
Statuses []base.Status
NotStatuses []base.Status
}
func (f *TaskFilter) IsEmpty() bool {
return len(f.IDs) == 0 && len(f.Statuses) == 0 && len(f.NotStatuses) == 0
}
type DownloaderConfig struct {
Controller *controller.Controller
FetchBuilders []fetcher.FetcherBuilder
FetchManagers []fetcher.FetcherManager
RefreshInterval int `json:"refreshInterval"` // RefreshInterval time duration to refresh task progress(ms)
Storage Storage
StorageDir string
RefreshInterval int // RefreshInterval time duration to refresh task progress(ms)
Storage Storage
StorageDir string
WhiteDownloadDirs []string
ProductionMode bool
*DownloaderStoreConfig
*base.DownloaderStoreConfig
}
func (cfg *DownloaderConfig) Init() *DownloaderConfig {
if cfg.Controller == nil {
cfg.Controller = controller.NewController()
}
if len(cfg.FetchBuilders) == 0 {
cfg.FetchBuilders = []fetcher.FetcherBuilder{
new(http.FetcherBuilder),
new(bt.FetcherBuilder),
if len(cfg.FetchManagers) == 0 {
cfg.FetchManagers = []fetcher.FetcherManager{
new(http.FetcherManager),
new(bt.FetcherManager),
}
}
if cfg.RefreshInterval == 0 {
@@ -98,20 +166,3 @@ func (cfg *DownloaderConfig) Init() *DownloaderConfig {
}
return cfg
}
// 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"` // Extra is the extra config
}
func (cfg *DownloaderStoreConfig) Init() *DownloaderStoreConfig {
if cfg.MaxRunning == 0 {
cfg.MaxRunning = 5
}
return cfg
}
+161
View File
@@ -0,0 +1,161 @@
package download
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"time"
)
// ScriptEvent represents the type of script event
type ScriptEvent string
const (
ScriptEventDownloadDone ScriptEvent = "DOWNLOAD_DONE"
ScriptEventDownloadError ScriptEvent = "DOWNLOAD_ERROR"
)
// ScriptData is the internal data structure for passing script information
type ScriptData struct {
Event ScriptEvent
Time int64 // Unix timestamp in milliseconds
Payload *ScriptPayload
}
// ScriptPayload contains the task data
type ScriptPayload struct {
Task *Task
}
// getScriptPaths extracts script paths from config
func (d *Downloader) getScriptPaths() []string {
cfg := d.cfg.DownloaderStoreConfig
if cfg == nil {
return nil
}
// Check new script config
if cfg.Script != nil && cfg.Script.Enable && len(cfg.Script.Paths) > 0 {
paths := make([]string, 0, len(cfg.Script.Paths))
for _, path := range cfg.Script.Paths {
if path != "" {
paths = append(paths, path)
}
}
if len(paths) > 0 {
return paths
}
}
return nil
}
// executeScriptAtPath executes a single script with the given data
// Returns any error that occurred during execution
func (d *Downloader) executeScriptAtPath(scriptPath string, data *ScriptData) error {
if scriptPath == "" {
return fmt.Errorf("script path is empty")
}
// Check if script file exists
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
return fmt.Errorf("script file does not exist: %s", scriptPath)
}
// Determine the script interpreter based on file extension
var cmd *exec.Cmd
ext := filepath.Ext(scriptPath)
switch ext {
case ".sh", ".bash":
cmd = exec.Command("bash", scriptPath)
case ".py":
cmd = exec.Command("python3", scriptPath)
case ".js":
cmd = exec.Command("node", scriptPath)
case ".bat", ".cmd":
// Windows batch files
if runtime.GOOS == "windows" {
cmd = exec.Command("cmd", "/c", scriptPath)
} else {
// Batch files are Windows-specific
return fmt.Errorf("batch files (.bat/.cmd) are only supported on Windows")
}
case ".ps1":
// PowerShell scripts
if runtime.GOOS == "windows" {
cmd = exec.Command("powershell", "-ExecutionPolicy", "Bypass", "-File", scriptPath)
} else {
// Try pwsh (PowerShell Core) on non-Windows systems
cmd = exec.Command("pwsh", "-File", scriptPath)
}
case "":
// No extension, try to execute directly (assumes shebang or executable)
cmd = exec.Command(scriptPath)
default:
// Unknown extension, try to execute directly
cmd = exec.Command(scriptPath)
}
// Set environment variables with task information
cmd.Env = append(os.Environ(),
fmt.Sprintf("GOPEED_EVENT=%s", data.Event),
fmt.Sprintf("GOPEED_TASK_ID=%s", data.Payload.Task.ID),
fmt.Sprintf("GOPEED_TASK_NAME=%s", data.Payload.Task.Name()),
fmt.Sprintf("GOPEED_TASK_STATUS=%s", data.Payload.Task.Status),
)
// Add task path using the same logic as task deletion
if data.Payload.Task.Meta != nil && data.Payload.Task.Meta.Res != nil {
var taskPath string
if data.Payload.Task.Meta.Res.Name != "" {
// Multi-file task (folder)
taskPath = data.Payload.Task.Meta.FolderPath()
} else {
// Single file task
taskPath = data.Payload.Task.Meta.SingleFilepath()
}
cmd.Env = append(cmd.Env,
fmt.Sprintf("GOPEED_TASK_PATH=%s", taskPath),
)
}
// Start and wait for the command to complete (no timeout)
return cmd.Run()
}
// triggerScripts executes all configured scripts
func (d *Downloader) triggerScripts(event ScriptEvent, task *Task, err error) {
paths := d.getScriptPaths()
if len(paths) == 0 {
return
}
data := &ScriptData{
Event: event,
Time: time.Now().UnixMilli(),
Payload: &ScriptPayload{
Task: task.clone(),
},
}
go d.executeScripts(paths, data)
}
func (d *Downloader) executeScripts(paths []string, data *ScriptData) {
for _, path := range paths {
if path == "" {
continue
}
go func(scriptPath string) {
err := d.executeScriptAtPath(scriptPath, data)
if err != nil {
d.Logger.Warn().Err(err).Str("path", scriptPath).Msg("script: failed to execute")
return
}
d.Logger.Debug().Str("path", scriptPath).Msg("script: executed successfully")
}(path)
}
}
+203
View File
@@ -0,0 +1,203 @@
package download
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/pkg/base"
)
func TestScript_NoScriptConfigured(t *testing.T) {
setupScriptTest(t, func(downloader *Downloader) {
// Create a mock task
task := NewTask()
task.Protocol = "http"
task.Meta = &mockFetcherMeta
// Trigger script (should not panic with no scripts configured)
downloader.triggerScripts(ScriptEventDownloadDone, task, nil)
})
}
func TestScript_GetScriptPaths_EmptyConfig(t *testing.T) {
setupScriptTest(t, func(downloader *Downloader) {
paths := downloader.getScriptPaths()
if paths != nil {
t.Errorf("Expected nil, got %v", paths)
}
})
}
func TestScript_GetScriptPaths_NoScriptConfig(t *testing.T) {
setupScriptTest(t, func(downloader *Downloader) {
cfg, _ := downloader.GetConfig()
cfg.Script = nil
downloader.PutConfig(cfg)
paths := downloader.getScriptPaths()
if paths != nil {
t.Errorf("Expected nil, got %v", paths)
}
})
}
func TestScript_GetScriptPaths_DisabledScript(t *testing.T) {
setupScriptTest(t, func(downloader *Downloader) {
cfg, _ := downloader.GetConfig()
cfg.Script = &base.ScriptConfig{
Enable: false,
Paths: []string{"/path/to/script.sh"},
}
downloader.PutConfig(cfg)
paths := downloader.getScriptPaths()
if paths != nil {
t.Errorf("Expected nil for disabled script, got %v", paths)
}
})
}
func TestScript_GetScriptPaths_EmptyPaths(t *testing.T) {
setupScriptTest(t, func(downloader *Downloader) {
cfg, _ := downloader.GetConfig()
cfg.Script = &base.ScriptConfig{
Enable: true,
Paths: []string{},
}
downloader.PutConfig(cfg)
paths := downloader.getScriptPaths()
if paths != nil {
t.Errorf("Expected nil for empty paths, got %v", paths)
}
})
}
func TestScript_GetScriptPaths_WithEmptyStrings(t *testing.T) {
setupScriptTest(t, func(downloader *Downloader) {
cfg, _ := downloader.GetConfig()
cfg.Script = &base.ScriptConfig{
Enable: true,
Paths: []string{"/path/to/script1.sh", "", "/path/to/script2.sh", ""},
}
downloader.PutConfig(cfg)
paths := downloader.getScriptPaths()
if len(paths) != 2 {
t.Errorf("Expected 2 valid paths (ignoring empty strings), got %d: %v", len(paths), paths)
}
if paths[0] != "/path/to/script1.sh" || paths[1] != "/path/to/script2.sh" {
t.Errorf("Paths don't match expected values: %v", paths)
}
})
}
func TestScript_ExecuteScriptAtPath_EmptyPath(t *testing.T) {
setupScriptTest(t, func(downloader *Downloader) {
data := &ScriptData{
Event: ScriptEventDownloadDone,
Time: time.Now().UnixMilli(),
}
err := downloader.executeScriptAtPath("", data)
if err == nil {
t.Error("Expected error for empty path")
}
if err.Error() != "script path is empty" {
t.Errorf("Expected 'script path is empty' error, got: %v", err)
}
})
}
func TestScript_ExecuteScriptAtPath_NonExistentFile(t *testing.T) {
setupScriptTest(t, func(downloader *Downloader) {
data := &ScriptData{
Event: ScriptEventDownloadDone,
Time: time.Now().UnixMilli(),
}
err := downloader.executeScriptAtPath("/non/existent/script.sh", data)
if err == nil {
t.Error("Expected error for non-existent script")
}
})
}
func createDownloadDoneTask(t *testing.T, downloadDir, fileName string) (*Task, string) {
t.Helper()
content := []byte("downloaded file")
task := NewTask()
task.Protocol = "http"
task.Status = base.DownloadStatusDone
task.Meta = &fetcher.FetcherMeta{
Req: &base.Request{
URL: "https://example.com/" + fileName,
},
Opts: &base.Options{
Name: fileName,
Path: filepath.ToSlash(downloadDir),
},
Res: &base.Resource{
Size: int64(len(content)),
Files: []*base.FileInfo{
{Name: fileName, Size: int64(len(content))},
},
},
}
filePath := task.Meta.SingleFilepath()
filePathOS := filepath.FromSlash(filePath)
if err := os.MkdirAll(filepath.Dir(filePathOS), 0755); err != nil {
t.Fatalf("Failed to create download dir: %v", err)
}
if err := os.WriteFile(filePathOS, content, 0644); err != nil {
t.Fatalf("Failed to create download file: %v", err)
}
return task, filePath
}
func waitForFile(t *testing.T, path string, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if _, err := os.Stat(path); err == nil {
return
}
time.Sleep(50 * time.Millisecond)
}
t.Fatalf("Timeout waiting for file: %s", path)
}
func getTestScriptPath(t *testing.T, name string) string {
t.Helper()
path := filepath.Join("testdata", "scripts", name)
if _, err := os.Stat(path); err != nil {
t.Fatalf("Missing test script %s: %v", path, err)
}
return path
}
func ensureScriptExecutable(t *testing.T, scriptPath string) {
t.Helper()
if filepath.Ext(scriptPath) != ".sh" {
return
}
if err := os.Chmod(scriptPath, 0755); err != nil {
t.Fatalf("Failed to chmod script: %v", err)
}
}
func setupScriptTest(t *testing.T, fn func(downloader *Downloader)) {
defaultDownloader.Setup()
defaultDownloader.cfg.StorageDir = ".test_storage"
defaultDownloader.cfg.DownloadDir = ".test_download"
defer func() {
defaultDownloader.Clear()
os.RemoveAll(defaultDownloader.cfg.StorageDir)
os.RemoveAll(defaultDownloader.cfg.DownloadDir)
}()
fn(defaultDownloader)
}
+117
View File
@@ -0,0 +1,117 @@
//go:build !windows
// +build !windows
package download
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/GopeedLab/gopeed/pkg/base"
)
func TestScript_TriggerOnDone_MoveFile(t *testing.T) {
tmpDir := t.TempDir()
downloadDir := filepath.Join(tmpDir, "downloads")
destDir := filepath.Join(tmpDir, "moved")
task, taskPath := createDownloadDoneTask(t, downloadDir, "test.txt")
srcPath := filepath.FromSlash(taskPath)
destFile := filepath.Join(destDir, "test.txt")
scriptPath := getTestScriptPath(t, "move.sh")
ensureScriptExecutable(t, scriptPath)
setupScriptTest(t, func(downloader *Downloader) {
cfg, _ := downloader.GetConfig()
cfg.Script = &base.ScriptConfig{
Enable: true,
Paths: []string{scriptPath},
}
downloader.PutConfig(cfg)
t.Setenv("GOPEED_TEST_DEST_DIR", destDir)
downloader.triggerScripts(ScriptEventDownloadDone, task, nil)
waitForFile(t, destFile, 3*time.Second)
if _, err := os.Stat(srcPath); !os.IsNotExist(err) {
t.Errorf("Expected source file to be moved, but it still exists: %s", srcPath)
}
})
}
func TestScript_MultipleScripts(t *testing.T) {
tmpDir := t.TempDir()
outputFile1 := filepath.Join(tmpDir, "output1.txt")
outputFile2 := filepath.Join(tmpDir, "output2.txt")
scriptPath1 := getTestScriptPath(t, "write_output1.sh")
ensureScriptExecutable(t, scriptPath1)
scriptPath2 := getTestScriptPath(t, "write_output2.sh")
ensureScriptExecutable(t, scriptPath2)
setupScriptTest(t, func(downloader *Downloader) {
cfg, _ := downloader.GetConfig()
cfg.Script = &base.ScriptConfig{
Enable: true,
Paths: []string{scriptPath1, scriptPath2},
}
downloader.PutConfig(cfg)
downloadDir := filepath.Join(tmpDir, "downloads")
task, _ := createDownloadDoneTask(t, downloadDir, "multi.txt")
t.Setenv("GOPEED_TEST_OUTPUT_FILE_1", outputFile1)
t.Setenv("GOPEED_TEST_OUTPUT_FILE_2", outputFile2)
downloader.triggerScripts(ScriptEventDownloadDone, task, nil)
waitForFile(t, outputFile1, 3*time.Second)
waitForFile(t, outputFile2, 3*time.Second)
})
}
func TestScript_EnvironmentVariables(t *testing.T) {
tmpDir := t.TempDir()
scriptPath := getTestScriptPath(t, "env_dump.sh")
ensureScriptExecutable(t, scriptPath)
outputFile := filepath.Join(tmpDir, "env_output.txt")
setupScriptTest(t, func(downloader *Downloader) {
cfg, _ := downloader.GetConfig()
cfg.Script = &base.ScriptConfig{
Enable: true,
Paths: []string{scriptPath},
}
downloader.PutConfig(cfg)
downloadDir := filepath.Join(tmpDir, "downloads")
task, taskPath := createDownloadDoneTask(t, downloadDir, "env.txt")
t.Setenv("GOPEED_TEST_OUTPUT_FILE", outputFile)
downloader.triggerScripts(ScriptEventDownloadDone, task, nil)
waitForFile(t, outputFile, 3*time.Second)
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
output := string(content)
if !strings.Contains(output, "GOPEED_EVENT=DOWNLOAD_DONE") {
t.Errorf("Expected GOPEED_EVENT in output, got: %s", output)
}
if !strings.Contains(output, "GOPEED_TASK_ID="+task.ID) {
t.Errorf("Expected GOPEED_TASK_ID in output, got: %s", output)
}
if !strings.Contains(output, "GOPEED_TASK_NAME="+task.Name()) {
t.Errorf("Expected GOPEED_TASK_NAME in output, got: %s", output)
}
if !strings.Contains(output, "GOPEED_TASK_STATUS="+string(task.Status)) {
t.Errorf("Expected GOPEED_TASK_STATUS in output, got: %s", output)
}
if !strings.Contains(output, "GOPEED_TASK_PATH="+taskPath) {
t.Errorf("Expected GOPEED_TASK_PATH in output, got: %s", output)
}
})
}
+113
View File
@@ -0,0 +1,113 @@
//go:build windows
// +build windows
package download
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/GopeedLab/gopeed/pkg/base"
)
func TestScript_TriggerOnDone_MoveFile(t *testing.T) {
tmpDir := t.TempDir()
downloadDir := filepath.Join(tmpDir, "downloads")
destDir := filepath.Join(tmpDir, "moved")
task, taskPath := createDownloadDoneTask(t, downloadDir, "test.txt")
srcPath := filepath.FromSlash(taskPath)
destFile := filepath.Join(destDir, "test.txt")
scriptPath := getTestScriptPath(t, "move.bat")
setupScriptTest(t, func(downloader *Downloader) {
cfg, _ := downloader.GetConfig()
cfg.Script = &base.ScriptConfig{
Enable: true,
Paths: []string{scriptPath},
}
downloader.PutConfig(cfg)
t.Setenv("GOPEED_TEST_DEST_DIR", destDir)
downloader.triggerScripts(ScriptEventDownloadDone, task, nil)
waitForFile(t, destFile, 5*time.Second)
if _, err := os.Stat(srcPath); !os.IsNotExist(err) {
t.Errorf("Expected source file to be moved, but it still exists: %s", srcPath)
}
})
}
func TestScript_MultipleScripts(t *testing.T) {
tmpDir := t.TempDir()
outputFile1 := filepath.Join(tmpDir, "output1.txt")
outputFile2 := filepath.Join(tmpDir, "output2.txt")
scriptPath1 := getTestScriptPath(t, "write_output1.bat")
scriptPath2 := getTestScriptPath(t, "write_output2.bat")
setupScriptTest(t, func(downloader *Downloader) {
cfg, _ := downloader.GetConfig()
cfg.Script = &base.ScriptConfig{
Enable: true,
Paths: []string{scriptPath1, scriptPath2},
}
downloader.PutConfig(cfg)
downloadDir := filepath.Join(tmpDir, "downloads")
task, _ := createDownloadDoneTask(t, downloadDir, "multi.txt")
t.Setenv("GOPEED_TEST_OUTPUT_FILE_1", outputFile1)
t.Setenv("GOPEED_TEST_OUTPUT_FILE_2", outputFile2)
downloader.triggerScripts(ScriptEventDownloadDone, task, nil)
waitForFile(t, outputFile1, 5*time.Second)
waitForFile(t, outputFile2, 5*time.Second)
})
}
func TestScript_EnvironmentVariables(t *testing.T) {
tmpDir := t.TempDir()
scriptPath := getTestScriptPath(t, "env_dump.bat")
outputFile := filepath.Join(tmpDir, "env_output.txt")
setupScriptTest(t, func(downloader *Downloader) {
cfg, _ := downloader.GetConfig()
cfg.Script = &base.ScriptConfig{
Enable: true,
Paths: []string{scriptPath},
}
downloader.PutConfig(cfg)
downloadDir := filepath.Join(tmpDir, "downloads")
task, taskPath := createDownloadDoneTask(t, downloadDir, "env.txt")
t.Setenv("GOPEED_TEST_OUTPUT_FILE", outputFile)
downloader.triggerScripts(ScriptEventDownloadDone, task, nil)
waitForFile(t, outputFile, 5*time.Second)
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
output := string(content)
if !strings.Contains(output, "GOPEED_EVENT=DOWNLOAD_DONE") {
t.Errorf("Expected GOPEED_EVENT in output, got: %s", output)
}
if !strings.Contains(output, "GOPEED_TASK_ID="+task.ID) {
t.Errorf("Expected GOPEED_TASK_ID in output, got: %s", output)
}
if !strings.Contains(output, "GOPEED_TASK_NAME="+task.Name()) {
t.Errorf("Expected GOPEED_TASK_NAME in output, got: %s", output)
}
if !strings.Contains(output, "GOPEED_TASK_STATUS="+string(task.Status)) {
t.Errorf("Expected GOPEED_TASK_STATUS in output, got: %s", output)
}
if !strings.Contains(output, "GOPEED_TASK_PATH="+taskPath) {
t.Errorf("Expected GOPEED_TASK_PATH in output, got: %s", output)
}
})
}
+19 -11
View File
@@ -2,11 +2,12 @@ package download
import (
"encoding/json"
"go.etcd.io/bbolt"
"os"
"path/filepath"
"reflect"
"sync"
"go.etcd.io/bbolt"
)
type Storage interface {
@@ -45,22 +46,24 @@ func changeValue(p any, v any) {
}
}
var memData = make(map[string]map[string]any)
type MemStorage struct {
lock *sync.RWMutex
data map[string]map[string]any
}
func NewMemStorage() *MemStorage {
return &MemStorage{
lock: &sync.RWMutex{},
data: make(map[string]map[string]any),
}
}
func (n *MemStorage) Setup(buckets []string) error {
n.lock.Lock()
defer n.lock.Unlock()
for _, bucket := range buckets {
if _, ok := memData[bucket]; !ok {
memData[bucket] = make(map[string]any)
if _, ok := n.data[bucket]; !ok {
n.data[bucket] = make(map[string]any)
}
}
return nil
@@ -69,14 +72,16 @@ func (n *MemStorage) Setup(buckets []string) error {
func (n *MemStorage) Put(bucket string, key string, v any) error {
n.lock.Lock()
defer n.lock.Unlock()
memData[bucket][key] = v
if bucketData, ok := n.data[bucket]; ok {
bucketData[key] = v
}
return nil
}
func (n *MemStorage) Get(bucket string, key string, v any) (bool, error) {
n.lock.RLock()
defer n.lock.RUnlock()
if dv, ok := memData[bucket][key]; ok {
if dv, ok := n.data[bucket][key]; ok {
changeValue(v, dv)
return true, nil
}
@@ -86,7 +91,7 @@ func (n *MemStorage) Get(bucket string, key string, v any) (bool, error) {
func (n *MemStorage) List(bucket string, v any) error {
n.lock.RLock()
defer n.lock.RUnlock()
data := memData[bucket]
data := n.data[bucket]
list := make([]any, 0)
for _, v := range data {
list = append(list, v)
@@ -98,7 +103,7 @@ func (n *MemStorage) List(bucket string, v any) error {
func (n *MemStorage) Pop(bucket string, key string, v any) error {
n.lock.Lock()
defer n.lock.Unlock()
data := memData[bucket]
data := n.data[bucket]
changeValue(v, data[key])
delete(data, key)
return nil
@@ -107,7 +112,7 @@ func (n *MemStorage) Pop(bucket string, key string, v any) error {
func (n *MemStorage) Delete(bucket string, key string) error {
n.lock.Lock()
defer n.lock.Unlock()
delete(memData[bucket], key)
delete(n.data[bucket], key)
return nil
}
@@ -118,7 +123,7 @@ func (n *MemStorage) Close() error {
func (n *MemStorage) Clear() error {
n.lock.Lock()
defer n.lock.Unlock()
memData = make(map[string]map[string]any)
n.data = make(map[string]map[string]any)
return nil
}
@@ -222,6 +227,9 @@ func (b *BoltStorage) Pop(bucket string, key string, v any) error {
if err != nil {
return err
}
if len(data) == 0 {
return nil
}
return json.Unmarshal(data, v)
}
+3
View File
@@ -6,6 +6,9 @@ gopeed.events.onResolve(async function (ctx) {
size: 1024,
req: {
url: ctx.req.url + "/" + i,
labels:{
"from": gopeed.info.name,
}
}
}),
),
+5 -3
View File
@@ -5,9 +5,11 @@
"scripts": [
{
"event": "onResolve",
"matches": [
"*://github.com/*"
],
"match": {
"urls": [
"*://github.com/*"
]
},
"entry": "index.js"
}
],
+18
View File
@@ -0,0 +1,18 @@
gopeed.events.onResolve(async function (ctx) {
ctx.res = {
name: "test",
files: Array(2).fill(true).map((_, i) => ({
name: `test-${i}.txt`,
size: 1024,
req: {
url: ctx.req.url + "/" + i,
extra: {
headers: {
'User-Agent': ctx.settings.ua,
},
}
}
}),
),
};
});
+22
View File
@@ -0,0 +1,22 @@
{
"name": "extra",
"title": "gopeed extension extra test",
"version": "0.0.1",
"scripts": [
{
"event": "onResolve",
"matches": [
"*://github.com/*"
],
"entry": "index.js"
}
],
"settings": [
{
"name": "ua",
"title": "User-Agent",
"type": "string",
"value": "gopeed"
}
]
}
Loaded 100 of 345 files, more files were not shown because too many files have changed in this diff. Show more