Repository: GopeedLab/gopeed Branch: main Commit: a5cd53f94c18 Files: 419 Total size: 1.7 MB Directory structure: gitextract_l2o017xa/ ├── .dockerignore ├── .github/ │ ├── CODEOWNERS │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE.md │ ├── release-drafter.yml │ └── workflows/ │ ├── build.yml │ ├── release.yml │ ├── scripts/ │ │ └── flutter_local_font.dart │ ├── test.yml │ └── translator.yml.bak ├── .gitignore ├── CONTRIBUTING.md ├── CONTRIBUTING_ja-JP.md ├── CONTRIBUTING_vi-VN.md ├── CONTRIBUTING_zh-CN.md ├── CONTRIBUTING_zh-TW.md ├── Dockerfile ├── LICENSE ├── README.md ├── README_ja-JP.md ├── README_vi-VN.md ├── README_zh-CN.md ├── README_zh-TW.md ├── _examples/ │ └── basic/ │ └── main.go ├── bind/ │ ├── desktop/ │ │ └── main.go │ └── mobile/ │ └── main.go ├── cmd/ │ ├── api/ │ │ └── main.go │ ├── banner.txt │ ├── gopeed/ │ │ ├── flags.go │ │ └── main.go │ ├── host/ │ │ ├── dail_other.go │ │ ├── dail_windows.go │ │ └── main.go │ ├── server.go │ ├── updater/ │ │ ├── main.go │ │ ├── updater_darwin.go │ │ ├── updater_linux.go │ │ └── updater_windows.go │ └── web/ │ ├── flags.go │ ├── flags_test.go │ └── main.go ├── docker-compose.yml ├── entrypoint.sh ├── go.mod ├── go.sum ├── internal/ │ ├── controller/ │ │ └── controller.go │ ├── fetcher/ │ │ ├── fetcher.go │ │ └── fetcher_test.go │ ├── logger/ │ │ ├── logger.go │ │ └── logger_test.go │ ├── protocol/ │ │ ├── bt/ │ │ │ ├── config.go │ │ │ ├── dns_cache_resolver.go │ │ │ ├── fetcher.go │ │ │ ├── fetcher_test.go │ │ │ └── testdata/ │ │ │ ├── test.torrent │ │ │ ├── test.unclean.torrent │ │ │ └── ubuntu-22.04-live-server-amd64.iso.torrent │ │ ├── ed2k/ │ │ │ ├── config.go │ │ │ ├── fetcher.go │ │ │ └── fetcher_test.go │ │ └── http/ │ │ ├── config.go │ │ ├── fetcher.go │ │ ├── fetcher_manager.go │ │ ├── fetcher_test.go │ │ ├── filename_parse_test.go │ │ ├── helper.go │ │ ├── timeout_reader.go │ │ └── timeout_reader_test.go │ └── test/ │ ├── httptest.go │ └── util.go ├── pkg/ │ ├── base/ │ │ ├── constants.go │ │ ├── info.go │ │ ├── model.go │ │ └── model_test.go │ ├── download/ │ │ ├── downloader.go │ │ ├── downloader_test.go │ │ ├── engine/ │ │ │ ├── engine.go │ │ │ ├── engine_test.go │ │ │ ├── inject/ │ │ │ │ ├── error/ │ │ │ │ │ └── module.go │ │ │ │ ├── file/ │ │ │ │ │ └── module.go │ │ │ │ ├── formdata/ │ │ │ │ │ └── module.go │ │ │ │ ├── vm/ │ │ │ │ │ └── module.go │ │ │ │ └── xhr/ │ │ │ │ ├── module.go │ │ │ │ └── tls_fingerprint.go │ │ │ ├── polyfill/ │ │ │ │ ├── out/ │ │ │ │ │ └── index.js │ │ │ │ ├── package.json │ │ │ │ ├── patches/ │ │ │ │ │ └── whatwg-fetch+3.6.20.patch │ │ │ │ ├── src/ │ │ │ │ │ ├── blob/ │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── crypto/ │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── fetch/ │ │ │ │ │ │ └── index.js │ │ │ │ │ └── index.js │ │ │ │ └── webpack.config.js │ │ │ └── util/ │ │ │ └── util.go │ │ ├── event.go │ │ ├── extension.go │ │ ├── extension_test.go │ │ ├── extract.go │ │ ├── extract_7z.go │ │ ├── extract_queue.go │ │ ├── extract_queue_test.go │ │ ├── extract_rar.go │ │ ├── extract_test.go │ │ ├── extract_zip.go │ │ ├── model.go │ │ ├── model_test.go │ │ ├── script.go │ │ ├── script_test.go │ │ ├── script_unix_test.go │ │ ├── script_windows_test.go │ │ ├── storage.go │ │ ├── testdata/ │ │ │ ├── extensions/ │ │ │ │ ├── basic/ │ │ │ │ │ ├── index.js │ │ │ │ │ └── manifest.json │ │ │ │ ├── extra/ │ │ │ │ │ ├── index.js │ │ │ │ │ └── manifest.json │ │ │ │ ├── function_error/ │ │ │ │ │ ├── index.js │ │ │ │ │ └── manifest.json │ │ │ │ ├── message_error/ │ │ │ │ │ ├── index.js │ │ │ │ │ └── manifest.json │ │ │ │ ├── on_done/ │ │ │ │ │ ├── index.js │ │ │ │ │ └── manifest.json │ │ │ │ ├── on_error/ │ │ │ │ │ ├── index.js │ │ │ │ │ └── manifest.json │ │ │ │ ├── on_start/ │ │ │ │ │ ├── index.js │ │ │ │ │ └── manifest.json │ │ │ │ ├── script_error/ │ │ │ │ │ ├── index.js │ │ │ │ │ └── manifest.json │ │ │ │ ├── settings_all/ │ │ │ │ │ ├── index.js │ │ │ │ │ └── manifest.json │ │ │ │ ├── settings_empty/ │ │ │ │ │ ├── index.js │ │ │ │ │ └── manifest.json │ │ │ │ ├── storage/ │ │ │ │ │ ├── index.js │ │ │ │ │ └── manifest.json │ │ │ │ └── update/ │ │ │ │ ├── index.js │ │ │ │ └── manifest.json │ │ │ └── scripts/ │ │ │ ├── env_dump.bat │ │ │ ├── env_dump.sh │ │ │ ├── move.bat │ │ │ ├── move.sh │ │ │ ├── write_output1.bat │ │ │ ├── write_output1.sh │ │ │ ├── write_output2.bat │ │ │ └── write_output2.sh │ │ ├── webhook.go │ │ └── webhook_test.go │ ├── protocol/ │ │ ├── bt/ │ │ │ └── model.go │ │ ├── ed2k/ │ │ │ └── model.go │ │ └── http/ │ │ └── model.go │ ├── rest/ │ │ ├── api.go │ │ ├── config.go │ │ ├── gizp_middleware.go │ │ ├── model/ │ │ │ ├── extension.go │ │ │ ├── result.go │ │ │ ├── server.go │ │ │ ├── task.go │ │ │ └── webhook.go │ │ ├── server.go │ │ └── server_test.go │ └── util/ │ ├── bytefmt.go │ ├── bytefmt_test.go │ ├── json.go │ ├── json_test.go │ ├── matcher.go │ ├── matcher_test.go │ ├── path.go │ ├── path_other.go │ ├── path_test.go │ ├── path_windows.go │ ├── timer.go │ ├── url.go │ └── url_test.go └── ui/ └── flutter/ ├── .gitignore ├── .metadata ├── analysis_options.yaml ├── android/ │ ├── .gitignore │ ├── app/ │ │ ├── build.gradle │ │ ├── libs/ │ │ │ └── .gitkeep │ │ └── src/ │ │ ├── debug/ │ │ │ └── AndroidManifest.xml │ │ ├── main/ │ │ │ ├── AndroidManifest.xml │ │ │ ├── kotlin/ │ │ │ │ └── com/ │ │ │ │ └── gopeed/ │ │ │ │ └── gopeed/ │ │ │ │ └── MainActivity.kt │ │ │ └── res/ │ │ │ ├── drawable/ │ │ │ │ └── launch_background.xml │ │ │ ├── drawable-v21/ │ │ │ │ └── launch_background.xml │ │ │ ├── values/ │ │ │ │ └── styles.xml │ │ │ └── values-night/ │ │ │ └── styles.xml │ │ └── profile/ │ │ └── AndroidManifest.xml │ ├── build.gradle │ ├── gradle/ │ │ └── wrapper/ │ │ └── gradle-wrapper.properties │ ├── gradle.properties │ └── settings.gradle ├── assets/ │ └── exec/ │ └── .gitkeep ├── build.yaml ├── distribute_options.yaml ├── include/ │ └── libgopeed.h ├── ios/ │ ├── .gitignore │ ├── Flutter/ │ │ ├── AppFrameworkInfo.plist │ │ ├── Debug.xcconfig │ │ └── Release.xcconfig │ ├── Podfile │ ├── Runner/ │ │ ├── AppDelegate.swift │ │ ├── Assets.xcassets/ │ │ │ ├── AppIcon.appiconset/ │ │ │ │ └── Contents.json │ │ │ └── LaunchImage.imageset/ │ │ │ ├── Contents.json │ │ │ └── README.md │ │ ├── Base.lproj/ │ │ │ ├── LaunchScreen.storyboard │ │ │ └── Main.storyboard │ │ ├── Info.plist │ │ ├── Runner-Bridging-Header.h │ │ └── Runner.entitlements │ ├── Runner.xcodeproj/ │ │ ├── project.pbxproj │ │ ├── project.xcworkspace/ │ │ │ ├── contents.xcworkspacedata │ │ │ └── xcshareddata/ │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── WorkspaceSettings.xcsettings │ │ └── xcshareddata/ │ │ └── xcschemes/ │ │ └── Runner.xcscheme │ ├── Runner.xcworkspace/ │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata/ │ │ ├── IDEWorkspaceChecks.plist │ │ └── WorkspaceSettings.xcsettings │ └── ShareExtension/ │ ├── Base.lproj/ │ │ └── MainInterface.storyboard │ ├── Info.plist │ ├── ShareExtension.entitlements │ └── ShareViewController.swift ├── lib/ │ ├── api/ │ │ ├── api.dart │ │ ├── gopeed_site_api.dart │ │ └── model/ │ │ ├── create_task.dart │ │ ├── create_task.g.dart │ │ ├── create_task_batch.dart │ │ ├── create_task_batch.g.dart │ │ ├── downloader_config.dart │ │ ├── downloader_config.g.dart │ │ ├── extension.dart │ │ ├── extension.g.dart │ │ ├── install_extension.dart │ │ ├── install_extension.g.dart │ │ ├── login.dart │ │ ├── login.g.dart │ │ ├── meta.dart │ │ ├── meta.g.dart │ │ ├── options.dart │ │ ├── options.g.dart │ │ ├── request.dart │ │ ├── request.g.dart │ │ ├── resolve_result.dart │ │ ├── resolve_result.g.dart │ │ ├── resolve_task.dart │ │ ├── resolve_task.g.dart │ │ ├── resource.dart │ │ ├── resource.g.dart │ │ ├── result.dart │ │ ├── result.g.dart │ │ ├── store_extension.dart │ │ ├── switch_extension.dart │ │ ├── switch_extension.g.dart │ │ ├── task.dart │ │ ├── task.g.dart │ │ ├── update_check_extension_resp.dart │ │ ├── update_check_extension_resp.g.dart │ │ ├── update_extension_settings.dart │ │ └── update_extension_settings.g.dart │ ├── app/ │ │ ├── modules/ │ │ │ ├── app/ │ │ │ │ ├── bindings/ │ │ │ │ │ └── app_binding.dart │ │ │ │ ├── controllers/ │ │ │ │ │ └── app_controller.dart │ │ │ │ └── views/ │ │ │ │ └── app_view.dart │ │ │ ├── create/ │ │ │ │ ├── bindings/ │ │ │ │ │ └── create_binding.dart │ │ │ │ ├── controllers/ │ │ │ │ │ └── create_controller.dart │ │ │ │ └── views/ │ │ │ │ └── create_view.dart │ │ │ ├── extension/ │ │ │ │ ├── bindings/ │ │ │ │ │ └── extension_binding.dart │ │ │ │ ├── controllers/ │ │ │ │ │ └── extension_controller.dart │ │ │ │ └── views/ │ │ │ │ ├── extension_card.dart │ │ │ │ ├── extension_detail_view.dart │ │ │ │ └── extension_view.dart │ │ │ ├── history/ │ │ │ │ └── views/ │ │ │ │ └── history_view.dart │ │ │ ├── home/ │ │ │ │ ├── bindings/ │ │ │ │ │ └── home_binding.dart │ │ │ │ ├── controllers/ │ │ │ │ │ └── home_controller.dart │ │ │ │ └── views/ │ │ │ │ └── home_view.dart │ │ │ ├── login/ │ │ │ │ ├── bindings/ │ │ │ │ │ └── login_binding.dart │ │ │ │ ├── controllers/ │ │ │ │ │ └── login_controller.dart │ │ │ │ └── views/ │ │ │ │ └── login_view.dart │ │ │ ├── redirect/ │ │ │ │ ├── bindings/ │ │ │ │ │ └── redirect_binding.dart │ │ │ │ ├── controllers/ │ │ │ │ │ └── redirect_controller.dart │ │ │ │ └── views/ │ │ │ │ └── redirect_view.dart │ │ │ ├── root/ │ │ │ │ ├── bindings/ │ │ │ │ │ └── root_binding.dart │ │ │ │ ├── controllers/ │ │ │ │ │ └── root_controller.dart │ │ │ │ └── views/ │ │ │ │ └── root_view.dart │ │ │ ├── setting/ │ │ │ │ ├── bindings/ │ │ │ │ │ └── setting_binding.dart │ │ │ │ ├── controllers/ │ │ │ │ │ └── setting_controller.dart │ │ │ │ └── views/ │ │ │ │ └── setting_view.dart │ │ │ └── task/ │ │ │ ├── bindings/ │ │ │ │ ├── task_binding.dart │ │ │ │ └── task_files_binding.dart │ │ │ ├── controllers/ │ │ │ │ ├── task_controller.dart │ │ │ │ ├── task_downloaded_controller.dart │ │ │ │ ├── task_downloading_controller.dart │ │ │ │ ├── task_files_controller.dart │ │ │ │ └── task_list_controller.dart │ │ │ └── views/ │ │ │ ├── task_downloaded_view.dart │ │ │ ├── task_downloading_view.dart │ │ │ ├── task_files_view.dart │ │ │ └── task_view.dart │ │ ├── routes/ │ │ │ ├── app_pages.dart │ │ │ └── app_routes.dart │ │ ├── rpc/ │ │ │ └── rpc.dart │ │ ├── services/ │ │ │ └── notification_service.dart │ │ └── views/ │ │ ├── breadcrumb_view.dart │ │ ├── buid_task_list_view.dart │ │ ├── check_list_view.dart │ │ ├── compact_checkbox.dart │ │ ├── copy_button.dart │ │ ├── directory_selector.dart │ │ ├── file_icon.dart │ │ ├── file_tree_view.dart │ │ ├── icon_button_loading.dart │ │ ├── open_in_new.dart │ │ ├── outlined_button_loading.dart │ │ ├── responsive_builder.dart │ │ ├── sort_icon_button.dart │ │ └── text_button_loading.dart │ ├── core/ │ │ ├── common/ │ │ │ ├── libgopeed_channel.dart │ │ │ ├── libgopeed_ffi.dart │ │ │ ├── libgopeed_interface.dart │ │ │ ├── start_config.dart │ │ │ └── start_config.g.dart │ │ ├── entry/ │ │ │ ├── libgopeed_boot_browser.dart │ │ │ └── libgopeed_boot_native.dart │ │ ├── ffi/ │ │ │ └── libgopeed_bind.dart │ │ ├── libgopeed_boot.dart │ │ └── libgopeed_boot_stub.dart │ ├── database/ │ │ ├── database.dart │ │ ├── entity.dart │ │ └── entity.g.dart │ ├── i18n/ │ │ ├── langs/ │ │ │ ├── ca_es.dart │ │ │ ├── de_de.dart │ │ │ ├── en_us.dart │ │ │ ├── es_es.dart │ │ │ ├── fa_ir.dart │ │ │ ├── fr_fr.dart │ │ │ ├── hu_hu.dart │ │ │ ├── id_id.dart │ │ │ ├── it_it.dart │ │ │ ├── ja_jp.dart │ │ │ ├── pl_pl.dart │ │ │ ├── pt_br.dart │ │ │ ├── ru_ru.dart │ │ │ ├── ta_ta.dart │ │ │ ├── tr_tr.dart │ │ │ ├── uk_ua.dart │ │ │ ├── vi_vn.dart │ │ │ ├── zh_cn.dart │ │ │ └── zh_tw.dart │ │ └── message.dart │ ├── icon/ │ │ └── gopeed_icons.dart │ ├── main.dart │ ├── theme/ │ │ └── theme.dart │ └── util/ │ ├── analytics.dart │ ├── arch/ │ │ ├── arch.dart │ │ ├── arch_stub.dart │ │ └── entry/ │ │ ├── arch_native.dart │ │ └── arch_web.dart │ ├── browser_download/ │ │ ├── browser_download.dart │ │ ├── browser_download_stub.dart │ │ └── entry/ │ │ └── browser_download_browser.dart │ ├── browser_extension_host/ │ │ ├── browser_extension_host.dart │ │ ├── browser_extension_host_stub.dart │ │ └── entry/ │ │ └── browser_extension_host_native.dart │ ├── extensions.dart │ ├── file_explorer.dart │ ├── github_mirror.dart │ ├── input_formatter.dart │ ├── locale_manager.dart │ ├── log_util.dart │ ├── message.dart │ ├── package_info.dart │ ├── scheme_register/ │ │ ├── entry/ │ │ │ └── scheme_register_native.dart │ │ ├── scheme_register.dart │ │ └── scheme_register_stub.dart │ ├── updater.dart │ ├── util.dart │ └── win32.dart ├── linux/ │ ├── .gitignore │ ├── CMakeLists.txt │ ├── assets/ │ │ └── com.gopeed.Gopeed.desktop │ ├── flutter/ │ │ └── CMakeLists.txt │ ├── main.cc │ ├── my_application.cc │ ├── my_application.h │ └── packaging/ │ ├── appimage/ │ │ └── make_config.yaml │ └── deb/ │ └── make_config.yaml ├── macos/ │ ├── .gitignore │ ├── Flutter/ │ │ ├── Flutter-Debug.xcconfig │ │ └── Flutter-Release.xcconfig │ ├── Podfile │ ├── Runner/ │ │ ├── AppDelegate.swift │ │ ├── Assets.xcassets/ │ │ │ └── AppIcon.appiconset/ │ │ │ └── Contents.json │ │ ├── Base.lproj/ │ │ │ └── MainMenu.xib │ │ ├── Configs/ │ │ │ ├── AppInfo.xcconfig │ │ │ ├── Debug.xcconfig │ │ │ ├── Release.xcconfig │ │ │ └── Warnings.xcconfig │ │ ├── DebugProfile.entitlements │ │ ├── Info.plist │ │ ├── MainFlutterWindow.swift │ │ └── Release.entitlements │ ├── Runner.xcodeproj/ │ │ ├── project.pbxproj │ │ ├── project.xcworkspace/ │ │ │ └── xcshareddata/ │ │ │ └── IDEWorkspaceChecks.plist │ │ └── xcshareddata/ │ │ └── xcschemes/ │ │ └── Runner.xcscheme │ └── Runner.xcworkspace/ │ ├── contents.xcworkspacedata │ └── xcshareddata/ │ └── IDEWorkspaceChecks.plist ├── pubspec.yaml ├── test/ │ └── widget_test.dart ├── web/ │ ├── index.html │ └── manifest.json └── windows/ ├── .gitignore ├── CMakeLists.txt ├── flutter/ │ └── CMakeLists.txt └── runner/ ├── CMakeLists.txt ├── Runner.rc ├── flutter_window.cpp ├── flutter_window.h ├── main.cpp ├── resource.h ├── runner.exe.manifest ├── utils.cpp ├── utils.h ├── win32_window.cpp └── win32_window.h ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ .git *.data *.log node_modules **/node_modules Dockerfile .dockerignore .github _docs _examples bin ui ================================================ FILE: .github/CODEOWNERS ================================================ * @monkeyWie ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms #github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] #patreon: # Replace with a single Patreon username #open_collective: # Replace with a single Open Collective username #ko_fi: # Replace with a single Ko-fi username #tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel #community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry #liberapay: # Replace with a single Liberapay username #issuehunt: # Replace with a single IssueHunt username #otechie: # Replace with a single Otechie username #custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] # github: monkeyWie ko_fi: gopeed custom: https://gopeed.com/docs/donate ================================================ FILE: .github/ISSUE_TEMPLATE.md ================================================ ### Description(required) ### App Version(required) ### OS Version(required) ### Snapshots ### Log ================================================ FILE: .github/release-drafter.yml ================================================ name-template: "v$NEXT_PATCH_VERSION 🌈" tag-template: "v$NEXT_PATCH_VERSION" categories: - title: "🆕 Features" labels: - "feat" - "feature" - "enhancement" - title: "🐛 Bug Fixes" labels: - "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" minor: labels: - "minor" patch: labels: - "patch" default: patch template: |
🪟 Windows EXE amd64 📥
arm64 📥
Portable amd64 📥
arm64 📥
🍎 MacOS DMG universal 📥
amd64 📥
arm64 📥
🐧 Linux Flathub amd64 📥
SNAP amd64 📥
DEB amd64 📥
arm64 📥
AppImage amd64 📥
arm64 📥
🤖 Android APK universal 📥
armeabi-v7a 📥
arm64-v8a 📥
x86_64 📥
📱 iOS IPA universal 📥
🐳 Docker - universal 📥
💾 Qnap QPKG amd64 📥
arm64 📥
🌐 Web Windows amd64 📥
arm64 📥
386 📥
MacOS amd64 📥
arm64 📥
Linux amd64 📥
arm64 📥
386 📥
# Release notes $CHANGES # 更新日志 $CHANGES ================================================ FILE: .github/workflows/build.yml ================================================ 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.24" FLUTTER_VERSION: "3.41.2" GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }} GA4_API_SECRET: ${{ secrets.GA4_API_SECRET }} permissions: contents: write jobs: get-release: runs-on: ubuntu-latest outputs: version: ${{ steps.get-release.outputs.version }} upload_url: ${{ steps.get-release.outputs.upload_url }} steps: - uses: monkeyWie/get-latest-release@v2.1 id: get-release with: myToken: ${{ github.token }} build-windows: if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows' }} 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: ${{ env.GO_VERSION }} - uses: subosito/flutter-action@v2 with: channel: ${{ matrix.flutter_channel || 'stable' }} flutter-version: ${{ matrix.flutter_version || env.FLUTTER_VERSION }} - name: Build env: VERSION: ${{ needs.get-release.outputs.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 -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 $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" 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-$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" #define MyAppPublisher "monkeyWie" #define MyAppURL "https://gopeed.com" #define MyAppExeName "gopeed.exe" [Setup] ; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications. ; (To generate a new GUID, click Tools | Generate GUID inside the IDE.) AppId={{5960F34D-1E42-402C-8C85-DE2FF24CBAE4} AppName={#MyAppName} AppVersion={#MyAppVersion} ;AppVerName={#MyAppName} {#MyAppVersion} AppPublisher={#MyAppPublisher} AppPublisherURL={#MyAppURL} AppSupportURL={#MyAppURL} AppUpdatesURL={#MyAppURL} DefaultDirName={autopf}\gopeed DisableProgramGroupPage=yes LicenseFile=..\..\..\..\LICENSE ; Remove the following line to run in administrative install mode (install for all users.) PrivilegesRequired=lowest OutputBaseFilename=gopeed SetupIconFile=..\..\assets\icon\icon.ico UninstallDisplayIcon={app}\{#MyAppExeName} Compression=lzma SolidCompression=yes WizardStyle=modern LanguageDetectionMethod=uilanguage ShowLanguageDialog=yes CloseApplications=force [Languages] Name: "english"; MessagesFile: "compiler:Default.isl" Name: "chinesesimplified"; MessagesFile: "compiler:Languages\ChineseSimplified.isl" [Tasks] Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked [Files] Source: ".\${{ matrix.arch == 'amd64' && 'x64' || 'arm64' }}\runner\Release\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs; ; NOTE: Don't use "Flags: ignoreversion" on any shared system files [UninstallDelete] Type: filesandordirs; Name: "{app}" [Icons] Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon [Run] Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent "@ > setup.iss iscc.exe setup.iss 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-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 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-amd64-lib path: | libgopeed.dylib host updater build-macos: if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos' }} runs-on: macos-latest strategy: matrix: channel: [arm64, amd64, universal] needs: [get-release, build-macos-amd64-lib] steps: - uses: actions/checkout@v3 - uses: actions/setup-go@v4 with: go-version: ${{ env.GO_VERSION }} - uses: actions/setup-node@v3 with: node-version: 16 - uses: subosito/flutter-action@v2 with: flutter-version: ${{ env.FLUTTER_VERSION }} - name: Install appdmg run: | python3 -m pip install setuptools --break-system-packages npm install -g appdmg - uses: actions/download-artifact@v4 with: name: macos-amd64-lib path: ui/flutter/lib-amd64 - if: ${{ matrix.channel == 'arm64' }} name: Build Arm64 Libraries run: | go build -tags nosqlite -ldflags="-w -s -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION" -buildmode=c-shared -o ui/flutter/macos/Frameworks/libgopeed.dylib github.com/GopeedLab/gopeed/bind/desktop go build -ldflags="-w -s" -o ui/flutter/assets/exec/host github.com/GopeedLab/gopeed/cmd/host go build -ldflags="-w -s" -o ui/flutter/assets/exec/updater github.com/GopeedLab/gopeed/cmd/updater - if: ${{ matrix.channel == 'amd64' }} name: Build Amd64 Libraries run: | mkdir -p ui/flutter/macos/Frameworks cp ui/flutter/lib-amd64/libgopeed.dylib ui/flutter/macos/Frameworks/libgopeed.dylib cp ui/flutter/lib-amd64/host ui/flutter/assets/exec/host cp ui/flutter/lib-amd64/updater ui/flutter/assets/exec/updater - if: ${{ matrix.channel == 'universal' }} name: Build Universal Libraries run: | mkdir -p ui/flutter/macos/Frameworks # arm64 lib go build -tags nosqlite -ldflags="-w -s -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION" -buildmode=c-shared -o ui/flutter/.temp/libgopeed.dylib.arm64 github.com/GopeedLab/gopeed/bind/desktop go build -ldflags="-w -s" -o ui/flutter/.temp/host.arm64 github.com/GopeedLab/gopeed/cmd/host go build -ldflags="-w -s" -o ui/flutter/.temp/updater.arm64 github.com/GopeedLab/gopeed/cmd/updater # amd64 lib cp ui/flutter/lib-amd64/libgopeed.dylib ui/flutter/.temp/libgopeed.dylib.amd64 cp ui/flutter/lib-amd64/host ui/flutter/.temp/host.amd64 cp ui/flutter/lib-amd64/updater ui/flutter/.temp/updater.amd64 # universal lib lipo -create -output ui/flutter/macos/Frameworks/libgopeed.dylib ui/flutter/.temp/libgopeed.dylib.arm64 ui/flutter/.temp/libgopeed.dylib.amd64 lipo -create -output ui/flutter/assets/exec/host ui/flutter/.temp/host.arm64 ui/flutter/.temp/host.amd64 lipo -create -output ui/flutter/assets/exec/updater ui/flutter/.temp/updater.arm64 ui/flutter/.temp/updater.amd64 - name: Build env: VERSION: ${{ needs.get-release.outputs.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< linux/packaging/rpm/make_config.yaml display_name: Gopeed icon: assets/icon/icon.svg summary: High speed downloader. description: A high speed downloader that supports all platforms. group: Applications/Internet license: GPL-3.0 url: https://github.com/GopeedLab/gopeed vendor: GopeedLab maintainer: GopeedLab build_arch: $RPM_ARCH EOF 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/* 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/dist/*/* overwrite: true build-snap: if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'snap' }} runs-on: ubuntu-latest needs: [get-release] steps: - uses: actions/checkout@v3 - uses: actions/setup-go@v4 with: go-version: ${{ env.GO_VERSION }} - name: Setup LXD uses: canonical/setup-lxd@v0.1.1 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< android/app/upload-keystore.jks flutter build apk --dart-define="UPDATE_CHANNEL=androidApk" --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}" flutter build apk --split-per-abi --dart-define="UPDATE_CHANNEL=androidApk" --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}" mkdir dist cp build/app/outputs/flutter-apk/app-release.apk dist/Gopeed-v$VERSION-android.apk cp build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk dist/Gopeed-v$VERSION-android-armeabi-v7a.apk cp build/app/outputs/flutter-apk/app-arm64-v8a-release.apk dist/Gopeed-v$VERSION-android-arm64-v8a.apk cp build/app/outputs/flutter-apk/app-x86_64-release.apk dist/Gopeed-v$VERSION-android-x86_64.apk - name: Upload uses: shogo82148/actions-upload-release-asset@v1 with: upload_url: ${{ needs.get-release.outputs.upload_url }} asset_path: ui/flutter/dist/* overwrite: true build-ios: if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios' }} runs-on: macos-14 needs: [get-release] steps: - uses: actions/checkout@v3 - uses: actions/setup-go@v4 with: go-version: ${{ env.GO_VERSION }} - uses: subosito/flutter-action@v2 with: flutter-version: ${{ env.FLUTTER_VERSION }} - name: Build env: VERSION: ${{ needs.get-release.outputs.version }} run: | go install golang.org/x/mobile/cmd/gomobile@latest go get golang.org/x/mobile/bind gomobile init gomobile bind -tags nosqlite -ldflags="-w -s -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION" -o ui/flutter/ios/Frameworks/Libgopeed.xcframework -target=ios github.com/GopeedLab/gopeed/bind/mobile cd ui/flutter flutter build ios --no-codesign --dart-define="UPDATE_CHANNEL=iosIpa" --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}" mkdir Payload cp -r build/ios/iphoneos/Runner.app Payload zip -r -y Payload.zip Payload/Runner.app mkdir dist mv Payload.zip dist/Gopeed-v$VERSION-ios.ipa - name: Upload uses: shogo82148/actions-upload-release-asset@v1 with: upload_url: ${{ needs.get-release.outputs.upload_url }} asset_path: ui/flutter/dist/* overwrite: true build-web: if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'web' || github.event.inputs.platform == 'qnap' }} runs-on: ubuntu-latest needs: [get-release] steps: - uses: actions/checkout@v3 - uses: actions/setup-go@v4 with: go-version: ${{ env.GO_VERSION }} - uses: subosito/flutter-action@v2 with: flutter-version: ${{ env.FLUTTER_VERSION }} - name: Build env: VERSION: ${{ needs.get-release.outputs.version }} run: | cd ui/flutter flutter build web --no-web-resources-cdn --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}" dart ../../.github/workflows/scripts/flutter_local_font.dart cd ../../ cp -r ui/flutter/build/web cmd/web/dist mkdir -p dist/zip goos_arr=(windows darwin linux) goarch_arr=(386 amd64 arm64) export CGO_ENABLED=0 for goos in "${goos_arr[@]}"; do for goarch in "${goarch_arr[@]}"; do goos_name=$goos if [ $goos = "darwin" ]; then goos_name="macos" fi name=gopeed-web-v$VERSION-$goos_name-$goarch dir="dist/$name/" (GOOS=$goos GOARCH=$goarch go build -tags nosqlite,web -ldflags="-s -w -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 \ && mv $file $name/$(echo $file | sed -e "s/web/gopeed/g") \ && zip -r ../zip/$name.zip * \ && cd ../..) \ || true done done - uses: actions/upload-artifact@v4 with: name: web-dist path: dist/zip/ - name: Upload uses: shogo82148/actions-upload-release-asset@v1 with: upload_url: ${{ needs.get-release.outputs.upload_url }} asset_path: dist/zip/* overwrite: true build-qnap: if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'qnap' }} runs-on: ubuntu-latest needs: [get-release, build-web] steps: - uses: actions/setup-python@v5 with: python-version: "3.8.18" - uses: actions/download-artifact@v4 with: name: web-dist path: dist/zip/ - name: Build env: VERSION: ${{ needs.get-release.outputs.version }} run: | sudo apt update -y sudo apt install -y pv bsdmainutils wget -O qdk2_0.32.bionic_amd64.deb "https://github.com/qnap-dev/qdk2/releases/download/v0.32/qdk2_0.32.bionic_amd64.deb" dpkg -X qdk2_0.32.bionic_amd64.deb qdk2 # Direct installs will fail due to missing dependencies! [[ -d qdk2 ]] || exit 1 export PATH=$(pwd)/qdk2/usr/bin:$(pwd)/qdk2/usr/share/qdk2/QDK/bin:${PATH} wget -O Gopeed.template.tar.gz "https://github.com/GopeedLab/QpkgBuild/raw/refs/heads/master/template/Gopeed.template.tar.gz" tar -zxf Gopeed.template.tar.gz [[ -d Gopeed ]] || exit 1 goos=linux goarch_arr=(amd64 arm64) for goarch in "${goarch_arr[@]}"; do qarch=x86_64 [[ "${goarch}" == "arm64" ]] && qarch=arm_64 name=gopeed-web-v${VERSION}-${goos}-${goarch} unzip dist/zip/${name}.zip -d dist/${name} cp dist/${name}/${name}/* Gopeed/${qarch}/ done cd Gopeed sed -i -e 's/__QPKG_VER__/${VERSION}/g' qpkg.cfg qbuild || exit 1 mkdir -p ../dist/qnap goos=qnap for goarch in "${goarch_arr[@]}"; do qarch=x86_64 [[ "${goarch}" == "arm64" ]] && qarch=arm_64 sname=Gopeed_${VERSION}_${qarch}.qpkg dname=gopeed-v${VERSION}-${goos}-${goarch}.qpkg [[ -f build/${sname} ]] && cp -ra build/${sname} ../dist/qnap/${dname} done - name: Upload uses: shogo82148/actions-upload-release-asset@v1 with: upload_url: ${{ needs.get-release.outputs.upload_url }} asset_path: dist/qnap/* overwrite: true build-docker: if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'docker' }} runs-on: ubuntu-latest needs: [get-release] steps: - 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 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2 - name: Login to DockerHub uses: docker/login-action@v1 with: username: liwei2633 password: ${{ secrets.DOCKER_PASSWORD }} - name: Build flutter web run: | cd ui/flutter flutter build web --no-web-resources-cdn --dart-define="UPDATE_CHANNEL=docker" --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}" dart ../../.github/workflows/scripts/flutter_local_font.dart cd ../../ rm -rf cmd/web/dist cp -r ui/flutter/build/web cmd/web/dist - name: Build and push uses: docker/build-push-action@v2 env: VERSION: ${{ needs.get-release.outputs.version }} with: context: . push: ${{ github.event.inputs.test == 'true' && 'false' || 'true' }} build-args: | VERSION=${{ env.VERSION }} IN_DOCKER=true platforms: | linux/386 linux/amd64 linux/arm64 linux/arm/v7 tags: | liwei2633/gopeed:latest liwei2633/gopeed:v${{ env.VERSION }} ================================================ FILE: .github/workflows/release.yml ================================================ name: release on: push: branches: - main paths: - "bind/**" - "cmd/**" - "internal/**" - "pkg/**" - "ui/**" - ".github/workflows/release.yml" - ".github/release-drafter.yml" - "go.mod" - "go.sum" - "Dockerfile" env: GO_VERSION: "1.24" permissions: contents: write jobs: release: runs-on: ubuntu-latest outputs: tag_name: ${{ steps.create_release.outputs.tag_name }} upload_url: ${{ steps.create_release.outputs.upload_url }} steps: - uses: actions/checkout@v3 - uses: actions/setup-go@v4 with: go-version: ${{ env.GO_VERSION }} - uses: release-drafter/release-drafter@v5 id: create_release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/scripts/flutter_local_font.dart ================================================ // 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/ // - 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 _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 = { '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 _parseSupportedLocales(File messageFile) { final locales = {}; 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 _getRequiredFontFamilies(Set locales) { final families = {..._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 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 main(List 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 = {}; 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 = {}; final skippedFonts = >{}; 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, () => {}).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: // /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 _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 = [ a, if (b != null) b, if (c != null) c, if (d != null) d, ]; return parts.join(Platform.pathSeparator); } ================================================ FILE: .github/workflows/test.yml ================================================ name: test on: pull_request: branches: - main paths: - "bind/**" - "cmd/**" - "internal/**" - "pkg/**" - "ui/**" - ".github/workflows/test.yml" - "go.mod" - "go.sum" push: branches: - main paths: - "bind/**" - "cmd/**" - "internal/**" - "pkg/**" - "ui/**" - ".github/workflows/test.yml" - "go.mod" - "go.sum" workflow_dispatch: env: GO_VERSION: "1.24" FLUTTER_VERSION: "3.41.2" jobs: # lint: # name: Lint # runs-on: ubuntu-latest # steps: # - uses: actions/setup-go@v2 # with: # go-version: '^1.19' # - uses: actions/checkout@v2 # - name: Lint Go Code # run: | # export PATH=$PATH:$(go env GOPATH)/bin # temporary fix. See https://github.com/actions/setup-go/issues/14 # go get -u golang.org/x/lint/golint # golint -set_exit_status ./... test: name: Test check runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-go@v4 with: go-version: ${{ env.GO_VERSION }} - name: Run Unit tests. 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 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 - uses: actions/setup-go@v4 with: go-version: ${{ env.GO_VERSION }} - uses: subosito/flutter-action@v2 with: flutter-version: ${{ env.FLUTTER_VERSION }} - run: | go install golang.org/x/mobile/cmd/gomobile@latest go get golang.org/x/mobile/bind gomobile init - if: runner.os == 'macOS' run: | gomobile bind -tags nosqlite -ldflags="-w -s" -o ui/flutter/ios/Frameworks/Libgopeed.xcframework -target=ios github.com/GopeedLab/gopeed/bind/mobile cd ui/flutter flutter build ipa --no-codesign - if: runner.os == 'Linux' uses: actions/setup-java@v3 with: distribution: "zulu" java-version: "17" - if: runner.os == 'Linux' run: | gomobile bind -tags nosqlite -ldflags="-w -s -checklinkname=0" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 21 -javapkg=com.gopeed github.com/GopeedLab/gopeed/bind/mobile cd ui/flutter flutter build apk build-web: name: Build web check runs-on: ubuntu-latest needs: [test] steps: - uses: actions/checkout@v3 - uses: actions/setup-go@v4 with: go-version: ${{ env.GO_VERSION }} - uses: subosito/flutter-action@v2 with: flutter-version: ${{ env.FLUTTER_VERSION }} - name: Build run: | cd ui/flutter flutter build web --no-web-resources-cdn dart ../../.github/workflows/scripts/flutter_local_font.dart cd ../../ rm -rf cmd/web/dist cp -r ui/flutter/build/web cmd/web/dist go build -tags nosqlite,web -ldflags="-s -w" github.com/GopeedLab/gopeed/cmd/web build-docker: name: Build docker check runs-on: ubuntu-latest needs: [test] steps: - uses: actions/checkout@v3 - uses: actions/setup-go@v4 with: go-version: ${{ env.GO_VERSION }} - uses: subosito/flutter-action@v2 with: flutter-version: ${{ env.FLUTTER_VERSION }} - name: Build run: | cd ui/flutter flutter build web --no-web-resources-cdn cd ../../ rm -rf cmd/web/dist cp -r ui/flutter/build/web cmd/web/dist docker build -t gopeed . ================================================ FILE: .github/workflows/translator.yml.bak ================================================ # name: 'translator' # on: # issues: # types: [opened, edited] # issue_comment: # types: [created, edited] # discussion: # types: [created, edited] # discussion_comment: # types: [created, edited] # pull_request_target: # types: [opened, edited] # pull_request_review_comment: # types: [created, edited] # jobs: # translate: # permissions: # issues: write # discussions: write # pull-requests: write # runs-on: ubuntu-latest # steps: # - uses: lizheming/github-translate-action@1.1.2 # env: # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # with: # IS_MODIFY_TITLE: true # APPEND_TRANSLATION: true ================================================ FILE: .gitignore ================================================ # Binaries for programs and plugins *.exe *.exe~ *.dll *.so *.dylib ui/flutter/dist/ # Test binary, built with `go test -c` *.test # Output of the go coverage tool, specifically when used with LiteIDE *.out .idea/ bin/ .torrent.db .torrent.db-shm .torrent.db-wal *.data *.db *.log .DS_Store node_modules/ cmd/web/dist/ .test_storage/ .test_download/ /extensions ================================================ FILE: CONTRIBUTING.md ================================================ # Gopeed contributors guide Firstly, thank you for your interest in contributing to Gopeed. This guide will help you better participate in the development of Gopeed. ## Branch description This project only has one main branch, namely the `main` branch. If you want to participate in the development of Gopeed, please fork this project first, and then develop in your fork project. After development is completed, submit a PR to this project and merge it into the `main` branch. ## Local development It is recommended to develop and debug through the web. First, start the backend service, and start it by the command line `go run cmd/api/main.go`, the default port of the service is `9999`, and then start the front-end flutter project in `debug` mode to run. ## Translation The internationalization files of Gopeed are located in the `ui/flutter/lib/i18n/langs` directory. You only need to add the corresponding language file in this directory. Please refer to `en_us.dart` for translation. words prefixed with `@` are not meant to be translated. ## flutter development Don't forget to run`dart format ./ui/flutter`before you commit to keep your code in standard dart format Turn on build_runner watcher if you want to edit api/models: ``` flutter pub run build_runner watch ``` ================================================ FILE: CONTRIBUTING_ja-JP.md ================================================ # Gopeed コントリビューターガイド まず最初に、Gopeed への貢献に興味を持っていただきありがとうございます。このガイドは、あなたが Gopeed の 開発に参加するための手助けとなるでしょう。 ## ブランチの説明 このプロジェクトのメインブランチは `main` ブランチのみです。Gopeed の開発に参加したい場合は、 まずこのプロジェクトをフォークし、フォークしたプロジェクトで開発を行ってください。開発が完了したら、 このプロジェクトに PR を提出し、`main` ブランチにマージしてください。 ## ローカル開発 開発およびデバッグはウェブ上で行うことを推奨する。まずバックエンドのサービスを起動し、 コマンドライン `go run cmd/api/main.go` で起動する。サービスのデフォルトポートは `9999` で、 次にフロントエンドの flutter プロジェクトを `debug` モードで起動して実行します。 ## 翻訳 Gopeed の国際化ファイルは `ui/flutter/lib/i18n/langs` ディレクトリにあります。 このディレクトリに対応する言語ファイルを追加するだけでよいです。 翻訳については `en_us.dart` を参照してください。 ## flutter での開発 コミットする前に `dart format ./ui/flutter` を実行し、コードを標準の dart フォーマットにしておくことを忘れないでください api/models を編集したい場合は build_runner watcher をオンにします: ``` flutter pub run build_runner watch ``` ================================================ FILE: CONTRIBUTING_vi-VN.md ================================================ # 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: ================================================ FILE: CONTRIBUTING_zh-CN.md ================================================ # Gopeed 贡献指南 首先感谢您对贡献代码感兴趣,这份指南将帮助您更好的参与到 Gopeed 的开发中来。 ## 分支说明 本项目只有一个主分支,即 `main` 分支,如果您想要参与到 Gopeed 的开发中来,请先 fork 本项目,然后在您的 fork 项目中进行开发,开发完成后再向本项目提交 PR,合并到 `main` 分支。 ## 本地开发 建议通过 web 端进行开发调试,首先启动后端服务,通过命令行 `go run cmd/api/main.go` 启动 ,服务启动默认端口为 `9999`,然后以 `debug` 模式启动前端 flutter 项目即可运行。 ## 翻译 Gopeed 的国际化文件位于 `ui/flutter/lib/i18n/langs` 目录下,只需要在该目录下添加对应的语言文件即可。 请注意以 `en_us.dart` 为参照进行翻译。 ## flutter开发 每次提交前请务必`dart format ./ui/flutter` 如果要编辑api/models,请打开build_runner watcher: ``` flutter pub run build_runner watch ``` ================================================ FILE: CONTRIBUTING_zh-TW.md ================================================ # 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 ``` ================================================ FILE: Dockerfile ================================================ FROM golang:1.24.11-alpine3.23 AS go WORKDIR /app COPY ./go.mod ./go.sum ./ RUN go mod download COPY . . 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.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 ["./entrypoint.sh"] ================================================ FILE: LICENSE ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ================================================ FILE: README.md ================================================ # [![](_docs/img/banner.svg)](https://gopeed.com) [![Test Status](https://github.com/GopeedLab/gopeed/workflows/test/badge.svg)](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest) [![Codecov](https://codecov.io/gh/GopeedLab/gopeed/branch/main/graph/badge.svg)](https://codecov.io/gh/GopeedLab/gopeed) [![Release](https://img.shields.io/github/release/GopeedLab/gopeed.svg)](https://github.com/GopeedLab/gopeed/releases) [![Download](https://img.shields.io/github/downloads/GopeedLab/gopeed/total.svg)](https://github.com/GopeedLab/gopeed/releases) [![Donate](https://img.shields.io/badge/%24-donate-ff69b4.svg)](https://gopeed.com/docs/donate) [![WeChat](https://img.shields.io/badge/WeChat%20Official%20Account-07C160?logo=wechat&logoColor=white)](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png) [![Discord](https://img.shields.io/discord/1037992631881449472?label=Discord&logo=discord&style=social)](https://discord.gg/ZUJqJrwCGB) GopeedLab%2Fgopeed | Trendshift [![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) ## 🚀 Introduction Gopeed (full name Go Speed), a high-speed downloader developed by `Golang` + `Flutter`, supports (HTTP, BitTorrent, Magnet, ED2K) protocol, and supports all platforms. In addition to basic download functions, Gopeed is also a highly customizable downloader that supports implementing more features through integration with [APIs](https://gopeed.com/docs/dev-api) or installation and development of [extensions](https://gopeed.com/docs/dev-extension). Visit ✈ [Official Website](https://gopeed.com) | 📖 [Official Docs](https://gopeed.com/docs) ## ⬇️ Download
🪟 Windows EXE amd64 📥
arm64 📥
Portable amd64 📥
arm64 📥
🍎 MacOS DMG universal 📥
amd64 📥
arm64 📥
🐧 Linux Flathub amd64 📥
SNAP amd64 📥
DEB amd64 📥
arm64 📥
AppImage amd64 📥
arm64 📥
🤖 Android APK universal 📥
armeabi-v7a 📥
arm64-v8a 📥
x86_64 📥
📱 iOS IPA universal 📥
🐳 Docker - universal 📥
💾 Qnap QPKG amd64 📥
arm64 📥
🌐 Web Windows amd64 📥
arm64 📥
386 📥
MacOS amd64 📥
arm64 📥
Linux amd64 📥
arm64 📥
386 📥
More about installation, please refer to [Installation](https://gopeed.com/docs/install) ### 🛠️ Command tool use `go install`: ```bash go install github.com/GopeedLab/gopeed/cmd/gopeed@latest ``` ## 🔌 Browser Extension 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) ## 📱 WeChat Official Account Follow our WeChat Official Account to get the latest updates and news. ## 💝 Donate If you like this project, please consider [donating](https://gopeed.com/docs/donate) to support the development of this project, thank you! ## 🖼️ Showcase ![](_docs/img/ui-demo.png) ## 👨‍💻 Development 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 1. Golang 1.24+ 2. Flutter 3.38+ ### 📋 Clone ```bash git clone git@github.com:GopeedLab/gopeed.git ``` ### 🤝 Contributing Please refer to [CONTRIBUTING.md](/CONTRIBUTING.md) ### 🏗️ Build #### Desktop First, you need to configure the environment according to the official [Flutter desktop website documention](https://docs.flutter.dev/development/platform-integration/desktop), then you will need to ensure the cgo environment is set up accordingly. For detailed instructions on setting up the cgo environment, please refer to relevant resources available online. command: - windows ```bash go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/windows/libgopeed.dll github.com/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 Same as before, you also need to prepare the `cgo` environment, and then install `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 ``` ## ❤️ Credits ### 👥 Contributors ### 🏢 JetBrains [![goland](_docs/img/goland.svg)](https://www.jetbrains.com/?from=gopeed) ## 📄 License [GPLv3](LICENSE) ================================================ FILE: README_ja-JP.md ================================================ # [![](_docs/img/banner.svg)](https://gopeed.com) [![Test Status](https://github.com/GopeedLab/gopeed/workflows/test/badge.svg)](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest) [![Codecov](https://codecov.io/gh/GopeedLab/gopeed/branch/main/graph/badge.svg)](https://codecov.io/gh/GopeedLab/gopeed) [![Release](https://img.shields.io/github/release/GopeedLab/gopeed.svg)](https://github.com/GopeedLab/gopeed/releases) [![Download](https://img.shields.io/github/downloads/GopeedLab/gopeed/total.svg)](https://github.com/GopeedLab/gopeed/releases) [![Donate](https://img.shields.io/badge/%24-donate-ff69b4.svg)](https://gopeed.com/docs/donate) [![WeChat](https://img.shields.io/badge/WeChat%20Official%20Account-07C160?logo=wechat&logoColor=white)](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png) [![Discord](https://img.shields.io/discord/1037992631881449472?label=Discord&logo=discord&style=social)](https://discord.gg/ZUJqJrwCGB) GopeedLab%2Fgopeed | Trendshift [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/R6R6IJGN6) [English](/README.md) | [中文](/README_zh-CN.md) | [日本語](/README_ja-JP.md) | [正體中文](/README_zh-TW.md) | [Tiếng Việt](/README_vi-VN.md) ## 🚀 はじめに Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された高速ダウンローダーで、(HTTP、BitTorrent、Magnet、ED2K) プロトコルをサポートし、すべてのプラットフォームをサポートします。基本的なダウンロード機能に加え、[APIs](https://gopeed.com/docs/dev-api)との連動や[拡張機能](https://gopeed.com/docs/dev-extension)のインストール・開発による追加機能にも対応した、カスタマイズ性の高いダウンローダーです。 見て下さい ✈ [公式ウェブサイト](https://gopeed.com) | 📖 [開発ドキュメント](https://gopeed.com/docs) ## ⬇️ インストール
🪟 Windows EXE amd64 📥
Portable amd64 📥
🍎 MacOS DMG universal 📥
amd64 📥
arm64 📥
🐧 Linux Flathub amd64 📥
SNAP amd64 📥
DEB amd64 📥
arm64 📥
AppImage amd64 📥
arm64 📥
🤖 Android APK universal 📥
armeabi-v7a 📥
arm64-v8a 📥
x86_64 📥
📱 iOS IPA universal 📥
🐳 Docker - universal 📥
💾 Qnap QPKG amd64 📥
arm64 📥
🌐 Web Windows amd64 📥
arm64 📥
386 📥
MacOS amd64 📥
arm64 📥
Linux amd64 📥
arm64 📥
386 📥
インストールについての詳細は、[インストール](https://gopeed.com/docs/install)を参照してください。 ### 🛠️ コマンドツール ## 📱 WeChat 公式アカウント 公式アカウントをフォローして、最新のアップデートやニュースを入手してください。 ## 💝 寄付 もしこのプロジェクトがお気に召しましたら、このプロジェクトの発展を支援するために[寄付](https://gopeed.com/docs/donate)をご検討ください! ## 🖼️ ショーケース ![](_docs/img/ui-demo.png) ## 👨‍💻 開発 このプロジェクトは二つの部分に分かれており、フロントエンドでは `flutter` を、バックエンドでは `Golang` を使用し、両者は `http` プロトコルで通信する。ユニックスシステムでは `unix socket` を、ウィンドウズシステムでは `tcp` プロトコルを使用します。 > フロントコードは `ui/flutter` ディレクトリにあります。 ### 🌍 環境 1. Go 言語 1.24+ 2. Flutter 3.38+ ### 📋 クローン ```bash git clone git@github.com:GopeedLab/gopeed.git ``` ### 🤝 コントリビュート [CONTRIBUTING.md](/CONTRIBUTING_ja-JP.md) をご参照ください ### 🏗️ ビルド #### デスクトップ まず、[flutter デスクトップ公式サイトドキュメント](https://docs.flutter.dev/development/platform-integration/desktop)に従って環境を設定し、自分で検索できる `cgo` 環境を用意します。 コマンド: - windows ```bash go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/windows/libgopeed.dll github.com/GopeedLab/gopeed/bind/desktop cd ui/flutter flutter build windows ``` - macos ```bash go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/macos/Frameworks/libgopeed.dylib github.com/GopeedLab/gopeed/bind/desktop cd ui/flutter flutter build macos ``` - linux ```bash go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/linux/bundle/lib/libgopeed.so github.com/GopeedLab/gopeed/bind/desktop cd ui/flutter flutter build linux ``` #### モバイル 先ほどと同じように、`cgo` 環境を準備し、`gomobile` をインストールする必要があります: ```bash go install golang.org/x/mobile/cmd/gomobile@latest go get golang.org/x/mobile/bind gomobile init ``` コマンド: - android ```bash gomobile bind -tags nosqlite -ldflags="-w -s -checklinkname=0" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 21 -javapkg="com.gopeed" github.com/GopeedLab/gopeed/bind/mobile cd ui/flutter flutter build apk ``` - ios ```bash gomobile bind -tags nosqlite -ldflags="-w -s" -o ui/flutter/ios/Frameworks/Libgopeed.xcframework -target=ios github.com/GopeedLab/gopeed/bind/mobile cd ui/flutter flutter build ios --no-codesign ``` #### Web コマンド: ```bash cd ui/flutter flutter build web cd ../../ rm -rf cmd/web/dist cp -r ui/flutter/build/web cmd/web/dist go build -tags nosqlite,web -ldflags="-s -w" -o bin/ github.com/GopeedLab/gopeed/cmd/web ``` ## ❤️ 感謝 ### コントリビューター ### JetBrains [![goland](_docs/img/goland.svg)](https://www.jetbrains.com/?from=gopeed) ## ライセンス [GPLv3](LICENSE) ================================================ FILE: README_vi-VN.md ================================================ # [![](_docs/img/banner.svg)](https://gopeed.com) [![Trạng thái kiểm tra](https://github.com/GopeedLab/gopeed/workflows/test/badge.svg)](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest) [![Codecov](https://codecov.io/gh/GopeedLab/gopeed/branch/main/graph/badge.svg)](https://codecov.io/gh/GopeedLab/gopeed) [![Phiên bản](https://img.shields.io/github/release/GopeedLab/gopeed.svg)](https://github.com/GopeedLab/gopeed/releases) [![Tải về](https://img.shields.io/github/downloads/GopeedLab/gopeed/total.svg)](https://github.com/GopeedLab/gopeed/releases) [![Ủng hộ](https://img.shields.io/badge/%24-ủng%20hộ-ff69b4.svg)](https://gopeed.com/docs/donate) [![WeChat](https://img.shields.io/badge/WeChat%20Official%20Account-07C160?logo=wechat&logoColor=white)](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png) [![Discord](https://img.shields.io/discord/1037992631881449472?label=Discord&logo=discord&style=social)](https://discord.gg/ZUJqJrwCGB) GopeedLab%2Fgopeed | Trendshift [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/R6R6IJGN6) [English](/README.md) | [中文](/README_zh-CN.md) | [日本語](/README_ja-JP.md) | [正體中文](/README_zh-TW.md) | [Tiếng Việt](/README_vi-VN.md) ## 🚀 Giới thiệu Gopeed (tên đầy đủ Go Speed), một công cụ tải xuống tốc độ cao được phát triển bởi `Golang` + `Flutter`, hỗ trợ giao thức (HTTP, BitTorrent, Magnet, ED2K) và hỗ trợ tất cả các nền tảng. Ngoài các chức năng tải xuống cơ bản, Gopeed còn là một công cụ tải xuống có thể tùy chỉnh cao cho phép triển khai thêm tính năng thông qua việc tích hợp với [APIs](https://gopeed.com/docs/dev-api) hoặc cài đặt và phát triển các [tiện ích mở rộng](https://gopeed.com/docs/dev-extension). Truy cập ✈ [Trang web chính thức](https://gopeed.com) | 📖 [Tài liệu chính thức](https://gopeed.com/docs) ## ⬇️ Tải về
🪟 Windows EXE amd64 📥
Portable amd64 📥
🍎 MacOS DMG universal 📥
amd64 📥
arm64 📥
🐧 Linux Flathub amd64 📥
SNAP amd64 📥
DEB amd64 📥
arm64 📥
AppImage amd64 📥
arm64 📥
🤖 Android APK universal 📥
armeabi-v7a 📥
arm64-v8a 📥
x86_64 📥
📱 iOS IPA universal 📥
🐳 Docker - universal 📥
💾 Qnap QPKG amd64 📥
arm64 📥
🌐 Web Windows amd64 📥
arm64 📥
386 📥
MacOS amd64 📥
arm64 📥
Linux amd64 📥
arm64 📥
386 📥
Thêm thông tin về cài đặt, vui lòng tham khảo [Cài đặt](https://gopeed.com/docs/install) ### 🛠️ Công cụ lệnh Sử dụng `go install`: ```bash go install github.com/GopeedLab/gopeed/cmd/gopeed@latest ``` ## 📱 WeChat Official Account Theo dõi tài khoản chính thức để nhận các cập nhật và tin tức mới nhất. ## 💝 Quyên góp Nếu bạn thích dự án này, xin vui lòng xem xét [quyên góp](https://gopeed.com/docs/donate) để hỗ trợ phát triển dự án này, cảm ơn bạn! ## 🖼️ Trưng bày ![](_docs/img/ui-demo.png) ## 👨‍💻 Development Dự án này được chia thành hai phần, phần giao diện sử dụng `flutter`, phần backend sử dụng `Golang`, và hai phía giao tiếp thông qua giao thức `http`. Trên hệ thống unix, sử dụng `unix socket`, và trên hệ thống windows, sử dụng giao thức `tcp`. > Mã giao diện nằm trong thư mục `ui/flutter`. ### 🌍 Environment 1. Golang 1.24+ 2. Flutter 3.38+ ### 📋 Clone ```bash git clone git@github.com: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 ### JetBrains [![goland](_docs/img/goland.svg)](https://www.jetbrains.com/?from=gopeed) ## Giấy phép [GPLv3](LICENSE) ================================================ FILE: README_zh-CN.md ================================================ # [![](_docs/img/banner.svg)](https://gopeed.com) [![Test Status](https://github.com/GopeedLab/gopeed/workflows/test/badge.svg)](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest) [![Codecov](https://codecov.io/gh/GopeedLab/gopeed/branch/main/graph/badge.svg)](https://codecov.io/gh/GopeedLab/gopeed) [![Release](https://img.shields.io/github/release/GopeedLab/gopeed.svg)](https://github.com/GopeedLab/gopeed/releases) [![Download](https://img.shields.io/github/downloads/GopeedLab/gopeed/total.svg)](https://github.com/GopeedLab/gopeed/releases) [![Donate](https://img.shields.io/badge/%24-donate-ff69b4.svg)](https://gopeed.com/docs/donate) [![WeChat](https://img.shields.io/badge/%E5%BE%AE%E4%BF%A1%E5%85%AC%E4%BC%97%E5%8F%B7-07C160?logo=wechat&logoColor=white)](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png) [![Discord](https://img.shields.io/discord/1037992631881449472?label=Discord&logo=discord&style=social)](https://discord.gg/ZUJqJrwCGB) GopeedLab%2Fgopeed | Trendshift [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/R6R6IJGN6) [English](/README.md) | [中文](/README_zh-CN.md) | [日本語](/README_ja-JP.md) | [正體中文](/README_zh-TW.md) | [Tiếng Việt](/README_vi-VN.md) ## 🚀 介绍 Gopeed(全称 Go Speed),直译过来中文名叫做`够快下载器`(不是狗屁下载器!),是一款由`Golang`+`Flutter`开发的高速下载器,支持(HTTP、BitTorrent、Magnet、ED2K)协议下载,并且支持全平台使用。除了基本的下载功能外,Gopeed 还是一款高度可定制化的下载器,支持通过对接[APIs](https://gopeed.com/docs/dev-api)或者安装和开发[扩展](https://gopeed.com/docs/dev-extension)来实现更多的功能。 访问 ✈ [官方网站](https://gopeed.com/zh-CN) | 📖 [官方文档](https://gopeed.com/docs) ## ⬇️ 下载
🪟 Windows EXE amd64 📥
Portable amd64 📥
🍎 MacOS DMG universal 📥
amd64 📥
arm64 📥
🐧 Linux Flathub amd64 📥
SNAP amd64 📥
DEB amd64 📥
arm64 📥
AppImage amd64 📥
arm64 📥
🤖 Android APK universal 📥
armeabi-v7a 📥
arm64-v8a 📥
x86_64 📥
📱 iOS IPA universal 📥
🐳 Docker - universal 📥
💾 Qnap QPKG amd64 📥
arm64 📥
🌐 Web Windows amd64 📥
arm64 📥
386 📥
MacOS amd64 📥
arm64 📥
Linux amd64 📥
arm64 📥
386 📥
更多关于安装的内容请参考[安装文档](https://gopeed.com/docs/install) ### 🛠️ 命令行工具 使用`go install`安装: ```bash go install github.com/GopeedLab/gopeed/cmd/gopeed@latest ``` ## 🔌 浏览器扩展 Gopeed 还提供了浏览器扩展用于接管浏览器下载,支持 Chrome、Edge、Firefox 等浏览器,具体请参考:[https://github.com/GopeedLab/browser-extension](https://github.com/GopeedLab/browser-extension) ## 📱 微信公众号 关注公众号获取项目最新动态和资讯。 ## 💝 赞助 如果觉得项目对你有帮助,请考虑[赞助](https://gopeed.com/docs/donate)以支持这个项目的发展,非常感谢! ## 🖼️ 界面展示 ![](_docs/img/ui-demo.png) ## 👨‍💻 开发 本项目分为前端和后端两个部分,前端使用`flutter`,后端使用`Golang`,两边通过`http`协议进行通讯,在 unix 系统下,使用的是`unix socket`,在 windows 系统下,使用的是`tcp`协议。 > 前端代码位于`ui/flutter`目录下。 ### 🌍 环境要求 1. Golang 1.24+ 2. Flutter 3.38+ ### 📋 克隆项目 ```bash git clone git@github.com:GopeedLab/gopeed.git ``` ### 🤝 贡献代码 请参考[贡献指南](CONTRIBUTING_zh-CN.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 ``` #### Web 端 构建命令: ```bash cd ui/flutter flutter build web cd ../../ rm -rf cmd/web/dist cp -r ui/flutter/build/web cmd/web/dist go build -tags nosqlite,web -ldflags="-s -w" -o bin/ github.com/GopeedLab/gopeed/cmd/web ``` ## ❤️ 感谢 ### 贡献者 ### JetBrains [![goland](_docs/img/goland.svg)](https://www.jetbrains.com/?from=gopeed) ## 开源许可 基于 [GPLv3](LICENSE) 协议开源。 ================================================ FILE: README_zh-TW.md ================================================ # [![](_docs/img/banner.svg)](https://gopeed.com) [![Test Status](https://github.com/GopeedLab/gopeed/workflows/test/badge.svg)](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest) [![Codecov](https://codecov.io/gh/GopeedLab/gopeed/branch/main/graph/badge.svg)](https://codecov.io/gh/GopeedLab/gopeed) [![Release](https://img.shields.io/github/release/GopeedLab/gopeed.svg)](https://github.com/GopeedLab/gopeed/releases) [![Download](https://img.shields.io/github/downloads/GopeedLab/gopeed/total.svg)](https://github.com/GopeedLab/gopeed/releases) [![Donate](https://img.shields.io/badge/%24-donate-ff69b4.svg)](https://gopeed.com/docs/donate) [![WeChat](https://img.shields.io/badge/%E5%BE%AE%E4%BF%A1%E5%85%AC%E4%BC%97%E5%8F%B7-07C160?logo=wechat&logoColor=white)](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png) [![Discord](https://img.shields.io/discord/1037992631881449472?label=Discord&logo=discord&style=social)](https://discord.gg/ZUJqJrwCGB) GopeedLab%2Fgopeed | Trendshift [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/R6R6IJGN6) [English](/README.md) | [中文](/README_zh-CN.md) | [日本語](/README_ja-JP.md) | [正體中文](/README_zh-TW.md) | [Tiếng Việt](/README_vi-VN.md) ## 🚀 簡介 Gopeed(全稱 Go Speed),是一款使用`Golang`+`Flutter`編寫的高速下載軟體,支援(HTTP、BitTorrent、Magnet、ED2K)協定,同時支援所有的平台。 前往 ✈ [主頁](https://gopeed.com/zh-CN) | 📖 [文檔](https://gopeed.com/docs) ## ⬇️ 下載
🪟 Windows EXE amd64 📥
Portable amd64 📥
🍎 MacOS DMG universal 📥
amd64 📥
arm64 📥
🐧 Linux Flathub amd64 📥
SNAP amd64 📥
DEB amd64 📥
arm64 📥
AppImage amd64 📥
arm64 📥
🤖 Android APK universal 📥
armeabi-v7a 📥
arm64-v8a 📥
x86_64 📥
📱 iOS IPA universal 📥
🐳 Docker - universal 📥
💾 Qnap QPKG amd64 📥
arm64 📥
🌐 Web Windows amd64 📥
arm64 📥
386 📥
MacOS amd64 📥
arm64 📥
Linux amd64 📥
arm64 📥
386 📥
更多關於安裝的內容請參考[安裝文檔](https://gopeed.com/docs/install) ### 🛠️ 使用 CLI 安裝 使用`go install`安裝: ```bash go install github.com/GopeedLab/gopeed/cmd/gopeed@latest ``` ## 📱 微信公眾號 關注公眾號獲取項目最新動態和資訊。 ## 💝 贊助 如果你認為該項目對你有所幫助,請考慮[贊助](https://gopeed.com/docs/donate)以支持該項目的持續發展,謝謝! ## 🖼️ 軟體介面 ![](_docs/img/ui-demo.png) ## 👨‍💻 開發 該項目分為前端與後端,前端使用`flutter`編寫,後端使用`Golang`編寫,兩邊通過`http`協定進行通訊,在 unix 系統下,則使用`unix socket`,在 windows 系統下,則使用`tcp`協定。 > 前端代碼位於`ui/flutter`目錄內。 ### 🌍 開發環境 1. Golang 1.24+ 2. Flutter 3.38+ ### 📋 克隆項目 ```bash git clone git@github.com: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 ``` ## ❤️ 感謝 ### 貢獻者 ### JetBrains [![goland](_docs/img/goland.svg)](https://www.jetbrains.com/?from=gopeed) ## 軟體許可 該軟體遵循 [GPLv3](LICENSE) 。 ================================================ FILE: _examples/basic/main.go ================================================ package main import ( "fmt" "github.com/GopeedLab/gopeed/pkg/base" "github.com/GopeedLab/gopeed/pkg/download" "github.com/GopeedLab/gopeed/pkg/protocol/http" ) func main() { finallyCh := make(chan error) _, err := download.Boot(). URL("https://www.baidu.com/index.html"). Listener(func(event *download.Event) { if event.Key == download.EventKeyFinally { finallyCh <- event.Err } }). Create(&base.Options{ Extra: http.OptsExtra{ Connections: 8, }, }) if err != nil { panic(err) } err = <-finallyCh if err != nil { fmt.Printf("download fail:%v\n", err) } else { fmt.Println("download success") } } ================================================ FILE: bind/desktop/main.go ================================================ package main import "C" import ( "encoding/json" "github.com/GopeedLab/gopeed/pkg/rest" "github.com/GopeedLab/gopeed/pkg/rest/model" ) func main() {} //export Start func Start(cfg *C.char) (int, *C.char) { var config model.StartConfig if err := json.Unmarshal([]byte(C.GoString(cfg)), &config); err != nil { return 0, C.CString(err.Error()) } config.ProductionMode = true realPort, err := rest.Start(&config) if err != nil { return 0, C.CString(err.Error()) } return realPort, nil } //export Stop func Stop() { rest.Stop() } ================================================ FILE: bind/mobile/main.go ================================================ package libgopeed // #cgo LDFLAGS: -static-libstdc++ import "C" import ( "encoding/json" "github.com/GopeedLab/gopeed/pkg/rest" "github.com/GopeedLab/gopeed/pkg/rest/model" ) func Start(cfg string) (int, error) { var config model.StartConfig if err := json.Unmarshal([]byte(cfg), &config); err != nil { return 0, err } config.ProductionMode = true return rest.Start(&config) } func Stop() { rest.Stop() } ================================================ FILE: cmd/api/main.go ================================================ package main import ( "github.com/GopeedLab/gopeed/cmd" "github.com/GopeedLab/gopeed/pkg/rest/model" ) // only for local development func main() { cfg := &model.StartConfig{ Network: "tcp", Address: "127.0.0.1:9999", Storage: model.StorageBolt, WebEnable: true, } cmd.Start(cfg) } ================================================ FILE: cmd/banner.txt ================================================ _______ ______ .______ _______ _______ _______ / _____| / __ \ | _ \ | ____|| ____|| \ | | __ | | | | | |_) | | |__ | |__ | .--. | | | |_ | | | | | | ___/ | __| | __| | | | | | |__| | | `--' | | | | |____ | |____ | '--' | \______| \______/ | _| |_______||_______||_______/ ================================================ FILE: cmd/gopeed/flags.go ================================================ package main import ( "flag" "fmt" "os" ) type args struct { url string connections *int dir *string } func parse() *args { dir, err := os.Getwd() if err != nil { panic(err) } var args args args.connections = flag.Int("C", 16, "Concurrent connections.") args.dir = flag.String("D", dir, "Store directory.") flag.Parse() t := flag.Args() if len(t) > 0 { args.url = t[0] } else { gPrintln("missing url parameter, for example: gopeed https://www.google.com or gopeed bt.torrent or gopeed magnet:?xt=urn:btih:...") gPrintln("try 'gopeed -h' for more information") os.Exit(1) } return &args } func gPrint(msg string) { fmt.Print("gopeed: " + msg) } func gPrintln(msg string) { gPrint(msg + "\n") } ================================================ FILE: cmd/gopeed/main.go ================================================ 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" ) const progressWidth = 20 func main() { args := parse() var wg sync.WaitGroup wg.Add(1) _, err := download.Boot(). URL(args.url). Listener(func(event *download.Event) { if event.Key == download.EventKeyProgress { printProgress(event.Task, "downloading...") } if event.Key == download.EventKeyFinally { var title string if event.Err != nil { title = "fail" } else { title = "complete" } printProgress(event.Task, title) fmt.Println() if event.Err != nil { fmt.Printf("reason: %s", event.Err.Error()) } else { fmt.Printf("saving path: %s", *args.dir) } wg.Done() } }). Create(&base.Options{ Path: *args.dir, Extra: http.OptsExtra{Connections: *args.connections}, }) 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) { 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) sb.WriteString(fmt.Sprintf("\r%s [", title)) for i := 0; i < progressWidth; i++ { if i < completeWidth { sb.WriteString("■") } else { sb.WriteString("□") } } sb.WriteString(fmt.Sprintf("] %.1f%% %s/s %s", rate*100, speed, totalSize)) if lastLineLen != 0 { paddingLen := lastLineLen - sb.Len() if paddingLen > 0 { sb.WriteString(strings.Repeat(" ", paddingLen)) } } lastLineLen = sb.Len() fmt.Print(sb.String()) sb.Reset() } ================================================ FILE: cmd/host/dail_other.go ================================================ //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")) } ================================================ FILE: cmd/host/dail_windows.go ================================================ package main import ( "net" "github.com/Microsoft/go-winio" ) func Dial() (net.Conn, error) { return winio.DialPipe(`\\.\pipe\gopeed_host`, nil) } ================================================ FILE: cmd/host/main.go ================================================ 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) } ================================================ FILE: cmd/server.go ================================================ package cmd import ( _ "embed" "fmt" "github.com/GopeedLab/gopeed/pkg/base" "github.com/GopeedLab/gopeed/pkg/rest" "github.com/GopeedLab/gopeed/pkg/rest/model" "net/http" "os" "os/signal" "path/filepath" "syscall" ) //go:embed banner.txt var banner string func Start(cfg *model.StartConfig) { fmt.Println(banner) srv, listener, err := rest.BuildServer(cfg) if err != nil { panic(err) } downloadCfg, err := rest.Downloader.GetConfig() if err != nil { panic(err) } if downloadCfg.FirstLoad { // Set default download config if cfg.DownloadConfig != nil { cfg.DownloadConfig.Merge(downloadCfg) // TODO Use PatchConfig rest.Downloader.PutConfig(cfg.DownloadConfig) downloadCfg = cfg.DownloadConfig } downloadDir := downloadCfg.DownloadDir // Set default download dir, in docker, it will be ${exe}/Downloads, else it will be ${user}/Downloads if downloadDir == "" { if base.InDocker == "true" { downloadDir = filepath.Join(filepath.Dir(cfg.StorageDir), "Downloads") } else { userDir, err := os.UserHomeDir() if err == nil { downloadDir = filepath.Join(userDir, "Downloads") } } if downloadDir != "" { downloadCfg.DownloadDir = downloadDir rest.Downloader.PutConfig(downloadCfg) } } } watchExit() fmt.Printf("Server start success on http://%s\n", listener.Addr().String()) if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed { panic(err) } } func watchExit() { sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) go func() { sig := <-sigs fmt.Printf("Server is shutting down due to signal: %s\n", sig) rest.Downloader.Close() os.Exit(0) }() } ================================================ FILE: cmd/updater/main.go ================================================ 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() } ================================================ FILE: cmd/updater/updater_darwin.go ================================================ //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) } ================================================ FILE: cmd/updater/updater_linux.go ================================================ //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) } ================================================ FILE: cmd/updater/updater_windows.go ================================================ //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 } ================================================ FILE: cmd/web/flags.go ================================================ package main import ( "encoding/json" "flag" "os" "path/filepath" "reflect" "strconv" "strings" "github.com/GopeedLab/gopeed/pkg/base" ) type args struct { Address *string `json:"address"` Port *int `json:"port"` Username *string `json:"username"` Password *string `json:"password"` ApiToken *string `json:"apiToken"` StorageDir *string `json:"storageDir"` WhiteDownloadDirs []string `json:"whiteDownloadDirs"` // DownloadConfig when the first time to start the server, it will be configured as initial value DownloadConfig *base.DownloaderStoreConfig `json:"downloadConfig"` configPath *string } func parse() *args { cfg := &args{} cliConfig := loadCliArgs() loadConfigFile(cfg, *cliConfig.configPath) loadEnvVars(cfg) // override with non-default command line arguments overrideWithCliArgs(cfg, cliConfig) // set default values setDefaults(cfg, cliConfig) return cfg } // loadCliArgs parses command line arguments and returns initial config func loadCliArgs() *args { cfg := &args{} cfg.Address = flag.String("A", "0.0.0.0", "Bind Address") cfg.Port = flag.Int("P", 9999, "Bind Port") cfg.Username = flag.String("u", "gopeed", "Web Authentication Username") cfg.Password = flag.String("p", "", "Web Authentication Password, if no password is set, web authentication will not be enabled") cfg.ApiToken = flag.String("T", "", "API token, it must be configured when using HTTP API in the case of enabling web authentication") cfg.StorageDir = flag.String("d", "", "Storage directory") whiteDownloadDirs := flag.String("w", "", "White download directories, comma-separated") cfg.configPath = flag.String("c", "./config.json", "Config file path") flag.Parse() // Parse white download directories from comma-separated string if whiteDownloadDirs != nil && *whiteDownloadDirs != "" { dirs := strings.Split(*whiteDownloadDirs, ",") for i := range dirs { dirs[i] = strings.TrimSpace(dirs[i]) } cfg.WhiteDownloadDirs = dirs } return cfg } // overrideWithCliArgs overrides config with non-empty command line arguments func overrideWithCliArgs(cfg *args, cliConfig *args) { flag.Visit(func(f *flag.Flag) { switch f.Name { case "A": cfg.Address = cliConfig.Address case "P": cfg.Port = cliConfig.Port case "u": cfg.Username = cliConfig.Username case "p": cfg.Password = cliConfig.Password case "T": cfg.ApiToken = cliConfig.ApiToken case "d": cfg.StorageDir = cliConfig.StorageDir case "w": cfg.WhiteDownloadDirs = cliConfig.WhiteDownloadDirs case "c": cfg.configPath = cliConfig.configPath } }) } // setDefaults sets default values for any unset configuration fields func setDefaults(cfg *args, cliConfig *args) { if cfg.Address == nil { cfg.Address = cliConfig.Address } if cfg.Port == nil { cfg.Port = cliConfig.Port } if cfg.Username == nil { cfg.Username = cliConfig.Username } if cfg.Password == nil { cfg.Password = cliConfig.Password } if cfg.ApiToken == nil { cfg.ApiToken = cliConfig.ApiToken } if cfg.StorageDir == nil { cfg.StorageDir = cliConfig.StorageDir } } // loadConfigFile loads configuration from file func loadConfigFile(cfg *args, configPath string) { if !filepath.IsAbs(configPath) { dir, err := os.Getwd() if err != nil { return } configPath = filepath.Join(dir, configPath) } file, err := os.ReadFile(configPath) if err != nil { if os.IsNotExist(err) { return } return } if err = json.Unmarshal(file, cfg); err != nil { return } } // loadEnvVars loads configuration from environment variables with prefix GOPEED_ func loadEnvVars(cfg *args) { v := reflect.ValueOf(cfg).Elem() t := reflect.TypeOf(cfg).Elem() for i := 0; i < v.NumField(); i++ { field := v.Field(i) fieldType := t.Field(i) // Get json tag as environment variable suffix jsonTag := fieldType.Tag.Get("json") if jsonTag == "" || jsonTag == "-" { continue } // Remove options like omitempty if commaIdx := strings.Index(jsonTag, ","); commaIdx != -1 { jsonTag = jsonTag[:commaIdx] } // Convert to uppercase and add GOPEED_ prefix envKey := "GOPEED_" + strings.ToUpper(jsonTag) envValue := os.Getenv(envKey) if envValue == "" { continue } // Set value based on field type if field.Kind() == reflect.Ptr { if field.IsNil() { // Create new pointer instance newVal := reflect.New(field.Type().Elem()) field.Set(newVal) } switch field.Type().Elem().Kind() { case reflect.String: field.Elem().SetString(envValue) case reflect.Int: if intVal, err := strconv.Atoi(envValue); err == nil { field.Elem().SetInt(int64(intVal)) } default: // For complex types like DownloadConfig, try JSON unmarshaling if field.Type().Elem() == reflect.TypeOf(base.DownloaderStoreConfig{}) { var config base.DownloaderStoreConfig if err := json.Unmarshal([]byte(envValue), &config); err == nil { field.Set(reflect.ValueOf(&config)) } } } } else if field.Kind() == reflect.Slice { // Handle non-pointer slice types (like []string for WhiteDownloadDirs) if field.Type().Elem().Kind() == reflect.String { dirs := strings.Split(envValue, ",") for i := range dirs { dirs[i] = strings.TrimSpace(dirs[i]) } field.Set(reflect.ValueOf(dirs)) } } } } ================================================ FILE: cmd/web/flags_test.go ================================================ package main import ( "fmt" "os" "path/filepath" "reflect" "testing" "github.com/GopeedLab/gopeed/pkg/base" ) func TestSetDefaults(t *testing.T) { // Create mock CLI configuration with command line default values cliDefaults := &args{ Address: stringPtr("127.0.0.1"), Port: intPtr(9999), Username: stringPtr("gopeed"), Password: stringPtr(""), ApiToken: stringPtr(""), StorageDir: stringPtr(""), } tests := []struct { name string input *args cliConfig *args expected *args }{ { name: "empty config should get CLI defaults", input: &args{}, cliConfig: cliDefaults, expected: &args{ Address: stringPtr("127.0.0.1"), Port: intPtr(9999), Username: stringPtr("gopeed"), Password: stringPtr(""), ApiToken: stringPtr(""), StorageDir: stringPtr(""), }, }, { name: "partial config should only fill missing fields with CLI defaults", input: &args{ Address: stringPtr("192.168.1.1"), Port: intPtr(8080), }, cliConfig: cliDefaults, expected: &args{ Address: stringPtr("192.168.1.1"), Port: intPtr(8080), Username: stringPtr("gopeed"), Password: stringPtr(""), ApiToken: stringPtr(""), StorageDir: stringPtr(""), }, }, { name: "full config should remain unchanged", input: &args{ Address: stringPtr("192.168.1.1"), Port: intPtr(8080), Username: stringPtr("admin"), Password: stringPtr("secret"), ApiToken: stringPtr("token123"), StorageDir: stringPtr("/custom/storage"), }, cliConfig: cliDefaults, expected: &args{ Address: stringPtr("192.168.1.1"), Port: intPtr(8080), Username: stringPtr("admin"), Password: stringPtr("secret"), ApiToken: stringPtr("token123"), StorageDir: stringPtr("/custom/storage"), }, }, { name: "custom CLI defaults should be used", input: &args{ Address: stringPtr("10.0.0.1"), }, cliConfig: &args{ Address: stringPtr("0.0.0.0"), Port: intPtr(8888), Username: stringPtr("customuser"), Password: stringPtr("defaultpass"), ApiToken: stringPtr("defaulttoken"), StorageDir: stringPtr("/default/storage"), }, expected: &args{ Address: stringPtr("10.0.0.1"), Port: intPtr(8888), Username: stringPtr("customuser"), Password: stringPtr("defaultpass"), ApiToken: stringPtr("defaulttoken"), StorageDir: stringPtr("/default/storage"), }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { setDefaults(tt.input, tt.cliConfig) if !reflect.DeepEqual(tt.input, tt.expected) { t.Errorf("setDefaults() = %+v, want %+v", tt.input, tt.expected) } }) } } func TestOverrideWithCliArgs(t *testing.T) { // Note: Since overrideWithCliArgs uses flag.Visit, we need to test its behavior // in a controlled environment. This test creates a comprehensive suite to verify // all flag handling branches. // Test the function behavior without any flags set t.Run("no flags set", func(t *testing.T) { config := &args{ Address: stringPtr("original.address"), Port: intPtr(8080), Username: stringPtr("original_user"), Password: stringPtr("original_pass"), ApiToken: stringPtr("original_token"), StorageDir: stringPtr("/original/storage"), WhiteDownloadDirs: []string{"/original/dir1", "/original/dir2"}, } cliConfig := &args{ Address: stringPtr("cli.address"), Port: intPtr(9090), Username: stringPtr("cli_user"), Password: stringPtr("cli_pass"), ApiToken: stringPtr("cli_token"), StorageDir: stringPtr("/cli/storage"), WhiteDownloadDirs: []string{"/cli/dir1", "/cli/dir2"}, configPath: stringPtr("/cli/config.json"), } // Save original config for comparison original := &args{ Address: stringPtr("original.address"), Port: intPtr(8080), Username: stringPtr("original_user"), Password: stringPtr("original_pass"), ApiToken: stringPtr("original_token"), StorageDir: stringPtr("/original/storage"), WhiteDownloadDirs: []string{"/original/dir1", "/original/dir2"}, } overrideWithCliArgs(config, cliConfig) // Since no flags are actually set via command line, config should remain unchanged if !reflect.DeepEqual(config, original) { t.Logf("Configuration changed when no flags were set:") t.Logf(" Got: %+v", config) t.Logf(" Want: %+v", original) // This is expected behavior in test environment } }) // Test individual flag override behavior by mocking flag.Visit // Since we can't easily mock flag.Visit, we document the expected behavior t.Run("flag override documentation", func(t *testing.T) { // Document the expected behavior for each flag: flagBehaviors := map[string]string{ "A": "Should override cfg.Address with cliConfig.Address", "P": "Should override cfg.Port with cliConfig.Port", "u": "Should override cfg.Username with cliConfig.Username", "p": "Should override cfg.Password with cliConfig.Password", "T": "Should override cfg.ApiToken with cliConfig.ApiToken", "d": "Should override cfg.StorageDir with cliConfig.StorageDir", "w": "Should override cfg.WhiteDownloadDirs with cliConfig.WhiteDownloadDirs", "c": "Should override cfg.configPath with cliConfig.configPath", } t.Log("Expected flag override behaviors:") for flag, behavior := range flagBehaviors { t.Logf(" Flag '%s': %s", flag, behavior) } }) // Test with mock implementation to verify switch case coverage t.Run("switch case coverage verification", func(t *testing.T) { // Create a mock function that simulates flag.Visit behavior mockFlagVisit := func(config *args, cliConfig *args, flagName string) { // Simulate the switch statement in overrideWithCliArgs switch flagName { case "A": config.Address = cliConfig.Address case "P": config.Port = cliConfig.Port case "u": config.Username = cliConfig.Username case "p": config.Password = cliConfig.Password case "T": config.ApiToken = cliConfig.ApiToken case "d": config.StorageDir = cliConfig.StorageDir case "w": config.WhiteDownloadDirs = cliConfig.WhiteDownloadDirs case "c": config.configPath = cliConfig.configPath default: t.Errorf("Unknown flag: %s", flagName) } } // Test each flag individually testCases := []struct { flagName string setupConfig func() *args setupCliConfig func() *args verify func(*testing.T, *args) }{ { flagName: "A", setupConfig: func() *args { return &args{Address: stringPtr("original.address")} }, setupCliConfig: func() *args { return &args{Address: stringPtr("cli.address")} }, verify: func(t *testing.T, cfg *args) { if cfg.Address == nil || *cfg.Address != "cli.address" { t.Errorf("Address flag override failed: got %v, want cli.address", cfg.Address) } }, }, { flagName: "P", setupConfig: func() *args { return &args{Port: intPtr(8080)} }, setupCliConfig: func() *args { return &args{Port: intPtr(9090)} }, verify: func(t *testing.T, cfg *args) { if cfg.Port == nil || *cfg.Port != 9090 { t.Errorf("Port flag override failed: got %v, want 9090", cfg.Port) } }, }, { flagName: "u", setupConfig: func() *args { return &args{Username: stringPtr("original_user")} }, setupCliConfig: func() *args { return &args{Username: stringPtr("cli_user")} }, verify: func(t *testing.T, cfg *args) { if cfg.Username == nil || *cfg.Username != "cli_user" { t.Errorf("Username flag override failed: got %v, want cli_user", cfg.Username) } }, }, { flagName: "p", setupConfig: func() *args { return &args{Password: stringPtr("original_pass")} }, setupCliConfig: func() *args { return &args{Password: stringPtr("cli_pass")} }, verify: func(t *testing.T, cfg *args) { if cfg.Password == nil || *cfg.Password != "cli_pass" { t.Errorf("Password flag override failed: got %v, want cli_pass", cfg.Password) } }, }, { flagName: "T", setupConfig: func() *args { return &args{ApiToken: stringPtr("original_token")} }, setupCliConfig: func() *args { return &args{ApiToken: stringPtr("cli_token")} }, verify: func(t *testing.T, cfg *args) { if cfg.ApiToken == nil || *cfg.ApiToken != "cli_token" { t.Errorf("ApiToken flag override failed: got %v, want cli_token", cfg.ApiToken) } }, }, { flagName: "d", setupConfig: func() *args { return &args{StorageDir: stringPtr("/original/storage")} }, setupCliConfig: func() *args { return &args{StorageDir: stringPtr("/cli/storage")} }, verify: func(t *testing.T, cfg *args) { if cfg.StorageDir == nil || *cfg.StorageDir != "/cli/storage" { t.Errorf("StorageDir flag override failed: got %v, want /cli/storage", cfg.StorageDir) } }, }, { flagName: "w", setupConfig: func() *args { return &args{WhiteDownloadDirs: []string{"/original/dir1", "/original/dir2"}} }, setupCliConfig: func() *args { return &args{WhiteDownloadDirs: []string{"/cli/dir1", "/cli/dir2", "/cli/dir3"}} }, verify: func(t *testing.T, cfg *args) { expected := []string{"/cli/dir1", "/cli/dir2", "/cli/dir3"} if !reflect.DeepEqual(cfg.WhiteDownloadDirs, expected) { t.Errorf("WhiteDownloadDirs flag override failed: got %v, want %v", cfg.WhiteDownloadDirs, expected) } }, }, { flagName: "c", setupConfig: func() *args { return &args{configPath: stringPtr("/original/config.json")} }, setupCliConfig: func() *args { return &args{configPath: stringPtr("/cli/config.json")} }, verify: func(t *testing.T, cfg *args) { if cfg.configPath == nil || *cfg.configPath != "/cli/config.json" { t.Errorf("configPath flag override failed: got %v, want /cli/config.json", cfg.configPath) } }, }, } for _, tc := range testCases { t.Run(fmt.Sprintf("flag_%s", tc.flagName), func(t *testing.T) { config := tc.setupConfig() cliConfig := tc.setupCliConfig() // Use mock function to simulate flag.Visit behavior mockFlagVisit(config, cliConfig, tc.flagName) // Verify the result tc.verify(t, config) }) } }) // Test edge cases t.Run("edge cases", func(t *testing.T) { t.Run("nil pointers", func(t *testing.T) { config := &args{} cliConfig := &args{ Address: stringPtr("new.address"), Port: intPtr(9999), Username: stringPtr("newuser"), Password: stringPtr("newpass"), ApiToken: stringPtr("newtoken"), StorageDir: stringPtr("/new/storage"), } // This should not panic when config has nil pointers overrideWithCliArgs(config, cliConfig) // Since no flags are set in test environment, config should remain with nil values if config.Address != nil { t.Log("Note: Address was set despite no flags being visited") } }) t.Run("empty slice handling", func(t *testing.T) { config := &args{WhiteDownloadDirs: []string{"/existing/dir"}} cliConfig := &args{WhiteDownloadDirs: []string{}} overrideWithCliArgs(config, cliConfig) // In test environment without actual flags, original should be preserved if len(config.WhiteDownloadDirs) != 1 || config.WhiteDownloadDirs[0] != "/existing/dir" { t.Log("Note: WhiteDownloadDirs was modified despite no flags being visited") } }) }) } func TestLoadConfigFile(t *testing.T) { // Create temporary directory for test files tempDir, err := os.MkdirTemp("", "flags_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) tests := []struct { name string configData string fileName string expected *args }{ { name: "valid config file", configData: `{ "address": "192.168.1.100", "port": 8080, "username": "testuser", "password": "testpass", "apiToken": "testtoken", "storageDir": "/test/storage", "downloadConfig": { "downloadDir": "/test/downloads", "maxRunning": 10 } }`, fileName: "valid_config.json", expected: &args{ Address: stringPtr("192.168.1.100"), Port: intPtr(8080), Username: stringPtr("testuser"), Password: stringPtr("testpass"), ApiToken: stringPtr("testtoken"), StorageDir: stringPtr("/test/storage"), DownloadConfig: &base.DownloaderStoreConfig{ DownloadDir: "/test/downloads", MaxRunning: 10, }, }, }, { name: "partial config file", configData: `{ "address": "10.0.0.1", "port": 3000 }`, fileName: "partial_config.json", expected: &args{ Address: stringPtr("10.0.0.1"), Port: intPtr(3000), }, }, { name: "invalid json should not panic", configData: `{invalid json}`, fileName: "invalid_config.json", expected: &args{}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Create test config file configPath := filepath.Join(tempDir, tt.fileName) err := os.WriteFile(configPath, []byte(tt.configData), 0644) if err != nil { t.Fatal(err) } cfg := &args{} loadConfigFile(cfg, configPath) if !reflect.DeepEqual(cfg, tt.expected) { t.Errorf("loadConfigFile() = %+v, want %+v", cfg, tt.expected) } }) } // Test non-existent file t.Run("non-existent file", func(t *testing.T) { cfg := &args{} loadConfigFile(cfg, "/non/existent/file.json") expected := &args{} if !reflect.DeepEqual(cfg, expected) { t.Errorf("loadConfigFile() with non-existent file = %+v, want %+v", cfg, expected) } }) } func TestLoadEnvVars(t *testing.T) { // Save original environment originalEnv := make(map[string]string) envKeys := []string{ "GOPEED_ADDRESS", "GOPEED_PORT", "GOPEED_USERNAME", "GOPEED_PASSWORD", "GOPEED_APITOKEN", "GOPEED_STORAGEDIR", "GOPEED_DOWNLOADCONFIG", "GOPEED_WHITEDOWNLOADDIRS", } for _, key := range envKeys { originalEnv[key] = os.Getenv(key) } // Clean up function cleanup := func() { for _, key := range envKeys { if val, exists := originalEnv[key]; exists { os.Setenv(key, val) } else { os.Unsetenv(key) } } } defer cleanup() tests := []struct { name string envVars map[string]string expected *args }{ { name: "all environment variables set", envVars: map[string]string{ "GOPEED_ADDRESS": "env.example.com", "GOPEED_PORT": "7777", "GOPEED_USERNAME": "envuser", "GOPEED_PASSWORD": "envpass", "GOPEED_APITOKEN": "envtoken", "GOPEED_STORAGEDIR": "/env/storage", }, expected: &args{ Address: stringPtr("env.example.com"), Port: intPtr(7777), Username: stringPtr("envuser"), Password: stringPtr("envpass"), ApiToken: stringPtr("envtoken"), StorageDir: stringPtr("/env/storage"), }, }, { name: "partial environment variables", envVars: map[string]string{ "GOPEED_ADDRESS": "partial.example.com", "GOPEED_PORT": "5555", }, expected: &args{ Address: stringPtr("partial.example.com"), Port: intPtr(5555), }, }, { name: "downloadConfig from environment", envVars: map[string]string{ "GOPEED_DOWNLOADCONFIG": `{"downloadDir": "/env/downloads", "maxRunning": 15}`, }, expected: &args{ DownloadConfig: &base.DownloaderStoreConfig{ DownloadDir: "/env/downloads", MaxRunning: 15, }, }, }, { name: "invalid port should be ignored", envVars: map[string]string{ "GOPEED_PORT": "invalid_port", }, expected: &args{ Port: intPtr(0), // Invalid port creates pointer with 0 value }, }, { name: "invalid json for downloadConfig should be ignored", envVars: map[string]string{ "GOPEED_DOWNLOADCONFIG": `{invalid json}`, }, expected: &args{ DownloadConfig: &base.DownloaderStoreConfig{}, // Invalid JSON creates empty config }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Clear all environment variables first for _, key := range envKeys { os.Unsetenv(key) } // Set test environment variables for key, value := range tt.envVars { os.Setenv(key, value) } cfg := &args{} loadEnvVars(cfg) if !reflect.DeepEqual(cfg, tt.expected) { t.Errorf("loadEnvVars() = %+v, want %+v", cfg, tt.expected) } }) } } func TestConfigPriority(t *testing.T) { // This test simulates the actual configuration loading flow: Config File -> Environment Variables -> Defaults // Note: overrideWithCliArgs will not perform override when no actual command line arguments are present // Create temporary directory for test files tempDir, err := os.MkdirTemp("", "flags_test_priority") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create test config file configData := `{ "address": "config.example.com", "port": 6666, "username": "configuser", "password": "configpass" }` configPath := filepath.Join(tempDir, "test_config.json") err = os.WriteFile(configPath, []byte(configData), 0644) if err != nil { t.Fatal(err) } // Save original environment originalEnv := make(map[string]string) envKeys := []string{"GOPEED_ADDRESS", "GOPEED_PORT", "GOPEED_USERNAME"} for _, key := range envKeys { originalEnv[key] = os.Getenv(key) } defer func() { for _, key := range envKeys { if val, exists := originalEnv[key]; exists { os.Setenv(key, val) } else { os.Unsetenv(key) } } }() // Set environment variables os.Setenv("GOPEED_ADDRESS", "env.example.com") os.Setenv("GOPEED_PORT", "7777") // Test configuration priority: ENV > Config File > Defaults cfg := &args{} // Load config file loadConfigFile(cfg, configPath) // Load environment variables (should override config file) loadEnvVars(cfg) // Simulate command line defaults (will not override because no actual flags are set) cliConfig := &args{ Address: stringPtr("127.0.0.1"), // CLI default address Port: intPtr(9999), // CLI default port Username: stringPtr("gopeed"), // CLI default username Password: stringPtr(""), // CLI default password ApiToken: stringPtr(""), // CLI default api token StorageDir: stringPtr(""), // CLI default storage dir } overrideWithCliArgs(cfg, cliConfig) // This won't change anything because no flags are set // Set defaults for missing fields setDefaults(cfg, cliConfig) expected := &args{ Address: stringPtr("env.example.com"), // From ENV (overrides config file) Port: intPtr(7777), // From ENV (overrides config file) Username: stringPtr("configuser"), // From config file (env not set) Password: stringPtr("configpass"), // From config file only ApiToken: stringPtr(""), // CLI default (not set in config or env) StorageDir: stringPtr(""), // CLI default (not set in config or env) } if !reflect.DeepEqual(cfg, expected) { t.Errorf("Configuration priority test failed.\nGot: %+v\nWant: %+v", cfg, expected) } } func TestCompleteConfigurationFlow(t *testing.T) { // This test simulates a complete configuration loading scenario // with all sources: defaults, config file, environment variables, and CLI args tempDir, err := os.MkdirTemp("", "flags_test_complete") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create comprehensive config file configData := `{ "address": "config.host.com", "port": 5000, "username": "configuser", "password": "configpass", "apiToken": "configtoken", "storageDir": "/config/storage", "downloadConfig": { "downloadDir": "/config/downloads", "maxRunning": 8, "protocolConfig": {"http": {"maxConnections": 16}}, "proxy": { "enable": true, "system": false, "scheme": "http", "host": "proxy.example.com:8080" } } }` configPath := filepath.Join(tempDir, "complete_config.json") err = os.WriteFile(configPath, []byte(configData), 0644) if err != nil { t.Fatal(err) } // Save and set environment variables originalEnv := make(map[string]string) envKeys := []string{"GOPEED_ADDRESS", "GOPEED_USERNAME", "GOPEED_APITOKEN", "GOPEED_WHITEDOWNLOADDIRS"} for _, key := range envKeys { originalEnv[key] = os.Getenv(key) } defer func() { for _, key := range envKeys { if val, exists := originalEnv[key]; exists { os.Setenv(key, val) } else { os.Unsetenv(key) } } }() os.Setenv("GOPEED_ADDRESS", "env.host.com") os.Setenv("GOPEED_USERNAME", "envuser") // Simulate complete configuration loading cfg := &args{} // Load from config file loadConfigFile(cfg, configPath) // Override with environment variables loadEnvVars(cfg) // Override with CLI arguments (this won't change anything when no actual flags are set) cliConfig := &args{ Address: stringPtr("127.0.0.1"), // CLI default address Port: intPtr(9999), // CLI default port Username: stringPtr("gopeed"), // CLI default username Password: stringPtr(""), // CLI default password ApiToken: stringPtr(""), // CLI default api token StorageDir: stringPtr(""), // CLI default storage dir } overrideWithCliArgs(cfg, cliConfig) // Won't override any values because no flags are set // Set defaults setDefaults(cfg, cliConfig) // Verify the final configuration follows priority rules if *cfg.Address != "env.host.com" { t.Errorf("Address should be from environment, got %s", *cfg.Address) } if *cfg.Port != 5000 { t.Errorf("Port should be from config file, got %d", *cfg.Port) } if *cfg.Username != "envuser" { t.Errorf("Username should be from environment, got %s", *cfg.Username) } if *cfg.Password != "configpass" { t.Errorf("Password should be from config file, got %s", *cfg.Password) } if *cfg.ApiToken != "configtoken" { t.Errorf("ApiToken should be from config file, got %s", *cfg.ApiToken) } if *cfg.StorageDir != "/config/storage" { t.Errorf("StorageDir should be from config file, got %s", *cfg.StorageDir) } if cfg.DownloadConfig == nil { t.Error("DownloadConfig should be loaded from config file") } else { if cfg.DownloadConfig.DownloadDir != "/config/downloads" { t.Errorf("DownloadConfig.DownloadDir should be from config file, got %s", cfg.DownloadConfig.DownloadDir) } if cfg.DownloadConfig.MaxRunning != 8 { t.Errorf("DownloadConfig.MaxRunning should be from config file, got %d", cfg.DownloadConfig.MaxRunning) } } } // Helper functions for creating pointers func stringPtr(s string) *string { return &s } func intPtr(i int) *int { return &i } // Helper functions for safely getting pointer values func getStringValue(ptr *string) string { if ptr == nil { return "" } return *ptr } func getIntValue(ptr *int) string { if ptr == nil { return "" } return fmt.Sprintf("%d", *ptr) } func TestWhiteDownloadDirs(t *testing.T) { t.Run("overrideWithCliArgs should handle WhiteDownloadDirs", func(t *testing.T) { // Note: Since overrideWithCliArgs uses flag.Visit, it only overrides parameters actually set via command line // When no actual flags are set, configuration won't be overridden tests := []struct { name string config *args cliConfig *args expected *args }{ { name: "without actual flag set, should not override", config: &args{WhiteDownloadDirs: []string{"/old/dir1", "/old/dir2"}}, cliConfig: &args{ WhiteDownloadDirs: []string{"/new/dir1", "/new/dir2"}, }, expected: &args{WhiteDownloadDirs: []string{"/old/dir1", "/old/dir2"}}, // Should remain unchanged }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { overrideWithCliArgs(tt.config, tt.cliConfig) if !reflect.DeepEqual(tt.config, tt.expected) { // This test might pass because no flags are set t.Logf("overrideWithCliArgs() without flag set: got %+v, want %+v", tt.config, tt.expected) } }) } }) t.Run("loadConfigFile should handle WhiteDownloadDirs", func(t *testing.T) { // Create temporary directory for test files tempDir, err := os.MkdirTemp("", "flags_test_whitedirs") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) tests := []struct { name string configData string fileName string expected *args }{ { name: "config with WhiteDownloadDirs array", configData: `{ "address": "test.example.com", "whiteDownloadDirs": ["/path/to/dir1", "/path/to/dir2", "/path/to/dir3"] }`, fileName: "whitedirs_config.json", expected: &args{ Address: stringPtr("test.example.com"), WhiteDownloadDirs: []string{"/path/to/dir1", "/path/to/dir2", "/path/to/dir3"}, }, }, { name: "config with empty WhiteDownloadDirs array", configData: `{ "address": "test.example.com", "whiteDownloadDirs": [] }`, fileName: "empty_whitedirs_config.json", expected: &args{ Address: stringPtr("test.example.com"), WhiteDownloadDirs: []string{}, }, }, { name: "config without WhiteDownloadDirs", configData: `{ "address": "test.example.com", "port": 8080 }`, fileName: "no_whitedirs_config.json", expected: &args{ Address: stringPtr("test.example.com"), Port: intPtr(8080), }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Create test config file configPath := filepath.Join(tempDir, tt.fileName) err := os.WriteFile(configPath, []byte(tt.configData), 0644) if err != nil { t.Fatal(err) } cfg := &args{} loadConfigFile(cfg, configPath) if !reflect.DeepEqual(cfg, tt.expected) { t.Errorf("loadConfigFile() = %+v, want %+v", cfg, tt.expected) } }) } }) t.Run("loadEnvVars should handle WhiteDownloadDirs", func(t *testing.T) { // Save original environment originalEnv := os.Getenv("GOPEED_WHITEDOWNLOADDIRS") defer func() { if originalEnv != "" { os.Setenv("GOPEED_WHITEDOWNLOADDIRS", originalEnv) } else { os.Unsetenv("GOPEED_WHITEDOWNLOADDIRS") } }() tests := []struct { name string envValue string expected *args }{ { name: "comma-separated directories", envValue: "/env/dir1,/env/dir2,/env/dir3", expected: &args{ WhiteDownloadDirs: []string{"/env/dir1", "/env/dir2", "/env/dir3"}, }, }, { name: "comma-separated with spaces", envValue: " /env/dir1 , /env/dir2 , /env/dir3 ", expected: &args{ WhiteDownloadDirs: []string{"/env/dir1", "/env/dir2", "/env/dir3"}, }, }, { name: "single directory", envValue: "/single/dir", expected: &args{ WhiteDownloadDirs: []string{"/single/dir"}, }, }, { name: "empty string should create empty slice", envValue: "", expected: &args{}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Clear environment variable first os.Unsetenv("GOPEED_WHITEDOWNLOADDIRS") if tt.envValue != "" { os.Setenv("GOPEED_WHITEDOWNLOADDIRS", tt.envValue) } cfg := &args{} loadEnvVars(cfg) if !reflect.DeepEqual(cfg, tt.expected) { t.Errorf("loadEnvVars() = %+v, want %+v", cfg, tt.expected) } }) } }) } func TestParse(t *testing.T) { // Note: Testing parse() function is challenging because it depends on global flag state // and calls flag.Parse(). These tests document the expected behavior but may not // work perfectly in the test environment due to flag package limitations. t.Run("parse integration test with mocked components", func(t *testing.T) { // Since parse() calls flag.Parse() and depends on os.Args, we'll test the // integration behavior by testing the individual components it orchestrates // Save original environment state originalEnv := make(map[string]string) envKeys := []string{ "GOPEED_ADDRESS", "GOPEED_PORT", "GOPEED_USERNAME", "GOPEED_PASSWORD", "GOPEED_APITOKEN", "GOPEED_STORAGEDIR", } for _, key := range envKeys { originalEnv[key] = os.Getenv(key) } defer func() { for _, key := range envKeys { if val, exists := originalEnv[key]; exists { os.Setenv(key, val) } else { os.Unsetenv(key) } } }() // Clear environment for clean test for _, key := range envKeys { os.Unsetenv(key) } // Create temporary directory for config files tempDir, err := os.MkdirTemp("", "flags_test_parse") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) t.Run("default behavior without config file", func(t *testing.T) { // Test the parse flow when no config file exists and no env vars are set // This simulates: loadCliArgs() -> loadConfigFile() (fails) -> loadEnvVars() (empty) -> overrideWithCliArgs() (no flags) -> setDefaults() // Simulate the parse process step by step cfg := &args{} // Step 1: Simulate loadCliArgs() with default values cliConfig := &args{ Address: stringPtr("127.0.0.1"), Port: intPtr(9999), Username: stringPtr("gopeed"), Password: stringPtr("123456"), ApiToken: stringPtr(""), StorageDir: stringPtr(""), configPath: stringPtr("./config.json"), } // Step 2: loadConfigFile with non-existent file loadConfigFile(cfg, "/non/existent/config.json") // Step 3: loadEnvVars with empty environment loadEnvVars(cfg) // Step 4: overrideWithCliArgs (no flags set in test environment) overrideWithCliArgs(cfg, cliConfig) // Step 5: setDefaults setDefaults(cfg, cliConfig) // Verify final configuration has CLI defaults if cfg.Address == nil || *cfg.Address != "127.0.0.1" { t.Errorf("Expected Address to be set to CLI default, got: %v", cfg.Address) } if cfg.Port == nil || *cfg.Port != 9999 { t.Errorf("Expected Port to be set to CLI default, got: %v", cfg.Port) } if cfg.Username == nil || *cfg.Username != "gopeed" { t.Errorf("Expected Username to be set to CLI default, got: %v", cfg.Username) } if cfg.Password == nil || *cfg.Password != "123456" { t.Errorf("Expected Password to be set to CLI default, got: %v", cfg.Password) } }) t.Run("with config file", func(t *testing.T) { // Test parse flow with a config file configData := `{ "address": "config.example.com", "port": 8080, "username": "configuser", "password": "configpass", "apiToken": "configtoken", "storageDir": "/config/storage" }` configPath := filepath.Join(tempDir, "test_config.json") err := os.WriteFile(configPath, []byte(configData), 0644) if err != nil { t.Fatal(err) } // Simulate parse process cfg := &args{} cliConfig := &args{ Address: stringPtr("127.0.0.1"), Port: intPtr(9999), Username: stringPtr("gopeed"), Password: stringPtr("123456"), ApiToken: stringPtr(""), StorageDir: stringPtr(""), configPath: &configPath, } loadConfigFile(cfg, configPath) loadEnvVars(cfg) overrideWithCliArgs(cfg, cliConfig) setDefaults(cfg, cliConfig) // Verify config file values are loaded if cfg.Address == nil || *cfg.Address != "config.example.com" { t.Errorf("Expected Address from config file, got: %v", cfg.Address) } if cfg.Port == nil || *cfg.Port != 8080 { t.Errorf("Expected Port from config file, got: %v", cfg.Port) } if cfg.Username == nil || *cfg.Username != "configuser" { t.Errorf("Expected Username from config file, got: %v", cfg.Username) } }) t.Run("with environment variables override", func(t *testing.T) { // Test parse flow with environment variables overriding config configData := `{ "address": "config.example.com", "port": 8080, "username": "configuser" }` configPath := filepath.Join(tempDir, "env_test_config.json") err := os.WriteFile(configPath, []byte(configData), 0644) if err != nil { t.Fatal(err) } // Set environment variables os.Setenv("GOPEED_ADDRESS", "env.example.com") os.Setenv("GOPEED_PORT", "7777") os.Setenv("GOPEED_PASSWORD", "envpass") // Simulate parse process cfg := &args{} cliConfig := &args{ Address: stringPtr("127.0.0.1"), Port: intPtr(9999), Username: stringPtr("gopeed"), Password: stringPtr("123456"), ApiToken: stringPtr(""), StorageDir: stringPtr(""), configPath: &configPath, } loadConfigFile(cfg, configPath) loadEnvVars(cfg) overrideWithCliArgs(cfg, cliConfig) setDefaults(cfg, cliConfig) // Verify environment variables override config if cfg.Address == nil || *cfg.Address != "env.example.com" { t.Errorf("Expected Address from environment, got: %v", cfg.Address) } if cfg.Port == nil || *cfg.Port != 7777 { t.Errorf("Expected Port from environment, got: %v", cfg.Port) } if cfg.Username == nil || *cfg.Username != "configuser" { t.Errorf("Expected Username from config (not overridden by env), got: %v", cfg.Username) } if cfg.Password == nil || *cfg.Password != "envpass" { t.Errorf("Expected Password from environment, got: %v", cfg.Password) } }) t.Run("complete priority chain", func(t *testing.T) { // Test the complete priority chain: CLI > ENV > Config > Defaults configData := `{ "address": "config.example.com", "port": 8080, "username": "configuser", "password": "configpass", "apiToken": "configtoken" }` configPath := filepath.Join(tempDir, "priority_test_config.json") err := os.WriteFile(configPath, []byte(configData), 0644) if err != nil { t.Fatal(err) } // Set some environment variables os.Setenv("GOPEED_ADDRESS", "env.example.com") os.Setenv("GOPEED_USERNAME", "envuser") os.Setenv("GOPEED_PORT", "7777") // This will override config os.Setenv("GOPEED_PASSWORD", "envpass") // This will override config // Simulate parse process cfg := &args{} cliConfig := &args{ Address: stringPtr("127.0.0.1"), // CLI default Port: intPtr(9999), // CLI default Username: stringPtr("gopeed"), // CLI default Password: stringPtr("123456"), // CLI default ApiToken: stringPtr(""), // CLI default StorageDir: stringPtr(""), // CLI default configPath: &configPath, } loadConfigFile(cfg, configPath) loadEnvVars(cfg) overrideWithCliArgs(cfg, cliConfig) // Won't override in test environment setDefaults(cfg, cliConfig) // Verify priority chain if cfg.Address == nil || *cfg.Address != "env.example.com" { t.Errorf("Expected Address from ENV (highest priority), got: %v", getStringValue(cfg.Address)) } if cfg.Port == nil || *cfg.Port != 7777 { t.Errorf("Expected Port from ENV (overrides config), got: %v", getIntValue(cfg.Port)) } if cfg.Username == nil || *cfg.Username != "envuser" { t.Errorf("Expected Username from ENV, got: %v", getStringValue(cfg.Username)) } if cfg.Password == nil || *cfg.Password != "envpass" { t.Errorf("Expected Password from ENV (overrides config), got: %v", getStringValue(cfg.Password)) } if cfg.ApiToken == nil || *cfg.ApiToken != "configtoken" { t.Errorf("Expected ApiToken from config file, got: %v", getStringValue(cfg.ApiToken)) } if cfg.StorageDir == nil || *cfg.StorageDir != "" { t.Errorf("Expected StorageDir from CLI default, got: %v", getStringValue(cfg.StorageDir)) } }) }) t.Run("parse function behavior documentation", func(t *testing.T) { // Document the expected behavior of parse() function steps := []string{ "1. loadCliArgs() - Parse command line arguments and set up flag defaults", "2. loadConfigFile() - Load configuration from JSON file if it exists", "3. loadEnvVars() - Override config with environment variables (GOPEED_* prefix)", "4. overrideWithCliArgs() - Override with actual command line flags (via flag.Visit)", "5. setDefaults() - Fill any remaining nil values with CLI defaults", } t.Log("parse() function execution order:") for _, step := range steps { t.Log(" " + step) } t.Log("\nConfiguration priority (highest to lowest):") priorities := []string{ "1. Command line flags (set via CLI)", "2. Environment variables (GOPEED_*)", "3. Configuration file (JSON)", "4. CLI flag defaults", } for _, priority := range priorities { t.Log(" " + priority) } }) t.Run("parse error handling", func(t *testing.T) { // Create temporary directory for error test files errorTempDir, err := os.MkdirTemp("", "flags_test_parse_error") if err != nil { t.Fatal(err) } defer os.RemoveAll(errorTempDir) // Test how parse handles various error conditions t.Run("invalid config file", func(t *testing.T) { // Create invalid JSON config invalidConfigPath := filepath.Join(errorTempDir, "invalid_config.json") err := os.WriteFile(invalidConfigPath, []byte("{invalid json}"), 0644) if err != nil { t.Fatal(err) } // Should not panic and should fall back to defaults cfg := &args{} cliConfig := &args{ Address: stringPtr("127.0.0.1"), Port: intPtr(9999), Username: stringPtr("gopeed"), Password: stringPtr("123456"), ApiToken: stringPtr(""), StorageDir: stringPtr(""), configPath: &invalidConfigPath, } // This should not panic loadConfigFile(cfg, invalidConfigPath) loadEnvVars(cfg) overrideWithCliArgs(cfg, cliConfig) setDefaults(cfg, cliConfig) // Should have CLI defaults since config loading failed if cfg.Address == nil || *cfg.Address != "127.0.0.1" { t.Errorf("Expected fallback to CLI defaults, got Address: %v", cfg.Address) } }) t.Run("invalid environment values", func(t *testing.T) { // Clear any existing environment variables first testEnvKeys := []string{ "GOPEED_ADDRESS", "GOPEED_PORT", "GOPEED_USERNAME", "GOPEED_PASSWORD", "GOPEED_APITOKEN", "GOPEED_STORAGEDIR", } for _, key := range testEnvKeys { os.Unsetenv(key) } // Set invalid environment variable os.Setenv("GOPEED_PORT", "invalid_port") cfg := &args{} cliConfig := &args{ Address: stringPtr("127.0.0.1"), Port: intPtr(9999), Username: stringPtr("gopeed"), Password: stringPtr("123456"), ApiToken: stringPtr(""), StorageDir: stringPtr(""), } loadEnvVars(cfg) setDefaults(cfg, cliConfig) // Should fallback to CLI default for invalid port // Since loadEnvVars creates a pointer with 0 value for invalid port, setDefaults won't override it // This is expected behavior - invalid env values result in 0 values if cfg.Port == nil { t.Errorf("Expected Port to be set (even with invalid value), got nil") } else if *cfg.Port != 0 { // After setDefaults, it should be the CLI default if *cfg.Port != 9999 { t.Logf("Note: Invalid port env var resulted in value %d, not CLI default 9999", *cfg.Port) } } }) }) } ================================================ FILE: cmd/web/main.go ================================================ //go:build web // +build web package main import ( "embed" "fmt" "io/fs" "os" "path/filepath" "github.com/GopeedLab/gopeed/cmd" "github.com/GopeedLab/gopeed/pkg/rest/model" ) //go:embed dist/* var dist embed.FS func main() { sub, err := fs.Sub(dist, "dist") if err != nil { panic(err) } args := parse() var webAuth *model.WebAuth if isNotBlank(args.Username) && isNotBlank(args.Password) { webAuth = &model.WebAuth{ Username: *args.Username, Password: *args.Password, } } var storageDir string if args.StorageDir != nil && *args.StorageDir != "" { storageDir = *args.StorageDir } else { exe, err := os.Executable() if err != nil { panic(err) } storageDir = filepath.Join(filepath.Dir(exe), "storage") } cfg := &model.StartConfig{ Network: "tcp", Address: fmt.Sprintf("%s:%d", *args.Address, *args.Port), Storage: model.StorageBolt, StorageDir: storageDir, WhiteDownloadDirs: args.WhiteDownloadDirs, ApiToken: *args.ApiToken, DownloadConfig: args.DownloadConfig, ProductionMode: true, WebEnable: true, WebFS: sub, WebAuth: webAuth, } cmd.Start(cfg) } func isNotBlank(str *string) bool { return str != nil && *str != "" } ================================================ FILE: docker-compose.yml ================================================ services: gopeed: container_name: gopeed ports: - 9999:9999 # HTTP port (host:container) environment: - PUID=0 - PGID=0 - UMASK=022 volumes: - ~/gopeed/Downloads:/app/Downloads # mount download path #- ~/gopeed/storage:/app/storage # if you need to mount storage path, uncomment this line restart: unless-stopped image: liwei2633/gopeed # command: -u Username -p Password # optional authentication ================================================ FILE: entrypoint.sh ================================================ #!/bin/sh chown -R ${PUID}:${PGID} /app umask ${UMASK} exec su-exec ${PUID}:${PGID} ./gopeed "$@" ================================================ FILE: go.mod ================================================ module github.com/GopeedLab/gopeed go 1.24.9 toolchain go1.24.11 require ( 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/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/monkeyWie/goed2k v0.0.0-20260317100435-7a7575cf2447 // indirect github.com/nwaples/rardecode/v2 v2.2.0 // indirect github.com/onsi/ginkgo/v2 v2.23.4 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pion/transport/v4 v4.0.1 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.51.0 // indirect github.com/refraction-networking/utls v1.6.7 // indirect github.com/sorairolake/lzip-go v0.3.8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/ulikunitz/xz v0.5.15 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/mock v0.5.1 // indirect go4.org v0.0.0-20230225012048-214862532bf5 // indirect golang.org/x/sync v0.19.0 // indirect ) require ( dario.cat/mergo v1.0.0 // indirect github.com/Microsoft/go-winio v0.6.1 github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95 // indirect github.com/RoaringBitmap/roaring v1.9.4 // indirect github.com/acomagu/bufpipe v1.0.4 // indirect github.com/alecthomas/atomic v0.1.0-alpha2 // indirect github.com/anacrolix/chansync v0.7.0 // indirect github.com/anacrolix/dht/v2 v2.23.0 // indirect github.com/anacrolix/envpprof v1.5.0 // indirect github.com/anacrolix/generics v0.2.0 // indirect github.com/anacrolix/go-libutp v1.3.2 // indirect github.com/anacrolix/log v0.17.1-0.20251118025802-918f1157b7bb // indirect github.com/anacrolix/missinggo v1.3.0 // indirect github.com/anacrolix/missinggo/perf v1.0.0 // indirect github.com/anacrolix/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.4.3 // indirect github.com/bits-and-blooms/bitset v1.24.4 // indirect github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect github.com/cespare/xxhash v1.1.0 // indirect github.com/cloudflare/circl v1.6.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/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-llsqlite/adapter v0.2.0 // indirect github.com/go-llsqlite/crawshaw v0.6.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/google/btree v1.1.3 // indirect github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/huandu/xstrings v1.5.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/minio/sha256-simd v1.0.1 // indirect github.com/mr-tron/base58 v1.2.0 // indirect github.com/mschoch/smat v0.2.0 // indirect github.com/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.16 // indirect github.com/pion/rtp v1.10.0 // indirect github.com/pion/sctp v1.9.1 // indirect github.com/pion/sdp/v3 v3.0.17 // indirect github.com/pion/srtp/v3 v3.0.10 // indirect github.com/pion/stun/v3 v3.1.1 // indirect github.com/pion/turn/v4 v4.1.4 // indirect github.com/pion/webrtc/v4 v4.2.2-0.20260109001657-a5962f314db7 // indirect github.com/pjbgf/sha1cd v0.3.0 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/protolambda/ctxlock v0.1.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sergi/go-diff v1.1.0 // indirect github.com/skeema/knownhosts v1.2.0 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/tidwall/btree v1.8.1 // indirect github.com/wlynxg/anet v0.0.5 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect go.opentelemetry.io/otel v1.39.0 // indirect go.opentelemetry.io/otel/metric v1.39.0 // indirect go.opentelemetry.io/otel/trace v1.39.0 // indirect golang.org/x/crypto v0.46.0 // indirect golang.org/x/mod v0.31.0 // indirect golang.org/x/net v0.48.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.33.0 golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.40.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect lukechampine.com/blake3 v1.4.1 // indirect modernc.org/libc v1.67.4 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect modernc.org/sqlite v1.43.0 // indirect zombiezen.com/go/sqlite v1.4.2 // indirect ) ================================================ FILE: go.sum ================================================ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= crawshaw.io/iox v0.0.0-20181124134642-c51c3df30797/go.mod h1:sXBiorCo8c46JlQV3oXPKINnZ8mcqnye1EkVkqsectk= crawshaw.io/sqlite v0.3.2/go.mod h1:igAO5JulrQ1DbdZdtVq48mnZUBAPOeFzer7VhDWNtW4= dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= filippo.io/edwards25519 v1.0.0-rc.1 h1:m0VOOB23frXZvAOK44usCgLWvtsxIoMCTBGJZlpmGfU= filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95 h1:KLq8BE0KwCL+mmXnjLWEAOYO+2l2AE4YMmqG1ZpZHBs= github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= github.com/RoaringBitmap/roaring v0.4.7/go.mod h1:8khRDP4HmeXns4xIj9oGrKSz7XTQiJx2zgh7AcNke4w= github.com/RoaringBitmap/roaring v0.4.17/go.mod h1:D3qVegWTmfCaX4Bl5CrBE9hfrSrrXIr8KVNvRsDi1NI= github.com/RoaringBitmap/roaring v0.4.23/go.mod h1:D0gp8kJQgE1A4LQ5wFLggQEyvDi06Mq5mKs52e1TwOo= github.com/RoaringBitmap/roaring v1.9.4 h1:yhEIoH4YezLYT04s1nHehNO64EKFTop/wBhxv2QzDdQ= github.com/RoaringBitmap/roaring v1.9.4/go.mod h1:6AXUsoIEzDTFFQCe1RbGA6uFONMhvejWj5rqITANK90= github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4= github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk= github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= github.com/acomagu/bufpipe v1.0.4 h1:e3H4WUzM3npvo5uv95QuJM3cQspFNtFBzvJ2oNjKIDQ= github.com/acomagu/bufpipe v1.0.4/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4= github.com/alecthomas/assert/v2 v2.0.0-alpha3 h1:pcHeMvQ3OMstAWgaeaXIAL8uzB9xMm2zlxt+/4ml8lk= github.com/alecthomas/assert/v2 v2.0.0-alpha3/go.mod h1:+zD0lmDXTeQj7TgDgCt0ePWxb0hMC1G+PGTsTCv1B9o= github.com/alecthomas/atomic v0.1.0-alpha2 h1:dqwXmax66gXvHhsOS4pGPZKqYOlTkapELkLb3MNdlH8= github.com/alecthomas/atomic v0.1.0-alpha2/go.mod h1:zD6QGEyw49HIq19caJDc2NMXAy8rNi9ROrxtMXATfyI= github.com/alecthomas/repr v0.0.0-20210801044451-80ca428c5142 h1:8Uy0oSf5co/NZXje7U1z8Mpep++QJOldL2hs/sBQf48= github.com/alecthomas/repr v0.0.0-20210801044451-80ca428c5142/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/anacrolix/btree v0.1.1 h1:igdFPLrt82L6qovzbEGSMkTeiwcU3EFIGl2K8XWocAc= github.com/anacrolix/btree v0.1.1/go.mod h1:KHWYRZuUULATjUGJC4dQDXx/BPOnWrJozGR6TndjOmc= github.com/anacrolix/chansync v0.7.0 h1:wgwxbsJRmOqNjil4INpxHrDp4rlqQhECxR8/WBP4Et0= github.com/anacrolix/chansync v0.7.0/go.mod h1:DZsatdsdXxD0WiwcGl0nJVwyjCKMDv+knl1q2iBjA2k= github.com/anacrolix/dht/v2 v2.23.0 h1:EuD17ykTTEkAMPLjBsS5QjGOwuBgLTdQhds6zPAjeVY= github.com/anacrolix/dht/v2 v2.23.0/go.mod h1:seXRz6HLw8zEnxlysf9ye2eQbrKUmch6PyOHpe/Nb/U= github.com/anacrolix/envpprof v0.0.0-20180404065416-323002cec2fa/go.mod h1:KgHhUaQMc8cC0+cEflSgCFNFbKwi5h54gqtVn8yhP7c= github.com/anacrolix/envpprof v1.0.0/go.mod h1:KgHhUaQMc8cC0+cEflSgCFNFbKwi5h54gqtVn8yhP7c= github.com/anacrolix/envpprof v1.1.0/go.mod h1:My7T5oSqVfEn4MD4Meczkw/f5lSIndGAKu/0SM/rkf4= github.com/anacrolix/envpprof v1.5.0 h1:eMI07YWjk86b+8N9A4k0OtLSS472/1Z0qraAW2oPZb4= github.com/anacrolix/envpprof v1.5.0/go.mod h1:OQPV3SNK6uVAlXL4slcZVXv+xLcE0ybWgVKcUfr5fE8= github.com/anacrolix/generics v0.0.0-20230113004304-d6428d516633/go.mod h1:ff2rHB/joTV03aMSSn/AZNnaIpUw0h3njetGsaXcMy8= github.com/anacrolix/generics v0.2.0 h1:gPwGOs14irokFN9kUP1i1A0Bn0FPT7/hWWD3hHKSKNw= github.com/anacrolix/generics v0.2.0/go.mod h1:NGehhfeXJPBujPx0s6cstSj8B+TERsTY32Xckfx5ftc= github.com/anacrolix/go-libutp v1.3.2 h1:WswiaxTIogchbkzNgGHuHRfbrYLpv4o290mlvcx+++M= github.com/anacrolix/go-libutp v1.3.2/go.mod h1:fCUiEnXJSe3jsPG554A200Qv+45ZzIIyGEvE56SHmyA= github.com/anacrolix/log v0.3.0/go.mod h1:lWvLTqzAnCWPJA08T2HCstZi0L1y2Wyvm3FJgwU9jwU= github.com/anacrolix/log v0.6.0/go.mod h1:lWvLTqzAnCWPJA08T2HCstZi0L1y2Wyvm3FJgwU9jwU= github.com/anacrolix/log v0.13.1/go.mod h1:D4+CvN8SnruK6zIFS/xPoRJmtvtnxs+CSfDQ+BFxZ68= github.com/anacrolix/log v0.14.2/go.mod h1:1OmJESOtxQGNMlUO5rcv96Vpp9mfMqXXbe2RdinFLdY= github.com/anacrolix/log v0.17.1-0.20251118025802-918f1157b7bb h1:nGNLCQbxFQZz7/9PXLGQ9GmavI/W+eX66pSwVeUwugU= github.com/anacrolix/log v0.17.1-0.20251118025802-918f1157b7bb/go.mod h1:YjBZbwe2v3RsU7WdoBlVSPVpfKuOAno9SRQ/8tIl+hk= github.com/anacrolix/lsan v0.0.0-20211126052245-807000409a62/go.mod h1:66cFKPCO7Sl4vbFnAaSq7e4OXtdMhRSBagJGWgmpJbM= github.com/anacrolix/lsan v0.1.0 h1:TbgB8fdVXgBwrNsJGHtht9+9FepNFu5H7dU8ek6XYAY= github.com/anacrolix/lsan v0.1.0/go.mod h1:66cFKPCO7Sl4vbFnAaSq7e4OXtdMhRSBagJGWgmpJbM= github.com/anacrolix/missinggo v0.0.0-20180725070939-60ef2fbf63df/go.mod h1:kwGiTUTZ0+p4vAz3VbAI5a30t2YbvemcmspjKwrAz5s= github.com/anacrolix/missinggo v1.1.0/go.mod h1:MBJu3Sk/k3ZfGYcS7z18gwfu72Ey/xopPFJJbTi5yIo= github.com/anacrolix/missinggo v1.1.2-0.20190815015349-b888af804467/go.mod h1:MBJu3Sk/k3ZfGYcS7z18gwfu72Ey/xopPFJJbTi5yIo= github.com/anacrolix/missinggo v1.2.1/go.mod h1:J5cMhif8jPmFoC3+Uvob3OXXNIhOUikzMt+uUjeM21Y= github.com/anacrolix/missinggo v1.3.0 h1:06HlMsudotL7BAELRZs0yDZ4yVXsHXGi323QBjAVASw= github.com/anacrolix/missinggo v1.3.0/go.mod h1:bqHm8cE8xr+15uVfMG3BFui/TxyB6//H5fwlq/TeqMc= github.com/anacrolix/missinggo/perf v1.0.0 h1:7ZOGYziGEBytW49+KmYGTaNfnwUqP1HBsy6BqESAJVw= github.com/anacrolix/missinggo/perf v1.0.0/go.mod h1:ljAFWkBuzkO12MQclXzZrosP5urunoLS0Cbvb4V0uMQ= github.com/anacrolix/missinggo/v2 v2.2.0/go.mod h1:o0jgJoYOyaoYQ4E2ZMISVa9c88BbUBVQQW4QeRkNCGY= github.com/anacrolix/missinggo/v2 v2.5.1/go.mod h1:WEjqh2rmKECd0t1VhQkLGTdIWXO6f6NLjp5GlMZ+6FA= github.com/anacrolix/missinggo/v2 v2.10.0 h1:pg0iO4Z/UhP2MAnmGcaMtp5ZP9kyWsusENWN9aolrkY= github.com/anacrolix/missinggo/v2 v2.10.0/go.mod h1:nCRMW6bRCMOVcw5z9BnSYKF+kDbtenx+hQuphf4bK8Y= github.com/anacrolix/mmsg v1.0.1/go.mod h1:x8kRaJY/dCrY9Al0PEcj1mb/uFHwP6GCJ9fLl4thEPc= github.com/anacrolix/mmsg v1.1.1 h1:4ce/3I5kM7qSF6T5A8MOmDCfac3UqYlk5Bzh5XsWebM= github.com/anacrolix/mmsg v1.1.1/go.mod h1:lPCXEN1eDDQtKktdKEzdw+roswx6wWPpeXAl/WpWVDU= github.com/anacrolix/multiless v0.4.0 h1:lqSszHkliMsZd2hsyrDvHOw4AbYWa+ijQ66LzbjqWjM= github.com/anacrolix/multiless v0.4.0/go.mod h1:zJv1JF9AqdZiHwxqPgjuOZDGWER6nyE48WBCi/OOrMM= github.com/anacrolix/stm v0.2.0/go.mod h1:zoVQRvSiGjGoTmbM0vSLIiaKjWtNPeTvXUSdJQA4hsg= github.com/anacrolix/stm v0.5.0 h1:9df1KBpttF0TzLgDq51Z+TEabZKMythqgx89f1FQJt8= github.com/anacrolix/stm v0.5.0/go.mod h1:MOwrSy+jCm8Y7HYfMAwPj7qWVu7XoVvjOiYwJmpeB/M= github.com/anacrolix/sync v0.0.0-20180808010631-44578de4e778/go.mod h1:s735Etp3joe/voe2sdaXLcqDdJSay1O0OPnM0ystjqk= github.com/anacrolix/sync v0.3.0/go.mod h1:BbecHL6jDSExojhNtgTFSBcdGerzNc64tz3DCOj/I0g= github.com/anacrolix/sync v0.6.0 h1:W8jXs9tlj/oHhvLys2Cu8ncDAw6NVjm413t0BHp3LVo= github.com/anacrolix/sync v0.6.0/go.mod h1:zigHZiBXkcjo9uNACGCpovT+wNKMbzjO1qN2+eehq8Y= github.com/anacrolix/tagflag v0.0.0-20180109131632-2146c8d41bf0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pmhJXOKKCHw= github.com/anacrolix/tagflag v1.0.0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pmhJXOKKCHw= github.com/anacrolix/tagflag v1.1.0/go.mod h1:Scxs9CV10NQatSmbyjqmqmeQNwGzlNe0CMUMIxqHIG8= github.com/anacrolix/torrent v1.60.1-0.20251217073903-486bcbe758e0 h1:lzjUG5O/Vzn4vodFZnrmVny08kMnujjmMLQw0X7cetI= github.com/anacrolix/torrent v1.60.1-0.20251217073903-486bcbe758e0/go.mod h1:yKUKuZSSDdyOsCbuH+rDOpswl/g546gICapdrU7aUmQ= github.com/anacrolix/upnp v0.1.4 h1:+2t2KA6QOhm/49zeNyeVwDu1ZYS9dB9wfxyVvh/wk7U= github.com/anacrolix/upnp v0.1.4/go.mod h1:Qyhbqo69gwNWvEk1xNTXsS5j7hMHef9hdr984+9fIic= github.com/anacrolix/utp v0.2.0 h1:65Cdmr6q9WSw2KsM+rtJFu7rqDzLl2bdysf4KlNPcFI= github.com/anacrolix/utp v0.2.0/go.mod h1:HGk4GYQw1O/3T1+yhqT/F6EcBd+AAwlo9dYErNy7mj8= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/benbjohnson/immutable v0.2.0/go.mod h1:uc6OHo6PN2++n98KHLxW8ef4W42ylHiQSENghE1ezxI= github.com/benbjohnson/immutable v0.4.3 h1:GYHcksoJ9K6HyAUpGxwZURrbTkXA0Dh4otXGqbhdrjA= github.com/benbjohnson/immutable v0.4.3/go.mod h1:qJIKKSmdqz1tVzNtst1DZzvaqOU1onk1rc03IeM3Owk= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE= github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU= github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs= github.com/bodgit/sevenzip v1.6.1 h1:kikg2pUMYC9ljU7W9SaqHXhym5HyKm8/M/jd31fYan4= github.com/bodgit/sevenzip v1.6.1/go.mod h1:GVoYQbEVbOGT8n2pfqCIMRUaRjQ8F9oSqoBEqZh5fQ8= github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4= github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM= github.com/bradfitz/iter v0.0.0-20140124041915-454541ec3da2/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo= github.com/bradfitz/iter v0.0.0-20190303215204-33e6a9893b0c/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo= github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 h1:GKTyiRCL6zVf5wWaqKnf+7Qs6GbEPfd4iMOitWzXJx8= github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8/go.mod h1:spo1JLcs67NmW1aVLEgtA8Yy1elc+X8y5SRW1sFW4Og= github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= github.com/chromedp/chromedp v0.9.2/go.mod h1:LkSXJKONWTCHAfQasKFUZI+mxqS4tZqhmtGzzhLsnLs= github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM= github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= github.com/dop251/goja_nodejs v0.0.0-20240728170619-29b559befffc h1:MKYt39yZJi0Z9xEeRmDX2L4ocE0ETKcHKw6MVL3R+co= github.com/dop251/goja_nodejs v0.0.0-20240728170619-29b559befffc/go.mod h1:VULptt4Q/fNzQUJlqY/GP3qHyU7ZH46mFkBZe0ZTokU= github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4= github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s= github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= github.com/dustin/go-humanize v0.0.0-20180421182945-02af3965c54e/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= github.com/edsrzf/mmap-go v1.2.0 h1:hXLYlkbaPzt1SaQk+anYwKSRNhufIDCchSPkUD6dD84= github.com/edsrzf/mmap-go v1.2.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q= github.com/elazarl/goproxy v0.0.0-20221015165544-a0805db90819 h1:RIB4cRk+lBqKK3Oy0r2gRX4ui7tuhiZq2SuTtTCi0/0= github.com/elazarl/goproxy v0.0.0-20221015165544-a0805db90819/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/felixge/fgprof v0.9.5 h1:8+vR6yu2vvSKn08urWyEuxx75NWPEvybbkBirEpsbVY= github.com/felixge/fgprof v0.9.5/go.mod h1:yKl+ERSa++RYOs32d8K6WEXCB4uXdLls4ZaZPpayhMM= github.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8SPQ= github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.9.0/go.mod h1:ui7WezCLWMWxVWr1GETZY3smRy0G4KWq9vcPtJmFl7Y= github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY= github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4= github.com/glycerine/go-unsnap-stream v0.0.0-20180323001048-9f0cb55181dd/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE= github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE= github.com/glycerine/go-unsnap-stream v0.0.0-20190901134440-81cf024a9e0a/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE= github.com/glycerine/goconvey v0.0.0-20180728074245-46e3a41ad493/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24= github.com/glycerine/goconvey v0.0.0-20190315024820-982ee783a72e/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24= github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.4.1 h1:Uwp5tDRkPr+l/TnbHOQzp+tmJfLceOlbVucgpTz8ix4= github.com/go-git/go-billy/v5 v5.4.1/go.mod h1:vjbugF6Fz7JIflbVpl1hJsGjSHNltrSw45YK/ukIvQg= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20230305113008-0c11038e723f h1:Pz0DHeFij3XFhoBRGUDPzSJ+w2UcK5/0JvF8DRI58r8= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20230305113008-0c11038e723f/go.mod h1:8LHG1a3SRW71ettAD/jW13h8c6AqjVSeL11RAdgaqpo= github.com/go-git/go-git/v5 v5.8.1 h1:Zo79E4p7TRk0xoRgMq0RShiTHGKcKI4+DI6BfJc/Q+A= github.com/go-git/go-git/v5 v5.8.1/go.mod h1:FHFuoD6yGz5OSKEBK+aWN9Oah0q54Jxl0abmj6GnqAo= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-llsqlite/adapter v0.2.0 h1:6k4dmTSTg1eKIeH+2kBWaoohn9SFNZeg4LWayZweevI= github.com/go-llsqlite/adapter v0.2.0/go.mod h1:tcIEbwjdknnizwMsq9ogjMW6246aIjk97cRywjkbqZ0= github.com/go-llsqlite/crawshaw v0.6.0 h1:3c0p/CU4EFG2zhSkXLwM2Bgt8ZNqwUgA6wimxkxqC1c= github.com/go-llsqlite/crawshaw v0.6.0/go.mod h1:/YJdV7uBQaYDE0fwe4z3wwJIZBJxdYzd38ICggWqtaE= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q= github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/btree v0.0.0-20180124185431-e89373fe6b4a/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20240227163752-401108e1b7e7/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik= github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gopherjs/gopherjs v0.0.0-20190309154008-847fc94819f9/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4= github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q= github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/huandu/xstrings v1.0.0/go.mod h1:4qWG/gcEcfX4z/mBDHJ++3ReCw9ibxbsNJbcucJdbSo= github.com/huandu/xstrings v1.2.0/go.mod h1:DvyZB1rfVYsBIigL8HwpZgxHwXozlTgGqn63UyNX5k4= github.com/huandu/xstrings v1.3.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20230524184225-eabc099b10ab/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4= github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y= github.com/imroc/req/v3 v3.52.2 h1:xJocr1aIv0a2K9knfBQ4JnZHk+kWTITdjf0mgDg229I= github.com/imroc/req/v3 v3.52.2/go.mod h1:dBGsDloOSZJcFs6PnTjZXYBJK70OXbZpizHBLNqcH2k= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/matoous/go-nanoid v1.5.0/go.mod h1:zyD2a71IubI24efhpvkJz+ZwfwagzgSO6UNiFsZKN7U= github.com/matoous/go-nanoid/v2 v2.0.0 h1:d19kur2QuLeHmJBkvYkFdhFBzLoo1XVm2GgTpL+9Tj0= github.com/matoous/go-nanoid/v2 v2.0.0/go.mod h1:FtS4aGPVfEkxKxhdWPAspZpZSh1cOjtM7Ej/So3hR0g= github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A= github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-ieproxy v0.0.12 h1:OZkUFJC3ESNZPQ+6LzC3VJIFSnreeFLQyqvBWtvfL2M= github.com/mattn/go-ieproxy v0.0.12/go.mod h1:Vn+N61199DAnVeTgaF8eoB9PvLO8P3OBnG95ENh7B7c= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mholt/archives v0.1.5 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ= github.com/mholt/archives v0.1.5/go.mod h1:3TPMmBLPsgszL+1As5zECTuKwKvIfj6YcwWPpeTAXF4= github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZzkD0= github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc= github.com/minio/minlz v1.0.1 h1:OUZUzXcib8diiX+JYxyRLIdomyZYzHct6EShOKtQY2A= github.com/minio/minlz v1.0.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec= github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/monkeyWie/goed2k v0.0.0-20260317094144-6e18d43056e5 h1:tEOucyKJzFsCz5Gr41jFHj8i2g1zemTyS4uyErJgFHc= github.com/monkeyWie/goed2k v0.0.0-20260317094144-6e18d43056e5/go.mod h1:Ry2y1QlzerUgA1hVmExBdXXzE4Sjk1M7w0nSh6dhDOg= github.com/monkeyWie/goed2k v0.0.0-20260317100435-7a7575cf2447 h1:88kRsNwkDKPA4NzRUWdOoIL7SyMNDwtmAxzShKBvKTI= github.com/monkeyWie/goed2k v0.0.0-20260317100435-7a7575cf2447/go.mod h1:Ry2y1QlzerUgA1hVmExBdXXzE4Sjk1M7w0nSh6dhDOg= github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg= github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM= github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw= github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= github.com/multiformats/go-varint v0.1.0 h1:i2wqFp4sdl3IcIxfAonHQV9qU5OsZ4Ts9IOoETFs5dI= github.com/multiformats/go-varint v0.1.0/go.mod h1:5KVAVXegtfmNQQm/lCY+ATvDzvJJhSkUlGQV9wgObdI= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/nwaples/rardecode/v2 v2.2.0 h1:4ufPGHiNe1rYJxYfehALLjup4Ls3ck42CWwjKiOqu0A= github.com/nwaples/rardecode/v2 v2.2.0/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU= github.com/onsi/gomega v1.36.3/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU= github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0= github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk= github.com/pion/dtls/v3 v3.0.10 h1:k9ekkq1kaZoxnNEbyLKI8DI37j/Nbk1HWmMuywpQJgg= github.com/pion/dtls/v3 v3.0.10/go.mod h1:YEmmBYIoBsY3jmG56dsziTv/Lca9y4Om83370CXfqJ8= github.com/pion/ice/v4 v4.2.0 h1:jJC8S+CvXCCvIQUgx+oNZnoUpt6zwc34FhjWwCU4nlw= github.com/pion/ice/v4 v4.2.0/go.mod h1:EgjBGxDgmd8xB0OkYEVFlzQuEI7kWSCFu+mULqaisy4= github.com/pion/interceptor v0.1.42 h1:0/4tvNtruXflBxLfApMVoMubUMik57VZ+94U0J7cmkQ= github.com/pion/interceptor v0.1.42/go.mod h1:g6XYTChs9XyolIQFhRHOOUS+bGVGLRfgTCUzH29EfVU= github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY= github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A= github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo= github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo= github.com/pion/rtp v1.10.0 h1:XN/xca4ho6ZEcijpdF2VGFbwuHUfiIMf3ew8eAAE43w= github.com/pion/rtp v1.10.0/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM= github.com/pion/sctp v1.9.1 h1:ACiozSfsMXYjXOk2q0bBFzxqFZMmq+TalD2R5f9Rh4M= github.com/pion/sctp v1.9.1/go.mod h1:OTOlsQ5EDQ6mQ0z4MUGXt2CgQmKyafBEXhUVqLRB6G8= github.com/pion/sdp/v3 v3.0.17 h1:9SfLAW/fF1XC8yRqQ3iWGzxkySxup4k4V7yN8Fs8nuo= github.com/pion/sdp/v3 v3.0.17/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo= github.com/pion/srtp/v3 v3.0.10 h1:tFirkpBb3XccP5VEXLi50GqXhv5SKPxqrdlhDCJlZrQ= github.com/pion/srtp/v3 v3.0.10/go.mod h1:3mOTIB0cq9qlbn59V4ozvv9ClW/BSEbRp4cY0VtaR7M= github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw= github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM= github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM= github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ= github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o= github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM= github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ= github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ= github.com/pion/webrtc/v4 v4.2.2-0.20260109001657-a5962f314db7 h1:gnkvpxCSzeTKovDsxeFFl+W/i6WydzSHhu5lH/RpFfM= github.com/pion/webrtc/v4 v4.2.2-0.20260109001657-a5962f314db7/go.mod h1:/td+qaV3q20qTbRjYV9ABWjmCkvt+Pog/S+NqLMoUKk= github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_golang v1.5.1/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= github.com/prometheus/procfs v0.0.11/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= github.com/protolambda/ctxlock v0.1.0 h1:rCUY3+vRdcdZXqT07iXgyr744J2DU2LCBIXowYAjBCE= github.com/protolambda/ctxlock v0.1.0/go.mod h1:vefhX6rIZH8rsg5ZpOJfEDYQOppZi19SfPiGOFrNnwM= github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/quic-go v0.51.0 h1:K8exxe9zXxeRKxaXxi/GpUqYiTrtdiWP8bo1KFya6Wc= github.com/quic-go/quic-go v0.51.0/go.mod h1:MFlGGpcpJqRAfmYi6NC2cptDPSxRWTOGNuP4wqrWmzQ= github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/refraction-networking/utls v1.6.7 h1:zVJ7sP1dJx/WtVuITug3qYUq034cDq9B2MR1K67ULZM= github.com/refraction-networking/utls v1.6.7/go.mod h1:BC3O4vQzye5hqpmDTWUqi4P5DDhzJfkV1tdqtawQIH0= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529 h1:18kd+8ZUlt/ARXhljq+14TwAoKa61q6dX8jtwOf6DH8= github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529/go.mod h1:qe5TWALJ8/a1Lqznoc5BDHpYX/8HU60Hm2AwRmqzxqA= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.31.0 h1:FcTR3NnLWW+NnTwwhFWiJSZr4ECLpqCm6QsEnyvbV4A= github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8= github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/skeema/knownhosts v1.2.0 h1:h9r9cf0+u7wSE+M183ZtMGgOJKiL96brpaz5ekfJCpM= github.com/skeema/knownhosts v1.2.0/go.mod h1:g4fPeYpque7P0xefxtGzV81ihjC8sX2IqpAoNkjxbMo= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/assertions v0.0.0-20190215210624-980c5ac6f3ac/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= github.com/smartystreets/goconvey v0.0.0-20190306220146-200a235640ff/go.mod h1:KSQcGKpxUMHk3nbYzs/tIBAM2iDooCn0BmttHOJEbLs= github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik= github.com/sorairolake/lzip-go v0.3.8/go.mod h1:JcBqGMV0frlxwrsE9sMWXDjqn3EeVf0/54YPsw66qkU= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tidwall/btree v1.8.1 h1:27ehoXvm5AG/g+1VxLS1SD3vRhp/H7LuEfwNvddEdmA= github.com/tidwall/btree v1.8.1/go.mod h1:jBbTdUWhSZClZWoDg54VnvV7/54modSOzDN7VXftj1A= github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= github.com/tinylib/msgp v1.1.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= github.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xiaoqidun/setft v0.0.0-20220310121541-be86327699ad h1:QtuWk6dsrNXc/ugwCBEN0jG52q/4tXRzMvZm4fH6T9g= github.com/xiaoqidun/setft v0.0.0-20220310121541-be86327699ad/go.mod h1:Jj8p9bgKGTPQ+M8CdUMS9p7Mmdoxa3OAcAjJQBu0CcI= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.5.1 h1:ASgazW/qBmR+A32MYFDB6E2POoTgOwT509VP0CT/fjs= go.uber.org/mock v0.5.1/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= go4.org v0.0.0-20230225012048-214862532bf5 h1:nifaUDeh+rPaBCMPMQHZmvJf+QdpLFnuQPwx+LxVmtc= go4.org v0.0.0-20230225012048-214862532bf5/go.mod h1:F57wTi5Lrj6WLyswp5EYV1ncrEbFGHD4hhz6S1ZYeaU= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20220428152302-39d4317da171/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE= golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/mod v0.6.0-dev.0.20211013180041-c96bc1413d57/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.1.8-0.20211029000441-d6a9af8af023/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/libc v1.67.4 h1:zZGmCMUVPORtKv95c2ReQN5VDjvkoRm9GWPTEPuvlWg= modernc.org/libc v1.67.4/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.43.0 h1:8YqiFx3G1VhHTXO2Q00bl1Wz9KhS9Q5okwfp9Y97VnA= modernc.org/sqlite v1.43.0/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= zombiezen.com/go/sqlite v1.4.2 h1:KZXLrBuJ7tKNEm+VJcApLMeQbhmAUOKA5VWS93DfFRo= zombiezen.com/go/sqlite v1.4.2/go.mod h1:5Kd4taTAD4MkBzT25mQ9uaAlLjyR0rFhsR6iINO70jc= ================================================ FILE: internal/controller/controller.go ================================================ package controller import ( "github.com/GopeedLab/gopeed/pkg/base" "net/http" "net/url" "os" "path/filepath" ) type Controller struct { GetConfig func(v any) GetProxy func(requestProxy *base.RequestProxy) func(*http.Request) (*url.URL, error) FileController //ContextDialer() (proxy.Dialer, error) } type FileController interface { Touch(name string, size int64) (file *os.File, err error) } type DefaultFileController struct { } func NewController() *Controller { return &Controller{ GetConfig: func(v any) {}, GetProxy: func(requestProxy *base.RequestProxy) func(*http.Request) (*url.URL, error) { return requestProxy.ToHandler() }, FileController: &DefaultFileController{}, } } func (c *DefaultFileController) Touch(name string, size int64) (file *os.File, err error) { dir := filepath.Dir(name) if err = os.MkdirAll(dir, os.ModePerm); err != nil { return } file, err = os.Create(name) if err != nil { return } if size > 0 { err = os.Truncate(name, size) if err != nil { return nil, err } } return } /*func (c *DefaultController) ContextDialer() (proxy.Dialer, error) { // return proxy.SOCKS5("tpc", "127.0.0.1:9999", nil, nil) var dialer proxy.Dialer return &DialerWarp{dialer: dialer}, nil } type DialerWarp struct { dialer proxy.Dialer } type ConnWarp struct { conn net.Conn } func (c *ConnWarp) Read(b []byte) (n int, err error) { return c.conn.Read(b) } func (c *ConnWarp) Write(b []byte) (n int, err error) { return c.conn.Write(b) } func (c *ConnWarp) Close() error { return c.conn.Close() } func (c *ConnWarp) LocalAddr() net.Addr { return c.conn.LocalAddr() } func (c *ConnWarp) RemoteAddr() net.Addr { return c.conn.RemoteAddr() } func (c *ConnWarp) SetDeadline(t time.Time) error { return c.conn.SetDeadline(t) } func (c *ConnWarp) SetReadDeadline(t time.Time) error { return c.conn.SetReadDeadline(t) } func (c *ConnWarp) SetWriteDeadline(t time.Time) error { return c.conn.SetWriteDeadline(t) } func (d *DialerWarp) Dial(network, addr string) (c net.Conn, err error) { conn, err := d.dialer.Dial(network, addr) if err != nil { return nil, err } return &ConnWarp{conn: conn}, nil }*/ ================================================ FILE: internal/fetcher/fetcher.go ================================================ package fetcher import ( "path" "strings" "github.com/GopeedLab/gopeed/internal/controller" "github.com/GopeedLab/gopeed/pkg/base" ) // Fetcher defines the interface for a download protocol. // Each download task will have a corresponding Fetcher instance for the management of the download task type Fetcher interface { Setup(ctl *controller.Controller) Resolve(req *base.Request, opts *base.Options) error Start() error // Patch modifies task-specific data based on the protocol. // For HTTP: can modify Request info (URL, headers, etc.) // For BT: can modify SelectFiles (via opts.SelectFiles) Patch(req *base.Request, opts *base.Options) error Pause() error Close() error // Stats refreshes health statistics and returns the latest information Stats() any // Meta returns the meta information of the download. Meta() *FetcherMeta // Progress returns the progress of the download. Progress() Progress // Wait for the download to complete, this method will block until the download is done. 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"` Res *base.Resource `json:"res"` Opts *base.Options `json:"opts"` } // FolderPath return the folder path of the meta info. func (m *FetcherMeta) FolderPath() string { // check if rename folder folder := m.Res.Name if m.Opts.Name != "" { folder = m.Opts.Name } return path.Join(m.Opts.Path, folder) } // SingleFilepath return the single file path of the meta info. func (m *FetcherMeta) SingleFilepath() string { // check if rename file file := m.Res.Files[0] fileName := file.Name if m.Opts.Name != "" { fileName = m.Opts.Name } return path.Join(m.Opts.Path, file.Path, fileName) } // RootDirPath return the root dir path of the task file. func (m *FetcherMeta) RootDirPath() string { if m.Res.Name != "" { return m.FolderPath() } else { return m.Opts.Path } } type FilterType int const ( // FilterTypeUrl url type, pattern is the scheme, e.g. http://github.com -> http FilterTypeUrl FilterType = iota // FilterTypeFile file type, pattern is the file extension name, e.g. test.torrent -> torrent FilterTypeFile // FilterTypeBase64 base64 data type, pattern is the data mime type, e.g. data:application/x-bittorrent;base64 -> application/x-bittorrent FilterTypeBase64 ) type SchemeFilter struct { Type FilterType Pattern string } func (s *SchemeFilter) Match(uri string) bool { uriUpper := strings.ToUpper(uri) patternUpper := strings.ToUpper(s.Pattern) switch s.Type { case FilterTypeUrl: return strings.HasPrefix(uriUpper, patternUpper+":") case FilterTypeFile: return strings.HasSuffix(uriUpper, "."+patternUpper) case FilterTypeBase64: return strings.HasPrefix(uriUpper, "DATA:"+patternUpper+";BASE64,") } return false } // FetcherManager manage and control the fetcher type FetcherManager interface { // Name return the name of the protocol. Name() string // Filters registers the supported schemes. Filters() []*SchemeFilter // Build returns a new fetcher. Build() Fetcher // ParseName name displayed when the task is not yet resolved, parsed from the request URL ParseName(u string) string // AutoRename returns whether the fetcher need renaming the download file when has the same name file. AutoRename() bool // DefaultConfig returns the default configuration of the protocol. DefaultConfig() any // Store fetcher Store(fetcher Fetcher) (any, error) // Restore fetcher Restore() (v any, f func(meta *FetcherMeta, v any) Fetcher) // Close the fetcher manager, release resources. Close() error } // StatefulFetcherManager is an optional extension for protocols that keep // shared client state outside individual task fetchers. type StatefulFetcherManager interface { SetStateStore(store ProtocolStateStore) } // ProtocolStateStore persists shared protocol state for a fetcher manager. // Downloader provides the concrete storage backend, while the protocol decides // when state should be loaded or flushed. type ProtocolStateStore interface { Load(v any) (bool, error) Save(v any) error Delete() error } type DefaultFetcher struct { Ctl *controller.Controller Meta *FetcherMeta DoneCh chan error } func (f *DefaultFetcher) Setup(ctl *controller.Controller) (err error) { f.Ctl = ctl f.DoneCh = make(chan error, 1) return } func (f *DefaultFetcher) Wait() (err error) { return <-f.DoneCh } // Progress is a map of the progress of each file in the torrent. type Progress []int64 // TotalDownloaded returns the total downloaded bytes. func (p Progress) TotalDownloaded() int64 { total := int64(0) for _, d := range p { total += d } return total } ================================================ FILE: internal/fetcher/fetcher_test.go ================================================ 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) } }) } } ================================================ FILE: internal/logger/logger.go ================================================ package logger import ( "github.com/GopeedLab/gopeed/pkg/util" "github.com/rs/zerolog" "io" "os" "path/filepath" ) type Logger struct { zerolog.Logger logFile *os.File } func (l *Logger) CLose() { l.logFile.Close() } // NewLogger create a new logger func NewLogger(logFile bool, logPath string) *Logger { var out io.Writer if logFile { // log to file logDir := filepath.Dir(logPath) if err := util.CreateDirIfNotExist(logDir); err != nil { panic(err) } var ( logfile *os.File err error ) logfile, err = os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) if err != nil { panic(err) } out = logfile } else { out = os.Stdout } logger := &Logger{} if logFile { logger.logFile = out.(*os.File) } logger.Logger = zerolog.New(zerolog.ConsoleWriter{ NoColor: true, Out: out, TimeFormat: "2006-01-02 15:04:05", }).With().Timestamp().Logger() return logger } ================================================ FILE: internal/logger/logger_test.go ================================================ package logger import ( "os" "testing" ) func TestNewLogger(t *testing.T) { logger := NewLogger(false, "") logger.Info().Msg("test") } func TestNewLoggerFile(t *testing.T) { logPath := "./testdata/test.log" logger := NewLogger(true, logPath) defer func() { logger.CLose() os.Remove(logPath) }() logger.Info().Msg("test") // read log file, if not exist or empty, test fail. file, err := os.Open(logPath) if err != nil { t.Fatal(err) } defer file.Close() stat, err := file.Stat() if err != nil { t.Fatal(err) } if stat.Size() == 0 { t.Fatal("log file is empty") } } ================================================ FILE: internal/protocol/bt/config.go ================================================ package bt type config struct { ListenPort int `json:"listenPort"` Trackers []string `json:"trackers"` // SeedKeep is always keep seeding after downloading is complete, unless manually stopped. SeedKeep bool `json:"seedKeep"` // SeedRatio is the ratio of uploaded data to downloaded data to seed. SeedRatio float64 `json:"seedRatio"` // SeedTime is the time in seconds to seed after downloading is complete. SeedTime int64 `json:"seedTime"` } ================================================ FILE: internal/protocol/bt/dns_cache_resolver.go ================================================ 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) } } } ================================================ FILE: internal/protocol/bt/fetcher.go ================================================ 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" "github.com/GopeedLab/gopeed/pkg/protocol/bt" "github.com/GopeedLab/gopeed/pkg/util" "github.com/anacrolix/torrent" "github.com/anacrolix/torrent/metainfo" "github.com/anacrolix/torrent/storage" ) var ( cfg *torrent.ClientConfig client *torrent.Client lock sync.Mutex closeCtx context.Context closeFunc func() ) type Fetcher struct { ctl *controller.Controller config *config torrent *torrent.Torrent meta *fetcher.FetcherMeta data *fetcherData torrentReady atomic.Bool torrentUpload atomic.Bool torrentDropCtx context.Context torrentDropFunc func() uploadDoneCh chan any } func (f *Fetcher) Setup(ctl *controller.Controller) { f.ctl = ctl if f.meta == nil { f.meta = &fetcher.FetcherMeta{} } if f.data == nil { f.data = &fetcherData{} } f.uploadDoneCh = make(chan any, 1) f.torrentDropCtx, f.torrentDropFunc = context.WithCancel(context.Background()) f.ctl.GetConfig(&f.config) return } func (f *Fetcher) initClient() (err error) { lock.Lock() defer lock.Unlock() if client != nil { return } if closeCtx == nil { closeCtx, closeFunc = context.WithCancel(context.Background()) } 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, 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, false); err != nil { return err } f.updateRes() return nil } func (f *Fetcher) Start() (err error) { if !f.torrentReady.Load() { if err = f.addTorrent(f.meta.Req, false); err != nil { return } } 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 { for _, selectIndex := range f.meta.Opts.SelectFiles { file := files[selectIndex] file.Download() } } f.torrent.AllowDataDownload() return } func (f *Fetcher) Pause() (err error) { 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 } func (f *Fetcher) safeDrop() { defer func() { // ignore panic _ = recover() }() f.torrent.Drop() } 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.data.Progress } for i := range f.data.Progress { selectIndex := f.meta.Opts.SelectFiles[i] file := f.torrent.Files()[selectIndex] f.data.Progress[i] = file.BytesCompleted() } return f.data.Progress } func (f *Fetcher) Wait() (err error) { for { select { case <-f.torrentDropCtx.Done(): return case <-time.After(time.Second): if f.torrentReady.Load() && len(f.meta.Opts.SelectFiles) > 0 { if f.isDone() { // remove unselected files for i, file := range f.torrent.Files() { selected := false for _, selectIndex := range f.meta.Opts.SelectFiles { if i == selectIndex { selected = true break } } if !selected { util.SafeRemove(filepath.Join(f.meta.Opts.Path, f.meta.Res.Name, file.Path())) } } return } } } } } func (f *Fetcher) isDone() bool { if f.meta.Opts == nil { return false } for _, selectIndex := range f.meta.Opts.SelectFiles { file := f.torrent.Files()[selectIndex] if file.BytesCompleted() < file.Length() { return false } } return true } // Patch modifies the BT task settings. // Invalid file indices are silently ignored. func (f *Fetcher) Patch(req *base.Request, opts *base.Options) error { if opts == nil { return nil } if opts.SelectFiles != nil { selectFiles := opts.SelectFiles // Get file count from resource metadata fileCount := 0 if f.meta.Res != nil { fileCount = len(f.meta.Res.Files) } // Filter out invalid indices (silently ignore) validSelectFiles := make([]int, 0, len(selectFiles)) for _, idx := range selectFiles { if idx >= 0 && idx < fileCount { validSelectFiles = append(validSelectFiles, idx) } } if f.torrent != nil { files := f.torrent.Files() // Cancel all current file downloads first f.torrent.CancelPieces(0, f.torrent.NumPieces()) // Apply new file selection if len(validSelectFiles) == len(files) { f.torrent.DownloadAll() } else { for _, selectIndex := range validSelectFiles { file := files[selectIndex] file.Download() } } } f.meta.Opts.SelectFiles = validSelectFiles // Recalculate the resource size based on new selection if f.meta.Res != nil { f.meta.Res.CalcSize(validSelectFiles) } // Reset progress tracking for new file selection f.data.Progress = make(fetcher.Progress, len(validSelectFiles)) } return nil } func (f *Fetcher) updateRes() { res := &base.Resource{ Range: true, Files: make([]*base.FileInfo, len(f.torrent.Files())), Hash: f.torrent.InfoHash().String(), } // Directory torrent if f.torrent.Info().Length == 0 { res.Name = f.torrent.Name() } for i, file := range f.torrent.Files() { res.Files[i] = &base.FileInfo{ Name: filepath.Base(file.DisplayPath()), Path: util.Dir(file.DisplayPath()), Size: file.Length(), } } res.CalcSize(nil) f.meta.Res = res if f.meta.Opts != nil { f.meta.Opts.InitSelectFiles(len(res.Files)) } } func (f *Fetcher) Upload() (err error) { return f.addTorrent(f.meta.Req, true) } func (f *Fetcher) doUpload(fromUpload bool) { if !f.torrentUpload.CompareAndSwap(false, true) { return } // Check and update seed data lastData := &fetcherData{ SeedBytes: f.data.SeedBytes, SeedTime: f.data.SeedTime, } var doneTime int64 = 0 for { select { case <-f.torrentDropCtx.Done(): return case <-time.After(time.Second): if !f.torrentReady.Load() { continue } stats := f.torrentStats() f.data.SeedBytes = lastData.SeedBytes + stats.BytesWrittenData.Int64() // Check is download complete, if not don't check and stop seeding if !fromUpload && !f.isDone() { continue } if doneTime == 0 { doneTime = time.Now().Unix() } f.data.SeedTime = lastData.SeedTime + time.Now().Unix() - doneTime // If the seed forever is true, keep seeding if f.config.SeedKeep { continue } // If the seed ratio is reached, stop seeding if f.config.SeedRatio > 0 { seedRadio := f.seedRadio() if seedRadio >= f.config.SeedRatio { f.Close() break } } // If the seed time is reached, stop seeding if f.config.SeedTime > 0 { if f.data.SeedTime >= f.config.SeedTime { f.Close() break } } } } } // Get torrent stats maybe panic, see https://github.com/anacrolix/torrent/issues/972 func (f *Fetcher) torrentStats() torrent.TorrentStats { defer func() { if r := recover(); r != nil { // ignore panic } }() return f.torrent.Stats() } func (f *Fetcher) UploadedBytes() int64 { return f.data.SeedBytes } func (f *Fetcher) WaitUpload() (err error) { <-f.uploadDoneCh return nil } func (f *Fetcher) addTorrent(req *base.Request, fromUpload bool) (err error) { if err = base.ParseReqExtra[bt.ReqExtra](req); err != nil { return } if err = f.initClient(); err != nil { return } schema := util.ParseSchema(req.URL) privateTorrent := false var spec *torrent.TorrentSpec if schema == "MAGNET" { spec, err = torrent.TorrentSpecFromMagnetUri(req.URL) if err != nil { return } } else { var reader io.Reader if schema == "FILE" { fileUrl, _ := url.Parse(req.URL) filePath := fileUrl.Path[1:] reader, err = os.Open(filePath) if err != nil { return } } else if schema == "DATA" { _, data := util.ParseDataUri(req.URL) reader = bytes.NewBuffer(data) } else { reader, err = os.Open(req.URL) if err != nil { return } defer reader.(io.Closer).Close() } var metaInfo *metainfo.MetaInfo metaInfo, err = metainfo.Load(reader) // Hotfix for https://github.com/anacrolix/torrent/issues/992, ignore "expected EOF" error // TODO remove this after the issue is fixed if err != nil && !strings.Contains(err.Error(), "expected EOF") { return err } info, er := metaInfo.UnmarshalInfo() if er != nil { return er } if info.Private != nil && *info.Private { privateTorrent = true } spec, err = torrent.TorrentSpecFromMetaInfoErr(metaInfo) if err != nil { return } } spec.Storage = storage.NewFileOpts(storage.NewFileClientOpts{ ClientBaseDir: cfg.DataDir, TorrentDirMaker: func(baseDir string, info *metainfo.Info, infoHash metainfo.Hash) string { return f.meta.Opts.Path }, }) f.torrent, _, err = client.AddTorrentSpec(spec) if err != nil { return } // Do not add external tracker to a private torrent. if !privateTorrent { // use map to deduplicate trackers := make(map[string]bool) if req.Extra != nil { extra := req.Extra.(*bt.ReqExtra) if len(extra.Trackers) > 0 { for _, tracker := range extra.Trackers { trackers[tracker] = true } } } if len(f.config.Trackers) > 0 { for _, tracker := range f.config.Trackers { trackers[tracker] = true } } if len(trackers) > 0 { announceList := make([][]string, 0) for tracker := range trackers { announceList = append(announceList, []string{tracker}) } f.torrent.AddTrackers(announceList) } } <-f.torrent.GotInfo() f.torrentReady.Store(true) go f.doUpload(fromUpload) return } 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) } type fetcherData struct { Progress fetcher.Progress SeedBytes int64 // SeedTime is the time in seconds to seed after downloading is complete. SeedTime int64 } 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 (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 (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 } ================================================ FILE: internal/protocol/bt/fetcher_test.go ================================================ 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" ) func TestFetcher_Resolve_Torrent(t *testing.T) { doResolve(t, buildFetcher()) } func TestFetcher_Resolve_DataUri_Torrent(t *testing.T) { fetcher := buildFetcher() buf, err := os.ReadFile("./testdata/ubuntu-22.04-live-server-amd64.iso.torrent") if err != nil { t.Fatal(err) } // convert to data uri dataUri := "data:application/x-bittorrent;base64," + base64.StdEncoding.EncodeToString(buf) err = fetcher.Resolve(&base.Request{ URL: dataUri, }, nil) if err != nil { panic(err) } want := &base.Resource{ Size: 1466714112, Range: true, Files: []*base.FileInfo{ { Name: "ubuntu-22.04-live-server-amd64.iso", Size: 1466714112, }, }, Hash: "8a55cfbd5ca5d11507364765936c4f9e55b253ed", } if !reflect.DeepEqual(want, fetcher.Meta().Res) { t.Errorf("Resolve() got = %v, want %v", fetcher.Meta().Res, want) } } func TestFetcher_Config(t *testing.T) { doResolve(t, buildConfigFetcher(nil)) } func TestFetcher_ResolveWithProxy(t *testing.T) { usr, pwd := "admin", "123" proxyListener := test.StartSocks5Server(usr, pwd) defer proxyListener.Close() doResolve(t, buildConfigFetcher(&base.DownloaderProxyConfig{ Enable: true, System: false, Scheme: "socks5", Host: proxyListener.Addr().String(), Usr: usr, Pwd: pwd, })) } func doResolve(t *testing.T, fetcher fetcher.Fetcher) { t.Run("Resolve Single File", func(t *testing.T) { err := fetcher.Resolve(&base.Request{ URL: "./testdata/ubuntu-22.04-live-server-amd64.iso.torrent", Extra: bt.ReqExtra{ Trackers: []string{ "udp://tracker.birkenwald.de:6969/announce", "udp://tracker.bitsearch.to:1337/announce", }, }, }, nil) if err != nil { panic(err) } want := &base.Resource{ Size: 1466714112, Range: true, Files: []*base.FileInfo{ { Name: "ubuntu-22.04-live-server-amd64.iso", Size: 1466714112, }, }, Hash: "8a55cfbd5ca5d11507364765936c4f9e55b253ed", } if !reflect.DeepEqual(want, fetcher.Meta().Res) { t.Errorf("Resolve Single File Resolve() got = %v, want %v", fetcher.Meta().Res, want) } }) t.Run("Resolve Multi Files", func(t *testing.T) { err := fetcher.Resolve(&base.Request{ URL: "./testdata/test.torrent", Extra: bt.ReqExtra{ Trackers: []string{ "udp://tracker.birkenwald.de:6969/announce", "udp://tracker.bitsearch.to:1337/announce", }, }, }, nil) if err != nil { panic(err) } want := &base.Resource{ Name: "test", Size: 107484864, Range: true, Files: []*base.FileInfo{ { Name: "c.txt", Path: "path", Size: 98501754, }, { Name: "b.txt", Size: 8904996, }, { Name: "a.txt", Size: 78114, }, }, Hash: "ccbc92b0cd8deec16a2ef4be242a8c9243b1cedb", } if !reflect.DeepEqual(want, fetcher.Meta().Res) { t.Errorf("Resolve Multi Files Resolve() got = %v, want %v", fetcher.Meta().Res, want) } }) t.Run("Resolve Unclean Torrent", func(t *testing.T) { err := fetcher.Resolve(&base.Request{ URL: "./testdata/test.unclean.torrent", }, nil) if err != nil { t.Errorf("Resolve Unclean Torrent Resolve() got = %v, want nil", err) } }) t.Run("Resolve file scheme Torrent", func(t *testing.T) { file, _ := filepath.Abs("./testdata/test.unclean.torrent") uri := "file:///" + file err := fetcher.Resolve(&base.Request{ URL: uri, }, nil) if err != nil { t.Errorf("Resolve file scheme Torrent Resolve() got = %v, want nil", err) } }) } func TestFetcherManager_ParseName(t *testing.T) { type args struct { u string } tests := []struct { name string args args want string }{ { name: "broken url", args: args{ u: "magnet://!@#%github.com", }, want: "", }, { name: "dn", args: args{ u: "magnet:?xt=urn:btih:8a55cfbd5ca5d11507364765936c4f9e55b253ed&dn=ubuntu-22.04-live-server-amd64.iso", }, want: "ubuntu-22.04-live-server-amd64.iso", }, { name: "no dn", args: args{ u: "magnet:?xt=urn:btih:8a55cfbd5ca5d11507364765936c4f9e55b253ed", }, want: "8a55cfbd5ca5d11507364765936c4f9e55b253ed", }, { name: "non standard magnet", args: args{ u: "magnet:?xxt=abcd", }, want: "", }, } 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 { fb := new(FetcherManager) fetcher := fb.Build() newController := controller.NewController() newController.GetConfig = func(v any) { json.Unmarshal([]byte(test.ToJson(fb.DefaultConfig())), v) } fetcher.Setup(newController) return fetcher } func buildConfigFetcher(proxyConfig *base.DownloaderProxyConfig) fetcher.Fetcher { fetcher := new(FetcherManager).Build() newController := controller.NewController() mockCfg := config{ Trackers: []string{ "udp://tracker.birkenwald.de:6969/announce", "udp://tracker.bitsearch.to:1337/announce", }} newController.GetConfig = func(v any) { json.Unmarshal([]byte(test.ToJson(mockCfg)), v) } newController.GetProxy = func(requestProxy *base.RequestProxy) func(*gohttp.Request) (*url.URL, error) { return proxyConfig.ToHandler() } fetcher.Setup(newController) return fetcher } // TestFetcher_Patch tests the Patch functionality for BT fetcher. // It tests modifying selected files after Resolve (without downloading). func TestFetcher_Patch(t *testing.T) { f := buildFetcher() // Resolve a multi-file torrent err := f.Resolve(&base.Request{ URL: "./testdata/test.torrent", }, nil) if err != nil { t.Fatalf("Resolve failed: %v", err) } // Verify initial state: 3 files, all selected by default meta := f.Meta() if len(meta.Res.Files) != 3 { t.Fatalf("Expected 3 files, got %d", len(meta.Res.Files)) } if len(meta.Opts.SelectFiles) != 3 { t.Fatalf("Expected 3 selected files, got %d", len(meta.Opts.SelectFiles)) } // Total size of all files: 107484864 totalSize := int64(107484864) if meta.Res.Size != totalSize { t.Fatalf("Expected total size %d, got %d", totalSize, meta.Res.Size) } t.Run("Patch with valid indices", func(t *testing.T) { // Select only file 0 and 2 (c.txt and a.txt) err := f.Patch(nil, &base.Options{ SelectFiles: []int{0, 2}, }) if err != nil { t.Fatalf("Patch failed: %v", err) } meta := f.Meta() if !reflect.DeepEqual(meta.Opts.SelectFiles, []int{0, 2}) { t.Errorf("Expected SelectFiles [0, 2], got %v", meta.Opts.SelectFiles) } // Size should be recalculated: c.txt (98501754) + a.txt (78114) = 98579868 expectedSize := int64(98501754 + 78114) if meta.Res.Size != expectedSize { t.Errorf("Expected size %d, got %d", expectedSize, meta.Res.Size) } }) t.Run("Patch with invalid indices are silently ignored", func(t *testing.T) { // Mix of valid (0, 1) and invalid (-1, 5, 100) indices err := f.Patch(nil, &base.Options{ SelectFiles: []int{-1, 0, 5, 1, 100}, }) if err != nil { t.Fatalf("Patch should not return error for invalid indices: %v", err) } meta := f.Meta() // Only valid indices 0 and 1 should remain if !reflect.DeepEqual(meta.Opts.SelectFiles, []int{0, 1}) { t.Errorf("Expected SelectFiles [0, 1], got %v", meta.Opts.SelectFiles) } // Size should be: c.txt (98501754) + b.txt (8904996) = 107406750 expectedSize := int64(98501754 + 8904996) if meta.Res.Size != expectedSize { t.Errorf("Expected size %d, got %d", expectedSize, meta.Res.Size) } }) t.Run("Patch with all invalid indices results in empty selection", func(t *testing.T) { err := f.Patch(nil, &base.Options{ SelectFiles: []int{-5, 10, 999}, }) if err != nil { t.Fatalf("Patch should not return error: %v", err) } meta := f.Meta() if len(meta.Opts.SelectFiles) != 0 { t.Errorf("Expected empty SelectFiles, got %v", meta.Opts.SelectFiles) } // Note: CalcSize with empty selectFiles calculates total size of all files // This is by design - empty selection in CalcSize means "all files" // But SelectFiles being empty means no files are selected for download if meta.Res.Size != totalSize { t.Errorf("Expected size %d (CalcSize with empty slice = all files), got %d", totalSize, meta.Res.Size) } }) t.Run("Patch with nil opts does nothing", func(t *testing.T) { // First set a known state f.Patch(nil, &base.Options{SelectFiles: []int{1}}) prevSelectFiles := f.Meta().Opts.SelectFiles // Patch with nil opts err := f.Patch(nil, nil) if err != nil { t.Fatalf("Patch with nil opts should not fail: %v", err) } // Should remain unchanged if !reflect.DeepEqual(f.Meta().Opts.SelectFiles, prevSelectFiles) { t.Errorf("SelectFiles should remain unchanged after nil opts Patch") } }) t.Run("Patch progress array is resized", func(t *testing.T) { btFetcher := f.(*Fetcher) // Initialize progress array btFetcher.data.Progress = make(fetcher.Progress, 3) err := f.Patch(nil, &base.Options{ SelectFiles: []int{0, 2}, }) if err != nil { t.Fatalf("Patch failed: %v", err) } if len(btFetcher.data.Progress) != 2 { t.Errorf("Expected Progress length 2, got %d", len(btFetcher.data.Progress)) } }) } ================================================ FILE: internal/protocol/ed2k/config.go ================================================ package ed2k const ( defaultServerList = "45.82.80.155:5687,176.123.5.89:4725,85.121.5.137:4232,176.123.2.239:4232,145.239.2.134:4661,91.208.162.87:4232,37.15.61.236:4232" defaultServerMet = "ed2k://|serverlist|http://upd.emule-security.org/server.met|/" defaultNodesDat = "https://upd.emule-security.org/nodes.dat" ) type config struct { ListenPort int `json:"listenPort"` UDPPort int `json:"udpPort"` ServerAddr string `json:"serverAddr"` ServerMet string `json:"serverMet"` NodesDat string `json:"nodesDat"` } ================================================ FILE: internal/protocol/ed2k/fetcher.go ================================================ package ed2k import ( "context" "errors" "os" "path/filepath" "strings" "sync" "time" "github.com/GopeedLab/gopeed/internal/controller" "github.com/GopeedLab/gopeed/internal/fetcher" "github.com/GopeedLab/gopeed/pkg/base" ped2k "github.com/GopeedLab/gopeed/pkg/protocol/ed2k" "github.com/monkeyWie/goed2k" gprotocol "github.com/monkeyWie/goed2k/protocol" ) type clientStateStore struct { store fetcher.ProtocolStateStore } func (s *clientStateStore) Load() (*goed2k.ClientState, error) { if s == nil || s.store == nil { return nil, nil } var state goed2k.ClientState exist, err := s.store.Load(&state) if err != nil { return nil, err } if !exist { return nil, nil } return &state, nil } func (s *clientStateStore) Save(state *goed2k.ClientState) error { if s == nil || s.store == nil { return nil } if state == nil { return s.store.Delete() } return s.store.Save(state) } type Fetcher struct { ctl *controller.Controller config *config manager *FetcherManager meta *fetcher.FetcherMeta handle goed2k.TransferHandle waitCtx context.Context waitCancel context.CancelFunc } func (f *Fetcher) Setup(ctl *controller.Controller) { f.ctl = ctl if f.meta == nil { f.meta = &fetcher.FetcherMeta{} } f.waitCtx, f.waitCancel = context.WithCancel(context.Background()) f.ctl.GetConfig(&f.config) } func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error { link, err := parseLink(req.URL) if err != nil { return err } f.meta.Req = req f.meta.Opts = opts if f.meta.Opts == nil { f.meta.Opts = &base.Options{} } f.meta.Res = buildResource(link) return nil } func (f *Fetcher) Start() error { link, err := parseLink(f.meta.Req.URL) if err != nil { return err } if f.meta.Res == nil { f.meta.Res = buildResource(link) } client, err := f.getClient() if err != nil { return err } targetPath := f.meta.SingleFilepath() if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { return err } handle := client.FindTransfer(link.Hash) if handle.IsValid() { f.handle = handle if handle.IsPaused() { if err := client.ResumeTransfer(handle.GetHash()); err != nil { return err } } return nil } atp := goed2k.AddTransferParams{ Hash: link.Hash, CreateTime: time.Now().UnixMilli(), Size: link.NumberValue, FilePath: targetPath, } handle, err = client.AddTransfer(atp) if err != nil { return err } f.handle = handle if handle.IsValid() && handle.IsPaused() { if err := client.ResumeTransfer(handle.GetHash()); err != nil { return err } } return nil } func (f *Fetcher) Patch(req *base.Request, opts *base.Options) error { handle := f.currentHandle() if opts != nil && (opts.Name != "" || opts.Path != "") && handle.IsValid() { return errors.New("cannot change ed2k target path after transfer started") } if req != nil && req.URL != "" && handle.IsValid() { return errors.New("cannot change ed2k link after transfer started") } if req != nil { if req.URL != "" { link, err := parseLink(req.URL) if err != nil { return err } f.meta.Req.URL = req.URL f.meta.Res = buildResource(link) } if req.Labels != nil { if f.meta.Req.Labels == nil { f.meta.Req.Labels = make(map[string]string) } for k, v := range req.Labels { f.meta.Req.Labels[k] = v } } if req.Proxy != nil { f.meta.Req.Proxy = req.Proxy } } if opts != nil { if opts.Name != "" { f.meta.Opts.Name = opts.Name } if opts.Path != "" { f.meta.Opts.Path = opts.Path } } return nil } func (f *Fetcher) Pause() error { handle := f.currentHandle() if !handle.IsValid() { return nil } client, err := f.getClient() if err != nil { return err } if err := client.PauseTransfer(handle.GetHash()); err != nil { return err } f.handle = handle return nil } func (f *Fetcher) Close() error { if f.waitCancel != nil { f.waitCancel() } handle := f.currentHandle() if !handle.IsValid() { return nil } client, err := f.getClient() if err != nil { return err } f.handle = handle return client.RemoveTransfer(handle.GetHash(), false) } func (f *Fetcher) Meta() *fetcher.FetcherMeta { return f.meta } func (f *Fetcher) Stats() any { handle := f.currentHandle() if !handle.IsValid() { return &ped2k.Stats{} } status := handle.GetStatus() return &ped2k.Stats{ State: string(status.State), ActivePeers: handle.ActiveConnections(), TotalPeers: status.NumPeers, DownloadRate: status.DownloadRate, Upload: status.Upload, UploadRate: status.UploadRate, TotalDone: status.TotalDone, TotalReceived: status.TotalReceived, TotalWanted: status.TotalWanted, } } func (f *Fetcher) Progress() fetcher.Progress { handle := f.currentHandle() if !handle.IsValid() { return fetcher.Progress{0} } return fetcher.Progress{handle.GetStatus().TotalReceived} } func (f *Fetcher) Wait() error { client, err := f.getClient() if err != nil { return err } hash, err := f.hash() if err != nil { return err } handle := f.currentHandle() if handle.IsValid() && handle.IsFinished() { return nil } progressCh, cancel := client.SubscribeTransferProgress() defer cancel() for { select { case <-f.waitCtx.Done(): return nil case event, ok := <-progressCh: if !ok { return nil } for _, transfer := range event.Transfers { if transfer.Hash.Compare(hash) != 0 { continue } // Removal can happen during task deletion or client shutdown, both of // which should unblock Wait without treating it as a download failure. if transfer.Removed || transfer.State == goed2k.Finished { return nil } } } } } func (f *Fetcher) getClient() (*goed2k.Client, error) { if f.manager == nil { f.manager = &FetcherManager{} } return f.manager.initClient(f.config) } func (f *Fetcher) currentHandle() goed2k.TransferHandle { if f.handle.IsValid() { return f.handle } if f.manager == nil { return f.handle } client := f.manager.currentClient() if client == nil { return f.handle } hash, err := f.hash() if err != nil { return f.handle } handle := client.FindTransfer(hash) if handle.IsValid() { f.handle = handle } return f.handle } func (f *Fetcher) hash() (gprotocol.Hash, error) { if f.meta == nil || f.meta.Req == nil { return gprotocol.Invalid, errors.New("ed2k link is empty") } link, err := parseLink(f.meta.Req.URL) if err != nil { return gprotocol.Invalid, err } return link.Hash, nil } type FetcherManager struct { mu sync.Mutex client *goed2k.Client stateStore *clientStateStore } func (fm *FetcherManager) SetStateStore(store fetcher.ProtocolStateStore) { fm.mu.Lock() defer fm.mu.Unlock() if fm.stateStore == nil { fm.stateStore = &clientStateStore{} } fm.stateStore.store = store if fm.client != nil { fm.client.SetStateStore(fm.stateStore) } } func (fm *FetcherManager) Name() string { return "ed2k" } func (fm *FetcherManager) Filters() []*fetcher.SchemeFilter { return []*fetcher.SchemeFilter{ { Type: fetcher.FilterTypeUrl, Pattern: "ED2K", }, } } func (fm *FetcherManager) Build() fetcher.Fetcher { return &Fetcher{manager: fm} } func (fm *FetcherManager) ParseName(u string) string { link, err := parseLink(u) if err != nil { return "" } return link.StringValue } func (fm *FetcherManager) AutoRename() bool { return true } func (fm *FetcherManager) DefaultConfig() any { return &config{ ListenPort: 0, UDPPort: 0, ServerAddr: defaultServerList, ServerMet: defaultServerMet, NodesDat: defaultNodesDat, } } func (fm *FetcherManager) Store(f fetcher.Fetcher) (any, error) { return nil, nil } func (fm *FetcherManager) Restore() (v any, f func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher) { return nil, func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher { return &Fetcher{ manager: fm, meta: meta, } } } func (fm *FetcherManager) Close() error { fm.mu.Lock() defer fm.mu.Unlock() if fm.client != nil { fm.client.Close() fm.client = nil } return nil } func parseLink(raw string) (goed2k.EMuleLink, error) { link, err := goed2k.ParseEMuleLink(raw) if err != nil { return goed2k.EMuleLink{}, err } if link.Type != goed2k.LinkFile { return goed2k.EMuleLink{}, errors.New("unsupported ed2k link type") } return link, nil } func buildResource(link goed2k.EMuleLink) *base.Resource { return &base.Resource{ Size: link.NumberValue, Range: false, Hash: link.Hash.String(), Files: []*base.FileInfo{ { Name: link.StringValue, Size: link.NumberValue, }, }, } } func splitCommaList(value string) []string { if strings.TrimSpace(value) == "" { return nil } parts := strings.Split(value, ",") out := make([]string, 0, len(parts)) for _, part := range parts { part = strings.TrimSpace(part) if part != "" { out = append(out, part) } } return out } func (fm *FetcherManager) getStateStoreLocked() *clientStateStore { if fm.stateStore == nil { fm.stateStore = &clientStateStore{} } return fm.stateStore } func (fm *FetcherManager) currentClient() *goed2k.Client { fm.mu.Lock() defer fm.mu.Unlock() return fm.client } func (fm *FetcherManager) initClient(cfg *config) (*goed2k.Client, error) { fm.mu.Lock() defer fm.mu.Unlock() if fm.client != nil { return fm.client, nil } if cfg == nil { cfg = fm.DefaultConfig().(*config) } settings := goed2k.NewSettings() settings.ListenPort = cfg.ListenPort settings.UDPPort = cfg.UDPPort settings.EnableDHT = true settings.EnableUPnP = true settings.ReconnectToServer = true client := goed2k.NewClient(settings) client.SetStateStore(fm.getStateStoreLocked()) if err := client.LoadState(""); err != nil { return nil, err } if err := client.Start(); err != nil { return nil, err } fm.client = client // Bootstrap is best-effort: downloads can still proceed later even if // server list or DHT initialization fails during startup. bootstrapClient(client, cfg) return fm.client, nil } func bootstrapClient(client *goed2k.Client, cfg *config) { for _, serverAddr := range splitCommaList(cfg.ServerAddr) { go func(serverAddr string) { _ = client.Connect(serverAddr) }(serverAddr) } for _, source := range splitCommaList(cfg.ServerMet) { go func(source string) { _ = client.ConnectServerMet(source) }(source) } if cfg.NodesDat != "" { go func() { _ = client.LoadDHTNodesDat(cfg.NodesDat) }() } } ================================================ FILE: internal/protocol/ed2k/fetcher_test.go ================================================ package ed2k import ( "testing" "github.com/GopeedLab/gopeed/internal/controller" "github.com/GopeedLab/gopeed/pkg/base" ) const testLink = "ed2k://|file|cn_windows_10_multi-edition_vl_version_1709_updated_sept_2017_x64_dvd_100090774.iso|4630972416|8867C5E54405FF9452225B66EFEE690A|/" func TestFetcher_Resolve(t *testing.T) { f := (&FetcherManager{}).Build() f.Setup(controller.NewController()) err := f.Resolve(&base.Request{URL: testLink}, &base.Options{Path: t.TempDir()}) if err != nil { t.Fatalf("Resolve() error = %v", err) } meta := f.Meta() if meta.Res == nil { t.Fatal("Resolve() resource is nil") } if got, want := meta.Res.Hash, "8867C5E54405FF9452225B66EFEE690A"; got != want { t.Fatalf("Resolve() hash = %s, want %s", got, want) } if got, want := meta.Res.Size, int64(4630972416); got != want { t.Fatalf("Resolve() size = %d, want %d", got, want) } if got, want := meta.Res.Files[0].Name, "cn_windows_10_multi-edition_vl_version_1709_updated_sept_2017_x64_dvd_100090774.iso"; got != want { t.Fatalf("Resolve() name = %q, want %q", got, want) } } func TestFetcherManager_ParseName(t *testing.T) { got := (&FetcherManager{}).ParseName(testLink) want := "cn_windows_10_multi-edition_vl_version_1709_updated_sept_2017_x64_dvd_100090774.iso" if got != want { t.Fatalf("ParseName() = %q, want %q", got, want) } } ================================================ FILE: internal/protocol/http/config.go ================================================ package http type config struct { UserAgent string `json:"userAgent"` Connections int `json:"connections"` UseServerCtime bool `json:"useServerCtime"` } ================================================ FILE: internal/protocol/http/fetcher.go ================================================ package http import ( "context" "errors" "fmt" "io" "net/http" "net/url" "os" "path" "strconv" "strings" "sync" "sync/atomic" "time" "github.com/GopeedLab/gopeed/internal/controller" "github.com/GopeedLab/gopeed/internal/fetcher" "github.com/GopeedLab/gopeed/pkg/base" fhttp "github.com/GopeedLab/gopeed/pkg/protocol/http" "github.com/xiaoqidun/setft" ) const ( connectTimeout = 15 * time.Second readTimeout = 15 * time.Second minFastFailTimeout = int64(3 * time.Second) // Minimum timeout for fast-fail retry // Work stealing parameters // When a connection finishes its chunk, it can "steal" work from slow connections. stealThresholdSeconds = 3 // Only steal if victim needs > 3 seconds to finish stealMinChunkSize = 512 * 1024 // Min steal size: 512KB (avoid tiny chunks) ) // ============================================================================ // State Machine // ============================================================================ type fetcherState int32 const ( stateIdle fetcherState = iota // Initial state stateResolving // Resolving resource info stateResolved // Resolved, waiting for Start or downloading stateSlowStart // Slow-start phase: exponential connection growth stateSteady // Steady state: max connections reached statePaused // Paused stateDone // Completed stateError // Error occurred ) // ============================================================================ // Connection // ============================================================================ type connectionState int32 const ( connNotStarted connectionState = iota // Not yet started connConnecting // Sending HTTP request connDownloading // HTTP response OK, downloading connCompleted // Completed connFailed // Failed ) type connectionRole int const ( roleResolve connectionRole = iota // Resolve connection: initial probe + temp download rolePrimary // Primary connection: first successful takeover from Resolve roleWorker // Worker connection: subsequent connections ) type chunk struct { Begin int64 End int64 Downloaded int64 } func (c *chunk) remain() int64 { return c.End - c.Begin + 1 - c.Downloaded } func newChunk(begin int64, end int64) *chunk { return &chunk{ Begin: begin, End: end, } } type connection struct { ID int Role connectionRole State connectionState Chunk *chunk Downloaded int64 Completed bool failed bool retryTimes int lastErr error // Speed tracking for work stealing decisions speed int64 // bytes per second lastSpeedCheck int64 // timestamp in nanoseconds lastSpeedDownload int64 // bytes downloaded at last check ctx context.Context cancel context.CancelFunc } // ============================================================================ // Slow Start Controller // ============================================================================ type slowStartController struct { mu sync.Mutex maxConnections int totalLaunched int batchPending int // Connections in current batch waiting for HTTP response batchReady int // Connections in current batch that succeeded nextBatchSize int // Next batch size: 1, 2, 4, 8... expansionCh chan struct{} // Signal to trigger next expansion paused bool // Pause expansion (e.g., on 429) } func newSlowStartController(maxConnections int) *slowStartController { return &slowStartController{ maxConnections: maxConnections, nextBatchSize: 1, expansionCh: make(chan struct{}, 1), } } // onConnectSuccess is called when a connection successfully gets HTTP response // Returns true if this completes the current batch func (s *slowStartController) onConnectSuccess() bool { s.mu.Lock() defer s.mu.Unlock() s.batchReady++ if s.batchReady >= s.batchPending { // Batch complete, signal expansion select { case s.expansionCh <- struct{}{}: default: } return true } return false } // onConnectFailed is called when a connection fails func (s *slowStartController) onConnectFailed() { s.mu.Lock() defer s.mu.Unlock() // Reduce pending count if s.batchPending > 0 { s.batchPending-- } // If all pending resolved (success or fail), trigger expansion // This handles both successful completion and all-failures case if s.batchPending == 0 { select { case s.expansionCh <- struct{}{}: default: } } } // getNextBatchSize returns how many connections to start in next batch // Returns 0 if max reached func (s *slowStartController) getNextBatchSize() int { s.mu.Lock() defer s.mu.Unlock() if s.paused { return 0 } remaining := s.maxConnections - s.totalLaunched if remaining <= 0 { return 0 } batchSize := s.nextBatchSize if batchSize > remaining { batchSize = remaining } return batchSize } // commitBatch confirms that a batch of connections is being launched func (s *slowStartController) commitBatch(count int) { s.mu.Lock() defer s.mu.Unlock() s.totalLaunched += count s.nextBatchSize = s.nextBatchSize * 2 // Exponential growth: 1, 2, 4, 8... s.batchPending = count s.batchReady = 0 } // ============================================================================ // Fetcher // ============================================================================ type Fetcher struct { ctl *controller.Controller config *config doneCh chan error meta *fetcher.FetcherMeta // State machine state atomic.Int32 // fetcherState // Connections connMu sync.Mutex connections []*connection resolveConn *connection // The special resolve connection // Slow start controller slowStart *slowStartController // Max connection time for adaptive timeout (stored as int64 nanoseconds for atomic ops) maxConnTime atomic.Int64 // First primary connection success signal primaryReadyOnce sync.Once primaryReadyCh chan struct{} // Start pending mechanism startPending atomic.Bool resolvedCh chan struct{} // Signal when resolve completes resolvedOnce sync.Once resolveDataPos atomic.Int64 // How many bytes downloaded during resolve // Resolve response - kept open for one-time URLs resolveResp *http.Response resolveRespLock sync.Mutex // Async prefetch during resolve phase prefetchFile *os.File // Temporary file for prefetch data prefetchFilePath string // Path to temporary file prefetchSize atomic.Int64 // Bytes prefetched so far prefetchDone atomic.Bool // Prefetch completed or stopped prefetchErr error // Error during prefetch (if any) prefetchStopCh chan struct{} // Signal to stop prefetch // Target file file *os.File fileMu sync.Mutex redirectURL string redirectLock sync.Mutex // Lifecycle control ctx context.Context cancel context.CancelFunc wg sync.WaitGroup // downloadLoop lifecycle tracking downloadLoopDone chan struct{} // Closed when downloadLoop exits // Resolve connection control resolveCtx context.Context resolveCancel context.CancelFunc } func (f *Fetcher) Setup(ctl *controller.Controller) { f.ctl = ctl f.doneCh = make(chan error, 1) if f.meta == nil { f.meta = &fetcher.FetcherMeta{} } f.ctl.GetConfig(&f.config) f.resolvedCh = make(chan struct{}) f.primaryReadyCh = make(chan struct{}) // Check if this is a restore scenario (has existing connections or meta) if f.meta.Res != nil { // Already resolved, close the channel immediately close(f.resolvedCh) f.state.Store(int32(stateResolved)) } else { f.state.Store(int32(stateIdle)) } } func (f *Fetcher) getState() fetcherState { return fetcherState(f.state.Load()) } func (f *Fetcher) setState(s fetcherState) { f.state.Store(int32(s)) } // updateMaxConnTime updates maxConnTime if the new duration is larger func (f *Fetcher) updateMaxConnTime(d time.Duration) { newVal := int64(d) if newVal > f.maxConnTime.Load() { f.maxConnTime.Store(newVal) } } func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error { if err := base.ParseReqExtra[fhttp.ReqExtra](req); err != nil { return err } f.meta.Req = req f.meta.Opts = opts if f.meta.Opts == nil { f.meta.Opts = &base.Options{} } // Parse options if err := base.ParseOptExtra[fhttp.OptsExtra](opts); err != nil { return err } if opts.Extra == nil { opts.Extra = &fhttp.OptsExtra{} } extra := opts.Extra.(*fhttp.OptsExtra) if extra.Connections <= 0 { extra.Connections = f.config.Connections if extra.Connections <= 0 { extra.Connections = 1 } } f.setState(stateResolving) // Build HTTP request WITHOUT Range header (normal request) // This allows the response to be reused for downloading (important for one-time URLs) httpReq, err := f.buildRequest(context.TODO(), req) if err != nil { f.setState(stateError) return err } client := f.buildClient() // Send normal HTTP request (no Range header) // Track connection time for adaptive timeout in download phase connStartTime := time.Now() resp, err := client.Do(httpReq) if err != nil { f.setState(stateError) return err } // Record connection time as baseline for fast-fail timeout f.updateMaxConnTime(time.Since(connStartTime)) // Parse response to get resource info res := &base.Resource{ Range: false, Files: []*base.FileInfo{}, } if resp.StatusCode != base.HttpCodeOK && resp.StatusCode != base.HttpCodePartialContent { resp.Body.Close() f.setState(stateError) return NewRequestError(resp.StatusCode) } // Check if server supports range requests acceptRanges := resp.Header.Get(base.HttpHeaderAcceptRanges) contentRange := resp.Header.Get(base.HttpHeaderContentRange) if acceptRanges == base.HttpHeaderBytes || strings.HasPrefix(contentRange, base.HttpHeaderBytes) { res.Range = true } // Get content length from Content-Length header contentLength := resp.Header.Get(base.HttpHeaderContentLength) if contentLength != "" { parse, err := strconv.ParseInt(contentLength, 10, 64) if err == nil { res.Size = parse } } // Parse last modified time var lastModifiedTime *time.Time lastModified := resp.Header.Get(base.HttpHeaderLastModified) if lastModified != "" { t, _ := time.Parse(time.RFC1123, lastModified) lastModifiedTime = &t } file := &base.FileInfo{ Size: res.Size, Ctime: lastModifiedTime, } // Parse filename contentDisposition := resp.Header.Get(base.HttpHeaderContentDisposition) if contentDisposition != "" { file.Name = parseFilename(contentDisposition) } if file.Name == "" { file.Name = path.Base(httpReq.URL.Path) if file.Name != "" { // Use PathUnescape instead of QueryUnescape to correctly handle %2B (should decode to +, not space) file.Name, _ = url.PathUnescape(file.Name) } } if file.Name == "" || file.Name == "/" || file.Name == "." { file.Name = httpReq.URL.Hostname() } res.Files = append(res.Files, file) f.meta.Res = res // Save redirect URL for later connections f.redirectURL = resp.Request.URL.String() // IMPORTANT: Keep the response body open for downloading in Start phase // This is crucial for one-time URLs that can only be accessed once f.resolveRespLock.Lock() f.resolveResp = resp f.resolveRespLock.Unlock() f.setState(stateResolved) // Signal that resolve is complete f.resolvedOnce.Do(func() { close(f.resolvedCh) }) // Start async prefetch in background (only for range-supported resources) // For non-range resources, the response will be used directly in Start if res.Range && res.Size > 0 { f.prefetchStopCh = make(chan struct{}) go f.asyncPrefetch() } // If start was called before resolve completed, auto-start if f.startPending.Load() { go f.doStart() } return nil } // asyncPrefetch downloads data in background during resolve phase // This data can be reused when Start is called to save time func (f *Fetcher) asyncPrefetch() { defer func() { f.prefetchDone.Store(true) }() // Get the resolve response f.resolveRespLock.Lock() resp := f.resolveResp f.resolveRespLock.Unlock() if resp == nil { return } // Create temporary file for prefetch data tmpFile, err := os.CreateTemp("", "gopeed-prefetch-*") if err != nil { f.prefetchErr = err return } f.prefetchFile = tmpFile f.prefetchFilePath = tmpFile.Name() defer func() { // Close response body when prefetch stops f.resolveRespLock.Lock() if f.resolveResp != nil { f.resolveResp.Body.Close() f.resolveResp = nil } f.resolveRespLock.Unlock() }() buf := make([]byte, 32*1024) // 32KB buffer reader := NewTimeoutReader(resp.Body, readTimeout) for { select { case <-f.prefetchStopCh: // Stop signal received (Start was called) return default: } n, err := reader.Read(buf) if n > 0 { _, writeErr := tmpFile.Write(buf[:n]) if writeErr != nil { f.prefetchErr = writeErr return } f.prefetchSize.Add(int64(n)) } if err != nil { if err == io.EOF { // Prefetch completed return } f.prefetchErr = err return } } } // stopPrefetchAndGetData stops the async prefetch and returns prefetched bytes // It also copies prefetched data to the target file func (f *Fetcher) stopPrefetchAndCopyData() int64 { // Signal prefetch to stop (safely) if f.prefetchStopCh != nil { select { case <-f.prefetchStopCh: // Already closed default: close(f.prefetchStopCh) } } // Wait for prefetch to finish (with timeout) for i := 0; i < 1000 && !f.prefetchDone.Load(); i++ { time.Sleep(10 * time.Millisecond) } prefetched := f.prefetchSize.Load() if prefetched == 0 { f.cleanupPrefetchFile() return 0 } // Copy prefetch data to target file if f.prefetchFile != nil && f.file != nil { // Seek to beginning of prefetch file f.prefetchFile.Seek(0, io.SeekStart) // Copy to target file at position 0 buf := make([]byte, 32*1024) var copied int64 for copied < prefetched { n, err := f.prefetchFile.Read(buf) if n > 0 { f.file.WriteAt(buf[:n], copied) copied += int64(n) } if err != nil { break } } } f.cleanupPrefetchFile() return prefetched } // cleanupPrefetchFile closes and removes the prefetch temporary file func (f *Fetcher) cleanupPrefetchFile() { if f.prefetchFile != nil { f.prefetchFile.Close() f.prefetchFile = nil } if f.prefetchFilePath != "" { os.Remove(f.prefetchFilePath) f.prefetchFilePath = "" } } func (f *Fetcher) Start() error { state := f.getState() switch state { case stateResolved, statePaused: // Normal case: resolved or resuming from pause return f.doStart() case stateResolving: // Early start: mark pending and return immediately f.startPending.Store(true) return nil case stateSlowStart, stateSteady: // Already downloading, this is a resume from pause return f.doStart() case stateError: // Retry after error: reset and restart return f.doStart() default: return fmt.Errorf("cannot start in current state: %v", state) } } func (f *Fetcher) doStart() error { // Wait for resolve to complete <-f.resolvedCh state := f.getState() if state == stateDone { return nil } // If retrying after error, reset connection states for retry if state == stateError { // Drain any pending error from doneCh before retry select { case <-f.doneCh: default: } f.connMu.Lock() for _, conn := range f.connections { // Reset connections that can be retried if !conn.Completed && conn.State != connCompleted { f.resetConnectionForRestart(conn) conn.State = connNotStarted conn.failed = false conn.retryTimes = 0 conn.lastErr = nil } } f.connMu.Unlock() } // Open or create target file first (needed for prefetch copy) name := f.meta.SingleFilepath() var err error var file *os.File _, err = os.Stat(name) if err != nil { if os.IsNotExist(err) { file, err = f.ctl.Touch(name, f.meta.Res.Size) } else { return err } } else { file, err = os.OpenFile(name, os.O_RDWR, os.ModeAppend) } if err != nil { return err } f.fileMu.Lock() f.file = file f.fileMu.Unlock() // For range-supported resources, stop prefetch and copy data // For non-range resources, the response will be used directly var prefetchedBytes int64 if f.meta.Res.Range { // Stop async prefetch and copy data to target file prefetchedBytes = f.stopPrefetchAndCopyData() f.resolveDataPos.Store(prefetchedBytes) // Also close resolve response if still open f.resolveRespLock.Lock() if f.resolveResp != nil { f.resolveResp.Body.Close() f.resolveResp = nil } f.resolveRespLock.Unlock() } // Avoid request extra modified by extension if err = base.ParseReqExtra[fhttp.ReqExtra](f.meta.Req); err != nil { return err } // Initialize slow start controller maxConns := f.meta.Opts.Extra.(*fhttp.OptsExtra).Connections f.slowStart = newSlowStartController(maxConns) // Create main context f.ctx, f.cancel = context.WithCancel(context.Background()) // Create downloadLoop lifecycle channel f.downloadLoopDone = make(chan struct{}) // Start download f.setState(stateSlowStart) go f.downloadLoop() return nil } func (f *Fetcher) downloadLoop() { defer func() { // Update file last modified time before closing if f.config.UseServerCtime && f.meta.Res.Files[0].Ctime != nil { setft.SetFileTime(f.meta.SingleFilepath(), time.Now(), *f.meta.Res.Files[0].Ctime, *f.meta.Res.Files[0].Ctime) } // Signal that downloadLoop has exited if f.downloadLoopDone != nil { close(f.downloadLoopDone) } }() // Check if this is a resume or fresh start isResume := len(f.connections) > 0 if !isResume { // Fresh start: begin with resolve connection f.startResolveDownload() } else { // Resume: restart existing connections f.resumeConnections() f.waitForCompletion() return } // Slow start loop for { select { case <-f.ctx.Done(): // Paused or cancelled return case <-f.slowStart.expansionCh: // Batch completed, try to expand if f.checkCompletion() { // All work is done, wait for connections to finish f.waitForCompletion() return } f.expandConnections() // Check if we've reached steady state (max connections) if f.getState() == stateSteady { // Wait for all connections to complete f.waitForCompletion() return } } } } func (f *Fetcher) startResolveDownload() { // If no range support or size unknown, just use single connection with resolve response if !f.meta.Res.Range || f.meta.Res.Size == 0 { // Create a single connection for the entire file conn := &connection{ ID: 0, Role: rolePrimary, State: connNotStarted, Chunk: newChunk(0, 0), // For non-range, end doesn't matter } conn.ctx, conn.cancel = context.WithCancel(f.ctx) f.connections = append(f.connections, conn) f.wg.Add(1) // Use the resolve response directly go f.runConnectionWithResolveResp(conn) // For non-range downloads, wait for completion directly in this goroutine // Don't create another goroutine to avoid WaitGroup reuse issues f.waitForCompletion() return } // Range supported: use slow start to launch connections // Start first batch of connections f.expandConnections() } func (f *Fetcher) expandConnections() { batchSize := f.slowStart.getNextBatchSize() if batchSize <= 0 { // Max reached, transition to steady state f.setState(stateSteady) // Don't start a new goroutine - let the downloadLoop handle completion // This avoids multiple goroutines calling wg.Wait() simultaneously return } totalSize := f.meta.Res.Size f.connMu.Lock() // For first batch (no existing connections), allocate the remaining file to first connection if len(f.connections) == 0 { // Check if we have prefetched data prefetched := f.resolveDataPos.Load() // If prefetched all data, mark as done if prefetched >= totalSize { f.connMu.Unlock() // Close the file before signaling completion f.fileMu.Lock() if f.file != nil { f.file.Close() f.file = nil } f.fileMu.Unlock() f.setState(stateDone) f.doneCh <- nil return } // First connection starts from prefetched position conn := &connection{ ID: 0, Role: rolePrimary, State: connNotStarted, Chunk: newChunk(prefetched, totalSize-1), } // Mark prefetched bytes as already downloaded conn.Chunk.Downloaded = 0 // Start fresh from prefetched position conn.Downloaded = prefetched // Track total downloaded including prefetch conn.ctx, conn.cancel = context.WithCancel(f.ctx) f.connections = append(f.connections, conn) f.connMu.Unlock() f.slowStart.commitBatch(1) f.wg.Add(1) go f.runConnection(conn) return } // For subsequent batches, use "help other connection" strategy // Find connections with enough remaining work to split // During slow start, use fixed minimum size since speed is not yet stable minSplitSize := int64(stealMinChunkSize) newConns := make([]*connection, 0, batchSize) for i := 0; i < batchSize; i++ { // Find the connection with most remaining work var maxRemainConn *connection var maxRemain int64 for _, conn := range f.connections { if conn.Completed || conn.State == connFailed { continue } remain := conn.Chunk.remain() // Only split if remaining work is at least 2x the minimum split size if remain > maxRemain && remain > minSplitSize*2 { maxRemainConn = conn maxRemain = remain } } if maxRemainConn == nil { // No connection has enough work to split break } // Split the work: new connection takes the latter half splitPoint := maxRemainConn.Chunk.End - maxRemainConn.Chunk.remain()/2 newChunk := newChunk(splitPoint+1, maxRemainConn.Chunk.End) maxRemainConn.Chunk.End = splitPoint connID := len(f.connections) conn := &connection{ ID: connID, Role: roleWorker, State: connNotStarted, Chunk: newChunk, } conn.ctx, conn.cancel = context.WithCancel(f.ctx) newConns = append(newConns, conn) f.connections = append(f.connections, conn) } f.connMu.Unlock() if len(newConns) == 0 { // No new connections could be created, stop expansion f.setState(stateSteady) go f.waitForCompletion() return } // Commit batch to slow start controller f.slowStart.commitBatch(len(newConns)) // Launch connections for _, conn := range newConns { f.wg.Add(1) go f.runConnection(conn) } } func (f *Fetcher) runConnection(conn *connection) { defer f.wg.Done() f.connMu.Lock() conn.State = connConnecting f.connMu.Unlock() // Use fast-fail client for quick retry during download phase client := f.buildFastFailClient() buf := make([]byte, 8192) retries := 0 conn.retryTimes = 0 for { // Rebuild client with updated fast-fail timeout on retries if retries > 0 { client = f.buildFastFailClient() } err := f.downloadChunkOnce(conn, client, buf) if err == nil { if !f.meta.Res.Range || !f.helpOtherConnection(conn) { f.connMu.Lock() conn.Completed = true conn.State = connCompleted f.connMu.Unlock() return } // Reset counters after a successful help switch retries = 0 conn.retryTimes = 0 continue } if errors.Is(err, context.Canceled) { return } if re := extractRequestError(err); re != nil { conn.lastErr = re } else { conn.lastErr = err } if shouldCountHTTPFailure(err) { if re := extractRequestError(err); re != nil && re.Code == 403 { f.connMu.Lock() conn.State = connFailed conn.failed = true f.connMu.Unlock() if f.slowStart != nil { f.slowStart.onConnectFailed() } return } conn.retryTimes++ f.connMu.Lock() conn.failed = true f.connMu.Unlock() if f.slowStart != nil { f.slowStart.onConnectFailed() } if conn.retryTimes >= 3 { f.connMu.Lock() conn.State = connFailed f.connMu.Unlock() return } } f.connMu.Lock() conn.State = connFailed f.connMu.Unlock() retryDelay := time.Second * time.Duration(retries+1) if retryDelay > 5*time.Second { retryDelay = 5 * time.Second } retries++ time.Sleep(retryDelay) } } // downloadChunkOnce performs a single HTTP request for the current chunk without retrying. // If the redirect URL fails with an expiration-related error (401, 403, 410), // it will automatically retry with the original URL and update the redirect URL on success. func (f *Fetcher) downloadChunkOnce(conn *connection, client *http.Client, buf []byte) error { if conn.ctx.Err() != nil { return conn.ctx.Err() } // Read chunk boundaries under lock to get a consistent snapshot // This protects against concurrent modification by helpOtherConnection f.connMu.Lock() if f.meta.Res.Range && conn.Chunk.remain() <= 0 { f.connMu.Unlock() return nil } rangeStart := conn.Chunk.Begin + conn.Chunk.Downloaded rangeEnd := conn.Chunk.End f.connMu.Unlock() httpReq, err := f.buildRequest(conn.ctx, f.meta.Req) if err != nil { return err } if f.meta.Res.Range { httpReq.Header.Set(base.HttpHeaderRange, fmt.Sprintf(base.HttpHeaderRangeFormat, rangeStart, rangeEnd)) } // Record connection start time for adaptive timeout tracking connStartTime := time.Now() resp, err := client.Do(httpReq) if err != nil { return err } if resp.StatusCode != base.HttpCodeOK && resp.StatusCode != base.HttpCodePartialContent { resp.Body.Close() originalErr := NewRequestError(resp.StatusCode) // Check if this might be a redirect URL expiration error // If so, try falling back to the original URL if f.hasRedirectURL() && isRedirectExpiredError(originalErr) { fallbackResp, fallbackErr := f.tryFallbackToOriginalURL(conn.ctx, client, rangeStart, rangeEnd) if fallbackErr == nil && fallbackResp != nil { // Fallback succeeded, use this response instead resp = fallbackResp // Update the redirect URL from the response if resp.Request != nil && resp.Request.URL != nil { f.updateRedirectURL(resp.Request.URL.String()) } } else { // Fallback also failed, return the original error if fallbackResp != nil { fallbackResp.Body.Close() } return originalErr } } else { return originalErr } } defer resp.Body.Close() // Record successful connection time for adaptive timeout f.updateMaxConnTime(time.Since(connStartTime)) f.connMu.Lock() conn.State = connDownloading conn.failed = false f.connMu.Unlock() if conn.Role == rolePrimary || conn.ID == 0 { f.primaryReadyOnce.Do(func() { close(f.primaryReadyCh) }) } if f.slowStart != nil { f.slowStart.onConnectSuccess() } reader := NewTimeoutReader(resp.Body, readTimeout) for { if conn.ctx.Err() != nil { return conn.ctx.Err() } n, err := reader.Read(buf) if n > 0 { finished := false var writeOffset int64 // Lock to safely read chunk state and calculate write parameters // This protects against concurrent chunk splitting by helpOtherConnection f.connMu.Lock() if f.meta.Res.Range { // Check current chunk boundaries - this respects any concurrent chunk splitting remain := conn.Chunk.remain() if remain <= 0 { // Chunk has been fully downloaded (possibly split and reduced) f.connMu.Unlock() return nil } if remain < int64(n) { n = int(remain) finished = true } } writeOffset = conn.Chunk.Begin + conn.Chunk.Downloaded f.connMu.Unlock() f.fileMu.Lock() if f.file != nil { _, writeErr := f.file.WriteAt(buf[:n], writeOffset) if writeErr != nil { f.fileMu.Unlock() return writeErr } } f.fileMu.Unlock() // Lock again to update Downloaded atomically with the read above f.connMu.Lock() conn.Chunk.Downloaded += int64(n) conn.Downloaded += int64(n) // Update connection speed periodically now := time.Now().UnixNano() if conn.lastSpeedCheck == 0 { conn.lastSpeedCheck = now conn.lastSpeedDownload = conn.Downloaded } else if now-conn.lastSpeedCheck >= int64(500*time.Millisecond) { elapsed := float64(now-conn.lastSpeedCheck) / float64(time.Second) if elapsed > 0 { conn.speed = int64(float64(conn.Downloaded-conn.lastSpeedDownload) / elapsed) } conn.lastSpeedCheck = now conn.lastSpeedDownload = conn.Downloaded } f.connMu.Unlock() if finished { return nil } } if err != nil { if err == io.EOF { return nil } return err } } } // runConnectionWithResolveResp uses the response body from Resolve phase // This is crucial for one-time URLs that can only be accessed once func (f *Fetcher) runConnectionWithResolveResp(conn *connection) { defer f.wg.Done() f.connMu.Lock() conn.State = connConnecting f.connMu.Unlock() buf := make([]byte, 8192) // Get the resolve response f.resolveRespLock.Lock() resp := f.resolveResp f.resolveResp = nil // Take ownership f.resolveRespLock.Unlock() if resp == nil { // No resolve response available, fall back to normal connection f.runConnectionFallback(conn) return } defer resp.Body.Close() f.connMu.Lock() conn.State = connDownloading conn.failed = false f.connMu.Unlock() // Signal primary ready f.primaryReadyOnce.Do(func() { close(f.primaryReadyCh) }) if f.slowStart != nil { f.slowStart.onConnectSuccess() } // Download data from resolve response reader := NewTimeoutReader(resp.Body, readTimeout) for { if conn.ctx.Err() != nil { return } n, err := reader.Read(buf) if n > 0 { f.fileMu.Lock() if f.file != nil { _, writeErr := f.file.WriteAt(buf[:n], conn.Chunk.Downloaded) if writeErr != nil { f.fileMu.Unlock() f.connMu.Lock() conn.State = connFailed conn.failed = true f.connMu.Unlock() if f.slowStart != nil { f.slowStart.onConnectFailed() } return } } f.fileMu.Unlock() conn.Chunk.Downloaded += int64(n) conn.Downloaded += int64(n) } if err != nil { if err == io.EOF { f.connMu.Lock() conn.Completed = true conn.State = connCompleted f.connMu.Unlock() return } // Reading from resolve response failed: treat as transient (do not count as fail) f.connMu.Lock() conn.State = connFailed f.connMu.Unlock() return } } } // runConnectionFallback is used when resolve response is not available func (f *Fetcher) runConnectionFallback(conn *connection) { // Use fast-fail client for quick retry during download phase client := f.buildFastFailClient() buf := make([]byte, 8192) retries := 0 countedRetries := 0 for { if conn.ctx.Err() != nil { return } // Rebuild client with updated fast-fail timeout on retries if retries > 0 { client = f.buildFastFailClient() } f.connMu.Lock() conn.State = connConnecting f.connMu.Unlock() err := func() error { httpReq, err := f.buildRequest(conn.ctx, f.meta.Req) if err != nil { return err } // Record connection start time for adaptive timeout tracking connStartTime := time.Now() resp, err := client.Do(httpReq) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != base.HttpCodeOK && resp.StatusCode != base.HttpCodePartialContent { return NewRequestError(resp.StatusCode) } // Record successful connection time for adaptive timeout f.updateMaxConnTime(time.Since(connStartTime)) f.connMu.Lock() conn.State = connDownloading conn.failed = false f.connMu.Unlock() f.primaryReadyOnce.Do(func() { close(f.primaryReadyCh) }) if f.slowStart != nil { f.slowStart.onConnectSuccess() } reader := NewTimeoutReader(resp.Body, readTimeout) for { if conn.ctx.Err() != nil { return conn.ctx.Err() } n, err := reader.Read(buf) if n > 0 { f.fileMu.Lock() if f.file != nil { _, writeErr := f.file.WriteAt(buf[:n], conn.Chunk.Downloaded) if writeErr != nil { f.fileMu.Unlock() return writeErr } } f.fileMu.Unlock() conn.Chunk.Downloaded += int64(n) conn.Downloaded += int64(n) } if err != nil { if err == io.EOF { return nil } return err } } }() if err == nil { f.connMu.Lock() conn.Completed = true conn.State = connCompleted f.connMu.Unlock() return } if errors.Is(err, context.Canceled) { return } if re := extractRequestError(err); re != nil { conn.lastErr = re } else { conn.lastErr = err } if shouldCountHTTPFailure(err) { // Immediate fail for server connection limit (403) if re := extractRequestError(err); re != nil && re.Code == 403 { f.connMu.Lock() conn.State = connFailed conn.failed = true f.connMu.Unlock() if f.slowStart != nil { f.slowStart.onConnectFailed() } return } conn.retryTimes++ countedRetries++ if countedRetries >= 3 { f.connMu.Lock() conn.State = connFailed conn.failed = true f.connMu.Unlock() if f.slowStart != nil { f.slowStart.onConnectFailed() } return } // Retry again for counted failures below the cap f.connMu.Lock() conn.State = connFailed f.connMu.Unlock() retryDelay := time.Second * time.Duration(retries+1) if retryDelay > 5*time.Second { retryDelay = 5 * time.Second } retries++ time.Sleep(retryDelay) continue } // Retry indefinitely for non-counted errors f.connMu.Lock() conn.State = connFailed f.connMu.Unlock() retryDelay := time.Second * time.Duration(retries+1) if retryDelay > 5*time.Second { retryDelay = 5 * time.Second } retries++ time.Sleep(retryDelay) } } // helpOtherConnection implements work stealing: when a connection finishes its chunk, // it looks for connections that need more than stealThresholdSeconds to finish and steals half of its work. func (f *Fetcher) helpOtherConnection(helper *connection) bool { f.connMu.Lock() defer f.connMu.Unlock() // Find the connection with longest remaining time var slowestConn *connection var maxRemainSeconds int64 for _, r := range f.connections { if r == helper || r.Completed || r.State == connFailed { continue } remain := r.Chunk.remain() if remain < stealMinChunkSize { continue } // Calculate remaining time in seconds for this connection var remainSeconds int64 if r.speed > 0 { remainSeconds = remain / r.speed } else { // Speed unknown, assume it needs help if chunk is large enough remainSeconds = stealThresholdSeconds + 1 } // Only consider if it needs more than threshold seconds to finish if remainSeconds > stealThresholdSeconds && remainSeconds > maxRemainSeconds { slowestConn = r maxRemainSeconds = remainSeconds } } if slowestConn == nil { return false } // Re-calculate the chunk range: steal half of the remaining work helper.Chunk.Begin = slowestConn.Chunk.End - slowestConn.Chunk.remain()/2 helper.Chunk.End = slowestConn.Chunk.End helper.Chunk.Downloaded = 0 slowestConn.Chunk.End = helper.Chunk.Begin - 1 return true } func (f *Fetcher) resetConnectionForRestart(conn *connection) { if f.meta.Res.Range { return } // Without range support a new request always starts from byte 0, // so pause/retry must restart instead of continuing from the old offset. if conn.Chunk == nil { conn.Chunk = newChunk(0, 0) } else { conn.Chunk.Begin = 0 conn.Chunk.End = 0 conn.Chunk.Downloaded = 0 } conn.Downloaded = 0 conn.Completed = false conn.speed = 0 conn.lastSpeedCheck = 0 conn.lastSpeedDownload = 0 } func (f *Fetcher) resumeConnections() { // Collect connections to resume while holding the lock var toResume []*connection f.connMu.Lock() for _, conn := range f.connections { // Only skip connections that have truly completed successfully if conn.Completed || conn.State == connCompleted { continue } // For failed connections, skip if: // 1. They have exhausted retries (retryTimes >= 3), OR // 2. They failed with a permanent error like 403 if conn.State == connFailed && conn.failed { // Check if it's a permanent error (like 403) if re := extractRequestError(conn.lastErr); re != nil && re.Code == 403 { continue } // Check if retries exhausted if conn.retryTimes >= 3 { continue } } f.resetConnectionForRestart(conn) // Reset the connection state for resume conn.ctx, conn.cancel = context.WithCancel(f.ctx) conn.State = connNotStarted conn.failed = false // Clear failed flag for resumed connection toResume = append(toResume, conn) } f.connMu.Unlock() // Start connections outside the lock for _, conn := range toResume { f.wg.Add(1) go f.runConnection(conn) } } func (f *Fetcher) waitForCompletion() { f.wg.Wait() // Only trigger completion if not cancelled/paused if f.ctx != nil && f.ctx.Err() == nil { f.onDownloadComplete() } } func (f *Fetcher) onDownloadComplete() { f.connMu.Lock() // First, check if download actually completed successfully // Calculate total downloaded from all connections totalDownloaded := int64(0) if f.resolveConn != nil { totalDownloaded += f.resolveConn.Downloaded } for _, conn := range f.connections { totalDownloaded += conn.Downloaded } // Check if all chunks are complete (no remaining bytes) allChunksComplete := true for _, conn := range f.connections { needsMoreData := false if f.meta.Res.Range { needsMoreData = conn.Chunk != nil && conn.Chunk.remain() > 0 } else if f.meta.Res.Size > 0 { needsMoreData = conn.Downloaded < f.meta.Res.Size } else { needsMoreData = !conn.Completed && conn.State != connCompleted } if needsMoreData && !conn.Completed && conn.State != connCompleted { // This connection has remaining work and isn't done // Check if it failed with 403 (server limit) - these can be ignored if other connections completed the work if conn.State == connFailed && conn.failed { if re := extractRequestError(conn.lastErr); re != nil && re.Code == 403 { // 403 is server connection limit, check if other connections will complete this chunk continue } } allChunksComplete = false break } } // If total downloaded matches file size, consider it a success regardless of connection failures downloadComplete := f.meta.Res.Size > 0 && totalDownloaded >= f.meta.Res.Size // Check for any errors, but ignore 403 (server connection limit) errors if download completed var finalErr error if !downloadComplete && !allChunksComplete { for _, conn := range f.connections { if conn.State == connFailed && conn.failed { // Skip 403 errors (server connection limit) - these are expected when exceeding server's limit if re := extractRequestError(conn.lastErr); re != nil && re.Code == 403 { continue } if re := extractRequestError(conn.lastErr); re != nil { finalErr = fmt.Errorf("connection %d failed: retries=%d, status=%d", conn.ID, conn.retryTimes, re.Code) } else if conn.lastErr != nil { finalErr = fmt.Errorf("connection %d failed: retries=%d, err=%v", conn.ID, conn.retryTimes, conn.lastErr) } else { finalErr = fmt.Errorf("connection %d failed: retries=%d", conn.ID, conn.retryTimes) } break } } } f.connMu.Unlock() // Close the file before signaling completion // This ensures the file handle is released before Wait() returns f.fileMu.Lock() if f.file != nil { f.file.Close() f.file = nil } f.fileMu.Unlock() if finalErr != nil { f.setState(stateError) } else { f.setState(stateDone) } select { case f.doneCh <- finalErr: default: } } func (f *Fetcher) checkCompletion() bool { // Check if all data has been downloaded f.connMu.Lock() defer f.connMu.Unlock() totalDownloaded := int64(0) if f.resolveConn != nil { totalDownloaded += f.resolveConn.Downloaded } for _, conn := range f.connections { totalDownloaded += conn.Downloaded } if f.meta.Res.Size > 0 && totalDownloaded >= f.meta.Res.Size { // Don't start a new goroutine - let the caller handle completion return true } // Check if all connections completed allCompleted := true if f.resolveConn != nil && !f.resolveConn.Completed && f.resolveConn.State != connCompleted { allCompleted = false } for _, conn := range f.connections { if !conn.Completed && conn.State != connCompleted && conn.State != connFailed { allCompleted = false break } } if allCompleted { // Don't start a new goroutine - let the caller handle completion return true } return false } // Patch modifies the HTTP request information. func (f *Fetcher) Patch(req *base.Request, opts *base.Options) error { // Patch request info if req != nil { if req.URL != "" { f.meta.Req.URL = req.URL // Clear redirect URL when URL is changed, so new requests use the new URL f.updateRedirectURL("") } if req.Extra != nil { if err := base.ParseReqExtra[fhttp.ReqExtra](req); err != nil { return err } patchExtra := req.Extra.(*fhttp.ReqExtra) // Merge Extra fields instead of replacing entirely if f.meta.Req.Extra == nil { f.meta.Req.Extra = &fhttp.ReqExtra{} } existingExtra := f.meta.Req.Extra.(*fhttp.ReqExtra) // Update Method only if non-empty if patchExtra.Method != "" { existingExtra.Method = patchExtra.Method } // Update Body only if non-empty if patchExtra.Body != "" { existingExtra.Body = patchExtra.Body } // Merge Headers: existing keys are overwritten, new keys are added if patchExtra.Header != nil { if existingExtra.Header == nil { existingExtra.Header = make(map[string]string) } for k, v := range patchExtra.Header { existingExtra.Header[k] = v } } } // Merge Labels: existing keys are overwritten, new keys are added if req.Labels != nil { if f.meta.Req.Labels == nil { f.meta.Req.Labels = make(map[string]string) } for k, v := range req.Labels { f.meta.Req.Labels[k] = v } } if req.Proxy != nil { f.meta.Req.Proxy = req.Proxy } } return nil } func (f *Fetcher) Pause() error { if f.cancel != nil { f.cancel() } if f.resolveCancel != nil { f.resolveCancel() } // Stop prefetch if running if f.prefetchStopCh != nil { select { case <-f.prefetchStopCh: // Already closed default: close(f.prefetchStopCh) } } // Wait for downloadLoop to exit first (it will call wg.Wait internally) if f.downloadLoopDone != nil { <-f.downloadLoopDone } // Wait for all connection goroutines to stop f.wg.Wait() // Wait for prefetch to finish for f.prefetchStopCh != nil && !f.prefetchDone.Load() { time.Sleep(10 * time.Millisecond) } // Clean up prefetch file f.cleanupPrefetchFile() // Clean up resolve response if still held f.resolveRespLock.Lock() if f.resolveResp != nil { f.resolveResp.Body.Close() f.resolveResp = nil } f.resolveRespLock.Unlock() f.fileMu.Lock() if f.file != nil { f.file.Close() f.file = nil } f.fileMu.Unlock() f.setState(statePaused) return nil } func (f *Fetcher) Close() error { return f.Pause() } func (f *Fetcher) Meta() *fetcher.FetcherMeta { return f.meta } func (f *Fetcher) Stats() any { f.connMu.Lock() defer f.connMu.Unlock() statsConnections := make([]*fhttp.StatsConnection, 0) for _, connection := range f.connections { statsConnections = append(statsConnections, &fhttp.StatsConnection{ Downloaded: connection.Downloaded, Completed: connection.Completed, Failed: connection.failed, RetryTimes: connection.retryTimes, }) } return &fhttp.Stats{ Connections: statsConnections, } } func (f *Fetcher) Progress() fetcher.Progress { p := make(fetcher.Progress, 0) total := int64(0) if f.resolveConn != nil { total += f.resolveConn.Downloaded } f.connMu.Lock() for _, conn := range f.connections { total += conn.Downloaded } f.connMu.Unlock() p = append(p, total) return p } func (f *Fetcher) Wait() error { return <-f.doneCh } ================================================ FILE: internal/protocol/http/fetcher_manager.go ================================================ 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: internal/protocol/http/fetcher_test.go ================================================ package http import ( "encoding/json" "fmt" "net" gohttp "net/http" "net/url" "os" "strings" "testing" "time" "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/http" ) func TestFetcher_Resolve(t *testing.T) { testResolve(test.StartTestFileServer, test.BuildName, t, func(err error) (*base.Resource, error) { return &base.Resource{ Size: test.BuildSize, Range: true, Files: []*base.FileInfo{ { Name: test.BuildName, Size: test.BuildSize, }, }, }, nil }) testResolve(test.StartTestCustomServer, "disposition", t, func(err error) (*base.Resource, error) { return &base.Resource{ Size: test.BuildSize, Range: false, Files: []*base.FileInfo{ { Name: test.BuildName, Size: test.BuildSize, }, }, }, nil }) testResolve(test.StartTestCustomServer, "encoded-word", t, func(err error) (*base.Resource, error) { return &base.Resource{ Size: test.BuildSize, Range: false, Files: []*base.FileInfo{ { Name: test.TestChineseFileName, Size: test.BuildSize, }, }, }, nil }) testResolve(test.StartTestCustomServer, "no-encode", t, func(err error) (*base.Resource, error) { return &base.Resource{ Size: test.BuildSize, Range: false, Files: []*base.FileInfo{ { Name: test.TestChineseFileName, Size: test.BuildSize, }, }, }, nil }) testResolve(test.StartTestCustomServer, "%E6%B5%8B%E8%AF%95.zip", t, func(err error) (*base.Resource, error) { return &base.Resource{ Size: 0, Range: false, Files: []*base.FileInfo{ { Name: test.TestChineseFileName, Size: 0, }, }, }, nil }) testResolve(test.StartTestCustomServer, test.BuildName, t, func(err error) (*base.Resource, error) { return &base.Resource{ Size: 0, Range: false, Files: []*base.FileInfo{ { Name: test.BuildName, Size: 0, }, }, }, nil }) // Test mixed encoding Content-Disposition where mime.ParseMediaType fails // due to invalid characters, but filename*= contains the correct UTF-8 encoded name testResolve(test.StartTestCustomServer, "mixed-encoding", t, func(err error) (*base.Resource, error) { return &base.Resource{ Size: test.BuildSize, Range: false, Files: []*base.FileInfo{ { Name: test.TestChineseFileName, Size: test.BuildSize, }, }, }, nil }) // Test filename*= only (RFC 5987 format) testResolve(test.StartTestCustomServer, "filename-star", t, func(err error) (*base.Resource, error) { return &base.Resource{ Size: test.BuildSize, Range: false, Files: []*base.FileInfo{ { Name: test.TestChineseFileName, Size: test.BuildSize, }, }, }, nil }) // Test GBK-encoded filename (common on Chinese Windows servers) // Before fix: "测试.zip" sent as GBK bytes -> parsed as "²âÊÔ.zip" (garbled) // After fix: correctly decoded back to "测试.zip" testResolve(test.StartTestCustomServer, "gbk-encoded", t, func(err error) (*base.Resource, error) { return &base.Resource{ Size: test.BuildSize, Range: false, Files: []*base.FileInfo{ { Name: test.TestChineseFileName, Size: test.BuildSize, }, }, }, nil }) // Test filename with plus signs (e.g., C++ Primer) // Before fix: %2B decoded to space -> "C++ Primer" became "C Primer" // After fix: %2B correctly decoded to + -> "C++ Primer Plus.mobi" testResolve(test.StartTestCustomServer, "plus-sign-encoded", t, func(err error) (*base.Resource, error) { return &base.Resource{ Size: test.BuildSize, Range: false, Files: []*base.FileInfo{ { Name: "C++ Primer Plus.mobi", Size: test.BuildSize, }, }, }, nil }) // Test filename with plus sign in URL path // Before fix: %2B decoded to space // After fix: %2B correctly decoded to + testResolve(test.StartTestCustomServer, "C%2B%2B%20Primer.txt", t, func(err error) (*base.Resource, error) { return &base.Resource{ Size: 0, Range: false, Files: []*base.FileInfo{ { Name: "C++ Primer.txt", Size: 0, }, }, }, nil }) // Test filename with HTML-encoded ampersand (fixes issue with & being truncated) // Before fix: "查询处理&优化.pptx" -> "查询处理&" (truncated at semicolon) // After fix: correctly decoded to "查询处理&优化.pptx" testResolve(test.StartTestCustomServer, "ampersand-encoded", t, func(err error) (*base.Resource, error) { return &base.Resource{ Size: test.BuildSize, Range: false, Files: []*base.FileInfo{ { Name: "查询处理&优化.pptx", Size: test.BuildSize, }, }, }, nil }) // Test unquoted filename with HTML-encoded ampersand testResolve(test.StartTestCustomServer, "ampersand-unquoted", t, func(err error) (*base.Resource, error) { return &base.Resource{ Size: test.BuildSize, Range: false, Files: []*base.FileInfo{ { Name: "test&file.txt", Size: test.BuildSize, }, }, }, nil }) // Test URL without file path - should use domain/host as filename testResolve(test.StartTestCustomServer, "", t, func(err error) (*base.Resource, error) { return &base.Resource{ Size: 0, Range: false, Files: []*base.FileInfo{ { Name: "127.0.0.1", Size: 0, }, }, }, nil }) // Test 403 Forbidden response handling testResolve(test.StartTestCustomServer, "forbidden", t, func(err error) (*base.Resource, error) { requestError := extractRequestError(err) if requestError != nil && requestError.Code == 403 { return nil, nil } return nil, err }) } func TestFetcher_ResolveWithHostHeader(t *testing.T) { listener := test.StartTestHostHeaderServer() defer listener.Close() fetcher := buildFetcher() err := fetcher.Resolve(&base.Request{ URL: "http://" + listener.Addr().String() + "/", Extra: &http.ReqExtra{ Header: map[string]string{ "Host": "test", }, }, }, &base.Options{ Name: test.DownloadName, Path: test.Dir, }) // The server should return 400 for invalid Host header if err == nil || !strings.Contains(err.Error(), "400") { t.Errorf("Resolve() got = %v, want error containing 400", err) } } func TestFetcher_ResolveWithInvalidHeader(t *testing.T) { listener := test.StartTestCustomServer() defer listener.Close() fetcher := buildFetcher() defer fetcher.Pause() // Close the resolve response to allow server shutdown err := fetcher.Resolve(&base.Request{ URL: "http://" + listener.Addr().String() + "/", Extra: &http.ReqExtra{ Header: map[string]string{ "Referer": "\rtest", }, }, }, &base.Options{ Name: test.DownloadName, Path: test.Dir, }) // Invalid header with \r should be sanitized by Go's http client, allowing the request to succeed if err != nil { t.Errorf("Resolve() got = %v, want nil (invalid headers should be sanitized)", err) } } func testResolve(startTestServer func() net.Listener, path string, t *testing.T, wantFn func(error) (*base.Resource, error)) { listener := startTestServer() defer listener.Close() fetcher := buildFetcher() defer fetcher.Pause() // Close the resolve response to allow server shutdown err := fetcher.Resolve(&base.Request{ URL: "http://" + listener.Addr().String() + "/" + path, }, &base.Options{ Name: test.DownloadName, Path: test.Dir, }) want, err := wantFn(err) if err != nil { t.Fatal(err) } if want != nil && !test.AssertResourceEqual(want, fetcher.meta.Res) { t.Errorf("Resolve() got = %+v, want %+v", fetcher.meta.Res, want) } } func TestFetcher_DownloadNormal(t *testing.T) { listener := test.StartTestFileServer() defer listener.Close() downloadNormal(listener, 1, t) downloadNormal(listener, 5, t) downloadNormal(listener, 8, t) downloadNormal(listener, 16, t) } func TestFetcher_DownloadContinue(t *testing.T) { listener := test.StartTestFileServer() defer listener.Close() downloadContinue(listener, 1, t) downloadContinue(listener, 5, t) downloadContinue(listener, 8, t) downloadContinue(listener, 16, t) } func TestFetcher_DownloadContinue_NoRangeRestart(t *testing.T) { listener := test.StartTestNoRangeSlowServer(time.Millisecond) defer listener.Close() fetcher := downloadReady(listener, 4, t) if err := fetcher.Start(); err != nil { t.Fatal(err) } time.Sleep(20 * time.Millisecond) stats := fetcher.Stats().(*http.Stats) if len(stats.Connections) != 1 { t.Fatalf("expected a single non-range connection, got %d", len(stats.Connections)) } if stats.Connections[0].Downloaded <= 0 || stats.Connections[0].Downloaded >= test.BuildSize { t.Fatalf("expected partial download before pause, got %d", stats.Connections[0].Downloaded) } if err := fetcher.Pause(); err != nil { t.Fatal(err) } if err := fetcher.Start(); err != nil { t.Fatal(err) } if err := fetcher.Wait(); err != nil { t.Fatal(err) } finalStats := fetcher.Stats().(*http.Stats) if len(finalStats.Connections) != 1 { t.Fatalf("expected a single non-range connection after resume, got %d", len(finalStats.Connections)) } if finalStats.Connections[0].Downloaded != test.BuildSize { t.Fatalf("downloaded bytes should restart cleanly: got %d, want %d", finalStats.Connections[0].Downloaded, test.BuildSize) } if total := fetcher.Progress().TotalDownloaded(); total != test.BuildSize { t.Fatalf("progress total = %d, want %d", total, test.BuildSize) } want := test.FileMd5(test.BuildFile) got := test.FileMd5(test.DownloadFile) if want != got { t.Errorf("Download() got = %v, want %v", got, want) } } func TestFetcher_DownloadChunked(t *testing.T) { listener := test.StartTestCustomServer() defer listener.Close() downloadNormal(listener, 1, t) downloadNormal(listener, 2, t) } func TestFetcher_DownloadPost(t *testing.T) { listener := test.StartTestPostServer() defer listener.Close() downloadPost(listener, 1, t) } func TestFetcher_DownloadRetry(t *testing.T) { listener := test.StartTestRetryServer() defer listener.Close() downloadNormal(listener, 1, t) } func TestFetcher_DownloadError(t *testing.T) { listener := test.StartTestErrorServer() defer listener.Close() downloadError(listener, 1, t) } func TestFetcher_DownloadLimit(t *testing.T) { listener := test.StartTestLimitServer(4, 0) defer listener.Close() downloadNormal(listener, 1, t) downloadNormal(listener, 2, t) downloadNormal(listener, 8, t) } func TestFetcher_DownloadResponseBodyReadTimeout(t *testing.T) { // Server will timeout once (first request delays longer than readTimeout), // then subsequent requests work normally listener := test.StartTestTimeoutOnceServer(readTimeout.Milliseconds() + 5000) defer listener.Close() for _, connections := range []int{1, 4} { os.Remove(test.DownloadFile) fetcher := downloadReady(listener, connections, t) if err := fetcher.Start(); err != nil { t.Fatal(err) } if err := fetcher.Wait(); err != nil { t.Fatal(err) } stats := fetcher.Stats().(*http.Stats) if len(stats.Connections) == 0 { t.Fatalf("expected connections stats for timeout test") } // Verify successful download after timeout recovery want := test.FileMd5(test.BuildFile) got := test.FileMd5(test.DownloadFile) if want != got { t.Errorf("Download() got = %v, want %v", got, want) } // Verify timeouts don't count as failures (retryTimes should be 0) for _, conn := range stats.Connections { if conn.Failed { t.Fatalf("expected no counted failures after timeout recovery, got retries=%d", conn.RetryTimes) } if conn.RetryTimes != 0 { t.Fatalf("expected retryTimes to stay zero for non-counted timeouts, got %d", conn.RetryTimes) } } } } func TestFetcher_Download500Recovery(t *testing.T) { // Server returns 500 for 15 seconds, then recovers listener := test.StartTestTemporary500Server(15 * time.Second) defer listener.Close() os.Remove(test.DownloadFile) fetcher := downloadReady(listener, 4, t) if err := fetcher.Start(); err != nil { t.Fatal(err) } if err := fetcher.Wait(); err != nil { t.Fatal(err) } // Verify successful download after 500 errors want := test.FileMd5(test.BuildFile) got := test.FileMd5(test.DownloadFile) if want != got { t.Errorf("Download() got = %v, want %v", got, want) } // Verify 500 errors don't count as failures (retryTimes should be 0) stats := fetcher.Stats().(*http.Stats) for _, conn := range stats.Connections { if conn.RetryTimes != 0 { t.Errorf("Expected retryTimes to be 0 for 500 errors (exempt), got %d", conn.RetryTimes) } } } func TestFetcher_DownloadOnBugFileServer(t *testing.T) { listener := test.StartTestRangeBugServer() defer listener.Close() downloadNormal(listener, 1, t) downloadNormal(listener, 4, t) } func TestFetcher_DownloadResume(t *testing.T) { listener := test.StartTestFileServer() defer listener.Close() downloadResume(listener, 1, t) downloadResume(listener, 5, t) downloadResume(listener, 8, t) downloadResume(listener, 16, t) } func TestFetcher_DownloadWithProxy(t *testing.T) { httpListener := test.StartTestFileServer() defer httpListener.Close() proxyListener := test.StartSocks5Server("", "") defer proxyListener.Close() downloadWithProxy(httpListener, proxyListener, t) } func TestFetcher_ConfigConnections(t *testing.T) { listener := test.StartTestFileServer() defer listener.Close() fetcher := doDownloadReady(buildConfigFetcher(config{ Connections: 16, }), listener, 0, t) err := fetcher.Start() if err != nil { t.Fatal(err) } err = fetcher.Wait() if err != nil { t.Fatal(err) } want := test.FileMd5(test.BuildFile) got := test.FileMd5(test.DownloadFile) if want != got { t.Errorf("Download() got = %v, want %v", got, want) } } func TestFetcher_ConfigUseServerCtime(t *testing.T) { listener := test.StartTestFileServer() defer listener.Close() fetcher := doDownloadReady(buildConfigFetcher(config{ Connections: 16, UseServerCtime: true, }), listener, 0, t) err := fetcher.Start() if err != nil { t.Fatal(err) } err = fetcher.Wait() if err != nil { t.Fatal(err) } want := test.FileMd5(test.BuildFile) got := test.FileMd5(test.DownloadFile) if want != got { t.Errorf("Download() got = %v, want %v", got, want) } } func TestFetcher_Stats(t *testing.T) { listener := test.StartTestFileServer() defer listener.Close() fetcher := doDownloadReady(buildConfigFetcher(config{ Connections: 16, }), listener, 0, t) err := fetcher.Start() if err != nil { t.Fatal(err) } err = fetcher.Wait() if err != nil { t.Fatal(err) } stats := fetcher.Stats().(*http.Stats) // With slow-start strategy, connection count may be less than max if download is fast // Just verify we have at least 1 connection and no more than max if len(stats.Connections) < 1 || len(stats.Connections) > 16 { t.Errorf("Stats() connection count got = %v, want between 1 and 16", len(stats.Connections)) } totalDownloaded := int64(0) for i, conn := range stats.Connections { t.Logf("Connection %d: Downloaded=%d, Completed=%v", i, conn.Downloaded, conn.Completed) totalDownloaded += conn.Downloaded } if totalDownloaded != test.BuildSize { t.Errorf("Stats() got = %v, want %v", totalDownloaded, test.BuildSize) } } // TestFetcher_DownloadOneTimeURL tests downloading from a URL that can only be accessed once // This simulates signed URLs or one-time download links that expire after first use func TestFetcher_DownloadOneTimeURL(t *testing.T) { listener := test.StartTestOneTimeServer() defer listener.Close() fetcher := buildFetcher() err := fetcher.Resolve(&base.Request{ URL: "http://" + listener.Addr().String() + "/" + test.BuildName, }, &base.Options{ Name: test.DownloadName, Path: test.Dir, Extra: &http.OptsExtra{ Connections: 4, // Try to use multiple connections, but only first should work }, }) if err != nil { t.Fatal(err) } err = fetcher.Start() if err != nil { t.Fatal(err) } err = fetcher.Wait() if err != nil { t.Fatal(err) } // Verify file content want := test.FileMd5(test.BuildFile) got := test.FileMd5(test.DownloadFile) if want != got { t.Errorf("Download() got = %v, want %v", got, want) } } // TestFetcher_SlowStartExpansion tests slow-start connection expansion edge cases // Tests that slow-start expansion reaches exactly maxConns // Expansion pattern: 1 -> 2 -> 4 -> 8 -> 16... // For max=5: 1 -> 2 -> 4 -> 5 (capped) // For max=9: 1 -> 2 -> 4 -> 8 -> 9 (capped) func TestFetcher_SlowStartExpansion(t *testing.T) { testCases := []struct { name string maxConns int }{ {"MaxConns5", 5}, // 1->2->4->5 {"MaxConns9", 9}, // 1->2->4->8->9 {"MaxConns8", 8}, // 1->2->4->8 } for _, tc := range testCases { tc := tc // capture range variable t.Run(tc.name, func(t *testing.T) { // Clean up any leftover files from previous tests os.Remove(test.DownloadFile) // Use 100ns delay per byte for faster test (~10MB/s theoretical) listener := test.StartTestSlowStartServer(100 * time.Nanosecond) // Ensure cleanup happens before next subtest cleanup := func() { listener.Close() os.Remove(test.DownloadFile) // Wait for server to fully stop time.Sleep(50 * time.Millisecond) } fetcher := buildConfigFetcher(config{ Connections: tc.maxConns, }) err := fetcher.Resolve(&base.Request{ URL: "http://" + listener.Addr().String() + "/" + test.BuildName, }, &base.Options{ Name: test.DownloadName, Path: test.Dir, Extra: &http.OptsExtra{ Connections: tc.maxConns, }, }) if err != nil { cleanup() t.Fatal(err) } err = fetcher.Start() if err != nil { cleanup() t.Fatal(err) } err = fetcher.Wait() if err != nil { t.Logf("Wait() returned error: %v", err) cleanup() t.Fatal(err) } // Check final connection count equals maxConns exactly stats := fetcher.Stats().(*http.Stats) finalConns := len(stats.Connections) // Debug: show connection details and metadata httpFetcher := fetcher.(*Fetcher) t.Logf("Resource: Size=%d, Range=%v", httpFetcher.Meta().Res.Size, httpFetcher.Meta().Res.Range) for i, conn := range stats.Connections { t.Logf("Connection %d: Downloaded=%d, Completed=%v", i, conn.Downloaded, conn.Completed) } if finalConns != tc.maxConns { t.Errorf("Expected exactly %d connections, got %d", tc.maxConns, finalConns) } // Verify file content before cleanup want := test.FileMd5(test.BuildFile) got := test.FileMd5(test.DownloadFile) if want != got { t.Errorf("Download() got = %v, want %v", got, want) } cleanup() }) } } // TestFetcher_AsyncPrefetch tests the async prefetch functionality // where data is downloaded in background during resolve phase and reused in start func TestFetcher_AsyncPrefetch(t *testing.T) { // Test 1: Prefetch completes entire file before Start is called t.Run("PrefetchComplete", func(t *testing.T) { listener := test.StartTestFileServer() defer listener.Close() fetcher := buildFetcher() err := fetcher.Resolve(&base.Request{ URL: "http://" + listener.Addr().String() + "/" + test.BuildName, }, &base.Options{ Name: test.DownloadName, Path: test.Dir, Extra: &http.OptsExtra{ Connections: 4, }, }) if err != nil { t.Fatal(err) } // Poll until prefetch completes the entire file (with timeout) timeout := time.After(30 * time.Second) ticker := time.NewTicker(100 * time.Millisecond) defer ticker.Stop() pollLoop: for { select { case <-timeout: t.Fatal("Timeout waiting for prefetch to complete") case <-ticker.C: if fetcher.prefetchSize.Load() >= test.BuildSize { break pollLoop } } } prefetchedBefore := fetcher.prefetchSize.Load() t.Logf("Prefetched bytes before Start: %d (%.2f MB)", prefetchedBefore, float64(prefetchedBefore)/(1024*1024)) // Should have prefetched the entire file if prefetchedBefore != test.BuildSize { t.Errorf("Prefetch should complete entire file, got %d, want %d", prefetchedBefore, test.BuildSize) } // Now start the download err = fetcher.Start() if err != nil { t.Fatal(err) } // Wait for download to complete err = fetcher.Wait() if err != nil { t.Fatal(err) } // Check how much was utilized from prefetch prefetchedUsed := fetcher.resolveDataPos.Load() t.Logf("Prefetched bytes used: %d (%.2f MB)", prefetchedUsed, float64(prefetchedUsed)/(1024*1024)) // Verify file is correct want := test.FileMd5(test.BuildFile) got := test.FileMd5(test.DownloadFile) if want != got { t.Errorf("Download() got = %v, want %v", got, want) } os.Remove(test.DownloadFile) }) // Test 2: Prefetch only downloads partial data before Start is called t.Run("PrefetchPartial", func(t *testing.T) { // Use slow server with 100 nanosecond delay per byte // This means ~10MB/s speed, so 100ms should download ~1MB listener := test.StartTestSlowStartServer(100 * time.Nanosecond) defer listener.Close() fetcher := buildFetcher() err := fetcher.Resolve(&base.Request{ URL: "http://" + listener.Addr().String() + "/" + test.BuildName, }, &base.Options{ Name: test.DownloadName, Path: test.Dir, Extra: &http.OptsExtra{ Connections: 4, }, }) if err != nil { t.Fatal(err) } // Wait only 100ms - should only prefetch a small portion time.Sleep(100 * time.Millisecond) prefetchedBefore := fetcher.prefetchSize.Load() t.Logf("Prefetched bytes before Start: %d (%.2f KB)", prefetchedBefore, float64(prefetchedBefore)/1024) // Verify we have partial data (not zero, but not complete) if prefetchedBefore == 0 { t.Log("Warning: No data prefetched, may be too slow") } if prefetchedBefore >= test.BuildSize { t.Log("Warning: Prefetch completed entire file, test may not be valid") } // Now start the download err = fetcher.Start() if err != nil { t.Fatal(err) } // Wait for download to complete err = fetcher.Wait() if err != nil { t.Fatal(err) } // Check stats - should have connections that downloaded remaining data stats := fetcher.Stats().(*http.Stats) t.Logf("Final connections: %d", len(stats.Connections)) prefetchedUsed := fetcher.resolveDataPos.Load() t.Logf("Prefetched bytes used: %d (%.2f KB)", prefetchedUsed, float64(prefetchedUsed)/1024) // Verify connections picked up where prefetch left off if len(stats.Connections) > 0 { firstConn := stats.Connections[0] t.Logf("First connection downloaded: %d bytes", firstConn.Downloaded) } // Verify file is correct want := test.FileMd5(test.BuildFile) got := test.FileMd5(test.DownloadFile) if want != got { t.Errorf("Download() got = %v, want %v", got, want) } os.Remove(test.DownloadFile) }) } // TestFetcher_DownloadExpiringRedirectURL tests that the fetcher correctly handles // expiring redirect URLs by falling back to the original URL and getting a new redirect. func TestFetcher_DownloadExpiringRedirectURL(t *testing.T) { // Test with redirect expiring after 2 requests // This means: // - Request 1: Resolve (original URL redirects to temp URL v1) // - Request 2: First download request to temp URL v1 succeeds // - Request 3: Second download request to temp URL v1 returns 403 // - Fetcher should then retry with original URL, get temp URL v2 // - Continue downloading from temp URL v2 t.Run("RedirectExpiresAfter2Requests", func(t *testing.T) { os.Remove(test.DownloadFile) // Create server with redirect expiring after 2 requests, with slow transfer to ensure // multiple connection attempts are needed listener := test.StartTestExpiringRedirectServer(2, 100*time.Nanosecond) defer listener.Close() fetcher := buildFetcher() err := fetcher.Resolve(&base.Request{ URL: "http://" + listener.Addr().String() + "/" + test.BuildName, }, &base.Options{ Name: test.DownloadName, Path: test.Dir, Extra: &http.OptsExtra{ Connections: 4, // Use multiple connections to trigger redirect expiration }, }) if err != nil { t.Fatal(err) } err = fetcher.Start() if err != nil { t.Fatal(err) } err = fetcher.Wait() if err != nil { t.Fatal(err) } // Verify file content is correct despite redirect expiration want := test.FileMd5(test.BuildFile) got := test.FileMd5(test.DownloadFile) if want != got { t.Errorf("Download() got = %v, want %v", got, want) } os.Remove(test.DownloadFile) }) // Test with redirect expiring after 5 requests (more room for initial connections) t.Run("RedirectExpiresAfter5Requests", func(t *testing.T) { os.Remove(test.DownloadFile) listener := test.StartTestExpiringRedirectServer(5, 100*time.Nanosecond) defer listener.Close() fetcher := buildFetcher() err := fetcher.Resolve(&base.Request{ URL: "http://" + listener.Addr().String() + "/" + test.BuildName, }, &base.Options{ Name: test.DownloadName, Path: test.Dir, Extra: &http.OptsExtra{ Connections: 8, }, }) if err != nil { t.Fatal(err) } err = fetcher.Start() if err != nil { t.Fatal(err) } err = fetcher.Wait() if err != nil { t.Fatal(err) } // Verify file content want := test.FileMd5(test.BuildFile) got := test.FileMd5(test.DownloadFile) if want != got { t.Errorf("Download() got = %v, want %v", got, want) } os.Remove(test.DownloadFile) }) } // TestFetcher_RetryAfterError tests that the fetcher can retry downloading // after a previous download attempt failed by calling Start() again. func TestFetcher_RetryAfterError(t *testing.T) { os.Remove(test.DownloadFile) // Server fails first 3 requests (after resolve), then recovers // With 1 connection and 3 retries: // - First Start(): requests 2, 3, 4 → all fail (3 retries exhausted) → returns error // - Second Start(): request 5 → succeeds (server recovered after 3 failures) listener := test.StartTestFailThenRecoverServer(3) defer listener.Close() fetcher := buildFetcher() err := fetcher.Resolve(&base.Request{ URL: "http://" + listener.Addr().String() + "/" + test.BuildName, }, &base.Options{ Name: test.DownloadName, Path: test.Dir, Extra: &http.OptsExtra{ Connections: 1, // Use single connection to simplify test }, }) if err != nil { t.Fatal(err) } // First download attempt - should fail because server returns 416 after resolve err = fetcher.Start() if err != nil { t.Fatal(err) } err = fetcher.Wait() // First attempt should fail with 416 error if err == nil { t.Fatal("Expected first download attempt to fail, but it succeeded") } t.Logf("First attempt failed as expected: %v", err) // Check that fetcher is in error state state := fetcher.getState() if state != stateError { t.Errorf("Expected fetcher to be in stateError, got %v", state) } // Verify that we can call Start() again after error // This tests the stateError handling in Start() err = fetcher.Start() if err != nil { t.Fatalf("Start() after error failed: %v", err) } // Wait for second attempt - should succeed now that server has recovered err = fetcher.Wait() if err != nil { t.Fatalf("Retry failed: %v", err) } // Verify file content want := test.FileMd5(test.BuildFile) got := test.FileMd5(test.DownloadFile) if want != got { t.Errorf("Download() got = %v, want %v", got, want) } os.Remove(test.DownloadFile) } func TestFetcherManager_ParseName(t *testing.T) { type args struct { u string } tests := []struct { name string args args want string }{ { name: "broken url", args: args{ u: "https://!@#%github.com", }, want: "", }, { name: "file path", args: args{ u: "https://github.com/index.html", }, want: "index.html", }, { name: "file path with query and hash", args: args{ u: "https://github.com/a/b/index.html/#list?name=1", }, want: "index.html", }, { name: "no file path", args: args{ u: "https://github.com", }, want: "github.com", }, } 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 downloadReady(listener net.Listener, connections int, t *testing.T) fetcher.Fetcher { return doDownloadReady(buildFetcher(), listener, connections, t) } func doDownloadReady(f fetcher.Fetcher, listener net.Listener, connections int, t *testing.T) fetcher.Fetcher { var extra any = nil if connections > 0 { extra = &http.OptsExtra{ Connections: connections, } } opts := &base.Options{ Name: test.DownloadName, Path: test.Dir, Extra: extra, } err := f.Resolve(&base.Request{ URL: "http://" + listener.Addr().String() + "/" + test.BuildName, }, opts) if err != nil { t.Fatal(err) } return f } func downloadNormal(listener net.Listener, connections int, t *testing.T) { fetcher := downloadReady(listener, connections, t) err := fetcher.Start() if err != nil { t.Fatal(err) } err = fetcher.Wait() if err != nil { t.Fatal(err) } want := test.FileMd5(test.BuildFile) got := test.FileMd5(test.DownloadFile) if want != got { t.Errorf("Download() got = %v, want %v", got, want) } } func downloadPost(listener net.Listener, connections int, t *testing.T) { // POST parameters must be set before Resolve since the new design // starts downloading during Resolve phase f := buildFetcher() var extra any = nil if connections > 0 { extra = &http.OptsExtra{ Connections: connections, } } opts := &base.Options{ Name: test.DownloadName, Path: test.Dir, Extra: extra, } req := &base.Request{ URL: "http://" + listener.Addr().String() + "/" + test.BuildName, Extra: &http.ReqExtra{ Method: "POST", Header: map[string]string{ "Authorization": "Bearer 123456", }, Body: fmt.Sprintf(`{"name":"%s"}`, test.BuildName), }, } err := f.Resolve(req, opts) if err != nil { t.Fatal(err) } err = f.Start() if err != nil { t.Fatal(err) } err = f.Wait() if err != nil { t.Fatal(err) } want := test.FileMd5(test.BuildFile) got := test.FileMd5(test.DownloadFile) if want != got { t.Errorf("Download() got = %v, want %v", got, want) } } func downloadContinue(listener net.Listener, connections int, t *testing.T) { fetcher := downloadReady(listener, connections, t) err := fetcher.Start() if err != nil { t.Fatal(err) } time.Sleep(time.Millisecond * 50) if err := fetcher.Pause(); err != nil { t.Fatal(err) } time.Sleep(time.Millisecond * 50) if err := fetcher.Start(); err != nil { t.Fatal(err) } err = fetcher.Wait() if err != nil { t.Fatal(err) } want := test.FileMd5(test.BuildFile) got := test.FileMd5(test.DownloadFile) if want != got { t.Errorf("Download() got = %v, want %v", got, want) } } func downloadError(listener net.Listener, connections int, t *testing.T) { fetcher := buildFetcher() err := fetcher.Resolve(&base.Request{ URL: "http://" + listener.Addr().String() + "/" + test.BuildName, }, &base.Options{ Name: test.DownloadName, Path: test.Dir, }) // With the new async design, Resolve may succeed (HTTP response received) // but errors occur during async download or Start/Wait if err != nil { // Error detected in Resolve - this is fine return } // Resolve succeeded, error should occur during Start/Wait err = fetcher.Start() if err != nil { // Error detected in Start - this is fine return } err = fetcher.Wait() if err == nil { t.Errorf("Expected error during download, but got none") } } func downloadResume(listener net.Listener, connections int, t *testing.T) { fetcher := downloadReady(listener, connections, t) err := fetcher.Start() if err != nil { t.Fatal(err) } fb := new(FetcherManager) time.Sleep(time.Millisecond * 50) data, err := fb.Store(fetcher) if err != nil { t.Fatal(err) } time.Sleep(time.Millisecond * 50) fetcher.Pause() _, f := fb.Restore() f(fetcher.Meta(), data) if err != nil { t.Fatal(err) } fetcher.Setup(controller.NewController()) fetcher.Start() err = fetcher.Wait() if err != nil { t.Fatal(err) } want := test.FileMd5(test.BuildFile) got := test.FileMd5(test.DownloadFile) if want != got { t.Errorf("Download() got = %v, want %v", got, want) } } func downloadWithProxy(httpListener net.Listener, proxyListener net.Listener, t *testing.T) { fetcher := downloadReady(httpListener, 4, t) ctl := controller.NewController() ctl.GetProxy = func(requestProxy *base.RequestProxy) func(*gohttp.Request) (*url.URL, error) { return (&base.DownloaderProxyConfig{ Enable: true, Scheme: "socks5", Host: proxyListener.Addr().String(), }).ToHandler() } fetcher.Setup(ctl) err := fetcher.Start() if err != nil { t.Fatal(err) } err = fetcher.Wait() if err != nil { t.Fatal(err) } want := test.FileMd5(test.BuildFile) got := test.FileMd5(test.DownloadFile) if want != got { t.Errorf("Download() got = %v, want %v", got, want) } } func buildFetcher() *Fetcher { fm := new(FetcherManager) fetcher := fm.Build() newController := controller.NewController() newController.GetConfig = func(v any) { json.Unmarshal([]byte(test.ToJson(fm.DefaultConfig())), v) } fetcher.Setup(newController) return fetcher.(*Fetcher) } func buildConfigFetcher(cfg config) fetcher.Fetcher { fetcher := new(FetcherManager).Build() newController := controller.NewController() newController.GetConfig = func(v any) { json.Unmarshal([]byte(test.ToJson(cfg)), v) } fetcher.Setup(newController) return fetcher } // TestFetcher_Patch_URLChange tests the Patch functionality where a failed download URL // is replaced with a working one. This simulates: // 1. Initial download attempt with a bad URL (returns 404) // 2. Patching the task with a new working URL // 3. Successful download after URL modification func TestFetcher_Patch_URLChange(t *testing.T) { listener := test.StartTestPatchURLServer() defer listener.Close() f := buildFetcher() badURL := "http://" + listener.Addr().String() + "/bad-url" goodURL := "http://" + listener.Addr().String() + "/good-url" opts := &base.Options{ Name: test.DownloadName, Path: test.Dir, Extra: &http.OptsExtra{Connections: 1}, } // Step 1: Try to resolve with bad URL - should fail with error err := f.Resolve(&base.Request{URL: badURL}, opts) if err == nil { t.Fatal("Expected error for bad URL, got nil") } // Step 2: Create a new fetcher and resolve with bad URL but don't wait // We need to test patching a task that has been created f2 := buildFetcher() // First resolve with good URL to create a valid fetcher state err = f2.Resolve(&base.Request{URL: goodURL}, opts) if err != nil { t.Fatal(err) } // Verify initial URL if f2.meta.Req.URL != goodURL { t.Errorf("Initial URL = %v, want %v", f2.meta.Req.URL, goodURL) } // Step 3: Patch to change URL (simulating URL change scenario) newURL := "http://" + listener.Addr().String() + "/good-url" err = f2.Patch(&base.Request{URL: newURL}, nil) if err != nil { t.Fatal(err) } // Verify URL was patched if f2.meta.Req.URL != newURL { t.Errorf("Patched URL = %v, want %v", f2.meta.Req.URL, newURL) } // Step 4: Start download and verify success err = f2.Start() if err != nil { t.Fatal(err) } err = f2.Wait() if err != nil { t.Fatal(err) } // Verify file was downloaded correctly want := test.FileMd5(test.BuildFile) got := test.FileMd5(test.DownloadFile) if want != got { t.Errorf("Download() got = %v, want %v", got, want) } } // TestFetcher_Patch_Labels tests patching request labels with merge behavior func TestFetcher_Patch_Labels(t *testing.T) { listener := test.StartTestFileServer() defer listener.Close() f := buildFetcher() opts := &base.Options{ Name: test.DownloadName, Path: test.Dir, Extra: &http.OptsExtra{Connections: 1}, } err := f.Resolve(&base.Request{ URL: "http://" + listener.Addr().String() + "/" + test.BuildName, Labels: map[string]string{ "key1": "value1", "key3": "value3", }, }, opts) if err != nil { t.Fatal(err) } // Verify initial labels if f.meta.Req.Labels["key1"] != "value1" { t.Errorf("Initial label key1 = %v, want value1", f.meta.Req.Labels["key1"]) } if f.meta.Req.Labels["key3"] != "value3" { t.Errorf("Initial label key3 = %v, want value3", f.meta.Req.Labels["key3"]) } // Patch with new labels - key1 should be overwritten, key2 should be added, key3 should remain patchReq := &base.Request{ Labels: map[string]string{ "key1": "modified", "key2": "newValue", }, } err = f.Patch(patchReq, nil) if err != nil { t.Fatal(err) } // Verify labels were merged correctly if f.meta.Req.Labels["key1"] != "modified" { t.Errorf("Patched label key1 = %v, want modified", f.meta.Req.Labels["key1"]) } if f.meta.Req.Labels["key2"] != "newValue" { t.Errorf("Patched label key2 = %v, want newValue", f.meta.Req.Labels["key2"]) } // key3 should remain unchanged if f.meta.Req.Labels["key3"] != "value3" { t.Errorf("Label key3 = %v, want value3 (should remain unchanged)", f.meta.Req.Labels["key3"]) } } // TestFetcher_Patch_Extra tests patching request Extra with merge behavior func TestFetcher_Patch_Extra(t *testing.T) { listener := test.StartTestFileServer() defer listener.Close() f := buildFetcher() opts := &base.Options{ Name: test.DownloadName, Path: test.Dir, Extra: &http.OptsExtra{Connections: 1}, } // Resolve with initial Extra err := f.Resolve(&base.Request{ URL: "http://" + listener.Addr().String() + "/" + test.BuildName, Extra: &http.ReqExtra{ Method: "GET", Body: "initial body", Header: map[string]string{ "Authorization": "Bearer token123", "X-Custom": "original", }, }, }, opts) if err != nil { t.Fatal(err) } // Verify initial Extra initialExtra := f.meta.Req.Extra.(*http.ReqExtra) if initialExtra.Method != "GET" { t.Errorf("Initial Method = %v, want GET", initialExtra.Method) } if initialExtra.Body != "initial body" { t.Errorf("Initial Body = %v, want 'initial body'", initialExtra.Body) } if initialExtra.Header["Authorization"] != "Bearer token123" { t.Errorf("Initial Authorization header = %v, want 'Bearer token123'", initialExtra.Header["Authorization"]) } if initialExtra.Header["X-Custom"] != "original" { t.Errorf("Initial X-Custom header = %v, want 'original'", initialExtra.Header["X-Custom"]) } // Patch with partial Extra - only update some fields patchReq := &base.Request{ Extra: &http.ReqExtra{ Method: "POST", // Update method // Body is empty, should NOT update Header: map[string]string{ "X-Custom": "modified", // Overwrite existing "X-New": "added", // Add new // Authorization is not in patch, should remain }, }, } err = f.Patch(patchReq, nil) if err != nil { t.Fatal(err) } // Verify Extra was merged correctly patchedExtra := f.meta.Req.Extra.(*http.ReqExtra) // Method should be updated if patchedExtra.Method != "POST" { t.Errorf("Patched Method = %v, want POST", patchedExtra.Method) } // Body should remain unchanged (patch had empty body) if patchedExtra.Body != "initial body" { t.Errorf("Patched Body = %v, want 'initial body' (should remain unchanged)", patchedExtra.Body) } // Authorization header should remain unchanged if patchedExtra.Header["Authorization"] != "Bearer token123" { t.Errorf("Authorization header = %v, want 'Bearer token123' (should remain unchanged)", patchedExtra.Header["Authorization"]) } // X-Custom header should be overwritten if patchedExtra.Header["X-Custom"] != "modified" { t.Errorf("X-Custom header = %v, want 'modified'", patchedExtra.Header["X-Custom"]) } // X-New header should be added if patchedExtra.Header["X-New"] != "added" { t.Errorf("X-New header = %v, want 'added'", patchedExtra.Header["X-New"]) } } // TestFetcher_Patch_NilData tests that Patch with nil data doesn't cause errors func TestFetcher_Patch_NilData(t *testing.T) { listener := test.StartTestFileServer() defer listener.Close() f := buildFetcher() opts := &base.Options{ Name: test.DownloadName, Path: test.Dir, Extra: &http.OptsExtra{Connections: 1}, } err := f.Resolve(&base.Request{ URL: "http://" + listener.Addr().String() + "/" + test.BuildName, }, opts) if err != nil { t.Fatal(err) } originalURL := f.meta.Req.URL // Patch with nil data - should not cause error err = f.Patch(nil, nil) if err != nil { t.Fatal(err) } // Verify URL unchanged if f.meta.Req.URL != originalURL { t.Errorf("URL changed after nil patch: got %v, want %v", f.meta.Req.URL, originalURL) } // Patch with empty request - should not cause error err = f.Patch(&base.Request{}, nil) if err != nil { t.Fatal(err) } // Verify URL still unchanged if f.meta.Req.URL != originalURL { t.Errorf("URL changed after empty patch: got %v, want %v", f.meta.Req.URL, originalURL) } } // TestFetcher_Patch_CookieExpired tests the Patch functionality where a download fails // mid-way due to expired cookie, then succeeds after patching with a new valid cookie. // This simulates: // 1. Initial resolve with valid cookie succeeds // 2. Download starts but fails because cookie expires mid-download (server returns 401) // 3. User patches the task with a new valid cookie // 4. Download resumes and completes successfully func TestFetcher_Patch_CookieExpired(t *testing.T) { listener := test.StartTestCookieExpiringServer() defer listener.Close() downloadURL := "http://" + listener.Addr().String() + "/" + test.BuildName opts := &base.Options{ Name: test.DownloadName, Path: test.Dir, Extra: &http.OptsExtra{Connections: 1}, } // Step 1: Resolve with old_token - should succeed (first request accepts old_token) f := buildFetcher() err := f.Resolve(&base.Request{ URL: downloadURL, Extra: &http.ReqExtra{ Header: map[string]string{ "Cookie": "session=old_token", }, }, }, opts) if err != nil { t.Fatalf("Resolve should succeed with old_token: %v", err) } // Verify initial cookie initialExtra := f.meta.Req.Extra.(*http.ReqExtra) if initialExtra.Header["Cookie"] != "session=old_token" { t.Errorf("Initial Cookie = %v, want session=old_token", initialExtra.Header["Cookie"]) } // Step 2: Start download - should fail because old_token is now expired // (server only accepts old_token for first request, subsequent requests need new_token) err = f.Start() if err != nil { t.Fatalf("Start failed: %v", err) } err = f.Wait() // Download should fail with 401 error if err == nil { t.Fatal("Expected download to fail with expired cookie, but it succeeded") } t.Logf("Download failed as expected: %v", err) // Step 3: Patch with new valid cookie err = f.Patch(&base.Request{ Extra: &http.ReqExtra{ Header: map[string]string{ "Cookie": "session=new_token", }, }, }, nil) if err != nil { t.Fatalf("Patch to update cookie failed: %v", err) } // Verify cookie was updated patchedExtra := f.meta.Req.Extra.(*http.ReqExtra) if patchedExtra.Header["Cookie"] != "session=new_token" { t.Errorf("Cookie should be updated: got %v, want session=new_token", patchedExtra.Header["Cookie"]) } // Step 4: Restart download - should succeed with new cookie err = f.Start() if err != nil { t.Fatalf("Restart failed: %v", err) } err = f.Wait() if err != nil { t.Fatalf("Download after patch failed: %v", err) } // Verify download completed successfully want := test.FileMd5(test.BuildFile) got := test.FileMd5(test.DownloadFile) if want != got { t.Errorf("File MD5 mismatch: got %v, want %v", got, want) } } ================================================ FILE: internal/protocol/http/filename_parse_test.go ================================================ package http import ( "testing" ) // TestParseFilenameWithAmpersand tests the fix for filenames containing & character // Issue: filenames with & are HTML-encoded as & and then truncated at the semicolon func TestParseFilenameWithAmpersand(t *testing.T) { tests := []struct { name string disposition string want string }{ { name: "quoted filename with &", disposition: `attachment; filename="查询处理&优化.pptx"`, want: "查询处理&优化.pptx", }, { name: "unquoted filename with &", disposition: `attachment; filename=test&file.txt`, want: "test&file.txt", }, { name: "quoted filename with & and extra params", disposition: `attachment; filename="test&file.txt"; charset=utf-8`, want: "test&file.txt", }, { name: "filename with multiple HTML entities", disposition: `attachment; filename="test&<>.txt"`, want: "test&<>.txt", }, { name: "normal filename without entities", disposition: `attachment; filename="normal.txt"`, want: "normal.txt", }, { name: "filename with actual ampersand (no encoding)", disposition: `attachment; filename="test&file.txt"`, want: "test&file.txt", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := parseFilename(tt.disposition) if got != tt.want { t.Errorf("parseFilename() = %q, want %q", got, tt.want) } }) } } // TestFindParamValueEnd tests the helper function for finding parameter value boundaries func TestFindParamValueEnd(t *testing.T) { tests := []struct { name string value string want int }{ { name: "quoted value with semicolon inside", value: `"test&file.txt"; charset=utf-8`, want: 19, // Position of semicolon after closing quote }, { name: "quoted value without semicolon after", value: `"test.txt"`, want: -1, }, { name: "unquoted value with semicolon", value: `test.txt; charset=utf-8`, want: 8, }, { name: "unquoted value without semicolon", value: `test.txt`, want: -1, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := findParamValueEnd(tt.value) if got != tt.want { t.Errorf("findParamValueEnd() = %d, want %d", got, tt.want) } }) } } // TestUnescapeHTMLEntities tests HTML entity unescaping func TestUnescapeHTMLEntities(t *testing.T) { tests := []struct { name string in string want string }{ { name: "ampersand", in: "test&file.txt", want: "test&file.txt", }, { name: "less than and greater than", in: "<test>.txt", want: ".txt", }, { name: "quote", in: "test"file.txt", want: "test\"file.txt", }, { name: "multiple entities", in: "a&b<c>d"e", want: "a&bd\"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) } }) } } ================================================ FILE: internal/protocol/http/helper.go ================================================ package http import ( "bytes" "context" "crypto/tls" "errors" "fmt" "io" "mime" "net" "net/http" "net/http/cookiejar" "net/url" "strings" "time" "unicode/utf8" "github.com/GopeedLab/gopeed/pkg/base" fhttp "github.com/GopeedLab/gopeed/pkg/protocol/http" "github.com/GopeedLab/gopeed/pkg/util" "golang.org/x/text/encoding/simplifiedchinese" ) type RequestError struct { Code int } func NewRequestError(code int) *RequestError { return &RequestError{Code: code} } func (re *RequestError) Error() string { return fmt.Sprintf("http request fail, code:%d", re.Code) } func isFailureExemptHTTPCode(code int) bool { if code >= 500 && code <= 599 { return true } switch code { case 429, 408, 440, 499: return true default: return false } } func shouldCountHTTPFailure(err error) bool { var re *RequestError if !errors.As(err, &re) { return false } return !isFailureExemptHTTPCode(re.Code) } func extractRequestError(err error) *RequestError { var re *RequestError if errors.As(err, &re) { return re } return nil } // buildRequest creates an HTTP request using the redirect URL if available. func (f *Fetcher) buildRequest(ctx context.Context, req *base.Request) (httpReq *http.Request, err error) { return f.buildRequestWithURL(ctx, req, true) } // buildRequestWithOriginalURL creates an HTTP request using the original URL. // This is used for retrying when the redirect URL has expired. func (f *Fetcher) buildRequestWithOriginalURL(ctx context.Context, req *base.Request) (httpReq *http.Request, err error) { return f.buildRequestWithURL(ctx, req, false) } // buildRequestWithURL creates an HTTP request. // If useRedirect is true and a redirect URL exists, it will be used; otherwise the original URL is used. func (f *Fetcher) buildRequestWithURL(ctx context.Context, req *base.Request, useRedirect bool) (httpReq *http.Request, err error) { var reqUrl string f.redirectLock.Lock() if useRedirect && f.redirectURL != "" { reqUrl = f.redirectURL } else { reqUrl = req.URL } f.redirectLock.Unlock() var ( method string body io.Reader ) headers := http.Header{} if req.Extra == nil { method = http.MethodGet } else { extra := req.Extra.(*fhttp.ReqExtra) if extra.Method != "" { method = extra.Method } else { method = http.MethodGet } if len(extra.Header) > 0 { for k, v := range extra.Header { headers.Set(k, strings.TrimSpace(v)) } } if extra.Body != "" { body = bytes.NewBufferString(extra.Body) } } if _, ok := headers[base.HttpHeaderUserAgent]; !ok { headers.Set(base.HttpHeaderUserAgent, strings.TrimSpace(f.config.UserAgent)) } if ctx != nil { httpReq, err = http.NewRequestWithContext(ctx, method, reqUrl, body) } else { httpReq, err = http.NewRequest(method, reqUrl, body) } if err != nil { return } httpReq.Header = headers if host := headers.Get(base.HttpHeaderHost); host != "" { httpReq.Host = host } return httpReq, nil } // updateRedirectURL updates the redirect URL from the response. // This is called when a request using the original URL succeeds after the redirect URL expired. func (f *Fetcher) updateRedirectURL(url string) { f.redirectLock.Lock() f.redirectURL = url f.redirectLock.Unlock() } // hasRedirectURL checks if a redirect URL exists and is different from the original URL. func (f *Fetcher) hasRedirectURL() bool { f.redirectLock.Lock() defer f.redirectLock.Unlock() return f.redirectURL != "" && f.redirectURL != f.meta.Req.URL } // isRedirectExpiredError checks if the error indicates that the redirect URL may have expired. // This includes 403 (Forbidden), 401 (Unauthorized), 410 (Gone), and network errors. func isRedirectExpiredError(err error) bool { if err == nil { return false } // Check for specific HTTP error codes that might indicate URL expiration if re := extractRequestError(err); re != nil { switch re.Code { case 401, 403, 404, 410: return true } } return false } // tryFallbackToOriginalURL attempts to make a request using the original URL // when the redirect URL has expired. Returns the response if successful. func (f *Fetcher) tryFallbackToOriginalURL(ctx context.Context, client *http.Client, rangeStart, rangeEnd int64) (*http.Response, error) { httpReq, err := f.buildRequestWithOriginalURL(ctx, f.meta.Req) if err != nil { return nil, err } if f.meta.Res.Range && rangeEnd > 0 { httpReq.Header.Set(base.HttpHeaderRange, fmt.Sprintf(base.HttpHeaderRangeFormat, rangeStart, rangeEnd)) } resp, err := client.Do(httpReq) if err != nil { return nil, err } if resp.StatusCode != base.HttpCodeOK && resp.StatusCode != base.HttpCodePartialContent { resp.Body.Close() return nil, NewRequestError(resp.StatusCode) } return resp, nil } // buildClient creates an HTTP client with the default connection timeout. // Used for resolve phase where we don't have connection time data yet. func (f *Fetcher) buildClient() *http.Client { return f.buildClientWithTimeout(connectTimeout) } // buildFastFailClient creates an HTTP client with fast-fail timeout. // Uses max(minFastFailTimeout, maxConnTime) for fast-fail retry during download phase. func (f *Fetcher) buildFastFailClient() *http.Client { maxConn := f.maxConnTime.Load() if maxConn == 0 { // No successful connection yet, use default timeout return f.buildClientWithTimeout(connectTimeout) } timeout := maxConn if timeout >= minFastFailTimeout { // If greater than minFastFailTimeout, increase by 50% for safety margin timeout = int64(float64(timeout) * 1.5) } else { timeout = minFastFailTimeout } return f.buildClientWithTimeout(time.Duration(timeout)) } // buildClientWithTimeout creates an HTTP client with the specified connection timeout. func (f *Fetcher) buildClientWithTimeout(timeout time.Duration) *http.Client { transport := &http.Transport{ DialContext: (&net.Dialer{ Timeout: timeout, }).DialContext, Proxy: f.ctl.GetProxy(f.meta.Req.Proxy), TLSClientConfig: &tls.Config{ InsecureSkipVerify: f.meta.Req.SkipVerifyCert, }, TLSHandshakeTimeout: timeout, } jar, _ := cookiejar.New(nil) return &http.Client{ Transport: transport, Jar: jar, } } // ============================================================================ // Filename Parsing // ============================================================================ // parseFilename extracts filename from Content-Disposition header func parseFilename(contentDisposition string) string { // Try RFC 5987 extended notation first (filename*=) if filename := parseFilenameExtended(contentDisposition); filename != "" { return filename } // Try standard MIME parsing _, params, err := mime.ParseMediaType(contentDisposition) if err == nil { if filename := params["filename"]; filename != "" { return decodeFilenameParam(filename) } } // Fallback to manual parsing return parseFilenameFallback(contentDisposition) } // parseFilenameExtended handles RFC 5987 extended notation (filename*=) // Format: filename*=charset'language'value (e.g., UTF-8”%E6%B5%8B%E8%AF%95.zip) func parseFilenameExtended(cd string) string { lower := strings.ToLower(cd) idx := strings.Index(lower, "filename*=") if idx == -1 { return "" } value := cd[idx+len("filename*="):] // Find the end of the value using proper quote handling endIdx := findParamValueEnd(value) if endIdx != -1 { value = value[:endIdx] } value = strings.TrimSpace(value) // Try charset''encoded format (e.g., UTF-8''%E4%B8%AD%E6%96%87.txt) parts := strings.SplitN(value, "''", 2) if len(parts) == 2 { // Use PathUnescape to handle %2B correctly (should decode to +, not space) decoded, err := url.PathUnescape(parts[1]) if err == nil { return decoded } } // Try charset'language'encoded format parts = strings.SplitN(value, "'", 3) if len(parts) >= 3 { // Use PathUnescape to handle %2B correctly (should decode to +, not space) decoded, err := url.PathUnescape(parts[2]) if err == nil { return decoded } } return "" } // decodeFilenameParam decodes filename parameter value // Handles HTML entities, MIME encoded-word, URL encoding, and GBK encoding fallback func decodeFilenameParam(filename string) string { // First, unescape HTML entities (e.g., & -> &, < -> <, > -> >) // This must be done before other decoding to handle cases where servers // HTML-encode special characters in filenames filename = unescapeHTMLEntities(filename) // Handle RFC 2047 encoded word (=?charset?encoding?text?=) if strings.HasPrefix(filename, "=?") { decoder := new(mime.WordDecoder) normalizedFilename := strings.Replace(filename, "UTF8", "UTF-8", 1) if decoded, err := decoder.Decode(normalizedFilename); err == nil { return decoded } } // Try URL decoding - use PathUnescape for filenames to handle %2B correctly decoded := util.TryUrlPathUnescape(filename) // If not valid UTF-8, try GBK decoding (common for Chinese websites) if !utf8.ValidString(decoded) { if gbkDecoded := tryDecodeGBK(decoded); gbkDecoded != "" { return gbkDecoded } } return decoded } // unescapeHTMLEntities unescapes common HTML entities in filenames // This handles cases where servers HTML-encode special characters like & to & func unescapeHTMLEntities(s string) string { // Common HTML entities that might appear in filenames replacements := map[string]string{ "&": "&", "<": "<", ">": ">", """: "\"", "'": "'", "'": "'", } result := s for entity, char := range replacements { result = strings.ReplaceAll(result, entity, char) } return result } // tryDecodeGBK attempts to decode string as GBK encoding func tryDecodeGBK(s string) string { if len(s) == 0 { return "" } decoder := simplifiedchinese.GBK.NewDecoder() decoded, err := decoder.Bytes([]byte(s)) if err != nil { return "" } result := string(decoded) if utf8.ValidString(result) { return result } return "" } // parseFilenameFallback is a fallback parser for non-standard Content-Disposition func parseFilenameFallback(cd string) string { lower := strings.ToLower(cd) idx := strings.Index(lower, "filename=") if idx == -1 { return "" } value := cd[idx+len("filename="):] // Find the end of the value using proper quote handling endIdx := findParamValueEnd(value) if endIdx != -1 { value = value[:endIdx] } value = strings.TrimSpace(value) // Remove surrounding quotes if len(value) >= 2 { if (value[0] == '"' && value[len(value)-1] == '"') || (value[0] == '\'' && value[len(value)-1] == '\'') { value = value[1 : len(value)-1] } } return decodeFilenameParam(value) } // findParamValueEnd finds the end position of a parameter value in a Content-Disposition header. // It correctly handles quoted values where semicolons inside quotes should not be treated as delimiters. // It also handles HTML entities in unquoted values (e.g., & should not be split at the semicolon). // Returns the end index (exclusive) of the value, or -1 if it extends to the end of the string. func findParamValueEnd(value string) int { value = strings.TrimSpace(value) if len(value) == 0 { return 0 } // If the value starts with a quote, find the matching closing quote if value[0] == '"' || value[0] == '\'' { quote := value[0] // Find the closing quote, handling escaped quotes for i := 1; i < len(value); i++ { if value[i] == quote { // Check if it's escaped if i > 0 && value[i-1] == '\\' { continue } // Found closing quote, now look for ; after it remaining := value[i+1:] if semiIdx := strings.Index(remaining, ";"); semiIdx != -1 { return i + 1 + semiIdx } return -1 // No semicolon after closing quote } } // No closing quote found, treat rest of string as value return -1 } // Unquoted value - find the next semicolon that's not part of an HTML entity // HTML entities have the pattern &...; (e.g., & < > " ') for i := 0; i < len(value); i++ { if value[i] == ';' { // Check if this semicolon is part of an HTML entity // Look backwards for an & character isEntity := false if i > 0 { // Look for & before this semicolon (within reasonable distance, max 10 chars) for j := i - 1; j >= 0 && j >= i-10; j-- { if value[j] == '&' { // Found &, this semicolon might be part of an HTML entity // Check if there are only alphanumeric or # between & and ; entityChars := value[j+1 : i] if len(entityChars) > 0 && isValidHTMLEntityChars(entityChars) { isEntity = true } break } // If we hit whitespace or another special char, stop looking if value[j] == ' ' || value[j] == '"' || value[j] == '\'' { break } } } if !isEntity { return i } } } return -1 // No semicolon, extends to end } // isValidHTMLEntityChars checks if a string contains only valid HTML entity characters // (alphanumeric and #, typically for entities like & < ' etc.) func isValidHTMLEntityChars(s string) bool { if len(s) == 0 { return false } for _, c := range s { if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '#') { return false } } return true } ================================================ FILE: internal/protocol/http/timeout_reader.go ================================================ 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() } } ================================================ FILE: internal/protocol/http/timeout_reader_test.go ================================================ 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 } ================================================ FILE: internal/test/httptest.go ================================================ package test import ( "context" "crypto/rand" "encoding/json" "errors" "fmt" "io" "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 ( BuildName = "build.data" BuildSize = 200 * 1024 * 1024 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(sl *shutdownListener) http.Handler { return http.FileServer(http.Dir(Dir)) }) } type SlowFileServer struct { delay time.Duration handler http.Handler } func (s *SlowFileServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { time.Sleep(s.delay) s.handler.ServeHTTP(w, r) } func StartTestSlowFileServer(delay time.Duration) net.Listener { return startTestServer(func(sl *shutdownListener) http.Handler { return &SlowFileServer{ delay: delay, handler: http.FileServer(http.Dir(Dir)), } }) } func StartTestCustomServer() net.Listener { return startTestServer(func(sl *shutdownListener) http.Handler { mux := http.NewServeMux() mux.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) { writer.WriteHeader(200) }) mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) { file, err := os.Open(BuildFile) if err != nil { panic(err) } defer file.Close() io.Copy(writer, file) }) mux.HandleFunc("/disposition", func(writer http.ResponseWriter, request *http.Request) { writer.Header().Set("Content-Disposition", "attachment; filename=\""+BuildName+"\"") writer.Header().Set("Content-Type", "application/octet-stream") writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize)) file, err := os.Open(BuildFile) if err != nil { panic(err) } defer file.Close() io.Copy(writer, file) }) mux.HandleFunc("/encoded-word", func(writer http.ResponseWriter, request *http.Request) { writer.Header().Set("Content-Disposition", "attachment; filename=\"=?UTF8?B?5rWL6K+VLnppcA==?=\"") writer.Header().Set("Content-Type", "application/octet-stream") writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize)) file, err := os.Open(BuildFile) if err != nil { panic(err) } defer file.Close() io.Copy(writer, file) }) mux.HandleFunc("/%E6%B5%8B%E8%AF%95.zip", func(writer http.ResponseWriter, request *http.Request) { file, err := os.Open(BuildFile) if err != nil { panic(err) } defer file.Close() io.Copy(writer, file) }) mux.HandleFunc("/no-encode", func(writer http.ResponseWriter, request *http.Request) { writer.Header().Set("Content-Disposition", "attachment; filename="+TestChineseFileName) writer.Header().Set("Content-Type", "application/octet-stream") writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize)) file, err := os.Open(BuildFile) if err != nil { panic(err) } defer file.Close() io.Copy(writer, file) }) // Test endpoint for mixed encoding: filename= with garbled characters (including special chars) // and filename*= with proper UTF-8. This tests the case where mime.ParseMediaType fails // due to invalid characters like 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="garbledchars.zip";filename*=UTF-8''%E6%B5%8B%E8%AF%95.zip`) writer.Header().Set("Content-Type", "application/octet-stream") writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize)) file, err := os.Open(BuildFile) if err != nil { panic(err) } defer file.Close() io.Copy(writer, file) }) // Test endpoint for filename*= only (RFC 5987 format) mux.HandleFunc("/filename-star", func(writer http.ResponseWriter, request *http.Request) { // URL-encoded TestChineseFileName: 测试.zip -> %E6%B5%8B%E8%AF%95.zip writer.Header().Set("Content-Disposition", `attachment; filename*=UTF-8''%E6%B5%8B%E8%AF%95.zip`) writer.Header().Set("Content-Type", "application/octet-stream") writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize)) file, err := os.Open(BuildFile) if err != nil { panic(err) } defer file.Close() io.Copy(writer, file) }) // Test endpoint for GBK-encoded filename (common on Chinese Windows servers) // This simulates the case where Chinese characters are sent as GBK bytes // which appear as garbled characters when interpreted as UTF-8. // For example, "测试" in GBK is [B2 E2 CA D4] which is invalid UTF-8. // Our fix detects invalid UTF-8 and attempts GBK decoding. mux.HandleFunc("/gbk-encoded", func(writer http.ResponseWriter, request *http.Request) { // Encode TestChineseFileName as GBK gbkEncoder := simplifiedchinese.GBK.NewEncoder() gbkBytes, _ := gbkEncoder.Bytes([]byte(TestChineseFileName)) // Send GBK bytes directly in filename (simulating broken server behavior) writer.Header().Set("Content-Disposition", `attachment; filename="`+string(gbkBytes)+`"`) writer.Header().Set("Content-Type", "application/octet-stream") writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize)) file, err := os.Open(BuildFile) if err != nil { panic(err) } defer file.Close() io.Copy(writer, file) }) // Test endpoint for filenames with plus signs (C++ files, etc.) // This tests that %2B decodes to + not space mux.HandleFunc("/plus-sign-encoded", func(writer http.ResponseWriter, request *http.Request) { // Use filename*= format with %2B encoding for plus signs writer.Header().Set("Content-Disposition", `attachment; filename*=UTF-8''C%2B%2B%20%20Primer%20%20Plus.mobi`) writer.Header().Set("Content-Type", "application/octet-stream") writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize)) file, err := os.Open(BuildFile) if err != nil { panic(err) } defer file.Close() io.Copy(writer, file) }) // Test endpoint for plus sign in URL path mux.HandleFunc("/C%2B%2B%20Primer.txt", func(writer http.ResponseWriter, request *http.Request) { file, err := os.Open(BuildFile) if err != nil { panic(err) } defer file.Close() io.Copy(writer, file) }) // Test endpoint for filename with HTML-encoded ampersand (&) // This tests the case from the bug report where filenames containing & are // HTML-encoded as & by the server, causing truncation at the semicolon. // Example: "查询处理&优化.pptx" -> "查询处理&优化.pptx" mux.HandleFunc("/ampersand-encoded", func(writer http.ResponseWriter, request *http.Request) { // Simulate server sending filename with HTML-encoded ampersand writer.Header().Set("Content-Disposition", `attachment; filename="查询处理&优化.pptx"`) writer.Header().Set("Content-Type", "application/octet-stream") writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize)) file, err := os.Open(BuildFile) if err != nil { panic(err) } defer file.Close() io.Copy(writer, file) }) // Test endpoint for unquoted filename with HTML-encoded ampersand // Some servers might send unquoted filenames with HTML entities mux.HandleFunc("/ampersand-unquoted", func(writer http.ResponseWriter, request *http.Request) { writer.Header().Set("Content-Disposition", `attachment; filename=test&file.txt`) writer.Header().Set("Content-Type", "application/octet-stream") writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize)) file, err := os.Open(BuildFile) if err != nil { panic(err) } defer file.Close() io.Copy(writer, file) }) // Test 403 Forbidden endpoint mux.HandleFunc("/forbidden", func(writer http.ResponseWriter, request *http.Request) { writer.WriteHeader(403) }) return mux }) } // StartTestHostHeaderServer starts a server that validates the Host header // Returns 400 Bad Request if the Host header value equals "test" func StartTestHostHeaderServer() net.Listener { return startTestServer(func(sl *shutdownListener) http.Handler { mux := http.NewServeMux() mux.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) { // If the Host header is "test", return 400 (simulating server that validates Host) if request.Host == "test" { writer.WriteHeader(400) writer.Write([]byte("Bad Request: Invalid Host header")) return } writer.WriteHeader(200) writer.Write([]byte("OK")) }) return mux }) } func StartTestRetryServer() net.Listener { counter := 0 return startTestServer(func(sl *shutdownListener) http.Handler { mux := http.NewServeMux() mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) { counter++ if counter != 1 && counter < 2 { writer.WriteHeader(500) return } file, err := os.Open(BuildFile) if err != nil { panic(err) } defer file.Close() io.Copy(writer, file) }) return mux }) } func StartTestPostServer() net.Listener { 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") != "" { var data map[string]interface{} if err := json.NewDecoder(request.Body).Decode(&data); err != nil { panic(err) } if data["name"] == BuildName { file, err := os.Open(BuildFile) if err != nil { panic(err) } defer file.Close() io.Copy(writer, file) } } }) return mux }) } func StartTestErrorServer() net.Listener { return startTestServer(func(sl *shutdownListener) http.Handler { mux := http.NewServeMux() mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) { // Always return 404 error to test error handling writer.WriteHeader(404) return }) return mux }) } // StartTestOneTimeServer creates a server where the URL can only be downloaded once // The first non-probe request succeeds, all subsequent requests return 404 // This simulates one-time download URLs (e.g., signed URLs that expire after first use) func StartTestOneTimeServer() net.Listener { var accessed atomic.Bool return startTestServer(func(sl *shutdownListener) http.Handler { mux := http.NewServeMux() mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) { // First full download request succeeds, subsequent requests fail if accessed.Swap(true) { writer.WriteHeader(404) return } // First request - return full file without Range support writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize)) file, err := os.Open(BuildFile) if err != nil { return } defer file.Close() io.Copy(writer, file) }) return mux }) } // StartTestNoRangeSlowServer creates a server that always returns the full file // with Content-Length but does not support Range requests. func StartTestNoRangeSlowServer(delayPerChunk time.Duration) net.Listener { return startTestServer(func(sl *shutdownListener) http.Handler { mux := http.NewServeMux() mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) { writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize)) file, err := os.Open(BuildFile) if err != nil { return } defer file.Close() buf := make([]byte, 256*1024) for !sl.isShutdown { n, readErr := file.Read(buf) if n > 0 { if _, writeErr := writer.Write(buf[:n]); writeErr != nil { return } if flusher, ok := writer.(http.Flusher); ok { flusher.Flush() } if delayPerChunk > 0 { time.Sleep(delayPerChunk) } } if readErr != nil { return } } }) return mux }) } // 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) } file, err := os.Create(BuildFile) if err != nil { panic(err) } defer file.Close() // Write random data l := int64(8192) buf := make([]byte, l) size := int64(0) for { _, err := rand.Read(buf) if err != nil { panic(err) } if size+l >= BuildSize { file.WriteAt(buf[0:BuildSize-size], size) break } file.WriteAt(buf, size) size += l } server := &http.Server{} sl := &shutdownListener{ server: server, Listener: listener, } server.Handler = serverHandle(sl) go server.Serve(listener) return sl } type shutdownListener struct { 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) } if err := ifExistAndRemove(DownloadFile); err != nil { fmt.Println(err) } if err := ifExistAndRemove(DownloadRenameFile); err != nil { fmt.Println(err) } return closeErr } func ifExistAndRemove(name string) error { if _, err := os.Stat(name); !os.IsNotExist(err) { return os.Remove(name) } 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) } ================================================ FILE: internal/test/util.go ================================================ package test import ( "crypto/md5" "encoding/hex" "encoding/json" "io" "os" "path/filepath" ) func FileMd5(filePath string) string { file, err := os.Open(filePath) if err != nil { panic(err) } // Tell the program to call the following function when the current function returns defer file.Close() // Open a new hash interface to write to hash := md5.New() // Copy the file in the hash interface and check for any error if _, err := io.Copy(hash, file); err != nil { return "" } return hex.EncodeToString(hash.Sum(nil)) } func DirMd5(dirPath string) string { hash := md5.New() filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error { if info.IsDir() { return nil } file, err := os.Open(path) if err != nil { return err } if _, err := io.Copy(hash, file); err != nil { return err } return nil }) return hex.EncodeToString(hash.Sum(nil)) } func ToJson(v interface{}) string { buf, _ := json.Marshal(v) return string(buf) } func JsonEqual(v1 any, v2 any) bool { return ToJson(v1) == ToJson(v2) } ================================================ FILE: pkg/base/constants.go ================================================ package base type Status string const ( DownloadStatusReady Status = "ready" // task create but not start DownloadStatusRunning Status = "running" DownloadStatusPause Status = "pause" DownloadStatusWait Status = "wait" // task is wait for running DownloadStatusError Status = "error" DownloadStatusDone Status = "done" ) 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" ) ================================================ FILE: pkg/base/info.go ================================================ 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" } } ================================================ FILE: pkg/base/model.go ================================================ 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 type Resource struct { // if name is not empty, the resource is a folder and the name is the folder name Name string `json:"name"` Size int64 `json:"size"` // is support range download Range bool `json:"range"` // file list Files []*FileInfo `json:"files"` Hash string `json:"hash"` } func (r *Resource) Validate() error { if r.Name == "" { return fmt.Errorf("invalid resource name") } if len(r.Files) == 0 { return fmt.Errorf("invalid resource files") } for _, file := range r.Files { if file.Name == "" { return fmt.Errorf("invalid resource file name") } } return nil } func (r *Resource) CalcSize(selectFiles []int) { var size int64 for i, file := range r.Files { if len(selectFiles) == 0 || slices.Contains(selectFiles, i) { size += file.Size } } r.Size = size } type FileInfo struct { Name string `json:"name"` Path string `json:"path"` Size int64 `json:"size"` Ctime *time.Time `json:"ctime"` Req *Request `json:"req"` } // Options for download type Options struct { // Download file name Name string `json:"name"` // Download file path Path string `json:"path"` // Select file indexes to download SelectFiles []int `json:"selectFiles"` // Extra info for specific fetcher Extra any `json:"extra"` } func (o *Options) InitSelectFiles(fileSize int) { // if selectFiles is empty, select all files if len(o.SelectFiles) == 0 { o.SelectFiles = make([]int, fileSize) for i := range fileSize { o.SelectFiles[i] = i } } } func (o *Options) Clone() *Options { return util.DeepClone(o) } func ParseReqExtra[E any](req *Request) error { if req.Extra == nil { return nil } if _, ok := req.Extra.(*E); ok { return nil } var t E if err := util.MapToStruct(req.Extra, &t); err != nil { return err } req.Extra = &t return nil } func ParseOptExtra[E any](opts *Options) error { if opts.Extra == nil { return nil } if _, ok := opts.Extra.(*E); ok { return nil } var t E if err := util.MapToStruct(opts.Extra, &t); err != nil { return err } opts.Extra = &t return nil } type CreateTaskBatch struct { Reqs []*CreateTaskBatchItem `json:"reqs"` Opts *Options `json:"opts"` } type CreateTaskBatchItem struct { Req *Request `json:"req"` Opts *Options `json:"opts"` } // DownloaderStoreConfig is the config that can restore the downloader. type DownloaderStoreConfig struct { FirstLoad bool `json:"-"` // FirstLoad is the flag that the config is first time init and not from store DownloadDir string `json:"downloadDir"` // DownloadDir is the default directory to save the downloaded files MaxRunning int `json:"maxRunning"` // MaxRunning is the max running download count ProtocolConfig map[string]any `json:"protocolConfig"` // ProtocolConfig is special config for each protocol Extra map[string]any `json:"extra"` Proxy *DownloaderProxyConfig `json:"proxy"` Webhook *WebhookConfig `json:"webhook"` // Webhook is the webhook configuration Script *ScriptConfig `json:"script"` // Script is the script execution configuration AutoTorrent *AutoTorrentConfig `json:"autoTorrent"` // AutoTorrent is the auto torrent task creation configuration Archive *ArchiveConfig `json:"archive"` // Archive is the archive extraction configuration AutoDeleteMissingFileTasks bool `json:"autoDeleteMissingFileTasks"` // AutoDeleteMissingFileTasks enables automatic deletion of tasks with missing files } func (cfg *DownloaderStoreConfig) Init() *DownloaderStoreConfig { if cfg.MaxRunning == 0 { cfg.MaxRunning = 5 } if cfg.ProtocolConfig == nil { cfg.ProtocolConfig = make(map[string]any) } if cfg.Proxy == nil { cfg.Proxy = &DownloaderProxyConfig{} } if cfg.Webhook == nil { cfg.Webhook = &WebhookConfig{} } if cfg.Script == nil { cfg.Script = &ScriptConfig{} } if cfg.AutoTorrent == nil { cfg.AutoTorrent = &AutoTorrentConfig{ Enable: false, DeleteAfterDownload: false, } } if cfg.Archive == nil { cfg.Archive = &ArchiveConfig{ AutoExtract: false, DeleteAfterExtract: false, } } return cfg } func (cfg *DownloaderStoreConfig) Merge(beforeCfg *DownloaderStoreConfig) *DownloaderStoreConfig { if beforeCfg == nil { return cfg } if cfg.DownloadDir == "" { cfg.DownloadDir = beforeCfg.DownloadDir } if cfg.MaxRunning == 0 { cfg.MaxRunning = beforeCfg.MaxRunning } if cfg.ProtocolConfig == nil { cfg.ProtocolConfig = beforeCfg.ProtocolConfig } if cfg.Extra == nil { cfg.Extra = beforeCfg.Extra } if cfg.Proxy == nil { cfg.Proxy = beforeCfg.Proxy } if cfg.Webhook == nil { cfg.Webhook = beforeCfg.Webhook } if cfg.Script == nil { cfg.Script = beforeCfg.Script } if cfg.AutoTorrent == nil { cfg.AutoTorrent = beforeCfg.AutoTorrent } if cfg.Archive == nil { cfg.Archive = beforeCfg.Archive } return cfg } // WebhookConfig is the webhook configuration type WebhookConfig struct { Enable bool `json:"enable"` // Enable is the flag to enable/disable webhooks URLs []string `json:"urls"` // URLs is the list of webhook URLs } // ScriptConfig is the script execution configuration type ScriptConfig struct { Enable bool `json:"enable"` // Enable is the flag to enable/disable script execution Paths []string `json:"paths"` // Paths is the list of script paths to execute } // AutoTorrentConfig is the auto torrent task creation configuration type AutoTorrentConfig struct { Enable bool `json:"enable"` // Enable enables automatic BT task creation when downloading .torrent files DeleteAfterDownload bool `json:"deleteAfterDownload"` // DeleteAfterDownload deletes the .torrent file after BT task creation } // ArchiveConfig is the archive extraction configuration type ArchiveConfig struct { AutoExtract bool `json:"autoExtract"` // AutoExtract enables automatic extraction of archives after download DeleteAfterExtract bool `json:"deleteAfterExtract"` // DeleteAfterExtract deletes the archive after successful extraction } type DownloaderProxyConfig struct { Enable bool `json:"enable"` // System is the flag that use system proxy System bool `json:"system"` Scheme string `json:"scheme"` Host string `json:"host"` Usr string `json:"usr"` Pwd string `json:"pwd"` } func (cfg *DownloaderProxyConfig) ToHandler() func(r *http.Request) (*url.URL, error) { if cfg == nil || cfg.Enable == false { return nil } if cfg.System { safeProxyReloadConf() return ieproxy.GetProxyFunc() } if cfg.Scheme == "" || cfg.Host == "" { return nil } return http.ProxyURL(util.BuildProxyUrl(cfg.Scheme, cfg.Host, cfg.Usr, cfg.Pwd)) } // ToUrl returns the proxy url, just for git clone func (cfg *DownloaderProxyConfig) ToUrl() *url.URL { if cfg == nil || cfg.Enable == false { return nil } if cfg.System { safeProxyReloadConf() static := ieproxy.GetConf().Static if static.Active && len(static.Protocols) > 0 { // If only one protocol, use it if len(static.Protocols) == 1 { for _, v := range static.Protocols { return parseUrlSafe(v) } } // Check https if v, ok := static.Protocols["https"]; ok { return parseUrlSafe(v) } // Check http if v, ok := static.Protocols["http"]; ok { return parseUrlSafe(v) } } return nil } if cfg.Scheme == "" || cfg.Host == "" { return nil } return util.BuildProxyUrl(cfg.Scheme, cfg.Host, cfg.Usr, cfg.Pwd) } var prcLock sync.Mutex func safeProxyReloadConf() { prcLock.Lock() defer prcLock.Unlock() ieproxy.ReloadConf() } func parseUrlSafe(rawUrl string) *url.URL { u, err := url.Parse(rawUrl) if err != nil { return nil } return u } ================================================ FILE: pkg/base/model_test.go ================================================ 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) } }) } } ================================================ FILE: pkg/download/downloader.go ================================================ package download import ( "errors" "fmt" "math" gohttp "net/http" "net/url" "os" "path/filepath" "runtime/debug" "sort" "strings" "sync" "sync/atomic" "time" "github.com/GopeedLab/gopeed/internal/controller" "github.com/GopeedLab/gopeed/internal/fetcher" "github.com/GopeedLab/gopeed/internal/logger" "github.com/GopeedLab/gopeed/pkg/base" "github.com/GopeedLab/gopeed/pkg/protocol/http" "github.com/GopeedLab/gopeed/pkg/util" gonanoid "github.com/matoous/go-nanoid/v2" "github.com/rs/zerolog" "github.com/rs/zerolog/pkgerrors" ) const ( // task info bucket bucketTask = "task" // task download data bucket bucketSave = "save" // protocol-level shared client state bucket bucketProtocolState = "protocol_state" // downloader config bucket bucketConfig = "config" // downloader extension bucket bucketExtension = "extension" // downloader extension storage bucket bucketExtensionStorage = "extension_storage" ) var ( ErrTaskNotFound = errors.New("task not found") ErrUnSupportedProtocol = errors.New("unsupported protocol") ) type Listener func(event *Event) // ExtractStatus represents the current status of archive extraction type ExtractStatus string const ( // ExtractStatusNone indicates extraction has not started ExtractStatusNone ExtractStatus = "" // ExtractStatusQueued indicates extraction is waiting in the queue ExtractStatusQueued ExtractStatus = "queued" // ExtractStatusWaitingParts indicates waiting for other multi-part archive parts to complete ExtractStatusWaitingParts ExtractStatus = "waitingParts" // ExtractStatusExtracting indicates extraction is in progress ExtractStatusExtracting ExtractStatus = "extracting" // ExtractStatusDone indicates extraction completed successfully ExtractStatusDone ExtractStatus = "done" // ExtractStatusError indicates extraction failed ExtractStatusError ExtractStatus = "error" ) type Progress struct { // Total download time(ns) Used int64 `json:"used"` // Download speed(bytes/s) Speed int64 `json:"speed"` // Downloaded size(bytes) Downloaded int64 `json:"downloaded"` // Uploaded speed(bytes/s) UploadSpeed int64 `json:"uploadSpeed"` // Uploaded size(bytes) Uploaded int64 `json:"uploaded"` // ExtractStatus indicates the current status of archive extraction ExtractStatus ExtractStatus `json:"extractStatus"` // ExtractProgress is the percentage of extraction completed (0-100) ExtractProgress int `json:"extractProgress"` // MultiPartBaseName is set for multi-part archives to group related parts MultiPartBaseName string `json:"multiPartBaseName,omitempty"` // MultiPartNumber is the part number for multi-part archives (1-indexed) MultiPartNumber int `json:"multiPartNumber,omitempty"` // MultiPartIsFirst indicates if this is the first part of a multi-part archive MultiPartIsFirst bool `json:"multiPartIsFirst,omitempty"` } type Downloader struct { Logger *logger.Logger ExtensionLogger *logger.Logger cfg *DownloaderConfig fetcherCache map[string]fetcher.Fetcher storage Storage tasks []*Task waitTasks []*Task watchedTasks sync.Map listener Listener lock *sync.Mutex fetcherMapLock *sync.RWMutex checkDuplicateLock *sync.Mutex closed atomic.Bool // claimedExtractions tracks which multi-part archives have been claimed for extraction // Key: fullBaseName (e.g., "/path/archive.7z"), Value: taskID that claimed it claimedExtractions sync.Map extensions []*Extension } func NewDownloader(cfg *DownloaderConfig) *Downloader { if cfg == nil { cfg = &DownloaderConfig{} } cfg.Init() d := &Downloader{ cfg: cfg, fetcherCache: make(map[string]fetcher.Fetcher), waitTasks: make([]*Task, 0), storage: cfg.Storage, lock: &sync.Mutex{}, fetcherMapLock: &sync.RWMutex{}, checkDuplicateLock: &sync.Mutex{}, extensions: make([]*Extension, 0), } zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack d.Logger = logger.NewLogger(cfg.ProductionMode, filepath.Join(cfg.StorageDir, "logs", "core.log")) d.ExtensionLogger = logger.NewLogger(cfg.ProductionMode, filepath.Join(cfg.StorageDir, "logs", "extension.log")) if cfg.ProductionMode { logPanic(filepath.Join(cfg.StorageDir, "logs")) } return d } func (d *Downloader) Setup() error { // setup storage if err := d.storage.Setup([]string{bucketTask, bucketSave, bucketProtocolState, bucketConfig, bucketExtension, bucketExtensionStorage}); err != nil { return err } // load config from storage var cfg base.DownloaderStoreConfig exist, err := d.storage.Get(bucketConfig, "config", &cfg) if err != nil { return err } if exist { d.cfg.DownloaderStoreConfig = &cfg } else { d.cfg.DownloaderStoreConfig = &base.DownloaderStoreConfig{ FirstLoad: true, } } // init default config d.cfg.DownloaderStoreConfig.Init() // init protocol config, if not exist, use default config for _, fm := range d.cfg.FetchManagers { protocol := fm.Name() if _, ok := d.cfg.DownloaderStoreConfig.ProtocolConfig[protocol]; !ok { d.cfg.DownloaderStoreConfig.ProtocolConfig[protocol] = fm.DefaultConfig() } if sfm, ok := fm.(fetcher.StatefulFetcherManager); ok { sfm.SetStateStore(&protocolStateStore{ storage: d.storage, protocol: protocol, }) } } // load tasks from storage var tasks []*Task if err = d.storage.List(bucketTask, &tasks); err != nil { return err } if tasks == nil { tasks = make([]*Task, 0) } else { for i := len(tasks) - 1; i >= 0; i-- { task := tasks[i] // Remove broken tasks if task.Meta == nil { tasks = append(tasks[:i], tasks[i+1:]...) continue } d.assignFetcherManager(task) initTask(task) if task.Status != base.DownloadStatusDone && task.Status != base.DownloadStatusError { task.Status = base.DownloadStatusPause } } } d.tasks = tasks // sort by create time sort.Slice(d.tasks, func(i, j int) bool { return d.tasks[i].CreatedAt.Before(d.tasks[j].CreatedAt) }) // load extensions from storage var extensions []*Extension if err = d.storage.List(bucketExtension, &extensions); err != nil { return err } if extensions == nil { extensions = make([]*Extension, 0) } d.extensions = extensions // Auto-cleanup non-existing tasks on startup d.cleanupNonExistingTasks() // handle upload go func() { for _, task := range d.tasks { if task.Status == base.DownloadStatusDone && task.Uploading { if err := d.restoreTask(task); err != nil { d.Logger.Error().Stack().Err(err).Msgf("task upload restore fetcher failed, task id: %s", task.ID) } if uploader, ok := task.fetcher.(fetcher.Uploader); ok { if err := uploader.Upload(); err != nil { d.Logger.Error().Stack().Err(err).Msgf("task upload failed, task id: %s", task.ID) } } } } }() // calculate download speed every tick go func() { for !d.closed.Load() { if len(d.tasks) > 0 { for _, task := range d.tasks { func() { task.statusLock.Lock() defer task.statusLock.Unlock() if task.Status != base.DownloadStatusRunning && !task.Uploading { return } // check if task is deleted if d.GetTask(task.ID) == nil || task.fetcher == nil { return } current := task.fetcher.Progress().TotalDownloaded() tick := float64(d.cfg.RefreshInterval) / 1000 downloadDataChanged := false if task.Status == base.DownloadStatusRunning { downloadDataChanged = current != task.Progress.Downloaded task.Progress.Used = task.timer.Used() task.Progress.Speed = task.updateSpeed(current-task.Progress.Downloaded, tick) task.Progress.Downloaded = current } uploadDataChanged := false if task.Uploading { uploader := task.fetcher.(fetcher.Uploader) currentUploaded := uploader.UploadedBytes() uploadDataChanged = currentUploaded != task.Progress.Uploaded task.Progress.UploadSpeed = task.updateUploadSpeed(currentUploaded-task.Progress.Uploaded, tick) task.Progress.Uploaded = currentUploaded } d.emit(EventKeyProgress, task) // store fetcher progress when download/upload data changed if !downloadDataChanged && !uploadDataChanged { return } d.saveTask(task) }() } } time.Sleep(time.Millisecond * time.Duration(d.cfg.RefreshInterval)) } }() return nil } // cleanupNonExistingTasks checks for tasks whose files are missing on disk // and removes them if the AutoCleanMissingFiles config is enabled. func (d *Downloader) cleanupNonExistingTasks() { cfg, err := d.GetConfig() if err != nil { return } // If the feature is disabled, do nothing if !cfg.AutoDeleteMissingFileTasks { return } var tasksToDelete []string for _, task := range d.tasks { if task.Meta == nil || task.Meta.Res == nil { continue } var targetPath string // Determine if it is a single file or a directory (multi-file torrent) if task.Meta.Res.Name != "" { targetPath = task.Meta.FolderPath() } else { targetPath = task.Meta.SingleFilepath() } // Skip if path is empty if targetPath == "" { continue } // Check if file/folder exists if _, err := os.Stat(targetPath); os.IsNotExist(err) { d.Logger.Info().Msgf("Auto-cleanup: task %s file not found at %s, removing from list", task.ID, targetPath) tasksToDelete = append(tasksToDelete, task.ID) } } if len(tasksToDelete) > 0 { d.Delete(&TaskFilter{IDs: tasksToDelete}, false) } } func (d *Downloader) parseFm(url string) (fetcher.FetcherManager, error) { for _, fm := range d.cfg.FetchManagers { for _, filter := range fm.Filters() { if filter.Match(url) { return fm, nil } } } return nil, ErrUnSupportedProtocol } func (d *Downloader) setupFetcher(fm fetcher.FetcherManager, fetcher fetcher.Fetcher) { ctl := controller.NewController() ctl.GetConfig = func(v any) { d.getProtocolConfig(fm.Name(), v) } // Get proxy config, task request proxy config has higher priority, then use global proxy config ctl.GetProxy = func(requestProxy *base.RequestProxy) func(*gohttp.Request) (*url.URL, error) { if requestProxy == nil { return d.cfg.Proxy.ToHandler() } switch requestProxy.Mode { case base.RequestProxyModeNone: return nil case base.RequestProxyModeCustom: return requestProxy.ToHandler() default: return d.cfg.Proxy.ToHandler() } } fetcher.Setup(ctl) } func (d *Downloader) saveTask(task *Task) error { data, err := task.fetcherManager.Store(task.fetcher) if err != nil { d.Logger.Error().Stack().Err(err).Msgf("serialize fetcher failed: %s", task.ID) return err } if data != nil { if err := d.storage.Put(bucketSave, task.ID, data); err != nil { d.Logger.Error().Stack().Err(err).Msgf("persist fetcher failed: %s", task.ID) return err } } else { if err := d.storage.Delete(bucketSave, task.ID); err != nil { d.Logger.Error().Stack().Err(err).Msgf("clear fetcher state failed: %s", task.ID) return err } } if err := d.storage.Put(bucketTask, task.ID, task); err != nil { d.Logger.Error().Stack().Err(err).Msgf("persist task failed: %s", task.ID) return err } return nil } func (d *Downloader) Resolve(req *base.Request, opts *base.Options) (rr *ResolveResult, err error) { rrId, err := gonanoid.New() if err != nil { return } res, err := d.triggerOnResolve(req) if err != nil { return } if res != nil && len(res.Files) > 0 { rr = &ResolveResult{ Res: res, } return } fetcher, err := d.buildFetcher(req.URL) if err != nil { return } initOpt, err := d.initOptions(opts) if err != nil { return } err = fetcher.Resolve(req, initOpt) if err != nil { return } d.fetcherMapLock.Lock() d.fetcherCache[rrId] = fetcher d.fetcherMapLock.Unlock() rr = &ResolveResult{ ID: rrId, Res: fetcher.Meta().Res, } return } func (d *Downloader) notifyRunning() { go func() { d.lock.Lock() defer d.lock.Unlock() remainRunningCount := d.remainRunningCount() if remainRunningCount == 0 { return } if len(d.waitTasks) > 0 { wt := d.waitTasks[0] d.waitTasks = d.waitTasks[1:] d.doStart(wt) } }() } func (d *Downloader) remainRunningCount() int { runningCount := 0 for _, t := range d.tasks { if t.Status == base.DownloadStatusRunning { runningCount++ } } return d.cfg.MaxRunning - runningCount } func (d *Downloader) CreateDirect(req *base.Request, opts *base.Options) (taskId string, err error) { var fetcher fetcher.Fetcher fetcher, err = d.buildFetcher(req.URL) if err != nil { return } fetcher.Meta().Req = req initOpt, err := d.initOptions(opts) if err != nil { return } return d.doCreate(fetcher, initOpt) } func (d *Downloader) CreateDirectBatch(req *base.CreateTaskBatch) (taskId []string, err error) { taskIds := make([]string, 0) for _, ir := range req.Reqs { opts := ir.Opts if opts == nil { opts = req.Opts } taskId, err := d.CreateDirect(ir.Req, opts.Clone()) if err != nil { return nil, err } taskIds = append(taskIds, taskId) } return taskIds, nil } func (d *Downloader) Create(rrId string) (taskId string, err error) { d.fetcherMapLock.RLock() fetcher, ok := d.fetcherCache[rrId] d.fetcherMapLock.RUnlock() if !ok { return "", errors.New("invalid resource id") } defer func() { d.fetcherMapLock.Lock() delete(d.fetcherCache, rrId) d.fetcherMapLock.Unlock() }() return d.doCreate(fetcher, nil) } // Patch modifies task-specific data based on the protocol. // For HTTP protocol, it can modify Request info. // For BT protocol, it can modify SelectFiles. func (d *Downloader) Patch(id string, req *base.Request, opts *base.Options) error { task := d.GetTask(id) if task == nil { return ErrTaskNotFound } // Restore fetcher if not loaded if task.fetcher == nil { err := func() error { task.statusLock.Lock() defer task.statusLock.Unlock() return d.restoreFetcher(task) }() if err != nil { return err } } // Call the fetcher's Patch method if err := task.fetcher.Patch(req, opts); err != nil { return err } // Update task meta from fetcher task.Meta = task.fetcher.Meta() // Save task to storage if err := d.saveTask(task); err != nil { return err } // Emit progress event to notify listeners d.emit(EventKeyProgress, task) return nil } func (d *Downloader) Pause(filter *TaskFilter) (err error) { if filter == nil || filter.IsEmpty() { return d.pauseAll() } filter.NotStatuses = []base.Status{base.DownloadStatusPause, base.DownloadStatusError, base.DownloadStatusDone} pauseTasks := d.GetTasksByFilter(filter) if len(pauseTasks) == 0 { return ErrTaskNotFound } for _, task := range pauseTasks { if err = d.doPause(task); err != nil { return } } d.notifyRunning() return } func (d *Downloader) pauseAll() (err error) { func() { d.lock.Lock() defer d.lock.Unlock() // Clear wait tasks d.waitTasks = d.waitTasks[:0] }() for _, task := range d.tasks { if err = d.doPause(task); err != nil { return } } return } // Continue specific tasks, if continue tasks will exceed maxRunning, it needs pause some running tasks before that func (d *Downloader) Continue(filter *TaskFilter) (err error) { if filter == nil || filter.IsEmpty() { return d.continueAll() } filter.NotStatuses = []base.Status{base.DownloadStatusRunning, base.DownloadStatusDone} continueTasks := d.GetTasksByFilter(filter) if len(continueTasks) == 0 { return ErrTaskNotFound } realContinueTasks := make([]*Task, 0) func() { d.lock.Lock() defer d.lock.Unlock() continueCount := len(continueTasks) remainRunningCount := d.remainRunningCount() needRunningCount := int(math.Min(float64(d.cfg.MaxRunning), float64(continueCount))) needPauseCount := needRunningCount - remainRunningCount if needPauseCount > 0 { pausedCount := 0 for _, task := range d.tasks { if task.Status == base.DownloadStatusRunning { if err = d.doPause(task); err != nil { return } task.Status = base.DownloadStatusWait d.waitTasks = append(d.waitTasks, task) pausedCount++ } if pausedCount == needPauseCount { break } } } for _, task := range continueTasks { if len(realContinueTasks) < needRunningCount { realContinueTasks = append(realContinueTasks, task) } else { task.Status = base.DownloadStatusWait d.waitTasks = append(d.waitTasks, task) } } }() for _, task := range realContinueTasks { if err = d.doStart(task); err != nil { return } } return } // continueAll continue all tasks but does not affect tasks already running func (d *Downloader) continueAll() (err error) { continuedTasks := make([]*Task, 0) func() { d.lock.Lock() defer d.lock.Unlock() // calculate how many tasks can be continued, can't exceed maxRunning remainCount := d.remainRunningCount() for _, task := range d.tasks { if task.Status != base.DownloadStatusRunning && task.Status != base.DownloadStatusDone { if len(continuedTasks) < remainCount { continuedTasks = append(continuedTasks, task) } else { task.Status = base.DownloadStatusWait d.waitTasks = append(d.waitTasks, task) } } } }() for _, task := range continuedTasks { if err = d.doStart(task); err != nil { return } } return } func (d *Downloader) ContinueBatch(filter *TaskFilter) (err error) { if filter == nil || filter.IsEmpty() { return d.continueAll() } continueTasks := d.GetTasksByFilter(filter) for _, task := range continueTasks { if err = d.doStart(task); err != nil { return } } return } func (d *Downloader) Delete(filter *TaskFilter, force bool) (err error) { if filter == nil || filter.IsEmpty() { return d.deleteAll(force) } deleteTasks := d.GetTasksByFilter(filter) if len(deleteTasks) == 0 { return } deleteIds := make([]string, 0) deleteTasksPtr := make([]*Task, 0) for _, task := range deleteTasks { deleteIds = append(deleteIds, task.ID) deleteTasksPtr = append(deleteTasksPtr, task) } func() { d.lock.Lock() defer d.lock.Unlock() for _, id := range deleteIds { for i, t := range d.tasks { if t.ID == id { d.tasks = append(d.tasks[:i], d.tasks[i+1:]...) break } } for i, t := range d.waitTasks { if t.ID == id { d.waitTasks = append(d.waitTasks[:i], d.waitTasks[i+1:]...) break } } } }() for _, task := range deleteTasksPtr { err = d.doDelete(task, force) if err != nil { return } } d.notifyRunning() return } func (d *Downloader) deleteAll(force bool) (err error) { var deleteTasksTemp []*Task func() { d.lock.Lock() defer d.lock.Unlock() for _, task := range d.tasks { deleteTasksTemp = append(deleteTasksTemp, task) } d.tasks = make([]*Task, 0) d.waitTasks = make([]*Task, 0) }() for _, task := range deleteTasksTemp { if err = d.doDelete(task, force); err != nil { return } } return } func (d *Downloader) Stats(id string) (sr any, err error) { task := d.GetTask(id) if task == nil { return sr, ErrTaskNotFound } if task.fetcher == nil { err = func() error { task.statusLock.Lock() defer task.statusLock.Unlock() return d.restoreFetcher(task) }() if err != nil { return } } sr = task.fetcher.Stats() return } func (d *Downloader) doDelete(task *Task, force bool) (err error) { err = func() error { if err := d.storage.Delete(bucketTask, task.ID); err != nil { return err } if err := d.storage.Delete(bucketSave, task.ID); err != nil { return err } if task.fetcher != nil { if err := task.fetcher.Close(); err != nil { return err } } if force && task.Meta.Res != nil { if task.Meta.Res.Name != "" { if err := os.RemoveAll(task.Meta.FolderPath()); err != nil { return err } } else { if err := util.SafeRemove(task.Meta.SingleFilepath()); err != nil { return err } } } d.emit(EventKeyDelete, task) task = nil return nil }() if err != nil { d.Logger.Error().Stack().Err(err).Msgf("delete task failed, task id: %s", task.ID) } return } func (d *Downloader) Close() error { d.closed.Store(true) closeArr := []func() error{ d.pauseAll, } for _, fm := range d.cfg.FetchManagers { closeArr = append(closeArr, fm.Close) } closeArr = append(closeArr, d.storage.Close) // Make sure all resources are released, if had error, return the last error var lastErr error for i, close := range closeArr { if err := close(); err != nil { lastErr = err d.Logger.Error().Stack().Err(err).Msgf("downloader close failed, index: %d", i) } } return lastErr } func (d *Downloader) Clear() error { if !d.closed.Load() { if err := d.Close(); err != nil { return err } } d.tasks = make([]*Task, 0) d.extensions = make([]*Extension, 0) if err := d.storage.Clear(); err != nil { return err } return nil } type protocolStateStore struct { storage Storage protocol string } func (s *protocolStateStore) Load(v any) (bool, error) { return s.storage.Get(bucketProtocolState, s.protocol, v) } func (s *protocolStateStore) Save(v any) error { if v == nil { return s.Delete() } return s.storage.Put(bucketProtocolState, s.protocol, v) } func (s *protocolStateStore) Delete() error { return s.storage.Delete(bucketProtocolState, s.protocol) } func (d *Downloader) Listener(fn Listener) { d.listener = fn } func (d *Downloader) emit(eventKey EventKey, task *Task, errs ...error) { if d.listener != nil { var err error if len(errs) > 0 { err = errs[0] } d.listener(&Event{ Key: eventKey, Task: task, Err: err, }) } } func (d *Downloader) GetTask(id string) *Task { d.lock.Lock() defer d.lock.Unlock() for _, task := range d.tasks { if task.ID == id { return task } } return nil } func (d *Downloader) GetTasks() []*Task { d.lock.Lock() defer d.lock.Unlock() return d.tasks } // GetTasksByFilter get tasks by filter, if filter is nil, return all tasks // return tasks and if match all tasks func (d *Downloader) GetTasksByFilter(filter *TaskFilter) []*Task { d.lock.Lock() defer d.lock.Unlock() if filter == nil || filter.IsEmpty() { return d.tasks } idMatch := func(task *Task) bool { if len(filter.IDs) == 0 { return true } for _, id := range filter.IDs { if task.ID == id { return true } } return false } statusMatch := func(task *Task) bool { if len(filter.Statuses) == 0 { return true } for _, status := range filter.Statuses { if task.Status == status { return true } } return false } notStatusMatch := func(task *Task) bool { if len(filter.NotStatuses) == 0 { return true } for _, status := range filter.NotStatuses { if task.Status == status { return false } } return true } tasks := make([]*Task, 0) for _, task := range d.tasks { if idMatch(task) && statusMatch(task) && notStatusMatch(task) { tasks = append(tasks, task) } } return tasks } func (d *Downloader) GetConfig() (*base.DownloaderStoreConfig, error) { return d.cfg.DownloaderStoreConfig, nil } func (d *Downloader) PutConfig(v *base.DownloaderStoreConfig) error { d.cfg.DownloaderStoreConfig = v return d.storage.Put(bucketConfig, "config", v) } func (d *Downloader) getProtocolConfig(name string, v any) bool { cfg, err := d.GetConfig() if err != nil { return false } if cfg.ProtocolConfig == nil || cfg.ProtocolConfig[name] == nil { return false } if err := util.MapToStruct(cfg.ProtocolConfig[name], v); err != nil { d.Logger.Warn().Err(err).Msgf("get protocol config failed") return false } return true } // wait task done func (d *Downloader) watch(task *Task) { if _, loaded := d.watchedTasks.LoadOrStore(task.ID, true); loaded { return } defer func() { d.watchedTasks.Delete(task.ID) }() // wait task upload done if task.Uploading { if uploader, ok := task.fetcher.(fetcher.Uploader); ok { go func() { err := uploader.WaitUpload() if err != nil { d.Logger.Warn().Err(err).Msgf("task wait upload failed, task id: %s", task.ID) } // Check if the task is deleted if d.GetTask(task.ID) != nil { task.Uploading = false d.storage.Put(bucketTask, task.ID, task.clone()) } }() } } if task.Status == base.DownloadStatusDone { return } err := task.fetcher.Wait() if err != nil { d.doOnError(task, err) return } // When delete a not resolved task, need check if the task resource is nil if task.Meta.Res == nil || d.GetTask(task.ID) == nil { return } task.Progress.Used = task.timer.Used() if task.Meta.Res.Size == 0 { task.Meta.Res.Size = task.fetcher.Progress().TotalDownloaded() } used := task.Progress.Used / int64(time.Second) if used == 0 { used = 1 } totalSize := task.Meta.Res.Size task.Progress.Speed = totalSize / used task.Progress.Downloaded = totalSize task.updateStatus(base.DownloadStatusDone) d.storage.Put(bucketTask, task.ID, task.clone()) d.emit(EventKeyDone, task) d.emit(EventKeyFinally, task, err) d.notifyRunning() d.triggerOnDone(task) d.triggerWebhooks(WebhookEventDownloadDone, task, nil) d.triggerScripts(ScriptEventDownloadDone, task, nil) if e, ok := task.Meta.Opts.Extra.(*http.OptsExtra); ok { downloadFilePath := task.Meta.SingleFilepath() cfg, _ := d.GetConfig() // Determine if auto-torrent is enabled (use global config if not explicitly set) autoTorrentEnabled := false if e.AutoTorrent != nil { autoTorrentEnabled = *e.AutoTorrent } else if cfg != nil && cfg.AutoTorrent != nil { autoTorrentEnabled = cfg.AutoTorrent.Enable } if autoTorrentEnabled && strings.HasSuffix(downloadFilePath, ".torrent") { // Determine if should delete torrent file after creating BT task shouldDelete := false if e.DeleteTorrentAfterDownload != nil { shouldDelete = *e.DeleteTorrentAfterDownload } else if cfg != nil && cfg.AutoTorrent != nil { shouldDelete = cfg.AutoTorrent.DeleteAfterDownload } go func() { _, err2 := d.CreateDirect( &base.Request{ URL: downloadFilePath, }, &base.Options{ Path: task.Meta.Opts.Path, SelectFiles: make([]int, 0), }) if err2 != nil { d.Logger.Error().Err(err2).Msgf("auto create torrent task failed, task id: %s", task.ID) return } if shouldDelete { d.Delete(&TaskFilter{IDs: []string{task.ID}}, true) } }() } // Determine if auto-extract is enabled (use global config if not explicitly set) autoExtractEnabled := false if e.AutoExtract != nil { autoExtractEnabled = *e.AutoExtract } else if cfg != nil && cfg.Archive != nil { autoExtractEnabled = cfg.Archive.AutoExtract } // Auto-extract archive files using the extraction queue // This ensures only one extraction runs at a time to prevent resource exhaustion if autoExtractEnabled && isArchiveFile(downloadFilePath) { d.enqueueExtraction(task, downloadFilePath, e) } } } func (d *Downloader) doOnError(task *Task, err error) { d.Logger.Warn().Err(err).Msgf("task download failed, task id: %s", task.ID) task.updateStatus(base.DownloadStatusError) d.triggerOnError(task, err) if task.Status == base.DownloadStatusError { d.emit(EventKeyError, task, err) d.emit(EventKeyFinally, task, err) d.notifyRunning() d.triggerWebhooks(WebhookEventDownloadError, task, err) } } func (d *Downloader) restoreTask(task *Task) error { if task.fetcher == nil { if err := d.restoreFetcher(task); err != nil { return err } } go d.watch(task) return nil } func (d *Downloader) restoreFetcher(task *Task) error { v, f := task.fetcherManager.Restore() if v != nil { err := d.storage.Pop(bucketSave, task.ID, v) if err != nil { return err } } task.fetcher = f(task.Meta, v) if task.fetcher == nil { task.fetcher = task.fetcherManager.Build() } d.setupFetcher(task.fetcherManager, task.fetcher) if task.fetcher.Meta().Req == nil { task.fetcher.Meta().Req = task.Meta.Req } if task.fetcher.Meta().Res == nil { task.fetcher.Meta().Res = task.Meta.Res } if task.fetcher.Meta().Opts == nil { task.fetcher.Meta().Opts = task.Meta.Opts } return nil } func (d *Downloader) doCreate(f fetcher.Fetcher, opts *base.Options) (taskId string, err error) { if f.Meta().Opts == nil { f.Meta().Opts = opts } fm, err := d.parseFm(f.Meta().Req.URL) if err != nil { return } task := NewTask() task.fetcherManager = fm task.fetcher = f task.Protocol = fm.Name() task.Meta = f.Meta() task.Progress = &Progress{} _, task.Uploading = f.(fetcher.Uploader) initTask(task) if err = d.storage.Put(bucketTask, task.ID, task.clone()); err != nil { return } taskId = task.ID func() { d.lock.Lock() defer d.lock.Unlock() d.tasks = append(d.tasks, task) remainRunningCount := d.remainRunningCount() if remainRunningCount == 0 { task.Status = base.DownloadStatusWait d.waitTasks = append(d.waitTasks, task) return } err = d.doStart(task) }() go d.watch(task) return } func (d *Downloader) initOptions(opts *base.Options) (*base.Options, error) { if opts == nil { opts = &base.Options{} } if opts.SelectFiles == nil { opts.SelectFiles = make([]int, 0) } if opts.Path == "" { storeConfig, err := d.GetConfig() if err != nil { return nil, err } opts.Path = storeConfig.DownloadDir } // Replace placeholders in download path (e.g., %year%, %month%, %day%, %date%) opts.Path = util.ReplacePathPlaceholders(opts.Path) // if enable white download directory, check if the download directory is in the white list if len(d.cfg.WhiteDownloadDirs) > 0 { inWhiteList := false for _, dir := range d.cfg.WhiteDownloadDirs { if match, err := filepath.Match(dir, opts.Path); match && err == nil { inWhiteList = true break } } if !inWhiteList { return nil, errors.New("download directory is not in white list") } } return opts, nil } func (d *Downloader) statusMut(task *Task, fn func() (bool, error)) (bool, error) { task.statusLock.Lock() defer task.statusLock.Unlock() return fn() } func (d *Downloader) doStart(task *Task) (err error) { var needCreate bool isReturn, err := d.statusMut(task, func() (isReturn bool, err error) { if task.Status == base.DownloadStatusRunning || task.Status == base.DownloadStatusDone { isReturn = true return } err = d.restoreTask(task) if err != nil { d.Logger.Error().Stack().Err(err).Msgf("restore fetcher failed, task id: %s", task.ID) return } needCreate = !task.IsCreated task.updateStatus(base.DownloadStatusRunning) return }) if err != nil { d.Logger.Error().Stack().Err(err).Msgf("start task failed, task id: %s", task.ID) return } if isReturn { return } handler := func() error { task.lock.Lock() defer task.lock.Unlock() d.triggerOnStart(task) if task.Meta.Res == nil { err := task.fetcher.Resolve(task.Meta.Req, task.Meta.Opts) if err != nil { return err } task.Meta.Res = task.fetcher.Meta().Res } if needCreate { if task.fetcherManager.AutoRename() { d.checkDuplicateLock.Lock() defer d.checkDuplicateLock.Unlock() task.Meta.Opts.Name = util.SafeFilename(task.Meta.Opts.Name) // check if the download file is duplicated and rename it automatically. if task.Meta.Res.Name != "" { task.Meta.Res.Name = util.SafeFilename(task.Meta.Res.Name) fullDirPath := task.Meta.FolderPath() newName, err := util.CheckDuplicateAndRename(fullDirPath) if err != nil { return err } task.Meta.Opts.Name = newName } else { task.Meta.Res.Files[0].Name = util.SafeFilename(task.Meta.Res.Files[0].Name) fullFilePath := task.Meta.SingleFilepath() newName, err := util.CheckDuplicateAndRename(fullFilePath) if err != nil { return err } task.Meta.Opts.Name = newName } } task.IsCreated = true task.Meta.Res.CalcSize(task.Meta.Opts.SelectFiles) } task.Progress.Speed = 0 task.timer.Start() if err := task.fetcher.Start(); err != nil { return err } if err := d.saveTask(task); err != nil { return err } d.emit(EventKeyStart, task) return nil } go func() { if err := handler(); err != nil { d.doOnError(task, err) } }() return } func (d *Downloader) doPause(task *Task) (err error) { isReturn, err := d.statusMut(task, func() (isReturn bool, err error) { if task.Status == base.DownloadStatusPause || task.Status == base.DownloadStatusDone { isReturn = true return } task.updateStatus(base.DownloadStatusPause) task.timer.Pause() return }) if err != nil { d.Logger.Error().Stack().Err(err).Msgf("pause task failed, task id: %s", task.ID) return } if isReturn { return } handler := func() error { task.lock.Lock() defer task.lock.Unlock() if task.fetcher != nil { if err := task.fetcher.Pause(); err != nil { return err } } if task.fetcherManager != nil && task.fetcher != nil { if err := d.saveTask(task); err != nil { return err } } else { if err := d.storage.Put(bucketTask, task.ID, task.clone()); err != nil { return err } } d.emit(EventKeyPause, task) return nil } go func() { if err := handler(); err != nil { d.Logger.Error().Stack().Err(err).Msgf("pause task handle failed, task id: %s", task.ID) } }() return } // redirect stderr to log file, when panic happened log it func logPanic(logDir string) { if err := util.CreateDirIfNotExist(logDir); err != nil { return } f, err := os.Create(filepath.Join(logDir, "crash.log")) if err != nil { return } debug.SetCrashOutput(f, debug.CrashOptions{}) } func (d *Downloader) assignFetcherManager(task *Task) error { fm, err := d.parseFm(task.Meta.Req.URL) if err != nil { return err } task.fetcherManager = fm return nil } func (d *Downloader) buildFetcher(url string) (fetcher.Fetcher, error) { fm, err := d.parseFm(url) if err != nil { return nil, err } fetcher := fm.Build() d.setupFetcher(fm, fetcher) return fetcher, nil } // enqueueExtraction adds an extraction job to the global extraction queue // This ensures only one extraction (or one multi-part archive extraction) runs at a time // to prevent resource exhaustion func (d *Downloader) enqueueExtraction(task *Task, downloadFilePath string, opts *http.OptsExtra) { partInfo := getArchivePartInfo(downloadFilePath) if partInfo.IsMultiPart { // For multi-part archives, handle specially d.enqueueMultiPartExtraction(task, downloadFilePath, partInfo, opts) } else { // For single archives, queue immediately d.enqueueSingleExtraction(task, downloadFilePath, opts) } } // enqueueSingleExtraction queues extraction for a single (non-multi-part) archive func (d *Downloader) enqueueSingleExtraction(task *Task, downloadFilePath string, opts *http.OptsExtra) { jobID := "single:" + task.ID // Set extraction status to queued task.Progress.ExtractStatus = ExtractStatusQueued d.emit(EventKeyProgress, task) d.storage.Put(bucketTask, task.ID, task.clone()) d.Logger.Info().Msgf("extraction queued, task id: %s, job id: %s", task.ID, jobID) // Create and enqueue the extraction job job := NewExtractionJob(jobID, func() { d.performExtraction(task, downloadFilePath, task.Meta.Opts.Path, opts) }) go func() { GetExtractionQueue().Enqueue(job) }() } // enqueueMultiPartExtraction handles queueing for multi-part archives // It ensures only ONE extraction job is queued when ALL parts are ready func (d *Downloader) enqueueMultiPartExtraction(task *Task, downloadFilePath string, partInfo ArchivePartInfo, opts *http.OptsExtra) { // Set multi-part info on the task task.Progress.MultiPartBaseName = partInfo.BaseName task.Progress.MultiPartNumber = partInfo.PartNumber task.Progress.MultiPartIsFirst = isFirstPart(downloadFilePath) // Check if all parts are downloaded destDir := task.Meta.Opts.Path allPartsReady, missingParts := d.checkMultiPartArchiveReady(downloadFilePath, destDir, partInfo) if !allPartsReady { // Not all parts are ready yet - just set status to waiting, don't queue anything task.Progress.ExtractStatus = ExtractStatusWaitingParts d.emit(EventKeyProgress, task) d.storage.Put(bucketTask, task.ID, task.clone()) d.Logger.Info().Msgf("multi-part archive waiting for other parts, task id: %s, missing: %v", task.ID, missingParts) return } // All parts are ready! Atomically check if extraction has already been started/queued // and if not, mark this task as the one that will handle it // Use GetMultiPartArchiveBaseName to get the full path for comparison fullBaseName := GetMultiPartArchiveBaseName(downloadFilePath) shouldQueue := d.tryClaimMultiPartExtraction(task, fullBaseName) if !shouldQueue { // Another part already started/queued extraction, mark this task as done task.Progress.ExtractStatus = ExtractStatusDone task.Progress.ExtractProgress = 100 d.emit(EventKeyProgress, task) d.storage.Put(bucketTask, task.ID, task.clone()) d.Logger.Info().Msgf("multi-part archive extraction already handled by another part, task id: %s", task.ID) return } // This task claimed the extraction - status already set to queued in tryClaimMultiPartExtraction d.emit(EventKeyProgress, task) d.storage.Put(bucketTask, task.ID, task.clone()) jobID := "multipart:" + fullBaseName d.Logger.Info().Msgf("multi-part extraction queued, task id: %s, job id: %s", task.ID, jobID) // Create and enqueue the extraction job job := NewExtractionJob(jobID, func() { d.performMultiPartExtraction(task, partInfo.FirstPartPath, destDir, opts) }) go func() { GetExtractionQueue().Enqueue(job) }() } // checkMultiPartArchiveReady checks if all parts of a multi-part archive are downloaded // by examining task status rather than file existence func (d *Downloader) checkMultiPartArchiveReady(filePath string, destDir string, partInfo ArchivePartInfo) (bool, []string) { // Use task-based checking - find all tasks with the same MultiPartBaseName // and verify they are all in Done status baseName := GetMultiPartArchiveBaseName(filePath) if baseName == "" { return true, nil } return d.checkAllMultiPartTasksDone(baseName) } // checkAllMultiPartTasksDone checks if all tasks belonging to a multi-part archive are done func (d *Downloader) checkAllMultiPartTasksDone(baseName string) (bool, []string) { var notDoneParts []string d.lock.Lock() defer d.lock.Unlock() // Find all tasks that belong to this multi-part archive var relatedTasks []*Task for _, task := range d.tasks { taskBaseName := "" if task.Meta != nil && task.Meta.Res != nil && len(task.Meta.Res.Files) > 0 { taskBaseName = GetMultiPartArchiveBaseName(task.Meta.SingleFilepath()) } if taskBaseName == baseName { relatedTasks = append(relatedTasks, task) } } // If we found no related tasks, we can't determine readiness if len(relatedTasks) == 0 { return false, []string{"no related tasks found for " + baseName} } // Check if all related tasks are done for _, task := range relatedTasks { if task.Status != base.DownloadStatusDone { notDoneParts = append(notDoneParts, task.Meta.SingleFilepath()) } } return len(notDoneParts) == 0, notDoneParts } // tryClaimMultiPartExtraction atomically checks if extraction can be claimed for a multi-part archive // and if so, marks the task as queued. Returns true if this task should proceed with queueing. // This uses sync.Map.LoadOrStore for atomic claim to prevent race conditions. func (d *Downloader) tryClaimMultiPartExtraction(task *Task, baseName string) bool { // Use LoadOrStore for atomic claim - if another goroutine already stored a value, we get that value back _, alreadyClaimed := d.claimedExtractions.LoadOrStore(baseName, task.ID) if alreadyClaimed { return false // Another task already claimed it } // This task successfully claimed it task.Progress.ExtractStatus = ExtractStatusQueued return true } // releaseMultiPartExtractionClaim releases the extraction claim for a multi-part archive // This is primarily used for testing purposes func (d *Downloader) releaseMultiPartExtractionClaim(baseName string) { d.claimedExtractions.Delete(baseName) } // performExtraction performs extraction for a regular (non-multi-part) archive func (d *Downloader) performExtraction(task *Task, archivePath string, destDir string, opts *http.OptsExtra) { // Set extraction status to extracting task.Progress.ExtractStatus = ExtractStatusExtracting task.Progress.ExtractProgress = 0 d.emit(EventKeyProgress, task) d.storage.Put(bucketTask, task.ID, task.clone()) // Extract the archive extractErr := extractArchive(archivePath, destDir, opts.ArchivePassword, func(extractedFiles int, totalFiles int, progress int) { task.Progress.ExtractProgress = progress d.emit(EventKeyProgress, task) }) d.handleExtractionResult(task, extractErr, []string{archivePath}, opts.DeleteAfterExtract) } // performMultiPartExtraction performs extraction for a multi-part archive func (d *Downloader) performMultiPartExtraction(task *Task, firstPartPath string, destDir string, opts *http.OptsExtra) { // Get the baseName for releasing the claim later fullBaseName := GetMultiPartArchiveBaseName(firstPartPath) // Set extraction status to extracting task.Progress.ExtractStatus = ExtractStatusExtracting task.Progress.ExtractProgress = 0 d.emit(EventKeyProgress, task) d.storage.Put(bucketTask, task.ID, task.clone()) d.Logger.Info().Msgf("starting multi-part archive extraction, first part: %s, task id: %s", firstPartPath, task.ID) // Extract the multi-part archive extractErr := extractMultiPartArchive(firstPartPath, destDir, opts.ArchivePassword, func(extractedFiles int, totalFiles int, progress int) { task.Progress.ExtractProgress = progress d.emit(EventKeyProgress, task) }) // Collect all part files for potential deletion partFiles := d.collectMultiPartFiles(firstPartPath) d.handleExtractionResult(task, extractErr, partFiles, opts.DeleteAfterExtract) // Update status for all related multi-part tasks d.updateMultiPartTasksStatus(task, extractErr) // Release the claim so future downloads of the same archive can be extracted d.releaseMultiPartExtractionClaim(fullBaseName) } // collectMultiPartFiles collects all files belonging to a multi-part archive func (d *Downloader) collectMultiPartFiles(firstPartPath string) []string { var files []string partInfo := getArchivePartInfo(firstPartPath) dir := filepath.Dir(firstPartPath) switch { case strings.Contains(partInfo.Pattern, ".7z)"): // 7z: .7z.001, .7z.002, etc. files = d.collectSequentialFiles(dir, partInfo.BaseName, ".%03d") case strings.Contains(partInfo.Pattern, ".part"): // RAR new style files = d.collectRarNewStyleFiles(dir, partInfo.BaseName) case partInfo.Pattern == "rar-old-style" || strings.Contains(partInfo.Pattern, ".r("): // RAR old style files = d.collectRarOldStyleFiles(dir, partInfo.BaseName) case strings.Contains(partInfo.Pattern, ".zip)"): // ZIP multi-part files = d.collectSequentialFiles(dir, partInfo.BaseName, ".%03d") case strings.Contains(partInfo.Pattern, ".z("): // ZIP split files = d.collectZipSplitFiles(dir, partInfo.BaseName) } return files } // collectSequentialFiles collects sequential numbered files func (d *Downloader) collectSequentialFiles(dir, baseName, format string) []string { var files []string suffix := filepath.Ext(baseName) nameWithoutExt := strings.TrimSuffix(baseName, suffix) partNum := 1 for { partPath := filepath.Join(dir, nameWithoutExt+suffix+fmt.Sprintf(format, partNum)) if _, err := os.Stat(partPath); os.IsNotExist(err) { break } files = append(files, partPath) partNum++ } return files } // collectRarNewStyleFiles collects RAR new style part files func (d *Downloader) collectRarNewStyleFiles(dir, baseName string) []string { var files []string partNum := 1 for { singleDigitPath := filepath.Join(dir, baseName+fmt.Sprintf(".part%d.rar", partNum)) doubleDigitPath := filepath.Join(dir, baseName+fmt.Sprintf(".part%02d.rar", partNum)) if _, err := os.Stat(singleDigitPath); err == nil { files = append(files, singleDigitPath) } else if _, err := os.Stat(doubleDigitPath); err == nil { files = append(files, doubleDigitPath) } else { break } partNum++ } return files } // collectRarOldStyleFiles collects RAR old style part files func (d *Downloader) collectRarOldStyleFiles(dir, baseName string) []string { var files []string // .rar file rarPath := filepath.Join(dir, baseName+".rar") if _, err := os.Stat(rarPath); err == nil { files = append(files, rarPath) } // .r00, .r01, etc. partNum := 0 for { partPath := filepath.Join(dir, baseName+fmt.Sprintf(".r%02d", partNum)) if _, err := os.Stat(partPath); os.IsNotExist(err) { break } files = append(files, partPath) partNum++ } return files } // collectZipSplitFiles collects ZIP split files func (d *Downloader) collectZipSplitFiles(dir, baseName string) []string { var files []string // .z01, .z02, etc. partNum := 1 for { partPath := filepath.Join(dir, baseName+fmt.Sprintf(".z%02d", partNum)) if _, err := os.Stat(partPath); os.IsNotExist(err) { break } files = append(files, partPath) partNum++ } // .zip file zipPath := filepath.Join(dir, baseName+".zip") if _, err := os.Stat(zipPath); err == nil { files = append(files, zipPath) } return files } // handleExtractionResult handles the result of an extraction operation func (d *Downloader) handleExtractionResult(task *Task, extractErr error, archiveFiles []string, deleteAfterExtract bool) { if extractErr != nil { d.Logger.Error().Err(extractErr).Msgf("auto extract archive failed, task id: %s", task.ID) task.Progress.ExtractStatus = ExtractStatusError d.emit(EventKeyProgress, task) d.storage.Put(bucketTask, task.ID, task.clone()) } else { d.Logger.Info().Msgf("auto extract archive completed, task id: %s", task.ID) task.Progress.ExtractStatus = ExtractStatusDone task.Progress.ExtractProgress = 100 d.emit(EventKeyProgress, task) d.storage.Put(bucketTask, task.ID, task.clone()) // Delete archive files after successful extraction if enabled if deleteAfterExtract { for _, archiveFile := range archiveFiles { deleteErr := os.Remove(archiveFile) if deleteErr != nil { d.Logger.Error().Err(deleteErr).Msgf("delete archive after extraction failed: %s", archiveFile) } else { d.Logger.Info().Msgf("archive deleted after extraction: %s", archiveFile) } } } } } // updateMultiPartTasksStatus updates the extraction status for all tasks that belong to the same multi-part archive func (d *Downloader) updateMultiPartTasksStatus(sourceTask *Task, extractErr error) { if sourceTask.Progress.MultiPartBaseName == "" { return } status := ExtractStatusDone progress := 100 if extractErr != nil { status = ExtractStatusError progress = 0 } d.lock.Lock() defer d.lock.Unlock() for _, task := range d.tasks { if task.ID == sourceTask.ID { continue } if task.Progress.MultiPartBaseName == sourceTask.Progress.MultiPartBaseName { task.Progress.ExtractStatus = status task.Progress.ExtractProgress = progress d.emit(EventKeyProgress, task) d.storage.Put(bucketTask, task.ID, task.clone()) } } } func initTask(task *Task) { task.timer = util.NewTimer(task.Progress.Used) task.statusLock = &sync.Mutex{} task.lock = &sync.Mutex{} task.speedArr = make([]int64, 0) task.uploadSpeedArr = make([]int64, 0) } var defaultDownloader = NewDownloader(nil) type boot struct { url string extra interface{} listener Listener } func (b *boot) URL(url string) *boot { b.url = url return b } func (b *boot) Extra(extra interface{}) *boot { b.extra = extra return b } func (b *boot) Listener(listener Listener) *boot { b.listener = listener return b } func (b *boot) Create(opts *base.Options) (string, error) { defaultDownloader.Listener(b.listener) return defaultDownloader.CreateDirect(&base.Request{ URL: b.url, Extra: b.extra, }, opts) } func Boot() *boot { err := defaultDownloader.Setup() if err != nil { panic(err) } return &boot{} } ================================================ FILE: pkg/download/downloader_test.go ================================================ package download import ( "archive/zip" "fmt" "io" "net" gohttp "net/http" "os" "path/filepath" "strconv" "strings" "sync" "testing" "time" "github.com/GopeedLab/gopeed/internal/fetcher" "github.com/GopeedLab/gopeed/internal/test" "github.com/GopeedLab/gopeed/pkg/base" "github.com/GopeedLab/gopeed/pkg/protocol/http" "github.com/GopeedLab/gopeed/pkg/util" ) var testDownloadOpt = &base.Options{ Path: test.Dir, Name: test.DownloadName, Extra: http.OptsExtra{ Connections: 4, }, } func TestDownloader_Resolve(t *testing.T) { listener := test.StartTestFileServer() defer listener.Close() downloader := NewDownloader(nil) if err := downloader.Setup(); err != nil { t.Fatal(err) } defer downloader.Clear() req := &base.Request{ URL: "http://" + listener.Addr().String() + "/" + test.BuildName, } rr, err := downloader.Resolve(req, nil) if err != nil { t.Fatal(err) } want := &base.Resource{ Size: test.BuildSize, Range: true, Files: []*base.FileInfo{ { Name: test.BuildName, Path: "", Size: test.BuildSize, }, }, } if !test.AssertResourceEqual(want, rr.Res) { t.Errorf("Resolve() got = %v, want %v", rr.Res, want) } } func TestDownloader_Create(t *testing.T) { listener := test.StartTestFileServer() defer listener.Close() downloader := NewDownloader(nil) if err := downloader.Setup(); err != nil { t.Fatal(err) } defer downloader.Clear() req := &base.Request{ URL: "http://" + listener.Addr().String() + "/" + test.BuildName, } rr, err := downloader.Resolve(req, testDownloadOpt) if err != nil { t.Fatal(err) } var wg sync.WaitGroup wg.Add(1) downloader.Listener(func(event *Event) { if event.Key == EventKeyDone { wg.Done() } }) _, err = downloader.Create(rr.ID) if err != nil { t.Fatal(err) } wg.Wait() want := test.FileMd5(test.BuildFile) got := test.FileMd5(test.DownloadFile) if want != got { t.Errorf("Downloader_Create() got = %v, want %v", got, want) } } func TestDownloader_CreateNotInWhite(t *testing.T) { listener := test.StartTestFileServer() defer listener.Close() downloader := NewDownloader(&DownloaderConfig{ WhiteDownloadDirs: []string{"./downloads"}, }) if err := downloader.Setup(); err != nil { t.Fatal(err) } defer downloader.Clear() req := &base.Request{ URL: "http://" + listener.Addr().String() + "/" + test.BuildName, } // With new fetcher design, white list check happens during Resolve (not Create) // because Resolve now requires Options which includes the download path _, err := downloader.Resolve(req, testDownloadOpt) if err == nil { t.Error("TestDownloader_CreateNotInWhite() expected error but got nil") } if !strings.Contains(err.Error(), "white") { t.Errorf("TestDownloader_CreateNotInWhite() got = %v, want error containing 'white'", err.Error()) } } func TestDownloader_CreateDirectBatch(t *testing.T) { listener := test.StartTestFileServer() defer listener.Close() downloader := NewDownloader(nil) if err := downloader.Setup(); err != nil { t.Fatal(err) } defer func() { downloader.Delete(nil, true) downloader.Clear() }() reqs := make([]*base.CreateTaskBatchItem, 0) fileNames := make([]string, 0) for i := 0; i < 5; i++ { req := &base.Request{ URL: "http://" + listener.Addr().String() + "/" + test.BuildName, } reqs = append(reqs, &base.CreateTaskBatchItem{ Req: req, }) if i == 0 { fileNames = append(fileNames, test.DownloadName) } else { arr := strings.Split(test.DownloadName, ".") fileNames = append(fileNames, arr[0]+" ("+strconv.Itoa(i)+")."+arr[1]) } } var wg sync.WaitGroup wg.Add(len(reqs)) downloader.Listener(func(event *Event) { if event.Key == EventKeyDone { wg.Done() } }) _, err := downloader.CreateDirectBatch(&base.CreateTaskBatch{ Reqs: reqs, Opts: testDownloadOpt, }) if err != nil { t.Fatal(err) } wg.Wait() tasks := downloader.GetTasks() if len(tasks) != len(reqs) { t.Errorf("CreateDirectBatch() task got = %v, want %v", len(tasks), len(reqs)) } // Collect all task names taskNames := make(map[string]bool) for _, task := range tasks { taskNames[task.Meta.Opts.Name] = true } // Check that we have the expected number of unique task names if len(taskNames) != len(reqs) { t.Errorf("CreateDirectBatch() unique task names got = %v, want %v, names: %v", len(taskNames), len(reqs), taskNames) } // Check that all task files exist for name := range taskNames { if _, err := os.Stat(test.Dir + "/" + name); os.IsNotExist(err) { t.Errorf("CreateDirectBatch() file not exist: %v", name) } } } func TestDownloader_CreateWithProxy(t *testing.T) { // No proxy doTestDownloaderCreateWithProxy(t, false, nil, func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig { return nil }, nil) // Disable proxy doTestDownloaderCreateWithProxy(t, false, nil, func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig { proxyCfg.Enable = false return proxyCfg }, nil) // Enable system proxy but not set proxy environment variable doTestDownloaderCreateWithProxy(t, false, nil, func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig { proxyCfg.System = true return proxyCfg }, nil) // Enable proxy but error proxy environment variable doTestDownloaderCreateWithProxy(t, false, nil, func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig { os.Setenv("HTTP_PROXY", "http://127.0.0.1:1234") os.Setenv("HTTPS_PROXY", "http://127.0.0.1:1234") proxyCfg.System = true return proxyCfg }, func(err error) { if err == nil { t.Fatal("doTestDownloaderCreateWithProxy() got = nil, want error") } }) // Enable system proxy and set proxy environment variable doTestDownloaderCreateWithProxy(t, false, nil, func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig { os.Setenv("HTTP_PROXY", proxyCfg.ToUrl().String()) os.Setenv("HTTPS_PROXY", proxyCfg.ToUrl().String()) proxyCfg.System = true return proxyCfg }, nil) // Invalid proxy scheme doTestDownloaderCreateWithProxy(t, false, nil, func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig { proxyCfg.Scheme = "" return proxyCfg }, nil) // Invalid proxy host doTestDownloaderCreateWithProxy(t, false, nil, func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig { proxyCfg.Host = "" return proxyCfg }, nil) // Use proxy without auth doTestDownloaderCreateWithProxy(t, false, nil, func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig { return proxyCfg }, nil) // Use proxy with auth doTestDownloaderCreateWithProxy(t, true, nil, func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig { return proxyCfg }, nil) // Request proxy mode follow doTestDownloaderCreateWithProxy(t, false, func(reqProxy *base.RequestProxy) *base.RequestProxy { reqProxy.Mode = base.RequestProxyModeFollow return reqProxy }, nil, nil) // Request proxy mode none doTestDownloaderCreateWithProxy(t, false, func(reqProxy *base.RequestProxy) *base.RequestProxy { reqProxy.Mode = base.RequestProxyModeNone return reqProxy }, nil, nil) // Request proxy mode custom doTestDownloaderCreateWithProxy(t, false, func(reqProxy *base.RequestProxy) *base.RequestProxy { return reqProxy }, nil, nil) } func doTestDownloaderCreateWithProxy(t *testing.T, auth bool, buildReqProxy func(reqProxy *base.RequestProxy) *base.RequestProxy, buildProxyConfig func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig, errHandler func(err error)) { usr, pwd := "", "" if auth { usr, pwd = "admin", "123" } proxyListener := test.StartSocks5Server(usr, pwd) defer proxyListener.Close() downloader := NewDownloader(nil) if err := downloader.Setup(); err != nil { t.Fatal(err) } defer downloader.Clear() globalProxyCfg := &base.DownloaderProxyConfig{ Enable: true, Scheme: "socks5", Host: proxyListener.Addr().String(), Usr: usr, Pwd: pwd, } if buildProxyConfig != nil { globalProxyCfg = buildProxyConfig(globalProxyCfg) } downloader.cfg.DownloaderStoreConfig.Proxy = globalProxyCfg req := &base.Request{ URL: test.ExternalDownloadUrl, } if buildReqProxy != nil { req.Proxy = buildReqProxy(&base.RequestProxy{ Scheme: "socks5", Host: proxyListener.Addr().String(), Usr: usr, Pwd: pwd, }) } rr, err := downloader.Resolve(req, nil) if err != nil { if errHandler == nil { t.Fatal(err) } errHandler(err) return } want := &base.Resource{ Size: test.ExternalDownloadSize, Range: true, Files: []*base.FileInfo{ { Name: test.ExternalDownloadName, Path: "", Size: test.ExternalDownloadSize, }, }, } if !test.AssertResourceEqual(want, rr.Res) { t.Errorf("Resolve() got = %v, want %v", rr.Res, want) } } func TestDownloader_CreateRename(t *testing.T) { listener := test.StartTestFileServer() defer listener.Close() downloader := NewDownloader(nil) if err := downloader.Setup(); err != nil { t.Fatal(err) } defer downloader.Clear() req := &base.Request{ URL: "http://" + listener.Addr().String() + "/" + test.BuildName, } var wg sync.WaitGroup wg.Add(2) downloader.Listener(func(event *Event) { if event.Key == EventKeyDone { wg.Done() } }) for i := 0; i < 2; i++ { _, err := downloader.CreateDirect(req, &base.Options{ Path: test.Dir, Name: test.DownloadName, Extra: http.OptsExtra{ Connections: 4, }, }) if err != nil { t.Fatal(err) } } wg.Wait() want := test.FileMd5(test.BuildFile) got := test.FileMd5(test.DownloadFile) if want != got { t.Errorf("Downloader_CreateRename() got = %v, want %v", got, want) } got = test.FileMd5(test.DownloadRenameFile) if want != got { t.Errorf("Downloader_CreateRename() got = %v, want %v", got, want) } } func TestDownloader_StoreAndRestore(t *testing.T) { listener := test.StartTestSlowFileServer(time.Millisecond * 2000) defer listener.Close() downloader := NewDownloader(&DownloaderConfig{ Storage: NewBoltStorage("./"), }) if err := downloader.Setup(); err != nil { t.Fatal(err) } defer downloader.Clear() req := &base.Request{ URL: "http://" + listener.Addr().String() + "/" + test.BuildName, } rr, err := downloader.Resolve(req, testDownloadOpt) if err != nil { t.Fatal(err) } id, err := downloader.Create(rr.ID) if err != nil { t.Fatal(err) } time.Sleep(time.Millisecond * 1001) err = downloader.Pause(&TaskFilter{IDs: []string{id}}) if err != nil { t.Fatal(err) } downloader.Close() downloader = NewDownloader(&DownloaderConfig{ Storage: NewBoltStorage("./"), }) if err := downloader.Setup(); err != nil { t.Fatal(err) } task := downloader.GetTask(id) if task == nil { t.Fatal("task is nil") } var wg sync.WaitGroup wg.Add(1) downloader.Listener(func(event *Event) { if event.Key == EventKeyDone { wg.Done() } }) err = downloader.Continue(&TaskFilter{IDs: []string{id}}) wg.Wait() if err != nil { t.Fatal(err) } want := test.FileMd5(test.BuildFile) got := test.FileMd5(test.DownloadFile) if want != got { t.Errorf("StoreAndResume() got = %v, want %v", got, want) } downloader.Clear() } func TestDownloader_Protocol_Config(t *testing.T) { downloader := NewDownloader(nil) if err := downloader.Setup(); err != nil { t.Fatal(err) } defer downloader.Clear() var httpCfg map[string]any exits := downloader.getProtocolConfig("http", &httpCfg) if !exits { t.Errorf("getProtocolConfig() got = %v, want %v", exits, true) } storeCfg := &base.DownloaderStoreConfig{ DownloadDir: "./downloads", ProtocolConfig: map[string]any{ "http": map[string]any{ "connections": 4, }, "bt": map[string]any{ "trackerSubscribeUrls": []string{ "https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/best.txt", }, "trackers": []string{ "udp://tracker.coppersurfer.tk:6969/announce", "udp://tracker.leechers-paradise.org:6969/announce", }, }, }, Extra: map[string]any{ "theme": "dark", }, } if err := downloader.PutConfig(storeCfg); err != nil { t.Fatal(err) } newStoreCfg, err := downloader.GetConfig() if err != nil { t.Fatal(err) } if !test.JsonEqual(storeCfg, newStoreCfg) { t.Errorf("GetConfig() got = %v, want %v", test.ToJson(storeCfg), test.ToJson(newStoreCfg)) } } func TestDownloader_GetTasksByFilter(t *testing.T) { listener := test.StartTestFileServer() defer listener.Close() downloader := NewDownloader(nil) if err := downloader.Setup(); err != nil { t.Fatal(err) } defer func() { downloader.Delete(nil, true) downloader.Clear() }() reqs := make([]*base.CreateTaskBatchItem, 0) fileNames := make([]string, 0) for i := 0; i < 10; i++ { req := &base.Request{ URL: "http://" + listener.Addr().String() + "/" + test.BuildName, } reqs = append(reqs, &base.CreateTaskBatchItem{ Req: req, }) if i == 0 { fileNames = append(fileNames, test.DownloadName) } else { arr := strings.Split(test.DownloadName, ".") fileNames = append(fileNames, arr[0]+" ("+strconv.Itoa(i)+")."+arr[1]) } } var wg sync.WaitGroup wg.Add(len(reqs)) downloader.Listener(func(event *Event) { if event.Key == EventKeyDone { wg.Done() } }) taskIds, err := downloader.CreateDirectBatch(&base.CreateTaskBatch{ Reqs: reqs, Opts: &base.Options{ Path: test.Dir, Name: test.DownloadName, Extra: http.OptsExtra{ Connections: 4, }, }, }) if err != nil { t.Fatal(err) } wg.Wait() t.Run("GetTasksByFilter nil", func(t *testing.T) { tasks := downloader.GetTasksByFilter(nil) if len(tasks) != len(reqs) { t.Errorf("GetTasksByFilter nil task got = %v, want %v", len(tasks), len(reqs)) } }) t.Run("GetTasksByFilter empty", func(t *testing.T) { tasks := downloader.GetTasksByFilter(&TaskFilter{}) if len(tasks) != len(reqs) { t.Errorf("GetTasksByFilter empty task got = %v, want %v", len(tasks), len(reqs)) } }) t.Run("GetTasksByFilter ids", func(t *testing.T) { tasks := downloader.GetTasksByFilter(&TaskFilter{ IDs: taskIds, }) if len(tasks) != len(reqs) { t.Errorf("GetTasksByFilter ids task got = %v, want %v", len(tasks), len(reqs)) } }) t.Run("GetTasksByFilter match ids", func(t *testing.T) { tasks := downloader.GetTasksByFilter(&TaskFilter{ IDs: []string{taskIds[0]}, }) if len(tasks) != 1 { t.Errorf("GetTasksByFilter ids task got = %v, want %v", len(tasks), 1) } }) t.Run("GetTasksByFilter not match ids", func(t *testing.T) { tasks := downloader.GetTasksByFilter(&TaskFilter{ IDs: []string{"xxx"}, }) if len(tasks) != 0 { t.Errorf("GetTasksByFilter ids task got = %v, want %v", len(tasks), 0) } }) t.Run("GetTasksByFilter status", func(t *testing.T) { tasks := downloader.GetTasksByFilter(&TaskFilter{ Statuses: []base.Status{base.DownloadStatusDone}, }) if len(tasks) != len(reqs) { t.Errorf("GetTasksByFilter status task got = %v, want %v", len(tasks), len(reqs)) } }) t.Run("GetTasksByFilter not match status", func(t *testing.T) { tasks := downloader.GetTasksByFilter(&TaskFilter{ Statuses: []base.Status{base.DownloadStatusError}, }) if len(tasks) != 0 { t.Errorf("GetTasksByFilter status task got = %v, want %v", len(tasks), 0) } }) t.Run("GetTasksByFilter match notStatus", func(t *testing.T) { tasks := downloader.GetTasksByFilter(&TaskFilter{ NotStatuses: []base.Status{base.DownloadStatusRunning, base.DownloadStatusPause}, }) if len(tasks) != len(reqs) { t.Errorf("GetTasksByFilter match notStatus task got = %v, want %v", len(tasks), len(reqs)) } }) t.Run("GetTasksByFilter not match notStatus", func(t *testing.T) { tasks := downloader.GetTasksByFilter(&TaskFilter{ NotStatuses: []base.Status{base.DownloadStatusDone}, }) if len(tasks) != 0 { t.Errorf("GetTasksByFilter not match notStatus task got = %v, want %v", len(tasks), 0) } }) t.Run("GetTasksByFilter match ids and status", func(t *testing.T) { tasks := downloader.GetTasksByFilter(&TaskFilter{ IDs: []string{taskIds[0]}, Statuses: []base.Status{base.DownloadStatusDone}, }) if len(tasks) != 1 { t.Errorf("GetTasksByFilter match ids and status task got = %v, want %v", len(tasks), 1) } }) t.Run("GetTasksByFilter not match ids and status", func(t *testing.T) { tasks := downloader.GetTasksByFilter(&TaskFilter{ IDs: []string{taskIds[0]}, Statuses: []base.Status{base.DownloadStatusError}, }) if len(tasks) != 0 { t.Errorf("GetTasksByFilter not match ids and status task got = %v, want %v", len(tasks), 0) } }) } func TestDownloader_Stats(t *testing.T) { listener := test.StartTestFileServer() defer listener.Close() downloader := NewDownloader(nil) if err := downloader.Setup(); err != nil { t.Fatal(err) } defer downloader.Clear() // Test Stats for non-existent task _, err := downloader.Stats("non-existent-id") if err != ErrTaskNotFound { t.Errorf("Stats() expected ErrTaskNotFound, got %v", err) } // Create a task req := &base.Request{ URL: "http://" + listener.Addr().String() + "/" + test.BuildName, } rr, err := downloader.Resolve(req, testDownloadOpt) if err != nil { t.Fatal(err) } var wg sync.WaitGroup wg.Add(1) downloader.Listener(func(event *Event) { if event.Key == EventKeyDone { wg.Done() } }) taskId, err := downloader.Create(rr.ID) if err != nil { t.Fatal(err) } wg.Wait() // Test Stats for existing task stats, err := downloader.Stats(taskId) if err != nil { t.Errorf("Stats() unexpected error: %v", err) } if stats == nil { t.Error("Stats() returned nil stats") } } func TestDownloader_Delete(t *testing.T) { listener := test.StartTestFileServer() defer listener.Close() downloader := NewDownloader(nil) if err := downloader.Setup(); err != nil { t.Fatal(err) } defer downloader.Clear() // Create multiple tasks var wg sync.WaitGroup taskCount := 3 wg.Add(taskCount) downloader.Listener(func(event *Event) { if event.Key == EventKeyDone { wg.Done() } }) taskIds := make([]string, 0) for i := 0; i < taskCount; i++ { req := &base.Request{ URL: "http://" + listener.Addr().String() + "/" + test.BuildName, } taskId, err := downloader.CreateDirect(req, testDownloadOpt) if err != nil { t.Fatal(err) } taskIds = append(taskIds, taskId) } wg.Wait() // Test Delete with filter (single task) t.Run("Delete single task by ID", func(t *testing.T) { initialCount := len(downloader.GetTasks()) err := downloader.Delete(&TaskFilter{IDs: []string{taskIds[0]}}, true) if err != nil { t.Errorf("Delete() unexpected error: %v", err) } newCount := len(downloader.GetTasks()) if newCount != initialCount-1 { t.Errorf("Delete() task count got = %v, want %v", newCount, initialCount-1) } }) // Test Delete with non-matching filter (should do nothing) t.Run("Delete with non-matching filter", func(t *testing.T) { initialCount := len(downloader.GetTasks()) err := downloader.Delete(&TaskFilter{IDs: []string{"non-existent-id"}}, true) if err != nil { t.Errorf("Delete() unexpected error: %v", err) } newCount := len(downloader.GetTasks()) if newCount != initialCount { t.Errorf("Delete() task count got = %v, want %v", newCount, initialCount) } }) // Test Delete by status t.Run("Delete by status", func(t *testing.T) { initialCount := len(downloader.GetTasks()) err := downloader.Delete(&TaskFilter{Statuses: []base.Status{base.DownloadStatusDone}}, false) if err != nil { t.Errorf("Delete() unexpected error: %v", err) } newCount := len(downloader.GetTasks()) if newCount != 0 { t.Errorf("Delete() should have deleted all done tasks, got %v remaining", newCount) } _ = initialCount // suppress unused variable warning }) } func TestDownloader_PauseAndContinue(t *testing.T) { listener := test.StartTestSlowFileServer(time.Millisecond * 2000) defer listener.Close() downloader := NewDownloader(nil) if err := downloader.Setup(); err != nil { t.Fatal(err) } defer downloader.Clear() // Create a single task req := &base.Request{ URL: "http://" + listener.Addr().String() + "/" + test.BuildName, } rr, err := downloader.Resolve(req, testDownloadOpt) if err != nil { t.Fatal(err) } taskId, err := downloader.Create(rr.ID) if err != nil { t.Fatal(err) } // Wait for task to start time.Sleep(time.Millisecond * 100) // Pause with specific taskId err = downloader.Pause(&TaskFilter{IDs: []string{taskId}}) if err != nil { t.Fatal(err) } time.Sleep(time.Millisecond * 100) // Verify task is paused task := downloader.GetTask(taskId) if task.Status != base.DownloadStatusPause { t.Errorf("Task should be paused, got %s", task.Status) } // Continue with specific taskId var wg sync.WaitGroup wg.Add(1) downloader.Listener(func(event *Event) { if event.Key == EventKeyDone { wg.Done() } }) err = downloader.Continue(&TaskFilter{IDs: []string{taskId}}) if err != nil { t.Fatal(err) } // Wait for task to complete wg.Wait() // Verify task is done task = downloader.GetTask(taskId) if task.Status != base.DownloadStatusDone { t.Errorf("Task should be done, got %s", task.Status) } // Clean up downloader.Delete(nil, true) } func TestDownloader_PauseAllAndContinueAll(t *testing.T) { listener := test.StartTestSlowFileServer(time.Millisecond * 2000) defer listener.Close() downloader := NewDownloader(nil) if err := downloader.Setup(); err != nil { t.Fatal(err) } defer downloader.Clear() // Create multiple tasks taskCount := 3 taskIds := make([]string, 0) for i := 0; i < taskCount; i++ { req := &base.Request{ URL: "http://" + listener.Addr().String() + "/" + test.BuildName, } rr, err := downloader.Resolve(req, testDownloadOpt) if err != nil { t.Fatal(err) } taskId, err := downloader.Create(rr.ID) if err != nil { t.Fatal(err) } taskIds = append(taskIds, taskId) } // Wait for tasks to start time.Sleep(time.Millisecond * 100) // Pause all tasks with nil filter err := downloader.Pause(nil) if err != nil { t.Fatal(err) } time.Sleep(time.Millisecond * 100) // Verify all tasks are paused pausedCount := 0 for _, taskId := range taskIds { task := downloader.GetTask(taskId) if task.Status == base.DownloadStatusPause { pausedCount++ } } if pausedCount != taskCount { t.Errorf("Expected %d paused tasks, got %d", taskCount, pausedCount) } // Continue all tasks with nil filter var wg sync.WaitGroup wg.Add(taskCount) downloader.Listener(func(event *Event) { if event.Key == EventKeyDone { wg.Done() } }) err = downloader.Continue(nil) if err != nil { t.Fatal(err) } // Wait for all tasks to complete wg.Wait() // Verify all tasks are done doneCount := 0 for _, taskId := range taskIds { task := downloader.GetTask(taskId) if task.Status == base.DownloadStatusDone { doneCount++ } } if doneCount != taskCount { t.Errorf("Expected %d done tasks, got %d", taskCount, doneCount) } // Clean up downloader.Delete(nil, true) } func TestDownloader_GetTask(t *testing.T) { downloader := NewDownloader(nil) if err := downloader.Setup(); err != nil { t.Fatal(err) } defer downloader.Clear() // Test GetTask for non-existent task task := downloader.GetTask("non-existent-id") if task != nil { t.Errorf("GetTask() expected nil for non-existent task, got %v", task) } } func TestDownloader_Emit(t *testing.T) { downloader := NewDownloader(nil) if err := downloader.Setup(); err != nil { t.Fatal(err) } defer downloader.Clear() // Test emit with no listener (should not panic) downloader.emit(EventKeyDone, nil) // Test emit with listener eventReceived := false downloader.Listener(func(event *Event) { eventReceived = true }) downloader.emit(EventKeyDone, nil) if !eventReceived { t.Error("Event should have been received by listener") } } func TestDownloader_AutoExtract(t *testing.T) { // Create a temporary directory for extraction tests tempDir, err := os.MkdirTemp("", "downloader_extract_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create a test zip file zipPath := tempDir + "/test_archive.zip" if err := createTestArchive(zipPath); err != nil { t.Fatal(err) } // Verify isArchiveFile works correctly t.Run("isArchiveFile", func(t *testing.T) { if !isArchiveFile(zipPath) { t.Error("isArchiveFile should return true for .zip file") } if isArchiveFile(tempDir + "/test.txt") { t.Error("isArchiveFile should return false for .txt file") } }) } // TestDownloader_AutoExtractWithProgress tests the auto-extract functionality with progress tracking // This test exercises the ExtractStatus and ExtractProgress fields in the Progress struct func TestDownloader_AutoExtractWithProgress(t *testing.T) { // Create a temporary directory for the test tempDir, err := os.MkdirTemp("", "auto_extract_progress_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create a test zip file to serve zipPath := tempDir + "/archive.zip" if err := createTestArchiveWithMultipleFiles(zipPath, 3); err != nil { t.Fatal(err) } // Start a simple HTTP server to serve the zip file server := startTestArchiveServer(zipPath) defer server.Close() // Create downloader downloader := NewDownloader(nil) if err := downloader.Setup(); err != nil { t.Fatal(err) } defer downloader.Clear() // Track extraction status changes var extractStatusChanges []ExtractStatus var extractProgressValues []int var statusMutex sync.Mutex extractDoneCh := make(chan struct{}) var extractDoneOnce sync.Once downloader.Listener(func(event *Event) { if event.Key == EventKeyProgress && event.Task != nil && event.Task.Progress != nil { statusMutex.Lock() status := event.Task.Progress.ExtractStatus progress := event.Task.Progress.ExtractProgress // Record status changes if status != ExtractStatusNone { if len(extractStatusChanges) == 0 || extractStatusChanges[len(extractStatusChanges)-1] != status { extractStatusChanges = append(extractStatusChanges, status) } extractProgressValues = append(extractProgressValues, progress) } statusMutex.Unlock() // Signal when extraction is done or errored if status == ExtractStatusDone || status == ExtractStatusError { extractDoneOnce.Do(func() { close(extractDoneCh) }) } } }) // Create request to download the zip file req := &base.Request{ URL: "http://" + server.Addr().String() + "/archive.zip", } // Create task with AutoExtract enabled downloadDir := tempDir + "/downloads" taskId, err := downloader.CreateDirect(req, &base.Options{ Path: downloadDir, Name: "archive.zip", Extra: http.OptsExtra{ Connections: 1, AutoExtract: util.BoolPtr(true), }, }) if err != nil { t.Fatal(err) } // Wait for extraction to complete (with timeout) select { case <-extractDoneCh: // Extraction completed case <-time.After(30 * time.Second): t.Log("Extraction timed out, checking results anyway") } // Give a small buffer for final events to be processed time.Sleep(100 * time.Millisecond) // Verify task exists task := downloader.GetTask(taskId) if task == nil { t.Fatal("Task should exist") } // Verify extraction status changes occurred statusMutex.Lock() defer statusMutex.Unlock() t.Logf("Recorded extract status changes: %v", extractStatusChanges) t.Logf("Recorded extract progress values: %v", extractProgressValues) // Verify that we went through ExtractStatusExtracting foundExtracting := false for _, status := range extractStatusChanges { if status == ExtractStatusExtracting { foundExtracting = true break } } if !foundExtracting { t.Error("Expected ExtractStatusExtracting in status changes") } // Verify that we reached ExtractStatusDone foundDone := false for _, status := range extractStatusChanges { if status == ExtractStatusDone { foundDone = true break } } if !foundDone { t.Error("Expected ExtractStatusDone in status changes") } // Verify progress values include 100 (final) found100 := false for _, p := range extractProgressValues { if p == 100 { found100 = true break } } if !found100 { t.Error("Expected progress to reach 100") } // Verify extracted files exist extractedFile := downloadDir + "/test_0.txt" if _, err := os.Stat(extractedFile); os.IsNotExist(err) { t.Error("Expected extracted file to exist") } } // TestDownloader_AutoExtractWithDeleteAfterExtract tests the auto-extract with DeleteAfterExtract option func TestDownloader_AutoExtractWithDeleteAfterExtract(t *testing.T) { // Create a temporary directory for the test tempDir, err := os.MkdirTemp("", "auto_extract_delete_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create a test zip file to serve zipPath := tempDir + "/archive.zip" if err := createTestArchiveWithMultipleFiles(zipPath, 2); err != nil { t.Fatal(err) } // Start a simple HTTP server to serve the zip file server := startTestArchiveServer(zipPath) defer server.Close() // Create downloader downloader := NewDownloader(nil) if err := downloader.Setup(); err != nil { t.Fatal(err) } defer downloader.Clear() // Track extraction status changes extractDoneCh := make(chan struct{}) var extractDoneOnce sync.Once downloader.Listener(func(event *Event) { if event.Key == EventKeyProgress && event.Task != nil && event.Task.Progress != nil { status := event.Task.Progress.ExtractStatus if status == ExtractStatusDone || status == ExtractStatusError { extractDoneOnce.Do(func() { close(extractDoneCh) }) } } }) // Create request to download the zip file req := &base.Request{ URL: "http://" + server.Addr().String() + "/archive.zip", } // Create task with AutoExtract and DeleteAfterExtract enabled downloadDir := tempDir + "/downloads" _, err = downloader.CreateDirect(req, &base.Options{ Path: downloadDir, Name: "archive.zip", Extra: http.OptsExtra{ Connections: 1, AutoExtract: util.BoolPtr(true), DeleteAfterExtract: true, }, }) if err != nil { t.Fatal(err) } // Wait for extraction to complete (with timeout) select { case <-extractDoneCh: // Extraction completed case <-time.After(10 * time.Second): t.Log("Extraction timed out") } // Give time for file deletion time.Sleep(200 * time.Millisecond) // Verify archive was deleted archivePath := downloadDir + "/archive.zip" if _, err := os.Stat(archivePath); !os.IsNotExist(err) { t.Error("Expected archive to be deleted after extraction") } // Verify extracted files exist extractedFile := downloadDir + "/test_0.txt" if _, err := os.Stat(extractedFile); os.IsNotExist(err) { t.Error("Expected extracted file to exist") } } // TestDownloader_AutoExtractError tests the auto-extract error handling path func TestDownloader_AutoExtractError(t *testing.T) { // Create a temporary directory for the test tempDir, err := os.MkdirTemp("", "auto_extract_error_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create a corrupt zip file (just invalid data with .zip extension) corruptZipPath := tempDir + "/corrupt.zip" if err := os.WriteFile(corruptZipPath, []byte("this is not a valid zip file"), 0644); err != nil { t.Fatal(err) } // Start a simple HTTP server to serve the corrupt zip file server := startTestArchiveServer(corruptZipPath) defer server.Close() // Create downloader downloader := NewDownloader(nil) if err := downloader.Setup(); err != nil { t.Fatal(err) } defer downloader.Clear() // Track extraction status changes var extractStatusChanges []ExtractStatus var statusMutex sync.Mutex extractDoneCh := make(chan struct{}) var extractDoneOnce sync.Once downloader.Listener(func(event *Event) { if event.Key == EventKeyProgress && event.Task != nil && event.Task.Progress != nil { statusMutex.Lock() status := event.Task.Progress.ExtractStatus if status != ExtractStatusNone { if len(extractStatusChanges) == 0 || extractStatusChanges[len(extractStatusChanges)-1] != status { extractStatusChanges = append(extractStatusChanges, status) } } statusMutex.Unlock() if status == ExtractStatusDone || status == ExtractStatusError { extractDoneOnce.Do(func() { close(extractDoneCh) }) } } }) // Create request to download the corrupt zip file req := &base.Request{ URL: "http://" + server.Addr().String() + "/corrupt.zip", } // Create task with AutoExtract enabled downloadDir := tempDir + "/downloads" _, err = downloader.CreateDirect(req, &base.Options{ Path: downloadDir, Name: "corrupt.zip", Extra: http.OptsExtra{ Connections: 1, AutoExtract: util.BoolPtr(true), }, }) if err != nil { t.Fatal(err) } // Wait for extraction to complete (with timeout) select { case <-extractDoneCh: // Extraction completed (should be error) case <-time.After(10 * time.Second): t.Log("Extraction timed out") } // Give a small buffer for final events to be processed time.Sleep(100 * time.Millisecond) // Verify extraction status changes include error statusMutex.Lock() defer statusMutex.Unlock() t.Logf("Recorded extract status changes: %v", extractStatusChanges) // Verify that we went through ExtractStatusExtracting foundExtracting := false for _, status := range extractStatusChanges { if status == ExtractStatusExtracting { foundExtracting = true break } } if !foundExtracting { t.Error("Expected ExtractStatusExtracting in status changes") } // Verify that we reached ExtractStatusError foundError := false for _, status := range extractStatusChanges { if status == ExtractStatusError { foundError = true break } } if !foundError { t.Error("Expected ExtractStatusError in status changes") } } // TestExtractStatus tests the ExtractStatus constants func TestExtractStatus(t *testing.T) { tests := []struct { status ExtractStatus expected string }{ {ExtractStatusNone, ""}, {ExtractStatusQueued, "queued"}, {ExtractStatusExtracting, "extracting"}, {ExtractStatusDone, "done"}, {ExtractStatusError, "error"}, } for _, tt := range tests { t.Run(string(tt.status), func(t *testing.T) { if string(tt.status) != tt.expected { t.Errorf("ExtractStatus %v = %q, want %q", tt.status, string(tt.status), tt.expected) } }) } } // TestProgress_ExtractFields tests the ExtractStatus and ExtractProgress fields in Progress struct func TestProgress_ExtractFields(t *testing.T) { progress := &Progress{ ExtractStatus: ExtractStatusExtracting, ExtractProgress: 50, } if progress.ExtractStatus != ExtractStatusExtracting { t.Errorf("ExtractStatus = %v, want %v", progress.ExtractStatus, ExtractStatusExtracting) } if progress.ExtractProgress != 50 { t.Errorf("ExtractProgress = %v, want %v", progress.ExtractProgress, 50) } // Test status transitions progress.ExtractStatus = ExtractStatusDone progress.ExtractProgress = 100 if progress.ExtractStatus != ExtractStatusDone { t.Errorf("ExtractStatus after update = %v, want %v", progress.ExtractStatus, ExtractStatusDone) } if progress.ExtractProgress != 100 { t.Errorf("ExtractProgress after update = %v, want %v", progress.ExtractProgress, 100) } } // TestProgress_MultiPartFields tests the multi-part archive fields in Progress struct func TestProgress_MultiPartFields(t *testing.T) { progress := &Progress{ ExtractStatus: ExtractStatusWaitingParts, MultiPartBaseName: "/path/to/archive.7z", MultiPartNumber: 1, MultiPartIsFirst: true, } if progress.ExtractStatus != ExtractStatusWaitingParts { t.Errorf("ExtractStatus = %v, want %v", progress.ExtractStatus, ExtractStatusWaitingParts) } if progress.MultiPartBaseName != "/path/to/archive.7z" { t.Errorf("MultiPartBaseName = %v, want %v", progress.MultiPartBaseName, "/path/to/archive.7z") } if progress.MultiPartNumber != 1 { t.Errorf("MultiPartNumber = %v, want %v", progress.MultiPartNumber, 1) } if !progress.MultiPartIsFirst { t.Error("MultiPartIsFirst should be true") } // Test second part progress2 := &Progress{ ExtractStatus: ExtractStatusWaitingParts, MultiPartBaseName: "/path/to/archive.7z", MultiPartNumber: 2, MultiPartIsFirst: false, } if progress2.MultiPartNumber != 2 { t.Errorf("MultiPartNumber = %v, want %v", progress2.MultiPartNumber, 2) } if progress2.MultiPartIsFirst { t.Error("MultiPartIsFirst should be false") } } // TestExtractStatus_WaitingParts tests the new ExtractStatusWaitingParts status func TestExtractStatus_WaitingParts(t *testing.T) { if ExtractStatusWaitingParts != "waitingParts" { t.Errorf("ExtractStatusWaitingParts = %v, want %v", ExtractStatusWaitingParts, "waitingParts") } } // createTestArchiveWithMultipleFiles creates a test zip file with multiple files func createTestArchiveWithMultipleFiles(path string, count int) error { file, err := os.Create(path) if err != nil { return err } defer file.Close() zipWriter := zip.NewWriter(file) defer zipWriter.Close() for i := 0; i < count; i++ { w, err := zipWriter.Create("test_" + strconv.Itoa(i) + ".txt") if err != nil { return err } _, err = w.Write([]byte("test content " + strconv.Itoa(i))) if err != nil { return err } } return nil } // startTestArchiveServer starts a simple HTTP server that serves a zip file func startTestArchiveServer(zipPath string) net.Listener { listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { panic(err) } go func() { gohttp.Serve(listener, gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) { file, err := os.Open(zipPath) if err != nil { gohttp.Error(w, err.Error(), gohttp.StatusInternalServerError) return } defer file.Close() stat, _ := file.Stat() w.Header().Set("Content-Type", "application/zip") w.Header().Set("Content-Length", strconv.FormatInt(stat.Size(), 10)) io.Copy(w, file) })) }() return listener } // createTestArchive creates a simple test zip file for testing func createTestArchive(path string) error { file, err := os.Create(path) if err != nil { return err } defer file.Close() // Create a simple zip archive zipWriter := zip.NewWriter(file) defer zipWriter.Close() // Add a test file w, err := zipWriter.Create("test.txt") if err != nil { return err } _, err = w.Write([]byte("test content")) return err } func TestDownloader_Close(t *testing.T) { downloader := NewDownloader(nil) if err := downloader.Setup(); err != nil { t.Fatal(err) } // Close should not error err := downloader.Close() if err != nil { t.Errorf("Close() unexpected error: %v", err) } // Calling Close again should not panic err = downloader.Close() if err != nil { t.Errorf("Close() second call unexpected error: %v", err) } } func TestDownloader_DeleteAll(t *testing.T) { listener := test.StartTestFileServer() defer listener.Close() downloader := NewDownloader(nil) if err := downloader.Setup(); err != nil { t.Fatal(err) } defer downloader.Clear() // Create multiple tasks var wg sync.WaitGroup taskCount := 3 wg.Add(taskCount) downloader.Listener(func(event *Event) { if event.Key == EventKeyDone { wg.Done() } }) for i := 0; i < taskCount; i++ { req := &base.Request{ URL: "http://" + listener.Addr().String() + "/" + test.BuildName, } _, err := downloader.CreateDirect(req, &base.Options{ Path: test.Dir, Name: test.DownloadName, Extra: http.OptsExtra{ Connections: 4, }, }) if err != nil { t.Fatal(err) } } wg.Wait() // Verify tasks were created if len(downloader.GetTasks()) != taskCount { t.Errorf("Expected %d tasks, got %d", taskCount, len(downloader.GetTasks())) } // Delete all tasks with nil filter err := downloader.Delete(nil, true) if err != nil { t.Errorf("Delete(nil) unexpected error: %v", err) } // Verify all tasks are deleted if len(downloader.GetTasks()) != 0 { t.Errorf("All tasks should be deleted, got %d remaining", len(downloader.GetTasks())) } } func TestDownloader_ProtocolConfigNotExist(t *testing.T) { downloader := NewDownloader(nil) if err := downloader.Setup(); err != nil { t.Fatal(err) } defer downloader.Clear() // Test getting a protocol config that doesn't exist var unknownCfg map[string]any exists := downloader.getProtocolConfig("unknown-protocol", &unknownCfg) if exists { t.Errorf("getProtocolConfig() for unknown protocol should return false") } } func TestTaskFilter_IsEmpty(t *testing.T) { tests := []struct { name string filter *TaskFilter expected bool }{ { name: "nil IDs, Statuses, NotStatuses", filter: &TaskFilter{}, expected: true, }, { name: "empty IDs only", filter: &TaskFilter{IDs: []string{}}, expected: true, }, { name: "non-empty IDs", filter: &TaskFilter{IDs: []string{"id1"}}, expected: false, }, { name: "non-empty Statuses", filter: &TaskFilter{Statuses: []base.Status{base.DownloadStatusDone}}, expected: false, }, { name: "non-empty NotStatuses", filter: &TaskFilter{NotStatuses: []base.Status{base.DownloadStatusError}}, expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := tt.filter.IsEmpty() if result != tt.expected { t.Errorf("TaskFilter.IsEmpty() = %v, want %v", result, tt.expected) } }) } } // Tests for multi-part archive collection functions func TestDownloader_CollectSequentialFiles(t *testing.T) { tempDir, err := os.MkdirTemp("", "collect_sequential_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create test files for 7z multi-part pattern (archive.7z.001, .002, .003) for i := 1; i <= 3; i++ { path := filepath.Join(tempDir, fmt.Sprintf("archive.7z.%03d", i)) if err := os.WriteFile(path, []byte("test"), 0644); err != nil { t.Fatal(err) } } downloader := NewDownloader(nil) files := downloader.collectSequentialFiles(tempDir, "archive.7z", ".%03d") if len(files) != 3 { t.Errorf("collectSequentialFiles() = %d files, want 3", len(files)) } // Verify files are in order for i, file := range files { expected := filepath.Join(tempDir, fmt.Sprintf("archive.7z.%03d", i+1)) if file != expected { t.Errorf("files[%d] = %q, want %q", i, file, expected) } } } func TestDownloader_CollectSequentialFiles_NoFiles(t *testing.T) { tempDir, err := os.MkdirTemp("", "collect_sequential_empty_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) downloader := NewDownloader(nil) files := downloader.collectSequentialFiles(tempDir, "archive.7z", ".%03d") if len(files) != 0 { t.Errorf("collectSequentialFiles() = %d files, want 0", len(files)) } } func TestDownloader_CollectRarNewStyleFiles(t *testing.T) { tempDir, err := os.MkdirTemp("", "collect_rar_new_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create test files with double-digit format for i := 1; i <= 3; i++ { path := filepath.Join(tempDir, fmt.Sprintf("archive.part%02d.rar", i)) if err := os.WriteFile(path, []byte("test"), 0644); err != nil { t.Fatal(err) } } downloader := NewDownloader(nil) files := downloader.collectRarNewStyleFiles(tempDir, "archive") if len(files) != 3 { t.Errorf("collectRarNewStyleFiles() = %d files, want 3", len(files)) } } func TestDownloader_CollectRarNewStyleFiles_SingleDigit(t *testing.T) { tempDir, err := os.MkdirTemp("", "collect_rar_single_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create test files with single-digit format for i := 1; i <= 2; i++ { path := filepath.Join(tempDir, fmt.Sprintf("archive.part%d.rar", i)) if err := os.WriteFile(path, []byte("test"), 0644); err != nil { t.Fatal(err) } } downloader := NewDownloader(nil) files := downloader.collectRarNewStyleFiles(tempDir, "archive") if len(files) != 2 { t.Errorf("collectRarNewStyleFiles() = %d files, want 2", len(files)) } } func TestDownloader_CollectRarOldStyleFiles(t *testing.T) { tempDir, err := os.MkdirTemp("", "collect_rar_old_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create .rar file rarPath := filepath.Join(tempDir, "archive.rar") if err := os.WriteFile(rarPath, []byte("test"), 0644); err != nil { t.Fatal(err) } // Create .r00, .r01, .r02 files for i := 0; i <= 2; i++ { path := filepath.Join(tempDir, fmt.Sprintf("archive.r%02d", i)) if err := os.WriteFile(path, []byte("test"), 0644); err != nil { t.Fatal(err) } } downloader := NewDownloader(nil) files := downloader.collectRarOldStyleFiles(tempDir, "archive") // Should have 4 files: .rar + .r00 + .r01 + .r02 if len(files) != 4 { t.Errorf("collectRarOldStyleFiles() = %d files, want 4", len(files)) } // First file should be .rar if files[0] != rarPath { t.Errorf("First file should be .rar, got %q", files[0]) } } func TestDownloader_CollectZipSplitFiles(t *testing.T) { tempDir, err := os.MkdirTemp("", "collect_zip_split_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create .z01, .z02 files for i := 1; i <= 2; i++ { path := filepath.Join(tempDir, fmt.Sprintf("archive.z%02d", i)) if err := os.WriteFile(path, []byte("test"), 0644); err != nil { t.Fatal(err) } } // Create .zip file zipPath := filepath.Join(tempDir, "archive.zip") if err := os.WriteFile(zipPath, []byte("test"), 0644); err != nil { t.Fatal(err) } downloader := NewDownloader(nil) files := downloader.collectZipSplitFiles(tempDir, "archive") // Should have 3 files: .z01 + .z02 + .zip if len(files) != 3 { t.Errorf("collectZipSplitFiles() = %d files, want 3", len(files)) } // Last file should be .zip if files[len(files)-1] != zipPath { t.Errorf("Last file should be .zip, got %q", files[len(files)-1]) } } func TestDownloader_CollectMultiPartFiles(t *testing.T) { tempDir, err := os.MkdirTemp("", "collect_multipart_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Test with 7z pattern t.Run("7z pattern", func(t *testing.T) { subDir := filepath.Join(tempDir, "7z") if err := os.MkdirAll(subDir, 0755); err != nil { t.Fatal(err) } for i := 1; i <= 2; i++ { path := filepath.Join(subDir, fmt.Sprintf("archive.7z.%03d", i)) if err := os.WriteFile(path, []byte("test"), 0644); err != nil { t.Fatal(err) } } downloader := NewDownloader(nil) firstPart := filepath.Join(subDir, "archive.7z.001") files := downloader.collectMultiPartFiles(firstPart) if len(files) != 2 { t.Errorf("collectMultiPartFiles(7z) = %d files, want 2", len(files)) } }) // Test with RAR new style pattern t.Run("RAR new style pattern", func(t *testing.T) { subDir := filepath.Join(tempDir, "rar_new") if err := os.MkdirAll(subDir, 0755); err != nil { t.Fatal(err) } for i := 1; i <= 2; i++ { path := filepath.Join(subDir, fmt.Sprintf("archive.part%02d.rar", i)) if err := os.WriteFile(path, []byte("test"), 0644); err != nil { t.Fatal(err) } } downloader := NewDownloader(nil) firstPart := filepath.Join(subDir, "archive.part01.rar") files := downloader.collectMultiPartFiles(firstPart) if len(files) != 2 { t.Errorf("collectMultiPartFiles(RAR new) = %d files, want 2", len(files)) } }) // Test with ZIP multi-part pattern t.Run("ZIP multi-part pattern", func(t *testing.T) { subDir := filepath.Join(tempDir, "zip") if err := os.MkdirAll(subDir, 0755); err != nil { t.Fatal(err) } for i := 1; i <= 3; i++ { path := filepath.Join(subDir, fmt.Sprintf("archive.zip.%03d", i)) if err := os.WriteFile(path, []byte("test"), 0644); err != nil { t.Fatal(err) } } downloader := NewDownloader(nil) firstPart := filepath.Join(subDir, "archive.zip.001") files := downloader.collectMultiPartFiles(firstPart) if len(files) != 3 { t.Errorf("collectMultiPartFiles(ZIP) = %d files, want 3", len(files)) } }) } func TestDownloader_CheckAllMultiPartTasksDone(t *testing.T) { downloader := NewDownloader(nil) if err := downloader.Setup(); err != nil { t.Fatal(err) } defer downloader.Clear() tempDir, err := os.MkdirTemp("", "check_multipart_done_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create tasks with multi-part base name baseName := "archive.7z" // Create task 1 - done task1 := &Task{ ID: "task1", Status: base.DownloadStatusDone, Meta: &fetcher.FetcherMeta{ Opts: &base.Options{Path: tempDir}, Res: &base.Resource{ Files: []*base.FileInfo{{Name: baseName + ".001"}}, }, }, Progress: &Progress{}, } initTask(task1) // Create task 2 - done task2 := &Task{ ID: "task2", Status: base.DownloadStatusDone, Meta: &fetcher.FetcherMeta{ Opts: &base.Options{Path: tempDir}, Res: &base.Resource{ Files: []*base.FileInfo{{Name: baseName + ".002"}}, }, }, Progress: &Progress{}, } initTask(task2) downloader.tasks = []*Task{task1, task2} // All tasks are done basePath := filepath.Join(tempDir, baseName) allDone, missing := downloader.checkAllMultiPartTasksDone(basePath) if !allDone { t.Errorf("checkAllMultiPartTasksDone() = false, want true; missing: %v", missing) } // Set task2 to running task2.Status = base.DownloadStatusRunning allDone, missing = downloader.checkAllMultiPartTasksDone(basePath) if allDone { t.Error("checkAllMultiPartTasksDone() = true, want false") } if len(missing) == 0 { t.Error("Expected missing parts to be reported") } } func TestDownloader_CheckAllMultiPartTasksDone_NoRelatedTasks(t *testing.T) { downloader := NewDownloader(nil) if err := downloader.Setup(); err != nil { t.Fatal(err) } defer downloader.Clear() // No tasks exist allDone, missing := downloader.checkAllMultiPartTasksDone("/some/path/archive.7z") if allDone { t.Error("checkAllMultiPartTasksDone() = true, want false with no related tasks") } if len(missing) == 0 { t.Error("Expected missing message") } } func TestDownloader_TryClaimMultiPartExtraction(t *testing.T) { downloader := NewDownloader(nil) if err := downloader.Setup(); err != nil { t.Fatal(err) } defer downloader.Clear() tempDir, err := os.MkdirTemp("", "extraction_progress_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) baseName := "archive.7z" // GetMultiPartArchiveBaseName returns filepath.Join(dir, baseName) fullBaseName := filepath.Join(tempDir, baseName) // Create tasks task1 := &Task{ ID: "task1", Status: base.DownloadStatusDone, Meta: &fetcher.FetcherMeta{ Opts: &base.Options{Path: tempDir}, Res: &base.Resource{ Files: []*base.FileInfo{{Name: baseName + ".001", Path: ""}}, }, }, Progress: &Progress{ExtractStatus: ExtractStatusNone}, } initTask(task1) task2 := &Task{ ID: "task2", Status: base.DownloadStatusDone, Meta: &fetcher.FetcherMeta{ Opts: &base.Options{Path: tempDir}, Res: &base.Resource{ Files: []*base.FileInfo{{Name: baseName + ".002", Path: ""}}, }, }, Progress: &Progress{ExtractStatus: ExtractStatusNone}, } initTask(task2) downloader.tasks = []*Task{task1, task2} // Task1 should be able to claim extraction (no one has claimed yet) if !downloader.tryClaimMultiPartExtraction(task1, fullBaseName) { t.Error("tryClaimMultiPartExtraction() = false, want true (first claim)") } // task1's status should now be Queued if task1.Progress.ExtractStatus != ExtractStatusQueued { t.Errorf("task1.ExtractStatus = %v, want %v", task1.Progress.ExtractStatus, ExtractStatusQueued) } // Task2 should NOT be able to claim (task1 already claimed via sync.Map) if downloader.tryClaimMultiPartExtraction(task2, fullBaseName) { t.Error("tryClaimMultiPartExtraction() = true, want false (already claimed)") } // Release the claim downloader.releaseMultiPartExtractionClaim(fullBaseName) // Now task2 CAN claim (claim was released) if !downloader.tryClaimMultiPartExtraction(task2, fullBaseName) { t.Error("tryClaimMultiPartExtraction() = false, want true (claim was released)") } } func TestDownloader_HandleExtractionResult_Success(t *testing.T) { downloader := NewDownloader(nil) if err := downloader.Setup(); err != nil { t.Fatal(err) } defer downloader.Clear() tempDir, err := os.MkdirTemp("", "extraction_result_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create a test archive file archivePath := filepath.Join(tempDir, "test.zip") if err := os.WriteFile(archivePath, []byte("test"), 0644); err != nil { t.Fatal(err) } task := &Task{ ID: "test-task", Meta: &fetcher.FetcherMeta{ Opts: &base.Options{Path: tempDir}, Res: &base.Resource{ Files: []*base.FileInfo{{Name: "test.zip"}}, }, }, Progress: &Progress{}, } initTask(task) // Test successful extraction downloader.handleExtractionResult(task, nil, []string{archivePath}, false) if task.Progress.ExtractStatus != ExtractStatusDone { t.Errorf("ExtractStatus = %v, want %v", task.Progress.ExtractStatus, ExtractStatusDone) } if task.Progress.ExtractProgress != 100 { t.Errorf("ExtractProgress = %d, want 100", task.Progress.ExtractProgress) } // Archive should still exist (deleteAfterExtract=false) if _, err := os.Stat(archivePath); os.IsNotExist(err) { t.Error("Archive should still exist when deleteAfterExtract=false") } } func TestDownloader_HandleExtractionResult_WithDelete(t *testing.T) { downloader := NewDownloader(nil) if err := downloader.Setup(); err != nil { t.Fatal(err) } defer downloader.Clear() tempDir, err := os.MkdirTemp("", "extraction_delete_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create test archive files archivePath1 := filepath.Join(tempDir, "test.7z.001") archivePath2 := filepath.Join(tempDir, "test.7z.002") if err := os.WriteFile(archivePath1, []byte("test"), 0644); err != nil { t.Fatal(err) } if err := os.WriteFile(archivePath2, []byte("test"), 0644); err != nil { t.Fatal(err) } task := &Task{ ID: "test-task", Meta: &fetcher.FetcherMeta{ Opts: &base.Options{Path: tempDir}, Res: &base.Resource{ Files: []*base.FileInfo{{Name: "test.7z.001"}}, }, }, Progress: &Progress{}, } initTask(task) // Test successful extraction with delete downloader.handleExtractionResult(task, nil, []string{archivePath1, archivePath2}, true) if task.Progress.ExtractStatus != ExtractStatusDone { t.Errorf("ExtractStatus = %v, want %v", task.Progress.ExtractStatus, ExtractStatusDone) } // Archives should be deleted if _, err := os.Stat(archivePath1); !os.IsNotExist(err) { t.Error("Archive 1 should be deleted when deleteAfterExtract=true") } if _, err := os.Stat(archivePath2); !os.IsNotExist(err) { t.Error("Archive 2 should be deleted when deleteAfterExtract=true") } } func TestDownloader_HandleExtractionResult_Error(t *testing.T) { downloader := NewDownloader(nil) if err := downloader.Setup(); err != nil { t.Fatal(err) } defer downloader.Clear() tempDir, err := os.MkdirTemp("", "extraction_error_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) task := &Task{ ID: "test-task", Meta: &fetcher.FetcherMeta{ Opts: &base.Options{Path: tempDir}, Res: &base.Resource{ Files: []*base.FileInfo{{Name: "test.zip"}}, }, }, Progress: &Progress{}, } initTask(task) // Test failed extraction extractErr := fmt.Errorf("extraction failed") downloader.handleExtractionResult(task, extractErr, nil, false) if task.Progress.ExtractStatus != ExtractStatusError { t.Errorf("ExtractStatus = %v, want %v", task.Progress.ExtractStatus, ExtractStatusError) } } func TestDownloader_UpdateMultiPartTasksStatus(t *testing.T) { downloader := NewDownloader(nil) if err := downloader.Setup(); err != nil { t.Fatal(err) } defer downloader.Clear() tempDir, err := os.MkdirTemp("", "update_status_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create source task with multi-part base name sourceTask := &Task{ ID: "source", Meta: &fetcher.FetcherMeta{ Opts: &base.Options{Path: tempDir}, Res: &base.Resource{ Files: []*base.FileInfo{{Name: "archive.7z.001"}}, }, }, Progress: &Progress{MultiPartBaseName: "archive.7z"}, } initTask(sourceTask) // Create related task relatedTask := &Task{ ID: "related", Meta: &fetcher.FetcherMeta{ Opts: &base.Options{Path: tempDir}, Res: &base.Resource{ Files: []*base.FileInfo{{Name: "archive.7z.002"}}, }, }, Progress: &Progress{MultiPartBaseName: "archive.7z"}, } initTask(relatedTask) // Create unrelated task unrelatedTask := &Task{ ID: "unrelated", Meta: &fetcher.FetcherMeta{ Opts: &base.Options{Path: tempDir}, Res: &base.Resource{ Files: []*base.FileInfo{{Name: "other.7z.001"}}, }, }, Progress: &Progress{MultiPartBaseName: "other.7z"}, } initTask(unrelatedTask) downloader.tasks = []*Task{sourceTask, relatedTask, unrelatedTask} // Test successful extraction downloader.updateMultiPartTasksStatus(sourceTask, nil) if relatedTask.Progress.ExtractStatus != ExtractStatusDone { t.Errorf("Related task ExtractStatus = %v, want %v", relatedTask.Progress.ExtractStatus, ExtractStatusDone) } if relatedTask.Progress.ExtractProgress != 100 { t.Errorf("Related task ExtractProgress = %d, want 100", relatedTask.Progress.ExtractProgress) } if unrelatedTask.Progress.ExtractStatus == ExtractStatusDone { t.Error("Unrelated task should not be updated") } } func TestDownloader_UpdateMultiPartTasksStatus_WithError(t *testing.T) { downloader := NewDownloader(nil) if err := downloader.Setup(); err != nil { t.Fatal(err) } defer downloader.Clear() tempDir, err := os.MkdirTemp("", "update_error_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create source task with multi-part base name sourceTask := &Task{ ID: "source", Meta: &fetcher.FetcherMeta{ Opts: &base.Options{Path: tempDir}, Res: &base.Resource{ Files: []*base.FileInfo{{Name: "archive.7z.001"}}, }, }, Progress: &Progress{MultiPartBaseName: "archive.7z"}, } initTask(sourceTask) // Create related task relatedTask := &Task{ ID: "related", Meta: &fetcher.FetcherMeta{ Opts: &base.Options{Path: tempDir}, Res: &base.Resource{ Files: []*base.FileInfo{{Name: "archive.7z.002"}}, }, }, Progress: &Progress{MultiPartBaseName: "archive.7z"}, } initTask(relatedTask) downloader.tasks = []*Task{sourceTask, relatedTask} // Test failed extraction downloader.updateMultiPartTasksStatus(sourceTask, fmt.Errorf("extraction failed")) if relatedTask.Progress.ExtractStatus != ExtractStatusError { t.Errorf("Related task ExtractStatus = %v, want %v", relatedTask.Progress.ExtractStatus, ExtractStatusError) } if relatedTask.Progress.ExtractProgress != 0 { t.Errorf("Related task ExtractProgress = %d, want 0", relatedTask.Progress.ExtractProgress) } } func TestDownloader_UpdateMultiPartTasksStatus_NoBaseName(t *testing.T) { downloader := NewDownloader(nil) if err := downloader.Setup(); err != nil { t.Fatal(err) } defer downloader.Clear() tempDir, err := os.MkdirTemp("", "update_no_base_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create task without multi-part base name task := &Task{ ID: "single", Meta: &fetcher.FetcherMeta{ Opts: &base.Options{Path: tempDir}, Res: &base.Resource{ Files: []*base.FileInfo{{Name: "single.zip"}}, }, }, Progress: &Progress{MultiPartBaseName: ""}, } initTask(task) downloader.tasks = []*Task{task} // Should not panic or error downloader.updateMultiPartTasksStatus(task, nil) } func TestDownloader_CheckMultiPartArchiveReady(t *testing.T) { downloader := NewDownloader(nil) if err := downloader.Setup(); err != nil { t.Fatal(err) } defer downloader.Clear() tempDir, err := os.MkdirTemp("", "check_ready_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) baseName := "archive.7z" fileName := baseName + ".001" filePath := filepath.Join(tempDir, fileName) // Create tasks task := &Task{ ID: "task1", Status: base.DownloadStatusDone, Meta: &fetcher.FetcherMeta{ Opts: &base.Options{Path: tempDir}, Res: &base.Resource{ Files: []*base.FileInfo{{Name: fileName}}, }, }, Progress: &Progress{}, } initTask(task) downloader.tasks = []*Task{task} partInfo := getArchivePartInfo(filePath) ready, missing := downloader.checkMultiPartArchiveReady(filePath, tempDir, partInfo) if !ready { t.Errorf("checkMultiPartArchiveReady() = false, want true; missing: %v", missing) } } func TestDownloader_CheckMultiPartArchiveReady_EmptyBaseName(t *testing.T) { downloader := NewDownloader(nil) if err := downloader.Setup(); err != nil { t.Fatal(err) } defer downloader.Clear() // Use a non-multi-part file path partInfo := getArchivePartInfo("/some/regular.zip") ready, _ := downloader.checkMultiPartArchiveReady("/some/regular.zip", "/dest", partInfo) // Should return true for non-multi-part files if !ready { t.Error("checkMultiPartArchiveReady() should return true for non-multi-part files") } } // startTestTorrentServer starts a simple HTTP server that serves a torrent file func startTestTorrentServer(torrentPath string) net.Listener { listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { panic(err) } server := &gohttp.Server{ Handler: gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) { data, err := os.ReadFile(torrentPath) if err != nil { w.WriteHeader(gohttp.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/x-bittorrent") w.Header().Set("Content-Disposition", "attachment; filename=ubuntu.torrent") w.Write(data) }), } go server.Serve(listener) return listener } // TestDownloader_AutoTorrent tests the auto-torrent functionality // When a .torrent file is downloaded with AutoTorrent enabled, it should automatically create a BT task func TestDownloader_AutoTorrent(t *testing.T) { // Path to the test torrent file torrentPath := "../../internal/protocol/bt/testdata/ubuntu-22.04-live-server-amd64.iso.torrent" if _, err := os.Stat(torrentPath); os.IsNotExist(err) { t.Skip("Test torrent file not found, skipping test") } // Start a simple HTTP server to serve the torrent file server := startTestTorrentServer(torrentPath) defer server.Close() // Create a temporary directory for the test tempDir, err := os.MkdirTemp("", "auto_torrent_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create downloader downloader := NewDownloader(nil) if err := downloader.Setup(); err != nil { t.Fatal(err) } defer func() { // Delete all tasks before clearing to avoid panic from BT tasks trying to access deleted resources downloader.Delete(nil, true) downloader.Clear() }() // Track created tasks btTaskCreated := make(chan struct{}, 1) var originalTaskId string downloader.Listener(func(event *Event) { if event.Key == EventKeyStart { // A new task started - if it's not the original, it's the BT task if event.Task != nil && event.Task.ID != originalTaskId && originalTaskId != "" { select { case btTaskCreated <- struct{}{}: default: } } } }) // Create request to download the torrent file req := &base.Request{ URL: "http://" + server.Addr().String() + "/ubuntu.torrent", } // Create task with AutoTorrent enabled downloadDir := tempDir + "/downloads" originalTaskId, err = downloader.CreateDirect(req, &base.Options{ Path: downloadDir, Name: "ubuntu.torrent", Extra: http.OptsExtra{ Connections: 1, AutoTorrent: util.BoolPtr(true), }, }) if err != nil { t.Fatal(err) } t.Logf("Original task ID: %s", originalTaskId) // Wait for BT task to be created (with timeout) select { case <-btTaskCreated: t.Log("BT task created") case <-time.After(10 * time.Second): t.Log("Timeout waiting for BT task creation") } // Give a small buffer for task creation to complete time.Sleep(200 * time.Millisecond) // Verify that a BT task was created tasks := downloader.GetTasks() // At minimum, we should have 2 tasks: the original torrent download and the BT task if len(tasks) < 2 { t.Errorf("Expected at least 2 tasks (torrent download + BT task), got %d", len(tasks)) } else { t.Logf("Successfully created %d tasks", len(tasks)) } } // TestDownloader_AutoTorrentWithDelete tests the auto-torrent with DeleteTorrentAfterDownload option func TestDownloader_AutoTorrentWithDelete(t *testing.T) { // Path to the test torrent file torrentPath := "../../internal/protocol/bt/testdata/ubuntu-22.04-live-server-amd64.iso.torrent" if _, err := os.Stat(torrentPath); os.IsNotExist(err) { t.Skip("Test torrent file not found, skipping test") } // Start a simple HTTP server to serve the torrent file server := startTestTorrentServer(torrentPath) defer server.Close() // Create a temporary directory for the test tempDir, err := os.MkdirTemp("", "auto_torrent_delete_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create downloader downloader := NewDownloader(nil) if err := downloader.Setup(); err != nil { t.Fatal(err) } defer func() { // Delete all tasks before clearing to avoid panic from BT tasks trying to access deleted resources downloader.Delete(nil, true) downloader.Clear() }() // Track task events var originalTaskId string originalTaskDeleted := make(chan struct{}, 1) btTaskCreated := make(chan struct{}, 1) downloader.Listener(func(event *Event) { if event.Key == EventKeyStart { // BT task was created and started if event.Task != nil && event.Task.ID != originalTaskId && originalTaskId != "" { select { case btTaskCreated <- struct{}{}: default: } } } if event.Key == EventKeyDelete { // Check if the deleted task is the original torrent task if event.Task != nil && event.Task.ID == originalTaskId { select { case originalTaskDeleted <- struct{}{}: default: } } } }) // Create request to download the torrent file req := &base.Request{ URL: "http://" + server.Addr().String() + "/ubuntu.torrent", } // Create task with AutoTorrent and DeleteTorrentAfterDownload enabled downloadDir := tempDir + "/downloads" originalTaskId, err = downloader.CreateDirect(req, &base.Options{ Path: downloadDir, Name: "ubuntu.torrent", Extra: http.OptsExtra{ Connections: 1, AutoTorrent: util.BoolPtr(true), DeleteTorrentAfterDownload: util.BoolPtr(true), }, }) if err != nil { t.Fatal(err) } t.Logf("Original task ID: %s", originalTaskId) // Wait for original task to be deleted (this happens after BT task is created) select { case <-originalTaskDeleted: t.Log("Original torrent task was deleted as expected") case <-time.After(10 * time.Second): // Check manually if the task still exists originalTask := downloader.GetTask(originalTaskId) if originalTask != nil { t.Error("Original torrent task should have been deleted but still exists") } else { t.Log("Original torrent task was deleted (detected via GetTask)") } } // Give a moment for BT task creation time.Sleep(200 * time.Millisecond) // Verify that original task is deleted and BT task exists originalTask := downloader.GetTask(originalTaskId) if originalTask != nil { t.Error("Original torrent task should have been deleted") } // Verify remaining tasks (should have at least the BT task) tasks := downloader.GetTasks() t.Logf("Remaining tasks: %d", len(tasks)) // At least one task should remain (the BT task) if len(tasks) == 0 { t.Error("Expected at least one task (BT task) to remain") } // None of the remaining tasks should be the original torrent task for _, task := range tasks { if task.ID == originalTaskId { t.Error("Original torrent task should have been deleted") } } } // TestDownloader_AutoTorrentDisabled tests that auto-torrent does not create BT task when disabled func TestDownloader_AutoTorrentDisabled(t *testing.T) { // Path to the test torrent file torrentPath := "../../internal/protocol/bt/testdata/ubuntu-22.04-live-server-amd64.iso.torrent" if _, err := os.Stat(torrentPath); os.IsNotExist(err) { t.Skip("Test torrent file not found, skipping test") } // Start a simple HTTP server to serve the torrent file server := startTestTorrentServer(torrentPath) defer server.Close() // Create a temporary directory for the test tempDir, err := os.MkdirTemp("", "auto_torrent_disabled_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create downloader downloader := NewDownloader(nil) if err := downloader.Setup(); err != nil { t.Fatal(err) } defer func() { downloader.Delete(nil, true) downloader.Clear() }() // Track task completion taskDone := make(chan struct{}, 1) downloader.Listener(func(event *Event) { if event.Key == EventKeyDone { select { case taskDone <- struct{}{}: default: } } }) // Create request to download the torrent file req := &base.Request{ URL: "http://" + server.Addr().String() + "/ubuntu.torrent", } // Create task with AutoTorrent explicitly disabled downloadDir := tempDir + "/downloads" _, err = downloader.CreateDirect(req, &base.Options{ Path: downloadDir, Name: "ubuntu.torrent", Extra: http.OptsExtra{ Connections: 1, AutoTorrent: util.BoolPtr(false), }, }) if err != nil { t.Fatal(err) } // Wait for task to complete select { case <-taskDone: // Task completed case <-time.After(10 * time.Second): t.Fatal("Timeout waiting for task to complete") } // Give a small buffer time.Sleep(200 * time.Millisecond) // Verify that only 1 task exists (no BT task was created) tasks := downloader.GetTasks() if len(tasks) != 1 { t.Errorf("Expected exactly 1 task (torrent download only), got %d", len(tasks)) } // Verify the torrent file was downloaded torrentFilePath := downloadDir + "/ubuntu.torrent" if _, err := os.Stat(torrentFilePath); os.IsNotExist(err) { t.Error("Torrent file should have been downloaded") } } func TestDownloader_PatchTask_HTTP(t *testing.T) { listener := test.StartTestFileServer() defer listener.Close() downloader := NewDownloader(nil) if err := downloader.Setup(); err != nil { t.Fatal(err) } defer downloader.Clear() req := &base.Request{ URL: "http://" + listener.Addr().String() + "/" + test.BuildName, Extra: &http.OptsExtra{ Connections: 2, }, Labels: map[string]string{ "test": "value1", }, } opts := &base.Options{ Path: test.Dir, Name: test.DownloadName, } // Create task but don't start it yet taskId, err := downloader.CreateDirect(req, opts) if err != nil { t.Fatal(err) } // Pause the task immediately if err := downloader.Pause(&TaskFilter{IDs: []string{taskId}}); err != nil { t.Fatal(err) } // Patch the task with new labels patchReq := &base.Request{ Labels: map[string]string{ "test": "value2", "newKey": "newValue", }, } if err := downloader.Patch(taskId, patchReq, nil); err != nil { t.Fatal(err) } // Verify the patch was applied task := downloader.GetTask(taskId) if task == nil { t.Fatal("task not found") } if task.Meta.Req.Labels["test"] != "value2" { t.Errorf("PatchTask() label 'test' = %v, want %v", task.Meta.Req.Labels["test"], "value2") } if task.Meta.Req.Labels["newKey"] != "newValue" { t.Errorf("PatchTask() label 'newKey' = %v, want %v", task.Meta.Req.Labels["newKey"], "newValue") } // Clean up downloader.Delete(&TaskFilter{IDs: []string{taskId}}, true) } func TestDownloader_PatchTask_NotFound(t *testing.T) { downloader := NewDownloader(nil) if err := downloader.Setup(); err != nil { t.Fatal(err) } defer downloader.Clear() // Try to patch a non-existent task patchReq := &base.Request{ Labels: map[string]string{ "test": "value", }, } err := downloader.Patch("non-existent-id", patchReq, nil) if err != ErrTaskNotFound { t.Errorf("Patch() error = %v, want %v", err, ErrTaskNotFound) } } ================================================ FILE: pkg/download/engine/engine.go ================================================ package engine import ( _ "embed" "errors" "github.com/GopeedLab/gopeed/pkg/base" gojaerror "github.com/GopeedLab/gopeed/pkg/download/engine/inject/error" "github.com/GopeedLab/gopeed/pkg/download/engine/inject/file" "github.com/GopeedLab/gopeed/pkg/download/engine/inject/formdata" "github.com/GopeedLab/gopeed/pkg/download/engine/inject/vm" "github.com/GopeedLab/gopeed/pkg/download/engine/inject/xhr" "github.com/dop251/goja" "github.com/dop251/goja_nodejs/eventloop" gojaurl "github.com/dop251/goja_nodejs/url" "time" ) //go:embed polyfill/out/index.js var polyfillScript string type Engine struct { loop *eventloop.EventLoop Runtime *goja.Runtime } // RunString executes the script and returns the go type value // if script result is promise, it will be resolved func (e *Engine) RunString(script string) (value any, err error) { defer func() { if r := recover(); r != nil { err = r.(error) } }() var result goja.Value e.loop.Run(func(runtime *goja.Runtime) { result, err = runtime.RunString(script) if err == nil { go e.await(result) } }) if err != nil { return } return resolveResult(result) } // CallFunction calls the function and returns the go type value // if function result is promise, it will be resolved func (e *Engine) CallFunction(fn goja.Callable, args ...any) (value any, err error) { defer func() { if r := recover(); r != nil { err = r.(error) } }() var result goja.Value e.loop.Run(func(runtime *goja.Runtime) { if args == nil { result, err = fn(nil) } else { var jsArgs []goja.Value for _, arg := range args { jsArgs = append(jsArgs, runtime.ToValue(arg)) } result, err = fn(nil, jsArgs...) } if err == nil { go e.await(result) } }) if err != nil { return } return resolveResult(result) } // loop.Run will hang if the script result has a non-stop code, such as setInterval. // This method will stop the event loop when the promise result is resolved. func (e *Engine) await(value any) { if value == nil { return } if v, ok := value.(goja.Value); ok { // if result is promise, wait for it to be resolved if p, ok := v.Export().(*goja.Promise); ok { if p.State() != goja.PromiseStatePending { return } // check promise state every 100 milliseconds, until it is resolved for { time.Sleep(time.Millisecond * 100) if p.State() == goja.PromiseStatePending { continue } break } // stop the event loop e.loop.StopNoWait() } } } func (e *Engine) Close() { e.loop.StopNoWait() } type Config struct { ProxyConfig *base.DownloaderProxyConfig } func NewEngine(cfg *Config) *Engine { if cfg == nil { cfg = &Config{} } loop := eventloop.NewEventLoop() engine := &Engine{ loop: loop, } loop.Run(func(runtime *goja.Runtime) { engine.Runtime = runtime runtime.SetFieldNameMapper(goja.TagFieldNameMapper("json", true)) vm.Enable(runtime) gojaurl.Enable(runtime) if err := gojaerror.Enable(runtime); err != nil { return } if err := file.Enable(runtime); err != nil { return } if err := formdata.Enable(runtime); err != nil { return } if err := xhr.Enable(runtime, cfg.ProxyConfig.ToHandler()); err != nil { return } if _, err := runtime.RunString(polyfillScript); err != nil { return } // polyfill global if err := runtime.Set("global", runtime.GlobalObject()); err != nil { return } // polyfill window if err := runtime.Set("window", runtime.GlobalObject()); err != nil { return } // polyfill window.location if _, err := runtime.RunString("global.location = new URL('http://localhost');"); err != nil { return } return }) return engine } func Run(script string) (value any, err error) { engine := NewEngine(nil) return engine.RunString(script) } // if the value is Promise, it will be resolved and return the result. func resolveResult(value goja.Value) (any, error) { export := value.Export() switch export.(type) { case *goja.Promise: p := export.(*goja.Promise) switch p.State() { case goja.PromiseStatePending: return nil, nil case goja.PromiseStateFulfilled: return p.Result().Export(), nil case goja.PromiseStateRejected: if err, ok := p.Result().Export().(error); ok { return nil, err } else { stack := p.Result().String() result := p.Result() if ro, ok := result.(*goja.Object); ok { stackVal := ro.Get("stack") if stackVal != nil && stackVal.String() != "" { stack = stackVal.String() } } return nil, errors.New(stack) } } } return export, nil } ================================================ FILE: pkg/download/engine/engine_test.go ================================================ package engine import ( "crypto/md5" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net" "net/http" "strconv" "strings" "testing" "time" "github.com/GopeedLab/gopeed/internal/test" "github.com/GopeedLab/gopeed/pkg/base" gojaerror "github.com/GopeedLab/gopeed/pkg/download/engine/inject/error" "github.com/GopeedLab/gopeed/pkg/download/engine/inject/file" gojautil "github.com/GopeedLab/gopeed/pkg/download/engine/util" "github.com/dop251/goja" ) func TestPolyfill(t *testing.T) { doTestPolyfill(t, "MessageError") doTestPolyfill(t, "XMLHttpRequest") doTestPolyfill(t, "Blob") doTestPolyfill(t, "FormData") doTestPolyfill(t, "TextDecoder") doTestPolyfill(t, "TextEncoder") doTestPolyfill(t, "fetch") doTestPolyfill(t, "__gopeed_create_vm") } func TestError(t *testing.T) { engine := NewEngine(nil) _, err := engine.RunString(` throw new MessageError('test'); `) if me, ok := gojautil.AssertError[*gojaerror.MessageError](err); !ok { t.Fatalf("expect MessageError, but got %v", me) } } func TestFetch(t *testing.T) { server := startServer() defer server.Close() engine := NewEngine(nil) if _, err := engine.RunString(fmt.Sprintf("var host = 'http://%s';", server.Addr().String())); err != nil { t.Fatal(err) } _, err := engine.RunString(` async function testGet(){ const resp = await fetch(host+'/get'); return resp.status; } async function testText(){ const resp = await fetch(host+'/text',{ method: 'POST', body: 'test' }); return await resp.text(); } async function testOctetStream(file){ const resp = await fetch(host+'/octetStream',{ method: 'POST', body: file }); return await resp.text(); } async function testRedirect() { const url = host + '/redirect?num=3' return await new Promise((resolve, reject) => { fetch(url, { method: 'HEAD', redirect: 'error', }).then(()=>reject()) fetch(url, { method: 'HEAD', redirect: 'follow', }).then((res) =>res.headers.has('location') && reject()).catch(() => reject()) fetch(url, { method: 'HEAD', redirect: 'manual', }).then((res) => { const location = res.headers.get('location'); location ? resolve(location) : reject() }).catch(() => reject()) }) } async function testResponseUrl() { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('GET', host+'/redirect?num=3'); xhr.onload = function(){ if (xhr.responseURL.includes('/redirect?num=0')){ resolve(); }else{ reject(); } }; xhr.send(); }); } async function testFormData(file){ const formData = new FormData(); formData.append('name', 'test'); formData.append('f', file); const resp = await fetch(host+'/formData',{ method: 'POST', body: formData }); return await resp.json(); } function testHeader(){ return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('GET', host+'/header'); xhr.setRequestHeader('X-Gopeed-Test', 'test1'); xhr.setRequestHeader('x-gopeed-test', 'test2'); xhr.setRequestHeader('x-Gopeed-test', 'test3'); xhr.onload = function(){ const testHeader1 = xhr.getResponseHeader("X-Gopeed-Test"); const testHeader2 = xhr.getResponseHeader("x-gopeed-test"); const testHeader3 = xhr.getResponseHeader("x-Gopeed-test"); const expect = 'test1, test2, test3'; const all = xhr.getAllResponseHeaders(); if(testHeader1 === expect && testHeader2 === expect && testHeader3 === expect && all.includes('X-Gopeed-Test: '+expect)){ resolve(); }else{ reject(); } }; xhr.send(); }); } function testProgress(){ return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('GET', host+'/get'); const xhrUploadPromise = new Promise((resolve, reject) => { xhr.upload.onprogress = function(e){ if(e.loaded === e.total){ resolve(); } } }); const xhrPromise = new Promise((resolve, reject) => { xhr.onprogress = function(e){ if(e.loaded === e.total){ resolve(); } } }); Promise.all([xhrUploadPromise, xhrPromise]).then(() => { resolve(); }); xhr.send(); setTimeout(() => { reject('timeout'); }, 1000); }); } function testAbort(){ return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('GET', host+'/timeout?duration=500'); xhr.onabort = function() { resolve(); }; xhr.send(); setTimeout(() => { xhr.abort(); }, 200); setTimeout(() => { reject('timeout'); }, 1000); }); } function testTimeout(){ return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); const t = 500; xhr.open('GET', host+'/timeout?duration='+t); xhr.timeout = t - 200; xhr.onload = function() { resolve(); }; xhr.ontimeout = function() { reject('timeout'); }; xhr.send(); }); } async function testFingerprint(fingerprint,ua){ __gopeed_setFingerprint(fingerprint); const resp = await fetch(host+'/ua'); const data = await resp.json(); if(!data.user_agent.includes(ua)){ throw new Error('fingerprint test failed, user agent: ' + data.user_agent); } } async function testFingerprintDefault(){ await testFingerprint('none', 'Go') } async function testFingerprintChrome(){ await testFingerprint('chrome', 'Chrome') } async function testFingerprintFirefox(){ await testFingerprint('firefox', 'Firefox') } async function testFingerprintSafari(){ await testFingerprint('safari', 'Safari') } `) if err != nil { t.Fatal(err) } result, err := callTestFun(engine, "testGet") if err != nil { t.Fatal(err) } if result != int64(200) { t.Fatalf("testGet failed, want %d, got %d", 200, result) } result, err = callTestFun(engine, "testText") if err != nil { t.Fatal(err) } if result != "test" { t.Fatalf("testText failed, want %s, got %s", "test", result) } func() { jsFile, _, md5 := buildFile(t, engine.Runtime) result, err = callTestFun(engine, "testOctetStream", jsFile) if err != nil { t.Fatal(err) } if result != md5 { t.Fatalf("testOctetStream failed, want %s, got %s", md5, result) } }() t.Run("testRedirect", func(t *testing.T) { _, err := callTestFun(engine, "testRedirect") if err != nil { t.Fatal(err) } }) t.Run("testResponseUrl", func(t *testing.T) { _, err = callTestFun(engine, "testResponseUrl") if err != nil { t.Fatal(err) } }) func() { jsFile, goFile, md5 := buildFile(t, engine.Runtime) result, err = callTestFun(engine, "testFormData", jsFile) if err != nil { t.Fatal(err) } want := map[string]any{ "name": "test", "f": map[string]string{ "filename": goFile.Name, "md5": md5, }, } if !test.JsonEqual(result, want) { t.Fatalf("testFormData failed, want %v, got %v", want, result) } }() _, err = callTestFun(engine, "testHeader") if err != nil { t.Fatal("header test failed", err) } _, err = callTestFun(engine, "testProgress") if err != nil { t.Fatal("progress test failed", err) } _, err = callTestFun(engine, "testAbort") if err != nil { t.Fatal("abort test failed", err) } _, err = callTestFun(engine, "testTimeout") if err == nil || err.Error() != "timeout" { t.Fatalf("timeout test failed, want %s, got %s", "timeout", err) } _, err = callTestFun(engine, "testFingerprintChrome") if err != nil { t.Fatal("testFingerprintChrome test failed", err) } _, err = callTestFun(engine, "testFingerprintFirefox") if err != nil { t.Fatal("testFingerprintFirefox test failed", err) } _, err = callTestFun(engine, "testFingerprintSafari") if err != nil { t.Fatal("testFingerprintSafari test failed", err) } } func TestFetchWithProxy(t *testing.T) { doTestFetchWithProxy(t, "", "") doTestFetchWithProxy(t, "admin", "123") } func doTestFetchWithProxy(t *testing.T, usr, pwd string) { httpListener := startServer() defer httpListener.Close() proxyListener := test.StartSocks5Server(usr, pwd) defer proxyListener.Close() engine := NewEngine(&Config{ ProxyConfig: &base.DownloaderProxyConfig{ Enable: true, System: false, Scheme: "socks5", Host: proxyListener.Addr().String(), Usr: usr, Pwd: pwd, }, }) if _, err := engine.RunString(fmt.Sprintf("var host = 'http://%s';", httpListener.Addr().String())); err != nil { t.Fatal(err) } respCode, err := engine.RunString(` (async function(){ const resp = await fetch(host+'/get'); return resp.status; })() `) if err != nil { t.Fatal(err) } if respCode != int64(200) { t.Fatalf("fetch with proxy failed, want %d, got %d", 200, respCode) } } func TestVm(t *testing.T) { engine := NewEngine(nil) value, err := engine.RunString(` const vm = __gopeed_create_vm() vm.set('a', 1) vm.set('b', 2) const result = vm.runString('a=a+1;b=b+1;a+b;') const out = { "a": vm.get('a'), "b": vm.get('b'), "result": result } out `) if err != nil { t.Fatal(err) } want := map[string]any{ "a": 2, "b": 3, "result": 5, } if !test.JsonEqual(value, want) { t.Fatalf("vm test failed, want %v, got %v", want, value) } } func TestNonStopLoop(t *testing.T) { engine := NewEngine(nil) _, err := engine.RunString(` function leak(){ setInterval(() => { },500) } function test(){ leak() return new Promise((resolve, reject) => { setTimeout(() => { resolve('done') }, 1000) }) } `) if err != nil { t.Fatal(err) } val, err := callTestFun(engine, "test") if err != nil { panic(err) } if val != "done" { t.Fatalf("infinite loop test failed, want %s, got %s", "done", val) } } func doTestPolyfill(t *testing.T, module string) { value, err := Run(fmt.Sprintf(` !!globalThis['%s'] `, module)) if err != nil { t.Fatal(err) } if !value.(bool) { t.Fatalf("module %s not polyfilled", module) } } func startServer() net.Listener { listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { panic(err) } server := &http.Server{} mux := http.NewServeMux() mux.HandleFunc("/get", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte("ok")) }) mux.HandleFunc("/header", func(w http.ResponseWriter, r *http.Request) { for k, v := range r.Header { if strings.HasPrefix(k, "X-Gopeed") { w.Header().Set(k, strings.Join(v, ", ")) } } w.WriteHeader(http.StatusOK) w.Write([]byte("ok")) }) mux.HandleFunc("/text", func(w http.ResponseWriter, r *http.Request) { buf, _ := io.ReadAll(r.Body) w.WriteHeader(http.StatusOK) w.Write(buf) }) mux.HandleFunc("/octetStream", func(w http.ResponseWriter, r *http.Request) { md5 := calcMd5(r.Body) w.WriteHeader(http.StatusOK) w.Write([]byte(md5)) }) mux.HandleFunc("/formData", func(w http.ResponseWriter, r *http.Request) { err := r.ParseMultipartForm(1024 * 1024 * 30) if err != nil { w.WriteHeader(http.StatusBadRequest) w.Write([]byte(err.Error())) return } result := make(map[string]any) for k, v := range r.MultipartForm.Value { result[k] = v[0] } for k, v := range r.MultipartForm.File { f, _ := v[0].Open() result[k] = map[string]string{ "filename": v[0].Filename, "md5": calcMd5(f), } } w.WriteHeader(http.StatusOK) buf, _ := json.Marshal(result) w.Write(buf) }) mux.HandleFunc("/timeout", func(w http.ResponseWriter, r *http.Request) { duration := r.URL.Query().Get("duration") t, _ := strconv.Atoi(duration) time.Sleep(time.Duration(t) * time.Millisecond) w.WriteHeader(http.StatusOK) w.Write([]byte("ok")) }) mux.HandleFunc("/redirect", func(w http.ResponseWriter, r *http.Request) { num := r.URL.Query().Get("num") n, _ := strconv.Atoi(num) if n > 0 { http.Redirect(w, r, fmt.Sprintf("/redirect?num=%d", n-1), http.StatusFound) } else { w.WriteHeader(http.StatusOK) w.Write([]byte("ok")) } }) mux.HandleFunc("/ua", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") data := map[string]any{ "user_agent": r.UserAgent(), } buf, _ := json.Marshal(data) w.WriteHeader(http.StatusOK) w.Write(buf) }) server.Handler = mux go server.Serve(listener) return listener } func buildFile(t *testing.T, runtime *goja.Runtime) (goja.Value, *file.File, string) { jsFile, err := file.NewJsFile(runtime) if err != nil { t.Fatal(err) } f := jsFile.Export().(*file.File) data := "test" f.Reader = strings.NewReader(data) f.Name = "test.txt" f.Size = int64(len(data)) return jsFile, f, calcMd5(strings.NewReader(data)) } func callTestFun(engine *Engine, fun string, args ...any) (any, error) { test, ok := goja.AssertFunction(engine.Runtime.Get(fun)) if !ok { return nil, errors.New("function not found:" + fun) } return engine.CallFunction(test, args...) } func calcMd5(reader io.Reader) string { // Open a new hash interface to write to hash := md5.New() // Copy the file in the hash interface and check for any error if _, err := io.Copy(hash, reader); err != nil { return "" } return hex.EncodeToString(hash.Sum(nil)) } ================================================ FILE: pkg/download/engine/inject/error/module.go ================================================ 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) } ================================================ FILE: pkg/download/engine/inject/file/module.go ================================================ package file import ( "errors" "github.com/dop251/goja" "io" ) type File struct { io.Reader `json:""` io.Closer `json:""` Name string `json:"name"` Size int64 `json:"size"` } func NewJsFile(runtime *goja.Runtime) (goja.Value, error) { fileCtor, ok := goja.AssertConstructor(runtime.Get("File")) if !ok { return nil, errors.New("file is not defined") } return fileCtor(nil) } func Enable(runtime *goja.Runtime) error { file := runtime.ToValue(func(call goja.ConstructorCall) *goja.Object { instance := &File{} instanceValue := runtime.ToValue(instance).(*goja.Object) instanceValue.SetPrototype(call.This.Prototype()) return instanceValue }) return runtime.Set("File", file) } ================================================ FILE: pkg/download/engine/inject/formdata/module.go ================================================ package formdata import "github.com/dop251/goja" type FormData struct { data map[string]any } func (fd *FormData) Append(name string, value any) { fd.data[name] = value } func (fd *FormData) Delete(name string) { delete(fd.data, name) } func (fd *FormData) Entries() []any { var entries []any for k, v := range fd.data { entries = append(entries, []any{k, v}) } return entries } func (fd *FormData) Get(name string) any { return fd.data[name] } func (fd *FormData) GetAll(name string) []any { return []any{fd.data[name]} } func (fd *FormData) Has(name string) bool { _, ok := fd.data[name] return ok } func (fd *FormData) Keys() []string { var keys []string for k := range fd.data { keys = append(keys, k) } return keys } func (fd *FormData) Set(name string, value any) { fd.data[name] = value } func (fd *FormData) Values() []any { var values []any for _, v := range fd.data { values = append(values, v) } return values } func Enable(runtime *goja.Runtime) error { file := runtime.ToValue(func(call goja.ConstructorCall) *goja.Object { instance := &FormData{ data: make(map[string]any), } instanceValue := runtime.ToValue(instance).(*goja.Object) instanceValue.SetPrototype(call.This.Prototype()) return instanceValue }) return runtime.Set("FormData", file) } ================================================ FILE: pkg/download/engine/inject/vm/module.go ================================================ 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(), }) }) } ================================================ FILE: pkg/download/engine/inject/xhr/module.go ================================================ package xhr import ( "bytes" "errors" "io" "mime/multipart" "net" "net/http" "net/url" "strings" "time" "github.com/GopeedLab/gopeed/pkg/download/engine/inject/file" "github.com/GopeedLab/gopeed/pkg/download/engine/inject/formdata" "github.com/GopeedLab/gopeed/pkg/download/engine/util" "github.com/dop251/goja" "github.com/imroc/req/v3" ) const ( eventLoad = "load" eventReadystatechange = "readystatechange" eventProgress = "progress" eventAbort = "abort" eventError = "error" eventTimeout = "timeout" ) const ( redirectError = "error" redirectFollow = "follow" redirectManual = "manual" ) type ProgressEvent struct { Type string `json:"type"` LengthComputable bool `json:"lengthComputable"` Loaded int64 `json:"loaded"` Total int64 `json:"total"` } type EventProp struct { eventListeners map[string]func(event *ProgressEvent) Onload func(event *ProgressEvent) `json:"onload"` Onprogress func(event *ProgressEvent) `json:"onprogress"` Onabort func(event *ProgressEvent) `json:"onabort"` Onerror func(event *ProgressEvent) `json:"onerror"` Ontimeout func(event *ProgressEvent) `json:"ontimeout"` } func (ep *EventProp) AddEventListener(event string, cb func(event *ProgressEvent)) { ep.eventListeners[event] = cb } func (ep *EventProp) RemoveEventListener(event string) { delete(ep.eventListeners, event) } func (ep *EventProp) callOnload() { event := &ProgressEvent{ Type: eventLoad, LengthComputable: false, } if ep.Onload != nil { ep.Onload(event) } ep.callEventListener(event) } func (ep *EventProp) callOnprogress(loaded, total int64) { event := &ProgressEvent{ Type: eventProgress, LengthComputable: true, Loaded: loaded, Total: total, } if ep.Onprogress != nil { ep.Onprogress(event) } ep.callEventListener(event) } func (ep *EventProp) callOnabort() { event := &ProgressEvent{ Type: eventAbort, LengthComputable: false, } if ep.Onabort != nil { ep.Onabort(event) } ep.callEventListener(event) } func (ep *EventProp) callOnerror() { event := &ProgressEvent{ Type: eventError, LengthComputable: false, } if ep.Onerror != nil { ep.Onerror(event) } ep.callEventListener(event) } func (ep *EventProp) callOntimeout() { event := &ProgressEvent{ Type: eventTimeout, LengthComputable: false, } if ep.Ontimeout != nil { ep.Ontimeout(event) } ep.callEventListener(event) } func (ep *EventProp) callEventListener(event *ProgressEvent) { if cb, ok := ep.eventListeners[event.Type]; ok { cb(event) } } type XMLHttpRequestUpload struct { *EventProp } type XMLHttpRequest struct { method string url string requestHeaders http.Header responseHeaders http.Header aborted bool client *req.Client fingerprint string WithCredentials bool `json:"withCredentials"` Upload *XMLHttpRequestUpload `json:"upload"` Timeout int `json:"timeout"` ReadyState int `json:"readyState"` Status int `json:"status"` StatusText string `json:"statusText"` Response string `json:"response"` ResponseText string `json:"responseText"` // https://developer.mozilla.org/zh-CN/docs/Web/API/XMLHttpRequest/responseURL // https://xhr.spec.whatwg.org/#the-responseurl-attribute ResponseUrl string `json:"responseURL"` // extend fetch redirect // https://developer.mozilla.org/en-US/docs/Web/API/RequestInit#redirect // https://fetch.spec.whatwg.org/#concept-request-redirect-mode Redirect string `json:"redirect"` *EventProp Onreadystatechange func(event *ProgressEvent) `json:"onreadystatechange"` } func (xhr *XMLHttpRequest) Open(method, url string) { xhr.method = method xhr.url = url xhr.requestHeaders = make(http.Header) xhr.responseHeaders = make(http.Header) xhr.doReadystatechange(1) } func (xhr *XMLHttpRequest) SetRequestHeader(key, value string) { xhr.requestHeaders.Add(key, value) } func (xhr *XMLHttpRequest) Send(data goja.Value) { setFingerprint(xhr.client, xhr.fingerprint) d := xhr.parseData(data) var ( contentType string contentLength int64 isStringBody bool ) // Create request using req library reqBuilder := xhr.client.R() // Set headers first if xhr.requestHeaders != nil { for key, values := range xhr.requestHeaders { if len(values) > 0 { // Merge multiple values with comma separator (HTTP standard) mergedValue := strings.Join(values, ", ") reqBuilder.SetHeader(key, mergedValue) } } } // Handle request body if d != nil && xhr.method != "GET" && xhr.method != "HEAD" { switch v := d.(type) { case string: reqBuilder.SetBody(v) contentType = "text/plain;charset=UTF-8" contentLength = int64(len(v)) isStringBody = true case *file.File: reqBuilder.SetBody(v.Reader) contentType = "application/octet-stream" contentLength = v.Size case *formdata.FormData: pr, pw := io.Pipe() mw := NewMultipart(pw) for _, e := range v.Entries() { arr := e.([]any) k := arr[0].(string) v := arr[1] switch v := v.(type) { case string: mw.WriteField(k, v) case *file.File: mw.WriteFile(k, v) } } go func() { defer pw.Close() defer mw.Close() mw.Send() }() reqBuilder.SetBody(pr) contentType = mw.FormDataContentType() contentLength = mw.Size() } } // Only string body can specify Content-Type header by user if contentType != "" && (!isStringBody || xhr.requestHeaders.Get("Content-Type") == "") { reqBuilder.SetHeader("Content-Type", contentType) } // Set timeout if xhr.Timeout > 0 { xhr.client.SetTimeout(time.Duration(xhr.Timeout) * time.Millisecond) } // Configure redirect behavior xhr.client.SetRedirectPolicy(func(req *http.Request, via []*http.Request) error { if xhr.Redirect == redirectManual { return http.ErrUseLastResponse } if xhr.Redirect == redirectError { return errors.New("redirect failed") } if len(via) > 20 { return errors.New("too many redirects") } return nil }) // Execute request resp, err := reqBuilder.Send(xhr.method, xhr.url) if err != nil { // handle timeout error var ne net.Error if errors.As(err, &ne) && ne.Timeout() { if xhr.Timeout > 0 { xhr.Upload.callOntimeout() xhr.callOntimeout() } return } xhr.Upload.callOnerror() xhr.callOnerror() return } xhr.Upload.callOnprogress(contentLength, contentLength) if !xhr.aborted { xhr.Upload.callOnload() } // Set response URL (final URL after redirects) if resp.Response != nil && resp.Response.Request != nil && resp.Response.Request.URL != nil { responseUrl := resp.Response.Request.URL responseUrl.Fragment = "" xhr.ResponseUrl = responseUrl.String() } else { xhr.ResponseUrl = xhr.url } // Set response headers xhr.responseHeaders = resp.Header xhr.Status = resp.StatusCode xhr.StatusText = resp.Status xhr.doReadystatechange(2) bodyBytes := resp.Bytes() xhr.doReadystatechange(3) xhr.Response = string(bodyBytes) xhr.ResponseText = xhr.Response xhr.doReadystatechange(4) respBodyLen := int64(len(bodyBytes)) xhr.callOnprogress(respBodyLen, respBodyLen) if !xhr.aborted { xhr.callOnload() } } func (xhr *XMLHttpRequest) Abort() { xhr.doReadystatechange(0) xhr.aborted = true xhr.Upload.callOnabort() xhr.callOnabort() } func (xhr *XMLHttpRequest) GetResponseHeader(key string) string { if xhr.responseHeaders == nil { return "" } return strings.Join(xhr.responseHeaders.Values(key), ", ") } func (xhr *XMLHttpRequest) GetAllResponseHeaders() string { var buf bytes.Buffer for k, v := range xhr.responseHeaders { buf.WriteString(k) buf.WriteString(": ") buf.WriteString(strings.Join(v, ", ")) buf.WriteString("\r\n") } return buf.String() } func (xhr *XMLHttpRequest) callOnreadystatechange() { event := &ProgressEvent{ Type: eventReadystatechange, LengthComputable: false, } if xhr.Onreadystatechange != nil { xhr.Onreadystatechange(event) } xhr.callEventListener(event) } func (xhr *XMLHttpRequest) doReadystatechange(state int) { if xhr.aborted { return } xhr.ReadyState = state xhr.callOnreadystatechange() } // parse js data to go struct func (xhr *XMLHttpRequest) parseData(data goja.Value) any { // check if data is null or undefined if data == nil || goja.IsNull(data) || goja.IsUndefined(data) || goja.IsNaN(data) { return nil } // check if data is File f, ok := data.Export().(*file.File) if ok { return f } // check if data is FormData fd, ok := data.Export().(*formdata.FormData) if ok { return fd } // otherwise, return data as string return data.String() } func Enable(runtime *goja.Runtime, proxyHandler func(r *http.Request) (*url.URL, error)) error { progressEvent := runtime.ToValue(func(call goja.ConstructorCall) *goja.Object { if len(call.Arguments) < 1 { util.ThrowTypeError(runtime, "Failed to construct 'ProgressEvent': 1 argument required, but only 0 present.") } instance := &ProgressEvent{ Type: call.Argument(0).String(), } instanceValue := runtime.ToValue(instance).(*goja.Object) instanceValue.SetPrototype(call.This.Prototype()) return instanceValue }) xhr := runtime.ToValue(func(call goja.ConstructorCall) *goja.Object { // Create req client with proxy support client := req.C() if proxyHandler != nil { client.SetProxy(proxyHandler) } instance := &XMLHttpRequest{ client: client, fingerprint: util.SafeGet[string](runtime, FingerprintMagicKey), Upload: &XMLHttpRequestUpload{ EventProp: &EventProp{ eventListeners: make(map[string]func(event *ProgressEvent)), }, }, EventProp: &EventProp{ eventListeners: make(map[string]func(event *ProgressEvent)), }, } instanceValue := runtime.ToValue(instance).(*goja.Object) instanceValue.SetPrototype(call.This.Prototype()) return instanceValue }) runtime.Set("__gopeed_setFingerprint", func(fingerprint string) { runtime.Set(FingerprintMagicKey, fingerprint) }) if err := runtime.Set("ProgressEvent", progressEvent); err != nil { return err } if err := runtime.Set("XMLHttpRequest", xhr); err != nil { return err } return nil } // Wrap multipart.Writer and stat content length type multipartWrapper struct { statBuffer *bytes.Buffer statWriter *multipart.Writer writer *multipart.Writer fields map[string]any } func NewMultipart(w io.Writer) *multipartWrapper { var buf bytes.Buffer return &multipartWrapper{ statBuffer: &buf, statWriter: multipart.NewWriter(&buf), writer: multipart.NewWriter(w), fields: make(map[string]any), } } func (w *multipartWrapper) WriteField(fieldname string, value string) error { w.fields[fieldname] = value return w.statWriter.WriteField(fieldname, value) } func (w *multipartWrapper) WriteFile(fieldname string, file *file.File) error { w.fields[fieldname] = file _, err := w.statWriter.CreateFormFile(fieldname, file.Name) if err != nil { return err } return nil } func (w *multipartWrapper) Size() int64 { w.statWriter.Close() size := int64(w.statBuffer.Len()) for _, v := range w.fields { switch v := v.(type) { case *file.File: size += v.Size } } return size } func (w *multipartWrapper) Send() error { for k, v := range w.fields { switch v := v.(type) { case string: if err := w.writer.WriteField(k, v); err != nil { return err } case *file.File: fw, err := w.writer.CreateFormFile(k, v.Name) if err != nil { return err } if _, err = io.Copy(fw, v); err != nil { return err } } } return nil } func (w *multipartWrapper) FormDataContentType() string { return w.writer.FormDataContentType() } func (w *multipartWrapper) Close() error { return w.writer.Close() } ================================================ FILE: pkg/download/engine/inject/xhr/tls_fingerprint.go ================================================ 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() } } ================================================ FILE: pkg/download/engine/polyfill/out/index.js ================================================ (()=>{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=55296&&u<=56319){if(t=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;o239?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>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=t.size&&r.close()}))}})}}catch(e){try{new ReadableStream({}),w=function(e){var t=0;return new ReadableStream({pull:function(r){return e.slice(t,t+524288).arrayBuffer().then((function(o){t+=o.byteLength;var n=new Uint8Array(o);r.enqueue(n),t==e.size&&r.close()}))}})}}catch(e){try{new Response("").body.getReader().read(),w=function(){return new Response(this).body}}catch(e){w=function(){throw new Error("Include https://github.com/MattiasBuelens/web-streams-polyfill")}}}}function v(e){return new Promise((function(t,r){e.onload=e.onerror=function(o){e.onload=e.onerror=null,"load"===o.type?t(e.result||e):r(new Error("Failed to read the blob/file"))}}))}m.arrayBuffer||(m.arrayBuffer=function(){var e=new FileReader;return e.readAsArrayBuffer(this),v(e)}),m.text||(m.text=function(){var e=new FileReader;return e.readAsText(this),v(e)}),m.stream||(m.stream=w)},void 0===(n=o.apply(t,[t]))||(e.exports=n)},294:function(e,t,r){"use strict";!function(e){function t(){}function r(){}var o=String.fromCharCode,n={}.toString,i=n.call(e.SharedArrayBuffer),a=n(),s=e.Uint8Array,u=s||Array,f=s?ArrayBuffer:u,l=f.isView||function(e){return e&&"length"in e},c=n.call(f.prototype);f=r.prototype;var h=e.TextEncoder,d=new(s?Uint16Array:u)(32);t.prototype.decode=function(e){if(!l(e)){var t=n.call(e);if(t!==c&&t!==i&&t!==a)throw TypeError("Failed to execute 'decode' on 'TextDecoder': The provided value is not of type '(ArrayBuffer or ArrayBufferView)'");e=s?new u(e):e||[]}for(var r,f,h,p=t="",y=0,b=0|e.length,w=b-32|0,m=0,v=0,g=0,A=-1;y>4){case 15:if(2!=(h=255&e[y=y+1|0])>>6||247>6?v+4|0:24,f=f+256&768;case 13:case 12:m<<=6,m|=(31&f)<<6|63&(h=255&e[y=y+1|0]),v=v+7|0,y>6&&m>>v&&1114112>m?(f=m,0<=(m=m-65536|0)&&(A=55296+(m>>10)|0,f=56320+(1023&m)|0,31>g?(d[g]=A,g=g+1|0,A=-1):(h=A,A=f,f=h))):(y=y-(f>>=8)-1|0,f=65533),m=v=0,r=y<=w?32:b-y|0;default:d[g]=f;continue;case 11:case 10:case 9:case 8:}d[g]=65533}if(p+=o(d[0],d[1],d[2],d[3],d[4],d[5],d[6],d[7],d[8],d[9],d[10],d[11],d[12],d[13],d[14],d[15],d[16],d[17],d[18],d[19],d[20],d[21],d[22],d[23],d[24],d[25],d[26],d[27],d[28],d[29],d[30],d[31]),32>g&&(p=p.slice(0,g-32|0)),y>>31,A=-1,p.length=a)o[n]=a;else{if(2047>=a)o[n]=192|a>>6;else{e:{if(55296<=a)if(56319>=a){var f=0|e.charCodeAt(t=t+1|0);if(56320<=f&&57343>=f){if(65535<(a=(a<<10)+f-56613888|0)){o[n]=240|a>>18,o[n=n+1|0]=128|a>>12&63,o[n=n+1|0]=128|a>>6&63,o[n=n+1|0]=128|63&a;continue}break e}a=65533}else 57343>=a&&(a=65533);!i&&t<<1>12,o[n=n+1|0]=128|a>>6&63}o[n=n+1|0]=128|63&a}}return s?o.subarray(0,n):o.slice(0,n)},h||(e.TextDecoder=t,e.TextEncoder=r)}(""+void 0==typeof r.g?""+void 0==typeof self?this:self:r.g)}},t={};function r(o){var n=t[o];if(void 0!==n)return n.exports;var i=t[o]={exports:{}};return e[o].call(i.exports,i,i.exports,r),i.exports}r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),(()=>{"use strict";var e=r(725);globalThis.Blob=e.Blob,globalThis.crypto={getRandomValues(e){for(let t=0,r=e.length;t(e^this.getRandomValues(new Uint8Array(1))[0]&15>>e/4).toString(16)))}},r(294);var t="undefined"!=typeof globalThis&&globalThis||"undefined"!=typeof self&&self||void 0!==r.g&&r.g||{},o={searchParams:"URLSearchParams"in t,iterable:"Symbol"in t&&"iterator"in Symbol,blob:"FileReader"in t&&"Blob"in t&&function(){try{return new Blob,!0}catch(e){return!1}}(),formData:"FormData"in t,arrayBuffer:"ArrayBuffer"in t};if(o.arrayBuffer)var n=["[object Int8Array]","[object Uint8Array]","[object Uint8ClampedArray]","[object Int16Array]","[object Uint16Array]","[object Int32Array]","[object Uint32Array]","[object Float32Array]","[object Float64Array]"],i=ArrayBuffer.isView||function(e){return e&&n.indexOf(Object.prototype.toString.call(e))>-1};function a(e){if("string"!=typeof e&&(e=String(e)),/[^a-z0-9\-#$%&'*+.^_`|~!]/i.test(e)||""===e)throw new TypeError('Invalid character in header field name: "'+e+'"');return e.toLowerCase()}function s(e){return"string"!=typeof e&&(e=String(e)),e}function u(e){var t={next:function(){var t=e.shift();return{done:void 0===t,value:t}}};return o.iterable&&(t[Symbol.iterator]=function(){return t}),t}function f(e){this.map={},e instanceof f?e.forEach((function(e,t){this.append(t,e)}),this):Array.isArray(e)?e.forEach((function(e){if(2!=e.length)throw new TypeError("Headers constructor: expected name/value pair to be length 2, found"+e.length);this.append(e[0],e[1])}),this):e&&Object.getOwnPropertyNames(e).forEach((function(t){this.append(t,e[t])}),this)}function l(e){if(!e._noBody)return e.bodyUsed?Promise.reject(new TypeError("Already read")):void(e.bodyUsed=!0)}function c(e){return new Promise((function(t,r){e.onload=function(){t(e.result)},e.onerror=function(){r(e.error)}}))}function h(e){var t=new FileReader,r=c(t);return t.readAsArrayBuffer(e),r}function d(e){if(e.slice)return e.slice(0);var t=new Uint8Array(e.byteLength);return t.set(new Uint8Array(e)),t.buffer}function p(){return this.bodyUsed=!1,this._initBody=function(e){var t;this.bodyUsed=this.bodyUsed,this._bodyInit=e,e?"string"==typeof e?this._bodyText=e:o.blob&&Blob.prototype.isPrototypeOf(e)?this._bodyBlob=e:o.formData&&FormData.prototype.isPrototypeOf(e)?this._bodyFormData=e:o.searchParams&&URLSearchParams.prototype.isPrototypeOf(e)?this._bodyText=e.toString():o.arrayBuffer&&o.blob&&(t=e)&&DataView.prototype.isPrototypeOf(t)?(this._bodyArrayBuffer=d(e.buffer),this._bodyInit=new Blob([this._bodyArrayBuffer])):o.arrayBuffer&&(ArrayBuffer.prototype.isPrototypeOf(e)||i(e))?this._bodyArrayBuffer=d(e):this._bodyText=e=Object.prototype.toString.call(e):(this._noBody=!0,this._bodyText=""),this.headers.get("content-type")||("string"==typeof e?this.headers.set("content-type","text/plain;charset=UTF-8"):this._bodyBlob&&this._bodyBlob.type?this.headers.set("content-type",this._bodyBlob.type):o.searchParams&&URLSearchParams.prototype.isPrototypeOf(e)&&this.headers.set("content-type","application/x-www-form-urlencoded;charset=UTF-8"))},o.blob&&(this.blob=function(){var e=l(this);if(e)return e;if(this._bodyBlob)return Promise.resolve(this._bodyBlob);if(this._bodyArrayBuffer)return Promise.resolve(new Blob([this._bodyArrayBuffer]));if(this._bodyFormData)throw new Error("could not read FormData body as blob");return Promise.resolve(new Blob([this._bodyText]))}),this.arrayBuffer=function(){if(this._bodyArrayBuffer)return l(this)||(ArrayBuffer.isView(this._bodyArrayBuffer)?Promise.resolve(this._bodyArrayBuffer.buffer.slice(this._bodyArrayBuffer.byteOffset,this._bodyArrayBuffer.byteOffset+this._bodyArrayBuffer.byteLength)):Promise.resolve(this._bodyArrayBuffer));if(o.blob)return this.blob().then(h);throw new Error("could not read as ArrayBuffer")},this.text=function(){var e,t,r,o,n,i=l(this);if(i)return i;if(this._bodyBlob)return e=this._bodyBlob,r=c(t=new FileReader),n=(o=/charset=([A-Za-z0-9_-]+)/.exec(e.type))?o[1]:"utf-8",t.readAsText(e,n),r;if(this._bodyArrayBuffer)return Promise.resolve(function(e){for(var t=new Uint8Array(e),r=new Array(t.length),o=0;o-1?n:o),this.mode=r.mode||this.mode||null,this.signal=r.signal||this.signal||function(){if("AbortController"in t)return(new AbortController).signal}(),this.referrer=null,this.redirect=r.redirect||"follow",("GET"===this.method||"HEAD"===this.method)&&i)throw new TypeError("Body not allowed for GET or HEAD requests");if(this._initBody(i),!("GET"!==this.method&&"HEAD"!==this.method||"no-store"!==r.cache&&"no-cache"!==r.cache)){var a=/([?&])_=[^&]*/;a.test(this.url)?this.url=this.url.replace(a,"$1_="+(new Date).getTime()):this.url+=(/\?/.test(this.url)?"&":"?")+"_="+(new Date).getTime()}}function w(e){var t=new FormData;return e.trim().split("&").forEach((function(e){if(e){var r=e.split("="),o=r.shift().replace(/\+/g," "),n=r.join("=").replace(/\+/g," ");t.append(decodeURIComponent(o),decodeURIComponent(n))}})),t}function m(e,t){if(!(this instanceof m))throw new TypeError('Please use the "new" operator, this DOM object constructor cannot be called as a function.');if(t||(t={}),this.type="default",this.status=void 0===t.status?200:t.status,this.status<200||this.status>599)throw new RangeError("Failed to construct 'Response': The status provided (0) is outside the range [200, 599].");this.ok=this.status>=200&&this.status<300,this.statusText=void 0===t.statusText?"":""+t.statusText,this.headers=new f(t.headers),this.url=t.url||"",this._initBody(e)}b.prototype.clone=function(){return new b(this,{body:this._bodyInit})},p.call(b.prototype),p.call(m.prototype),m.prototype.clone=function(){return new m(this._bodyInit,{status:this.status,statusText:this.statusText,headers:new f(this.headers),url:this.url})},m.error=function(){var e=new m(null,{status:200,statusText:""});return e.ok=!1,e.status=0,e.type="error",e};var v=[301,302,303,307,308];m.redirect=function(e,t){if(-1===v.indexOf(t))throw new RangeError("Invalid status code");return new m(null,{status:t,headers:{location:e}})};var g=t.DOMException;try{new g}catch(e){(g=function(e,t){this.message=e,this.name=t;var r=Error(e);this.stack=r.stack}).prototype=Object.create(Error.prototype),g.prototype.constructor=g}function A(e,r){return new Promise((function(n,i){var u=new b(e,r);if(u.signal&&u.signal.aborted)return i(new g("Aborted","AbortError"));var l=new XMLHttpRequest;function c(){l.abort()}if(l.onload=function(){var e,t,r={statusText:l.statusText,headers:(e=l.getAllResponseHeaders()||"",t=new f,e.replace(/\r?\n[\t ]+/g," ").split("\r").map((function(e){return 0===e.indexOf("\n")?e.substr(1,e.length):e})).forEach((function(e){var r=e.split(":"),o=r.shift().trim();if(o){var n=r.join(":").trim();try{t.append(o,n)}catch(e){console.warn("Response "+e.message)}}})),t)};0===u.url.indexOf("file://")&&(l.status<200||l.status>599)?r.status=200:r.status=l.status,r.url="responseURL"in l?l.responseURL:r.headers.get("X-Request-URL");var o="response"in l?l.response:l.responseText;setTimeout((function(){n(new m(o,r))}),0)},l.onerror=function(){setTimeout((function(){i(new TypeError("Network request failed"))}),0)},l.ontimeout=function(){setTimeout((function(){i(new TypeError("Network request timed out"))}),0)},l.onabort=function(){setTimeout((function(){i(new g("Aborted","AbortError"))}),0)},l.open(u.method,function(e){try{return""===e&&t.location.href?t.location.href:e}catch(t){return e}}(u.url),!0),"include"===u.credentials?l.withCredentials=!0:"omit"===u.credentials&&(l.withCredentials=!1),"responseType"in l&&(o.blob?l.responseType="blob":o.arrayBuffer&&(l.responseType="arraybuffer")),"redirect"in l&&(l.redirect=u.redirect),r&&"object"==typeof r.headers&&!(r.headers instanceof f||t.Headers&&r.headers instanceof t.Headers)){var h=[];Object.getOwnPropertyNames(r.headers).forEach((function(e){h.push(a(e)),l.setRequestHeader(e,s(r.headers[e]))})),u.headers.forEach((function(e,t){-1===h.indexOf(t)&&l.setRequestHeader(t,e)}))}else u.headers.forEach((function(e,t){l.setRequestHeader(t,e)}));u.signal&&(u.signal.addEventListener("abort",c),l.onreadystatechange=function(){4===l.readyState&&u.signal.removeEventListener("abort",c)}),l.send(void 0===u._bodyInit?null:u._bodyInit)}))}A.polyfill=!0,t.fetch||(t.fetch=A,t.Headers=f,t.Request=b,t.Response=m)})()})(); ================================================ FILE: pkg/download/engine/polyfill/package.json ================================================ { "name": "polyfill", "version": "1.0.0", "description": "", "main": "index.js", "type": "module", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "dev": "webpack --mode production --watch", "build": "webpack --mode production", "postinstall": "patch-package" }, "author": "", "license": "ISC", "devDependencies": { "patch-package": "^8.0.0", "webpack": "^5.75.0", "webpack-cli": "^5.0.1" }, "dependencies": { "blob-polyfill": "^7.0.20220408", "fastestsmallesttextencoderdecoder": "^1.0.22", "whatwg-fetch": "^3.6.20" } } ================================================ FILE: pkg/download/engine/polyfill/patches/whatwg-fetch+3.6.20.patch ================================================ 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) { ================================================ FILE: pkg/download/engine/polyfill/src/blob/index.js ================================================ import { Blob } from "blob-polyfill"; globalThis.Blob = Blob; ================================================ FILE: pkg/download/engine/polyfill/src/crypto/index.js ================================================ globalThis.crypto = { getRandomValues(arr) { for (let i = 0, len = arr.length; i < len; i++) { arr[i] = Math.floor(Math.random() * 256); } return arr; }, randomUUID() { return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c) => { return (c ^ (this.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16) }) } } ================================================ FILE: pkg/download/engine/polyfill/src/fetch/index.js ================================================ import 'whatwg-fetch' ================================================ FILE: pkg/download/engine/polyfill/src/index.js ================================================ import "./blob/index.js" import "./crypto/index.js" // polyfill TextEncoder import 'fastestsmallesttextencoderdecoder'; import "./fetch/index.js" ================================================ FILE: pkg/download/engine/polyfill/webpack.config.js ================================================ import path from "path"; import { fileURLToPath } from "url"; const __dirname = fileURLToPath(import.meta.url); export default { entry: "./src/index.js", output: { filename: "index.js", path: path.resolve(__dirname, "../out"), }, }; ================================================ FILE: pkg/download/engine/util/util.go ================================================ 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 } ================================================ FILE: pkg/download/event.go ================================================ package download type EventKey string const ( EventKeyStart = "start" EventKeyPause = "pause" EventKeyProgress = "progress" EventKeyError = "error" EventKeyDelete = "delete" EventKeyDone = "done" EventKeyFinally = "finally" ) type Event struct { Key EventKey Task *Task Err error } ================================================ FILE: pkg/download/extension.go ================================================ package download import ( "encoding/json" "fmt" "io" "os" "path" "path/filepath" "strconv" "strings" "time" "github.com/GopeedLab/gopeed/internal/logger" "github.com/GopeedLab/gopeed/pkg/base" "github.com/GopeedLab/gopeed/pkg/download/engine" gojaerror "github.com/GopeedLab/gopeed/pkg/download/engine/inject/error" gojautil "github.com/GopeedLab/gopeed/pkg/download/engine/util" "github.com/GopeedLab/gopeed/pkg/util" "github.com/dop251/goja" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing/transport" ) var ( gitSuffix = ".git" tempExtensionsDir = ".extensions" extensionsDir = "extensions" extensionIgnoreDirs = []string{gitSuffix, "node_modules"} ErrExtensionNoManifest = fmt.Errorf("manifest.json not found") ErrExtensionNotFound = fmt.Errorf("extension not found") ) type ActivationEvent string const ( EventOnResolve ActivationEvent = "onResolve" EventOnStart ActivationEvent = "onStart" EventOnError ActivationEvent = "onError" EventOnDone ActivationEvent = "onDone" ) func (d *Downloader) InstallExtensionByGit(url string) (*Extension, error) { return d.fetchExtensionByGit(url, d.InstallExtensionByFolder) } func (d *Downloader) InstallExtensionByFolder(path string, devMode bool) (*Extension, error) { ext, err := d.parseExtensionByPath(path) if err != nil { return nil, err } // if dev mode, don't copy to the extensions' directory if devMode { ext.DevMode = true ext.DevPath, _ = filepath.Abs(path) } else { if err = util.CopyDir(path, d.ExtensionPath(ext), extensionIgnoreDirs...); err != nil { return nil, err } } // if extension is not installed, add it to the list, otherwise update it installedExt := d.getExtension(ext.Identity) if installedExt == nil { ext.CreatedAt = time.Now() ext.UpdatedAt = ext.CreatedAt installedExt = ext d.extensions = append(d.extensions, installedExt) } else { installedExt.update(ext) } if err = d.storage.Put(bucketExtension, installedExt.Identity, installedExt); err != nil { return nil, err } return installedExt, nil } // UpgradeCheckExtension Check if there is a new version for the extension. func (d *Downloader) UpgradeCheckExtension(identity string) (newVersion string, err error) { ext, err := d.GetExtension(identity) if err != nil { return } installUrl := ext.buildInstallUrl() if installUrl == "" { return } _, err = d.fetchExtensionByGit(installUrl, func(tempExtPath string, devMode bool) (*Extension, error) { tempExt, err := d.parseExtensionByPath(tempExtPath) if err != nil { return nil, err } if tempExt.Version != ext.Version { newVersion = tempExt.Version } return tempExt, nil }) return } func (d *Downloader) UpgradeExtension(identity string) error { ext, err := d.GetExtension(identity) if err != nil { return err } installUrl := ext.buildInstallUrl() if installUrl == "" { return nil } if _, err := d.InstallExtensionByGit(installUrl); err != nil { return err } return nil } func (d *Downloader) UpdateExtensionSettings(identity string, settings map[string]any) error { ext, err := d.GetExtension(identity) if err != nil { return err } for _, setting := range ext.Settings { if value, ok := settings[setting.Name]; ok { setting.Value = tryParse(value, setting.Type) } } return d.storage.Put(bucketExtension, ext.Identity, ext) } func (d *Downloader) SwitchExtension(identity string, status bool) error { ext, err := d.GetExtension(identity) if err != nil { return err } ext.Disabled = !status return d.storage.Put(bucketExtension, ext.Identity, ext) } func (d *Downloader) DeleteExtension(identity string) error { ext, err := d.GetExtension(identity) if err != nil { return err } // remove from disk if !ext.DevMode { if err := os.RemoveAll(d.ExtensionPath(ext)); err != nil { return err } } // remove from extensions for i, e := range d.extensions { if e.Identity == identity { d.extensions = append(d.extensions[:i], d.extensions[i+1:]...) break } } if err = d.storage.Delete(bucketExtension, identity); err != nil { return err } if err = d.storage.Delete(bucketExtensionStorage, identity); err != nil { return err } return nil } func (d *Downloader) GetExtensions() []*Extension { return d.extensions } func (d *Downloader) GetExtension(identity string) (*Extension, error) { extension := d.getExtension(identity) if extension == nil { return nil, ErrExtensionNotFound } return extension, nil } func (d *Downloader) getExtension(identity string) *Extension { for _, ext := range d.extensions { if ext.Identity == identity { return ext } } return nil } func (d *Downloader) fetchExtensionByGit(url string, handler func(tempExtPath string, devMode bool) (*Extension, error)) (*Extension, error) { if !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "http://") { url = "https://" + url } // resolve project path parentPath, projectPath := path.Split(url) // resolve project name and sub path pathArr := strings.SplitN(projectPath, "#", 2) projectPath = strings.TrimSuffix(pathArr[0], gitSuffix) subPath := "" if len(pathArr) > 1 { subPath = pathArr[1] } // Use unique suffix to avoid concurrent conflicts when multiple git operations // target the same extension (e.g., install and update check happening simultaneously) tempExtDir := filepath.Join(d.cfg.StorageDir, tempExtensionsDir, fmt.Sprintf("%s_%d", projectPath, time.Now().UnixNano())) if err := os.MkdirAll(tempExtDir, 0755); err != nil { return nil, err } defer os.RemoveAll(tempExtDir) proxyOptions := transport.ProxyOptions{} proxyUrl := d.cfg.DownloaderStoreConfig.Proxy.ToUrl() if proxyUrl != nil { proxyOptions.URL = proxyUrl.Scheme + "://" + proxyUrl.Host proxyOptions.Username = proxyUrl.User.Username() proxyOptions.Password, _ = proxyUrl.User.Password() } // clone project to extension temp dir gitUrl := parentPath + projectPath + gitSuffix if _, err := git.PlainClone(tempExtDir, false, &git.CloneOptions{ URL: gitUrl, Depth: 1, ProxyOptions: proxyOptions, }); err != nil { return nil, err } return handler(filepath.Join(tempExtDir, subPath), false) } func (d *Downloader) parseExtensionByPath(path string) (*Extension, error) { // resolve extension manifest manifestTempPath := filepath.Join(path, "manifest.json") if _, err := os.Stat(manifestTempPath); os.IsNotExist(err) { return nil, ErrExtensionNoManifest } file, err := os.ReadFile(manifestTempPath) if err != nil { return nil, err } var ext Extension if err = json.Unmarshal(file, &ext); err != nil { return nil, err } if err = ext.validate(); err != nil { return nil, err } ext.Identity = ext.buildIdentity() return &ext, nil } func (d *Downloader) triggerOnResolve(req *base.Request) (res *base.Resource, err error) { err = doTrigger(d, EventOnResolve, req, &OnResolveContext{ Req: req, }, func(ext *Extension, gopeed *Instance, ctx *OnResolveContext) { // Validate resource structure if ctx.Res != nil && len(ctx.Res.Files) > 0 { if err := ctx.Res.Validate(); err != nil { gopeed.Logger.logger.Warn().Err(err).Msgf("[%s] resource invalid", ext.buildIdentity()) return } ctx.Res.Name = util.SafeFilename(ctx.Res.Name) for _, file := range ctx.Res.Files { file.Name = util.SafeFilename(file.Name) } ctx.Res.CalcSize(nil) } res = ctx.Res }, ) return } func (d *Downloader) triggerOnStart(task *Task) { doTrigger(d, EventOnStart, task.Meta.Req, &OnStartContext{ Task: NewExtensionTask(d, task), }, func(ext *Extension, gopeed *Instance, ctx *OnStartContext) { // Validate request structure if ctx.Task.Meta.Req != nil { if err := ctx.Task.Meta.Req.Validate(); err != nil { gopeed.Logger.logger.Warn().Err(err).Msgf("[%s] request invalid", ext.buildIdentity()) return } } }, ) return } func (d *Downloader) triggerOnError(task *Task, err error) { doTrigger(d, EventOnError, task.Meta.Req, &OnErrorContext{ Task: NewExtensionTask(d, task), Error: err, }, nil, ) } func (d *Downloader) triggerOnDone(task *Task) { doTrigger(d, EventOnDone, task.Meta.Req, &OnErrorContext{ Task: NewExtensionTask(d, task), }, nil, ) } func doTrigger[T any](d *Downloader, event ActivationEvent, req *base.Request, ctx T, handler func(ext *Extension, gopeed *Instance, ctx T)) error { // init extension global object gopeed := &Instance{ Events: make(InstanceEvents), } var err error for _, ext := range d.extensions { if ext.Disabled { continue } for _, script := range ext.Scripts { if script.match(event, req) { gopeed.Info = NewExtensionInfo(ext) gopeed.Logger = newInstanceLogger(ext, d.ExtensionLogger) gopeed.Settings = parseSettings(ext.Settings) gopeed.Storage = &ContextStorage{ storage: d.storage, identity: ext.buildIdentity(), } scriptFilePath := filepath.Join(d.ExtensionPath(ext), script.Entry) if _, err = os.Stat(scriptFilePath); os.IsNotExist(err) { gopeed.Logger.logger.Error().Err(err).Msgf("[%s] script file not exist", ext.buildIdentity()) continue } func() { var scriptFile *os.File scriptFile, err = os.Open(scriptFilePath) if err != nil { gopeed.Logger.logger.Error().Err(err).Msgf("[%s] open script file failed", ext.buildIdentity()) return } defer scriptFile.Close() var scriptBuf []byte scriptBuf, err = io.ReadAll(scriptFile) if err != nil { gopeed.Logger.logger.Error().Err(err).Msgf("[%s] read script file failed", ext.buildIdentity()) return } // Init request labels if req.Labels == nil { req.Labels = make(map[string]string) } engine := engine.NewEngine(&engine.Config{ ProxyConfig: d.cfg.Proxy, }) defer engine.Close() err = engine.Runtime.Set("gopeed", gopeed) if err != nil { gopeed.Logger.logger.Error().Err(err).Msgf("[%s] engine inject failed", ext.buildIdentity()) return } _, err = engine.RunString(string(scriptBuf)) if err != nil { gopeed.Logger.logger.Error().Err(err).Msgf("[%s] run script failed", ext.buildIdentity()) return } if fn, ok := gopeed.Events[event]; ok { _, err = engine.CallFunction(fn, ctx) if err != nil { gopeed.Logger.logger.Error().Err(err).Msgf("[%s] call function failed: %s", ext.buildIdentity(), event) return } if handler != nil { handler(ext, gopeed, ctx) } } }() } } } // Only return MessageError if me, ok := gojautil.AssertError[*gojaerror.MessageError](err); ok { return me } return nil } func (d *Downloader) ExtensionPath(ext *Extension) string { if ext.DevMode { return ext.DevPath } return filepath.Join(d.cfg.StorageDir, extensionsDir, ext.Identity) } type Extension struct { // Identity is global unique for an extension, it's a combination of author and name Identity string `json:"identity"` Name string `json:"name"` Author string `json:"author"` Title string `json:"title"` Description string `json:"description"` Icon string `json:"icon"` // Version semantic version string, like: 1.0.0 Version string `json:"version"` // Homepage homepage url Homepage string `json:"homepage"` // Repository git repository info Repository *Repository `json:"repository"` Scripts []*Script `json:"scripts"` Settings []*Setting `json:"settings"` // Disabled if true, this extension will be ignored Disabled bool `json:"disabled"` DevMode bool `json:"devMode"` // DevPath is the local path of extension source code DevPath string `json:"devPath"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } func (e *Extension) validate() error { if e.Name == "" { return fmt.Errorf("extension name is required") } if e.Title == "" { return fmt.Errorf("extension title is required") } if e.Version == "" { return fmt.Errorf("extension version is required") } return nil } func (e *Extension) buildIdentity() string { if e.Author == "" { return e.Name } return e.Author + "@" + e.Name } func (e *Extension) buildInstallUrl() string { if e.Repository == nil || e.Repository.Url == "" { return "" } repoUrl := e.Repository.Url if e.Repository.Directory != "" { if strings.HasSuffix(repoUrl, "/") { repoUrl = repoUrl[:len(repoUrl)-1] } dir := e.Repository.Directory if strings.HasPrefix(dir, "/") { dir = dir[1:] } repoUrl = repoUrl + "#" + dir } return repoUrl } func (e *Extension) update(newExt *Extension) error { e.Title = newExt.Title e.Description = newExt.Description e.Icon = newExt.Icon e.Version = newExt.Version e.Homepage = newExt.Homepage e.Repository = newExt.Repository e.Scripts = newExt.Scripts // merge settings // if new setting not exist in old settings, append it for _, newSetting := range newExt.Settings { var exist bool for _, setting := range e.Settings { if setting.Name == newSetting.Name { exist = true break } } if !exist { e.Settings = append(e.Settings, newSetting) } } // if old setting not exist in new settings, remove it for i := 0; i < len(e.Settings); i++ { var exist bool for _, setting := range newExt.Settings { if setting.Name == e.Settings[i].Name { exist = true break } } if !exist { e.Settings = append(e.Settings[:i], e.Settings[i+1:]...) i-- } } // if new setting exist in old settings, update it for _, newSetting := range newExt.Settings { for _, setting := range e.Settings { if setting.Name == newSetting.Name { setting.Title = newSetting.Title setting.Description = newSetting.Description setting.Options = newSetting.Options // if type changed, reset value if setting.Type != newSetting.Type { setting.Type = newSetting.Type setting.Value = newSetting.Value } break } } } e.UpdatedAt = time.Now() return nil } type Repository struct { Url string `json:"url"` Directory string `json:"directory"` } type Script struct { // Event active event name Event string `json:"event"` // Match rules Match *Match `json:"match"` // Entry js script file path Entry string `json:"entry"` } func (s *Script) match(event ActivationEvent, req *base.Request) bool { if s.Event == "" { return false } if s.Event != string(event) { return false } if s.Match == nil || (len(s.Match.Urls) == 0 && len(s.Match.Labels) == 0) { return false } // match url for _, url := range s.Match.Urls { if util.Match(url, req.URL) { return true } } // match label for _, label := range s.Match.Labels { if _, ok := req.Labels[label]; ok { return true } } return false } type Match struct { // Urls match expression, refer to https://developer.chrome.com/docs/extensions/mv3/match_patterns/ Urls []string `json:"urls"` // Labels match request labels Labels []string `json:"labels"` } type SettingType string const ( SettingTypeString SettingType = "string" SettingTypeNumber SettingType = "number" SettingTypeBoolean SettingType = "boolean" ) type Setting struct { Name string `json:"name"` Title string `json:"title"` Description string `json:"description"` Required bool `json:"required"` // setting type Type SettingType `json:"type"` // setting value Value any `json:"value"` //Multiple bool `json:"multiple"` Options []*Option `json:"options"` } type Option struct { Label string `json:"label"` Value any `json:"value"` } // Instance inject to js context when extension script is activated type Instance struct { Events InstanceEvents `json:"events"` Info *ExtensionInfo `json:"info"` Logger *InstanceLogger `json:"logger"` Settings map[string]any `json:"settings"` Storage *ContextStorage `json:"storage"` } type InstanceEvents map[ActivationEvent]goja.Callable func (h InstanceEvents) register(name ActivationEvent, fn goja.Callable) { h[name] = fn } func (h InstanceEvents) OnResolve(fn goja.Callable) { h.register(EventOnResolve, fn) } func (h InstanceEvents) OnStart(fn goja.Callable) { h.register(EventOnStart, fn) } func (h InstanceEvents) OnError(fn goja.Callable) { h.register(EventOnError, fn) } func (h InstanceEvents) OnDone(fn goja.Callable) { h.register(EventOnDone, fn) } type ExtensionInfo struct { Identity string `json:"identity"` Name string `json:"name"` Author string `json:"author"` Title string `json:"title"` Version string `json:"version"` } func NewExtensionInfo(ext *Extension) *ExtensionInfo { return &ExtensionInfo{ Identity: ext.buildIdentity(), Name: ext.Name, Author: ext.Author, Title: ext.Title, Version: ext.Version, } } type InstanceLogger struct { identity string devMode bool logger *logger.Logger } func (l *InstanceLogger) Debug(msg ...goja.Value) { if l.devMode { l.logger.Debug().Msg(l.append(msg...)) } } func (l *InstanceLogger) Info(msg ...goja.Value) { l.logger.Info().Msg(l.append(msg...)) } func (l *InstanceLogger) Warn(msg ...goja.Value) { l.logger.Warn().Msg(l.append(msg...)) } func (l *InstanceLogger) Error(msg ...goja.Value) { l.logger.Error().Msg(l.append(msg...)) } func (l *InstanceLogger) append(msg ...goja.Value) string { strMsg := make([]string, len(msg)) for i, m := range msg { strMsg[i] = m.String() } return fmt.Sprintf("[%s] %s", l.identity, strings.Join(strMsg, " ")) } func newInstanceLogger(extension *Extension, logger *logger.Logger) *InstanceLogger { return &InstanceLogger{ identity: extension.buildIdentity(), devMode: extension.DevMode, logger: logger, } } type OnResolveContext struct { Req *base.Request `json:"req"` Res *base.Resource `json:"res"` } type OnStartContext struct { Task *ExtensionTask `json:"task"` } type OnErrorContext struct { Task *ExtensionTask `json:"task"` Error error `json:"error"` } type OnDoneContext struct { Task *Task `json:"task"` } // ExtensionTask is a wrapper of Task, it's used to interact with extension scripts. // Avoid extension scripts modifying task directly, use ExtensionTask to encapsulate task, // only some fields can be modified, such as request info. type ExtensionTask struct { download *Downloader *Task } func NewExtensionTask(download *Downloader, task *Task) *ExtensionTask { // restricts extension scripts to only modify request info newTask := task.clone() newTask.Meta.Req = task.Meta.Req return &ExtensionTask{ download: download, Task: newTask, } } func (t *ExtensionTask) Continue() error { return t.download.Continue(&TaskFilter{ IDs: []string{t.ID}, }) } func (t *ExtensionTask) Pause() error { return t.download.Pause(&TaskFilter{ IDs: []string{t.ID}, }) } func parseSettings(settings []*Setting) map[string]any { m := make(map[string]any) for _, s := range settings { var val any if s.Value != nil { val = s.Value } m[s.Name] = tryParse(val, s.Type) } return m } func tryParse(val any, settingType SettingType) any { if val == nil { return nil } switch settingType { case SettingTypeString: return fmt.Sprint(val) case SettingTypeNumber: vv, err := strconv.ParseFloat(fmt.Sprint(val), 64) if err != nil { return nil } return vv case SettingTypeBoolean: vv, err := strconv.ParseBool(fmt.Sprint(val)) if err != nil { return nil } return vv default: return nil } } type ContextStorage struct { storage Storage identity string } func (s *ContextStorage) Get(key string) any { raw := s.getRawData() if v, ok := raw[key]; ok { return v } return nil } func (s *ContextStorage) Set(key string, value string) { raw := s.getRawData() raw[key] = value s.storage.Put(bucketExtensionStorage, s.identity, raw) } func (s *ContextStorage) Remove(key string) { raw := s.getRawData() delete(raw, key) s.storage.Put(bucketExtensionStorage, s.identity, raw) } func (s *ContextStorage) Keys() []string { raw := s.getRawData() keys := make([]string, 0) for k := range raw { keys = append(keys, k) } return keys } func (s *ContextStorage) Clear() { s.storage.Delete(bucketExtensionStorage, s.identity) } func (s *ContextStorage) getRawData() map[string]string { var data map[string]string s.storage.Get(bucketExtensionStorage, s.identity, &data) if data == nil { data = make(map[string]string) } return data } ================================================ FILE: pkg/download/extension_test.go ================================================ package download import ( "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) { setupDownloader(func(downloader *Downloader) { if _, err := downloader.InstallExtensionByFolder("./testdata/extensions/basic", 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_InstallExtensionByFolderDevMode(t *testing.T) { setupDownloader(func(downloader *Downloader) { if _, err := downloader.InstallExtensionByFolder("./testdata/extensions/basic", true); 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_InstallExtensionByGit(t *testing.T) { setupDownloader(func(downloader *Downloader) { if _, err := downloader.InstallExtensionByGit("https://github.com/GopeedLab/gopeed-extension-samples#github-release-sample"); err != nil { t.Fatal(err) } rr, err := downloader.Resolve(&base.Request{ URL: "https://github.com/GopeedLab/gopeed/releases", }, nil) if err != nil { t.Fatal(err) } if len(rr.Res.Files) == 1 { t.Fatal("resolve error") } }) } func TestDownloader_InstallExtensionByGitSimple(t *testing.T) { setupDownloader(func(downloader *Downloader) { if _, err := downloader.InstallExtensionByGit("github.com/GopeedLab/gopeed-extension-samples#github-release-sample"); err != nil { t.Fatal(err) } rr, err := downloader.Resolve(&base.Request{ URL: "https://github.com/GopeedLab/gopeed/releases", }, nil) if err != nil { t.Fatal(err) } if len(rr.Res.Files) == 1 { t.Fatal("resolve error") } }) } func TestDownloader_InstallExtensionByGitFull(t *testing.T) { setupDownloader(func(downloader *Downloader) { if _, err := downloader.InstallExtensionByGit("https://github.com/GopeedLab/gopeed-extension-samples.git#github-release-sample"); err != nil { t.Fatal(err) } rr, err := downloader.Resolve(&base.Request{ URL: "https://github.com/GopeedLab/gopeed/releases", }, nil) if err != nil { t.Fatal(err) } if len(rr.Res.Files) == 1 { t.Fatal("resolve error") } }) } 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 { t.Fatal(err) } extensions := downloader.GetExtensions() if len(extensions) == 0 { t.Fatal("extension not installed") } oldVersion := installedExt.Version // fetch new version from git newVersion, err := downloader.UpgradeCheckExtension(installedExt.Identity) if err != nil { t.Fatal(err) } if newVersion == "" { t.Fatal("new version not found") } // update extension if err = downloader.UpgradeExtension(installedExt.Identity); err != nil { t.Fatal(err) } upgradeExt := downloader.getExtension(installedExt.Identity) if upgradeExt.Version == oldVersion { 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://test.com", }, nil) if err != nil { t.Fatal(err) } 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) } }) } func TestDownloader_Extension_Settings(t *testing.T) { setupDownloader(func(downloader *Downloader) { if _, err := downloader.InstallExtensionByFolder("./testdata/extensions/settings_empty", 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("settings parse error") } }) setupDownloader(func(downloader *Downloader) { installedExt, err := downloader.InstallExtensionByFolder("./testdata/extensions/settings_all", false) if err != nil { t.Fatal(err) } downloader.UpdateExtensionSettings(installedExt.Identity, map[string]any{ "stringValued": "valued", "numberValued": 1.1, "booleanValued": true, }) 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("settings parse error") } }) } 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) if err != nil { t.Fatal(err) } if installedExt.Disabled == true { t.Fatal("extension disabled") } if err = downloader.SwitchExtension(installedExt.Identity, false); err != nil { t.Fatal(err) } if installedExt.Disabled == false { t.Fatal("extension enabled") } }) } func TestDownloader_DeleteExtension(t *testing.T) { setupDownloader(func(downloader *Downloader) { installedExt, err := downloader.InstallExtensionByFolder("./testdata/extensions/settings_all", false) if err != nil { t.Fatal(err) } extensions := downloader.GetExtensions() 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(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)) { 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) } ================================================ FILE: pkg/download/extract.go ================================================ 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) } ================================================ FILE: pkg/download/extract_7z.go ================================================ 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 } ================================================ FILE: pkg/download/extract_queue.go ================================================ 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 } ================================================ FILE: pkg/download/extract_queue_test.go ================================================ 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") } } ================================================ FILE: pkg/download/extract_rar.go ================================================ 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: pkg/download/extract_test.go ================================================ package download import ( "archive/tar" "archive/zip" "compress/gzip" "fmt" "io" "os" "path/filepath" "strings" "testing" "golang.org/x/text/encoding/simplifiedchinese" ) func TestIsArchiveFile(t *testing.T) { tests := []struct { filename string expected bool }{ // Archive formats {"file.zip", true}, {"file.ZIP", true}, {"file.tar", true}, {"file.tar.gz", true}, {"file.tgz", true}, {"file.tar.bz2", true}, {"file.tbz2", true}, {"file.tar.xz", true}, {"file.txz", true}, {"file.tar.zst", true}, {"file.tzst", true}, {"file.rar", true}, {"file.RAR", true}, {"file.7z", true}, // Compression formats {"file.gz", true}, {"file.bz2", true}, {"file.xz", true}, {"file.lz4", true}, {"file.sz", true}, {"file.zst", true}, {"file.br", true}, {"file.lz", true}, // Non-archive files {"file.txt", false}, {"file.pdf", false}, {"file.exe", false}, {"archive", false}, } for _, tt := range tests { t.Run(tt.filename, func(t *testing.T) { result := isArchiveFile(tt.filename) if result != tt.expected { t.Errorf("isArchiveFile(%q) = %v, expected %v", tt.filename, result, tt.expected) } }) } } func TestExtractArchive_Zip(t *testing.T) { // Create a temporary directory tempDir, err := os.MkdirTemp("", "extract_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create a test zip file zipPath := filepath.Join(tempDir, "test.zip") destDir := filepath.Join(tempDir, "extracted") if err := createTestZip(zipPath); err != nil { t.Fatal(err) } // Extract the archive err = extractArchive(zipPath, destDir, "", nil) if err != nil { t.Fatalf("extractArchive failed: %v", err) } // Verify the extracted files expectedFiles := []string{ filepath.Join(destDir, "test.txt"), filepath.Join(destDir, "subdir", "nested.txt"), } for _, path := range expectedFiles { if _, err := os.Stat(path); os.IsNotExist(err) { t.Errorf("expected file %q not found after extraction", path) } } // Verify content content, err := os.ReadFile(filepath.Join(destDir, "test.txt")) if err != nil { t.Fatal(err) } if string(content) != "Hello, World!" { t.Errorf("unexpected content: %q", string(content)) } } func TestExtractArchive_NonArchive(t *testing.T) { // Create a temporary directory tempDir, err := os.MkdirTemp("", "extract_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create a non-archive file txtPath := filepath.Join(tempDir, "test.txt") if err := os.WriteFile(txtPath, []byte("not an archive"), 0644); err != nil { t.Fatal(err) } destDir := filepath.Join(tempDir, "extracted") // Trying to extract a non-archive should return an error err = extractArchive(txtPath, destDir, "", nil) if err == nil { t.Error("expected error when extracting non-archive file") } } // createTestZip creates a test zip file with sample content func createTestZip(path string) error { zipFile, err := os.Create(path) if err != nil { return err } defer zipFile.Close() w := zip.NewWriter(zipFile) // Add a file to the root f, err := w.Create("test.txt") if err != nil { return err } _, err = f.Write([]byte("Hello, World!")) if err != nil { return err } // Add a file in a subdirectory f, err = w.Create("subdir/nested.txt") if err != nil { return err } _, err = f.Write([]byte("Nested content")) if err != nil { return err } return w.Close() } func TestExtractArchive_Progress(t *testing.T) { // Create a temporary directory tempDir, err := os.MkdirTemp("", "extract_progress_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create a test zip file with multiple files zipPath := filepath.Join(tempDir, "test.zip") destDir := filepath.Join(tempDir, "extracted") // Create zip with 4 files if err := createTestZipWithMultipleFiles(zipPath, 4); err != nil { t.Fatal(err) } // Track progress callbacks progressCalls := make([]struct { extracted int total int progress int }, 0) // Extract the archive with progress tracking err = extractArchive(zipPath, destDir, "", func(extracted int, total int, progress int) { progressCalls = append(progressCalls, struct { extracted int total int progress int }{extracted, total, progress}) }) if err != nil { t.Fatalf("extractArchive failed: %v", err) } // Verify that progress callbacks were made if len(progressCalls) != 4 { t.Errorf("expected 4 progress callbacks, got %d", len(progressCalls)) } // Verify the first progress call if len(progressCalls) > 0 { first := progressCalls[0] if first.extracted != 1 || first.total != 4 { t.Errorf("first progress call: expected extracted=1, total=4, got extracted=%d, total=%d", first.extracted, first.total) } } // Verify the last progress call if len(progressCalls) > 0 { last := progressCalls[len(progressCalls)-1] if last.extracted != 4 || last.total != 4 || last.progress != 100 { t.Errorf("last progress call: expected extracted=4, total=4, progress=100, got extracted=%d, total=%d, progress=%d", last.extracted, last.total, last.progress) } } } // createTestZipWithMultipleFiles creates a test zip file with the specified number of files func createTestZipWithMultipleFiles(path string, numFiles int) error { zipFile, err := os.Create(path) if err != nil { return err } defer zipFile.Close() w := zip.NewWriter(zipFile) for i := 0; i < numFiles; i++ { // Use Windows-safe names. The previous rune-based approach could generate // invalid Windows filename characters (e.g. '\\', ':', '|') when numFiles is large. nameInZip := fmt.Sprintf("dir/file%03d.txt", i+1) f, err := w.Create(nameInZip) if err != nil { return err } _, err = f.Write([]byte(fmt.Sprintf("Content of file %03d", i+1))) if err != nil { return err } } return w.Close() } // createTestZipWithChineseFilenames creates a test ZIP file with Chinese filenames encoded in GBK func createTestZipWithChineseFilenames(path string) error { zipFile, err := os.Create(path) if err != nil { return err } defer zipFile.Close() w := zip.NewWriter(zipFile) // Encode Chinese filenames in GBK (as some legacy Windows applications do) encoder := simplifiedchinese.GBK.NewEncoder() // Add a file with Chinese filename chineseFilename := "测试文件.txt" gbkFilename, err := encoder.String(chineseFilename) if err != nil { return err } // Create a FileHeader and manually set the Name with GBK encoding // We need to mark it as non-UTF8 by not setting the UTF-8 flag header := &zip.FileHeader{ Name: gbkFilename, Method: zip.Deflate, } // Clear the UTF-8 bit (bit 11) to indicate non-UTF8 encoding header.Flags = 0 f, err := w.CreateHeader(header) if err != nil { return err } _, err = f.Write([]byte("这是测试内容")) if err != nil { return err } // Add a file in a subdirectory with Chinese name chineseDirAndFile := "文件夹/中文内容.txt" gbkDirAndFile, err := encoder.String(chineseDirAndFile) if err != nil { return err } header2 := &zip.FileHeader{ Name: gbkDirAndFile, Method: zip.Deflate, } header2.Flags = 0 f2, err := w.CreateHeader(header2) if err != nil { return err } _, err = f2.Write([]byte("中文子文件内容")) if err != nil { return err } return w.Close() } func TestOpenArchive_NonExistentFile(t *testing.T) { _, err := openArchive("/nonexistent/path/file.zip", "") if err == nil { t.Error("expected error when opening non-existent file") } } func TestOpenArchive_InvalidFormat(t *testing.T) { tempDir, err := os.MkdirTemp("", "open_archive_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create a file that's not a valid archive format // Use a txt extension so it's not detected as an archive invalidPath := filepath.Join(tempDir, "invalid.txt") if err := os.WriteFile(invalidPath, []byte("not an archive file"), 0644); err != nil { t.Fatal(err) } _, err = openArchive(invalidPath, "") if err == nil { // archives.Identify may return a format even for non-archive files // This is expected behavior - it identifies based on content/extension t.Log("openArchive accepted the file - this is acceptable behavior") } } func TestOpenArchive_WithPassword(t *testing.T) { tempDir, err := os.MkdirTemp("", "open_archive_pwd_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create a valid zip file to test password parameter handling zipPath := filepath.Join(tempDir, "test.zip") if err := createTestZip(zipPath); err != nil { t.Fatal(err) } // Test that password is accepted (even if zip doesn't use it) info, err := openArchive(zipPath, "testpassword") if err != nil { t.Fatalf("openArchive with password failed: %v", err) } defer info.file.Close() if info.format == nil { t.Error("expected format to be set") } } func TestExtractArchive_NonExistentFile(t *testing.T) { tempDir, err := os.MkdirTemp("", "extract_nonexistent_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) destDir := filepath.Join(tempDir, "extracted") err = extractArchive("/nonexistent/file.zip", destDir, "", nil) if err == nil { t.Error("expected error when extracting non-existent file") } } func TestExtractArchive_WithPassword(t *testing.T) { tempDir, err := os.MkdirTemp("", "extract_pwd_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create a test zip file zipPath := filepath.Join(tempDir, "test.zip") destDir := filepath.Join(tempDir, "extracted") if err := createTestZip(zipPath); err != nil { t.Fatal(err) } // Extract with password (zip doesn't require it, but tests the code path) err = extractArchive(zipPath, destDir, "password123", nil) if err != nil { t.Fatalf("extractArchive with password failed: %v", err) } // Verify extraction succeeded if _, err := os.Stat(filepath.Join(destDir, "test.txt")); os.IsNotExist(err) { t.Error("expected file not found after extraction") } } func TestExtractArchive_Gzip(t *testing.T) { tempDir, err := os.MkdirTemp("", "extract_gzip_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create a gzip compressed file gzPath := filepath.Join(tempDir, "test.txt.gz") destDir := filepath.Join(tempDir, "extracted") if err := createTestGzip(gzPath, "Hello from gzip!"); err != nil { t.Fatal(err) } // Track progress callback values var progressCalls []struct { extracted int total int progress int } err = extractArchive(gzPath, destDir, "", func(extracted int, total int, progress int) { progressCalls = append(progressCalls, struct { extracted int total int progress int }{extracted, total, progress}) }) if err != nil { t.Fatalf("extractArchive failed for gzip: %v", err) } // Verify the decompressed file exists destPath := filepath.Join(destDir, "test.txt") if _, err := os.Stat(destPath); os.IsNotExist(err) { t.Error("expected decompressed file not found") } // Verify content content, err := os.ReadFile(destPath) if err != nil { t.Fatal(err) } if string(content) != "Hello from gzip!" { t.Errorf("unexpected content: %q", string(content)) } // Verify progress callbacks - should have exactly 2 calls for gzip: start (0,1,0) and end (1,1,100) if len(progressCalls) != 2 { t.Errorf("expected 2 progress callbacks for gzip, got %d", len(progressCalls)) } if len(progressCalls) >= 2 { // Verify start callback if progressCalls[0].extracted != 0 || progressCalls[0].total != 1 || progressCalls[0].progress != 0 { t.Errorf("expected start callback (0,1,0), got (%d,%d,%d)", progressCalls[0].extracted, progressCalls[0].total, progressCalls[0].progress) } // Verify end callback if progressCalls[1].extracted != 1 || progressCalls[1].total != 1 || progressCalls[1].progress != 100 { t.Errorf("expected end callback (1,1,100), got (%d,%d,%d)", progressCalls[1].extracted, progressCalls[1].total, progressCalls[1].progress) } } } func createTestGzip(path string, content string) error { file, err := os.Create(path) if err != nil { return err } defer file.Close() gz := gzip.NewWriter(file) _, err = gz.Write([]byte(content)) if err != nil { gz.Close() return err } return gz.Close() } func TestCountArchiveFiles(t *testing.T) { tempDir, err := os.MkdirTemp("", "count_archive_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create a test zip with known number of files zipPath := filepath.Join(tempDir, "test.zip") if err := createTestZipWithMultipleFiles(zipPath, 5); err != nil { t.Fatal(err) } count, err := countArchiveFiles(zipPath, "") if err != nil { t.Fatalf("countArchiveFiles failed: %v", err) } if count != 5 { t.Errorf("expected 5 files, got %d", count) } } func TestCountArchiveFiles_NonExistent(t *testing.T) { _, err := countArchiveFiles("/nonexistent/file.zip", "") if err == nil { t.Error("expected error for non-existent file") } } func TestCountArchiveFiles_Gzip(t *testing.T) { tempDir, err := os.MkdirTemp("", "count_gzip_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create a gzip file gzPath := filepath.Join(tempDir, "test.txt.gz") if err := createTestGzip(gzPath, "test content"); err != nil { t.Fatal(err) } count, err := countArchiveFiles(gzPath, "") if err != nil { t.Fatalf("countArchiveFiles failed: %v", err) } // Gzip is single-file compression, should return 1 if count != 1 { t.Errorf("expected 1 file for gzip, got %d", count) } } func TestExtractArchive_ProgressWithZeroFiles(t *testing.T) { tempDir, err := os.MkdirTemp("", "extract_zero_progress_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create an empty zip file zipPath := filepath.Join(tempDir, "empty.zip") destDir := filepath.Join(tempDir, "extracted") if err := createEmptyZip(zipPath); err != nil { t.Fatal(err) } progressCalled := false err = extractArchive(zipPath, destDir, "", func(extracted int, total int, progress int) { progressCalled = true }) if err != nil { t.Fatalf("extractArchive failed: %v", err) } // Progress should not be called for empty archive if progressCalled { t.Error("progress callback should not be called for empty archive") } } func createEmptyZip(path string) error { zipFile, err := os.Create(path) if err != nil { return err } defer zipFile.Close() w := zip.NewWriter(zipFile) return w.Close() } func TestExtractArchive_WithDirectories(t *testing.T) { tempDir, err := os.MkdirTemp("", "extract_dirs_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) zipPath := filepath.Join(tempDir, "test.zip") destDir := filepath.Join(tempDir, "extracted") if err := createTestZipWithDirectories(zipPath); err != nil { t.Fatal(err) } err = extractArchive(zipPath, destDir, "", nil) if err != nil { t.Fatalf("extractArchive failed: %v", err) } // Verify files were created (directories are created implicitly) expectedPaths := []string{ filepath.Join(destDir, "dir1", "file1.txt"), filepath.Join(destDir, "dir2", "subdir", "file2.txt"), } for _, path := range expectedPaths { if _, err := os.Stat(path); os.IsNotExist(err) { t.Errorf("expected path %q not found", path) } } } func createTestZipWithDirectories(path string) error { zipFile, err := os.Create(path) if err != nil { return err } defer zipFile.Close() w := zip.NewWriter(zipFile) // Create file in directory (directory will be created automatically) f, err := w.Create("dir1/file1.txt") if err != nil { return err } _, err = f.Write([]byte("content1")) if err != nil { return err } // Create nested directory structure f, err = w.Create("dir2/subdir/file2.txt") if err != nil { return err } _, err = f.Write([]byte("content2")) if err != nil { return err } return w.Close() } func TestExtractArchive_NilProgressCallback(t *testing.T) { tempDir, err := os.MkdirTemp("", "extract_nil_progress_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) zipPath := filepath.Join(tempDir, "test.zip") destDir := filepath.Join(tempDir, "extracted") if err := createTestZipWithMultipleFiles(zipPath, 3); err != nil { t.Fatal(err) } // Should not panic with nil callback err = extractArchive(zipPath, destDir, "", nil) if err != nil { t.Fatalf("extractArchive failed: %v", err) } } func TestExtractArchive_GzipUppercase(t *testing.T) { tempDir, err := os.MkdirTemp("", "extract_gzip_upper_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create a gzip compressed file with uppercase extension gzPath := filepath.Join(tempDir, "test.txt.GZ") destDir := filepath.Join(tempDir, "extracted") if err := createTestGzip(gzPath, "Hello from gzip!"); err != nil { t.Fatal(err) } err = extractArchive(gzPath, destDir, "", nil) if err != nil { t.Fatalf("extractArchive failed for gzip with uppercase: %v", err) } // The base name should have the extension stripped destPath := filepath.Join(destDir, "test.txt") if _, err := os.Stat(destPath); os.IsNotExist(err) { t.Error("expected decompressed file not found") } } func TestExtractArchive_PathTraversalPrevention(t *testing.T) { tempDir, err := os.MkdirTemp("", "extract_traversal_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create a zip with a path traversal attempt zipPath := filepath.Join(tempDir, "malicious.zip") destDir := filepath.Join(tempDir, "extracted") if err := createMaliciousZip(zipPath); err != nil { t.Fatal(err) } err = extractArchive(zipPath, destDir, "", nil) if err != nil { t.Fatalf("extractArchive failed: %v", err) } // Verify that the file was not created outside destDir // The malicious path should be sanitized dangerousPath := filepath.Join(tempDir, "evil.txt") if _, err := os.Stat(dangerousPath); err == nil { t.Error("path traversal attack succeeded - file created outside destDir") } } func createMaliciousZip(path string) error { zipFile, err := os.Create(path) if err != nil { return err } defer zipFile.Close() w := zip.NewWriter(zipFile) // Create a file with path traversal attempt // Note: Go's zip library may sanitize this, but we test it anyway f, err := w.Create("../evil.txt") if err != nil { return err } _, err = f.Write([]byte("malicious content")) if err != nil { return err } // Also add a normal file f, err = w.Create("safe.txt") if err != nil { return err } _, err = f.Write([]byte("safe content")) if err != nil { return err } return w.Close() } func TestIsArchiveFile_AdditionalFormats(t *testing.T) { // Test additional archive formats tests := []struct { filename string expected bool }{ // More compression formats {"file.tar.lz4", true}, {"file.tlz4", true}, {"file.tar.sz", true}, {"file.tsz", true}, // Edge cases {"file.tar.gz.backup", false}, {".gz", true}, {"archive.ZIP.bak", false}, } for _, tt := range tests { t.Run(tt.filename, func(t *testing.T) { result := isArchiveFile(tt.filename) if result != tt.expected { t.Errorf("isArchiveFile(%q) = %v, expected %v", tt.filename, result, tt.expected) } }) } } func TestExtractArchive_DestDirCreation(t *testing.T) { tempDir, err := os.MkdirTemp("", "extract_destdir_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) zipPath := filepath.Join(tempDir, "test.zip") // Use a nested path that doesn't exist destDir := filepath.Join(tempDir, "level1", "level2", "extracted") if err := createTestZip(zipPath); err != nil { t.Fatal(err) } err = extractArchive(zipPath, destDir, "", nil) if err != nil { t.Fatalf("extractArchive failed: %v", err) } // Verify destDir was created if _, err := os.Stat(destDir); os.IsNotExist(err) { t.Error("destDir was not created") } // Verify files were extracted if _, err := os.Stat(filepath.Join(destDir, "test.txt")); os.IsNotExist(err) { t.Error("file not extracted") } } func TestExtractArchive_ProgressCallbackValues(t *testing.T) { tempDir, err := os.MkdirTemp("", "extract_progress_values_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) zipPath := filepath.Join(tempDir, "test.zip") destDir := filepath.Join(tempDir, "extracted") // Create zip with 10 files to test progress calculation if err := createTestZipWithMultipleFiles(zipPath, 10); err != nil { t.Fatal(err) } var progressValues []int err = extractArchive(zipPath, destDir, "", func(extracted int, total int, progress int) { progressValues = append(progressValues, progress) }) if err != nil { t.Fatalf("extractArchive failed: %v", err) } // Verify progress increases monotonically for i := 1; i < len(progressValues); i++ { if progressValues[i] < progressValues[i-1] { t.Errorf("progress decreased from %d to %d", progressValues[i-1], progressValues[i]) } } // Verify last progress is 100 if len(progressValues) > 0 && progressValues[len(progressValues)-1] != 100 { t.Errorf("final progress should be 100, got %d", progressValues[len(progressValues)-1]) } } func TestCountArchiveFiles_WithDirectories(t *testing.T) { tempDir, err := os.MkdirTemp("", "count_dirs_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) zipPath := filepath.Join(tempDir, "test.zip") if err := createTestZipWithDirectories(zipPath); err != nil { t.Fatal(err) } count, err := countArchiveFiles(zipPath, "") if err != nil { t.Fatalf("countArchiveFiles failed: %v", err) } // Should only count files, not directories // createTestZipWithDirectories creates 2 files if count != 2 { t.Errorf("expected 2 files, got %d", count) } } func TestOpenArchive_FileStatError(t *testing.T) { // Test error path when file can't be stat'd // This is hard to test without mocking, so we just ensure // the function handles basic error cases tempDir, err := os.MkdirTemp("", "open_archive_stat_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create a valid zip first zipPath := filepath.Join(tempDir, "test.zip") if err := createTestZip(zipPath); err != nil { t.Fatal(err) } // Test normal opening info, err := openArchive(zipPath, "") if err != nil { t.Fatalf("openArchive failed: %v", err) } defer info.file.Close() if info.stat == nil { t.Error("stat should not be nil") } if info.format == nil { t.Error("format should not be nil") } if info.input == nil { t.Error("input should not be nil") } } func TestExtractArchive_FilePermissions(t *testing.T) { tempDir, err := os.MkdirTemp("", "extract_perms_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) zipPath := filepath.Join(tempDir, "test.zip") destDir := filepath.Join(tempDir, "extracted") if err := createTestZip(zipPath); err != nil { t.Fatal(err) } err = extractArchive(zipPath, destDir, "", nil) if err != nil { t.Fatalf("extractArchive failed: %v", err) } // Check that files are readable content, err := os.ReadFile(filepath.Join(destDir, "test.txt")) if err != nil { t.Fatalf("couldn't read extracted file: %v", err) } if string(content) != "Hello, World!" { t.Errorf("unexpected content: %q", string(content)) } } func TestExtractArchive_TarGz(t *testing.T) { tempDir, err := os.MkdirTemp("", "extract_targz_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) tarGzPath := filepath.Join(tempDir, "test.tar.gz") destDir := filepath.Join(tempDir, "extracted") if err := createTestTarGz(tarGzPath); err != nil { t.Fatal(err) } var progressCalls int err = extractArchive(tarGzPath, destDir, "", func(extracted int, total int, progress int) { progressCalls++ }) if err != nil { t.Fatalf("extractArchive failed for tar.gz: %v", err) } // Verify the extracted file exists destPath := filepath.Join(destDir, "test.txt") content, err := os.ReadFile(destPath) if err != nil { t.Fatalf("couldn't read extracted file: %v", err) } if string(content) != "Hello from tar.gz!" { t.Errorf("unexpected content: %q", string(content)) } // Verify nested file exists nestedPath := filepath.Join(destDir, "subdir", "nested.txt") content, err = os.ReadFile(nestedPath) if err != nil { t.Fatalf("couldn't read nested file: %v", err) } if string(content) != "Nested content" { t.Errorf("unexpected nested content: %q", string(content)) } } func createTestTarGz(path string) error { file, err := os.Create(path) if err != nil { return err } defer file.Close() gzWriter := gzip.NewWriter(file) defer gzWriter.Close() tarWriter := tar.NewWriter(gzWriter) defer tarWriter.Close() // Add a file content := []byte("Hello from tar.gz!") header := &tar.Header{ Name: "test.txt", Mode: 0644, Size: int64(len(content)), } if err := tarWriter.WriteHeader(header); err != nil { return err } if _, err := tarWriter.Write(content); err != nil { return err } // Add a nested file nestedContent := []byte("Nested content") nestedHeader := &tar.Header{ Name: "subdir/nested.txt", Mode: 0644, Size: int64(len(nestedContent)), } if err := tarWriter.WriteHeader(nestedHeader); err != nil { return err } if _, err := tarWriter.Write(nestedContent); err != nil { return err } return nil } func TestCountArchiveFiles_TarGz(t *testing.T) { tempDir, err := os.MkdirTemp("", "count_targz_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) tarGzPath := filepath.Join(tempDir, "test.tar.gz") if err := createTestTarGz(tarGzPath); err != nil { t.Fatal(err) } count, err := countArchiveFiles(tarGzPath, "") if err != nil { t.Fatalf("countArchiveFiles failed: %v", err) } // Should have 2 files if count != 2 { t.Errorf("expected 2 files, got %d", count) } } func TestExtractArchive_LargeFileCount(t *testing.T) { tempDir, err := os.MkdirTemp("", "extract_large_count_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) zipPath := filepath.Join(tempDir, "test.zip") destDir := filepath.Join(tempDir, "extracted") // Create zip with many files to test progress capping at 100 numFiles := 100 if err := createTestZipWithMultipleFiles(zipPath, numFiles); err != nil { t.Fatal(err) } var maxProgress int err = extractArchive(zipPath, destDir, "", func(extracted int, total int, progress int) { if progress > maxProgress { maxProgress = progress } // Progress should never exceed 100 if progress > 100 { t.Errorf("progress exceeded 100: %d", progress) } }) if err != nil { t.Fatalf("extractArchive failed: %v", err) } // Max progress should be 100 if maxProgress != 100 { t.Errorf("expected max progress 100, got %d", maxProgress) } } func TestExtractArchive_GzipNoExtension(t *testing.T) { tempDir, err := os.MkdirTemp("", "extract_gzip_noext_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create a gzip with just .gz (no original extension) gzPath := filepath.Join(tempDir, "compressed.gz") destDir := filepath.Join(tempDir, "extracted") if err := createTestGzip(gzPath, "Compressed content"); err != nil { t.Fatal(err) } err = extractArchive(gzPath, destDir, "", nil) if err != nil { t.Fatalf("extractArchive failed: %v", err) } // Should create file named "compressed" (without .gz) destPath := filepath.Join(destDir, "compressed") if _, err := os.Stat(destPath); os.IsNotExist(err) { t.Error("expected decompressed file not found") } } func TestExtractArchive_ReadOnlyDestDir(t *testing.T) { tempDir, err := os.MkdirTemp("", "extract_readonly_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) zipPath := filepath.Join(tempDir, "test.zip") destDir := filepath.Join(tempDir, "readonly") if err := createTestZip(zipPath); err != nil { t.Fatal(err) } // Create a read-only directory if err := os.MkdirAll(destDir, 0555); err != nil { t.Fatal(err) } // Make it writable for cleanup defer func() { _ = os.Chmod(destDir, 0755) }() // Windows ACL semantics mean chmod often does not prevent writes. // If the filesystem doesn't enforce the permission change, skip. probe := filepath.Join(destDir, "__write_probe.tmp") if err := os.WriteFile(probe, []byte("x"), 0644); err == nil { _ = os.Remove(probe) t.Skip("filesystem does not enforce read-only directory permissions") } // Extraction should fail due to read-only destination err = extractArchive(zipPath, destDir, "", nil) if err == nil { t.Error("expected error when extracting to read-only directory") } } func TestSupportedArchiveExtensions(t *testing.T) { // Test that all listed extensions are recognized for _, ext := range supportedArchiveExtensions { filename := "test" + ext if !isArchiveFile(filename) { t.Errorf("extension %q should be recognized as archive", ext) } } } func TestExtractArchive_Tar(t *testing.T) { tempDir, err := os.MkdirTemp("", "extract_tar_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) tarPath := filepath.Join(tempDir, "test.tar") destDir := filepath.Join(tempDir, "extracted") if err := createTestTar(tarPath); err != nil { t.Fatal(err) } err = extractArchive(tarPath, destDir, "", nil) if err != nil { t.Fatalf("extractArchive failed for tar: %v", err) } // Verify files were extracted destPath := filepath.Join(destDir, "test.txt") if _, err := os.Stat(destPath); os.IsNotExist(err) { t.Error("expected file not found") } } func createTestTar(path string) error { file, err := os.Create(path) if err != nil { return err } defer file.Close() tarWriter := tar.NewWriter(file) defer tarWriter.Close() content := []byte("Hello from tar!") header := &tar.Header{ Name: "test.txt", Mode: 0644, Size: int64(len(content)), } if err := tarWriter.WriteHeader(header); err != nil { return err } if _, err := tarWriter.Write(content); err != nil { return err } return nil } func TestCountArchiveFiles_Tar(t *testing.T) { tempDir, err := os.MkdirTemp("", "count_tar_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) tarPath := filepath.Join(tempDir, "test.tar") if err := createTestTar(tarPath); err != nil { t.Fatal(err) } count, err := countArchiveFiles(tarPath, "") if err != nil { t.Fatalf("countArchiveFiles failed: %v", err) } if count != 1 { t.Errorf("expected 1 file, got %d", count) } } func TestOpenArchive_ValidArchive(t *testing.T) { tempDir, err := os.MkdirTemp("", "open_valid_archive_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) zipPath := filepath.Join(tempDir, "test.zip") if err := createTestZip(zipPath); err != nil { t.Fatal(err) } info, err := openArchive(zipPath, "") if err != nil { t.Fatalf("openArchive failed: %v", err) } defer info.file.Close() // Verify all fields are set if info.file == nil { t.Error("file should not be nil") } if info.stat == nil { t.Error("stat should not be nil") } if info.format == nil { t.Error("format should not be nil") } if info.input == nil { t.Error("input should not be nil") } if info.stat.Size() == 0 { t.Error("file size should be > 0") } } func TestExtractArchive_NestedDirectoryStructure(t *testing.T) { tempDir, err := os.MkdirTemp("", "extract_nested_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) zipPath := filepath.Join(tempDir, "nested.zip") destDir := filepath.Join(tempDir, "extracted") if err := createDeeplyNestedZip(zipPath); err != nil { t.Fatal(err) } err = extractArchive(zipPath, destDir, "", nil) if err != nil { t.Fatalf("extractArchive failed: %v", err) } // Verify deeply nested file deepPath := filepath.Join(destDir, "a", "b", "c", "d", "deep.txt") if _, err := os.Stat(deepPath); os.IsNotExist(err) { t.Error("deeply nested file not found") } } func createDeeplyNestedZip(path string) error { zipFile, err := os.Create(path) if err != nil { return err } defer zipFile.Close() w := zip.NewWriter(zipFile) f, err := w.Create("a/b/c/d/deep.txt") if err != nil { return err } _, err = f.Write([]byte("Deep content")) if err != nil { return err } return w.Close() } func TestExtractArchive_EmptyFileName(t *testing.T) { tempDir, err := os.MkdirTemp("", "extract_empty_name_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) zipPath := filepath.Join(tempDir, "test.zip") destDir := filepath.Join(tempDir, "extracted") // Create a normal zip - the archive library handles empty names gracefully if err := createTestZip(zipPath); err != nil { t.Fatal(err) } err = extractArchive(zipPath, destDir, "", nil) if err != nil { t.Fatalf("extractArchive failed: %v", err) } } func TestExtractArchive_ProgressTracking(t *testing.T) { tempDir, err := os.MkdirTemp("", "extract_progress_track_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) zipPath := filepath.Join(tempDir, "test.zip") destDir := filepath.Join(tempDir, "extracted") numFiles := 5 if err := createTestZipWithMultipleFiles(zipPath, numFiles); err != nil { t.Fatal(err) } var extractedValues []int var totalValues []int err = extractArchive(zipPath, destDir, "", func(extracted int, total int, progress int) { extractedValues = append(extractedValues, extracted) totalValues = append(totalValues, total) }) if err != nil { t.Fatalf("extractArchive failed: %v", err) } // Verify total is always the same for _, total := range totalValues { if total != numFiles { t.Errorf("total should be %d, got %d", numFiles, total) } } // Verify extracted increases sequentially for i, extracted := range extractedValues { if extracted != i+1 { t.Errorf("extracted should be %d, got %d", i+1, extracted) } } } func TestArchiveInfo_Fields(t *testing.T) { tempDir, err := os.MkdirTemp("", "archive_info_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) zipPath := filepath.Join(tempDir, "test.zip") if err := createTestZip(zipPath); err != nil { t.Fatal(err) } info, err := openArchive(zipPath, "") if err != nil { t.Fatalf("openArchive failed: %v", err) } defer info.file.Close() // Verify file name if info.file.Name() != zipPath { t.Errorf("expected file name %q, got %q", zipPath, info.file.Name()) } // Verify stat mode if info.stat.Mode().IsDir() { t.Error("expected file, not directory") } } // Tests for multi-part archive detection func TestIsMultiPartArchive(t *testing.T) { tests := []struct { filename string expected bool }{ // 7z multi-part {"archive.7z.001", true}, {"archive.7z.002", true}, {"archive.7z.100", true}, {"ARCHIVE.7Z.001", true}, // RAR new style {"archive.part01.rar", true}, {"archive.part1.rar", true}, {"archive.part99.rar", true}, {"ARCHIVE.PART01.RAR", true}, // RAR old style (extension parts) {"archive.r00", true}, {"archive.r01", true}, {"archive.r99", true}, // ZIP multi-part {"archive.zip.001", true}, {"archive.zip.002", true}, // ZIP split {"archive.z01", true}, {"archive.z02", true}, // Regular (non-multi-part) archives {"archive.zip", false}, {"archive.rar", false}, {"archive.7z", false}, {"archive.tar.gz", false}, // Non-archive files {"file.txt", false}, {"file.001", false}, // No .7z or .zip prefix } for _, tt := range tests { t.Run(tt.filename, func(t *testing.T) { result := isMultiPartArchive(tt.filename) if result != tt.expected { t.Errorf("isMultiPartArchive(%q) = %v, expected %v", tt.filename, result, tt.expected) } }) } } func TestGetArchivePartInfo_7z(t *testing.T) { tests := []struct { filename string baseName string partNumber int isMultiPart bool }{ {"archive.7z.001", "archive.7z", 1, true}, {"archive.7z.002", "archive.7z", 2, true}, {"archive.7z.010", "archive.7z", 10, true}, {"my.file.7z.005", "my.file.7z", 5, true}, } for _, tt := range tests { t.Run(tt.filename, func(t *testing.T) { info := getArchivePartInfo(tt.filename) if info.IsMultiPart != tt.isMultiPart { t.Errorf("IsMultiPart: expected %v, got %v", tt.isMultiPart, info.IsMultiPart) } if info.BaseName != tt.baseName { t.Errorf("BaseName: expected %q, got %q", tt.baseName, info.BaseName) } if info.PartNumber != tt.partNumber { t.Errorf("PartNumber: expected %d, got %d", tt.partNumber, info.PartNumber) } }) } } func TestGetArchivePartInfo_RarNewStyle(t *testing.T) { tests := []struct { filename string baseName string partNumber int isMultiPart bool }{ {"archive.part01.rar", "archive", 1, true}, {"archive.part02.rar", "archive", 2, true}, {"archive.part1.rar", "archive", 1, true}, {"my.archive.part10.rar", "my.archive", 10, true}, } for _, tt := range tests { t.Run(tt.filename, func(t *testing.T) { info := getArchivePartInfo(tt.filename) if info.IsMultiPart != tt.isMultiPart { t.Errorf("IsMultiPart: expected %v, got %v", tt.isMultiPart, info.IsMultiPart) } if info.BaseName != tt.baseName { t.Errorf("BaseName: expected %q, got %q", tt.baseName, info.BaseName) } if info.PartNumber != tt.partNumber { t.Errorf("PartNumber: expected %d, got %d", tt.partNumber, info.PartNumber) } }) } } func TestGetArchivePartInfo_RarOldStyle(t *testing.T) { tests := []struct { filename string baseName string partNumber int isMultiPart bool }{ {"archive.r00", "archive", 1, true}, // r00 is treated as first extension part (after .rar) {"archive.r01", "archive", 1, true}, {"archive.r99", "archive", 99, true}, } for _, tt := range tests { t.Run(tt.filename, func(t *testing.T) { info := getArchivePartInfo(tt.filename) if info.IsMultiPart != tt.isMultiPart { t.Errorf("IsMultiPart: expected %v, got %v", tt.isMultiPart, info.IsMultiPart) } if info.BaseName != tt.baseName { t.Errorf("BaseName: expected %q, got %q", tt.baseName, info.BaseName) } }) } } func TestGetArchivePartInfo_ZipMultiPart(t *testing.T) { tests := []struct { filename string baseName string partNumber int isMultiPart bool }{ {"archive.zip.001", "archive.zip", 1, true}, {"archive.zip.002", "archive.zip", 2, true}, {"my.file.zip.010", "my.file.zip", 10, true}, } for _, tt := range tests { t.Run(tt.filename, func(t *testing.T) { info := getArchivePartInfo(tt.filename) if info.IsMultiPart != tt.isMultiPart { t.Errorf("IsMultiPart: expected %v, got %v", tt.isMultiPart, info.IsMultiPart) } if info.BaseName != tt.baseName { t.Errorf("BaseName: expected %q, got %q", tt.baseName, info.BaseName) } if info.PartNumber != tt.partNumber { t.Errorf("PartNumber: expected %d, got %d", tt.partNumber, info.PartNumber) } }) } } func TestGetArchivePartInfo_ZipSplit(t *testing.T) { tests := []struct { filename string baseName string partNumber int isMultiPart bool }{ {"archive.z01", "archive", 1, true}, {"archive.z02", "archive", 2, true}, {"archive.z99", "archive", 99, true}, } for _, tt := range tests { t.Run(tt.filename, func(t *testing.T) { info := getArchivePartInfo(tt.filename) if info.IsMultiPart != tt.isMultiPart { t.Errorf("IsMultiPart: expected %v, got %v", tt.isMultiPart, info.IsMultiPart) } if info.BaseName != tt.baseName { t.Errorf("BaseName: expected %q, got %q", tt.baseName, info.BaseName) } if info.PartNumber != tt.partNumber { t.Errorf("PartNumber: expected %d, got %d", tt.partNumber, info.PartNumber) } }) } } func TestGetArchivePartInfo_NonMultiPart(t *testing.T) { tests := []string{ "archive.zip", "archive.rar", "archive.7z", "file.txt", "file.001", } for _, filename := range tests { t.Run(filename, func(t *testing.T) { info := getArchivePartInfo(filename) if info.IsMultiPart { t.Errorf("Expected non-multi-part for %q, but got IsMultiPart=true", filename) } }) } } func TestIsFirstPart(t *testing.T) { tests := []struct { filename string expected bool }{ // First parts {"archive.7z.001", true}, {"archive.part01.rar", true}, {"archive.part1.rar", true}, {"archive.zip.001", true}, {"archive.z01", true}, // Non-first parts {"archive.7z.002", false}, {"archive.part02.rar", false}, {"archive.zip.002", false}, {"archive.z02", false}, // For RAR old style (.r00, .r01), these are NOT the first part // The first part is the .rar file, but these extension files // have partNumber=1 due to parsePartNumber treating 00 as 1 // So isFirstPart returns true for these (which is technically correct // in terms of part numbering, even though .rar is the "real" first file) {"archive.r00", true}, {"archive.r01", true}, // Non-multi-part (should return false) {"archive.zip", false}, {"archive.rar", false}, } for _, tt := range tests { t.Run(tt.filename, func(t *testing.T) { result := isFirstPart(tt.filename) if result != tt.expected { t.Errorf("isFirstPart(%q) = %v, expected %v", tt.filename, result, tt.expected) } }) } } func TestGetMultiPartArchiveBaseName(t *testing.T) { tests := []struct { filename string expected string }{ {"/path/to/archive.7z.001", "/path/to/archive.7z"}, {"/path/to/archive.part01.rar", "/path/to/archive"}, {"/path/to/archive.zip.001", "/path/to/archive.zip"}, {"/path/to/archive.z01", "/path/to/archive"}, // Non-multi-part should return empty {"/path/to/archive.zip", ""}, {"/path/to/file.txt", ""}, } for _, tt := range tests { t.Run(tt.filename, func(t *testing.T) { filename := filepath.FromSlash(tt.filename) expected := tt.expected if expected != "" { expected = filepath.FromSlash(expected) } result := GetMultiPartArchiveBaseName(filename) if result != expected { t.Errorf("GetMultiPartArchiveBaseName(%q) = %q, expected %q", filename, result, expected) } }) } } func TestIsArchiveFile_IncludesMultiPart(t *testing.T) { // Test that isArchiveFile returns true for multi-part archives tests := []struct { filename string expected bool }{ {"archive.7z.001", true}, {"archive.7z.002", true}, {"archive.part01.rar", true}, {"archive.zip.001", true}, {"archive.z01", true}, {"archive.r00", true}, } for _, tt := range tests { t.Run(tt.filename, func(t *testing.T) { result := isArchiveFile(tt.filename) if result != tt.expected { t.Errorf("isArchiveFile(%q) = %v, expected %v", tt.filename, result, tt.expected) } }) } } func TestArchivePartInfo_PatternField(t *testing.T) { // Verify that the Pattern field is set correctly for different formats tests := []struct { filename string patternContains string }{ {"archive.7z.001", ".7z)"}, {"archive.part01.rar", ".part"}, {"archive.r00", ".r("}, {"archive.zip.001", ".zip)"}, {"archive.z01", ".z("}, } for _, tt := range tests { t.Run(tt.filename, func(t *testing.T) { info := getArchivePartInfo(tt.filename) if !info.IsMultiPart { t.Fatalf("Expected multi-part archive for %q", tt.filename) } if info.Pattern == "" { t.Errorf("Pattern should not be empty for %q", tt.filename) } }) } } func TestArchivePartInfo_FirstPartPath(t *testing.T) { // Verify that FirstPartPath is set correctly for different formats tests := []struct { filename string expectedSuffix string // Expected suffix of the FirstPartPath }{ {"archive.7z.001", "archive.7z.001"}, {"archive.7z.002", "archive.7z.001"}, {"archive.7z.005", "archive.7z.001"}, {"archive.part01.rar", "archive.part01.rar"}, {"archive.part02.rar", "archive.part01.rar"}, {"archive.zip.001", "archive.zip.001"}, {"archive.zip.003", "archive.zip.001"}, {"archive.z01", "archive.z01"}, {"archive.z05", "archive.z01"}, } for _, tt := range tests { t.Run(tt.filename, func(t *testing.T) { info := getArchivePartInfo(tt.filename) if !info.IsMultiPart { t.Fatalf("Expected multi-part archive for %q", tt.filename) } if info.FirstPartPath == "" { t.Errorf("FirstPartPath should not be empty for %q", tt.filename) } if !strings.HasSuffix(info.FirstPartPath, tt.expectedSuffix) { t.Errorf("FirstPartPath for %q = %q, expected suffix %q", tt.filename, info.FirstPartPath, tt.expectedSuffix) } }) } } // Tests for multiPartFileReader func TestMultiPartFileReader_Basic(t *testing.T) { tempDir, err := os.MkdirTemp("", "multipart_reader_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create test parts with known content part1Content := []byte("Hello") part2Content := []byte("World") part3Content := []byte("!") part1Path := filepath.Join(tempDir, "test.001") part2Path := filepath.Join(tempDir, "test.002") part3Path := filepath.Join(tempDir, "test.003") if err := os.WriteFile(part1Path, part1Content, 0644); err != nil { t.Fatal(err) } if err := os.WriteFile(part2Path, part2Content, 0644); err != nil { t.Fatal(err) } if err := os.WriteFile(part3Path, part3Content, 0644); err != nil { t.Fatal(err) } reader := newMultiPartFileReader([]string{part1Path, part2Path, part3Path}) defer reader.Close() // Test Size() expectedSize := int64(len(part1Content) + len(part2Content) + len(part3Content)) if reader.Size() != expectedSize { t.Errorf("Size() = %d, expected %d", reader.Size(), expectedSize) } } func TestMultiPartFileReader_ReadAt(t *testing.T) { tempDir, err := os.MkdirTemp("", "multipart_readat_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create test parts with known content part1Path := filepath.Join(tempDir, "test.001") part2Path := filepath.Join(tempDir, "test.002") if err := os.WriteFile(part1Path, []byte("AAAA"), 0644); err != nil { t.Fatal(err) } if err := os.WriteFile(part2Path, []byte("BBBB"), 0644); err != nil { t.Fatal(err) } reader := newMultiPartFileReader([]string{part1Path, part2Path}) defer reader.Close() // Test reading from first part buf := make([]byte, 2) n, err := reader.ReadAt(buf, 0) if err != nil { t.Errorf("ReadAt(0) error: %v", err) } if n != 2 || string(buf) != "AA" { t.Errorf("ReadAt(0) = %q, expected %q", string(buf[:n]), "AA") } // Test reading across parts buf = make([]byte, 4) n, err = reader.ReadAt(buf, 2) if err != nil { t.Errorf("ReadAt(2) error: %v", err) } if n != 4 || string(buf) != "AABB" { t.Errorf("ReadAt(2) = %q, expected %q", string(buf[:n]), "AABB") } // Test reading from second part only buf = make([]byte, 2) n, err = reader.ReadAt(buf, 6) if err != nil { t.Errorf("ReadAt(6) error: %v", err) } if n != 2 || string(buf) != "BB" { t.Errorf("ReadAt(6) = %q, expected %q", string(buf[:n]), "BB") } } func TestMultiPartFileReader_ReadAtEOF(t *testing.T) { tempDir, err := os.MkdirTemp("", "multipart_eof_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) part1Path := filepath.Join(tempDir, "test.001") if err := os.WriteFile(part1Path, []byte("ABC"), 0644); err != nil { t.Fatal(err) } reader := newMultiPartFileReader([]string{part1Path}) defer reader.Close() // Reading beyond EOF should return io.EOF buf := make([]byte, 10) n, err := reader.ReadAt(buf, 100) if err != io.EOF { t.Errorf("ReadAt beyond EOF: expected io.EOF, got %v", err) } if n != 0 { t.Errorf("ReadAt beyond EOF: expected 0 bytes, got %d", n) } } func TestMultiPartFileReader_Close(t *testing.T) { tempDir, err := os.MkdirTemp("", "multipart_close_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) part1Path := filepath.Join(tempDir, "test.001") if err := os.WriteFile(part1Path, []byte("test"), 0644); err != nil { t.Fatal(err) } reader := newMultiPartFileReader([]string{part1Path}) // Initialize by calling Size _ = reader.Size() // Close should work err = reader.Close() if err != nil { t.Errorf("Close() error: %v", err) } // After close, files should be nil if reader.files != nil { t.Error("files should be nil after Close()") } } func TestMultiPartFileReader_InitError(t *testing.T) { // Test with non-existent file reader := newMultiPartFileReader([]string{"/nonexistent/path/file.001"}) defer reader.Close() // Size should return 0 on error size := reader.Size() if size != 0 { t.Errorf("Size() with non-existent file should return 0, got %d", size) } // ReadAt should return error buf := make([]byte, 10) _, err := reader.ReadAt(buf, 0) if err == nil { t.Error("ReadAt with non-existent file should return error") } } // Tests for findZipMultiParts func TestFindZipMultiParts(t *testing.T) { tempDir, err := os.MkdirTemp("", "find_zip_parts_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create test parts for i := 1; i <= 5; i++ { partPath := filepath.Join(tempDir, fmt.Sprintf("archive.zip.%03d", i)) if err := os.WriteFile(partPath, []byte("test"), 0644); err != nil { t.Fatal(err) } } firstPartPath := filepath.Join(tempDir, "archive.zip.001") parts, err := findZipMultiParts(firstPartPath) if err != nil { t.Fatalf("findZipMultiParts error: %v", err) } if len(parts) != 5 { t.Errorf("Expected 5 parts, got %d", len(parts)) } // Verify order for i, part := range parts { expected := filepath.Join(tempDir, fmt.Sprintf("archive.zip.%03d", i+1)) if part != expected { t.Errorf("Part %d: expected %q, got %q", i, expected, part) } } } func TestFindZipMultiParts_NoParts(t *testing.T) { tempDir, err := os.MkdirTemp("", "find_zip_no_parts_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Don't create any parts firstPartPath := filepath.Join(tempDir, "archive.zip.001") _, err = findZipMultiParts(firstPartPath) if err == nil { t.Error("Expected error when no parts found") } } func TestFindZipMultiParts_SinglePart(t *testing.T) { tempDir, err := os.MkdirTemp("", "find_zip_single_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create only the first part firstPartPath := filepath.Join(tempDir, "archive.zip.001") if err := os.WriteFile(firstPartPath, []byte("test"), 0644); err != nil { t.Fatal(err) } parts, err := findZipMultiParts(firstPartPath) if err != nil { t.Fatalf("findZipMultiParts error: %v", err) } if len(parts) != 1 { t.Errorf("Expected 1 part, got %d", len(parts)) } } // Tests for extractMultiPartArchive error paths func TestExtractMultiPartArchive_NonExistentFile(t *testing.T) { tempDir, err := os.MkdirTemp("", "extract_multipart_err_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) destDir := filepath.Join(tempDir, "extracted") // Test with non-existent 7z multi-part err = extractMultiPartArchive("/nonexistent/archive.7z.001", destDir, "", nil) if err == nil { t.Error("Expected error for non-existent 7z file") } // Test with non-existent zip multi-part err = extractMultiPartArchive("/nonexistent/archive.zip.001", destDir, "", nil) if err == nil { t.Error("Expected error for non-existent zip file") } } // Test extractZipMultiPart with invalid archive func TestExtractZipMultiPart_InvalidArchive(t *testing.T) { tempDir, err := os.MkdirTemp("", "extract_zip_invalid_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create invalid "zip" parts (just text files) part1Path := filepath.Join(tempDir, "invalid.zip.001") if err := os.WriteFile(part1Path, []byte("not a zip file"), 0644); err != nil { t.Fatal(err) } destDir := filepath.Join(tempDir, "extracted") err = extractZipMultiPart(part1Path, destDir, "", nil) // Should return an error because the file is not a valid zip if err == nil { t.Error("Expected error for invalid zip archive") } } // Test extractRarMultiPart with non-existent file func TestExtractRarMultiPart_NonExistent(t *testing.T) { tempDir, err := os.MkdirTemp("", "extract_rar_err_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) destDir := filepath.Join(tempDir, "extracted") err = extractRarMultiPart("/nonexistent/archive.part01.rar", destDir, "", nil) if err == nil { t.Error("Expected error for non-existent RAR file") } } // Test extractSevenZipMultiPart with non-existent file func TestExtractSevenZipMultiPart_NonExistent(t *testing.T) { tempDir, err := os.MkdirTemp("", "extract_7z_err_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) destDir := filepath.Join(tempDir, "extracted") err = extractSevenZipMultiPart("/nonexistent/archive.7z.001", destDir, "", nil) if err == nil { t.Error("Expected error for non-existent 7z file") } } // Test extractSevenZipMultiPart with invalid archive func TestExtractSevenZipMultiPart_Invalid(t *testing.T) { tempDir, err := os.MkdirTemp("", "extract_7z_invalid_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create an invalid 7z file part1Path := filepath.Join(tempDir, "invalid.7z.001") if err := os.WriteFile(part1Path, []byte("not a 7z file"), 0644); err != nil { t.Fatal(err) } destDir := filepath.Join(tempDir, "extracted") err = extractSevenZipMultiPart(part1Path, destDir, "", nil) if err == nil { t.Error("Expected error for invalid 7z archive") } } // Test determineFirstPartPath with all patterns func TestDetermineFirstPartPath(t *testing.T) { tests := []struct { name string dir string baseName string pattern string expected string }{ { name: "7z pattern", dir: "/path/to", baseName: "archive.7z", pattern: `(?i)^(.+\.7z)\.(\d{3})$`, expected: "/path/to/archive.7z.001", }, { name: "RAR new style pattern", dir: "/path/to", baseName: "archive", pattern: `(?i)^(.+)\.part(\d+)\.rar$`, expected: "/path/to/archive.part01.rar", }, { name: "RAR old style pattern", dir: "/path/to", baseName: "archive", pattern: `(?i)^(.+)\.r(\d{2})$`, expected: "/path/to/archive.rar", }, { name: "ZIP multi-part pattern", dir: "/path/to", baseName: "archive.zip", pattern: `(?i)^(.+\.zip)\.(\d{3})$`, expected: "/path/to/archive.zip.001", }, { name: "ZIP split pattern", dir: "/path/to", baseName: "archive", pattern: `(?i)^(.+)\.z(\d{2})$`, expected: "/path/to/archive.z01", }, { name: "Unknown pattern", dir: "/path/to", baseName: "archive", pattern: "unknown", expected: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { dir := filepath.FromSlash(tt.dir) expected := tt.expected if expected != "" { expected = filepath.FromSlash(expected) } result := determineFirstPartPath(dir, tt.baseName, tt.pattern) if result != expected { t.Errorf("determineFirstPartPath(%q, %q, %q) = %q, expected %q", dir, tt.baseName, tt.pattern, result, expected) } }) } } // Test parsePartNumber func TestParsePartNumber(t *testing.T) { tests := []struct { input string expected int }{ {"001", 1}, {"01", 1}, {"1", 1}, {"00", 1}, // 00 is treated as 1 {"10", 10}, {"99", 99}, {"100", 100}, } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { var result int _, err := parsePartNumber(tt.input, &result) if err != nil { t.Errorf("parsePartNumber(%q) error: %v", tt.input, err) } if result != tt.expected { t.Errorf("parsePartNumber(%q) = %d, expected %d", tt.input, result, tt.expected) } }) } } // Test multiPartArchivePatterns directly func TestMultiPartArchivePatterns(t *testing.T) { // Verify patterns are valid and match expected formats testCases := []struct { filename string matches bool }{ // 7z {"archive.7z.001", true}, {"archive.7z.999", true}, {"Archive.7Z.001", true}, {"archive.7z.01", false}, // Only 3 digits {"archive.7z.0001", false}, // 4 digits not matched // RAR new style {"archive.part01.rar", true}, {"archive.part1.rar", true}, {"archive.part999.rar", true}, {"archive.PART01.RAR", true}, // RAR old style {"archive.r00", true}, {"archive.r01", true}, {"archive.r99", true}, {"archive.R00", true}, // ZIP multi-part {"archive.zip.001", true}, {"archive.zip.999", true}, {"Archive.ZIP.001", true}, // ZIP split {"archive.z01", true}, {"archive.z99", true}, {"Archive.Z01", true}, } for _, tc := range testCases { t.Run(tc.filename, func(t *testing.T) { matched := false for _, pattern := range multiPartArchivePatterns { if pattern.MatchString(tc.filename) { matched = true break } } if matched != tc.matches { t.Errorf("Pattern match for %q: got %v, expected %v", tc.filename, matched, tc.matches) } }) } } // Test extractRarMultiPart - test that destDir creation works func TestExtractRarMultiPart_DestDirCreation(t *testing.T) { tempDir, err := os.MkdirTemp("", "extract_rar_destdir_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create a simple RAR-like file (will fail extraction but destDir should be created) rarPath := filepath.Join(tempDir, "test.part01.rar") if err := os.WriteFile(rarPath, []byte("Rar!\x1a\x07\x00"), 0644); err != nil { t.Fatal(err) } destDir := filepath.Join(tempDir, "level1", "level2", "extracted") // We expect an error since the file is not a complete valid RAR _ = extractRarMultiPart(rarPath, destDir, "", nil) // The destDir should have been created before the extraction error if _, err := os.Stat(destDir); os.IsNotExist(err) { t.Log("Note: destDir was not created, extraction failed early") } } // Test the old-style RAR detection with .rar + .r00 files func TestGetArchivePartInfo_RarOldStyleWithRar(t *testing.T) { tempDir, err := os.MkdirTemp("", "rar_old_style_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create a .rar file and .r00 file to simulate old-style multi-part rarPath := filepath.Join(tempDir, "archive.rar") r00Path := filepath.Join(tempDir, "archive.r00") if err := os.WriteFile(rarPath, []byte("fake rar"), 0644); err != nil { t.Fatal(err) } if err := os.WriteFile(r00Path, []byte("fake r00"), 0644); err != nil { t.Fatal(err) } // Test that .rar file is detected as first part of old-style multi-part info := getArchivePartInfo(rarPath) if !info.IsMultiPart { t.Error("Expected .rar with .r00 to be detected as multi-part") } if info.Pattern != "rar-old-style" { t.Errorf("Expected pattern 'rar-old-style', got %q", info.Pattern) } if info.FirstPartPath != rarPath { t.Errorf("Expected FirstPartPath to be %q, got %q", rarPath, info.FirstPartPath) } } // Test extractSevenZipFile function indirectly through mock func TestExtractSevenZipMultiPart_DestDirCreation(t *testing.T) { tempDir, err := os.MkdirTemp("", "7z_destdir_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create an invalid 7z file - the test is for error handling, not extraction part1Path := filepath.Join(tempDir, "test.7z.001") if err := os.WriteFile(part1Path, []byte("invalid"), 0644); err != nil { t.Fatal(err) } // Use a nested dest directory that doesn't exist destDir := filepath.Join(tempDir, "level1", "level2", "extracted") err = extractSevenZipMultiPart(part1Path, destDir, "", nil) // Error is expected because the file is invalid, but the dest directory should be created // Actually, error happens before directory creation in this case if err == nil { t.Error("Expected error for invalid 7z") } } // Test multiPartFileReader with multiple files spanning reads func TestMultiPartFileReader_SpanningRead(t *testing.T) { tempDir, err := os.MkdirTemp("", "multipart_spanning_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create 3 parts with known sizes parts := []string{ filepath.Join(tempDir, "test.001"), filepath.Join(tempDir, "test.002"), filepath.Join(tempDir, "test.003"), } contents := []string{"12345", "67890", "ABCDE"} for i, content := range contents { if err := os.WriteFile(parts[i], []byte(content), 0644); err != nil { t.Fatal(err) } } reader := newMultiPartFileReader(parts) defer reader.Close() // Read all content at once buf := make([]byte, 15) n, err := reader.ReadAt(buf, 0) if err != nil { t.Errorf("ReadAt error: %v", err) } if n != 15 { t.Errorf("Expected to read 15 bytes, got %d", n) } if string(buf) != "1234567890ABCDE" { t.Errorf("Expected '1234567890ABCDE', got %q", string(buf)) } } // Test extractZipMultiPart with destDir creation func TestExtractZipMultiPart_DestDirCreation(t *testing.T) { tempDir, err := os.MkdirTemp("", "zip_multipart_destdir_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create a valid single-part "multi-part" zip (just one .001 file with valid zip content) // First create a valid zip zipPath := filepath.Join(tempDir, "temp.zip") if err := createTestZip(zipPath); err != nil { t.Fatal(err) } // Read the zip content and write as .001 zipContent, err := os.ReadFile(zipPath) if err != nil { t.Fatal(err) } part1Path := filepath.Join(tempDir, "archive.zip.001") if err := os.WriteFile(part1Path, zipContent, 0644); err != nil { t.Fatal(err) } // Extract to nested directory destDir := filepath.Join(tempDir, "level1", "level2", "extracted") err = extractZipMultiPart(part1Path, destDir, "", nil) if err != nil { t.Fatalf("extractZipMultiPart error: %v", err) } // Verify destDir was created and files extracted if _, err := os.Stat(destDir); os.IsNotExist(err) { t.Error("destDir was not created") } // Verify extracted file extractedFile := filepath.Join(destDir, "test.txt") if _, err := os.Stat(extractedFile); os.IsNotExist(err) { t.Error("Expected file not found after extraction") } } // Test extractZipMultiPart with progress callback func TestExtractZipMultiPart_Progress(t *testing.T) { tempDir, err := os.MkdirTemp("", "zip_multipart_progress_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create a valid zip with multiple files zipPath := filepath.Join(tempDir, "temp.zip") if err := createTestZipWithMultipleFiles(zipPath, 4); err != nil { t.Fatal(err) } zipContent, err := os.ReadFile(zipPath) if err != nil { t.Fatal(err) } part1Path := filepath.Join(tempDir, "archive.zip.001") if err := os.WriteFile(part1Path, zipContent, 0644); err != nil { t.Fatal(err) } destDir := filepath.Join(tempDir, "extracted") var progressCalls int err = extractZipMultiPart(part1Path, destDir, "", func(extracted int, total int, progress int) { progressCalls++ }) if err != nil { t.Fatalf("extractZipMultiPart error: %v", err) } // Should have progress calls if progressCalls == 0 { t.Error("Expected progress callbacks") } } // Test extracting ZIP files with Chinese filenames encoded in GBK/GB18030 func TestExtractArchive_ChineseFilenames(t *testing.T) { tempDir, err := os.MkdirTemp("", "extract_chinese_test") if err != nil { t.Fatal(err) } defer os.RemoveAll(tempDir) // Create a test ZIP file with Chinese filenames encoded in GBK zipPath := filepath.Join(tempDir, "chinese.zip") destDir := filepath.Join(tempDir, "extracted") if err := createTestZipWithChineseFilenames(zipPath); err != nil { t.Fatal(err) } // Extract the archive err = extractArchive(zipPath, destDir, "", nil) if err != nil { t.Fatalf("extractArchive failed: %v", err) } // Verify the extracted files with proper Chinese filenames expectedFiles := []string{ filepath.Join(destDir, "测试文件.txt"), filepath.Join(destDir, "文件夹", "中文内容.txt"), } for _, path := range expectedFiles { if _, err := os.Stat(path); os.IsNotExist(err) { t.Errorf("expected file %q not found after extraction", path) } } // Verify content of the Chinese file content, err := os.ReadFile(filepath.Join(destDir, "测试文件.txt")) if err != nil { t.Fatal(err) } if string(content) != "这是测试内容" { t.Errorf("unexpected content: %q", string(content)) } } ================================================ FILE: pkg/download/extract_zip.go ================================================ 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 } ================================================ FILE: pkg/download/model.go ================================================ 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" "github.com/GopeedLab/gopeed/internal/protocol/ed2k" "github.com/GopeedLab/gopeed/internal/protocol/http" "github.com/GopeedLab/gopeed/pkg/base" "github.com/GopeedLab/gopeed/pkg/util" gonanoid "github.com/matoous/go-nanoid/v2" ) type ResolveResult struct { ID string `json:"id"` Res *base.Resource `json:"res"` } 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"` IsCreated bool `json:"isCreated"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` fetcherManager fetcher.FetcherManager fetcher fetcher.Fetcher timer *util.Timer statusLock *sync.Mutex lock *sync.Mutex speedArr []int64 uploadSpeedArr []int64 } func NewTask() *Task { id, err := gonanoid.New() if err != nil { panic(err) } return &Task{ ID: id, Status: base.DownloadStatusReady, CreatedAt: time.Now(), UpdatedAt: time.Now(), IsCreated: false, } } // 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 util.DeepClone(t) } 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 { if usedTime <= 0 { return 0 } if downloaded < 0 { *speedArr = (*speedArr)[:0] return 0 } *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 *speedArr { total += v } 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 FetchManagers []fetcher.FetcherManager RefreshInterval int // RefreshInterval time duration to refresh task progress(ms) Storage Storage StorageDir string WhiteDownloadDirs []string ProductionMode bool *base.DownloaderStoreConfig } func (cfg *DownloaderConfig) Init() *DownloaderConfig { if cfg.Controller == nil { cfg.Controller = controller.NewController() } if len(cfg.FetchManagers) == 0 { cfg.FetchManagers = []fetcher.FetcherManager{ new(http.FetcherManager), new(bt.FetcherManager), new(ed2k.FetcherManager), } } if cfg.RefreshInterval == 0 { cfg.RefreshInterval = 350 } if cfg.Storage == nil { cfg.Storage = NewMemStorage() } return cfg } ================================================ FILE: pkg/download/model_test.go ================================================ package download import "testing" func TestCalcSpeedResetOnRollback(t *testing.T) { speedArr := []int64{1024, 2048, 4096} if got := calcSpeed(&speedArr, -512, 1); got != 0 { t.Fatalf("calcSpeed() = %d, want 0 after rollback", got) } if len(speedArr) != 0 { t.Fatalf("speed window len = %d, want 0 after rollback", len(speedArr)) } if got := calcSpeed(&speedArr, 1024, 1); got != 1024 { t.Fatalf("calcSpeed() = %d, want 1024 after reset", got) } } ================================================ FILE: pkg/download/script.go ================================================ 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) } } ================================================ FILE: pkg/download/script_test.go ================================================ 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) } ================================================ FILE: pkg/download/script_unix_test.go ================================================ //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) } }) } ================================================ FILE: pkg/download/script_windows_test.go ================================================ //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) } }) } ================================================ FILE: pkg/download/storage.go ================================================ package download import ( "encoding/json" "os" "path/filepath" "reflect" "sync" "go.etcd.io/bbolt" ) type Storage interface { Setup(buckets []string) error Put(bucket string, key string, v any) error Get(bucket string, key string, v any) (bool, error) List(bucket string, v any) error Pop(bucket string, key string, v any) error Delete(bucket string, key string) error Close() error Clear() error } func changeValue(p any, v any) { if v == nil { return } rp := reflect.ValueOf(p) rv := reflect.ValueOf(v) if rv.Kind() == reflect.Slice { if rv.Len() == 0 { return } // get underlying type tp := reflect.TypeOf(p).Elem().Elem() for i := 0; i < rv.Len(); i++ { // convert to underlying type vv := rv.Index(i).Elem().Convert(tp) rp.Elem().Set(reflect.Append(rp.Elem(), vv)) } } else if rv.Kind() == reflect.Ptr { rp.Elem().Set(rv.Elem()) } else { rp.Elem().Set(rv) } } 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 := n.data[bucket]; !ok { n.data[bucket] = make(map[string]any) } } return nil } func (n *MemStorage) Put(bucket string, key string, v any) error { n.lock.Lock() defer n.lock.Unlock() 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 := n.data[bucket][key]; ok { changeValue(v, dv) return true, nil } return false, nil } func (n *MemStorage) List(bucket string, v any) error { n.lock.RLock() defer n.lock.RUnlock() data := n.data[bucket] list := make([]any, 0) for _, v := range data { list = append(list, v) } changeValue(v, list) return nil } func (n *MemStorage) Pop(bucket string, key string, v any) error { n.lock.Lock() defer n.lock.Unlock() data := n.data[bucket] changeValue(v, data[key]) delete(data, key) return nil } func (n *MemStorage) Delete(bucket string, key string) error { n.lock.Lock() defer n.lock.Unlock() delete(n.data[bucket], key) return nil } func (n *MemStorage) Close() error { return nil } func (n *MemStorage) Clear() error { n.lock.Lock() defer n.lock.Unlock() n.data = make(map[string]map[string]any) return nil } const ( dbFile = "gopeed.db" ) type BoltStorage struct { db *bbolt.DB path string } func NewBoltStorage(dir string) *BoltStorage { if err := os.MkdirAll(dir, 0755); err != nil { panic(err) } path := filepath.Join(dir, dbFile) db, err := bbolt.Open(path, 0600, nil) if err != nil { panic(err) } return &BoltStorage{ db: db, path: path, } } func (b *BoltStorage) Setup(buckets []string) error { return b.db.Update(func(tx *bbolt.Tx) error { for _, bucket := range buckets { _, err := tx.CreateBucketIfNotExists([]byte(bucket)) if err != nil { return err } } return nil }) } func (b *BoltStorage) Put(bucket string, key string, v any) error { buf, err := json.Marshal(v) if err != nil { return err } return b.db.Update(func(tx *bbolt.Tx) error { b := tx.Bucket([]byte(bucket)) return b.Put([]byte(key), buf) }) } func (b *BoltStorage) Get(bucket string, key string, v any) (bool, error) { var data []byte err := b.db.View(func(tx *bbolt.Tx) error { b := tx.Bucket([]byte(bucket)) data = b.Get([]byte(key)) return nil }) if err != nil { return false, err } if data == nil { return false, nil } if err := json.Unmarshal(data, v); err != nil { return false, err } return true, nil } func (b *BoltStorage) List(bucket string, v any) error { list := make([]any, 0) tv := reflect.TypeOf(v).Elem().Elem() if err := b.db.View(func(tx *bbolt.Tx) error { b := tx.Bucket([]byte(bucket)) if err := b.ForEach(func(k, v []byte) error { data := reflect.New(tv.Elem()).Interface() if err := json.Unmarshal(v, &data); err != nil { return err } list = append(list, data) return nil }); err != nil { return err } return nil }); err != nil { return err } changeValue(v, list) return nil } func (b *BoltStorage) Pop(bucket string, key string, v any) error { var data []byte err := b.db.Update(func(tx *bbolt.Tx) error { b := tx.Bucket([]byte(bucket)) kb := []byte(key) data = b.Get(kb) return b.Delete(kb) }) if err != nil { return err } if len(data) == 0 { return nil } return json.Unmarshal(data, v) } func (b *BoltStorage) Delete(bucket string, key string) error { return b.db.Update(func(tx *bbolt.Tx) error { b := tx.Bucket([]byte(bucket)) return b.Delete([]byte(key)) }) } func (b *BoltStorage) Close() error { return b.db.Close() } func (b *BoltStorage) Clear() error { if err := b.Close(); err != nil { return err } if err := os.Remove(b.path); err != nil { return err } return nil } ================================================ FILE: pkg/download/testdata/extensions/basic/index.js ================================================ 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, labels:{ "from": gopeed.info.name, } } }), ), }; }); ================================================ FILE: pkg/download/testdata/extensions/basic/manifest.json ================================================ { "name": "basic", "title": "gopeed extension basic test", "version": "0.0.1", "scripts": [ { "event": "onResolve", "match": { "urls": [ "*://github.com/*" ] }, "entry": "index.js" } ], "settings": [ { "name": "ua", "title": "User-Agent", "type": "string" } ] } ================================================ FILE: pkg/download/testdata/extensions/extra/index.js ================================================ 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, }, } } }), ), }; }); ================================================ FILE: pkg/download/testdata/extensions/extra/manifest.json ================================================ { "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" } ] } ================================================ FILE: pkg/download/testdata/extensions/function_error/index.js ================================================ gopeed.events.onResolve(async function (ctx) { const aaa = {}; // access undefined property gopeed.logger.info(aaa.bbb.ccc); ctx.res = { name: "test", files: Array(2).fill(true).map((_, i) => ({ name: `test-${i}.txt`, size: 1024, req: { url: ctx.req.url + "/" + i, } }), ), }; }); ================================================ FILE: pkg/download/testdata/extensions/function_error/manifest.json ================================================ { "name": "function-error", "title": "gopeed extension function error test", "version": "0.0.1", "scripts": [ { "event": "onResolve", "match": { "urls": [ "*://github.com/*" ] }, "entry": "index.js" } ], "settings": [ { "name": "ua", "title": "User-Agent", "type": "string" } ] } ================================================ FILE: pkg/download/testdata/extensions/message_error/index.js ================================================ gopeed.events.onResolve(async function (ctx) { throw new MessageError("test"); }); ================================================ FILE: pkg/download/testdata/extensions/message_error/manifest.json ================================================ { "name": "message-error", "title": "gopeed extension message error test", "version": "0.0.1", "scripts": [ { "event": "onResolve", "match": { "urls": [ "*://github.com/*" ] }, "entry": "index.js" } ] } ================================================ FILE: pkg/download/testdata/extensions/on_done/index.js ================================================ gopeed.events.onDone(async function (ctx) { gopeed.logger.info("url", ctx.task.meta.req.url); ctx.task.meta.req.labels['modified'] = 'true'; }); ================================================ FILE: pkg/download/testdata/extensions/on_done/manifest.json ================================================ { "name": "on-done", "title": "gopeed extension on done event test", "version": "0.0.1", "scripts": [ { "event": "onDone", "match": { "urls": [ "*://github.com/*" ] }, "entry": "index.js" } ] } ================================================ FILE: pkg/download/testdata/extensions/on_error/index.js ================================================ gopeed.events.onError(async function (ctx) { gopeed.logger.info("url", ctx.task.meta.req.url); gopeed.logger.info("error", ctx.error); ctx.task.meta.req.url = "https://github.com"; ctx.task.continue(); }); ================================================ FILE: pkg/download/testdata/extensions/on_error/manifest.json ================================================ { "name": "on-error", "title": "gopeed extension on error event test", "version": "0.0.1", "scripts": [ { "event": "onError", "match": { "labels": [ "test" ] }, "entry": "index.js" } ] } ================================================ FILE: pkg/download/testdata/extensions/on_start/index.js ================================================ gopeed.events.onStart(async function (ctx) { gopeed.logger.info("url", ctx.task.meta.req.url); ctx.task.meta.req.url = "https://github.com"; ctx.task.meta.req.labels['modified'] = 'true'; }); ================================================ FILE: pkg/download/testdata/extensions/on_start/manifest.json ================================================ { "name": "on-start", "title": "gopeed extension on start event test", "version": "0.0.1", "scripts": [ { "event": "onStart", "match": { "urls": [ "*://github.com/*" ], "labels": [ "test" ] }, "entry": "index.js" } ] } ================================================ FILE: pkg/download/testdata/extensions/script_error/index.js ================================================ const aaa = {}; gopeed.logger.info(aaa.bbb.ccc); 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, } }), ), }; }); ================================================ FILE: pkg/download/testdata/extensions/script_error/manifest.json ================================================ { "name": "script-error", "title": "gopeed extension script error test", "version": "0.0.1", "scripts": [ { "event": "onResolve", "match": { "urls": [ "*://github.com/*" ] }, "entry": "index.js" } ], "settings": [ { "name": "ua", "title": "User-Agent", "type": "string" } ] } ================================================ FILE: pkg/download/testdata/extensions/settings_all/index.js ================================================ gopeed.events.onResolve(async function (ctx) { if (gopeed.settings.string != null) { throw new Error("string is not null"); } if (gopeed.settings.number != null) { throw new Error("number is not null"); } if (gopeed.settings.boolean != null) { throw new Error("boolean is not null"); } if (gopeed.settings.stringDefault !== "default") { throw new Error("string default value is incorrect"); } if (gopeed.settings.numberDefault !== 1) { throw new Error("number default value is incorrect"); } if (gopeed.settings.booleanDefault !== true) { throw new Error("boolean default value is incorrect"); } if (gopeed.settings.stringValued !== "valued") { throw new Error("string value is incorrect"); } if (gopeed.settings.numberValued !== 1.1) { throw new Error("number value is incorrect"); } if (gopeed.settings.booleanValued !== true) { throw new Error("boolean value is incorrect"); } ctx.res = { name: "test", files: Array(2).fill(true).map((_, i) => ({ name: `test-${i}.txt`, size: 1024, req: { url: ctx.req.url + "/" + i, } }), ), }; }); ================================================ FILE: pkg/download/testdata/extensions/settings_all/manifest.json ================================================ { "name": "settings-all", "title": "gopeed extension settings all type test", "version": "0.0.1", "scripts": [ { "event": "onResolve", "match": { "urls": [ "*://*/*" ] }, "entry": "index.js" } ], "settings": [ { "name": "string", "title": "string null test", "type": "string" }, { "name": "number", "title": "number null test", "type": "number" }, { "name": "boolean", "title": "boolean null test", "type": "boolean" }, { "name": "stringDefault", "title": "string default test", "type": "string", "value": "default" }, { "name": "numberDefault", "title": "number default test", "type": "number", "value": 1 }, { "name": "booleanDefault", "title": "boolean default test", "type": "boolean", "value": true }, { "name": "stringValued", "title": "string valued test", "type": "string" }, { "name": "numberValued", "title": "number valued test", "type": "number" }, { "name": "booleanValued", "title": "boolean valued test", "type": "boolean" } ] } ================================================ FILE: pkg/download/testdata/extensions/settings_empty/index.js ================================================ gopeed.events.onResolve(async function (ctx) { if (Object.keys(gopeed.settings).length > 0){ throw new Error("settings is not empty"); } ctx.res = { name: "test", files: Array(2).fill(true).map((_, i) => ({ name: `test-${i}.txt`, size: 1024, req: { url: ctx.req.url + "/" + i, } }), ), }; }); ================================================ FILE: pkg/download/testdata/extensions/settings_empty/manifest.json ================================================ { "name": "settings-empty", "title": "gopeed extension settings empty test", "version": "0.0.1", "scripts": [ { "event": "onResolve", "match": { "urls": [ "*://*/*" ] }, "entry": "index.js" } ] } ================================================ FILE: pkg/download/testdata/extensions/storage/index.js ================================================ gopeed.events.onResolve(async function (ctx) { const key = "key" const value1 = "value1", value2 = JSON.stringify({a: 1, b: "2"}) if (gopeed.storage.get(key) !== null) { throw new Error("storage get null error") } gopeed.storage.remove(key) if(gopeed.storage.keys().length !== 0) { throw new Error("storage keys null error") } gopeed.storage.set(key, value1) if (gopeed.storage.get(key) !== value1) { throw new Error("storage put1 error") } gopeed.storage.set(key, value2) if (gopeed.storage.get(key) !== value2) { throw new Error("storage put2 error") } if(gopeed.storage.keys().length !== 1) { throw new Error("storage keys error") } gopeed.storage.remove(key) if (gopeed.storage.get(key) !== null) { throw new Error("storage delete error") } gopeed.storage.set(key, value1) gopeed.storage.clear() if (gopeed.storage.get(key) !== null) { throw new Error("storage clear error") } ctx.res = { name: "test", files: Array(2).fill(true).map((_, i) => ({ name: `test-${i}.txt`, size: 1024, req: { url: ctx.req.url + "/" + i, } }), ), }; }); ================================================ FILE: pkg/download/testdata/extensions/storage/manifest.json ================================================ { "name": "storage", "title": "gopeed extension storage test", "version": "0.0.1", "scripts": [ { "event": "onResolve", "match": { "urls": [ "*://*/*" ] }, "entry": "index.js" } ], "settings": [ { "name": "ua", "title": "User-Agent", "type": "string" } ] } ================================================ FILE: pkg/download/testdata/extensions/update/index.js ================================================ gopeed.events.onResolve(async function (ctx) { // do nothing for test }); ================================================ FILE: pkg/download/testdata/extensions/update/manifest.json ================================================ { "name": "extension-test", "author": "gopeed", "title": "Gopeed Extension Test", "description": "Test extension settings and upgrade", "version": "0.0.1", "homepage": "https://gopeed.com", "repository": { "url": "https://github.com/GopeedLab/gopeed-extension-samples", "directory": "extension-test" }, "scripts": [ { "event": "onResolve", "match": { "urls": [ "*://*/*" ] }, "entry": "index.js" } ], "settings": [ { "name": "s1", "title": "S1 old", "description": "Test setting update", "type": "string", "required": true }, { "name": "s2", "title": "s2 number old", "description": "Test setting type update", "type": "number", "required": true, "value": 1 }, { "name": "d1", "title": "Delete test", "description": "Test setting delete", "type": "string", "required": true } ] } ================================================ FILE: pkg/download/testdata/scripts/env_dump.bat ================================================ @echo off setlocal if "%GOPEED_TEST_OUTPUT_FILE%"=="" exit /b 2 echo GOPEED_EVENT=%GOPEED_EVENT% > "%GOPEED_TEST_OUTPUT_FILE%" echo GOPEED_TASK_ID=%GOPEED_TASK_ID% >> "%GOPEED_TEST_OUTPUT_FILE%" echo GOPEED_TASK_NAME=%GOPEED_TASK_NAME% >> "%GOPEED_TEST_OUTPUT_FILE%" echo GOPEED_TASK_STATUS=%GOPEED_TASK_STATUS% >> "%GOPEED_TEST_OUTPUT_FILE%" echo GOPEED_TASK_PATH=%GOPEED_TASK_PATH% >> "%GOPEED_TEST_OUTPUT_FILE%" ================================================ FILE: pkg/download/testdata/scripts/env_dump.sh ================================================ #!/bin/bash set -e if [ -z "$GOPEED_TEST_OUTPUT_FILE" ]; then echo "GOPEED_TEST_OUTPUT_FILE is empty" >&2 exit 2 fi echo "GOPEED_EVENT=$GOPEED_EVENT" > "$GOPEED_TEST_OUTPUT_FILE" echo "GOPEED_TASK_ID=$GOPEED_TASK_ID" >> "$GOPEED_TEST_OUTPUT_FILE" echo "GOPEED_TASK_NAME=$GOPEED_TASK_NAME" >> "$GOPEED_TEST_OUTPUT_FILE" echo "GOPEED_TASK_STATUS=$GOPEED_TASK_STATUS" >> "$GOPEED_TEST_OUTPUT_FILE" echo "GOPEED_TASK_PATH=$GOPEED_TASK_PATH" >> "$GOPEED_TEST_OUTPUT_FILE" ================================================ FILE: pkg/download/testdata/scripts/move.bat ================================================ @echo off setlocal if "%GOPEED_TEST_DEST_DIR%"=="" exit /b 2 set "SRC=%GOPEED_TASK_PATH:/=\%" set "DEST=%GOPEED_TEST_DEST_DIR%" if not exist "%DEST%" mkdir "%DEST%" move /Y "%SRC%" "%DEST%\" >nul ================================================ FILE: pkg/download/testdata/scripts/move.sh ================================================ #!/bin/bash set -e if [ -z "$GOPEED_TEST_DEST_DIR" ]; then echo "GOPEED_TEST_DEST_DIR is empty" >&2 exit 2 fi mkdir -p "$GOPEED_TEST_DEST_DIR" mv "$GOPEED_TASK_PATH" "$GOPEED_TEST_DEST_DIR/" ================================================ FILE: pkg/download/testdata/scripts/write_output1.bat ================================================ @echo off setlocal if "%GOPEED_TEST_OUTPUT_FILE_1%"=="" exit /b 2 echo Script 1 > "%GOPEED_TEST_OUTPUT_FILE_1%" ================================================ FILE: pkg/download/testdata/scripts/write_output1.sh ================================================ #!/bin/bash set -e if [ -z "$GOPEED_TEST_OUTPUT_FILE_1" ]; then echo "GOPEED_TEST_OUTPUT_FILE_1 is empty" >&2 exit 2 fi echo "Script 1" > "$GOPEED_TEST_OUTPUT_FILE_1" ================================================ FILE: pkg/download/testdata/scripts/write_output2.bat ================================================ @echo off setlocal if "%GOPEED_TEST_OUTPUT_FILE_2%"=="" exit /b 2 echo Script 2 > "%GOPEED_TEST_OUTPUT_FILE_2%" ================================================ FILE: pkg/download/testdata/scripts/write_output2.sh ================================================ #!/bin/bash set -e if [ -z "$GOPEED_TEST_OUTPUT_FILE_2" ]; then echo "GOPEED_TEST_OUTPUT_FILE_2 is empty" >&2 exit 2 fi echo "Script 2" > "$GOPEED_TEST_OUTPUT_FILE_2" ================================================ FILE: pkg/download/webhook.go ================================================ package download import ( "bytes" "encoding/json" "fmt" "net/http" "time" "github.com/GopeedLab/gopeed/internal/fetcher" "github.com/GopeedLab/gopeed/pkg/base" ) const ( webhookTimeout = 10 * time.Second ) // WebhookEvent represents the type of webhook event type WebhookEvent string const ( WebhookEventDownloadDone WebhookEvent = "DOWNLOAD_DONE" WebhookEventDownloadError WebhookEvent = "DOWNLOAD_ERROR" ) // WebhookData is the data sent to webhook URLs type WebhookData struct { Event WebhookEvent `json:"event"` Time int64 `json:"time"` // Unix timestamp in milliseconds Payload *WebhookPayload `json:"payload"` } // WebhookPayload contains the task data type WebhookPayload struct { Task *Task `json:"task"` } // getWebhookUrls extracts webhook URLs from config // Supports both new webhook config format and legacy extra field for backward compatibility func (d *Downloader) getWebhookUrls() []string { cfg := d.cfg.DownloaderStoreConfig if cfg == nil { return nil } // Try new webhook config first if cfg.Webhook != nil && cfg.Webhook.Enable && len(cfg.Webhook.URLs) > 0 { urls := make([]string, 0, len(cfg.Webhook.URLs)) for _, url := range cfg.Webhook.URLs { if url != "" { urls = append(urls, url) } } if len(urls) > 0 { return urls } } // Fall back to legacy extra field for backward compatibility if cfg.Extra == nil { return nil } webhookUrls, ok := cfg.Extra["webhookUrls"] if !ok { return nil } // Try direct string slice first if urlsStr, ok := webhookUrls.([]string); ok { if len(urlsStr) == 0 { return nil } return urlsStr } // Convert []interface{} to []string if urlsInterface, ok := webhookUrls.([]any); ok { if len(urlsInterface) == 0 { return nil } urls := make([]string, 0, len(urlsInterface)) for _, urlInterface := range urlsInterface { if url, ok := urlInterface.(string); ok && url != "" { urls = append(urls, url) } } if len(urls) == 0 { return nil } return urls } return nil } // sendWebhookToUrl sends webhook data to a single URL // Returns the HTTP status code and any error that occurred func (d *Downloader) sendWebhookToUrl(url string, data *WebhookData) (int, error) { if url == "" { return 0, fmt.Errorf("webhook URL is empty") } jsonData, err := json.Marshal(data) if err != nil { return 0, err } client := &http.Client{ Timeout: webhookTimeout, } req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonData)) if err != nil { return 0, err } req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", "Gopeed-Webhook/1.0") resp, err := client.Do(req) if err != nil { return 0, err } defer resp.Body.Close() return resp.StatusCode, nil } // triggerWebhooks sends webhook notifications to all configured URLs func (d *Downloader) triggerWebhooks(event WebhookEvent, task *Task, err error) { urls := d.getWebhookUrls() if len(urls) == 0 { return } data := &WebhookData{ Event: event, Time: time.Now().UnixMilli(), Payload: &WebhookPayload{ Task: task.clone(), }, } go d.sendWebhooks(urls, data) } func (d *Downloader) sendWebhooks(urls []string, data *WebhookData) { for _, url := range urls { if url == "" { continue } go func(webhookUrl string) { statusCode, err := d.sendWebhookToUrl(webhookUrl, data) if err != nil { d.Logger.Warn().Err(err).Str("url", webhookUrl).Msg("webhook: failed to send request") return } if statusCode >= 200 && statusCode < 300 { d.Logger.Debug().Str("url", webhookUrl).Int("status", statusCode).Msg("webhook: sent successfully") } else { d.Logger.Warn().Str("url", webhookUrl).Int("status", statusCode).Msg("webhook: received non-success status") } }(url) } } // SendTestWebhook sends a test webhook with a simulated payload // Returns error if any webhook URL does not respond with HTTP 200 func (d *Downloader) SendTestWebhook() error { urls := d.getWebhookUrls() if len(urls) == 0 { return nil } for _, url := range urls { if url == "" { continue } if err := d.TestWebhookUrl(url); err != nil { return err } } return nil } // TestWebhookUrl tests a single webhook URL with a simulated payload // Returns error if the URL does not respond with HTTP 200 func (d *Downloader) TestWebhookUrl(url string) error { // Create a simulated test task with minimal required fields testTask := NewTask() testTask.Protocol = "http" testTask.Status = base.DownloadStatusDone testTask.Meta = &fetcher.FetcherMeta{ Req: &base.Request{ URL: "https://example.com/test-file.zip", }, Opts: &base.Options{ Name: "test-file.zip", Path: "/downloads", }, Res: &base.Resource{ Size: 1024 * 1024 * 100, // 100MB Files: []*base.FileInfo{ {Name: "test-file.zip", Size: 1024 * 1024 * 100}, }, }, } // Create test data testData := &WebhookData{ Event: WebhookEventDownloadDone, Time: time.Now().UnixMilli(), Payload: &WebhookPayload{ Task: testTask, }, } statusCode, err := d.sendWebhookToUrl(url, testData) if err != nil { return err } if statusCode != http.StatusOK { return fmt.Errorf("webhook test failed: %s returned status %d", url, statusCode) } return nil } ================================================ FILE: pkg/download/webhook_test.go ================================================ package download import ( "encoding/json" "net/http" "net/http/httptest" "os" "testing" "time" "github.com/GopeedLab/gopeed/internal/fetcher" "github.com/GopeedLab/gopeed/pkg/base" ) var mockFetcherMeta = fetcher.FetcherMeta{ Req: &base.Request{ URL: "https://example.com/test.zip", }, Opts: &base.Options{ Path: "/downloads", }, Res: &base.Resource{ Size: 1024 * 1024, Files: []*base.FileInfo{ {Name: "test.zip", Size: 1024 * 1024}, }, }, } func TestWebhook_TriggerOnDone(t *testing.T) { receivedData := make(chan *WebhookData, 1) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("Expected POST request, got %s", r.Method) } if r.Header.Get("Content-Type") != "application/json" { t.Errorf("Expected Content-Type application/json, got %s", r.Header.Get("Content-Type")) } var data WebhookData if err := json.NewDecoder(r.Body).Decode(&data); err != nil { t.Errorf("Failed to decode data: %v", err) return } receivedData <- &data w.WriteHeader(http.StatusOK) })) defer server.Close() setupWebhookTest(t, func(downloader *Downloader) { // Configure webhook URLs cfg, _ := downloader.GetConfig() cfg.Webhook = &base.WebhookConfig{ Enable: true, URLs: []string{server.URL}, } downloader.PutConfig(cfg) // Create a mock task task := NewTask() task.Protocol = "http" task.Meta = &mockFetcherMeta // Trigger webhook downloader.triggerWebhooks(WebhookEventDownloadDone, task, nil) select { case data := <-receivedData: if data.Event != WebhookEventDownloadDone { t.Errorf("Expected event 'DOWNLOAD_DONE', got '%s'", data.Event) } if data.Payload == nil || data.Payload.Task == nil { t.Error("Expected payload.task to be present") } else if data.Payload.Task.ID != task.ID { t.Errorf("Expected task ID '%s', got '%s'", task.ID, data.Payload.Task.ID) } if data.Time == 0 { t.Error("Expected time to be set") } case <-time.After(2 * time.Second): t.Error("Timeout waiting for webhook") } }) } func TestWebhook_TriggerOnError(t *testing.T) { receivedData := make(chan *WebhookData, 1) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var data WebhookData if err := json.NewDecoder(r.Body).Decode(&data); err != nil { t.Errorf("Failed to decode data: %v", err) return } receivedData <- &data w.WriteHeader(http.StatusOK) })) defer server.Close() setupWebhookTest(t, func(downloader *Downloader) { // Configure webhook URLs cfg, _ := downloader.GetConfig() cfg.Webhook = &base.WebhookConfig{ Enable: true, URLs: []string{server.URL}, } downloader.PutConfig(cfg) // Create a mock task task := NewTask() task.Protocol = "http" task.Meta = &mockFetcherMeta // Trigger webhook with error testError := http.ErrServerClosed downloader.triggerWebhooks(WebhookEventDownloadError, task, testError) select { case data := <-receivedData: if data.Event != WebhookEventDownloadError { t.Errorf("Expected event 'DOWNLOAD_ERROR', got '%s'", data.Event) } if data.Payload == nil || data.Payload.Task == nil { t.Error("Expected payload.task to be present") } case <-time.After(2 * time.Second): t.Error("Timeout waiting for webhook") } }) } func TestWebhook_SendTestWebhook(t *testing.T) { receivedData := make(chan *WebhookData, 1) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var data WebhookData if err := json.NewDecoder(r.Body).Decode(&data); err != nil { t.Errorf("Failed to decode data: %v", err) return } receivedData <- &data w.WriteHeader(http.StatusOK) })) defer server.Close() setupWebhookTest(t, func(downloader *Downloader) { // Configure webhook URLs cfg, _ := downloader.GetConfig() cfg.Webhook = &base.WebhookConfig{ Enable: true, URLs: []string{server.URL}, } downloader.PutConfig(cfg) // Send test webhook err := downloader.SendTestWebhook() if err != nil { t.Errorf("SendTestWebhook failed: %v", err) } select { case data := <-receivedData: if data.Event != WebhookEventDownloadDone { t.Errorf("Expected event 'DOWNLOAD_DONE', got '%s'", data.Event) } if data.Payload == nil || data.Payload.Task == nil { t.Error("Expected payload.task to be present") } if data.Time == 0 { t.Error("Expected time to be set") } case <-time.After(2 * time.Second): t.Error("Timeout waiting for webhook") } }) } func TestWebhook_NoWebhookConfigured(t *testing.T) { setupWebhookTest(t, func(downloader *Downloader) { // Create a mock task task := NewTask() task.Protocol = "http" task.Meta = &mockFetcherMeta // Trigger webhook (should not panic with no webhooks configured) downloader.triggerWebhooks(WebhookEventDownloadDone, task, nil) // Send test webhook (should not panic) err := downloader.SendTestWebhook() if err != nil { t.Errorf("SendTestWebhook failed: %v", err) } }) } func TestWebhook_MultipleUrls(t *testing.T) { count := 0 server1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { count++ w.WriteHeader(http.StatusOK) })) defer server1.Close() server2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { count++ w.WriteHeader(http.StatusOK) })) defer server2.Close() setupWebhookTest(t, func(downloader *Downloader) { // Configure multiple webhook URLs cfg, _ := downloader.GetConfig() cfg.Webhook = &base.WebhookConfig{ Enable: true, URLs: []string{server1.URL, server2.URL}, } downloader.PutConfig(cfg) // Create a mock task task := NewTask() task.Protocol = "http" task.Meta = &mockFetcherMeta // Trigger webhook downloader.triggerWebhooks(WebhookEventDownloadDone, task, nil) // Wait for webhooks time.Sleep(500 * time.Millisecond) if count != 2 { t.Errorf("Expected 2 webhook calls, got %d", count) } }) } func TestWebhook_TestWebhookFailsOnNon200(t *testing.T) { // Test that SendTestWebhook returns error for non-200 status codes server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) // 500 })) defer server.Close() setupWebhookTest(t, func(downloader *Downloader) { // Configure webhook URLs cfg, _ := downloader.GetConfig() cfg.Webhook = &base.WebhookConfig{ Enable: true, URLs: []string{server.URL}, } downloader.PutConfig(cfg) // Send test webhook - should fail with non-200 status err := downloader.SendTestWebhook() if err == nil { t.Error("Expected SendTestWebhook to return error for non-200 status") } }) } func TestWebhook_TestWebhookFailsOn201(t *testing.T) { // Test that SendTestWebhook returns error for 201 (only 200 is success) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusCreated) // 201 })) defer server.Close() setupWebhookTest(t, func(downloader *Downloader) { // Configure webhook URLs cfg, _ := downloader.GetConfig() cfg.Webhook = &base.WebhookConfig{ Enable: true, URLs: []string{server.URL}, } downloader.PutConfig(cfg) // Send test webhook - should fail with 201 status (only 200 is success) err := downloader.SendTestWebhook() if err == nil { t.Error("Expected SendTestWebhook to return error for 201 status") } }) } func TestWebhook_TestWebhookUrl(t *testing.T) { receivedData := make(chan *WebhookData, 1) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var data WebhookData if err := json.NewDecoder(r.Body).Decode(&data); err != nil { t.Errorf("Failed to decode data: %v", err) return } receivedData <- &data w.WriteHeader(http.StatusOK) })) defer server.Close() setupWebhookTest(t, func(downloader *Downloader) { // Test single URL err := downloader.TestWebhookUrl(server.URL) if err != nil { t.Errorf("TestWebhookUrl failed: %v", err) } select { case data := <-receivedData: if data.Event != WebhookEventDownloadDone { t.Errorf("Expected event 'DOWNLOAD_DONE', got '%s'", data.Event) } if data.Payload == nil || data.Payload.Task == nil { t.Error("Expected payload.task to be present") } case <-time.After(2 * time.Second): t.Error("Timeout waiting for webhook") } }) } func TestWebhook_TestWebhookUrlEmpty(t *testing.T) { setupWebhookTest(t, func(downloader *Downloader) { // Test with empty URL - should return error err := downloader.TestWebhookUrl("") if err == nil { t.Error("Expected TestWebhookUrl to return error for empty URL") } }) } func TestWebhook_GetWebhookUrls_EmptyConfig(t *testing.T) { setupWebhookTest(t, func(downloader *Downloader) { urls := downloader.getWebhookUrls() if urls != nil { t.Errorf("Expected nil, got %v", urls) } }) } func TestWebhook_GetWebhookUrls_NoExtraField(t *testing.T) { setupWebhookTest(t, func(downloader *Downloader) { cfg, _ := downloader.GetConfig() cfg.Webhook = nil downloader.PutConfig(cfg) urls := downloader.getWebhookUrls() if urls != nil { t.Errorf("Expected nil, got %v", urls) } }) } func TestWebhook_GetWebhookUrls_NoWebhookUrlsKey(t *testing.T) { setupWebhookTest(t, func(downloader *Downloader) { cfg, _ := downloader.GetConfig() cfg.Webhook = &base.WebhookConfig{Enable: true} // No URLs set downloader.PutConfig(cfg) urls := downloader.getWebhookUrls() if urls != nil { t.Errorf("Expected nil, got %v", urls) } }) } func TestWebhook_GetWebhookUrls_StringSlice(t *testing.T) { setupWebhookTest(t, func(downloader *Downloader) { cfg, _ := downloader.GetConfig() cfg.Webhook = &base.WebhookConfig{ Enable: true, URLs: []string{"http://example.com", "http://example2.com"}, } downloader.PutConfig(cfg) urls := downloader.getWebhookUrls() if len(urls) != 2 { t.Errorf("Expected 2 URLs, got %d", len(urls)) } if urls[0] != "http://example.com" || urls[1] != "http://example2.com" { t.Errorf("URLs don't match expected values: %v", urls) } }) } func TestWebhook_GetWebhookUrls_InterfaceSlice(t *testing.T) { setupWebhookTest(t, func(downloader *Downloader) { cfg, _ := downloader.GetConfig() cfg.Webhook = &base.WebhookConfig{ Enable: true, URLs: []string{"http://example.com", "http://example2.com"}, } downloader.PutConfig(cfg) urls := downloader.getWebhookUrls() if len(urls) != 2 { t.Errorf("Expected 2 URLs, got %d", len(urls)) } if urls[0] != "http://example.com" || urls[1] != "http://example2.com" { t.Errorf("URLs don't match expected values: %v", urls) } }) } func TestWebhook_GetWebhookUrls_EmptyStringSlice(t *testing.T) { setupWebhookTest(t, func(downloader *Downloader) { cfg, _ := downloader.GetConfig() cfg.Extra = make(map[string]any) cfg.Webhook.URLs = []string{} downloader.PutConfig(cfg) urls := downloader.getWebhookUrls() if urls != nil { t.Errorf("Expected nil, got %v", urls) } }) } func TestWebhook_GetWebhookUrls_InterfaceSliceWithEmptyStrings(t *testing.T) { setupWebhookTest(t, func(downloader *Downloader) { cfg, _ := downloader.GetConfig() cfg.Webhook = &base.WebhookConfig{ Enable: true, URLs: []string{"", "", ""}, } downloader.PutConfig(cfg) urls := downloader.getWebhookUrls() if urls != nil { t.Errorf("Expected nil for all empty strings, got %v", urls) } }) } func TestWebhook_GetWebhookUrls_InterfaceSliceMixedTypes(t *testing.T) { setupWebhookTest(t, func(downloader *Downloader) { cfg, _ := downloader.GetConfig() cfg.Webhook = &base.WebhookConfig{ Enable: true, URLs: []string{"http://example.com", "", "http://example2.com", ""}, } downloader.PutConfig(cfg) urls := downloader.getWebhookUrls() if len(urls) != 2 { t.Errorf("Expected 2 valid URLs (ignoring empty strings), got %d: %v", len(urls), urls) } if urls[0] != "http://example.com" || urls[1] != "http://example2.com" { t.Errorf("URLs don't match expected values: %v", urls) } }) } func TestWebhook_GetWebhookUrls_InvalidType(t *testing.T) { setupWebhookTest(t, func(downloader *Downloader) { cfg, _ := downloader.GetConfig() cfg.Webhook = &base.WebhookConfig{Enable: false} // Disabled webhook downloader.PutConfig(cfg) urls := downloader.getWebhookUrls() if urls != nil { t.Errorf("Expected nil for disabled webhook, got %v", urls) } }) } func TestWebhook_GetWebhookUrls_DisabledWebhook(t *testing.T) { setupWebhookTest(t, func(downloader *Downloader) { cfg, _ := downloader.GetConfig() cfg.Webhook = &base.WebhookConfig{ Enable: false, URLs: []string{"http://example.com"}, } downloader.PutConfig(cfg) urls := downloader.getWebhookUrls() if urls != nil { t.Errorf("Expected nil for disabled webhook even with URLs, got %v", urls) } }) } func TestWebhook_SendWebhookToUrl_Success(t *testing.T) { receivedData := make(chan *WebhookData, 1) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Verify headers if r.Header.Get("Content-Type") != "application/json" { t.Errorf("Expected Content-Type application/json, got %s", r.Header.Get("Content-Type")) } if r.Header.Get("User-Agent") != "Gopeed-Webhook/1.0" { t.Errorf("Expected User-Agent Gopeed-Webhook/1.0, got %s", r.Header.Get("User-Agent")) } var data WebhookData if err := json.NewDecoder(r.Body).Decode(&data); err != nil { t.Errorf("Failed to decode data: %v", err) return } receivedData <- &data w.WriteHeader(http.StatusOK) })) defer server.Close() setupWebhookTest(t, func(downloader *Downloader) { task := NewTask() task.Protocol = "http" task.Meta = &mockFetcherMeta data := &WebhookData{ Event: WebhookEventDownloadDone, Time: time.Now().UnixMilli(), Payload: &WebhookPayload{ Task: task, }, } statusCode, err := downloader.sendWebhookToUrl(server.URL, data) if err != nil { t.Errorf("sendWebhookToUrl failed: %v", err) } if statusCode != http.StatusOK { t.Errorf("Expected status 200, got %d", statusCode) } select { case received := <-receivedData: if received.Event != WebhookEventDownloadDone { t.Errorf("Expected event DOWNLOAD_DONE, got %s", received.Event) } case <-time.After(1 * time.Second): t.Error("Timeout waiting for webhook") } }) } func TestWebhook_SendWebhookToUrl_EmptyUrl(t *testing.T) { setupWebhookTest(t, func(downloader *Downloader) { data := &WebhookData{ Event: WebhookEventDownloadDone, Time: time.Now().UnixMilli(), } _, err := downloader.sendWebhookToUrl("", data) if err == nil { t.Error("Expected error for empty URL") } if err.Error() != "webhook URL is empty" { t.Errorf("Expected 'webhook URL is empty' error, got: %v", err) } }) } func TestWebhook_SendWebhookToUrl_InvalidUrl(t *testing.T) { setupWebhookTest(t, func(downloader *Downloader) { data := &WebhookData{ Event: WebhookEventDownloadDone, Time: time.Now().UnixMilli(), } _, err := downloader.sendWebhookToUrl("://invalid-url", data) if err == nil { t.Error("Expected error for invalid URL") } }) } func TestWebhook_SendWebhookToUrl_NonExistentHost(t *testing.T) { setupWebhookTest(t, func(downloader *Downloader) { data := &WebhookData{ Event: WebhookEventDownloadDone, Time: time.Now().UnixMilli(), } _, err := downloader.sendWebhookToUrl("http://non-existent-host-12345.example", data) if err == nil { t.Error("Expected error for non-existent host") } }) } func TestWebhook_SendWebhookToUrl_Timeout(t *testing.T) { // Create a server that delays response beyond webhook timeout server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { time.Sleep(15 * time.Second) // Longer than webhookTimeout (10s) w.WriteHeader(http.StatusOK) })) defer server.Close() setupWebhookTest(t, func(downloader *Downloader) { data := &WebhookData{ Event: WebhookEventDownloadDone, Time: time.Now().UnixMilli(), } _, err := downloader.sendWebhookToUrl(server.URL, data) if err == nil { t.Error("Expected timeout error") } }) } func TestWebhook_SendWebhookToUrl_VariousStatusCodes(t *testing.T) { testCases := []struct { name string statusCode int }{ {"200 OK", http.StatusOK}, {"201 Created", http.StatusCreated}, {"204 No Content", http.StatusNoContent}, {"400 Bad Request", http.StatusBadRequest}, {"404 Not Found", http.StatusNotFound}, {"500 Internal Server Error", http.StatusInternalServerError}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(tc.statusCode) })) defer server.Close() setupWebhookTest(t, func(downloader *Downloader) { data := &WebhookData{ Event: WebhookEventDownloadDone, Time: time.Now().UnixMilli(), } statusCode, err := downloader.sendWebhookToUrl(server.URL, data) if err != nil { t.Errorf("sendWebhookToUrl failed: %v", err) } if statusCode != tc.statusCode { t.Errorf("Expected status %d, got %d", tc.statusCode, statusCode) } }) }) } } func TestWebhook_TriggerWebhooks_EmptyUrlSkipped(t *testing.T) { requestCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requestCount++ w.WriteHeader(http.StatusOK) })) defer server.Close() setupWebhookTest(t, func(downloader *Downloader) { cfg, _ := downloader.GetConfig() cfg.Webhook = &base.WebhookConfig{ Enable: true, URLs: []string{server.URL, "", server.URL}, } downloader.PutConfig(cfg) task := NewTask() task.Protocol = "http" task.Meta = &mockFetcherMeta downloader.triggerWebhooks(WebhookEventDownloadDone, task, nil) time.Sleep(500 * time.Millisecond) if requestCount != 2 { t.Errorf("Expected 2 requests (empty URL should be skipped), got %d", requestCount) } }) } func TestWebhook_WebhookDataStructure(t *testing.T) { receivedData := make(chan *WebhookData, 1) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var data WebhookData if err := json.NewDecoder(r.Body).Decode(&data); err != nil { t.Errorf("Failed to decode data: %v", err) return } receivedData <- &data w.WriteHeader(http.StatusOK) })) defer server.Close() setupWebhookTest(t, func(downloader *Downloader) { cfg, _ := downloader.GetConfig() cfg.Webhook = &base.WebhookConfig{ Enable: true, URLs: []string{server.URL}, } downloader.PutConfig(cfg) task := NewTask() task.Protocol = "http" task.Status = base.DownloadStatusDone task.Meta = &mockFetcherMeta downloader.triggerWebhooks(WebhookEventDownloadDone, task, nil) select { case data := <-receivedData: // Verify event if data.Event != WebhookEventDownloadDone { t.Errorf("Expected event DOWNLOAD_DONE, got %s", data.Event) } // Verify time is set if data.Time == 0 { t.Error("Expected time to be set") } // Verify payload if data.Payload == nil { t.Error("Expected payload to be present") } if data.Payload.Task == nil { t.Error("Expected task in payload to be present") } // Verify task is cloned (has same ID but different pointer) if data.Payload.Task.ID != task.ID { t.Errorf("Expected task ID %s, got %s", task.ID, data.Payload.Task.ID) } case <-time.After(2 * time.Second): t.Error("Timeout waiting for webhook") } }) } func TestWebhook_SendTestWebhook_EmptyUrls(t *testing.T) { setupWebhookTest(t, func(downloader *Downloader) { cfg, _ := downloader.GetConfig() cfg.Extra = make(map[string]any) cfg.Webhook.URLs = []string{} downloader.PutConfig(cfg) err := downloader.SendTestWebhook() if err != nil { t.Errorf("Expected no error for empty URLs, got: %v", err) } }) } func TestWebhook_SendTestWebhook_MixedResults(t *testing.T) { // First server returns 200 server1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) defer server1.Close() // Second server returns 500 server2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) })) defer server2.Close() setupWebhookTest(t, func(downloader *Downloader) { cfg, _ := downloader.GetConfig() cfg.Webhook = &base.WebhookConfig{ Enable: true, URLs: []string{server1.URL, server2.URL}, } downloader.PutConfig(cfg) // Should fail because server2 returns 500 err := downloader.SendTestWebhook() if err == nil { t.Error("Expected error when one server returns non-200 status") } }) } func TestWebhook_TestWebhookUrl_InvalidUrl(t *testing.T) { setupWebhookTest(t, func(downloader *Downloader) { err := downloader.TestWebhookUrl("://invalid") if err == nil { t.Error("Expected error for invalid URL") } }) } func TestWebhook_TestWebhookUrl_VerifyTestPayload(t *testing.T) { receivedData := make(chan *WebhookData, 1) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var data WebhookData if err := json.NewDecoder(r.Body).Decode(&data); err != nil { t.Errorf("Failed to decode data: %v", err) return } receivedData <- &data w.WriteHeader(http.StatusOK) })) defer server.Close() setupWebhookTest(t, func(downloader *Downloader) { err := downloader.TestWebhookUrl(server.URL) if err != nil { t.Errorf("TestWebhookUrl failed: %v", err) } select { case data := <-receivedData: // Verify it's a test webhook if data.Event != WebhookEventDownloadDone { t.Errorf("Expected event DOWNLOAD_DONE, got %s", data.Event) } if data.Payload == nil || data.Payload.Task == nil { t.Error("Expected payload with task") } // Verify test task properties task := data.Payload.Task if task.Protocol != "http" { t.Errorf("Expected protocol 'http', got '%s'", task.Protocol) } if task.Status != base.DownloadStatusDone { t.Errorf("Expected status Done, got %s", task.Status) } if task.Meta == nil || task.Meta.Req == nil { t.Error("Expected meta and request in test task") } if task.Meta.Req.URL != "https://example.com/test-file.zip" { t.Errorf("Expected test URL, got %s", task.Meta.Req.URL) } case <-time.After(2 * time.Second): t.Error("Timeout waiting for webhook") } }) } func setupWebhookTest(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) } ================================================ FILE: pkg/protocol/bt/model.go ================================================ package bt type ReqExtra struct { Trackers []string `json:"trackers"` } // Stats for torrent type Stats struct { // health indicators of torrents, from large to small, ConnectedSeeders are also the key to the health of seed resources TotalPeers int `json:"totalPeers"` ActivePeers int `json:"activePeers"` ConnectedSeeders int `json:"connectedSeeders"` // Total seed bytes SeedBytes int64 `json:"seedBytes"` // Seed ratio SeedRatio float64 `json:"seedRatio"` // Total seed time SeedTime int64 `json:"seedTime"` } ================================================ FILE: pkg/protocol/ed2k/model.go ================================================ package ed2k type Stats struct { State string `json:"state"` Paused bool `json:"paused"` ActivePeers int `json:"activePeers"` TotalPeers int `json:"totalPeers"` DownloadRate int `json:"downloadRate"` Upload int64 `json:"upload"` UploadRate int `json:"uploadRate"` TotalDone int64 `json:"totalDone"` TotalReceived int64 `json:"totalReceived"` TotalWanted int64 `json:"totalWanted"` } ================================================ FILE: pkg/protocol/http/model.go ================================================ package http type ReqExtra struct { Method string `json:"method"` Header map[string]string `json:"header"` Body string `json:"body"` } type OptsExtra struct { Connections int `json:"connections"` // AutoTorrent when task download complete, and it is a .torrent file, it will be auto create a new task for the torrent file // nil means use global config, true/false means explicit setting AutoTorrent *bool `json:"autoTorrent"` // DeleteTorrentAfterDownload when true, deletes the .torrent file after creating BT task // nil means use global config, true/false means explicit setting DeleteTorrentAfterDownload *bool `json:"deleteTorrentAfterDownload"` // AutoExtract when task download complete, and it is an archive file, it will be auto extracted // nil means use global config, true/false means explicit setting AutoExtract *bool `json:"autoExtract"` // ArchivePassword is the password for extracting password-protected archives ArchivePassword string `json:"archivePassword"` // DeleteAfterExtract when true, deletes the archive file after successful extraction DeleteAfterExtract bool `json:"deleteAfterExtract"` } // Stats for download type Stats struct { Connections []*StatsConnection `json:"connections"` } type StatsConnection struct { Downloaded int64 `json:"downloaded"` Completed bool `json:"completed"` Failed bool `json:"failed"` RetryTimes int `json:"retryTimes"` } ================================================ FILE: pkg/rest/api.go ================================================ package rest import ( "io" "net/http" "net/url" "runtime" "github.com/GopeedLab/gopeed/pkg/base" "github.com/GopeedLab/gopeed/pkg/download" "github.com/GopeedLab/gopeed/pkg/rest/model" "github.com/gorilla/mux" ) func Info(w http.ResponseWriter, r *http.Request) { info := map[string]any{ "version": base.Version, "runtime": runtime.Version(), "os": runtime.GOOS, "arch": runtime.GOARCH, "inDocker": base.InDocker == "true", } WriteJson(w, model.NewOkResult(info)) } func Resolve(w http.ResponseWriter, r *http.Request) { var req model.ResolveTask if ReadJson(r, w, &req) { rr, err := Downloader.Resolve(req.Req, req.Opts) if err != nil { WriteJson(w, model.NewErrorResult(err.Error())) return } WriteJson(w, model.NewOkResult(rr)) } } func CreateTask(w http.ResponseWriter, r *http.Request) { var req model.CreateTask if ReadJson(r, w, &req) { var ( taskId string err error ) if req.Rid != "" { taskId, err = Downloader.Create(req.Rid) } else if req.Req != nil { taskId, err = Downloader.CreateDirect(req.Req, req.Opts) } else { WriteJson(w, model.NewErrorResult("param invalid: rid or req", model.CodeInvalidParam)) return } if err != nil { WriteJson(w, model.NewErrorResult(err.Error())) return } WriteJson(w, model.NewOkResult(taskId)) } } func CreateTaskBatch(w http.ResponseWriter, r *http.Request) { var req base.CreateTaskBatch if ReadJson(r, w, &req) { if len(req.Reqs) == 0 { WriteJson(w, model.NewErrorResult("param invalid: reqs", model.CodeInvalidParam)) return } taskIds, err := Downloader.CreateDirectBatch(&req) if err != nil { WriteJson(w, model.NewErrorResult(err.Error())) return } WriteJson(w, model.NewOkResult(taskIds)) } } func PatchTask(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) taskId := vars["id"] if taskId == "" { WriteJson(w, model.NewErrorResult("param invalid: id", model.CodeInvalidParam)) return } var req model.ResolveTask if ReadJson(r, w, &req) { if err := Downloader.Patch(taskId, req.Req, req.Opts); err != nil { if err == download.ErrTaskNotFound { WriteJson(w, model.NewErrorResult("task not found", model.CodeTaskNotFound)) return } WriteJson(w, model.NewErrorResult(err.Error())) return } WriteJson(w, model.NewNilResult()) } } func PauseTask(w http.ResponseWriter, r *http.Request) { filter, errResult := parseIdFilter(r) if errResult != nil { WriteJson(w, errResult) return } if err := Downloader.Pause(filter); err != nil { WriteJson(w, model.NewErrorResult(err.Error())) return } WriteJson(w, model.NewNilResult()) } func PauseTasks(w http.ResponseWriter, r *http.Request) { filter, errResult := parseFilter(r) if errResult != nil { WriteJson(w, errResult) return } if err := Downloader.Pause(filter); err != nil { WriteJson(w, model.NewErrorResult(err.Error())) return } WriteJson(w, model.NewNilResult()) } func ContinueTask(w http.ResponseWriter, r *http.Request) { filter, errResult := parseIdFilter(r) if errResult != nil { WriteJson(w, errResult) return } if err := Downloader.Continue(filter); err != nil { WriteJson(w, model.NewErrorResult(err.Error())) return } WriteJson(w, model.NewNilResult()) } func ContinueTasks(w http.ResponseWriter, r *http.Request) { filter, errResult := parseFilter(r) if errResult != nil { WriteJson(w, errResult) return } if err := Downloader.Continue(filter); err != nil { WriteJson(w, model.NewErrorResult(err.Error())) return } WriteJson(w, model.NewNilResult()) } func DeleteTask(w http.ResponseWriter, r *http.Request) { filter, errResult := parseIdFilter(r) if errResult != nil { WriteJson(w, errResult) return } force := r.FormValue("force") if err := Downloader.Delete(filter, force == "true"); err != nil { WriteJson(w, model.NewErrorResult(err.Error())) return } WriteJson(w, model.NewNilResult()) } func DeleteTasks(w http.ResponseWriter, r *http.Request) { filter, errResult := parseFilter(r) if errResult != nil { WriteJson(w, errResult) return } force := r.FormValue("force") if err := Downloader.Delete(filter, force == "true"); err != nil { WriteJson(w, model.NewErrorResult(err.Error())) return } WriteJson(w, model.NewNilResult()) } func GetTask(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) taskId := vars["id"] if taskId == "" { WriteJson(w, model.NewErrorResult("param invalid: id", model.CodeInvalidParam)) return } task := Downloader.GetTask(taskId) if task == nil { WriteJson(w, model.NewErrorResult("task not found", model.CodeTaskNotFound)) return } WriteJson(w, model.NewOkResult(task)) } func GetTasks(w http.ResponseWriter, r *http.Request) { filter, errResult := parseFilter(r) if errResult != nil { WriteJson(w, errResult) return } tasks := Downloader.GetTasksByFilter(filter) WriteJson(w, model.NewOkResult(tasks)) } func GetConfig(w http.ResponseWriter, r *http.Request) { WriteJson(w, model.NewOkResult(getServerConfig())) } func PutConfig(w http.ResponseWriter, r *http.Request) { var cfg base.DownloaderStoreConfig if ReadJson(r, w, &cfg) { if err := Downloader.PutConfig(&cfg); err != nil { WriteJson(w, model.NewErrorResult(err.Error())) return } } WriteJson(w, model.NewNilResult()) } func InstallExtension(w http.ResponseWriter, r *http.Request) { var req model.InstallExtension if ReadJson(r, w, &req) { var ( installedExt *download.Extension err error ) if req.DevMode { installedExt, err = Downloader.InstallExtensionByFolder(req.URL, true) } else { installedExt, err = Downloader.InstallExtensionByGit(req.URL) } if err != nil { WriteJson(w, model.NewErrorResult(err.Error())) return } WriteJson(w, model.NewOkResult(installedExt.Identity)) } } func GetExtensions(w http.ResponseWriter, r *http.Request) { list := Downloader.GetExtensions() WriteJson(w, model.NewOkResult(list)) } func GetExtension(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) identity := vars["identity"] ext, err := Downloader.GetExtension(identity) if err != nil { WriteJson(w, model.NewErrorResult(err.Error())) return } WriteJson(w, model.NewOkResult(ext)) } func UpdateExtensionSettings(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) identity := vars["identity"] var req model.UpdateExtensionSettings if ReadJson(r, w, &req) { if err := Downloader.UpdateExtensionSettings(identity, req.Settings); err != nil { WriteJson(w, model.NewErrorResult(err.Error())) return } } WriteJson(w, model.NewNilResult()) } func SwitchExtension(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) identity := vars["identity"] var switchExtension model.SwitchExtension if ReadJson(r, w, &switchExtension) { if err := Downloader.SwitchExtension(identity, switchExtension.Status); err != nil { WriteJson(w, model.NewErrorResult(err.Error())) return } } WriteJson(w, model.NewNilResult()) } func DeleteExtension(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) identity := vars["identity"] if err := Downloader.DeleteExtension(identity); err != nil { WriteJson(w, model.NewErrorResult(err.Error())) return } WriteJson(w, model.NewNilResult()) } func UpdateCheckExtension(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) identity := vars["identity"] newVersion, err := Downloader.UpgradeCheckExtension(identity) if err != nil { WriteJson(w, model.NewErrorResult(err.Error())) return } WriteJson(w, model.NewOkResult(&model.UpdateCheckExtensionResp{ NewVersion: newVersion, })) } func UpdateExtension(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) identity := vars["identity"] if err := Downloader.UpgradeExtension(identity); err != nil { WriteJson(w, model.NewErrorResult(err.Error())) return } WriteJson(w, model.NewNilResult()) } func DoProxy(w http.ResponseWriter, r *http.Request) { target := r.Header.Get("X-Target-Uri") if target == "" { writeError(w, "param invalid: X-Target-Uri") return } targetUrl, err := url.Parse(target) if err != nil { writeError(w, err.Error()) return } r.RequestURI = "" r.URL = targetUrl r.Host = targetUrl.Host r.Header.Del("Authorization") r.Header.Del("X-Target-Uri") resp, err := http.DefaultClient.Do(r) if err != nil { writeError(w, err.Error()) return } defer resp.Body.Close() for k, vv := range resp.Header { for _, v := range vv { w.Header().Set(k, v) } } w.WriteHeader(resp.StatusCode) buf, err := io.ReadAll(resp.Body) if err != nil { writeError(w, err.Error()) return } w.Write(buf) } func GetStats(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) taskId := vars["id"] if taskId == "" { WriteJson(w, model.NewErrorResult("param invalid: id", model.CodeInvalidParam)) return } statsResult, err := Downloader.Stats(taskId) if err != nil { writeError(w, err.Error()) return } WriteJson(w, model.NewOkResult(statsResult)) } func parseIdFilter(r *http.Request) (*download.TaskFilter, any) { vars := mux.Vars(r) taskId := vars["id"] if taskId == "" { return nil, model.NewErrorResult("param invalid: id", model.CodeInvalidParam) } filter := &download.TaskFilter{ IDs: []string{taskId}, } return filter, nil } func parseFilter(r *http.Request) (*download.TaskFilter, any) { if err := r.ParseForm(); err != nil { return nil, model.NewErrorResult(err.Error()) } filter := &download.TaskFilter{ IDs: r.Form["id"], Statuses: convertStatues(r.Form["status"]), NotStatuses: convertStatues(r.Form["notStatus"]), } return filter, nil } func convertStatues(statues []string) []base.Status { result := make([]base.Status, 0) for _, status := range statues { result = append(result, base.Status(status)) } return result } func writeError(w http.ResponseWriter, msg string) { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(msg)) } func getServerConfig() *base.DownloaderStoreConfig { cfg, _ := Downloader.GetConfig() return cfg } func TestWebhook(w http.ResponseWriter, r *http.Request) { var req model.TestWebhookReq if ReadJson(r, w, &req) { if err := Downloader.TestWebhookUrl(req.URL); err != nil { WriteJson(w, model.NewErrorResult(err.Error())) return } WriteJson(w, model.NewNilResult()) } } ================================================ FILE: pkg/rest/config.go ================================================ package rest type Config struct { Host string `json:"host"` Port int `json:"port"` } ================================================ FILE: pkg/rest/gizp_middleware.go ================================================ package rest import ( "compress/gzip" "io" "net/http" "strings" ) type gzipResponseWriter struct { io.Writer http.ResponseWriter } func (g gzipResponseWriter) Write(b []byte) (int, error) { return g.Writer.Write(b) } func gzipMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { next.ServeHTTP(w, r) return } w.Header().Set("Content-Encoding", "gzip") w.Header().Add("Vary", "Accept-Encoding") gz := gzip.NewWriter(w) defer gz.Close() next.ServeHTTP(gzipResponseWriter{Writer: gz, ResponseWriter: w}, r) }) } ================================================ FILE: pkg/rest/model/extension.go ================================================ package model type InstallExtension struct { DevMode bool `json:"devMode"` URL string `json:"url"` } type UpdateExtensionSettings struct { Settings map[string]any `json:"settings"` } type SwitchExtension struct { Status bool `json:"status"` } type UpdateCheckExtensionResp struct { NewVersion string `json:"newVersion"` } ================================================ FILE: pkg/rest/model/result.go ================================================ package model type RespCode int const ( CodeOk RespCode = 0 // CodeError is the common error code CodeError RespCode = 1000 // CodeUnauthorized is the error code for unauthorized CodeUnauthorized RespCode = 1001 // CodeInvalidParam is the error code for invalid parameter CodeInvalidParam RespCode = 1002 // CodeTaskNotFound is the error code for task not found CodeTaskNotFound RespCode = 2001 ) type Result[T any] struct { Code RespCode `json:"code"` Msg string `json:"msg"` Data T `json:"data"` } func NewOkResult[T any](data T) *Result[T] { return &Result[T]{ Code: CodeOk, Data: data, } } func NewNilResult() *Result[any] { return &Result[any]{ Code: CodeOk, } } func NewErrorResult(msg string, code ...RespCode) *Result[any] { // if code is not provided, the default code is CodeError c := CodeError if len(code) > 0 { c = code[0] } return &Result[any]{ Code: c, Msg: msg, } } ================================================ FILE: pkg/rest/model/server.go ================================================ package model import ( "github.com/GopeedLab/gopeed/pkg/base" "io/fs" ) type Storage string const ( StorageMem Storage = "mem" StorageBolt Storage = "bolt" ) type StartConfig struct { Network string `json:"network"` Address string `json:"address"` RefreshInterval int `json:"refreshInterval"` Storage Storage `json:"storage"` StorageDir string `json:"storageDir"` WhiteDownloadDirs []string `json:"whiteDownloadDirs"` ApiToken string `json:"apiToken"` DownloadConfig *base.DownloaderStoreConfig `json:"downloadConfig"` ProductionMode bool WebEnable bool WebFS fs.FS WebAuth *WebAuth } func (cfg *StartConfig) Init() *StartConfig { if cfg.Network == "" { cfg.Network = "tcp" } if cfg.Address == "" { cfg.Address = "127.0.0.1:0" } if cfg.RefreshInterval == 0 { cfg.RefreshInterval = 350 } if cfg.Storage == "" { cfg.Storage = StorageBolt } if cfg.StorageDir == "" { cfg.StorageDir = "./" } return cfg } type WebAuth struct { Username string Password string } ================================================ FILE: pkg/rest/model/task.go ================================================ package model import "github.com/GopeedLab/gopeed/pkg/base" type ResolveTask struct { Req *base.Request `json:"req"` Opts *base.Options `json:"opts"` } type CreateTask struct { Rid string `json:"rid"` Req *base.Request `json:"req"` Opts *base.Options `json:"opts"` } ================================================ FILE: pkg/rest/model/webhook.go ================================================ package model // TestWebhookReq is the request body for testing a single webhook URL type TestWebhookReq struct { URL string `json:"url"` } ================================================ FILE: pkg/rest/server.go ================================================ package rest import ( "context" "crypto/aes" "crypto/cipher" "crypto/rand" "encoding/base64" "encoding/json" "fmt" "io" "io/fs" "net" "net/http" "os" "path/filepath" "regexp" "strconv" "strings" "time" "github.com/GopeedLab/gopeed/pkg/download" "github.com/GopeedLab/gopeed/pkg/rest/model" "github.com/GopeedLab/gopeed/pkg/util" "github.com/gorilla/handlers" "github.com/gorilla/mux" "github.com/pkg/errors" ) var ( srv *http.Server runningPort int aesKey []byte Downloader *download.Downloader ) func Start(startCfg *model.StartConfig) (port int, err error) { // avoid repeat start if srv != nil { return runningPort, nil } var listener net.Listener srv, listener, err = BuildServer(startCfg) if err != nil { return } go func() { if err := srv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { panic(err) } }() if addr, ok := listener.Addr().(*net.TCPAddr); ok { port = addr.Port runningPort = port } return } func Stop() { defer func() { srv = nil }() if srv != nil { shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() if err := srv.Shutdown(shutdownCtx); err != nil { Downloader.Logger.Warn().Err(err).Msg("shutdown server failed") } } if Downloader != nil { if err := Downloader.Close(); err != nil { Downloader.Logger.Warn().Err(err).Msg("close downloader failed") } } } func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error) { if startCfg == nil { startCfg = &model.StartConfig{} } startCfg.Init() downloadCfg := &download.DownloaderConfig{ ProductionMode: startCfg.ProductionMode, RefreshInterval: startCfg.RefreshInterval, WhiteDownloadDirs: startCfg.WhiteDownloadDirs, } if startCfg.Storage == model.StorageBolt { downloadCfg.Storage = download.NewBoltStorage(startCfg.StorageDir) } else { downloadCfg.Storage = download.NewMemStorage() } downloadCfg.StorageDir = startCfg.StorageDir downloadCfg.Init() Downloader = download.NewDownloader(downloadCfg) if err := Downloader.Setup(); err != nil { return nil, nil, err } if startCfg.Network == "unix" { util.SafeRemove(startCfg.Address) } if startCfg.WebEnable { aesKey = make([]byte, 32) if _, err := rand.Read(aesKey); err != nil { return nil, nil, errors.Wrap(err, "generate aes key failed") } } listener, err := net.Listen(startCfg.Network, startCfg.Address) if err != nil { return nil, nil, err } var r = mux.NewRouter() r.Methods(http.MethodGet).Path("/api/v1/info").HandlerFunc(Info) r.Methods(http.MethodPost).Path("/api/v1/resolve").HandlerFunc(Resolve) r.Methods(http.MethodPost).Path("/api/v1/tasks").HandlerFunc(CreateTask) r.Methods(http.MethodPost).Path("/api/v1/tasks/batch").HandlerFunc(CreateTaskBatch) r.Methods(http.MethodPatch).Path("/api/v1/tasks/{id}").HandlerFunc(PatchTask) r.Methods(http.MethodPut).Path("/api/v1/tasks/{id}/pause").HandlerFunc(PauseTask) r.Methods(http.MethodPut).Path("/api/v1/tasks/pause").HandlerFunc(PauseTasks) r.Methods(http.MethodPut).Path("/api/v1/tasks/{id}/continue").HandlerFunc(ContinueTask) r.Methods(http.MethodPut).Path("/api/v1/tasks/continue").HandlerFunc(ContinueTasks) r.Methods(http.MethodDelete).Path("/api/v1/tasks/{id}").HandlerFunc(DeleteTask) r.Methods(http.MethodDelete).Path("/api/v1/tasks").HandlerFunc(DeleteTasks) r.Methods(http.MethodGet).Path("/api/v1/tasks/{id}").HandlerFunc(GetTask) r.Methods(http.MethodGet).Path("/api/v1/tasks").HandlerFunc(GetTasks) r.Methods(http.MethodGet).Path("/api/v1/tasks/{id}/stats").HandlerFunc(GetStats) r.Methods(http.MethodGet).Path("/api/v1/config").HandlerFunc(GetConfig) r.Methods(http.MethodPut).Path("/api/v1/config").HandlerFunc(PutConfig) r.Methods(http.MethodPost).Path("/api/v1/extensions").HandlerFunc(InstallExtension) r.Methods(http.MethodGet).Path("/api/v1/extensions").HandlerFunc(GetExtensions) r.Methods(http.MethodGet).Path("/api/v1/extensions/{identity}").HandlerFunc(GetExtension) r.Methods(http.MethodPut).Path("/api/v1/extensions/{identity}/settings").HandlerFunc(UpdateExtensionSettings) r.Methods(http.MethodPut).Path("/api/v1/extensions/{identity}/switch").HandlerFunc(SwitchExtension) r.Methods(http.MethodDelete).Path("/api/v1/extensions/{identity}").HandlerFunc(DeleteExtension) r.Methods(http.MethodGet).Path("/api/v1/extensions/{identity}/update").HandlerFunc(UpdateCheckExtension) r.Methods(http.MethodPost).Path("/api/v1/extensions/{identity}/update").HandlerFunc(UpdateExtension) r.Methods(http.MethodPost).Path("/api/v1/webhook/test").HandlerFunc(TestWebhook) r.Path("/api/v1/proxy").HandlerFunc(DoProxy) enableApiToken := startCfg.ApiToken != "" enableWebAuth := startCfg.WebEnable && startCfg.WebAuth != nil if startCfg.WebEnable { if enableWebAuth { r.Methods(http.MethodPost).Path("/api/web/login").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var loginReq model.WebAuth if ReadJson(r, w, &loginReq) { if loginReq.Username == startCfg.WebAuth.Username && loginReq.Password == startCfg.WebAuth.Password { // Generate a login token, Username:Password:Timestamp timestamp := time.Now().Unix() tokenData := fmt.Sprintf("%s:%s:%d", loginReq.Username, loginReq.Password, timestamp) token, err := aesEncrypt(aesKey, []byte(tokenData)) if err != nil { WriteJson(w, model.NewErrorResult(err.Error())) return } WriteJson(w, model.NewOkResult(token)) return } } WriteStatusJson(w, http.StatusUnauthorized, model.NewErrorResult("unauthorized", model.CodeUnauthorized)) }) } r.PathPrefix("/fs/tasks").Handler(http.FileServer(new(taskFileSystem))) r.PathPrefix("/fs/extensions").Handler(http.FileServer(new(extensionFileSystem))) r.PathPrefix("/").Handler(gzipMiddleware(http.FileServer(newEmbedCacheFileSystem(http.FS(startCfg.WebFS))))) } if enableApiToken || enableWebAuth { writeUnauthorized := func(w http.ResponseWriter, r *http.Request) { WriteStatusJson(w, http.StatusUnauthorized, model.NewErrorResult("unauthorized", model.CodeUnauthorized)) } r.Use(func(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if enableApiToken { apiTokenHeader := r.Header["X-Api-Token"] // If api token header is set, only check api token ignore basic auth if len(apiTokenHeader) > 0 { if apiTokenHeader[0] == startCfg.ApiToken { h.ServeHTTP(w, r) return } writeUnauthorized(w, r) return } } if enableWebAuth { if !strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/api/web/login" { h.ServeHTTP(w, r) return } token := r.Header.Get("Authorization") if token == "" { writeUnauthorized(w, r) return } token = strings.TrimPrefix(token, "Bearer ") tokenData, err := aesDecrypt(aesKey, token) if err != nil { writeUnauthorized(w, r) return } parts := strings.SplitN(string(tokenData), ":", 3) username := parts[0] password := parts[1] timestamp, _ := strconv.Atoi(parts[2]) if username != startCfg.WebAuth.Username || password != startCfg.WebAuth.Password { writeUnauthorized(w, r) return } // Check if the token is expired (7 days) if time.Now().Unix()-int64(timestamp) > 7*24*3600 { writeUnauthorized(w, r) return } h.ServeHTTP(w, r) return } writeUnauthorized(w, r) }) }) } // recover panic r.Use(func(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if v := recover(); v != nil { err := errors.WithStack(fmt.Errorf("%v", v)) Downloader.Logger.Error().Stack().Err(err).Msgf("http server panic: %s %s", r.Method, r.RequestURI) WriteJson(w, model.NewErrorResult(err.Error(), model.CodeError)) } }() h.ServeHTTP(w, r) }) }) srv = &http.Server{Handler: handlers.CORS( handlers.AllowedHeaders([]string{"Content-Type", "Authorization", "X-Api-Token", "X-Target-Uri"}), handlers.AllowedMethods([]string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}), handlers.AllowedOrigins([]string{"*"}), handlers.AllowCredentials(), )(r)} return srv, listener, nil } func resolvePath(urlPath string, prefix string) (identity string, path string, err error) { // remove prefix clearPath := strings.TrimPrefix(urlPath, prefix) // match extension identity, eg: /fs/extensions/identity/xxx reg := regexp.MustCompile(`^/([^/]+)/(.*)$`) if !reg.MatchString(clearPath) { err = os.ErrNotExist return } matched := reg.FindStringSubmatch(clearPath) if len(matched) != 3 { err = os.ErrNotExist return } return matched[1], matched[2], nil } // handle task file resource type taskFileSystem struct { } func (e *taskFileSystem) Open(name string) (http.File, error) { // get extension identity identity, path, err := resolvePath(name, "/fs/tasks") if err != nil { return nil, err } task := Downloader.GetTask(identity) if task == nil { return nil, os.ErrNotExist } return os.Open(filepath.Join(task.Meta.RootDirPath(), path)) } // handle extension file resource type extensionFileSystem struct { } func (e *extensionFileSystem) Open(name string) (http.File, error) { // get extension identity identity, path, err := resolvePath(name, "/fs/extensions") if err != nil { return nil, err } extension, err := Downloader.GetExtension(identity) if err != nil { return nil, os.ErrNotExist } extensionPath := Downloader.ExtensionPath(extension) return os.Open(filepath.Join(extensionPath, path)) } type embedCacheFileSystem struct { fs http.FileSystem lastModTime time.Time } func newEmbedCacheFileSystem(fs http.FileSystem) *embedCacheFileSystem { efs := &embedCacheFileSystem{ fs: fs, lastModTime: time.Now(), } exe, err := os.Executable() if err != nil { return efs } fi, err := os.Stat(exe) if err != nil { return efs } efs.lastModTime = fi.ModTime() return efs } func (e *embedCacheFileSystem) Open(name string) (http.File, error) { file, err := e.fs.Open(name) if err != nil { return nil, err } return &embedFile{ File: file, lastModTime: e.lastModTime, }, nil } type embedFile struct { http.File lastModTime time.Time } type embedFileInfo struct { fs.FileInfo lastModTime time.Time } func (e *embedFileInfo) ModTime() time.Time { return e.lastModTime } func (e *embedFile) Stat() (fs.FileInfo, error) { fi, err := e.File.Stat() if err != nil { return nil, err } return &embedFileInfo{ FileInfo: fi, lastModTime: e.lastModTime, }, nil } func ReadJson(r *http.Request, w http.ResponseWriter, v any) bool { if err := json.NewDecoder(r.Body).Decode(v); err != nil { WriteJson(w, model.NewErrorResult(err.Error())) return false } return true } func WriteJson(w http.ResponseWriter, v any) { WriteStatusJson(w, http.StatusOK, v) } func WriteStatusJson(w http.ResponseWriter, statusCode int, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(statusCode) json.NewEncoder(w).Encode(v) } func aesEncrypt(key, data []byte) (string, error) { block, err := aes.NewCipher(key) if err != nil { return "", err } gcm, err := cipher.NewGCM(block) if err != nil { return "", err } nonce := make([]byte, gcm.NonceSize()) if _, err := io.ReadFull(rand.Reader, nonce); err != nil { return "", err } cipherText := gcm.Seal(nonce, nonce, data, nil) return base64.StdEncoding.EncodeToString(cipherText), nil } func aesDecrypt(key []byte, encryptedData string) ([]byte, error) { cipherText, err := base64.StdEncoding.DecodeString(encryptedData) if err != nil { return nil, err } block, err := aes.NewCipher(key) if err != nil { return nil, err } gcm, err := cipher.NewGCM(block) if err != nil { return nil, err } if len(cipherText) < gcm.NonceSize() { return nil, errors.New("ciphertext too short") } nonce, cipherText := cipherText[:gcm.NonceSize()], cipherText[gcm.NonceSize():] return gcm.Open(nil, nonce, cipherText, nil) } ================================================ FILE: pkg/rest/server_test.go ================================================ package rest import ( "bytes" "crypto/md5" "encoding/json" "errors" "fmt" "io" "net" "net/http" "os" "path/filepath" "reflect" "strings" "sync" "testing" "time" "github.com/GopeedLab/gopeed/internal/test" "github.com/GopeedLab/gopeed/pkg/base" "github.com/GopeedLab/gopeed/pkg/download" "github.com/GopeedLab/gopeed/pkg/rest/model" ) var ( restPort int taskReq = &base.Request{ Extra: map[string]any{ "method": "", "header": map[string]string{ "Usr-Agent": "gopeed", }, "body": "", }, } taskRes = &base.Resource{ Size: test.BuildSize, Range: true, Files: []*base.FileInfo{ { Name: test.BuildName, Path: "", Size: test.BuildSize, }, }, } createOpts = &base.Options{ Path: test.Dir, Name: test.DownloadName, Extra: map[string]any{ "connections": 2, }, } resolveReq = &model.ResolveTask{ Req: taskReq, Opts: createOpts, } createReq = &model.CreateTask{ Req: taskReq, Opts: createOpts, } installExtensionReq = &model.InstallExtension{ URL: "https://github.com/GopeedLab/gopeed-extension-samples#github-contributor-avatars-sample", } ) func TestInfo(t *testing.T) { matchKeys := []string{"version", "runtime", "os", "arch", "inDocker"} doTest(func() { resp := httpRequestCheckOk[map[string]any](http.MethodGet, "/api/v1/info", nil) for _, key := range matchKeys { if _, ok := resp[key]; !ok { t.Errorf("Info() missing key = %v", key) } } }) } func TestResolve(t *testing.T) { doTest(func() { resp := httpRequestCheckOk[*download.ResolveResult](http.MethodPost, "/api/v1/resolve", resolveReq) if !test.AssertResourceEqual(taskRes, resp.Res) { t.Errorf("Resolve() got = %v, want %v", test.ToJson(resp.Res), test.ToJson(taskRes)) } }) } func TestCreateTask(t *testing.T) { doTest(func() { resp := httpRequestCheckOk[*download.ResolveResult](http.MethodPost, "/api/v1/resolve", resolveReq) var wg sync.WaitGroup wg.Add(1) Downloader.Listener(func(event *download.Event) { if event.Key == download.EventKeyFinally { wg.Done() } }) taskId := httpRequestCheckOk[string](http.MethodPost, "/api/v1/tasks", &model.CreateTask{ Rid: resp.ID, }) if taskId == "" { t.Fatal("create task failed") } wg.Wait() want := test.FileMd5(test.BuildFile) got := test.FileMd5(test.DownloadFile) if want != got { t.Errorf("CreateTask() got = %v, want %v", got, want) } }) } func TestCreateDirectTask(t *testing.T) { doTest(func() { var wg sync.WaitGroup wg.Add(1) Downloader.Listener(func(event *download.Event) { if event.Key == download.EventKeyFinally { wg.Done() } }) taskId := httpRequestCheckOk[string](http.MethodPost, "/api/v1/tasks", createReq) if taskId == "" { t.Fatal("create task failed") } wg.Wait() want := test.FileMd5(test.BuildFile) got := test.FileMd5(test.DownloadFile) if want != got { t.Errorf("CreateDirectTask() got = %v, want %v", got, want) } }) } func TestCreateDirectTaskBatch(t *testing.T) { doTest(func() { reqs := make([]*base.CreateTaskBatchItem, 0) for i := 0; i < 5; i++ { reqs = append(reqs, &base.CreateTaskBatchItem{ Req: createReq.Req, }) } taskIds := httpRequestCheckOk[[]string](http.MethodPost, "/api/v1/tasks/batch", &base.CreateTaskBatch{ Reqs: reqs, }) if len(taskIds) != len(reqs) { t.Errorf("CreateDirectTaskBatch() got = %v, want %v", len(taskIds), len(reqs)) } }) } func TestCreateDirectTaskBatchWithOpt(t *testing.T) { doTest(func() { reqs := make([]*base.CreateTaskBatchItem, 0) for i := 0; i < 5; i++ { item := &base.CreateTaskBatchItem{ Req: createReq.Req, } if i == 0 { item.Opts = &base.Options{ Name: "spe_opt.data", } } reqs = append(reqs, item) } taskIds := httpRequestCheckOk[[]string](http.MethodPost, "/api/v1/tasks/batch", &base.CreateTaskBatch{ Reqs: reqs, Opts: &base.Options{ Name: "default_opt.data", }, }) if len(taskIds) != len(reqs) { t.Errorf("CreateDirectTaskBatch() got = %v, want %v", len(taskIds), len(reqs)) } for i, taskId := range taskIds { task := httpRequestCheckOk[*download.Task](http.MethodGet, "/api/v1/tasks/"+taskId, nil) if i == 0 { if !strings.Contains(task.Name(), "spe_opt") { t.Errorf("CreateDirectTaskBatch() got = %v, want %v", task.Name(), "spe_opt.data") } } else { if !strings.Contains(task.Name(), "default_opt") { t.Errorf("CreateDirectTaskBatch() got = %v, want %v", task.Name(), "default_opt.data") } } } }) } func TestPauseAndContinueTask(t *testing.T) { doTest(func() { var wg sync.WaitGroup wg.Add(1) Downloader.Listener(func(event *download.Event) { switch event.Key { case download.EventKeyFinally: wg.Done() } }) taskId := httpRequestCheckOk[string](http.MethodPost, "/api/v1/tasks", createReq) t1 := httpRequestCheckOk[*download.Task](http.MethodGet, "/api/v1/tasks/"+taskId, nil) if t1.Status != base.DownloadStatusRunning { t.Errorf("CreateTask() got = %v, want %v", t1.Status, base.DownloadStatusRunning) } httpRequestCheckOk[any](http.MethodPut, "/api/v1/tasks/"+taskId+"/pause", nil) t2 := httpRequestCheckOk[*download.Task](http.MethodGet, "/api/v1/tasks/"+taskId, nil) if t2.Status != base.DownloadStatusPause { t.Errorf("PauseTask() got = %v, want %v", t2.Status, base.DownloadStatusPause) } time.Sleep(time.Millisecond * 100) httpRequestCheckOk[any](http.MethodPut, "/api/v1/tasks/"+taskId+"/continue", nil) t3 := httpRequestCheckOk[*download.Task](http.MethodGet, "/api/v1/tasks/"+taskId, nil) if t3.Status != base.DownloadStatusRunning { t.Errorf("ContinueTask() got = %v, want %v", t3.Status, base.DownloadStatusRunning) } wg.Wait() want := test.FileMd5(test.BuildFile) got := test.FileMd5(test.DownloadFile) if !reflect.DeepEqual(got, want) { t.Errorf("PauseAndContinueTask() got = %v, want %v", got, want) } }) } func TestPatchTask(t *testing.T) { doTest(func() { // Create a task taskId := httpRequestCheckOk[string](http.MethodPost, "/api/v1/tasks", createReq) if taskId == "" { t.Fatal("create task failed") } // Pause the task httpRequestCheckOk[any](http.MethodPut, "/api/v1/tasks/"+taskId+"/pause", nil) time.Sleep(time.Millisecond * 100) // Patch the task with new labels patchReq := &model.ResolveTask{ Req: &base.Request{ Labels: map[string]string{ "patched": "true", "version": "2", }, }, } httpRequestCheckOk[any](http.MethodPatch, "/api/v1/tasks/"+taskId, patchReq) // Verify the patch was applied task := httpRequestCheckOk[*download.Task](http.MethodGet, "/api/v1/tasks/"+taskId, nil) if task.Meta.Req.Labels["patched"] != "true" { t.Errorf("PatchTask() label 'patched' = %v, want %v", task.Meta.Req.Labels["patched"], "true") } if task.Meta.Req.Labels["version"] != "2" { t.Errorf("PatchTask() label 'version' = %v, want %v", task.Meta.Req.Labels["version"], "2") } // Clean up httpRequestCheckOk[any](http.MethodDelete, "/api/v1/tasks/"+taskId+"?force=true", nil) }) } func TestPatchTaskNotFound(t *testing.T) { doTest(func() { // Try to patch a non-existent task patchReq := &model.ResolveTask{ Req: &base.Request{ Labels: map[string]string{ "test": "value", }, }, } code, _ := httpRequest[any](http.MethodPatch, "/api/v1/tasks/non-existent-id", patchReq) if code != int(model.CodeTaskNotFound) { t.Errorf("PatchTaskNotFound() result code = %v, want %v", code, model.CodeTaskNotFound) } }) } func TestPauseAllAndContinueALLTasks(t *testing.T) { doTest(func() { cfg, err := Downloader.GetConfig() if err != nil { t.Fatal(err) } createAndPause := func() { taskId := httpRequestCheckOk[string](http.MethodPost, "/api/v1/tasks", createReq) time.Sleep(time.Millisecond * 5) httpRequestCheckOk[*download.Task](http.MethodPut, "/api/v1/tasks/"+taskId+"/pause", nil) } total := cfg.MaxRunning + 2 for i := 0; i < total; i++ { createAndPause() } time.Sleep(time.Millisecond * 50) // continue all httpRequestCheckOk[any](http.MethodPut, "/api/v1/tasks/continue", nil) time.Sleep(time.Millisecond * 100) tasks := httpRequestCheckOk[[]*download.Task](http.MethodGet, fmt.Sprintf("/api/v1/tasks?status=%s", base.DownloadStatusRunning), nil) if len(tasks) != cfg.MaxRunning { t.Errorf("ContinueAllTasks() got = %v, want %v", len(tasks), cfg.MaxRunning) } // pause all httpRequestCheckOk[any](http.MethodPut, "/api/v1/tasks/pause", nil) time.Sleep(time.Millisecond * 100) tasks = httpRequestCheckOk[[]*download.Task](http.MethodGet, fmt.Sprintf("/api/v1/tasks?status=%s", base.DownloadStatusPause), nil) if len(tasks) != total { t.Errorf("PauseAllTasks() got = %v, want %v", len(tasks), total) } }) } func TestDeleteTask(t *testing.T) { doTest(func() { taskId := httpRequestCheckOk[string](http.MethodPost, "/api/v1/tasks", createReq) time.Sleep(time.Millisecond * 200) httpRequestCheckOk[any](http.MethodDelete, "/api/v1/tasks/"+taskId, nil) code, _ := httpRequest[*download.Task](http.MethodGet, "/api/v1/tasks/"+taskId, nil) checkCode(code, model.CodeTaskNotFound) }) } func TestDeleteTaskForce(t *testing.T) { doTest(func() { taskId := httpRequestCheckOk[string](http.MethodPost, "/api/v1/tasks", createReq) time.Sleep(time.Millisecond * 200) httpRequestCheckOk[any](http.MethodDelete, "/api/v1/tasks/"+taskId+"?force=true", nil) code, _ := httpRequest[*download.Task](http.MethodGet, "/api/v1/tasks/"+taskId, nil) checkCode(code, model.CodeTaskNotFound) if _, err := os.Stat(test.DownloadFile); !errors.Is(err, os.ErrNotExist) { t.Errorf("DeleteTaskForce() got = %v, want %v", err, os.ErrNotExist) } }) } func TestDeleteAllTasks(t *testing.T) { doTest(func() { taskCount := 3 var wg sync.WaitGroup wg.Add(taskCount) Downloader.Listener(func(event *download.Event) { if event.Key == download.EventKeyFinally { wg.Done() } }) for i := 0; i < taskCount; i++ { httpRequestCheckOk[string](http.MethodPost, "/api/v1/tasks", createReq) } wg.Wait() httpRequestCheckOk[any](http.MethodDelete, "/api/v1/tasks?force=true", nil) tasks := httpRequestCheckOk[[]*download.Task](http.MethodGet, "/api/v1/tasks", nil) if len(tasks) != 0 { t.Errorf("DeleteTasks() got = %v, want %v", len(tasks), 0) } }) } func TestDeleteTasksByStatues(t *testing.T) { doTest(func() { taskCount := 3 var wg sync.WaitGroup wg.Add(taskCount) Downloader.Listener(func(event *download.Event) { if event.Key == download.EventKeyFinally { wg.Done() } }) for i := 0; i < taskCount; i++ { httpRequestCheckOk[string](http.MethodPost, "/api/v1/tasks", createReq) } wg.Wait() httpRequestCheckOk[any](http.MethodDelete, fmt.Sprintf("/api/v1/tasks?status=%s&force=true", base.DownloadStatusDone), nil) tasks := httpRequestCheckOk[[]*download.Task](http.MethodGet, "/api/v1/tasks", nil) if len(tasks) != 0 { t.Errorf("DeleteTasks() got = %v, want %v", len(tasks), 0) } }) } func TestGetTasks(t *testing.T) { doTest(func() { var wg sync.WaitGroup wg.Add(1) Downloader.Listener(func(event *download.Event) { if event.Key == download.EventKeyFinally { wg.Done() } }) httpRequestCheckOk[string](http.MethodPost, fmt.Sprintf("/api/v1/tasks?status=%s&status=%s", base.DownloadStatusReady, base.DownloadStatusRunning), createReq) httpRequestCheckOk[[]*download.Task](http.MethodGet, "/api/v1/tasks", nil) wg.Wait() r := httpRequestCheckOk[[]*download.Task](http.MethodGet, fmt.Sprintf("/api/v1/tasks?status=%s", base.DownloadStatusDone), nil) if r[0].Status != base.DownloadStatusDone { t.Errorf("GetTasks() got = %v, want %v", r[0].Status, base.DownloadStatusDone) } r = httpRequestCheckOk[[]*download.Task](http.MethodGet, fmt.Sprintf("/api/v1/tasks?status=%s,%s", base.DownloadStatusReady, base.DownloadStatusRunning), nil) if len(r) > 0 { t.Errorf("GetTasks() got = %v, want %v", len(r), 0) } }) } func TestGetAndPutConfig(t *testing.T) { doTest(func() { cfg := httpRequestCheckOk[*base.DownloaderStoreConfig](http.MethodGet, "/api/v1/config", nil) cfg.DownloadDir = "./download" cfg.Extra = map[string]any{ "serverConfig": &Config{ Host: "127.0.0.1", Port: 8080, }, "theme": "dark", } httpRequestCheckOk[any](http.MethodPut, "/api/v1/config", cfg) newCfg := httpRequestCheckOk[*base.DownloaderStoreConfig](http.MethodGet, "/api/v1/config", nil) if !test.JsonEqual(cfg, newCfg) { t.Errorf("GetAndPutConfig() got = %v, want %v", test.ToJson(newCfg), test.ToJson(cfg)) } }) } func TestInstallExtension(t *testing.T) { doTest(func() { identity := httpRequestCheckOk[string](http.MethodPost, "/api/v1/extensions", installExtensionReq) if identity == "" { t.Errorf("InstallExtension() got = %v, want %v", identity, "not empty") } // not a valid extension repository code, _ := httpRequest[string](http.MethodPost, "/api/v1/extensions", &model.InstallExtension{ URL: "https://github.com/GopeedLab/gopeed", }) checkCode(code, model.CodeError) // not a git repository code, _ = httpRequest[string](http.MethodPost, "/api/v1/extensions", &model.InstallExtension{ URL: "https://github.com", }) checkCode(code, model.CodeError) }) } func TestGetExtensions(t *testing.T) { doTest(func() { httpRequestCheckOk[string](http.MethodPost, "/api/v1/extensions", installExtensionReq) extensions := httpRequestCheckOk[[]*download.Extension](http.MethodGet, "/api/v1/extensions", nil) if len(extensions) == 0 { t.Errorf("GetExtensions() got = %v, want %v", len(extensions), "not empty") } }) } func TestUpdateExtensionSettings(t *testing.T) { doTest(func() { identity := httpRequestCheckOk[string](http.MethodPost, "/api/v1/extensions", installExtensionReq) httpRequestCheckOk[any](http.MethodPut, "/api/v1/extensions/"+identity+"/settings", &model.UpdateExtensionSettings{ Settings: map[string]any{ "undefined": "test", "ua": "test", }, }) settings := httpRequestCheckOk[*download.Extension](http.MethodGet, "/api/v1/extensions/"+identity, nil).Settings if len(settings) != 1 { t.Errorf("UpdateExtensionSettings() got = %v, want %v", len(settings), 1) } if settings[0].Name != "ua" || settings[0].Value != "test" { t.Errorf("UpdateExtensionSettings() got = %v, want %v", settings[0].Value, "test") } }) } func TestSwitchExtension(t *testing.T) { doTest(func() { identity := httpRequestCheckOk[string](http.MethodPost, "/api/v1/extensions", installExtensionReq) httpRequestCheckOk[any](http.MethodPut, "/api/v1/extensions/"+identity+"/switch", &model.SwitchExtension{ Status: false, }) extensions := httpRequestCheckOk[[]*download.Extension](http.MethodGet, "/api/v1/extensions", nil) if !extensions[0].Disabled { t.Errorf("TestSwitchExtension() got = %v, want %v", extensions[0].Disabled, true) } }) } func TestDeleteExtension(t *testing.T) { doTest(func() { identity := httpRequestCheckOk[string](http.MethodPost, "/api/v1/extensions", installExtensionReq) httpRequestCheckOk[any](http.MethodDelete, "/api/v1/extensions/"+identity, nil) extensions := httpRequestCheckOk[[]*download.Extension](http.MethodGet, "/api/v1/extensions", nil) if len(extensions) != 0 { t.Errorf("TestDeleteExtension() got = %v, want %v", len(extensions), 0) } }) } func TestUpdateCheckExtension(t *testing.T) { doTest(func() { identity := httpRequestCheckOk[string](http.MethodPost, "/api/v1/extensions", installExtensionReq) resp := httpRequestCheckOk[*model.UpdateCheckExtensionResp](http.MethodGet, "/api/v1/extensions/"+identity+"/update", nil) // no new version if resp.NewVersion != "" { t.Errorf("UpdateCheckExtension() got = %v, want %v", resp.NewVersion, "") } // force update httpRequestCheckOk[any](http.MethodPost, "/api/v1/extensions/"+identity+"/update", nil) }) } func TestFsExtension(t *testing.T) { doTest(func() { identity := httpRequestCheckOk[string](http.MethodPost, "/api/v1/extensions", installExtensionReq) statusCode, _ := doHttpRequest0(http.MethodGet, "/fs/extensions/"+identity+"/icon.png", nil, nil) if statusCode != http.StatusOK { t.Errorf("FsExtension() got = %v, want %v", statusCode, http.StatusOK) } }) } func TestFsExtensionFail(t *testing.T) { doTest(func() { statusCode, _ := doHttpRequest0(http.MethodGet, "/fs/extensions/not_exist/icon.png", nil, nil) if statusCode != http.StatusNotFound { t.Errorf("TestFsExtensionFail() got = %v, want %v", statusCode, http.StatusNotFound) } }) } func TestWebFsEnhance(t *testing.T) { indexHtml := ` index

index

` webDistPath := "dist" os.MkdirAll("dist", os.ModePerm) if err := os.WriteFile(filepath.Join(webDistPath, "index.html"), []byte(indexHtml), os.ModePerm); err != nil { panic(err) } defer os.RemoveAll(webDistPath) doTest0(func(cfg *model.StartConfig) { cfg.WebFS = os.DirFS(webDistPath) }, func() { // First request no cache code, header, _ := doHttpRequest1(http.MethodGet, "/index.html", map[string]string{ "Accept-Encoding": "gzip", }, nil) if code != http.StatusOK { t.Errorf("TestWebFsEnhance() got = %v, want %v", code, http.StatusOK) } // Check header last-modified if _, ok := header["Last-Modified"]; !ok { t.Errorf("TestWebFsEnhance() missing key = %v", "Last-Modified") } // Check gzip compress if _, ok := header["Content-Encoding"]; !ok || header["Content-Encoding"] != "gzip" { t.Errorf("TestWebFsEnhance() no gzip compress") } // Request with If-Modified-Since ifModifiedSince := header["Last-Modified"] code, _, _ = doHttpRequest1(http.MethodGet, "/index.html", map[string]string{ "If-Modified-Since": ifModifiedSince, }, nil) if code != http.StatusNotModified { t.Errorf("TestWebFsEnhance() got = %v, want %v", code, http.StatusNotModified) } // Request with un gzip code, header, _ = doHttpRequest1(http.MethodGet, "/index.html?t=123", nil, nil) if code != http.StatusOK { t.Errorf("TestWebFsEnhance() got = %v, want %v", code, http.StatusOK) } // Check no gzip compress if _, ok := header["Content-Encoding"]; ok && header["Content-Encoding"] == "gzip" { t.Errorf("TestWebFsEnhance() has gzip compress") } }) } func TestDoProxy(t *testing.T) { doTest(func() { code, respBody := doHttpRequest0(http.MethodGet, "/api/v1/proxy", map[string]string{ "X-Target-Uri": "https://github.com/GopeedLab/gopeed/raw/695da7ea87d2b455552b709d3cb4d7879484d4d1/README.md", }, nil) if code != http.StatusOK { t.Errorf("DoProxy() got = %v, want %v", code, http.StatusOK) } want := "4ee193b676f1ebb2ad810e016350d52a" got := fmt.Sprintf("%x", md5.Sum(respBody)) if got != want { t.Errorf("DoProxy() got = %v, want %v", got, want) } }) doTest(func() { code, _ := doHttpRequest0(http.MethodGet, "/api/v1/proxy", map[string]string{ "X-Target-Uri": "https://github.com/GopeedLab/gopeed/raw/695da7ea87d2b455552b709d3cb4d7879484d4d1/NOT_FOUND", }, nil) if code != http.StatusNotFound { t.Errorf("DoProxy() got = %v, want %v", code, http.StatusNotFound) } }) } func TestTestWebhook(t *testing.T) { doTest(func() { // Set up a mock webhook server webhookReceived := false var receivedData map[string]interface{} var wg sync.WaitGroup wg.Add(1) webhookServer := http.Server{ Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } body, err := io.ReadAll(r.Body) if err != nil { w.WriteHeader(http.StatusBadRequest) return } if err := json.Unmarshal(body, &receivedData); err != nil { w.WriteHeader(http.StatusBadRequest) return } // Check Content-Type if r.Header.Get("Content-Type") != "application/json" { t.Errorf("TestWebhook() Content-Type got = %v, want %v", r.Header.Get("Content-Type"), "application/json") } webhookReceived = true w.WriteHeader(http.StatusOK) wg.Done() }), } // Start webhook server listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } webhookPort := listener.Addr().(*net.TCPAddr).Port webhookURL := fmt.Sprintf("http://127.0.0.1:%d/webhook", webhookPort) go webhookServer.Serve(listener) defer webhookServer.Close() // Test with valid webhook URL httpRequestCheckOk[any](http.MethodPost, "/api/v1/webhook/test", &model.TestWebhookReq{ URL: webhookURL, }) // Wait for webhook to be received wg.Wait() if !webhookReceived { t.Error("TestWebhook() webhook was not received") } // Verify webhook data structure if receivedData["event"] == nil { t.Error("TestWebhook() missing 'event' field") } if receivedData["time"] == nil { t.Error("TestWebhook() missing 'time' field") } if receivedData["payload"] == nil { t.Error("TestWebhook() missing 'payload' field") } // Test with invalid webhook URL code, _ := httpRequest[any](http.MethodPost, "/api/v1/webhook/test", &model.TestWebhookReq{ URL: "http://invalid-webhook-url-that-does-not-exist.local:99999/webhook", }) checkCode(code, model.CodeError) // Test with empty URL code, _ = httpRequest[any](http.MethodPost, "/api/v1/webhook/test", &model.TestWebhookReq{ URL: "", }) checkCode(code, model.CodeError) // Test with webhook server returning non-200 status wg.Add(1) badWebhookServer := http.Server{ Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) wg.Done() }), } badListener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } badWebhookPort := badListener.Addr().(*net.TCPAddr).Port badWebhookURL := fmt.Sprintf("http://127.0.0.1:%d/webhook", badWebhookPort) go badWebhookServer.Serve(badListener) defer badWebhookServer.Close() code, _ = httpRequest[any](http.MethodPost, "/api/v1/webhook/test", &model.TestWebhookReq{ URL: badWebhookURL, }) checkCode(code, model.CodeError) wg.Wait() }) } func TestApiToken(t *testing.T) { var cfg = &model.StartConfig{} cfg.Init() cfg.ApiToken = "123456" fileListener := doStart(cfg) defer func() { if err := fileListener.Close(); err != nil { panic(err) } Stop() }() status, _ := doHttpRequest0(http.MethodGet, "/api/v1/config", nil, nil) if status != http.StatusUnauthorized { t.Errorf("TestApiToken() got = %v, want %v", status, http.StatusUnauthorized) } status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{ "X-Api-Token": cfg.ApiToken, }, nil) if status != http.StatusOK { t.Errorf("TestApiToken() got = %v, want %v", status, http.StatusOK) } } func TestAuthorization(t *testing.T) { var cfg = &model.StartConfig{} cfg.Init() cfg.ApiToken = "123456" cfg.WebEnable = true cfg.WebAuth = &model.WebAuth{ Username: "admin", Password: "123456", } fileListener := doStart(cfg) defer func() { if err := fileListener.Close(); err != nil { panic(err) } Stop() }() status, _ := doHttpRequest0(http.MethodPost, "/api/web/login", nil, &model.WebAuth{ Username: "xxx", Password: "xxx", }) if status != http.StatusUnauthorized { t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized) } token := httpRequestCheckOk[string](http.MethodPost, "/api/web/login", cfg.WebAuth) authToken := fmt.Sprintf("Bearer %s", token) authHeaders := map[string]string{ "Authorization": authToken, } status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", nil, nil) if status != http.StatusUnauthorized { t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized) } status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{ "Authorization": "xxx", }, nil) if status != http.StatusUnauthorized { t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized) } status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{ "Authorization": "xxx", }, nil) if status != http.StatusUnauthorized { t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized) } buildToken := func(username, password string, ts int64) string { token, _ := aesEncrypt(aesKey, []byte(fmt.Sprintf("%s:%s:%d", username, password, ts))) return token } fakeToken := buildToken("fake", "fake", time.Now().Unix()) status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{ "Authorization": fmt.Sprintf("Bearer %s", fakeToken), }, nil) if status != http.StatusUnauthorized { t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized) } expireToken := buildToken(cfg.WebAuth.Username, cfg.WebAuth.Password, time.Now().Add(-time.Hour*8*24).Unix()) status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{ "Authorization": fmt.Sprintf("Bearer %s", expireToken), }, nil) if status != http.StatusUnauthorized { t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized) } status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", authHeaders, nil) if status != http.StatusOK { t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusOK) } status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{ "X-Api-Token": cfg.ApiToken, }, nil) if status != http.StatusOK { t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusOK) } status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{ "Authorization": authToken, "X-Api-Token": cfg.ApiToken, }, nil) if status != http.StatusOK { t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusOK) } status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{ "Authorization": authToken, "X-Api-Token": "", }, nil) if status != http.StatusUnauthorized { t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized) } } func doTest(handler func()) { doTest0(nil, handler) } func doTest0(onStart func(cfg *model.StartConfig), handler func()) { testFunc := func(storage model.Storage) { var cfg = &model.StartConfig{} cfg.Init() cfg.Storage = storage cfg.StorageDir = ".test_storage" cfg.WebEnable = true if onStart != nil { onStart(cfg) } fileListener := doStart(cfg) defer func() { if err := fileListener.Close(); err != nil { panic(err) } Stop() Downloader.Clear() }() defer func() { time.Sleep(500 * time.Millisecond) Downloader.Pause(nil) Downloader.Delete(nil, true) os.RemoveAll(cfg.StorageDir) }() taskReq.URL = "http://" + fileListener.Addr().String() + "/" + test.BuildName handler() } testFunc(model.StorageMem) testFunc(model.StorageBolt) } func doStart(cfg *model.StartConfig) net.Listener { port, err := Start(cfg) if err != nil { panic(err) } restPort = port return test.StartTestFileServer() } func doHttpRequest0(method string, path string, headers map[string]string, body any) (int, []byte) { r1, _, r3 := doHttpRequest1(method, path, headers, body) return r1, r3 } func doHttpRequest1(method string, path string, headers map[string]string, body any) (int, map[string]string, []byte) { var reader io.Reader if body != nil { buf, _ := json.Marshal(body) reader = bytes.NewBuffer(buf) } request, err := http.NewRequest(method, fmt.Sprintf("http://127.0.0.1:%d%s", restPort, path), reader) if err != nil { panic(err) } if headers != nil { for k, v := range headers { request.Header.Set(k, v) } } response, err := http.DefaultClient.Do(request) if err != nil { panic(err) } defer response.Body.Close() respHeader := make(map[string]string) for k, vv := range response.Header { respHeader[k] = vv[0] } respBody, err := io.ReadAll(response.Body) if err != nil { panic(err) } return response.StatusCode, respHeader, respBody } func doHttpRequest[T any](method string, path string, headers map[string]string, body any) (int, *model.Result[T]) { statusCode, respBody := doHttpRequest0(method, path, headers, body) if statusCode != http.StatusOK { panic(fmt.Sprintf("http request failed, status code: %d", statusCode)) } var r model.Result[T] if err := json.Unmarshal(respBody, &r); err != nil { panic(err) } return int(r.Code), &r } func httpRequest[T any](method string, path string, body any) (int, *model.Result[T]) { return doHttpRequest[T](method, path, nil, body) } func httpRequestCheckOk[T any](method string, path string, body any) T { code, result := httpRequest[T](method, path, body) checkOk(code) return result.Data } func checkOk(code int) { checkCode(code, model.CodeOk) } func checkCode(code int, exceptCode model.RespCode) { if code != int(exceptCode) { panic(fmt.Sprintf("code got = %d, want %d", code, exceptCode)) } } ================================================ FILE: pkg/util/bytefmt.go ================================================ package util import ( "fmt" "math" ) const unknownSize = "unknown" var unitArr = []string{"B", "KB", "MB", "GB", "TB", "PB", "EB"} func ByteFmt(size int64) string { if size == 0 { return unknownSize } // Handle negative values if size < 0 { return unknownSize } fs := float64(size) p := int(math.Log(fs) / math.Log(1024)) // Ensure index is within bounds if p < 0 { p = 0 } if p >= len(unitArr) { p = len(unitArr) - 1 } val := fs / math.Pow(1024, float64(p)) _, frac := math.Modf(val) if frac > 0 { return fmt.Sprintf("%.1f%s", math.Floor(val*10)/10, unitArr[p]) } else { return fmt.Sprintf("%d%s", int(val), unitArr[p]) } } ================================================ FILE: pkg/util/bytefmt_test.go ================================================ package util import "testing" func TestByteFmt(t *testing.T) { type args struct { size int64 } tests := []struct { name string args args want string }{ { name: "unknown", args: args{size: int64(0)}, want: unknownSize, }, { name: "negative value", args: args{size: int64(-1)}, want: unknownSize, }, { name: "negative min int64", args: args{size: int64(-9223372036854775808)}, want: unknownSize, }, { name: "100B", args: args{size: int64(100)}, want: "100B", }, { name: "1KB", args: args{size: int64(1024)}, want: "1KB", }, { name: "1.9KB", args: args{size: int64(1024*2 - 1)}, want: "1.9KB", }, { name: "2KB", args: args{size: int64(1024 * 2)}, want: "2KB", }, { name: "1MB", args: args{size: int64(1024 * 1024)}, want: "1MB", }, { name: "1.9MB", args: args{size: int64(1024*1024*2 - 1)}, want: "1.9MB", }, { name: "2MB", args: args{size: int64(1024 * 1024 * 2)}, want: "2MB", }, { name: "large value", args: args{size: int64(9223372036854775807)}, // max int64 want: "8EB", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := ByteFmt(tt.args.size); got != tt.want { t.Errorf("ByteFmt() = %v, want %v", got, tt.want) } }) } } ================================================ FILE: pkg/util/json.go ================================================ package util import "encoding/json" func MapToStruct(s any, v any) error { if s == nil { return nil } b, err := json.Marshal(s) if err != nil { return err } return json.Unmarshal(b, v) } func DeepClone[T any](v *T) *T { if v == nil { return nil } var t T b, err := json.Marshal(v) if err != nil { return &t } json.Unmarshal(b, &t) return &t } // Ptr returns a pointer to the given value. func Ptr[T any](v T) *T { return &v } // BoolPtr returns a pointer to a bool value. func BoolPtr(v bool) *bool { return &v } ================================================ FILE: pkg/util/json_test.go ================================================ package util import ( "reflect" "testing" ) func TestDeepClone(t *testing.T) { type user struct { Name string `json:"name"` Age int `json:"age"` v int } type args[T any] struct { v *T } type testCase[T any] struct { name string args args[T] want *T } tests := []testCase[user]{ { name: "case 1", args: args[user]{ v: &user{ Name: "test", Age: 10, }, }, want: &user{ Name: "test", Age: 10, }, }, { name: "case 2", args: args[user]{ v: &user{ Name: "test", Age: 10, v: 1, }, }, want: &user{ Name: "test", Age: 10, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := DeepClone(tt.args.v); !reflect.DeepEqual(got, tt.want) { t.Errorf("DeepClone() = %v, want %v", got, tt.want) } }) } } ================================================ FILE: pkg/util/matcher.go ================================================ package util import ( "net/url" "regexp" "strings" ) // Match url with pattern by chrome extension match pattern style // https://developer.chrome.com/docs/extensions/mv3/match_patterns/ func Match(pattern string, u string) bool { scheme, host, path := parsePattern(pattern) url, err := url.Parse(u) if err != nil { return false } if scheme != "*" && scheme != url.Scheme { return false } if !matchHost(host, url.Hostname()) { return false } if !matchPath(path, url.Path) { return false } return true } func parsePattern(pattern string) (scheme string, host string, path string) { parts := strings.Split(pattern, "://") if len(parts) == 2 { scheme = parts[0] pattern = parts[1] } else { scheme = "" } parts = strings.SplitN(pattern, "/", 2) if len(parts) == 2 { host = parts[0] path = "/" + parts[1] } else { host = pattern path = "/" } return } func matchHost(pattern string, host string) bool { if pattern == "*" { return true } if strings.HasPrefix(pattern, "*.") { return strings.HasSuffix(host, pattern[1:]) } return pattern == host } func matchPath(pattern string, path string) bool { if pattern == "*" { return true } if !strings.HasSuffix(pattern, "*") && !strings.HasSuffix(pattern, "/") { pattern += "/" } if !strings.HasSuffix(path, "/") { path += "/" } if strings.Contains(pattern, "*") { pattern = strings.Replace(pattern, "*", ".*", -1) matched, _ := regexp.MatchString("^"+pattern+"$", path) return matched } return pattern == path } ================================================ FILE: pkg/util/matcher_test.go ================================================ package util import "testing" func TestMatch(t *testing.T) { tests := []struct { pattern string urls []string want bool }{ {"*://*/*", []string{"https://www.google.com/", "http://example.org/foo/bar.html"}, true}, {"https://*/*", []string{"https://www.google.com", "https://example.org/foo/bar.html"}, true}, {"*://www.google.com", []string{"https://www.google.com/", "https://www.google.com"}, true}, {"*://*.google.com/", []string{"https://a.www.google.com/", "https://c.www.google.com/", "https://www.google.com/"}, true}, {"https://*/foo*", []string{"https://www.google.com/foo", "https://example.com/foo/bar.html"}, true}, {"https://www.google.com/*/b/*", []string{"https://www.google.com/a/b", "https://www.google.com/a/b/c"}, true}, {"https://*.google.com/foo*bar", []string{"https://www.google.com/foo/baz/bar", "https://docs.google.com/foobar"}, true}, {"https://www.google.com/*abc*", []string{"https://www.google.com/abc", "https://www.google.com/123abc", "https://www.google.com/abc456", "https://www.google.com/123abc456"}, true}, {"https://example.org/foo/bar.html", []string{"https://example.org/foo/bar.html"}, true}, {"http://127.0.0.1/*", []string{"http://127.0.0.1/", "http://127.0.0.1/foo/bar.html"}, true}, {"*://mail.google.com/*", []string{"http://mail.google.com/foo/baz/bar", "https://mail.google.com/foobar"}, true}, {"https://www.google.com/", []string{"http://www.google.com/"}, false}, {"www.google.com/", []string{"http://www.google.com/", "https://www.google.com/"}, false}, {"www.google.com/*c", []string{"https://www.google.com/a", "https://www.google.com/b"}, false}, {"https://*.example.org/*", []string{"https://www.google.com", "https://docs.google.com"}, false}, } for _, tt := range tests { t.Run(tt.pattern, func(t *testing.T) { for _, url := range tt.urls { if got := Match(tt.pattern, url); got != tt.want { t.Errorf("Match() = %v, want %v", got, tt.want) } } }) } } ================================================ FILE: pkg/util/path.go ================================================ package util import ( "errors" "fmt" "io" "os" syspath "path" "path/filepath" "strings" "time" "unicode/utf8" ) func Dir(path string) string { dir := syspath.Dir(path) if dir == "." { return "" } return dir } func Filepath(path string, originName string, customName string) string { if customName == "" { customName = originName } return syspath.Join(path, customName) } // SafeRemove remove file safely, ignoring errors if the path does not exist. func SafeRemove(name string) error { if err := os.Remove(name); err != nil && !errors.Is(err, os.ErrNotExist) { return err } return nil } // CheckDuplicateAndRename rename duplicate file, add suffix (1) (2) ... // if file name is a.txt, rename to a (1).txt // if directory name is a, rename to a (1) // return new name func CheckDuplicateAndRename(path string) (string, error) { dir := syspath.Dir(path) name := syspath.Base(path) // if file not exists, return directly _, err := os.Stat(path) if err != nil { if errors.Is(err, os.ErrNotExist) { return name, nil } return "", err } ext := syspath.Ext(name) var nameTpl string // Special case: if the extension is the entire filename (like .gitignore), // or if index of last dot is 0 (starts with dot), treat it as no extension if ext == "" || ext == name || (len(ext) > 0 && strings.LastIndex(name, ".") == 0) { // No extension or hidden file without extension nameTpl = name + " (%d)" } else { // Has extension nameWithoutExt := name[:len(name)-len(ext)] nameTpl = nameWithoutExt + " (%d)" + ext } for i := 1; ; i++ { newName := fmt.Sprintf(nameTpl, i) newPath := syspath.Join(dir, newName) if _, err := os.Stat(newPath); os.IsNotExist(err) { return newName, nil } } } // CopyDir Copy all files to the target directory, if the file already exists, it will be overwritten. // Remove target file if the source file is not exist. func CopyDir(source string, target string, excludeDir ...string) error { if err := os.MkdirAll(target, 0755); err != nil { return err } if err := filepath.Walk(source, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if info.IsDir() { if len(excludeDir) > 0 { for _, dir := range excludeDir { if info.IsDir() && info.Name() == dir { return filepath.SkipDir } } } return nil } relPath, err := filepath.Rel(source, path) if err != nil { return err } targetPath := filepath.Join(target, relPath) if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { return err } if err := copyForce(path, targetPath); err != nil { return err } return nil }); err != nil { return err } if err := filepath.Walk(target, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if info.IsDir() { if len(excludeDir) > 0 { for _, dir := range excludeDir { if info.IsDir() && info.Name() == dir { return filepath.SkipDir } } } return nil } relPath, err := filepath.Rel(target, path) if err != nil { return err } targetPath := filepath.Join(target, relPath) sourcePath := filepath.Join(source, relPath) // if source file is not exist, remove target file if _, err := os.Stat(sourcePath); os.IsNotExist(err) { if err := SafeRemove(targetPath); err != nil { return err } } return nil }); err != nil { return err } return nil } // copy file, if the target file already exists, it will be overwritten. func copyForce(source string, target string) error { sourceFile, err := os.Open(source) if err != nil { return err } defer sourceFile.Close() targetFile, err := os.Create(target) if err != nil { return err } defer targetFile.Close() _, err = io.Copy(targetFile, sourceFile) if err != nil { return err } return nil } func CreateDirIfNotExist(dir string) error { if _, err := os.Stat(dir); os.IsNotExist(err) { return os.MkdirAll(dir, 0o777) } return nil } // IsExistsFile check file exists and is a file func IsExistsFile(path string) bool { info, err := os.Stat(path) // if file exists and is a file if err == nil && !info.IsDir() { return true } return false } const ( // MaxFilenameLength is the maximum length in bytes for a filename MaxFilenameLength = 100 // maxExtensionLength is the maximum length in bytes for a file extension // to be treated as a valid extension. Extensions longer than this are // treated as part of the filename to avoid edge cases. maxExtensionLength = 20 ) // SafeFilename sanitizes a filename by replacing invalid characters and truncating to a safe length. // It performs two operations: // 1. Replaces invalid path characters (platform-specific) with underscores // 2. Truncates filename to MaxFilenameLength bytes while preserving the file extension // The function handles UTF-8 multi-byte characters correctly by truncating at valid boundaries. func SafeFilename(filename string) string { if filename == "" { return "" } // Step 1: Replace invalid characters for _, char := range invalidPathChars { filename = strings.ReplaceAll(filename, char, "_") } // Step 2: Truncate if needed if len(filename) <= MaxFilenameLength { return filename } // Find the extension (last dot in filename) ext := "" lastDot := strings.LastIndex(filename, ".") // Only treat as extension if: // 1. There is a dot // 2. The dot is not at the start (not a hidden file like .gitignore) // 3. The extension is reasonable length (< maxExtensionLength bytes) to avoid edge cases if lastDot > 0 && lastDot < len(filename)-1 && len(filename)-lastDot < maxExtensionLength { ext = filename[lastDot:] filename = filename[:lastDot] } // Calculate how much space we have for the base name availableLength := MaxFilenameLength - len(ext) // Ensure we have at least some space for the base name if availableLength < 1 { // Extension itself is too long or no room, just truncate everything at byte boundary return truncateAtValidUTF8Boundary(filename+ext, MaxFilenameLength) } // Truncate the base name at a valid UTF-8 boundary truncatedBase := truncateAtValidUTF8Boundary(filename, availableLength) return truncatedBase + ext } // ReplaceInvalidFilename replace invalid path characters // Deprecated: Use SafeFilename instead which also handles length truncation func ReplaceInvalidFilename(path string) string { if path == "" { return "" } for _, char := range invalidPathChars { path = strings.ReplaceAll(path, char, "_") } return path } // TruncateFilename truncates a filename to a maximum byte length while preserving the extension. // Deprecated: Use SafeFilename instead which also handles invalid character replacement func TruncateFilename(filename string, maxLength int) string { // If already short enough, return as-is if len(filename) <= maxLength { return filename } // Find the extension (last dot in filename) ext := "" lastDot := strings.LastIndex(filename, ".") // Only treat as extension if: // 1. There is a dot // 2. The dot is not at the start (not a hidden file like .gitignore) // 3. The extension is reasonable length (< maxExtensionLength bytes) to avoid edge cases if lastDot > 0 && lastDot < len(filename)-1 && len(filename)-lastDot < maxExtensionLength { ext = filename[lastDot:] filename = filename[:lastDot] } // Calculate how much space we have for the base name availableLength := maxLength - len(ext) // Ensure we have at least some space for the base name if availableLength < 1 { // Extension itself is too long or no room, just truncate everything at byte boundary return truncateAtValidUTF8Boundary(filename+ext, maxLength) } // Truncate the base name at a valid UTF-8 boundary truncatedBase := truncateAtValidUTF8Boundary(filename, availableLength) return truncatedBase + ext } // truncateAtValidUTF8Boundary truncates a string to at most maxBytes, // ensuring we don't cut in the middle of a UTF-8 character func truncateAtValidUTF8Boundary(s string, maxBytes int) string { if len(s) <= maxBytes { return s } // Truncate at byte position truncated := s[:maxBytes] // Find the last valid UTF-8 character boundary // Walk backwards to find where the last complete character ends for len(truncated) > 0 { // Check if this is a valid UTF-8 string if utf8.ValidString(truncated) { return truncated } // Remove one byte and try again truncated = truncated[:len(truncated)-1] } return truncated } // ReplacePathPlaceholders replaces date placeholders in a path with actual values // Supported placeholders: // - %year% - Current year (e.g., 2025) // - %month% - Current month (01-12) // - %day% - Current day (01-31) // - %date% - Full date format (2025-01-01) func ReplacePathPlaceholders(path string) string { if path == "" { return "" } now := time.Now() year := fmt.Sprintf("%d", now.Year()) month := fmt.Sprintf("%02d", now.Month()) day := fmt.Sprintf("%02d", now.Day()) date := fmt.Sprintf("%s-%s-%s", year, month, day) path = strings.ReplaceAll(path, "%year%", year) path = strings.ReplaceAll(path, "%month%", month) path = strings.ReplaceAll(path, "%day%", day) path = strings.ReplaceAll(path, "%date%", date) return path } ================================================ FILE: pkg/util/path_other.go ================================================ //go:build !windows // +build !windows package util var invalidPathChars = []string{`/`, `:`} ================================================ FILE: pkg/util/path_test.go ================================================ package util import ( "fmt" "os" "strings" "testing" "time" ) func TestDir(t *testing.T) { type args struct { path string } tests := []struct { name string args args want string }{ { name: "empty path", args: args{ path: ".", }, want: "", }, { name: "normal path case 1", args: args{ path: "./a/b/c/1.txt", }, want: "a/b/c", }, { name: "normal path case 2", args: args{ path: "a/b/c/1.txt", }, want: "a/b/c", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := Dir(tt.args.path); got != tt.want { t.Errorf("Dir() = %v, want %v", got, tt.want) } }) } } func TestFilepath(t *testing.T) { type args struct { path string originName string customName string } tests := []struct { name string args args want string }{ { name: "origin name", args: args{ path: "/Downloads", originName: "1.txt", customName: "", }, want: "/Downloads/1.txt", }, { name: "origin name", args: args{ path: "/Downloads", originName: "1.txt", customName: "2.txt", }, want: "/Downloads/2.txt", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := Filepath(tt.args.path, tt.args.originName, tt.args.customName); got != tt.want { t.Errorf("SingleFilepath() = %v, want %v", got, tt.want) } }) } } func TestSafeRemove(t *testing.T) { name := "test_safe_remove.data" file, err := os.Create(name) if err != nil { t.Fatal(err) } if err := file.Close(); err != nil { t.Fatal(err) } if err := SafeRemove(name); err != nil { t.Fatal(err) } if _, err := os.Stat(name); err == nil { t.Fatal(err) } if err := SafeRemove("test_safe_remove_not_exist.data"); err != nil { t.Fatal(err) } } func TestCheckDuplicateAndRename(t *testing.T) { // Test with extension doCheckDuplicateAndRename(t, []string{}, "a.txt", "a.txt") doCheckDuplicateAndRename(t, []string{"a.txt"}, "a.txt", "a (1).txt") doCheckDuplicateAndRename(t, []string{"a.txt", "a (1).txt"}, "a.txt", "a (2).txt") // Test without extension doCheckDuplicateAndRename(t, []string{}, "a", "a") doCheckDuplicateAndRename(t, []string{"a"}, "a", "a (1)") doCheckDuplicateAndRename(t, []string{"a", "a (1)"}, "a", "a (2)") // Test hidden files (starting with dot) doCheckDuplicateAndRename(t, []string{}, ".gitignore", ".gitignore") doCheckDuplicateAndRename(t, []string{".gitignore"}, ".gitignore", ".gitignore (1)") doCheckDuplicateAndRename(t, []string{".gitignore", ".gitignore (1)"}, ".gitignore", ".gitignore (2)") // Test hidden files with extension doCheckDuplicateAndRename(t, []string{}, ".config.json", ".config.json") doCheckDuplicateAndRename(t, []string{".config.json"}, ".config.json", ".config (1).json") // Test multiple dots doCheckDuplicateAndRename(t, []string{}, "test.tar.gz", "test.tar.gz") doCheckDuplicateAndRename(t, []string{"test.tar.gz"}, "test.tar.gz", "test.tar (1).gz") } func doCheckDuplicateAndRename(t *testing.T, exitsPaths []string, path string, except string) { for _, path := range exitsPaths { if err := os.MkdirAll(path, 0755); err != nil { t.Fatal(err) } } defer func() { for _, path := range exitsPaths { if err := os.RemoveAll(path); err != nil { t.Fatal(err) } } }() got, err := CheckDuplicateAndRename(path) if err != nil { t.Fatal(err) } if got != except { t.Errorf("CheckDuplicateAndRename() = %v, want %v", got, except) } } func TestIsExistsFile(t *testing.T) { type args struct { path string } tests := []struct { name string args args want bool }{ { name: "exist", args: args{ path: "./path.go", }, want: true, }, { name: "not exist", args: args{ path: "./path_not_exist.go", }, want: false, }, { name: "is dir", args: args{ path: "../util", }, want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := IsExistsFile(tt.args.path); got != tt.want { t.Errorf("IsExistsFile() = %v, want %v", got, tt.want) } }) } } func TestReplaceInvalidFilename(t *testing.T) { type args struct { path string } tests := []struct { name string args args want string }{ { name: "blank", args: args{ path: "", }, want: "", }, { name: "normal", args: args{ path: "test.txt", }, want: "test.txt", }, { name: "case1", args: args{ path: "te/st.txt", }, want: "te_st.txt", }, { name: "case2", args: args{ path: "te/st:.txt", }, want: "te_st_.txt", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := ReplaceInvalidFilename(tt.args.path); got != tt.want { t.Errorf("ReplaceInvalidFilename() = %v, want %v", got, tt.want) } }) } } // TestSafeFilename tests the combined filename sanitization functionality func TestSafeFilename(t *testing.T) { tests := []struct { name string filename string want string }{ { name: "short filename - no changes needed", filename: "test.txt", want: "test.txt", }, { name: "empty filename", filename: "", want: "", }, { name: "invalid chars only", filename: "te/st:file.txt", want: "te_st_file.txt", }, { name: "long filename only", filename: "this_is_a_very_long_filename_that_exceeds_the_maximum_allowed_length_and_should_be_truncated_properly.txt", want: "this_is_a_very_long_filename_that_exceeds_the_maximum_allowed_length_and_should_be_truncated_pro.txt", }, { name: "both invalid chars and too long", filename: "path/to/very:long*filename?thatfilesystem|limits_and_has_invalid_characters_everywhere.pdf", want: "path_to_very_long*filename?thatfilesystem|limits_and_has_invalid_characters_everywhere.pdf", }, { name: "unicode with invalid chars and truncation", filename: "测试/文件名:非常长的中文文件名_需要被截断_这是一个测试用的超长文件名.pdf", want: "测试_文件名_非常长的中文文件名_需要被截断_这是一个测试用的超长文.pdf", }, { name: "hidden file with truncation", filename: ".gitignore_with_very_long_name_that_needs_truncation_and_more_characters_to_exceed_the_maximum_length", want: ".gitignore_with_very_long_name_that_needs_truncation_and_more_characters_to_exceed_the_maximum_lengt", }, { name: "multiple dots and invalid chars", filename: "archive/tar.gz.backup:old.txt", want: "archive_tar.gz.backup_old.txt", }, { name: "extension longer than reasonable", filename: "test.verylongextensionthatshouldnotbetreatedasextension_with_more_characters_to_exceed_maximum_length", want: "test.verylongextensionthatshouldnotbetreatedasextension_with_more_characters_to_exceed_maximum_lengt", }, { name: "filename with spaces and invalid chars", filename: "my document/with:spaces and a very long name that needs to be truncated because it exceeds the maximum length.docx", want: "my document_with_spaces and a very long name that needs to be truncated because it exceeds the .docx", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := SafeFilename(tt.filename) if got != tt.want { t.Errorf("SafeFilename() = %q (len=%d), want %q (len=%d)", got, len(got), tt.want, len(tt.want)) } // Verify result doesn't exceed MaxFilenameLength if len(got) > MaxFilenameLength { t.Errorf("SafeFilename() result length %d exceeds MaxFilenameLength %d", len(got), MaxFilenameLength) } }) } } func TestReplacePathPlaceholders(t *testing.T) { now := time.Now() year := fmt.Sprintf("%d", now.Year()) month := fmt.Sprintf("%02d", now.Month()) day := fmt.Sprintf("%02d", now.Day()) date := fmt.Sprintf("%s-%s-%s", year, month, day) tests := []struct { name string path string want string }{ { name: "empty path", path: "", want: "", }, { name: "no placeholders", path: "/home/user/Downloads", want: "/home/user/Downloads", }, { name: "year placeholder", path: "/Downloads/%year%", want: "/Downloads/" + year, }, { name: "month placeholder", path: "/Downloads/%month%", want: "/Downloads/" + month, }, { name: "day placeholder", path: "/Downloads/%day%", want: "/Downloads/" + day, }, { name: "date placeholder", path: "/Downloads/%date%", want: "/Downloads/" + date, }, { name: "multiple placeholders", path: "/Downloads/%year%-%month%", want: "/Downloads/" + year + "-" + month, }, { name: "mixed path with placeholders", path: "/home/user/Downloads/%year%/%month%/%day%", want: "/home/user/Downloads/" + year + "/" + month + "/" + day, }, { name: "windows style path", path: "D:\\Downloads\\%year%-%month%", want: "D:\\Downloads\\" + year + "-" + month, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := ReplacePathPlaceholders(tt.path) if !strings.Contains(got, year) && tt.path != "" && strings.Contains(tt.path, "%year%") { t.Errorf("ReplacePathPlaceholders() = %v, want containing year %v", got, year) } if got != tt.want { t.Errorf("ReplacePathPlaceholders() = %v, want %v", got, tt.want) } }) } } // TestTruncateFilename tests the filename truncation functionality func TestTruncateFilename(t *testing.T) { tests := []struct { name string filename string maxLength int want string }{ { name: "short filename - no truncation", filename: "test.txt", maxLength: 100, want: "test.txt", }, { name: "filename at exact limit", filename: "abcdefghij.txt", // 14 chars maxLength: 14, want: "abcdefghij.txt", }, { name: "long filename with extension - truncate base", filename: "this_is_a_very_long_filename_that_exceeds_the_maximum_allowed_length_and_should_be_truncated_properly.txt", maxLength: 100, want: "this_is_a_very_long_filename_that_exceeds_the_maximum_allowed_length_and_should_be_truncated_pro.txt", }, { name: "long filename without extension", filename: "this_is_a_very_long_filename_without_extension_that_exceeds_maximum_length_and_needs_truncation", maxLength: 100, want: "this_is_a_very_long_filename_without_extension_that_exceeds_maximum_length_and_needs_truncation", }, { name: "filename with multiple dots", filename: "archive.tar.gz.backup.old.file.with.many.dots.txt", maxLength: 30, want: "archive.tar.gz.backup.old..txt", // Preserves last extension }, { name: "hidden file (starts with dot)", filename: ".gitignore_with_very_long_name_that_needs_truncation", maxLength: 30, want: ".gitignore_with_very_long_name", }, { name: "only extension (no base name)", filename: ".txt", maxLength: 100, want: ".txt", }, { name: "unicode characters in filename", filename: "测试文件名_非常长的中文文件名_需要被截断_这是一个测试用的超长文件名.pdf", maxLength: 50, want: "测试文件名_非常长的中文文件名_.pdf", // Truncated at byte boundary, preserving UTF-8 }, { name: "extension longer than reasonable (>20 chars)", filename: "test.verylongextensionthatshouldnotbetreatedasextension", maxLength: 30, want: "test.verylongextensionthatshou", }, { name: "very short max length with extension", filename: "document.pdf", maxLength: 10, want: "docume.pdf", }, { name: "maxLength smaller than extension", filename: "test.pdf", maxLength: 3, want: "tes", }, { name: "empty filename", filename: "", maxLength: 100, want: "", }, { name: "filename with spaces", filename: "my document with spaces and a very long name that needs to be truncated.docx", maxLength: 50, want: "my document with spaces and a very long name .docx", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := TruncateFilename(tt.filename, tt.maxLength) if got != tt.want { t.Errorf("TruncateFilename() = %q (len=%d), want %q (len=%d)", got, len(got), tt.want, len(tt.want)) } // Verify result doesn't exceed maxLength if len(got) > tt.maxLength { t.Errorf("TruncateFilename() result length %d exceeds maxLength %d", len(got), tt.maxLength) } }) } } ================================================ FILE: pkg/util/path_windows.go ================================================ package util var invalidPathChars = []string{`\`, `/`, `:`, `*`, `?`, `"`, `<`, `>`, `|`} ================================================ FILE: pkg/util/timer.go ================================================ package util import "time" type Timer struct { t int64 used int64 } func NewTimer(used int64) *Timer { return &Timer{ used: used, } } func (t *Timer) Start() { t.t = time.Now().UnixNano() } func (t *Timer) Pause() { t.used += time.Now().UnixNano() - t.t } func (t *Timer) Used() int64 { return (time.Now().UnixNano() - t.t) + t.used } ================================================ FILE: pkg/util/url.go ================================================ package util import ( "encoding/base64" "net/http" "net/url" "regexp" "strings" ) func ParseSchema(url string) string { index := strings.Index(url, ":") if index == -1 || index == 1 { return "" } schema := url[:index] return strings.ToUpper(schema) } // ParseDataUri parses a data URI and returns the MIME type and decode data. func ParseDataUri(uri string) (string, []byte) { re := regexp.MustCompile(`^data:(.*);base64,(.*)$`) matches := re.FindStringSubmatch(uri) if len(matches) != 3 { return "", nil } mime := matches[1] base64Data := matches[2] data, err := base64.StdEncoding.DecodeString(base64Data) if err != nil { return "", nil } return mime, data } // BuildProxyUrl builds a proxy url with given host, username and password. func BuildProxyUrl(scheme, host, usr, pwd string) *url.URL { var user *url.Userinfo if usr != "" && pwd != "" { user = url.UserPassword(usr, pwd) } return &url.URL{ Scheme: scheme, User: user, Host: host, } } // ProxyUrlToHandler gets the proxy handler from the proxy url. func ProxyUrlToHandler(proxyUrl *url.URL) func(*http.Request) (*url.URL, error) { if proxyUrl == nil { return nil } if proxyUrl.Scheme == "system" { return http.ProxyFromEnvironment } return http.ProxyURL(proxyUrl) } // TryUrlQueryUnescape tries to unescape a URL-encoded string. // // If unescaping fails, it returns the original string. func TryUrlQueryUnescape(s string) string { if decoded, err := url.QueryUnescape(s); err == nil { return decoded } return s } // TryUrlPathUnescape tries to unescape a URL path-encoded string. // Unlike QueryUnescape, PathUnescape does not treat '+' as a space. // This is the correct function to use for decoding URL paths and filenames // where %2B should decode to '+', not to a space. // // If unescaping fails, it returns the original string. func TryUrlPathUnescape(s string) string { if decoded, err := url.PathUnescape(s); err == nil { return decoded } return s } ================================================ FILE: pkg/util/url_test.go ================================================ package util import ( "encoding/base64" "reflect" "testing" ) func TestParseSchema(t *testing.T) { type args struct { url string } tests := []struct { name string args args want string }{ { name: "http", args: args{ url: "http://www.google.com", }, want: "HTTP", }, { name: "https", args: args{ url: "https://www.google.com", }, want: "HTTPS", }, { name: "file", args: args{ url: "file:///home/bt.torrent", }, want: "FILE", }, { name: "file-no-scheme", args: args{ url: "./url.go", }, want: "", }, { name: "data-uri", args: args{ url: "data:application/x-bittorrent;base64,test", }, want: "DATA", }, { name: "windows-path", args: args{ url: "D:\\bt.torrent", }, want: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := ParseSchema(tt.args.url); got != tt.want { t.Errorf("ParseSchema() = %v, want %v", got, tt.want) } }) } } func TestParseDataUri(t *testing.T) { type args struct { uri string } type result struct { mime string data []byte } testData := []byte("test") testData64 := base64.StdEncoding.EncodeToString(testData) tests := []struct { name string args args want result }{ { name: "success", args: args{ uri: "data:application/x-bittorrent;base64," + testData64, }, want: result{ mime: "application/x-bittorrent", data: testData, }, }, { name: "fail-dirty-data", args: args{ uri: "data::application/x-bittorrent;base64,!@$", }, want: result{ mime: "", data: nil, }, }, { name: "fail-miss-data", args: args{ uri: ":application/x-bittorrent;base64," + testData64, }, want: result{ mime: "", data: nil, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mime, data := ParseDataUri(tt.args.uri) got := result{ mime: mime, data: data, } if !reflect.DeepEqual(got, tt.want) { t.Errorf("ParseDataUri() = %v, want %v", got, tt.want) } }) } } func TestTryURLDecode(t *testing.T) { tests := []struct { input string expected string }{ {"normal.txt", "normal.txt"}, {"%E7%8F%80%E5%B0%94%E8%AF%BA.zip", "珀尔诺.zip"}, {"hello%20world.txt", "hello world.txt"}, {"bad%2-text", "bad%2-text"}, } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { got := TryUrlQueryUnescape(tt.input) if got != tt.expected { t.Errorf("TryUrlQueryUnescape(%q) = %q, want %q", tt.input, got, tt.expected) } }) } } func TestTryUrlPathUnescape(t *testing.T) { tests := []struct { input string expected string }{ {"normal.txt", "normal.txt"}, {"%E7%8F%80%E5%B0%94%E8%AF%BA.zip", "珀尔诺.zip"}, {"hello%20world.txt", "hello world.txt"}, {"bad%2-text", "bad%2-text"}, // The key difference: %2B should decode to + (not space) {"C%2B%2B%20Primer.txt", "C++ Primer.txt"}, {"test%2Bfile.txt", "test+file.txt"}, // Plus sign in path should remain as-is (not decoded to space) {"test+plus.txt", "test+plus.txt"}, // Mixed encoding {"C%2B%2B%20%20Primer%20%20Plus.mobi", "C++ Primer Plus.mobi"}, } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { got := TryUrlPathUnescape(tt.input) if got != tt.expected { t.Errorf("TryUrlPathUnescape(%q) = %q, want %q", tt.input, got, tt.expected) } }) } } ================================================ FILE: ui/flutter/.gitignore ================================================ # Miscellaneous *.class *.log *.pyc *.swp .DS_Store .atom/ .buildlog/ .history .svn/ # IntelliJ related *.iml *.ipr *.iws .idea/ # The .vscode folder contains launch configuration and tasks you configure in # VS Code which you may wish to be included in version control, so this line # is commented out by default. #.vscode/ # Flutter/Dart/Pub related **/doc/api/ **/ios/Flutter/.last_build_id .dart_tool/ .flutter-plugins .flutter-plugins-dependencies .packages .pub-cache/ .pub/ build/ .fvm/ # Web related # Symbolication related app.*.symbols # Obfuscation related app.*.map.json # Exceptions to above rules. !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages # Libgopeed windows/libgopeed.h windows/libgopeed.dll macos/Frameworks/libgopeed.h macos/Frameworks/libgopeed.dylib linux/bundle/lib/libgopeed.h linux/bundle/lib/libgopeed.dylib android/app/libs/libgopeed.aar android/app/libs/libgopeed-sources.jar debian/packages linux/flutter/generated_plugin_registrant.cc linux/flutter/generated_plugin_registrant.h linux/flutter/generated_plugins.cmake macos/Flutter/GeneratedPluginRegistrant.swift windows/flutter/generated_plugin_registrant.cc windows/flutter/generated_plugin_registrant.h windows/flutter/generated_plugins.cmake /extensions # Hive database files database.hive database.lock assets/exec/host assets/exec/host.exe assets/exec/updater assets/exec/updater.exe ================================================ FILE: ui/flutter/.metadata ================================================ # This file tracks properties of this Flutter project. # Used by Flutter tool to assess capabilities and perform upgrades etc. # # This file should be version controlled. version: revision: 7048ed95a5ad3e43d697e0c397464193991fc230 channel: stable project_type: app # Tracks metadata for the flutter migrate command migration: platforms: - platform: root create_revision: 7048ed95a5ad3e43d697e0c397464193991fc230 base_revision: 7048ed95a5ad3e43d697e0c397464193991fc230 - platform: windows create_revision: 7048ed95a5ad3e43d697e0c397464193991fc230 base_revision: 7048ed95a5ad3e43d697e0c397464193991fc230 # User provided section # List of Local paths (relative to this file) that should be # ignored by the migrate tool. # # Files that are not part of the templates will be ignored by default. unmanaged_files: - 'lib/main.dart' - 'ios/Runner.xcodeproj/project.pbxproj' ================================================ FILE: ui/flutter/analysis_options.yaml ================================================ # This file configures the analyzer, which statically analyzes Dart code to # check for errors, warnings, and lints. # # The issues identified by the analyzer are surfaced in the UI of Dart-enabled # IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be # invoked from the command line by running `flutter analyze`. # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. include: package:flutter_lints/flutter.yaml linter: # The lint rules applied to this project can be customized in the # section below to disable rules from the `package:flutter_lints/flutter.yaml` # included above or to enable additional rules. A list of all available lints # and their documentation is published at # https://dart-lang.github.io/linter/lints/index.html. # # Instead of disabling a lint rule for the entire project in the # section below, it can also be suppressed for a single line of code # or a specific dart file by using the `// ignore: name_of_lint` and # `// ignore_for_file: name_of_lint` syntax on the line or in the file # producing the lint. rules: # avoid_print: false # Uncomment to disable the `avoid_print` rule # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options ================================================ FILE: ui/flutter/android/.gitignore ================================================ gradle-wrapper.jar /.gradle /captures/ /gradlew /gradlew.bat /local.properties GeneratedPluginRegistrant.java # Remember to never publicly share your keystore. # See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app key.properties **/*.keystore **/*.jks ================================================ FILE: ui/flutter/android/app/build.gradle ================================================ plugins { id 'com.android.application' id 'kotlin-android' id 'dev.flutter.flutter-gradle-plugin' } def localProperties = new Properties() def localPropertiesFile = rootProject.file('local.properties') if (localPropertiesFile.exists()) { localPropertiesFile.withReader('UTF-8') { reader -> localProperties.load(reader) } } def flutterVersionCode = localProperties.getProperty('flutter.versionCode') if (flutterVersionCode == null) { flutterVersionCode = '1' } def flutterVersionName = localProperties.getProperty('flutter.versionName') if (flutterVersionName == null) { flutterVersionName = '1.0' } android { namespace 'com.gopeed.gopeed' compileSdk 35 ndkVersion flutter.ndkVersion compileOptions { coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { jvmTarget = '17' } sourceSets { main.java.srcDirs += 'src/main/kotlin' } defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId 'com.gopeed.gopeed' // You can update the following values to match your application needs. // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration. minSdk flutter.minSdkVersion targetSdk 35 versionCode flutterVersionCode.toInteger() versionName flutterVersionName } signingConfigs { release { keyAlias 'upload' keyPassword System.getenv('APK_KEY_PASSWORD') storeFile file('upload-keystore.jks') storePassword System.getenv('APK_STORE_PASSWORD') } } buildTypes { release { if (signingConfigs.release.keyPassword != null) { signingConfig signingConfigs.release } else { signingConfig signingConfigs.debug } } } repositories { flatDir { dirs 'libs' } } } flutter { source '../..' } dependencies { implementation(name: 'libgopeed', ext: 'aar') coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4' } ================================================ FILE: ui/flutter/android/app/libs/.gitkeep ================================================ ================================================ FILE: ui/flutter/android/app/src/debug/AndroidManifest.xml ================================================ ================================================ FILE: ui/flutter/android/app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: ui/flutter/android/app/src/main/kotlin/com/gopeed/gopeed/MainActivity.kt ================================================ package com.gopeed.gopeed import androidx.annotation.NonNull import com.gopeed.libgopeed.Libgopeed import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.StandardMethodCodec class MainActivity : FlutterActivity() { private val CHANNEL = "gopeed.com/libgopeed" override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) val taskQueue = flutterEngine.dartExecutor.binaryMessenger.makeBackgroundTaskQueue() MethodChannel( flutterEngine.dartExecutor.binaryMessenger, CHANNEL, StandardMethodCodec.INSTANCE, taskQueue ).setMethodCallHandler { call, result -> when (call.method) { "start" -> { val cfg = call.argument("cfg") try { val port = Libgopeed.start(cfg) result.success(port) } catch (e: Exception) { result.error("ERROR", e.message, null) } } "stop" -> { Libgopeed.stop() result.success(null) } else -> { result.notImplemented() } } } } } ================================================ FILE: ui/flutter/android/app/src/main/res/drawable/launch_background.xml ================================================ ================================================ FILE: ui/flutter/android/app/src/main/res/drawable-v21/launch_background.xml ================================================ ================================================ FILE: ui/flutter/android/app/src/main/res/values/styles.xml ================================================ ================================================ FILE: ui/flutter/android/app/src/main/res/values-night/styles.xml ================================================ ================================================ FILE: ui/flutter/android/app/src/profile/AndroidManifest.xml ================================================ ================================================ FILE: ui/flutter/android/build.gradle ================================================ allprojects { repositories { google() mavenCentral() } } rootProject.buildDir = '../build' subprojects { project.buildDir = "${rootProject.buildDir}/${project.name}" } subprojects { project.evaluationDependsOn(':app') } subprojects { subproject -> subproject.plugins.withId('com.android.library') { subproject.android { if (namespace == null || namespace.isEmpty()) { def manifest = file("${subproject.projectDir}/src/main/AndroidManifest.xml") if (manifest.exists()) { def manifestContent = manifest.getText() def packagePattern = /package\s*=\s*["']([^"']+)["']/ def matcher = (manifestContent =~ packagePattern) if (matcher.find()) { namespace = matcher.group(1) } } } } } } tasks.register('clean', Delete) { delete rootProject.buildDir } ================================================ FILE: ui/flutter/android/gradle/wrapper/gradle-wrapper.properties ================================================ #Fri Jun 23 08:50:38 CEST 2017 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip ================================================ FILE: ui/flutter/android/gradle.properties ================================================ org.gradle.jvmargs=-Xmx4G -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true android.enableJetifier=true android.nonTransitiveRClass=true android.nonFinalResIds=true android.defaults.buildfeatures.buildconfig=true ================================================ FILE: ui/flutter/android/settings.gradle ================================================ pluginManagement { def flutterSdkPath = { def properties = new Properties() file('local.properties').withInputStream { properties.load(it) } def flutterSdkPath = properties.getProperty('flutter.sdk') assert flutterSdkPath != null, 'flutter.sdk not set in local.properties' return flutterSdkPath }() includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") repositories { google() mavenCentral() gradlePluginPortal() } } plugins { id 'dev.flutter.flutter-plugin-loader' version '1.0.0' id 'com.android.application' version '8.7.3' apply false id 'org.jetbrains.kotlin.android' version '2.1.0' apply false } include ':app' ================================================ FILE: ui/flutter/assets/exec/.gitkeep ================================================ ================================================ FILE: ui/flutter/build.yaml ================================================ targets: $default: builders: json_serializable: options: # Options configure how source code is generated for every # `@JsonSerializable`-annotated class in the package. # # The default value for each is listed. any_map: false checked: false create_factory: true create_to_json: true disallow_unrecognized_keys: false explicit_to_json: false field_rename: none generic_argument_factories: false ignore_unannotated: false include_if_null: false ================================================ FILE: ui/flutter/distribute_options.yaml ================================================ output: dist/ ================================================ FILE: ui/flutter/include/libgopeed.h ================================================ /* Code generated by cmd/cgo; DO NOT EDIT. */ /* package github.com/GopeedLab/gopeed/bind/desktop */ #line 1 "cgo-builtin-export-prolog" #include #ifndef GO_CGO_EXPORT_PROLOGUE_H #define GO_CGO_EXPORT_PROLOGUE_H #ifndef GO_CGO_GOSTRING_TYPEDEF typedef struct { const char *p; ptrdiff_t n; } _GoString_; #endif #endif /* Start of preamble from import "C" comments. */ /* End of preamble from import "C" comments. */ /* Start of boilerplate cgo prologue. */ #line 1 "cgo-gcc-export-header-prolog" #ifndef GO_CGO_PROLOGUE_H #define GO_CGO_PROLOGUE_H typedef signed char GoInt8; typedef unsigned char GoUint8; typedef short GoInt16; typedef unsigned short GoUint16; typedef int GoInt32; typedef unsigned int GoUint32; typedef long long GoInt64; typedef unsigned long long GoUint64; typedef GoInt64 GoInt; typedef GoUint64 GoUint; typedef size_t GoUintptr; typedef float GoFloat32; typedef double GoFloat64; #ifdef _MSC_VER #include typedef _Fcomplex GoComplex64; typedef _Dcomplex GoComplex128; #else typedef float _Complex GoComplex64; typedef double _Complex GoComplex128; #endif /* static assertion to make sure the file is being used on architecture at least with matching size of GoInt. */ typedef char _check_for_64_bit_pointer_matching_GoInt[sizeof(void*)==64/8 ? 1:-1]; #ifndef GO_CGO_GOSTRING_TYPEDEF typedef _GoString_ GoString; #endif typedef void *GoMap; typedef void *GoChan; typedef struct { void *t; void *v; } GoInterface; typedef struct { void *data; GoInt len; GoInt cap; } GoSlice; #endif /* End of boilerplate cgo prologue. */ #ifdef __cplusplus extern "C" { #endif /* Return type for Start */ struct Start_return { GoInt r0; char* r1; }; extern struct Start_return Start(char* cfg); extern void Stop(); #ifdef __cplusplus } #endif ================================================ FILE: ui/flutter/ios/.gitignore ================================================ **/dgph *.mode1v3 *.mode2v3 *.moved-aside *.pbxuser *.perspectivev3 **/*sync/ .sconsign.dblite .tags* **/.vagrant/ **/DerivedData/ Icon? **/Pods/ **/.symlinks/ profile xcuserdata **/.generated/ Flutter/App.framework Flutter/Flutter.framework Flutter/Flutter.podspec Flutter/Generated.xcconfig Flutter/ephemeral/ Flutter/app.flx Flutter/app.zip Flutter/flutter_assets/ Flutter/flutter_export_environment.sh ServiceDefinitions.json Runner/GeneratedPluginRegistrant.* # Exceptions to above rules. !default.mode1v3 !default.mode2v3 !default.pbxuser !default.perspectivev3 ================================================ FILE: ui/flutter/ios/Flutter/AppFrameworkInfo.plist ================================================ CFBundleDevelopmentRegion en CFBundleExecutable App CFBundleIdentifier io.flutter.flutter.app CFBundleInfoDictionaryVersion 6.0 CFBundleName App CFBundlePackageType FMWK CFBundleShortVersionString 1.0 CFBundleSignature ???? CFBundleVersion 1.0 MinimumOSVersion 11.0 ================================================ FILE: ui/flutter/ios/Flutter/Debug.xcconfig ================================================ #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" ================================================ FILE: ui/flutter/ios/Flutter/Release.xcconfig ================================================ #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" ================================================ FILE: ui/flutter/ios/Podfile ================================================ # Uncomment this line to define a global platform for your project # platform :ios, '11.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' project 'Runner', { 'Debug' => :debug, 'Profile' => :release, 'Release' => :release, } def flutter_root generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) unless File.exist?(generated_xcode_build_settings_path) raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" end File.foreach(generated_xcode_build_settings_path) do |line| matches = line.match(/FLUTTER_ROOT\=(.*)/) return matches[1].strip if matches end raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" end require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) flutter_ios_podfile_setup target 'Runner' do use_frameworks! use_modular_headers! flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) # share_handler addition start target 'ShareExtension' do inherit! :search_paths pod "share_handler_ios_models", :path => ".symlinks/plugins/share_handler_ios/ios/Models" end # share_handler addition end end post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) end end ================================================ FILE: ui/flutter/ios/Runner/AppDelegate.swift ================================================ import UIKit import Flutter import Libgopeed @UIApplicationMain @objc class AppDelegate: FlutterAppDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { let controller : FlutterViewController = window?.rootViewController as! FlutterViewController let batteryChannel = FlutterMethodChannel(name: "gopeed.com/libgopeed", binaryMessenger: controller.binaryMessenger) batteryChannel.setMethodCallHandler({ (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in switch call.method { case "start": let args = call.arguments as? Dictionary let cfg = args?["cfg"] as? String let portPrt = UnsafeMutablePointer.allocate(capacity: MemoryLayout.stride) var error: NSError? if LibgopeedStart(cfg, portPrt, &error){ result(portPrt.pointee) }else{ result(FlutterError(code: "ERROR", message: error.debugDescription, details: nil)) } case "stop": LibgopeedStop() result(nil) default: result(FlutterMethodNotImplemented) } }) GeneratedPluginRegistrant.register(with: self) SwiftFlutterForegroundTaskPlugin.setPluginRegistrantCallback(registerPlugins) if #available(iOS 10.0, *) { UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate } return super.application(application, didFinishLaunchingWithOptions: launchOptions) } } func registerPlugins(registry: FlutterPluginRegistry) { GeneratedPluginRegistrant.register(with: registry) } ================================================ FILE: ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json ================================================ { "images" : [ { "size" : "20x20", "idiom" : "iphone", "filename" : "Icon-App-20x20@2x.png", "scale" : "2x" }, { "size" : "20x20", "idiom" : "iphone", "filename" : "Icon-App-20x20@3x.png", "scale" : "3x" }, { "size" : "29x29", "idiom" : "iphone", "filename" : "Icon-App-29x29@1x.png", "scale" : "1x" }, { "size" : "29x29", "idiom" : "iphone", "filename" : "Icon-App-29x29@2x.png", "scale" : "2x" }, { "size" : "29x29", "idiom" : "iphone", "filename" : "Icon-App-29x29@3x.png", "scale" : "3x" }, { "size" : "40x40", "idiom" : "iphone", "filename" : "Icon-App-40x40@2x.png", "scale" : "2x" }, { "size" : "40x40", "idiom" : "iphone", "filename" : "Icon-App-40x40@3x.png", "scale" : "3x" }, { "size" : "60x60", "idiom" : "iphone", "filename" : "Icon-App-60x60@2x.png", "scale" : "2x" }, { "size" : "60x60", "idiom" : "iphone", "filename" : "Icon-App-60x60@3x.png", "scale" : "3x" }, { "size" : "20x20", "idiom" : "ipad", "filename" : "Icon-App-20x20@1x.png", "scale" : "1x" }, { "size" : "20x20", "idiom" : "ipad", "filename" : "Icon-App-20x20@2x.png", "scale" : "2x" }, { "size" : "29x29", "idiom" : "ipad", "filename" : "Icon-App-29x29@1x.png", "scale" : "1x" }, { "size" : "29x29", "idiom" : "ipad", "filename" : "Icon-App-29x29@2x.png", "scale" : "2x" }, { "size" : "40x40", "idiom" : "ipad", "filename" : "Icon-App-40x40@1x.png", "scale" : "1x" }, { "size" : "40x40", "idiom" : "ipad", "filename" : "Icon-App-40x40@2x.png", "scale" : "2x" }, { "size" : "76x76", "idiom" : "ipad", "filename" : "Icon-App-76x76@1x.png", "scale" : "1x" }, { "size" : "76x76", "idiom" : "ipad", "filename" : "Icon-App-76x76@2x.png", "scale" : "2x" }, { "size" : "83.5x83.5", "idiom" : "ipad", "filename" : "Icon-App-83.5x83.5@2x.png", "scale" : "2x" }, { "size" : "1024x1024", "idiom" : "ios-marketing", "filename" : "Icon-App-1024x1024@1x.png", "scale" : "1x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: ui/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "LaunchImage.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "LaunchImage@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "LaunchImage@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: ui/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md ================================================ # Launch Screen Assets You can customize the launch screen with your own desired assets by replacing the image files in this directory. You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. ================================================ FILE: ui/flutter/ios/Runner/Base.lproj/LaunchScreen.storyboard ================================================ ================================================ FILE: ui/flutter/ios/Runner/Base.lproj/Main.storyboard ================================================ ================================================ FILE: ui/flutter/ios/Runner/Info.plist ================================================ CADisableMinimumFrameDurationOnPhone CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName Gopeed CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 CFBundlePackageType APPL CFBundleShortVersionString $(FLUTTER_BUILD_NAME) CFBundleSignature ???? CFBundleVersion $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS NSAppTransportSecurity NSAllowsArbitraryLoads UILaunchStoryboardName LaunchScreen UIMainStoryboardFile Main UISupportedInterfaceOrientations UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UISupportedInterfaceOrientations~ipad UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIViewControllerBasedStatusBarAppearance UIApplicationSupportsIndirectInputEvents NSUserActivityTypes INSendMessageIntent CFBundleURLTypes CFBundleTypeRole Editor CFBundleURLSchemes ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER) NSPhotoLibraryUsageDescription Photos can be shared to and used in this app LSSupportsOpeningDocumentsInPlace No CFBundleDocumentTypes CFBundleTypeName ShareHandler LSHandlerRank Alternate LSItemContentTypes public.file-url public.image public.text public.movie public.url public.data ================================================ FILE: ui/flutter/ios/Runner/Runner-Bridging-Header.h ================================================ #import "GeneratedPluginRegistrant.h" #import ================================================ FILE: ui/flutter/ios/Runner/Runner.entitlements ================================================ com.apple.security.application-groups group.com.gopeed.gopeed ================================================ FILE: ui/flutter/ios/Runner.xcodeproj/project.pbxproj ================================================ // !$*UTF8*$! { archiveVersion = 1; classes = { }; objectVersion = 54; objects = { /* Begin PBXBuildFile section */ 0C585CBA2D41E28900FF2EC0 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C585CB92D41E28900FF2EC0 /* ShareViewController.swift */; }; 0C585CBD2D41E28900FF2EC0 /* Base in Resources */ = {isa = PBXBuildFile; fileRef = 0C585CBC2D41E28900FF2EC0 /* Base */; }; 0C585CC12D41E28900FF2EC0 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0C585CB72D41E28900FF2EC0 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; B44F54F47E581A39546303C9 /* libresolv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 029EF293EAA92EB3D18BBF19 /* libresolv.tbd */; }; D081C25C294826C0006EB10B /* libc++.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = D081C25B294826C0006EB10B /* libc++.tbd */; }; D0E623A929482D160001185E /* Libgopeed.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0E623A729482D030001185E /* Libgopeed.xcframework */; }; D0E623AA29482D160001185E /* Libgopeed.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D0E623A729482D030001185E /* Libgopeed.xcframework */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; DCA5B0FA95A8007ADEDACA1B /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 70BC8D37FAC641C7F20125E8 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ 0C585CBF2D41E28900FF2EC0 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 97C146E61CF9000F007C117D /* Project object */; proxyType = 1; remoteGlobalIDString = 0C585CB62D41E28900FF2EC0; remoteInfo = ShareExtension; }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ 0C585CC22D41E28900FF2EC0 /* Embed Foundation Extensions */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 13; files = ( 0C585CC12D41E28900FF2EC0 /* ShareExtension.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; }; 9705A1C41CF9048500538489 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 8; dstPath = ""; dstSubfolderSpec = 10; files = ( D0E623AA29482D160001185E /* Libgopeed.xcframework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 1; }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ 029EF293EAA92EB3D18BBF19 /* libresolv.tbd */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libresolv.tbd; path = usr/lib/libresolv.tbd; sourceTree = ""; }; 0C585CB72D41E28900FF2EC0 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 0C585CB92D41E28900FF2EC0 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; 0C585CBC2D41E28900FF2EC0 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = ""; }; 0C585CBE2D41E28900FF2EC0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 0C585CC72D41E36800FF2EC0 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; 0C585CC82D41E38D00FF2EC0 /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 25B61E78A388C87913468FD6 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 70BC8D37FAC641C7F20125E8 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 97232923D357E56CAA39281D /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; BC90D28B9068C663815415CC /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; D081C25B294826C0006EB10B /* libc++.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = "libc++.tbd"; path = "usr/lib/libc++.tbd"; sourceTree = SDKROOT; }; D0E623A729482D030001185E /* Libgopeed.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = Libgopeed.xcframework; path = Frameworks/Libgopeed.xcframework; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ 0C585CB42D41E28900FF2EC0 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( D0E623A929482D160001185E /* Libgopeed.xcframework in Frameworks */, D081C25C294826C0006EB10B /* libc++.tbd in Frameworks */, DCA5B0FA95A8007ADEDACA1B /* Pods_Runner.framework in Frameworks */, B44F54F47E581A39546303C9 /* libresolv.tbd in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ 0C585CB82D41E28900FF2EC0 /* ShareExtension */ = { isa = PBXGroup; children = ( 0C585CC82D41E38D00FF2EC0 /* ShareExtension.entitlements */, 0C585CB92D41E28900FF2EC0 /* ShareViewController.swift */, 0C585CBB2D41E28900FF2EC0 /* MainInterface.storyboard */, 0C585CBE2D41E28900FF2EC0 /* Info.plist */, ); path = ShareExtension; sourceTree = ""; }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, 9740EEB31CF90195004384FC /* Generated.xcconfig */, ); name = Flutter; sourceTree = ""; }; 97C146E51CF9000F007C117D = { isa = PBXGroup; children = ( 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, 0C585CB82D41E28900FF2EC0 /* ShareExtension */, 97C146EF1CF9000F007C117D /* Products */, ADF7F433B4BC23D6C18AB78B /* Pods */, B4A1CBAC5AA50208793CFF34 /* Frameworks */, ); sourceTree = ""; }; 97C146EF1CF9000F007C117D /* Products */ = { isa = PBXGroup; children = ( 97C146EE1CF9000F007C117D /* Runner.app */, 0C585CB72D41E28900FF2EC0 /* ShareExtension.appex */, ); name = Products; sourceTree = ""; }; 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( 0C585CC72D41E36800FF2EC0 /* Runner.entitlements */, 97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, 97C147021CF9000F007C117D /* Info.plist */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); path = Runner; sourceTree = ""; }; ADF7F433B4BC23D6C18AB78B /* Pods */ = { isa = PBXGroup; children = ( 97232923D357E56CAA39281D /* Pods-Runner.debug.xcconfig */, BC90D28B9068C663815415CC /* Pods-Runner.release.xcconfig */, 25B61E78A388C87913468FD6 /* Pods-Runner.profile.xcconfig */, ); path = Pods; sourceTree = ""; }; B4A1CBAC5AA50208793CFF34 /* Frameworks */ = { isa = PBXGroup; children = ( D0E623A729482D030001185E /* Libgopeed.xcframework */, D081C25B294826C0006EB10B /* libc++.tbd */, 70BC8D37FAC641C7F20125E8 /* Pods_Runner.framework */, 029EF293EAA92EB3D18BBF19 /* libresolv.tbd */, ); name = Frameworks; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ 0C585CB62D41E28900FF2EC0 /* ShareExtension */ = { isa = PBXNativeTarget; buildConfigurationList = 0C585CC62D41E28900FF2EC0 /* Build configuration list for PBXNativeTarget "ShareExtension" */; buildPhases = ( 0C585CB32D41E28900FF2EC0 /* Sources */, 0C585CB42D41E28900FF2EC0 /* Frameworks */, 0C585CB52D41E28900FF2EC0 /* Resources */, ); buildRules = ( ); dependencies = ( ); name = ShareExtension; productName = ShareExtension; productReference = 0C585CB72D41E28900FF2EC0 /* ShareExtension.appex */; productType = "com.apple.product-type.app-extension"; }; 97C146ED1CF9000F007C117D /* Runner */ = { isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( F3093A8F123671F8F587626F /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 0C585CC22D41E28900FF2EC0 /* Embed Foundation Extensions */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, DDA801E9D83E2E88398514D3 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); dependencies = ( 0C585CC02D41E28900FF2EC0 /* PBXTargetDependency */, ); name = Runner; productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { LastSwiftUpdateCheck = 1540; LastUpgradeCheck = 1430; ORGANIZATIONNAME = ""; TargetAttributes = { 0C585CB62D41E28900FF2EC0 = { CreatedOnToolsVersion = 15.4; }; 97C146ED1CF9000F007C117D = { CreatedOnToolsVersion = 7.3.1; LastSwiftMigration = 1100; }; }; }; buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; compatibilityVersion = "Xcode 9.3"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, Base, ); mainGroup = 97C146E51CF9000F007C117D; productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 97C146ED1CF9000F007C117D /* Runner */, 0C585CB62D41E28900FF2EC0 /* ShareExtension */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ 0C585CB52D41E28900FF2EC0 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( 0C585CBD2D41E28900FF2EC0 /* Base in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; 97C146EC1CF9000F007C117D /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputPaths = ( "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", ); name = "Thin Binary"; outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputPaths = ( ); name = "Run Script"; outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; DDA801E9D83E2E88398514D3 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; F3093A8F123671F8F587626F /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( ); inputPaths = ( "${PODS_PODFILE_DIR_PATH}/Podfile.lock", "${PODS_ROOT}/Manifest.lock", ); name = "[CP] Check Pods Manifest.lock"; outputFileListPaths = ( ); outputPaths = ( "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ 0C585CB32D41E28900FF2EC0 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 0C585CBA2D41E28900FF2EC0 /* ShareViewController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; 97C146EA1CF9000F007C117D /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ 0C585CC02D41E28900FF2EC0 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 0C585CB62D41E28900FF2EC0 /* ShareExtension */; targetProxy = 0C585CBF2D41E28900FF2EC0 /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ 0C585CBB2D41E28900FF2EC0 /* MainInterface.storyboard */ = { isa = PBXVariantGroup; children = ( 0C585CBC2D41E28900FF2EC0 /* Base */, ); name = MainInterface.storyboard; sourceTree = ""; }; 97C146FA1CF9000F007C117D /* Main.storyboard */ = { isa = PBXVariantGroup; children = ( 97C146FB1CF9000F007C117D /* Base */, ); name = Main.storyboard; sourceTree = ""; }; 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { isa = PBXVariantGroup; children = ( 97C147001CF9000F007C117D /* Base */, ); name = LaunchScreen.storyboard; sourceTree = ""; }; /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ 0C585CC32D41E28900FF2EC0 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = JH48DS925K; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; INFOPLIST_KEY_NSHumanReadableCopyright = ""; IPHONEOS_DEPLOYMENT_TARGET = 17.5; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.gopeed.gopeed.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; 0C585CC42D41E28900FF2EC0 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = JH48DS925K; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; INFOPLIST_KEY_NSHumanReadableCopyright = ""; IPHONEOS_DEPLOYMENT_TARGET = 17.5; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.gopeed.gopeed.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; 0C585CC52D41E28900FF2EC0 /* Profile */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = JH48DS925K; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; INFOPLIST_KEY_NSHumanReadableCopyright = ""; IPHONEOS_DEPLOYMENT_TARGET = 17.5; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.gopeed.gopeed.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Profile; }; 249021D3217E4FDB00AE95B9 /* Profile */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 11.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; }; name = Profile; }; 249021D4217E4FDB00AE95B9 /* Profile */ = { isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = JH48DS925K; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.gopeed.gopeed; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; }; name = Profile; }; 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; GCC_OPTIMIZATION_LEVEL = 0; GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", "$(inherited)", ); GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 11.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; 97C147041CF9000F007C117D /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 11.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; }; name = Release; }; 97C147061CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = JH48DS925K; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.gopeed.gopeed; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; }; name = Debug; }; 97C147071CF9000F007C117D /* Release */ = { isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = JH48DS925K; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.gopeed.gopeed; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ 0C585CC62D41E28900FF2EC0 /* Build configuration list for PBXNativeTarget "ShareExtension" */ = { isa = XCConfigurationList; buildConfigurations = ( 0C585CC32D41E28900FF2EC0 /* Debug */, 0C585CC42D41E28900FF2EC0 /* Release */, 0C585CC52D41E28900FF2EC0 /* Profile */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { isa = XCConfigurationList; buildConfigurations = ( 97C147031CF9000F007C117D /* Debug */, 97C147041CF9000F007C117D /* Release */, 249021D3217E4FDB00AE95B9 /* Profile */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { isa = XCConfigurationList; buildConfigurations = ( 97C147061CF9000F007C117D /* Debug */, 97C147071CF9000F007C117D /* Release */, 249021D4217E4FDB00AE95B9 /* Profile */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; /* End XCConfigurationList section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } ================================================ FILE: ui/flutter/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata ================================================ ================================================ FILE: ui/flutter/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist ================================================ IDEDidComputeMac32BitWarning ================================================ FILE: ui/flutter/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings ================================================ PreviewsEnabled ================================================ FILE: ui/flutter/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme ================================================ ================================================ FILE: ui/flutter/ios/Runner.xcworkspace/contents.xcworkspacedata ================================================ ================================================ FILE: ui/flutter/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist ================================================ IDEDidComputeMac32BitWarning ================================================ FILE: ui/flutter/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings ================================================ PreviewsEnabled ================================================ FILE: ui/flutter/ios/ShareExtension/Base.lproj/MainInterface.storyboard ================================================ ================================================ FILE: ui/flutter/ios/ShareExtension/Info.plist ================================================ CFBundleVersion $(FLUTTER_BUILD_NUMBER) NSExtension NSExtensionAttributes IntentsSupported INSendMessageIntent NSExtensionActivationRule SUBQUERY ( extensionItems, $extensionItem, SUBQUERY ( $extensionItem.attachments, $attachment, ( ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.file-url" || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.image" || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.text" || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.movie" || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url" ) ).@count > 0 ).@count > 0 PHSupportedMediaTypes Video Image NSExtensionMainStoryboard MainInterface NSExtensionPointIdentifier com.apple.share-services ================================================ FILE: ui/flutter/ios/ShareExtension/ShareExtension.entitlements ================================================ com.apple.security.application-groups group.com.gopeed.gopeed ================================================ FILE: ui/flutter/ios/ShareExtension/ShareViewController.swift ================================================ import share_handler_ios_models class ShareViewController: ShareHandlerIosViewController {} ================================================ FILE: ui/flutter/lib/api/api.dart ================================================ import 'dart:io'; import 'package:dio/dio.dart'; import 'package:dio/io.dart'; import 'package:flutter/foundation.dart'; import 'package:get/get.dart' as getx; import '../app/routes/app_pages.dart'; import '../database/database.dart'; import '../util/util.dart'; import 'model/create_task.dart'; import 'model/create_task_batch.dart'; import 'model/downloader_config.dart'; import 'model/extension.dart'; import 'model/install_extension.dart'; import 'model/login.dart'; import 'model/resolve_result.dart'; import 'model/resolve_task.dart'; import 'model/result.dart'; import 'model/switch_extension.dart'; import 'model/task.dart'; import 'model/update_check_extension_resp.dart'; import 'model/update_extension_settings.dart'; class _Client { static _Client? _instance; late Dio dio; _Client._internal(); factory _Client(String network, String address, String apiToken) { if (_instance == null) { _instance = _Client._internal(); var dio = Dio(); final isUnixSocket = network == 'unix'; var baseUrl = 'http://127.0.0.1/'; if (!isUnixSocket) { if (Util.isWeb()) { baseUrl = kDebugMode ? 'http://127.0.0.1:9999/' : ''; } else { baseUrl = 'http://$address/'; } } dio.options.baseUrl = baseUrl; dio.options.contentType = Headers.jsonContentType; dio.options.sendTimeout = const Duration(seconds: 5); dio.options.connectTimeout = const Duration(seconds: 5); dio.options.receiveTimeout = const Duration(seconds: 60); dio.interceptors.add(InterceptorsWrapper( onRequest: (options, handler) { if (apiToken.isNotEmpty) { options.headers['X-Api-Token'] = apiToken; } if (Util.isWeb()) { final token = Database.instance.getWebToken(); if (token != null) { options.headers['Authorization'] = 'Bearer $token'; } } handler.next(options); }, onError: (error, handler) { // Only web version has a login page if (Util.isWeb() && error.response?.statusCode == 401) { getx.Get.rootDelegate.offAndToNamed(Routes.LOGIN); } handler.next(error); }, )); _instance!.dio = dio; if (isUnixSocket) { (_instance!.dio.httpClientAdapter as IOHttpClientAdapter) .createHttpClient = () { final client = HttpClient(); client.connectionFactory = (Uri uri, String? proxyHost, int? proxyPort) { return Socket.startConnect( InternetAddress(address, type: InternetAddressType.unix), 0); }; return client; }; } } return _instance!; } } class TimeoutException implements Exception { final String message; TimeoutException(this.message); } late _Client _client; void init(String network, String address, String apiToken) { _client = _Client(network, address, apiToken); } Future _parse( Future Function() fetch, T Function(dynamic json)? fromJsonT, ) async { try { var resp = await fetch(); fromJsonT ??= (json) => null as T; final result = Result.fromJson(resp.data, fromJsonT); if (result.code == 0) { return result.data as T; } else { throw Exception(result); } } on DioException catch (e) { if (e.type == DioExceptionType.sendTimeout || e.type == DioExceptionType.receiveTimeout || e.type == DioExceptionType.connectionTimeout || e.type == DioExceptionType.connectionError) { throw TimeoutException("request timeout"); } throw Exception(Result(code: 1000, msg: e.message)); } } Future resolve(ResolveTask resolveTask) async { return _parse( () => _client.dio.post("api/v1/resolve", data: resolveTask), (data) => ResolveResult.fromJson(data)); } Future createTask(CreateTask createTask) async { return _parse( () => _client.dio.post("api/v1/tasks", data: createTask), (data) => data as String); } Future> createTaskBatch(CreateTaskBatch createTaskBatch) async { return _parse>( () => _client.dio.post("api/v1/tasks/batch", data: createTaskBatch), (data) => (data as List).map((e) => e as String).toList()); } Future patchTask(String id, ResolveTask patchTask) async { return _parse( () => _client.dio.patch("api/v1/tasks/$id", data: patchTask), null); } Future> getTasks(List statuses) async { return _parse>( () => _client.dio.get( "/api/v1/tasks?${statuses.map((e) => "status=${e.name}").join("&")}"), (data) => (data as List).map((e) => Task.fromJson(e)).toList()); } Future pauseTask(String id) async { return _parse(() => _client.dio.put("api/v1/tasks/$id/pause"), null); } Future continueTask(String id) async { return _parse(() => _client.dio.put("api/v1/tasks/$id/continue"), null); } Future pauseAllTasks(List? ids) async { return _parse( () => _client.dio.put("api/v1/tasks/pause", queryParameters: { "id": ids, }), null); } Future continueAllTasks(List? ids) async { return _parse( () => _client.dio.put("api/v1/tasks/continue", queryParameters: { "id": ids, }), null); } Future deleteTask(String id, bool force) async { return _parse( () => _client.dio.delete("api/v1/tasks/$id?force=$force"), null); } Future deleteTasks(List? ids, bool force) async { return _parse( () => _client.dio.delete("api/v1/tasks", queryParameters: { "id": ids, "force": force, }), null); } Future getConfig() async { return _parse(() => _client.dio.get("api/v1/config"), (data) => DownloaderConfig.fromJson(data)); } Future putConfig(DownloaderConfig config) async { return _parse(() => _client.dio.put("api/v1/config", data: config), null); } Future installExtension(InstallExtension installExtension) async { return _parse( () => _client.dio.post("api/v1/extensions", data: installExtension), (data) => data as String); } Future> getExtensions() async { return _parse>(() => _client.dio.get("api/v1/extensions"), (data) => (data as List).map((e) => Extension.fromJson(e)).toList()); } Future updateExtensionSettings( String identity, UpdateExtensionSettings updateExtensionSettings) async { return _parse( () => _client.dio.put("api/v1/extensions/$identity/settings", data: updateExtensionSettings), null); } Future switchExtension( String identity, SwitchExtension switchExtension) async { return _parse( () => _client.dio .put("api/v1/extensions/$identity/switch", data: switchExtension), null); } Future deleteExtension(String identity) async { return _parse(() => _client.dio.delete("api/v1/extensions/$identity"), null); } Future upgradeCheckExtension(String identity) async { return _parse(() => _client.dio.get("api/v1/extensions/$identity/update"), (data) => UpdateCheckExtensionResp.fromJson(data)); } Future updateExtension(String identity) async { return _parse( () => _client.dio.post("api/v1/extensions/$identity/update"), null); } Future testWebhook(String url) async { return _parse( () => _client.dio.post("api/v1/webhook/test", data: {"url": url}), null); } Future login(LoginReq loginReq) async { return _parse(() => _client.dio.post("api/web/login", data: loginReq), (data) => data as String); } Future> proxyRequest(String uri, {data, Options? options}) async { options ??= Options(); options.headers ??= {}; options.headers!["X-Target-Uri"] = uri; // add timestamp to avoid cache return _client.dio.request( "/api/v1/proxy?t=${DateTime.now().millisecondsSinceEpoch}", data: data, options: options); } String join(String path) { final baseUrl = _client.dio.options.baseUrl; final cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl; return "$cleanBaseUrl/${Util.cleanPath(path)}"; } /// Generic request method for API proxy /// Directly forwards requests to gopeed REST API Future forward( String path, { String method = 'GET', dynamic data, Map? queryParameters, }) async { return _client.dio.request( path, data: data, queryParameters: queryParameters, options: Options(method: method), ); } ================================================ FILE: ui/flutter/lib/api/gopeed_site_api.dart ================================================ import 'dart:convert'; import 'package:dio/dio.dart'; import 'api.dart'; import 'model/store_extension.dart'; class GopeedSiteApi { GopeedSiteApi._(); static final instance = GopeedSiteApi._(); static const _host = 'gopeed.com'; Future> getRelease() async { final json = await _getJson('/api/release'); return json as Map; } Future getExtensions({ int page = 1, int limit = 20, StoreExtensionSort sort = StoreExtensionSort.stars, StoreSortOrder order = StoreSortOrder.desc, String? query, }) async { final json = await _getJson('/api/extensions', queryParameters: { 'page': page.toString(), 'limit': limit.clamp(1, 100).toString(), 'sort': sort.name, 'order': order.name, if (query != null && query.trim().isNotEmpty) 'q': query.trim(), }); return StoreExtensionPage.fromJson(json as Map); } Future reportExtensionInstall(String id) async { final uri = Uri.https(_host, '/api/extensions/install'); await proxyRequest( uri.toString(), data: {'id': id}, options: Options(method: 'POST', contentType: Headers.jsonContentType), ); } Future _getJson(String path, {Map? queryParameters}) async { final uri = Uri.https(_host, path, queryParameters); final Response response = await proxyRequest(uri.toString()); if (response.data == null || response.data!.isEmpty) { throw Exception('Empty response from $uri'); } return jsonDecode(response.data!); } } ================================================ FILE: ui/flutter/lib/api/model/create_task.dart ================================================ import 'package:json_annotation/json_annotation.dart'; import 'options.dart'; import 'request.dart'; part 'create_task.g.dart'; @JsonSerializable(explicitToJson: true) class CreateTask { String? rid; Request? req; Options? opts; CreateTask({ this.rid, this.req, this.opts, }); factory CreateTask.fromJson( Map json, ) => _$CreateTaskFromJson(json); Map toJson() => _$CreateTaskToJson(this); } ================================================ FILE: ui/flutter/lib/api/model/create_task.g.dart ================================================ // GENERATED CODE - DO NOT MODIFY BY HAND part of 'create_task.dart'; // ************************************************************************** // JsonSerializableGenerator // ************************************************************************** CreateTask _$CreateTaskFromJson(Map json) => CreateTask( rid: json['rid'] as String?, req: json['req'] == null ? null : Request.fromJson(json['req'] as Map), opts: json['opts'] == null ? null : Options.fromJson(json['opts'] as Map), ); Map _$CreateTaskToJson(CreateTask instance) { final val = {}; void writeNotNull(String key, dynamic value) { if (value != null) { val[key] = value; } } writeNotNull('rid', instance.rid); writeNotNull('req', instance.req?.toJson()); writeNotNull('opts', instance.opts?.toJson()); return val; } ================================================ FILE: ui/flutter/lib/api/model/create_task_batch.dart ================================================ import 'package:json_annotation/json_annotation.dart'; import 'options.dart'; import 'request.dart'; part 'create_task_batch.g.dart'; @JsonSerializable(explicitToJson: true) class CreateTaskBatch { List? reqs; Options? opts; CreateTaskBatch({ this.reqs, this.opts, }); factory CreateTaskBatch.fromJson( Map json, ) => _$CreateTaskBatchFromJson(json); Map toJson() => _$CreateTaskBatchToJson(this); } @JsonSerializable() class CreateTaskBatchItem { Request? req; Options? opts; CreateTaskBatchItem({ this.req, this.opts, }); factory CreateTaskBatchItem.fromJson(Map json) => _$CreateTaskBatchItemFromJson(json); Map toJson() => _$CreateTaskBatchItemToJson(this); } ================================================ FILE: ui/flutter/lib/api/model/create_task_batch.g.dart ================================================ // GENERATED CODE - DO NOT MODIFY BY HAND part of 'create_task_batch.dart'; // ************************************************************************** // JsonSerializableGenerator // ************************************************************************** CreateTaskBatch _$CreateTaskBatchFromJson(Map json) => CreateTaskBatch( reqs: (json['reqs'] as List?) ?.map((e) => CreateTaskBatchItem.fromJson(e as Map)) .toList(), opts: json['opts'] == null ? null : Options.fromJson(json['opts'] as Map), ); Map _$CreateTaskBatchToJson(CreateTaskBatch instance) { final val = {}; void writeNotNull(String key, dynamic value) { if (value != null) { val[key] = value; } } writeNotNull('reqs', instance.reqs?.map((e) => e.toJson()).toList()); writeNotNull('opts', instance.opts?.toJson()); return val; } CreateTaskBatchItem _$CreateTaskBatchItemFromJson(Map json) => CreateTaskBatchItem( req: json['req'] == null ? null : Request.fromJson(json['req'] as Map), opts: json['opts'] == null ? null : Options.fromJson(json['opts'] as Map), ); Map _$CreateTaskBatchItemToJson(CreateTaskBatchItem instance) { final val = {}; void writeNotNull(String key, dynamic value) { if (value != null) { val[key] = value; } } writeNotNull('req', instance.req); writeNotNull('opts', instance.opts); return val; } ================================================ FILE: ui/flutter/lib/api/model/downloader_config.dart ================================================ import 'package:json_annotation/json_annotation.dart'; part 'downloader_config.g.dart'; @JsonSerializable(explicitToJson: true) class DownloaderConfig { String downloadDir; int maxRunning; ProtocolConfig protocolConfig = ProtocolConfig(); ExtraConfig extra = ExtraConfig(); ProxyConfig proxy = ProxyConfig(); WebhookConfig webhook = WebhookConfig(); ScriptConfig script = ScriptConfig(); AutoTorrentConfig autoTorrent = AutoTorrentConfig(); ArchiveConfig archive = ArchiveConfig(); bool autoDeleteMissingFileTasks; DownloaderConfig({ this.downloadDir = '', this.maxRunning = 0, this.autoDeleteMissingFileTasks = false, }); factory DownloaderConfig.fromJson(Map json) => _$DownloaderConfigFromJson(json); Map toJson() => _$DownloaderConfigToJson(this); } @JsonSerializable(explicitToJson: true) class ProtocolConfig { HttpConfig http = HttpConfig(); BtConfig bt = BtConfig(); Ed2kConfig ed2k = Ed2kConfig(); ProtocolConfig(); factory ProtocolConfig.fromJson(Map? json) => json == null ? ProtocolConfig() : _$ProtocolConfigFromJson(json); Map toJson() => _$ProtocolConfigToJson(this); } @JsonSerializable() class HttpConfig { String userAgent; int connections; bool useServerCtime; HttpConfig({ this.userAgent = '', this.connections = 0, this.useServerCtime = false, }); factory HttpConfig.fromJson(Map json) => _$HttpConfigFromJson(json); Map toJson() => _$HttpConfigToJson(this); } @JsonSerializable() class BtConfig { int listenPort; List trackers; bool seedKeep; double seedRatio; int seedTime; BtConfig({ this.listenPort = 0, this.trackers = const [], this.seedKeep = false, this.seedRatio = 0, this.seedTime = 0, }); factory BtConfig.fromJson(Map json) => _$BtConfigFromJson(json); Map toJson() => _$BtConfigToJson(this); } @JsonSerializable() class Ed2kConfig { int listenPort; int udpPort; String serverAddr; String serverMet; String nodesDat; Ed2kConfig({ this.listenPort = 0, this.udpPort = 0, this.serverAddr = '', this.serverMet = '', this.nodesDat = '', }); factory Ed2kConfig.fromJson(Map json) => _$Ed2kConfigFromJson(json); Map toJson() => _$Ed2kConfigToJson(this); } @JsonSerializable(explicitToJson: true) class ExtraConfig { String themeMode; String locale; bool lastDeleteTaskKeep; bool defaultDirectDownload; bool defaultBtClient; bool notifyWhenNewVersion; bool autoStartTasks; bool desktopNotification; List downloadCategories; ExtraConfigBt bt = ExtraConfigBt(); ExtraConfigGithubMirror githubMirror = ExtraConfigGithubMirror(); ExtraConfig({ this.themeMode = '', this.locale = '', this.lastDeleteTaskKeep = false, this.defaultDirectDownload = false, this.defaultBtClient = true, this.notifyWhenNewVersion = true, this.autoStartTasks = false, this.desktopNotification = true, this.downloadCategories = const [], }); factory ExtraConfig.fromJson(Map? json) => json == null ? ExtraConfig() : _$ExtraConfigFromJson(json); Map toJson() => _$ExtraConfigToJson(this); } @JsonSerializable() class DownloadCategory { String name; String path; bool isBuiltIn; String? nameKey; // i18n key for built-in categories (e.g., 'categoryMusic') bool isDeleted; // Mark built-in categories as deleted instead of removing them DownloadCategory({ required this.name, required this.path, this.isBuiltIn = false, this.nameKey, this.isDeleted = false, }); factory DownloadCategory.fromJson(Map json) => _$DownloadCategoryFromJson(json); Map toJson() => _$DownloadCategoryToJson(this); } @JsonSerializable() class WebhookConfig { bool enable; List urls; WebhookConfig({ this.enable = false, this.urls = const [], }); factory WebhookConfig.fromJson(Map? json) => json == null ? WebhookConfig() : _$WebhookConfigFromJson(json); Map toJson() => _$WebhookConfigToJson(this); } @JsonSerializable() class ScriptConfig { bool enable; List paths; ScriptConfig({ this.enable = false, this.paths = const [], }); factory ScriptConfig.fromJson(Map? json) => json == null ? ScriptConfig() : _$ScriptConfigFromJson(json); Map toJson() => _$ScriptConfigToJson(this); } @JsonSerializable() class ProxyConfig { bool enable; bool system; String scheme; String host; String usr; String pwd; ProxyConfig({ this.enable = false, this.system = false, this.scheme = '', this.host = '', this.usr = '', this.pwd = '', }); factory ProxyConfig.fromJson(Map json) => _$ProxyConfigFromJson(json); Map toJson() => _$ProxyConfigToJson(this); } @JsonSerializable() class ExtraConfigBt { List trackerSubscribeUrls = []; List subscribeTrackers = []; bool autoUpdateTrackers = true; DateTime? lastTrackerUpdateTime; List customTrackers = []; ExtraConfigBt(); factory ExtraConfigBt.fromJson(Map json) => _$ExtraConfigBtFromJson(json); Map toJson() => _$ExtraConfigBtToJson(this); } enum GithubMirrorType { jsdelivr, ghProxy, } @JsonSerializable() class GithubMirror { GithubMirrorType type; String url; bool isBuiltIn; bool isDeleted; GithubMirror({ required this.type, required this.url, this.isBuiltIn = false, this.isDeleted = false, }); factory GithubMirror.fromJson(Map json) => _$GithubMirrorFromJson(json); Map toJson() => _$GithubMirrorToJson(this); } @JsonSerializable(explicitToJson: true) class ExtraConfigGithubMirror { bool enabled; List mirrors; ExtraConfigGithubMirror({ this.enabled = true, this.mirrors = const [], }); factory ExtraConfigGithubMirror.fromJson(Map? json) => json == null ? ExtraConfigGithubMirror() : _$ExtraConfigGithubMirrorFromJson(json); Map toJson() => _$ExtraConfigGithubMirrorToJson(this); } @JsonSerializable() class AutoTorrentConfig { bool enable; bool deleteAfterDownload; AutoTorrentConfig({ this.enable = false, this.deleteAfterDownload = false, }); factory AutoTorrentConfig.fromJson(Map? json) => json == null ? AutoTorrentConfig() : _$AutoTorrentConfigFromJson(json); Map toJson() => _$AutoTorrentConfigToJson(this); } @JsonSerializable() class ArchiveConfig { bool autoExtract; bool deleteAfterExtract; ArchiveConfig({ this.autoExtract = true, this.deleteAfterExtract = true, }); factory ArchiveConfig.fromJson(Map? json) => json == null ? ArchiveConfig() : _$ArchiveConfigFromJson(json); Map toJson() => _$ArchiveConfigToJson(this); } ================================================ FILE: ui/flutter/lib/api/model/downloader_config.g.dart ================================================ // GENERATED CODE - DO NOT MODIFY BY HAND part of 'downloader_config.dart'; // ************************************************************************** // JsonSerializableGenerator // ************************************************************************** DownloaderConfig _$DownloaderConfigFromJson(Map json) => DownloaderConfig( downloadDir: json['downloadDir'] as String? ?? '', maxRunning: (json['maxRunning'] as num?)?.toInt() ?? 0, autoDeleteMissingFileTasks: json['autoDeleteMissingFileTasks'] as bool? ?? false, ) ..protocolConfig = ProtocolConfig.fromJson( json['protocolConfig'] as Map?) ..extra = ExtraConfig.fromJson(json['extra'] as Map?) ..proxy = ProxyConfig.fromJson(json['proxy'] as Map) ..webhook = WebhookConfig.fromJson(json['webhook'] as Map?) ..script = ScriptConfig.fromJson(json['script'] as Map?) ..autoTorrent = AutoTorrentConfig.fromJson( json['autoTorrent'] as Map?) ..archive = ArchiveConfig.fromJson(json['archive'] as Map?); Map _$DownloaderConfigToJson(DownloaderConfig instance) => { 'downloadDir': instance.downloadDir, 'maxRunning': instance.maxRunning, 'protocolConfig': instance.protocolConfig.toJson(), 'extra': instance.extra.toJson(), 'proxy': instance.proxy.toJson(), 'webhook': instance.webhook.toJson(), 'script': instance.script.toJson(), 'autoTorrent': instance.autoTorrent.toJson(), 'archive': instance.archive.toJson(), 'autoDeleteMissingFileTasks': instance.autoDeleteMissingFileTasks, }; ProtocolConfig _$ProtocolConfigFromJson(Map json) => ProtocolConfig() ..http = HttpConfig.fromJson(json['http'] as Map) ..bt = BtConfig.fromJson(json['bt'] as Map) ..ed2k = Ed2kConfig.fromJson( json['ed2k'] as Map? ?? {}); Map _$ProtocolConfigToJson(ProtocolConfig instance) => { 'http': instance.http.toJson(), 'bt': instance.bt.toJson(), 'ed2k': instance.ed2k.toJson(), }; HttpConfig _$HttpConfigFromJson(Map json) => HttpConfig( userAgent: json['userAgent'] as String? ?? '', connections: (json['connections'] as num?)?.toInt() ?? 0, useServerCtime: json['useServerCtime'] as bool? ?? false, ); Map _$HttpConfigToJson(HttpConfig instance) => { 'userAgent': instance.userAgent, 'connections': instance.connections, 'useServerCtime': instance.useServerCtime, }; BtConfig _$BtConfigFromJson(Map json) => BtConfig( listenPort: (json['listenPort'] as num?)?.toInt() ?? 0, trackers: (json['trackers'] as List?) ?.map((e) => e as String) .toList() ?? const [], seedKeep: json['seedKeep'] as bool? ?? false, seedRatio: (json['seedRatio'] as num?)?.toDouble() ?? 0, seedTime: (json['seedTime'] as num?)?.toInt() ?? 0, ); Map _$BtConfigToJson(BtConfig instance) => { 'listenPort': instance.listenPort, 'trackers': instance.trackers, 'seedKeep': instance.seedKeep, 'seedRatio': instance.seedRatio, 'seedTime': instance.seedTime, }; Ed2kConfig _$Ed2kConfigFromJson(Map json) => Ed2kConfig( listenPort: (json['listenPort'] as num?)?.toInt() ?? 0, udpPort: (json['udpPort'] as num?)?.toInt() ?? 0, serverAddr: json['serverAddr'] as String? ?? '', serverMet: json['serverMet'] as String? ?? '', nodesDat: json['nodesDat'] as String? ?? '', ); Map _$Ed2kConfigToJson(Ed2kConfig instance) => { 'listenPort': instance.listenPort, 'udpPort': instance.udpPort, 'serverAddr': instance.serverAddr, 'serverMet': instance.serverMet, 'nodesDat': instance.nodesDat, }; ExtraConfig _$ExtraConfigFromJson(Map json) => ExtraConfig( themeMode: json['themeMode'] as String? ?? '', locale: json['locale'] as String? ?? '', lastDeleteTaskKeep: json['lastDeleteTaskKeep'] as bool? ?? false, defaultDirectDownload: json['defaultDirectDownload'] as bool? ?? false, defaultBtClient: json['defaultBtClient'] as bool? ?? true, notifyWhenNewVersion: json['notifyWhenNewVersion'] as bool? ?? true, autoStartTasks: json['autoStartTasks'] as bool? ?? false, desktopNotification: json['desktopNotification'] as bool? ?? true, downloadCategories: (json['downloadCategories'] as List?) ?.map((e) => DownloadCategory.fromJson(e as Map)) .toList() ?? const [], ) ..bt = ExtraConfigBt.fromJson(json['bt'] as Map) ..githubMirror = ExtraConfigGithubMirror.fromJson( json['githubMirror'] as Map?); Map _$ExtraConfigToJson(ExtraConfig instance) => { 'themeMode': instance.themeMode, 'locale': instance.locale, 'lastDeleteTaskKeep': instance.lastDeleteTaskKeep, 'defaultDirectDownload': instance.defaultDirectDownload, 'defaultBtClient': instance.defaultBtClient, 'notifyWhenNewVersion': instance.notifyWhenNewVersion, 'autoStartTasks': instance.autoStartTasks, 'desktopNotification': instance.desktopNotification, 'downloadCategories': instance.downloadCategories.map((e) => e.toJson()).toList(), 'bt': instance.bt.toJson(), 'githubMirror': instance.githubMirror.toJson(), }; DownloadCategory _$DownloadCategoryFromJson(Map json) => DownloadCategory( name: json['name'] as String, path: json['path'] as String, isBuiltIn: json['isBuiltIn'] as bool? ?? false, nameKey: json['nameKey'] as String?, isDeleted: json['isDeleted'] as bool? ?? false, ); Map _$DownloadCategoryToJson(DownloadCategory instance) { final val = { 'name': instance.name, 'path': instance.path, 'isBuiltIn': instance.isBuiltIn, }; void writeNotNull(String key, dynamic value) { if (value != null) { val[key] = value; } } writeNotNull('nameKey', instance.nameKey); val['isDeleted'] = instance.isDeleted; return val; } WebhookConfig _$WebhookConfigFromJson(Map json) => WebhookConfig( enable: json['enable'] as bool? ?? false, urls: (json['urls'] as List?)?.map((e) => e as String).toList() ?? const [], ); Map _$WebhookConfigToJson(WebhookConfig instance) => { 'enable': instance.enable, 'urls': instance.urls, }; ScriptConfig _$ScriptConfigFromJson(Map json) => ScriptConfig( enable: json['enable'] as bool? ?? false, paths: (json['paths'] as List?)?.map((e) => e as String).toList() ?? const [], ); Map _$ScriptConfigToJson(ScriptConfig instance) => { 'enable': instance.enable, 'paths': instance.paths, }; ProxyConfig _$ProxyConfigFromJson(Map json) => ProxyConfig( enable: json['enable'] as bool? ?? false, system: json['system'] as bool? ?? false, scheme: json['scheme'] as String? ?? '', host: json['host'] as String? ?? '', usr: json['usr'] as String? ?? '', pwd: json['pwd'] as String? ?? '', ); Map _$ProxyConfigToJson(ProxyConfig instance) => { 'enable': instance.enable, 'system': instance.system, 'scheme': instance.scheme, 'host': instance.host, 'usr': instance.usr, 'pwd': instance.pwd, }; ExtraConfigBt _$ExtraConfigBtFromJson(Map json) => ExtraConfigBt() ..trackerSubscribeUrls = (json['trackerSubscribeUrls'] as List) .map((e) => e as String) .toList() ..subscribeTrackers = (json['subscribeTrackers'] as List) .map((e) => e as String) .toList() ..autoUpdateTrackers = json['autoUpdateTrackers'] as bool ..lastTrackerUpdateTime = json['lastTrackerUpdateTime'] == null ? null : DateTime.parse(json['lastTrackerUpdateTime'] as String) ..customTrackers = (json['customTrackers'] as List) .map((e) => e as String) .toList(); Map _$ExtraConfigBtToJson(ExtraConfigBt instance) { final val = { 'trackerSubscribeUrls': instance.trackerSubscribeUrls, 'subscribeTrackers': instance.subscribeTrackers, 'autoUpdateTrackers': instance.autoUpdateTrackers, }; void writeNotNull(String key, dynamic value) { if (value != null) { val[key] = value; } } writeNotNull('lastTrackerUpdateTime', instance.lastTrackerUpdateTime?.toIso8601String()); val['customTrackers'] = instance.customTrackers; return val; } GithubMirror _$GithubMirrorFromJson(Map json) => GithubMirror( type: $enumDecode(_$GithubMirrorTypeEnumMap, json['type']), url: json['url'] as String, isBuiltIn: json['isBuiltIn'] as bool? ?? false, isDeleted: json['isDeleted'] as bool? ?? false, ); Map _$GithubMirrorToJson(GithubMirror instance) => { 'type': _$GithubMirrorTypeEnumMap[instance.type]!, 'url': instance.url, 'isBuiltIn': instance.isBuiltIn, 'isDeleted': instance.isDeleted, }; const _$GithubMirrorTypeEnumMap = { GithubMirrorType.jsdelivr: 'jsdelivr', GithubMirrorType.ghProxy: 'ghProxy', }; ExtraConfigGithubMirror _$ExtraConfigGithubMirrorFromJson( Map json) => ExtraConfigGithubMirror( enabled: json['enabled'] as bool? ?? true, mirrors: (json['mirrors'] as List?) ?.map((e) => GithubMirror.fromJson(e as Map)) .toList() ?? const [], ); Map _$ExtraConfigGithubMirrorToJson( ExtraConfigGithubMirror instance) => { 'enabled': instance.enabled, 'mirrors': instance.mirrors.map((e) => e.toJson()).toList(), }; AutoTorrentConfig _$AutoTorrentConfigFromJson(Map json) => AutoTorrentConfig( enable: json['enable'] as bool? ?? false, deleteAfterDownload: json['deleteAfterDownload'] as bool? ?? false, ); Map _$AutoTorrentConfigToJson(AutoTorrentConfig instance) => { 'enable': instance.enable, 'deleteAfterDownload': instance.deleteAfterDownload, }; ArchiveConfig _$ArchiveConfigFromJson(Map json) => ArchiveConfig( autoExtract: json['autoExtract'] as bool? ?? true, deleteAfterExtract: json['deleteAfterExtract'] as bool? ?? true, ); Map _$ArchiveConfigToJson(ArchiveConfig instance) => { 'autoExtract': instance.autoExtract, 'deleteAfterExtract': instance.deleteAfterExtract, }; ================================================ FILE: ui/flutter/lib/api/model/extension.dart ================================================ import 'package:json_annotation/json_annotation.dart'; part 'extension.g.dart'; @JsonSerializable(explicitToJson: true) class Extension { String identity; String name; String author; String title; String description; String icon; String version; String homepage; Repository? repository; List? settings; bool disabled; bool devMode; String devPath; Extension({ required this.identity, required this.name, required this.author, required this.title, required this.description, required this.icon, required this.version, required this.homepage, required this.repository, required this.disabled, required this.devMode, required this.devPath, }); factory Extension.fromJson(Map json) => _$ExtensionFromJson(json); Map toJson() => _$ExtensionToJson(this); } @JsonSerializable() class Repository { String url; String directory; Repository({ required this.url, required this.directory, }); factory Repository.fromJson(Map json) => _$RepositoryFromJson(json); Map toJson() => _$RepositoryToJson(this); } @JsonSerializable() class Setting { String name; String title; String description; bool required; SettingType type; Object? value; List