[
  {
    "path": ".dockerignore",
    "content": ".git\n\n*.data\n*.log\n\nnode_modules\n**/node_modules\n\nDockerfile\n.dockerignore\n\n.github\n_docs\n_examples\nbin\nui"
  },
  {
    "path": ".github/CODEOWNERS",
    "content": "* @monkeyWie\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "# These are supported funding model platforms\n\n#github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]\n#patreon: # Replace with a single Patreon username\n#open_collective: # Replace with a single Open Collective username\n#ko_fi: # Replace with a single Ko-fi username\n#tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel\n#community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry\n#liberapay: # Replace with a single Liberapay username\n#issuehunt: # Replace with a single IssueHunt username\n#otechie: # Replace with a single Otechie username\n#custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']\n# github: monkeyWie\nko_fi: gopeed\ncustom: https://gopeed.com/docs/donate\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE.md",
    "content": "<!--\nPlease use the following template to create an issue.\n-->\n### Description(required)\n### App Version(required)\n### OS Version(required)\n### Snapshots\n### Log"
  },
  {
    "path": ".github/release-drafter.yml",
    "content": "name-template: \"v$NEXT_PATCH_VERSION 🌈\"\ntag-template: \"v$NEXT_PATCH_VERSION\"\ncategories:\n  - title: \"🆕 Features\"\n    labels:\n      - \"feat\"\n      - \"feature\"\n      - \"enhancement\"\n  - title: \"🐛 Bug Fixes\"\n    labels:\n      - \"fix\"\n      - \"bugfix\"\n      - \"bug\"\n  - title: \"🔧 Performance Improvements\"\n    labels:\n      - \"perf\"\n  - title: \"🧪 Tests\"\n    label: \"test\"\n  - title: \"🧰 Maintenance\"\n    label: \"chore\"\n  - title: \"📖 Document\"\n    label: \"docs\"\n  - title: \"🚀 CI/CD\"\n    label: \"ci\"\n  - title: \"🌎 Internationalization\"\n    label: \"translation\"\nchange-template: \"- $TITLE @$AUTHOR (#$NUMBER)\"\nversion-resolver:\n  major:\n    labels:\n      - \"major\"\n  minor:\n    labels:\n      - \"minor\"\n  patch:\n    labels:\n      - \"patch\"\n  default: patch\ntemplate: |\n  <table>\n    <tbody>\n      <tr>\n        <td rowspan=\"4\">🪟 Windows</td>\n        <td rowspan=\"2\"><code>EXE</code></td>\n        <td>amd64</td>\n        <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-windows-amd64.zip\">📥</a></td>\n      </tr>\n      <tr>\n        <td>arm64</td>\n        <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-windows-arm64.zip\">📥</a></td>\n      </tr>\n      <tr>\n        <td rowspan=\"2\"><code>Portable</code></td>\n        <td>amd64</td>\n        <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-windows-amd64-portable.zip\">📥</a></td>\n      </tr>\n      <tr>\n        <td>arm64</td>\n        <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-windows-arm64-portable.zip\">📥</a></td>\n      </tr>\n      <tr>\n        <td rowspan=\"3\">🍎 MacOS</td>\n        <td rowspan=\"3\"><code>DMG</code></td>\n        <td>universal</td>\n        <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-macos.dmg\">📥</a></td>\n      </tr>\n      <tr>\n        <td>amd64</td>\n        <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-macos-amd64.dmg\">📥</a></td>\n      </tr>\n      <tr>\n        <td>arm64</td>\n        <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-macos-arm64.dmg\">📥</a></td>\n      </tr>\n      <tr>\n        <td rowspan=\"6\">🐧 Linux</td>\n        <td><code>Flathub</code></td>\n        <td>amd64</td>\n        <td><a href=\"https://flathub.org/apps/com.gopeed.Gopeed\">📥</a></td>\n      </tr>\n      <tr>\n        <td><code>SNAP</code></td>\n        <td>amd64</td>\n        <td><a href=\"https://snapcraft.io/gopeed\">📥</a></td>\n      </tr>\n      <tr>\n        <td rowspan=\"2\"><code>DEB</code></td>\n        <td>amd64</td>\n        <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-linux-amd64.deb\">📥</a></td>\n      </tr>\n      <tr>\n        <td>arm64</td>\n        <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-linux-arm64.deb\">📥</a></td>\n      </tr>\n      <tr>\n        <td rowspan=\"2\"><code>AppImage</code></td>\n        <td>amd64</td>\n        <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-linux-amd64.AppImage\">📥</a></td>\n      </tr>\n      <tr>\n        <td>arm64</td>\n        <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-linux-arm64.AppImage\">📥</a></td>\n      </tr>\n      <tr>\n        <td rowspan=\"4\">🤖 Android</td>\n        <td rowspan=\"4\"><code>APK</code></td>\n        <td>universal</td>\n        <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-android.apk\">📥</a></td>\n      </tr>\n      <tr>\n        <td>armeabi-v7a</td>\n        <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-android-armeabi-v7a.apk\">📥</a></td>\n      </tr>\n      <tr>\n        <td>arm64-v8a</td>\n        <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-android-arm64-v8a.apk\">📥</a></td>\n      </tr>\n      <tr>\n        <td>x86_64</td>\n        <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-android-x86_64.apk\">📥</a></td>\n      </tr>\n      <tr>\n        <td>📱 iOS</td>\n        <td><code>IPA</code></td>\n        <td>universal</td>\n        <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-ios.ipa\">📥</a></td>\n      </tr>\n      <tr>\n        <td>🐳 Docker</td>\n        <td>-</td>\n        <td>universal</td>\n        <td><a href=\"https://hub.docker.com/r/liwei2633/gopeed\">📥</a></td>\n      </tr>\n      <tr>\n        <td rowspan=\"2\">💾 Qnap</td>\n        <td rowspan=\"2\"><code>QPKG</code></td>\n        <td>amd64</td>\n        <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-v$NEXT_PATCH_VERSION-qnap-amd64.qpkg\">📥</a></td>\n      </tr>\n      <tr>\n        <td>arm64</td>\n        <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-v$NEXT_PATCH_VERSION-qnap-arm64.qpkg\">📥</a></td>\n      </tr>\n      <tr>\n        <td rowspan=\"8\">🌐 Web</td>\n        <td rowspan=\"3\"><code>Windows</code></td>\n        <td>amd64</td>\n        <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-v$NEXT_PATCH_VERSION-windows-amd64.zip\">📥</a></td>\n      </tr>\n      <tr>\n        <td>arm64</td>\n        <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-v$NEXT_PATCH_VERSION-windows-arm64.zip\">📥</a></td>\n      </tr>\n      <tr>\n        <td>386</td>\n        <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-v$NEXT_PATCH_VERSION-windows-386.zip\">📥</a></td>\n      </tr>\n      <tr>\n        <td rowspan=\"2\"><code>MacOS</code></td>\n        <td>amd64</td>\n        <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-v$NEXT_PATCH_VERSION-macos-amd64.zip\">📥</a></td>\n      </tr>\n      <tr>\n        <td>arm64</td>\n        <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-v$NEXT_PATCH_VERSION-macos-arm64.zip\">📥</a></td>\n      </tr>\n      <tr>\n        <td rowspan=\"3\"><code>Linux</code></td>\n        <td>amd64</td>\n        <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-v$NEXT_PATCH_VERSION-linux-amd64.zip\">📥</a></td>\n      </tr>\n      <tr>\n        <td>arm64</td>\n        <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-v$NEXT_PATCH_VERSION-linux-arm64.zip\">📥</a></td>\n      </tr>\n      <tr>\n        <td>386</td>\n        <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-v$NEXT_PATCH_VERSION-linux-386.zip\">📥</a></td>\n      </tr>\n    </tbody>\n  </table>\n\n  # Release notes\n\n  $CHANGES\n\n  # 更新日志\n\n  $CHANGES\n"
  },
  {
    "path": ".github/workflows/build.yml",
    "content": "name: build\n\non:\n  workflow_dispatch:\n    inputs:\n      platform:\n        description: \"Build platform\"\n        required: true\n        default: \"all\"\n      test:\n        description: \"Test mode\"\n        required: true\n        default: \"false\"\n\nenv:\n  GO_VERSION: \"1.24\"\n  FLUTTER_VERSION: \"3.41.2\"\n  GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }}\n  GA4_API_SECRET: ${{ secrets.GA4_API_SECRET }}\n\npermissions:\n  contents: write\n\njobs:\n  get-release:\n    runs-on: ubuntu-latest\n    outputs:\n      version: ${{ steps.get-release.outputs.version }}\n      upload_url: ${{ steps.get-release.outputs.upload_url }}\n\n    steps:\n      - uses: monkeyWie/get-latest-release@v2.1\n        id: get-release\n        with:\n          myToken: ${{ github.token }}\n\n  build-windows:\n    if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows' }}\n    needs: [get-release]\n    strategy:\n      fail-fast: false\n      matrix:\n        include:\n          - arch: arm64\n            os: windows-11-arm\n            llvm_ver: \"20251202\" # Only ARM64\n            flutter_channel: \"main\"\n            flutter_version: \"7e1c8868\"\n          - arch: amd64\n            os: windows-2022\n    runs-on: ${{ matrix.os }}\n\n    steps:\n      - uses: actions/checkout@v3\n      - name: Enable long paths for flutter main branch checks\n        run: |\n          git config --global core.longpaths true\n\n      # Install LLVM environment only on ARM64\n      - name: Install llvm-mingw-ucrt-aarch64 (ARM64 only)\n        if: matrix.arch == 'arm64'\n        run: |\n          $ver = \"${{ matrix.llvm_ver }}\"\n          $url = \"https://github.com/mstorsjo/llvm-mingw/releases/download/$ver/llvm-mingw-$ver-ucrt-aarch64.zip\"\n          $zip = \"$env:RUNNER_TEMP\\\\llvm.zip\"\n          $extract = \"$env:RUNNER_TEMP\\\\extract\"\n          $target = \"C:\\\\clangarm64\"\n\n          curl -L $url -o $zip\n          rm -r -fo $extract,$target -ea Ignore\n          mkdir $extract | Out-Null\n\n          tar -xf $zip -C $extract\n          mv (Get-ChildItem $extract)[0].FullName $target\n\n          $b = \"$target\\\\bin\"\n          \"CC=$b\\\\clang.exe\"        >> $env:GITHUB_ENV\n          \"CXX=$b\\\\clang++.exe\"     >> $env:GITHUB_ENV\n          \"CLANGARM64_BIN=$b\"       >> $env:GITHUB_ENV\n          \"CGO_ENABLED=1\"           >> $env:GITHUB_ENV\n          \"CLANGARM64_ROOT=$target\" >> $env:GITHUB_ENV\n          $b >> $env:GITHUB_PATH\n\n      - uses: actions/setup-go@v4\n        with:\n          go-version: ${{ env.GO_VERSION }}\n      - uses: subosito/flutter-action@v2\n        with:\n          channel: ${{ matrix.flutter_channel || 'stable' }}\n          flutter-version: ${{ matrix.flutter_version || env.FLUTTER_VERSION }}\n      - name: Build\n        env:\n          VERSION: ${{ needs.get-release.outputs.version }}\n          GOARCH: ${{ matrix.arch }}\n        run: |\n          Invoke-WebRequest -Uri \"https://github.com/jrsoftware/issrc/raw/main/Files/Languages/Unofficial/ChineseSimplified.isl\" -OutFile \"ChineseSimplified.isl\"\n          mv ChineseSimplified.isl \"C:\\Program Files (x86)\\Inno Setup 6\\Languages\\\"\n\n          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\n          go build -ldflags=\"-w -s\" -o ui/flutter/assets/exec/host.exe github.com/GopeedLab/gopeed/cmd/host\n          go build -ldflags=\"-w -s\" -o ui/flutter/assets/exec/updater.exe github.com/GopeedLab/gopeed/cmd/updater\n          cd ui/flutter\n          $TAG = \"v$env:VERSION\"\n          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 }}\"\n          $system = \"C:\\Windows\\System32\"\n          if (\"${{ matrix.arch }}\" -eq \"amd64\") {\n              # for amd64\n              $release = \"build\\windows\\x64\\runner\\Release\\\"\n              $mingw = \"C:\\Program Files\\Git\\mingw64\\bin\"\n              cp $mingw\\libstdc++-6.dll $release\n              cp $mingw\\libgcc_s_seh-1.dll $release\n          } else {\n              # for ARM64\n              $release = \"build\\windows\\arm64\\runner\\Release\\\"\n              $mingw = \"$env:CLANGARM64_ROOT\\aarch64-w64-mingw32\\bin\"\n              cp $mingw\\libc++.dll $release\n              cp $mingw\\libunwind.dll $release\n          }\n          cp $mingw\\libwinpthread-1.dll $release\n          cp $system\\msvcp140.dll $release\n          cp $system\\vcruntime140.dll $release\n          cp $system\\vcruntime140_1.dll $release\n\n          New-Item -Path build\\windows\\Output -ItemType Directory\n          Compress-Archive -Path \"$release*\" -DestinationPath \"build\\windows\\Output\\Gopeed-$TAG-windows-${{ matrix.arch }}-portable.zip\"\n\n          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 }}\"\n          cd build/windows\n          echo @\"\n          ; Script generated by the Inno Setup Script Wizard.\n          ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!\n\n          #define MyAppName \"Gopeed\"\n          #define MyAppVersion \"$env:VERSION\"\n          #define MyAppPublisher \"monkeyWie\"\n          #define MyAppURL \"https://gopeed.com\"\n          #define MyAppExeName \"gopeed.exe\"\n\n          [Setup]\n          ; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications.\n          ; (To generate a new GUID, click Tools | Generate GUID inside the IDE.)\n          AppId={{5960F34D-1E42-402C-8C85-DE2FF24CBAE4}\n          AppName={#MyAppName}\n          AppVersion={#MyAppVersion}\n          ;AppVerName={#MyAppName} {#MyAppVersion}\n          AppPublisher={#MyAppPublisher}\n          AppPublisherURL={#MyAppURL}\n          AppSupportURL={#MyAppURL}\n          AppUpdatesURL={#MyAppURL}\n          DefaultDirName={autopf}\\gopeed\n          DisableProgramGroupPage=yes\n          LicenseFile=..\\..\\..\\..\\LICENSE\n          ; Remove the following line to run in administrative install mode (install for all users.)\n          PrivilegesRequired=lowest\n          OutputBaseFilename=gopeed\n          SetupIconFile=..\\..\\assets\\icon\\icon.ico\n          UninstallDisplayIcon={app}\\{#MyAppExeName}\n          Compression=lzma\n          SolidCompression=yes\n          WizardStyle=modern\n          LanguageDetectionMethod=uilanguage\n          ShowLanguageDialog=yes\n          CloseApplications=force\n\n          [Languages]\n          Name: \"english\"; MessagesFile: \"compiler:Default.isl\"\n          Name: \"chinesesimplified\"; MessagesFile: \"compiler:Languages\\ChineseSimplified.isl\"\n\n          [Tasks]\n          Name: \"desktopicon\"; Description: \"{cm:CreateDesktopIcon}\"; GroupDescription: \"{cm:AdditionalIcons}\"; Flags: unchecked\n\n          [Files]\n          Source: \".\\${{ matrix.arch == 'amd64' && 'x64' || 'arm64' }}\\runner\\Release\\*\"; DestDir: \"{app}\"; Flags: ignoreversion recursesubdirs;\n          ; NOTE: Don't use \"Flags: ignoreversion\" on any shared system files\n\n          [UninstallDelete]\n          Type: filesandordirs; Name: \"{app}\"\n\n          [Icons]\n          Name: \"{autoprograms}\\{#MyAppName}\"; Filename: \"{app}\\{#MyAppExeName}\"\n          Name: \"{autodesktop}\\{#MyAppName}\"; Filename: \"{app}\\{#MyAppExeName}\"; Tasks: desktopicon\n\n          [Run]\n          Filename: \"{app}\\{#MyAppExeName}\"; Description: \"{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}\"; Flags: nowait postinstall skipifsilent\n          \"@ > setup.iss\n          iscc.exe setup.iss\n          mv \"Output\\gopeed.exe\" \"Output\\Gopeed-$TAG-windows-${{ matrix.arch }}.exe\"\n          Compress-Archive -Path \"Output\\Gopeed-$TAG-windows-${{ matrix.arch }}.exe\" -DestinationPath \"Output\\Gopeed-$TAG-windows-${{ matrix.arch }}.zip\"\n      - name: Upload\n        uses: shogo82148/actions-upload-release-asset@v1\n        with:\n          upload_url: ${{ needs.get-release.outputs.upload_url }}\n          asset_path: ui/flutter/build/windows/Output/*\n          overwrite: true\n  build-macos-amd64-lib:\n    if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos' }}\n    runs-on: macos-15-intel\n    needs: [get-release]\n    steps:\n      - uses: actions/checkout@v3\n      - uses: actions/setup-go@v4\n        with:\n          go-version: ${{ env.GO_VERSION }}\n      - name: Build\n        env:\n          VERSION: ${{ needs.get-release.outputs.version }}\n        run: |\n          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\n          go build -ldflags=\"-w -s\" github.com/GopeedLab/gopeed/cmd/host\n          go build -ldflags=\"-w -s\" github.com/GopeedLab/gopeed/cmd/updater\n      - uses: actions/upload-artifact@v4\n        with:\n          name: macos-amd64-lib\n          path: |\n            libgopeed.dylib\n            host\n            updater\n  build-macos:\n    if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos' }}\n    runs-on: macos-latest\n    strategy:\n      matrix:\n        channel: [arm64, amd64, universal]\n    needs: [get-release, build-macos-amd64-lib]\n    steps:\n      - uses: actions/checkout@v3\n      - uses: actions/setup-go@v4\n        with:\n          go-version: ${{ env.GO_VERSION }}\n      - uses: actions/setup-node@v3\n        with:\n          node-version: 16\n      - uses: subosito/flutter-action@v2\n        with:\n          flutter-version: ${{ env.FLUTTER_VERSION }}\n      - name: Install appdmg\n        run: |\n          python3 -m pip install setuptools --break-system-packages\n          npm install -g appdmg\n      - uses: actions/download-artifact@v4\n        with:\n          name: macos-amd64-lib\n          path: ui/flutter/lib-amd64\n      - if: ${{ matrix.channel == 'arm64' }}\n        name: Build Arm64 Libraries\n        run: |\n          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\n          go build -ldflags=\"-w -s\" -o ui/flutter/assets/exec/host github.com/GopeedLab/gopeed/cmd/host\n          go build -ldflags=\"-w -s\" -o ui/flutter/assets/exec/updater github.com/GopeedLab/gopeed/cmd/updater\n      - if: ${{ matrix.channel == 'amd64' }}\n        name: Build Amd64 Libraries\n        run: |\n          mkdir -p ui/flutter/macos/Frameworks\n\n          cp ui/flutter/lib-amd64/libgopeed.dylib ui/flutter/macos/Frameworks/libgopeed.dylib\n          cp ui/flutter/lib-amd64/host ui/flutter/assets/exec/host\n          cp ui/flutter/lib-amd64/updater ui/flutter/assets/exec/updater\n      - if: ${{ matrix.channel == 'universal' }}\n        name: Build Universal Libraries\n        run: |\n          mkdir -p ui/flutter/macos/Frameworks\n\n          # arm64 lib\n          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\n          go build -ldflags=\"-w -s\" -o ui/flutter/.temp/host.arm64 github.com/GopeedLab/gopeed/cmd/host\n          go build -ldflags=\"-w -s\" -o ui/flutter/.temp/updater.arm64 github.com/GopeedLab/gopeed/cmd/updater\n\n          # amd64 lib\n          cp ui/flutter/lib-amd64/libgopeed.dylib ui/flutter/.temp/libgopeed.dylib.amd64\n          cp ui/flutter/lib-amd64/host ui/flutter/.temp/host.amd64\n          cp ui/flutter/lib-amd64/updater ui/flutter/.temp/updater.amd64\n\n          # universal lib\n          lipo -create -output ui/flutter/macos/Frameworks/libgopeed.dylib ui/flutter/.temp/libgopeed.dylib.arm64 ui/flutter/.temp/libgopeed.dylib.amd64\n          lipo -create -output ui/flutter/assets/exec/host ui/flutter/.temp/host.arm64 ui/flutter/.temp/host.amd64\n          lipo -create -output ui/flutter/assets/exec/updater ui/flutter/.temp/updater.arm64 ui/flutter/.temp/updater.amd64\n      - name: Build\n        env:\n          VERSION: ${{ needs.get-release.outputs.version }}\n        run: |\n          cd ui/flutter\n          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 }}\"\n          cd build/macos/Build/Products/Release\n          cat>appdmg.json<<EOF\n          {\n            \"title\": \"Gopeed\",\n            \"icon\": \"Gopeed.app/Contents/Resources/AppIcon.icns\",\n            \"contents\": [\n              { \"x\": 448, \"y\": 344, \"type\": \"link\", \"path\": \"/Applications\" },\n              { \"x\": 192, \"y\": 344, \"type\": \"file\", \"path\": \"Gopeed.app\" }\n            ]\n          }\n          EOF\n          mkdir dist\n          appdmg appdmg.json dist/Gopeed-v$VERSION-macos.dmg\n\n          if [[ \"${{ matrix.channel }}\" == \"arm64\" ]]; then\n            mv dist/Gopeed-v$VERSION-macos.dmg dist/Gopeed-v$VERSION-macos-arm64.dmg\n          fi\n          if [[ \"${{ matrix.channel }}\" == \"amd64\" ]]; then\n            mv dist/Gopeed-v$VERSION-macos.dmg dist/Gopeed-v$VERSION-macos-amd64.dmg\n          fi\n      - name: Upload\n        uses: shogo82148/actions-upload-release-asset@v1\n        with:\n          upload_url: ${{ needs.get-release.outputs.upload_url }}\n          asset_path: ui/flutter/build/macos/Build/Products/Release/dist/*\n          overwrite: true\n  build-linux:\n    if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux' }}\n    runs-on: ${{ matrix.os }}\n    strategy:\n      matrix:\n        os: [ubuntu-22.04, ubuntu-22.04-arm]\n    needs: [get-release]\n    steps:\n      - uses: actions/checkout@v3\n      - uses: actions/setup-go@v4\n        with:\n          go-version: ${{ env.GO_VERSION }}\n      - uses: subosito/flutter-action@v2\n        with:\n          flutter-version: ${{ env.FLUTTER_VERSION }}\n          channel: master\n      - run: |\n          sudo apt update -y\n          sudo apt install -y ninja-build libgtk-3-dev libayatana-appindicator3-1 libayatana-appindicator3-dev rpm patchelf libfuse2 locate libkeybinder-3.0-dev\n          arch=$(uname -m)\n          wget -O appimagetool \"https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${arch}.AppImage\"\n          chmod +x appimagetool\n          sudo mv appimagetool /usr/local/bin/\n      - name: Build\n        env:\n          VERSION: ${{ needs.get-release.outputs.version }}\n        run: |\n          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\n          go build -ldflags=\"-w -s\" -o ui/flutter/assets/exec/host github.com/GopeedLab/gopeed/cmd/host\n          go build -ldflags=\"-w -s\" -o ui/flutter/assets/exec/updater github.com/GopeedLab/gopeed/cmd/updater\n\n          cd ui/flutter\n\n          RPM_ARCH=$(uname -m)\n          mkdir -p linux/packaging/rpm\n          cat << EOF > linux/packaging/rpm/make_config.yaml\n          display_name: Gopeed\n          icon: assets/icon/icon.svg\n          summary: High speed downloader.\n          description: A high speed downloader that supports all platforms.\n          group: Applications/Internet\n          license: GPL-3.0\n          url: https://github.com/GopeedLab/gopeed\n          vendor: GopeedLab\n          maintainer: GopeedLab <support@gopeed.com>\n          build_arch: $RPM_ARCH\n          EOF\n\n          cat << EOF > distribute_options.yaml\n          output: dist/\n          releases:\n            - name: linux\n              jobs:\n                - name: appimage\n                  package:\n                    platform: linux\n                    target: appimage\n                    build_args:\n                      dart-define:\n                        UPDATE_CHANNEL: linuxAppImage\n                        GA4_MEASUREMENT_ID: ${{ env.GA4_MEASUREMENT_ID }}\n                        GA4_API_SECRET: ${{ env.GA4_API_SECRET }}\n                - name: deb\n                  package:\n                    platform: linux\n                    target: deb\n                    build_args:\n                      dart-define:\n                        UPDATE_CHANNEL: linuxDeb\n                        GA4_MEASUREMENT_ID: ${{ env.GA4_MEASUREMENT_ID }}\n                        GA4_API_SECRET: ${{ env.GA4_API_SECRET }}\n                - name: rpm\n                  package:\n                    platform: linux\n                    target: rpm\n                    build_args:\n                      dart-define:\n                        UPDATE_CHANNEL: linuxRpm\n                        GA4_MEASUREMENT_ID: ${{ env.GA4_MEASUREMENT_ID }}\n                        GA4_API_SECRET: ${{ env.GA4_API_SECRET }}\n          EOF\n          dart pub global activate -sgit https://github.com/GopeedLab/flutter_distributor.git --git-path packages/flutter_distributor\n          flutter_distributor release --name linux\n          cd dist/*\n\n          ARCH=\"amd64\"\n          if [[ \"${{ matrix.os }}\" == *-arm ]]; then\n            ARCH=\"arm64\"\n          fi\n\n          mv gopeed-*-linux.AppImage Gopeed-v${VERSION}-linux-${ARCH}.AppImage\n          mv gopeed-*-linux.deb Gopeed-v${VERSION}-linux-${ARCH}.deb\n          mv gopeed-*-linux.rpm Gopeed-v${VERSION}-linux-${ARCH}.rpm\n      - name: Upload\n        uses: shogo82148/actions-upload-release-asset@v1\n        with:\n          upload_url: ${{ needs.get-release.outputs.upload_url }}\n          asset_path: ui/flutter/dist/*/*\n          overwrite: true\n  build-snap:\n    if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'snap' }}\n    runs-on: ubuntu-latest\n    needs: [get-release]\n    steps:\n      - uses: actions/checkout@v3\n      - uses: actions/setup-go@v4\n        with:\n          go-version: ${{ env.GO_VERSION }}\n      - name: Setup LXD\n        uses: canonical/setup-lxd@v0.1.1\n        with:\n          channel: latest/stable\n      - name: Build\n        env:\n          VERSION: ${{ needs.get-release.outputs.version }}\n        run: |\n          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\n          go build -ldflags=\"-w -s\" -o ui/flutter/assets/exec/host github.com/GopeedLab/gopeed/cmd/host\n          go build -ldflags=\"-w -s\" -o ui/flutter/assets/exec/updater github.com/GopeedLab/gopeed/cmd/updater\n          cd ui/flutter\n\n          sudo snap install snapcraft --classic\n          mkdir -p snap/gui\n          cp assets/icon/icon.svg snap/gui/gopeed.svg\n\n          cat>snap/snapcraft.yaml<<EOF\n          name: gopeed\n          version: $VERSION\n          summary: A modern download manager\n          description: High speed downloader that supports all platforms.\n\n          confinement: strict\n          base: core24\n          grade: stable\n\n          platforms:\n            amd64:\n              build-on: amd64\n              build-for: amd64\n\n          slots:\n            dbus-gopeed:\n              interface: dbus\n              bus: session\n              name: com.gopeed.gopeed\n\n          apps:\n            gopeed:\n              command-chain:\n                - bin/gpu-2404-wrapper\n              command: bin/gopeed\n              extensions: [gnome]\n              common-id: com.gopeed.Gopeed\n              plugs:\n                - network\n                - home\n          parts:\n            flutter-git:\n              source: https://github.com/flutter/flutter.git\n              source-tag: $FLUTTER_VERSION\n              source-depth: 1\n              plugin: nil\n              override-build: |\n                mkdir -p \\$CRAFT_PART_INSTALL/usr/bin\n                mkdir -p \\$CRAFT_PART_INSTALL/usr/libexec\n                cp -r \\$CRAFT_PART_SRC \\$CRAFT_PART_INSTALL/usr/libexec/flutter\n                ln -s \\$CRAFT_PART_INSTALL/usr/libexec/flutter/bin/flutter \\$CRAFT_PART_INSTALL/usr/bin/flutter\n                ln -s \\$SNAPCRAFT_PART_INSTALL/usr/libexec/flutter/bin/dart \\$SNAPCRAFT_PART_INSTALL/usr/bin/dart\n                \\$CRAFT_PART_INSTALL/usr/bin/flutter doctor\n              build-packages:\n                - clang\n                - cmake\n                - curl\n                - libgtk-3-dev\n                - libkeybinder-3.0-dev\n                - ninja-build\n                - unzip\n                - xz-utils\n                - zip\n              override-prime: ''\n            zenity:\n              plugin: nil\n              stage-packages:\n                - zenity\n            gopeed:\n              after: [flutter-git,zenity]\n              source: .\n              plugin: nil\n              stage-packages:\n                - libgtk-3-dev\n                - libkeybinder-3.0-dev\n                - libappindicator3-dev\n              override-build: |\n                # work around pub get stack overflow # https://github.com/dart-lang/sdk/issues/51068#issuecomment-1396588253\n                set +e\n                dart pub get\n                set -eux\n                flutter build linux --dart-define=\"UPDATE_CHANNEL=linuxSnap\" --dart-define=\"GA4_MEASUREMENT_ID=$GA4_MEASUREMENT_ID\" --dart-define=\"GA4_API_SECRET=$GA4_API_SECRET\"\n                mkdir -p \\$CRAFT_PART_INSTALL/bin/\n                cp -r build/linux/*/release/bundle/* \\$CRAFT_PART_INSTALL/bin/\n            gpu-2404:\n              after: [gopeed]\n              source: https://github.com/canonical/gpu-snap.git\n              plugin: dump\n              override-prime: |\n                craftctl default\n                \\${CRAFT_PART_SRC}/bin/gpu-2404-cleanup mesa-2404\n              prime:\n                - bin/gpu-2404-wrapper\n\n          plugs:\n            gpu-2404:\n              interface: content\n              target: \\$SNAP/gpu-2404\n              default-provider: mesa-2404\n\n          layout:\n            /usr/share/libdrm:\n              bind: \\$SNAP/gpu-2404/libdrm\n            /usr/share/drirc.d:\n              symlink: \\$SNAP/gpu-2404/drirc.d\n            /usr/share/X11/XErrorDB:\n              symlink: \\$SNAP/gpu-2404/X11/XErrorDB\n          EOF\n\n          cp linux/assets/com.gopeed.Gopeed.desktop snap/gui/gopeed.desktop\n          sed -i 's/Icon=com.gopeed.Gopeed/Icon=\\${SNAP}\\/meta\\/gui\\/gopeed.svg/g' snap/gui/gopeed.desktop\n\n          snapcraft --use-lxd\n\n          # Snapcraft login\n          export SNAPCRAFT_STORE_CREDENTIALS=${{ secrets.SNAP_STORE_LOGIN }}\n          snapcraft upload --release=${{ github.event.inputs.test == 'true' && 'edge' || 'stable' }} gopeed_${VERSION}_amd64.snap\n  build-android:\n    if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'android' }}\n    runs-on: ubuntu-latest\n    needs: [get-release]\n    steps:\n      - uses: actions/checkout@v3\n      - uses: actions/setup-go@v4\n        with:\n          go-version: ${{ env.GO_VERSION }}\n      - uses: actions/setup-java@v3\n        with:\n          distribution: \"zulu\"\n          java-version: \"17\"\n      - uses: subosito/flutter-action@v2\n        with:\n          flutter-version: ${{ env.FLUTTER_VERSION }}\n      - name: Build\n        env:\n          VERSION: ${{ needs.get-release.outputs.version }}\n          APK_KEYSTORE: ${{ secrets.APK_KEYSTORE }}\n          APK_KEY_PASSWORD: ${{ secrets.APK_KEY_PASSWORD }}\n          APK_STORE_PASSWORD: ${{ secrets.APK_STORE_PASSWORD }}\n        run: |\n          go install golang.org/x/mobile/cmd/gomobile@latest\n          go get golang.org/x/mobile/bind\n          gomobile init\n          gomobile bind -tags nosqlite -ldflags=\"-w -s -checklinkname=0 -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION\" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 21 -javapkg=com.gopeed github.com/GopeedLab/gopeed/bind/mobile\n          cd ui/flutter\n          echo $APK_KEYSTORE | base64 -di > android/app/upload-keystore.jks\n          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 }}\"\n          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 }}\"\n          mkdir dist\n          cp build/app/outputs/flutter-apk/app-release.apk dist/Gopeed-v$VERSION-android.apk\n          cp build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk dist/Gopeed-v$VERSION-android-armeabi-v7a.apk\n          cp build/app/outputs/flutter-apk/app-arm64-v8a-release.apk dist/Gopeed-v$VERSION-android-arm64-v8a.apk\n          cp build/app/outputs/flutter-apk/app-x86_64-release.apk dist/Gopeed-v$VERSION-android-x86_64.apk\n      - name: Upload\n        uses: shogo82148/actions-upload-release-asset@v1\n        with:\n          upload_url: ${{ needs.get-release.outputs.upload_url }}\n          asset_path: ui/flutter/dist/*\n          overwrite: true\n  build-ios:\n    if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios' }}\n    runs-on: macos-14\n    needs: [get-release]\n    steps:\n      - uses: actions/checkout@v3\n      - uses: actions/setup-go@v4\n        with:\n          go-version: ${{ env.GO_VERSION }}\n      - uses: subosito/flutter-action@v2\n        with:\n          flutter-version: ${{ env.FLUTTER_VERSION }}\n      - name: Build\n        env:\n          VERSION: ${{ needs.get-release.outputs.version }}\n        run: |\n          go install golang.org/x/mobile/cmd/gomobile@latest\n          go get golang.org/x/mobile/bind\n          gomobile init\n          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\n          cd ui/flutter\n          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 }}\"\n          mkdir Payload\n          cp -r build/ios/iphoneos/Runner.app Payload\n          zip -r -y Payload.zip Payload/Runner.app\n          mkdir dist\n          mv Payload.zip dist/Gopeed-v$VERSION-ios.ipa\n      - name: Upload\n        uses: shogo82148/actions-upload-release-asset@v1\n        with:\n          upload_url: ${{ needs.get-release.outputs.upload_url }}\n          asset_path: ui/flutter/dist/*\n          overwrite: true\n  build-web:\n    if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'web' || github.event.inputs.platform == 'qnap' }}\n    runs-on: ubuntu-latest\n    needs: [get-release]\n    steps:\n      - uses: actions/checkout@v3\n      - uses: actions/setup-go@v4\n        with:\n          go-version: ${{ env.GO_VERSION }}\n      - uses: subosito/flutter-action@v2\n        with:\n          flutter-version: ${{ env.FLUTTER_VERSION }}\n      - name: Build\n        env:\n          VERSION: ${{ needs.get-release.outputs.version }}\n        run: |\n          cd ui/flutter\n          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 }}\"\n          dart ../../.github/workflows/scripts/flutter_local_font.dart\n          cd ../../\n          cp -r ui/flutter/build/web cmd/web/dist\n          mkdir -p dist/zip\n\n          goos_arr=(windows darwin linux)\n          goarch_arr=(386 amd64 arm64)\n          export CGO_ENABLED=0\n          for goos in \"${goos_arr[@]}\"; do\n            for goarch in \"${goarch_arr[@]}\"; do\n              goos_name=$goos\n              if [ $goos = \"darwin\" ]; then\n                goos_name=\"macos\"\n              fi\n              name=gopeed-web-v$VERSION-$goos_name-$goarch\n              dir=\"dist/$name/\"\n              (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 \\\n              && cd $dir \\\n              && file=$(ls -AU | head -1) \\\n              && mkdir $name \\\n              && mv $file $name/$(echo $file | sed -e \"s/web/gopeed/g\") \\\n              && zip -r ../zip/$name.zip * \\\n              && cd ../..) \\\n              || true\n            done\n          done\n      - uses: actions/upload-artifact@v4\n        with:\n          name: web-dist\n          path: dist/zip/\n      - name: Upload\n        uses: shogo82148/actions-upload-release-asset@v1\n        with:\n          upload_url: ${{ needs.get-release.outputs.upload_url }}\n          asset_path: dist/zip/*\n          overwrite: true\n  build-qnap:\n    if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'qnap' }}\n    runs-on: ubuntu-latest\n    needs: [get-release, build-web]\n    steps:\n      - uses: actions/setup-python@v5\n        with:\n          python-version: \"3.8.18\"\n      - uses: actions/download-artifact@v4\n        with:\n          name: web-dist\n          path: dist/zip/\n      - name: Build\n        env:\n          VERSION: ${{ needs.get-release.outputs.version }}\n        run: |\n          sudo apt update -y\n          sudo apt install -y pv bsdmainutils\n          wget -O qdk2_0.32.bionic_amd64.deb \"https://github.com/qnap-dev/qdk2/releases/download/v0.32/qdk2_0.32.bionic_amd64.deb\"\n          dpkg -X qdk2_0.32.bionic_amd64.deb qdk2 # Direct installs will fail due to missing dependencies!\n          [[ -d qdk2 ]] || exit 1\n\n          export PATH=$(pwd)/qdk2/usr/bin:$(pwd)/qdk2/usr/share/qdk2/QDK/bin:${PATH}\n          wget -O Gopeed.template.tar.gz \"https://github.com/GopeedLab/QpkgBuild/raw/refs/heads/master/template/Gopeed.template.tar.gz\"\n          tar -zxf Gopeed.template.tar.gz\n          [[ -d Gopeed ]] || exit 1\n\n          goos=linux\n          goarch_arr=(amd64 arm64)\n          for goarch in \"${goarch_arr[@]}\"; do\n            qarch=x86_64\n            [[ \"${goarch}\" == \"arm64\" ]] && qarch=arm_64\n            name=gopeed-web-v${VERSION}-${goos}-${goarch}\n            unzip dist/zip/${name}.zip -d dist/${name}\n            cp dist/${name}/${name}/* Gopeed/${qarch}/\n          done\n          cd Gopeed\n          sed -i -e 's/__QPKG_VER__/${VERSION}/g' qpkg.cfg\n          qbuild || exit 1\n\n          mkdir -p ../dist/qnap\n          goos=qnap\n          for goarch in \"${goarch_arr[@]}\"; do\n            qarch=x86_64\n            [[ \"${goarch}\" == \"arm64\" ]] && qarch=arm_64\n            sname=Gopeed_${VERSION}_${qarch}.qpkg\n            dname=gopeed-v${VERSION}-${goos}-${goarch}.qpkg\n            [[ -f build/${sname} ]] && cp -ra build/${sname} ../dist/qnap/${dname}\n          done\n      - name: Upload\n        uses: shogo82148/actions-upload-release-asset@v1\n        with:\n          upload_url: ${{ needs.get-release.outputs.upload_url }}\n          asset_path: dist/qnap/*\n          overwrite: true\n  build-docker:\n    if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'docker' }}\n    runs-on: ubuntu-latest\n    needs: [get-release]\n    steps:\n      - name: Remove unnecessary files\n        run: |\n          sudo rm -rf /usr/share/dotnet\n          sudo rm -rf \"$AGENT_TOOLSDIRECTORY\"\n      - uses: subosito/flutter-action@v2\n        with:\n          flutter-version: ${{ env.FLUTTER_VERSION }}\n      - uses: actions/checkout@v3\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v2\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v2\n      - name: Login to DockerHub\n        uses: docker/login-action@v1\n        with:\n          username: liwei2633\n          password: ${{ secrets.DOCKER_PASSWORD }}\n      - name: Build flutter web\n        run: |\n          cd ui/flutter\n          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 }}\"\n          dart ../../.github/workflows/scripts/flutter_local_font.dart\n          cd ../../\n          rm -rf cmd/web/dist\n          cp -r ui/flutter/build/web cmd/web/dist\n      - name: Build and push\n        uses: docker/build-push-action@v2\n        env:\n          VERSION: ${{ needs.get-release.outputs.version }}\n        with:\n          context: .\n          push: ${{ github.event.inputs.test == 'true' && 'false' || 'true' }}\n          build-args: |\n            VERSION=${{ env.VERSION }}\n            IN_DOCKER=true\n          platforms: |\n            linux/386\n            linux/amd64\n            linux/arm64\n            linux/arm/v7\n          tags: |\n            liwei2633/gopeed:latest\n            liwei2633/gopeed:v${{ env.VERSION }}\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: release\n\non:\n  push:\n    branches:\n      - main\n    paths:\n      - \"bind/**\"\n      - \"cmd/**\"\n      - \"internal/**\"\n      - \"pkg/**\"\n      - \"ui/**\"\n      - \".github/workflows/release.yml\"\n      - \".github/release-drafter.yml\"\n      - \"go.mod\"\n      - \"go.sum\"\n      - \"Dockerfile\"\n\nenv:\n  GO_VERSION: \"1.24\"\n\npermissions:\n  contents: write\n\njobs:\n  release:\n    runs-on: ubuntu-latest\n    outputs:\n      tag_name: ${{ steps.create_release.outputs.tag_name }}\n      upload_url: ${{ steps.create_release.outputs.upload_url }}\n\n    steps:\n      - uses: actions/checkout@v3\n      - uses: actions/setup-go@v4\n        with:\n          go-version: ${{ env.GO_VERSION }}\n      - uses: release-drafter/release-drafter@v5\n        id: create_release\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/scripts/flutter_local_font.dart",
    "content": "// Localize external Google Fonts (fonts.gstatic.com) references in Flutter Web build output.\n//\n// Usage (post-build):\n//   # Run AFTER: flutter build web --no-web-resources-cdn\n//   dart ../../.github/workflows/scripts/flutter_local_font.dart\n//\n// What it does:\n// In main.dart.js:\n//    - Find any referenced resources under: https://fonts.gstatic.com/s/\n//      (including the common pattern where Flutter concatenates the base URL with a relative path)\n//    - Download them into: build/web/assets/gstatic/<path>\n//    - Rewrite \"https://fonts.gstatic.com/s/\" -> \"assets/gstatic/\" so runtime loads locally\n//    - Only download font subsets needed by supported locales (parsed from message.dart)\n//\n// Notes:\n// - This script DOES NOT call `flutter build web`. It only patches the output.\n// - This is a best-effort post-process. Always verify in browser devtools.\n\nimport 'dart:async';\nimport 'dart:io';\n\n/// Get font families required for a specific locale based on language/script.\n/// Uses language code prefix to automatically detect the script system.\n/// Returns empty set if base fonts (Latin/Cyrillic/Greek) are sufficient.\nSet<String> _getFontFamiliesForLocale(String locale) {\n  final lang = locale.toLowerCase().split('_').first;\n\n  // CJK languages - need specific large font files\n  if (lang == 'zh') {\n    // Simplified vs Traditional Chinese\n    if (locale.contains('cn') || locale.contains('sg')) {\n      return {'notosanssc'}; // Simplified Chinese\n    }\n    return {'notosanstc', 'notosanshk'}; // Traditional Chinese (TW, HK, MO)\n  }\n  if (lang == 'ja') return {'notosansjp'}; // Japanese\n  if (lang == 'ko') return {'notosanskr'}; // Korean\n\n  // Arabic script languages\n  if (lang == 'ar') return {'notosansarabic'}; // Arabic\n  if (lang == 'fa') return {'notosansarabic'}; // Persian/Farsi\n  if (lang == 'ur') return {'notosansarabic'}; // Urdu\n  if (lang == 'ps') return {'notosansarabic'}; // Pashto\n  if (lang == 'ku') return {'notosansarabic'}; // Kurdish (Arabic script)\n\n  // Hebrew script\n  if (lang == 'he' || lang == 'yi') return {'notosanshebrew'};\n\n  // South Asian scripts\n  if (lang == 'hi' || lang == 'mr' || lang == 'ne' || lang == 'sa') {\n    return {'notosansdevanagari'}; // Hindi, Marathi, Nepali, Sanskrit\n  }\n  if (lang == 'bn' || lang == 'as')\n    return {'notosansbengali'}; // Bengali, Assamese\n  if (lang == 'ta')\n    return {'notosanstamil', 'notosanstamilsupplement'}; // Tamil\n  if (lang == 'te') return {'notosanstelugu'}; // Telugu\n  if (lang == 'kn') return {'notosanskannada'}; // Kannada\n  if (lang == 'ml') return {'notosansmalayalam'}; // Malayalam\n  if (lang == 'gu') return {'notosansgujarati'}; // Gujarati\n  if (lang == 'pa') return {'notosansgurmukhi'}; // Punjabi (Gurmukhi)\n  if (lang == 'or') return {'notosansoriya'}; // Odia/Oriya\n  if (lang == 'si') return {'notosanssinhala'}; // Sinhala\n\n  // Southeast Asian scripts\n  if (lang == 'th') return {'notosansthai'}; // Thai\n  if (lang == 'lo') return {'notosanslao'}; // Lao\n  if (lang == 'my') return {'notosansmyanmar'}; // Myanmar/Burmese\n  if (lang == 'km') return {'notosanskhmer'}; // Khmer/Cambodian\n  if (lang == 'jv') return {'notosansjavanese'}; // Javanese\n\n  // Other scripts\n  if (lang == 'ka') return {'notosansgeorgian'}; // Georgian\n  if (lang == 'hy') return {'notosansarmenian'}; // Armenian\n  if (lang == 'am' || lang == 'ti')\n    return {'notosansethiopic'}; // Amharic, Tigrinya\n  if (lang == 'mn') return {'notosansmongolian'}; // Mongolian\n\n  // Latin/Cyrillic/Greek based languages - base notosans is sufficient\n  // Includes: English, German, French, Spanish, Italian, Portuguese,\n  // Russian, Ukrainian, Polish, Turkish, Vietnamese, Greek, etc.\n  return {};\n}\n\n/// Base font families that are always included regardless of locale.\n/// These provide core functionality and are relatively small.\nconst _baseFontFamilies = <String>{\n  'notosans', // Base Latin/Cyrillic/Greek/Vietnamese\n  'roboto', // Material Design default font\n  'notosanssymbols', // Common symbols\n  'notosanssymbols2', // Additional symbols\n  'notosansmath', // Math symbols\n  'notomusic', // Music notation symbols\n  // Note: notocoloremoji (~24MB) and notoemoji (~860KB) are excluded\n  // to reduce bundle size. Add them back if emoji support is needed.\n};\n\n/// Parse supported locales from message.dart file.\n/// Reads the import statements and extracts locale codes like 'zh_cn', 'en_us', etc.\nSet<String> _parseSupportedLocales(File messageFile) {\n  final locales = <String>{};\n\n  if (!messageFile.existsSync()) {\n    _fail('Warning: message.dart not found, only base fonts will be used');\n  }\n\n  final content = messageFile.readAsStringSync();\n\n  // Match import statements like: import 'langs/zh_cn.dart';\n  final importRegex = RegExp(r\"import\\s+'langs/(\\w+)\\.dart'\");\n  for (final match in importRegex.allMatches(content)) {\n    final locale = match.group(1);\n    if (locale != null) {\n      locales.add(locale);\n    }\n  }\n\n  if (locales.isEmpty) {\n    _fail(\n        'Warning: No locales found in message.dart, only base fonts will be used');\n  }\n\n  return locales;\n}\n\n/// Get required font families for the given locales.\n/// Returns a set of font family names that should be included.\n/// Automatically detects required fonts based on language code prefix.\nSet<String> _getRequiredFontFamilies(Set<String> locales) {\n  final families = <String>{..._baseFontFamilies};\n  for (final locale in locales) {\n    final localeFamilies = _getFontFamiliesForLocale(locale);\n    families.addAll(localeFamilies);\n  }\n  return families;\n}\n\n/// Check if a font file path should be included based on required font families.\n/// Font paths have the format: \"fontfamily/version/filename.ext\"\n/// Example: \"notosanssc/v36/xxx.ttf\" -> font family is \"notosanssc\"\nbool _fontMatchesRequiredFamilies(\n    String fontPath, Set<String> requiredFamilies) {\n  // Extract font family from path (first segment before '/')\n  final slashIndex = fontPath.indexOf('/');\n  if (slashIndex == -1) {\n    // No slash found, include by default (unusual path format)\n    return true;\n  }\n\n  final fontFamily = fontPath.substring(0, slashIndex).toLowerCase();\n  return requiredFamilies.contains(fontFamily);\n}\n\nFuture<void> main(List<String> args) async {\n  try {\n    // This script is intentionally argument-free for CI convenience.\n    // It assumes it is executed from the Flutter project directory (ui/flutter),\n    // but will also try to locate pubspec.yaml by walking up.\n    final flutterDir = _findFlutterProjectRoot(Directory.current);\n\n    // Parse supported locales from message.dart\n    final messageFile =\n        File(_join(flutterDir.path, 'lib', 'i18n', 'message.dart'));\n    final supportedLocales = _parseSupportedLocales(messageFile);\n    stdout.writeln('Supported locales: ${supportedLocales.join(', ')}');\n\n    // Get required font families based on supported locales\n    final requiredFamilies = _getRequiredFontFamilies(supportedLocales);\n    stdout.writeln('Required font families: ${requiredFamilies.join(', ')}');\n\n    final webDir = Directory(_join(flutterDir.path, 'build', 'web'));\n    if (!webDir.existsSync()) {\n      _fail(\n        'Web build directory not found: ${webDir.path}\\n'\n        'Did you run: flutter build web --no-web-resources-cdn ?',\n      );\n    }\n\n    final mainJs = File(_join(webDir.path, 'main.dart.js'));\n    if (!mainJs.existsSync()) {\n      _fail('main.dart.js not found at: ${mainJs.path}');\n    }\n\n    final gstaticRoot = Directory(_join(webDir.path, 'assets', 'gstatic'));\n    if (!gstaticRoot.existsSync()) gstaticRoot.createSync(recursive: true);\n\n    const gstaticSPrefix = 'https://fonts.gstatic.com/s/';\n    final original = mainJs.readAsStringSync();\n\n    // Relative font asset paths which Flutter's loader typically concatenates with:\n    //   https://fonts.gstatic.com/s/\n    // Keep this as a best-effort heuristic to cover the common concatenation pattern.\n    // Some gstatic assets include extra dot segments like: notocoloremoji/v32/Yq6P-KqIXTD0t4D9z1ESnKM3-HpFabsE4tq3luCC7p-aXxcn.0.woff2\n    // Allow dots in the path, while still restricting to font-like extensions.\n    final relAssetRegex = RegExp(\n      r\"\"\"[\"']([a-zA-Z0-9/_\\.-]+\\.(?:woff2|woff|ttf|otf|eot|svg))[\"']\"\"\",\n      caseSensitive: false,\n    );\n    final relAssetsUnderS = <String>{};\n    for (final m in relAssetRegex.allMatches(original)) {\n      final p = m.group(1);\n      if (p == null || p.isEmpty) continue;\n      if (p.contains('://') || p.startsWith('data:')) continue;\n      if (p.startsWith('/') ||\n          p.startsWith('assets/') ||\n          p.startsWith('packages/')) continue;\n      if (p.contains('..')) continue;\n      relAssetsUnderS.add(p);\n    }\n\n    if (relAssetsUnderS.isEmpty) {\n      _fail('No fonts.gstatic.com assets found in main.dart.js.');\n    }\n\n    // Build a download plan: dest relative path (under assets/gstatic/) -> URL.\n    // Filter fonts based on required font families to reduce package size.\n    final downloads = <String, Uri>{};\n    final skippedFonts = <String, Set<String>>{};\n\n    for (final rel in relAssetsUnderS) {\n      if (_fontMatchesRequiredFamilies(rel, requiredFamilies)) {\n        downloads[rel] = Uri.parse('$gstaticSPrefix$rel');\n      } else {\n        // Track skipped fonts and their paths for fallback generation\n        final slashIndex = rel.indexOf('/');\n        final family = slashIndex > 0 ? rel.substring(0, slashIndex) : rel;\n        skippedFonts.putIfAbsent(family, () => <String>{}).add(rel);\n      }\n    }\n\n    if (skippedFonts.isNotEmpty) {\n      stdout.writeln(\n        'Skipped ${skippedFonts.length} font families not required by supported locales:',\n      );\n      stdout.writeln('  ${skippedFonts.keys.join(', ')}');\n    }\n\n    if (downloads.isNotEmpty) {\n      stdout.writeln(\n        'Found ${downloads.length} fonts.gstatic.com assets to download...',\n      );\n\n      // Reuse a single HttpClient to avoid creating hundreds of short-lived\n      // connections (can be flaky on some environments).\n      final httpClient = HttpClient()\n        ..connectionTimeout = const Duration(seconds: 30)\n        ..idleTimeout = const Duration(seconds: 30)\n        ..maxConnectionsPerHost = 6;\n      try {\n        for (final entry in downloads.entries) {\n          final relPath = entry.key;\n          final url = entry.value;\n          final destPath = relPath.replaceAll('/', Platform.pathSeparator);\n          final dest = File(_join(gstaticRoot.path, destPath));\n          await _downloadIfMissing(httpClient, url, dest);\n        }\n      } finally {\n        httpClient.close(force: true);\n      }\n    }\n\n    // Generate empty fallback font files for skipped fonts to prevent infinite loading\n    // Empty files will cause browsers to fail fast instead of retrying indefinitely\n    if (skippedFonts.isNotEmpty) {\n      var fallbackCount = 0;\n      for (final entry in skippedFonts.entries) {\n        for (final relPath in entry.value) {\n          final destPath = relPath.replaceAll('/', Platform.pathSeparator);\n          final dest = File(_join(gstaticRoot.path, destPath));\n          if (!dest.existsSync()) {\n            dest.parent.createSync(recursive: true);\n            // Create an empty file - browsers will recognize it as invalid and skip\n            dest.writeAsBytesSync([]);\n            fallbackCount++;\n          }\n        }\n      }\n      if (fallbackCount > 0) {\n        stdout.writeln(\n          'Generated $fallbackCount empty fallback font files to prevent infinite loading',\n        );\n      }\n    }\n\n    // Rewrite remote prefix so requests become:\n    //   <origin>/assets/gstatic/<...>.woff2\n    final replacedGstatic = original.replaceAll(\n      gstaticSPrefix,\n      'assets/gstatic/',\n    );\n\n    if (replacedGstatic == original && downloads.isEmpty) {\n      stdout.writeln(\n        'No changes applied (no fonts.gstatic.com references found).',\n      );\n      exitCode = 0;\n      return;\n    }\n\n    mainJs.writeAsStringSync(replacedGstatic);\n\n    stdout.writeln('Patched: ${mainJs.path}');\n    if (downloads.isNotEmpty) {\n      stdout.writeln(\n        ' - downloaded ${downloads.length} files into: ${gstaticRoot.path}',\n      );\n    }\n    stdout.writeln(' - fonts.gstatic.com rewritten -> assets/gstatic/');\n  } catch (e, st) {\n    stderr.writeln('ERROR: $e');\n    stderr.writeln(st);\n    exitCode = 1;\n  }\n}\n\nFuture<void> _downloadIfMissing(HttpClient client, Uri url, File dest) async {\n  if (dest.existsSync() && dest.lengthSync() > 0) return;\n\n  dest.parent.createSync(recursive: true);\n\n  // Network can be flaky in CI. Retry a few times on transient errors.\n  const maxAttempts = 4;\n\n  for (var attempt = 1; attempt <= maxAttempts; attempt++) {\n    final tmp = File('${dest.path}.tmp');\n    try {\n      try {\n        final req = await client.getUrl(url);\n\n        final resp = await req.close().timeout(const Duration(seconds: 60));\n        if (resp.statusCode != 200) {\n          // 404 is not transient in practice - fail fast with a clear message.\n          if (resp.statusCode == 404) {\n            _fail('Missing gstatic asset (404): $url');\n          }\n          throw HttpException('HTTP ${resp.statusCode}', uri: url);\n        }\n\n        // Stream to temp file first, then rename to avoid leaving partial files.\n        if (tmp.existsSync()) tmp.deleteSync();\n        final sink = tmp.openWrite();\n        await resp.pipe(sink).timeout(const Duration(seconds: 120));\n\n        if (!tmp.existsSync() || tmp.lengthSync() == 0) {\n          throw const FormatException('Downloaded asset is empty');\n        }\n        if (dest.existsSync()) dest.deleteSync();\n        tmp.renameSync(dest.path);\n        return;\n      } finally {\n        // no-op: HttpClient lifecycle managed by the caller\n      }\n    } on TimeoutException {\n      if (tmp.existsSync()) tmp.deleteSync();\n      if (attempt == maxAttempts) {\n        _fail('Timeout downloading asset: $url');\n      }\n    } on SocketException catch (e) {\n      if (tmp.existsSync()) tmp.deleteSync();\n      if (attempt == maxAttempts) {\n        _fail('Socket error downloading asset: $url ($e)');\n      }\n    } on HttpException catch (e) {\n      if (tmp.existsSync()) tmp.deleteSync();\n      if (attempt == maxAttempts) {\n        _fail('HTTP error downloading asset: $url ($e)');\n      }\n    } on FileSystemException catch (e) {\n      if (tmp.existsSync()) tmp.deleteSync();\n      _fail('File write error downloading asset: $url ($e)');\n    } catch (e) {\n      if (tmp.existsSync()) tmp.deleteSync();\n      if (attempt == maxAttempts) {\n        _fail('Unexpected error downloading asset: $url ($e)');\n      }\n    }\n\n    // Backoff before retrying.\n    final backoffSeconds = 1 << (attempt - 1);\n    stdout.writeln(\n      'Retrying (${attempt + 1}/$maxAttempts) for: $url (wait ${backoffSeconds}s)',\n    );\n    await Future.delayed(Duration(seconds: backoffSeconds));\n  }\n}\n\nDirectory _findFlutterProjectRoot(Directory start) {\n  var dir = start;\n  for (var i = 0; i < 10; i++) {\n    final pubspec = File(_join(dir.path, 'pubspec.yaml'));\n    if (pubspec.existsSync()) return dir;\n    final parent = dir.parent;\n    if (parent.path == dir.path) break;\n    dir = parent;\n  }\n  // Fallback: use current directory, error messages will explain what is missing.\n  return start;\n}\n\nNever _fail(String msg) {\n  throw FormatException(msg);\n}\n\nString _join(String a, [String? b, String? c, String? d]) {\n  final parts = <String>[\n    a,\n    if (b != null) b,\n    if (c != null) c,\n    if (d != null) d,\n  ];\n  return parts.join(Platform.pathSeparator);\n}\n"
  },
  {
    "path": ".github/workflows/test.yml",
    "content": "name: test\n\non:\n  pull_request:\n    branches:\n      - main\n    paths:\n      - \"bind/**\"\n      - \"cmd/**\"\n      - \"internal/**\"\n      - \"pkg/**\"\n      - \"ui/**\"\n      - \".github/workflows/test.yml\"\n      - \"go.mod\"\n      - \"go.sum\"\n  push:\n    branches:\n      - main\n    paths:\n      - \"bind/**\"\n      - \"cmd/**\"\n      - \"internal/**\"\n      - \"pkg/**\"\n      - \"ui/**\"\n      - \".github/workflows/test.yml\"\n      - \"go.mod\"\n      - \"go.sum\"\n  workflow_dispatch:\n\nenv:\n  GO_VERSION: \"1.24\"\n  FLUTTER_VERSION: \"3.41.2\"\n\njobs:\n  #  lint:\n  #    name: Lint\n  #    runs-on: ubuntu-latest\n  #    steps:\n  #      - uses: actions/setup-go@v2\n  #        with:\n  #          go-version: '^1.19'\n  #      - uses: actions/checkout@v2\n  #      - name: Lint Go Code\n  #        run: |\n  #          export PATH=$PATH:$(go env GOPATH)/bin # temporary fix. See https://github.com/actions/setup-go/issues/14\n  #          go get -u golang.org/x/lint/golint\n  #          golint -set_exit_status ./...\n  test:\n    name: Test check\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v3\n      - uses: actions/setup-go@v4\n        with:\n          go-version: ${{ env.GO_VERSION }}\n      - name: Run Unit tests.\n        run: |\n          # Get packages for testing and coverage (including cmd/web for flags_test.go)\n          PACKAGES=$(go list ./... | grep -v /bind/ | grep -v -E '/cmd/(?!web)')\n          go test -v -coverpkg=$(echo \"$PACKAGES\" | paste -sd \",\" -) -covermode=count -coverprofile=coverage.txt $PACKAGES\n          # Filter out main.go from coverage report to avoid lowering coverage percentage\n          grep -v \"main.go\" coverage.txt > coverage_filtered.txt || true\n          mv coverage_filtered.txt coverage.txt\n      - uses: codecov/codecov-action@v4\n        with:\n          files: ./coverage.txt\n          token: ${{ secrets.CODECOV_UPLOAD_TOKEN }}\n  build-desktop:\n    strategy:\n      matrix:\n        include:\n          - os: windows-2022\n          - os: windows-11-arm\n            llvm_ver: \"20251202\" # Only ARM64\n            flutter_channel: \"main\"\n            flutter_version: \"7e1c8868\"\n          - os: macos-latest\n          - os: ubuntu-22.04\n          - os: ubuntu-22.04-arm\n            flutter_channel: \"main\"\n    name: Build desktop check (${{ matrix.os }})\n    runs-on: ${{ matrix.os }}\n    needs: [test]\n    steps:\n      - uses: actions/checkout@v3\n      - name: Enable long paths for flutter main branch checks\n        run: |\n          git config --global core.longpaths true\n      - name: Install llvm-mingw-ucrt-aarch64 (ARM64 only)\n        if: matrix.os == 'windows-11-arm'\n        run: |\n          $ver = \"${{ matrix.llvm_ver }}\"\n          $url = \"https://github.com/mstorsjo/llvm-mingw/releases/download/$ver/llvm-mingw-$ver-ucrt-aarch64.zip\"\n          $zip = \"$env:RUNNER_TEMP\\\\llvm.zip\"\n          $extract = \"$env:RUNNER_TEMP\\\\extract\"\n          $target = \"C:\\\\clangarm64\"\n\n          curl -L $url -o $zip\n          rm -r -fo $extract,$target -ea Ignore\n          mkdir $extract | Out-Null\n\n          tar -xf $zip -C $extract\n          mv (Get-ChildItem $extract)[0].FullName $target\n\n          $b = \"$target\\\\bin\"\n          \"CC=$b\\\\clang.exe\"        >> $env:GITHUB_ENV\n          \"CXX=$b\\\\clang++.exe\"     >> $env:GITHUB_ENV\n          \"CLANGARM64_BIN=$b\"       >> $env:GITHUB_ENV\n          \"CGO_ENABLED=1\"           >> $env:GITHUB_ENV\n          \"CLANGARM64_ROOT=$target\" >> $env:GITHUB_ENV\n          $b >> $env:GITHUB_PATH\n      - uses: actions/setup-go@v4\n        with:\n          go-version: ${{ env.GO_VERSION }}\n      - uses: subosito/flutter-action@v2\n        with:\n          channel: ${{ matrix.flutter_channel || 'stable' }}\n          flutter-version: ${{ matrix.flutter_version || env.FLUTTER_VERSION }}\n      - if: runner.os == 'Windows'\n        run: |\n          go build -tags nosqlite -ldflags=\"-w -s\" -buildmode=c-shared -o ui/flutter/windows/libgopeed.dll github.com/GopeedLab/gopeed/bind/desktop\n          cd ui/flutter\n          flutter build windows\n      - if: runner.os == 'macOS'\n        run: |\n          go build -tags nosqlite -ldflags=\"-w -s\" -buildmode=c-shared -o ui/flutter/macos/Frameworks/libgopeed.dylib github.com/GopeedLab/gopeed/bind/desktop\n          cd ui/flutter\n          flutter build macos\n      - if: runner.os == 'Linux'\n        run: |\n          sudo apt-get update -y\n          sudo apt-get install -y ninja-build libgtk-3-dev libayatana-appindicator3-dev libkeybinder-3.0-dev\n          go build -tags nosqlite -ldflags=\"-w -s\" -buildmode=c-shared -o ui/flutter/linux/bundle/lib/libgopeed.so github.com/GopeedLab/gopeed/bind/desktop\n          cd ui/flutter\n          flutter build linux\n  build-mobile:\n    name: Build mobile check\n    runs-on: ${{ matrix.os }}\n    strategy:\n      matrix:\n        os: [macos-14, ubuntu-latest]\n    needs: [test]\n    steps:\n      - uses: actions/checkout@v3\n      - uses: actions/setup-go@v4\n        with:\n          go-version: ${{ env.GO_VERSION }}\n      - uses: subosito/flutter-action@v2\n        with:\n          flutter-version: ${{ env.FLUTTER_VERSION }}\n      - run: |\n          go install golang.org/x/mobile/cmd/gomobile@latest\n          go get golang.org/x/mobile/bind\n          gomobile init\n      - if: runner.os == 'macOS'\n        run: |\n          gomobile bind -tags nosqlite -ldflags=\"-w -s\" -o ui/flutter/ios/Frameworks/Libgopeed.xcframework -target=ios github.com/GopeedLab/gopeed/bind/mobile\n          cd ui/flutter\n          flutter build ipa --no-codesign\n      - if: runner.os == 'Linux'\n        uses: actions/setup-java@v3\n        with:\n          distribution: \"zulu\"\n          java-version: \"17\"\n      - if: runner.os == 'Linux'\n        run: |\n          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\n          cd ui/flutter\n          flutter build apk\n\n  build-web:\n    name: Build web check\n    runs-on: ubuntu-latest\n    needs: [test]\n    steps:\n      - uses: actions/checkout@v3\n      - uses: actions/setup-go@v4\n        with:\n          go-version: ${{ env.GO_VERSION }}\n      - uses: subosito/flutter-action@v2\n        with:\n          flutter-version: ${{ env.FLUTTER_VERSION }}\n      - name: Build\n        run: |\n          cd ui/flutter\n          flutter build web --no-web-resources-cdn\n          dart ../../.github/workflows/scripts/flutter_local_font.dart\n          cd ../../\n          rm -rf cmd/web/dist\n          cp -r ui/flutter/build/web cmd/web/dist\n          go build -tags nosqlite,web -ldflags=\"-s -w\" github.com/GopeedLab/gopeed/cmd/web\n  build-docker:\n    name: Build docker check\n    runs-on: ubuntu-latest\n    needs: [test]\n    steps:\n      - uses: actions/checkout@v3\n      - uses: actions/setup-go@v4\n        with:\n          go-version: ${{ env.GO_VERSION }}\n      - uses: subosito/flutter-action@v2\n        with:\n          flutter-version: ${{ env.FLUTTER_VERSION }}\n      - name: Build\n        run: |\n          cd ui/flutter\n          flutter build web --no-web-resources-cdn\n          cd ../../\n          rm -rf cmd/web/dist\n          cp -r ui/flutter/build/web cmd/web/dist\n          docker build -t gopeed .\n"
  },
  {
    "path": ".github/workflows/translator.yml.bak",
    "content": "# name: 'translator'\n\n# on:\n#   issues:\n#     types: [opened, edited]\n#   issue_comment:\n#     types: [created, edited]\n#   discussion:\n#     types: [created, edited]\n#   discussion_comment:\n#     types: [created, edited]\n#   pull_request_target:\n#     types: [opened, edited]\n#   pull_request_review_comment:\n#     types: [created, edited]\n\n# jobs:\n#   translate:\n#     permissions:\n#       issues: write\n#       discussions: write\n#       pull-requests: write\n#     runs-on: ubuntu-latest\n#     steps:\n#       - uses: lizheming/github-translate-action@1.1.2\n#         env:\n#           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n#         with:\n#           IS_MODIFY_TITLE: true\n#           APPEND_TRANSLATION: true\n"
  },
  {
    "path": ".gitignore",
    "content": "# Binaries for programs and plugins\n*.exe\n*.exe~\n*.dll\n*.so\n*.dylib\nui/flutter/dist/\n# Test binary, built with `go test -c`\n*.test\n\n# Output of the go coverage tool, specifically when used with LiteIDE\n*.out\n\n.idea/\n\nbin/\n\n.torrent.db\n.torrent.db-shm\n.torrent.db-wal\n\n*.data\n*.db\n*.log\n\n.DS_Store\n\nnode_modules/\ncmd/web/dist/\n.test_storage/\n.test_download/\n/extensions"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Gopeed contributors guide\n\nFirstly, thank you for your interest in contributing to Gopeed. This guide will help you better\nparticipate in the development of Gopeed.\n\n## Branch description\n\nThis project only has one main branch, namely the `main` branch. If you want to participate in the\ndevelopment of Gopeed, please fork this project first, and then develop in your fork project. After\ndevelopment is completed, submit a PR to this project and merge it into the `main` branch.\n\n## Local development\n\nIt is recommended to develop and debug through the web. First, start the backend service, and start\nit by the command line `go run cmd/api/main.go`, the default port of the service is `9999`, and then\nstart the front-end flutter project in `debug` mode to run.\n\n## Translation\n\nThe internationalization files of Gopeed are located in the `ui/flutter/lib/i18n/langs` directory.\nYou only need to add the corresponding language file in this directory.\n\nPlease refer to `en_us.dart` for translation.\nwords prefixed with `@` are not meant to be translated.\n\n## flutter development\n\nDon't forget to run`dart format ./ui/flutter`before you commit to keep your code in standard dart format\n\nTurn on build_runner watcher if you want to edit api/models:\n\n```\nflutter pub run build_runner watch\n```"
  },
  {
    "path": "CONTRIBUTING_ja-JP.md",
    "content": "# Gopeed コントリビューターガイド\n\nまず最初に、Gopeed への貢献に興味を持っていただきありがとうございます。このガイドは、あなたが Gopeed の\n開発に参加するための手助けとなるでしょう。\n\n## ブランチの説明\n\nこのプロジェクトのメインブランチは `main` ブランチのみです。Gopeed の開発に参加したい場合は、\nまずこのプロジェクトをフォークし、フォークしたプロジェクトで開発を行ってください。開発が完了したら、\nこのプロジェクトに PR を提出し、`main` ブランチにマージしてください。\n\n## ローカル開発\n\n開発およびデバッグはウェブ上で行うことを推奨する。まずバックエンドのサービスを起動し、\nコマンドライン `go run cmd/api/main.go` で起動する。サービスのデフォルトポートは `9999` で、\n次にフロントエンドの flutter プロジェクトを `debug` モードで起動して実行します。\n\n## 翻訳\n\nGopeed の国際化ファイルは `ui/flutter/lib/i18n/langs` ディレクトリにあります。\nこのディレクトリに対応する言語ファイルを追加するだけでよいです。\n\n翻訳については `en_us.dart` を参照してください。\n\n## flutter での開発\n\nコミットする前に `dart format ./ui/flutter` を実行し、コードを標準の dart フォーマットにしておくことを忘れないでください\n\napi/models を編集したい場合は build_runner watcher をオンにします:\n\n```\nflutter pub run build_runner watch\n```\n"
  },
  {
    "path": "CONTRIBUTING_vi-VN.md",
    "content": "# Hướng dẫn đóng góp cho Gopeed\n\nTrướ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\nphát triển Gopeed một cách tốt hơn.\n\n## Mô tả nhánh\n\nDự á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\nphá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\nhoàn thành phát triển, gửi một PR đến dự án này và merge vào nhánh `main`.\n\n## Phát triển cục bộ\n\nĐề 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\nlệ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 đó\nkhởi động dự án flutter frontend trong chế độ `debug` để chạy.\n\n## Dịch thuật\n\nCác tệp quốc tế hóa của Gopeed được đặt trong thư mục `ui/flutter/lib/i18n/langs`.\nBạn chỉ cần thêm tệp ngôn ngữ tương ứng trong thư mục này.\n\nVui lòng tham khảo `en_us.dart` để biết cách dịch thuật.\n\n## Phát triển flutter\n\nĐừ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.\n\nBật build_runner watcher nếu bạn muốn chỉnh sửa api/models:\n"
  },
  {
    "path": "CONTRIBUTING_zh-CN.md",
    "content": "# Gopeed 贡献指南\n\n首先感谢您对贡献代码感兴趣，这份指南将帮助您更好的参与到 Gopeed 的开发中来。\n\n## 分支说明\n\n本项目只有一个主分支，即 `main` 分支，如果您想要参与到 Gopeed 的开发中来，请先 fork 本项目，然后在您的 fork 项目中进行开发，开发完成后再向本项目提交\nPR，合并到 `main` 分支。\n\n## 本地开发\n\n建议通过 web 端进行开发调试，首先启动后端服务，通过命令行 `go run cmd/api/main.go` 启动 ，服务启动默认端口为 `9999`，然后以 `debug` 模式启动前端\nflutter 项目即可运行。\n\n## 翻译\n \nGopeed 的国际化文件位于 `ui/flutter/lib/i18n/langs` 目录下，只需要在该目录下添加对应的语言文件即可。\n\n请注意以 `en_us.dart` 为参照进行翻译。\n\n## flutter开发\n\n每次提交前请务必`dart format ./ui/flutter`\n\n如果要编辑api/models，请打开build_runner watcher:\n\n```\nflutter pub run build_runner watch\n```\n\n"
  },
  {
    "path": "CONTRIBUTING_zh-TW.md",
    "content": "# Gopeed 協助指南\n\n首先感謝您願意幫助我們改進並優化該項目，這份指南將會幫助您更好的參與 Gopeed 的開發。\n\n## 分支說明\n\n本項目只有一個分支，即 `main` 分支，如果您想要參與 Gopeed 的開發，請先 fork 該項目，再在您自己的 fork 中進行開發，開發完成後再開啟PR，以合併至 `main` 分支。\n\n## 離線開發\n\n建議使用 web 端進行開發與調試，首先啟動服務，使用指令 `go run cmd/api/main.go` 啟動 ，該服務默認連接埠為 `9999`，接著以 `debug` 模式啟動前端 flutter 項目即可。\n\n## 翻譯\n \nGopeed 的翻譯文件位於 `ui/flutter/lib/i18n/langs` 目錄中，只需要修改或新建翻譯文件即可。\n\n\n請以 `en_us.dart` 作為參照。\n\n## flutter開發\n\n每次提交PR前請務必執行 `dart format ./ui/flutter`\n\n如果需要編輯 api/models，請打開build_runner watcher:\n\n```\nflutter pub run build_runner watch\n```\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM golang:1.24.11-alpine3.23 AS go\nWORKDIR /app\nCOPY ./go.mod ./go.sum ./\nRUN go mod download\nCOPY . .\nARG VERSION=dev\nRUN CGO_ENABLED=0 go build -tags nosqlite,web \\\n      -ldflags=\"-s -w -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION -X github.com/GopeedLab/gopeed/pkg/base.InDocker=true\" \\\n      -o dist/gopeed github.com/GopeedLab/gopeed/cmd/web\n\nFROM alpine:3.23\nLABEL maintainer=\"monkeyWie\"\nWORKDIR /app\nCOPY --from=go /app/dist/gopeed ./\nCOPY entrypoint.sh ./entrypoint.sh\nRUN apk update && \\\n    apk add --no-cache su-exec ; \\\n    chmod +x ./entrypoint.sh && \\\n    rm -rf /var/cache/apk/*\nVOLUME [\"/app/storage\"]\nENV PUID=0 PGID=0 UMASK=022\nEXPOSE 9999\nENTRYPOINT [\"./entrypoint.sh\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU GENERAL PUBLIC LICENSE\n                       Version 3, 29 June 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU General Public License is a free, copyleft license for\nsoftware and other kinds of works.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nthe GNU General Public License is intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.  We, the Free Software Foundation, use the\nGNU General Public License for most of our software; it applies also to\nany other work released this way by its authors.  You can apply it to\nyour programs, too.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  To protect your rights, we need to prevent others from denying you\nthese rights or asking you to surrender the rights.  Therefore, you have\ncertain responsibilities if you distribute copies of the software, or if\nyou modify it: responsibilities to respect the freedom of others.\n\n  For example, if you distribute copies of such a program, whether\ngratis or for a fee, you must pass on to the recipients the same\nfreedoms that you received.  You must make sure that they, too, receive\nor can get the source code.  And you must show them these terms so they\nknow their rights.\n\n  Developers that use the GNU GPL protect your rights with two steps:\n(1) assert copyright on the software, and (2) offer you this License\ngiving you legal permission to copy, distribute and/or modify it.\n\n  For the developers' and authors' protection, the GPL clearly explains\nthat there is no warranty for this free software.  For both users' and\nauthors' sake, the GPL requires that modified versions be marked as\nchanged, so that their problems will not be attributed erroneously to\nauthors of previous versions.\n\n  Some devices are designed to deny users access to install or run\nmodified versions of the software inside them, although the manufacturer\ncan do so.  This is fundamentally incompatible with the aim of\nprotecting users' freedom to change the software.  The systematic\npattern of such abuse occurs in the area of products for individuals to\nuse, which is precisely where it is most unacceptable.  Therefore, we\nhave designed this version of the GPL to prohibit the practice for those\nproducts.  If such problems arise substantially in other domains, we\nstand ready to extend this provision to those domains in future versions\nof the GPL, as needed to protect the freedom of users.\n\n  Finally, every program is threatened constantly by software patents.\nStates should not allow patents to restrict development and use of\nsoftware on general-purpose computers, but in those that do, we wish to\navoid the special danger that patents applied to a free program could\nmake it effectively proprietary.  To prevent this, the GPL assures that\npatents cannot be used to render the program non-free.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Use with the GNU Affero General Public License.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU Affero General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the special requirements of the GNU Affero General Public License,\nsection 13, concerning interaction through a network will apply to the\ncombination as such.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU General Public License from time to time.  Such new versions will\nbe similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU General Public License as published by\n    the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU General Public License for more details.\n\n    You should have received a copy of the GNU General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If the program does terminal interaction, make it output a short\nnotice like this when it starts in an interactive mode:\n\n    <program>  Copyright (C) <year>  <name of author>\n    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.\n    This is free software, and you are welcome to redistribute it\n    under certain conditions; type `show c' for details.\n\nThe hypothetical commands `show w' and `show c' should show the appropriate\nparts of the General Public License.  Of course, your program's commands\nmight be different; for a GUI interface, you would use an \"about box\".\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU GPL, see\n<https://www.gnu.org/licenses/>.\n\n  The GNU General Public License does not permit incorporating your program\ninto proprietary programs.  If your program is a subroutine library, you\nmay consider it more useful to permit linking proprietary applications with\nthe library.  If this is what you want to do, use the GNU Lesser General\nPublic License instead of this License.  But first, please read\n<https://www.gnu.org/licenses/why-not-lgpl.html>.\n"
  },
  {
    "path": "README.md",
    "content": "# [![](_docs/img/banner.svg)](https://gopeed.com)\n\n[![Test Status](https://github.com/GopeedLab/gopeed/workflows/test/badge.svg)](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)\n[![Codecov](https://codecov.io/gh/GopeedLab/gopeed/branch/main/graph/badge.svg)](https://codecov.io/gh/GopeedLab/gopeed)\n[![Release](https://img.shields.io/github/release/GopeedLab/gopeed.svg)](https://github.com/GopeedLab/gopeed/releases)\n[![Download](https://img.shields.io/github/downloads/GopeedLab/gopeed/total.svg)](https://github.com/GopeedLab/gopeed/releases)\n[![Donate](https://img.shields.io/badge/%24-donate-ff69b4.svg)](https://gopeed.com/docs/donate)\n[![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)\n[![Discord](https://img.shields.io/discord/1037992631881449472?label=Discord&logo=discord&style=social)](https://discord.gg/ZUJqJrwCGB)\n\n<a href=\"https://trendshift.io/repositories/7953\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/7953\" alt=\"GopeedLab%2Fgopeed | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n\n[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/R6R6IJGN6)\n\n[English](/README.md) | [中文](/README_zh-CN.md) | [日本語](/README_ja-JP.md) | [正體中文](/README_zh-TW.md) | [Tiếng Việt](/README_vi-VN.md)\n\n## 🚀 Introduction\n\nGopeed (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).\n\nVisit ✈ [Official Website](https://gopeed.com) | 📖 [Official Docs](https://gopeed.com/docs)\n\n## ⬇️ Download\n\n<table>\n  <tbody>\n    <tr>\n      <td rowspan=\"4\">🪟 Windows</td>\n      <td rowspan=\"2\"><code>EXE</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-windows-amd64.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td>arm64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-windows-arm64.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"2\"><code>Portable</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-windows-amd64-portable.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td>arm64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-windows-arm64-portable.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"3\">🍎 MacOS</td>\n      <td rowspan=\"3\"><code>DMG</code></td>\n      <td>universal</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-macos.dmg\">📥</a></td>\n    </tr>\n    <tr>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-macos-amd64.dmg\">📥</a></td>\n    </tr>\n    <tr>\n      <td>arm64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-macos-arm64.dmg\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"6\">🐧 Linux</td>\n      <td><code>Flathub</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://flathub.org/apps/com.gopeed.Gopeed\">📥</a></td>\n    </tr>\n    <tr>\n      <td><code>SNAP</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://snapcraft.io/gopeed\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"2\"><code>DEB</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-linux-amd64.deb\">📥</a></td>\n    </tr>\n    <tr>\n      <td>arm64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-linux-arm64.deb\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"2\"><code>AppImage</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-linux-amd64.AppImage\">📥</a></td>\n    </tr>\n    <tr>\n      <td>arm64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-linux-arm64.AppImage\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"4\">🤖 Android</td>\n      <td rowspan=\"4\"><code>APK</code></td>\n      <td>universal</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-android.apk\">📥</a></td>\n    </tr>\n     <tr>\n      <td>armeabi-v7a</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-android-armeabi-v7a.apk\">📥</a></td>\n    </tr>\n     <tr>\n      <td>arm64-v8a</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-android-arm64-v8a.apk\">📥</a></td>\n    </tr>\n    <tr>\n      <td>x86_64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-android-x86_64.apk\">📥</a></td>\n    </tr>\n    <tr>\n      <td>📱 iOS</td>\n      <td><code>IPA</code></td>\n      <td>universal</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-ios.ipa\">📥</a></td>\n    </tr>\n    <tr>\n      <td>🐳 Docker</td>\n      <td>-</td>\n      <td>universal</td>\n      <td><a href=\"https://hub.docker.com/r/liwei2633/gopeed\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"2\">💾 Qnap</td>\n      <td rowspan=\"2\"><code>QPKG</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-$version-qnap-amd64.qpkg\">📥</a></td>\n    </tr>\n    <tr>\n      <td>arm64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-$version-qnap-arm64.qpkg\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"8\">🌐 Web</td>\n      <td rowspan=\"3\"><code>Windows</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-$version-windows-amd64.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td>arm64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-$version-windows-arm64.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td>386</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-$version-windows-386.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"2\"><code>MacOS</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-$version-macos-amd64.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td>arm64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-$version-macos-arm64.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"3\"><code>Linux</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-$version-linux-amd64.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td>arm64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-$version-linux-arm64.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td>386</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-$version-linux-386.zip\">📥</a></td>\n    </tr>\n  </tbody>\n</table>\n\nMore about installation, please refer to [Installation](https://gopeed.com/docs/install)\n\n### 🛠️ Command tool\n\nuse `go install`:\n\n```bash\ngo install github.com/GopeedLab/gopeed/cmd/gopeed@latest\n```\n\n## 🔌 Browser Extension\n\nGopeed 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)\n\n## 📱 WeChat Official Account\n\nFollow our WeChat Official Account to get the latest updates and news.\n\n<img src=\"_docs/img/weixin.png\" width=\"200\" />\n\n## 💝 Donate\n\nIf you like this project, please consider [donating](https://gopeed.com/docs/donate) to support the development of this project, thank you!\n\n## 🖼️ Showcase\n\n![](_docs/img/ui-demo.png)\n\n## 👨‍💻 Development\n\nThis 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.\n\n> The front code is located in the `ui/flutter` directory.\n\n### 🌍 Environment\n\n1. Golang 1.24+\n2. Flutter 3.38+\n\n### 📋 Clone\n\n```bash\ngit clone git@github.com:GopeedLab/gopeed.git\n```\n\n### 🤝 Contributing\n\nPlease refer to [CONTRIBUTING.md](/CONTRIBUTING.md)\n\n### 🏗️ Build\n\n#### Desktop\n\nFirst, 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.\n\ncommand:\n\n- windows\n\n```bash\ngo build -tags nosqlite -ldflags=\"-w -s\" -buildmode=c-shared -o ui/flutter/windows/libgopeed.dll github.com/GopeedLab/gopeed/bind/desktop\ncd ui/flutter\nflutter build windows\n```\n\n- macos\n\n```bash\ngo build -tags nosqlite -ldflags=\"-w -s\" -buildmode=c-shared -o ui/flutter/macos/Frameworks/libgopeed.dylib github.com/GopeedLab/gopeed/bind/desktop\ncd ui/flutter\nflutter build macos\n```\n\n- linux\n\n```bash\ngo build -tags nosqlite -ldflags=\"-w -s\" -buildmode=c-shared -o ui/flutter/linux/bundle/lib/libgopeed.so github.com/GopeedLab/gopeed/bind/desktop\ncd ui/flutter\nflutter build linux\n```\n\n#### Mobile\n\nSame as before, you also need to prepare the `cgo` environment, and then install `gomobile`:\n\n```bash\ngo install golang.org/x/mobile/cmd/gomobile@latest\ngo get golang.org/x/mobile/bind\ngomobile init\n```\n\ncommand:\n\n- android\n\n```bash\ngomobile 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\ncd ui/flutter\nflutter build apk\n```\n\n- ios\n\n```bash\ngomobile bind -tags nosqlite -ldflags=\"-w -s\" -o ui/flutter/ios/Frameworks/Libgopeed.xcframework -target=ios github.com/GopeedLab/gopeed/bind/mobile\ncd ui/flutter\nflutter build ios --no-codesign\n```\n\n#### Web\n\ncommand:\n\n```bash\ncd ui/flutter\nflutter build web\ncd ../../\nrm -rf cmd/web/dist\ncp -r ui/flutter/build/web cmd/web/dist\ngo build -tags nosqlite,web -ldflags=\"-s -w\" -o bin/ github.com/GopeedLab/gopeed/cmd/web\n```\n\n## ❤️ Credits\n\n### 👥 Contributors\n\n<a href=\"https://github.com/GopeedLab/gopeed/graphs/contributors\">\n  <img src=\"https://contrib.rocks/image?repo=GopeedLab/gopeed\" />\n</a>\n\n### 🏢 JetBrains\n\n[![goland](_docs/img/goland.svg)](https://www.jetbrains.com/?from=gopeed)\n\n## 📄 License\n\n[GPLv3](LICENSE)\n"
  },
  {
    "path": "README_ja-JP.md",
    "content": "# [![](_docs/img/banner.svg)](https://gopeed.com)\n\n[![Test Status](https://github.com/GopeedLab/gopeed/workflows/test/badge.svg)](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)\n[![Codecov](https://codecov.io/gh/GopeedLab/gopeed/branch/main/graph/badge.svg)](https://codecov.io/gh/GopeedLab/gopeed)\n[![Release](https://img.shields.io/github/release/GopeedLab/gopeed.svg)](https://github.com/GopeedLab/gopeed/releases)\n[![Download](https://img.shields.io/github/downloads/GopeedLab/gopeed/total.svg)](https://github.com/GopeedLab/gopeed/releases)\n[![Donate](https://img.shields.io/badge/%24-donate-ff69b4.svg)](https://gopeed.com/docs/donate)\n[![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)\n[![Discord](https://img.shields.io/discord/1037992631881449472?label=Discord&logo=discord&style=social)](https://discord.gg/ZUJqJrwCGB)\n\n<a href=\"https://trendshift.io/repositories/7953\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/7953\" alt=\"GopeedLab%2Fgopeed | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n\n[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/R6R6IJGN6)\n\n[English](/README.md) | [中文](/README_zh-CN.md) | [日本語](/README_ja-JP.md) | [正體中文](/README_zh-TW.md) | [Tiếng Việt](/README_vi-VN.md)\n\n## 🚀 はじめに\n\nGopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された高速ダウンローダーで、(HTTP、BitTorrent、Magnet、ED2K) プロトコルをサポートし、すべてのプラットフォームをサポートします。基本的なダウンロード機能に加え、[APIs](https://gopeed.com/docs/dev-api)との連動や[拡張機能](https://gopeed.com/docs/dev-extension)のインストール・開発による追加機能にも対応した、カスタマイズ性の高いダウンローダーです。\n\n見て下さい ✈ [公式ウェブサイト](https://gopeed.com) | 📖 [開発ドキュメント](https://gopeed.com/docs)\n\n## ⬇️ インストール\n\n<table>\n  <tbody>\n    <tr>\n      <td rowspan=\"2\">🪟 Windows</td>\n      <td><code>EXE</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-windows-amd64.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td><code>Portable</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-windows-amd64-portable.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"3\">🍎 MacOS</td>\n      <td rowspan=\"3\"><code>DMG</code></td>\n      <td>universal</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-macos.dmg\">📥</a></td>\n    </tr>\n    <tr>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-macos-amd64.dmg\">📥</a></td>\n    </tr>\n    <tr>\n      <td>arm64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-macos-arm64.dmg\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"6\">🐧 Linux</td>\n      <td><code>Flathub</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://flathub.org/apps/com.gopeed.Gopeed\">📥</a></td>\n    </tr>\n    <tr>\n      <td><code>SNAP</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://snapcraft.io/gopeed\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"2\"><code>DEB</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-linux-amd64.deb\">📥</a></td>\n    </tr>\n    <tr>\n      <td>arm64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-linux-arm64.deb\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"2\"><code>AppImage</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-linux-amd64.AppImage\">📥</a></td>\n    </tr>\n    <tr>\n      <td>arm64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-linux-arm64.AppImage\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"4\">🤖 Android</td>\n      <td rowspan=\"4\"><code>APK</code></td>\n      <td>universal</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-android.apk\">📥</a></td>\n    </tr>\n     <tr>\n      <td>armeabi-v7a</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-android-armeabi-v7a.apk\">📥</a></td>\n    </tr>\n     <tr>\n      <td>arm64-v8a</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-android-arm64-v8a.apk\">📥</a></td>\n    </tr>\n    <tr>\n      <td>x86_64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-android-x86_64.apk\">📥</a></td>\n    </tr>\n    <tr>\n      <td>📱 iOS</td>\n      <td><code>IPA</code></td>\n      <td>universal</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-ios.ipa\">📥</a></td>\n    </tr>\n    <tr>\n      <td>🐳 Docker</td>\n      <td>-</td>\n      <td>universal</td>\n      <td><a href=\"https://hub.docker.com/r/liwei2633/gopeed\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"2\">💾 Qnap</td>\n      <td rowspan=\"2\"><code>QPKG</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-$version-qnap-amd64.qpkg\">📥</a></td>\n    </tr>\n    <tr>\n      <td>arm64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-$version-qnap-arm64.qpkg\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"8\">🌐 Web</td>\n      <td rowspan=\"3\"><code>Windows</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-$version-windows-amd64.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td>arm64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-$version-windows-arm64.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td>386</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-$version-windows-386.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"2\"><code>MacOS</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-$version-macos-amd64.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td>arm64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-$version-macos-arm64.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"3\"><code>Linux</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-$version-linux-amd64.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td>arm64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-$version-linux-arm64.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td>386</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-$version-linux-386.zip\">📥</a></td>\n    </tr>\n  </tbody>\n</table>\nインストールについての詳細は、[インストール](https://gopeed.com/docs/install)を参照してください。\n\n### 🛠️ コマンドツール\n\n## 📱 WeChat 公式アカウント\n\n公式アカウントをフォローして、最新のアップデートやニュースを入手してください。\n\n<img src=\"_docs/img/weixin.png\" width=\"200\" />\n\n## 💝 寄付\n\nもしこのプロジェクトがお気に召しましたら、このプロジェクトの発展を支援するために[寄付](https://gopeed.com/docs/donate)をご検討ください！\n\n## 🖼️ ショーケース\n\n![](_docs/img/ui-demo.png)\n\n## 👨‍💻 開発\n\nこのプロジェクトは二つの部分に分かれており、フロントエンドでは `flutter` を、バックエンドでは `Golang` を使用し、両者は `http` プロトコルで通信する。ユニックスシステムでは `unix socket` を、ウィンドウズシステムでは `tcp` プロトコルを使用します。\n\n> フロントコードは `ui/flutter` ディレクトリにあります。\n\n### 🌍 環境\n\n1. Go 言語 1.24+\n2. Flutter 3.38+\n\n### 📋 クローン\n\n```bash\ngit clone git@github.com:GopeedLab/gopeed.git\n```\n\n### 🤝 コントリビュート\n\n[CONTRIBUTING.md](/CONTRIBUTING_ja-JP.md) をご参照ください\n\n### 🏗️ ビルド\n\n#### デスクトップ\n\nまず、[flutter デスクトップ公式サイトドキュメント](https://docs.flutter.dev/development/platform-integration/desktop)に従って環境を設定し、自分で検索できる `cgo` 環境を用意します。\n\nコマンド:\n\n- windows\n\n```bash\ngo build -tags nosqlite -ldflags=\"-w -s\" -buildmode=c-shared -o ui/flutter/windows/libgopeed.dll github.com/GopeedLab/gopeed/bind/desktop\ncd ui/flutter\nflutter build windows\n```\n\n- macos\n\n```bash\ngo build -tags nosqlite -ldflags=\"-w -s\" -buildmode=c-shared -o ui/flutter/macos/Frameworks/libgopeed.dylib github.com/GopeedLab/gopeed/bind/desktop\ncd ui/flutter\nflutter build macos\n```\n\n- linux\n\n```bash\ngo build -tags nosqlite -ldflags=\"-w -s\" -buildmode=c-shared -o ui/flutter/linux/bundle/lib/libgopeed.so github.com/GopeedLab/gopeed/bind/desktop\ncd ui/flutter\nflutter build linux\n```\n\n#### モバイル\n\n先ほどと同じように、`cgo` 環境を準備し、`gomobile` をインストールする必要があります:\n\n```bash\ngo install golang.org/x/mobile/cmd/gomobile@latest\ngo get golang.org/x/mobile/bind\ngomobile init\n```\n\nコマンド:\n\n- android\n\n```bash\ngomobile 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\ncd ui/flutter\nflutter build apk\n```\n\n- ios\n\n```bash\ngomobile bind -tags nosqlite -ldflags=\"-w -s\" -o ui/flutter/ios/Frameworks/Libgopeed.xcframework -target=ios github.com/GopeedLab/gopeed/bind/mobile\ncd ui/flutter\nflutter build ios --no-codesign\n```\n\n#### Web\n\nコマンド:\n\n```bash\ncd ui/flutter\nflutter build web\ncd ../../\nrm -rf cmd/web/dist\ncp -r ui/flutter/build/web cmd/web/dist\ngo build -tags nosqlite,web -ldflags=\"-s -w\" -o bin/ github.com/GopeedLab/gopeed/cmd/web\n```\n\n## ❤️ 感謝\n\n### コントリビューター\n\n<a href=\"https://github.com/GopeedLab/gopeed/graphs/contributors\">\n  <img src=\"https://contrib.rocks/image?repo=GopeedLab/gopeed\" />\n</a>\n\n### JetBrains\n\n[![goland](_docs/img/goland.svg)](https://www.jetbrains.com/?from=gopeed)\n\n## ライセンス\n\n[GPLv3](LICENSE)\n"
  },
  {
    "path": "README_vi-VN.md",
    "content": "# [![](_docs/img/banner.svg)](https://gopeed.com)\n\n[![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)\n[![Codecov](https://codecov.io/gh/GopeedLab/gopeed/branch/main/graph/badge.svg)](https://codecov.io/gh/GopeedLab/gopeed)\n[![Phiên bản](https://img.shields.io/github/release/GopeedLab/gopeed.svg)](https://github.com/GopeedLab/gopeed/releases)\n[![Tải về](https://img.shields.io/github/downloads/GopeedLab/gopeed/total.svg)](https://github.com/GopeedLab/gopeed/releases)\n[![Ủng hộ](https://img.shields.io/badge/%24-ủng%20hộ-ff69b4.svg)](https://gopeed.com/docs/donate)\n[![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)\n[![Discord](https://img.shields.io/discord/1037992631881449472?label=Discord&logo=discord&style=social)](https://discord.gg/ZUJqJrwCGB)\n\n<a href=\"https://trendshift.io/repositories/7953\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/7953\" alt=\"GopeedLab%2Fgopeed | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n\n[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/R6R6IJGN6)\n\n[English](/README.md) | [中文](/README_zh-CN.md) | [日本語](/README_ja-JP.md) | [正體中文](/README_zh-TW.md) | [Tiếng Việt](/README_vi-VN.md)\n\n## 🚀 Giới thiệu\n\nGopeed (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).\n\nTruy cập ✈ [Trang web chính thức](https://gopeed.com) | 📖 [Tài liệu chính thức](https://gopeed.com/docs)\n\n## ⬇️ Tải về\n\n<table>\n  <tbody>\n    <tr>\n      <td rowspan=\"2\">🪟 Windows</td>\n      <td><code>EXE</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-windows-amd64.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td><code>Portable</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-windows-amd64-portable.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"3\">🍎 MacOS</td>\n      <td rowspan=\"3\"><code>DMG</code></td>\n      <td>universal</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-macos.dmg\">📥</a></td>\n    </tr>\n    <tr>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-macos-amd64.dmg\">📥</a></td>\n    </tr>\n    <tr>\n      <td>arm64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-macos-arm64.dmg\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"6\">🐧 Linux</td>\n      <td><code>Flathub</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://flathub.org/apps/com.gopeed.Gopeed\">📥</a></td>\n    </tr>\n    <tr>\n      <td><code>SNAP</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://snapcraft.io/gopeed\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"2\"><code>DEB</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-linux-amd64.deb\">📥</a></td>\n    </tr>\n    <tr>\n      <td>arm64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-linux-arm64.deb\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"2\"><code>AppImage</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-linux-amd64.AppImage\">📥</a></td>\n    </tr>\n    <tr>\n      <td>arm64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-linux-arm64.AppImage\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"4\">🤖 Android</td>\n      <td rowspan=\"4\"><code>APK</code></td>\n      <td>universal</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-android.apk\">📥</a></td>\n    </tr>\n     <tr>\n      <td>armeabi-v7a</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-android-armeabi-v7a.apk\">📥</a></td>\n    </tr>\n     <tr>\n      <td>arm64-v8a</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-android-arm64-v8a.apk\">📥</a></td>\n    </tr>\n    <tr>\n      <td>x86_64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-android-x86_64.apk\">📥</a></td>\n    </tr>\n    <tr>\n      <td>📱 iOS</td>\n      <td><code>IPA</code></td>\n      <td>universal</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-ios.ipa\">📥</a></td>\n    </tr>\n    <tr>\n      <td>🐳 Docker</td>\n      <td>-</td>\n      <td>universal</td>\n      <td><a href=\"https://hub.docker.com/r/liwei2633/gopeed\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"2\">💾 Qnap</td>\n      <td rowspan=\"2\"><code>QPKG</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-$version-qnap-amd64.qpkg\">📥</a></td>\n    </tr>\n    <tr>\n      <td>arm64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-$version-qnap-arm64.qpkg\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"8\">🌐 Web</td>\n      <td rowspan=\"3\"><code>Windows</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-$version-windows-amd64.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td>arm64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-$version-windows-arm64.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td>386</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-$version-windows-386.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"2\"><code>MacOS</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-$version-macos-amd64.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td>arm64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-$version-macos-arm64.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"3\"><code>Linux</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-$version-linux-amd64.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td>arm64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-$version-linux-arm64.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td>386</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-$version-linux-386.zip\">📥</a></td>\n    </tr>\n  </tbody>\n</table>\nThêm thông tin về cài đặt, vui lòng tham khảo [Cài đặt](https://gopeed.com/docs/install)\n\n### 🛠️ Công cụ lệnh\n\nSử dụng `go install`:\n\n```bash\ngo install github.com/GopeedLab/gopeed/cmd/gopeed@latest\n```\n\n## 📱 WeChat Official Account\n\nTheo 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.\n\n<img src=\"_docs/img/weixin.png\" width=\"200\" />\n\n## 💝 Quyên góp\n\nNế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!\n\n## 🖼️ Trưng bày\n\n![](_docs/img/ui-demo.png)\n\n## 👨‍💻 Development\n\nDự á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`.\n\n> Mã giao diện nằm trong thư mục `ui/flutter`.\n\n### 🌍 Environment\n\n1. Golang 1.24+\n2. Flutter 3.38+\n\n### 📋 Clone\n\n```bash\ngit clone git@github.com:GopeedLab/gopeed.git\n```\n\n### 🤝 Đóng góp\n\nVui lòng tham khảo [CONTRIBUTING_vi-VN.md](/CONTRIBUTING_vi-VN.md)\n\n### 🏗️ Xây dựng\n\n#### Desktop\n\nTrướ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.\n\ncommand:\n\n- windows\n\n```bash\ngo build -tags nosqlite -ldflags=\"-w -s\" -buildmode=c-shared -o ui/flutter/windows/libgopeed.dll github.com/GopeedLab/gopeed/bind/desktop\ncd ui/flutter\nflutter build windows\n```\n\n- macos\n\n```bash\ngo build -tags nosqlite -ldflags=\"-w -s\" -buildmode=c-shared -o ui/flutter/macos/Frameworks/libgopeed.dylib github.com/GopeedLab/gopeed/bind/desktop\ncd ui/flutter\nflutter build macos\n```\n\n- linux\n\n```bash\ngo build -tags nosqlite -ldflags=\"-w -s\" -buildmode=c-shared -o ui/flutter/linux/bundle/lib/libgopeed.so github.com/GopeedLab/gopeed/bind/desktop\ncd ui/flutter\nflutter build linux\n```\n\n#### Mobile\n\nGiố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`:\n\n```bash\ngo install golang.org/x/mobile/cmd/gomobile@latest\ngo get golang.org/x/mobile/bind\ngomobile init\n```\n\ncommand:\n\n- android\n\n```bash\ngomobile 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\ncd ui/flutter\nflutter build apk\n```\n\n- ios\n\n```bash\ngomobile bind -tags nosqlite -ldflags=\"-w -s\" -o ui/flutter/ios/Frameworks/Libgopeed.xcframework -target=ios github.com/GopeedLab/gopeed/bind/mobile\ncd ui/flutter\nflutter build ios --no-codesign\n```\n\n#### Web\n\ncommand:\n\n```bash\ncd ui/flutter\nflutter build web\ncd ../../\nrm -rf cmd/web/dist\ncp -r ui/flutter/build/web cmd/web/dist\ngo build -tags nosqlite,web -ldflags=\"-s -w\" -o bin/ github.com/GopeedLab/gopeed/cmd/web\n```\n\n## ❤️ Tín dụng\n\n### Người đóng góp\n\n<a href=\"https://github.com/GopeedLab/gopeed/graphs/contributors\">\n  <img src=\"https://contrib.rocks/image?repo=GopeedLab/gopeed\" />\n</a>\n\n### JetBrains\n\n[![goland](_docs/img/goland.svg)](https://www.jetbrains.com/?from=gopeed)\n\n## Giấy phép\n\n[GPLv3](LICENSE)\n"
  },
  {
    "path": "README_zh-CN.md",
    "content": "# [![](_docs/img/banner.svg)](https://gopeed.com)\n\n[![Test Status](https://github.com/GopeedLab/gopeed/workflows/test/badge.svg)](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)\n[![Codecov](https://codecov.io/gh/GopeedLab/gopeed/branch/main/graph/badge.svg)](https://codecov.io/gh/GopeedLab/gopeed)\n[![Release](https://img.shields.io/github/release/GopeedLab/gopeed.svg)](https://github.com/GopeedLab/gopeed/releases)\n[![Download](https://img.shields.io/github/downloads/GopeedLab/gopeed/total.svg)](https://github.com/GopeedLab/gopeed/releases)\n[![Donate](https://img.shields.io/badge/%24-donate-ff69b4.svg)](https://gopeed.com/docs/donate)\n[![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)\n[![Discord](https://img.shields.io/discord/1037992631881449472?label=Discord&logo=discord&style=social)](https://discord.gg/ZUJqJrwCGB)\n\n<a href=\"https://trendshift.io/repositories/7953\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/7953\" alt=\"GopeedLab%2Fgopeed | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n\n[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/R6R6IJGN6)\n\n[English](/README.md) | [中文](/README_zh-CN.md) | [日本語](/README_ja-JP.md) | [正體中文](/README_zh-TW.md) | [Tiếng Việt](/README_vi-VN.md)\n\n## 🚀 介绍\n\nGopeed（全称 Go Speed），直译过来中文名叫做`够快下载器`（不是狗屁下载器！），是一款由`Golang`+`Flutter`开发的高速下载器，支持（HTTP、BitTorrent、Magnet、ED2K）协议下载，并且支持全平台使用。除了基本的下载功能外，Gopeed 还是一款高度可定制化的下载器，支持通过对接[APIs](https://gopeed.com/docs/dev-api)或者安装和开发[扩展](https://gopeed.com/docs/dev-extension)来实现更多的功能。\n\n访问 ✈ [官方网站](https://gopeed.com/zh-CN) | 📖 [官方文档](https://gopeed.com/docs)\n\n## ⬇️ 下载\n\n<table>\n  <tbody>\n    <tr>\n      <td rowspan=\"2\">🪟 Windows</td>\n      <td><code>EXE</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-windows-amd64.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td><code>Portable</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-windows-amd64-portable.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"3\">🍎 MacOS</td>\n      <td rowspan=\"3\"><code>DMG</code></td>\n      <td>universal</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-macos.dmg\">📥</a></td>\n    </tr>\n    <tr>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-macos-amd64.dmg\">📥</a></td>\n    </tr>\n    <tr>\n      <td>arm64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-macos-arm64.dmg\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"6\">🐧 Linux</td>\n      <td><code>Flathub</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://flathub.org/apps/com.gopeed.Gopeed\">📥</a></td>\n    </tr>\n    <tr>\n      <td><code>SNAP</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://snapcraft.io/gopeed\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"2\"><code>DEB</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-linux-amd64.deb\">📥</a></td>\n    </tr>\n    <tr>\n      <td>arm64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-linux-arm64.deb\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"2\"><code>AppImage</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-linux-amd64.AppImage\">📥</a></td>\n    </tr>\n    <tr>\n      <td>arm64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-linux-arm64.AppImage\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"4\">🤖 Android</td>\n      <td rowspan=\"4\"><code>APK</code></td>\n      <td>universal</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-android.apk\">📥</a></td>\n    </tr>\n     <tr>\n      <td>armeabi-v7a</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-android-armeabi-v7a.apk\">📥</a></td>\n    </tr>\n     <tr>\n      <td>arm64-v8a</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-android-arm64-v8a.apk\">📥</a></td>\n    </tr>\n    <tr>\n      <td>x86_64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-android-x86_64.apk\">📥</a></td>\n    </tr>\n    <tr>\n      <td>📱 iOS</td>\n      <td><code>IPA</code></td>\n      <td>universal</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-ios.ipa\">📥</a></td>\n    </tr>\n    <tr>\n      <td>🐳 Docker</td>\n      <td>-</td>\n      <td>universal</td>\n      <td><a href=\"https://hub.docker.com/r/liwei2633/gopeed\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"2\">💾 Qnap</td>\n      <td rowspan=\"2\"><code>QPKG</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-$version-qnap-amd64.qpkg\">📥</a></td>\n    </tr>\n    <tr>\n      <td>arm64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-$version-qnap-arm64.qpkg\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"8\">🌐 Web</td>\n      <td rowspan=\"3\"><code>Windows</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-$version-windows-amd64.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td>arm64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-$version-windows-arm64.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td>386</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-$version-windows-386.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"2\"><code>MacOS</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-$version-macos-amd64.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td>arm64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-$version-macos-arm64.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"3\"><code>Linux</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-$version-linux-amd64.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td>arm64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-$version-linux-arm64.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td>386</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-$version-linux-386.zip\">📥</a></td>\n    </tr>\n  </tbody>\n</table>\n更多关于安装的内容请参考[安装文档](https://gopeed.com/docs/install)\n\n### 🛠️ 命令行工具\n\n使用`go install`安装：\n\n```bash\ngo install github.com/GopeedLab/gopeed/cmd/gopeed@latest\n```\n\n## 🔌 浏览器扩展\n\nGopeed 还提供了浏览器扩展用于接管浏览器下载，支持 Chrome、Edge、Firefox 等浏览器，具体请参考：[https://github.com/GopeedLab/browser-extension](https://github.com/GopeedLab/browser-extension)\n\n## 📱 微信公众号\n\n关注公众号获取项目最新动态和资讯。\n\n<img src=\"_docs/img/weixin.png\" width=\"200\" />\n\n## 💝 赞助\n\n如果觉得项目对你有帮助，请考虑[赞助](https://gopeed.com/docs/donate)以支持这个项目的发展，非常感谢！\n\n## 🖼️ 界面展示\n\n![](_docs/img/ui-demo.png)\n\n## 👨‍💻 开发\n\n本项目分为前端和后端两个部分，前端使用`flutter`，后端使用`Golang`，两边通过`http`协议进行通讯，在 unix 系统下，使用的是`unix socket`，在 windows 系统下，使用的是`tcp`协议。\n\n> 前端代码位于`ui/flutter`目录下。\n\n### 🌍 环境要求\n\n1. Golang 1.24+\n2. Flutter 3.38+\n\n### 📋 克隆项目\n\n```bash\ngit clone git@github.com:GopeedLab/gopeed.git\n```\n\n### 🤝 贡献代码\n\n请参考[贡献指南](CONTRIBUTING_zh-CN.md)\n\n### 🏗️ 编译\n\n#### 桌面端\n\n首先需要按照[flutter desktop 官网文档](https://docs.flutter.dev/development/platform-integration/desktop)进行环境配置，然后需要准备好`cgo`环境，具体可以自行搜索。\n\n构建命令：\n\n- windows\n\n```bash\ngo build -tags nosqlite -ldflags=\"-w -s\" -buildmode=c-shared -o ui/flutter/windows/libgopeed.dll github.com/GopeedLab/gopeed/bind/desktop\ncd ui/flutter\nflutter build windows\n```\n\n- macos\n\n```bash\ngo build -tags nosqlite -ldflags=\"-w -s\" -buildmode=c-shared -o ui/flutter/macos/Frameworks/libgopeed.dylib github.com/GopeedLab/gopeed/bind/desktop\ncd ui/flutter\nflutter build macos\n```\n\n- linux\n\n```bash\ngo build -tags nosqlite -ldflags=\"-w -s\" -buildmode=c-shared -o ui/flutter/linux/bundle/lib/libgopeed.so github.com/GopeedLab/gopeed/bind/desktop\ncd ui/flutter\nflutter build linux\n```\n\n#### 移动端\n\n同样的也是需要准备好`cgo`环境，接着安装`gomobile`：\n\n```bash\ngo install golang.org/x/mobile/cmd/gomobile@latest\ngo get golang.org/x/mobile/bind\ngomobile init\n```\n\n构建命令：\n\n- android\n\n```bash\ngomobile 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\ncd ui/flutter\nflutter build apk\n```\n\n- ios\n\n```bash\ngomobile bind -tags nosqlite -ldflags=\"-w -s\" -o ui/flutter/ios/Frameworks/Libgopeed.xcframework -target=ios github.com/GopeedLab/gopeed/bind/mobile\ncd ui/flutter\nflutter build ios --no-codesign\n```\n\n#### Web 端\n\n构建命令：\n\n```bash\ncd ui/flutter\nflutter build web\ncd ../../\nrm -rf cmd/web/dist\ncp -r ui/flutter/build/web cmd/web/dist\ngo build -tags nosqlite,web -ldflags=\"-s -w\" -o bin/ github.com/GopeedLab/gopeed/cmd/web\n```\n\n## ❤️ 感谢\n\n### 贡献者\n\n<a href=\"https://github.com/GopeedLab/gopeed/graphs/contributors\">\n  <img src=\"https://contrib.rocks/image?repo=GopeedLab/gopeed\" />\n</a>\n\n### JetBrains\n\n[![goland](_docs/img/goland.svg)](https://www.jetbrains.com/?from=gopeed)\n\n## 开源许可\n\n基于 [GPLv3](LICENSE) 协议开源。\n"
  },
  {
    "path": "README_zh-TW.md",
    "content": "# [![](_docs/img/banner.svg)](https://gopeed.com)\n\n[![Test Status](https://github.com/GopeedLab/gopeed/workflows/test/badge.svg)](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)\n[![Codecov](https://codecov.io/gh/GopeedLab/gopeed/branch/main/graph/badge.svg)](https://codecov.io/gh/GopeedLab/gopeed)\n[![Release](https://img.shields.io/github/release/GopeedLab/gopeed.svg)](https://github.com/GopeedLab/gopeed/releases)\n[![Download](https://img.shields.io/github/downloads/GopeedLab/gopeed/total.svg)](https://github.com/GopeedLab/gopeed/releases)\n[![Donate](https://img.shields.io/badge/%24-donate-ff69b4.svg)](https://gopeed.com/docs/donate)\n[![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)\n[![Discord](https://img.shields.io/discord/1037992631881449472?label=Discord&logo=discord&style=social)](https://discord.gg/ZUJqJrwCGB)\n\n<a href=\"https://trendshift.io/repositories/7953\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/7953\" alt=\"GopeedLab%2Fgopeed | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n\n[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/R6R6IJGN6)\n\n[English](/README.md) | [中文](/README_zh-CN.md) | [日本語](/README_ja-JP.md) | [正體中文](/README_zh-TW.md) | [Tiếng Việt](/README_vi-VN.md)\n\n## 🚀 簡介\n\nGopeed（全稱 Go Speed），是一款使用`Golang`+`Flutter`編寫的高速下載軟體，支援（HTTP、BitTorrent、Magnet、ED2K）協定，同時支援所有的平台。\n\n前往 ✈ [主頁](https://gopeed.com/zh-CN) | 📖 [文檔](https://gopeed.com/docs)\n\n## ⬇️ 下載\n\n<table>\n  <tbody>\n    <tr>\n      <td rowspan=\"2\">🪟 Windows</td>\n      <td><code>EXE</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-windows-amd64.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td><code>Portable</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-windows-amd64-portable.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"3\">🍎 MacOS</td>\n      <td rowspan=\"3\"><code>DMG</code></td>\n      <td>universal</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-macos.dmg\">📥</a></td>\n    </tr>\n    <tr>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-macos-amd64.dmg\">📥</a></td>\n    </tr>\n    <tr>\n      <td>arm64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-macos-arm64.dmg\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"6\">🐧 Linux</td>\n      <td><code>Flathub</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://flathub.org/apps/com.gopeed.Gopeed\">📥</a></td>\n    </tr>\n    <tr>\n      <td><code>SNAP</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://snapcraft.io/gopeed\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"2\"><code>DEB</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-linux-amd64.deb\">📥</a></td>\n    </tr>\n    <tr>\n      <td>arm64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-linux-arm64.deb\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"2\"><code>AppImage</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-linux-amd64.AppImage\">📥</a></td>\n    </tr>\n    <tr>\n      <td>arm64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-linux-arm64.AppImage\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"4\">🤖 Android</td>\n      <td rowspan=\"4\"><code>APK</code></td>\n      <td>universal</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-android.apk\">📥</a></td>\n    </tr>\n     <tr>\n      <td>armeabi-v7a</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-android-armeabi-v7a.apk\">📥</a></td>\n    </tr>\n     <tr>\n      <td>arm64-v8a</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-android-arm64-v8a.apk\">📥</a></td>\n    </tr>\n    <tr>\n      <td>x86_64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-android-x86_64.apk\">📥</a></td>\n    </tr>\n    <tr>\n      <td>📱 iOS</td>\n      <td><code>IPA</code></td>\n      <td>universal</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=Gopeed-$version-ios.ipa\">📥</a></td>\n    </tr>\n    <tr>\n      <td>🐳 Docker</td>\n      <td>-</td>\n      <td>universal</td>\n      <td><a href=\"https://hub.docker.com/r/liwei2633/gopeed\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"2\">💾 Qnap</td>\n      <td rowspan=\"2\"><code>QPKG</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-$version-qnap-amd64.qpkg\">📥</a></td>\n    </tr>\n    <tr>\n      <td>arm64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-$version-qnap-arm64.qpkg\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"8\">🌐 Web</td>\n      <td rowspan=\"3\"><code>Windows</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-$version-windows-amd64.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td>arm64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-$version-windows-arm64.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td>386</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-$version-windows-386.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"2\"><code>MacOS</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-$version-macos-amd64.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td>arm64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-$version-macos-arm64.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td rowspan=\"3\"><code>Linux</code></td>\n      <td>amd64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-$version-linux-amd64.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td>arm64</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-$version-linux-arm64.zip\">📥</a></td>\n    </tr>\n    <tr>\n      <td>386</td>\n      <td><a href=\"https://gopeed.com/api/download?tpl=gopeed-web-$version-linux-386.zip\">📥</a></td>\n    </tr>\n  </tbody>\n</table>\n\n更多關於安裝的內容請參考[安裝文檔](https://gopeed.com/docs/install)\n\n### 🛠️ 使用 CLI 安裝\n\n使用`go install`安裝：\n\n```bash\ngo install github.com/GopeedLab/gopeed/cmd/gopeed@latest\n```\n\n## 📱 微信公眾號\n\n關注公眾號獲取項目最新動態和資訊。\n\n<img src=\"_docs/img/weixin.png\" width=\"200\" />\n\n## 💝 贊助\n\n如果你認為該項目對你有所幫助，請考慮[贊助](https://gopeed.com/docs/donate)以支持該項目的持續發展，謝謝！\n\n## 🖼️ 軟體介面\n\n![](_docs/img/ui-demo.png)\n\n## 👨‍💻 開發\n\n該項目分為前端與後端，前端使用`flutter`編寫，後端使用`Golang`編寫，兩邊通過`http`協定進行通訊，在 unix 系統下，則使用`unix socket`，在 windows 系統下，則使用`tcp`協定。\n\n> 前端代碼位於`ui/flutter`目錄內。\n\n### 🌍 開發環境\n\n1. Golang 1.24+\n2. Flutter 3.38+\n\n### 📋 克隆項目\n\n```bash\ngit clone git@github.com:GopeedLab/gopeed.git\n```\n\n### 🤝 協助開發\n\n請參考[協助指南](CONTRIBUTING_zh-TW.md)\n\n### 🏗️ 編譯\n\n#### 桌面端\n\n首先需要按照[flutter desktop 官方文檔](https://docs.flutter.dev/development/platform-integration/desktop)配置開發環境，並準備好`cgo`環境，具體方法可以自行搜索。\n\n組建指令：\n\n- windows\n\n```bash\ngo build -tags nosqlite -ldflags=\"-w -s\" -buildmode=c-shared -o ui/flutter/windows/libgopeed.dll github.com/GopeedLab/gopeed/bind/desktop\ncd ui/flutter\nflutter build windows\n```\n\n- macos\n\n```bash\ngo build -tags nosqlite -ldflags=\"-w -s\" -buildmode=c-shared -o ui/flutter/macos/Frameworks/libgopeed.dylib github.com/GopeedLab/gopeed/bind/desktop\ncd ui/flutter\nflutter build macos\n```\n\n- linux\n\n```bash\ngo build -tags nosqlite -ldflags=\"-w -s\" -buildmode=c-shared -o ui/flutter/linux/bundle/lib/libgopeed.so github.com/GopeedLab/gopeed/bind/desktop\ncd ui/flutter\nflutter build linux\n```\n\n#### 移動設備\n\n需要`cgo`環境，並安裝`gomobile`：\n\n```bash\ngo install golang.org/x/mobile/cmd/gomobile@latest\ngo get golang.org/x/mobile/bind\ngomobile init\n```\n\n組建指令：\n\n- android\n\n```bash\ngomobile 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\ncd ui/flutter\nflutter build apk\n```\n\n- ios\n\n```bash\ngomobile bind -tags nosqlite -ldflags=\"-w -s\" -o ui/flutter/ios/Frameworks/Libgopeed.xcframework -target=ios github.com/GopeedLab/gopeed/bind/mobile\ncd ui/flutter\nflutter build ios --no-codesign\n```\n\n#### 網頁端\n\n組建指令：\n\n```bash\ncd ui/flutter\nflutter build web\ncd ../../\nrm -rf cmd/web/dist\ncp -r ui/flutter/build/web cmd/web/dist\ngo build -tags nosqlite,web -ldflags=\"-s -w\" -o bin/ github.com/GopeedLab/gopeed/cmd/web\n```\n\n## ❤️ 感謝\n\n### 貢獻者\n\n<a href=\"https://github.com/GopeedLab/gopeed/graphs/contributors\">\n  <img src=\"https://contrib.rocks/image?repo=GopeedLab/gopeed\" />\n</a>\n\n### JetBrains\n\n[![goland](_docs/img/goland.svg)](https://www.jetbrains.com/?from=gopeed)\n\n## 軟體許可\n\n該軟體遵循 [GPLv3](LICENSE) 。\n"
  },
  {
    "path": "_examples/basic/main.go",
    "content": "package main\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/GopeedLab/gopeed/pkg/base\"\n\t\"github.com/GopeedLab/gopeed/pkg/download\"\n\t\"github.com/GopeedLab/gopeed/pkg/protocol/http\"\n)\n\nfunc main() {\n\tfinallyCh := make(chan error)\n\t_, err := download.Boot().\n\t\tURL(\"https://www.baidu.com/index.html\").\n\t\tListener(func(event *download.Event) {\n\t\t\tif event.Key == download.EventKeyFinally {\n\t\t\t\tfinallyCh <- event.Err\n\t\t\t}\n\t\t}).\n\t\tCreate(&base.Options{\n\t\t\tExtra: http.OptsExtra{\n\t\t\t\tConnections: 8,\n\t\t\t},\n\t\t})\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\terr = <-finallyCh\n\tif err != nil {\n\t\tfmt.Printf(\"download fail:%v\\n\", err)\n\t} else {\n\t\tfmt.Println(\"download success\")\n\t}\n}\n"
  },
  {
    "path": "bind/desktop/main.go",
    "content": "package main\n\nimport \"C\"\nimport (\n\t\"encoding/json\"\n\t\"github.com/GopeedLab/gopeed/pkg/rest\"\n\t\"github.com/GopeedLab/gopeed/pkg/rest/model\"\n)\n\nfunc main() {}\n\n//export Start\nfunc Start(cfg *C.char) (int, *C.char) {\n\tvar config model.StartConfig\n\tif err := json.Unmarshal([]byte(C.GoString(cfg)), &config); err != nil {\n\t\treturn 0, C.CString(err.Error())\n\t}\n\tconfig.ProductionMode = true\n\trealPort, err := rest.Start(&config)\n\tif err != nil {\n\t\treturn 0, C.CString(err.Error())\n\t}\n\treturn realPort, nil\n}\n\n//export Stop\nfunc Stop() {\n\trest.Stop()\n}\n"
  },
  {
    "path": "bind/mobile/main.go",
    "content": "package libgopeed\n\n// #cgo LDFLAGS: -static-libstdc++\nimport \"C\"\nimport (\n\t\"encoding/json\"\n\t\"github.com/GopeedLab/gopeed/pkg/rest\"\n\t\"github.com/GopeedLab/gopeed/pkg/rest/model\"\n)\n\nfunc Start(cfg string) (int, error) {\n\tvar config model.StartConfig\n\tif err := json.Unmarshal([]byte(cfg), &config); err != nil {\n\t\treturn 0, err\n\t}\n\tconfig.ProductionMode = true\n\treturn rest.Start(&config)\n}\n\nfunc Stop() {\n\trest.Stop()\n}\n"
  },
  {
    "path": "cmd/api/main.go",
    "content": "package main\n\nimport (\n\t\"github.com/GopeedLab/gopeed/cmd\"\n\t\"github.com/GopeedLab/gopeed/pkg/rest/model\"\n)\n\n// only for local development\nfunc main() {\n\tcfg := &model.StartConfig{\n\t\tNetwork:   \"tcp\",\n\t\tAddress:   \"127.0.0.1:9999\",\n\t\tStorage:   model.StorageBolt,\n\t\tWebEnable: true,\n\t}\n\tcmd.Start(cfg)\n}\n"
  },
  {
    "path": "cmd/banner.txt",
    "content": "\n  _______   ______   .______    _______  _______  _______\n /  _____| /  __  \\  |   _  \\  |   ____||   ____||       \\\n|  |  __  |  |  |  | |  |_)  | |  |__   |  |__   |  .--.  |\n|  | |_ | |  |  |  | |   ___/  |   __|  |   __|  |  |  |  |\n|  |__| | |  `--'  | |  |      |  |____ |  |____ |  '--'  |\n \\______|  \\______/  | _|      |_______||_______||_______/\n"
  },
  {
    "path": "cmd/gopeed/flags.go",
    "content": "package main\n\nimport (\n\t\"flag\"\n\t\"fmt\"\n\t\"os\"\n)\n\ntype args struct {\n\turl         string\n\tconnections *int\n\tdir         *string\n}\n\nfunc parse() *args {\n\tdir, err := os.Getwd()\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\tvar args args\n\targs.connections = flag.Int(\"C\", 16, \"Concurrent connections.\")\n\targs.dir = flag.String(\"D\", dir, \"Store directory.\")\n\tflag.Parse()\n\tt := flag.Args()\n\tif len(t) > 0 {\n\t\targs.url = t[0]\n\t} else {\n\t\tgPrintln(\"missing url parameter, for example: gopeed https://www.google.com or gopeed bt.torrent or gopeed magnet:?xt=urn:btih:...\")\n\t\tgPrintln(\"try 'gopeed -h' for more information\")\n\t\tos.Exit(1)\n\t}\n\treturn &args\n}\n\nfunc gPrint(msg string) {\n\tfmt.Print(\"gopeed: \" + msg)\n}\n\nfunc gPrintln(msg string) {\n\tgPrint(msg + \"\\n\")\n}\n"
  },
  {
    "path": "cmd/gopeed/main.go",
    "content": "package main\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"sync\"\n\n\t\"github.com/GopeedLab/gopeed/internal/fetcher\"\n\t\"github.com/GopeedLab/gopeed/pkg/base\"\n\t\"github.com/GopeedLab/gopeed/pkg/download\"\n\t\"github.com/GopeedLab/gopeed/pkg/protocol/http\"\n\t\"github.com/GopeedLab/gopeed/pkg/util\"\n)\n\nconst progressWidth = 20\n\nfunc main() {\n\targs := parse()\n\n\tvar wg sync.WaitGroup\n\twg.Add(1)\n\t_, err := download.Boot().\n\t\tURL(args.url).\n\t\tListener(func(event *download.Event) {\n\t\t\tif event.Key == download.EventKeyProgress {\n\t\t\t\tprintProgress(event.Task, \"downloading...\")\n\t\t\t}\n\t\t\tif event.Key == download.EventKeyFinally {\n\t\t\t\tvar title string\n\t\t\t\tif event.Err != nil {\n\t\t\t\t\ttitle = \"fail\"\n\t\t\t\t} else {\n\t\t\t\t\ttitle = \"complete\"\n\t\t\t\t}\n\t\t\t\tprintProgress(event.Task, title)\n\t\t\t\tfmt.Println()\n\t\t\t\tif event.Err != nil {\n\t\t\t\t\tfmt.Printf(\"reason: %s\", event.Err.Error())\n\t\t\t\t} else {\n\t\t\t\t\tfmt.Printf(\"saving path: %s\", *args.dir)\n\t\t\t\t}\n\t\t\t\twg.Done()\n\t\t\t}\n\t\t}).\n\t\tCreate(&base.Options{\n\t\t\tPath:  *args.dir,\n\t\t\tExtra: http.OptsExtra{Connections: *args.connections},\n\t\t})\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\tprintProgress(emptyTask, \"downloading...\")\n\twg.Wait()\n}\n\nvar (\n\tlastLineLen = 0\n\tsb          = new(strings.Builder)\n\temptyTask   = &download.Task{\n\t\tProgress: &download.Progress{},\n\t\tMeta: &fetcher.FetcherMeta{\n\t\t\tRes: &base.Resource{},\n\t\t},\n\t}\n)\n\nfunc printProgress(task *download.Task, title string) {\n\tvar rate float64\n\tif task.Meta.Res == nil {\n\t\ttask = emptyTask\n\t}\n\tif task.Meta.Res.Size <= 0 {\n\t\trate = 0\n\t} else {\n\t\trate = float64(task.Progress.Downloaded) / float64(task.Meta.Res.Size)\n\t}\n\tcompleteWidth := int(progressWidth * rate)\n\tspeed := util.ByteFmt(task.Progress.Speed)\n\ttotalSize := util.ByteFmt(task.Meta.Res.Size)\n\tsb.WriteString(fmt.Sprintf(\"\\r%s [\", title))\n\tfor i := 0; i < progressWidth; i++ {\n\t\tif i < completeWidth {\n\t\t\tsb.WriteString(\"■\")\n\t\t} else {\n\t\t\tsb.WriteString(\"□\")\n\t\t}\n\t}\n\tsb.WriteString(fmt.Sprintf(\"] %.1f%%    %s/s    %s\", rate*100, speed, totalSize))\n\tif lastLineLen != 0 {\n\t\tpaddingLen := lastLineLen - sb.Len()\n\t\tif paddingLen > 0 {\n\t\t\tsb.WriteString(strings.Repeat(\" \", paddingLen))\n\t\t}\n\t}\n\tlastLineLen = sb.Len()\n\tfmt.Print(sb.String())\n\tsb.Reset()\n}\n"
  },
  {
    "path": "cmd/host/dail_other.go",
    "content": "//go:build !windows\n// +build !windows\n\npackage main\n\nimport (\n\t\"net\"\n\t\"os\"\n\t\"path/filepath\"\n)\n\nfunc Dial() (net.Conn, error) {\n\t// Get binary path\n\texe, err := os.Executable()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn net.Dial(\"unix\", filepath.Join(filepath.Dir(exe), \"gopeed_host.sock\"))\n}\n"
  },
  {
    "path": "cmd/host/dail_windows.go",
    "content": "package main\n\nimport (\n\t\"net\"\n\n\t\"github.com/Microsoft/go-winio\"\n)\n\nfunc Dial() (net.Conn, error) {\n\treturn winio.DialPipe(`\\\\.\\pipe\\gopeed_host`, nil)\n}\n"
  },
  {
    "path": "cmd/host/main.go",
    "content": "package main\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/binary\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"net/http\"\n\t\"os\"\n\t\"time\"\n\n\t\"github.com/pkg/browser\"\n)\n\ntype Message struct {\n\tMethod string          `json:\"method\"`\n\tMeta   map[string]any  `json:\"meta\"`\n\tParams json.RawMessage `json:\"params\"`\n}\n\ntype Response struct {\n\tCode    int    `json:\"code\"`\n\tData    any    `json:\"data,omitempty\"`\n\tMessage string `json:\"message,omitempty\"`\n}\n\nfunc check() (data bool, err error) {\n\tconn, err := Dial()\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tdefer conn.Close()\n\treturn true, nil\n}\n\nfunc wakeup(hidden bool) error {\n\trunning, _ := check()\n\tif running {\n\t\treturn nil\n\t}\n\n\turi := \"gopeed:\"\n\tif hidden {\n\t\turi = uri + \"?hidden=true\"\n\t}\n\tif err := browser.OpenURL(uri); err != nil {\n\t\treturn err\n\t}\n\n\tfor i := 0; i < 10; i++ {\n\t\tif ok, _ := check(); ok {\n\t\t\treturn nil\n\t\t}\n\t\ttime.Sleep(1 * time.Second)\n\t}\n\treturn fmt.Errorf(\"start gopeed failed\")\n}\n\n// postToFlutter sends a POST request to Flutter RPC server\nfunc postToFlutter(path string, body []byte, headers map[string]string, timeout time.Duration) (*http.Response, error) {\n\tclient := &http.Client{\n\t\tTransport: &http.Transport{\n\t\t\tDialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {\n\t\t\t\treturn Dial()\n\t\t\t},\n\t\t},\n\t\tTimeout: timeout,\n\t}\n\treq, err := http.NewRequest(\"POST\", \"http://127.0.0.1\"+path, bytes.NewBuffer(body))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tfor k, v := range headers {\n\t\treq.Header.Set(k, v)\n\t}\n\treturn client.Do(req)\n}\n\nvar apiMap = map[string]func(message *Message) (data any, err error){\n\t\"ping\": func(message *Message) (data any, err error) {\n\t\treturn check()\n\t},\n\t\"wakeup\": func(message *Message) (data any, err error) {\n\t\tsilent := false\n\t\tif v, ok := message.Meta[\"silent\"]; ok {\n\t\t\tsilent, _ = v.(bool)\n\t\t}\n\t\terr = wakeup(silent)\n\t\treturn\n\t},\n\t\"create\": func(message *Message) (data any, err error) {\n\t\tbuf, err := message.Params.MarshalJSON()\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\n\t\tsilent := false\n\t\tif v, ok := message.Meta[\"silent\"]; ok {\n\t\t\tsilent, _ = v.(bool)\n\t\t}\n\n\t\tif err := wakeup(silent); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\theaders := make(map[string]string)\n\t\tif message.Meta != nil {\n\t\t\tmetaJson, _ := json.Marshal(message.Meta)\n\t\t\theaders[\"X-Gopeed-Host-Meta\"] = string(metaJson)\n\t\t}\n\t\t_, err = postToFlutter(\"/create\", buf, headers, 10*time.Second)\n\t\treturn\n\t},\n\t\"forward\": func(message *Message) (data any, err error) {\n\t\tbuf, err := message.Params.MarshalJSON()\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\n\t\tresp, err := postToFlutter(\"/forward\", buf, nil, 60*time.Second)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tdefer resp.Body.Close()\n\n\t\trespBody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tvar respData map[string]json.RawMessage\n\t\tif err := json.Unmarshal(respBody, &respData); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn respData, nil\n\t},\n}\n\n// go build -ldflags=\"-s -w\" -o ui/flutter/assets/exec/ github.com/GopeedLab/gopeed/cmd/host\n\nfunc main() {\n\tfor {\n\t\t// Read message length (first 4 bytes)\n\t\tvar length uint32\n\t\tif err := binary.Read(os.Stdin, binary.NativeEndian, &length); err != nil {\n\t\t\tif err == io.EOF {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tsendError(\"Failed to read message length: \" + err.Error())\n\t\t\treturn\n\t\t}\n\n\t\t// Read the message\n\t\tinput := make([]byte, length)\n\t\tif _, err := io.ReadFull(os.Stdin, input); err != nil {\n\t\t\tsendError(\"Failed to read message: \" + err.Error())\n\t\t\treturn\n\t\t}\n\n\t\t// Parse message\n\t\tvar message Message\n\t\tif err := json.Unmarshal(input, &message); err != nil {\n\t\t\tsendError(\"Failed to parse message: \" + err.Error())\n\t\t\treturn\n\t\t}\n\n\t\t// Handle request\n\t\tvar data any\n\t\tvar err error\n\t\tif handler, ok := apiMap[message.Method]; ok {\n\t\t\tdata, err = handler(&message)\n\t\t} else {\n\t\t\terr = errors.New(\"Unknown method: \" + message.Method)\n\t\t}\n\t\tif err != nil {\n\t\t\tsendError(err.Error())\n\t\t\tcontinue\n\t\t}\n\t\tsendResponse(0, data, \"\")\n\t}\n}\n\nfunc sendResponse(code int, data interface{}, message string) {\n\tresponse := Response{\n\t\tCode:    code,\n\t\tData:    data,\n\t\tMessage: message,\n\t}\n\n\t// Encode response\n\tresponseBytes, err := json.Marshal(response)\n\tif err != nil {\n\t\tsendError(\"Failed to encode response: \" + err.Error())\n\t\treturn\n\t}\n\n\t// Write message length\n\tbinary.Write(os.Stdout, binary.NativeEndian, uint32(len(responseBytes)))\n\t// Write message\n\tos.Stdout.Write(responseBytes)\n}\n\nfunc sendError(msg string) {\n\tsendResponse(1, nil, msg)\n}\n"
  },
  {
    "path": "cmd/server.go",
    "content": "package cmd\n\nimport (\n\t_ \"embed\"\n\t\"fmt\"\n\t\"github.com/GopeedLab/gopeed/pkg/base\"\n\t\"github.com/GopeedLab/gopeed/pkg/rest\"\n\t\"github.com/GopeedLab/gopeed/pkg/rest/model\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/signal\"\n\t\"path/filepath\"\n\t\"syscall\"\n)\n\n//go:embed banner.txt\nvar banner string\n\nfunc Start(cfg *model.StartConfig) {\n\tfmt.Println(banner)\n\tsrv, listener, err := rest.BuildServer(cfg)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\tdownloadCfg, err := rest.Downloader.GetConfig()\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\tif downloadCfg.FirstLoad {\n\t\t// Set default download config\n\t\tif cfg.DownloadConfig != nil {\n\t\t\tcfg.DownloadConfig.Merge(downloadCfg)\n\t\t\t// TODO Use PatchConfig\n\t\t\trest.Downloader.PutConfig(cfg.DownloadConfig)\n\t\t\tdownloadCfg = cfg.DownloadConfig\n\t\t}\n\n\t\tdownloadDir := downloadCfg.DownloadDir\n\t\t// Set default download dir, in docker, it will be ${exe}/Downloads, else it will be ${user}/Downloads\n\t\tif downloadDir == \"\" {\n\t\t\tif base.InDocker == \"true\" {\n\t\t\t\tdownloadDir = filepath.Join(filepath.Dir(cfg.StorageDir), \"Downloads\")\n\t\t\t} else {\n\t\t\t\tuserDir, err := os.UserHomeDir()\n\t\t\t\tif err == nil {\n\t\t\t\t\tdownloadDir = filepath.Join(userDir, \"Downloads\")\n\t\t\t\t}\n\t\t\t}\n\t\t\tif downloadDir != \"\" {\n\t\t\t\tdownloadCfg.DownloadDir = downloadDir\n\t\t\t\trest.Downloader.PutConfig(downloadCfg)\n\t\t\t}\n\t\t}\n\t}\n\twatchExit()\n\n\tfmt.Printf(\"Server start success on http://%s\\n\", listener.Addr().String())\n\tif err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {\n\t\tpanic(err)\n\t}\n}\n\nfunc watchExit() {\n\tsigs := make(chan os.Signal, 1)\n\tsignal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)\n\tgo func() {\n\t\tsig := <-sigs\n\t\tfmt.Printf(\"Server is shutting down due to signal: %s\\n\", sig)\n\t\trest.Downloader.Close()\n\t\tos.Exit(0)\n\t}()\n}\n"
  },
  {
    "path": "cmd/updater/main.go",
    "content": "package main\n\nimport (\n\t\"flag\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"syscall\"\n\t\"time\"\n\n\t\"github.com/pkg/browser\"\n\t\"github.com/pkg/errors\"\n)\n\n// go build -ldflags=\"-s -w\" -o ui/flutter/assets/exec/ github.com/GopeedLab/gopeed/cmd/updater\n\nfunc main() {\n\tpid := flag.Int(\"pid\", 0, \"PID of the process to update\")\n\tupdateChannel := flag.String(\"channel\", \"\", \"Update channel\")\n\tpackagePath := flag.String(\"asset\", \"\", \"Path to the package asset\")\n\texeDir := flag.String(\"exeDir\", \"\", \"Directory of the entry executable\")\n\tlogPath := flag.String(\"log\", \"\", \"Log file path\")\n\tflag.Parse()\n\n\tif *pid == 0 {\n\t\tlog.Println(\"Invalid PID\")\n\t\tos.Exit(1)\n\t}\n\tif *updateChannel == \"\" {\n\t\tlog.Println(\"Invalid update channel\")\n\t\tos.Exit(1)\n\t}\n\n\tif *logPath != \"\" {\n\t\tlogFile, err := os.OpenFile(*logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0666)\n\t\tif err == nil {\n\t\t\tdefer logFile.Close()\n\t\t\tlog.SetOutput(logFile)\n\t\t}\n\t}\n\n\tvar (\n\t\trestart bool\n\t\terr     error\n\t)\n\tif restart, err = update(*pid, *updateChannel, *packagePath, *exeDir); err != nil {\n\t\tlog.Printf(\"Update failed: %v\\n\", err)\n\t\tos.Exit(1)\n\t}\n\n\t// Restart the application\n\tif restart {\n\t\tbrowser.OpenURL(\"gopeed:///\")\n\t}\n\n\t// Delete package asset\n\tif *packagePath != \"\" {\n\t\tos.Remove(*packagePath)\n\t}\n\n\tos.Exit(0)\n}\n\nfunc update(pid int, updateChannel, packagePath, exeDir string) (restart bool, err error) {\n\tkillSignalChan := make(chan any, 1)\n\n\tgo func() {\n\t\t<-killSignalChan\n\n\t\tif err = killProcess(pid); err != nil {\n\t\t\tlog.Printf(\"Failed to kill process: %v\\n\", err)\n\t\t}\n\n\t\tif err = waitForProcessExit(pid); err != nil {\n\t\t\tlog.Printf(\"Failed to wait for process exit: %v\\n\", err)\n\t\t}\n\t}()\n\n\tlog.Printf(\"Updating process updateChannel=%s packagePath=%s exeDir=%s\\n\", updateChannel, packagePath, exeDir)\n\tif restart, err = install(killSignalChan, updateChannel, packagePath, exeDir); err != nil {\n\t\treturn false, errors.Wrap(err, \"failed to install package\")\n\t}\n\n\treturn\n}\n\nfunc waitForProcessExit(pid int) error {\n\tdeadline := time.Now().Add(10 * time.Second)\n\tfor time.Now().Before(deadline) {\n\t\tprocess, err := os.FindProcess(pid)\n\t\tif err != nil {\n\t\t\t// On some systems, error is returned if process doesn't exist\n\t\t\treturn nil\n\t\t}\n\n\t\t// Send null signal to test if process exists\n\t\terr = process.Signal(syscall.Signal(0))\n\t\tif err != nil {\n\t\t\t// If error occurs, the process no longer exists\n\t\t\treturn nil\n\t\t}\n\n\t\ttime.Sleep(100 * time.Millisecond)\n\t}\n\treturn fmt.Errorf(\"process %d still running after timeout\", pid)\n}\n\nfunc killProcess(pid int) error {\n\tprocess, err := os.FindProcess(pid)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn process.Kill()\n}\n"
  },
  {
    "path": "cmd/updater/updater_darwin.go",
    "content": "//go:build darwin\n\npackage main\n\nimport (\n\t\"fmt\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"strings\"\n)\n\nfunc install(killSignalChan chan<- any, updateChannel, packagePath, destDir string) (bool, error) {\n\treturn true, installByDmg(killSignalChan, packagePath, destDir)\n}\n\n// installByDmg handles macOS dmg package installation\nfunc installByDmg(killSignalChan chan<- any, packagePath, destDir string) error {\n\t// /Applications/Gopeed.app/Contents/MacOS -> /Applications\n\tappPath := getParentDir(getParentDir(getParentDir(destDir)))\n\toutput, err := exec.Command(\"hdiutil\", \"attach\", packagePath, \"-nobrowse\").Output()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tmountPoint := \"\"\n\tfor _, line := range strings.Split(string(output), \"\\n\") {\n\t\tif strings.Contains(line, \"/Volumes/\") {\n\t\t\t// Find the /Volumes/ path in the line\n\t\t\t// hdiutil output format: /dev/disk4s1  Apple_HFS  /Volumes/Gopeed\n\t\t\t// or with sequence number: /dev/disk4s1  Apple_HFS  /Volumes/Gopeed 1\n\t\t\tidx := strings.Index(line, \"/Volumes/\")\n\t\t\tif idx != -1 {\n\t\t\t\t// Extract everything from /Volumes/ onwards and trim whitespace\n\t\t\t\tmountPoint = strings.TrimSpace(line[idx:])\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\tif mountPoint == \"\" {\n\t\treturn fmt.Errorf(\"failed to get mount point from hdiutil output: %s\", string(output))\n\t}\n\n\t// Detach the mounted DMG\n\tdefer exec.Command(\"hdiutil\", \"detach\", mountPoint, \"-quiet\").Run()\n\n\tmatches, err := filepath.Glob(filepath.Join(mountPoint, \"*.app\"))\n\tif err != nil {\n\t\treturn err\n\t}\n\tif len(matches) == 0 {\n\t\treturn fmt.Errorf(\"no .app found in dmg, mountPoint: %s\", mountPoint)\n\t}\n\n\tkillSignalChan <- nil\n\n\t// Copy the new app to the destination\n\t// cp -Rf /Volumes/GoPeed/GoPeed.app /Applications\n\tif err := exec.Command(\"cp\", \"-Rf\", matches[0], appPath).Run(); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\n// Get parent directory safely, handling trailing separators\nfunc getParentDir(path string) string {\n\t// Remove trailing separators if they exist\n\tpath = strings.TrimRight(path, string(filepath.Separator))\n\t// Now get the parent directory\n\treturn filepath.Dir(path)\n}\n"
  },
  {
    "path": "cmd/updater/updater_linux.go",
    "content": "//go:build linux\n\npackage main\n\nimport (\n\t\"fmt\"\n\t\"os/exec\"\n)\n\nfunc install(killSignalChan chan<- any, updateChannel, packagePath, destDir string) (bool, error) {\n\tkillSignalChan <- nil\n\tswitch updateChannel {\n\tcase \"linuxDeb\":\n\t\treturn true, installByDeb(packagePath)\n\tcase \"linuxFlathub\":\n\t\treturn true, installByFlathub()\n\tcase \"linuxSnap\":\n\t\treturn true, installBySnap()\n\tdefault:\n\t\treturn false, fmt.Errorf(\"unsupported update channel for Linux: %s\", updateChannel)\n\t}\n}\n\n// executeInTerminal tries to execute a command in one of several common terminal emulators\nfunc executeInTerminal(command string) error {\n\tterminals := []string{\n\t\t\"gnome-terminal\", // GNOME\n\t\t\"konsole\",        // KDE\n\t\t\"xfce4-terminal\", // XFCE\n\t\t\"xterm\",          // X11\n\t}\n\n\tcommand = fmt.Sprintf(`echo \"Starting update...\" && echo \"[CMD] %s\" && %s`, command, command)\n\n\tfor _, term := range terminals {\n\t\tif _, err := exec.LookPath(term); err == nil {\n\t\t\tvar cmd *exec.Cmd\n\t\t\tswitch term {\n\t\t\tcase \"gnome-terminal\", \"xfce4-terminal\":\n\t\t\t\tcmd = exec.Command(term, \"--\", \"bash\", \"-c\", command)\n\t\t\tcase \"konsole\":\n\t\t\t\tcmd = exec.Command(term, \"-e\", \"bash\", \"-c\", command)\n\t\t\tcase \"xterm\":\n\t\t\t\tcmd = exec.Command(term, \"-e\", command)\n\t\t\t}\n\n\t\t\tif err := cmd.Start(); err == nil {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t}\n\n\treturn fmt.Errorf(\"no suitable terminal emulator found. Please install gnome-terminal, konsole, xfce4-terminal or xterm\")\n}\n\n// installByDeb installs the .deb package\nfunc installByDeb(packagePath string) error {\n\tcommand := fmt.Sprintf(`sudo dpkg -i \"%s\"`, packagePath)\n\treturn executeInTerminal(command)\n}\n\n// installByFlathub updates the application via Flathub\nfunc installByFlathub() error {\n\tcommand := \"flatpak update com.gopeed.Gopeed -y\"\n\treturn executeInTerminal(command)\n}\n\n// installBySnap updates the application via Snap\nfunc installBySnap() error {\n\tcommand := \"sudo snap refresh gopeed\"\n\treturn executeInTerminal(command)\n}\n"
  },
  {
    "path": "cmd/updater/updater_windows.go",
    "content": "//go:build windows\n\npackage main\n\nimport (\n\t\"archive/zip\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"strings\"\n)\n\nfunc install(killSignalChan chan<- any, updateChannel, packagePath, destDir string) (bool, error) {\n\tswitch updateChannel {\n\tcase \"windowsInstaller\":\n\t\treturn false, installByInstaller(killSignalChan, packagePath, destDir)\n\tdefault:\n\t\treturn true, installByPortable(killSignalChan, packagePath, destDir)\n\t}\n}\n\n// installByInstaller extracts the installer from the zip file and runs it\nfunc installByInstaller(killSignalChan chan<- any, packagePath, destDir string) error {\n\t// Create a temp directory for extraction\n\ttempDir, err := os.MkdirTemp(\"\", \"gopeed_update\")\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Extract the zip file\n\treader, err := zip.OpenReader(packagePath)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer reader.Close()\n\n\t// Find the installer file\n\tvar installerPath string\n\tfor _, file := range reader.File {\n\t\tif file.FileInfo().IsDir() {\n\t\t\tcontinue\n\t\t}\n\n\t\t// Extract file\n\t\tpath := filepath.Join(tempDir, file.Name)\n\n\t\tif err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tdstFile, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode())\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tsrcFile, err := file.Open()\n\t\tif err != nil {\n\t\t\tdstFile.Close()\n\t\t\treturn err\n\t\t}\n\n\t\t_, err = io.Copy(dstFile, srcFile)\n\t\tsrcFile.Close()\n\t\tdstFile.Close()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// If this is likely an installer (.exe, .msi), save its path\n\t\text := strings.ToLower(filepath.Ext(file.Name))\n\t\tif ext == \".exe\" || ext == \".msi\" {\n\t\t\tinstallerPath = path\n\t\t}\n\t}\n\n\tif installerPath == \"\" {\n\t\treturn fmt.Errorf(\"no installer found in the update package\")\n\t}\n\n\t// Run the installer\n\tcmd := exec.Command(installerPath)\n\tif err := cmd.Start(); err != nil {\n\t\treturn err\n\t}\n\n\tkillSignalChan <- nil\n\treturn nil\n}\n\n// installByPortable extracts the portable version to the destination directory\nfunc installByPortable(killSignalChan chan<- any, packagePath, destDir string) error {\n\tkillSignalChan <- nil\n\n\treader, err := zip.OpenReader(packagePath)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer reader.Close()\n\n\tfor _, file := range reader.File {\n\t\tpath := filepath.Join(destDir, file.Name)\n\n\t\tif file.FileInfo().IsDir() {\n\t\t\tos.MkdirAll(path, file.Mode())\n\t\t\tcontinue\n\t\t}\n\n\t\tif err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tdstFile, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode())\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tsrcFile, err := file.Open()\n\t\tif err != nil {\n\t\t\tdstFile.Close()\n\t\t\treturn err\n\t\t}\n\n\t\t_, err = io.Copy(dstFile, srcFile)\n\t\tsrcFile.Close()\n\t\tdstFile.Close()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/web/flags.go",
    "content": "package main\n\nimport (\n\t\"encoding/json\"\n\t\"flag\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/GopeedLab/gopeed/pkg/base\"\n)\n\ntype args struct {\n\tAddress           *string  `json:\"address\"`\n\tPort              *int     `json:\"port\"`\n\tUsername          *string  `json:\"username\"`\n\tPassword          *string  `json:\"password\"`\n\tApiToken          *string  `json:\"apiToken\"`\n\tStorageDir        *string  `json:\"storageDir\"`\n\tWhiteDownloadDirs []string `json:\"whiteDownloadDirs\"`\n\t// DownloadConfig when the first time to start the server, it will be configured as initial value\n\tDownloadConfig *base.DownloaderStoreConfig `json:\"downloadConfig\"`\n\n\tconfigPath *string\n}\n\nfunc parse() *args {\n\tcfg := &args{}\n\n\tcliConfig := loadCliArgs()\n\tloadConfigFile(cfg, *cliConfig.configPath)\n\tloadEnvVars(cfg)\n\t// override with non-default command line arguments\n\toverrideWithCliArgs(cfg, cliConfig)\n\t// set default values\n\tsetDefaults(cfg, cliConfig)\n\treturn cfg\n}\n\n// loadCliArgs parses command line arguments and returns initial config\nfunc loadCliArgs() *args {\n\tcfg := &args{}\n\tcfg.Address = flag.String(\"A\", \"0.0.0.0\", \"Bind Address\")\n\tcfg.Port = flag.Int(\"P\", 9999, \"Bind Port\")\n\tcfg.Username = flag.String(\"u\", \"gopeed\", \"Web Authentication Username\")\n\tcfg.Password = flag.String(\"p\", \"\", \"Web Authentication Password, if no password is set, web authentication will not be enabled\")\n\tcfg.ApiToken = flag.String(\"T\", \"\", \"API token, it must be configured when using HTTP API in the case of enabling web authentication\")\n\tcfg.StorageDir = flag.String(\"d\", \"\", \"Storage directory\")\n\twhiteDownloadDirs := flag.String(\"w\", \"\", \"White download directories, comma-separated\")\n\tcfg.configPath = flag.String(\"c\", \"./config.json\", \"Config file path\")\n\tflag.Parse()\n\n\t// Parse white download directories from comma-separated string\n\tif whiteDownloadDirs != nil && *whiteDownloadDirs != \"\" {\n\t\tdirs := strings.Split(*whiteDownloadDirs, \",\")\n\t\tfor i := range dirs {\n\t\t\tdirs[i] = strings.TrimSpace(dirs[i])\n\t\t}\n\t\tcfg.WhiteDownloadDirs = dirs\n\t}\n\n\treturn cfg\n}\n\n// overrideWithCliArgs overrides config with non-empty command line arguments\nfunc overrideWithCliArgs(cfg *args, cliConfig *args) {\n\tflag.Visit(func(f *flag.Flag) {\n\t\tswitch f.Name {\n\t\tcase \"A\":\n\t\t\tcfg.Address = cliConfig.Address\n\t\tcase \"P\":\n\t\t\tcfg.Port = cliConfig.Port\n\t\tcase \"u\":\n\t\t\tcfg.Username = cliConfig.Username\n\t\tcase \"p\":\n\t\t\tcfg.Password = cliConfig.Password\n\t\tcase \"T\":\n\t\t\tcfg.ApiToken = cliConfig.ApiToken\n\t\tcase \"d\":\n\t\t\tcfg.StorageDir = cliConfig.StorageDir\n\t\tcase \"w\":\n\t\t\tcfg.WhiteDownloadDirs = cliConfig.WhiteDownloadDirs\n\t\tcase \"c\":\n\t\t\tcfg.configPath = cliConfig.configPath\n\t\t}\n\t})\n}\n\n// setDefaults sets default values for any unset configuration fields\nfunc setDefaults(cfg *args, cliConfig *args) {\n\tif cfg.Address == nil {\n\t\tcfg.Address = cliConfig.Address\n\t}\n\tif cfg.Port == nil {\n\t\tcfg.Port = cliConfig.Port\n\t}\n\tif cfg.Username == nil {\n\t\tcfg.Username = cliConfig.Username\n\t}\n\tif cfg.Password == nil {\n\t\tcfg.Password = cliConfig.Password\n\t}\n\tif cfg.ApiToken == nil {\n\t\tcfg.ApiToken = cliConfig.ApiToken\n\t}\n\tif cfg.StorageDir == nil {\n\t\tcfg.StorageDir = cliConfig.StorageDir\n\t}\n}\n\n// loadConfigFile loads configuration from file\nfunc loadConfigFile(cfg *args, configPath string) {\n\tif !filepath.IsAbs(configPath) {\n\t\tdir, err := os.Getwd()\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tconfigPath = filepath.Join(dir, configPath)\n\t}\n\n\tfile, err := os.ReadFile(configPath)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn\n\t\t}\n\t\treturn\n\t}\n\n\tif err = json.Unmarshal(file, cfg); err != nil {\n\t\treturn\n\t}\n}\n\n// loadEnvVars loads configuration from environment variables with prefix GOPEED_\nfunc loadEnvVars(cfg *args) {\n\tv := reflect.ValueOf(cfg).Elem()\n\tt := reflect.TypeOf(cfg).Elem()\n\n\tfor i := 0; i < v.NumField(); i++ {\n\t\tfield := v.Field(i)\n\t\tfieldType := t.Field(i)\n\n\t\t// Get json tag as environment variable suffix\n\t\tjsonTag := fieldType.Tag.Get(\"json\")\n\t\tif jsonTag == \"\" || jsonTag == \"-\" {\n\t\t\tcontinue\n\t\t}\n\n\t\t// Remove options like omitempty\n\t\tif commaIdx := strings.Index(jsonTag, \",\"); commaIdx != -1 {\n\t\t\tjsonTag = jsonTag[:commaIdx]\n\t\t}\n\n\t\t// Convert to uppercase and add GOPEED_ prefix\n\t\tenvKey := \"GOPEED_\" + strings.ToUpper(jsonTag)\n\t\tenvValue := os.Getenv(envKey)\n\n\t\tif envValue == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\t// Set value based on field type\n\t\tif field.Kind() == reflect.Ptr {\n\t\t\tif field.IsNil() {\n\t\t\t\t// Create new pointer instance\n\t\t\t\tnewVal := reflect.New(field.Type().Elem())\n\t\t\t\tfield.Set(newVal)\n\t\t\t}\n\n\t\t\tswitch field.Type().Elem().Kind() {\n\t\t\tcase reflect.String:\n\t\t\t\tfield.Elem().SetString(envValue)\n\t\t\tcase reflect.Int:\n\t\t\t\tif intVal, err := strconv.Atoi(envValue); err == nil {\n\t\t\t\t\tfield.Elem().SetInt(int64(intVal))\n\t\t\t\t}\n\t\t\tdefault:\n\t\t\t\t// For complex types like DownloadConfig, try JSON unmarshaling\n\t\t\t\tif field.Type().Elem() == reflect.TypeOf(base.DownloaderStoreConfig{}) {\n\t\t\t\t\tvar config base.DownloaderStoreConfig\n\t\t\t\t\tif err := json.Unmarshal([]byte(envValue), &config); err == nil {\n\t\t\t\t\t\tfield.Set(reflect.ValueOf(&config))\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t} else if field.Kind() == reflect.Slice {\n\t\t\t// Handle non-pointer slice types (like []string for WhiteDownloadDirs)\n\t\t\tif field.Type().Elem().Kind() == reflect.String {\n\t\t\t\tdirs := strings.Split(envValue, \",\")\n\t\t\t\tfor i := range dirs {\n\t\t\t\t\tdirs[i] = strings.TrimSpace(dirs[i])\n\t\t\t\t}\n\t\t\t\tfield.Set(reflect.ValueOf(dirs))\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "cmd/web/flags_test.go",
    "content": "package main\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"testing\"\n\n\t\"github.com/GopeedLab/gopeed/pkg/base\"\n)\n\nfunc TestSetDefaults(t *testing.T) {\n\t// Create mock CLI configuration with command line default values\n\tcliDefaults := &args{\n\t\tAddress:    stringPtr(\"127.0.0.1\"),\n\t\tPort:       intPtr(9999),\n\t\tUsername:   stringPtr(\"gopeed\"),\n\t\tPassword:   stringPtr(\"\"),\n\t\tApiToken:   stringPtr(\"\"),\n\t\tStorageDir: stringPtr(\"\"),\n\t}\n\n\ttests := []struct {\n\t\tname      string\n\t\tinput     *args\n\t\tcliConfig *args\n\t\texpected  *args\n\t}{\n\t\t{\n\t\t\tname:      \"empty config should get CLI defaults\",\n\t\t\tinput:     &args{},\n\t\t\tcliConfig: cliDefaults,\n\t\t\texpected: &args{\n\t\t\t\tAddress:    stringPtr(\"127.0.0.1\"),\n\t\t\t\tPort:       intPtr(9999),\n\t\t\t\tUsername:   stringPtr(\"gopeed\"),\n\t\t\t\tPassword:   stringPtr(\"\"),\n\t\t\t\tApiToken:   stringPtr(\"\"),\n\t\t\t\tStorageDir: stringPtr(\"\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"partial config should only fill missing fields with CLI defaults\",\n\t\t\tinput: &args{\n\t\t\t\tAddress: stringPtr(\"192.168.1.1\"),\n\t\t\t\tPort:    intPtr(8080),\n\t\t\t},\n\t\t\tcliConfig: cliDefaults,\n\t\t\texpected: &args{\n\t\t\t\tAddress:    stringPtr(\"192.168.1.1\"),\n\t\t\t\tPort:       intPtr(8080),\n\t\t\t\tUsername:   stringPtr(\"gopeed\"),\n\t\t\t\tPassword:   stringPtr(\"\"),\n\t\t\t\tApiToken:   stringPtr(\"\"),\n\t\t\t\tStorageDir: stringPtr(\"\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"full config should remain unchanged\",\n\t\t\tinput: &args{\n\t\t\t\tAddress:    stringPtr(\"192.168.1.1\"),\n\t\t\t\tPort:       intPtr(8080),\n\t\t\t\tUsername:   stringPtr(\"admin\"),\n\t\t\t\tPassword:   stringPtr(\"secret\"),\n\t\t\t\tApiToken:   stringPtr(\"token123\"),\n\t\t\t\tStorageDir: stringPtr(\"/custom/storage\"),\n\t\t\t},\n\t\t\tcliConfig: cliDefaults,\n\t\t\texpected: &args{\n\t\t\t\tAddress:    stringPtr(\"192.168.1.1\"),\n\t\t\t\tPort:       intPtr(8080),\n\t\t\t\tUsername:   stringPtr(\"admin\"),\n\t\t\t\tPassword:   stringPtr(\"secret\"),\n\t\t\t\tApiToken:   stringPtr(\"token123\"),\n\t\t\t\tStorageDir: stringPtr(\"/custom/storage\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"custom CLI defaults should be used\",\n\t\t\tinput: &args{\n\t\t\t\tAddress: stringPtr(\"10.0.0.1\"),\n\t\t\t},\n\t\t\tcliConfig: &args{\n\t\t\t\tAddress:    stringPtr(\"0.0.0.0\"),\n\t\t\t\tPort:       intPtr(8888),\n\t\t\t\tUsername:   stringPtr(\"customuser\"),\n\t\t\t\tPassword:   stringPtr(\"defaultpass\"),\n\t\t\t\tApiToken:   stringPtr(\"defaulttoken\"),\n\t\t\t\tStorageDir: stringPtr(\"/default/storage\"),\n\t\t\t},\n\t\t\texpected: &args{\n\t\t\t\tAddress:    stringPtr(\"10.0.0.1\"),\n\t\t\t\tPort:       intPtr(8888),\n\t\t\t\tUsername:   stringPtr(\"customuser\"),\n\t\t\t\tPassword:   stringPtr(\"defaultpass\"),\n\t\t\t\tApiToken:   stringPtr(\"defaulttoken\"),\n\t\t\t\tStorageDir: stringPtr(\"/default/storage\"),\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tsetDefaults(tt.input, tt.cliConfig)\n\t\t\tif !reflect.DeepEqual(tt.input, tt.expected) {\n\t\t\t\tt.Errorf(\"setDefaults() = %+v, want %+v\", tt.input, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestOverrideWithCliArgs(t *testing.T) {\n\t// Note: Since overrideWithCliArgs uses flag.Visit, we need to test its behavior\n\t// in a controlled environment. This test creates a comprehensive suite to verify\n\t// all flag handling branches.\n\n\t// Test the function behavior without any flags set\n\tt.Run(\"no flags set\", func(t *testing.T) {\n\t\tconfig := &args{\n\t\t\tAddress:           stringPtr(\"original.address\"),\n\t\t\tPort:              intPtr(8080),\n\t\t\tUsername:          stringPtr(\"original_user\"),\n\t\t\tPassword:          stringPtr(\"original_pass\"),\n\t\t\tApiToken:          stringPtr(\"original_token\"),\n\t\t\tStorageDir:        stringPtr(\"/original/storage\"),\n\t\t\tWhiteDownloadDirs: []string{\"/original/dir1\", \"/original/dir2\"},\n\t\t}\n\n\t\tcliConfig := &args{\n\t\t\tAddress:           stringPtr(\"cli.address\"),\n\t\t\tPort:              intPtr(9090),\n\t\t\tUsername:          stringPtr(\"cli_user\"),\n\t\t\tPassword:          stringPtr(\"cli_pass\"),\n\t\t\tApiToken:          stringPtr(\"cli_token\"),\n\t\t\tStorageDir:        stringPtr(\"/cli/storage\"),\n\t\t\tWhiteDownloadDirs: []string{\"/cli/dir1\", \"/cli/dir2\"},\n\t\t\tconfigPath:        stringPtr(\"/cli/config.json\"),\n\t\t}\n\n\t\t// Save original config for comparison\n\t\toriginal := &args{\n\t\t\tAddress:           stringPtr(\"original.address\"),\n\t\t\tPort:              intPtr(8080),\n\t\t\tUsername:          stringPtr(\"original_user\"),\n\t\t\tPassword:          stringPtr(\"original_pass\"),\n\t\t\tApiToken:          stringPtr(\"original_token\"),\n\t\t\tStorageDir:        stringPtr(\"/original/storage\"),\n\t\t\tWhiteDownloadDirs: []string{\"/original/dir1\", \"/original/dir2\"},\n\t\t}\n\n\t\toverrideWithCliArgs(config, cliConfig)\n\n\t\t// Since no flags are actually set via command line, config should remain unchanged\n\t\tif !reflect.DeepEqual(config, original) {\n\t\t\tt.Logf(\"Configuration changed when no flags were set:\")\n\t\t\tt.Logf(\"  Got: %+v\", config)\n\t\t\tt.Logf(\"  Want: %+v\", original)\n\t\t\t// This is expected behavior in test environment\n\t\t}\n\t})\n\n\t// Test individual flag override behavior by mocking flag.Visit\n\t// Since we can't easily mock flag.Visit, we document the expected behavior\n\tt.Run(\"flag override documentation\", func(t *testing.T) {\n\t\t// Document the expected behavior for each flag:\n\t\tflagBehaviors := map[string]string{\n\t\t\t\"A\": \"Should override cfg.Address with cliConfig.Address\",\n\t\t\t\"P\": \"Should override cfg.Port with cliConfig.Port\",\n\t\t\t\"u\": \"Should override cfg.Username with cliConfig.Username\",\n\t\t\t\"p\": \"Should override cfg.Password with cliConfig.Password\",\n\t\t\t\"T\": \"Should override cfg.ApiToken with cliConfig.ApiToken\",\n\t\t\t\"d\": \"Should override cfg.StorageDir with cliConfig.StorageDir\",\n\t\t\t\"w\": \"Should override cfg.WhiteDownloadDirs with cliConfig.WhiteDownloadDirs\",\n\t\t\t\"c\": \"Should override cfg.configPath with cliConfig.configPath\",\n\t\t}\n\n\t\tt.Log(\"Expected flag override behaviors:\")\n\t\tfor flag, behavior := range flagBehaviors {\n\t\t\tt.Logf(\"  Flag '%s': %s\", flag, behavior)\n\t\t}\n\t})\n\n\t// Test with mock implementation to verify switch case coverage\n\tt.Run(\"switch case coverage verification\", func(t *testing.T) {\n\t\t// Create a mock function that simulates flag.Visit behavior\n\t\tmockFlagVisit := func(config *args, cliConfig *args, flagName string) {\n\t\t\t// Simulate the switch statement in overrideWithCliArgs\n\t\t\tswitch flagName {\n\t\t\tcase \"A\":\n\t\t\t\tconfig.Address = cliConfig.Address\n\t\t\tcase \"P\":\n\t\t\t\tconfig.Port = cliConfig.Port\n\t\t\tcase \"u\":\n\t\t\t\tconfig.Username = cliConfig.Username\n\t\t\tcase \"p\":\n\t\t\t\tconfig.Password = cliConfig.Password\n\t\t\tcase \"T\":\n\t\t\t\tconfig.ApiToken = cliConfig.ApiToken\n\t\t\tcase \"d\":\n\t\t\t\tconfig.StorageDir = cliConfig.StorageDir\n\t\t\tcase \"w\":\n\t\t\t\tconfig.WhiteDownloadDirs = cliConfig.WhiteDownloadDirs\n\t\t\tcase \"c\":\n\t\t\t\tconfig.configPath = cliConfig.configPath\n\t\t\tdefault:\n\t\t\t\tt.Errorf(\"Unknown flag: %s\", flagName)\n\t\t\t}\n\t\t}\n\n\t\t// Test each flag individually\n\t\ttestCases := []struct {\n\t\t\tflagName       string\n\t\t\tsetupConfig    func() *args\n\t\t\tsetupCliConfig func() *args\n\t\t\tverify         func(*testing.T, *args)\n\t\t}{\n\t\t\t{\n\t\t\t\tflagName: \"A\",\n\t\t\t\tsetupConfig: func() *args {\n\t\t\t\t\treturn &args{Address: stringPtr(\"original.address\")}\n\t\t\t\t},\n\t\t\t\tsetupCliConfig: func() *args {\n\t\t\t\t\treturn &args{Address: stringPtr(\"cli.address\")}\n\t\t\t\t},\n\t\t\t\tverify: func(t *testing.T, cfg *args) {\n\t\t\t\t\tif cfg.Address == nil || *cfg.Address != \"cli.address\" {\n\t\t\t\t\t\tt.Errorf(\"Address flag override failed: got %v, want cli.address\", cfg.Address)\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tflagName: \"P\",\n\t\t\t\tsetupConfig: func() *args {\n\t\t\t\t\treturn &args{Port: intPtr(8080)}\n\t\t\t\t},\n\t\t\t\tsetupCliConfig: func() *args {\n\t\t\t\t\treturn &args{Port: intPtr(9090)}\n\t\t\t\t},\n\t\t\t\tverify: func(t *testing.T, cfg *args) {\n\t\t\t\t\tif cfg.Port == nil || *cfg.Port != 9090 {\n\t\t\t\t\t\tt.Errorf(\"Port flag override failed: got %v, want 9090\", cfg.Port)\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tflagName: \"u\",\n\t\t\t\tsetupConfig: func() *args {\n\t\t\t\t\treturn &args{Username: stringPtr(\"original_user\")}\n\t\t\t\t},\n\t\t\t\tsetupCliConfig: func() *args {\n\t\t\t\t\treturn &args{Username: stringPtr(\"cli_user\")}\n\t\t\t\t},\n\t\t\t\tverify: func(t *testing.T, cfg *args) {\n\t\t\t\t\tif cfg.Username == nil || *cfg.Username != \"cli_user\" {\n\t\t\t\t\t\tt.Errorf(\"Username flag override failed: got %v, want cli_user\", cfg.Username)\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tflagName: \"p\",\n\t\t\t\tsetupConfig: func() *args {\n\t\t\t\t\treturn &args{Password: stringPtr(\"original_pass\")}\n\t\t\t\t},\n\t\t\t\tsetupCliConfig: func() *args {\n\t\t\t\t\treturn &args{Password: stringPtr(\"cli_pass\")}\n\t\t\t\t},\n\t\t\t\tverify: func(t *testing.T, cfg *args) {\n\t\t\t\t\tif cfg.Password == nil || *cfg.Password != \"cli_pass\" {\n\t\t\t\t\t\tt.Errorf(\"Password flag override failed: got %v, want cli_pass\", cfg.Password)\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tflagName: \"T\",\n\t\t\t\tsetupConfig: func() *args {\n\t\t\t\t\treturn &args{ApiToken: stringPtr(\"original_token\")}\n\t\t\t\t},\n\t\t\t\tsetupCliConfig: func() *args {\n\t\t\t\t\treturn &args{ApiToken: stringPtr(\"cli_token\")}\n\t\t\t\t},\n\t\t\t\tverify: func(t *testing.T, cfg *args) {\n\t\t\t\t\tif cfg.ApiToken == nil || *cfg.ApiToken != \"cli_token\" {\n\t\t\t\t\t\tt.Errorf(\"ApiToken flag override failed: got %v, want cli_token\", cfg.ApiToken)\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tflagName: \"d\",\n\t\t\t\tsetupConfig: func() *args {\n\t\t\t\t\treturn &args{StorageDir: stringPtr(\"/original/storage\")}\n\t\t\t\t},\n\t\t\t\tsetupCliConfig: func() *args {\n\t\t\t\t\treturn &args{StorageDir: stringPtr(\"/cli/storage\")}\n\t\t\t\t},\n\t\t\t\tverify: func(t *testing.T, cfg *args) {\n\t\t\t\t\tif cfg.StorageDir == nil || *cfg.StorageDir != \"/cli/storage\" {\n\t\t\t\t\t\tt.Errorf(\"StorageDir flag override failed: got %v, want /cli/storage\", cfg.StorageDir)\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tflagName: \"w\",\n\t\t\t\tsetupConfig: func() *args {\n\t\t\t\t\treturn &args{WhiteDownloadDirs: []string{\"/original/dir1\", \"/original/dir2\"}}\n\t\t\t\t},\n\t\t\t\tsetupCliConfig: func() *args {\n\t\t\t\t\treturn &args{WhiteDownloadDirs: []string{\"/cli/dir1\", \"/cli/dir2\", \"/cli/dir3\"}}\n\t\t\t\t},\n\t\t\t\tverify: func(t *testing.T, cfg *args) {\n\t\t\t\t\texpected := []string{\"/cli/dir1\", \"/cli/dir2\", \"/cli/dir3\"}\n\t\t\t\t\tif !reflect.DeepEqual(cfg.WhiteDownloadDirs, expected) {\n\t\t\t\t\t\tt.Errorf(\"WhiteDownloadDirs flag override failed: got %v, want %v\", cfg.WhiteDownloadDirs, expected)\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tflagName: \"c\",\n\t\t\t\tsetupConfig: func() *args {\n\t\t\t\t\treturn &args{configPath: stringPtr(\"/original/config.json\")}\n\t\t\t\t},\n\t\t\t\tsetupCliConfig: func() *args {\n\t\t\t\t\treturn &args{configPath: stringPtr(\"/cli/config.json\")}\n\t\t\t\t},\n\t\t\t\tverify: func(t *testing.T, cfg *args) {\n\t\t\t\t\tif cfg.configPath == nil || *cfg.configPath != \"/cli/config.json\" {\n\t\t\t\t\t\tt.Errorf(\"configPath flag override failed: got %v, want /cli/config.json\", cfg.configPath)\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\n\t\tfor _, tc := range testCases {\n\t\t\tt.Run(fmt.Sprintf(\"flag_%s\", tc.flagName), func(t *testing.T) {\n\t\t\t\tconfig := tc.setupConfig()\n\t\t\t\tcliConfig := tc.setupCliConfig()\n\n\t\t\t\t// Use mock function to simulate flag.Visit behavior\n\t\t\t\tmockFlagVisit(config, cliConfig, tc.flagName)\n\n\t\t\t\t// Verify the result\n\t\t\t\ttc.verify(t, config)\n\t\t\t})\n\t\t}\n\t})\n\n\t// Test edge cases\n\tt.Run(\"edge cases\", func(t *testing.T) {\n\t\tt.Run(\"nil pointers\", func(t *testing.T) {\n\t\t\tconfig := &args{}\n\t\t\tcliConfig := &args{\n\t\t\t\tAddress:    stringPtr(\"new.address\"),\n\t\t\t\tPort:       intPtr(9999),\n\t\t\t\tUsername:   stringPtr(\"newuser\"),\n\t\t\t\tPassword:   stringPtr(\"newpass\"),\n\t\t\t\tApiToken:   stringPtr(\"newtoken\"),\n\t\t\t\tStorageDir: stringPtr(\"/new/storage\"),\n\t\t\t}\n\n\t\t\t// This should not panic when config has nil pointers\n\t\t\toverrideWithCliArgs(config, cliConfig)\n\n\t\t\t// Since no flags are set in test environment, config should remain with nil values\n\t\t\tif config.Address != nil {\n\t\t\t\tt.Log(\"Note: Address was set despite no flags being visited\")\n\t\t\t}\n\t\t})\n\n\t\tt.Run(\"empty slice handling\", func(t *testing.T) {\n\t\t\tconfig := &args{WhiteDownloadDirs: []string{\"/existing/dir\"}}\n\t\t\tcliConfig := &args{WhiteDownloadDirs: []string{}}\n\n\t\t\toverrideWithCliArgs(config, cliConfig)\n\n\t\t\t// In test environment without actual flags, original should be preserved\n\t\t\tif len(config.WhiteDownloadDirs) != 1 || config.WhiteDownloadDirs[0] != \"/existing/dir\" {\n\t\t\t\tt.Log(\"Note: WhiteDownloadDirs was modified despite no flags being visited\")\n\t\t\t}\n\t\t})\n\t})\n}\n\nfunc TestLoadConfigFile(t *testing.T) {\n\t// Create temporary directory for test files\n\ttempDir, err := os.MkdirTemp(\"\", \"flags_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\ttests := []struct {\n\t\tname       string\n\t\tconfigData string\n\t\tfileName   string\n\t\texpected   *args\n\t}{\n\t\t{\n\t\t\tname: \"valid config file\",\n\t\t\tconfigData: `{\n\t\t\t\t\"address\": \"192.168.1.100\",\n\t\t\t\t\"port\": 8080,\n\t\t\t\t\"username\": \"testuser\",\n\t\t\t\t\"password\": \"testpass\",\n\t\t\t\t\"apiToken\": \"testtoken\",\n\t\t\t\t\"storageDir\": \"/test/storage\",\n\t\t\t\t\"downloadConfig\": {\n\t\t\t\t\t\"downloadDir\": \"/test/downloads\",\n\t\t\t\t\t\"maxRunning\": 10\n\t\t\t\t}\n\t\t\t}`,\n\t\t\tfileName: \"valid_config.json\",\n\t\t\texpected: &args{\n\t\t\t\tAddress:    stringPtr(\"192.168.1.100\"),\n\t\t\t\tPort:       intPtr(8080),\n\t\t\t\tUsername:   stringPtr(\"testuser\"),\n\t\t\t\tPassword:   stringPtr(\"testpass\"),\n\t\t\t\tApiToken:   stringPtr(\"testtoken\"),\n\t\t\t\tStorageDir: stringPtr(\"/test/storage\"),\n\t\t\t\tDownloadConfig: &base.DownloaderStoreConfig{\n\t\t\t\t\tDownloadDir: \"/test/downloads\",\n\t\t\t\t\tMaxRunning:  10,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"partial config file\",\n\t\t\tconfigData: `{\n\t\t\t\t\"address\": \"10.0.0.1\",\n\t\t\t\t\"port\": 3000\n\t\t\t}`,\n\t\t\tfileName: \"partial_config.json\",\n\t\t\texpected: &args{\n\t\t\t\tAddress: stringPtr(\"10.0.0.1\"),\n\t\t\t\tPort:    intPtr(3000),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:       \"invalid json should not panic\",\n\t\t\tconfigData: `{invalid json}`,\n\t\t\tfileName:   \"invalid_config.json\",\n\t\t\texpected:   &args{},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t// Create test config file\n\t\t\tconfigPath := filepath.Join(tempDir, tt.fileName)\n\t\t\terr := os.WriteFile(configPath, []byte(tt.configData), 0644)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\n\t\t\tcfg := &args{}\n\t\t\tloadConfigFile(cfg, configPath)\n\n\t\t\tif !reflect.DeepEqual(cfg, tt.expected) {\n\t\t\t\tt.Errorf(\"loadConfigFile() = %+v, want %+v\", cfg, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n\n\t// Test non-existent file\n\tt.Run(\"non-existent file\", func(t *testing.T) {\n\t\tcfg := &args{}\n\t\tloadConfigFile(cfg, \"/non/existent/file.json\")\n\t\texpected := &args{}\n\t\tif !reflect.DeepEqual(cfg, expected) {\n\t\t\tt.Errorf(\"loadConfigFile() with non-existent file = %+v, want %+v\", cfg, expected)\n\t\t}\n\t})\n}\n\nfunc TestLoadEnvVars(t *testing.T) {\n\t// Save original environment\n\toriginalEnv := make(map[string]string)\n\tenvKeys := []string{\n\t\t\"GOPEED_ADDRESS\", \"GOPEED_PORT\", \"GOPEED_USERNAME\",\n\t\t\"GOPEED_PASSWORD\", \"GOPEED_APITOKEN\", \"GOPEED_STORAGEDIR\",\n\t\t\"GOPEED_DOWNLOADCONFIG\", \"GOPEED_WHITEDOWNLOADDIRS\",\n\t}\n\tfor _, key := range envKeys {\n\t\toriginalEnv[key] = os.Getenv(key)\n\t}\n\n\t// Clean up function\n\tcleanup := func() {\n\t\tfor _, key := range envKeys {\n\t\t\tif val, exists := originalEnv[key]; exists {\n\t\t\t\tos.Setenv(key, val)\n\t\t\t} else {\n\t\t\t\tos.Unsetenv(key)\n\t\t\t}\n\t\t}\n\t}\n\tdefer cleanup()\n\n\ttests := []struct {\n\t\tname     string\n\t\tenvVars  map[string]string\n\t\texpected *args\n\t}{\n\t\t{\n\t\t\tname: \"all environment variables set\",\n\t\t\tenvVars: map[string]string{\n\t\t\t\t\"GOPEED_ADDRESS\":    \"env.example.com\",\n\t\t\t\t\"GOPEED_PORT\":       \"7777\",\n\t\t\t\t\"GOPEED_USERNAME\":   \"envuser\",\n\t\t\t\t\"GOPEED_PASSWORD\":   \"envpass\",\n\t\t\t\t\"GOPEED_APITOKEN\":   \"envtoken\",\n\t\t\t\t\"GOPEED_STORAGEDIR\": \"/env/storage\",\n\t\t\t},\n\t\t\texpected: &args{\n\t\t\t\tAddress:    stringPtr(\"env.example.com\"),\n\t\t\t\tPort:       intPtr(7777),\n\t\t\t\tUsername:   stringPtr(\"envuser\"),\n\t\t\t\tPassword:   stringPtr(\"envpass\"),\n\t\t\t\tApiToken:   stringPtr(\"envtoken\"),\n\t\t\t\tStorageDir: stringPtr(\"/env/storage\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"partial environment variables\",\n\t\t\tenvVars: map[string]string{\n\t\t\t\t\"GOPEED_ADDRESS\": \"partial.example.com\",\n\t\t\t\t\"GOPEED_PORT\":    \"5555\",\n\t\t\t},\n\t\t\texpected: &args{\n\t\t\t\tAddress: stringPtr(\"partial.example.com\"),\n\t\t\t\tPort:    intPtr(5555),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"downloadConfig from environment\",\n\t\t\tenvVars: map[string]string{\n\t\t\t\t\"GOPEED_DOWNLOADCONFIG\": `{\"downloadDir\": \"/env/downloads\", \"maxRunning\": 15}`,\n\t\t\t},\n\t\t\texpected: &args{\n\t\t\t\tDownloadConfig: &base.DownloaderStoreConfig{\n\t\t\t\t\tDownloadDir: \"/env/downloads\",\n\t\t\t\t\tMaxRunning:  15,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"invalid port should be ignored\",\n\t\t\tenvVars: map[string]string{\n\t\t\t\t\"GOPEED_PORT\": \"invalid_port\",\n\t\t\t},\n\t\t\texpected: &args{\n\t\t\t\tPort: intPtr(0), // Invalid port creates pointer with 0 value\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"invalid json for downloadConfig should be ignored\",\n\t\t\tenvVars: map[string]string{\n\t\t\t\t\"GOPEED_DOWNLOADCONFIG\": `{invalid json}`,\n\t\t\t},\n\t\t\texpected: &args{\n\t\t\t\tDownloadConfig: &base.DownloaderStoreConfig{}, // Invalid JSON creates empty config\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t// Clear all environment variables first\n\t\t\tfor _, key := range envKeys {\n\t\t\t\tos.Unsetenv(key)\n\t\t\t}\n\n\t\t\t// Set test environment variables\n\t\t\tfor key, value := range tt.envVars {\n\t\t\t\tos.Setenv(key, value)\n\t\t\t}\n\n\t\t\tcfg := &args{}\n\t\t\tloadEnvVars(cfg)\n\n\t\t\tif !reflect.DeepEqual(cfg, tt.expected) {\n\t\t\t\tt.Errorf(\"loadEnvVars() = %+v, want %+v\", cfg, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestConfigPriority(t *testing.T) {\n\t// This test simulates the actual configuration loading flow: Config File -> Environment Variables -> Defaults\n\t// Note: overrideWithCliArgs will not perform override when no actual command line arguments are present\n\n\t// Create temporary directory for test files\n\ttempDir, err := os.MkdirTemp(\"\", \"flags_test_priority\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create test config file\n\tconfigData := `{\n\t\t\"address\": \"config.example.com\",\n\t\t\"port\": 6666,\n\t\t\"username\": \"configuser\",\n\t\t\"password\": \"configpass\"\n\t}`\n\tconfigPath := filepath.Join(tempDir, \"test_config.json\")\n\terr = os.WriteFile(configPath, []byte(configData), 0644)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Save original environment\n\toriginalEnv := make(map[string]string)\n\tenvKeys := []string{\"GOPEED_ADDRESS\", \"GOPEED_PORT\", \"GOPEED_USERNAME\"}\n\tfor _, key := range envKeys {\n\t\toriginalEnv[key] = os.Getenv(key)\n\t}\n\tdefer func() {\n\t\tfor _, key := range envKeys {\n\t\t\tif val, exists := originalEnv[key]; exists {\n\t\t\t\tos.Setenv(key, val)\n\t\t\t} else {\n\t\t\t\tos.Unsetenv(key)\n\t\t\t}\n\t\t}\n\t}()\n\n\t// Set environment variables\n\tos.Setenv(\"GOPEED_ADDRESS\", \"env.example.com\")\n\tos.Setenv(\"GOPEED_PORT\", \"7777\")\n\n\t// Test configuration priority: ENV > Config File > Defaults\n\tcfg := &args{}\n\n\t// Load config file\n\tloadConfigFile(cfg, configPath)\n\n\t// Load environment variables (should override config file)\n\tloadEnvVars(cfg)\n\n\t// Simulate command line defaults (will not override because no actual flags are set)\n\tcliConfig := &args{\n\t\tAddress:    stringPtr(\"127.0.0.1\"), // CLI default address\n\t\tPort:       intPtr(9999),           // CLI default port\n\t\tUsername:   stringPtr(\"gopeed\"),    // CLI default username\n\t\tPassword:   stringPtr(\"\"),          // CLI default password\n\t\tApiToken:   stringPtr(\"\"),          // CLI default api token\n\t\tStorageDir: stringPtr(\"\"),          // CLI default storage dir\n\t}\n\toverrideWithCliArgs(cfg, cliConfig) // This won't change anything because no flags are set\n\n\t// Set defaults for missing fields\n\tsetDefaults(cfg, cliConfig)\n\n\texpected := &args{\n\t\tAddress:    stringPtr(\"env.example.com\"), // From ENV (overrides config file)\n\t\tPort:       intPtr(7777),                 // From ENV (overrides config file)\n\t\tUsername:   stringPtr(\"configuser\"),      // From config file (env not set)\n\t\tPassword:   stringPtr(\"configpass\"),      // From config file only\n\t\tApiToken:   stringPtr(\"\"),                // CLI default (not set in config or env)\n\t\tStorageDir: stringPtr(\"\"),                // CLI default (not set in config or env)\n\t}\n\n\tif !reflect.DeepEqual(cfg, expected) {\n\t\tt.Errorf(\"Configuration priority test failed.\\nGot: %+v\\nWant: %+v\", cfg, expected)\n\t}\n}\n\nfunc TestCompleteConfigurationFlow(t *testing.T) {\n\t// This test simulates a complete configuration loading scenario\n\t// with all sources: defaults, config file, environment variables, and CLI args\n\n\ttempDir, err := os.MkdirTemp(\"\", \"flags_test_complete\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create comprehensive config file\n\tconfigData := `{\n\t\t\"address\": \"config.host.com\",\n\t\t\"port\": 5000,\n\t\t\"username\": \"configuser\",\n\t\t\"password\": \"configpass\",\n\t\t\"apiToken\": \"configtoken\",\n\t\t\"storageDir\": \"/config/storage\",\n\t\t\"downloadConfig\": {\n\t\t\t\"downloadDir\": \"/config/downloads\",\n\t\t\t\"maxRunning\": 8,\n\t\t\t\"protocolConfig\": {\"http\": {\"maxConnections\": 16}},\n\t\t\t\"proxy\": {\n\t\t\t\t\"enable\": true,\n\t\t\t\t\"system\": false,\n\t\t\t\t\"scheme\": \"http\",\n\t\t\t\t\"host\": \"proxy.example.com:8080\"\n\t\t\t}\n\t\t}\n\t}`\n\tconfigPath := filepath.Join(tempDir, \"complete_config.json\")\n\terr = os.WriteFile(configPath, []byte(configData), 0644)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Save and set environment variables\n\toriginalEnv := make(map[string]string)\n\tenvKeys := []string{\"GOPEED_ADDRESS\", \"GOPEED_USERNAME\", \"GOPEED_APITOKEN\", \"GOPEED_WHITEDOWNLOADDIRS\"}\n\tfor _, key := range envKeys {\n\t\toriginalEnv[key] = os.Getenv(key)\n\t}\n\tdefer func() {\n\t\tfor _, key := range envKeys {\n\t\t\tif val, exists := originalEnv[key]; exists {\n\t\t\t\tos.Setenv(key, val)\n\t\t\t} else {\n\t\t\t\tos.Unsetenv(key)\n\t\t\t}\n\t\t}\n\t}()\n\n\tos.Setenv(\"GOPEED_ADDRESS\", \"env.host.com\")\n\tos.Setenv(\"GOPEED_USERNAME\", \"envuser\")\n\n\t// Simulate complete configuration loading\n\tcfg := &args{}\n\n\t// Load from config file\n\tloadConfigFile(cfg, configPath)\n\n\t// Override with environment variables\n\tloadEnvVars(cfg)\n\n\t// Override with CLI arguments (this won't change anything when no actual flags are set)\n\tcliConfig := &args{\n\t\tAddress:    stringPtr(\"127.0.0.1\"), // CLI default address\n\t\tPort:       intPtr(9999),           // CLI default port\n\t\tUsername:   stringPtr(\"gopeed\"),    // CLI default username\n\t\tPassword:   stringPtr(\"\"),          // CLI default password\n\t\tApiToken:   stringPtr(\"\"),          // CLI default api token\n\t\tStorageDir: stringPtr(\"\"),          // CLI default storage dir\n\t}\n\toverrideWithCliArgs(cfg, cliConfig) // Won't override any values because no flags are set\n\n\t// Set defaults\n\tsetDefaults(cfg, cliConfig)\n\n\t// Verify the final configuration follows priority rules\n\tif *cfg.Address != \"env.host.com\" {\n\t\tt.Errorf(\"Address should be from environment, got %s\", *cfg.Address)\n\t}\n\tif *cfg.Port != 5000 {\n\t\tt.Errorf(\"Port should be from config file, got %d\", *cfg.Port)\n\t}\n\tif *cfg.Username != \"envuser\" {\n\t\tt.Errorf(\"Username should be from environment, got %s\", *cfg.Username)\n\t}\n\tif *cfg.Password != \"configpass\" {\n\t\tt.Errorf(\"Password should be from config file, got %s\", *cfg.Password)\n\t}\n\tif *cfg.ApiToken != \"configtoken\" {\n\t\tt.Errorf(\"ApiToken should be from config file, got %s\", *cfg.ApiToken)\n\t}\n\tif *cfg.StorageDir != \"/config/storage\" {\n\t\tt.Errorf(\"StorageDir should be from config file, got %s\", *cfg.StorageDir)\n\t}\n\tif cfg.DownloadConfig == nil {\n\t\tt.Error(\"DownloadConfig should be loaded from config file\")\n\t} else {\n\t\tif cfg.DownloadConfig.DownloadDir != \"/config/downloads\" {\n\t\t\tt.Errorf(\"DownloadConfig.DownloadDir should be from config file, got %s\", cfg.DownloadConfig.DownloadDir)\n\t\t}\n\t\tif cfg.DownloadConfig.MaxRunning != 8 {\n\t\t\tt.Errorf(\"DownloadConfig.MaxRunning should be from config file, got %d\", cfg.DownloadConfig.MaxRunning)\n\t\t}\n\t}\n}\n\n// Helper functions for creating pointers\nfunc stringPtr(s string) *string {\n\treturn &s\n}\n\nfunc intPtr(i int) *int {\n\treturn &i\n}\n\n// Helper functions for safely getting pointer values\nfunc getStringValue(ptr *string) string {\n\tif ptr == nil {\n\t\treturn \"<nil>\"\n\t}\n\treturn *ptr\n}\n\nfunc getIntValue(ptr *int) string {\n\tif ptr == nil {\n\t\treturn \"<nil>\"\n\t}\n\treturn fmt.Sprintf(\"%d\", *ptr)\n}\n\nfunc TestWhiteDownloadDirs(t *testing.T) {\n\tt.Run(\"overrideWithCliArgs should handle WhiteDownloadDirs\", func(t *testing.T) {\n\t\t// Note: Since overrideWithCliArgs uses flag.Visit, it only overrides parameters actually set via command line\n\t\t// When no actual flags are set, configuration won't be overridden\n\t\ttests := []struct {\n\t\t\tname      string\n\t\t\tconfig    *args\n\t\t\tcliConfig *args\n\t\t\texpected  *args\n\t\t}{\n\t\t\t{\n\t\t\t\tname:   \"without actual flag set, should not override\",\n\t\t\t\tconfig: &args{WhiteDownloadDirs: []string{\"/old/dir1\", \"/old/dir2\"}},\n\t\t\t\tcliConfig: &args{\n\t\t\t\t\tWhiteDownloadDirs: []string{\"/new/dir1\", \"/new/dir2\"},\n\t\t\t\t},\n\t\t\t\texpected: &args{WhiteDownloadDirs: []string{\"/old/dir1\", \"/old/dir2\"}}, // Should remain unchanged\n\t\t\t},\n\t\t}\n\n\t\tfor _, tt := range tests {\n\t\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t\toverrideWithCliArgs(tt.config, tt.cliConfig)\n\t\t\t\tif !reflect.DeepEqual(tt.config, tt.expected) {\n\t\t\t\t\t// This test might pass because no flags are set\n\t\t\t\t\tt.Logf(\"overrideWithCliArgs() without flag set: got %+v, want %+v\", tt.config, tt.expected)\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\t})\n\n\tt.Run(\"loadConfigFile should handle WhiteDownloadDirs\", func(t *testing.T) {\n\t\t// Create temporary directory for test files\n\t\ttempDir, err := os.MkdirTemp(\"\", \"flags_test_whitedirs\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer os.RemoveAll(tempDir)\n\n\t\ttests := []struct {\n\t\t\tname       string\n\t\t\tconfigData string\n\t\t\tfileName   string\n\t\t\texpected   *args\n\t\t}{\n\t\t\t{\n\t\t\t\tname: \"config with WhiteDownloadDirs array\",\n\t\t\t\tconfigData: `{\n\t\t\t\t\t\"address\": \"test.example.com\",\n\t\t\t\t\t\"whiteDownloadDirs\": [\"/path/to/dir1\", \"/path/to/dir2\", \"/path/to/dir3\"]\n\t\t\t\t}`,\n\t\t\t\tfileName: \"whitedirs_config.json\",\n\t\t\t\texpected: &args{\n\t\t\t\t\tAddress:           stringPtr(\"test.example.com\"),\n\t\t\t\t\tWhiteDownloadDirs: []string{\"/path/to/dir1\", \"/path/to/dir2\", \"/path/to/dir3\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tname: \"config with empty WhiteDownloadDirs array\",\n\t\t\t\tconfigData: `{\n\t\t\t\t\t\"address\": \"test.example.com\",\n\t\t\t\t\t\"whiteDownloadDirs\": []\n\t\t\t\t}`,\n\t\t\t\tfileName: \"empty_whitedirs_config.json\",\n\t\t\t\texpected: &args{\n\t\t\t\t\tAddress:           stringPtr(\"test.example.com\"),\n\t\t\t\t\tWhiteDownloadDirs: []string{},\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tname: \"config without WhiteDownloadDirs\",\n\t\t\t\tconfigData: `{\n\t\t\t\t\t\"address\": \"test.example.com\",\n\t\t\t\t\t\"port\": 8080\n\t\t\t\t}`,\n\t\t\t\tfileName: \"no_whitedirs_config.json\",\n\t\t\t\texpected: &args{\n\t\t\t\t\tAddress: stringPtr(\"test.example.com\"),\n\t\t\t\t\tPort:    intPtr(8080),\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\n\t\tfor _, tt := range tests {\n\t\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t\t// Create test config file\n\t\t\t\tconfigPath := filepath.Join(tempDir, tt.fileName)\n\t\t\t\terr := os.WriteFile(configPath, []byte(tt.configData), 0644)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatal(err)\n\t\t\t\t}\n\n\t\t\t\tcfg := &args{}\n\t\t\t\tloadConfigFile(cfg, configPath)\n\n\t\t\t\tif !reflect.DeepEqual(cfg, tt.expected) {\n\t\t\t\t\tt.Errorf(\"loadConfigFile() = %+v, want %+v\", cfg, tt.expected)\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\t})\n\n\tt.Run(\"loadEnvVars should handle WhiteDownloadDirs\", func(t *testing.T) {\n\t\t// Save original environment\n\t\toriginalEnv := os.Getenv(\"GOPEED_WHITEDOWNLOADDIRS\")\n\t\tdefer func() {\n\t\t\tif originalEnv != \"\" {\n\t\t\t\tos.Setenv(\"GOPEED_WHITEDOWNLOADDIRS\", originalEnv)\n\t\t\t} else {\n\t\t\t\tos.Unsetenv(\"GOPEED_WHITEDOWNLOADDIRS\")\n\t\t\t}\n\t\t}()\n\n\t\ttests := []struct {\n\t\t\tname     string\n\t\t\tenvValue string\n\t\t\texpected *args\n\t\t}{\n\t\t\t{\n\t\t\t\tname:     \"comma-separated directories\",\n\t\t\t\tenvValue: \"/env/dir1,/env/dir2,/env/dir3\",\n\t\t\t\texpected: &args{\n\t\t\t\t\tWhiteDownloadDirs: []string{\"/env/dir1\", \"/env/dir2\", \"/env/dir3\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tname:     \"comma-separated with spaces\",\n\t\t\t\tenvValue: \" /env/dir1 , /env/dir2 , /env/dir3 \",\n\t\t\t\texpected: &args{\n\t\t\t\t\tWhiteDownloadDirs: []string{\"/env/dir1\", \"/env/dir2\", \"/env/dir3\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tname:     \"single directory\",\n\t\t\t\tenvValue: \"/single/dir\",\n\t\t\t\texpected: &args{\n\t\t\t\t\tWhiteDownloadDirs: []string{\"/single/dir\"},\n\t\t\t\t},\n\t\t\t},\n\t\t\t{\n\t\t\t\tname:     \"empty string should create empty slice\",\n\t\t\t\tenvValue: \"\",\n\t\t\t\texpected: &args{},\n\t\t\t},\n\t\t}\n\n\t\tfor _, tt := range tests {\n\t\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t\t// Clear environment variable first\n\t\t\t\tos.Unsetenv(\"GOPEED_WHITEDOWNLOADDIRS\")\n\n\t\t\t\tif tt.envValue != \"\" {\n\t\t\t\t\tos.Setenv(\"GOPEED_WHITEDOWNLOADDIRS\", tt.envValue)\n\t\t\t\t}\n\n\t\t\t\tcfg := &args{}\n\t\t\t\tloadEnvVars(cfg)\n\n\t\t\t\tif !reflect.DeepEqual(cfg, tt.expected) {\n\t\t\t\t\tt.Errorf(\"loadEnvVars() = %+v, want %+v\", cfg, tt.expected)\n\t\t\t\t}\n\t\t\t})\n\t\t}\n\t})\n}\n\nfunc TestParse(t *testing.T) {\n\t// Note: Testing parse() function is challenging because it depends on global flag state\n\t// and calls flag.Parse(). These tests document the expected behavior but may not\n\t// work perfectly in the test environment due to flag package limitations.\n\n\tt.Run(\"parse integration test with mocked components\", func(t *testing.T) {\n\t\t// Since parse() calls flag.Parse() and depends on os.Args, we'll test the\n\t\t// integration behavior by testing the individual components it orchestrates\n\n\t\t// Save original environment state\n\t\toriginalEnv := make(map[string]string)\n\t\tenvKeys := []string{\n\t\t\t\"GOPEED_ADDRESS\", \"GOPEED_PORT\", \"GOPEED_USERNAME\",\n\t\t\t\"GOPEED_PASSWORD\", \"GOPEED_APITOKEN\", \"GOPEED_STORAGEDIR\",\n\t\t}\n\t\tfor _, key := range envKeys {\n\t\t\toriginalEnv[key] = os.Getenv(key)\n\t\t}\n\t\tdefer func() {\n\t\t\tfor _, key := range envKeys {\n\t\t\t\tif val, exists := originalEnv[key]; exists {\n\t\t\t\t\tos.Setenv(key, val)\n\t\t\t\t} else {\n\t\t\t\t\tos.Unsetenv(key)\n\t\t\t\t}\n\t\t\t}\n\t\t}()\n\n\t\t// Clear environment for clean test\n\t\tfor _, key := range envKeys {\n\t\t\tos.Unsetenv(key)\n\t\t}\n\n\t\t// Create temporary directory for config files\n\t\ttempDir, err := os.MkdirTemp(\"\", \"flags_test_parse\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer os.RemoveAll(tempDir)\n\n\t\tt.Run(\"default behavior without config file\", func(t *testing.T) {\n\t\t\t// Test the parse flow when no config file exists and no env vars are set\n\t\t\t// This simulates: loadCliArgs() -> loadConfigFile() (fails) -> loadEnvVars() (empty) -> overrideWithCliArgs() (no flags) -> setDefaults()\n\n\t\t\t// Simulate the parse process step by step\n\t\t\tcfg := &args{}\n\n\t\t\t// Step 1: Simulate loadCliArgs() with default values\n\t\t\tcliConfig := &args{\n\t\t\t\tAddress:    stringPtr(\"127.0.0.1\"),\n\t\t\t\tPort:       intPtr(9999),\n\t\t\t\tUsername:   stringPtr(\"gopeed\"),\n\t\t\t\tPassword:   stringPtr(\"123456\"),\n\t\t\t\tApiToken:   stringPtr(\"\"),\n\t\t\t\tStorageDir: stringPtr(\"\"),\n\t\t\t\tconfigPath: stringPtr(\"./config.json\"),\n\t\t\t}\n\n\t\t\t// Step 2: loadConfigFile with non-existent file\n\t\t\tloadConfigFile(cfg, \"/non/existent/config.json\")\n\n\t\t\t// Step 3: loadEnvVars with empty environment\n\t\t\tloadEnvVars(cfg)\n\n\t\t\t// Step 4: overrideWithCliArgs (no flags set in test environment)\n\t\t\toverrideWithCliArgs(cfg, cliConfig)\n\n\t\t\t// Step 5: setDefaults\n\t\t\tsetDefaults(cfg, cliConfig)\n\n\t\t\t// Verify final configuration has CLI defaults\n\t\t\tif cfg.Address == nil || *cfg.Address != \"127.0.0.1\" {\n\t\t\t\tt.Errorf(\"Expected Address to be set to CLI default, got: %v\", cfg.Address)\n\t\t\t}\n\t\t\tif cfg.Port == nil || *cfg.Port != 9999 {\n\t\t\t\tt.Errorf(\"Expected Port to be set to CLI default, got: %v\", cfg.Port)\n\t\t\t}\n\t\t\tif cfg.Username == nil || *cfg.Username != \"gopeed\" {\n\t\t\t\tt.Errorf(\"Expected Username to be set to CLI default, got: %v\", cfg.Username)\n\t\t\t}\n\t\t\tif cfg.Password == nil || *cfg.Password != \"123456\" {\n\t\t\t\tt.Errorf(\"Expected Password to be set to CLI default, got: %v\", cfg.Password)\n\t\t\t}\n\t\t})\n\n\t\tt.Run(\"with config file\", func(t *testing.T) {\n\t\t\t// Test parse flow with a config file\n\t\t\tconfigData := `{\n\t\t\t\t\"address\": \"config.example.com\",\n\t\t\t\t\"port\": 8080,\n\t\t\t\t\"username\": \"configuser\",\n\t\t\t\t\"password\": \"configpass\",\n\t\t\t\t\"apiToken\": \"configtoken\",\n\t\t\t\t\"storageDir\": \"/config/storage\"\n\t\t\t}`\n\t\t\tconfigPath := filepath.Join(tempDir, \"test_config.json\")\n\t\t\terr := os.WriteFile(configPath, []byte(configData), 0644)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\n\t\t\t// Simulate parse process\n\t\t\tcfg := &args{}\n\t\t\tcliConfig := &args{\n\t\t\t\tAddress:    stringPtr(\"127.0.0.1\"),\n\t\t\t\tPort:       intPtr(9999),\n\t\t\t\tUsername:   stringPtr(\"gopeed\"),\n\t\t\t\tPassword:   stringPtr(\"123456\"),\n\t\t\t\tApiToken:   stringPtr(\"\"),\n\t\t\t\tStorageDir: stringPtr(\"\"),\n\t\t\t\tconfigPath: &configPath,\n\t\t\t}\n\n\t\t\tloadConfigFile(cfg, configPath)\n\t\t\tloadEnvVars(cfg)\n\t\t\toverrideWithCliArgs(cfg, cliConfig)\n\t\t\tsetDefaults(cfg, cliConfig)\n\n\t\t\t// Verify config file values are loaded\n\t\t\tif cfg.Address == nil || *cfg.Address != \"config.example.com\" {\n\t\t\t\tt.Errorf(\"Expected Address from config file, got: %v\", cfg.Address)\n\t\t\t}\n\t\t\tif cfg.Port == nil || *cfg.Port != 8080 {\n\t\t\t\tt.Errorf(\"Expected Port from config file, got: %v\", cfg.Port)\n\t\t\t}\n\t\t\tif cfg.Username == nil || *cfg.Username != \"configuser\" {\n\t\t\t\tt.Errorf(\"Expected Username from config file, got: %v\", cfg.Username)\n\t\t\t}\n\t\t})\n\n\t\tt.Run(\"with environment variables override\", func(t *testing.T) {\n\t\t\t// Test parse flow with environment variables overriding config\n\t\t\tconfigData := `{\n\t\t\t\t\"address\": \"config.example.com\",\n\t\t\t\t\"port\": 8080,\n\t\t\t\t\"username\": \"configuser\"\n\t\t\t}`\n\t\t\tconfigPath := filepath.Join(tempDir, \"env_test_config.json\")\n\t\t\terr := os.WriteFile(configPath, []byte(configData), 0644)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\n\t\t\t// Set environment variables\n\t\t\tos.Setenv(\"GOPEED_ADDRESS\", \"env.example.com\")\n\t\t\tos.Setenv(\"GOPEED_PORT\", \"7777\")\n\t\t\tos.Setenv(\"GOPEED_PASSWORD\", \"envpass\")\n\n\t\t\t// Simulate parse process\n\t\t\tcfg := &args{}\n\t\t\tcliConfig := &args{\n\t\t\t\tAddress:    stringPtr(\"127.0.0.1\"),\n\t\t\t\tPort:       intPtr(9999),\n\t\t\t\tUsername:   stringPtr(\"gopeed\"),\n\t\t\t\tPassword:   stringPtr(\"123456\"),\n\t\t\t\tApiToken:   stringPtr(\"\"),\n\t\t\t\tStorageDir: stringPtr(\"\"),\n\t\t\t\tconfigPath: &configPath,\n\t\t\t}\n\n\t\t\tloadConfigFile(cfg, configPath)\n\t\t\tloadEnvVars(cfg)\n\t\t\toverrideWithCliArgs(cfg, cliConfig)\n\t\t\tsetDefaults(cfg, cliConfig)\n\n\t\t\t// Verify environment variables override config\n\t\t\tif cfg.Address == nil || *cfg.Address != \"env.example.com\" {\n\t\t\t\tt.Errorf(\"Expected Address from environment, got: %v\", cfg.Address)\n\t\t\t}\n\t\t\tif cfg.Port == nil || *cfg.Port != 7777 {\n\t\t\t\tt.Errorf(\"Expected Port from environment, got: %v\", cfg.Port)\n\t\t\t}\n\t\t\tif cfg.Username == nil || *cfg.Username != \"configuser\" {\n\t\t\t\tt.Errorf(\"Expected Username from config (not overridden by env), got: %v\", cfg.Username)\n\t\t\t}\n\t\t\tif cfg.Password == nil || *cfg.Password != \"envpass\" {\n\t\t\t\tt.Errorf(\"Expected Password from environment, got: %v\", cfg.Password)\n\t\t\t}\n\t\t})\n\n\t\tt.Run(\"complete priority chain\", func(t *testing.T) {\n\t\t\t// Test the complete priority chain: CLI > ENV > Config > Defaults\n\t\t\tconfigData := `{\n\t\t\t\t\"address\": \"config.example.com\",\n\t\t\t\t\"port\": 8080,\n\t\t\t\t\"username\": \"configuser\",\n\t\t\t\t\"password\": \"configpass\",\n\t\t\t\t\"apiToken\": \"configtoken\"\n\t\t\t}`\n\t\t\tconfigPath := filepath.Join(tempDir, \"priority_test_config.json\")\n\t\t\terr := os.WriteFile(configPath, []byte(configData), 0644)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\n\t\t\t// Set some environment variables\n\t\t\tos.Setenv(\"GOPEED_ADDRESS\", \"env.example.com\")\n\t\t\tos.Setenv(\"GOPEED_USERNAME\", \"envuser\")\n\t\t\tos.Setenv(\"GOPEED_PORT\", \"7777\")        // This will override config\n\t\t\tos.Setenv(\"GOPEED_PASSWORD\", \"envpass\") // This will override config\n\n\t\t\t// Simulate parse process\n\t\t\tcfg := &args{}\n\t\t\tcliConfig := &args{\n\t\t\t\tAddress:    stringPtr(\"127.0.0.1\"), // CLI default\n\t\t\t\tPort:       intPtr(9999),           // CLI default\n\t\t\t\tUsername:   stringPtr(\"gopeed\"),    // CLI default\n\t\t\t\tPassword:   stringPtr(\"123456\"),    // CLI default\n\t\t\t\tApiToken:   stringPtr(\"\"),          // CLI default\n\t\t\t\tStorageDir: stringPtr(\"\"),          // CLI default\n\t\t\t\tconfigPath: &configPath,\n\t\t\t}\n\n\t\t\tloadConfigFile(cfg, configPath)\n\t\t\tloadEnvVars(cfg)\n\t\t\toverrideWithCliArgs(cfg, cliConfig) // Won't override in test environment\n\t\t\tsetDefaults(cfg, cliConfig)\n\n\t\t\t// Verify priority chain\n\t\t\tif cfg.Address == nil || *cfg.Address != \"env.example.com\" {\n\t\t\t\tt.Errorf(\"Expected Address from ENV (highest priority), got: %v\", getStringValue(cfg.Address))\n\t\t\t}\n\t\t\tif cfg.Port == nil || *cfg.Port != 7777 {\n\t\t\t\tt.Errorf(\"Expected Port from ENV (overrides config), got: %v\", getIntValue(cfg.Port))\n\t\t\t}\n\t\t\tif cfg.Username == nil || *cfg.Username != \"envuser\" {\n\t\t\t\tt.Errorf(\"Expected Username from ENV, got: %v\", getStringValue(cfg.Username))\n\t\t\t}\n\t\t\tif cfg.Password == nil || *cfg.Password != \"envpass\" {\n\t\t\t\tt.Errorf(\"Expected Password from ENV (overrides config), got: %v\", getStringValue(cfg.Password))\n\t\t\t}\n\t\t\tif cfg.ApiToken == nil || *cfg.ApiToken != \"configtoken\" {\n\t\t\t\tt.Errorf(\"Expected ApiToken from config file, got: %v\", getStringValue(cfg.ApiToken))\n\t\t\t}\n\t\t\tif cfg.StorageDir == nil || *cfg.StorageDir != \"\" {\n\t\t\t\tt.Errorf(\"Expected StorageDir from CLI default, got: %v\", getStringValue(cfg.StorageDir))\n\t\t\t}\n\t\t})\n\t})\n\n\tt.Run(\"parse function behavior documentation\", func(t *testing.T) {\n\t\t// Document the expected behavior of parse() function\n\t\tsteps := []string{\n\t\t\t\"1. loadCliArgs() - Parse command line arguments and set up flag defaults\",\n\t\t\t\"2. loadConfigFile() - Load configuration from JSON file if it exists\",\n\t\t\t\"3. loadEnvVars() - Override config with environment variables (GOPEED_* prefix)\",\n\t\t\t\"4. overrideWithCliArgs() - Override with actual command line flags (via flag.Visit)\",\n\t\t\t\"5. setDefaults() - Fill any remaining nil values with CLI defaults\",\n\t\t}\n\n\t\tt.Log(\"parse() function execution order:\")\n\t\tfor _, step := range steps {\n\t\t\tt.Log(\"  \" + step)\n\t\t}\n\n\t\tt.Log(\"\\nConfiguration priority (highest to lowest):\")\n\t\tpriorities := []string{\n\t\t\t\"1. Command line flags (set via CLI)\",\n\t\t\t\"2. Environment variables (GOPEED_*)\",\n\t\t\t\"3. Configuration file (JSON)\",\n\t\t\t\"4. CLI flag defaults\",\n\t\t}\n\t\tfor _, priority := range priorities {\n\t\t\tt.Log(\"  \" + priority)\n\t\t}\n\t})\n\n\tt.Run(\"parse error handling\", func(t *testing.T) {\n\t\t// Create temporary directory for error test files\n\t\terrorTempDir, err := os.MkdirTemp(\"\", \"flags_test_parse_error\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdefer os.RemoveAll(errorTempDir)\n\n\t\t// Test how parse handles various error conditions\n\t\tt.Run(\"invalid config file\", func(t *testing.T) {\n\t\t\t// Create invalid JSON config\n\t\t\tinvalidConfigPath := filepath.Join(errorTempDir, \"invalid_config.json\")\n\t\t\terr := os.WriteFile(invalidConfigPath, []byte(\"{invalid json}\"), 0644)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\n\t\t\t// Should not panic and should fall back to defaults\n\t\t\tcfg := &args{}\n\t\t\tcliConfig := &args{\n\t\t\t\tAddress:    stringPtr(\"127.0.0.1\"),\n\t\t\t\tPort:       intPtr(9999),\n\t\t\t\tUsername:   stringPtr(\"gopeed\"),\n\t\t\t\tPassword:   stringPtr(\"123456\"),\n\t\t\t\tApiToken:   stringPtr(\"\"),\n\t\t\t\tStorageDir: stringPtr(\"\"),\n\t\t\t\tconfigPath: &invalidConfigPath,\n\t\t\t}\n\n\t\t\t// This should not panic\n\t\t\tloadConfigFile(cfg, invalidConfigPath)\n\t\t\tloadEnvVars(cfg)\n\t\t\toverrideWithCliArgs(cfg, cliConfig)\n\t\t\tsetDefaults(cfg, cliConfig)\n\n\t\t\t// Should have CLI defaults since config loading failed\n\t\t\tif cfg.Address == nil || *cfg.Address != \"127.0.0.1\" {\n\t\t\t\tt.Errorf(\"Expected fallback to CLI defaults, got Address: %v\", cfg.Address)\n\t\t\t}\n\t\t})\n\n\t\tt.Run(\"invalid environment values\", func(t *testing.T) {\n\t\t\t// Clear any existing environment variables first\n\t\t\ttestEnvKeys := []string{\n\t\t\t\t\"GOPEED_ADDRESS\", \"GOPEED_PORT\", \"GOPEED_USERNAME\",\n\t\t\t\t\"GOPEED_PASSWORD\", \"GOPEED_APITOKEN\", \"GOPEED_STORAGEDIR\",\n\t\t\t}\n\t\t\tfor _, key := range testEnvKeys {\n\t\t\t\tos.Unsetenv(key)\n\t\t\t}\n\n\t\t\t// Set invalid environment variable\n\t\t\tos.Setenv(\"GOPEED_PORT\", \"invalid_port\")\n\n\t\t\tcfg := &args{}\n\t\t\tcliConfig := &args{\n\t\t\t\tAddress:    stringPtr(\"127.0.0.1\"),\n\t\t\t\tPort:       intPtr(9999),\n\t\t\t\tUsername:   stringPtr(\"gopeed\"),\n\t\t\t\tPassword:   stringPtr(\"123456\"),\n\t\t\t\tApiToken:   stringPtr(\"\"),\n\t\t\t\tStorageDir: stringPtr(\"\"),\n\t\t\t}\n\n\t\t\tloadEnvVars(cfg)\n\t\t\tsetDefaults(cfg, cliConfig)\n\n\t\t\t// Should fallback to CLI default for invalid port\n\t\t\t// Since loadEnvVars creates a pointer with 0 value for invalid port, setDefaults won't override it\n\t\t\t// This is expected behavior - invalid env values result in 0 values\n\t\t\tif cfg.Port == nil {\n\t\t\t\tt.Errorf(\"Expected Port to be set (even with invalid value), got nil\")\n\t\t\t} else if *cfg.Port != 0 {\n\t\t\t\t// After setDefaults, it should be the CLI default\n\t\t\t\tif *cfg.Port != 9999 {\n\t\t\t\t\tt.Logf(\"Note: Invalid port env var resulted in value %d, not CLI default 9999\", *cfg.Port)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t})\n}\n"
  },
  {
    "path": "cmd/web/main.go",
    "content": "//go:build web\n// +build web\n\npackage main\n\nimport (\n\t\"embed\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"github.com/GopeedLab/gopeed/cmd\"\n\t\"github.com/GopeedLab/gopeed/pkg/rest/model\"\n)\n\n//go:embed dist/*\nvar dist embed.FS\n\nfunc main() {\n\tsub, err := fs.Sub(dist, \"dist\")\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\targs := parse()\n\tvar webAuth *model.WebAuth\n\tif isNotBlank(args.Username) && isNotBlank(args.Password) {\n\t\twebAuth = &model.WebAuth{\n\t\t\tUsername: *args.Username,\n\t\t\tPassword: *args.Password,\n\t\t}\n\t}\n\n\tvar storageDir string\n\tif args.StorageDir != nil && *args.StorageDir != \"\" {\n\t\tstorageDir = *args.StorageDir\n\t} else {\n\t\texe, err := os.Executable()\n\t\tif err != nil {\n\t\t\tpanic(err)\n\t\t}\n\t\tstorageDir = filepath.Join(filepath.Dir(exe), \"storage\")\n\t}\n\n\tcfg := &model.StartConfig{\n\t\tNetwork:           \"tcp\",\n\t\tAddress:           fmt.Sprintf(\"%s:%d\", *args.Address, *args.Port),\n\t\tStorage:           model.StorageBolt,\n\t\tStorageDir:        storageDir,\n\t\tWhiteDownloadDirs: args.WhiteDownloadDirs,\n\t\tApiToken:          *args.ApiToken,\n\t\tDownloadConfig:    args.DownloadConfig,\n\t\tProductionMode:    true,\n\t\tWebEnable:         true,\n\t\tWebFS:             sub,\n\t\tWebAuth:           webAuth,\n\t}\n\tcmd.Start(cfg)\n}\n\nfunc isNotBlank(str *string) bool {\n\treturn str != nil && *str != \"\"\n}\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "services:\n    gopeed:\n      container_name: gopeed\n      ports:\n        - 9999:9999 # HTTP port (host:container)\n      environment:\n        - PUID=0\n        - PGID=0\n        - UMASK=022\n      volumes:\n        - ~/gopeed/Downloads:/app/Downloads # mount download path\n        #- ~/gopeed/storage:/app/storage # if you need to mount storage path, uncomment this line\n      restart: unless-stopped\n      image: liwei2633/gopeed\n      # command: -u Username -p Password # optional authentication"
  },
  {
    "path": "entrypoint.sh",
    "content": "#!/bin/sh\n\nchown -R ${PUID}:${PGID} /app\n\numask ${UMASK}\n\nexec su-exec ${PUID}:${PGID} ./gopeed \"$@\""
  },
  {
    "path": "go.mod",
    "content": "module github.com/GopeedLab/gopeed\n\ngo 1.24.9\n\ntoolchain go1.24.11\n\nrequire (\n\tgithub.com/anacrolix/torrent v1.60.1-0.20251217073903-486bcbe758e0\n\tgithub.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5\n\tgithub.com/bodgit/sevenzip v1.6.1\n\tgithub.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3\n\tgithub.com/dop251/goja_nodejs v0.0.0-20240728170619-29b559befffc\n\tgithub.com/go-git/go-git/v5 v5.8.1\n\tgithub.com/gorilla/handlers v1.5.1\n\tgithub.com/gorilla/mux v1.8.0\n\tgithub.com/imroc/req/v3 v3.52.2\n\tgithub.com/matoous/go-nanoid/v2 v2.0.0\n\tgithub.com/mattn/go-ieproxy v0.0.12\n\tgithub.com/mholt/archives v0.1.5\n\tgithub.com/pkg/errors v0.9.1\n\tgithub.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529\n\tgithub.com/rs/zerolog v1.31.0\n\tgithub.com/xiaoqidun/setft v0.0.0-20220310121541-be86327699ad\n\tgo.etcd.io/bbolt v1.4.3\n\tgolang.org/x/exp v0.0.0-20251219203646-944ab1f22d93\n)\n\nrequire (\n\tgithub.com/STARRY-S/zip v0.2.3 // indirect\n\tgithub.com/anacrolix/btree v0.1.1 // indirect\n\tgithub.com/anacrolix/missinggo/v2 v2.10.0 // indirect\n\tgithub.com/andybalholm/brotli v1.2.0 // indirect\n\tgithub.com/bodgit/plumbing v1.3.0 // indirect\n\tgithub.com/bodgit/windows v1.0.1 // indirect\n\tgithub.com/cespare/xxhash/v2 v2.3.0 // indirect\n\tgithub.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect\n\tgithub.com/felixge/fgprof v0.9.5 // indirect\n\tgithub.com/go-task/slim-sprig/v3 v3.0.0 // indirect\n\tgithub.com/google/go-cmp v0.7.0 // indirect\n\tgithub.com/hashicorp/errwrap v1.1.0 // indirect\n\tgithub.com/hashicorp/go-multierror v1.1.1 // indirect\n\tgithub.com/hashicorp/golang-lru/v2 v2.0.7 // indirect\n\tgithub.com/icholy/digest v1.1.0 // indirect\n\tgithub.com/klauspost/compress v1.18.0 // indirect\n\tgithub.com/klauspost/pgzip v1.2.6 // indirect\n\tgithub.com/mikelolasagasti/xz v1.0.1 // indirect\n\tgithub.com/minio/minlz v1.0.1 // indirect\n\tgithub.com/monkeyWie/goed2k v0.0.0-20260317100435-7a7575cf2447 // indirect\n\tgithub.com/nwaples/rardecode/v2 v2.2.0 // indirect\n\tgithub.com/onsi/ginkgo/v2 v2.23.4 // indirect\n\tgithub.com/pierrec/lz4/v4 v4.1.22 // indirect\n\tgithub.com/pion/transport/v4 v4.0.1 // indirect\n\tgithub.com/quic-go/qpack v0.5.1 // indirect\n\tgithub.com/quic-go/quic-go v0.51.0 // indirect\n\tgithub.com/refraction-networking/utls v1.6.7 // indirect\n\tgithub.com/sorairolake/lzip-go v0.3.8 // indirect\n\tgithub.com/spf13/afero v1.15.0 // indirect\n\tgithub.com/ulikunitz/xz v0.5.15 // indirect\n\tgo.opentelemetry.io/auto/sdk v1.2.1 // indirect\n\tgo.uber.org/automaxprocs v1.6.0 // indirect\n\tgo.uber.org/mock v0.5.1 // indirect\n\tgo4.org v0.0.0-20230225012048-214862532bf5 // indirect\n\tgolang.org/x/sync v0.19.0 // indirect\n)\n\nrequire (\n\tdario.cat/mergo v1.0.0 // indirect\n\tgithub.com/Microsoft/go-winio v0.6.1\n\tgithub.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95 // indirect\n\tgithub.com/RoaringBitmap/roaring v1.9.4 // indirect\n\tgithub.com/acomagu/bufpipe v1.0.4 // indirect\n\tgithub.com/alecthomas/atomic v0.1.0-alpha2 // indirect\n\tgithub.com/anacrolix/chansync v0.7.0 // indirect\n\tgithub.com/anacrolix/dht/v2 v2.23.0 // indirect\n\tgithub.com/anacrolix/envpprof v1.5.0 // indirect\n\tgithub.com/anacrolix/generics v0.2.0 // indirect\n\tgithub.com/anacrolix/go-libutp v1.3.2 // indirect\n\tgithub.com/anacrolix/log v0.17.1-0.20251118025802-918f1157b7bb // indirect\n\tgithub.com/anacrolix/missinggo v1.3.0 // indirect\n\tgithub.com/anacrolix/missinggo/perf v1.0.0 // indirect\n\tgithub.com/anacrolix/mmsg v1.1.1 // indirect\n\tgithub.com/anacrolix/multiless v0.4.0 // indirect\n\tgithub.com/anacrolix/stm v0.5.0 // indirect\n\tgithub.com/anacrolix/sync v0.6.0 // indirect\n\tgithub.com/anacrolix/upnp v0.1.4 // indirect\n\tgithub.com/anacrolix/utp v0.2.0 // indirect\n\tgithub.com/bahlo/generic-list-go v0.2.0 // indirect\n\tgithub.com/benbjohnson/immutable v0.4.3 // indirect\n\tgithub.com/bits-and-blooms/bitset v1.24.4 // indirect\n\tgithub.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect\n\tgithub.com/cespare/xxhash v1.1.0 // indirect\n\tgithub.com/cloudflare/circl v1.6.1 // indirect\n\tgithub.com/davecgh/go-spew v1.1.1 // indirect\n\tgithub.com/dlclark/regexp2 v1.11.5 // indirect\n\tgithub.com/dustin/go-humanize v1.0.1 // indirect\n\tgithub.com/edsrzf/mmap-go v1.2.0 // indirect\n\tgithub.com/emirpasic/gods v1.18.1 // indirect\n\tgithub.com/felixge/httpsnoop v1.0.1 // indirect\n\tgithub.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect\n\tgithub.com/go-git/go-billy/v5 v5.4.1 // indirect\n\tgithub.com/go-llsqlite/adapter v0.2.0 // indirect\n\tgithub.com/go-llsqlite/crawshaw v0.6.0 // indirect\n\tgithub.com/go-logr/logr v1.4.3 // indirect\n\tgithub.com/go-logr/stdr v1.2.2 // indirect\n\tgithub.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect\n\tgithub.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect\n\tgithub.com/google/btree v1.1.3 // indirect\n\tgithub.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect\n\tgithub.com/google/uuid v1.6.0 // indirect\n\tgithub.com/gorilla/websocket v1.5.3 // indirect\n\tgithub.com/huandu/xstrings v1.5.0 // indirect\n\tgithub.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect\n\tgithub.com/kevinburke/ssh_config v1.2.0 // indirect\n\tgithub.com/klauspost/cpuid/v2 v2.3.0 // indirect\n\tgithub.com/mattn/go-colorable v0.1.13 // indirect\n\tgithub.com/mattn/go-isatty v0.0.20 // indirect\n\tgithub.com/minio/sha256-simd v1.0.1 // indirect\n\tgithub.com/mr-tron/base58 v1.2.0 // indirect\n\tgithub.com/mschoch/smat v0.2.0 // indirect\n\tgithub.com/multiformats/go-multihash v0.2.3 // indirect\n\tgithub.com/multiformats/go-varint v0.1.0 // indirect\n\tgithub.com/ncruces/go-strftime v1.0.0 // indirect\n\tgithub.com/pion/datachannel v1.6.0 // indirect\n\tgithub.com/pion/dtls/v3 v3.0.10 // indirect\n\tgithub.com/pion/ice/v4 v4.2.0 // indirect\n\tgithub.com/pion/interceptor v0.1.42 // indirect\n\tgithub.com/pion/logging v0.2.4 // indirect\n\tgithub.com/pion/mdns/v2 v2.1.0 // indirect\n\tgithub.com/pion/randutil v0.1.0 // indirect\n\tgithub.com/pion/rtcp v1.2.16 // indirect\n\tgithub.com/pion/rtp v1.10.0 // indirect\n\tgithub.com/pion/sctp v1.9.1 // indirect\n\tgithub.com/pion/sdp/v3 v3.0.17 // indirect\n\tgithub.com/pion/srtp/v3 v3.0.10 // indirect\n\tgithub.com/pion/stun/v3 v3.1.1 // indirect\n\tgithub.com/pion/turn/v4 v4.1.4 // indirect\n\tgithub.com/pion/webrtc/v4 v4.2.2-0.20260109001657-a5962f314db7 // indirect\n\tgithub.com/pjbgf/sha1cd v0.3.0 // indirect\n\tgithub.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c\n\tgithub.com/protolambda/ctxlock v0.1.0 // indirect\n\tgithub.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect\n\tgithub.com/sergi/go-diff v1.1.0 // indirect\n\tgithub.com/skeema/knownhosts v1.2.0 // indirect\n\tgithub.com/spaolacci/murmur3 v1.1.0 // indirect\n\tgithub.com/tidwall/btree v1.8.1 // indirect\n\tgithub.com/wlynxg/anet v0.0.5 // indirect\n\tgithub.com/xanzy/ssh-agent v0.3.3 // indirect\n\tgo.opentelemetry.io/otel v1.39.0 // indirect\n\tgo.opentelemetry.io/otel/metric v1.39.0 // indirect\n\tgo.opentelemetry.io/otel/trace v1.39.0 // indirect\n\tgolang.org/x/crypto v0.46.0 // indirect\n\tgolang.org/x/mod v0.31.0 // indirect\n\tgolang.org/x/net v0.48.0 // indirect\n\tgolang.org/x/sys v0.40.0 // indirect\n\tgolang.org/x/text v0.33.0\n\tgolang.org/x/time v0.14.0 // indirect\n\tgolang.org/x/tools v0.40.0 // indirect\n\tgopkg.in/warnings.v0 v0.1.2 // indirect\n\tlukechampine.com/blake3 v1.4.1 // indirect\n\tmodernc.org/libc v1.67.4 // indirect\n\tmodernc.org/mathutil v1.7.1 // indirect\n\tmodernc.org/memory v1.11.0 // indirect\n\tmodernc.org/sqlite v1.43.0 // indirect\n\tzombiezen.com/go/sqlite v1.4.2 // indirect\n)\n"
  },
  {
    "path": "go.sum",
    "content": "cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=\ncloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=\ncloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=\ncloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=\ncloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=\ncloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=\ncloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=\ncloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=\ncloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=\ncloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=\ncloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=\ncloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=\ncloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=\ncloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=\ncloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=\ncloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=\ncrawshaw.io/iox v0.0.0-20181124134642-c51c3df30797/go.mod h1:sXBiorCo8c46JlQV3oXPKINnZ8mcqnye1EkVkqsectk=\ncrawshaw.io/sqlite v0.3.2/go.mod h1:igAO5JulrQ1DbdZdtVq48mnZUBAPOeFzer7VhDWNtW4=\ndario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=\ndario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=\ndmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=\nfilippo.io/edwards25519 v1.0.0-rc.1 h1:m0VOOB23frXZvAOK44usCgLWvtsxIoMCTBGJZlpmGfU=\nfilippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns=\ngithub.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=\ngithub.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=\ngithub.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=\ngithub.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=\ngithub.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=\ngithub.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=\ngithub.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=\ngithub.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=\ngithub.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=\ngithub.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95 h1:KLq8BE0KwCL+mmXnjLWEAOYO+2l2AE4YMmqG1ZpZHBs=\ngithub.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=\ngithub.com/RoaringBitmap/roaring v0.4.7/go.mod h1:8khRDP4HmeXns4xIj9oGrKSz7XTQiJx2zgh7AcNke4w=\ngithub.com/RoaringBitmap/roaring v0.4.17/go.mod h1:D3qVegWTmfCaX4Bl5CrBE9hfrSrrXIr8KVNvRsDi1NI=\ngithub.com/RoaringBitmap/roaring v0.4.23/go.mod h1:D0gp8kJQgE1A4LQ5wFLggQEyvDi06Mq5mKs52e1TwOo=\ngithub.com/RoaringBitmap/roaring v1.9.4 h1:yhEIoH4YezLYT04s1nHehNO64EKFTop/wBhxv2QzDdQ=\ngithub.com/RoaringBitmap/roaring v1.9.4/go.mod h1:6AXUsoIEzDTFFQCe1RbGA6uFONMhvejWj5rqITANK90=\ngithub.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4=\ngithub.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk=\ngithub.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=\ngithub.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=\ngithub.com/acomagu/bufpipe v1.0.4 h1:e3H4WUzM3npvo5uv95QuJM3cQspFNtFBzvJ2oNjKIDQ=\ngithub.com/acomagu/bufpipe v1.0.4/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=\ngithub.com/alecthomas/assert/v2 v2.0.0-alpha3 h1:pcHeMvQ3OMstAWgaeaXIAL8uzB9xMm2zlxt+/4ml8lk=\ngithub.com/alecthomas/assert/v2 v2.0.0-alpha3/go.mod h1:+zD0lmDXTeQj7TgDgCt0ePWxb0hMC1G+PGTsTCv1B9o=\ngithub.com/alecthomas/atomic v0.1.0-alpha2 h1:dqwXmax66gXvHhsOS4pGPZKqYOlTkapELkLb3MNdlH8=\ngithub.com/alecthomas/atomic v0.1.0-alpha2/go.mod h1:zD6QGEyw49HIq19caJDc2NMXAy8rNi9ROrxtMXATfyI=\ngithub.com/alecthomas/repr v0.0.0-20210801044451-80ca428c5142 h1:8Uy0oSf5co/NZXje7U1z8Mpep++QJOldL2hs/sBQf48=\ngithub.com/alecthomas/repr v0.0.0-20210801044451-80ca428c5142/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=\ngithub.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=\ngithub.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=\ngithub.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=\ngithub.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=\ngithub.com/anacrolix/btree v0.1.1 h1:igdFPLrt82L6qovzbEGSMkTeiwcU3EFIGl2K8XWocAc=\ngithub.com/anacrolix/btree v0.1.1/go.mod h1:KHWYRZuUULATjUGJC4dQDXx/BPOnWrJozGR6TndjOmc=\ngithub.com/anacrolix/chansync v0.7.0 h1:wgwxbsJRmOqNjil4INpxHrDp4rlqQhECxR8/WBP4Et0=\ngithub.com/anacrolix/chansync v0.7.0/go.mod h1:DZsatdsdXxD0WiwcGl0nJVwyjCKMDv+knl1q2iBjA2k=\ngithub.com/anacrolix/dht/v2 v2.23.0 h1:EuD17ykTTEkAMPLjBsS5QjGOwuBgLTdQhds6zPAjeVY=\ngithub.com/anacrolix/dht/v2 v2.23.0/go.mod h1:seXRz6HLw8zEnxlysf9ye2eQbrKUmch6PyOHpe/Nb/U=\ngithub.com/anacrolix/envpprof v0.0.0-20180404065416-323002cec2fa/go.mod h1:KgHhUaQMc8cC0+cEflSgCFNFbKwi5h54gqtVn8yhP7c=\ngithub.com/anacrolix/envpprof v1.0.0/go.mod h1:KgHhUaQMc8cC0+cEflSgCFNFbKwi5h54gqtVn8yhP7c=\ngithub.com/anacrolix/envpprof v1.1.0/go.mod h1:My7T5oSqVfEn4MD4Meczkw/f5lSIndGAKu/0SM/rkf4=\ngithub.com/anacrolix/envpprof v1.5.0 h1:eMI07YWjk86b+8N9A4k0OtLSS472/1Z0qraAW2oPZb4=\ngithub.com/anacrolix/envpprof v1.5.0/go.mod h1:OQPV3SNK6uVAlXL4slcZVXv+xLcE0ybWgVKcUfr5fE8=\ngithub.com/anacrolix/generics v0.0.0-20230113004304-d6428d516633/go.mod h1:ff2rHB/joTV03aMSSn/AZNnaIpUw0h3njetGsaXcMy8=\ngithub.com/anacrolix/generics v0.2.0 h1:gPwGOs14irokFN9kUP1i1A0Bn0FPT7/hWWD3hHKSKNw=\ngithub.com/anacrolix/generics v0.2.0/go.mod h1:NGehhfeXJPBujPx0s6cstSj8B+TERsTY32Xckfx5ftc=\ngithub.com/anacrolix/go-libutp v1.3.2 h1:WswiaxTIogchbkzNgGHuHRfbrYLpv4o290mlvcx+++M=\ngithub.com/anacrolix/go-libutp v1.3.2/go.mod h1:fCUiEnXJSe3jsPG554A200Qv+45ZzIIyGEvE56SHmyA=\ngithub.com/anacrolix/log v0.3.0/go.mod h1:lWvLTqzAnCWPJA08T2HCstZi0L1y2Wyvm3FJgwU9jwU=\ngithub.com/anacrolix/log v0.6.0/go.mod h1:lWvLTqzAnCWPJA08T2HCstZi0L1y2Wyvm3FJgwU9jwU=\ngithub.com/anacrolix/log v0.13.1/go.mod h1:D4+CvN8SnruK6zIFS/xPoRJmtvtnxs+CSfDQ+BFxZ68=\ngithub.com/anacrolix/log v0.14.2/go.mod h1:1OmJESOtxQGNMlUO5rcv96Vpp9mfMqXXbe2RdinFLdY=\ngithub.com/anacrolix/log v0.17.1-0.20251118025802-918f1157b7bb h1:nGNLCQbxFQZz7/9PXLGQ9GmavI/W+eX66pSwVeUwugU=\ngithub.com/anacrolix/log v0.17.1-0.20251118025802-918f1157b7bb/go.mod h1:YjBZbwe2v3RsU7WdoBlVSPVpfKuOAno9SRQ/8tIl+hk=\ngithub.com/anacrolix/lsan v0.0.0-20211126052245-807000409a62/go.mod h1:66cFKPCO7Sl4vbFnAaSq7e4OXtdMhRSBagJGWgmpJbM=\ngithub.com/anacrolix/lsan v0.1.0 h1:TbgB8fdVXgBwrNsJGHtht9+9FepNFu5H7dU8ek6XYAY=\ngithub.com/anacrolix/lsan v0.1.0/go.mod h1:66cFKPCO7Sl4vbFnAaSq7e4OXtdMhRSBagJGWgmpJbM=\ngithub.com/anacrolix/missinggo v0.0.0-20180725070939-60ef2fbf63df/go.mod h1:kwGiTUTZ0+p4vAz3VbAI5a30t2YbvemcmspjKwrAz5s=\ngithub.com/anacrolix/missinggo v1.1.0/go.mod h1:MBJu3Sk/k3ZfGYcS7z18gwfu72Ey/xopPFJJbTi5yIo=\ngithub.com/anacrolix/missinggo v1.1.2-0.20190815015349-b888af804467/go.mod h1:MBJu3Sk/k3ZfGYcS7z18gwfu72Ey/xopPFJJbTi5yIo=\ngithub.com/anacrolix/missinggo v1.2.1/go.mod h1:J5cMhif8jPmFoC3+Uvob3OXXNIhOUikzMt+uUjeM21Y=\ngithub.com/anacrolix/missinggo v1.3.0 h1:06HlMsudotL7BAELRZs0yDZ4yVXsHXGi323QBjAVASw=\ngithub.com/anacrolix/missinggo v1.3.0/go.mod h1:bqHm8cE8xr+15uVfMG3BFui/TxyB6//H5fwlq/TeqMc=\ngithub.com/anacrolix/missinggo/perf v1.0.0 h1:7ZOGYziGEBytW49+KmYGTaNfnwUqP1HBsy6BqESAJVw=\ngithub.com/anacrolix/missinggo/perf v1.0.0/go.mod h1:ljAFWkBuzkO12MQclXzZrosP5urunoLS0Cbvb4V0uMQ=\ngithub.com/anacrolix/missinggo/v2 v2.2.0/go.mod h1:o0jgJoYOyaoYQ4E2ZMISVa9c88BbUBVQQW4QeRkNCGY=\ngithub.com/anacrolix/missinggo/v2 v2.5.1/go.mod h1:WEjqh2rmKECd0t1VhQkLGTdIWXO6f6NLjp5GlMZ+6FA=\ngithub.com/anacrolix/missinggo/v2 v2.10.0 h1:pg0iO4Z/UhP2MAnmGcaMtp5ZP9kyWsusENWN9aolrkY=\ngithub.com/anacrolix/missinggo/v2 v2.10.0/go.mod h1:nCRMW6bRCMOVcw5z9BnSYKF+kDbtenx+hQuphf4bK8Y=\ngithub.com/anacrolix/mmsg v1.0.1/go.mod h1:x8kRaJY/dCrY9Al0PEcj1mb/uFHwP6GCJ9fLl4thEPc=\ngithub.com/anacrolix/mmsg v1.1.1 h1:4ce/3I5kM7qSF6T5A8MOmDCfac3UqYlk5Bzh5XsWebM=\ngithub.com/anacrolix/mmsg v1.1.1/go.mod h1:lPCXEN1eDDQtKktdKEzdw+roswx6wWPpeXAl/WpWVDU=\ngithub.com/anacrolix/multiless v0.4.0 h1:lqSszHkliMsZd2hsyrDvHOw4AbYWa+ijQ66LzbjqWjM=\ngithub.com/anacrolix/multiless v0.4.0/go.mod h1:zJv1JF9AqdZiHwxqPgjuOZDGWER6nyE48WBCi/OOrMM=\ngithub.com/anacrolix/stm v0.2.0/go.mod h1:zoVQRvSiGjGoTmbM0vSLIiaKjWtNPeTvXUSdJQA4hsg=\ngithub.com/anacrolix/stm v0.5.0 h1:9df1KBpttF0TzLgDq51Z+TEabZKMythqgx89f1FQJt8=\ngithub.com/anacrolix/stm v0.5.0/go.mod h1:MOwrSy+jCm8Y7HYfMAwPj7qWVu7XoVvjOiYwJmpeB/M=\ngithub.com/anacrolix/sync v0.0.0-20180808010631-44578de4e778/go.mod h1:s735Etp3joe/voe2sdaXLcqDdJSay1O0OPnM0ystjqk=\ngithub.com/anacrolix/sync v0.3.0/go.mod h1:BbecHL6jDSExojhNtgTFSBcdGerzNc64tz3DCOj/I0g=\ngithub.com/anacrolix/sync v0.6.0 h1:W8jXs9tlj/oHhvLys2Cu8ncDAw6NVjm413t0BHp3LVo=\ngithub.com/anacrolix/sync v0.6.0/go.mod h1:zigHZiBXkcjo9uNACGCpovT+wNKMbzjO1qN2+eehq8Y=\ngithub.com/anacrolix/tagflag v0.0.0-20180109131632-2146c8d41bf0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pmhJXOKKCHw=\ngithub.com/anacrolix/tagflag v1.0.0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pmhJXOKKCHw=\ngithub.com/anacrolix/tagflag v1.1.0/go.mod h1:Scxs9CV10NQatSmbyjqmqmeQNwGzlNe0CMUMIxqHIG8=\ngithub.com/anacrolix/torrent v1.60.1-0.20251217073903-486bcbe758e0 h1:lzjUG5O/Vzn4vodFZnrmVny08kMnujjmMLQw0X7cetI=\ngithub.com/anacrolix/torrent v1.60.1-0.20251217073903-486bcbe758e0/go.mod h1:yKUKuZSSDdyOsCbuH+rDOpswl/g546gICapdrU7aUmQ=\ngithub.com/anacrolix/upnp v0.1.4 h1:+2t2KA6QOhm/49zeNyeVwDu1ZYS9dB9wfxyVvh/wk7U=\ngithub.com/anacrolix/upnp v0.1.4/go.mod h1:Qyhbqo69gwNWvEk1xNTXsS5j7hMHef9hdr984+9fIic=\ngithub.com/anacrolix/utp v0.2.0 h1:65Cdmr6q9WSw2KsM+rtJFu7rqDzLl2bdysf4KlNPcFI=\ngithub.com/anacrolix/utp v0.2.0/go.mod h1:HGk4GYQw1O/3T1+yhqT/F6EcBd+AAwlo9dYErNy7mj8=\ngithub.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=\ngithub.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=\ngithub.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=\ngithub.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=\ngithub.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=\ngithub.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=\ngithub.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=\ngithub.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=\ngithub.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=\ngithub.com/benbjohnson/immutable v0.2.0/go.mod h1:uc6OHo6PN2++n98KHLxW8ef4W42ylHiQSENghE1ezxI=\ngithub.com/benbjohnson/immutable v0.4.3 h1:GYHcksoJ9K6HyAUpGxwZURrbTkXA0Dh4otXGqbhdrjA=\ngithub.com/benbjohnson/immutable v0.4.3/go.mod h1:qJIKKSmdqz1tVzNtst1DZzvaqOU1onk1rc03IeM3Owk=\ngithub.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=\ngithub.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=\ngithub.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=\ngithub.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=\ngithub.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=\ngithub.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=\ngithub.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU=\ngithub.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs=\ngithub.com/bodgit/sevenzip v1.6.1 h1:kikg2pUMYC9ljU7W9SaqHXhym5HyKm8/M/jd31fYan4=\ngithub.com/bodgit/sevenzip v1.6.1/go.mod h1:GVoYQbEVbOGT8n2pfqCIMRUaRjQ8F9oSqoBEqZh5fQ8=\ngithub.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=\ngithub.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=\ngithub.com/bradfitz/iter v0.0.0-20140124041915-454541ec3da2/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo=\ngithub.com/bradfitz/iter v0.0.0-20190303215204-33e6a9893b0c/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo=\ngithub.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 h1:GKTyiRCL6zVf5wWaqKnf+7Qs6GbEPfd4iMOitWzXJx8=\ngithub.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8/go.mod h1:spo1JLcs67NmW1aVLEgtA8Yy1elc+X8y5SRW1sFW4Og=\ngithub.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=\ngithub.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=\ngithub.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=\ngithub.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=\ngithub.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=\ngithub.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=\ngithub.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=\ngithub.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=\ngithub.com/chromedp/chromedp v0.9.2/go.mod h1:LkSXJKONWTCHAfQasKFUZI+mxqS4tZqhmtGzzhLsnLs=\ngithub.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=\ngithub.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=\ngithub.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=\ngithub.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=\ngithub.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=\ngithub.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=\ngithub.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=\ngithub.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=\ngithub.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=\ngithub.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=\ngithub.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=\ngithub.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=\ngithub.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=\ngithub.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=\ngithub.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=\ngithub.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=\ngithub.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=\ngithub.com/dop251/goja_nodejs v0.0.0-20240728170619-29b559befffc h1:MKYt39yZJi0Z9xEeRmDX2L4ocE0ETKcHKw6MVL3R+co=\ngithub.com/dop251/goja_nodejs v0.0.0-20240728170619-29b559befffc/go.mod h1:VULptt4Q/fNzQUJlqY/GP3qHyU7ZH46mFkBZe0ZTokU=\ngithub.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4=\ngithub.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=\ngithub.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=\ngithub.com/dustin/go-humanize v0.0.0-20180421182945-02af3965c54e/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=\ngithub.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=\ngithub.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=\ngithub.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=\ngithub.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=\ngithub.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=\ngithub.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=\ngithub.com/edsrzf/mmap-go v1.2.0 h1:hXLYlkbaPzt1SaQk+anYwKSRNhufIDCchSPkUD6dD84=\ngithub.com/edsrzf/mmap-go v1.2.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q=\ngithub.com/elazarl/goproxy v0.0.0-20221015165544-a0805db90819 h1:RIB4cRk+lBqKK3Oy0r2gRX4ui7tuhiZq2SuTtTCi0/0=\ngithub.com/elazarl/goproxy v0.0.0-20221015165544-a0805db90819/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM=\ngithub.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=\ngithub.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=\ngithub.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=\ngithub.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=\ngithub.com/felixge/fgprof v0.9.5 h1:8+vR6yu2vvSKn08urWyEuxx75NWPEvybbkBirEpsbVY=\ngithub.com/felixge/fgprof v0.9.5/go.mod h1:yKl+ERSa++RYOs32d8K6WEXCB4uXdLls4ZaZPpayhMM=\ngithub.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8SPQ=\ngithub.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=\ngithub.com/frankban/quicktest v1.9.0/go.mod h1:ui7WezCLWMWxVWr1GETZY3smRy0G4KWq9vcPtJmFl7Y=\ngithub.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=\ngithub.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=\ngithub.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=\ngithub.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=\ngithub.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY=\ngithub.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4=\ngithub.com/glycerine/go-unsnap-stream v0.0.0-20180323001048-9f0cb55181dd/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=\ngithub.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=\ngithub.com/glycerine/go-unsnap-stream v0.0.0-20190901134440-81cf024a9e0a/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=\ngithub.com/glycerine/goconvey v0.0.0-20180728074245-46e3a41ad493/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=\ngithub.com/glycerine/goconvey v0.0.0-20190315024820-982ee783a72e/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=\ngithub.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=\ngithub.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=\ngithub.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=\ngithub.com/go-git/go-billy/v5 v5.4.1 h1:Uwp5tDRkPr+l/TnbHOQzp+tmJfLceOlbVucgpTz8ix4=\ngithub.com/go-git/go-billy/v5 v5.4.1/go.mod h1:vjbugF6Fz7JIflbVpl1hJsGjSHNltrSw45YK/ukIvQg=\ngithub.com/go-git/go-git-fixtures/v4 v4.3.2-0.20230305113008-0c11038e723f h1:Pz0DHeFij3XFhoBRGUDPzSJ+w2UcK5/0JvF8DRI58r8=\ngithub.com/go-git/go-git-fixtures/v4 v4.3.2-0.20230305113008-0c11038e723f/go.mod h1:8LHG1a3SRW71ettAD/jW13h8c6AqjVSeL11RAdgaqpo=\ngithub.com/go-git/go-git/v5 v5.8.1 h1:Zo79E4p7TRk0xoRgMq0RShiTHGKcKI4+DI6BfJc/Q+A=\ngithub.com/go-git/go-git/v5 v5.8.1/go.mod h1:FHFuoD6yGz5OSKEBK+aWN9Oah0q54Jxl0abmj6GnqAo=\ngithub.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=\ngithub.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=\ngithub.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=\ngithub.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=\ngithub.com/go-llsqlite/adapter v0.2.0 h1:6k4dmTSTg1eKIeH+2kBWaoohn9SFNZeg4LWayZweevI=\ngithub.com/go-llsqlite/adapter v0.2.0/go.mod h1:tcIEbwjdknnizwMsq9ogjMW6246aIjk97cRywjkbqZ0=\ngithub.com/go-llsqlite/crawshaw v0.6.0 h1:3c0p/CU4EFG2zhSkXLwM2Bgt8ZNqwUgA6wimxkxqC1c=\ngithub.com/go-llsqlite/crawshaw v0.6.0/go.mod h1:/YJdV7uBQaYDE0fwe4z3wwJIZBJxdYzd38ICggWqtaE=\ngithub.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=\ngithub.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=\ngithub.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=\ngithub.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=\ngithub.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=\ngithub.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=\ngithub.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=\ngithub.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=\ngithub.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=\ngithub.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=\ngithub.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=\ngithub.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=\ngithub.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=\ngithub.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=\ngithub.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=\ngithub.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=\ngithub.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=\ngithub.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=\ngithub.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=\ngithub.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=\ngithub.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=\ngithub.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=\ngithub.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=\ngithub.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=\ngithub.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=\ngithub.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=\ngithub.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=\ngithub.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=\ngithub.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=\ngithub.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=\ngithub.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=\ngithub.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=\ngithub.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=\ngithub.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=\ngithub.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=\ngithub.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=\ngithub.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=\ngithub.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=\ngithub.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=\ngithub.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=\ngithub.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=\ngithub.com/google/btree v0.0.0-20180124185431-e89373fe6b4a/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=\ngithub.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=\ngithub.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=\ngithub.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=\ngithub.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=\ngithub.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=\ngithub.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=\ngithub.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=\ngithub.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=\ngithub.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=\ngithub.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=\ngithub.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=\ngithub.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=\ngithub.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=\ngithub.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=\ngithub.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=\ngithub.com/google/pprof v0.0.0-20240227163752-401108e1b7e7/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik=\ngithub.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=\ngithub.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=\ngithub.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=\ngithub.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=\ngithub.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=\ngithub.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=\ngithub.com/gopherjs/gopherjs v0.0.0-20190309154008-847fc94819f9/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=\ngithub.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=\ngithub.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=\ngithub.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4=\ngithub.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q=\ngithub.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=\ngithub.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=\ngithub.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=\ngithub.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=\ngithub.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=\ngithub.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=\ngithub.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=\ngithub.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=\ngithub.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=\ngithub.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=\ngithub.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=\ngithub.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=\ngithub.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=\ngithub.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=\ngithub.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=\ngithub.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=\ngithub.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=\ngithub.com/huandu/xstrings v1.0.0/go.mod h1:4qWG/gcEcfX4z/mBDHJ++3ReCw9ibxbsNJbcucJdbSo=\ngithub.com/huandu/xstrings v1.2.0/go.mod h1:DvyZB1rfVYsBIigL8HwpZgxHwXozlTgGqn63UyNX5k4=\ngithub.com/huandu/xstrings v1.3.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=\ngithub.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=\ngithub.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=\ngithub.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=\ngithub.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=\ngithub.com/ianlancetaylor/demangle v0.0.0-20230524184225-eabc099b10ab/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw=\ngithub.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4=\ngithub.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y=\ngithub.com/imroc/req/v3 v3.52.2 h1:xJocr1aIv0a2K9knfBQ4JnZHk+kWTITdjf0mgDg229I=\ngithub.com/imroc/req/v3 v3.52.2/go.mod h1:dBGsDloOSZJcFs6PnTjZXYBJK70OXbZpizHBLNqcH2k=\ngithub.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=\ngithub.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=\ngithub.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=\ngithub.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=\ngithub.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=\ngithub.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=\ngithub.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=\ngithub.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=\ngithub.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=\ngithub.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=\ngithub.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=\ngithub.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=\ngithub.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=\ngithub.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=\ngithub.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=\ngithub.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=\ngithub.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=\ngithub.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=\ngithub.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=\ngithub.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=\ngithub.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=\ngithub.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=\ngithub.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=\ngithub.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=\ngithub.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=\ngithub.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=\ngithub.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=\ngithub.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=\ngithub.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=\ngithub.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=\ngithub.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=\ngithub.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=\ngithub.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=\ngithub.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=\ngithub.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=\ngithub.com/matoous/go-nanoid v1.5.0/go.mod h1:zyD2a71IubI24efhpvkJz+ZwfwagzgSO6UNiFsZKN7U=\ngithub.com/matoous/go-nanoid/v2 v2.0.0 h1:d19kur2QuLeHmJBkvYkFdhFBzLoo1XVm2GgTpL+9Tj0=\ngithub.com/matoous/go-nanoid/v2 v2.0.0/go.mod h1:FtS4aGPVfEkxKxhdWPAspZpZSh1cOjtM7Ej/So3hR0g=\ngithub.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A=\ngithub.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA=\ngithub.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=\ngithub.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=\ngithub.com/mattn/go-ieproxy v0.0.12 h1:OZkUFJC3ESNZPQ+6LzC3VJIFSnreeFLQyqvBWtvfL2M=\ngithub.com/mattn/go-ieproxy v0.0.12/go.mod h1:Vn+N61199DAnVeTgaF8eoB9PvLO8P3OBnG95ENh7B7c=\ngithub.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=\ngithub.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=\ngithub.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=\ngithub.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=\ngithub.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=\ngithub.com/mholt/archives v0.1.5 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ=\ngithub.com/mholt/archives v0.1.5/go.mod h1:3TPMmBLPsgszL+1As5zECTuKwKvIfj6YcwWPpeTAXF4=\ngithub.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZzkD0=\ngithub.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc=\ngithub.com/minio/minlz v1.0.1 h1:OUZUzXcib8diiX+JYxyRLIdomyZYzHct6EShOKtQY2A=\ngithub.com/minio/minlz v1.0.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec=\ngithub.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=\ngithub.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=\ngithub.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=\ngithub.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=\ngithub.com/monkeyWie/goed2k v0.0.0-20260317094144-6e18d43056e5 h1:tEOucyKJzFsCz5Gr41jFHj8i2g1zemTyS4uyErJgFHc=\ngithub.com/monkeyWie/goed2k v0.0.0-20260317094144-6e18d43056e5/go.mod h1:Ry2y1QlzerUgA1hVmExBdXXzE4Sjk1M7w0nSh6dhDOg=\ngithub.com/monkeyWie/goed2k v0.0.0-20260317100435-7a7575cf2447 h1:88kRsNwkDKPA4NzRUWdOoIL7SyMNDwtmAxzShKBvKTI=\ngithub.com/monkeyWie/goed2k v0.0.0-20260317100435-7a7575cf2447/go.mod h1:Ry2y1QlzerUgA1hVmExBdXXzE4Sjk1M7w0nSh6dhDOg=\ngithub.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=\ngithub.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=\ngithub.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg=\ngithub.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=\ngithub.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=\ngithub.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U=\ngithub.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=\ngithub.com/multiformats/go-varint v0.1.0 h1:i2wqFp4sdl3IcIxfAonHQV9qU5OsZ4Ts9IOoETFs5dI=\ngithub.com/multiformats/go-varint v0.1.0/go.mod h1:5KVAVXegtfmNQQm/lCY+ATvDzvJJhSkUlGQV9wgObdI=\ngithub.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=\ngithub.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=\ngithub.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=\ngithub.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=\ngithub.com/nwaples/rardecode/v2 v2.2.0 h1:4ufPGHiNe1rYJxYfehALLjup4Ls3ck42CWwjKiOqu0A=\ngithub.com/nwaples/rardecode/v2 v2.2.0/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=\ngithub.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=\ngithub.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=\ngithub.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=\ngithub.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=\ngithub.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=\ngithub.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU=\ngithub.com/onsi/gomega v1.36.3/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=\ngithub.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw=\ngithub.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=\ngithub.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU=\ngithub.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=\ngithub.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=\ngithub.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=\ngithub.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0=\ngithub.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk=\ngithub.com/pion/dtls/v3 v3.0.10 h1:k9ekkq1kaZoxnNEbyLKI8DI37j/Nbk1HWmMuywpQJgg=\ngithub.com/pion/dtls/v3 v3.0.10/go.mod h1:YEmmBYIoBsY3jmG56dsziTv/Lca9y4Om83370CXfqJ8=\ngithub.com/pion/ice/v4 v4.2.0 h1:jJC8S+CvXCCvIQUgx+oNZnoUpt6zwc34FhjWwCU4nlw=\ngithub.com/pion/ice/v4 v4.2.0/go.mod h1:EgjBGxDgmd8xB0OkYEVFlzQuEI7kWSCFu+mULqaisy4=\ngithub.com/pion/interceptor v0.1.42 h1:0/4tvNtruXflBxLfApMVoMubUMik57VZ+94U0J7cmkQ=\ngithub.com/pion/interceptor v0.1.42/go.mod h1:g6XYTChs9XyolIQFhRHOOUS+bGVGLRfgTCUzH29EfVU=\ngithub.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=\ngithub.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=\ngithub.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY=\ngithub.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A=\ngithub.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=\ngithub.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=\ngithub.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo=\ngithub.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo=\ngithub.com/pion/rtp v1.10.0 h1:XN/xca4ho6ZEcijpdF2VGFbwuHUfiIMf3ew8eAAE43w=\ngithub.com/pion/rtp v1.10.0/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=\ngithub.com/pion/sctp v1.9.1 h1:ACiozSfsMXYjXOk2q0bBFzxqFZMmq+TalD2R5f9Rh4M=\ngithub.com/pion/sctp v1.9.1/go.mod h1:OTOlsQ5EDQ6mQ0z4MUGXt2CgQmKyafBEXhUVqLRB6G8=\ngithub.com/pion/sdp/v3 v3.0.17 h1:9SfLAW/fF1XC8yRqQ3iWGzxkySxup4k4V7yN8Fs8nuo=\ngithub.com/pion/sdp/v3 v3.0.17/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo=\ngithub.com/pion/srtp/v3 v3.0.10 h1:tFirkpBb3XccP5VEXLi50GqXhv5SKPxqrdlhDCJlZrQ=\ngithub.com/pion/srtp/v3 v3.0.10/go.mod h1:3mOTIB0cq9qlbn59V4ozvv9ClW/BSEbRp4cY0VtaR7M=\ngithub.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw=\ngithub.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM=\ngithub.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=\ngithub.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=\ngithub.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=\ngithub.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=\ngithub.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ=\ngithub.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ=\ngithub.com/pion/webrtc/v4 v4.2.2-0.20260109001657-a5962f314db7 h1:gnkvpxCSzeTKovDsxeFFl+W/i6WydzSHhu5lH/RpFfM=\ngithub.com/pion/webrtc/v4 v4.2.2-0.20260109001657-a5962f314db7/go.mod h1:/td+qaV3q20qTbRjYV9ABWjmCkvt+Pog/S+NqLMoUKk=\ngithub.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4=\ngithub.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI=\ngithub.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=\ngithub.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=\ngithub.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=\ngithub.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=\ngithub.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=\ngithub.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=\ngithub.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=\ngithub.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs=\ngithub.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=\ngithub.com/prometheus/client_golang v1.5.1/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=\ngithub.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=\ngithub.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=\ngithub.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=\ngithub.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=\ngithub.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=\ngithub.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=\ngithub.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=\ngithub.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=\ngithub.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=\ngithub.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=\ngithub.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=\ngithub.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=\ngithub.com/prometheus/procfs v0.0.11/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=\ngithub.com/protolambda/ctxlock v0.1.0 h1:rCUY3+vRdcdZXqT07iXgyr744J2DU2LCBIXowYAjBCE=\ngithub.com/protolambda/ctxlock v0.1.0/go.mod h1:vefhX6rIZH8rsg5ZpOJfEDYQOppZi19SfPiGOFrNnwM=\ngithub.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=\ngithub.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=\ngithub.com/quic-go/quic-go v0.51.0 h1:K8exxe9zXxeRKxaXxi/GpUqYiTrtdiWP8bo1KFya6Wc=\ngithub.com/quic-go/quic-go v0.51.0/go.mod h1:MFlGGpcpJqRAfmYi6NC2cptDPSxRWTOGNuP4wqrWmzQ=\ngithub.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=\ngithub.com/refraction-networking/utls v1.6.7 h1:zVJ7sP1dJx/WtVuITug3qYUq034cDq9B2MR1K67ULZM=\ngithub.com/refraction-networking/utls v1.6.7/go.mod h1:BC3O4vQzye5hqpmDTWUqi4P5DDhzJfkV1tdqtawQIH0=\ngithub.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=\ngithub.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=\ngithub.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=\ngithub.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=\ngithub.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=\ngithub.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=\ngithub.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=\ngithub.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=\ngithub.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529 h1:18kd+8ZUlt/ARXhljq+14TwAoKa61q6dX8jtwOf6DH8=\ngithub.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529/go.mod h1:qe5TWALJ8/a1Lqznoc5BDHpYX/8HU60Hm2AwRmqzxqA=\ngithub.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=\ngithub.com/rs/zerolog v1.31.0 h1:FcTR3NnLWW+NnTwwhFWiJSZr4ECLpqCm6QsEnyvbV4A=\ngithub.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=\ngithub.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=\ngithub.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8=\ngithub.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=\ngithub.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=\ngithub.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=\ngithub.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=\ngithub.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=\ngithub.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=\ngithub.com/skeema/knownhosts v1.2.0 h1:h9r9cf0+u7wSE+M183ZtMGgOJKiL96brpaz5ekfJCpM=\ngithub.com/skeema/knownhosts v1.2.0/go.mod h1:g4fPeYpque7P0xefxtGzV81ihjC8sX2IqpAoNkjxbMo=\ngithub.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=\ngithub.com/smartystreets/assertions v0.0.0-20190215210624-980c5ac6f3ac/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=\ngithub.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s=\ngithub.com/smartystreets/goconvey v0.0.0-20190306220146-200a235640ff/go.mod h1:KSQcGKpxUMHk3nbYzs/tIBAM2iDooCn0BmttHOJEbLs=\ngithub.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik=\ngithub.com/sorairolake/lzip-go v0.3.8/go.mod h1:JcBqGMV0frlxwrsE9sMWXDjqn3EeVf0/54YPsw66qkU=\ngithub.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=\ngithub.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=\ngithub.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=\ngithub.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=\ngithub.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=\ngithub.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=\ngithub.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=\ngithub.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=\ngithub.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=\ngithub.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=\ngithub.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=\ngithub.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=\ngithub.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=\ngithub.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=\ngithub.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=\ngithub.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=\ngithub.com/tidwall/btree v1.8.1 h1:27ehoXvm5AG/g+1VxLS1SD3vRhp/H7LuEfwNvddEdmA=\ngithub.com/tidwall/btree v1.8.1/go.mod h1:jBbTdUWhSZClZWoDg54VnvV7/54modSOzDN7VXftj1A=\ngithub.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=\ngithub.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=\ngithub.com/tinylib/msgp v1.1.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=\ngithub.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=\ngithub.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=\ngithub.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=\ngithub.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=\ngithub.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=\ngithub.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=\ngithub.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=\ngithub.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=\ngithub.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=\ngithub.com/xiaoqidun/setft v0.0.0-20220310121541-be86327699ad h1:QtuWk6dsrNXc/ugwCBEN0jG52q/4tXRzMvZm4fH6T9g=\ngithub.com/xiaoqidun/setft v0.0.0-20220310121541-be86327699ad/go.mod h1:Jj8p9bgKGTPQ+M8CdUMS9p7Mmdoxa3OAcAjJQBu0CcI=\ngithub.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=\ngithub.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=\ngithub.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=\ngithub.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=\ngo.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=\ngo.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=\ngo.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=\ngo.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=\ngo.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=\ngo.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=\ngo.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=\ngo.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=\ngo.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=\ngo.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=\ngo.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=\ngo.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=\ngo.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=\ngo.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=\ngo.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=\ngo.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=\ngo.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=\ngo.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=\ngo.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=\ngo.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=\ngo.uber.org/mock v0.5.1 h1:ASgazW/qBmR+A32MYFDB6E2POoTgOwT509VP0CT/fjs=\ngo.uber.org/mock v0.5.1/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=\ngo4.org v0.0.0-20230225012048-214862532bf5 h1:nifaUDeh+rPaBCMPMQHZmvJf+QdpLFnuQPwx+LxVmtc=\ngo4.org v0.0.0-20230225012048-214862532bf5/go.mod h1:F57wTi5Lrj6WLyswp5EYV1ncrEbFGHD4hhz6S1ZYeaU=\ngolang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=\ngolang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=\ngolang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=\ngolang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=\ngolang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=\ngolang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=\ngolang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=\ngolang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=\ngolang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=\ngolang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=\ngolang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=\ngolang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=\ngolang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=\ngolang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=\ngolang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=\ngolang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=\ngolang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=\ngolang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=\ngolang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=\ngolang.org/x/exp v0.0.0-20220428152302-39d4317da171/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE=\ngolang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=\ngolang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=\ngolang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=\ngolang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=\ngolang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=\ngolang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=\ngolang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=\ngolang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=\ngolang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=\ngolang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=\ngolang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=\ngolang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=\ngolang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=\ngolang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=\ngolang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=\ngolang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=\ngolang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=\ngolang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=\ngolang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=\ngolang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=\ngolang.org/x/mod v0.6.0-dev.0.20211013180041-c96bc1413d57/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=\ngolang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=\ngolang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=\ngolang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=\ngolang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=\ngolang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=\ngolang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=\ngolang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=\ngolang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=\ngolang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=\ngolang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=\ngolang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=\ngolang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=\ngolang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=\ngolang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=\ngolang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=\ngolang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=\ngolang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=\ngolang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=\ngolang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=\ngolang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=\ngolang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=\ngolang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=\ngolang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=\ngolang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=\ngolang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=\ngolang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=\ngolang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=\ngolang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=\ngolang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=\ngolang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=\ngolang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=\ngolang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=\ngolang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=\ngolang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=\ngolang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=\ngolang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=\ngolang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=\ngolang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=\ngolang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=\ngolang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=\ngolang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=\ngolang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=\ngolang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=\ngolang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=\ngolang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=\ngolang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=\ngolang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=\ngolang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=\ngolang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=\ngolang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=\ngolang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=\ngolang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=\ngolang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=\ngolang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=\ngolang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=\ngolang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=\ngolang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=\ngolang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=\ngolang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=\ngolang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=\ngolang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=\ngolang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=\ngolang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=\ngolang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=\ngolang.org/x/tools v0.1.8-0.20211029000441-d6a9af8af023/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=\ngolang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=\ngolang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=\ngolang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=\ngolang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=\ngolang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngoogle.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=\ngoogle.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=\ngoogle.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=\ngoogle.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=\ngoogle.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=\ngoogle.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=\ngoogle.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=\ngoogle.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=\ngoogle.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=\ngoogle.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=\ngoogle.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=\ngoogle.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=\ngoogle.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=\ngoogle.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=\ngoogle.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=\ngoogle.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=\ngoogle.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=\ngoogle.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=\ngoogle.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=\ngoogle.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=\ngoogle.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=\ngoogle.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=\ngoogle.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=\ngoogle.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=\ngoogle.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=\ngoogle.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=\ngoogle.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=\ngoogle.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=\ngoogle.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=\ngoogle.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=\ngoogle.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=\ngoogle.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=\ngoogle.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=\ngoogle.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=\ngoogle.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=\ngoogle.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=\ngoogle.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=\ngoogle.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=\ngoogle.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=\ngoogle.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=\ngoogle.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=\ngoogle.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=\ngopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=\ngopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=\ngopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=\ngopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=\ngopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=\ngopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=\ngopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=\ngopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=\ngopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=\ngotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=\nhonnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=\nhonnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=\nhonnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=\nhonnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=\nhonnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=\nhonnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=\nlukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=\nlukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo=\nmodernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=\nmodernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=\nmodernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=\nmodernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=\nmodernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=\nmodernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=\nmodernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=\nmodernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=\nmodernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=\nmodernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=\nmodernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=\nmodernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=\nmodernc.org/libc v1.67.4 h1:zZGmCMUVPORtKv95c2ReQN5VDjvkoRm9GWPTEPuvlWg=\nmodernc.org/libc v1.67.4/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=\nmodernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=\nmodernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=\nmodernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=\nmodernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=\nmodernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=\nmodernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=\nmodernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=\nmodernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=\nmodernc.org/sqlite v1.43.0 h1:8YqiFx3G1VhHTXO2Q00bl1Wz9KhS9Q5okwfp9Y97VnA=\nmodernc.org/sqlite v1.43.0/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8=\nmodernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=\nmodernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=\nmodernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=\nmodernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=\nrsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=\nrsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=\nrsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=\nzombiezen.com/go/sqlite v1.4.2 h1:KZXLrBuJ7tKNEm+VJcApLMeQbhmAUOKA5VWS93DfFRo=\nzombiezen.com/go/sqlite v1.4.2/go.mod h1:5Kd4taTAD4MkBzT25mQ9uaAlLjyR0rFhsR6iINO70jc=\n"
  },
  {
    "path": "internal/controller/controller.go",
    "content": "package controller\n\nimport (\n\t\"github.com/GopeedLab/gopeed/pkg/base\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n)\n\ntype Controller struct {\n\tGetConfig func(v any)\n\tGetProxy  func(requestProxy *base.RequestProxy) func(*http.Request) (*url.URL, error)\n\tFileController\n\t//ContextDialer() (proxy.Dialer, error)\n}\n\ntype FileController interface {\n\tTouch(name string, size int64) (file *os.File, err error)\n}\n\ntype DefaultFileController struct {\n}\n\nfunc NewController() *Controller {\n\treturn &Controller{\n\t\tGetConfig: func(v any) {},\n\t\tGetProxy: func(requestProxy *base.RequestProxy) func(*http.Request) (*url.URL, error) {\n\t\t\treturn requestProxy.ToHandler()\n\t\t},\n\t\tFileController: &DefaultFileController{},\n\t}\n}\n\nfunc (c *DefaultFileController) Touch(name string, size int64) (file *os.File, err error) {\n\tdir := filepath.Dir(name)\n\tif err = os.MkdirAll(dir, os.ModePerm); err != nil {\n\t\treturn\n\t}\n\tfile, err = os.Create(name)\n\tif err != nil {\n\t\treturn\n\t}\n\tif size > 0 {\n\t\terr = os.Truncate(name, size)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\treturn\n}\n\n/*func (c *DefaultController) ContextDialer() (proxy.Dialer, error) {\n\t// return proxy.SOCKS5(\"tpc\", \"127.0.0.1:9999\", nil, nil)\n\tvar dialer proxy.Dialer\n\treturn &DialerWarp{dialer: dialer}, nil\n}\n\ntype DialerWarp struct {\n\tdialer proxy.Dialer\n}\n\ntype ConnWarp struct {\n\tconn net.Conn\n}\n\nfunc (c *ConnWarp) Read(b []byte) (n int, err error) {\n\treturn c.conn.Read(b)\n}\n\nfunc (c *ConnWarp) Write(b []byte) (n int, err error) {\n\treturn c.conn.Write(b)\n}\n\nfunc (c *ConnWarp) Close() error {\n\treturn c.conn.Close()\n}\n\nfunc (c *ConnWarp) LocalAddr() net.Addr {\n\treturn c.conn.LocalAddr()\n}\n\nfunc (c *ConnWarp) RemoteAddr() net.Addr {\n\treturn c.conn.RemoteAddr()\n}\n\nfunc (c *ConnWarp) SetDeadline(t time.Time) error {\n\treturn c.conn.SetDeadline(t)\n}\n\nfunc (c *ConnWarp) SetReadDeadline(t time.Time) error {\n\treturn c.conn.SetReadDeadline(t)\n}\n\nfunc (c *ConnWarp) SetWriteDeadline(t time.Time) error {\n\treturn c.conn.SetWriteDeadline(t)\n}\n\nfunc (d *DialerWarp) Dial(network, addr string) (c net.Conn, err error) {\n\tconn, err := d.dialer.Dial(network, addr)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &ConnWarp{conn: conn}, nil\n}*/\n"
  },
  {
    "path": "internal/fetcher/fetcher.go",
    "content": "package fetcher\n\nimport (\n\t\"path\"\n\t\"strings\"\n\n\t\"github.com/GopeedLab/gopeed/internal/controller\"\n\t\"github.com/GopeedLab/gopeed/pkg/base\"\n)\n\n// Fetcher defines the interface for a download protocol.\n// Each download task will have a corresponding Fetcher instance for the management of the download task\ntype Fetcher interface {\n\tSetup(ctl *controller.Controller)\n\tResolve(req *base.Request, opts *base.Options) error\n\tStart() error\n\t// Patch modifies task-specific data based on the protocol.\n\t// For HTTP: can modify Request info (URL, headers, etc.)\n\t// For BT: can modify SelectFiles (via opts.SelectFiles)\n\tPatch(req *base.Request, opts *base.Options) error\n\tPause() error\n\tClose() error\n\n\t// Stats refreshes health statistics and returns the latest information\n\tStats() any\n\t// Meta returns the meta information of the download.\n\tMeta() *FetcherMeta\n\t// Progress returns the progress of the download.\n\tProgress() Progress\n\t// Wait for the download to complete, this method will block until the download is done.\n\tWait() error\n}\n\ntype Uploader interface {\n\tUpload() error\n\tUploadedBytes() int64\n\tWaitUpload() error\n}\n\n// FetcherMeta defines the meta information of a fetcher.\ntype FetcherMeta struct {\n\tReq  *base.Request  `json:\"req\"`\n\tRes  *base.Resource `json:\"res\"`\n\tOpts *base.Options  `json:\"opts\"`\n}\n\n// FolderPath return the folder path of the meta info.\nfunc (m *FetcherMeta) FolderPath() string {\n\t// check if rename folder\n\tfolder := m.Res.Name\n\tif m.Opts.Name != \"\" {\n\t\tfolder = m.Opts.Name\n\t}\n\treturn path.Join(m.Opts.Path, folder)\n}\n\n// SingleFilepath return the single file path of the meta info.\nfunc (m *FetcherMeta) SingleFilepath() string {\n\t// check if rename file\n\tfile := m.Res.Files[0]\n\tfileName := file.Name\n\tif m.Opts.Name != \"\" {\n\t\tfileName = m.Opts.Name\n\t}\n\treturn path.Join(m.Opts.Path, file.Path, fileName)\n}\n\n// RootDirPath return the root dir path of the task file.\nfunc (m *FetcherMeta) RootDirPath() string {\n\tif m.Res.Name != \"\" {\n\t\treturn m.FolderPath()\n\t} else {\n\t\treturn m.Opts.Path\n\t}\n}\n\ntype FilterType int\n\nconst (\n\t// FilterTypeUrl url type, pattern is the scheme, e.g. http://github.com -> http\n\tFilterTypeUrl FilterType = iota\n\t// FilterTypeFile file type, pattern is the file extension name, e.g. test.torrent -> torrent\n\tFilterTypeFile\n\t// FilterTypeBase64 base64 data type, pattern is the data mime type, e.g. data:application/x-bittorrent;base64 -> application/x-bittorrent\n\tFilterTypeBase64\n)\n\ntype SchemeFilter struct {\n\tType    FilterType\n\tPattern string\n}\n\nfunc (s *SchemeFilter) Match(uri string) bool {\n\turiUpper := strings.ToUpper(uri)\n\tpatternUpper := strings.ToUpper(s.Pattern)\n\tswitch s.Type {\n\tcase FilterTypeUrl:\n\t\treturn strings.HasPrefix(uriUpper, patternUpper+\":\")\n\tcase FilterTypeFile:\n\t\treturn strings.HasSuffix(uriUpper, \".\"+patternUpper)\n\tcase FilterTypeBase64:\n\t\treturn strings.HasPrefix(uriUpper, \"DATA:\"+patternUpper+\";BASE64,\")\n\t}\n\treturn false\n}\n\n// FetcherManager manage and control the fetcher\ntype FetcherManager interface {\n\t// Name return the name of the protocol.\n\tName() string\n\t// Filters registers the supported schemes.\n\tFilters() []*SchemeFilter\n\t// Build returns a new fetcher.\n\tBuild() Fetcher\n\t// ParseName name displayed when the task is not yet resolved, parsed from the request URL\n\tParseName(u string) string\n\t// AutoRename returns whether the fetcher need renaming the download file when has the same name file.\n\tAutoRename() bool\n\n\t// DefaultConfig returns the default configuration of the protocol.\n\tDefaultConfig() any\n\t// Store fetcher\n\tStore(fetcher Fetcher) (any, error)\n\t// Restore fetcher\n\tRestore() (v any, f func(meta *FetcherMeta, v any) Fetcher)\n\t// Close the fetcher manager, release resources.\n\tClose() error\n}\n\n// StatefulFetcherManager is an optional extension for protocols that keep\n// shared client state outside individual task fetchers.\ntype StatefulFetcherManager interface {\n\tSetStateStore(store ProtocolStateStore)\n}\n\n// ProtocolStateStore persists shared protocol state for a fetcher manager.\n// Downloader provides the concrete storage backend, while the protocol decides\n// when state should be loaded or flushed.\ntype ProtocolStateStore interface {\n\tLoad(v any) (bool, error)\n\tSave(v any) error\n\tDelete() error\n}\n\ntype DefaultFetcher struct {\n\tCtl    *controller.Controller\n\tMeta   *FetcherMeta\n\tDoneCh chan error\n}\n\nfunc (f *DefaultFetcher) Setup(ctl *controller.Controller) (err error) {\n\tf.Ctl = ctl\n\tf.DoneCh = make(chan error, 1)\n\treturn\n}\n\nfunc (f *DefaultFetcher) Wait() (err error) {\n\treturn <-f.DoneCh\n}\n\n// Progress is a map of the progress of each file in the torrent.\ntype Progress []int64\n\n// TotalDownloaded returns the total downloaded bytes.\nfunc (p Progress) TotalDownloaded() int64 {\n\ttotal := int64(0)\n\tfor _, d := range p {\n\t\ttotal += d\n\t}\n\treturn total\n}\n"
  },
  {
    "path": "internal/fetcher/fetcher_test.go",
    "content": "package fetcher\n\nimport \"testing\"\n\nfunc TestSchemeFilter_Match(t *testing.T) {\n\ttype fields struct {\n\t\tType    FilterType\n\t\tPattern string\n\t}\n\ttype args struct {\n\t\turi string\n\t}\n\ttests := []struct {\n\t\tname   string\n\t\tfields fields\n\t\targs   args\n\t\twant   bool\n\t}{\n\t\t{\n\t\t\tname: \"url match\",\n\t\t\tfields: fields{\n\t\t\t\tType:    FilterTypeUrl,\n\t\t\t\tPattern: \"https\",\n\t\t\t},\n\t\t\targs: args{\n\t\t\t\turi: \"https://github.com\",\n\t\t\t},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tname: \"url not match\",\n\t\t\tfields: fields{\n\t\t\t\tType:    FilterTypeUrl,\n\t\t\t\tPattern: \"https\",\n\t\t\t},\n\t\t\targs: args{\n\t\t\t\turi: \"ftp://github.com\",\n\t\t\t},\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tname: \"file match\",\n\t\t\tfields: fields{\n\t\t\t\tType:    FilterTypeFile,\n\t\t\t\tPattern: \"torrent\",\n\t\t\t},\n\t\t\targs: args{\n\t\t\t\turi: \"d:/temp/test.torrent\",\n\t\t\t},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tname: \"file not match\",\n\t\t\tfields: fields{\n\t\t\t\tType:    FilterTypeFile,\n\t\t\t\tPattern: \"torrent\",\n\t\t\t},\n\t\t\targs: args{\n\t\t\t\turi: \"d:/temp/test.txt\",\n\t\t\t},\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tname: \"base64 match\",\n\t\t\tfields: fields{\n\t\t\t\tType:    FilterTypeBase64,\n\t\t\t\tPattern: \"application/x-bittorrent\",\n\t\t\t},\n\t\t\targs: args{\n\t\t\t\turi: \"data:application/x-bittorrent;base64,xxx\",\n\t\t\t},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tname: \"base64 not match\",\n\t\t\tfields: fields{\n\t\t\t\tType:    FilterTypeBase64,\n\t\t\t\tPattern: \"application/x-bittorrent\",\n\t\t\t},\n\t\t\targs: args{\n\t\t\t\turi: \"data:application/javascript;base64,xxx\",\n\t\t\t},\n\t\t\twant: false,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\ts := &SchemeFilter{\n\t\t\t\tType:    tt.fields.Type,\n\t\t\t\tPattern: tt.fields.Pattern,\n\t\t\t}\n\t\t\tif got := s.Match(tt.args.uri); got != tt.want {\n\t\t\t\tt.Errorf(\"Match() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/logger/logger.go",
    "content": "package logger\n\nimport (\n\t\"github.com/GopeedLab/gopeed/pkg/util\"\n\t\"github.com/rs/zerolog\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n)\n\ntype Logger struct {\n\tzerolog.Logger\n\tlogFile *os.File\n}\n\nfunc (l *Logger) CLose() {\n\tl.logFile.Close()\n}\n\n// NewLogger create a new logger\nfunc NewLogger(logFile bool, logPath string) *Logger {\n\tvar out io.Writer\n\tif logFile {\n\t\t// log to file\n\t\tlogDir := filepath.Dir(logPath)\n\t\tif err := util.CreateDirIfNotExist(logDir); err != nil {\n\t\t\tpanic(err)\n\t\t}\n\t\tvar (\n\t\t\tlogfile *os.File\n\t\t\terr     error\n\t\t)\n\t\tlogfile, err = os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)\n\t\tif err != nil {\n\t\t\tpanic(err)\n\t\t}\n\t\tout = logfile\n\t} else {\n\t\tout = os.Stdout\n\t}\n\n\tlogger := &Logger{}\n\tif logFile {\n\t\tlogger.logFile = out.(*os.File)\n\t}\n\tlogger.Logger = zerolog.New(zerolog.ConsoleWriter{\n\t\tNoColor:    true,\n\t\tOut:        out,\n\t\tTimeFormat: \"2006-01-02 15:04:05\",\n\t}).With().Timestamp().Logger()\n\treturn logger\n}\n"
  },
  {
    "path": "internal/logger/logger_test.go",
    "content": "package logger\n\nimport (\n\t\"os\"\n\t\"testing\"\n)\n\nfunc TestNewLogger(t *testing.T) {\n\tlogger := NewLogger(false, \"\")\n\tlogger.Info().Msg(\"test\")\n}\n\nfunc TestNewLoggerFile(t *testing.T) {\n\tlogPath := \"./testdata/test.log\"\n\tlogger := NewLogger(true, logPath)\n\tdefer func() {\n\t\tlogger.CLose()\n\t\tos.Remove(logPath)\n\t}()\n\tlogger.Info().Msg(\"test\")\n\n\t// read log file, if not exist or empty, test fail.\n\tfile, err := os.Open(logPath)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer file.Close()\n\tstat, err := file.Stat()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif stat.Size() == 0 {\n\t\tt.Fatal(\"log file is empty\")\n\t}\n}\n"
  },
  {
    "path": "internal/protocol/bt/config.go",
    "content": "package bt\n\ntype config struct {\n\tListenPort int      `json:\"listenPort\"`\n\tTrackers   []string `json:\"trackers\"`\n\t// SeedKeep is always keep seeding after downloading is complete, unless manually stopped.\n\tSeedKeep bool `json:\"seedKeep\"`\n\t// SeedRatio is the ratio of uploaded data to downloaded data to seed.\n\tSeedRatio float64 `json:\"seedRatio\"`\n\t// SeedTime is the time in seconds to seed after downloading is complete.\n\tSeedTime int64 `json:\"seedTime\"`\n}\n"
  },
  {
    "path": "internal/protocol/bt/dns_cache_resolver.go",
    "content": "package bt\n\nimport (\n\t\"context\"\n\t\"net\"\n\t\"time\"\n\n\t\"github.com/rs/dnscache\"\n)\n\n// DnsCacheResolver resolves DNS requests for an HTTP client using an in-memory cache.\ntype DnsCacheResolver struct {\n\tRefreshTimeout time.Duration\n\n\tresolver dnscache.Resolver\n}\n\nfunc (r *DnsCacheResolver) DialContext(ctx context.Context, network, address string) (net.Conn, error) {\n\thost, port, err := net.SplitHostPort(address)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tips, err := r.resolver.LookupHost(ctx, host)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar conn net.Conn\n\tfor _, ip := range ips {\n\t\tvar dialer net.Dialer\n\t\tconn, err = dialer.DialContext(ctx, network, net.JoinHostPort(ip, port))\n\t\tif err == nil {\n\t\t\tbreak\n\t\t}\n\t}\n\treturn conn, err\n}\n\nfunc (r *DnsCacheResolver) Run(ctx context.Context) {\n\tticker := time.NewTicker(r.RefreshTimeout)\n\tdefer ticker.Stop()\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase <-ticker.C:\n\t\t\tr.resolver.Refresh(true)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/protocol/bt/fetcher.go",
    "content": "package bt\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/GopeedLab/gopeed/internal/controller\"\n\t\"github.com/GopeedLab/gopeed/internal/fetcher\"\n\t\"github.com/GopeedLab/gopeed/pkg/base\"\n\t\"github.com/GopeedLab/gopeed/pkg/protocol/bt\"\n\t\"github.com/GopeedLab/gopeed/pkg/util\"\n\t\"github.com/anacrolix/torrent\"\n\t\"github.com/anacrolix/torrent/metainfo\"\n\t\"github.com/anacrolix/torrent/storage\"\n)\n\nvar (\n\tcfg       *torrent.ClientConfig\n\tclient    *torrent.Client\n\tlock      sync.Mutex\n\tcloseCtx  context.Context\n\tcloseFunc func()\n)\n\ntype Fetcher struct {\n\tctl    *controller.Controller\n\tconfig *config\n\n\ttorrent *torrent.Torrent\n\tmeta    *fetcher.FetcherMeta\n\tdata    *fetcherData\n\n\ttorrentReady    atomic.Bool\n\ttorrentUpload   atomic.Bool\n\ttorrentDropCtx  context.Context\n\ttorrentDropFunc func()\n\tuploadDoneCh    chan any\n}\n\nfunc (f *Fetcher) Setup(ctl *controller.Controller) {\n\tf.ctl = ctl\n\tif f.meta == nil {\n\t\tf.meta = &fetcher.FetcherMeta{}\n\t}\n\tif f.data == nil {\n\t\tf.data = &fetcherData{}\n\t}\n\tf.uploadDoneCh = make(chan any, 1)\n\tf.torrentDropCtx, f.torrentDropFunc = context.WithCancel(context.Background())\n\tf.ctl.GetConfig(&f.config)\n\treturn\n}\n\nfunc (f *Fetcher) initClient() (err error) {\n\tlock.Lock()\n\tdefer lock.Unlock()\n\n\tif client != nil {\n\t\treturn\n\t}\n\tif closeCtx == nil {\n\t\tcloseCtx, closeFunc = context.WithCancel(context.Background())\n\t}\n\n\tcfg = torrent.NewDefaultClientConfig()\n\tcfg.Seed = true\n\tcfg.Bep20 = fmt.Sprintf(\"-GP%s-\", parseBep20())\n\tcfg.ExtendedHandshakeClientVersion = fmt.Sprintf(\"Gopeed %s\", base.Version)\n\tcfg.ListenPort = f.config.ListenPort\n\tcfg.HTTPProxy = f.ctl.GetProxy(f.meta.Req.Proxy)\n\tdnsResolver := &DnsCacheResolver{RefreshTimeout: 5 * time.Minute}\n\tcfg.TrackerDialContext = dnsResolver.DialContext\n\tclient, err = torrent.NewClient(cfg)\n\tif err != nil {\n\t\treturn\n\t}\n\n\tcloseCtx, closeFunc = context.WithCancel(context.Background())\n\tgo func() {\n\t\tdnsResolver.Run(closeCtx)\n\t}()\n\treturn\n}\n\nfunc (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {\n\tf.meta.Req = req\n\tf.meta.Opts = opts\n\tif f.meta.Opts == nil {\n\t\tf.meta.Opts = &base.Options{}\n\t}\n\tif err := f.addTorrent(req, false); err != nil {\n\t\treturn err\n\t}\n\tf.updateRes()\n\treturn nil\n}\n\nfunc (f *Fetcher) Start() (err error) {\n\tif !f.torrentReady.Load() {\n\t\tif err = f.addTorrent(f.meta.Req, false); err != nil {\n\t\t\treturn\n\t\t}\n\t}\n\n\tfiles := f.torrent.Files()\n\t// If the user does not specify the file to download, all files will be downloaded by default\n\tif f.data.Progress == nil {\n\t\tif len(f.meta.Opts.SelectFiles) == 0 {\n\t\t\tf.meta.Opts.SelectFiles = make([]int, len(files))\n\t\t\tfor i := range files {\n\t\t\t\tf.meta.Opts.SelectFiles[i] = i\n\t\t\t}\n\t\t}\n\t\tf.data.Progress = make(fetcher.Progress, len(f.meta.Opts.SelectFiles))\n\t}\n\tif len(f.meta.Opts.SelectFiles) == len(files) {\n\t\tf.torrent.DownloadAll()\n\t} else {\n\t\tfor _, selectIndex := range f.meta.Opts.SelectFiles {\n\t\t\tfile := files[selectIndex]\n\t\t\tfile.Download()\n\t\t}\n\t}\n\tf.torrent.AllowDataDownload()\n\treturn\n}\n\nfunc (f *Fetcher) Pause() (err error) {\n\tf.torrent.DisallowDataDownload()\n\treturn\n}\n\nfunc (f *Fetcher) Close() (err error) {\n\tf.safeDrop()\n\tf.torrentDropFunc()\n\tf.uploadDoneCh <- nil\n\tif len(client.Torrents()) == 0 {\n\t\terr = closeClient()\n\t}\n\treturn nil\n}\n\nfunc (f *Fetcher) safeDrop() {\n\tdefer func() {\n\t\t// ignore panic\n\t\t_ = recover()\n\t}()\n\n\tf.torrent.Drop()\n}\n\nfunc (f *Fetcher) Meta() *fetcher.FetcherMeta {\n\treturn f.meta\n}\n\nfunc (f *Fetcher) Stats() any {\n\tvar stats torrent.TorrentStats\n\tif f.torrent != nil {\n\t\tstats = f.torrent.Stats()\n\t} else {\n\t\tstats = torrent.TorrentStats{}\n\t}\n\treturn &bt.Stats{\n\t\tTotalPeers:       stats.TotalPeers,\n\t\tActivePeers:      stats.ActivePeers,\n\t\tConnectedSeeders: stats.ConnectedSeeders,\n\t\tSeedBytes:        f.data.SeedBytes,\n\t\tSeedRatio:        f.seedRadio(),\n\t\tSeedTime:         f.data.SeedTime,\n\t}\n}\n\nfunc (f *Fetcher) Progress() fetcher.Progress {\n\tif !f.torrentReady.Load() {\n\t\treturn f.data.Progress\n\t}\n\tfor i := range f.data.Progress {\n\t\tselectIndex := f.meta.Opts.SelectFiles[i]\n\t\tfile := f.torrent.Files()[selectIndex]\n\t\tf.data.Progress[i] = file.BytesCompleted()\n\t}\n\treturn f.data.Progress\n}\n\nfunc (f *Fetcher) Wait() (err error) {\n\tfor {\n\t\tselect {\n\t\tcase <-f.torrentDropCtx.Done():\n\t\t\treturn\n\t\tcase <-time.After(time.Second):\n\t\t\tif f.torrentReady.Load() && len(f.meta.Opts.SelectFiles) > 0 {\n\t\t\t\tif f.isDone() {\n\t\t\t\t\t// remove unselected files\n\t\t\t\t\tfor i, file := range f.torrent.Files() {\n\t\t\t\t\t\tselected := false\n\t\t\t\t\t\tfor _, selectIndex := range f.meta.Opts.SelectFiles {\n\t\t\t\t\t\t\tif i == selectIndex {\n\t\t\t\t\t\t\t\tselected = true\n\t\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif !selected {\n\t\t\t\t\t\t\tutil.SafeRemove(filepath.Join(f.meta.Opts.Path, f.meta.Res.Name, file.Path()))\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (f *Fetcher) isDone() bool {\n\tif f.meta.Opts == nil {\n\t\treturn false\n\t}\n\tfor _, selectIndex := range f.meta.Opts.SelectFiles {\n\t\tfile := f.torrent.Files()[selectIndex]\n\t\tif file.BytesCompleted() < file.Length() {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// Patch modifies the BT task settings.\n// Invalid file indices are silently ignored.\nfunc (f *Fetcher) Patch(req *base.Request, opts *base.Options) error {\n\tif opts == nil {\n\t\treturn nil\n\t}\n\n\tif opts.SelectFiles != nil {\n\t\tselectFiles := opts.SelectFiles\n\n\t\t// Get file count from resource metadata\n\t\tfileCount := 0\n\t\tif f.meta.Res != nil {\n\t\t\tfileCount = len(f.meta.Res.Files)\n\t\t}\n\n\t\t// Filter out invalid indices (silently ignore)\n\t\tvalidSelectFiles := make([]int, 0, len(selectFiles))\n\t\tfor _, idx := range selectFiles {\n\t\t\tif idx >= 0 && idx < fileCount {\n\t\t\t\tvalidSelectFiles = append(validSelectFiles, idx)\n\t\t\t}\n\t\t}\n\n\t\tif f.torrent != nil {\n\t\t\tfiles := f.torrent.Files()\n\n\t\t\t// Cancel all current file downloads first\n\t\t\tf.torrent.CancelPieces(0, f.torrent.NumPieces())\n\n\t\t\t// Apply new file selection\n\t\t\tif len(validSelectFiles) == len(files) {\n\t\t\t\tf.torrent.DownloadAll()\n\t\t\t} else {\n\t\t\t\tfor _, selectIndex := range validSelectFiles {\n\t\t\t\t\tfile := files[selectIndex]\n\t\t\t\t\tfile.Download()\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tf.meta.Opts.SelectFiles = validSelectFiles\n\t\t// Recalculate the resource size based on new selection\n\t\tif f.meta.Res != nil {\n\t\t\tf.meta.Res.CalcSize(validSelectFiles)\n\t\t}\n\t\t// Reset progress tracking for new file selection\n\t\tf.data.Progress = make(fetcher.Progress, len(validSelectFiles))\n\t}\n\n\treturn nil\n}\n\nfunc (f *Fetcher) updateRes() {\n\tres := &base.Resource{\n\t\tRange: true,\n\t\tFiles: make([]*base.FileInfo, len(f.torrent.Files())),\n\t\tHash:  f.torrent.InfoHash().String(),\n\t}\n\t// Directory torrent\n\tif f.torrent.Info().Length == 0 {\n\t\tres.Name = f.torrent.Name()\n\t}\n\tfor i, file := range f.torrent.Files() {\n\t\tres.Files[i] = &base.FileInfo{\n\t\t\tName: filepath.Base(file.DisplayPath()),\n\t\t\tPath: util.Dir(file.DisplayPath()),\n\t\t\tSize: file.Length(),\n\t\t}\n\t}\n\tres.CalcSize(nil)\n\tf.meta.Res = res\n\tif f.meta.Opts != nil {\n\t\tf.meta.Opts.InitSelectFiles(len(res.Files))\n\t}\n}\n\nfunc (f *Fetcher) Upload() (err error) {\n\treturn f.addTorrent(f.meta.Req, true)\n}\n\nfunc (f *Fetcher) doUpload(fromUpload bool) {\n\tif !f.torrentUpload.CompareAndSwap(false, true) {\n\t\treturn\n\t}\n\n\t// Check and update seed data\n\tlastData := &fetcherData{\n\t\tSeedBytes: f.data.SeedBytes,\n\t\tSeedTime:  f.data.SeedTime,\n\t}\n\tvar doneTime int64 = 0\n\tfor {\n\t\tselect {\n\t\tcase <-f.torrentDropCtx.Done():\n\t\t\treturn\n\t\tcase <-time.After(time.Second):\n\t\t\tif !f.torrentReady.Load() {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tstats := f.torrentStats()\n\t\t\tf.data.SeedBytes = lastData.SeedBytes + stats.BytesWrittenData.Int64()\n\n\t\t\t// Check is download complete, if not don't check and stop seeding\n\t\t\tif !fromUpload && !f.isDone() {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif doneTime == 0 {\n\t\t\t\tdoneTime = time.Now().Unix()\n\t\t\t}\n\t\t\tf.data.SeedTime = lastData.SeedTime + time.Now().Unix() - doneTime\n\n\t\t\t// If the seed forever is true, keep seeding\n\t\t\tif f.config.SeedKeep {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// If the seed ratio is reached, stop seeding\n\t\t\tif f.config.SeedRatio > 0 {\n\t\t\t\tseedRadio := f.seedRadio()\n\t\t\t\tif seedRadio >= f.config.SeedRatio {\n\t\t\t\t\tf.Close()\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// If the seed time is reached, stop seeding\n\t\t\tif f.config.SeedTime > 0 {\n\t\t\t\tif f.data.SeedTime >= f.config.SeedTime {\n\t\t\t\t\tf.Close()\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\n// Get torrent stats maybe panic, see https://github.com/anacrolix/torrent/issues/972\nfunc (f *Fetcher) torrentStats() torrent.TorrentStats {\n\tdefer func() {\n\t\tif r := recover(); r != nil {\n\t\t\t// ignore panic\n\t\t}\n\t}()\n\n\treturn f.torrent.Stats()\n}\n\nfunc (f *Fetcher) UploadedBytes() int64 {\n\treturn f.data.SeedBytes\n}\n\nfunc (f *Fetcher) WaitUpload() (err error) {\n\t<-f.uploadDoneCh\n\treturn nil\n}\n\nfunc (f *Fetcher) addTorrent(req *base.Request, fromUpload bool) (err error) {\n\tif err = base.ParseReqExtra[bt.ReqExtra](req); err != nil {\n\t\treturn\n\t}\n\tif err = f.initClient(); err != nil {\n\t\treturn\n\t}\n\tschema := util.ParseSchema(req.URL)\n\tprivateTorrent := false\n\tvar spec *torrent.TorrentSpec\n\tif schema == \"MAGNET\" {\n\t\tspec, err = torrent.TorrentSpecFromMagnetUri(req.URL)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tvar reader io.Reader\n\t\tif schema == \"FILE\" {\n\t\t\tfileUrl, _ := url.Parse(req.URL)\n\t\t\tfilePath := fileUrl.Path[1:]\n\t\t\treader, err = os.Open(filePath)\n\t\t\tif err != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t} else if schema == \"DATA\" {\n\t\t\t_, data := util.ParseDataUri(req.URL)\n\t\t\treader = bytes.NewBuffer(data)\n\t\t} else {\n\t\t\treader, err = os.Open(req.URL)\n\t\t\tif err != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer reader.(io.Closer).Close()\n\t\t}\n\n\t\tvar metaInfo *metainfo.MetaInfo\n\t\tmetaInfo, err = metainfo.Load(reader)\n\t\t// Hotfix for https://github.com/anacrolix/torrent/issues/992, ignore \"expected EOF\" error\n\t\t// TODO remove this after the issue is fixed\n\t\tif err != nil && !strings.Contains(err.Error(), \"expected EOF\") {\n\t\t\treturn err\n\t\t}\n\n\t\tinfo, er := metaInfo.UnmarshalInfo()\n\t\tif er != nil {\n\t\t\treturn er\n\t\t}\n\n\t\tif info.Private != nil && *info.Private {\n\t\t\tprivateTorrent = true\n\t\t}\n\t\tspec, err = torrent.TorrentSpecFromMetaInfoErr(metaInfo)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t}\n\tspec.Storage = storage.NewFileOpts(storage.NewFileClientOpts{\n\t\tClientBaseDir: cfg.DataDir,\n\t\tTorrentDirMaker: func(baseDir string, info *metainfo.Info, infoHash metainfo.Hash) string {\n\t\t\treturn f.meta.Opts.Path\n\t\t},\n\t})\n\tf.torrent, _, err = client.AddTorrentSpec(spec)\n\tif err != nil {\n\t\treturn\n\t}\n\n\t// Do not add external tracker to a private torrent.\n\tif !privateTorrent {\n\t\t// use map to deduplicate\n\t\ttrackers := make(map[string]bool)\n\t\tif req.Extra != nil {\n\t\t\textra := req.Extra.(*bt.ReqExtra)\n\t\t\tif len(extra.Trackers) > 0 {\n\t\t\t\tfor _, tracker := range extra.Trackers {\n\t\t\t\t\ttrackers[tracker] = true\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif len(f.config.Trackers) > 0 {\n\t\t\tfor _, tracker := range f.config.Trackers {\n\t\t\t\ttrackers[tracker] = true\n\t\t\t}\n\t\t}\n\t\tif len(trackers) > 0 {\n\t\t\tannounceList := make([][]string, 0)\n\t\t\tfor tracker := range trackers {\n\t\t\t\tannounceList = append(announceList, []string{tracker})\n\t\t\t}\n\t\t\tf.torrent.AddTrackers(announceList)\n\t\t}\n\t}\n\t<-f.torrent.GotInfo()\n\tf.torrentReady.Store(true)\n\n\tgo f.doUpload(fromUpload)\n\treturn\n}\n\nfunc (f *Fetcher) seedRadio() float64 {\n\tvar bytesRead int64\n\tif f.Meta().Res != nil {\n\t\tbytesRead = f.Meta().Res.Size\n\t} else {\n\t\tbytesRead = 0\n\t}\n\tif bytesRead <= 0 {\n\t\treturn 0\n\t}\n\n\treturn float64(f.data.SeedBytes) / float64(bytesRead)\n}\n\ntype fetcherData struct {\n\tProgress  fetcher.Progress\n\tSeedBytes int64\n\t// SeedTime is the time in seconds to seed after downloading is complete.\n\tSeedTime int64\n}\n\nfunc closeClient() error {\n\tlock.Lock()\n\tdefer lock.Unlock()\n\n\tif closeFunc != nil {\n\t\tcloseFunc()\n\t}\n\tif client != nil {\n\t\terrs := client.Close()\n\t\tif len(errs) > 0 {\n\t\t\treturn errs[0]\n\t\t}\n\t\tclient = nil\n\t\tcloseCtx = nil\n\t\tcloseFunc = nil\n\t}\n\treturn nil\n}\n\ntype FetcherManager struct {\n}\n\nfunc (fm *FetcherManager) Name() string {\n\treturn \"bt\"\n}\n\nfunc (fm *FetcherManager) Filters() []*fetcher.SchemeFilter {\n\treturn []*fetcher.SchemeFilter{\n\t\t{\n\t\t\tType:    fetcher.FilterTypeUrl,\n\t\t\tPattern: \"MAGNET\",\n\t\t},\n\t\t{\n\t\t\tType:    fetcher.FilterTypeFile,\n\t\t\tPattern: \"TORRENT\",\n\t\t},\n\t\t{\n\t\t\tType:    fetcher.FilterTypeBase64,\n\t\t\tPattern: \"APPLICATION/X-BITTORRENT\",\n\t\t},\n\t}\n}\n\nfunc (fm *FetcherManager) Build() fetcher.Fetcher {\n\treturn &Fetcher{}\n}\n\nfunc (fm *FetcherManager) ParseName(u string) string {\n\tvar name string\n\turl, err := url.Parse(u)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\n\tparams := url.Query()\n\tif params.Get(\"dn\") != \"\" {\n\t\treturn params.Get(\"dn\")\n\t}\n\tif params.Get(\"xt\") != \"\" {\n\t\txt := strings.Split(params.Get(\"xt\"), \":\")\n\t\treturn xt[len(xt)-1]\n\t}\n\treturn name\n}\n\nfunc (fm *FetcherManager) AutoRename() bool {\n\treturn false\n}\n\nfunc (fm *FetcherManager) DefaultConfig() any {\n\treturn &config{\n\t\tListenPort: 0,\n\t\tTrackers:   []string{},\n\t\tSeedKeep:   false,\n\t\tSeedRatio:  1.0,\n\t\tSeedTime:   120 * 60,\n\t}\n}\n\nfunc (fm *FetcherManager) Store(f fetcher.Fetcher) (data any, err error) {\n\t_f := f.(*Fetcher)\n\treturn _f.data, nil\n}\n\nfunc (fm *FetcherManager) Restore() (v any, f func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher) {\n\treturn &fetcherData{}, func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher {\n\t\treturn &Fetcher{\n\t\t\tmeta: meta,\n\t\t\tdata: v.(*fetcherData),\n\t\t}\n\t}\n}\n\nfunc (fm *FetcherManager) Close() error {\n\treturn closeClient()\n}\n\n// parse version to bep20 format, fixed length 4, if not enough, fill 0\nfunc parseBep20() string {\n\ts := strings.ReplaceAll(base.Version, \".\", \"\")\n\tif len(s) < 4 {\n\t\ts += strings.Repeat(\"0\", 4-len(s))\n\t}\n\treturn s\n}\n"
  },
  {
    "path": "internal/protocol/bt/fetcher_test.go",
    "content": "package bt\n\nimport (\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\tgohttp \"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"testing\"\n\n\t\"github.com/GopeedLab/gopeed/internal/controller\"\n\t\"github.com/GopeedLab/gopeed/internal/fetcher\"\n\t\"github.com/GopeedLab/gopeed/internal/test\"\n\t\"github.com/GopeedLab/gopeed/pkg/base\"\n\t\"github.com/GopeedLab/gopeed/pkg/protocol/bt\"\n)\n\nfunc TestFetcher_Resolve_Torrent(t *testing.T) {\n\tdoResolve(t, buildFetcher())\n}\n\nfunc TestFetcher_Resolve_DataUri_Torrent(t *testing.T) {\n\tfetcher := buildFetcher()\n\tbuf, err := os.ReadFile(\"./testdata/ubuntu-22.04-live-server-amd64.iso.torrent\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// convert to data uri\n\tdataUri := \"data:application/x-bittorrent;base64,\" + base64.StdEncoding.EncodeToString(buf)\n\terr = fetcher.Resolve(&base.Request{\n\t\tURL: dataUri,\n\t}, nil)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\twant := &base.Resource{\n\t\tSize:  1466714112,\n\t\tRange: true,\n\t\tFiles: []*base.FileInfo{\n\t\t\t{\n\t\t\t\tName: \"ubuntu-22.04-live-server-amd64.iso\",\n\t\t\t\tSize: 1466714112,\n\t\t\t},\n\t\t},\n\t\tHash: \"8a55cfbd5ca5d11507364765936c4f9e55b253ed\",\n\t}\n\tif !reflect.DeepEqual(want, fetcher.Meta().Res) {\n\t\tt.Errorf(\"Resolve() got = %v, want %v\", fetcher.Meta().Res, want)\n\t}\n}\n\nfunc TestFetcher_Config(t *testing.T) {\n\tdoResolve(t, buildConfigFetcher(nil))\n}\n\nfunc TestFetcher_ResolveWithProxy(t *testing.T) {\n\tusr, pwd := \"admin\", \"123\"\n\tproxyListener := test.StartSocks5Server(usr, pwd)\n\tdefer proxyListener.Close()\n\n\tdoResolve(t, buildConfigFetcher(&base.DownloaderProxyConfig{\n\t\tEnable: true,\n\t\tSystem: false,\n\t\tScheme: \"socks5\",\n\t\tHost:   proxyListener.Addr().String(),\n\t\tUsr:    usr,\n\t\tPwd:    pwd,\n\t}))\n}\n\nfunc doResolve(t *testing.T, fetcher fetcher.Fetcher) {\n\tt.Run(\"Resolve Single File\", func(t *testing.T) {\n\t\terr := fetcher.Resolve(&base.Request{\n\t\t\tURL: \"./testdata/ubuntu-22.04-live-server-amd64.iso.torrent\",\n\t\t\tExtra: bt.ReqExtra{\n\t\t\t\tTrackers: []string{\n\t\t\t\t\t\"udp://tracker.birkenwald.de:6969/announce\",\n\t\t\t\t\t\"udp://tracker.bitsearch.to:1337/announce\",\n\t\t\t\t},\n\t\t\t},\n\t\t}, nil)\n\t\tif err != nil {\n\t\t\tpanic(err)\n\t\t}\n\n\t\twant := &base.Resource{\n\t\t\tSize:  1466714112,\n\t\t\tRange: true,\n\t\t\tFiles: []*base.FileInfo{\n\t\t\t\t{\n\t\t\t\t\tName: \"ubuntu-22.04-live-server-amd64.iso\",\n\t\t\t\t\tSize: 1466714112,\n\t\t\t\t},\n\t\t\t},\n\t\t\tHash: \"8a55cfbd5ca5d11507364765936c4f9e55b253ed\",\n\t\t}\n\t\tif !reflect.DeepEqual(want, fetcher.Meta().Res) {\n\t\t\tt.Errorf(\"Resolve Single File Resolve() got = %v, want %v\", fetcher.Meta().Res, want)\n\t\t}\n\t})\n\n\tt.Run(\"Resolve Multi Files\", func(t *testing.T) {\n\t\terr := fetcher.Resolve(&base.Request{\n\t\t\tURL: \"./testdata/test.torrent\",\n\t\t\tExtra: bt.ReqExtra{\n\t\t\t\tTrackers: []string{\n\t\t\t\t\t\"udp://tracker.birkenwald.de:6969/announce\",\n\t\t\t\t\t\"udp://tracker.bitsearch.to:1337/announce\",\n\t\t\t\t},\n\t\t\t},\n\t\t}, nil)\n\t\tif err != nil {\n\t\t\tpanic(err)\n\t\t}\n\n\t\twant := &base.Resource{\n\t\t\tName:  \"test\",\n\t\t\tSize:  107484864,\n\t\t\tRange: true,\n\t\t\tFiles: []*base.FileInfo{\n\t\t\t\t{\n\t\t\t\t\tName: \"c.txt\",\n\t\t\t\t\tPath: \"path\",\n\t\t\t\t\tSize: 98501754,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tName: \"b.txt\",\n\t\t\t\t\tSize: 8904996,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tName: \"a.txt\",\n\t\t\t\t\tSize: 78114,\n\t\t\t\t},\n\t\t\t},\n\t\t\tHash: \"ccbc92b0cd8deec16a2ef4be242a8c9243b1cedb\",\n\t\t}\n\t\tif !reflect.DeepEqual(want, fetcher.Meta().Res) {\n\t\t\tt.Errorf(\"Resolve Multi Files Resolve() got = %v, want %v\", fetcher.Meta().Res, want)\n\t\t}\n\t})\n\n\tt.Run(\"Resolve Unclean Torrent\", func(t *testing.T) {\n\t\terr := fetcher.Resolve(&base.Request{\n\t\t\tURL: \"./testdata/test.unclean.torrent\",\n\t\t}, nil)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Resolve Unclean Torrent Resolve() got = %v, want nil\", err)\n\t\t}\n\t})\n\n\tt.Run(\"Resolve file scheme Torrent\", func(t *testing.T) {\n\t\tfile, _ := filepath.Abs(\"./testdata/test.unclean.torrent\")\n\t\turi := \"file:///\" + file\n\t\terr := fetcher.Resolve(&base.Request{\n\t\t\tURL: uri,\n\t\t}, nil)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Resolve file scheme Torrent Resolve() got = %v, want nil\", err)\n\t\t}\n\t})\n}\n\nfunc TestFetcherManager_ParseName(t *testing.T) {\n\ttype args struct {\n\t\tu string\n\t}\n\ttests := []struct {\n\t\tname string\n\t\targs args\n\t\twant string\n\t}{\n\t\t{\n\t\t\tname: \"broken url\",\n\t\t\targs: args{\n\t\t\t\tu: \"magnet://!@#%github.com\",\n\t\t\t},\n\t\t\twant: \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"dn\",\n\t\t\targs: args{\n\t\t\t\tu: \"magnet:?xt=urn:btih:8a55cfbd5ca5d11507364765936c4f9e55b253ed&dn=ubuntu-22.04-live-server-amd64.iso\",\n\t\t\t},\n\t\t\twant: \"ubuntu-22.04-live-server-amd64.iso\",\n\t\t},\n\t\t{\n\t\t\tname: \"no dn\",\n\t\t\targs: args{\n\t\t\t\tu: \"magnet:?xt=urn:btih:8a55cfbd5ca5d11507364765936c4f9e55b253ed\",\n\t\t\t},\n\t\t\twant: \"8a55cfbd5ca5d11507364765936c4f9e55b253ed\",\n\t\t},\n\t\t{\n\t\t\tname: \"non standard magnet\",\n\t\t\targs: args{\n\t\t\t\tu: \"magnet:?xxt=abcd\",\n\t\t\t},\n\t\t\twant: \"\",\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tfm := &FetcherManager{}\n\t\t\tif got := fm.ParseName(tt.args.u); got != tt.want {\n\t\t\t\tt.Errorf(\"ParseName() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc buildFetcher() fetcher.Fetcher {\n\tfb := new(FetcherManager)\n\tfetcher := fb.Build()\n\tnewController := controller.NewController()\n\tnewController.GetConfig = func(v any) {\n\t\tjson.Unmarshal([]byte(test.ToJson(fb.DefaultConfig())), v)\n\t}\n\tfetcher.Setup(newController)\n\treturn fetcher\n}\n\nfunc buildConfigFetcher(proxyConfig *base.DownloaderProxyConfig) fetcher.Fetcher {\n\tfetcher := new(FetcherManager).Build()\n\tnewController := controller.NewController()\n\tmockCfg := config{\n\t\tTrackers: []string{\n\t\t\t\"udp://tracker.birkenwald.de:6969/announce\",\n\t\t\t\"udp://tracker.bitsearch.to:1337/announce\",\n\t\t}}\n\tnewController.GetConfig = func(v any) {\n\t\tjson.Unmarshal([]byte(test.ToJson(mockCfg)), v)\n\t}\n\tnewController.GetProxy = func(requestProxy *base.RequestProxy) func(*gohttp.Request) (*url.URL, error) {\n\t\treturn proxyConfig.ToHandler()\n\t}\n\tfetcher.Setup(newController)\n\treturn fetcher\n}\n\n// TestFetcher_Patch tests the Patch functionality for BT fetcher.\n// It tests modifying selected files after Resolve (without downloading).\nfunc TestFetcher_Patch(t *testing.T) {\n\tf := buildFetcher()\n\n\t// Resolve a multi-file torrent\n\terr := f.Resolve(&base.Request{\n\t\tURL: \"./testdata/test.torrent\",\n\t}, nil)\n\tif err != nil {\n\t\tt.Fatalf(\"Resolve failed: %v\", err)\n\t}\n\n\t// Verify initial state: 3 files, all selected by default\n\tmeta := f.Meta()\n\tif len(meta.Res.Files) != 3 {\n\t\tt.Fatalf(\"Expected 3 files, got %d\", len(meta.Res.Files))\n\t}\n\tif len(meta.Opts.SelectFiles) != 3 {\n\t\tt.Fatalf(\"Expected 3 selected files, got %d\", len(meta.Opts.SelectFiles))\n\t}\n\n\t// Total size of all files: 107484864\n\ttotalSize := int64(107484864)\n\tif meta.Res.Size != totalSize {\n\t\tt.Fatalf(\"Expected total size %d, got %d\", totalSize, meta.Res.Size)\n\t}\n\n\tt.Run(\"Patch with valid indices\", func(t *testing.T) {\n\t\t// Select only file 0 and 2 (c.txt and a.txt)\n\t\terr := f.Patch(nil, &base.Options{\n\t\t\tSelectFiles: []int{0, 2},\n\t\t})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Patch failed: %v\", err)\n\t\t}\n\n\t\tmeta := f.Meta()\n\t\tif !reflect.DeepEqual(meta.Opts.SelectFiles, []int{0, 2}) {\n\t\t\tt.Errorf(\"Expected SelectFiles [0, 2], got %v\", meta.Opts.SelectFiles)\n\t\t}\n\n\t\t// Size should be recalculated: c.txt (98501754) + a.txt (78114) = 98579868\n\t\texpectedSize := int64(98501754 + 78114)\n\t\tif meta.Res.Size != expectedSize {\n\t\t\tt.Errorf(\"Expected size %d, got %d\", expectedSize, meta.Res.Size)\n\t\t}\n\t})\n\n\tt.Run(\"Patch with invalid indices are silently ignored\", func(t *testing.T) {\n\t\t// Mix of valid (0, 1) and invalid (-1, 5, 100) indices\n\t\terr := f.Patch(nil, &base.Options{\n\t\t\tSelectFiles: []int{-1, 0, 5, 1, 100},\n\t\t})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Patch should not return error for invalid indices: %v\", err)\n\t\t}\n\n\t\tmeta := f.Meta()\n\t\t// Only valid indices 0 and 1 should remain\n\t\tif !reflect.DeepEqual(meta.Opts.SelectFiles, []int{0, 1}) {\n\t\t\tt.Errorf(\"Expected SelectFiles [0, 1], got %v\", meta.Opts.SelectFiles)\n\t\t}\n\n\t\t// Size should be: c.txt (98501754) + b.txt (8904996) = 107406750\n\t\texpectedSize := int64(98501754 + 8904996)\n\t\tif meta.Res.Size != expectedSize {\n\t\t\tt.Errorf(\"Expected size %d, got %d\", expectedSize, meta.Res.Size)\n\t\t}\n\t})\n\n\tt.Run(\"Patch with all invalid indices results in empty selection\", func(t *testing.T) {\n\t\terr := f.Patch(nil, &base.Options{\n\t\t\tSelectFiles: []int{-5, 10, 999},\n\t\t})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Patch should not return error: %v\", err)\n\t\t}\n\n\t\tmeta := f.Meta()\n\t\tif len(meta.Opts.SelectFiles) != 0 {\n\t\t\tt.Errorf(\"Expected empty SelectFiles, got %v\", meta.Opts.SelectFiles)\n\t\t}\n\n\t\t// Note: CalcSize with empty selectFiles calculates total size of all files\n\t\t// This is by design - empty selection in CalcSize means \"all files\"\n\t\t// But SelectFiles being empty means no files are selected for download\n\t\tif meta.Res.Size != totalSize {\n\t\t\tt.Errorf(\"Expected size %d (CalcSize with empty slice = all files), got %d\", totalSize, meta.Res.Size)\n\t\t}\n\t})\n\n\tt.Run(\"Patch with nil opts does nothing\", func(t *testing.T) {\n\t\t// First set a known state\n\t\tf.Patch(nil, &base.Options{SelectFiles: []int{1}})\n\t\tprevSelectFiles := f.Meta().Opts.SelectFiles\n\n\t\t// Patch with nil opts\n\t\terr := f.Patch(nil, nil)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Patch with nil opts should not fail: %v\", err)\n\t\t}\n\n\t\t// Should remain unchanged\n\t\tif !reflect.DeepEqual(f.Meta().Opts.SelectFiles, prevSelectFiles) {\n\t\t\tt.Errorf(\"SelectFiles should remain unchanged after nil opts Patch\")\n\t\t}\n\t})\n\n\tt.Run(\"Patch progress array is resized\", func(t *testing.T) {\n\t\tbtFetcher := f.(*Fetcher)\n\t\t// Initialize progress array\n\t\tbtFetcher.data.Progress = make(fetcher.Progress, 3)\n\n\t\terr := f.Patch(nil, &base.Options{\n\t\t\tSelectFiles: []int{0, 2},\n\t\t})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Patch failed: %v\", err)\n\t\t}\n\n\t\tif len(btFetcher.data.Progress) != 2 {\n\t\t\tt.Errorf(\"Expected Progress length 2, got %d\", len(btFetcher.data.Progress))\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "internal/protocol/ed2k/config.go",
    "content": "package ed2k\n\nconst (\n\tdefaultServerList = \"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\"\n\tdefaultServerMet  = \"ed2k://|serverlist|http://upd.emule-security.org/server.met|/\"\n\tdefaultNodesDat   = \"https://upd.emule-security.org/nodes.dat\"\n)\n\ntype config struct {\n\tListenPort int    `json:\"listenPort\"`\n\tUDPPort    int    `json:\"udpPort\"`\n\tServerAddr string `json:\"serverAddr\"`\n\tServerMet  string `json:\"serverMet\"`\n\tNodesDat   string `json:\"nodesDat\"`\n}\n"
  },
  {
    "path": "internal/protocol/ed2k/fetcher.go",
    "content": "package ed2k\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/GopeedLab/gopeed/internal/controller\"\n\t\"github.com/GopeedLab/gopeed/internal/fetcher\"\n\t\"github.com/GopeedLab/gopeed/pkg/base\"\n\tped2k \"github.com/GopeedLab/gopeed/pkg/protocol/ed2k\"\n\t\"github.com/monkeyWie/goed2k\"\n\tgprotocol \"github.com/monkeyWie/goed2k/protocol\"\n)\n\ntype clientStateStore struct {\n\tstore fetcher.ProtocolStateStore\n}\n\nfunc (s *clientStateStore) Load() (*goed2k.ClientState, error) {\n\tif s == nil || s.store == nil {\n\t\treturn nil, nil\n\t}\n\tvar state goed2k.ClientState\n\texist, err := s.store.Load(&state)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif !exist {\n\t\treturn nil, nil\n\t}\n\treturn &state, nil\n}\n\nfunc (s *clientStateStore) Save(state *goed2k.ClientState) error {\n\tif s == nil || s.store == nil {\n\t\treturn nil\n\t}\n\tif state == nil {\n\t\treturn s.store.Delete()\n\t}\n\treturn s.store.Save(state)\n}\n\ntype Fetcher struct {\n\tctl    *controller.Controller\n\tconfig *config\n\n\tmanager *FetcherManager\n\tmeta    *fetcher.FetcherMeta\n\thandle  goed2k.TransferHandle\n\n\twaitCtx    context.Context\n\twaitCancel context.CancelFunc\n}\n\nfunc (f *Fetcher) Setup(ctl *controller.Controller) {\n\tf.ctl = ctl\n\tif f.meta == nil {\n\t\tf.meta = &fetcher.FetcherMeta{}\n\t}\n\tf.waitCtx, f.waitCancel = context.WithCancel(context.Background())\n\tf.ctl.GetConfig(&f.config)\n}\n\nfunc (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {\n\tlink, err := parseLink(req.URL)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tf.meta.Req = req\n\tf.meta.Opts = opts\n\tif f.meta.Opts == nil {\n\t\tf.meta.Opts = &base.Options{}\n\t}\n\tf.meta.Res = buildResource(link)\n\treturn nil\n}\n\nfunc (f *Fetcher) Start() error {\n\tlink, err := parseLink(f.meta.Req.URL)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif f.meta.Res == nil {\n\t\tf.meta.Res = buildResource(link)\n\t}\n\n\tclient, err := f.getClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttargetPath := f.meta.SingleFilepath()\n\tif err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {\n\t\treturn err\n\t}\n\n\thandle := client.FindTransfer(link.Hash)\n\tif handle.IsValid() {\n\t\tf.handle = handle\n\t\tif handle.IsPaused() {\n\t\t\tif err := client.ResumeTransfer(handle.GetHash()); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t}\n\n\tatp := goed2k.AddTransferParams{\n\t\tHash:       link.Hash,\n\t\tCreateTime: time.Now().UnixMilli(),\n\t\tSize:       link.NumberValue,\n\t\tFilePath:   targetPath,\n\t}\n\thandle, err = client.AddTransfer(atp)\n\tif err != nil {\n\t\treturn err\n\t}\n\tf.handle = handle\n\tif handle.IsValid() && handle.IsPaused() {\n\t\tif err := client.ResumeTransfer(handle.GetHash()); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (f *Fetcher) Patch(req *base.Request, opts *base.Options) error {\n\thandle := f.currentHandle()\n\n\tif opts != nil && (opts.Name != \"\" || opts.Path != \"\") && handle.IsValid() {\n\t\treturn errors.New(\"cannot change ed2k target path after transfer started\")\n\t}\n\tif req != nil && req.URL != \"\" && handle.IsValid() {\n\t\treturn errors.New(\"cannot change ed2k link after transfer started\")\n\t}\n\n\tif req != nil {\n\t\tif req.URL != \"\" {\n\t\t\tlink, err := parseLink(req.URL)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tf.meta.Req.URL = req.URL\n\t\t\tf.meta.Res = buildResource(link)\n\t\t}\n\t\tif req.Labels != nil {\n\t\t\tif f.meta.Req.Labels == nil {\n\t\t\t\tf.meta.Req.Labels = make(map[string]string)\n\t\t\t}\n\t\t\tfor k, v := range req.Labels {\n\t\t\t\tf.meta.Req.Labels[k] = v\n\t\t\t}\n\t\t}\n\t\tif req.Proxy != nil {\n\t\t\tf.meta.Req.Proxy = req.Proxy\n\t\t}\n\t}\n\n\tif opts != nil {\n\t\tif opts.Name != \"\" {\n\t\t\tf.meta.Opts.Name = opts.Name\n\t\t}\n\t\tif opts.Path != \"\" {\n\t\t\tf.meta.Opts.Path = opts.Path\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (f *Fetcher) Pause() error {\n\thandle := f.currentHandle()\n\tif !handle.IsValid() {\n\t\treturn nil\n\t}\n\tclient, err := f.getClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := client.PauseTransfer(handle.GetHash()); err != nil {\n\t\treturn err\n\t}\n\tf.handle = handle\n\treturn nil\n}\n\nfunc (f *Fetcher) Close() error {\n\tif f.waitCancel != nil {\n\t\tf.waitCancel()\n\t}\n\thandle := f.currentHandle()\n\tif !handle.IsValid() {\n\t\treturn nil\n\t}\n\tclient, err := f.getClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\tf.handle = handle\n\treturn client.RemoveTransfer(handle.GetHash(), false)\n}\n\nfunc (f *Fetcher) Meta() *fetcher.FetcherMeta {\n\treturn f.meta\n}\n\nfunc (f *Fetcher) Stats() any {\n\thandle := f.currentHandle()\n\tif !handle.IsValid() {\n\t\treturn &ped2k.Stats{}\n\t}\n\tstatus := handle.GetStatus()\n\treturn &ped2k.Stats{\n\t\tState:         string(status.State),\n\t\tActivePeers:   handle.ActiveConnections(),\n\t\tTotalPeers:    status.NumPeers,\n\t\tDownloadRate:  status.DownloadRate,\n\t\tUpload:        status.Upload,\n\t\tUploadRate:    status.UploadRate,\n\t\tTotalDone:     status.TotalDone,\n\t\tTotalReceived: status.TotalReceived,\n\t\tTotalWanted:   status.TotalWanted,\n\t}\n}\n\nfunc (f *Fetcher) Progress() fetcher.Progress {\n\thandle := f.currentHandle()\n\tif !handle.IsValid() {\n\t\treturn fetcher.Progress{0}\n\t}\n\treturn fetcher.Progress{handle.GetStatus().TotalReceived}\n}\n\nfunc (f *Fetcher) Wait() error {\n\tclient, err := f.getClient()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\thash, err := f.hash()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\thandle := f.currentHandle()\n\tif handle.IsValid() && handle.IsFinished() {\n\t\treturn nil\n\t}\n\n\tprogressCh, cancel := client.SubscribeTransferProgress()\n\tdefer cancel()\n\n\tfor {\n\t\tselect {\n\t\tcase <-f.waitCtx.Done():\n\t\t\treturn nil\n\t\tcase event, ok := <-progressCh:\n\t\t\tif !ok {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tfor _, transfer := range event.Transfers {\n\t\t\t\tif transfer.Hash.Compare(hash) != 0 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\t// Removal can happen during task deletion or client shutdown, both of\n\t\t\t\t// which should unblock Wait without treating it as a download failure.\n\t\t\t\tif transfer.Removed || transfer.State == goed2k.Finished {\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (f *Fetcher) getClient() (*goed2k.Client, error) {\n\tif f.manager == nil {\n\t\tf.manager = &FetcherManager{}\n\t}\n\treturn f.manager.initClient(f.config)\n}\n\nfunc (f *Fetcher) currentHandle() goed2k.TransferHandle {\n\tif f.handle.IsValid() {\n\t\treturn f.handle\n\t}\n\tif f.manager == nil {\n\t\treturn f.handle\n\t}\n\tclient := f.manager.currentClient()\n\tif client == nil {\n\t\treturn f.handle\n\t}\n\thash, err := f.hash()\n\tif err != nil {\n\t\treturn f.handle\n\t}\n\thandle := client.FindTransfer(hash)\n\tif handle.IsValid() {\n\t\tf.handle = handle\n\t}\n\treturn f.handle\n}\n\nfunc (f *Fetcher) hash() (gprotocol.Hash, error) {\n\tif f.meta == nil || f.meta.Req == nil {\n\t\treturn gprotocol.Invalid, errors.New(\"ed2k link is empty\")\n\t}\n\tlink, err := parseLink(f.meta.Req.URL)\n\tif err != nil {\n\t\treturn gprotocol.Invalid, err\n\t}\n\treturn link.Hash, nil\n}\n\ntype FetcherManager struct {\n\tmu         sync.Mutex\n\tclient     *goed2k.Client\n\tstateStore *clientStateStore\n}\n\nfunc (fm *FetcherManager) SetStateStore(store fetcher.ProtocolStateStore) {\n\tfm.mu.Lock()\n\tdefer fm.mu.Unlock()\n\n\tif fm.stateStore == nil {\n\t\tfm.stateStore = &clientStateStore{}\n\t}\n\tfm.stateStore.store = store\n\tif fm.client != nil {\n\t\tfm.client.SetStateStore(fm.stateStore)\n\t}\n}\n\nfunc (fm *FetcherManager) Name() string {\n\treturn \"ed2k\"\n}\n\nfunc (fm *FetcherManager) Filters() []*fetcher.SchemeFilter {\n\treturn []*fetcher.SchemeFilter{\n\t\t{\n\t\t\tType:    fetcher.FilterTypeUrl,\n\t\t\tPattern: \"ED2K\",\n\t\t},\n\t}\n}\n\nfunc (fm *FetcherManager) Build() fetcher.Fetcher {\n\treturn &Fetcher{manager: fm}\n}\n\nfunc (fm *FetcherManager) ParseName(u string) string {\n\tlink, err := parseLink(u)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn link.StringValue\n}\n\nfunc (fm *FetcherManager) AutoRename() bool {\n\treturn true\n}\n\nfunc (fm *FetcherManager) DefaultConfig() any {\n\treturn &config{\n\t\tListenPort: 0,\n\t\tUDPPort:    0,\n\t\tServerAddr: defaultServerList,\n\t\tServerMet:  defaultServerMet,\n\t\tNodesDat:   defaultNodesDat,\n\t}\n}\n\nfunc (fm *FetcherManager) Store(f fetcher.Fetcher) (any, error) {\n\treturn nil, nil\n}\n\nfunc (fm *FetcherManager) Restore() (v any, f func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher) {\n\treturn nil, func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher {\n\t\treturn &Fetcher{\n\t\t\tmanager: fm,\n\t\t\tmeta:    meta,\n\t\t}\n\t}\n}\n\nfunc (fm *FetcherManager) Close() error {\n\tfm.mu.Lock()\n\tdefer fm.mu.Unlock()\n\n\tif fm.client != nil {\n\t\tfm.client.Close()\n\t\tfm.client = nil\n\t}\n\treturn nil\n}\n\nfunc parseLink(raw string) (goed2k.EMuleLink, error) {\n\tlink, err := goed2k.ParseEMuleLink(raw)\n\tif err != nil {\n\t\treturn goed2k.EMuleLink{}, err\n\t}\n\tif link.Type != goed2k.LinkFile {\n\t\treturn goed2k.EMuleLink{}, errors.New(\"unsupported ed2k link type\")\n\t}\n\treturn link, nil\n}\n\nfunc buildResource(link goed2k.EMuleLink) *base.Resource {\n\treturn &base.Resource{\n\t\tSize:  link.NumberValue,\n\t\tRange: false,\n\t\tHash:  link.Hash.String(),\n\t\tFiles: []*base.FileInfo{\n\t\t\t{\n\t\t\t\tName: link.StringValue,\n\t\t\t\tSize: link.NumberValue,\n\t\t\t},\n\t\t},\n\t}\n}\n\nfunc splitCommaList(value string) []string {\n\tif strings.TrimSpace(value) == \"\" {\n\t\treturn nil\n\t}\n\tparts := strings.Split(value, \",\")\n\tout := make([]string, 0, len(parts))\n\tfor _, part := range parts {\n\t\tpart = strings.TrimSpace(part)\n\t\tif part != \"\" {\n\t\t\tout = append(out, part)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc (fm *FetcherManager) getStateStoreLocked() *clientStateStore {\n\tif fm.stateStore == nil {\n\t\tfm.stateStore = &clientStateStore{}\n\t}\n\treturn fm.stateStore\n}\n\nfunc (fm *FetcherManager) currentClient() *goed2k.Client {\n\tfm.mu.Lock()\n\tdefer fm.mu.Unlock()\n\treturn fm.client\n}\n\nfunc (fm *FetcherManager) initClient(cfg *config) (*goed2k.Client, error) {\n\tfm.mu.Lock()\n\tdefer fm.mu.Unlock()\n\n\tif fm.client != nil {\n\t\treturn fm.client, nil\n\t}\n\tif cfg == nil {\n\t\tcfg = fm.DefaultConfig().(*config)\n\t}\n\n\tsettings := goed2k.NewSettings()\n\tsettings.ListenPort = cfg.ListenPort\n\tsettings.UDPPort = cfg.UDPPort\n\tsettings.EnableDHT = true\n\tsettings.EnableUPnP = true\n\tsettings.ReconnectToServer = true\n\n\tclient := goed2k.NewClient(settings)\n\tclient.SetStateStore(fm.getStateStoreLocked())\n\tif err := client.LoadState(\"\"); err != nil {\n\t\treturn nil, err\n\t}\n\tif err := client.Start(); err != nil {\n\t\treturn nil, err\n\t}\n\tfm.client = client\n\t// Bootstrap is best-effort: downloads can still proceed later even if\n\t// server list or DHT initialization fails during startup.\n\tbootstrapClient(client, cfg)\n\treturn fm.client, nil\n}\n\nfunc bootstrapClient(client *goed2k.Client, cfg *config) {\n\tfor _, serverAddr := range splitCommaList(cfg.ServerAddr) {\n\t\tgo func(serverAddr string) {\n\t\t\t_ = client.Connect(serverAddr)\n\t\t}(serverAddr)\n\t}\n\tfor _, source := range splitCommaList(cfg.ServerMet) {\n\t\tgo func(source string) {\n\t\t\t_ = client.ConnectServerMet(source)\n\t\t}(source)\n\t}\n\tif cfg.NodesDat != \"\" {\n\t\tgo func() {\n\t\t\t_ = client.LoadDHTNodesDat(cfg.NodesDat)\n\t\t}()\n\t}\n}\n"
  },
  {
    "path": "internal/protocol/ed2k/fetcher_test.go",
    "content": "package ed2k\n\nimport (\n\t\"testing\"\n\n\t\"github.com/GopeedLab/gopeed/internal/controller\"\n\t\"github.com/GopeedLab/gopeed/pkg/base\"\n)\n\nconst testLink = \"ed2k://|file|cn_windows_10_multi-edition_vl_version_1709_updated_sept_2017_x64_dvd_100090774.iso|4630972416|8867C5E54405FF9452225B66EFEE690A|/\"\n\nfunc TestFetcher_Resolve(t *testing.T) {\n\tf := (&FetcherManager{}).Build()\n\tf.Setup(controller.NewController())\n\n\terr := f.Resolve(&base.Request{URL: testLink}, &base.Options{Path: t.TempDir()})\n\tif err != nil {\n\t\tt.Fatalf(\"Resolve() error = %v\", err)\n\t}\n\n\tmeta := f.Meta()\n\tif meta.Res == nil {\n\t\tt.Fatal(\"Resolve() resource is nil\")\n\t}\n\tif got, want := meta.Res.Hash, \"8867C5E54405FF9452225B66EFEE690A\"; got != want {\n\t\tt.Fatalf(\"Resolve() hash = %s, want %s\", got, want)\n\t}\n\tif got, want := meta.Res.Size, int64(4630972416); got != want {\n\t\tt.Fatalf(\"Resolve() size = %d, want %d\", got, want)\n\t}\n\tif got, want := meta.Res.Files[0].Name, \"cn_windows_10_multi-edition_vl_version_1709_updated_sept_2017_x64_dvd_100090774.iso\"; got != want {\n\t\tt.Fatalf(\"Resolve() name = %q, want %q\", got, want)\n\t}\n}\n\nfunc TestFetcherManager_ParseName(t *testing.T) {\n\tgot := (&FetcherManager{}).ParseName(testLink)\n\twant := \"cn_windows_10_multi-edition_vl_version_1709_updated_sept_2017_x64_dvd_100090774.iso\"\n\tif got != want {\n\t\tt.Fatalf(\"ParseName() = %q, want %q\", got, want)\n\t}\n}\n"
  },
  {
    "path": "internal/protocol/http/config.go",
    "content": "package http\n\ntype config struct {\n\tUserAgent      string `json:\"userAgent\"`\n\tConnections    int    `json:\"connections\"`\n\tUseServerCtime bool   `json:\"useServerCtime\"`\n}\n"
  },
  {
    "path": "internal/protocol/http/fetcher.go",
    "content": "package http\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"path\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/GopeedLab/gopeed/internal/controller\"\n\t\"github.com/GopeedLab/gopeed/internal/fetcher\"\n\t\"github.com/GopeedLab/gopeed/pkg/base\"\n\tfhttp \"github.com/GopeedLab/gopeed/pkg/protocol/http\"\n\t\"github.com/xiaoqidun/setft\"\n)\n\nconst (\n\tconnectTimeout     = 15 * time.Second\n\treadTimeout        = 15 * time.Second\n\tminFastFailTimeout = int64(3 * time.Second) // Minimum timeout for fast-fail retry\n\n\t// Work stealing parameters\n\t// When a connection finishes its chunk, it can \"steal\" work from slow connections.\n\tstealThresholdSeconds = 3          // Only steal if victim needs > 3 seconds to finish\n\tstealMinChunkSize     = 512 * 1024 // Min steal size: 512KB (avoid tiny chunks)\n)\n\n// ============================================================================\n// State Machine\n// ============================================================================\n\ntype fetcherState int32\n\nconst (\n\tstateIdle      fetcherState = iota // Initial state\n\tstateResolving                     // Resolving resource info\n\tstateResolved                      // Resolved, waiting for Start or downloading\n\tstateSlowStart                     // Slow-start phase: exponential connection growth\n\tstateSteady                        // Steady state: max connections reached\n\tstatePaused                        // Paused\n\tstateDone                          // Completed\n\tstateError                         // Error occurred\n)\n\n// ============================================================================\n// Connection\n// ============================================================================\n\ntype connectionState int32\n\nconst (\n\tconnNotStarted  connectionState = iota // Not yet started\n\tconnConnecting                         // Sending HTTP request\n\tconnDownloading                        // HTTP response OK, downloading\n\tconnCompleted                          // Completed\n\tconnFailed                             // Failed\n)\n\ntype connectionRole int\n\nconst (\n\troleResolve connectionRole = iota // Resolve connection: initial probe + temp download\n\trolePrimary                       // Primary connection: first successful takeover from Resolve\n\troleWorker                        // Worker connection: subsequent connections\n)\n\ntype chunk struct {\n\tBegin      int64\n\tEnd        int64\n\tDownloaded int64\n}\n\nfunc (c *chunk) remain() int64 {\n\treturn c.End - c.Begin + 1 - c.Downloaded\n}\n\nfunc newChunk(begin int64, end int64) *chunk {\n\treturn &chunk{\n\t\tBegin: begin,\n\t\tEnd:   end,\n\t}\n}\n\ntype connection struct {\n\tID         int\n\tRole       connectionRole\n\tState      connectionState\n\tChunk      *chunk\n\tDownloaded int64\n\tCompleted  bool\n\n\tfailed     bool\n\tretryTimes int\n\tlastErr    error\n\n\t// Speed tracking for work stealing decisions\n\tspeed             int64 // bytes per second\n\tlastSpeedCheck    int64 // timestamp in nanoseconds\n\tlastSpeedDownload int64 // bytes downloaded at last check\n\n\tctx    context.Context\n\tcancel context.CancelFunc\n}\n\n// ============================================================================\n// Slow Start Controller\n// ============================================================================\n\ntype slowStartController struct {\n\tmu             sync.Mutex\n\tmaxConnections int\n\ttotalLaunched  int\n\tbatchPending   int           // Connections in current batch waiting for HTTP response\n\tbatchReady     int           // Connections in current batch that succeeded\n\tnextBatchSize  int           // Next batch size: 1, 2, 4, 8...\n\texpansionCh    chan struct{} // Signal to trigger next expansion\n\tpaused         bool          // Pause expansion (e.g., on 429)\n}\n\nfunc newSlowStartController(maxConnections int) *slowStartController {\n\treturn &slowStartController{\n\t\tmaxConnections: maxConnections,\n\t\tnextBatchSize:  1,\n\t\texpansionCh:    make(chan struct{}, 1),\n\t}\n}\n\n// onConnectSuccess is called when a connection successfully gets HTTP response\n// Returns true if this completes the current batch\nfunc (s *slowStartController) onConnectSuccess() bool {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.batchReady++\n\tif s.batchReady >= s.batchPending {\n\t\t// Batch complete, signal expansion\n\t\tselect {\n\t\tcase s.expansionCh <- struct{}{}:\n\t\tdefault:\n\t\t}\n\t\treturn true\n\t}\n\treturn false\n}\n\n// onConnectFailed is called when a connection fails\nfunc (s *slowStartController) onConnectFailed() {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\t// Reduce pending count\n\tif s.batchPending > 0 {\n\t\ts.batchPending--\n\t}\n\t// If all pending resolved (success or fail), trigger expansion\n\t// This handles both successful completion and all-failures case\n\tif s.batchPending == 0 {\n\t\tselect {\n\t\tcase s.expansionCh <- struct{}{}:\n\t\tdefault:\n\t\t}\n\t}\n}\n\n// getNextBatchSize returns how many connections to start in next batch\n// Returns 0 if max reached\nfunc (s *slowStartController) getNextBatchSize() int {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\tif s.paused {\n\t\treturn 0\n\t}\n\n\tremaining := s.maxConnections - s.totalLaunched\n\tif remaining <= 0 {\n\t\treturn 0\n\t}\n\n\tbatchSize := s.nextBatchSize\n\tif batchSize > remaining {\n\t\tbatchSize = remaining\n\t}\n\n\treturn batchSize\n}\n\n// commitBatch confirms that a batch of connections is being launched\nfunc (s *slowStartController) commitBatch(count int) {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\ts.totalLaunched += count\n\ts.nextBatchSize = s.nextBatchSize * 2 // Exponential growth: 1, 2, 4, 8...\n\ts.batchPending = count\n\ts.batchReady = 0\n}\n\n// ============================================================================\n// Fetcher\n// ============================================================================\n\ntype Fetcher struct {\n\tctl    *controller.Controller\n\tconfig *config\n\tdoneCh chan error\n\n\tmeta *fetcher.FetcherMeta\n\n\t// State machine\n\tstate atomic.Int32 // fetcherState\n\n\t// Connections\n\tconnMu      sync.Mutex\n\tconnections []*connection\n\tresolveConn *connection // The special resolve connection\n\n\t// Slow start controller\n\tslowStart *slowStartController\n\n\t// Max connection time for adaptive timeout (stored as int64 nanoseconds for atomic ops)\n\tmaxConnTime atomic.Int64\n\n\t// First primary connection success signal\n\tprimaryReadyOnce sync.Once\n\tprimaryReadyCh   chan struct{}\n\n\t// Start pending mechanism\n\tstartPending   atomic.Bool\n\tresolvedCh     chan struct{} // Signal when resolve completes\n\tresolvedOnce   sync.Once\n\tresolveDataPos atomic.Int64 // How many bytes downloaded during resolve\n\n\t// Resolve response - kept open for one-time URLs\n\tresolveResp     *http.Response\n\tresolveRespLock sync.Mutex\n\n\t// Async prefetch during resolve phase\n\tprefetchFile     *os.File      // Temporary file for prefetch data\n\tprefetchFilePath string        // Path to temporary file\n\tprefetchSize     atomic.Int64  // Bytes prefetched so far\n\tprefetchDone     atomic.Bool   // Prefetch completed or stopped\n\tprefetchErr      error         // Error during prefetch (if any)\n\tprefetchStopCh   chan struct{} // Signal to stop prefetch\n\n\t// Target file\n\tfile         *os.File\n\tfileMu       sync.Mutex\n\tredirectURL  string\n\tredirectLock sync.Mutex\n\n\t// Lifecycle control\n\tctx    context.Context\n\tcancel context.CancelFunc\n\twg     sync.WaitGroup\n\n\t// downloadLoop lifecycle tracking\n\tdownloadLoopDone chan struct{} // Closed when downloadLoop exits\n\n\t// Resolve connection control\n\tresolveCtx    context.Context\n\tresolveCancel context.CancelFunc\n}\n\nfunc (f *Fetcher) Setup(ctl *controller.Controller) {\n\tf.ctl = ctl\n\tf.doneCh = make(chan error, 1)\n\tif f.meta == nil {\n\t\tf.meta = &fetcher.FetcherMeta{}\n\t}\n\tf.ctl.GetConfig(&f.config)\n\tf.resolvedCh = make(chan struct{})\n\tf.primaryReadyCh = make(chan struct{})\n\n\t// Check if this is a restore scenario (has existing connections or meta)\n\tif f.meta.Res != nil {\n\t\t// Already resolved, close the channel immediately\n\t\tclose(f.resolvedCh)\n\t\tf.state.Store(int32(stateResolved))\n\t} else {\n\t\tf.state.Store(int32(stateIdle))\n\t}\n}\n\nfunc (f *Fetcher) getState() fetcherState {\n\treturn fetcherState(f.state.Load())\n}\n\nfunc (f *Fetcher) setState(s fetcherState) {\n\tf.state.Store(int32(s))\n}\n\n// updateMaxConnTime updates maxConnTime if the new duration is larger\nfunc (f *Fetcher) updateMaxConnTime(d time.Duration) {\n\tnewVal := int64(d)\n\tif newVal > f.maxConnTime.Load() {\n\t\tf.maxConnTime.Store(newVal)\n\t}\n}\n\nfunc (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {\n\tif err := base.ParseReqExtra[fhttp.ReqExtra](req); err != nil {\n\t\treturn err\n\t}\n\tf.meta.Req = req\n\tf.meta.Opts = opts\n\tif f.meta.Opts == nil {\n\t\tf.meta.Opts = &base.Options{}\n\t}\n\n\t// Parse options\n\tif err := base.ParseOptExtra[fhttp.OptsExtra](opts); err != nil {\n\t\treturn err\n\t}\n\tif opts.Extra == nil {\n\t\topts.Extra = &fhttp.OptsExtra{}\n\t}\n\textra := opts.Extra.(*fhttp.OptsExtra)\n\tif extra.Connections <= 0 {\n\t\textra.Connections = f.config.Connections\n\t\tif extra.Connections <= 0 {\n\t\t\textra.Connections = 1\n\t\t}\n\t}\n\n\tf.setState(stateResolving)\n\n\t// Build HTTP request WITHOUT Range header (normal request)\n\t// This allows the response to be reused for downloading (important for one-time URLs)\n\thttpReq, err := f.buildRequest(context.TODO(), req)\n\tif err != nil {\n\t\tf.setState(stateError)\n\t\treturn err\n\t}\n\n\tclient := f.buildClient()\n\n\t// Send normal HTTP request (no Range header)\n\t// Track connection time for adaptive timeout in download phase\n\tconnStartTime := time.Now()\n\tresp, err := client.Do(httpReq)\n\tif err != nil {\n\t\tf.setState(stateError)\n\t\treturn err\n\t}\n\t// Record connection time as baseline for fast-fail timeout\n\tf.updateMaxConnTime(time.Since(connStartTime))\n\n\t// Parse response to get resource info\n\tres := &base.Resource{\n\t\tRange: false,\n\t\tFiles: []*base.FileInfo{},\n\t}\n\n\tif resp.StatusCode != base.HttpCodeOK && resp.StatusCode != base.HttpCodePartialContent {\n\t\tresp.Body.Close()\n\t\tf.setState(stateError)\n\t\treturn NewRequestError(resp.StatusCode)\n\t}\n\n\t// Check if server supports range requests\n\tacceptRanges := resp.Header.Get(base.HttpHeaderAcceptRanges)\n\tcontentRange := resp.Header.Get(base.HttpHeaderContentRange)\n\tif acceptRanges == base.HttpHeaderBytes || strings.HasPrefix(contentRange, base.HttpHeaderBytes) {\n\t\tres.Range = true\n\t}\n\n\t// Get content length from Content-Length header\n\tcontentLength := resp.Header.Get(base.HttpHeaderContentLength)\n\tif contentLength != \"\" {\n\t\tparse, err := strconv.ParseInt(contentLength, 10, 64)\n\t\tif err == nil {\n\t\t\tres.Size = parse\n\t\t}\n\t}\n\n\t// Parse last modified time\n\tvar lastModifiedTime *time.Time\n\tlastModified := resp.Header.Get(base.HttpHeaderLastModified)\n\tif lastModified != \"\" {\n\t\tt, _ := time.Parse(time.RFC1123, lastModified)\n\t\tlastModifiedTime = &t\n\t}\n\n\tfile := &base.FileInfo{\n\t\tSize:  res.Size,\n\t\tCtime: lastModifiedTime,\n\t}\n\n\t// Parse filename\n\tcontentDisposition := resp.Header.Get(base.HttpHeaderContentDisposition)\n\tif contentDisposition != \"\" {\n\t\tfile.Name = parseFilename(contentDisposition)\n\t}\n\tif file.Name == \"\" {\n\t\tfile.Name = path.Base(httpReq.URL.Path)\n\t\tif file.Name != \"\" {\n\t\t\t// Use PathUnescape instead of QueryUnescape to correctly handle %2B (should decode to +, not space)\n\t\t\tfile.Name, _ = url.PathUnescape(file.Name)\n\t\t}\n\t}\n\tif file.Name == \"\" || file.Name == \"/\" || file.Name == \".\" {\n\t\tfile.Name = httpReq.URL.Hostname()\n\t}\n\n\tres.Files = append(res.Files, file)\n\tf.meta.Res = res\n\n\t// Save redirect URL for later connections\n\tf.redirectURL = resp.Request.URL.String()\n\n\t// IMPORTANT: Keep the response body open for downloading in Start phase\n\t// This is crucial for one-time URLs that can only be accessed once\n\tf.resolveRespLock.Lock()\n\tf.resolveResp = resp\n\tf.resolveRespLock.Unlock()\n\n\tf.setState(stateResolved)\n\n\t// Signal that resolve is complete\n\tf.resolvedOnce.Do(func() {\n\t\tclose(f.resolvedCh)\n\t})\n\n\t// Start async prefetch in background (only for range-supported resources)\n\t// For non-range resources, the response will be used directly in Start\n\tif res.Range && res.Size > 0 {\n\t\tf.prefetchStopCh = make(chan struct{})\n\t\tgo f.asyncPrefetch()\n\t}\n\n\t// If start was called before resolve completed, auto-start\n\tif f.startPending.Load() {\n\t\tgo f.doStart()\n\t}\n\n\treturn nil\n}\n\n// asyncPrefetch downloads data in background during resolve phase\n// This data can be reused when Start is called to save time\nfunc (f *Fetcher) asyncPrefetch() {\n\tdefer func() {\n\t\tf.prefetchDone.Store(true)\n\t}()\n\n\t// Get the resolve response\n\tf.resolveRespLock.Lock()\n\tresp := f.resolveResp\n\tf.resolveRespLock.Unlock()\n\n\tif resp == nil {\n\t\treturn\n\t}\n\n\t// Create temporary file for prefetch data\n\ttmpFile, err := os.CreateTemp(\"\", \"gopeed-prefetch-*\")\n\tif err != nil {\n\t\tf.prefetchErr = err\n\t\treturn\n\t}\n\tf.prefetchFile = tmpFile\n\tf.prefetchFilePath = tmpFile.Name()\n\n\tdefer func() {\n\t\t// Close response body when prefetch stops\n\t\tf.resolveRespLock.Lock()\n\t\tif f.resolveResp != nil {\n\t\t\tf.resolveResp.Body.Close()\n\t\t\tf.resolveResp = nil\n\t\t}\n\t\tf.resolveRespLock.Unlock()\n\t}()\n\n\tbuf := make([]byte, 32*1024) // 32KB buffer\n\treader := NewTimeoutReader(resp.Body, readTimeout)\n\n\tfor {\n\t\tselect {\n\t\tcase <-f.prefetchStopCh:\n\t\t\t// Stop signal received (Start was called)\n\t\t\treturn\n\t\tdefault:\n\t\t}\n\n\t\tn, err := reader.Read(buf)\n\t\tif n > 0 {\n\t\t\t_, writeErr := tmpFile.Write(buf[:n])\n\t\t\tif writeErr != nil {\n\t\t\t\tf.prefetchErr = writeErr\n\t\t\t\treturn\n\t\t\t}\n\t\t\tf.prefetchSize.Add(int64(n))\n\t\t}\n\t\tif err != nil {\n\t\t\tif err == io.EOF {\n\t\t\t\t// Prefetch completed\n\t\t\t\treturn\n\t\t\t}\n\t\t\tf.prefetchErr = err\n\t\t\treturn\n\t\t}\n\t}\n}\n\n// stopPrefetchAndGetData stops the async prefetch and returns prefetched bytes\n// It also copies prefetched data to the target file\nfunc (f *Fetcher) stopPrefetchAndCopyData() int64 {\n\t// Signal prefetch to stop (safely)\n\tif f.prefetchStopCh != nil {\n\t\tselect {\n\t\tcase <-f.prefetchStopCh:\n\t\t\t// Already closed\n\t\tdefault:\n\t\t\tclose(f.prefetchStopCh)\n\t\t}\n\t}\n\n\t// Wait for prefetch to finish (with timeout)\n\tfor i := 0; i < 1000 && !f.prefetchDone.Load(); i++ {\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\n\tprefetched := f.prefetchSize.Load()\n\tif prefetched == 0 {\n\t\tf.cleanupPrefetchFile()\n\t\treturn 0\n\t}\n\n\t// Copy prefetch data to target file\n\tif f.prefetchFile != nil && f.file != nil {\n\t\t// Seek to beginning of prefetch file\n\t\tf.prefetchFile.Seek(0, io.SeekStart)\n\n\t\t// Copy to target file at position 0\n\t\tbuf := make([]byte, 32*1024)\n\t\tvar copied int64\n\t\tfor copied < prefetched {\n\t\t\tn, err := f.prefetchFile.Read(buf)\n\t\t\tif n > 0 {\n\t\t\t\tf.file.WriteAt(buf[:n], copied)\n\t\t\t\tcopied += int64(n)\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\tf.cleanupPrefetchFile()\n\treturn prefetched\n}\n\n// cleanupPrefetchFile closes and removes the prefetch temporary file\nfunc (f *Fetcher) cleanupPrefetchFile() {\n\tif f.prefetchFile != nil {\n\t\tf.prefetchFile.Close()\n\t\tf.prefetchFile = nil\n\t}\n\tif f.prefetchFilePath != \"\" {\n\t\tos.Remove(f.prefetchFilePath)\n\t\tf.prefetchFilePath = \"\"\n\t}\n}\n\nfunc (f *Fetcher) Start() error {\n\tstate := f.getState()\n\n\tswitch state {\n\tcase stateResolved, statePaused:\n\t\t// Normal case: resolved or resuming from pause\n\t\treturn f.doStart()\n\n\tcase stateResolving:\n\t\t// Early start: mark pending and return immediately\n\t\tf.startPending.Store(true)\n\t\treturn nil\n\n\tcase stateSlowStart, stateSteady:\n\t\t// Already downloading, this is a resume from pause\n\t\treturn f.doStart()\n\n\tcase stateError:\n\t\t// Retry after error: reset and restart\n\t\treturn f.doStart()\n\n\tdefault:\n\t\treturn fmt.Errorf(\"cannot start in current state: %v\", state)\n\t}\n}\n\nfunc (f *Fetcher) doStart() error {\n\t// Wait for resolve to complete\n\t<-f.resolvedCh\n\n\tstate := f.getState()\n\tif state == stateDone {\n\t\treturn nil\n\t}\n\n\t// If retrying after error, reset connection states for retry\n\tif state == stateError {\n\t\t// Drain any pending error from doneCh before retry\n\t\tselect {\n\t\tcase <-f.doneCh:\n\t\tdefault:\n\t\t}\n\n\t\tf.connMu.Lock()\n\t\tfor _, conn := range f.connections {\n\t\t\t// Reset connections that can be retried\n\t\t\tif !conn.Completed && conn.State != connCompleted {\n\t\t\t\tf.resetConnectionForRestart(conn)\n\t\t\t\tconn.State = connNotStarted\n\t\t\t\tconn.failed = false\n\t\t\t\tconn.retryTimes = 0\n\t\t\t\tconn.lastErr = nil\n\t\t\t}\n\t\t}\n\t\tf.connMu.Unlock()\n\t}\n\n\t// Open or create target file first (needed for prefetch copy)\n\tname := f.meta.SingleFilepath()\n\tvar err error\n\tvar file *os.File\n\t_, err = os.Stat(name)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\tfile, err = f.ctl.Touch(name, f.meta.Res.Size)\n\t\t} else {\n\t\t\treturn err\n\t\t}\n\t} else {\n\t\tfile, err = os.OpenFile(name, os.O_RDWR, os.ModeAppend)\n\t}\n\tif err != nil {\n\t\treturn err\n\t}\n\tf.fileMu.Lock()\n\tf.file = file\n\tf.fileMu.Unlock()\n\n\t// For range-supported resources, stop prefetch and copy data\n\t// For non-range resources, the response will be used directly\n\tvar prefetchedBytes int64\n\tif f.meta.Res.Range {\n\t\t// Stop async prefetch and copy data to target file\n\t\tprefetchedBytes = f.stopPrefetchAndCopyData()\n\t\tf.resolveDataPos.Store(prefetchedBytes)\n\n\t\t// Also close resolve response if still open\n\t\tf.resolveRespLock.Lock()\n\t\tif f.resolveResp != nil {\n\t\t\tf.resolveResp.Body.Close()\n\t\t\tf.resolveResp = nil\n\t\t}\n\t\tf.resolveRespLock.Unlock()\n\t}\n\n\t// Avoid request extra modified by extension\n\tif err = base.ParseReqExtra[fhttp.ReqExtra](f.meta.Req); err != nil {\n\t\treturn err\n\t}\n\n\t// Initialize slow start controller\n\tmaxConns := f.meta.Opts.Extra.(*fhttp.OptsExtra).Connections\n\tf.slowStart = newSlowStartController(maxConns)\n\n\t// Create main context\n\tf.ctx, f.cancel = context.WithCancel(context.Background())\n\n\t// Create downloadLoop lifecycle channel\n\tf.downloadLoopDone = make(chan struct{})\n\n\t// Start download\n\tf.setState(stateSlowStart)\n\tgo f.downloadLoop()\n\n\treturn nil\n}\n\nfunc (f *Fetcher) downloadLoop() {\n\tdefer func() {\n\t\t// Update file last modified time before closing\n\t\tif f.config.UseServerCtime && f.meta.Res.Files[0].Ctime != nil {\n\t\t\tsetft.SetFileTime(f.meta.SingleFilepath(), time.Now(), *f.meta.Res.Files[0].Ctime, *f.meta.Res.Files[0].Ctime)\n\t\t}\n\n\t\t// Signal that downloadLoop has exited\n\t\tif f.downloadLoopDone != nil {\n\t\t\tclose(f.downloadLoopDone)\n\t\t}\n\t}()\n\n\t// Check if this is a resume or fresh start\n\tisResume := len(f.connections) > 0\n\n\tif !isResume {\n\t\t// Fresh start: begin with resolve connection\n\t\tf.startResolveDownload()\n\t} else {\n\t\t// Resume: restart existing connections\n\t\tf.resumeConnections()\n\t\tf.waitForCompletion()\n\t\treturn\n\t}\n\n\t// Slow start loop\n\tfor {\n\t\tselect {\n\t\tcase <-f.ctx.Done():\n\t\t\t// Paused or cancelled\n\t\t\treturn\n\t\tcase <-f.slowStart.expansionCh:\n\t\t\t// Batch completed, try to expand\n\t\t\tif f.checkCompletion() {\n\t\t\t\t// All work is done, wait for connections to finish\n\t\t\t\tf.waitForCompletion()\n\t\t\t\treturn\n\t\t\t}\n\t\t\tf.expandConnections()\n\n\t\t\t// Check if we've reached steady state (max connections)\n\t\t\tif f.getState() == stateSteady {\n\t\t\t\t// Wait for all connections to complete\n\t\t\t\tf.waitForCompletion()\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (f *Fetcher) startResolveDownload() {\n\t// If no range support or size unknown, just use single connection with resolve response\n\tif !f.meta.Res.Range || f.meta.Res.Size == 0 {\n\t\t// Create a single connection for the entire file\n\t\tconn := &connection{\n\t\t\tID:    0,\n\t\t\tRole:  rolePrimary,\n\t\t\tState: connNotStarted,\n\t\t\tChunk: newChunk(0, 0), // For non-range, end doesn't matter\n\t\t}\n\t\tconn.ctx, conn.cancel = context.WithCancel(f.ctx)\n\t\tf.connections = append(f.connections, conn)\n\n\t\tf.wg.Add(1)\n\t\t// Use the resolve response directly\n\t\tgo f.runConnectionWithResolveResp(conn)\n\n\t\t// For non-range downloads, wait for completion directly in this goroutine\n\t\t// Don't create another goroutine to avoid WaitGroup reuse issues\n\t\tf.waitForCompletion()\n\t\treturn\n\t}\n\n\t// Range supported: use slow start to launch connections\n\t// Start first batch of connections\n\tf.expandConnections()\n}\n\nfunc (f *Fetcher) expandConnections() {\n\tbatchSize := f.slowStart.getNextBatchSize()\n\tif batchSize <= 0 {\n\t\t// Max reached, transition to steady state\n\t\tf.setState(stateSteady)\n\t\t// Don't start a new goroutine - let the downloadLoop handle completion\n\t\t// This avoids multiple goroutines calling wg.Wait() simultaneously\n\t\treturn\n\t}\n\n\ttotalSize := f.meta.Res.Size\n\n\tf.connMu.Lock()\n\n\t// For first batch (no existing connections), allocate the remaining file to first connection\n\tif len(f.connections) == 0 {\n\t\t// Check if we have prefetched data\n\t\tprefetched := f.resolveDataPos.Load()\n\n\t\t// If prefetched all data, mark as done\n\t\tif prefetched >= totalSize {\n\t\t\tf.connMu.Unlock()\n\n\t\t\t// Close the file before signaling completion\n\t\t\tf.fileMu.Lock()\n\t\t\tif f.file != nil {\n\t\t\t\tf.file.Close()\n\t\t\t\tf.file = nil\n\t\t\t}\n\t\t\tf.fileMu.Unlock()\n\n\t\t\tf.setState(stateDone)\n\t\t\tf.doneCh <- nil\n\t\t\treturn\n\t\t}\n\n\t\t// First connection starts from prefetched position\n\t\tconn := &connection{\n\t\t\tID:    0,\n\t\t\tRole:  rolePrimary,\n\t\t\tState: connNotStarted,\n\t\t\tChunk: newChunk(prefetched, totalSize-1),\n\t\t}\n\t\t// Mark prefetched bytes as already downloaded\n\t\tconn.Chunk.Downloaded = 0    // Start fresh from prefetched position\n\t\tconn.Downloaded = prefetched // Track total downloaded including prefetch\n\n\t\tconn.ctx, conn.cancel = context.WithCancel(f.ctx)\n\t\tf.connections = append(f.connections, conn)\n\t\tf.connMu.Unlock()\n\n\t\tf.slowStart.commitBatch(1)\n\t\tf.wg.Add(1)\n\t\tgo f.runConnection(conn)\n\t\treturn\n\t}\n\n\t// For subsequent batches, use \"help other connection\" strategy\n\t// Find connections with enough remaining work to split\n\t// During slow start, use fixed minimum size since speed is not yet stable\n\tminSplitSize := int64(stealMinChunkSize)\n\n\tnewConns := make([]*connection, 0, batchSize)\n\tfor i := 0; i < batchSize; i++ {\n\t\t// Find the connection with most remaining work\n\t\tvar maxRemainConn *connection\n\t\tvar maxRemain int64\n\n\t\tfor _, conn := range f.connections {\n\t\t\tif conn.Completed || conn.State == connFailed {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tremain := conn.Chunk.remain()\n\t\t\t// Only split if remaining work is at least 2x the minimum split size\n\t\t\tif remain > maxRemain && remain > minSplitSize*2 {\n\t\t\t\tmaxRemainConn = conn\n\t\t\t\tmaxRemain = remain\n\t\t\t}\n\t\t}\n\n\t\tif maxRemainConn == nil {\n\t\t\t// No connection has enough work to split\n\t\t\tbreak\n\t\t}\n\n\t\t// Split the work: new connection takes the latter half\n\t\tsplitPoint := maxRemainConn.Chunk.End - maxRemainConn.Chunk.remain()/2\n\t\tnewChunk := newChunk(splitPoint+1, maxRemainConn.Chunk.End)\n\t\tmaxRemainConn.Chunk.End = splitPoint\n\n\t\tconnID := len(f.connections)\n\t\tconn := &connection{\n\t\t\tID:    connID,\n\t\t\tRole:  roleWorker,\n\t\t\tState: connNotStarted,\n\t\t\tChunk: newChunk,\n\t\t}\n\t\tconn.ctx, conn.cancel = context.WithCancel(f.ctx)\n\n\t\tnewConns = append(newConns, conn)\n\t\tf.connections = append(f.connections, conn)\n\t}\n\n\tf.connMu.Unlock()\n\n\tif len(newConns) == 0 {\n\t\t// No new connections could be created, stop expansion\n\t\tf.setState(stateSteady)\n\t\tgo f.waitForCompletion()\n\t\treturn\n\t}\n\n\t// Commit batch to slow start controller\n\tf.slowStart.commitBatch(len(newConns))\n\n\t// Launch connections\n\tfor _, conn := range newConns {\n\t\tf.wg.Add(1)\n\t\tgo f.runConnection(conn)\n\t}\n}\n\nfunc (f *Fetcher) runConnection(conn *connection) {\n\tdefer f.wg.Done()\n\n\tf.connMu.Lock()\n\tconn.State = connConnecting\n\tf.connMu.Unlock()\n\n\t// Use fast-fail client for quick retry during download phase\n\tclient := f.buildFastFailClient()\n\tbuf := make([]byte, 8192)\n\n\tretries := 0\n\tconn.retryTimes = 0\n\n\tfor {\n\t\t// Rebuild client with updated fast-fail timeout on retries\n\t\tif retries > 0 {\n\t\t\tclient = f.buildFastFailClient()\n\t\t}\n\n\t\terr := f.downloadChunkOnce(conn, client, buf)\n\t\tif err == nil {\n\t\t\tif !f.meta.Res.Range || !f.helpOtherConnection(conn) {\n\t\t\t\tf.connMu.Lock()\n\t\t\t\tconn.Completed = true\n\t\t\t\tconn.State = connCompleted\n\t\t\t\tf.connMu.Unlock()\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// Reset counters after a successful help switch\n\t\t\tretries = 0\n\t\t\tconn.retryTimes = 0\n\t\t\tcontinue\n\t\t}\n\n\t\tif errors.Is(err, context.Canceled) {\n\t\t\treturn\n\t\t}\n\n\t\tif re := extractRequestError(err); re != nil {\n\t\t\tconn.lastErr = re\n\t\t} else {\n\t\t\tconn.lastErr = err\n\t\t}\n\n\t\tif shouldCountHTTPFailure(err) {\n\t\t\tif re := extractRequestError(err); re != nil && re.Code == 403 {\n\t\t\t\tf.connMu.Lock()\n\t\t\t\tconn.State = connFailed\n\t\t\t\tconn.failed = true\n\t\t\t\tf.connMu.Unlock()\n\t\t\t\tif f.slowStart != nil {\n\t\t\t\t\tf.slowStart.onConnectFailed()\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconn.retryTimes++\n\t\t\tf.connMu.Lock()\n\t\t\tconn.failed = true\n\t\t\tf.connMu.Unlock()\n\t\t\tif f.slowStart != nil {\n\t\t\t\tf.slowStart.onConnectFailed()\n\t\t\t}\n\t\t\tif conn.retryTimes >= 3 {\n\t\t\t\tf.connMu.Lock()\n\t\t\t\tconn.State = connFailed\n\t\t\t\tf.connMu.Unlock()\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\tf.connMu.Lock()\n\t\tconn.State = connFailed\n\t\tf.connMu.Unlock()\n\t\tretryDelay := time.Second * time.Duration(retries+1)\n\t\tif retryDelay > 5*time.Second {\n\t\t\tretryDelay = 5 * time.Second\n\t\t}\n\t\tretries++\n\t\ttime.Sleep(retryDelay)\n\t}\n}\n\n// downloadChunkOnce performs a single HTTP request for the current chunk without retrying.\n// If the redirect URL fails with an expiration-related error (401, 403, 410),\n// it will automatically retry with the original URL and update the redirect URL on success.\nfunc (f *Fetcher) downloadChunkOnce(conn *connection, client *http.Client, buf []byte) error {\n\tif conn.ctx.Err() != nil {\n\t\treturn conn.ctx.Err()\n\t}\n\n\t// Read chunk boundaries under lock to get a consistent snapshot\n\t// This protects against concurrent modification by helpOtherConnection\n\tf.connMu.Lock()\n\tif f.meta.Res.Range && conn.Chunk.remain() <= 0 {\n\t\tf.connMu.Unlock()\n\t\treturn nil\n\t}\n\trangeStart := conn.Chunk.Begin + conn.Chunk.Downloaded\n\trangeEnd := conn.Chunk.End\n\tf.connMu.Unlock()\n\n\thttpReq, err := f.buildRequest(conn.ctx, f.meta.Req)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif f.meta.Res.Range {\n\t\thttpReq.Header.Set(base.HttpHeaderRange,\n\t\t\tfmt.Sprintf(base.HttpHeaderRangeFormat, rangeStart, rangeEnd))\n\t}\n\n\t// Record connection start time for adaptive timeout tracking\n\tconnStartTime := time.Now()\n\n\tresp, err := client.Do(httpReq)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif resp.StatusCode != base.HttpCodeOK && resp.StatusCode != base.HttpCodePartialContent {\n\t\tresp.Body.Close()\n\t\toriginalErr := NewRequestError(resp.StatusCode)\n\n\t\t// Check if this might be a redirect URL expiration error\n\t\t// If so, try falling back to the original URL\n\t\tif f.hasRedirectURL() && isRedirectExpiredError(originalErr) {\n\t\t\tfallbackResp, fallbackErr := f.tryFallbackToOriginalURL(conn.ctx, client, rangeStart, rangeEnd)\n\t\t\tif fallbackErr == nil && fallbackResp != nil {\n\t\t\t\t// Fallback succeeded, use this response instead\n\t\t\t\tresp = fallbackResp\n\t\t\t\t// Update the redirect URL from the response\n\t\t\t\tif resp.Request != nil && resp.Request.URL != nil {\n\t\t\t\t\tf.updateRedirectURL(resp.Request.URL.String())\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Fallback also failed, return the original error\n\t\t\t\tif fallbackResp != nil {\n\t\t\t\t\tfallbackResp.Body.Close()\n\t\t\t\t}\n\t\t\t\treturn originalErr\n\t\t\t}\n\t\t} else {\n\t\t\treturn originalErr\n\t\t}\n\t}\n\tdefer resp.Body.Close()\n\n\t// Record successful connection time for adaptive timeout\n\tf.updateMaxConnTime(time.Since(connStartTime))\n\n\tf.connMu.Lock()\n\tconn.State = connDownloading\n\tconn.failed = false\n\tf.connMu.Unlock()\n\n\tif conn.Role == rolePrimary || conn.ID == 0 {\n\t\tf.primaryReadyOnce.Do(func() {\n\t\t\tclose(f.primaryReadyCh)\n\t\t})\n\t}\n\tif f.slowStart != nil {\n\t\tf.slowStart.onConnectSuccess()\n\t}\n\n\treader := NewTimeoutReader(resp.Body, readTimeout)\n\tfor {\n\t\tif conn.ctx.Err() != nil {\n\t\t\treturn conn.ctx.Err()\n\t\t}\n\n\t\tn, err := reader.Read(buf)\n\t\tif n > 0 {\n\t\t\tfinished := false\n\t\t\tvar writeOffset int64\n\n\t\t\t// Lock to safely read chunk state and calculate write parameters\n\t\t\t// This protects against concurrent chunk splitting by helpOtherConnection\n\t\t\tf.connMu.Lock()\n\t\t\tif f.meta.Res.Range {\n\t\t\t\t// Check current chunk boundaries - this respects any concurrent chunk splitting\n\t\t\t\tremain := conn.Chunk.remain()\n\t\t\t\tif remain <= 0 {\n\t\t\t\t\t// Chunk has been fully downloaded (possibly split and reduced)\n\t\t\t\t\tf.connMu.Unlock()\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\t\t\t\tif remain < int64(n) {\n\t\t\t\t\tn = int(remain)\n\t\t\t\t\tfinished = true\n\t\t\t\t}\n\t\t\t}\n\t\t\twriteOffset = conn.Chunk.Begin + conn.Chunk.Downloaded\n\t\t\tf.connMu.Unlock()\n\n\t\t\tf.fileMu.Lock()\n\t\t\tif f.file != nil {\n\t\t\t\t_, writeErr := f.file.WriteAt(buf[:n], writeOffset)\n\t\t\t\tif writeErr != nil {\n\t\t\t\t\tf.fileMu.Unlock()\n\t\t\t\t\treturn writeErr\n\t\t\t\t}\n\t\t\t}\n\t\t\tf.fileMu.Unlock()\n\n\t\t\t// Lock again to update Downloaded atomically with the read above\n\t\t\tf.connMu.Lock()\n\t\t\tconn.Chunk.Downloaded += int64(n)\n\t\t\tconn.Downloaded += int64(n)\n\t\t\t// Update connection speed periodically\n\t\t\tnow := time.Now().UnixNano()\n\t\t\tif conn.lastSpeedCheck == 0 {\n\t\t\t\tconn.lastSpeedCheck = now\n\t\t\t\tconn.lastSpeedDownload = conn.Downloaded\n\t\t\t} else if now-conn.lastSpeedCheck >= int64(500*time.Millisecond) {\n\t\t\t\telapsed := float64(now-conn.lastSpeedCheck) / float64(time.Second)\n\t\t\t\tif elapsed > 0 {\n\t\t\t\t\tconn.speed = int64(float64(conn.Downloaded-conn.lastSpeedDownload) / elapsed)\n\t\t\t\t}\n\t\t\t\tconn.lastSpeedCheck = now\n\t\t\t\tconn.lastSpeedDownload = conn.Downloaded\n\t\t\t}\n\t\t\tf.connMu.Unlock()\n\n\t\t\tif finished {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\n\t\tif err != nil {\n\t\t\tif err == io.EOF {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\treturn err\n\t\t}\n\t}\n}\n\n// runConnectionWithResolveResp uses the response body from Resolve phase\n// This is crucial for one-time URLs that can only be accessed once\nfunc (f *Fetcher) runConnectionWithResolveResp(conn *connection) {\n\tdefer f.wg.Done()\n\n\tf.connMu.Lock()\n\tconn.State = connConnecting\n\tf.connMu.Unlock()\n\n\tbuf := make([]byte, 8192)\n\n\t// Get the resolve response\n\tf.resolveRespLock.Lock()\n\tresp := f.resolveResp\n\tf.resolveResp = nil // Take ownership\n\tf.resolveRespLock.Unlock()\n\n\tif resp == nil {\n\t\t// No resolve response available, fall back to normal connection\n\t\tf.runConnectionFallback(conn)\n\t\treturn\n\t}\n\n\tdefer resp.Body.Close()\n\n\tf.connMu.Lock()\n\tconn.State = connDownloading\n\tconn.failed = false\n\tf.connMu.Unlock()\n\n\t// Signal primary ready\n\tf.primaryReadyOnce.Do(func() {\n\t\tclose(f.primaryReadyCh)\n\t})\n\tif f.slowStart != nil {\n\t\tf.slowStart.onConnectSuccess()\n\t}\n\n\t// Download data from resolve response\n\treader := NewTimeoutReader(resp.Body, readTimeout)\n\tfor {\n\t\tif conn.ctx.Err() != nil {\n\t\t\treturn\n\t\t}\n\n\t\tn, err := reader.Read(buf)\n\t\tif n > 0 {\n\t\t\tf.fileMu.Lock()\n\t\t\tif f.file != nil {\n\t\t\t\t_, writeErr := f.file.WriteAt(buf[:n], conn.Chunk.Downloaded)\n\t\t\t\tif writeErr != nil {\n\t\t\t\t\tf.fileMu.Unlock()\n\t\t\t\t\tf.connMu.Lock()\n\t\t\t\t\tconn.State = connFailed\n\t\t\t\t\tconn.failed = true\n\t\t\t\t\tf.connMu.Unlock()\n\t\t\t\t\tif f.slowStart != nil {\n\t\t\t\t\t\tf.slowStart.onConnectFailed()\n\t\t\t\t\t}\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t\tf.fileMu.Unlock()\n\n\t\t\tconn.Chunk.Downloaded += int64(n)\n\t\t\tconn.Downloaded += int64(n)\n\t\t}\n\t\tif err != nil {\n\t\t\tif err == io.EOF {\n\t\t\t\tf.connMu.Lock()\n\t\t\t\tconn.Completed = true\n\t\t\t\tconn.State = connCompleted\n\t\t\t\tf.connMu.Unlock()\n\t\t\t\treturn\n\t\t\t}\n\t\t\t// Reading from resolve response failed: treat as transient (do not count as fail)\n\t\t\tf.connMu.Lock()\n\t\t\tconn.State = connFailed\n\t\t\tf.connMu.Unlock()\n\t\t\treturn\n\t\t}\n\t}\n}\n\n// runConnectionFallback is used when resolve response is not available\nfunc (f *Fetcher) runConnectionFallback(conn *connection) {\n\t// Use fast-fail client for quick retry during download phase\n\tclient := f.buildFastFailClient()\n\tbuf := make([]byte, 8192)\n\n\tretries := 0\n\tcountedRetries := 0\n\n\tfor {\n\t\tif conn.ctx.Err() != nil {\n\t\t\treturn\n\t\t}\n\n\t\t// Rebuild client with updated fast-fail timeout on retries\n\t\tif retries > 0 {\n\t\t\tclient = f.buildFastFailClient()\n\t\t}\n\n\t\tf.connMu.Lock()\n\t\tconn.State = connConnecting\n\t\tf.connMu.Unlock()\n\n\t\terr := func() error {\n\t\t\thttpReq, err := f.buildRequest(conn.ctx, f.meta.Req)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\t// Record connection start time for adaptive timeout tracking\n\t\t\tconnStartTime := time.Now()\n\n\t\t\tresp, err := client.Do(httpReq)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tdefer resp.Body.Close()\n\n\t\t\tif resp.StatusCode != base.HttpCodeOK && resp.StatusCode != base.HttpCodePartialContent {\n\t\t\t\treturn NewRequestError(resp.StatusCode)\n\t\t\t}\n\n\t\t\t// Record successful connection time for adaptive timeout\n\t\t\tf.updateMaxConnTime(time.Since(connStartTime))\n\n\t\t\tf.connMu.Lock()\n\t\t\tconn.State = connDownloading\n\t\t\tconn.failed = false\n\t\t\tf.connMu.Unlock()\n\n\t\t\tf.primaryReadyOnce.Do(func() {\n\t\t\t\tclose(f.primaryReadyCh)\n\t\t\t})\n\t\t\tif f.slowStart != nil {\n\t\t\t\tf.slowStart.onConnectSuccess()\n\t\t\t}\n\n\t\t\treader := NewTimeoutReader(resp.Body, readTimeout)\n\t\t\tfor {\n\t\t\t\tif conn.ctx.Err() != nil {\n\t\t\t\t\treturn conn.ctx.Err()\n\t\t\t\t}\n\n\t\t\t\tn, err := reader.Read(buf)\n\t\t\t\tif n > 0 {\n\t\t\t\t\tf.fileMu.Lock()\n\t\t\t\t\tif f.file != nil {\n\t\t\t\t\t\t_, writeErr := f.file.WriteAt(buf[:n], conn.Chunk.Downloaded)\n\t\t\t\t\t\tif writeErr != nil {\n\t\t\t\t\t\t\tf.fileMu.Unlock()\n\t\t\t\t\t\t\treturn writeErr\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tf.fileMu.Unlock()\n\n\t\t\t\t\tconn.Chunk.Downloaded += int64(n)\n\t\t\t\t\tconn.Downloaded += int64(n)\n\t\t\t\t}\n\t\t\t\tif err != nil {\n\t\t\t\t\tif err == io.EOF {\n\t\t\t\t\t\treturn nil\n\t\t\t\t\t}\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\t\t}()\n\n\t\tif err == nil {\n\t\t\tf.connMu.Lock()\n\t\t\tconn.Completed = true\n\t\t\tconn.State = connCompleted\n\t\t\tf.connMu.Unlock()\n\t\t\treturn\n\t\t}\n\n\t\tif errors.Is(err, context.Canceled) {\n\t\t\treturn\n\t\t}\n\n\t\tif re := extractRequestError(err); re != nil {\n\t\t\tconn.lastErr = re\n\t\t} else {\n\t\t\tconn.lastErr = err\n\t\t}\n\n\t\tif shouldCountHTTPFailure(err) {\n\t\t\t// Immediate fail for server connection limit (403)\n\t\t\tif re := extractRequestError(err); re != nil && re.Code == 403 {\n\t\t\t\tf.connMu.Lock()\n\t\t\t\tconn.State = connFailed\n\t\t\t\tconn.failed = true\n\t\t\t\tf.connMu.Unlock()\n\t\t\t\tif f.slowStart != nil {\n\t\t\t\t\tf.slowStart.onConnectFailed()\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconn.retryTimes++\n\t\t\tcountedRetries++\n\t\t\tif countedRetries >= 3 {\n\t\t\t\tf.connMu.Lock()\n\t\t\t\tconn.State = connFailed\n\t\t\t\tconn.failed = true\n\t\t\t\tf.connMu.Unlock()\n\t\t\t\tif f.slowStart != nil {\n\t\t\t\t\tf.slowStart.onConnectFailed()\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\t// Retry again for counted failures below the cap\n\t\t\tf.connMu.Lock()\n\t\t\tconn.State = connFailed\n\t\t\tf.connMu.Unlock()\n\t\t\tretryDelay := time.Second * time.Duration(retries+1)\n\t\t\tif retryDelay > 5*time.Second {\n\t\t\t\tretryDelay = 5 * time.Second\n\t\t\t}\n\t\t\tretries++\n\t\t\ttime.Sleep(retryDelay)\n\t\t\tcontinue\n\t\t}\n\n\t\t// Retry indefinitely for non-counted errors\n\t\tf.connMu.Lock()\n\t\tconn.State = connFailed\n\t\tf.connMu.Unlock()\n\t\tretryDelay := time.Second * time.Duration(retries+1)\n\t\tif retryDelay > 5*time.Second {\n\t\t\tretryDelay = 5 * time.Second\n\t\t}\n\t\tretries++\n\t\ttime.Sleep(retryDelay)\n\t}\n}\n\n// helpOtherConnection implements work stealing: when a connection finishes its chunk,\n// it looks for connections that need more than stealThresholdSeconds to finish and steals half of its work.\nfunc (f *Fetcher) helpOtherConnection(helper *connection) bool {\n\tf.connMu.Lock()\n\tdefer f.connMu.Unlock()\n\n\t// Find the connection with longest remaining time\n\tvar slowestConn *connection\n\tvar maxRemainSeconds int64\n\tfor _, r := range f.connections {\n\t\tif r == helper || r.Completed || r.State == connFailed {\n\t\t\tcontinue\n\t\t}\n\n\t\tremain := r.Chunk.remain()\n\t\tif remain < stealMinChunkSize {\n\t\t\tcontinue\n\t\t}\n\n\t\t// Calculate remaining time in seconds for this connection\n\t\tvar remainSeconds int64\n\t\tif r.speed > 0 {\n\t\t\tremainSeconds = remain / r.speed\n\t\t} else {\n\t\t\t// Speed unknown, assume it needs help if chunk is large enough\n\t\t\tremainSeconds = stealThresholdSeconds + 1\n\t\t}\n\n\t\t// Only consider if it needs more than threshold seconds to finish\n\t\tif remainSeconds > stealThresholdSeconds && remainSeconds > maxRemainSeconds {\n\t\t\tslowestConn = r\n\t\t\tmaxRemainSeconds = remainSeconds\n\t\t}\n\t}\n\n\tif slowestConn == nil {\n\t\treturn false\n\t}\n\n\t// Re-calculate the chunk range: steal half of the remaining work\n\thelper.Chunk.Begin = slowestConn.Chunk.End - slowestConn.Chunk.remain()/2\n\thelper.Chunk.End = slowestConn.Chunk.End\n\thelper.Chunk.Downloaded = 0\n\tslowestConn.Chunk.End = helper.Chunk.Begin - 1\n\treturn true\n}\n\nfunc (f *Fetcher) resetConnectionForRestart(conn *connection) {\n\tif f.meta.Res.Range {\n\t\treturn\n\t}\n\n\t// Without range support a new request always starts from byte 0,\n\t// so pause/retry must restart instead of continuing from the old offset.\n\tif conn.Chunk == nil {\n\t\tconn.Chunk = newChunk(0, 0)\n\t} else {\n\t\tconn.Chunk.Begin = 0\n\t\tconn.Chunk.End = 0\n\t\tconn.Chunk.Downloaded = 0\n\t}\n\tconn.Downloaded = 0\n\tconn.Completed = false\n\tconn.speed = 0\n\tconn.lastSpeedCheck = 0\n\tconn.lastSpeedDownload = 0\n}\n\nfunc (f *Fetcher) resumeConnections() {\n\t// Collect connections to resume while holding the lock\n\tvar toResume []*connection\n\n\tf.connMu.Lock()\n\tfor _, conn := range f.connections {\n\t\t// Only skip connections that have truly completed successfully\n\t\tif conn.Completed || conn.State == connCompleted {\n\t\t\tcontinue\n\t\t}\n\t\t// For failed connections, skip if:\n\t\t// 1. They have exhausted retries (retryTimes >= 3), OR\n\t\t// 2. They failed with a permanent error like 403\n\t\tif conn.State == connFailed && conn.failed {\n\t\t\t// Check if it's a permanent error (like 403)\n\t\t\tif re := extractRequestError(conn.lastErr); re != nil && re.Code == 403 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t// Check if retries exhausted\n\t\t\tif conn.retryTimes >= 3 {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\tf.resetConnectionForRestart(conn)\n\t\t// Reset the connection state for resume\n\t\tconn.ctx, conn.cancel = context.WithCancel(f.ctx)\n\t\tconn.State = connNotStarted\n\t\tconn.failed = false // Clear failed flag for resumed connection\n\t\ttoResume = append(toResume, conn)\n\t}\n\tf.connMu.Unlock()\n\n\t// Start connections outside the lock\n\tfor _, conn := range toResume {\n\t\tf.wg.Add(1)\n\t\tgo f.runConnection(conn)\n\t}\n}\n\nfunc (f *Fetcher) waitForCompletion() {\n\tf.wg.Wait()\n\t// Only trigger completion if not cancelled/paused\n\tif f.ctx != nil && f.ctx.Err() == nil {\n\t\tf.onDownloadComplete()\n\t}\n}\n\nfunc (f *Fetcher) onDownloadComplete() {\n\tf.connMu.Lock()\n\n\t// First, check if download actually completed successfully\n\t// Calculate total downloaded from all connections\n\ttotalDownloaded := int64(0)\n\tif f.resolveConn != nil {\n\t\ttotalDownloaded += f.resolveConn.Downloaded\n\t}\n\tfor _, conn := range f.connections {\n\t\ttotalDownloaded += conn.Downloaded\n\t}\n\n\t// Check if all chunks are complete (no remaining bytes)\n\tallChunksComplete := true\n\tfor _, conn := range f.connections {\n\t\tneedsMoreData := false\n\t\tif f.meta.Res.Range {\n\t\t\tneedsMoreData = conn.Chunk != nil && conn.Chunk.remain() > 0\n\t\t} else if f.meta.Res.Size > 0 {\n\t\t\tneedsMoreData = conn.Downloaded < f.meta.Res.Size\n\t\t} else {\n\t\t\tneedsMoreData = !conn.Completed && conn.State != connCompleted\n\t\t}\n\n\t\tif needsMoreData && !conn.Completed && conn.State != connCompleted {\n\t\t\t// This connection has remaining work and isn't done\n\t\t\t// Check if it failed with 403 (server limit) - these can be ignored if other connections completed the work\n\t\t\tif conn.State == connFailed && conn.failed {\n\t\t\t\tif re := extractRequestError(conn.lastErr); re != nil && re.Code == 403 {\n\t\t\t\t\t// 403 is server connection limit, check if other connections will complete this chunk\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t}\n\t\t\tallChunksComplete = false\n\t\t\tbreak\n\t\t}\n\t}\n\n\t// If total downloaded matches file size, consider it a success regardless of connection failures\n\tdownloadComplete := f.meta.Res.Size > 0 && totalDownloaded >= f.meta.Res.Size\n\n\t// Check for any errors, but ignore 403 (server connection limit) errors if download completed\n\tvar finalErr error\n\tif !downloadComplete && !allChunksComplete {\n\t\tfor _, conn := range f.connections {\n\t\t\tif conn.State == connFailed && conn.failed {\n\t\t\t\t// Skip 403 errors (server connection limit) - these are expected when exceeding server's limit\n\t\t\t\tif re := extractRequestError(conn.lastErr); re != nil && re.Code == 403 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif re := extractRequestError(conn.lastErr); re != nil {\n\t\t\t\t\tfinalErr = fmt.Errorf(\"connection %d failed: retries=%d, status=%d\", conn.ID, conn.retryTimes, re.Code)\n\t\t\t\t} else if conn.lastErr != nil {\n\t\t\t\t\tfinalErr = fmt.Errorf(\"connection %d failed: retries=%d, err=%v\", conn.ID, conn.retryTimes, conn.lastErr)\n\t\t\t\t} else {\n\t\t\t\t\tfinalErr = fmt.Errorf(\"connection %d failed: retries=%d\", conn.ID, conn.retryTimes)\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\tf.connMu.Unlock()\n\n\t// Close the file before signaling completion\n\t// This ensures the file handle is released before Wait() returns\n\tf.fileMu.Lock()\n\tif f.file != nil {\n\t\tf.file.Close()\n\t\tf.file = nil\n\t}\n\tf.fileMu.Unlock()\n\n\tif finalErr != nil {\n\t\tf.setState(stateError)\n\t} else {\n\t\tf.setState(stateDone)\n\t}\n\n\tselect {\n\tcase f.doneCh <- finalErr:\n\tdefault:\n\t}\n}\n\nfunc (f *Fetcher) checkCompletion() bool {\n\t// Check if all data has been downloaded\n\tf.connMu.Lock()\n\tdefer f.connMu.Unlock()\n\n\ttotalDownloaded := int64(0)\n\tif f.resolveConn != nil {\n\t\ttotalDownloaded += f.resolveConn.Downloaded\n\t}\n\tfor _, conn := range f.connections {\n\t\ttotalDownloaded += conn.Downloaded\n\t}\n\n\tif f.meta.Res.Size > 0 && totalDownloaded >= f.meta.Res.Size {\n\t\t// Don't start a new goroutine - let the caller handle completion\n\t\treturn true\n\t}\n\n\t// Check if all connections completed\n\tallCompleted := true\n\tif f.resolveConn != nil && !f.resolveConn.Completed && f.resolveConn.State != connCompleted {\n\t\tallCompleted = false\n\t}\n\tfor _, conn := range f.connections {\n\t\tif !conn.Completed && conn.State != connCompleted && conn.State != connFailed {\n\t\t\tallCompleted = false\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif allCompleted {\n\t\t// Don't start a new goroutine - let the caller handle completion\n\t\treturn true\n\t}\n\n\treturn false\n}\n\n// Patch modifies the HTTP request information.\nfunc (f *Fetcher) Patch(req *base.Request, opts *base.Options) error {\n\t// Patch request info\n\tif req != nil {\n\t\tif req.URL != \"\" {\n\t\t\tf.meta.Req.URL = req.URL\n\t\t\t// Clear redirect URL when URL is changed, so new requests use the new URL\n\t\t\tf.updateRedirectURL(\"\")\n\t\t}\n\t\tif req.Extra != nil {\n\t\t\tif err := base.ParseReqExtra[fhttp.ReqExtra](req); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tpatchExtra := req.Extra.(*fhttp.ReqExtra)\n\t\t\t// Merge Extra fields instead of replacing entirely\n\t\t\tif f.meta.Req.Extra == nil {\n\t\t\t\tf.meta.Req.Extra = &fhttp.ReqExtra{}\n\t\t\t}\n\t\t\texistingExtra := f.meta.Req.Extra.(*fhttp.ReqExtra)\n\t\t\t// Update Method only if non-empty\n\t\t\tif patchExtra.Method != \"\" {\n\t\t\t\texistingExtra.Method = patchExtra.Method\n\t\t\t}\n\t\t\t// Update Body only if non-empty\n\t\t\tif patchExtra.Body != \"\" {\n\t\t\t\texistingExtra.Body = patchExtra.Body\n\t\t\t}\n\t\t\t// Merge Headers: existing keys are overwritten, new keys are added\n\t\t\tif patchExtra.Header != nil {\n\t\t\t\tif existingExtra.Header == nil {\n\t\t\t\t\texistingExtra.Header = make(map[string]string)\n\t\t\t\t}\n\t\t\t\tfor k, v := range patchExtra.Header {\n\t\t\t\t\texistingExtra.Header[k] = v\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t// Merge Labels: existing keys are overwritten, new keys are added\n\t\tif req.Labels != nil {\n\t\t\tif f.meta.Req.Labels == nil {\n\t\t\t\tf.meta.Req.Labels = make(map[string]string)\n\t\t\t}\n\t\t\tfor k, v := range req.Labels {\n\t\t\t\tf.meta.Req.Labels[k] = v\n\t\t\t}\n\t\t}\n\t\tif req.Proxy != nil {\n\t\t\tf.meta.Req.Proxy = req.Proxy\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (f *Fetcher) Pause() error {\n\tif f.cancel != nil {\n\t\tf.cancel()\n\t}\n\tif f.resolveCancel != nil {\n\t\tf.resolveCancel()\n\t}\n\n\t// Stop prefetch if running\n\tif f.prefetchStopCh != nil {\n\t\tselect {\n\t\tcase <-f.prefetchStopCh:\n\t\t\t// Already closed\n\t\tdefault:\n\t\t\tclose(f.prefetchStopCh)\n\t\t}\n\t}\n\n\t// Wait for downloadLoop to exit first (it will call wg.Wait internally)\n\tif f.downloadLoopDone != nil {\n\t\t<-f.downloadLoopDone\n\t}\n\n\t// Wait for all connection goroutines to stop\n\tf.wg.Wait()\n\n\t// Wait for prefetch to finish\n\tfor f.prefetchStopCh != nil && !f.prefetchDone.Load() {\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\n\t// Clean up prefetch file\n\tf.cleanupPrefetchFile()\n\n\t// Clean up resolve response if still held\n\tf.resolveRespLock.Lock()\n\tif f.resolveResp != nil {\n\t\tf.resolveResp.Body.Close()\n\t\tf.resolveResp = nil\n\t}\n\tf.resolveRespLock.Unlock()\n\n\tf.fileMu.Lock()\n\tif f.file != nil {\n\t\tf.file.Close()\n\t\tf.file = nil\n\t}\n\tf.fileMu.Unlock()\n\n\tf.setState(statePaused)\n\treturn nil\n}\n\nfunc (f *Fetcher) Close() error {\n\treturn f.Pause()\n}\n\nfunc (f *Fetcher) Meta() *fetcher.FetcherMeta {\n\treturn f.meta\n}\n\nfunc (f *Fetcher) Stats() any {\n\tf.connMu.Lock()\n\tdefer f.connMu.Unlock()\n\n\tstatsConnections := make([]*fhttp.StatsConnection, 0)\n\tfor _, connection := range f.connections {\n\t\tstatsConnections = append(statsConnections, &fhttp.StatsConnection{\n\t\t\tDownloaded: connection.Downloaded,\n\t\t\tCompleted:  connection.Completed,\n\t\t\tFailed:     connection.failed,\n\t\t\tRetryTimes: connection.retryTimes,\n\t\t})\n\t}\n\treturn &fhttp.Stats{\n\t\tConnections: statsConnections,\n\t}\n}\n\nfunc (f *Fetcher) Progress() fetcher.Progress {\n\tp := make(fetcher.Progress, 0)\n\n\ttotal := int64(0)\n\tif f.resolveConn != nil {\n\t\ttotal += f.resolveConn.Downloaded\n\t}\n\n\tf.connMu.Lock()\n\tfor _, conn := range f.connections {\n\t\ttotal += conn.Downloaded\n\t}\n\tf.connMu.Unlock()\n\n\tp = append(p, total)\n\treturn p\n}\n\nfunc (f *Fetcher) Wait() error {\n\treturn <-f.doneCh\n}\n"
  },
  {
    "path": "internal/protocol/http/fetcher_manager.go",
    "content": "package http\n\nimport (\n\t\"net/url\"\n\t\"path\"\n\n\t\"github.com/GopeedLab/gopeed/internal/fetcher\"\n\t\"github.com/GopeedLab/gopeed/pkg/base\"\n\tfhttp \"github.com/GopeedLab/gopeed/pkg/protocol/http\"\n)\n\n// ============================================================================\n// Fetcher Data (for Store/Restore)\n// ============================================================================\n\ntype fetcherData struct {\n\tConnections []*connection\n\tRedirectURL string // Saved redirect URL for resume\n}\n\n// ============================================================================\n// Fetcher Manager\n// ============================================================================\n\ntype FetcherManager struct {\n}\n\nfunc (fm *FetcherManager) Name() string {\n\treturn \"http\"\n}\n\nfunc (fm *FetcherManager) Filters() []*fetcher.SchemeFilter {\n\treturn []*fetcher.SchemeFilter{\n\t\t{\n\t\t\tType:    fetcher.FilterTypeUrl,\n\t\t\tPattern: \"HTTP\",\n\t\t},\n\t\t{\n\t\t\tType:    fetcher.FilterTypeUrl,\n\t\t\tPattern: \"HTTPS\",\n\t\t},\n\t}\n}\n\nfunc (fm *FetcherManager) Build() fetcher.Fetcher {\n\treturn &Fetcher{}\n}\n\nfunc (fm *FetcherManager) ParseName(u string) string {\n\tvar name string\n\turl, err := url.Parse(u)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tname = path.Base(url.Path)\n\tif name == \"\" || name == \"/\" || name == \".\" {\n\t\tname = url.Hostname()\n\t}\n\treturn name\n}\n\nfunc (fm *FetcherManager) AutoRename() bool {\n\treturn true\n}\n\nfunc (fm *FetcherManager) DefaultConfig() any {\n\treturn &config{\n\t\tUserAgent:   \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36\",\n\t\tConnections: 16,\n\t}\n}\n\nfunc (fm *FetcherManager) Store(f fetcher.Fetcher) (data any, err error) {\n\t_f := f.(*Fetcher)\n\t_f.redirectLock.Lock()\n\tredirectURL := _f.redirectURL\n\t_f.redirectLock.Unlock()\n\treturn &fetcherData{\n\t\tConnections: _f.connections,\n\t\tRedirectURL: redirectURL,\n\t}, nil\n}\n\nfunc (fm *FetcherManager) Restore() (v any, f func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher) {\n\treturn &fetcherData{}, func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher {\n\t\tfd := v.(*fetcherData)\n\t\tfb := &FetcherManager{}\n\t\tfetcher := fb.Build().(*Fetcher)\n\t\tfetcher.meta = meta\n\t\tbase.ParseReqExtra[fhttp.ReqExtra](fetcher.meta.Req)\n\t\tbase.ParseOptExtra[fhttp.OptsExtra](fetcher.meta.Opts)\n\t\tif len(fd.Connections) > 0 {\n\t\t\tfetcher.connections = fd.Connections\n\t\t}\n\t\t// Restore redirect URL for resume\n\t\tif fd.RedirectURL != \"\" {\n\t\t\tfetcher.redirectURL = fd.RedirectURL\n\t\t}\n\t\treturn fetcher\n\t}\n}\n\nfunc (fm *FetcherManager) Close() error {\n\treturn nil\n}\n"
  },
  {
    "path": "internal/protocol/http/fetcher_test.go",
    "content": "package http\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net\"\n\tgohttp \"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/GopeedLab/gopeed/internal/controller\"\n\t\"github.com/GopeedLab/gopeed/internal/fetcher\"\n\t\"github.com/GopeedLab/gopeed/internal/test\"\n\t\"github.com/GopeedLab/gopeed/pkg/base\"\n\t\"github.com/GopeedLab/gopeed/pkg/protocol/http\"\n)\n\nfunc TestFetcher_Resolve(t *testing.T) {\n\ttestResolve(test.StartTestFileServer, test.BuildName, t, func(err error) (*base.Resource, error) {\n\t\treturn &base.Resource{\n\t\t\tSize:  test.BuildSize,\n\t\t\tRange: true,\n\t\t\tFiles: []*base.FileInfo{\n\t\t\t\t{\n\t\t\t\t\tName: test.BuildName,\n\t\t\t\t\tSize: test.BuildSize,\n\t\t\t\t},\n\t\t\t},\n\t\t}, nil\n\t})\n\ttestResolve(test.StartTestCustomServer, \"disposition\", t, func(err error) (*base.Resource, error) {\n\t\treturn &base.Resource{\n\t\t\tSize:  test.BuildSize,\n\t\t\tRange: false,\n\t\t\tFiles: []*base.FileInfo{\n\t\t\t\t{\n\t\t\t\t\tName: test.BuildName,\n\t\t\t\t\tSize: test.BuildSize,\n\t\t\t\t},\n\t\t\t},\n\t\t}, nil\n\t})\n\ttestResolve(test.StartTestCustomServer, \"encoded-word\", t, func(err error) (*base.Resource, error) {\n\t\treturn &base.Resource{\n\t\t\tSize:  test.BuildSize,\n\t\t\tRange: false,\n\t\t\tFiles: []*base.FileInfo{\n\t\t\t\t{\n\t\t\t\t\tName: test.TestChineseFileName,\n\t\t\t\t\tSize: test.BuildSize,\n\t\t\t\t},\n\t\t\t},\n\t\t}, nil\n\t})\n\ttestResolve(test.StartTestCustomServer, \"no-encode\", t, func(err error) (*base.Resource, error) {\n\t\treturn &base.Resource{\n\t\t\tSize:  test.BuildSize,\n\t\t\tRange: false,\n\t\t\tFiles: []*base.FileInfo{\n\t\t\t\t{\n\t\t\t\t\tName: test.TestChineseFileName,\n\t\t\t\t\tSize: test.BuildSize,\n\t\t\t\t},\n\t\t\t},\n\t\t}, nil\n\t})\n\ttestResolve(test.StartTestCustomServer, \"%E6%B5%8B%E8%AF%95.zip\", t, func(err error) (*base.Resource, error) {\n\t\treturn &base.Resource{\n\t\t\tSize:  0,\n\t\t\tRange: false,\n\t\t\tFiles: []*base.FileInfo{\n\t\t\t\t{\n\t\t\t\t\tName: test.TestChineseFileName,\n\t\t\t\t\tSize: 0,\n\t\t\t\t},\n\t\t\t},\n\t\t}, nil\n\t})\n\ttestResolve(test.StartTestCustomServer, test.BuildName, t, func(err error) (*base.Resource, error) {\n\t\treturn &base.Resource{\n\t\t\tSize:  0,\n\t\t\tRange: false,\n\t\t\tFiles: []*base.FileInfo{\n\t\t\t\t{\n\t\t\t\t\tName: test.BuildName,\n\t\t\t\t\tSize: 0,\n\t\t\t\t},\n\t\t\t},\n\t\t}, nil\n\t})\n\t// Test mixed encoding Content-Disposition where mime.ParseMediaType fails\n\t// due to invalid characters, but filename*= contains the correct UTF-8 encoded name\n\ttestResolve(test.StartTestCustomServer, \"mixed-encoding\", t, func(err error) (*base.Resource, error) {\n\t\treturn &base.Resource{\n\t\t\tSize:  test.BuildSize,\n\t\t\tRange: false,\n\t\t\tFiles: []*base.FileInfo{\n\t\t\t\t{\n\t\t\t\t\tName: test.TestChineseFileName,\n\t\t\t\t\tSize: test.BuildSize,\n\t\t\t\t},\n\t\t\t},\n\t\t}, nil\n\t})\n\t// Test filename*= only (RFC 5987 format)\n\ttestResolve(test.StartTestCustomServer, \"filename-star\", t, func(err error) (*base.Resource, error) {\n\t\treturn &base.Resource{\n\t\t\tSize:  test.BuildSize,\n\t\t\tRange: false,\n\t\t\tFiles: []*base.FileInfo{\n\t\t\t\t{\n\t\t\t\t\tName: test.TestChineseFileName,\n\t\t\t\t\tSize: test.BuildSize,\n\t\t\t\t},\n\t\t\t},\n\t\t}, nil\n\t})\n\t// Test GBK-encoded filename (common on Chinese Windows servers)\n\t// Before fix: \"测试.zip\" sent as GBK bytes -> parsed as \"²âÊÔ.zip\" (garbled)\n\t// After fix: correctly decoded back to \"测试.zip\"\n\ttestResolve(test.StartTestCustomServer, \"gbk-encoded\", t, func(err error) (*base.Resource, error) {\n\t\treturn &base.Resource{\n\t\t\tSize:  test.BuildSize,\n\t\t\tRange: false,\n\t\t\tFiles: []*base.FileInfo{\n\t\t\t\t{\n\t\t\t\t\tName: test.TestChineseFileName,\n\t\t\t\t\tSize: test.BuildSize,\n\t\t\t\t},\n\t\t\t},\n\t\t}, nil\n\t})\n\t// Test filename with plus signs (e.g., C++ Primer)\n\t// Before fix: %2B decoded to space -> \"C++ Primer\" became \"C  Primer\"\n\t// After fix: %2B correctly decoded to + -> \"C++  Primer  Plus.mobi\"\n\ttestResolve(test.StartTestCustomServer, \"plus-sign-encoded\", t, func(err error) (*base.Resource, error) {\n\t\treturn &base.Resource{\n\t\t\tSize:  test.BuildSize,\n\t\t\tRange: false,\n\t\t\tFiles: []*base.FileInfo{\n\t\t\t\t{\n\t\t\t\t\tName: \"C++  Primer  Plus.mobi\",\n\t\t\t\t\tSize: test.BuildSize,\n\t\t\t\t},\n\t\t\t},\n\t\t}, nil\n\t})\n\t// Test filename with plus sign in URL path\n\t// Before fix: %2B decoded to space\n\t// After fix: %2B correctly decoded to +\n\ttestResolve(test.StartTestCustomServer, \"C%2B%2B%20Primer.txt\", t, func(err error) (*base.Resource, error) {\n\t\treturn &base.Resource{\n\t\t\tSize:  0,\n\t\t\tRange: false,\n\t\t\tFiles: []*base.FileInfo{\n\t\t\t\t{\n\t\t\t\t\tName: \"C++ Primer.txt\",\n\t\t\t\t\tSize: 0,\n\t\t\t\t},\n\t\t\t},\n\t\t}, nil\n\t})\n\t// Test filename with HTML-encoded ampersand (fixes issue with & being truncated)\n\t// Before fix: \"查询处理&amp;优化.pptx\" -> \"查询处理&amp\" (truncated at semicolon)\n\t// After fix: correctly decoded to \"查询处理&优化.pptx\"\n\ttestResolve(test.StartTestCustomServer, \"ampersand-encoded\", t, func(err error) (*base.Resource, error) {\n\t\treturn &base.Resource{\n\t\t\tSize:  test.BuildSize,\n\t\t\tRange: false,\n\t\t\tFiles: []*base.FileInfo{\n\t\t\t\t{\n\t\t\t\t\tName: \"查询处理&优化.pptx\",\n\t\t\t\t\tSize: test.BuildSize,\n\t\t\t\t},\n\t\t\t},\n\t\t}, nil\n\t})\n\t// Test unquoted filename with HTML-encoded ampersand\n\ttestResolve(test.StartTestCustomServer, \"ampersand-unquoted\", t, func(err error) (*base.Resource, error) {\n\t\treturn &base.Resource{\n\t\t\tSize:  test.BuildSize,\n\t\t\tRange: false,\n\t\t\tFiles: []*base.FileInfo{\n\t\t\t\t{\n\t\t\t\t\tName: \"test&file.txt\",\n\t\t\t\t\tSize: test.BuildSize,\n\t\t\t\t},\n\t\t\t},\n\t\t}, nil\n\t})\n\t// Test URL without file path - should use domain/host as filename\n\ttestResolve(test.StartTestCustomServer, \"\", t, func(err error) (*base.Resource, error) {\n\t\treturn &base.Resource{\n\t\t\tSize:  0,\n\t\t\tRange: false,\n\t\t\tFiles: []*base.FileInfo{\n\t\t\t\t{\n\t\t\t\t\tName: \"127.0.0.1\",\n\t\t\t\t\tSize: 0,\n\t\t\t\t},\n\t\t\t},\n\t\t}, nil\n\t})\n\t// Test 403 Forbidden response handling\n\ttestResolve(test.StartTestCustomServer, \"forbidden\", t, func(err error) (*base.Resource, error) {\n\t\trequestError := extractRequestError(err)\n\t\tif requestError != nil && requestError.Code == 403 {\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, err\n\t})\n}\n\nfunc TestFetcher_ResolveWithHostHeader(t *testing.T) {\n\tlistener := test.StartTestHostHeaderServer()\n\tdefer listener.Close()\n\n\tfetcher := buildFetcher()\n\terr := fetcher.Resolve(&base.Request{\n\t\tURL: \"http://\" + listener.Addr().String() + \"/\",\n\t\tExtra: &http.ReqExtra{\n\t\t\tHeader: map[string]string{\n\t\t\t\t\"Host\": \"test\",\n\t\t\t},\n\t\t},\n\t}, &base.Options{\n\t\tName: test.DownloadName,\n\t\tPath: test.Dir,\n\t})\n\t// The server should return 400 for invalid Host header\n\tif err == nil || !strings.Contains(err.Error(), \"400\") {\n\t\tt.Errorf(\"Resolve() got = %v, want error containing 400\", err)\n\t}\n}\n\nfunc TestFetcher_ResolveWithInvalidHeader(t *testing.T) {\n\tlistener := test.StartTestCustomServer()\n\tdefer listener.Close()\n\n\tfetcher := buildFetcher()\n\tdefer fetcher.Pause() // Close the resolve response to allow server shutdown\n\terr := fetcher.Resolve(&base.Request{\n\t\tURL: \"http://\" + listener.Addr().String() + \"/\",\n\t\tExtra: &http.ReqExtra{\n\t\t\tHeader: map[string]string{\n\t\t\t\t\"Referer\": \"\\rtest\",\n\t\t\t},\n\t\t},\n\t}, &base.Options{\n\t\tName: test.DownloadName,\n\t\tPath: test.Dir,\n\t})\n\t// Invalid header with \\r should be sanitized by Go's http client, allowing the request to succeed\n\tif err != nil {\n\t\tt.Errorf(\"Resolve() got = %v, want nil (invalid headers should be sanitized)\", err)\n\t}\n}\n\nfunc testResolve(startTestServer func() net.Listener, path string, t *testing.T, wantFn func(error) (*base.Resource, error)) {\n\tlistener := startTestServer()\n\tdefer listener.Close()\n\tfetcher := buildFetcher()\n\tdefer fetcher.Pause() // Close the resolve response to allow server shutdown\n\terr := fetcher.Resolve(&base.Request{\n\t\tURL: \"http://\" + listener.Addr().String() + \"/\" + path,\n\t}, &base.Options{\n\t\tName: test.DownloadName,\n\t\tPath: test.Dir,\n\t})\n\twant, err := wantFn(err)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif want != nil && !test.AssertResourceEqual(want, fetcher.meta.Res) {\n\t\tt.Errorf(\"Resolve() got = %+v, want %+v\", fetcher.meta.Res, want)\n\t}\n}\n\nfunc TestFetcher_DownloadNormal(t *testing.T) {\n\tlistener := test.StartTestFileServer()\n\tdefer listener.Close()\n\n\tdownloadNormal(listener, 1, t)\n\tdownloadNormal(listener, 5, t)\n\tdownloadNormal(listener, 8, t)\n\tdownloadNormal(listener, 16, t)\n}\n\nfunc TestFetcher_DownloadContinue(t *testing.T) {\n\tlistener := test.StartTestFileServer()\n\tdefer listener.Close()\n\n\tdownloadContinue(listener, 1, t)\n\tdownloadContinue(listener, 5, t)\n\tdownloadContinue(listener, 8, t)\n\tdownloadContinue(listener, 16, t)\n}\n\nfunc TestFetcher_DownloadContinue_NoRangeRestart(t *testing.T) {\n\tlistener := test.StartTestNoRangeSlowServer(time.Millisecond)\n\tdefer listener.Close()\n\n\tfetcher := downloadReady(listener, 4, t)\n\tif err := fetcher.Start(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\ttime.Sleep(20 * time.Millisecond)\n\n\tstats := fetcher.Stats().(*http.Stats)\n\tif len(stats.Connections) != 1 {\n\t\tt.Fatalf(\"expected a single non-range connection, got %d\", len(stats.Connections))\n\t}\n\tif stats.Connections[0].Downloaded <= 0 || stats.Connections[0].Downloaded >= test.BuildSize {\n\t\tt.Fatalf(\"expected partial download before pause, got %d\", stats.Connections[0].Downloaded)\n\t}\n\n\tif err := fetcher.Pause(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := fetcher.Start(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := fetcher.Wait(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tfinalStats := fetcher.Stats().(*http.Stats)\n\tif len(finalStats.Connections) != 1 {\n\t\tt.Fatalf(\"expected a single non-range connection after resume, got %d\", len(finalStats.Connections))\n\t}\n\tif finalStats.Connections[0].Downloaded != test.BuildSize {\n\t\tt.Fatalf(\"downloaded bytes should restart cleanly: got %d, want %d\", finalStats.Connections[0].Downloaded, test.BuildSize)\n\t}\n\tif total := fetcher.Progress().TotalDownloaded(); total != test.BuildSize {\n\t\tt.Fatalf(\"progress total = %d, want %d\", total, test.BuildSize)\n\t}\n\n\twant := test.FileMd5(test.BuildFile)\n\tgot := test.FileMd5(test.DownloadFile)\n\tif want != got {\n\t\tt.Errorf(\"Download() got = %v, want %v\", got, want)\n\t}\n}\n\nfunc TestFetcher_DownloadChunked(t *testing.T) {\n\tlistener := test.StartTestCustomServer()\n\tdefer listener.Close()\n\n\tdownloadNormal(listener, 1, t)\n\tdownloadNormal(listener, 2, t)\n}\n\nfunc TestFetcher_DownloadPost(t *testing.T) {\n\tlistener := test.StartTestPostServer()\n\tdefer listener.Close()\n\n\tdownloadPost(listener, 1, t)\n}\n\nfunc TestFetcher_DownloadRetry(t *testing.T) {\n\tlistener := test.StartTestRetryServer()\n\tdefer listener.Close()\n\n\tdownloadNormal(listener, 1, t)\n}\n\nfunc TestFetcher_DownloadError(t *testing.T) {\n\tlistener := test.StartTestErrorServer()\n\tdefer listener.Close()\n\n\tdownloadError(listener, 1, t)\n}\n\nfunc TestFetcher_DownloadLimit(t *testing.T) {\n\tlistener := test.StartTestLimitServer(4, 0)\n\tdefer listener.Close()\n\n\tdownloadNormal(listener, 1, t)\n\tdownloadNormal(listener, 2, t)\n\tdownloadNormal(listener, 8, t)\n}\n\nfunc TestFetcher_DownloadResponseBodyReadTimeout(t *testing.T) {\n\t// Server will timeout once (first request delays longer than readTimeout),\n\t// then subsequent requests work normally\n\tlistener := test.StartTestTimeoutOnceServer(readTimeout.Milliseconds() + 5000)\n\tdefer listener.Close()\n\n\tfor _, connections := range []int{1, 4} {\n\t\tos.Remove(test.DownloadFile)\n\n\t\tfetcher := downloadReady(listener, connections, t)\n\t\tif err := fetcher.Start(); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif err := fetcher.Wait(); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\tstats := fetcher.Stats().(*http.Stats)\n\t\tif len(stats.Connections) == 0 {\n\t\t\tt.Fatalf(\"expected connections stats for timeout test\")\n\t\t}\n\n\t\t// Verify successful download after timeout recovery\n\t\twant := test.FileMd5(test.BuildFile)\n\t\tgot := test.FileMd5(test.DownloadFile)\n\t\tif want != got {\n\t\t\tt.Errorf(\"Download() got = %v, want %v\", got, want)\n\t\t}\n\n\t\t// Verify timeouts don't count as failures (retryTimes should be 0)\n\t\tfor _, conn := range stats.Connections {\n\t\t\tif conn.Failed {\n\t\t\t\tt.Fatalf(\"expected no counted failures after timeout recovery, got retries=%d\", conn.RetryTimes)\n\t\t\t}\n\t\t\tif conn.RetryTimes != 0 {\n\t\t\t\tt.Fatalf(\"expected retryTimes to stay zero for non-counted timeouts, got %d\", conn.RetryTimes)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc TestFetcher_Download500Recovery(t *testing.T) {\n\t// Server returns 500 for 15 seconds, then recovers\n\tlistener := test.StartTestTemporary500Server(15 * time.Second)\n\tdefer listener.Close()\n\n\tos.Remove(test.DownloadFile)\n\tfetcher := downloadReady(listener, 4, t)\n\tif err := fetcher.Start(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := fetcher.Wait(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Verify successful download after 500 errors\n\twant := test.FileMd5(test.BuildFile)\n\tgot := test.FileMd5(test.DownloadFile)\n\tif want != got {\n\t\tt.Errorf(\"Download() got = %v, want %v\", got, want)\n\t}\n\n\t// Verify 500 errors don't count as failures (retryTimes should be 0)\n\tstats := fetcher.Stats().(*http.Stats)\n\tfor _, conn := range stats.Connections {\n\t\tif conn.RetryTimes != 0 {\n\t\t\tt.Errorf(\"Expected retryTimes to be 0 for 500 errors (exempt), got %d\", conn.RetryTimes)\n\t\t}\n\t}\n}\n\nfunc TestFetcher_DownloadOnBugFileServer(t *testing.T) {\n\tlistener := test.StartTestRangeBugServer()\n\tdefer listener.Close()\n\n\tdownloadNormal(listener, 1, t)\n\tdownloadNormal(listener, 4, t)\n}\n\nfunc TestFetcher_DownloadResume(t *testing.T) {\n\tlistener := test.StartTestFileServer()\n\tdefer listener.Close()\n\n\tdownloadResume(listener, 1, t)\n\tdownloadResume(listener, 5, t)\n\tdownloadResume(listener, 8, t)\n\tdownloadResume(listener, 16, t)\n}\n\nfunc TestFetcher_DownloadWithProxy(t *testing.T) {\n\thttpListener := test.StartTestFileServer()\n\tdefer httpListener.Close()\n\tproxyListener := test.StartSocks5Server(\"\", \"\")\n\tdefer proxyListener.Close()\n\n\tdownloadWithProxy(httpListener, proxyListener, t)\n}\n\nfunc TestFetcher_ConfigConnections(t *testing.T) {\n\tlistener := test.StartTestFileServer()\n\tdefer listener.Close()\n\tfetcher := doDownloadReady(buildConfigFetcher(config{\n\t\tConnections: 16,\n\t}), listener, 0, t)\n\terr := fetcher.Start()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\terr = fetcher.Wait()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := test.FileMd5(test.BuildFile)\n\tgot := test.FileMd5(test.DownloadFile)\n\tif want != got {\n\t\tt.Errorf(\"Download() got = %v, want %v\", got, want)\n\t}\n}\n\nfunc TestFetcher_ConfigUseServerCtime(t *testing.T) {\n\tlistener := test.StartTestFileServer()\n\tdefer listener.Close()\n\tfetcher := doDownloadReady(buildConfigFetcher(config{\n\t\tConnections:    16,\n\t\tUseServerCtime: true,\n\t}), listener, 0, t)\n\terr := fetcher.Start()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\terr = fetcher.Wait()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := test.FileMd5(test.BuildFile)\n\tgot := test.FileMd5(test.DownloadFile)\n\tif want != got {\n\t\tt.Errorf(\"Download() got = %v, want %v\", got, want)\n\t}\n}\n\nfunc TestFetcher_Stats(t *testing.T) {\n\tlistener := test.StartTestFileServer()\n\tdefer listener.Close()\n\tfetcher := doDownloadReady(buildConfigFetcher(config{\n\t\tConnections: 16,\n\t}), listener, 0, t)\n\terr := fetcher.Start()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\terr = fetcher.Wait()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tstats := fetcher.Stats().(*http.Stats)\n\t// With slow-start strategy, connection count may be less than max if download is fast\n\t// Just verify we have at least 1 connection and no more than max\n\tif len(stats.Connections) < 1 || len(stats.Connections) > 16 {\n\t\tt.Errorf(\"Stats() connection count got = %v, want between 1 and 16\", len(stats.Connections))\n\t}\n\ttotalDownloaded := int64(0)\n\tfor i, conn := range stats.Connections {\n\t\tt.Logf(\"Connection %d: Downloaded=%d, Completed=%v\", i, conn.Downloaded, conn.Completed)\n\t\ttotalDownloaded += conn.Downloaded\n\t}\n\tif totalDownloaded != test.BuildSize {\n\t\tt.Errorf(\"Stats() got = %v, want %v\", totalDownloaded, test.BuildSize)\n\t}\n}\n\n// TestFetcher_DownloadOneTimeURL tests downloading from a URL that can only be accessed once\n// This simulates signed URLs or one-time download links that expire after first use\nfunc TestFetcher_DownloadOneTimeURL(t *testing.T) {\n\tlistener := test.StartTestOneTimeServer()\n\tdefer listener.Close()\n\n\tfetcher := buildFetcher()\n\terr := fetcher.Resolve(&base.Request{\n\t\tURL: \"http://\" + listener.Addr().String() + \"/\" + test.BuildName,\n\t}, &base.Options{\n\t\tName: test.DownloadName,\n\t\tPath: test.Dir,\n\t\tExtra: &http.OptsExtra{\n\t\t\tConnections: 4, // Try to use multiple connections, but only first should work\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\terr = fetcher.Start()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\terr = fetcher.Wait()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Verify file content\n\twant := test.FileMd5(test.BuildFile)\n\tgot := test.FileMd5(test.DownloadFile)\n\tif want != got {\n\t\tt.Errorf(\"Download() got = %v, want %v\", got, want)\n\t}\n}\n\n// TestFetcher_SlowStartExpansion tests slow-start connection expansion edge cases\n// Tests that slow-start expansion reaches exactly maxConns\n// Expansion pattern: 1 -> 2 -> 4 -> 8 -> 16...\n// For max=5: 1 -> 2 -> 4 -> 5 (capped)\n// For max=9: 1 -> 2 -> 4 -> 8 -> 9 (capped)\nfunc TestFetcher_SlowStartExpansion(t *testing.T) {\n\ttestCases := []struct {\n\t\tname     string\n\t\tmaxConns int\n\t}{\n\t\t{\"MaxConns5\", 5}, // 1->2->4->5\n\t\t{\"MaxConns9\", 9}, // 1->2->4->8->9\n\t\t{\"MaxConns8\", 8}, // 1->2->4->8\n\t}\n\n\tfor _, tc := range testCases {\n\t\ttc := tc // capture range variable\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\t// Clean up any leftover files from previous tests\n\t\t\tos.Remove(test.DownloadFile)\n\n\t\t\t// Use 100ns delay per byte for faster test (~10MB/s theoretical)\n\t\t\tlistener := test.StartTestSlowStartServer(100 * time.Nanosecond)\n\n\t\t\t// Ensure cleanup happens before next subtest\n\t\t\tcleanup := func() {\n\t\t\t\tlistener.Close()\n\t\t\t\tos.Remove(test.DownloadFile)\n\t\t\t\t// Wait for server to fully stop\n\t\t\t\ttime.Sleep(50 * time.Millisecond)\n\t\t\t}\n\n\t\t\tfetcher := buildConfigFetcher(config{\n\t\t\t\tConnections: tc.maxConns,\n\t\t\t})\n\n\t\t\terr := fetcher.Resolve(&base.Request{\n\t\t\t\tURL: \"http://\" + listener.Addr().String() + \"/\" + test.BuildName,\n\t\t\t}, &base.Options{\n\t\t\t\tName: test.DownloadName,\n\t\t\t\tPath: test.Dir,\n\t\t\t\tExtra: &http.OptsExtra{\n\t\t\t\t\tConnections: tc.maxConns,\n\t\t\t\t},\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\tcleanup()\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\n\t\t\terr = fetcher.Start()\n\t\t\tif err != nil {\n\t\t\t\tcleanup()\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\n\t\t\terr = fetcher.Wait()\n\t\t\tif err != nil {\n\t\t\t\tt.Logf(\"Wait() returned error: %v\", err)\n\t\t\t\tcleanup()\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\n\t\t\t// Check final connection count equals maxConns exactly\n\t\t\tstats := fetcher.Stats().(*http.Stats)\n\t\t\tfinalConns := len(stats.Connections)\n\n\t\t\t// Debug: show connection details and metadata\n\t\t\thttpFetcher := fetcher.(*Fetcher)\n\t\t\tt.Logf(\"Resource: Size=%d, Range=%v\", httpFetcher.Meta().Res.Size, httpFetcher.Meta().Res.Range)\n\t\t\tfor i, conn := range stats.Connections {\n\t\t\t\tt.Logf(\"Connection %d: Downloaded=%d, Completed=%v\", i, conn.Downloaded, conn.Completed)\n\t\t\t}\n\n\t\t\tif finalConns != tc.maxConns {\n\t\t\t\tt.Errorf(\"Expected exactly %d connections, got %d\", tc.maxConns, finalConns)\n\t\t\t}\n\n\t\t\t// Verify file content before cleanup\n\t\t\twant := test.FileMd5(test.BuildFile)\n\t\t\tgot := test.FileMd5(test.DownloadFile)\n\t\t\tif want != got {\n\t\t\t\tt.Errorf(\"Download() got = %v, want %v\", got, want)\n\t\t\t}\n\n\t\t\tcleanup()\n\t\t})\n\t}\n}\n\n// TestFetcher_AsyncPrefetch tests the async prefetch functionality\n// where data is downloaded in background during resolve phase and reused in start\nfunc TestFetcher_AsyncPrefetch(t *testing.T) {\n\t// Test 1: Prefetch completes entire file before Start is called\n\tt.Run(\"PrefetchComplete\", func(t *testing.T) {\n\t\tlistener := test.StartTestFileServer()\n\t\tdefer listener.Close()\n\n\t\tfetcher := buildFetcher()\n\t\terr := fetcher.Resolve(&base.Request{\n\t\t\tURL: \"http://\" + listener.Addr().String() + \"/\" + test.BuildName,\n\t\t}, &base.Options{\n\t\t\tName: test.DownloadName,\n\t\t\tPath: test.Dir,\n\t\t\tExtra: &http.OptsExtra{\n\t\t\t\tConnections: 4,\n\t\t\t},\n\t\t})\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\t// Poll until prefetch completes the entire file (with timeout)\n\t\ttimeout := time.After(30 * time.Second)\n\t\tticker := time.NewTicker(100 * time.Millisecond)\n\t\tdefer ticker.Stop()\n\tpollLoop:\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-timeout:\n\t\t\t\tt.Fatal(\"Timeout waiting for prefetch to complete\")\n\t\t\tcase <-ticker.C:\n\t\t\t\tif fetcher.prefetchSize.Load() >= test.BuildSize {\n\t\t\t\t\tbreak pollLoop\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tprefetchedBefore := fetcher.prefetchSize.Load()\n\t\tt.Logf(\"Prefetched bytes before Start: %d (%.2f MB)\", prefetchedBefore, float64(prefetchedBefore)/(1024*1024))\n\n\t\t// Should have prefetched the entire file\n\t\tif prefetchedBefore != test.BuildSize {\n\t\t\tt.Errorf(\"Prefetch should complete entire file, got %d, want %d\", prefetchedBefore, test.BuildSize)\n\t\t}\n\n\t\t// Now start the download\n\t\terr = fetcher.Start()\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\t// Wait for download to complete\n\t\terr = fetcher.Wait()\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\t// Check how much was utilized from prefetch\n\t\tprefetchedUsed := fetcher.resolveDataPos.Load()\n\t\tt.Logf(\"Prefetched bytes used: %d (%.2f MB)\", prefetchedUsed, float64(prefetchedUsed)/(1024*1024))\n\n\t\t// Verify file is correct\n\t\twant := test.FileMd5(test.BuildFile)\n\t\tgot := test.FileMd5(test.DownloadFile)\n\t\tif want != got {\n\t\t\tt.Errorf(\"Download() got = %v, want %v\", got, want)\n\t\t}\n\n\t\tos.Remove(test.DownloadFile)\n\t})\n\n\t// Test 2: Prefetch only downloads partial data before Start is called\n\tt.Run(\"PrefetchPartial\", func(t *testing.T) {\n\t\t// Use slow server with 100 nanosecond delay per byte\n\t\t// This means ~10MB/s speed, so 100ms should download ~1MB\n\t\tlistener := test.StartTestSlowStartServer(100 * time.Nanosecond)\n\t\tdefer listener.Close()\n\n\t\tfetcher := buildFetcher()\n\t\terr := fetcher.Resolve(&base.Request{\n\t\t\tURL: \"http://\" + listener.Addr().String() + \"/\" + test.BuildName,\n\t\t}, &base.Options{\n\t\t\tName: test.DownloadName,\n\t\t\tPath: test.Dir,\n\t\t\tExtra: &http.OptsExtra{\n\t\t\t\tConnections: 4,\n\t\t\t},\n\t\t})\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\t// Wait only 100ms - should only prefetch a small portion\n\t\ttime.Sleep(100 * time.Millisecond)\n\n\t\tprefetchedBefore := fetcher.prefetchSize.Load()\n\t\tt.Logf(\"Prefetched bytes before Start: %d (%.2f KB)\", prefetchedBefore, float64(prefetchedBefore)/1024)\n\n\t\t// Verify we have partial data (not zero, but not complete)\n\t\tif prefetchedBefore == 0 {\n\t\t\tt.Log(\"Warning: No data prefetched, may be too slow\")\n\t\t}\n\t\tif prefetchedBefore >= test.BuildSize {\n\t\t\tt.Log(\"Warning: Prefetch completed entire file, test may not be valid\")\n\t\t}\n\n\t\t// Now start the download\n\t\terr = fetcher.Start()\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\t// Wait for download to complete\n\t\terr = fetcher.Wait()\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\t// Check stats - should have connections that downloaded remaining data\n\t\tstats := fetcher.Stats().(*http.Stats)\n\t\tt.Logf(\"Final connections: %d\", len(stats.Connections))\n\n\t\tprefetchedUsed := fetcher.resolveDataPos.Load()\n\t\tt.Logf(\"Prefetched bytes used: %d (%.2f KB)\", prefetchedUsed, float64(prefetchedUsed)/1024)\n\n\t\t// Verify connections picked up where prefetch left off\n\t\tif len(stats.Connections) > 0 {\n\t\t\tfirstConn := stats.Connections[0]\n\t\t\tt.Logf(\"First connection downloaded: %d bytes\", firstConn.Downloaded)\n\t\t}\n\n\t\t// Verify file is correct\n\t\twant := test.FileMd5(test.BuildFile)\n\t\tgot := test.FileMd5(test.DownloadFile)\n\t\tif want != got {\n\t\t\tt.Errorf(\"Download() got = %v, want %v\", got, want)\n\t\t}\n\n\t\tos.Remove(test.DownloadFile)\n\t})\n}\n\n// TestFetcher_DownloadExpiringRedirectURL tests that the fetcher correctly handles\n// expiring redirect URLs by falling back to the original URL and getting a new redirect.\nfunc TestFetcher_DownloadExpiringRedirectURL(t *testing.T) {\n\t// Test with redirect expiring after 2 requests\n\t// This means:\n\t// - Request 1: Resolve (original URL redirects to temp URL v1)\n\t// - Request 2: First download request to temp URL v1 succeeds\n\t// - Request 3: Second download request to temp URL v1 returns 403\n\t// - Fetcher should then retry with original URL, get temp URL v2\n\t// - Continue downloading from temp URL v2\n\tt.Run(\"RedirectExpiresAfter2Requests\", func(t *testing.T) {\n\t\tos.Remove(test.DownloadFile)\n\n\t\t// Create server with redirect expiring after 2 requests, with slow transfer to ensure\n\t\t// multiple connection attempts are needed\n\t\tlistener := test.StartTestExpiringRedirectServer(2, 100*time.Nanosecond)\n\t\tdefer listener.Close()\n\n\t\tfetcher := buildFetcher()\n\t\terr := fetcher.Resolve(&base.Request{\n\t\t\tURL: \"http://\" + listener.Addr().String() + \"/\" + test.BuildName,\n\t\t}, &base.Options{\n\t\t\tName: test.DownloadName,\n\t\t\tPath: test.Dir,\n\t\t\tExtra: &http.OptsExtra{\n\t\t\t\tConnections: 4, // Use multiple connections to trigger redirect expiration\n\t\t\t},\n\t\t})\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\terr = fetcher.Start()\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\terr = fetcher.Wait()\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\t// Verify file content is correct despite redirect expiration\n\t\twant := test.FileMd5(test.BuildFile)\n\t\tgot := test.FileMd5(test.DownloadFile)\n\t\tif want != got {\n\t\t\tt.Errorf(\"Download() got = %v, want %v\", got, want)\n\t\t}\n\n\t\tos.Remove(test.DownloadFile)\n\t})\n\n\t// Test with redirect expiring after 5 requests (more room for initial connections)\n\tt.Run(\"RedirectExpiresAfter5Requests\", func(t *testing.T) {\n\t\tos.Remove(test.DownloadFile)\n\n\t\tlistener := test.StartTestExpiringRedirectServer(5, 100*time.Nanosecond)\n\t\tdefer listener.Close()\n\n\t\tfetcher := buildFetcher()\n\t\terr := fetcher.Resolve(&base.Request{\n\t\t\tURL: \"http://\" + listener.Addr().String() + \"/\" + test.BuildName,\n\t\t}, &base.Options{\n\t\t\tName: test.DownloadName,\n\t\t\tPath: test.Dir,\n\t\t\tExtra: &http.OptsExtra{\n\t\t\t\tConnections: 8,\n\t\t\t},\n\t\t})\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\terr = fetcher.Start()\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\terr = fetcher.Wait()\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\t// Verify file content\n\t\twant := test.FileMd5(test.BuildFile)\n\t\tgot := test.FileMd5(test.DownloadFile)\n\t\tif want != got {\n\t\t\tt.Errorf(\"Download() got = %v, want %v\", got, want)\n\t\t}\n\n\t\tos.Remove(test.DownloadFile)\n\t})\n}\n\n// TestFetcher_RetryAfterError tests that the fetcher can retry downloading\n// after a previous download attempt failed by calling Start() again.\nfunc TestFetcher_RetryAfterError(t *testing.T) {\n\tos.Remove(test.DownloadFile)\n\n\t// Server fails first 3 requests (after resolve), then recovers\n\t// With 1 connection and 3 retries:\n\t// - First Start(): requests 2, 3, 4 → all fail (3 retries exhausted) → returns error\n\t// - Second Start(): request 5 → succeeds (server recovered after 3 failures)\n\tlistener := test.StartTestFailThenRecoverServer(3)\n\tdefer listener.Close()\n\n\tfetcher := buildFetcher()\n\terr := fetcher.Resolve(&base.Request{\n\t\tURL: \"http://\" + listener.Addr().String() + \"/\" + test.BuildName,\n\t}, &base.Options{\n\t\tName: test.DownloadName,\n\t\tPath: test.Dir,\n\t\tExtra: &http.OptsExtra{\n\t\t\tConnections: 1, // Use single connection to simplify test\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// First download attempt - should fail because server returns 416 after resolve\n\terr = fetcher.Start()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\terr = fetcher.Wait()\n\t// First attempt should fail with 416 error\n\tif err == nil {\n\t\tt.Fatal(\"Expected first download attempt to fail, but it succeeded\")\n\t}\n\tt.Logf(\"First attempt failed as expected: %v\", err)\n\n\t// Check that fetcher is in error state\n\tstate := fetcher.getState()\n\tif state != stateError {\n\t\tt.Errorf(\"Expected fetcher to be in stateError, got %v\", state)\n\t}\n\n\t// Verify that we can call Start() again after error\n\t// This tests the stateError handling in Start()\n\terr = fetcher.Start()\n\tif err != nil {\n\t\tt.Fatalf(\"Start() after error failed: %v\", err)\n\t}\n\n\t// Wait for second attempt - should succeed now that server has recovered\n\terr = fetcher.Wait()\n\tif err != nil {\n\t\tt.Fatalf(\"Retry failed: %v\", err)\n\t}\n\n\t// Verify file content\n\twant := test.FileMd5(test.BuildFile)\n\tgot := test.FileMd5(test.DownloadFile)\n\tif want != got {\n\t\tt.Errorf(\"Download() got = %v, want %v\", got, want)\n\t}\n\n\tos.Remove(test.DownloadFile)\n}\n\nfunc TestFetcherManager_ParseName(t *testing.T) {\n\ttype args struct {\n\t\tu string\n\t}\n\ttests := []struct {\n\t\tname string\n\t\targs args\n\t\twant string\n\t}{\n\t\t{\n\t\t\tname: \"broken url\",\n\t\t\targs: args{\n\t\t\t\tu: \"https://!@#%github.com\",\n\t\t\t},\n\t\t\twant: \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"file path\",\n\t\t\targs: args{\n\t\t\t\tu: \"https://github.com/index.html\",\n\t\t\t},\n\t\t\twant: \"index.html\",\n\t\t},\n\t\t{\n\t\t\tname: \"file path with query and hash\",\n\t\t\targs: args{\n\t\t\t\tu: \"https://github.com/a/b/index.html/#list?name=1\",\n\t\t\t},\n\t\t\twant: \"index.html\",\n\t\t},\n\t\t{\n\t\t\tname: \"no file path\",\n\t\t\targs: args{\n\t\t\t\tu: \"https://github.com\",\n\t\t\t},\n\t\t\twant: \"github.com\",\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tfm := &FetcherManager{}\n\t\t\tif got := fm.ParseName(tt.args.u); got != tt.want {\n\t\t\t\tt.Errorf(\"ParseName() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc downloadReady(listener net.Listener, connections int, t *testing.T) fetcher.Fetcher {\n\treturn doDownloadReady(buildFetcher(), listener, connections, t)\n}\n\nfunc doDownloadReady(f fetcher.Fetcher, listener net.Listener, connections int, t *testing.T) fetcher.Fetcher {\n\tvar extra any = nil\n\tif connections > 0 {\n\t\textra = &http.OptsExtra{\n\t\t\tConnections: connections,\n\t\t}\n\t}\n\topts := &base.Options{\n\t\tName:  test.DownloadName,\n\t\tPath:  test.Dir,\n\t\tExtra: extra,\n\t}\n\terr := f.Resolve(&base.Request{\n\t\tURL: \"http://\" + listener.Addr().String() + \"/\" + test.BuildName,\n\t}, opts)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\treturn f\n}\n\nfunc downloadNormal(listener net.Listener, connections int, t *testing.T) {\n\tfetcher := downloadReady(listener, connections, t)\n\terr := fetcher.Start()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\terr = fetcher.Wait()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := test.FileMd5(test.BuildFile)\n\tgot := test.FileMd5(test.DownloadFile)\n\tif want != got {\n\t\tt.Errorf(\"Download() got = %v, want %v\", got, want)\n\t}\n}\n\nfunc downloadPost(listener net.Listener, connections int, t *testing.T) {\n\t// POST parameters must be set before Resolve since the new design\n\t// starts downloading during Resolve phase\n\tf := buildFetcher()\n\tvar extra any = nil\n\tif connections > 0 {\n\t\textra = &http.OptsExtra{\n\t\t\tConnections: connections,\n\t\t}\n\t}\n\topts := &base.Options{\n\t\tName:  test.DownloadName,\n\t\tPath:  test.Dir,\n\t\tExtra: extra,\n\t}\n\treq := &base.Request{\n\t\tURL: \"http://\" + listener.Addr().String() + \"/\" + test.BuildName,\n\t\tExtra: &http.ReqExtra{\n\t\t\tMethod: \"POST\",\n\t\t\tHeader: map[string]string{\n\t\t\t\t\"Authorization\": \"Bearer 123456\",\n\t\t\t},\n\t\t\tBody: fmt.Sprintf(`{\"name\":\"%s\"}`, test.BuildName),\n\t\t},\n\t}\n\terr := f.Resolve(req, opts)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\terr = f.Start()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\terr = f.Wait()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := test.FileMd5(test.BuildFile)\n\tgot := test.FileMd5(test.DownloadFile)\n\tif want != got {\n\t\tt.Errorf(\"Download() got = %v, want %v\", got, want)\n\t}\n}\n\nfunc downloadContinue(listener net.Listener, connections int, t *testing.T) {\n\tfetcher := downloadReady(listener, connections, t)\n\terr := fetcher.Start()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttime.Sleep(time.Millisecond * 50)\n\tif err := fetcher.Pause(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttime.Sleep(time.Millisecond * 50)\n\tif err := fetcher.Start(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\terr = fetcher.Wait()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := test.FileMd5(test.BuildFile)\n\tgot := test.FileMd5(test.DownloadFile)\n\tif want != got {\n\t\tt.Errorf(\"Download() got = %v, want %v\", got, want)\n\t}\n}\n\nfunc downloadError(listener net.Listener, connections int, t *testing.T) {\n\tfetcher := buildFetcher()\n\terr := fetcher.Resolve(&base.Request{\n\t\tURL: \"http://\" + listener.Addr().String() + \"/\" + test.BuildName,\n\t}, &base.Options{\n\t\tName: test.DownloadName,\n\t\tPath: test.Dir,\n\t})\n\t// With the new async design, Resolve may succeed (HTTP response received)\n\t// but errors occur during async download or Start/Wait\n\tif err != nil {\n\t\t// Error detected in Resolve - this is fine\n\t\treturn\n\t}\n\n\t// Resolve succeeded, error should occur during Start/Wait\n\terr = fetcher.Start()\n\tif err != nil {\n\t\t// Error detected in Start - this is fine\n\t\treturn\n\t}\n\n\terr = fetcher.Wait()\n\tif err == nil {\n\t\tt.Errorf(\"Expected error during download, but got none\")\n\t}\n}\n\nfunc downloadResume(listener net.Listener, connections int, t *testing.T) {\n\tfetcher := downloadReady(listener, connections, t)\n\terr := fetcher.Start()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tfb := new(FetcherManager)\n\ttime.Sleep(time.Millisecond * 50)\n\tdata, err := fb.Store(fetcher)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttime.Sleep(time.Millisecond * 50)\n\tfetcher.Pause()\n\n\t_, f := fb.Restore()\n\tf(fetcher.Meta(), data)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tfetcher.Setup(controller.NewController())\n\tfetcher.Start()\n\n\terr = fetcher.Wait()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := test.FileMd5(test.BuildFile)\n\tgot := test.FileMd5(test.DownloadFile)\n\tif want != got {\n\t\tt.Errorf(\"Download() got = %v, want %v\", got, want)\n\t}\n}\n\nfunc downloadWithProxy(httpListener net.Listener, proxyListener net.Listener, t *testing.T) {\n\tfetcher := downloadReady(httpListener, 4, t)\n\tctl := controller.NewController()\n\tctl.GetProxy = func(requestProxy *base.RequestProxy) func(*gohttp.Request) (*url.URL, error) {\n\t\treturn (&base.DownloaderProxyConfig{\n\t\t\tEnable: true,\n\t\t\tScheme: \"socks5\",\n\t\t\tHost:   proxyListener.Addr().String(),\n\t\t}).ToHandler()\n\t}\n\tfetcher.Setup(ctl)\n\terr := fetcher.Start()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\terr = fetcher.Wait()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := test.FileMd5(test.BuildFile)\n\tgot := test.FileMd5(test.DownloadFile)\n\tif want != got {\n\t\tt.Errorf(\"Download() got = %v, want %v\", got, want)\n\t}\n}\n\nfunc buildFetcher() *Fetcher {\n\tfm := new(FetcherManager)\n\tfetcher := fm.Build()\n\tnewController := controller.NewController()\n\tnewController.GetConfig = func(v any) {\n\t\tjson.Unmarshal([]byte(test.ToJson(fm.DefaultConfig())), v)\n\t}\n\tfetcher.Setup(newController)\n\treturn fetcher.(*Fetcher)\n}\n\nfunc buildConfigFetcher(cfg config) fetcher.Fetcher {\n\tfetcher := new(FetcherManager).Build()\n\tnewController := controller.NewController()\n\tnewController.GetConfig = func(v any) {\n\t\tjson.Unmarshal([]byte(test.ToJson(cfg)), v)\n\t}\n\tfetcher.Setup(newController)\n\treturn fetcher\n}\n\n// TestFetcher_Patch_URLChange tests the Patch functionality where a failed download URL\n// is replaced with a working one. This simulates:\n// 1. Initial download attempt with a bad URL (returns 404)\n// 2. Patching the task with a new working URL\n// 3. Successful download after URL modification\nfunc TestFetcher_Patch_URLChange(t *testing.T) {\n\tlistener := test.StartTestPatchURLServer()\n\tdefer listener.Close()\n\n\tf := buildFetcher()\n\tbadURL := \"http://\" + listener.Addr().String() + \"/bad-url\"\n\tgoodURL := \"http://\" + listener.Addr().String() + \"/good-url\"\n\n\topts := &base.Options{\n\t\tName:  test.DownloadName,\n\t\tPath:  test.Dir,\n\t\tExtra: &http.OptsExtra{Connections: 1},\n\t}\n\n\t// Step 1: Try to resolve with bad URL - should fail with error\n\terr := f.Resolve(&base.Request{URL: badURL}, opts)\n\tif err == nil {\n\t\tt.Fatal(\"Expected error for bad URL, got nil\")\n\t}\n\n\t// Step 2: Create a new fetcher and resolve with bad URL but don't wait\n\t// We need to test patching a task that has been created\n\tf2 := buildFetcher()\n\n\t// First resolve with good URL to create a valid fetcher state\n\terr = f2.Resolve(&base.Request{URL: goodURL}, opts)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Verify initial URL\n\tif f2.meta.Req.URL != goodURL {\n\t\tt.Errorf(\"Initial URL = %v, want %v\", f2.meta.Req.URL, goodURL)\n\t}\n\n\t// Step 3: Patch to change URL (simulating URL change scenario)\n\tnewURL := \"http://\" + listener.Addr().String() + \"/good-url\"\n\terr = f2.Patch(&base.Request{URL: newURL}, nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Verify URL was patched\n\tif f2.meta.Req.URL != newURL {\n\t\tt.Errorf(\"Patched URL = %v, want %v\", f2.meta.Req.URL, newURL)\n\t}\n\n\t// Step 4: Start download and verify success\n\terr = f2.Start()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\terr = f2.Wait()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Verify file was downloaded correctly\n\twant := test.FileMd5(test.BuildFile)\n\tgot := test.FileMd5(test.DownloadFile)\n\tif want != got {\n\t\tt.Errorf(\"Download() got = %v, want %v\", got, want)\n\t}\n}\n\n// TestFetcher_Patch_Labels tests patching request labels with merge behavior\nfunc TestFetcher_Patch_Labels(t *testing.T) {\n\tlistener := test.StartTestFileServer()\n\tdefer listener.Close()\n\n\tf := buildFetcher()\n\topts := &base.Options{\n\t\tName:  test.DownloadName,\n\t\tPath:  test.Dir,\n\t\tExtra: &http.OptsExtra{Connections: 1},\n\t}\n\n\terr := f.Resolve(&base.Request{\n\t\tURL: \"http://\" + listener.Addr().String() + \"/\" + test.BuildName,\n\t\tLabels: map[string]string{\n\t\t\t\"key1\": \"value1\",\n\t\t\t\"key3\": \"value3\",\n\t\t},\n\t}, opts)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Verify initial labels\n\tif f.meta.Req.Labels[\"key1\"] != \"value1\" {\n\t\tt.Errorf(\"Initial label key1 = %v, want value1\", f.meta.Req.Labels[\"key1\"])\n\t}\n\tif f.meta.Req.Labels[\"key3\"] != \"value3\" {\n\t\tt.Errorf(\"Initial label key3 = %v, want value3\", f.meta.Req.Labels[\"key3\"])\n\t}\n\n\t// Patch with new labels - key1 should be overwritten, key2 should be added, key3 should remain\n\tpatchReq := &base.Request{\n\t\tLabels: map[string]string{\n\t\t\t\"key1\": \"modified\",\n\t\t\t\"key2\": \"newValue\",\n\t\t},\n\t}\n\terr = f.Patch(patchReq, nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Verify labels were merged correctly\n\tif f.meta.Req.Labels[\"key1\"] != \"modified\" {\n\t\tt.Errorf(\"Patched label key1 = %v, want modified\", f.meta.Req.Labels[\"key1\"])\n\t}\n\tif f.meta.Req.Labels[\"key2\"] != \"newValue\" {\n\t\tt.Errorf(\"Patched label key2 = %v, want newValue\", f.meta.Req.Labels[\"key2\"])\n\t}\n\t// key3 should remain unchanged\n\tif f.meta.Req.Labels[\"key3\"] != \"value3\" {\n\t\tt.Errorf(\"Label key3 = %v, want value3 (should remain unchanged)\", f.meta.Req.Labels[\"key3\"])\n\t}\n}\n\n// TestFetcher_Patch_Extra tests patching request Extra with merge behavior\nfunc TestFetcher_Patch_Extra(t *testing.T) {\n\tlistener := test.StartTestFileServer()\n\tdefer listener.Close()\n\n\tf := buildFetcher()\n\topts := &base.Options{\n\t\tName:  test.DownloadName,\n\t\tPath:  test.Dir,\n\t\tExtra: &http.OptsExtra{Connections: 1},\n\t}\n\n\t// Resolve with initial Extra\n\terr := f.Resolve(&base.Request{\n\t\tURL: \"http://\" + listener.Addr().String() + \"/\" + test.BuildName,\n\t\tExtra: &http.ReqExtra{\n\t\t\tMethod: \"GET\",\n\t\t\tBody:   \"initial body\",\n\t\t\tHeader: map[string]string{\n\t\t\t\t\"Authorization\": \"Bearer token123\",\n\t\t\t\t\"X-Custom\":      \"original\",\n\t\t\t},\n\t\t},\n\t}, opts)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Verify initial Extra\n\tinitialExtra := f.meta.Req.Extra.(*http.ReqExtra)\n\tif initialExtra.Method != \"GET\" {\n\t\tt.Errorf(\"Initial Method = %v, want GET\", initialExtra.Method)\n\t}\n\tif initialExtra.Body != \"initial body\" {\n\t\tt.Errorf(\"Initial Body = %v, want 'initial body'\", initialExtra.Body)\n\t}\n\tif initialExtra.Header[\"Authorization\"] != \"Bearer token123\" {\n\t\tt.Errorf(\"Initial Authorization header = %v, want 'Bearer token123'\", initialExtra.Header[\"Authorization\"])\n\t}\n\tif initialExtra.Header[\"X-Custom\"] != \"original\" {\n\t\tt.Errorf(\"Initial X-Custom header = %v, want 'original'\", initialExtra.Header[\"X-Custom\"])\n\t}\n\n\t// Patch with partial Extra - only update some fields\n\tpatchReq := &base.Request{\n\t\tExtra: &http.ReqExtra{\n\t\t\tMethod: \"POST\", // Update method\n\t\t\t// Body is empty, should NOT update\n\t\t\tHeader: map[string]string{\n\t\t\t\t\"X-Custom\": \"modified\", // Overwrite existing\n\t\t\t\t\"X-New\":    \"added\",    // Add new\n\t\t\t\t// Authorization is not in patch, should remain\n\t\t\t},\n\t\t},\n\t}\n\terr = f.Patch(patchReq, nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Verify Extra was merged correctly\n\tpatchedExtra := f.meta.Req.Extra.(*http.ReqExtra)\n\n\t// Method should be updated\n\tif patchedExtra.Method != \"POST\" {\n\t\tt.Errorf(\"Patched Method = %v, want POST\", patchedExtra.Method)\n\t}\n\n\t// Body should remain unchanged (patch had empty body)\n\tif patchedExtra.Body != \"initial body\" {\n\t\tt.Errorf(\"Patched Body = %v, want 'initial body' (should remain unchanged)\", patchedExtra.Body)\n\t}\n\n\t// Authorization header should remain unchanged\n\tif patchedExtra.Header[\"Authorization\"] != \"Bearer token123\" {\n\t\tt.Errorf(\"Authorization header = %v, want 'Bearer token123' (should remain unchanged)\", patchedExtra.Header[\"Authorization\"])\n\t}\n\n\t// X-Custom header should be overwritten\n\tif patchedExtra.Header[\"X-Custom\"] != \"modified\" {\n\t\tt.Errorf(\"X-Custom header = %v, want 'modified'\", patchedExtra.Header[\"X-Custom\"])\n\t}\n\n\t// X-New header should be added\n\tif patchedExtra.Header[\"X-New\"] != \"added\" {\n\t\tt.Errorf(\"X-New header = %v, want 'added'\", patchedExtra.Header[\"X-New\"])\n\t}\n}\n\n// TestFetcher_Patch_NilData tests that Patch with nil data doesn't cause errors\nfunc TestFetcher_Patch_NilData(t *testing.T) {\n\tlistener := test.StartTestFileServer()\n\tdefer listener.Close()\n\n\tf := buildFetcher()\n\topts := &base.Options{\n\t\tName:  test.DownloadName,\n\t\tPath:  test.Dir,\n\t\tExtra: &http.OptsExtra{Connections: 1},\n\t}\n\n\terr := f.Resolve(&base.Request{\n\t\tURL: \"http://\" + listener.Addr().String() + \"/\" + test.BuildName,\n\t}, opts)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\toriginalURL := f.meta.Req.URL\n\n\t// Patch with nil data - should not cause error\n\terr = f.Patch(nil, nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Verify URL unchanged\n\tif f.meta.Req.URL != originalURL {\n\t\tt.Errorf(\"URL changed after nil patch: got %v, want %v\", f.meta.Req.URL, originalURL)\n\t}\n\n\t// Patch with empty request - should not cause error\n\terr = f.Patch(&base.Request{}, nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Verify URL still unchanged\n\tif f.meta.Req.URL != originalURL {\n\t\tt.Errorf(\"URL changed after empty patch: got %v, want %v\", f.meta.Req.URL, originalURL)\n\t}\n}\n\n// TestFetcher_Patch_CookieExpired tests the Patch functionality where a download fails\n// mid-way due to expired cookie, then succeeds after patching with a new valid cookie.\n// This simulates:\n// 1. Initial resolve with valid cookie succeeds\n// 2. Download starts but fails because cookie expires mid-download (server returns 401)\n// 3. User patches the task with a new valid cookie\n// 4. Download resumes and completes successfully\nfunc TestFetcher_Patch_CookieExpired(t *testing.T) {\n\tlistener := test.StartTestCookieExpiringServer()\n\tdefer listener.Close()\n\n\tdownloadURL := \"http://\" + listener.Addr().String() + \"/\" + test.BuildName\n\topts := &base.Options{\n\t\tName:  test.DownloadName,\n\t\tPath:  test.Dir,\n\t\tExtra: &http.OptsExtra{Connections: 1},\n\t}\n\n\t// Step 1: Resolve with old_token - should succeed (first request accepts old_token)\n\tf := buildFetcher()\n\terr := f.Resolve(&base.Request{\n\t\tURL: downloadURL,\n\t\tExtra: &http.ReqExtra{\n\t\t\tHeader: map[string]string{\n\t\t\t\t\"Cookie\": \"session=old_token\",\n\t\t\t},\n\t\t},\n\t}, opts)\n\tif err != nil {\n\t\tt.Fatalf(\"Resolve should succeed with old_token: %v\", err)\n\t}\n\n\t// Verify initial cookie\n\tinitialExtra := f.meta.Req.Extra.(*http.ReqExtra)\n\tif initialExtra.Header[\"Cookie\"] != \"session=old_token\" {\n\t\tt.Errorf(\"Initial Cookie = %v, want session=old_token\", initialExtra.Header[\"Cookie\"])\n\t}\n\n\t// Step 2: Start download - should fail because old_token is now expired\n\t// (server only accepts old_token for first request, subsequent requests need new_token)\n\terr = f.Start()\n\tif err != nil {\n\t\tt.Fatalf(\"Start failed: %v\", err)\n\t}\n\terr = f.Wait()\n\t// Download should fail with 401 error\n\tif err == nil {\n\t\tt.Fatal(\"Expected download to fail with expired cookie, but it succeeded\")\n\t}\n\tt.Logf(\"Download failed as expected: %v\", err)\n\n\t// Step 3: Patch with new valid cookie\n\terr = f.Patch(&base.Request{\n\t\tExtra: &http.ReqExtra{\n\t\t\tHeader: map[string]string{\n\t\t\t\t\"Cookie\": \"session=new_token\",\n\t\t\t},\n\t\t},\n\t}, nil)\n\tif err != nil {\n\t\tt.Fatalf(\"Patch to update cookie failed: %v\", err)\n\t}\n\n\t// Verify cookie was updated\n\tpatchedExtra := f.meta.Req.Extra.(*http.ReqExtra)\n\tif patchedExtra.Header[\"Cookie\"] != \"session=new_token\" {\n\t\tt.Errorf(\"Cookie should be updated: got %v, want session=new_token\", patchedExtra.Header[\"Cookie\"])\n\t}\n\n\t// Step 4: Restart download - should succeed with new cookie\n\terr = f.Start()\n\tif err != nil {\n\t\tt.Fatalf(\"Restart failed: %v\", err)\n\t}\n\terr = f.Wait()\n\tif err != nil {\n\t\tt.Fatalf(\"Download after patch failed: %v\", err)\n\t}\n\n\t// Verify download completed successfully\n\twant := test.FileMd5(test.BuildFile)\n\tgot := test.FileMd5(test.DownloadFile)\n\tif want != got {\n\t\tt.Errorf(\"File MD5 mismatch: got %v, want %v\", got, want)\n\t}\n}\n"
  },
  {
    "path": "internal/protocol/http/filename_parse_test.go",
    "content": "package http\n\nimport (\n\t\"testing\"\n)\n\n// TestParseFilenameWithAmpersand tests the fix for filenames containing & character\n// Issue: filenames with & are HTML-encoded as &amp; and then truncated at the semicolon\nfunc TestParseFilenameWithAmpersand(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tdisposition string\n\t\twant        string\n\t}{\n\t\t{\n\t\t\tname:        \"quoted filename with &amp;\",\n\t\t\tdisposition: `attachment; filename=\"查询处理&amp;优化.pptx\"`,\n\t\t\twant:        \"查询处理&优化.pptx\",\n\t\t},\n\t\t{\n\t\t\tname:        \"unquoted filename with &amp;\",\n\t\t\tdisposition: `attachment; filename=test&amp;file.txt`,\n\t\t\twant:        \"test&file.txt\",\n\t\t},\n\t\t{\n\t\t\tname:        \"quoted filename with &amp; and extra params\",\n\t\t\tdisposition: `attachment; filename=\"test&amp;file.txt\"; charset=utf-8`,\n\t\t\twant:        \"test&file.txt\",\n\t\t},\n\t\t{\n\t\t\tname:        \"filename with multiple HTML entities\",\n\t\t\tdisposition: `attachment; filename=\"test&amp;&lt;&gt;.txt\"`,\n\t\t\twant:        \"test&<>.txt\",\n\t\t},\n\t\t{\n\t\t\tname:        \"normal filename without entities\",\n\t\t\tdisposition: `attachment; filename=\"normal.txt\"`,\n\t\t\twant:        \"normal.txt\",\n\t\t},\n\t\t{\n\t\t\tname:        \"filename with actual ampersand (no encoding)\",\n\t\t\tdisposition: `attachment; filename=\"test&file.txt\"`,\n\t\t\twant:        \"test&file.txt\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := parseFilename(tt.disposition)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"parseFilename() = %q, want %q\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestFindParamValueEnd tests the helper function for finding parameter value boundaries\nfunc TestFindParamValueEnd(t *testing.T) {\n\ttests := []struct {\n\t\tname  string\n\t\tvalue string\n\t\twant  int\n\t}{\n\t\t{\n\t\t\tname:  \"quoted value with semicolon inside\",\n\t\t\tvalue: `\"test&amp;file.txt\"; charset=utf-8`,\n\t\t\twant:  19, // Position of semicolon after closing quote\n\t\t},\n\t\t{\n\t\t\tname:  \"quoted value without semicolon after\",\n\t\t\tvalue: `\"test.txt\"`,\n\t\t\twant:  -1,\n\t\t},\n\t\t{\n\t\t\tname:  \"unquoted value with semicolon\",\n\t\t\tvalue: `test.txt; charset=utf-8`,\n\t\t\twant:  8,\n\t\t},\n\t\t{\n\t\t\tname:  \"unquoted value without semicolon\",\n\t\t\tvalue: `test.txt`,\n\t\t\twant:  -1,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := findParamValueEnd(tt.value)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"findParamValueEnd() = %d, want %d\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestUnescapeHTMLEntities tests HTML entity unescaping\nfunc TestUnescapeHTMLEntities(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tin   string\n\t\twant string\n\t}{\n\t\t{\n\t\t\tname: \"ampersand\",\n\t\t\tin:   \"test&amp;file.txt\",\n\t\t\twant: \"test&file.txt\",\n\t\t},\n\t\t{\n\t\t\tname: \"less than and greater than\",\n\t\t\tin:   \"&lt;test&gt;.txt\",\n\t\t\twant: \"<test>.txt\",\n\t\t},\n\t\t{\n\t\t\tname: \"quote\",\n\t\t\tin:   \"test&quot;file.txt\",\n\t\t\twant: \"test\\\"file.txt\",\n\t\t},\n\t\t{\n\t\t\tname: \"multiple entities\",\n\t\t\tin:   \"a&amp;b&lt;c&gt;d&quot;e\",\n\t\t\twant: \"a&b<c>d\\\"e\",\n\t\t},\n\t\t{\n\t\t\tname: \"no entities\",\n\t\t\tin:   \"normal.txt\",\n\t\t\twant: \"normal.txt\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := unescapeHTMLEntities(tt.in)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"unescapeHTMLEntities() = %q, want %q\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/protocol/http/helper.go",
    "content": "package http\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/tls\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"mime\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/http/cookiejar\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n\t\"unicode/utf8\"\n\n\t\"github.com/GopeedLab/gopeed/pkg/base\"\n\tfhttp \"github.com/GopeedLab/gopeed/pkg/protocol/http\"\n\t\"github.com/GopeedLab/gopeed/pkg/util\"\n\t\"golang.org/x/text/encoding/simplifiedchinese\"\n)\n\ntype RequestError struct {\n\tCode int\n}\n\nfunc NewRequestError(code int) *RequestError {\n\treturn &RequestError{Code: code}\n}\n\nfunc (re *RequestError) Error() string {\n\treturn fmt.Sprintf(\"http request fail, code:%d\", re.Code)\n}\n\nfunc isFailureExemptHTTPCode(code int) bool {\n\tif code >= 500 && code <= 599 {\n\t\treturn true\n\t}\n\n\tswitch code {\n\tcase 429, 408, 440, 499:\n\t\treturn true\n\tdefault:\n\t\treturn false\n\t}\n}\n\nfunc shouldCountHTTPFailure(err error) bool {\n\tvar re *RequestError\n\tif !errors.As(err, &re) {\n\t\treturn false\n\t}\n\n\treturn !isFailureExemptHTTPCode(re.Code)\n}\n\nfunc extractRequestError(err error) *RequestError {\n\tvar re *RequestError\n\tif errors.As(err, &re) {\n\t\treturn re\n\t}\n\n\treturn nil\n}\n\n// buildRequest creates an HTTP request using the redirect URL if available.\nfunc (f *Fetcher) buildRequest(ctx context.Context, req *base.Request) (httpReq *http.Request, err error) {\n\treturn f.buildRequestWithURL(ctx, req, true)\n}\n\n// buildRequestWithOriginalURL creates an HTTP request using the original URL.\n// This is used for retrying when the redirect URL has expired.\nfunc (f *Fetcher) buildRequestWithOriginalURL(ctx context.Context, req *base.Request) (httpReq *http.Request, err error) {\n\treturn f.buildRequestWithURL(ctx, req, false)\n}\n\n// buildRequestWithURL creates an HTTP request.\n// If useRedirect is true and a redirect URL exists, it will be used; otherwise the original URL is used.\nfunc (f *Fetcher) buildRequestWithURL(ctx context.Context, req *base.Request, useRedirect bool) (httpReq *http.Request, err error) {\n\tvar reqUrl string\n\tf.redirectLock.Lock()\n\tif useRedirect && f.redirectURL != \"\" {\n\t\treqUrl = f.redirectURL\n\t} else {\n\t\treqUrl = req.URL\n\t}\n\tf.redirectLock.Unlock()\n\n\tvar (\n\t\tmethod string\n\t\tbody   io.Reader\n\t)\n\theaders := http.Header{}\n\tif req.Extra == nil {\n\t\tmethod = http.MethodGet\n\t} else {\n\t\textra := req.Extra.(*fhttp.ReqExtra)\n\t\tif extra.Method != \"\" {\n\t\t\tmethod = extra.Method\n\t\t} else {\n\t\t\tmethod = http.MethodGet\n\t\t}\n\t\tif len(extra.Header) > 0 {\n\t\t\tfor k, v := range extra.Header {\n\t\t\t\theaders.Set(k, strings.TrimSpace(v))\n\t\t\t}\n\t\t}\n\t\tif extra.Body != \"\" {\n\t\t\tbody = bytes.NewBufferString(extra.Body)\n\t\t}\n\t}\n\tif _, ok := headers[base.HttpHeaderUserAgent]; !ok {\n\t\theaders.Set(base.HttpHeaderUserAgent, strings.TrimSpace(f.config.UserAgent))\n\t}\n\n\tif ctx != nil {\n\t\thttpReq, err = http.NewRequestWithContext(ctx, method, reqUrl, body)\n\t} else {\n\t\thttpReq, err = http.NewRequest(method, reqUrl, body)\n\t}\n\tif err != nil {\n\t\treturn\n\t}\n\thttpReq.Header = headers\n\tif host := headers.Get(base.HttpHeaderHost); host != \"\" {\n\t\thttpReq.Host = host\n\t}\n\treturn httpReq, nil\n}\n\n// updateRedirectURL updates the redirect URL from the response.\n// This is called when a request using the original URL succeeds after the redirect URL expired.\nfunc (f *Fetcher) updateRedirectURL(url string) {\n\tf.redirectLock.Lock()\n\tf.redirectURL = url\n\tf.redirectLock.Unlock()\n}\n\n// hasRedirectURL checks if a redirect URL exists and is different from the original URL.\nfunc (f *Fetcher) hasRedirectURL() bool {\n\tf.redirectLock.Lock()\n\tdefer f.redirectLock.Unlock()\n\treturn f.redirectURL != \"\" && f.redirectURL != f.meta.Req.URL\n}\n\n// isRedirectExpiredError checks if the error indicates that the redirect URL may have expired.\n// This includes 403 (Forbidden), 401 (Unauthorized), 410 (Gone), and network errors.\nfunc isRedirectExpiredError(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\n\t// Check for specific HTTP error codes that might indicate URL expiration\n\tif re := extractRequestError(err); re != nil {\n\t\tswitch re.Code {\n\t\tcase 401, 403, 404, 410:\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\n// tryFallbackToOriginalURL attempts to make a request using the original URL\n// when the redirect URL has expired. Returns the response if successful.\nfunc (f *Fetcher) tryFallbackToOriginalURL(ctx context.Context, client *http.Client, rangeStart, rangeEnd int64) (*http.Response, error) {\n\thttpReq, err := f.buildRequestWithOriginalURL(ctx, f.meta.Req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif f.meta.Res.Range && rangeEnd > 0 {\n\t\thttpReq.Header.Set(base.HttpHeaderRange,\n\t\t\tfmt.Sprintf(base.HttpHeaderRangeFormat, rangeStart, rangeEnd))\n\t}\n\n\tresp, err := client.Do(httpReq)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif resp.StatusCode != base.HttpCodeOK && resp.StatusCode != base.HttpCodePartialContent {\n\t\tresp.Body.Close()\n\t\treturn nil, NewRequestError(resp.StatusCode)\n\t}\n\n\treturn resp, nil\n}\n\n// buildClient creates an HTTP client with the default connection timeout.\n// Used for resolve phase where we don't have connection time data yet.\nfunc (f *Fetcher) buildClient() *http.Client {\n\treturn f.buildClientWithTimeout(connectTimeout)\n}\n\n// buildFastFailClient creates an HTTP client with fast-fail timeout.\n// Uses max(minFastFailTimeout, maxConnTime) for fast-fail retry during download phase.\nfunc (f *Fetcher) buildFastFailClient() *http.Client {\n\tmaxConn := f.maxConnTime.Load()\n\tif maxConn == 0 {\n\t\t// No successful connection yet, use default timeout\n\t\treturn f.buildClientWithTimeout(connectTimeout)\n\t}\n\n\ttimeout := maxConn\n\tif timeout >= minFastFailTimeout {\n\t\t// If greater than minFastFailTimeout, increase by 50% for safety margin\n\t\ttimeout = int64(float64(timeout) * 1.5)\n\t} else {\n\t\ttimeout = minFastFailTimeout\n\t}\n\treturn f.buildClientWithTimeout(time.Duration(timeout))\n}\n\n// buildClientWithTimeout creates an HTTP client with the specified connection timeout.\nfunc (f *Fetcher) buildClientWithTimeout(timeout time.Duration) *http.Client {\n\ttransport := &http.Transport{\n\t\tDialContext: (&net.Dialer{\n\t\t\tTimeout: timeout,\n\t\t}).DialContext,\n\t\tProxy: f.ctl.GetProxy(f.meta.Req.Proxy),\n\t\tTLSClientConfig: &tls.Config{\n\t\t\tInsecureSkipVerify: f.meta.Req.SkipVerifyCert,\n\t\t},\n\t\tTLSHandshakeTimeout: timeout,\n\t}\n\tjar, _ := cookiejar.New(nil)\n\treturn &http.Client{\n\t\tTransport: transport,\n\t\tJar:       jar,\n\t}\n}\n\n// ============================================================================\n// Filename Parsing\n// ============================================================================\n\n// parseFilename extracts filename from Content-Disposition header\nfunc parseFilename(contentDisposition string) string {\n\t// Try RFC 5987 extended notation first (filename*=)\n\tif filename := parseFilenameExtended(contentDisposition); filename != \"\" {\n\t\treturn filename\n\t}\n\n\t// Try standard MIME parsing\n\t_, params, err := mime.ParseMediaType(contentDisposition)\n\tif err == nil {\n\t\tif filename := params[\"filename\"]; filename != \"\" {\n\t\t\treturn decodeFilenameParam(filename)\n\t\t}\n\t}\n\n\t// Fallback to manual parsing\n\treturn parseFilenameFallback(contentDisposition)\n}\n\n// parseFilenameExtended handles RFC 5987 extended notation (filename*=)\n// Format: filename*=charset'language'value (e.g., UTF-8”%E6%B5%8B%E8%AF%95.zip)\nfunc parseFilenameExtended(cd string) string {\n\tlower := strings.ToLower(cd)\n\tidx := strings.Index(lower, \"filename*=\")\n\tif idx == -1 {\n\t\treturn \"\"\n\t}\n\n\tvalue := cd[idx+len(\"filename*=\"):]\n\n\t// Find the end of the value using proper quote handling\n\tendIdx := findParamValueEnd(value)\n\tif endIdx != -1 {\n\t\tvalue = value[:endIdx]\n\t}\n\tvalue = strings.TrimSpace(value)\n\n\t// Try charset''encoded format (e.g., UTF-8''%E4%B8%AD%E6%96%87.txt)\n\tparts := strings.SplitN(value, \"''\", 2)\n\tif len(parts) == 2 {\n\t\t// Use PathUnescape to handle %2B correctly (should decode to +, not space)\n\t\tdecoded, err := url.PathUnescape(parts[1])\n\t\tif err == nil {\n\t\t\treturn decoded\n\t\t}\n\t}\n\n\t// Try charset'language'encoded format\n\tparts = strings.SplitN(value, \"'\", 3)\n\tif len(parts) >= 3 {\n\t\t// Use PathUnescape to handle %2B correctly (should decode to +, not space)\n\t\tdecoded, err := url.PathUnescape(parts[2])\n\t\tif err == nil {\n\t\t\treturn decoded\n\t\t}\n\t}\n\n\treturn \"\"\n}\n\n// decodeFilenameParam decodes filename parameter value\n// Handles HTML entities, MIME encoded-word, URL encoding, and GBK encoding fallback\nfunc decodeFilenameParam(filename string) string {\n\t// First, unescape HTML entities (e.g., &amp; -> &, &lt; -> <, &gt; -> >)\n\t// This must be done before other decoding to handle cases where servers\n\t// HTML-encode special characters in filenames\n\tfilename = unescapeHTMLEntities(filename)\n\n\t// Handle RFC 2047 encoded word (=?charset?encoding?text?=)\n\tif strings.HasPrefix(filename, \"=?\") {\n\t\tdecoder := new(mime.WordDecoder)\n\t\tnormalizedFilename := strings.Replace(filename, \"UTF8\", \"UTF-8\", 1)\n\t\tif decoded, err := decoder.Decode(normalizedFilename); err == nil {\n\t\t\treturn decoded\n\t\t}\n\t}\n\n\t// Try URL decoding - use PathUnescape for filenames to handle %2B correctly\n\tdecoded := util.TryUrlPathUnescape(filename)\n\n\t// If not valid UTF-8, try GBK decoding (common for Chinese websites)\n\tif !utf8.ValidString(decoded) {\n\t\tif gbkDecoded := tryDecodeGBK(decoded); gbkDecoded != \"\" {\n\t\t\treturn gbkDecoded\n\t\t}\n\t}\n\n\treturn decoded\n}\n\n// unescapeHTMLEntities unescapes common HTML entities in filenames\n// This handles cases where servers HTML-encode special characters like & to &amp;\nfunc unescapeHTMLEntities(s string) string {\n\t// Common HTML entities that might appear in filenames\n\treplacements := map[string]string{\n\t\t\"&amp;\":  \"&\",\n\t\t\"&lt;\":   \"<\",\n\t\t\"&gt;\":   \">\",\n\t\t\"&quot;\": \"\\\"\",\n\t\t\"&#39;\":  \"'\",\n\t\t\"&apos;\": \"'\",\n\t}\n\n\tresult := s\n\tfor entity, char := range replacements {\n\t\tresult = strings.ReplaceAll(result, entity, char)\n\t}\n\treturn result\n}\n\n// tryDecodeGBK attempts to decode string as GBK encoding\nfunc tryDecodeGBK(s string) string {\n\tif len(s) == 0 {\n\t\treturn \"\"\n\t}\n\n\tdecoder := simplifiedchinese.GBK.NewDecoder()\n\tdecoded, err := decoder.Bytes([]byte(s))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tresult := string(decoded)\n\tif utf8.ValidString(result) {\n\t\treturn result\n\t}\n\treturn \"\"\n}\n\n// parseFilenameFallback is a fallback parser for non-standard Content-Disposition\nfunc parseFilenameFallback(cd string) string {\n\tlower := strings.ToLower(cd)\n\tidx := strings.Index(lower, \"filename=\")\n\tif idx == -1 {\n\t\treturn \"\"\n\t}\n\n\tvalue := cd[idx+len(\"filename=\"):]\n\n\t// Find the end of the value using proper quote handling\n\tendIdx := findParamValueEnd(value)\n\tif endIdx != -1 {\n\t\tvalue = value[:endIdx]\n\t}\n\tvalue = strings.TrimSpace(value)\n\n\t// Remove surrounding quotes\n\tif len(value) >= 2 {\n\t\tif (value[0] == '\"' && value[len(value)-1] == '\"') ||\n\t\t\t(value[0] == '\\'' && value[len(value)-1] == '\\'') {\n\t\t\tvalue = value[1 : len(value)-1]\n\t\t}\n\t}\n\n\treturn decodeFilenameParam(value)\n}\n\n// findParamValueEnd finds the end position of a parameter value in a Content-Disposition header.\n// It correctly handles quoted values where semicolons inside quotes should not be treated as delimiters.\n// It also handles HTML entities in unquoted values (e.g., &amp; should not be split at the semicolon).\n// Returns the end index (exclusive) of the value, or -1 if it extends to the end of the string.\nfunc findParamValueEnd(value string) int {\n\tvalue = strings.TrimSpace(value)\n\tif len(value) == 0 {\n\t\treturn 0\n\t}\n\n\t// If the value starts with a quote, find the matching closing quote\n\tif value[0] == '\"' || value[0] == '\\'' {\n\t\tquote := value[0]\n\t\t// Find the closing quote, handling escaped quotes\n\t\tfor i := 1; i < len(value); i++ {\n\t\t\tif value[i] == quote {\n\t\t\t\t// Check if it's escaped\n\t\t\t\tif i > 0 && value[i-1] == '\\\\' {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\t// Found closing quote, now look for ; after it\n\t\t\t\tremaining := value[i+1:]\n\t\t\t\tif semiIdx := strings.Index(remaining, \";\"); semiIdx != -1 {\n\t\t\t\t\treturn i + 1 + semiIdx\n\t\t\t\t}\n\t\t\t\treturn -1 // No semicolon after closing quote\n\t\t\t}\n\t\t}\n\t\t// No closing quote found, treat rest of string as value\n\t\treturn -1\n\t}\n\n\t// Unquoted value - find the next semicolon that's not part of an HTML entity\n\t// HTML entities have the pattern &...;  (e.g., &amp; &lt; &gt; &quot; &#39;)\n\tfor i := 0; i < len(value); i++ {\n\t\tif value[i] == ';' {\n\t\t\t// Check if this semicolon is part of an HTML entity\n\t\t\t// Look backwards for an & character\n\t\t\tisEntity := false\n\t\t\tif i > 0 {\n\t\t\t\t// Look for & before this semicolon (within reasonable distance, max 10 chars)\n\t\t\t\tfor j := i - 1; j >= 0 && j >= i-10; j-- {\n\t\t\t\t\tif value[j] == '&' {\n\t\t\t\t\t\t// Found &, this semicolon might be part of an HTML entity\n\t\t\t\t\t\t// Check if there are only alphanumeric or # between & and ;\n\t\t\t\t\t\tentityChars := value[j+1 : i]\n\t\t\t\t\t\tif len(entityChars) > 0 && isValidHTMLEntityChars(entityChars) {\n\t\t\t\t\t\t\tisEntity = true\n\t\t\t\t\t\t}\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t\t// If we hit whitespace or another special char, stop looking\n\t\t\t\t\tif value[j] == ' ' || value[j] == '\"' || value[j] == '\\'' {\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif !isEntity {\n\t\t\t\treturn i\n\t\t\t}\n\t\t}\n\t}\n\treturn -1 // No semicolon, extends to end\n}\n\n// isValidHTMLEntityChars checks if a string contains only valid HTML entity characters\n// (alphanumeric and #, typically for entities like &amp; &lt; &#39; etc.)\nfunc isValidHTMLEntityChars(s string) bool {\n\tif len(s) == 0 {\n\t\treturn false\n\t}\n\tfor _, c := range s {\n\t\tif !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '#') {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n"
  },
  {
    "path": "internal/protocol/http/timeout_reader.go",
    "content": "package http\n\nimport (\n\t\"context\"\n\t\"io\"\n\t\"time\"\n)\n\ntype TimeoutReader struct {\n\treader  io.Reader\n\ttimeout time.Duration\n}\n\nfunc NewTimeoutReader(r io.Reader, timeout time.Duration) *TimeoutReader {\n\treturn &TimeoutReader{\n\t\treader:  r,\n\t\ttimeout: timeout,\n\t}\n}\n\nfunc (tr *TimeoutReader) Read(p []byte) (n int, err error) {\n\tctx, cancel := context.WithTimeout(context.Background(), tr.timeout)\n\tdefer cancel()\n\n\tdone := make(chan struct{})\n\tvar readErr error\n\tvar bytesRead int\n\n\tgo func() {\n\t\tbytesRead, readErr = tr.reader.Read(p)\n\t\tclose(done)\n\t}()\n\n\tselect {\n\tcase <-done:\n\t\treturn bytesRead, readErr\n\tcase <-ctx.Done():\n\t\treturn 0, ctx.Err()\n\t}\n}\n"
  },
  {
    "path": "internal/protocol/http/timeout_reader_test.go",
    "content": "package http\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"errors\"\n\t\"io\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestTimeoutReader_Read(t *testing.T) {\n\tdata := []byte(\"Hello, World!\")\n\treader := bytes.NewReader(data)\n\ttimeoutReader := NewTimeoutReader(reader, 1*time.Second)\n\n\tbuf := make([]byte, len(data))\n\tn, err := timeoutReader.Read(buf)\n\tif err != nil {\n\t\tt.Fatalf(\"expected no error, got %v\", err)\n\t}\n\tif n != len(data) {\n\t\tt.Fatalf(\"expected to read %d bytes, read %d\", len(data), n)\n\t}\n\tif !bytes.Equal(buf, data) {\n\t\tt.Fatalf(\"expected %s, got %s\", data, buf)\n\t}\n}\n\nfunc TestTimeoutReader_ReadTimeout(t *testing.T) {\n\treader := &slowReader{delay: 2 * time.Second}\n\ttimeoutReader := NewTimeoutReader(reader, 1*time.Second)\n\n\tbuf := make([]byte, 8192)\n\t_, err := timeoutReader.Read(buf)\n\tif err == nil {\n\t\tt.Fatal(\"expected timeout error, got nil\")\n\t}\n\tif !errors.Is(err, context.DeadlineExceeded) {\n\t\tt.Fatalf(\"expected %v, got %v\", context.DeadlineExceeded, err)\n\t}\n}\n\ntype slowReader struct {\n\tdelay time.Duration\n}\n\nfunc (sr *slowReader) Read(p []byte) (n int, err error) {\n\ttime.Sleep(sr.delay)\n\treturn 0, io.EOF\n}\n"
  },
  {
    "path": "internal/test/httptest.go",
    "content": "package test\n\nimport (\n\t\"context\"\n\t\"crypto/rand\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"net/http\"\n\t\"os\"\n\t\"reflect\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/GopeedLab/gopeed/pkg/base\"\n\t\"github.com/armon/go-socks5\"\n\t\"golang.org/x/text/encoding/simplifiedchinese\"\n)\n\nconst (\n\tBuildName = \"build.data\"\n\tBuildSize = 200 * 1024 * 1024\n\tDir       = \"./\"\n\tBuildFile = Dir + BuildName\n\n\tExternalDownloadUrl  = \"https://raw.githubusercontent.com/GopeedLab/gopeed/v1.5.6/_docs/img/banner.png\"\n\tExternalDownloadName = \"banner.png\"\n\tExternalDownloadSize = 26416\n\t//ExternalDownloadMd5 = \"c67c6e3cae79a95342485676571e8a5c\"\n\n\tDownloadName       = \"download.data\"\n\tDownloadRename     = \"download (1).data\"\n\tDownloadFile       = Dir + DownloadName\n\tDownloadRenameFile = Dir + DownloadRename\n\n\t// TestChineseFileName is a common test filename with Chinese characters\n\t// Used to test Content-Disposition parsing with various encodings\n\tTestChineseFileName = \"测试.zip\"\n)\n\nfunc StartTestFileServer() net.Listener {\n\treturn startTestServer(func(sl *shutdownListener) http.Handler {\n\t\treturn http.FileServer(http.Dir(Dir))\n\t})\n}\n\ntype SlowFileServer struct {\n\tdelay   time.Duration\n\thandler http.Handler\n}\n\nfunc (s *SlowFileServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {\n\ttime.Sleep(s.delay)\n\ts.handler.ServeHTTP(w, r)\n}\n\nfunc StartTestSlowFileServer(delay time.Duration) net.Listener {\n\treturn startTestServer(func(sl *shutdownListener) http.Handler {\n\t\treturn &SlowFileServer{\n\t\t\tdelay:   delay,\n\t\t\thandler: http.FileServer(http.Dir(Dir)),\n\t\t}\n\t})\n}\n\nfunc StartTestCustomServer() net.Listener {\n\treturn startTestServer(func(sl *shutdownListener) http.Handler {\n\t\tmux := http.NewServeMux()\n\t\tmux.HandleFunc(\"/\", func(writer http.ResponseWriter, request *http.Request) {\n\t\t\twriter.WriteHeader(200)\n\t\t})\n\t\tmux.HandleFunc(\"/\"+BuildName, func(writer http.ResponseWriter, request *http.Request) {\n\t\t\tfile, err := os.Open(BuildFile)\n\t\t\tif err != nil {\n\t\t\t\tpanic(err)\n\t\t\t}\n\t\t\tdefer file.Close()\n\t\t\tio.Copy(writer, file)\n\t\t})\n\t\tmux.HandleFunc(\"/disposition\", func(writer http.ResponseWriter, request *http.Request) {\n\t\t\twriter.Header().Set(\"Content-Disposition\", \"attachment; filename=\\\"\"+BuildName+\"\\\"\")\n\t\t\twriter.Header().Set(\"Content-Type\", \"application/octet-stream\")\n\t\t\twriter.Header().Set(\"Content-Length\", fmt.Sprintf(\"%d\", BuildSize))\n\t\t\tfile, err := os.Open(BuildFile)\n\t\t\tif err != nil {\n\t\t\t\tpanic(err)\n\t\t\t}\n\t\t\tdefer file.Close()\n\t\t\tio.Copy(writer, file)\n\t\t})\n\t\tmux.HandleFunc(\"/encoded-word\", func(writer http.ResponseWriter, request *http.Request) {\n\t\t\twriter.Header().Set(\"Content-Disposition\", \"attachment; filename=\\\"=?UTF8?B?5rWL6K+VLnppcA==?=\\\"\")\n\t\t\twriter.Header().Set(\"Content-Type\", \"application/octet-stream\")\n\t\t\twriter.Header().Set(\"Content-Length\", fmt.Sprintf(\"%d\", BuildSize))\n\t\t\tfile, err := os.Open(BuildFile)\n\t\t\tif err != nil {\n\t\t\t\tpanic(err)\n\t\t\t}\n\t\t\tdefer file.Close()\n\t\t\tio.Copy(writer, file)\n\t\t})\n\t\tmux.HandleFunc(\"/%E6%B5%8B%E8%AF%95.zip\", func(writer http.ResponseWriter, request *http.Request) {\n\t\t\tfile, err := os.Open(BuildFile)\n\t\t\tif err != nil {\n\t\t\t\tpanic(err)\n\t\t\t}\n\t\t\tdefer file.Close()\n\t\t\tio.Copy(writer, file)\n\t\t})\n\t\tmux.HandleFunc(\"/no-encode\", func(writer http.ResponseWriter, request *http.Request) {\n\t\t\twriter.Header().Set(\"Content-Disposition\", \"attachment; filename=\"+TestChineseFileName)\n\t\t\twriter.Header().Set(\"Content-Type\", \"application/octet-stream\")\n\t\t\twriter.Header().Set(\"Content-Length\", fmt.Sprintf(\"%d\", BuildSize))\n\t\t\tfile, err := os.Open(BuildFile)\n\t\t\tif err != nil {\n\t\t\t\tpanic(err)\n\t\t\t}\n\t\t\tdefer file.Close()\n\t\t\tio.Copy(writer, file)\n\t\t})\n\t\t// Test endpoint for mixed encoding: filename= with garbled characters (including special chars)\n\t\t// and filename*= with proper UTF-8. This tests the case where mime.ParseMediaType fails\n\t\t// due to invalid characters like <a> tags in the filename.\n\t\tmux.HandleFunc(\"/mixed-encoding\", func(writer http.ResponseWriter, request *http.Request) {\n\t\t\t// This simulates a server that sends a garbled filename= with special chars that cause\n\t\t\t// mime.ParseMediaType to fail, plus a proper filename*=UTF-8''...\n\t\t\t// The filename*= should be preferred and correctly parsed.\n\t\t\twriter.Header().Set(\"Content-Disposition\", `attachment;filename=\"garbled<invalid>chars.zip\";filename*=UTF-8''%E6%B5%8B%E8%AF%95.zip`)\n\t\t\twriter.Header().Set(\"Content-Type\", \"application/octet-stream\")\n\t\t\twriter.Header().Set(\"Content-Length\", fmt.Sprintf(\"%d\", BuildSize))\n\t\t\tfile, err := os.Open(BuildFile)\n\t\t\tif err != nil {\n\t\t\t\tpanic(err)\n\t\t\t}\n\t\t\tdefer file.Close()\n\t\t\tio.Copy(writer, file)\n\t\t})\n\t\t// Test endpoint for filename*= only (RFC 5987 format)\n\t\tmux.HandleFunc(\"/filename-star\", func(writer http.ResponseWriter, request *http.Request) {\n\t\t\t// URL-encoded TestChineseFileName: 测试.zip -> %E6%B5%8B%E8%AF%95.zip\n\t\t\twriter.Header().Set(\"Content-Disposition\", `attachment; filename*=UTF-8''%E6%B5%8B%E8%AF%95.zip`)\n\t\t\twriter.Header().Set(\"Content-Type\", \"application/octet-stream\")\n\t\t\twriter.Header().Set(\"Content-Length\", fmt.Sprintf(\"%d\", BuildSize))\n\t\t\tfile, err := os.Open(BuildFile)\n\t\t\tif err != nil {\n\t\t\t\tpanic(err)\n\t\t\t}\n\t\t\tdefer file.Close()\n\t\t\tio.Copy(writer, file)\n\t\t})\n\t\t// Test endpoint for GBK-encoded filename (common on Chinese Windows servers)\n\t\t// This simulates the case where Chinese characters are sent as GBK bytes\n\t\t// which appear as garbled characters when interpreted as UTF-8.\n\t\t// For example, \"测试\" in GBK is [B2 E2 CA D4] which is invalid UTF-8.\n\t\t// Our fix detects invalid UTF-8 and attempts GBK decoding.\n\t\tmux.HandleFunc(\"/gbk-encoded\", func(writer http.ResponseWriter, request *http.Request) {\n\t\t\t// Encode TestChineseFileName as GBK\n\t\t\tgbkEncoder := simplifiedchinese.GBK.NewEncoder()\n\t\t\tgbkBytes, _ := gbkEncoder.Bytes([]byte(TestChineseFileName))\n\t\t\t// Send GBK bytes directly in filename (simulating broken server behavior)\n\t\t\twriter.Header().Set(\"Content-Disposition\", `attachment; filename=\"`+string(gbkBytes)+`\"`)\n\t\t\twriter.Header().Set(\"Content-Type\", \"application/octet-stream\")\n\t\t\twriter.Header().Set(\"Content-Length\", fmt.Sprintf(\"%d\", BuildSize))\n\t\t\tfile, err := os.Open(BuildFile)\n\t\t\tif err != nil {\n\t\t\t\tpanic(err)\n\t\t\t}\n\t\t\tdefer file.Close()\n\t\t\tio.Copy(writer, file)\n\t\t})\n\t\t// Test endpoint for filenames with plus signs (C++ files, etc.)\n\t\t// This tests that %2B decodes to + not space\n\t\tmux.HandleFunc(\"/plus-sign-encoded\", func(writer http.ResponseWriter, request *http.Request) {\n\t\t\t// Use filename*= format with %2B encoding for plus signs\n\t\t\twriter.Header().Set(\"Content-Disposition\", `attachment; filename*=UTF-8''C%2B%2B%20%20Primer%20%20Plus.mobi`)\n\t\t\twriter.Header().Set(\"Content-Type\", \"application/octet-stream\")\n\t\t\twriter.Header().Set(\"Content-Length\", fmt.Sprintf(\"%d\", BuildSize))\n\t\t\tfile, err := os.Open(BuildFile)\n\t\t\tif err != nil {\n\t\t\t\tpanic(err)\n\t\t\t}\n\t\t\tdefer file.Close()\n\t\t\tio.Copy(writer, file)\n\t\t})\n\t\t// Test endpoint for plus sign in URL path\n\t\tmux.HandleFunc(\"/C%2B%2B%20Primer.txt\", func(writer http.ResponseWriter, request *http.Request) {\n\t\t\tfile, err := os.Open(BuildFile)\n\t\t\tif err != nil {\n\t\t\t\tpanic(err)\n\t\t\t}\n\t\t\tdefer file.Close()\n\t\t\tio.Copy(writer, file)\n\t\t})\n\t\t// Test endpoint for filename with HTML-encoded ampersand (&amp;)\n\t\t// This tests the case from the bug report where filenames containing & are\n\t\t// HTML-encoded as &amp; by the server, causing truncation at the semicolon.\n\t\t// Example: \"查询处理&优化.pptx\" -> \"查询处理&amp;优化.pptx\"\n\t\tmux.HandleFunc(\"/ampersand-encoded\", func(writer http.ResponseWriter, request *http.Request) {\n\t\t\t// Simulate server sending filename with HTML-encoded ampersand\n\t\t\twriter.Header().Set(\"Content-Disposition\", `attachment; filename=\"查询处理&amp;优化.pptx\"`)\n\t\t\twriter.Header().Set(\"Content-Type\", \"application/octet-stream\")\n\t\t\twriter.Header().Set(\"Content-Length\", fmt.Sprintf(\"%d\", BuildSize))\n\t\t\tfile, err := os.Open(BuildFile)\n\t\t\tif err != nil {\n\t\t\t\tpanic(err)\n\t\t\t}\n\t\t\tdefer file.Close()\n\t\t\tio.Copy(writer, file)\n\t\t})\n\t\t// Test endpoint for unquoted filename with HTML-encoded ampersand\n\t\t// Some servers might send unquoted filenames with HTML entities\n\t\tmux.HandleFunc(\"/ampersand-unquoted\", func(writer http.ResponseWriter, request *http.Request) {\n\t\t\twriter.Header().Set(\"Content-Disposition\", `attachment; filename=test&amp;file.txt`)\n\t\t\twriter.Header().Set(\"Content-Type\", \"application/octet-stream\")\n\t\t\twriter.Header().Set(\"Content-Length\", fmt.Sprintf(\"%d\", BuildSize))\n\t\t\tfile, err := os.Open(BuildFile)\n\t\t\tif err != nil {\n\t\t\t\tpanic(err)\n\t\t\t}\n\t\t\tdefer file.Close()\n\t\t\tio.Copy(writer, file)\n\t\t})\n\t\t// Test 403 Forbidden endpoint\n\t\tmux.HandleFunc(\"/forbidden\", func(writer http.ResponseWriter, request *http.Request) {\n\t\t\twriter.WriteHeader(403)\n\t\t})\n\t\treturn mux\n\t})\n}\n\n// StartTestHostHeaderServer starts a server that validates the Host header\n// Returns 400 Bad Request if the Host header value equals \"test\"\nfunc StartTestHostHeaderServer() net.Listener {\n\treturn startTestServer(func(sl *shutdownListener) http.Handler {\n\t\tmux := http.NewServeMux()\n\t\tmux.HandleFunc(\"/\", func(writer http.ResponseWriter, request *http.Request) {\n\t\t\t// If the Host header is \"test\", return 400 (simulating server that validates Host)\n\t\t\tif request.Host == \"test\" {\n\t\t\t\twriter.WriteHeader(400)\n\t\t\t\twriter.Write([]byte(\"Bad Request: Invalid Host header\"))\n\t\t\t\treturn\n\t\t\t}\n\t\t\twriter.WriteHeader(200)\n\t\t\twriter.Write([]byte(\"OK\"))\n\t\t})\n\t\treturn mux\n\t})\n}\n\nfunc StartTestRetryServer() net.Listener {\n\tcounter := 0\n\treturn startTestServer(func(sl *shutdownListener) http.Handler {\n\t\tmux := http.NewServeMux()\n\t\tmux.HandleFunc(\"/\"+BuildName, func(writer http.ResponseWriter, request *http.Request) {\n\t\t\tcounter++\n\t\t\tif counter != 1 && counter < 2 {\n\t\t\t\twriter.WriteHeader(500)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tfile, err := os.Open(BuildFile)\n\t\t\tif err != nil {\n\t\t\t\tpanic(err)\n\t\t\t}\n\t\t\tdefer file.Close()\n\t\t\tio.Copy(writer, file)\n\t\t})\n\t\treturn mux\n\t})\n}\n\nfunc StartTestPostServer() net.Listener {\n\treturn startTestServer(func(sl *shutdownListener) http.Handler {\n\t\tmux := http.NewServeMux()\n\t\tmux.HandleFunc(\"/\"+BuildName, func(writer http.ResponseWriter, request *http.Request) {\n\t\t\tif request.Method == \"POST\" && request.Header.Get(\"Authorization\") != \"\" {\n\t\t\t\tvar data map[string]interface{}\n\t\t\t\tif err := json.NewDecoder(request.Body).Decode(&data); err != nil {\n\t\t\t\t\tpanic(err)\n\t\t\t\t}\n\t\t\t\tif data[\"name\"] == BuildName {\n\t\t\t\t\tfile, err := os.Open(BuildFile)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tpanic(err)\n\t\t\t\t\t}\n\t\t\t\t\tdefer file.Close()\n\t\t\t\t\tio.Copy(writer, file)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t\treturn mux\n\t})\n}\n\nfunc StartTestErrorServer() net.Listener {\n\treturn startTestServer(func(sl *shutdownListener) http.Handler {\n\t\tmux := http.NewServeMux()\n\t\tmux.HandleFunc(\"/\"+BuildName, func(writer http.ResponseWriter, request *http.Request) {\n\t\t\t// Always return 404 error to test error handling\n\t\t\twriter.WriteHeader(404)\n\t\t\treturn\n\t\t})\n\t\treturn mux\n\t})\n}\n\n// StartTestOneTimeServer creates a server where the URL can only be downloaded once\n// The first non-probe request succeeds, all subsequent requests return 404\n// This simulates one-time download URLs (e.g., signed URLs that expire after first use)\nfunc StartTestOneTimeServer() net.Listener {\n\tvar accessed atomic.Bool\n\n\treturn startTestServer(func(sl *shutdownListener) http.Handler {\n\t\tmux := http.NewServeMux()\n\t\tmux.HandleFunc(\"/\"+BuildName, func(writer http.ResponseWriter, request *http.Request) {\n\t\t\t// First full download request succeeds, subsequent requests fail\n\t\t\tif accessed.Swap(true) {\n\t\t\t\twriter.WriteHeader(404)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// First request - return full file without Range support\n\t\t\twriter.Header().Set(\"Content-Length\", fmt.Sprintf(\"%d\", BuildSize))\n\t\t\tfile, err := os.Open(BuildFile)\n\t\t\tif err != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer file.Close()\n\t\t\tio.Copy(writer, file)\n\t\t})\n\t\treturn mux\n\t})\n}\n\n// StartTestNoRangeSlowServer creates a server that always returns the full file\n// with Content-Length but does not support Range requests.\nfunc StartTestNoRangeSlowServer(delayPerChunk time.Duration) net.Listener {\n\treturn startTestServer(func(sl *shutdownListener) http.Handler {\n\t\tmux := http.NewServeMux()\n\t\tmux.HandleFunc(\"/\"+BuildName, func(writer http.ResponseWriter, request *http.Request) {\n\t\t\twriter.Header().Set(\"Content-Length\", fmt.Sprintf(\"%d\", BuildSize))\n\n\t\t\tfile, err := os.Open(BuildFile)\n\t\t\tif err != nil {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer file.Close()\n\n\t\t\tbuf := make([]byte, 256*1024)\n\t\t\tfor !sl.isShutdown {\n\t\t\t\tn, readErr := file.Read(buf)\n\t\t\t\tif n > 0 {\n\t\t\t\t\tif _, writeErr := writer.Write(buf[:n]); writeErr != nil {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tif flusher, ok := writer.(http.Flusher); ok {\n\t\t\t\t\t\tflusher.Flush()\n\t\t\t\t\t}\n\t\t\t\t\tif delayPerChunk > 0 {\n\t\t\t\t\t\ttime.Sleep(delayPerChunk)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif readErr != nil {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t\treturn mux\n\t})\n}\n\n// StartTestExpiringRedirectServer creates a server that simulates expiring redirect URLs.\n// The original URL redirects to a temporary URL that expires after a specified number of requests.\n// When the temporary URL expires (returns 403), the client should retry with the original URL\n// to get a new redirect URL.\n// Parameters:\n//   - requestsBeforeExpire: number of requests the temporary URL accepts before expiring\n//   - delayPerByte: optional delay per byte for slow transfer (use 0 for no delay)\nfunc StartTestExpiringRedirectServer(requestsBeforeExpire int32, delayPerByte time.Duration) net.Listener {\n\tvar redirectVersion atomic.Int32\n\tvar requestCount atomic.Int32\n\tredirectVersion.Store(1)\n\n\treturn startTestServer(func(sl *shutdownListener) http.Handler {\n\t\treturn http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {\n\t\t\tpath := request.URL.Path\n\n\t\t\t// Original URL - redirects to current version of temporary URL\n\t\t\tif path == \"/\"+BuildName {\n\t\t\t\t// Reset request count when original URL is accessed\n\t\t\t\trequestCount.Store(0)\n\t\t\t\t// Redirect to versioned temporary URL\n\t\t\t\tversion := redirectVersion.Load()\n\t\t\t\tredirectURL := fmt.Sprintf(\"/redirect-v%d/%s\", version, BuildName)\n\t\t\t\thttp.Redirect(writer, request, redirectURL, http.StatusFound)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// Temporary URL handler - matches /redirect-v{N}/... pattern\n\t\t\tif strings.HasPrefix(path, \"/redirect-v\") {\n\t\t\t\t// Check if the redirect has expired\n\t\t\t\tcount := requestCount.Add(1)\n\t\t\t\tif count > requestsBeforeExpire {\n\t\t\t\t\t// Redirect expired - increment version for next redirect\n\t\t\t\t\tredirectVersion.Add(1)\n\t\t\t\t\twriter.WriteHeader(403)\n\t\t\t\t\twriter.Write([]byte(\"Redirect URL expired\"))\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\t// Serve the file with range support\n\t\t\t\trangeFileHandle(\n\t\t\t\t\twriter,\n\t\t\t\t\trequest,\n\t\t\t\t\tnil,\n\t\t\t\t\tfunc(file *os.File, n int64) {\n\t\t\t\t\t\tif delayPerByte > 0 {\n\t\t\t\t\t\t\tslowCopyNWithDelay(sl, writer, file, n, delayPerByte)\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tio.CopyN(writer, file, n)\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// Not found\n\t\t\twriter.WriteHeader(404)\n\t\t})\n\t})\n}\n\n// StartTestSlowStartServer creates a server with configurable delay per request\n// This allows testing slow-start connection expansion to reach max connections\nfunc StartTestSlowStartServer(delayPerByte time.Duration) net.Listener {\n\treturn startTestServer(func(sl *shutdownListener) http.Handler {\n\t\tmux := http.NewServeMux()\n\t\tmux.HandleFunc(\"/\"+BuildName, func(writer http.ResponseWriter, request *http.Request) {\n\t\t\trangeFileHandle(\n\t\t\t\twriter,\n\t\t\t\trequest,\n\t\t\t\tnil,\n\t\t\t\tfunc(file *os.File, n int64) {\n\t\t\t\t\tslowCopyNWithDelay(sl, writer, file, n, delayPerByte)\n\t\t\t\t},\n\t\t\t)\n\t\t})\n\t\treturn mux\n\t})\n}\n\n// slowCopyNWithDelay copies n bytes from src to dst with a delay per byte\nfunc slowCopyNWithDelay(sl *shutdownListener, dst io.Writer, src io.Reader, n int64, delayPerByte time.Duration) {\n\tbuf := make([]byte, 32*1024)\n\tremaining := n\n\tfor remaining > 0 {\n\t\tif sl.isShutdown {\n\t\t\treturn\n\t\t}\n\t\ttoRead := int64(len(buf))\n\t\tif toRead > remaining {\n\t\t\ttoRead = remaining\n\t\t}\n\t\tnr, er := src.Read(buf[:toRead])\n\t\tif nr > 0 {\n\t\t\tnw, ew := dst.Write(buf[0:nr])\n\t\t\tif nw > 0 {\n\t\t\t\tremaining -= int64(nw)\n\t\t\t\t// Add delay based on bytes written\n\t\t\t\tif delayPerByte > 0 {\n\t\t\t\t\ttime.Sleep(delayPerByte * time.Duration(nw))\n\t\t\t\t}\n\t\t\t}\n\t\t\tif ew != nil {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif er != nil {\n\t\t\tbreak\n\t\t}\n\t}\n}\n\n// StartTestLimitServer connections limit server\nfunc StartTestLimitServer(maxConnections int32, delay int64) net.Listener {\n\tvar connections atomic.Int32\n\tvar slowOnce atomic.Bool\n\n\treturn startTestServer(func(sl *shutdownListener) http.Handler {\n\t\tmux := http.NewServeMux()\n\t\tmux.HandleFunc(\"/\"+BuildName, func(writer http.ResponseWriter, request *http.Request) {\n\t\t\tdefer func() {\n\t\t\t\tconnections.Add(-1)\n\t\t\t}()\n\t\t\tconnections.Add(1)\n\t\t\tif maxConnections != 0 && connections.Load() > maxConnections {\n\t\t\t\twriter.WriteHeader(403)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// First request intentionally delays the first write to trigger a single read timeout,\n\t\t\t// subsequent requests respond at normal speed.\n\t\t\tuseInitialDelay := delay > 0 && !slowOnce.Swap(true)\n\t\t\trangeFileHandle(\n\t\t\t\twriter,\n\t\t\t\trequest,\n\t\t\t\tnil,\n\t\t\t\tfunc(file *os.File, n int64) {\n\t\t\t\t\tif useInitialDelay {\n\t\t\t\t\t\tslowCopyAfterDelay(sl, writer, file, n, delay)\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tslowCopyN(sl, writer, file, n, 0)\n\t\t\t\t},\n\t\t\t)\n\t\t})\n\t\treturn mux\n\t})\n}\n\n// StartTestTimeoutOnceServer creates a server that times out on first request, then works normally\nfunc StartTestTimeoutOnceServer(delay int64) net.Listener {\n\tvar timeoutOnce atomic.Bool\n\n\treturn startTestServer(func(sl *shutdownListener) http.Handler {\n\t\tmux := http.NewServeMux()\n\t\tmux.HandleFunc(\"/\"+BuildName, func(writer http.ResponseWriter, request *http.Request) {\n\t\t\t// First request delays to trigger timeout, subsequent requests respond normally\n\t\t\tuseInitialDelay := delay > 0 && !timeoutOnce.Swap(true)\n\t\t\trangeFileHandle(\n\t\t\t\twriter,\n\t\t\t\trequest,\n\t\t\t\tnil,\n\t\t\t\tfunc(file *os.File, n int64) {\n\t\t\t\t\tif useInitialDelay {\n\t\t\t\t\t\tslowCopyAfterDelay(sl, writer, file, n, delay)\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tslowCopyN(sl, writer, file, n, 0)\n\t\t\t\t},\n\t\t\t)\n\t\t})\n\t\treturn mux\n\t})\n}\n\n// StartTestTemporary500Server creates a server that returns 500 for a duration, then recovers\n// Uses slow transfer to ensure the file isn't fully downloaded during resolve phase\nfunc StartTestTemporary500Server(errorDuration time.Duration) net.Listener {\n\tstartTime := time.Now()\n\tvar requestCount atomic.Int32\n\n\treturn startTestServer(func(sl *shutdownListener) http.Handler {\n\t\tmux := http.NewServeMux()\n\t\tmux.HandleFunc(\"/\"+BuildName, func(writer http.ResponseWriter, request *http.Request) {\n\t\t\treqNum := requestCount.Add(1)\n\n\t\t\t// First request (Resolve) succeeds with slow transfer to prevent full download\n\t\t\t// Subsequent requests return 500 for the specified duration, then recover\n\t\t\tif reqNum == 1 {\n\t\t\t\t// Slow transfer for resolve: 50 microsecond per byte (~20MB/s)\n\t\t\t\t// This ensures resolve doesn't complete the full 200MB file\n\t\t\t\trangeFileHandle(\n\t\t\t\t\twriter,\n\t\t\t\t\trequest,\n\t\t\t\t\tnil,\n\t\t\t\t\tfunc(file *os.File, n int64) {\n\t\t\t\t\t\tslowCopyNWithDelay(sl, writer, file, n, 50*time.Microsecond)\n\t\t\t\t\t},\n\t\t\t\t)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// Subsequent requests: return 500 for errorDuration, then normal\n\t\t\tif time.Since(startTime) < errorDuration {\n\t\t\t\twriter.WriteHeader(500)\n\t\t\t\twriter.Write([]byte(\"Internal Server Error\"))\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\trangeFileHandle(\n\t\t\t\twriter,\n\t\t\t\trequest,\n\t\t\t\tnil,\n\t\t\t\tfunc(file *os.File, n int64) {\n\t\t\t\t\tslowCopyN(sl, writer, file, n, 0)\n\t\t\t\t},\n\t\t\t)\n\t\t})\n\t\treturn mux\n\t})\n}\n\n// StartTestFailThenRecoverServer creates a server that fails all connections initially,\n// then recovers after a specified number of failed requests.\n// This tests the retry functionality when calling Start() again after a download fails.\n// Parameters:\n//   - failedRequestsBeforeRecover: number of requests that will fail before server recovers\n//\n// Note: Uses 416 (Range Not Satisfiable) instead of 500 because 5xx errors are exempt\n// from failure counting and will retry indefinitely. 416 is counted as a failure.\nfunc StartTestFailThenRecoverServer(failedRequestsBeforeRecover int32) net.Listener {\n\tvar requestCount atomic.Int32\n\n\treturn startTestServer(func(sl *shutdownListener) http.Handler {\n\t\tmux := http.NewServeMux()\n\t\tmux.HandleFunc(\"/\"+BuildName, func(writer http.ResponseWriter, request *http.Request) {\n\t\t\treqNum := requestCount.Add(1)\n\n\t\t\t// First request (Resolve) always succeeds\n\t\t\tif reqNum == 1 {\n\t\t\t\trangeFileHandle(\n\t\t\t\t\twriter,\n\t\t\t\t\trequest,\n\t\t\t\t\tnil,\n\t\t\t\t\tfunc(file *os.File, n int64) {\n\t\t\t\t\t\tio.CopyN(writer, file, n)\n\t\t\t\t\t},\n\t\t\t\t)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// Subsequent requests fail until we've had enough failures\n\t\t\t// Use 416 Range Not Satisfiable - this error is counted towards failure limit\n\t\t\tif reqNum <= failedRequestsBeforeRecover+1 { // +1 because first request is resolve\n\t\t\t\twriter.WriteHeader(416)\n\t\t\t\twriter.Write([]byte(\"Range Not Satisfiable\"))\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// After enough failures, server recovers\n\t\t\trangeFileHandle(\n\t\t\t\twriter,\n\t\t\t\trequest,\n\t\t\t\tnil,\n\t\t\t\tfunc(file *os.File, n int64) {\n\t\t\t\t\tio.CopyN(writer, file, n)\n\t\t\t\t},\n\t\t\t)\n\t\t})\n\t\treturn mux\n\t})\n}\n\n// StartTestRangeBugServer simulate bug server:\n// Don't follow Range request rules, always return more data than range, e.g. Range: bytes=0-100, return 150 bytes\nfunc StartTestRangeBugServer() net.Listener {\n\treturn startTestServer(func(sl *shutdownListener) http.Handler {\n\t\tmux := http.NewServeMux()\n\t\tmux.HandleFunc(\"/\"+BuildName, func(writer http.ResponseWriter, request *http.Request) {\n\t\t\trangeFileHandle(\n\t\t\t\twriter,\n\t\t\t\trequest,\n\t\t\t\tfunc(end int64) int64 {\n\t\t\t\t\tvar bugEnd = end\n\t\t\t\t\tif end != 0 {\n\t\t\t\t\t\tbugEnd = end + 50\n\t\t\t\t\t\tif bugEnd >= BuildSize {\n\t\t\t\t\t\t\tbugEnd = end\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\treturn bugEnd\n\t\t\t\t},\n\t\t\t\tfunc(file *os.File, n int64) {\n\t\t\t\t\tio.CopyN(writer, file, n)\n\t\t\t\t},\n\t\t\t)\n\t\t})\n\t\treturn mux\n\t})\n}\n\nfunc rangeFileHandle(writer http.ResponseWriter, request *http.Request, modifyEnd func(end int64) int64, iocpN func(file *os.File, n int64)) {\n\tr := request.Header.Get(\"Range\")\n\n\t// If no Range header, return full file with Accept-Ranges header\n\tif r == \"\" {\n\t\t// Open file first to ensure it exists\n\t\tfile, err := os.Open(BuildFile)\n\t\tif err != nil {\n\t\t\twriter.WriteHeader(500)\n\t\t\treturn\n\t\t}\n\t\tdefer file.Close()\n\n\t\twriter.Header().Set(\"Content-Length\", fmt.Sprintf(\"%d\", BuildSize))\n\t\twriter.Header().Set(\"Accept-Ranges\", \"bytes\")\n\t\twriter.WriteHeader(200)\n\t\t(writer.(http.Flusher)).Flush()\n\n\t\tiocpN(file, BuildSize)\n\t\treturn\n\t}\n\n\t// split range\n\ts := strings.Split(r, \"=\")\n\tif len(s) != 2 {\n\t\twriter.WriteHeader(400)\n\t\treturn\n\t}\n\ts = strings.Split(s[1], \"-\")\n\tif len(s) != 2 {\n\t\twriter.WriteHeader(400)\n\t\treturn\n\t}\n\tstart, err := strconv.ParseInt(s[0], 10, 64)\n\tif err != nil {\n\t\twriter.WriteHeader(400)\n\t\treturn\n\t}\n\tend, err := strconv.ParseInt(s[1], 10, 64)\n\tif err != nil {\n\t\twriter.WriteHeader(400)\n\t\treturn\n\t}\n\tif start < 0 || end < 0 || start > end {\n\t\twriter.WriteHeader(400)\n\t\treturn\n\t}\n\tif end >= BuildSize {\n\t\tend = BuildSize - 1\n\t}\n\n\tif modifyEnd != nil {\n\t\tend = modifyEnd(end)\n\t}\n\n\t// Open file before sending headers to ensure it exists\n\tfile, err := os.Open(BuildFile)\n\tif err != nil {\n\t\twriter.WriteHeader(500)\n\t\treturn\n\t}\n\tdefer file.Close()\n\tfile.Seek(start, 0)\n\n\twriter.Header().Set(\"Content-Length\", fmt.Sprintf(\"%d\", end-start+1))\n\twriter.Header().Set(\"Content-Range\", fmt.Sprintf(\"bytes %d-%d/%d\", start, end, BuildSize))\n\twriter.Header().Set(\"Accept-Ranges\", \"bytes\")\n\twriter.WriteHeader(206)\n\t(writer.(http.Flusher)).Flush()\n\n\tiocpN(file, end-start+1)\n}\n\n// slowCopyN copies n bytes from src to dst, speed limit is bytes per second\nfunc slowCopy(sl *shutdownListener, dst io.Writer, src io.Reader, delay int64) (written int64, err error) {\n\tbuf := make([]byte, 32*1024)\n\tfor {\n\t\tif sl.isShutdown {\n\t\t\treturn 0, errors.New(\"server shutdown\")\n\t\t}\n\t\tnr, er := src.Read(buf)\n\t\tif nr > 0 {\n\t\t\tnw, ew := dst.Write(buf[0:nr])\n\t\t\tif nw > 0 {\n\t\t\t\twritten += int64(nw)\n\t\t\t}\n\t\t\tif ew != nil {\n\t\t\t\terr = ew\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif nr != nw {\n\t\t\t\terr = io.ErrShortWrite\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif er != nil {\n\t\t\tif er != io.EOF {\n\t\t\t\terr = er\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\t\tif delay > 0 {\n\t\t\ttime.Sleep(time.Millisecond * time.Duration(delay))\n\t\t}\n\t}\n\treturn written, err\n}\n\nfunc slowCopyN(sl *shutdownListener, dst io.Writer, src io.Reader, n int64, delay int64) (written int64, err error) {\n\twritten, err = slowCopy(sl, dst, io.LimitReader(src, n), delay)\n\tif written == n {\n\t\treturn n, nil\n\t}\n\tif written < n && err == nil {\n\t\t// src stopped early; must have been EOF.\n\t\terr = io.EOF\n\t}\n\treturn\n}\n\n// slowCopyAfterDelay sleeps once before performing a normal copy to simulate a single timeout.\nfunc slowCopyAfterDelay(sl *shutdownListener, dst io.Writer, src io.Reader, n int64, delay int64) (written int64, err error) {\n\tif delay > 0 {\n\t\ttime.Sleep(time.Millisecond * time.Duration(delay))\n\t}\n\treturn slowCopyN(sl, dst, src, n, 0)\n}\n\nfunc startTestServer(serverHandle func(sl *shutdownListener) http.Handler) net.Listener {\n\tlistener, err := net.Listen(\"tcp\", \"127.0.0.1:0\")\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\tfile, err := os.Create(BuildFile)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\tdefer file.Close()\n\t// Write random data\n\tl := int64(8192)\n\tbuf := make([]byte, l)\n\tsize := int64(0)\n\tfor {\n\t\t_, err := rand.Read(buf)\n\t\tif err != nil {\n\t\t\tpanic(err)\n\t\t}\n\t\tif size+l >= BuildSize {\n\t\t\tfile.WriteAt(buf[0:BuildSize-size], size)\n\t\t\tbreak\n\t\t}\n\t\tfile.WriteAt(buf, size)\n\t\tsize += l\n\t}\n\tserver := &http.Server{}\n\tsl := &shutdownListener{\n\t\tserver:   server,\n\t\tListener: listener,\n\t}\n\tserver.Handler = serverHandle(sl)\n\tgo server.Serve(listener)\n\n\treturn sl\n}\n\ntype shutdownListener struct {\n\tserver     *http.Server\n\tisShutdown bool\n\tnet.Listener\n}\n\nfunc (c *shutdownListener) Close() error {\n\t// Shutdown server first (waits for in-flight requests), then set isShutdown\n\tcloseErr := c.server.Shutdown(context.Background())\n\tc.isShutdown = true\n\tif err := ifExistAndRemove(BuildFile); err != nil {\n\t\tfmt.Println(err)\n\t}\n\tif err := ifExistAndRemove(DownloadFile); err != nil {\n\t\tfmt.Println(err)\n\t}\n\tif err := ifExistAndRemove(DownloadRenameFile); err != nil {\n\t\tfmt.Println(err)\n\t}\n\treturn closeErr\n}\n\nfunc ifExistAndRemove(name string) error {\n\tif _, err := os.Stat(name); !os.IsNotExist(err) {\n\t\treturn os.Remove(name)\n\t}\n\treturn nil\n}\n\nfunc StartSocks5Server(usr, pwd string) net.Listener {\n\tconf := &socks5.Config{}\n\tif usr != \"\" && pwd != \"\" {\n\t\tconf.Credentials = socks5.StaticCredentials{\n\t\t\tusr: pwd,\n\t\t}\n\t}\n\n\tserver, err := socks5.New(conf)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\tlistener, err := net.Listen(\"tcp\", \"127.0.0.1:0\") // 你可以根据需要更改监听地址\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\tgo server.Serve(listener)\n\treturn listener\n}\n\n// StartTestPatchURLServer creates a server with two endpoints:\n// - /bad-url: always returns 404 (simulates a broken download link)\n// - /good-url: returns the file successfully (simulates a working download link)\n// This is used to test the Patch functionality where a failed download URL can be\n// replaced with a working one.\nfunc StartTestPatchURLServer() net.Listener {\n\treturn startTestServer(func(sl *shutdownListener) http.Handler {\n\t\tmux := http.NewServeMux()\n\t\t// Bad URL - always fails with 404\n\t\tmux.HandleFunc(\"/bad-url\", func(writer http.ResponseWriter, request *http.Request) {\n\t\t\twriter.WriteHeader(404)\n\t\t\twriter.Write([]byte(\"Not Found\"))\n\t\t})\n\t\t// Good URL - returns the file successfully with range support\n\t\tmux.HandleFunc(\"/good-url\", func(writer http.ResponseWriter, request *http.Request) {\n\t\t\trangeFileHandle(\n\t\t\t\twriter,\n\t\t\t\trequest,\n\t\t\t\tnil,\n\t\t\t\tfunc(file *os.File, n int64) {\n\t\t\t\t\tio.CopyN(writer, file, n)\n\t\t\t\t},\n\t\t\t)\n\t\t})\n\t\treturn mux\n\t})\n}\n\n// StartTestCookieExpiringServer creates a server that simulates cookie expiration during download.\n// - First request (Resolve): accepts \"session=old_token\" and succeeds\n// - Subsequent requests: \"session=old_token\" is expired (returns 401), only \"session=new_token\" works\n// This is used to test the Patch functionality where a cookie expires mid-download,\n// requiring the user to patch with a new cookie to resume.\nfunc StartTestCookieExpiringServer() net.Listener {\n\tvar requestCount atomic.Int32\n\n\treturn startTestServer(func(sl *shutdownListener) http.Handler {\n\t\tmux := http.NewServeMux()\n\t\tmux.HandleFunc(\"/\"+BuildName, func(writer http.ResponseWriter, request *http.Request) {\n\t\t\treqNum := requestCount.Add(1)\n\t\t\tcookie := request.Header.Get(\"Cookie\")\n\n\t\t\t// First request (Resolve): accept old_token\n\t\t\tif reqNum == 1 {\n\t\t\t\tif !strings.Contains(cookie, \"session=old_token\") {\n\t\t\t\t\twriter.WriteHeader(401)\n\t\t\t\t\twriter.Write([]byte(\"Unauthorized: Invalid cookie\"))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\t// Return file info for resolve (with range support)\n\t\t\t\trangeFileHandle(\n\t\t\t\t\twriter,\n\t\t\t\t\trequest,\n\t\t\t\t\tnil,\n\t\t\t\t\tfunc(file *os.File, n int64) {\n\t\t\t\t\t\tio.CopyN(writer, file, n)\n\t\t\t\t\t},\n\t\t\t\t)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// Subsequent requests: old_token is expired, only new_token works\n\t\t\tif strings.Contains(cookie, \"session=new_token\") {\n\t\t\t\t// New token is valid - return the file with range support\n\t\t\t\trangeFileHandle(\n\t\t\t\t\twriter,\n\t\t\t\t\trequest,\n\t\t\t\t\tnil,\n\t\t\t\t\tfunc(file *os.File, n int64) {\n\t\t\t\t\t\tio.CopyN(writer, file, n)\n\t\t\t\t\t},\n\t\t\t\t)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// Old token or invalid token - return 401\n\t\t\twriter.WriteHeader(401)\n\t\t\twriter.Write([]byte(\"Unauthorized: Cookie expired\"))\n\t\t})\n\t\treturn mux\n\t})\n}\n\nfunc AssertResourceEqual(want, got *base.Resource) bool {\n\t// Ignore ctime\n\tif got != nil && len(got.Files) > 0 {\n\t\tgot.Files[0].Ctime = nil\n\t}\n\treturn reflect.DeepEqual(want, got)\n}\n"
  },
  {
    "path": "internal/test/util.go",
    "content": "package test\n\nimport (\n\t\"crypto/md5\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n)\n\nfunc FileMd5(filePath string) string {\n\tfile, err := os.Open(filePath)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\t// Tell the program to call the following function when the current function returns\n\tdefer file.Close()\n\n\t// Open a new hash interface to write to\n\thash := md5.New()\n\n\t// Copy the file in the hash interface and check for any error\n\tif _, err := io.Copy(hash, file); err != nil {\n\t\treturn \"\"\n\t}\n\treturn hex.EncodeToString(hash.Sum(nil))\n}\n\nfunc DirMd5(dirPath string) string {\n\thash := md5.New()\n\tfilepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {\n\t\tif info.IsDir() {\n\t\t\treturn nil\n\t\t}\n\t\tfile, err := os.Open(path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif _, err := io.Copy(hash, file); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t})\n\treturn hex.EncodeToString(hash.Sum(nil))\n}\n\nfunc ToJson(v interface{}) string {\n\tbuf, _ := json.Marshal(v)\n\treturn string(buf)\n}\n\nfunc JsonEqual(v1 any, v2 any) bool {\n\treturn ToJson(v1) == ToJson(v2)\n}\n"
  },
  {
    "path": "pkg/base/constants.go",
    "content": "package base\n\ntype Status string\n\nconst (\n\tDownloadStatusReady   Status = \"ready\" // task create but not start\n\tDownloadStatusRunning Status = \"running\"\n\tDownloadStatusPause   Status = \"pause\"\n\tDownloadStatusWait    Status = \"wait\" // task is wait for running\n\tDownloadStatusError   Status = \"error\"\n\tDownloadStatusDone    Status = \"done\"\n)\n\nconst (\n\tHttpCodeOK             = 200\n\tHttpCodePartialContent = 206\n\n\tHttpHeaderHost               = \"Host\"\n\tHttpHeaderRange              = \"Range\"\n\tHttpHeaderAcceptRanges       = \"Accept-Ranges\"\n\tHttpHeaderContentLength      = \"Content-Length\"\n\tHttpHeaderContentRange       = \"Content-Range\"\n\tHttpHeaderContentDisposition = \"Content-Disposition\"\n\tHttpHeaderUserAgent          = \"User-Agent\"\n\tHttpHeaderLastModified       = \"Last-Modified\"\n\n\tHttpHeaderBytes       = \"bytes\"\n\tHttpHeaderRangeFormat = \"bytes=%d-%d\"\n)\n"
  },
  {
    "path": "pkg/base/info.go",
    "content": "package base\n\n// Version is the build version, set at build time, using `go build -ldflags \"-X github.com/GopeedLab/gopeed/pkg/base.Version=1.0.0\"`.\nvar Version string\nvar InDocker string\n\nfunc init() {\n\tif Version == \"\" {\n\t\tVersion = \"dev\"\n\t}\n}\n"
  },
  {
    "path": "pkg/base/model.go",
    "content": "package base\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/GopeedLab/gopeed/pkg/util\"\n\t\"github.com/mattn/go-ieproxy\"\n\t\"golang.org/x/exp/slices\"\n)\n\n// Request download request\ntype Request struct {\n\tURL   string `json:\"url\"`\n\tExtra any    `json:\"extra\"`\n\t// Labels is used to mark the download task\n\tLabels map[string]string `json:\"labels\"`\n\t// Proxy is special proxy config for request\n\tProxy *RequestProxy `json:\"proxy\"`\n\t// SkipVerifyCert is the flag that skip verify cert\n\tSkipVerifyCert bool `json:\"skipVerifyCert\"`\n}\n\nfunc (r *Request) Validate() error {\n\tif r.URL == \"\" {\n\t\treturn fmt.Errorf(\"invalid request url\")\n\t}\n\treturn nil\n}\n\ntype RequestProxyMode string\n\nconst (\n\t// RequestProxyModeFollow follow setting proxy\n\tRequestProxyModeFollow RequestProxyMode = \"follow\"\n\t// RequestProxyModeNone not use proxy\n\tRequestProxyModeNone RequestProxyMode = \"none\"\n\t// RequestProxyModeCustom custom proxy\n\tRequestProxyModeCustom RequestProxyMode = \"custom\"\n)\n\ntype RequestProxy struct {\n\tMode   RequestProxyMode `json:\"mode\"`\n\tScheme string           `json:\"scheme\"`\n\tHost   string           `json:\"host\"`\n\tUsr    string           `json:\"usr\"`\n\tPwd    string           `json:\"pwd\"`\n}\n\nfunc (p *RequestProxy) ToHandler() func(r *http.Request) (*url.URL, error) {\n\tif p == nil || p.Mode != RequestProxyModeCustom {\n\t\treturn nil\n\t}\n\n\tif p.Scheme == \"\" || p.Host == \"\" {\n\t\treturn nil\n\t}\n\n\treturn http.ProxyURL(util.BuildProxyUrl(p.Scheme, p.Host, p.Usr, p.Pwd))\n}\n\n// Resource download resource\ntype Resource struct {\n\t// if name is not empty, the resource is a folder and the name is the folder name\n\tName string `json:\"name\"`\n\tSize int64  `json:\"size\"`\n\t// is support range download\n\tRange bool `json:\"range\"`\n\t// file list\n\tFiles []*FileInfo `json:\"files\"`\n\tHash  string      `json:\"hash\"`\n}\n\nfunc (r *Resource) Validate() error {\n\tif r.Name == \"\" {\n\t\treturn fmt.Errorf(\"invalid resource name\")\n\t}\n\tif len(r.Files) == 0 {\n\t\treturn fmt.Errorf(\"invalid resource files\")\n\t}\n\tfor _, file := range r.Files {\n\t\tif file.Name == \"\" {\n\t\t\treturn fmt.Errorf(\"invalid resource file name\")\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (r *Resource) CalcSize(selectFiles []int) {\n\tvar size int64\n\tfor i, file := range r.Files {\n\t\tif len(selectFiles) == 0 || slices.Contains(selectFiles, i) {\n\t\t\tsize += file.Size\n\t\t}\n\t}\n\tr.Size = size\n}\n\ntype FileInfo struct {\n\tName  string     `json:\"name\"`\n\tPath  string     `json:\"path\"`\n\tSize  int64      `json:\"size\"`\n\tCtime *time.Time `json:\"ctime\"`\n\n\tReq *Request `json:\"req\"`\n}\n\n// Options for download\ntype Options struct {\n\t// Download file name\n\tName string `json:\"name\"`\n\t// Download file path\n\tPath string `json:\"path\"`\n\t// Select file indexes to download\n\tSelectFiles []int `json:\"selectFiles\"`\n\t// Extra info for specific fetcher\n\tExtra any `json:\"extra\"`\n}\n\nfunc (o *Options) InitSelectFiles(fileSize int) {\n\t// if selectFiles is empty, select all files\n\tif len(o.SelectFiles) == 0 {\n\t\to.SelectFiles = make([]int, fileSize)\n\t\tfor i := range fileSize {\n\t\t\to.SelectFiles[i] = i\n\t\t}\n\t}\n}\n\nfunc (o *Options) Clone() *Options {\n\treturn util.DeepClone(o)\n}\n\nfunc ParseReqExtra[E any](req *Request) error {\n\tif req.Extra == nil {\n\t\treturn nil\n\t}\n\tif _, ok := req.Extra.(*E); ok {\n\t\treturn nil\n\t}\n\tvar t E\n\tif err := util.MapToStruct(req.Extra, &t); err != nil {\n\t\treturn err\n\t}\n\treq.Extra = &t\n\treturn nil\n}\n\nfunc ParseOptExtra[E any](opts *Options) error {\n\tif opts.Extra == nil {\n\t\treturn nil\n\t}\n\tif _, ok := opts.Extra.(*E); ok {\n\t\treturn nil\n\t}\n\tvar t E\n\tif err := util.MapToStruct(opts.Extra, &t); err != nil {\n\t\treturn err\n\t}\n\topts.Extra = &t\n\treturn nil\n}\n\ntype CreateTaskBatch struct {\n\tReqs []*CreateTaskBatchItem `json:\"reqs\"`\n\tOpts *Options               `json:\"opts\"`\n}\n\ntype CreateTaskBatchItem struct {\n\tReq  *Request `json:\"req\"`\n\tOpts *Options `json:\"opts\"`\n}\n\n// DownloaderStoreConfig is the config that can restore the downloader.\ntype DownloaderStoreConfig struct {\n\tFirstLoad bool `json:\"-\"` // FirstLoad is the flag that the config is first time init and not from store\n\n\tDownloadDir                string                 `json:\"downloadDir\"`    // DownloadDir is the default directory to save the downloaded files\n\tMaxRunning                 int                    `json:\"maxRunning\"`     // MaxRunning is the max running download count\n\tProtocolConfig             map[string]any         `json:\"protocolConfig\"` // ProtocolConfig is special config for each protocol\n\tExtra                      map[string]any         `json:\"extra\"`\n\tProxy                      *DownloaderProxyConfig `json:\"proxy\"`\n\tWebhook                    *WebhookConfig         `json:\"webhook\"`                    // Webhook is the webhook configuration\n\tScript                     *ScriptConfig          `json:\"script\"`                     // Script is the script execution configuration\n\tAutoTorrent                *AutoTorrentConfig     `json:\"autoTorrent\"`                // AutoTorrent is the auto torrent task creation configuration\n\tArchive                    *ArchiveConfig         `json:\"archive\"`                    // Archive is the archive extraction configuration\n\tAutoDeleteMissingFileTasks bool                   `json:\"autoDeleteMissingFileTasks\"` // AutoDeleteMissingFileTasks enables automatic deletion of tasks with missing files\n}\n\nfunc (cfg *DownloaderStoreConfig) Init() *DownloaderStoreConfig {\n\tif cfg.MaxRunning == 0 {\n\t\tcfg.MaxRunning = 5\n\t}\n\tif cfg.ProtocolConfig == nil {\n\t\tcfg.ProtocolConfig = make(map[string]any)\n\t}\n\tif cfg.Proxy == nil {\n\t\tcfg.Proxy = &DownloaderProxyConfig{}\n\t}\n\tif cfg.Webhook == nil {\n\t\tcfg.Webhook = &WebhookConfig{}\n\t}\n\tif cfg.Script == nil {\n\t\tcfg.Script = &ScriptConfig{}\n\t}\n\tif cfg.AutoTorrent == nil {\n\t\tcfg.AutoTorrent = &AutoTorrentConfig{\n\t\t\tEnable:              false,\n\t\t\tDeleteAfterDownload: false,\n\t\t}\n\t}\n\tif cfg.Archive == nil {\n\t\tcfg.Archive = &ArchiveConfig{\n\t\t\tAutoExtract:        false,\n\t\t\tDeleteAfterExtract: false,\n\t\t}\n\t}\n\treturn cfg\n}\n\nfunc (cfg *DownloaderStoreConfig) Merge(beforeCfg *DownloaderStoreConfig) *DownloaderStoreConfig {\n\tif beforeCfg == nil {\n\t\treturn cfg\n\t}\n\tif cfg.DownloadDir == \"\" {\n\t\tcfg.DownloadDir = beforeCfg.DownloadDir\n\t}\n\tif cfg.MaxRunning == 0 {\n\t\tcfg.MaxRunning = beforeCfg.MaxRunning\n\t}\n\tif cfg.ProtocolConfig == nil {\n\t\tcfg.ProtocolConfig = beforeCfg.ProtocolConfig\n\t}\n\tif cfg.Extra == nil {\n\t\tcfg.Extra = beforeCfg.Extra\n\t}\n\tif cfg.Proxy == nil {\n\t\tcfg.Proxy = beforeCfg.Proxy\n\t}\n\tif cfg.Webhook == nil {\n\t\tcfg.Webhook = beforeCfg.Webhook\n\t}\n\tif cfg.Script == nil {\n\t\tcfg.Script = beforeCfg.Script\n\t}\n\tif cfg.AutoTorrent == nil {\n\t\tcfg.AutoTorrent = beforeCfg.AutoTorrent\n\t}\n\tif cfg.Archive == nil {\n\t\tcfg.Archive = beforeCfg.Archive\n\t}\n\treturn cfg\n}\n\n// WebhookConfig is the webhook configuration\ntype WebhookConfig struct {\n\tEnable bool     `json:\"enable\"` // Enable is the flag to enable/disable webhooks\n\tURLs   []string `json:\"urls\"`   // URLs is the list of webhook URLs\n}\n\n// ScriptConfig is the script execution configuration\ntype ScriptConfig struct {\n\tEnable bool     `json:\"enable\"` // Enable is the flag to enable/disable script execution\n\tPaths  []string `json:\"paths\"`  // Paths is the list of script paths to execute\n}\n\n// AutoTorrentConfig is the auto torrent task creation configuration\ntype AutoTorrentConfig struct {\n\tEnable              bool `json:\"enable\"`              // Enable enables automatic BT task creation when downloading .torrent files\n\tDeleteAfterDownload bool `json:\"deleteAfterDownload\"` // DeleteAfterDownload deletes the .torrent file after BT task creation\n}\n\n// ArchiveConfig is the archive extraction configuration\ntype ArchiveConfig struct {\n\tAutoExtract        bool `json:\"autoExtract\"`        // AutoExtract enables automatic extraction of archives after download\n\tDeleteAfterExtract bool `json:\"deleteAfterExtract\"` // DeleteAfterExtract deletes the archive after successful extraction\n}\n\ntype DownloaderProxyConfig struct {\n\tEnable bool `json:\"enable\"`\n\t// System is the flag that use system proxy\n\tSystem bool   `json:\"system\"`\n\tScheme string `json:\"scheme\"`\n\tHost   string `json:\"host\"`\n\tUsr    string `json:\"usr\"`\n\tPwd    string `json:\"pwd\"`\n}\n\nfunc (cfg *DownloaderProxyConfig) ToHandler() func(r *http.Request) (*url.URL, error) {\n\tif cfg == nil || cfg.Enable == false {\n\t\treturn nil\n\t}\n\tif cfg.System {\n\t\tsafeProxyReloadConf()\n\t\treturn ieproxy.GetProxyFunc()\n\t}\n\tif cfg.Scheme == \"\" || cfg.Host == \"\" {\n\t\treturn nil\n\t}\n\treturn http.ProxyURL(util.BuildProxyUrl(cfg.Scheme, cfg.Host, cfg.Usr, cfg.Pwd))\n}\n\n// ToUrl returns the proxy url, just for git clone\nfunc (cfg *DownloaderProxyConfig) ToUrl() *url.URL {\n\tif cfg == nil || cfg.Enable == false {\n\t\treturn nil\n\t}\n\tif cfg.System {\n\t\tsafeProxyReloadConf()\n\t\tstatic := ieproxy.GetConf().Static\n\t\tif static.Active && len(static.Protocols) > 0 {\n\t\t\t// If only one protocol, use it\n\t\t\tif len(static.Protocols) == 1 {\n\t\t\t\tfor _, v := range static.Protocols {\n\t\t\t\t\treturn parseUrlSafe(v)\n\t\t\t\t}\n\t\t\t}\n\t\t\t// Check https\n\t\t\tif v, ok := static.Protocols[\"https\"]; ok {\n\t\t\t\treturn parseUrlSafe(v)\n\t\t\t}\n\t\t\t// Check http\n\t\t\tif v, ok := static.Protocols[\"http\"]; ok {\n\t\t\t\treturn parseUrlSafe(v)\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t}\n\tif cfg.Scheme == \"\" || cfg.Host == \"\" {\n\t\treturn nil\n\t}\n\treturn util.BuildProxyUrl(cfg.Scheme, cfg.Host, cfg.Usr, cfg.Pwd)\n}\n\nvar prcLock sync.Mutex\n\nfunc safeProxyReloadConf() {\n\tprcLock.Lock()\n\tdefer prcLock.Unlock()\n\n\tieproxy.ReloadConf()\n}\n\nfunc parseUrlSafe(rawUrl string) *url.URL {\n\tu, err := url.Parse(rawUrl)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn u\n}\n"
  },
  {
    "path": "pkg/base/model_test.go",
    "content": "package base\n\nimport (\n\t\"reflect\"\n\t\"testing\"\n)\n\nfunc TestDownloaderStoreConfig_Init(t *testing.T) {\n\ttests := []struct {\n\t\tname   string\n\t\tfields *DownloaderStoreConfig\n\t\twant   *DownloaderStoreConfig\n\t}{\n\t\t{\n\t\t\t\"Init\",\n\t\t\t&DownloaderStoreConfig{},\n\t\t\t&DownloaderStoreConfig{\n\t\t\t\tMaxRunning:     5,\n\t\t\t\tProtocolConfig: map[string]any{},\n\t\t\t\tProxy:          &DownloaderProxyConfig{},\n\t\t\t\tWebhook:        &WebhookConfig{},\n\t\t\t\tScript:         &ScriptConfig{},\n\t\t\t\tAutoTorrent: &AutoTorrentConfig{\n\t\t\t\t\tEnable:              false,\n\t\t\t\t\tDeleteAfterDownload: false,\n\t\t\t\t},\n\t\t\t\tArchive: &ArchiveConfig{\n\t\t\t\t\tAutoExtract:        false,\n\t\t\t\t\tDeleteAfterExtract: false,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"Init MaxRunning\",\n\t\t\t&DownloaderStoreConfig{\n\t\t\t\tMaxRunning: 10,\n\t\t\t},\n\t\t\t&DownloaderStoreConfig{\n\t\t\t\tMaxRunning:     10,\n\t\t\t\tProtocolConfig: map[string]any{},\n\t\t\t\tProxy:          &DownloaderProxyConfig{},\n\t\t\t\tWebhook:        &WebhookConfig{},\n\t\t\t\tScript:         &ScriptConfig{},\n\t\t\t\tAutoTorrent: &AutoTorrentConfig{\n\t\t\t\t\tEnable:              false,\n\t\t\t\t\tDeleteAfterDownload: false,\n\t\t\t\t},\n\t\t\t\tArchive: &ArchiveConfig{\n\t\t\t\t\tAutoExtract:        false,\n\t\t\t\t\tDeleteAfterExtract: false,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"Init ProtocolConfig\",\n\t\t\t&DownloaderStoreConfig{\n\t\t\t\tProtocolConfig: map[string]any{\n\t\t\t\t\t\"key\": \"value\",\n\t\t\t\t},\n\t\t\t},\n\t\t\t&DownloaderStoreConfig{\n\t\t\t\tMaxRunning: 5,\n\t\t\t\tProtocolConfig: map[string]any{\n\t\t\t\t\t\"key\": \"value\",\n\t\t\t\t},\n\t\t\t\tProxy:   &DownloaderProxyConfig{},\n\t\t\t\tWebhook: &WebhookConfig{},\n\t\t\t\tScript:  &ScriptConfig{},\n\t\t\t\tAutoTorrent: &AutoTorrentConfig{\n\t\t\t\t\tEnable:              false,\n\t\t\t\t\tDeleteAfterDownload: false,\n\t\t\t\t},\n\t\t\t\tArchive: &ArchiveConfig{\n\t\t\t\t\tAutoExtract:        false,\n\t\t\t\t\tDeleteAfterExtract: false,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"Init Proxy\",\n\t\t\t&DownloaderStoreConfig{\n\t\t\t\tProxy: &DownloaderProxyConfig{\n\t\t\t\t\tEnable: true,\n\t\t\t\t},\n\t\t\t},\n\t\t\t&DownloaderStoreConfig{\n\t\t\t\tMaxRunning:     5,\n\t\t\t\tProtocolConfig: map[string]any{},\n\t\t\t\tProxy: &DownloaderProxyConfig{\n\t\t\t\t\tEnable: true,\n\t\t\t\t},\n\t\t\t\tWebhook: &WebhookConfig{},\n\t\t\t\tScript:  &ScriptConfig{},\n\t\t\t\tAutoTorrent: &AutoTorrentConfig{\n\t\t\t\t\tEnable:              false,\n\t\t\t\t\tDeleteAfterDownload: false,\n\t\t\t\t},\n\t\t\t\tArchive: &ArchiveConfig{\n\t\t\t\t\tAutoExtract:        false,\n\t\t\t\t\tDeleteAfterExtract: false,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"Init AutoTorrent\",\n\t\t\t&DownloaderStoreConfig{\n\t\t\t\tAutoTorrent: &AutoTorrentConfig{\n\t\t\t\t\tEnable:              true,\n\t\t\t\t\tDeleteAfterDownload: true,\n\t\t\t\t},\n\t\t\t},\n\t\t\t&DownloaderStoreConfig{\n\t\t\t\tMaxRunning:     5,\n\t\t\t\tProtocolConfig: map[string]any{},\n\t\t\t\tProxy:          &DownloaderProxyConfig{},\n\t\t\t\tWebhook:        &WebhookConfig{},\n\t\t\t\tScript:         &ScriptConfig{},\n\t\t\t\tAutoTorrent: &AutoTorrentConfig{\n\t\t\t\t\tEnable:              true,\n\t\t\t\t\tDeleteAfterDownload: true,\n\t\t\t\t},\n\t\t\t\tArchive: &ArchiveConfig{\n\t\t\t\t\tAutoExtract:        false,\n\t\t\t\t\tDeleteAfterExtract: false,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"Init Archive\",\n\t\t\t&DownloaderStoreConfig{\n\t\t\t\tArchive: &ArchiveConfig{\n\t\t\t\t\tAutoExtract:        true,\n\t\t\t\t\tDeleteAfterExtract: false,\n\t\t\t\t},\n\t\t\t},\n\t\t\t&DownloaderStoreConfig{\n\t\t\t\tMaxRunning:     5,\n\t\t\t\tProtocolConfig: map[string]any{},\n\t\t\t\tProxy:          &DownloaderProxyConfig{},\n\t\t\t\tWebhook:        &WebhookConfig{},\n\t\t\t\tScript:         &ScriptConfig{},\n\t\t\t\tAutoTorrent: &AutoTorrentConfig{\n\t\t\t\t\tEnable:              false,\n\t\t\t\t\tDeleteAfterDownload: false,\n\t\t\t\t},\n\t\t\t\tArchive: &ArchiveConfig{\n\t\t\t\t\tAutoExtract:        true,\n\t\t\t\t\tDeleteAfterExtract: false,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tcfg := &DownloaderStoreConfig{\n\t\t\t\tFirstLoad:      tt.fields.FirstLoad,\n\t\t\t\tDownloadDir:    tt.fields.DownloadDir,\n\t\t\t\tMaxRunning:     tt.fields.MaxRunning,\n\t\t\t\tProtocolConfig: tt.fields.ProtocolConfig,\n\t\t\t\tExtra:          tt.fields.Extra,\n\t\t\t\tProxy:          tt.fields.Proxy,\n\t\t\t\tWebhook:        tt.fields.Webhook,\n\t\t\t\tScript:         tt.fields.Script,\n\t\t\t\tAutoTorrent:    tt.fields.AutoTorrent,\n\t\t\t\tArchive:        tt.fields.Archive,\n\t\t\t}\n\t\t\tif got := cfg.Init(); !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Errorf(\"Init() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestDownloaderStoreConfig_Merge(t *testing.T) {\n\ttype args struct {\n\t\tbeforeCfg *DownloaderStoreConfig\n\t}\n\ttests := []struct {\n\t\tname   string\n\t\tfields *DownloaderStoreConfig\n\t\targs   args\n\t\twant   *DownloaderStoreConfig\n\t}{\n\t\t{\n\t\t\t\"Merge Nil\",\n\t\t\t&DownloaderStoreConfig{},\n\t\t\targs{\n\t\t\t\tbeforeCfg: nil,\n\t\t\t},\n\t\t\t&DownloaderStoreConfig{},\n\t\t},\n\t\t{\n\t\t\t\"Merge DownloadDir No Override\",\n\t\t\t&DownloaderStoreConfig{\n\t\t\t\tDownloadDir: \"before\",\n\t\t\t},\n\t\t\targs{\n\t\t\t\tbeforeCfg: &DownloaderStoreConfig{\n\t\t\t\t\tDownloadDir: \"after\",\n\t\t\t\t},\n\t\t\t},\n\t\t\t&DownloaderStoreConfig{\n\t\t\t\tDownloadDir: \"before\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"Merge DownloadDir Override\",\n\t\t\t&DownloaderStoreConfig{},\n\t\t\targs{\n\t\t\t\tbeforeCfg: &DownloaderStoreConfig{\n\t\t\t\t\tDownloadDir: \"after\",\n\t\t\t\t},\n\t\t\t},\n\t\t\t&DownloaderStoreConfig{\n\t\t\t\tDownloadDir: \"after\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"Merge MaxRunning No Override\",\n\t\t\t&DownloaderStoreConfig{\n\t\t\t\tMaxRunning: 1,\n\t\t\t},\n\t\t\targs{\n\t\t\t\tbeforeCfg: &DownloaderStoreConfig{\n\t\t\t\t\tMaxRunning: 10,\n\t\t\t\t},\n\t\t\t},\n\t\t\t&DownloaderStoreConfig{\n\t\t\t\tMaxRunning: 1,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"Merge MaxRunning Override\",\n\t\t\t&DownloaderStoreConfig{},\n\t\t\targs{\n\t\t\t\tbeforeCfg: &DownloaderStoreConfig{\n\t\t\t\t\tMaxRunning: 10,\n\t\t\t\t},\n\t\t\t},\n\t\t\t&DownloaderStoreConfig{\n\t\t\t\tMaxRunning: 10,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"Merge ProtocolConfig No Override\",\n\t\t\t&DownloaderStoreConfig{\n\t\t\t\tProtocolConfig: map[string]any{},\n\t\t\t},\n\t\t\targs{\n\t\t\t\tbeforeCfg: &DownloaderStoreConfig{\n\t\t\t\t\tProtocolConfig: map[string]any{\n\t\t\t\t\t\t\"key\": \"after\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t&DownloaderStoreConfig{\n\t\t\t\tProtocolConfig: map[string]any{},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"Merge ProtocolConfig Override\",\n\t\t\t&DownloaderStoreConfig{},\n\t\t\targs{\n\t\t\t\tbeforeCfg: &DownloaderStoreConfig{\n\t\t\t\t\tProtocolConfig: map[string]any{\n\t\t\t\t\t\t\"key\": \"after\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t&DownloaderStoreConfig{\n\t\t\t\tProtocolConfig: map[string]any{\n\t\t\t\t\t\"key\": \"after\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"Merge Extra No Override\",\n\t\t\t&DownloaderStoreConfig{\n\t\t\t\tExtra: map[string]any{},\n\t\t\t},\n\t\t\targs{\n\t\t\t\tbeforeCfg: &DownloaderStoreConfig{\n\t\t\t\t\tExtra: map[string]any{\n\t\t\t\t\t\t\"key\": \"after\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t&DownloaderStoreConfig{\n\t\t\t\tExtra: map[string]any{},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"Merge Extra Override\",\n\t\t\t&DownloaderStoreConfig{},\n\t\t\targs{\n\t\t\t\tbeforeCfg: &DownloaderStoreConfig{\n\t\t\t\t\tExtra: map[string]any{\n\t\t\t\t\t\t\"key\": \"after\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t&DownloaderStoreConfig{\n\t\t\t\tExtra: map[string]any{\n\t\t\t\t\t\"key\": \"after\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"Merge Proxy No Override\",\n\t\t\t&DownloaderStoreConfig{\n\t\t\t\tProxy: &DownloaderProxyConfig{},\n\t\t\t},\n\t\t\targs{\n\t\t\t\tbeforeCfg: &DownloaderStoreConfig{\n\t\t\t\t\tProxy: &DownloaderProxyConfig{\n\t\t\t\t\t\tScheme: \"http\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t&DownloaderStoreConfig{\n\t\t\t\tProxy: &DownloaderProxyConfig{},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"Merge Proxy Override\",\n\t\t\t&DownloaderStoreConfig{},\n\t\t\targs{\n\t\t\t\tbeforeCfg: &DownloaderStoreConfig{\n\t\t\t\t\tProxy: &DownloaderProxyConfig{\n\t\t\t\t\t\tScheme: \"http\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t&DownloaderStoreConfig{\n\t\t\t\tProxy: &DownloaderProxyConfig{\n\t\t\t\t\tScheme: \"http\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"Merge AutoTorrent No Override\",\n\t\t\t&DownloaderStoreConfig{\n\t\t\t\tAutoTorrent: &AutoTorrentConfig{\n\t\t\t\t\tEnable: true,\n\t\t\t\t},\n\t\t\t},\n\t\t\targs{\n\t\t\t\tbeforeCfg: &DownloaderStoreConfig{\n\t\t\t\t\tAutoTorrent: &AutoTorrentConfig{\n\t\t\t\t\t\tEnable:              false,\n\t\t\t\t\t\tDeleteAfterDownload: false,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t&DownloaderStoreConfig{\n\t\t\t\tAutoTorrent: &AutoTorrentConfig{\n\t\t\t\t\tEnable: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"Merge AutoTorrent Override\",\n\t\t\t&DownloaderStoreConfig{},\n\t\t\targs{\n\t\t\t\tbeforeCfg: &DownloaderStoreConfig{\n\t\t\t\t\tAutoTorrent: &AutoTorrentConfig{\n\t\t\t\t\t\tEnable:              true,\n\t\t\t\t\t\tDeleteAfterDownload: true,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t&DownloaderStoreConfig{\n\t\t\t\tAutoTorrent: &AutoTorrentConfig{\n\t\t\t\t\tEnable:              true,\n\t\t\t\t\tDeleteAfterDownload: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"Merge Archive No Override\",\n\t\t\t&DownloaderStoreConfig{\n\t\t\t\tArchive: &ArchiveConfig{\n\t\t\t\t\tAutoExtract: true,\n\t\t\t\t},\n\t\t\t},\n\t\t\targs{\n\t\t\t\tbeforeCfg: &DownloaderStoreConfig{\n\t\t\t\t\tArchive: &ArchiveConfig{\n\t\t\t\t\t\tAutoExtract:        false,\n\t\t\t\t\t\tDeleteAfterExtract: false,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t&DownloaderStoreConfig{\n\t\t\t\tArchive: &ArchiveConfig{\n\t\t\t\t\tAutoExtract: true,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\t\"Merge Archive Override\",\n\t\t\t&DownloaderStoreConfig{},\n\t\t\targs{\n\t\t\t\tbeforeCfg: &DownloaderStoreConfig{\n\t\t\t\t\tArchive: &ArchiveConfig{\n\t\t\t\t\t\tAutoExtract:        false,\n\t\t\t\t\t\tDeleteAfterExtract: false,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\t&DownloaderStoreConfig{\n\t\t\t\tArchive: &ArchiveConfig{\n\t\t\t\t\tAutoExtract:        false,\n\t\t\t\t\tDeleteAfterExtract: false,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tcfg := &DownloaderStoreConfig{\n\t\t\t\tFirstLoad:      tt.fields.FirstLoad,\n\t\t\t\tDownloadDir:    tt.fields.DownloadDir,\n\t\t\t\tMaxRunning:     tt.fields.MaxRunning,\n\t\t\t\tProtocolConfig: tt.fields.ProtocolConfig,\n\t\t\t\tExtra:          tt.fields.Extra,\n\t\t\t\tProxy:          tt.fields.Proxy,\n\t\t\t\tWebhook:        tt.fields.Webhook,\n\t\t\t\tAutoTorrent:    tt.fields.AutoTorrent,\n\t\t\t\tArchive:        tt.fields.Archive,\n\t\t\t}\n\t\t\tif got := cfg.Merge(tt.args.beforeCfg); !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Errorf(\"Merge() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/download/downloader.go",
    "content": "package download\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"math\"\n\tgohttp \"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"runtime/debug\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/GopeedLab/gopeed/internal/controller\"\n\t\"github.com/GopeedLab/gopeed/internal/fetcher\"\n\t\"github.com/GopeedLab/gopeed/internal/logger\"\n\t\"github.com/GopeedLab/gopeed/pkg/base\"\n\t\"github.com/GopeedLab/gopeed/pkg/protocol/http\"\n\t\"github.com/GopeedLab/gopeed/pkg/util\"\n\tgonanoid \"github.com/matoous/go-nanoid/v2\"\n\t\"github.com/rs/zerolog\"\n\t\"github.com/rs/zerolog/pkgerrors\"\n)\n\nconst (\n\t// task info bucket\n\tbucketTask = \"task\"\n\t// task download data bucket\n\tbucketSave = \"save\"\n\t// protocol-level shared client state bucket\n\tbucketProtocolState = \"protocol_state\"\n\t// downloader config bucket\n\tbucketConfig = \"config\"\n\t// downloader extension bucket\n\tbucketExtension = \"extension\"\n\t// downloader extension storage bucket\n\tbucketExtensionStorage = \"extension_storage\"\n)\n\nvar (\n\tErrTaskNotFound        = errors.New(\"task not found\")\n\tErrUnSupportedProtocol = errors.New(\"unsupported protocol\")\n)\n\ntype Listener func(event *Event)\n\n// ExtractStatus represents the current status of archive extraction\ntype ExtractStatus string\n\nconst (\n\t// ExtractStatusNone indicates extraction has not started\n\tExtractStatusNone ExtractStatus = \"\"\n\t// ExtractStatusQueued indicates extraction is waiting in the queue\n\tExtractStatusQueued ExtractStatus = \"queued\"\n\t// ExtractStatusWaitingParts indicates waiting for other multi-part archive parts to complete\n\tExtractStatusWaitingParts ExtractStatus = \"waitingParts\"\n\t// ExtractStatusExtracting indicates extraction is in progress\n\tExtractStatusExtracting ExtractStatus = \"extracting\"\n\t// ExtractStatusDone indicates extraction completed successfully\n\tExtractStatusDone ExtractStatus = \"done\"\n\t// ExtractStatusError indicates extraction failed\n\tExtractStatusError ExtractStatus = \"error\"\n)\n\ntype Progress struct {\n\t// Total download time(ns)\n\tUsed int64 `json:\"used\"`\n\t// Download speed(bytes/s)\n\tSpeed int64 `json:\"speed\"`\n\t// Downloaded size(bytes)\n\tDownloaded int64 `json:\"downloaded\"`\n\t// Uploaded speed(bytes/s)\n\tUploadSpeed int64 `json:\"uploadSpeed\"`\n\t// Uploaded size(bytes)\n\tUploaded int64 `json:\"uploaded\"`\n\t// ExtractStatus indicates the current status of archive extraction\n\tExtractStatus ExtractStatus `json:\"extractStatus\"`\n\t// ExtractProgress is the percentage of extraction completed (0-100)\n\tExtractProgress int `json:\"extractProgress\"`\n\t// MultiPartBaseName is set for multi-part archives to group related parts\n\tMultiPartBaseName string `json:\"multiPartBaseName,omitempty\"`\n\t// MultiPartNumber is the part number for multi-part archives (1-indexed)\n\tMultiPartNumber int `json:\"multiPartNumber,omitempty\"`\n\t// MultiPartIsFirst indicates if this is the first part of a multi-part archive\n\tMultiPartIsFirst bool `json:\"multiPartIsFirst,omitempty\"`\n}\n\ntype Downloader struct {\n\tLogger          *logger.Logger\n\tExtensionLogger *logger.Logger\n\n\tcfg          *DownloaderConfig\n\tfetcherCache map[string]fetcher.Fetcher\n\tstorage      Storage\n\ttasks        []*Task\n\twaitTasks    []*Task\n\twatchedTasks sync.Map\n\tlistener     Listener\n\n\tlock               *sync.Mutex\n\tfetcherMapLock     *sync.RWMutex\n\tcheckDuplicateLock *sync.Mutex\n\tclosed             atomic.Bool\n\n\t// claimedExtractions tracks which multi-part archives have been claimed for extraction\n\t// Key: fullBaseName (e.g., \"/path/archive.7z\"), Value: taskID that claimed it\n\tclaimedExtractions sync.Map\n\n\textensions []*Extension\n}\n\nfunc NewDownloader(cfg *DownloaderConfig) *Downloader {\n\tif cfg == nil {\n\t\tcfg = &DownloaderConfig{}\n\t}\n\tcfg.Init()\n\n\td := &Downloader{\n\t\tcfg:          cfg,\n\t\tfetcherCache: make(map[string]fetcher.Fetcher),\n\t\twaitTasks:    make([]*Task, 0),\n\t\tstorage:      cfg.Storage,\n\n\t\tlock:               &sync.Mutex{},\n\t\tfetcherMapLock:     &sync.RWMutex{},\n\t\tcheckDuplicateLock: &sync.Mutex{},\n\n\t\textensions: make([]*Extension, 0),\n\t}\n\n\tzerolog.ErrorStackMarshaler = pkgerrors.MarshalStack\n\td.Logger = logger.NewLogger(cfg.ProductionMode, filepath.Join(cfg.StorageDir, \"logs\", \"core.log\"))\n\td.ExtensionLogger = logger.NewLogger(cfg.ProductionMode, filepath.Join(cfg.StorageDir, \"logs\", \"extension.log\"))\n\tif cfg.ProductionMode {\n\t\tlogPanic(filepath.Join(cfg.StorageDir, \"logs\"))\n\t}\n\treturn d\n}\n\nfunc (d *Downloader) Setup() error {\n\t// setup storage\n\tif err := d.storage.Setup([]string{bucketTask, bucketSave, bucketProtocolState, bucketConfig, bucketExtension, bucketExtensionStorage}); err != nil {\n\t\treturn err\n\t}\n\t// load config from storage\n\tvar cfg base.DownloaderStoreConfig\n\texist, err := d.storage.Get(bucketConfig, \"config\", &cfg)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif exist {\n\t\td.cfg.DownloaderStoreConfig = &cfg\n\t} else {\n\t\td.cfg.DownloaderStoreConfig = &base.DownloaderStoreConfig{\n\t\t\tFirstLoad: true,\n\t\t}\n\t}\n\t// init default config\n\td.cfg.DownloaderStoreConfig.Init()\n\t// init protocol config, if not exist, use default config\n\tfor _, fm := range d.cfg.FetchManagers {\n\t\tprotocol := fm.Name()\n\t\tif _, ok := d.cfg.DownloaderStoreConfig.ProtocolConfig[protocol]; !ok {\n\t\t\td.cfg.DownloaderStoreConfig.ProtocolConfig[protocol] = fm.DefaultConfig()\n\t\t}\n\t\tif sfm, ok := fm.(fetcher.StatefulFetcherManager); ok {\n\t\t\tsfm.SetStateStore(&protocolStateStore{\n\t\t\t\tstorage:  d.storage,\n\t\t\t\tprotocol: protocol,\n\t\t\t})\n\t\t}\n\t}\n\n\t// load tasks from storage\n\tvar tasks []*Task\n\tif err = d.storage.List(bucketTask, &tasks); err != nil {\n\t\treturn err\n\t}\n\tif tasks == nil {\n\t\ttasks = make([]*Task, 0)\n\t} else {\n\t\tfor i := len(tasks) - 1; i >= 0; i-- {\n\t\t\ttask := tasks[i]\n\t\t\t// Remove broken tasks\n\t\t\tif task.Meta == nil {\n\t\t\t\ttasks = append(tasks[:i], tasks[i+1:]...)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\td.assignFetcherManager(task)\n\t\t\tinitTask(task)\n\t\t\tif task.Status != base.DownloadStatusDone && task.Status != base.DownloadStatusError {\n\t\t\t\ttask.Status = base.DownloadStatusPause\n\t\t\t}\n\t\t}\n\t}\n\td.tasks = tasks\n\t// sort by create time\n\tsort.Slice(d.tasks, func(i, j int) bool {\n\t\treturn d.tasks[i].CreatedAt.Before(d.tasks[j].CreatedAt)\n\t})\n\n\t// load extensions from storage\n\tvar extensions []*Extension\n\tif err = d.storage.List(bucketExtension, &extensions); err != nil {\n\t\treturn err\n\t}\n\tif extensions == nil {\n\t\textensions = make([]*Extension, 0)\n\t}\n\td.extensions = extensions\n\n\t// Auto-cleanup non-existing tasks on startup\n\td.cleanupNonExistingTasks()\n\n\t// handle upload\n\tgo func() {\n\t\tfor _, task := range d.tasks {\n\t\t\tif task.Status == base.DownloadStatusDone && task.Uploading {\n\t\t\t\tif err := d.restoreTask(task); err != nil {\n\t\t\t\t\td.Logger.Error().Stack().Err(err).Msgf(\"task upload restore fetcher failed, task id: %s\", task.ID)\n\t\t\t\t}\n\t\t\t\tif uploader, ok := task.fetcher.(fetcher.Uploader); ok {\n\t\t\t\t\tif err := uploader.Upload(); err != nil {\n\t\t\t\t\t\td.Logger.Error().Stack().Err(err).Msgf(\"task upload failed, task id: %s\", task.ID)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}()\n\n\t// calculate download speed every tick\n\tgo func() {\n\t\tfor !d.closed.Load() {\n\t\t\tif len(d.tasks) > 0 {\n\t\t\t\tfor _, task := range d.tasks {\n\t\t\t\t\tfunc() {\n\t\t\t\t\t\ttask.statusLock.Lock()\n\t\t\t\t\t\tdefer task.statusLock.Unlock()\n\t\t\t\t\t\tif task.Status != base.DownloadStatusRunning && !task.Uploading {\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t\t// check if task is deleted\n\t\t\t\t\t\tif d.GetTask(task.ID) == nil || task.fetcher == nil {\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tcurrent := task.fetcher.Progress().TotalDownloaded()\n\t\t\t\t\t\ttick := float64(d.cfg.RefreshInterval) / 1000\n\t\t\t\t\t\tdownloadDataChanged := false\n\t\t\t\t\t\tif task.Status == base.DownloadStatusRunning {\n\t\t\t\t\t\t\tdownloadDataChanged = current != task.Progress.Downloaded\n\t\t\t\t\t\t\ttask.Progress.Used = task.timer.Used()\n\t\t\t\t\t\t\ttask.Progress.Speed = task.updateSpeed(current-task.Progress.Downloaded, tick)\n\t\t\t\t\t\t\ttask.Progress.Downloaded = current\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tuploadDataChanged := false\n\t\t\t\t\t\tif task.Uploading {\n\t\t\t\t\t\t\tuploader := task.fetcher.(fetcher.Uploader)\n\t\t\t\t\t\t\tcurrentUploaded := uploader.UploadedBytes()\n\t\t\t\t\t\t\tuploadDataChanged = currentUploaded != task.Progress.Uploaded\n\t\t\t\t\t\t\ttask.Progress.UploadSpeed = task.updateUploadSpeed(currentUploaded-task.Progress.Uploaded, tick)\n\t\t\t\t\t\t\ttask.Progress.Uploaded = currentUploaded\n\t\t\t\t\t\t}\n\t\t\t\t\t\td.emit(EventKeyProgress, task)\n\n\t\t\t\t\t\t// store fetcher progress when download/upload data changed\n\t\t\t\t\t\tif !downloadDataChanged && !uploadDataChanged {\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t\td.saveTask(task)\n\t\t\t\t\t}()\n\t\t\t\t}\n\t\t\t}\n\t\t\ttime.Sleep(time.Millisecond * time.Duration(d.cfg.RefreshInterval))\n\t\t}\n\t}()\n\treturn nil\n}\n\n// cleanupNonExistingTasks checks for tasks whose files are missing on disk\n// and removes them if the AutoCleanMissingFiles config is enabled.\nfunc (d *Downloader) cleanupNonExistingTasks() {\n\tcfg, err := d.GetConfig()\n\tif err != nil {\n\t\treturn\n\t}\n\n\t// If the feature is disabled, do nothing\n\tif !cfg.AutoDeleteMissingFileTasks {\n\t\treturn\n\t}\n\n\tvar tasksToDelete []string\n\n\tfor _, task := range d.tasks {\n\t\tif task.Meta == nil || task.Meta.Res == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar targetPath string\n\t\t// Determine if it is a single file or a directory (multi-file torrent)\n\t\tif task.Meta.Res.Name != \"\" {\n\t\t\ttargetPath = task.Meta.FolderPath()\n\t\t} else {\n\t\t\ttargetPath = task.Meta.SingleFilepath()\n\t\t}\n\n\t\t// Skip if path is empty\n\t\tif targetPath == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\t// Check if file/folder exists\n\t\tif _, err := os.Stat(targetPath); os.IsNotExist(err) {\n\t\t\td.Logger.Info().Msgf(\"Auto-cleanup: task %s file not found at %s, removing from list\", task.ID, targetPath)\n\t\t\ttasksToDelete = append(tasksToDelete, task.ID)\n\t\t}\n\t}\n\n\tif len(tasksToDelete) > 0 {\n\t\td.Delete(&TaskFilter{IDs: tasksToDelete}, false)\n\t}\n}\n\nfunc (d *Downloader) parseFm(url string) (fetcher.FetcherManager, error) {\n\tfor _, fm := range d.cfg.FetchManagers {\n\t\tfor _, filter := range fm.Filters() {\n\t\t\tif filter.Match(url) {\n\t\t\t\treturn fm, nil\n\t\t\t}\n\t\t}\n\t}\n\treturn nil, ErrUnSupportedProtocol\n}\n\nfunc (d *Downloader) setupFetcher(fm fetcher.FetcherManager, fetcher fetcher.Fetcher) {\n\tctl := controller.NewController()\n\tctl.GetConfig = func(v any) {\n\t\td.getProtocolConfig(fm.Name(), v)\n\t}\n\t// Get proxy config, task request proxy config has higher priority, then use global proxy config\n\tctl.GetProxy = func(requestProxy *base.RequestProxy) func(*gohttp.Request) (*url.URL, error) {\n\t\tif requestProxy == nil {\n\t\t\treturn d.cfg.Proxy.ToHandler()\n\t\t}\n\t\tswitch requestProxy.Mode {\n\t\tcase base.RequestProxyModeNone:\n\t\t\treturn nil\n\t\tcase base.RequestProxyModeCustom:\n\t\t\treturn requestProxy.ToHandler()\n\t\tdefault:\n\t\t\treturn d.cfg.Proxy.ToHandler()\n\t\t}\n\t}\n\tfetcher.Setup(ctl)\n}\n\nfunc (d *Downloader) saveTask(task *Task) error {\n\tdata, err := task.fetcherManager.Store(task.fetcher)\n\tif err != nil {\n\t\td.Logger.Error().Stack().Err(err).Msgf(\"serialize fetcher failed: %s\", task.ID)\n\t\treturn err\n\t}\n\tif data != nil {\n\t\tif err := d.storage.Put(bucketSave, task.ID, data); err != nil {\n\t\t\td.Logger.Error().Stack().Err(err).Msgf(\"persist fetcher failed: %s\", task.ID)\n\t\t\treturn err\n\t\t}\n\t} else {\n\t\tif err := d.storage.Delete(bucketSave, task.ID); err != nil {\n\t\t\td.Logger.Error().Stack().Err(err).Msgf(\"clear fetcher state failed: %s\", task.ID)\n\t\t\treturn err\n\t\t}\n\t}\n\tif err := d.storage.Put(bucketTask, task.ID, task); err != nil {\n\t\td.Logger.Error().Stack().Err(err).Msgf(\"persist task failed: %s\", task.ID)\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc (d *Downloader) Resolve(req *base.Request, opts *base.Options) (rr *ResolveResult, err error) {\n\trrId, err := gonanoid.New()\n\tif err != nil {\n\t\treturn\n\t}\n\n\tres, err := d.triggerOnResolve(req)\n\tif err != nil {\n\t\treturn\n\t}\n\tif res != nil && len(res.Files) > 0 {\n\t\trr = &ResolveResult{\n\t\t\tRes: res,\n\t\t}\n\t\treturn\n\t}\n\n\tfetcher, err := d.buildFetcher(req.URL)\n\tif err != nil {\n\t\treturn\n\t}\n\tinitOpt, err := d.initOptions(opts)\n\tif err != nil {\n\t\treturn\n\t}\n\terr = fetcher.Resolve(req, initOpt)\n\tif err != nil {\n\t\treturn\n\t}\n\td.fetcherMapLock.Lock()\n\td.fetcherCache[rrId] = fetcher\n\td.fetcherMapLock.Unlock()\n\trr = &ResolveResult{\n\t\tID:  rrId,\n\t\tRes: fetcher.Meta().Res,\n\t}\n\treturn\n}\n\nfunc (d *Downloader) notifyRunning() {\n\tgo func() {\n\t\td.lock.Lock()\n\t\tdefer d.lock.Unlock()\n\n\t\tremainRunningCount := d.remainRunningCount()\n\t\tif remainRunningCount == 0 {\n\t\t\treturn\n\t\t}\n\t\tif len(d.waitTasks) > 0 {\n\t\t\twt := d.waitTasks[0]\n\t\t\td.waitTasks = d.waitTasks[1:]\n\t\t\td.doStart(wt)\n\t\t}\n\t}()\n}\n\nfunc (d *Downloader) remainRunningCount() int {\n\trunningCount := 0\n\tfor _, t := range d.tasks {\n\t\tif t.Status == base.DownloadStatusRunning {\n\t\t\trunningCount++\n\t\t}\n\t}\n\treturn d.cfg.MaxRunning - runningCount\n}\n\nfunc (d *Downloader) CreateDirect(req *base.Request, opts *base.Options) (taskId string, err error) {\n\tvar fetcher fetcher.Fetcher\n\tfetcher, err = d.buildFetcher(req.URL)\n\tif err != nil {\n\t\treturn\n\t}\n\tfetcher.Meta().Req = req\n\tinitOpt, err := d.initOptions(opts)\n\tif err != nil {\n\t\treturn\n\t}\n\treturn d.doCreate(fetcher, initOpt)\n}\n\nfunc (d *Downloader) CreateDirectBatch(req *base.CreateTaskBatch) (taskId []string, err error) {\n\ttaskIds := make([]string, 0)\n\tfor _, ir := range req.Reqs {\n\t\topts := ir.Opts\n\t\tif opts == nil {\n\t\t\topts = req.Opts\n\t\t}\n\t\ttaskId, err := d.CreateDirect(ir.Req, opts.Clone())\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\ttaskIds = append(taskIds, taskId)\n\t}\n\treturn taskIds, nil\n}\n\nfunc (d *Downloader) Create(rrId string) (taskId string, err error) {\n\td.fetcherMapLock.RLock()\n\tfetcher, ok := d.fetcherCache[rrId]\n\td.fetcherMapLock.RUnlock()\n\tif !ok {\n\t\treturn \"\", errors.New(\"invalid resource id\")\n\t}\n\tdefer func() {\n\t\td.fetcherMapLock.Lock()\n\t\tdelete(d.fetcherCache, rrId)\n\t\td.fetcherMapLock.Unlock()\n\t}()\n\treturn d.doCreate(fetcher, nil)\n}\n\n// Patch modifies task-specific data based on the protocol.\n// For HTTP protocol, it can modify Request info.\n// For BT protocol, it can modify SelectFiles.\nfunc (d *Downloader) Patch(id string, req *base.Request, opts *base.Options) error {\n\ttask := d.GetTask(id)\n\tif task == nil {\n\t\treturn ErrTaskNotFound\n\t}\n\n\t// Restore fetcher if not loaded\n\tif task.fetcher == nil {\n\t\terr := func() error {\n\t\t\ttask.statusLock.Lock()\n\t\t\tdefer task.statusLock.Unlock()\n\n\t\t\treturn d.restoreFetcher(task)\n\t\t}()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// Call the fetcher's Patch method\n\tif err := task.fetcher.Patch(req, opts); err != nil {\n\t\treturn err\n\t}\n\n\t// Update task meta from fetcher\n\ttask.Meta = task.fetcher.Meta()\n\n\t// Save task to storage\n\tif err := d.saveTask(task); err != nil {\n\t\treturn err\n\t}\n\n\t// Emit progress event to notify listeners\n\td.emit(EventKeyProgress, task)\n\n\treturn nil\n}\n\nfunc (d *Downloader) Pause(filter *TaskFilter) (err error) {\n\tif filter == nil || filter.IsEmpty() {\n\t\treturn d.pauseAll()\n\t}\n\n\tfilter.NotStatuses = []base.Status{base.DownloadStatusPause, base.DownloadStatusError, base.DownloadStatusDone}\n\tpauseTasks := d.GetTasksByFilter(filter)\n\tif len(pauseTasks) == 0 {\n\t\treturn ErrTaskNotFound\n\t}\n\n\tfor _, task := range pauseTasks {\n\t\tif err = d.doPause(task); err != nil {\n\t\t\treturn\n\t\t}\n\t}\n\td.notifyRunning()\n\n\treturn\n}\n\nfunc (d *Downloader) pauseAll() (err error) {\n\tfunc() {\n\t\td.lock.Lock()\n\t\tdefer d.lock.Unlock()\n\n\t\t// Clear wait tasks\n\t\td.waitTasks = d.waitTasks[:0]\n\t}()\n\n\tfor _, task := range d.tasks {\n\t\tif err = d.doPause(task); err != nil {\n\t\t\treturn\n\t\t}\n\t}\n\n\treturn\n}\n\n// Continue specific tasks, if continue tasks will exceed maxRunning, it needs pause some running tasks before that\nfunc (d *Downloader) Continue(filter *TaskFilter) (err error) {\n\tif filter == nil || filter.IsEmpty() {\n\t\treturn d.continueAll()\n\t}\n\n\tfilter.NotStatuses = []base.Status{base.DownloadStatusRunning, base.DownloadStatusDone}\n\tcontinueTasks := d.GetTasksByFilter(filter)\n\tif len(continueTasks) == 0 {\n\t\treturn ErrTaskNotFound\n\t}\n\n\trealContinueTasks := make([]*Task, 0)\n\tfunc() {\n\t\td.lock.Lock()\n\t\tdefer d.lock.Unlock()\n\n\t\tcontinueCount := len(continueTasks)\n\t\tremainRunningCount := d.remainRunningCount()\n\t\tneedRunningCount := int(math.Min(float64(d.cfg.MaxRunning), float64(continueCount)))\n\t\tneedPauseCount := needRunningCount - remainRunningCount\n\t\tif needPauseCount > 0 {\n\t\t\tpausedCount := 0\n\t\t\tfor _, task := range d.tasks {\n\t\t\t\tif task.Status == base.DownloadStatusRunning {\n\t\t\t\t\tif err = d.doPause(task); err != nil {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\ttask.Status = base.DownloadStatusWait\n\t\t\t\t\td.waitTasks = append(d.waitTasks, task)\n\t\t\t\t\tpausedCount++\n\t\t\t\t}\n\t\t\t\tif pausedCount == needPauseCount {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tfor _, task := range continueTasks {\n\t\t\tif len(realContinueTasks) < needRunningCount {\n\t\t\t\trealContinueTasks = append(realContinueTasks, task)\n\t\t\t} else {\n\t\t\t\ttask.Status = base.DownloadStatusWait\n\t\t\t\td.waitTasks = append(d.waitTasks, task)\n\t\t\t}\n\t\t}\n\t}()\n\n\tfor _, task := range realContinueTasks {\n\t\tif err = d.doStart(task); err != nil {\n\t\t\treturn\n\t\t}\n\t}\n\n\treturn\n}\n\n// continueAll continue all tasks but does not affect tasks already running\nfunc (d *Downloader) continueAll() (err error) {\n\tcontinuedTasks := make([]*Task, 0)\n\n\tfunc() {\n\t\td.lock.Lock()\n\t\tdefer d.lock.Unlock()\n\t\t// calculate how many tasks can be continued, can't exceed maxRunning\n\t\tremainCount := d.remainRunningCount()\n\t\tfor _, task := range d.tasks {\n\t\t\tif task.Status != base.DownloadStatusRunning && task.Status != base.DownloadStatusDone {\n\t\t\t\tif len(continuedTasks) < remainCount {\n\t\t\t\t\tcontinuedTasks = append(continuedTasks, task)\n\t\t\t\t} else {\n\t\t\t\t\ttask.Status = base.DownloadStatusWait\n\t\t\t\t\td.waitTasks = append(d.waitTasks, task)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}()\n\n\tfor _, task := range continuedTasks {\n\t\tif err = d.doStart(task); err != nil {\n\t\t\treturn\n\t\t}\n\t}\n\n\treturn\n}\n\nfunc (d *Downloader) ContinueBatch(filter *TaskFilter) (err error) {\n\tif filter == nil || filter.IsEmpty() {\n\t\treturn d.continueAll()\n\t}\n\n\tcontinueTasks := d.GetTasksByFilter(filter)\n\tfor _, task := range continueTasks {\n\t\tif err = d.doStart(task); err != nil {\n\t\t\treturn\n\t\t}\n\t}\n\treturn\n}\n\nfunc (d *Downloader) Delete(filter *TaskFilter, force bool) (err error) {\n\tif filter == nil || filter.IsEmpty() {\n\t\treturn d.deleteAll(force)\n\t}\n\n\tdeleteTasks := d.GetTasksByFilter(filter)\n\tif len(deleteTasks) == 0 {\n\t\treturn\n\t}\n\n\tdeleteIds := make([]string, 0)\n\tdeleteTasksPtr := make([]*Task, 0)\n\tfor _, task := range deleteTasks {\n\t\tdeleteIds = append(deleteIds, task.ID)\n\t\tdeleteTasksPtr = append(deleteTasksPtr, task)\n\t}\n\tfunc() {\n\t\td.lock.Lock()\n\t\tdefer d.lock.Unlock()\n\n\t\tfor _, id := range deleteIds {\n\t\t\tfor i, t := range d.tasks {\n\t\t\t\tif t.ID == id {\n\t\t\t\t\td.tasks = append(d.tasks[:i], d.tasks[i+1:]...)\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tfor i, t := range d.waitTasks {\n\t\t\t\tif t.ID == id {\n\t\t\t\t\td.waitTasks = append(d.waitTasks[:i], d.waitTasks[i+1:]...)\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}()\n\n\tfor _, task := range deleteTasksPtr {\n\t\terr = d.doDelete(task, force)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t}\n\n\td.notifyRunning()\n\treturn\n}\n\nfunc (d *Downloader) deleteAll(force bool) (err error) {\n\tvar deleteTasksTemp []*Task\n\tfunc() {\n\t\td.lock.Lock()\n\t\tdefer d.lock.Unlock()\n\n\t\tfor _, task := range d.tasks {\n\t\t\tdeleteTasksTemp = append(deleteTasksTemp, task)\n\t\t}\n\t\td.tasks = make([]*Task, 0)\n\t\td.waitTasks = make([]*Task, 0)\n\t}()\n\n\tfor _, task := range deleteTasksTemp {\n\t\tif err = d.doDelete(task, force); err != nil {\n\t\t\treturn\n\t\t}\n\t}\n\treturn\n}\n\nfunc (d *Downloader) Stats(id string) (sr any, err error) {\n\ttask := d.GetTask(id)\n\tif task == nil {\n\t\treturn sr, ErrTaskNotFound\n\t}\n\tif task.fetcher == nil {\n\t\terr = func() error {\n\t\t\ttask.statusLock.Lock()\n\t\t\tdefer task.statusLock.Unlock()\n\n\t\t\treturn d.restoreFetcher(task)\n\t\t}()\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t}\n\tsr = task.fetcher.Stats()\n\treturn\n}\n\nfunc (d *Downloader) doDelete(task *Task, force bool) (err error) {\n\terr = func() error {\n\t\tif err := d.storage.Delete(bucketTask, task.ID); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := d.storage.Delete(bucketSave, task.ID); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tif task.fetcher != nil {\n\t\t\tif err := task.fetcher.Close(); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif force && task.Meta.Res != nil {\n\t\t\tif task.Meta.Res.Name != \"\" {\n\t\t\t\tif err := os.RemoveAll(task.Meta.FolderPath()); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif err := util.SafeRemove(task.Meta.SingleFilepath()); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\td.emit(EventKeyDelete, task)\n\t\ttask = nil\n\t\treturn nil\n\t}()\n\n\tif err != nil {\n\t\td.Logger.Error().Stack().Err(err).Msgf(\"delete task failed, task id: %s\", task.ID)\n\t}\n\treturn\n}\n\nfunc (d *Downloader) Close() error {\n\td.closed.Store(true)\n\n\tcloseArr := []func() error{\n\t\td.pauseAll,\n\t}\n\tfor _, fm := range d.cfg.FetchManagers {\n\t\tcloseArr = append(closeArr, fm.Close)\n\t}\n\tcloseArr = append(closeArr, d.storage.Close)\n\t// Make sure all resources are released, if had error, return the last error\n\tvar lastErr error\n\tfor i, close := range closeArr {\n\t\tif err := close(); err != nil {\n\t\t\tlastErr = err\n\t\t\td.Logger.Error().Stack().Err(err).Msgf(\"downloader close failed, index: %d\", i)\n\t\t}\n\t}\n\treturn lastErr\n}\n\nfunc (d *Downloader) Clear() error {\n\tif !d.closed.Load() {\n\t\tif err := d.Close(); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\td.tasks = make([]*Task, 0)\n\td.extensions = make([]*Extension, 0)\n\tif err := d.storage.Clear(); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\ntype protocolStateStore struct {\n\tstorage  Storage\n\tprotocol string\n}\n\nfunc (s *protocolStateStore) Load(v any) (bool, error) {\n\treturn s.storage.Get(bucketProtocolState, s.protocol, v)\n}\n\nfunc (s *protocolStateStore) Save(v any) error {\n\tif v == nil {\n\t\treturn s.Delete()\n\t}\n\treturn s.storage.Put(bucketProtocolState, s.protocol, v)\n}\n\nfunc (s *protocolStateStore) Delete() error {\n\treturn s.storage.Delete(bucketProtocolState, s.protocol)\n}\n\nfunc (d *Downloader) Listener(fn Listener) {\n\td.listener = fn\n}\n\nfunc (d *Downloader) emit(eventKey EventKey, task *Task, errs ...error) {\n\tif d.listener != nil {\n\t\tvar err error\n\t\tif len(errs) > 0 {\n\t\t\terr = errs[0]\n\t\t}\n\t\td.listener(&Event{\n\t\t\tKey:  eventKey,\n\t\t\tTask: task,\n\t\t\tErr:  err,\n\t\t})\n\t}\n}\n\nfunc (d *Downloader) GetTask(id string) *Task {\n\td.lock.Lock()\n\tdefer d.lock.Unlock()\n\n\tfor _, task := range d.tasks {\n\t\tif task.ID == id {\n\t\t\treturn task\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (d *Downloader) GetTasks() []*Task {\n\td.lock.Lock()\n\tdefer d.lock.Unlock()\n\n\treturn d.tasks\n}\n\n// GetTasksByFilter get tasks by filter, if filter is nil, return all tasks\n// return tasks and if match all tasks\nfunc (d *Downloader) GetTasksByFilter(filter *TaskFilter) []*Task {\n\td.lock.Lock()\n\tdefer d.lock.Unlock()\n\n\tif filter == nil || filter.IsEmpty() {\n\t\treturn d.tasks\n\t}\n\n\tidMatch := func(task *Task) bool {\n\t\tif len(filter.IDs) == 0 {\n\t\t\treturn true\n\t\t}\n\t\tfor _, id := range filter.IDs {\n\t\t\tif task.ID == id {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t\treturn false\n\t}\n\tstatusMatch := func(task *Task) bool {\n\t\tif len(filter.Statuses) == 0 {\n\t\t\treturn true\n\t\t}\n\t\tfor _, status := range filter.Statuses {\n\t\t\tif task.Status == status {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t\treturn false\n\t}\n\tnotStatusMatch := func(task *Task) bool {\n\t\tif len(filter.NotStatuses) == 0 {\n\t\t\treturn true\n\t\t}\n\t\tfor _, status := range filter.NotStatuses {\n\t\t\tif task.Status == status {\n\t\t\t\treturn false\n\t\t\t}\n\t\t}\n\t\treturn true\n\t}\n\n\ttasks := make([]*Task, 0)\n\tfor _, task := range d.tasks {\n\t\tif idMatch(task) && statusMatch(task) && notStatusMatch(task) {\n\t\t\ttasks = append(tasks, task)\n\t\t}\n\t}\n\treturn tasks\n}\n\nfunc (d *Downloader) GetConfig() (*base.DownloaderStoreConfig, error) {\n\treturn d.cfg.DownloaderStoreConfig, nil\n}\n\nfunc (d *Downloader) PutConfig(v *base.DownloaderStoreConfig) error {\n\td.cfg.DownloaderStoreConfig = v\n\treturn d.storage.Put(bucketConfig, \"config\", v)\n}\n\nfunc (d *Downloader) getProtocolConfig(name string, v any) bool {\n\tcfg, err := d.GetConfig()\n\tif err != nil {\n\t\treturn false\n\t}\n\tif cfg.ProtocolConfig == nil || cfg.ProtocolConfig[name] == nil {\n\t\treturn false\n\t}\n\tif err := util.MapToStruct(cfg.ProtocolConfig[name], v); err != nil {\n\t\td.Logger.Warn().Err(err).Msgf(\"get protocol config failed\")\n\t\treturn false\n\t}\n\treturn true\n}\n\n// wait task done\nfunc (d *Downloader) watch(task *Task) {\n\tif _, loaded := d.watchedTasks.LoadOrStore(task.ID, true); loaded {\n\t\treturn\n\t}\n\n\tdefer func() {\n\t\td.watchedTasks.Delete(task.ID)\n\t}()\n\n\t// wait task upload done\n\tif task.Uploading {\n\t\tif uploader, ok := task.fetcher.(fetcher.Uploader); ok {\n\t\t\tgo func() {\n\t\t\t\terr := uploader.WaitUpload()\n\t\t\t\tif err != nil {\n\t\t\t\t\td.Logger.Warn().Err(err).Msgf(\"task wait upload failed, task id: %s\", task.ID)\n\t\t\t\t}\n\n\t\t\t\t// Check if the task is deleted\n\t\t\t\tif d.GetTask(task.ID) != nil {\n\t\t\t\t\ttask.Uploading = false\n\t\t\t\t\td.storage.Put(bucketTask, task.ID, task.clone())\n\t\t\t\t}\n\t\t\t}()\n\t\t}\n\t}\n\n\tif task.Status == base.DownloadStatusDone {\n\t\treturn\n\t}\n\n\terr := task.fetcher.Wait()\n\tif err != nil {\n\t\td.doOnError(task, err)\n\t\treturn\n\t}\n\n\t// When delete a not resolved task, need check if the task resource is nil\n\tif task.Meta.Res == nil || d.GetTask(task.ID) == nil {\n\t\treturn\n\t}\n\n\ttask.Progress.Used = task.timer.Used()\n\tif task.Meta.Res.Size == 0 {\n\t\ttask.Meta.Res.Size = task.fetcher.Progress().TotalDownloaded()\n\t}\n\tused := task.Progress.Used / int64(time.Second)\n\tif used == 0 {\n\t\tused = 1\n\t}\n\ttotalSize := task.Meta.Res.Size\n\ttask.Progress.Speed = totalSize / used\n\ttask.Progress.Downloaded = totalSize\n\ttask.updateStatus(base.DownloadStatusDone)\n\td.storage.Put(bucketTask, task.ID, task.clone())\n\td.emit(EventKeyDone, task)\n\td.emit(EventKeyFinally, task, err)\n\td.notifyRunning()\n\td.triggerOnDone(task)\n\td.triggerWebhooks(WebhookEventDownloadDone, task, nil)\n\td.triggerScripts(ScriptEventDownloadDone, task, nil)\n\n\tif e, ok := task.Meta.Opts.Extra.(*http.OptsExtra); ok {\n\t\tdownloadFilePath := task.Meta.SingleFilepath()\n\n\t\tcfg, _ := d.GetConfig()\n\n\t\t// Determine if auto-torrent is enabled (use global config if not explicitly set)\n\t\tautoTorrentEnabled := false\n\t\tif e.AutoTorrent != nil {\n\t\t\tautoTorrentEnabled = *e.AutoTorrent\n\t\t} else if cfg != nil && cfg.AutoTorrent != nil {\n\t\t\tautoTorrentEnabled = cfg.AutoTorrent.Enable\n\t\t}\n\n\t\tif autoTorrentEnabled && strings.HasSuffix(downloadFilePath, \".torrent\") {\n\t\t\t// Determine if should delete torrent file after creating BT task\n\t\t\tshouldDelete := false\n\t\t\tif e.DeleteTorrentAfterDownload != nil {\n\t\t\t\tshouldDelete = *e.DeleteTorrentAfterDownload\n\t\t\t} else if cfg != nil && cfg.AutoTorrent != nil {\n\t\t\t\tshouldDelete = cfg.AutoTorrent.DeleteAfterDownload\n\t\t\t}\n\n\t\t\tgo func() {\n\t\t\t\t_, err2 := d.CreateDirect(\n\t\t\t\t\t&base.Request{\n\t\t\t\t\t\tURL: downloadFilePath,\n\t\t\t\t\t},\n\t\t\t\t\t&base.Options{\n\t\t\t\t\t\tPath:        task.Meta.Opts.Path,\n\t\t\t\t\t\tSelectFiles: make([]int, 0),\n\t\t\t\t\t})\n\t\t\t\tif err2 != nil {\n\t\t\t\t\td.Logger.Error().Err(err2).Msgf(\"auto create torrent task failed, task id: %s\", task.ID)\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tif shouldDelete {\n\t\t\t\t\td.Delete(&TaskFilter{IDs: []string{task.ID}}, true)\n\t\t\t\t}\n\t\t\t}()\n\t\t}\n\n\t\t// Determine if auto-extract is enabled (use global config if not explicitly set)\n\t\tautoExtractEnabled := false\n\t\tif e.AutoExtract != nil {\n\t\t\tautoExtractEnabled = *e.AutoExtract\n\t\t} else if cfg != nil && cfg.Archive != nil {\n\t\t\tautoExtractEnabled = cfg.Archive.AutoExtract\n\t\t}\n\n\t\t// Auto-extract archive files using the extraction queue\n\t\t// This ensures only one extraction runs at a time to prevent resource exhaustion\n\t\tif autoExtractEnabled && isArchiveFile(downloadFilePath) {\n\t\t\td.enqueueExtraction(task, downloadFilePath, e)\n\t\t}\n\t}\n}\n\nfunc (d *Downloader) doOnError(task *Task, err error) {\n\td.Logger.Warn().Err(err).Msgf(\"task download failed, task id: %s\", task.ID)\n\ttask.updateStatus(base.DownloadStatusError)\n\td.triggerOnError(task, err)\n\tif task.Status == base.DownloadStatusError {\n\t\td.emit(EventKeyError, task, err)\n\t\td.emit(EventKeyFinally, task, err)\n\t\td.notifyRunning()\n\t\td.triggerWebhooks(WebhookEventDownloadError, task, err)\n\t}\n}\n\nfunc (d *Downloader) restoreTask(task *Task) error {\n\tif task.fetcher == nil {\n\t\tif err := d.restoreFetcher(task); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tgo d.watch(task)\n\treturn nil\n}\n\nfunc (d *Downloader) restoreFetcher(task *Task) error {\n\tv, f := task.fetcherManager.Restore()\n\tif v != nil {\n\t\terr := d.storage.Pop(bucketSave, task.ID, v)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\ttask.fetcher = f(task.Meta, v)\n\tif task.fetcher == nil {\n\t\ttask.fetcher = task.fetcherManager.Build()\n\t}\n\td.setupFetcher(task.fetcherManager, task.fetcher)\n\tif task.fetcher.Meta().Req == nil {\n\t\ttask.fetcher.Meta().Req = task.Meta.Req\n\t}\n\tif task.fetcher.Meta().Res == nil {\n\t\ttask.fetcher.Meta().Res = task.Meta.Res\n\t}\n\tif task.fetcher.Meta().Opts == nil {\n\t\ttask.fetcher.Meta().Opts = task.Meta.Opts\n\t}\n\treturn nil\n}\n\nfunc (d *Downloader) doCreate(f fetcher.Fetcher, opts *base.Options) (taskId string, err error) {\n\tif f.Meta().Opts == nil {\n\t\tf.Meta().Opts = opts\n\t}\n\n\tfm, err := d.parseFm(f.Meta().Req.URL)\n\tif err != nil {\n\t\treturn\n\t}\n\n\ttask := NewTask()\n\ttask.fetcherManager = fm\n\ttask.fetcher = f\n\ttask.Protocol = fm.Name()\n\ttask.Meta = f.Meta()\n\ttask.Progress = &Progress{}\n\t_, task.Uploading = f.(fetcher.Uploader)\n\tinitTask(task)\n\tif err = d.storage.Put(bucketTask, task.ID, task.clone()); err != nil {\n\t\treturn\n\t}\n\ttaskId = task.ID\n\n\tfunc() {\n\t\td.lock.Lock()\n\t\tdefer d.lock.Unlock()\n\n\t\td.tasks = append(d.tasks, task)\n\n\t\tremainRunningCount := d.remainRunningCount()\n\t\tif remainRunningCount == 0 {\n\t\t\ttask.Status = base.DownloadStatusWait\n\t\t\td.waitTasks = append(d.waitTasks, task)\n\t\t\treturn\n\t\t}\n\n\t\terr = d.doStart(task)\n\t}()\n\n\tgo d.watch(task)\n\treturn\n}\n\nfunc (d *Downloader) initOptions(opts *base.Options) (*base.Options, error) {\n\tif opts == nil {\n\t\topts = &base.Options{}\n\t}\n\tif opts.SelectFiles == nil {\n\t\topts.SelectFiles = make([]int, 0)\n\t}\n\tif opts.Path == \"\" {\n\t\tstoreConfig, err := d.GetConfig()\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\topts.Path = storeConfig.DownloadDir\n\t}\n\t// Replace placeholders in download path (e.g., %year%, %month%, %day%, %date%)\n\topts.Path = util.ReplacePathPlaceholders(opts.Path)\n\n\t// if enable white download directory, check if the download directory is in the white list\n\tif len(d.cfg.WhiteDownloadDirs) > 0 {\n\t\tinWhiteList := false\n\t\tfor _, dir := range d.cfg.WhiteDownloadDirs {\n\t\t\tif match, err := filepath.Match(dir, opts.Path); match && err == nil {\n\t\t\t\tinWhiteList = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif !inWhiteList {\n\t\t\treturn nil, errors.New(\"download directory is not in white list\")\n\t\t}\n\t}\n\treturn opts, nil\n}\n\nfunc (d *Downloader) statusMut(task *Task, fn func() (bool, error)) (bool, error) {\n\ttask.statusLock.Lock()\n\tdefer task.statusLock.Unlock()\n\n\treturn fn()\n}\n\nfunc (d *Downloader) doStart(task *Task) (err error) {\n\tvar needCreate bool\n\tisReturn, err := d.statusMut(task, func() (isReturn bool, err error) {\n\t\tif task.Status == base.DownloadStatusRunning || task.Status == base.DownloadStatusDone {\n\t\t\tisReturn = true\n\t\t\treturn\n\t\t}\n\n\t\terr = d.restoreTask(task)\n\t\tif err != nil {\n\t\t\td.Logger.Error().Stack().Err(err).Msgf(\"restore fetcher failed, task id: %s\", task.ID)\n\t\t\treturn\n\t\t}\n\t\tneedCreate = !task.IsCreated\n\t\ttask.updateStatus(base.DownloadStatusRunning)\n\n\t\treturn\n\t})\n\tif err != nil {\n\t\td.Logger.Error().Stack().Err(err).Msgf(\"start task failed, task id: %s\", task.ID)\n\t\treturn\n\t}\n\tif isReturn {\n\t\treturn\n\t}\n\n\thandler := func() error {\n\t\ttask.lock.Lock()\n\t\tdefer task.lock.Unlock()\n\n\t\td.triggerOnStart(task)\n\t\tif task.Meta.Res == nil {\n\t\t\terr := task.fetcher.Resolve(task.Meta.Req, task.Meta.Opts)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\ttask.Meta.Res = task.fetcher.Meta().Res\n\t\t}\n\n\t\tif needCreate {\n\t\t\tif task.fetcherManager.AutoRename() {\n\t\t\t\td.checkDuplicateLock.Lock()\n\t\t\t\tdefer d.checkDuplicateLock.Unlock()\n\t\t\t\ttask.Meta.Opts.Name = util.SafeFilename(task.Meta.Opts.Name)\n\t\t\t\t// check if the download file is duplicated and rename it automatically.\n\t\t\t\tif task.Meta.Res.Name != \"\" {\n\t\t\t\t\ttask.Meta.Res.Name = util.SafeFilename(task.Meta.Res.Name)\n\t\t\t\t\tfullDirPath := task.Meta.FolderPath()\n\t\t\t\t\tnewName, err := util.CheckDuplicateAndRename(fullDirPath)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\ttask.Meta.Opts.Name = newName\n\t\t\t\t} else {\n\t\t\t\t\ttask.Meta.Res.Files[0].Name = util.SafeFilename(task.Meta.Res.Files[0].Name)\n\t\t\t\t\tfullFilePath := task.Meta.SingleFilepath()\n\t\t\t\t\tnewName, err := util.CheckDuplicateAndRename(fullFilePath)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn err\n\t\t\t\t\t}\n\t\t\t\t\ttask.Meta.Opts.Name = newName\n\t\t\t\t}\n\t\t\t}\n\t\t\ttask.IsCreated = true\n\t\t\ttask.Meta.Res.CalcSize(task.Meta.Opts.SelectFiles)\n\t\t}\n\t\ttask.Progress.Speed = 0\n\t\ttask.timer.Start()\n\t\tif err := task.fetcher.Start(); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := d.saveTask(task); err != nil {\n\t\t\treturn err\n\t\t}\n\t\td.emit(EventKeyStart, task)\n\t\treturn nil\n\t}\n\tgo func() {\n\t\tif err := handler(); err != nil {\n\t\t\td.doOnError(task, err)\n\t\t}\n\t}()\n\n\treturn\n}\n\nfunc (d *Downloader) doPause(task *Task) (err error) {\n\tisReturn, err := d.statusMut(task, func() (isReturn bool, err error) {\n\t\tif task.Status == base.DownloadStatusPause || task.Status == base.DownloadStatusDone {\n\t\t\tisReturn = true\n\t\t\treturn\n\t\t}\n\n\t\ttask.updateStatus(base.DownloadStatusPause)\n\t\ttask.timer.Pause()\n\t\treturn\n\t})\n\tif err != nil {\n\t\td.Logger.Error().Stack().Err(err).Msgf(\"pause task failed, task id: %s\", task.ID)\n\t\treturn\n\t}\n\tif isReturn {\n\t\treturn\n\t}\n\n\thandler := func() error {\n\t\ttask.lock.Lock()\n\t\tdefer task.lock.Unlock()\n\n\t\tif task.fetcher != nil {\n\t\t\tif err := task.fetcher.Pause(); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif task.fetcherManager != nil && task.fetcher != nil {\n\t\t\tif err := d.saveTask(task); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t} else {\n\t\t\tif err := d.storage.Put(bucketTask, task.ID, task.clone()); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\td.emit(EventKeyPause, task)\n\t\treturn nil\n\t}\n\tgo func() {\n\t\tif err := handler(); err != nil {\n\t\t\td.Logger.Error().Stack().Err(err).Msgf(\"pause task handle failed, task id: %s\", task.ID)\n\t\t}\n\t}()\n\treturn\n}\n\n// redirect stderr to log file, when panic happened log it\nfunc logPanic(logDir string) {\n\tif err := util.CreateDirIfNotExist(logDir); err != nil {\n\t\treturn\n\t}\n\tf, err := os.Create(filepath.Join(logDir, \"crash.log\"))\n\tif err != nil {\n\t\treturn\n\t}\n\tdebug.SetCrashOutput(f, debug.CrashOptions{})\n}\n\nfunc (d *Downloader) assignFetcherManager(task *Task) error {\n\tfm, err := d.parseFm(task.Meta.Req.URL)\n\tif err != nil {\n\t\treturn err\n\t}\n\ttask.fetcherManager = fm\n\treturn nil\n}\n\nfunc (d *Downloader) buildFetcher(url string) (fetcher.Fetcher, error) {\n\tfm, err := d.parseFm(url)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tfetcher := fm.Build()\n\td.setupFetcher(fm, fetcher)\n\treturn fetcher, nil\n}\n\n// enqueueExtraction adds an extraction job to the global extraction queue\n// This ensures only one extraction (or one multi-part archive extraction) runs at a time\n// to prevent resource exhaustion\nfunc (d *Downloader) enqueueExtraction(task *Task, downloadFilePath string, opts *http.OptsExtra) {\n\tpartInfo := getArchivePartInfo(downloadFilePath)\n\n\tif partInfo.IsMultiPart {\n\t\t// For multi-part archives, handle specially\n\t\td.enqueueMultiPartExtraction(task, downloadFilePath, partInfo, opts)\n\t} else {\n\t\t// For single archives, queue immediately\n\t\td.enqueueSingleExtraction(task, downloadFilePath, opts)\n\t}\n}\n\n// enqueueSingleExtraction queues extraction for a single (non-multi-part) archive\nfunc (d *Downloader) enqueueSingleExtraction(task *Task, downloadFilePath string, opts *http.OptsExtra) {\n\tjobID := \"single:\" + task.ID\n\n\t// Set extraction status to queued\n\ttask.Progress.ExtractStatus = ExtractStatusQueued\n\td.emit(EventKeyProgress, task)\n\td.storage.Put(bucketTask, task.ID, task.clone())\n\td.Logger.Info().Msgf(\"extraction queued, task id: %s, job id: %s\", task.ID, jobID)\n\n\t// Create and enqueue the extraction job\n\tjob := NewExtractionJob(jobID, func() {\n\t\td.performExtraction(task, downloadFilePath, task.Meta.Opts.Path, opts)\n\t})\n\n\tgo func() {\n\t\tGetExtractionQueue().Enqueue(job)\n\t}()\n}\n\n// enqueueMultiPartExtraction handles queueing for multi-part archives\n// It ensures only ONE extraction job is queued when ALL parts are ready\nfunc (d *Downloader) enqueueMultiPartExtraction(task *Task, downloadFilePath string, partInfo ArchivePartInfo, opts *http.OptsExtra) {\n\t// Set multi-part info on the task\n\ttask.Progress.MultiPartBaseName = partInfo.BaseName\n\ttask.Progress.MultiPartNumber = partInfo.PartNumber\n\ttask.Progress.MultiPartIsFirst = isFirstPart(downloadFilePath)\n\n\t// Check if all parts are downloaded\n\tdestDir := task.Meta.Opts.Path\n\tallPartsReady, missingParts := d.checkMultiPartArchiveReady(downloadFilePath, destDir, partInfo)\n\n\tif !allPartsReady {\n\t\t// Not all parts are ready yet - just set status to waiting, don't queue anything\n\t\ttask.Progress.ExtractStatus = ExtractStatusWaitingParts\n\t\td.emit(EventKeyProgress, task)\n\t\td.storage.Put(bucketTask, task.ID, task.clone())\n\t\td.Logger.Info().Msgf(\"multi-part archive waiting for other parts, task id: %s, missing: %v\", task.ID, missingParts)\n\t\treturn\n\t}\n\n\t// All parts are ready! Atomically check if extraction has already been started/queued\n\t// and if not, mark this task as the one that will handle it\n\t// Use GetMultiPartArchiveBaseName to get the full path for comparison\n\tfullBaseName := GetMultiPartArchiveBaseName(downloadFilePath)\n\tshouldQueue := d.tryClaimMultiPartExtraction(task, fullBaseName)\n\n\tif !shouldQueue {\n\t\t// Another part already started/queued extraction, mark this task as done\n\t\ttask.Progress.ExtractStatus = ExtractStatusDone\n\t\ttask.Progress.ExtractProgress = 100\n\t\td.emit(EventKeyProgress, task)\n\t\td.storage.Put(bucketTask, task.ID, task.clone())\n\t\td.Logger.Info().Msgf(\"multi-part archive extraction already handled by another part, task id: %s\", task.ID)\n\t\treturn\n\t}\n\n\t// This task claimed the extraction - status already set to queued in tryClaimMultiPartExtraction\n\td.emit(EventKeyProgress, task)\n\td.storage.Put(bucketTask, task.ID, task.clone())\n\n\tjobID := \"multipart:\" + fullBaseName\n\td.Logger.Info().Msgf(\"multi-part extraction queued, task id: %s, job id: %s\", task.ID, jobID)\n\n\t// Create and enqueue the extraction job\n\tjob := NewExtractionJob(jobID, func() {\n\t\td.performMultiPartExtraction(task, partInfo.FirstPartPath, destDir, opts)\n\t})\n\n\tgo func() {\n\t\tGetExtractionQueue().Enqueue(job)\n\t}()\n}\n\n// checkMultiPartArchiveReady checks if all parts of a multi-part archive are downloaded\n// by examining task status rather than file existence\nfunc (d *Downloader) checkMultiPartArchiveReady(filePath string, destDir string, partInfo ArchivePartInfo) (bool, []string) {\n\t// Use task-based checking - find all tasks with the same MultiPartBaseName\n\t// and verify they are all in Done status\n\tbaseName := GetMultiPartArchiveBaseName(filePath)\n\tif baseName == \"\" {\n\t\treturn true, nil\n\t}\n\n\treturn d.checkAllMultiPartTasksDone(baseName)\n}\n\n// checkAllMultiPartTasksDone checks if all tasks belonging to a multi-part archive are done\nfunc (d *Downloader) checkAllMultiPartTasksDone(baseName string) (bool, []string) {\n\tvar notDoneParts []string\n\n\td.lock.Lock()\n\tdefer d.lock.Unlock()\n\n\t// Find all tasks that belong to this multi-part archive\n\tvar relatedTasks []*Task\n\tfor _, task := range d.tasks {\n\t\ttaskBaseName := \"\"\n\t\tif task.Meta != nil && task.Meta.Res != nil && len(task.Meta.Res.Files) > 0 {\n\t\t\ttaskBaseName = GetMultiPartArchiveBaseName(task.Meta.SingleFilepath())\n\t\t}\n\t\tif taskBaseName == baseName {\n\t\t\trelatedTasks = append(relatedTasks, task)\n\t\t}\n\t}\n\n\t// If we found no related tasks, we can't determine readiness\n\tif len(relatedTasks) == 0 {\n\t\treturn false, []string{\"no related tasks found for \" + baseName}\n\t}\n\n\t// Check if all related tasks are done\n\tfor _, task := range relatedTasks {\n\t\tif task.Status != base.DownloadStatusDone {\n\t\t\tnotDoneParts = append(notDoneParts, task.Meta.SingleFilepath())\n\t\t}\n\t}\n\n\treturn len(notDoneParts) == 0, notDoneParts\n}\n\n// tryClaimMultiPartExtraction atomically checks if extraction can be claimed for a multi-part archive\n// and if so, marks the task as queued. Returns true if this task should proceed with queueing.\n// This uses sync.Map.LoadOrStore for atomic claim to prevent race conditions.\nfunc (d *Downloader) tryClaimMultiPartExtraction(task *Task, baseName string) bool {\n\t// Use LoadOrStore for atomic claim - if another goroutine already stored a value, we get that value back\n\t_, alreadyClaimed := d.claimedExtractions.LoadOrStore(baseName, task.ID)\n\tif alreadyClaimed {\n\t\treturn false // Another task already claimed it\n\t}\n\n\t// This task successfully claimed it\n\ttask.Progress.ExtractStatus = ExtractStatusQueued\n\treturn true\n}\n\n// releaseMultiPartExtractionClaim releases the extraction claim for a multi-part archive\n// This is primarily used for testing purposes\nfunc (d *Downloader) releaseMultiPartExtractionClaim(baseName string) {\n\td.claimedExtractions.Delete(baseName)\n}\n\n// performExtraction performs extraction for a regular (non-multi-part) archive\nfunc (d *Downloader) performExtraction(task *Task, archivePath string, destDir string, opts *http.OptsExtra) {\n\t// Set extraction status to extracting\n\ttask.Progress.ExtractStatus = ExtractStatusExtracting\n\ttask.Progress.ExtractProgress = 0\n\td.emit(EventKeyProgress, task)\n\td.storage.Put(bucketTask, task.ID, task.clone())\n\n\t// Extract the archive\n\textractErr := extractArchive(archivePath, destDir, opts.ArchivePassword, func(extractedFiles int, totalFiles int, progress int) {\n\t\ttask.Progress.ExtractProgress = progress\n\t\td.emit(EventKeyProgress, task)\n\t})\n\n\td.handleExtractionResult(task, extractErr, []string{archivePath}, opts.DeleteAfterExtract)\n}\n\n// performMultiPartExtraction performs extraction for a multi-part archive\nfunc (d *Downloader) performMultiPartExtraction(task *Task, firstPartPath string, destDir string, opts *http.OptsExtra) {\n\t// Get the baseName for releasing the claim later\n\tfullBaseName := GetMultiPartArchiveBaseName(firstPartPath)\n\n\t// Set extraction status to extracting\n\ttask.Progress.ExtractStatus = ExtractStatusExtracting\n\ttask.Progress.ExtractProgress = 0\n\td.emit(EventKeyProgress, task)\n\td.storage.Put(bucketTask, task.ID, task.clone())\n\n\td.Logger.Info().Msgf(\"starting multi-part archive extraction, first part: %s, task id: %s\", firstPartPath, task.ID)\n\n\t// Extract the multi-part archive\n\textractErr := extractMultiPartArchive(firstPartPath, destDir, opts.ArchivePassword, func(extractedFiles int, totalFiles int, progress int) {\n\t\ttask.Progress.ExtractProgress = progress\n\t\td.emit(EventKeyProgress, task)\n\t})\n\n\t// Collect all part files for potential deletion\n\tpartFiles := d.collectMultiPartFiles(firstPartPath)\n\n\td.handleExtractionResult(task, extractErr, partFiles, opts.DeleteAfterExtract)\n\n\t// Update status for all related multi-part tasks\n\td.updateMultiPartTasksStatus(task, extractErr)\n\n\t// Release the claim so future downloads of the same archive can be extracted\n\td.releaseMultiPartExtractionClaim(fullBaseName)\n}\n\n// collectMultiPartFiles collects all files belonging to a multi-part archive\nfunc (d *Downloader) collectMultiPartFiles(firstPartPath string) []string {\n\tvar files []string\n\tpartInfo := getArchivePartInfo(firstPartPath)\n\tdir := filepath.Dir(firstPartPath)\n\n\tswitch {\n\tcase strings.Contains(partInfo.Pattern, \".7z)\"):\n\t\t// 7z: .7z.001, .7z.002, etc.\n\t\tfiles = d.collectSequentialFiles(dir, partInfo.BaseName, \".%03d\")\n\tcase strings.Contains(partInfo.Pattern, \".part\"):\n\t\t// RAR new style\n\t\tfiles = d.collectRarNewStyleFiles(dir, partInfo.BaseName)\n\tcase partInfo.Pattern == \"rar-old-style\" || strings.Contains(partInfo.Pattern, \".r(\"):\n\t\t// RAR old style\n\t\tfiles = d.collectRarOldStyleFiles(dir, partInfo.BaseName)\n\tcase strings.Contains(partInfo.Pattern, \".zip)\"):\n\t\t// ZIP multi-part\n\t\tfiles = d.collectSequentialFiles(dir, partInfo.BaseName, \".%03d\")\n\tcase strings.Contains(partInfo.Pattern, \".z(\"):\n\t\t// ZIP split\n\t\tfiles = d.collectZipSplitFiles(dir, partInfo.BaseName)\n\t}\n\n\treturn files\n}\n\n// collectSequentialFiles collects sequential numbered files\nfunc (d *Downloader) collectSequentialFiles(dir, baseName, format string) []string {\n\tvar files []string\n\tsuffix := filepath.Ext(baseName)\n\tnameWithoutExt := strings.TrimSuffix(baseName, suffix)\n\tpartNum := 1\n\n\tfor {\n\t\tpartPath := filepath.Join(dir, nameWithoutExt+suffix+fmt.Sprintf(format, partNum))\n\t\tif _, err := os.Stat(partPath); os.IsNotExist(err) {\n\t\t\tbreak\n\t\t}\n\t\tfiles = append(files, partPath)\n\t\tpartNum++\n\t}\n\n\treturn files\n}\n\n// collectRarNewStyleFiles collects RAR new style part files\nfunc (d *Downloader) collectRarNewStyleFiles(dir, baseName string) []string {\n\tvar files []string\n\tpartNum := 1\n\n\tfor {\n\t\tsingleDigitPath := filepath.Join(dir, baseName+fmt.Sprintf(\".part%d.rar\", partNum))\n\t\tdoubleDigitPath := filepath.Join(dir, baseName+fmt.Sprintf(\".part%02d.rar\", partNum))\n\n\t\tif _, err := os.Stat(singleDigitPath); err == nil {\n\t\t\tfiles = append(files, singleDigitPath)\n\t\t} else if _, err := os.Stat(doubleDigitPath); err == nil {\n\t\t\tfiles = append(files, doubleDigitPath)\n\t\t} else {\n\t\t\tbreak\n\t\t}\n\t\tpartNum++\n\t}\n\n\treturn files\n}\n\n// collectRarOldStyleFiles collects RAR old style part files\nfunc (d *Downloader) collectRarOldStyleFiles(dir, baseName string) []string {\n\tvar files []string\n\n\t// .rar file\n\trarPath := filepath.Join(dir, baseName+\".rar\")\n\tif _, err := os.Stat(rarPath); err == nil {\n\t\tfiles = append(files, rarPath)\n\t}\n\n\t// .r00, .r01, etc.\n\tpartNum := 0\n\tfor {\n\t\tpartPath := filepath.Join(dir, baseName+fmt.Sprintf(\".r%02d\", partNum))\n\t\tif _, err := os.Stat(partPath); os.IsNotExist(err) {\n\t\t\tbreak\n\t\t}\n\t\tfiles = append(files, partPath)\n\t\tpartNum++\n\t}\n\n\treturn files\n}\n\n// collectZipSplitFiles collects ZIP split files\nfunc (d *Downloader) collectZipSplitFiles(dir, baseName string) []string {\n\tvar files []string\n\n\t// .z01, .z02, etc.\n\tpartNum := 1\n\tfor {\n\t\tpartPath := filepath.Join(dir, baseName+fmt.Sprintf(\".z%02d\", partNum))\n\t\tif _, err := os.Stat(partPath); os.IsNotExist(err) {\n\t\t\tbreak\n\t\t}\n\t\tfiles = append(files, partPath)\n\t\tpartNum++\n\t}\n\n\t// .zip file\n\tzipPath := filepath.Join(dir, baseName+\".zip\")\n\tif _, err := os.Stat(zipPath); err == nil {\n\t\tfiles = append(files, zipPath)\n\t}\n\n\treturn files\n}\n\n// handleExtractionResult handles the result of an extraction operation\nfunc (d *Downloader) handleExtractionResult(task *Task, extractErr error, archiveFiles []string, deleteAfterExtract bool) {\n\tif extractErr != nil {\n\t\td.Logger.Error().Err(extractErr).Msgf(\"auto extract archive failed, task id: %s\", task.ID)\n\t\ttask.Progress.ExtractStatus = ExtractStatusError\n\t\td.emit(EventKeyProgress, task)\n\t\td.storage.Put(bucketTask, task.ID, task.clone())\n\t} else {\n\t\td.Logger.Info().Msgf(\"auto extract archive completed, task id: %s\", task.ID)\n\t\ttask.Progress.ExtractStatus = ExtractStatusDone\n\t\ttask.Progress.ExtractProgress = 100\n\t\td.emit(EventKeyProgress, task)\n\t\td.storage.Put(bucketTask, task.ID, task.clone())\n\n\t\t// Delete archive files after successful extraction if enabled\n\t\tif deleteAfterExtract {\n\t\t\tfor _, archiveFile := range archiveFiles {\n\t\t\t\tdeleteErr := os.Remove(archiveFile)\n\t\t\t\tif deleteErr != nil {\n\t\t\t\t\td.Logger.Error().Err(deleteErr).Msgf(\"delete archive after extraction failed: %s\", archiveFile)\n\t\t\t\t} else {\n\t\t\t\t\td.Logger.Info().Msgf(\"archive deleted after extraction: %s\", archiveFile)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\n// updateMultiPartTasksStatus updates the extraction status for all tasks that belong to the same multi-part archive\nfunc (d *Downloader) updateMultiPartTasksStatus(sourceTask *Task, extractErr error) {\n\tif sourceTask.Progress.MultiPartBaseName == \"\" {\n\t\treturn\n\t}\n\n\tstatus := ExtractStatusDone\n\tprogress := 100\n\tif extractErr != nil {\n\t\tstatus = ExtractStatusError\n\t\tprogress = 0\n\t}\n\n\td.lock.Lock()\n\tdefer d.lock.Unlock()\n\n\tfor _, task := range d.tasks {\n\t\tif task.ID == sourceTask.ID {\n\t\t\tcontinue\n\t\t}\n\t\tif task.Progress.MultiPartBaseName == sourceTask.Progress.MultiPartBaseName {\n\t\t\ttask.Progress.ExtractStatus = status\n\t\t\ttask.Progress.ExtractProgress = progress\n\t\t\td.emit(EventKeyProgress, task)\n\t\t\td.storage.Put(bucketTask, task.ID, task.clone())\n\t\t}\n\t}\n}\n\nfunc initTask(task *Task) {\n\ttask.timer = util.NewTimer(task.Progress.Used)\n\n\ttask.statusLock = &sync.Mutex{}\n\ttask.lock = &sync.Mutex{}\n\ttask.speedArr = make([]int64, 0)\n\ttask.uploadSpeedArr = make([]int64, 0)\n}\n\nvar defaultDownloader = NewDownloader(nil)\n\ntype boot struct {\n\turl      string\n\textra    interface{}\n\tlistener Listener\n}\n\nfunc (b *boot) URL(url string) *boot {\n\tb.url = url\n\treturn b\n}\n\nfunc (b *boot) Extra(extra interface{}) *boot {\n\tb.extra = extra\n\treturn b\n}\n\nfunc (b *boot) Listener(listener Listener) *boot {\n\tb.listener = listener\n\treturn b\n}\n\nfunc (b *boot) Create(opts *base.Options) (string, error) {\n\tdefaultDownloader.Listener(b.listener)\n\treturn defaultDownloader.CreateDirect(&base.Request{\n\t\tURL:   b.url,\n\t\tExtra: b.extra,\n\t}, opts)\n}\n\nfunc Boot() *boot {\n\terr := defaultDownloader.Setup()\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\treturn &boot{}\n}\n"
  },
  {
    "path": "pkg/download/downloader_test.go",
    "content": "package download\n\nimport (\n\t\"archive/zip\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\tgohttp \"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/GopeedLab/gopeed/internal/fetcher\"\n\t\"github.com/GopeedLab/gopeed/internal/test\"\n\t\"github.com/GopeedLab/gopeed/pkg/base\"\n\t\"github.com/GopeedLab/gopeed/pkg/protocol/http\"\n\t\"github.com/GopeedLab/gopeed/pkg/util\"\n)\n\nvar testDownloadOpt = &base.Options{\n\tPath: test.Dir,\n\tName: test.DownloadName,\n\tExtra: http.OptsExtra{\n\t\tConnections: 4,\n\t},\n}\n\nfunc TestDownloader_Resolve(t *testing.T) {\n\tlistener := test.StartTestFileServer()\n\tdefer listener.Close()\n\n\tdownloader := NewDownloader(nil)\n\tif err := downloader.Setup(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer downloader.Clear()\n\treq := &base.Request{\n\t\tURL: \"http://\" + listener.Addr().String() + \"/\" + test.BuildName,\n\t}\n\trr, err := downloader.Resolve(req, nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := &base.Resource{\n\t\tSize:  test.BuildSize,\n\t\tRange: true,\n\t\tFiles: []*base.FileInfo{\n\t\t\t{\n\t\t\t\tName: test.BuildName,\n\t\t\t\tPath: \"\",\n\t\t\t\tSize: test.BuildSize,\n\t\t\t},\n\t\t},\n\t}\n\tif !test.AssertResourceEqual(want, rr.Res) {\n\t\tt.Errorf(\"Resolve() got = %v, want %v\", rr.Res, want)\n\t}\n}\n\nfunc TestDownloader_Create(t *testing.T) {\n\tlistener := test.StartTestFileServer()\n\tdefer listener.Close()\n\n\tdownloader := NewDownloader(nil)\n\tif err := downloader.Setup(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer downloader.Clear()\n\treq := &base.Request{\n\t\tURL: \"http://\" + listener.Addr().String() + \"/\" + test.BuildName,\n\t}\n\trr, err := downloader.Resolve(req, testDownloadOpt)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tvar wg sync.WaitGroup\n\twg.Add(1)\n\tdownloader.Listener(func(event *Event) {\n\t\tif event.Key == EventKeyDone {\n\t\t\twg.Done()\n\t\t}\n\t})\n\t_, err = downloader.Create(rr.ID)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twg.Wait()\n\twant := test.FileMd5(test.BuildFile)\n\tgot := test.FileMd5(test.DownloadFile)\n\tif want != got {\n\t\tt.Errorf(\"Downloader_Create() got = %v, want %v\", got, want)\n\t}\n}\n\nfunc TestDownloader_CreateNotInWhite(t *testing.T) {\n\tlistener := test.StartTestFileServer()\n\tdefer listener.Close()\n\n\tdownloader := NewDownloader(&DownloaderConfig{\n\t\tWhiteDownloadDirs: []string{\"./downloads\"},\n\t})\n\tif err := downloader.Setup(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer downloader.Clear()\n\treq := &base.Request{\n\t\tURL: \"http://\" + listener.Addr().String() + \"/\" + test.BuildName,\n\t}\n\t// With new fetcher design, white list check happens during Resolve (not Create)\n\t// because Resolve now requires Options which includes the download path\n\t_, err := downloader.Resolve(req, testDownloadOpt)\n\tif err == nil {\n\t\tt.Error(\"TestDownloader_CreateNotInWhite() expected error but got nil\")\n\t}\n\tif !strings.Contains(err.Error(), \"white\") {\n\t\tt.Errorf(\"TestDownloader_CreateNotInWhite() got = %v, want error containing 'white'\", err.Error())\n\t}\n}\n\nfunc TestDownloader_CreateDirectBatch(t *testing.T) {\n\tlistener := test.StartTestFileServer()\n\tdefer listener.Close()\n\n\tdownloader := NewDownloader(nil)\n\tif err := downloader.Setup(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer func() {\n\t\tdownloader.Delete(nil, true)\n\t\tdownloader.Clear()\n\t}()\n\n\treqs := make([]*base.CreateTaskBatchItem, 0)\n\tfileNames := make([]string, 0)\n\tfor i := 0; i < 5; i++ {\n\t\treq := &base.Request{\n\t\t\tURL: \"http://\" + listener.Addr().String() + \"/\" + test.BuildName,\n\t\t}\n\t\treqs = append(reqs, &base.CreateTaskBatchItem{\n\t\t\tReq: req,\n\t\t})\n\t\tif i == 0 {\n\t\t\tfileNames = append(fileNames, test.DownloadName)\n\t\t} else {\n\t\t\tarr := strings.Split(test.DownloadName, \".\")\n\t\t\tfileNames = append(fileNames, arr[0]+\" (\"+strconv.Itoa(i)+\").\"+arr[1])\n\t\t}\n\t}\n\n\tvar wg sync.WaitGroup\n\twg.Add(len(reqs))\n\tdownloader.Listener(func(event *Event) {\n\t\tif event.Key == EventKeyDone {\n\t\t\twg.Done()\n\t\t}\n\t})\n\n\t_, err := downloader.CreateDirectBatch(&base.CreateTaskBatch{\n\t\tReqs: reqs,\n\t\tOpts: testDownloadOpt,\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\twg.Wait()\n\n\ttasks := downloader.GetTasks()\n\tif len(tasks) != len(reqs) {\n\t\tt.Errorf(\"CreateDirectBatch() task got = %v, want %v\", len(tasks), len(reqs))\n\t}\n\n\t// Collect all task names\n\ttaskNames := make(map[string]bool)\n\tfor _, task := range tasks {\n\t\ttaskNames[task.Meta.Opts.Name] = true\n\t}\n\n\t// Check that we have the expected number of unique task names\n\tif len(taskNames) != len(reqs) {\n\t\tt.Errorf(\"CreateDirectBatch() unique task names got = %v, want %v, names: %v\", len(taskNames), len(reqs), taskNames)\n\t}\n\n\t// Check that all task files exist\n\tfor name := range taskNames {\n\t\tif _, err := os.Stat(test.Dir + \"/\" + name); os.IsNotExist(err) {\n\t\t\tt.Errorf(\"CreateDirectBatch() file not exist: %v\", name)\n\t\t}\n\t}\n\n}\n\nfunc TestDownloader_CreateWithProxy(t *testing.T) {\n\t// No proxy\n\tdoTestDownloaderCreateWithProxy(t, false, nil, func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig {\n\t\treturn nil\n\t}, nil)\n\t// Disable proxy\n\tdoTestDownloaderCreateWithProxy(t, false, nil, func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig {\n\t\tproxyCfg.Enable = false\n\t\treturn proxyCfg\n\t}, nil)\n\t// Enable system proxy but not set proxy environment variable\n\tdoTestDownloaderCreateWithProxy(t, false, nil, func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig {\n\t\tproxyCfg.System = true\n\t\treturn proxyCfg\n\t}, nil)\n\t// Enable proxy but error proxy environment variable\n\tdoTestDownloaderCreateWithProxy(t, false, nil, func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig {\n\t\tos.Setenv(\"HTTP_PROXY\", \"http://127.0.0.1:1234\")\n\t\tos.Setenv(\"HTTPS_PROXY\", \"http://127.0.0.1:1234\")\n\t\tproxyCfg.System = true\n\t\treturn proxyCfg\n\t}, func(err error) {\n\t\tif err == nil {\n\t\t\tt.Fatal(\"doTestDownloaderCreateWithProxy() got = nil, want error\")\n\t\t}\n\t})\n\t// Enable system proxy and set proxy environment variable\n\tdoTestDownloaderCreateWithProxy(t, false, nil, func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig {\n\t\tos.Setenv(\"HTTP_PROXY\", proxyCfg.ToUrl().String())\n\t\tos.Setenv(\"HTTPS_PROXY\", proxyCfg.ToUrl().String())\n\t\tproxyCfg.System = true\n\t\treturn proxyCfg\n\t}, nil)\n\t// Invalid proxy scheme\n\tdoTestDownloaderCreateWithProxy(t, false, nil, func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig {\n\t\tproxyCfg.Scheme = \"\"\n\t\treturn proxyCfg\n\t}, nil)\n\t// Invalid proxy host\n\tdoTestDownloaderCreateWithProxy(t, false, nil, func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig {\n\t\tproxyCfg.Host = \"\"\n\t\treturn proxyCfg\n\t}, nil)\n\t// Use proxy without auth\n\tdoTestDownloaderCreateWithProxy(t, false, nil, func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig {\n\t\treturn proxyCfg\n\t}, nil)\n\t// Use proxy with auth\n\tdoTestDownloaderCreateWithProxy(t, true, nil, func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig {\n\t\treturn proxyCfg\n\t}, nil)\n\n\t// Request proxy mode follow\n\tdoTestDownloaderCreateWithProxy(t, false, func(reqProxy *base.RequestProxy) *base.RequestProxy {\n\t\treqProxy.Mode = base.RequestProxyModeFollow\n\t\treturn reqProxy\n\t}, nil, nil)\n\n\t// Request proxy mode none\n\tdoTestDownloaderCreateWithProxy(t, false, func(reqProxy *base.RequestProxy) *base.RequestProxy {\n\t\treqProxy.Mode = base.RequestProxyModeNone\n\t\treturn reqProxy\n\t}, nil, nil)\n\n\t// Request proxy mode custom\n\tdoTestDownloaderCreateWithProxy(t, false, func(reqProxy *base.RequestProxy) *base.RequestProxy {\n\t\treturn reqProxy\n\t}, nil, nil)\n}\n\nfunc doTestDownloaderCreateWithProxy(t *testing.T, auth bool, buildReqProxy func(reqProxy *base.RequestProxy) *base.RequestProxy, buildProxyConfig func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig, errHandler func(err error)) {\n\tusr, pwd := \"\", \"\"\n\tif auth {\n\t\tusr, pwd = \"admin\", \"123\"\n\t}\n\tproxyListener := test.StartSocks5Server(usr, pwd)\n\tdefer proxyListener.Close()\n\n\tdownloader := NewDownloader(nil)\n\tif err := downloader.Setup(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer downloader.Clear()\n\tglobalProxyCfg := &base.DownloaderProxyConfig{\n\t\tEnable: true,\n\t\tScheme: \"socks5\",\n\t\tHost:   proxyListener.Addr().String(),\n\t\tUsr:    usr,\n\t\tPwd:    pwd,\n\t}\n\tif buildProxyConfig != nil {\n\t\tglobalProxyCfg = buildProxyConfig(globalProxyCfg)\n\t}\n\tdownloader.cfg.DownloaderStoreConfig.Proxy = globalProxyCfg\n\n\treq := &base.Request{\n\t\tURL: test.ExternalDownloadUrl,\n\t}\n\tif buildReqProxy != nil {\n\t\treq.Proxy = buildReqProxy(&base.RequestProxy{\n\t\t\tScheme: \"socks5\",\n\t\t\tHost:   proxyListener.Addr().String(),\n\t\t\tUsr:    usr,\n\t\t\tPwd:    pwd,\n\t\t})\n\t}\n\trr, err := downloader.Resolve(req, nil)\n\tif err != nil {\n\t\tif errHandler == nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\terrHandler(err)\n\t\treturn\n\t}\n\twant := &base.Resource{\n\t\tSize:  test.ExternalDownloadSize,\n\t\tRange: true,\n\t\tFiles: []*base.FileInfo{\n\t\t\t{\n\t\t\t\tName: test.ExternalDownloadName,\n\t\t\t\tPath: \"\",\n\t\t\t\tSize: test.ExternalDownloadSize,\n\t\t\t},\n\t\t},\n\t}\n\tif !test.AssertResourceEqual(want, rr.Res) {\n\t\tt.Errorf(\"Resolve() got = %v, want %v\", rr.Res, want)\n\t}\n}\n\nfunc TestDownloader_CreateRename(t *testing.T) {\n\tlistener := test.StartTestFileServer()\n\tdefer listener.Close()\n\n\tdownloader := NewDownloader(nil)\n\tif err := downloader.Setup(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer downloader.Clear()\n\n\treq := &base.Request{\n\t\tURL: \"http://\" + listener.Addr().String() + \"/\" + test.BuildName,\n\t}\n\tvar wg sync.WaitGroup\n\twg.Add(2)\n\tdownloader.Listener(func(event *Event) {\n\t\tif event.Key == EventKeyDone {\n\t\t\twg.Done()\n\t\t}\n\t})\n\tfor i := 0; i < 2; i++ {\n\t\t_, err := downloader.CreateDirect(req, &base.Options{\n\t\t\tPath: test.Dir,\n\t\t\tName: test.DownloadName,\n\t\t\tExtra: http.OptsExtra{\n\t\t\t\tConnections: 4,\n\t\t\t},\n\t\t})\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\twg.Wait()\n\n\twant := test.FileMd5(test.BuildFile)\n\tgot := test.FileMd5(test.DownloadFile)\n\tif want != got {\n\t\tt.Errorf(\"Downloader_CreateRename() got = %v, want %v\", got, want)\n\t}\n\tgot = test.FileMd5(test.DownloadRenameFile)\n\tif want != got {\n\t\tt.Errorf(\"Downloader_CreateRename() got = %v, want %v\", got, want)\n\t}\n}\n\nfunc TestDownloader_StoreAndRestore(t *testing.T) {\n\tlistener := test.StartTestSlowFileServer(time.Millisecond * 2000)\n\tdefer listener.Close()\n\n\tdownloader := NewDownloader(&DownloaderConfig{\n\t\tStorage: NewBoltStorage(\"./\"),\n\t})\n\tif err := downloader.Setup(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer downloader.Clear()\n\treq := &base.Request{\n\t\tURL: \"http://\" + listener.Addr().String() + \"/\" + test.BuildName,\n\t}\n\trr, err := downloader.Resolve(req, testDownloadOpt)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tid, err := downloader.Create(rr.ID)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttime.Sleep(time.Millisecond * 1001)\n\terr = downloader.Pause(&TaskFilter{IDs: []string{id}})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdownloader.Close()\n\n\tdownloader = NewDownloader(&DownloaderConfig{\n\t\tStorage: NewBoltStorage(\"./\"),\n\t})\n\tif err := downloader.Setup(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttask := downloader.GetTask(id)\n\n\tif task == nil {\n\t\tt.Fatal(\"task is nil\")\n\t}\n\tvar wg sync.WaitGroup\n\twg.Add(1)\n\tdownloader.Listener(func(event *Event) {\n\t\tif event.Key == EventKeyDone {\n\t\t\twg.Done()\n\t\t}\n\t})\n\terr = downloader.Continue(&TaskFilter{IDs: []string{id}})\n\twg.Wait()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := test.FileMd5(test.BuildFile)\n\tgot := test.FileMd5(test.DownloadFile)\n\tif want != got {\n\t\tt.Errorf(\"StoreAndResume() got = %v, want %v\", got, want)\n\t}\n\n\tdownloader.Clear()\n}\n\nfunc TestDownloader_Protocol_Config(t *testing.T) {\n\tdownloader := NewDownloader(nil)\n\tif err := downloader.Setup(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer downloader.Clear()\n\n\tvar httpCfg map[string]any\n\texits := downloader.getProtocolConfig(\"http\", &httpCfg)\n\tif !exits {\n\t\tt.Errorf(\"getProtocolConfig() got = %v, want %v\", exits, true)\n\t}\n\n\tstoreCfg := &base.DownloaderStoreConfig{\n\t\tDownloadDir: \"./downloads\",\n\t\tProtocolConfig: map[string]any{\n\t\t\t\"http\": map[string]any{\n\t\t\t\t\"connections\": 4,\n\t\t\t},\n\t\t\t\"bt\": map[string]any{\n\t\t\t\t\"trackerSubscribeUrls\": []string{\n\t\t\t\t\t\"https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/best.txt\",\n\t\t\t\t},\n\t\t\t\t\"trackers\": []string{\n\t\t\t\t\t\"udp://tracker.coppersurfer.tk:6969/announce\",\n\t\t\t\t\t\"udp://tracker.leechers-paradise.org:6969/announce\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tExtra: map[string]any{\n\t\t\t\"theme\": \"dark\",\n\t\t},\n\t}\n\n\tif err := downloader.PutConfig(storeCfg); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tnewStoreCfg, err := downloader.GetConfig()\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif !test.JsonEqual(storeCfg, newStoreCfg) {\n\t\tt.Errorf(\"GetConfig() got = %v, want %v\", test.ToJson(storeCfg), test.ToJson(newStoreCfg))\n\t}\n}\n\nfunc TestDownloader_GetTasksByFilter(t *testing.T) {\n\tlistener := test.StartTestFileServer()\n\tdefer listener.Close()\n\n\tdownloader := NewDownloader(nil)\n\tif err := downloader.Setup(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer func() {\n\t\tdownloader.Delete(nil, true)\n\t\tdownloader.Clear()\n\t}()\n\n\treqs := make([]*base.CreateTaskBatchItem, 0)\n\tfileNames := make([]string, 0)\n\tfor i := 0; i < 10; i++ {\n\t\treq := &base.Request{\n\t\t\tURL: \"http://\" + listener.Addr().String() + \"/\" + test.BuildName,\n\t\t}\n\t\treqs = append(reqs, &base.CreateTaskBatchItem{\n\t\t\tReq: req,\n\t\t})\n\t\tif i == 0 {\n\t\t\tfileNames = append(fileNames, test.DownloadName)\n\t\t} else {\n\t\t\tarr := strings.Split(test.DownloadName, \".\")\n\t\t\tfileNames = append(fileNames, arr[0]+\" (\"+strconv.Itoa(i)+\").\"+arr[1])\n\t\t}\n\t}\n\n\tvar wg sync.WaitGroup\n\twg.Add(len(reqs))\n\tdownloader.Listener(func(event *Event) {\n\t\tif event.Key == EventKeyDone {\n\t\t\twg.Done()\n\t\t}\n\t})\n\n\ttaskIds, err := downloader.CreateDirectBatch(&base.CreateTaskBatch{\n\t\tReqs: reqs,\n\t\tOpts: &base.Options{\n\t\t\tPath: test.Dir,\n\t\t\tName: test.DownloadName,\n\t\t\tExtra: http.OptsExtra{\n\t\t\t\tConnections: 4,\n\t\t\t},\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\twg.Wait()\n\n\tt.Run(\"GetTasksByFilter nil\", func(t *testing.T) {\n\t\ttasks := downloader.GetTasksByFilter(nil)\n\t\tif len(tasks) != len(reqs) {\n\t\t\tt.Errorf(\"GetTasksByFilter nil task got = %v, want %v\", len(tasks), len(reqs))\n\t\t}\n\t})\n\n\tt.Run(\"GetTasksByFilter empty\", func(t *testing.T) {\n\t\ttasks := downloader.GetTasksByFilter(&TaskFilter{})\n\t\tif len(tasks) != len(reqs) {\n\t\t\tt.Errorf(\"GetTasksByFilter empty task got = %v, want %v\", len(tasks), len(reqs))\n\t\t}\n\t})\n\n\tt.Run(\"GetTasksByFilter ids\", func(t *testing.T) {\n\t\ttasks := downloader.GetTasksByFilter(&TaskFilter{\n\t\t\tIDs: taskIds,\n\t\t})\n\t\tif len(tasks) != len(reqs) {\n\t\t\tt.Errorf(\"GetTasksByFilter ids task got = %v, want %v\", len(tasks), len(reqs))\n\t\t}\n\t})\n\n\tt.Run(\"GetTasksByFilter match ids\", func(t *testing.T) {\n\t\ttasks := downloader.GetTasksByFilter(&TaskFilter{\n\t\t\tIDs: []string{taskIds[0]},\n\t\t})\n\t\tif len(tasks) != 1 {\n\t\t\tt.Errorf(\"GetTasksByFilter ids task got = %v, want %v\", len(tasks), 1)\n\t\t}\n\t})\n\n\tt.Run(\"GetTasksByFilter not match ids\", func(t *testing.T) {\n\t\ttasks := downloader.GetTasksByFilter(&TaskFilter{\n\t\t\tIDs: []string{\"xxx\"},\n\t\t})\n\t\tif len(tasks) != 0 {\n\t\t\tt.Errorf(\"GetTasksByFilter ids task got = %v, want %v\", len(tasks), 0)\n\t\t}\n\t})\n\n\tt.Run(\"GetTasksByFilter status\", func(t *testing.T) {\n\t\ttasks := downloader.GetTasksByFilter(&TaskFilter{\n\t\t\tStatuses: []base.Status{base.DownloadStatusDone},\n\t\t})\n\t\tif len(tasks) != len(reqs) {\n\t\t\tt.Errorf(\"GetTasksByFilter status task got = %v, want %v\", len(tasks), len(reqs))\n\t\t}\n\t})\n\n\tt.Run(\"GetTasksByFilter not match status\", func(t *testing.T) {\n\t\ttasks := downloader.GetTasksByFilter(&TaskFilter{\n\t\t\tStatuses: []base.Status{base.DownloadStatusError},\n\t\t})\n\t\tif len(tasks) != 0 {\n\t\t\tt.Errorf(\"GetTasksByFilter status task got = %v, want %v\", len(tasks), 0)\n\t\t}\n\t})\n\n\tt.Run(\"GetTasksByFilter match notStatus\", func(t *testing.T) {\n\t\ttasks := downloader.GetTasksByFilter(&TaskFilter{\n\t\t\tNotStatuses: []base.Status{base.DownloadStatusRunning, base.DownloadStatusPause},\n\t\t})\n\t\tif len(tasks) != len(reqs) {\n\t\t\tt.Errorf(\"GetTasksByFilter match notStatus task got = %v, want %v\", len(tasks), len(reqs))\n\t\t}\n\t})\n\n\tt.Run(\"GetTasksByFilter not match notStatus\", func(t *testing.T) {\n\t\ttasks := downloader.GetTasksByFilter(&TaskFilter{\n\t\t\tNotStatuses: []base.Status{base.DownloadStatusDone},\n\t\t})\n\t\tif len(tasks) != 0 {\n\t\t\tt.Errorf(\"GetTasksByFilter not match notStatus task got = %v, want %v\", len(tasks), 0)\n\t\t}\n\t})\n\n\tt.Run(\"GetTasksByFilter match ids and status\", func(t *testing.T) {\n\t\ttasks := downloader.GetTasksByFilter(&TaskFilter{\n\t\t\tIDs:      []string{taskIds[0]},\n\t\t\tStatuses: []base.Status{base.DownloadStatusDone},\n\t\t})\n\t\tif len(tasks) != 1 {\n\t\t\tt.Errorf(\"GetTasksByFilter match ids and status task got = %v, want %v\", len(tasks), 1)\n\t\t}\n\t})\n\n\tt.Run(\"GetTasksByFilter not match ids and status\", func(t *testing.T) {\n\t\ttasks := downloader.GetTasksByFilter(&TaskFilter{\n\t\t\tIDs:      []string{taskIds[0]},\n\t\t\tStatuses: []base.Status{base.DownloadStatusError},\n\t\t})\n\t\tif len(tasks) != 0 {\n\t\t\tt.Errorf(\"GetTasksByFilter not match ids and status task got = %v, want %v\", len(tasks), 0)\n\t\t}\n\t})\n\n}\n\nfunc TestDownloader_Stats(t *testing.T) {\n\tlistener := test.StartTestFileServer()\n\tdefer listener.Close()\n\n\tdownloader := NewDownloader(nil)\n\tif err := downloader.Setup(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer downloader.Clear()\n\n\t// Test Stats for non-existent task\n\t_, err := downloader.Stats(\"non-existent-id\")\n\tif err != ErrTaskNotFound {\n\t\tt.Errorf(\"Stats() expected ErrTaskNotFound, got %v\", err)\n\t}\n\n\t// Create a task\n\treq := &base.Request{\n\t\tURL: \"http://\" + listener.Addr().String() + \"/\" + test.BuildName,\n\t}\n\trr, err := downloader.Resolve(req, testDownloadOpt)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tvar wg sync.WaitGroup\n\twg.Add(1)\n\tdownloader.Listener(func(event *Event) {\n\t\tif event.Key == EventKeyDone {\n\t\t\twg.Done()\n\t\t}\n\t})\n\n\ttaskId, err := downloader.Create(rr.ID)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\twg.Wait()\n\n\t// Test Stats for existing task\n\tstats, err := downloader.Stats(taskId)\n\tif err != nil {\n\t\tt.Errorf(\"Stats() unexpected error: %v\", err)\n\t}\n\tif stats == nil {\n\t\tt.Error(\"Stats() returned nil stats\")\n\t}\n}\n\nfunc TestDownloader_Delete(t *testing.T) {\n\tlistener := test.StartTestFileServer()\n\tdefer listener.Close()\n\n\tdownloader := NewDownloader(nil)\n\tif err := downloader.Setup(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer downloader.Clear()\n\n\t// Create multiple tasks\n\tvar wg sync.WaitGroup\n\ttaskCount := 3\n\twg.Add(taskCount)\n\tdownloader.Listener(func(event *Event) {\n\t\tif event.Key == EventKeyDone {\n\t\t\twg.Done()\n\t\t}\n\t})\n\n\ttaskIds := make([]string, 0)\n\tfor i := 0; i < taskCount; i++ {\n\t\treq := &base.Request{\n\t\t\tURL: \"http://\" + listener.Addr().String() + \"/\" + test.BuildName,\n\t\t}\n\t\ttaskId, err := downloader.CreateDirect(req, testDownloadOpt)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\ttaskIds = append(taskIds, taskId)\n\t}\n\n\twg.Wait()\n\n\t// Test Delete with filter (single task)\n\tt.Run(\"Delete single task by ID\", func(t *testing.T) {\n\t\tinitialCount := len(downloader.GetTasks())\n\t\terr := downloader.Delete(&TaskFilter{IDs: []string{taskIds[0]}}, true)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Delete() unexpected error: %v\", err)\n\t\t}\n\t\tnewCount := len(downloader.GetTasks())\n\t\tif newCount != initialCount-1 {\n\t\t\tt.Errorf(\"Delete() task count got = %v, want %v\", newCount, initialCount-1)\n\t\t}\n\t})\n\n\t// Test Delete with non-matching filter (should do nothing)\n\tt.Run(\"Delete with non-matching filter\", func(t *testing.T) {\n\t\tinitialCount := len(downloader.GetTasks())\n\t\terr := downloader.Delete(&TaskFilter{IDs: []string{\"non-existent-id\"}}, true)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Delete() unexpected error: %v\", err)\n\t\t}\n\t\tnewCount := len(downloader.GetTasks())\n\t\tif newCount != initialCount {\n\t\t\tt.Errorf(\"Delete() task count got = %v, want %v\", newCount, initialCount)\n\t\t}\n\t})\n\n\t// Test Delete by status\n\tt.Run(\"Delete by status\", func(t *testing.T) {\n\t\tinitialCount := len(downloader.GetTasks())\n\t\terr := downloader.Delete(&TaskFilter{Statuses: []base.Status{base.DownloadStatusDone}}, false)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Delete() unexpected error: %v\", err)\n\t\t}\n\t\tnewCount := len(downloader.GetTasks())\n\t\tif newCount != 0 {\n\t\t\tt.Errorf(\"Delete() should have deleted all done tasks, got %v remaining\", newCount)\n\t\t}\n\t\t_ = initialCount // suppress unused variable warning\n\t})\n}\n\nfunc TestDownloader_PauseAndContinue(t *testing.T) {\n\tlistener := test.StartTestSlowFileServer(time.Millisecond * 2000)\n\tdefer listener.Close()\n\n\tdownloader := NewDownloader(nil)\n\tif err := downloader.Setup(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer downloader.Clear()\n\n\t// Create a single task\n\treq := &base.Request{\n\t\tURL: \"http://\" + listener.Addr().String() + \"/\" + test.BuildName,\n\t}\n\trr, err := downloader.Resolve(req, testDownloadOpt)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\ttaskId, err := downloader.Create(rr.ID)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Wait for task to start\n\ttime.Sleep(time.Millisecond * 100)\n\n\t// Pause with specific taskId\n\terr = downloader.Pause(&TaskFilter{IDs: []string{taskId}})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\ttime.Sleep(time.Millisecond * 100)\n\n\t// Verify task is paused\n\ttask := downloader.GetTask(taskId)\n\tif task.Status != base.DownloadStatusPause {\n\t\tt.Errorf(\"Task should be paused, got %s\", task.Status)\n\t}\n\n\t// Continue with specific taskId\n\tvar wg sync.WaitGroup\n\twg.Add(1)\n\tdownloader.Listener(func(event *Event) {\n\t\tif event.Key == EventKeyDone {\n\t\t\twg.Done()\n\t\t}\n\t})\n\n\terr = downloader.Continue(&TaskFilter{IDs: []string{taskId}})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Wait for task to complete\n\twg.Wait()\n\n\t// Verify task is done\n\ttask = downloader.GetTask(taskId)\n\tif task.Status != base.DownloadStatusDone {\n\t\tt.Errorf(\"Task should be done, got %s\", task.Status)\n\t}\n\n\t// Clean up\n\tdownloader.Delete(nil, true)\n}\n\nfunc TestDownloader_PauseAllAndContinueAll(t *testing.T) {\n\tlistener := test.StartTestSlowFileServer(time.Millisecond * 2000)\n\tdefer listener.Close()\n\n\tdownloader := NewDownloader(nil)\n\tif err := downloader.Setup(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer downloader.Clear()\n\n\t// Create multiple tasks\n\ttaskCount := 3\n\ttaskIds := make([]string, 0)\n\n\tfor i := 0; i < taskCount; i++ {\n\t\treq := &base.Request{\n\t\t\tURL: \"http://\" + listener.Addr().String() + \"/\" + test.BuildName,\n\t\t}\n\t\trr, err := downloader.Resolve(req, testDownloadOpt)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\ttaskId, err := downloader.Create(rr.ID)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\ttaskIds = append(taskIds, taskId)\n\t}\n\n\t// Wait for tasks to start\n\ttime.Sleep(time.Millisecond * 100)\n\n\t// Pause all tasks with nil filter\n\terr := downloader.Pause(nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\ttime.Sleep(time.Millisecond * 100)\n\n\t// Verify all tasks are paused\n\tpausedCount := 0\n\tfor _, taskId := range taskIds {\n\t\ttask := downloader.GetTask(taskId)\n\t\tif task.Status == base.DownloadStatusPause {\n\t\t\tpausedCount++\n\t\t}\n\t}\n\tif pausedCount != taskCount {\n\t\tt.Errorf(\"Expected %d paused tasks, got %d\", taskCount, pausedCount)\n\t}\n\n\t// Continue all tasks with nil filter\n\tvar wg sync.WaitGroup\n\twg.Add(taskCount)\n\tdownloader.Listener(func(event *Event) {\n\t\tif event.Key == EventKeyDone {\n\t\t\twg.Done()\n\t\t}\n\t})\n\n\terr = downloader.Continue(nil)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Wait for all tasks to complete\n\twg.Wait()\n\n\t// Verify all tasks are done\n\tdoneCount := 0\n\tfor _, taskId := range taskIds {\n\t\ttask := downloader.GetTask(taskId)\n\t\tif task.Status == base.DownloadStatusDone {\n\t\t\tdoneCount++\n\t\t}\n\t}\n\tif doneCount != taskCount {\n\t\tt.Errorf(\"Expected %d done tasks, got %d\", taskCount, doneCount)\n\t}\n\n\t// Clean up\n\tdownloader.Delete(nil, true)\n}\n\nfunc TestDownloader_GetTask(t *testing.T) {\n\tdownloader := NewDownloader(nil)\n\tif err := downloader.Setup(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer downloader.Clear()\n\n\t// Test GetTask for non-existent task\n\ttask := downloader.GetTask(\"non-existent-id\")\n\tif task != nil {\n\t\tt.Errorf(\"GetTask() expected nil for non-existent task, got %v\", task)\n\t}\n}\n\nfunc TestDownloader_Emit(t *testing.T) {\n\tdownloader := NewDownloader(nil)\n\tif err := downloader.Setup(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer downloader.Clear()\n\n\t// Test emit with no listener (should not panic)\n\tdownloader.emit(EventKeyDone, nil)\n\n\t// Test emit with listener\n\teventReceived := false\n\tdownloader.Listener(func(event *Event) {\n\t\teventReceived = true\n\t})\n\tdownloader.emit(EventKeyDone, nil)\n\tif !eventReceived {\n\t\tt.Error(\"Event should have been received by listener\")\n\t}\n}\n\nfunc TestDownloader_AutoExtract(t *testing.T) {\n\t// Create a temporary directory for extraction tests\n\ttempDir, err := os.MkdirTemp(\"\", \"downloader_extract_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create a test zip file\n\tzipPath := tempDir + \"/test_archive.zip\"\n\tif err := createTestArchive(zipPath); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Verify isArchiveFile works correctly\n\tt.Run(\"isArchiveFile\", func(t *testing.T) {\n\t\tif !isArchiveFile(zipPath) {\n\t\t\tt.Error(\"isArchiveFile should return true for .zip file\")\n\t\t}\n\t\tif isArchiveFile(tempDir + \"/test.txt\") {\n\t\t\tt.Error(\"isArchiveFile should return false for .txt file\")\n\t\t}\n\t})\n}\n\n// TestDownloader_AutoExtractWithProgress tests the auto-extract functionality with progress tracking\n// This test exercises the ExtractStatus and ExtractProgress fields in the Progress struct\nfunc TestDownloader_AutoExtractWithProgress(t *testing.T) {\n\t// Create a temporary directory for the test\n\ttempDir, err := os.MkdirTemp(\"\", \"auto_extract_progress_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create a test zip file to serve\n\tzipPath := tempDir + \"/archive.zip\"\n\tif err := createTestArchiveWithMultipleFiles(zipPath, 3); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Start a simple HTTP server to serve the zip file\n\tserver := startTestArchiveServer(zipPath)\n\tdefer server.Close()\n\n\t// Create downloader\n\tdownloader := NewDownloader(nil)\n\tif err := downloader.Setup(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer downloader.Clear()\n\n\t// Track extraction status changes\n\tvar extractStatusChanges []ExtractStatus\n\tvar extractProgressValues []int\n\tvar statusMutex sync.Mutex\n\textractDoneCh := make(chan struct{})\n\tvar extractDoneOnce sync.Once\n\n\tdownloader.Listener(func(event *Event) {\n\t\tif event.Key == EventKeyProgress && event.Task != nil && event.Task.Progress != nil {\n\t\t\tstatusMutex.Lock()\n\t\t\tstatus := event.Task.Progress.ExtractStatus\n\t\t\tprogress := event.Task.Progress.ExtractProgress\n\t\t\t// Record status changes\n\t\t\tif status != ExtractStatusNone {\n\t\t\t\tif len(extractStatusChanges) == 0 || extractStatusChanges[len(extractStatusChanges)-1] != status {\n\t\t\t\t\textractStatusChanges = append(extractStatusChanges, status)\n\t\t\t\t}\n\t\t\t\textractProgressValues = append(extractProgressValues, progress)\n\t\t\t}\n\t\t\tstatusMutex.Unlock()\n\t\t\t// Signal when extraction is done or errored\n\t\t\tif status == ExtractStatusDone || status == ExtractStatusError {\n\t\t\t\textractDoneOnce.Do(func() {\n\t\t\t\t\tclose(extractDoneCh)\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t})\n\n\t// Create request to download the zip file\n\treq := &base.Request{\n\t\tURL: \"http://\" + server.Addr().String() + \"/archive.zip\",\n\t}\n\n\t// Create task with AutoExtract enabled\n\tdownloadDir := tempDir + \"/downloads\"\n\ttaskId, err := downloader.CreateDirect(req, &base.Options{\n\t\tPath: downloadDir,\n\t\tName: \"archive.zip\",\n\t\tExtra: http.OptsExtra{\n\t\t\tConnections: 1,\n\t\t\tAutoExtract: util.BoolPtr(true),\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Wait for extraction to complete (with timeout)\n\tselect {\n\tcase <-extractDoneCh:\n\t\t// Extraction completed\n\tcase <-time.After(30 * time.Second):\n\t\tt.Log(\"Extraction timed out, checking results anyway\")\n\t}\n\n\t// Give a small buffer for final events to be processed\n\ttime.Sleep(100 * time.Millisecond)\n\n\t// Verify task exists\n\ttask := downloader.GetTask(taskId)\n\tif task == nil {\n\t\tt.Fatal(\"Task should exist\")\n\t}\n\n\t// Verify extraction status changes occurred\n\tstatusMutex.Lock()\n\tdefer statusMutex.Unlock()\n\n\tt.Logf(\"Recorded extract status changes: %v\", extractStatusChanges)\n\tt.Logf(\"Recorded extract progress values: %v\", extractProgressValues)\n\n\t// Verify that we went through ExtractStatusExtracting\n\tfoundExtracting := false\n\tfor _, status := range extractStatusChanges {\n\t\tif status == ExtractStatusExtracting {\n\t\t\tfoundExtracting = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundExtracting {\n\t\tt.Error(\"Expected ExtractStatusExtracting in status changes\")\n\t}\n\n\t// Verify that we reached ExtractStatusDone\n\tfoundDone := false\n\tfor _, status := range extractStatusChanges {\n\t\tif status == ExtractStatusDone {\n\t\t\tfoundDone = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundDone {\n\t\tt.Error(\"Expected ExtractStatusDone in status changes\")\n\t}\n\n\t// Verify progress values include 100 (final)\n\tfound100 := false\n\tfor _, p := range extractProgressValues {\n\t\tif p == 100 {\n\t\t\tfound100 = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !found100 {\n\t\tt.Error(\"Expected progress to reach 100\")\n\t}\n\n\t// Verify extracted files exist\n\textractedFile := downloadDir + \"/test_0.txt\"\n\tif _, err := os.Stat(extractedFile); os.IsNotExist(err) {\n\t\tt.Error(\"Expected extracted file to exist\")\n\t}\n}\n\n// TestDownloader_AutoExtractWithDeleteAfterExtract tests the auto-extract with DeleteAfterExtract option\nfunc TestDownloader_AutoExtractWithDeleteAfterExtract(t *testing.T) {\n\t// Create a temporary directory for the test\n\ttempDir, err := os.MkdirTemp(\"\", \"auto_extract_delete_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create a test zip file to serve\n\tzipPath := tempDir + \"/archive.zip\"\n\tif err := createTestArchiveWithMultipleFiles(zipPath, 2); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Start a simple HTTP server to serve the zip file\n\tserver := startTestArchiveServer(zipPath)\n\tdefer server.Close()\n\n\t// Create downloader\n\tdownloader := NewDownloader(nil)\n\tif err := downloader.Setup(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer downloader.Clear()\n\n\t// Track extraction status changes\n\textractDoneCh := make(chan struct{})\n\tvar extractDoneOnce sync.Once\n\n\tdownloader.Listener(func(event *Event) {\n\t\tif event.Key == EventKeyProgress && event.Task != nil && event.Task.Progress != nil {\n\t\t\tstatus := event.Task.Progress.ExtractStatus\n\t\t\tif status == ExtractStatusDone || status == ExtractStatusError {\n\t\t\t\textractDoneOnce.Do(func() {\n\t\t\t\t\tclose(extractDoneCh)\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t})\n\n\t// Create request to download the zip file\n\treq := &base.Request{\n\t\tURL: \"http://\" + server.Addr().String() + \"/archive.zip\",\n\t}\n\n\t// Create task with AutoExtract and DeleteAfterExtract enabled\n\tdownloadDir := tempDir + \"/downloads\"\n\t_, err = downloader.CreateDirect(req, &base.Options{\n\t\tPath: downloadDir,\n\t\tName: \"archive.zip\",\n\t\tExtra: http.OptsExtra{\n\t\t\tConnections:        1,\n\t\t\tAutoExtract:        util.BoolPtr(true),\n\t\t\tDeleteAfterExtract: true,\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Wait for extraction to complete (with timeout)\n\tselect {\n\tcase <-extractDoneCh:\n\t\t// Extraction completed\n\tcase <-time.After(10 * time.Second):\n\t\tt.Log(\"Extraction timed out\")\n\t}\n\n\t// Give time for file deletion\n\ttime.Sleep(200 * time.Millisecond)\n\n\t// Verify archive was deleted\n\tarchivePath := downloadDir + \"/archive.zip\"\n\tif _, err := os.Stat(archivePath); !os.IsNotExist(err) {\n\t\tt.Error(\"Expected archive to be deleted after extraction\")\n\t}\n\n\t// Verify extracted files exist\n\textractedFile := downloadDir + \"/test_0.txt\"\n\tif _, err := os.Stat(extractedFile); os.IsNotExist(err) {\n\t\tt.Error(\"Expected extracted file to exist\")\n\t}\n}\n\n// TestDownloader_AutoExtractError tests the auto-extract error handling path\nfunc TestDownloader_AutoExtractError(t *testing.T) {\n\t// Create a temporary directory for the test\n\ttempDir, err := os.MkdirTemp(\"\", \"auto_extract_error_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create a corrupt zip file (just invalid data with .zip extension)\n\tcorruptZipPath := tempDir + \"/corrupt.zip\"\n\tif err := os.WriteFile(corruptZipPath, []byte(\"this is not a valid zip file\"), 0644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Start a simple HTTP server to serve the corrupt zip file\n\tserver := startTestArchiveServer(corruptZipPath)\n\tdefer server.Close()\n\n\t// Create downloader\n\tdownloader := NewDownloader(nil)\n\tif err := downloader.Setup(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer downloader.Clear()\n\n\t// Track extraction status changes\n\tvar extractStatusChanges []ExtractStatus\n\tvar statusMutex sync.Mutex\n\textractDoneCh := make(chan struct{})\n\tvar extractDoneOnce sync.Once\n\n\tdownloader.Listener(func(event *Event) {\n\t\tif event.Key == EventKeyProgress && event.Task != nil && event.Task.Progress != nil {\n\t\t\tstatusMutex.Lock()\n\t\t\tstatus := event.Task.Progress.ExtractStatus\n\t\t\tif status != ExtractStatusNone {\n\t\t\t\tif len(extractStatusChanges) == 0 || extractStatusChanges[len(extractStatusChanges)-1] != status {\n\t\t\t\t\textractStatusChanges = append(extractStatusChanges, status)\n\t\t\t\t}\n\t\t\t}\n\t\t\tstatusMutex.Unlock()\n\t\t\tif status == ExtractStatusDone || status == ExtractStatusError {\n\t\t\t\textractDoneOnce.Do(func() {\n\t\t\t\t\tclose(extractDoneCh)\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t})\n\n\t// Create request to download the corrupt zip file\n\treq := &base.Request{\n\t\tURL: \"http://\" + server.Addr().String() + \"/corrupt.zip\",\n\t}\n\n\t// Create task with AutoExtract enabled\n\tdownloadDir := tempDir + \"/downloads\"\n\t_, err = downloader.CreateDirect(req, &base.Options{\n\t\tPath: downloadDir,\n\t\tName: \"corrupt.zip\",\n\t\tExtra: http.OptsExtra{\n\t\t\tConnections: 1,\n\t\t\tAutoExtract: util.BoolPtr(true),\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Wait for extraction to complete (with timeout)\n\tselect {\n\tcase <-extractDoneCh:\n\t\t// Extraction completed (should be error)\n\tcase <-time.After(10 * time.Second):\n\t\tt.Log(\"Extraction timed out\")\n\t}\n\n\t// Give a small buffer for final events to be processed\n\ttime.Sleep(100 * time.Millisecond)\n\n\t// Verify extraction status changes include error\n\tstatusMutex.Lock()\n\tdefer statusMutex.Unlock()\n\n\tt.Logf(\"Recorded extract status changes: %v\", extractStatusChanges)\n\n\t// Verify that we went through ExtractStatusExtracting\n\tfoundExtracting := false\n\tfor _, status := range extractStatusChanges {\n\t\tif status == ExtractStatusExtracting {\n\t\t\tfoundExtracting = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundExtracting {\n\t\tt.Error(\"Expected ExtractStatusExtracting in status changes\")\n\t}\n\n\t// Verify that we reached ExtractStatusError\n\tfoundError := false\n\tfor _, status := range extractStatusChanges {\n\t\tif status == ExtractStatusError {\n\t\t\tfoundError = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundError {\n\t\tt.Error(\"Expected ExtractStatusError in status changes\")\n\t}\n}\n\n// TestExtractStatus tests the ExtractStatus constants\nfunc TestExtractStatus(t *testing.T) {\n\ttests := []struct {\n\t\tstatus   ExtractStatus\n\t\texpected string\n\t}{\n\t\t{ExtractStatusNone, \"\"},\n\t\t{ExtractStatusQueued, \"queued\"},\n\t\t{ExtractStatusExtracting, \"extracting\"},\n\t\t{ExtractStatusDone, \"done\"},\n\t\t{ExtractStatusError, \"error\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(string(tt.status), func(t *testing.T) {\n\t\t\tif string(tt.status) != tt.expected {\n\t\t\t\tt.Errorf(\"ExtractStatus %v = %q, want %q\", tt.status, string(tt.status), tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestProgress_ExtractFields tests the ExtractStatus and ExtractProgress fields in Progress struct\nfunc TestProgress_ExtractFields(t *testing.T) {\n\tprogress := &Progress{\n\t\tExtractStatus:   ExtractStatusExtracting,\n\t\tExtractProgress: 50,\n\t}\n\n\tif progress.ExtractStatus != ExtractStatusExtracting {\n\t\tt.Errorf(\"ExtractStatus = %v, want %v\", progress.ExtractStatus, ExtractStatusExtracting)\n\t}\n\tif progress.ExtractProgress != 50 {\n\t\tt.Errorf(\"ExtractProgress = %v, want %v\", progress.ExtractProgress, 50)\n\t}\n\n\t// Test status transitions\n\tprogress.ExtractStatus = ExtractStatusDone\n\tprogress.ExtractProgress = 100\n\tif progress.ExtractStatus != ExtractStatusDone {\n\t\tt.Errorf(\"ExtractStatus after update = %v, want %v\", progress.ExtractStatus, ExtractStatusDone)\n\t}\n\tif progress.ExtractProgress != 100 {\n\t\tt.Errorf(\"ExtractProgress after update = %v, want %v\", progress.ExtractProgress, 100)\n\t}\n}\n\n// TestProgress_MultiPartFields tests the multi-part archive fields in Progress struct\nfunc TestProgress_MultiPartFields(t *testing.T) {\n\tprogress := &Progress{\n\t\tExtractStatus:     ExtractStatusWaitingParts,\n\t\tMultiPartBaseName: \"/path/to/archive.7z\",\n\t\tMultiPartNumber:   1,\n\t\tMultiPartIsFirst:  true,\n\t}\n\n\tif progress.ExtractStatus != ExtractStatusWaitingParts {\n\t\tt.Errorf(\"ExtractStatus = %v, want %v\", progress.ExtractStatus, ExtractStatusWaitingParts)\n\t}\n\tif progress.MultiPartBaseName != \"/path/to/archive.7z\" {\n\t\tt.Errorf(\"MultiPartBaseName = %v, want %v\", progress.MultiPartBaseName, \"/path/to/archive.7z\")\n\t}\n\tif progress.MultiPartNumber != 1 {\n\t\tt.Errorf(\"MultiPartNumber = %v, want %v\", progress.MultiPartNumber, 1)\n\t}\n\tif !progress.MultiPartIsFirst {\n\t\tt.Error(\"MultiPartIsFirst should be true\")\n\t}\n\n\t// Test second part\n\tprogress2 := &Progress{\n\t\tExtractStatus:     ExtractStatusWaitingParts,\n\t\tMultiPartBaseName: \"/path/to/archive.7z\",\n\t\tMultiPartNumber:   2,\n\t\tMultiPartIsFirst:  false,\n\t}\n\n\tif progress2.MultiPartNumber != 2 {\n\t\tt.Errorf(\"MultiPartNumber = %v, want %v\", progress2.MultiPartNumber, 2)\n\t}\n\tif progress2.MultiPartIsFirst {\n\t\tt.Error(\"MultiPartIsFirst should be false\")\n\t}\n}\n\n// TestExtractStatus_WaitingParts tests the new ExtractStatusWaitingParts status\nfunc TestExtractStatus_WaitingParts(t *testing.T) {\n\tif ExtractStatusWaitingParts != \"waitingParts\" {\n\t\tt.Errorf(\"ExtractStatusWaitingParts = %v, want %v\", ExtractStatusWaitingParts, \"waitingParts\")\n\t}\n}\n\n// createTestArchiveWithMultipleFiles creates a test zip file with multiple files\nfunc createTestArchiveWithMultipleFiles(path string, count int) error {\n\tfile, err := os.Create(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer file.Close()\n\n\tzipWriter := zip.NewWriter(file)\n\tdefer zipWriter.Close()\n\n\tfor i := 0; i < count; i++ {\n\t\tw, err := zipWriter.Create(\"test_\" + strconv.Itoa(i) + \".txt\")\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t_, err = w.Write([]byte(\"test content \" + strconv.Itoa(i)))\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\n// startTestArchiveServer starts a simple HTTP server that serves a zip file\nfunc startTestArchiveServer(zipPath string) net.Listener {\n\tlistener, err := net.Listen(\"tcp\", \"127.0.0.1:0\")\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\tgo func() {\n\t\tgohttp.Serve(listener, gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {\n\t\t\tfile, err := os.Open(zipPath)\n\t\t\tif err != nil {\n\t\t\t\tgohttp.Error(w, err.Error(), gohttp.StatusInternalServerError)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tdefer file.Close()\n\n\t\t\tstat, _ := file.Stat()\n\t\t\tw.Header().Set(\"Content-Type\", \"application/zip\")\n\t\t\tw.Header().Set(\"Content-Length\", strconv.FormatInt(stat.Size(), 10))\n\t\t\tio.Copy(w, file)\n\t\t}))\n\t}()\n\n\treturn listener\n}\n\n// createTestArchive creates a simple test zip file for testing\nfunc createTestArchive(path string) error {\n\tfile, err := os.Create(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer file.Close()\n\n\t// Create a simple zip archive\n\tzipWriter := zip.NewWriter(file)\n\tdefer zipWriter.Close()\n\n\t// Add a test file\n\tw, err := zipWriter.Create(\"test.txt\")\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = w.Write([]byte(\"test content\"))\n\treturn err\n}\n\nfunc TestDownloader_Close(t *testing.T) {\n\tdownloader := NewDownloader(nil)\n\tif err := downloader.Setup(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Close should not error\n\terr := downloader.Close()\n\tif err != nil {\n\t\tt.Errorf(\"Close() unexpected error: %v\", err)\n\t}\n\n\t// Calling Close again should not panic\n\terr = downloader.Close()\n\tif err != nil {\n\t\tt.Errorf(\"Close() second call unexpected error: %v\", err)\n\t}\n}\n\nfunc TestDownloader_DeleteAll(t *testing.T) {\n\tlistener := test.StartTestFileServer()\n\tdefer listener.Close()\n\n\tdownloader := NewDownloader(nil)\n\tif err := downloader.Setup(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer downloader.Clear()\n\n\t// Create multiple tasks\n\tvar wg sync.WaitGroup\n\ttaskCount := 3\n\twg.Add(taskCount)\n\tdownloader.Listener(func(event *Event) {\n\t\tif event.Key == EventKeyDone {\n\t\t\twg.Done()\n\t\t}\n\t})\n\n\tfor i := 0; i < taskCount; i++ {\n\t\treq := &base.Request{\n\t\t\tURL: \"http://\" + listener.Addr().String() + \"/\" + test.BuildName,\n\t\t}\n\t\t_, err := downloader.CreateDirect(req, &base.Options{\n\t\t\tPath: test.Dir,\n\t\t\tName: test.DownloadName,\n\t\t\tExtra: http.OptsExtra{\n\t\t\t\tConnections: 4,\n\t\t\t},\n\t\t})\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\n\twg.Wait()\n\n\t// Verify tasks were created\n\tif len(downloader.GetTasks()) != taskCount {\n\t\tt.Errorf(\"Expected %d tasks, got %d\", taskCount, len(downloader.GetTasks()))\n\t}\n\n\t// Delete all tasks with nil filter\n\terr := downloader.Delete(nil, true)\n\tif err != nil {\n\t\tt.Errorf(\"Delete(nil) unexpected error: %v\", err)\n\t}\n\n\t// Verify all tasks are deleted\n\tif len(downloader.GetTasks()) != 0 {\n\t\tt.Errorf(\"All tasks should be deleted, got %d remaining\", len(downloader.GetTasks()))\n\t}\n}\n\nfunc TestDownloader_ProtocolConfigNotExist(t *testing.T) {\n\tdownloader := NewDownloader(nil)\n\tif err := downloader.Setup(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer downloader.Clear()\n\n\t// Test getting a protocol config that doesn't exist\n\tvar unknownCfg map[string]any\n\texists := downloader.getProtocolConfig(\"unknown-protocol\", &unknownCfg)\n\tif exists {\n\t\tt.Errorf(\"getProtocolConfig() for unknown protocol should return false\")\n\t}\n}\n\nfunc TestTaskFilter_IsEmpty(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tfilter   *TaskFilter\n\t\texpected bool\n\t}{\n\t\t{\n\t\t\tname:     \"nil IDs, Statuses, NotStatuses\",\n\t\t\tfilter:   &TaskFilter{},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"empty IDs only\",\n\t\t\tfilter:   &TaskFilter{IDs: []string{}},\n\t\t\texpected: true,\n\t\t},\n\t\t{\n\t\t\tname:     \"non-empty IDs\",\n\t\t\tfilter:   &TaskFilter{IDs: []string{\"id1\"}},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"non-empty Statuses\",\n\t\t\tfilter:   &TaskFilter{Statuses: []base.Status{base.DownloadStatusDone}},\n\t\t\texpected: false,\n\t\t},\n\t\t{\n\t\t\tname:     \"non-empty NotStatuses\",\n\t\t\tfilter:   &TaskFilter{NotStatuses: []base.Status{base.DownloadStatusError}},\n\t\t\texpected: false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := tt.filter.IsEmpty()\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"TaskFilter.IsEmpty() = %v, want %v\", result, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// Tests for multi-part archive collection functions\nfunc TestDownloader_CollectSequentialFiles(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"collect_sequential_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create test files for 7z multi-part pattern (archive.7z.001, .002, .003)\n\tfor i := 1; i <= 3; i++ {\n\t\tpath := filepath.Join(tempDir, fmt.Sprintf(\"archive.7z.%03d\", i))\n\t\tif err := os.WriteFile(path, []byte(\"test\"), 0644); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\n\tdownloader := NewDownloader(nil)\n\tfiles := downloader.collectSequentialFiles(tempDir, \"archive.7z\", \".%03d\")\n\n\tif len(files) != 3 {\n\t\tt.Errorf(\"collectSequentialFiles() = %d files, want 3\", len(files))\n\t}\n\n\t// Verify files are in order\n\tfor i, file := range files {\n\t\texpected := filepath.Join(tempDir, fmt.Sprintf(\"archive.7z.%03d\", i+1))\n\t\tif file != expected {\n\t\t\tt.Errorf(\"files[%d] = %q, want %q\", i, file, expected)\n\t\t}\n\t}\n}\n\nfunc TestDownloader_CollectSequentialFiles_NoFiles(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"collect_sequential_empty_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\tdownloader := NewDownloader(nil)\n\tfiles := downloader.collectSequentialFiles(tempDir, \"archive.7z\", \".%03d\")\n\n\tif len(files) != 0 {\n\t\tt.Errorf(\"collectSequentialFiles() = %d files, want 0\", len(files))\n\t}\n}\n\nfunc TestDownloader_CollectRarNewStyleFiles(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"collect_rar_new_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create test files with double-digit format\n\tfor i := 1; i <= 3; i++ {\n\t\tpath := filepath.Join(tempDir, fmt.Sprintf(\"archive.part%02d.rar\", i))\n\t\tif err := os.WriteFile(path, []byte(\"test\"), 0644); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\n\tdownloader := NewDownloader(nil)\n\tfiles := downloader.collectRarNewStyleFiles(tempDir, \"archive\")\n\n\tif len(files) != 3 {\n\t\tt.Errorf(\"collectRarNewStyleFiles() = %d files, want 3\", len(files))\n\t}\n}\n\nfunc TestDownloader_CollectRarNewStyleFiles_SingleDigit(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"collect_rar_single_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create test files with single-digit format\n\tfor i := 1; i <= 2; i++ {\n\t\tpath := filepath.Join(tempDir, fmt.Sprintf(\"archive.part%d.rar\", i))\n\t\tif err := os.WriteFile(path, []byte(\"test\"), 0644); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\n\tdownloader := NewDownloader(nil)\n\tfiles := downloader.collectRarNewStyleFiles(tempDir, \"archive\")\n\n\tif len(files) != 2 {\n\t\tt.Errorf(\"collectRarNewStyleFiles() = %d files, want 2\", len(files))\n\t}\n}\n\nfunc TestDownloader_CollectRarOldStyleFiles(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"collect_rar_old_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create .rar file\n\trarPath := filepath.Join(tempDir, \"archive.rar\")\n\tif err := os.WriteFile(rarPath, []byte(\"test\"), 0644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Create .r00, .r01, .r02 files\n\tfor i := 0; i <= 2; i++ {\n\t\tpath := filepath.Join(tempDir, fmt.Sprintf(\"archive.r%02d\", i))\n\t\tif err := os.WriteFile(path, []byte(\"test\"), 0644); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\n\tdownloader := NewDownloader(nil)\n\tfiles := downloader.collectRarOldStyleFiles(tempDir, \"archive\")\n\n\t// Should have 4 files: .rar + .r00 + .r01 + .r02\n\tif len(files) != 4 {\n\t\tt.Errorf(\"collectRarOldStyleFiles() = %d files, want 4\", len(files))\n\t}\n\n\t// First file should be .rar\n\tif files[0] != rarPath {\n\t\tt.Errorf(\"First file should be .rar, got %q\", files[0])\n\t}\n}\n\nfunc TestDownloader_CollectZipSplitFiles(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"collect_zip_split_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create .z01, .z02 files\n\tfor i := 1; i <= 2; i++ {\n\t\tpath := filepath.Join(tempDir, fmt.Sprintf(\"archive.z%02d\", i))\n\t\tif err := os.WriteFile(path, []byte(\"test\"), 0644); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\n\t// Create .zip file\n\tzipPath := filepath.Join(tempDir, \"archive.zip\")\n\tif err := os.WriteFile(zipPath, []byte(\"test\"), 0644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tdownloader := NewDownloader(nil)\n\tfiles := downloader.collectZipSplitFiles(tempDir, \"archive\")\n\n\t// Should have 3 files: .z01 + .z02 + .zip\n\tif len(files) != 3 {\n\t\tt.Errorf(\"collectZipSplitFiles() = %d files, want 3\", len(files))\n\t}\n\n\t// Last file should be .zip\n\tif files[len(files)-1] != zipPath {\n\t\tt.Errorf(\"Last file should be .zip, got %q\", files[len(files)-1])\n\t}\n}\n\nfunc TestDownloader_CollectMultiPartFiles(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"collect_multipart_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Test with 7z pattern\n\tt.Run(\"7z pattern\", func(t *testing.T) {\n\t\tsubDir := filepath.Join(tempDir, \"7z\")\n\t\tif err := os.MkdirAll(subDir, 0755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\tfor i := 1; i <= 2; i++ {\n\t\t\tpath := filepath.Join(subDir, fmt.Sprintf(\"archive.7z.%03d\", i))\n\t\t\tif err := os.WriteFile(path, []byte(\"test\"), 0644); err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\t\t}\n\n\t\tdownloader := NewDownloader(nil)\n\t\tfirstPart := filepath.Join(subDir, \"archive.7z.001\")\n\t\tfiles := downloader.collectMultiPartFiles(firstPart)\n\n\t\tif len(files) != 2 {\n\t\t\tt.Errorf(\"collectMultiPartFiles(7z) = %d files, want 2\", len(files))\n\t\t}\n\t})\n\n\t// Test with RAR new style pattern\n\tt.Run(\"RAR new style pattern\", func(t *testing.T) {\n\t\tsubDir := filepath.Join(tempDir, \"rar_new\")\n\t\tif err := os.MkdirAll(subDir, 0755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\tfor i := 1; i <= 2; i++ {\n\t\t\tpath := filepath.Join(subDir, fmt.Sprintf(\"archive.part%02d.rar\", i))\n\t\t\tif err := os.WriteFile(path, []byte(\"test\"), 0644); err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\t\t}\n\n\t\tdownloader := NewDownloader(nil)\n\t\tfirstPart := filepath.Join(subDir, \"archive.part01.rar\")\n\t\tfiles := downloader.collectMultiPartFiles(firstPart)\n\n\t\tif len(files) != 2 {\n\t\t\tt.Errorf(\"collectMultiPartFiles(RAR new) = %d files, want 2\", len(files))\n\t\t}\n\t})\n\n\t// Test with ZIP multi-part pattern\n\tt.Run(\"ZIP multi-part pattern\", func(t *testing.T) {\n\t\tsubDir := filepath.Join(tempDir, \"zip\")\n\t\tif err := os.MkdirAll(subDir, 0755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\tfor i := 1; i <= 3; i++ {\n\t\t\tpath := filepath.Join(subDir, fmt.Sprintf(\"archive.zip.%03d\", i))\n\t\t\tif err := os.WriteFile(path, []byte(\"test\"), 0644); err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\t\t}\n\n\t\tdownloader := NewDownloader(nil)\n\t\tfirstPart := filepath.Join(subDir, \"archive.zip.001\")\n\t\tfiles := downloader.collectMultiPartFiles(firstPart)\n\n\t\tif len(files) != 3 {\n\t\t\tt.Errorf(\"collectMultiPartFiles(ZIP) = %d files, want 3\", len(files))\n\t\t}\n\t})\n}\n\nfunc TestDownloader_CheckAllMultiPartTasksDone(t *testing.T) {\n\tdownloader := NewDownloader(nil)\n\tif err := downloader.Setup(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer downloader.Clear()\n\n\ttempDir, err := os.MkdirTemp(\"\", \"check_multipart_done_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create tasks with multi-part base name\n\tbaseName := \"archive.7z\"\n\n\t// Create task 1 - done\n\ttask1 := &Task{\n\t\tID:     \"task1\",\n\t\tStatus: base.DownloadStatusDone,\n\t\tMeta: &fetcher.FetcherMeta{\n\t\t\tOpts: &base.Options{Path: tempDir},\n\t\t\tRes: &base.Resource{\n\t\t\t\tFiles: []*base.FileInfo{{Name: baseName + \".001\"}},\n\t\t\t},\n\t\t},\n\t\tProgress: &Progress{},\n\t}\n\tinitTask(task1)\n\n\t// Create task 2 - done\n\ttask2 := &Task{\n\t\tID:     \"task2\",\n\t\tStatus: base.DownloadStatusDone,\n\t\tMeta: &fetcher.FetcherMeta{\n\t\t\tOpts: &base.Options{Path: tempDir},\n\t\t\tRes: &base.Resource{\n\t\t\t\tFiles: []*base.FileInfo{{Name: baseName + \".002\"}},\n\t\t\t},\n\t\t},\n\t\tProgress: &Progress{},\n\t}\n\tinitTask(task2)\n\n\tdownloader.tasks = []*Task{task1, task2}\n\n\t// All tasks are done\n\tbasePath := filepath.Join(tempDir, baseName)\n\tallDone, missing := downloader.checkAllMultiPartTasksDone(basePath)\n\tif !allDone {\n\t\tt.Errorf(\"checkAllMultiPartTasksDone() = false, want true; missing: %v\", missing)\n\t}\n\n\t// Set task2 to running\n\ttask2.Status = base.DownloadStatusRunning\n\tallDone, missing = downloader.checkAllMultiPartTasksDone(basePath)\n\tif allDone {\n\t\tt.Error(\"checkAllMultiPartTasksDone() = true, want false\")\n\t}\n\tif len(missing) == 0 {\n\t\tt.Error(\"Expected missing parts to be reported\")\n\t}\n}\n\nfunc TestDownloader_CheckAllMultiPartTasksDone_NoRelatedTasks(t *testing.T) {\n\tdownloader := NewDownloader(nil)\n\tif err := downloader.Setup(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer downloader.Clear()\n\n\t// No tasks exist\n\tallDone, missing := downloader.checkAllMultiPartTasksDone(\"/some/path/archive.7z\")\n\tif allDone {\n\t\tt.Error(\"checkAllMultiPartTasksDone() = true, want false with no related tasks\")\n\t}\n\tif len(missing) == 0 {\n\t\tt.Error(\"Expected missing message\")\n\t}\n}\n\nfunc TestDownloader_TryClaimMultiPartExtraction(t *testing.T) {\n\tdownloader := NewDownloader(nil)\n\tif err := downloader.Setup(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer downloader.Clear()\n\n\ttempDir, err := os.MkdirTemp(\"\", \"extraction_progress_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\tbaseName := \"archive.7z\"\n\t// GetMultiPartArchiveBaseName returns filepath.Join(dir, baseName)\n\tfullBaseName := filepath.Join(tempDir, baseName)\n\n\t// Create tasks\n\ttask1 := &Task{\n\t\tID:     \"task1\",\n\t\tStatus: base.DownloadStatusDone,\n\t\tMeta: &fetcher.FetcherMeta{\n\t\t\tOpts: &base.Options{Path: tempDir},\n\t\t\tRes: &base.Resource{\n\t\t\t\tFiles: []*base.FileInfo{{Name: baseName + \".001\", Path: \"\"}},\n\t\t\t},\n\t\t},\n\t\tProgress: &Progress{ExtractStatus: ExtractStatusNone},\n\t}\n\tinitTask(task1)\n\n\ttask2 := &Task{\n\t\tID:     \"task2\",\n\t\tStatus: base.DownloadStatusDone,\n\t\tMeta: &fetcher.FetcherMeta{\n\t\t\tOpts: &base.Options{Path: tempDir},\n\t\t\tRes: &base.Resource{\n\t\t\t\tFiles: []*base.FileInfo{{Name: baseName + \".002\", Path: \"\"}},\n\t\t\t},\n\t\t},\n\t\tProgress: &Progress{ExtractStatus: ExtractStatusNone},\n\t}\n\tinitTask(task2)\n\n\tdownloader.tasks = []*Task{task1, task2}\n\n\t// Task1 should be able to claim extraction (no one has claimed yet)\n\tif !downloader.tryClaimMultiPartExtraction(task1, fullBaseName) {\n\t\tt.Error(\"tryClaimMultiPartExtraction() = false, want true (first claim)\")\n\t}\n\t// task1's status should now be Queued\n\tif task1.Progress.ExtractStatus != ExtractStatusQueued {\n\t\tt.Errorf(\"task1.ExtractStatus = %v, want %v\", task1.Progress.ExtractStatus, ExtractStatusQueued)\n\t}\n\n\t// Task2 should NOT be able to claim (task1 already claimed via sync.Map)\n\tif downloader.tryClaimMultiPartExtraction(task2, fullBaseName) {\n\t\tt.Error(\"tryClaimMultiPartExtraction() = true, want false (already claimed)\")\n\t}\n\n\t// Release the claim\n\tdownloader.releaseMultiPartExtractionClaim(fullBaseName)\n\n\t// Now task2 CAN claim (claim was released)\n\tif !downloader.tryClaimMultiPartExtraction(task2, fullBaseName) {\n\t\tt.Error(\"tryClaimMultiPartExtraction() = false, want true (claim was released)\")\n\t}\n}\n\nfunc TestDownloader_HandleExtractionResult_Success(t *testing.T) {\n\tdownloader := NewDownloader(nil)\n\tif err := downloader.Setup(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer downloader.Clear()\n\n\ttempDir, err := os.MkdirTemp(\"\", \"extraction_result_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create a test archive file\n\tarchivePath := filepath.Join(tempDir, \"test.zip\")\n\tif err := os.WriteFile(archivePath, []byte(\"test\"), 0644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\ttask := &Task{\n\t\tID: \"test-task\",\n\t\tMeta: &fetcher.FetcherMeta{\n\t\t\tOpts: &base.Options{Path: tempDir},\n\t\t\tRes: &base.Resource{\n\t\t\t\tFiles: []*base.FileInfo{{Name: \"test.zip\"}},\n\t\t\t},\n\t\t},\n\t\tProgress: &Progress{},\n\t}\n\tinitTask(task)\n\n\t// Test successful extraction\n\tdownloader.handleExtractionResult(task, nil, []string{archivePath}, false)\n\n\tif task.Progress.ExtractStatus != ExtractStatusDone {\n\t\tt.Errorf(\"ExtractStatus = %v, want %v\", task.Progress.ExtractStatus, ExtractStatusDone)\n\t}\n\tif task.Progress.ExtractProgress != 100 {\n\t\tt.Errorf(\"ExtractProgress = %d, want 100\", task.Progress.ExtractProgress)\n\t}\n\n\t// Archive should still exist (deleteAfterExtract=false)\n\tif _, err := os.Stat(archivePath); os.IsNotExist(err) {\n\t\tt.Error(\"Archive should still exist when deleteAfterExtract=false\")\n\t}\n}\n\nfunc TestDownloader_HandleExtractionResult_WithDelete(t *testing.T) {\n\tdownloader := NewDownloader(nil)\n\tif err := downloader.Setup(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer downloader.Clear()\n\n\ttempDir, err := os.MkdirTemp(\"\", \"extraction_delete_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create test archive files\n\tarchivePath1 := filepath.Join(tempDir, \"test.7z.001\")\n\tarchivePath2 := filepath.Join(tempDir, \"test.7z.002\")\n\tif err := os.WriteFile(archivePath1, []byte(\"test\"), 0644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(archivePath2, []byte(\"test\"), 0644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\ttask := &Task{\n\t\tID: \"test-task\",\n\t\tMeta: &fetcher.FetcherMeta{\n\t\t\tOpts: &base.Options{Path: tempDir},\n\t\t\tRes: &base.Resource{\n\t\t\t\tFiles: []*base.FileInfo{{Name: \"test.7z.001\"}},\n\t\t\t},\n\t\t},\n\t\tProgress: &Progress{},\n\t}\n\tinitTask(task)\n\n\t// Test successful extraction with delete\n\tdownloader.handleExtractionResult(task, nil, []string{archivePath1, archivePath2}, true)\n\n\tif task.Progress.ExtractStatus != ExtractStatusDone {\n\t\tt.Errorf(\"ExtractStatus = %v, want %v\", task.Progress.ExtractStatus, ExtractStatusDone)\n\t}\n\n\t// Archives should be deleted\n\tif _, err := os.Stat(archivePath1); !os.IsNotExist(err) {\n\t\tt.Error(\"Archive 1 should be deleted when deleteAfterExtract=true\")\n\t}\n\tif _, err := os.Stat(archivePath2); !os.IsNotExist(err) {\n\t\tt.Error(\"Archive 2 should be deleted when deleteAfterExtract=true\")\n\t}\n}\n\nfunc TestDownloader_HandleExtractionResult_Error(t *testing.T) {\n\tdownloader := NewDownloader(nil)\n\tif err := downloader.Setup(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer downloader.Clear()\n\n\ttempDir, err := os.MkdirTemp(\"\", \"extraction_error_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\ttask := &Task{\n\t\tID: \"test-task\",\n\t\tMeta: &fetcher.FetcherMeta{\n\t\t\tOpts: &base.Options{Path: tempDir},\n\t\t\tRes: &base.Resource{\n\t\t\t\tFiles: []*base.FileInfo{{Name: \"test.zip\"}},\n\t\t\t},\n\t\t},\n\t\tProgress: &Progress{},\n\t}\n\tinitTask(task)\n\n\t// Test failed extraction\n\textractErr := fmt.Errorf(\"extraction failed\")\n\tdownloader.handleExtractionResult(task, extractErr, nil, false)\n\n\tif task.Progress.ExtractStatus != ExtractStatusError {\n\t\tt.Errorf(\"ExtractStatus = %v, want %v\", task.Progress.ExtractStatus, ExtractStatusError)\n\t}\n}\n\nfunc TestDownloader_UpdateMultiPartTasksStatus(t *testing.T) {\n\tdownloader := NewDownloader(nil)\n\tif err := downloader.Setup(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer downloader.Clear()\n\n\ttempDir, err := os.MkdirTemp(\"\", \"update_status_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create source task with multi-part base name\n\tsourceTask := &Task{\n\t\tID: \"source\",\n\t\tMeta: &fetcher.FetcherMeta{\n\t\t\tOpts: &base.Options{Path: tempDir},\n\t\t\tRes: &base.Resource{\n\t\t\t\tFiles: []*base.FileInfo{{Name: \"archive.7z.001\"}},\n\t\t\t},\n\t\t},\n\t\tProgress: &Progress{MultiPartBaseName: \"archive.7z\"},\n\t}\n\tinitTask(sourceTask)\n\n\t// Create related task\n\trelatedTask := &Task{\n\t\tID: \"related\",\n\t\tMeta: &fetcher.FetcherMeta{\n\t\t\tOpts: &base.Options{Path: tempDir},\n\t\t\tRes: &base.Resource{\n\t\t\t\tFiles: []*base.FileInfo{{Name: \"archive.7z.002\"}},\n\t\t\t},\n\t\t},\n\t\tProgress: &Progress{MultiPartBaseName: \"archive.7z\"},\n\t}\n\tinitTask(relatedTask)\n\n\t// Create unrelated task\n\tunrelatedTask := &Task{\n\t\tID: \"unrelated\",\n\t\tMeta: &fetcher.FetcherMeta{\n\t\t\tOpts: &base.Options{Path: tempDir},\n\t\t\tRes: &base.Resource{\n\t\t\t\tFiles: []*base.FileInfo{{Name: \"other.7z.001\"}},\n\t\t\t},\n\t\t},\n\t\tProgress: &Progress{MultiPartBaseName: \"other.7z\"},\n\t}\n\tinitTask(unrelatedTask)\n\n\tdownloader.tasks = []*Task{sourceTask, relatedTask, unrelatedTask}\n\n\t// Test successful extraction\n\tdownloader.updateMultiPartTasksStatus(sourceTask, nil)\n\n\tif relatedTask.Progress.ExtractStatus != ExtractStatusDone {\n\t\tt.Errorf(\"Related task ExtractStatus = %v, want %v\", relatedTask.Progress.ExtractStatus, ExtractStatusDone)\n\t}\n\tif relatedTask.Progress.ExtractProgress != 100 {\n\t\tt.Errorf(\"Related task ExtractProgress = %d, want 100\", relatedTask.Progress.ExtractProgress)\n\t}\n\tif unrelatedTask.Progress.ExtractStatus == ExtractStatusDone {\n\t\tt.Error(\"Unrelated task should not be updated\")\n\t}\n}\n\nfunc TestDownloader_UpdateMultiPartTasksStatus_WithError(t *testing.T) {\n\tdownloader := NewDownloader(nil)\n\tif err := downloader.Setup(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer downloader.Clear()\n\n\ttempDir, err := os.MkdirTemp(\"\", \"update_error_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create source task with multi-part base name\n\tsourceTask := &Task{\n\t\tID: \"source\",\n\t\tMeta: &fetcher.FetcherMeta{\n\t\t\tOpts: &base.Options{Path: tempDir},\n\t\t\tRes: &base.Resource{\n\t\t\t\tFiles: []*base.FileInfo{{Name: \"archive.7z.001\"}},\n\t\t\t},\n\t\t},\n\t\tProgress: &Progress{MultiPartBaseName: \"archive.7z\"},\n\t}\n\tinitTask(sourceTask)\n\n\t// Create related task\n\trelatedTask := &Task{\n\t\tID: \"related\",\n\t\tMeta: &fetcher.FetcherMeta{\n\t\t\tOpts: &base.Options{Path: tempDir},\n\t\t\tRes: &base.Resource{\n\t\t\t\tFiles: []*base.FileInfo{{Name: \"archive.7z.002\"}},\n\t\t\t},\n\t\t},\n\t\tProgress: &Progress{MultiPartBaseName: \"archive.7z\"},\n\t}\n\tinitTask(relatedTask)\n\n\tdownloader.tasks = []*Task{sourceTask, relatedTask}\n\n\t// Test failed extraction\n\tdownloader.updateMultiPartTasksStatus(sourceTask, fmt.Errorf(\"extraction failed\"))\n\n\tif relatedTask.Progress.ExtractStatus != ExtractStatusError {\n\t\tt.Errorf(\"Related task ExtractStatus = %v, want %v\", relatedTask.Progress.ExtractStatus, ExtractStatusError)\n\t}\n\tif relatedTask.Progress.ExtractProgress != 0 {\n\t\tt.Errorf(\"Related task ExtractProgress = %d, want 0\", relatedTask.Progress.ExtractProgress)\n\t}\n}\n\nfunc TestDownloader_UpdateMultiPartTasksStatus_NoBaseName(t *testing.T) {\n\tdownloader := NewDownloader(nil)\n\tif err := downloader.Setup(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer downloader.Clear()\n\n\ttempDir, err := os.MkdirTemp(\"\", \"update_no_base_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create task without multi-part base name\n\ttask := &Task{\n\t\tID: \"single\",\n\t\tMeta: &fetcher.FetcherMeta{\n\t\t\tOpts: &base.Options{Path: tempDir},\n\t\t\tRes: &base.Resource{\n\t\t\t\tFiles: []*base.FileInfo{{Name: \"single.zip\"}},\n\t\t\t},\n\t\t},\n\t\tProgress: &Progress{MultiPartBaseName: \"\"},\n\t}\n\tinitTask(task)\n\n\tdownloader.tasks = []*Task{task}\n\n\t// Should not panic or error\n\tdownloader.updateMultiPartTasksStatus(task, nil)\n}\n\nfunc TestDownloader_CheckMultiPartArchiveReady(t *testing.T) {\n\tdownloader := NewDownloader(nil)\n\tif err := downloader.Setup(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer downloader.Clear()\n\n\ttempDir, err := os.MkdirTemp(\"\", \"check_ready_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\tbaseName := \"archive.7z\"\n\tfileName := baseName + \".001\"\n\tfilePath := filepath.Join(tempDir, fileName)\n\n\t// Create tasks\n\ttask := &Task{\n\t\tID:     \"task1\",\n\t\tStatus: base.DownloadStatusDone,\n\t\tMeta: &fetcher.FetcherMeta{\n\t\t\tOpts: &base.Options{Path: tempDir},\n\t\t\tRes: &base.Resource{\n\t\t\t\tFiles: []*base.FileInfo{{Name: fileName}},\n\t\t\t},\n\t\t},\n\t\tProgress: &Progress{},\n\t}\n\tinitTask(task)\n\tdownloader.tasks = []*Task{task}\n\n\tpartInfo := getArchivePartInfo(filePath)\n\tready, missing := downloader.checkMultiPartArchiveReady(filePath, tempDir, partInfo)\n\n\tif !ready {\n\t\tt.Errorf(\"checkMultiPartArchiveReady() = false, want true; missing: %v\", missing)\n\t}\n}\n\nfunc TestDownloader_CheckMultiPartArchiveReady_EmptyBaseName(t *testing.T) {\n\tdownloader := NewDownloader(nil)\n\tif err := downloader.Setup(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer downloader.Clear()\n\n\t// Use a non-multi-part file path\n\tpartInfo := getArchivePartInfo(\"/some/regular.zip\")\n\tready, _ := downloader.checkMultiPartArchiveReady(\"/some/regular.zip\", \"/dest\", partInfo)\n\n\t// Should return true for non-multi-part files\n\tif !ready {\n\t\tt.Error(\"checkMultiPartArchiveReady() should return true for non-multi-part files\")\n\t}\n}\n\n// startTestTorrentServer starts a simple HTTP server that serves a torrent file\nfunc startTestTorrentServer(torrentPath string) net.Listener {\n\tlistener, err := net.Listen(\"tcp\", \"127.0.0.1:0\")\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\tserver := &gohttp.Server{\n\t\tHandler: gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {\n\t\t\tdata, err := os.ReadFile(torrentPath)\n\t\t\tif err != nil {\n\t\t\t\tw.WriteHeader(gohttp.StatusInternalServerError)\n\t\t\t\treturn\n\t\t\t}\n\t\t\tw.Header().Set(\"Content-Type\", \"application/x-bittorrent\")\n\t\t\tw.Header().Set(\"Content-Disposition\", \"attachment; filename=ubuntu.torrent\")\n\t\t\tw.Write(data)\n\t\t}),\n\t}\n\tgo server.Serve(listener)\n\treturn listener\n}\n\n// TestDownloader_AutoTorrent tests the auto-torrent functionality\n// When a .torrent file is downloaded with AutoTorrent enabled, it should automatically create a BT task\nfunc TestDownloader_AutoTorrent(t *testing.T) {\n\t// Path to the test torrent file\n\ttorrentPath := \"../../internal/protocol/bt/testdata/ubuntu-22.04-live-server-amd64.iso.torrent\"\n\tif _, err := os.Stat(torrentPath); os.IsNotExist(err) {\n\t\tt.Skip(\"Test torrent file not found, skipping test\")\n\t}\n\n\t// Start a simple HTTP server to serve the torrent file\n\tserver := startTestTorrentServer(torrentPath)\n\tdefer server.Close()\n\n\t// Create a temporary directory for the test\n\ttempDir, err := os.MkdirTemp(\"\", \"auto_torrent_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create downloader\n\tdownloader := NewDownloader(nil)\n\tif err := downloader.Setup(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer func() {\n\t\t// Delete all tasks before clearing to avoid panic from BT tasks trying to access deleted resources\n\t\tdownloader.Delete(nil, true)\n\t\tdownloader.Clear()\n\t}()\n\n\t// Track created tasks\n\tbtTaskCreated := make(chan struct{}, 1)\n\tvar originalTaskId string\n\n\tdownloader.Listener(func(event *Event) {\n\t\tif event.Key == EventKeyStart {\n\t\t\t// A new task started - if it's not the original, it's the BT task\n\t\t\tif event.Task != nil && event.Task.ID != originalTaskId && originalTaskId != \"\" {\n\t\t\t\tselect {\n\t\t\t\tcase btTaskCreated <- struct{}{}:\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t})\n\n\t// Create request to download the torrent file\n\treq := &base.Request{\n\t\tURL: \"http://\" + server.Addr().String() + \"/ubuntu.torrent\",\n\t}\n\n\t// Create task with AutoTorrent enabled\n\tdownloadDir := tempDir + \"/downloads\"\n\toriginalTaskId, err = downloader.CreateDirect(req, &base.Options{\n\t\tPath: downloadDir,\n\t\tName: \"ubuntu.torrent\",\n\t\tExtra: http.OptsExtra{\n\t\t\tConnections: 1,\n\t\t\tAutoTorrent: util.BoolPtr(true),\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tt.Logf(\"Original task ID: %s\", originalTaskId)\n\n\t// Wait for BT task to be created (with timeout)\n\tselect {\n\tcase <-btTaskCreated:\n\t\tt.Log(\"BT task created\")\n\tcase <-time.After(10 * time.Second):\n\t\tt.Log(\"Timeout waiting for BT task creation\")\n\t}\n\n\t// Give a small buffer for task creation to complete\n\ttime.Sleep(200 * time.Millisecond)\n\n\t// Verify that a BT task was created\n\ttasks := downloader.GetTasks()\n\n\t// At minimum, we should have 2 tasks: the original torrent download and the BT task\n\tif len(tasks) < 2 {\n\t\tt.Errorf(\"Expected at least 2 tasks (torrent download + BT task), got %d\", len(tasks))\n\t} else {\n\t\tt.Logf(\"Successfully created %d tasks\", len(tasks))\n\t}\n}\n\n// TestDownloader_AutoTorrentWithDelete tests the auto-torrent with DeleteTorrentAfterDownload option\nfunc TestDownloader_AutoTorrentWithDelete(t *testing.T) {\n\t// Path to the test torrent file\n\ttorrentPath := \"../../internal/protocol/bt/testdata/ubuntu-22.04-live-server-amd64.iso.torrent\"\n\tif _, err := os.Stat(torrentPath); os.IsNotExist(err) {\n\t\tt.Skip(\"Test torrent file not found, skipping test\")\n\t}\n\n\t// Start a simple HTTP server to serve the torrent file\n\tserver := startTestTorrentServer(torrentPath)\n\tdefer server.Close()\n\n\t// Create a temporary directory for the test\n\ttempDir, err := os.MkdirTemp(\"\", \"auto_torrent_delete_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create downloader\n\tdownloader := NewDownloader(nil)\n\tif err := downloader.Setup(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer func() {\n\t\t// Delete all tasks before clearing to avoid panic from BT tasks trying to access deleted resources\n\t\tdownloader.Delete(nil, true)\n\t\tdownloader.Clear()\n\t}()\n\n\t// Track task events\n\tvar originalTaskId string\n\toriginalTaskDeleted := make(chan struct{}, 1)\n\tbtTaskCreated := make(chan struct{}, 1)\n\n\tdownloader.Listener(func(event *Event) {\n\t\tif event.Key == EventKeyStart {\n\t\t\t// BT task was created and started\n\t\t\tif event.Task != nil && event.Task.ID != originalTaskId && originalTaskId != \"\" {\n\t\t\t\tselect {\n\t\t\t\tcase btTaskCreated <- struct{}{}:\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif event.Key == EventKeyDelete {\n\t\t\t// Check if the deleted task is the original torrent task\n\t\t\tif event.Task != nil && event.Task.ID == originalTaskId {\n\t\t\t\tselect {\n\t\t\t\tcase originalTaskDeleted <- struct{}{}:\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t})\n\n\t// Create request to download the torrent file\n\treq := &base.Request{\n\t\tURL: \"http://\" + server.Addr().String() + \"/ubuntu.torrent\",\n\t}\n\n\t// Create task with AutoTorrent and DeleteTorrentAfterDownload enabled\n\tdownloadDir := tempDir + \"/downloads\"\n\toriginalTaskId, err = downloader.CreateDirect(req, &base.Options{\n\t\tPath: downloadDir,\n\t\tName: \"ubuntu.torrent\",\n\t\tExtra: http.OptsExtra{\n\t\t\tConnections:                1,\n\t\t\tAutoTorrent:                util.BoolPtr(true),\n\t\t\tDeleteTorrentAfterDownload: util.BoolPtr(true),\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tt.Logf(\"Original task ID: %s\", originalTaskId)\n\n\t// Wait for original task to be deleted (this happens after BT task is created)\n\tselect {\n\tcase <-originalTaskDeleted:\n\t\tt.Log(\"Original torrent task was deleted as expected\")\n\tcase <-time.After(10 * time.Second):\n\t\t// Check manually if the task still exists\n\t\toriginalTask := downloader.GetTask(originalTaskId)\n\t\tif originalTask != nil {\n\t\t\tt.Error(\"Original torrent task should have been deleted but still exists\")\n\t\t} else {\n\t\t\tt.Log(\"Original torrent task was deleted (detected via GetTask)\")\n\t\t}\n\t}\n\n\t// Give a moment for BT task creation\n\ttime.Sleep(200 * time.Millisecond)\n\n\t// Verify that original task is deleted and BT task exists\n\toriginalTask := downloader.GetTask(originalTaskId)\n\tif originalTask != nil {\n\t\tt.Error(\"Original torrent task should have been deleted\")\n\t}\n\n\t// Verify remaining tasks (should have at least the BT task)\n\ttasks := downloader.GetTasks()\n\tt.Logf(\"Remaining tasks: %d\", len(tasks))\n\n\t// At least one task should remain (the BT task)\n\tif len(tasks) == 0 {\n\t\tt.Error(\"Expected at least one task (BT task) to remain\")\n\t}\n\n\t// None of the remaining tasks should be the original torrent task\n\tfor _, task := range tasks {\n\t\tif task.ID == originalTaskId {\n\t\t\tt.Error(\"Original torrent task should have been deleted\")\n\t\t}\n\t}\n}\n\n// TestDownloader_AutoTorrentDisabled tests that auto-torrent does not create BT task when disabled\nfunc TestDownloader_AutoTorrentDisabled(t *testing.T) {\n\t// Path to the test torrent file\n\ttorrentPath := \"../../internal/protocol/bt/testdata/ubuntu-22.04-live-server-amd64.iso.torrent\"\n\tif _, err := os.Stat(torrentPath); os.IsNotExist(err) {\n\t\tt.Skip(\"Test torrent file not found, skipping test\")\n\t}\n\n\t// Start a simple HTTP server to serve the torrent file\n\tserver := startTestTorrentServer(torrentPath)\n\tdefer server.Close()\n\n\t// Create a temporary directory for the test\n\ttempDir, err := os.MkdirTemp(\"\", \"auto_torrent_disabled_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create downloader\n\tdownloader := NewDownloader(nil)\n\tif err := downloader.Setup(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer func() {\n\t\tdownloader.Delete(nil, true)\n\t\tdownloader.Clear()\n\t}()\n\n\t// Track task completion\n\ttaskDone := make(chan struct{}, 1)\n\n\tdownloader.Listener(func(event *Event) {\n\t\tif event.Key == EventKeyDone {\n\t\t\tselect {\n\t\t\tcase taskDone <- struct{}{}:\n\t\t\tdefault:\n\t\t\t}\n\t\t}\n\t})\n\n\t// Create request to download the torrent file\n\treq := &base.Request{\n\t\tURL: \"http://\" + server.Addr().String() + \"/ubuntu.torrent\",\n\t}\n\n\t// Create task with AutoTorrent explicitly disabled\n\tdownloadDir := tempDir + \"/downloads\"\n\t_, err = downloader.CreateDirect(req, &base.Options{\n\t\tPath: downloadDir,\n\t\tName: \"ubuntu.torrent\",\n\t\tExtra: http.OptsExtra{\n\t\t\tConnections: 1,\n\t\t\tAutoTorrent: util.BoolPtr(false),\n\t\t},\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Wait for task to complete\n\tselect {\n\tcase <-taskDone:\n\t\t// Task completed\n\tcase <-time.After(10 * time.Second):\n\t\tt.Fatal(\"Timeout waiting for task to complete\")\n\t}\n\n\t// Give a small buffer\n\ttime.Sleep(200 * time.Millisecond)\n\n\t// Verify that only 1 task exists (no BT task was created)\n\ttasks := downloader.GetTasks()\n\tif len(tasks) != 1 {\n\t\tt.Errorf(\"Expected exactly 1 task (torrent download only), got %d\", len(tasks))\n\t}\n\n\t// Verify the torrent file was downloaded\n\ttorrentFilePath := downloadDir + \"/ubuntu.torrent\"\n\tif _, err := os.Stat(torrentFilePath); os.IsNotExist(err) {\n\t\tt.Error(\"Torrent file should have been downloaded\")\n\t}\n}\n\nfunc TestDownloader_PatchTask_HTTP(t *testing.T) {\n\tlistener := test.StartTestFileServer()\n\tdefer listener.Close()\n\n\tdownloader := NewDownloader(nil)\n\tif err := downloader.Setup(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer downloader.Clear()\n\n\treq := &base.Request{\n\t\tURL: \"http://\" + listener.Addr().String() + \"/\" + test.BuildName,\n\t\tExtra: &http.OptsExtra{\n\t\t\tConnections: 2,\n\t\t},\n\t\tLabels: map[string]string{\n\t\t\t\"test\": \"value1\",\n\t\t},\n\t}\n\topts := &base.Options{\n\t\tPath: test.Dir,\n\t\tName: test.DownloadName,\n\t}\n\n\t// Create task but don't start it yet\n\ttaskId, err := downloader.CreateDirect(req, opts)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Pause the task immediately\n\tif err := downloader.Pause(&TaskFilter{IDs: []string{taskId}}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Patch the task with new labels\n\tpatchReq := &base.Request{\n\t\tLabels: map[string]string{\n\t\t\t\"test\":   \"value2\",\n\t\t\t\"newKey\": \"newValue\",\n\t\t},\n\t}\n\n\tif err := downloader.Patch(taskId, patchReq, nil); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Verify the patch was applied\n\ttask := downloader.GetTask(taskId)\n\tif task == nil {\n\t\tt.Fatal(\"task not found\")\n\t}\n\n\tif task.Meta.Req.Labels[\"test\"] != \"value2\" {\n\t\tt.Errorf(\"PatchTask() label 'test' = %v, want %v\", task.Meta.Req.Labels[\"test\"], \"value2\")\n\t}\n\tif task.Meta.Req.Labels[\"newKey\"] != \"newValue\" {\n\t\tt.Errorf(\"PatchTask() label 'newKey' = %v, want %v\", task.Meta.Req.Labels[\"newKey\"], \"newValue\")\n\t}\n\n\t// Clean up\n\tdownloader.Delete(&TaskFilter{IDs: []string{taskId}}, true)\n}\n\nfunc TestDownloader_PatchTask_NotFound(t *testing.T) {\n\tdownloader := NewDownloader(nil)\n\tif err := downloader.Setup(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer downloader.Clear()\n\n\t// Try to patch a non-existent task\n\tpatchReq := &base.Request{\n\t\tLabels: map[string]string{\n\t\t\t\"test\": \"value\",\n\t\t},\n\t}\n\n\terr := downloader.Patch(\"non-existent-id\", patchReq, nil)\n\tif err != ErrTaskNotFound {\n\t\tt.Errorf(\"Patch() error = %v, want %v\", err, ErrTaskNotFound)\n\t}\n}\n"
  },
  {
    "path": "pkg/download/engine/engine.go",
    "content": "package engine\n\nimport (\n\t_ \"embed\"\n\t\"errors\"\n\t\"github.com/GopeedLab/gopeed/pkg/base\"\n\tgojaerror \"github.com/GopeedLab/gopeed/pkg/download/engine/inject/error\"\n\t\"github.com/GopeedLab/gopeed/pkg/download/engine/inject/file\"\n\t\"github.com/GopeedLab/gopeed/pkg/download/engine/inject/formdata\"\n\t\"github.com/GopeedLab/gopeed/pkg/download/engine/inject/vm\"\n\t\"github.com/GopeedLab/gopeed/pkg/download/engine/inject/xhr\"\n\t\"github.com/dop251/goja\"\n\t\"github.com/dop251/goja_nodejs/eventloop\"\n\tgojaurl \"github.com/dop251/goja_nodejs/url\"\n\t\"time\"\n)\n\n//go:embed polyfill/out/index.js\nvar polyfillScript string\n\ntype Engine struct {\n\tloop *eventloop.EventLoop\n\n\tRuntime *goja.Runtime\n}\n\n// RunString executes the script and returns the go type value\n// if script result is promise, it will be resolved\nfunc (e *Engine) RunString(script string) (value any, err error) {\n\tdefer func() {\n\t\tif r := recover(); r != nil {\n\t\t\terr = r.(error)\n\t\t}\n\t}()\n\n\tvar result goja.Value\n\te.loop.Run(func(runtime *goja.Runtime) {\n\t\tresult, err = runtime.RunString(script)\n\t\tif err == nil {\n\t\t\tgo e.await(result)\n\t\t}\n\t})\n\tif err != nil {\n\t\treturn\n\t}\n\treturn resolveResult(result)\n}\n\n// CallFunction calls the function and returns the go type value\n// if function result is promise, it will be resolved\nfunc (e *Engine) CallFunction(fn goja.Callable, args ...any) (value any, err error) {\n\tdefer func() {\n\t\tif r := recover(); r != nil {\n\t\t\terr = r.(error)\n\t\t}\n\t}()\n\n\tvar result goja.Value\n\te.loop.Run(func(runtime *goja.Runtime) {\n\t\tif args == nil {\n\t\t\tresult, err = fn(nil)\n\t\t} else {\n\t\t\tvar jsArgs []goja.Value\n\t\t\tfor _, arg := range args {\n\t\t\t\tjsArgs = append(jsArgs, runtime.ToValue(arg))\n\t\t\t}\n\t\t\tresult, err = fn(nil, jsArgs...)\n\t\t}\n\t\tif err == nil {\n\t\t\tgo e.await(result)\n\t\t}\n\t})\n\tif err != nil {\n\t\treturn\n\t}\n\treturn resolveResult(result)\n}\n\n// loop.Run will hang if the script result has a non-stop code, such as setInterval.\n// This method will stop the event loop when the promise result is resolved.\nfunc (e *Engine) await(value any) {\n\tif value == nil {\n\t\treturn\n\t}\n\n\tif v, ok := value.(goja.Value); ok {\n\t\t// if result is promise, wait for it to be resolved\n\t\tif p, ok := v.Export().(*goja.Promise); ok {\n\t\t\tif p.State() != goja.PromiseStatePending {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// check promise state every 100 milliseconds, until it is resolved\n\t\t\tfor {\n\t\t\t\ttime.Sleep(time.Millisecond * 100)\n\t\t\t\tif p.State() == goja.PromiseStatePending {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\t// stop the event loop\n\t\t\te.loop.StopNoWait()\n\t\t}\n\t}\n}\n\nfunc (e *Engine) Close() {\n\te.loop.StopNoWait()\n}\n\ntype Config struct {\n\tProxyConfig *base.DownloaderProxyConfig\n}\n\nfunc NewEngine(cfg *Config) *Engine {\n\tif cfg == nil {\n\t\tcfg = &Config{}\n\t}\n\tloop := eventloop.NewEventLoop()\n\tengine := &Engine{\n\t\tloop: loop,\n\t}\n\tloop.Run(func(runtime *goja.Runtime) {\n\t\tengine.Runtime = runtime\n\t\truntime.SetFieldNameMapper(goja.TagFieldNameMapper(\"json\", true))\n\t\tvm.Enable(runtime)\n\t\tgojaurl.Enable(runtime)\n\t\tif err := gojaerror.Enable(runtime); err != nil {\n\t\t\treturn\n\t\t}\n\t\tif err := file.Enable(runtime); err != nil {\n\t\t\treturn\n\t\t}\n\t\tif err := formdata.Enable(runtime); err != nil {\n\t\t\treturn\n\t\t}\n\t\tif err := xhr.Enable(runtime, cfg.ProxyConfig.ToHandler()); err != nil {\n\t\t\treturn\n\t\t}\n\t\tif _, err := runtime.RunString(polyfillScript); err != nil {\n\t\t\treturn\n\t\t}\n\t\t// polyfill global\n\t\tif err := runtime.Set(\"global\", runtime.GlobalObject()); err != nil {\n\t\t\treturn\n\t\t}\n\t\t// polyfill window\n\t\tif err := runtime.Set(\"window\", runtime.GlobalObject()); err != nil {\n\t\t\treturn\n\t\t}\n\t\t// polyfill window.location\n\t\tif _, err := runtime.RunString(\"global.location = new URL('http://localhost');\"); err != nil {\n\t\t\treturn\n\t\t}\n\t\treturn\n\t})\n\treturn engine\n}\n\nfunc Run(script string) (value any, err error) {\n\tengine := NewEngine(nil)\n\treturn engine.RunString(script)\n}\n\n// if the value is Promise, it will be resolved and return the result.\nfunc resolveResult(value goja.Value) (any, error) {\n\texport := value.Export()\n\tswitch export.(type) {\n\tcase *goja.Promise:\n\t\tp := export.(*goja.Promise)\n\t\tswitch p.State() {\n\t\tcase goja.PromiseStatePending:\n\t\t\treturn nil, nil\n\t\tcase goja.PromiseStateFulfilled:\n\t\t\treturn p.Result().Export(), nil\n\t\tcase goja.PromiseStateRejected:\n\t\t\tif err, ok := p.Result().Export().(error); ok {\n\t\t\t\treturn nil, err\n\t\t\t} else {\n\t\t\t\tstack := p.Result().String()\n\t\t\t\tresult := p.Result()\n\t\t\t\tif ro, ok := result.(*goja.Object); ok {\n\t\t\t\t\tstackVal := ro.Get(\"stack\")\n\t\t\t\t\tif stackVal != nil && stackVal.String() != \"\" {\n\t\t\t\t\t\tstack = stackVal.String()\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn nil, errors.New(stack)\n\t\t\t}\n\t\t}\n\t}\n\treturn export, nil\n}\n"
  },
  {
    "path": "pkg/download/engine/engine_test.go",
    "content": "package engine\n\nimport (\n\t\"crypto/md5\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/GopeedLab/gopeed/internal/test\"\n\t\"github.com/GopeedLab/gopeed/pkg/base\"\n\tgojaerror \"github.com/GopeedLab/gopeed/pkg/download/engine/inject/error\"\n\t\"github.com/GopeedLab/gopeed/pkg/download/engine/inject/file\"\n\tgojautil \"github.com/GopeedLab/gopeed/pkg/download/engine/util\"\n\t\"github.com/dop251/goja\"\n)\n\nfunc TestPolyfill(t *testing.T) {\n\tdoTestPolyfill(t, \"MessageError\")\n\tdoTestPolyfill(t, \"XMLHttpRequest\")\n\tdoTestPolyfill(t, \"Blob\")\n\tdoTestPolyfill(t, \"FormData\")\n\tdoTestPolyfill(t, \"TextDecoder\")\n\tdoTestPolyfill(t, \"TextEncoder\")\n\tdoTestPolyfill(t, \"fetch\")\n\tdoTestPolyfill(t, \"__gopeed_create_vm\")\n}\n\nfunc TestError(t *testing.T) {\n\tengine := NewEngine(nil)\n\t_, err := engine.RunString(`\n      throw new MessageError('test');\n\t`)\n\tif me, ok := gojautil.AssertError[*gojaerror.MessageError](err); !ok {\n\t\tt.Fatalf(\"expect MessageError, but got %v\", me)\n\t}\n}\n\nfunc TestFetch(t *testing.T) {\n\tserver := startServer()\n\tdefer server.Close()\n\tengine := NewEngine(nil)\n\tif _, err := engine.RunString(fmt.Sprintf(\"var host = 'http://%s';\", server.Addr().String())); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t_, err := engine.RunString(`\nasync function testGet(){\n\tconst resp = await fetch(host+'/get');\n\treturn resp.status;\n}\n\nasync function testText(){\n\tconst resp = await fetch(host+'/text',{\n\t\tmethod: 'POST',\n\t\tbody: 'test'\n\t});\n\treturn await resp.text();\n}\n\nasync function testOctetStream(file){\n\tconst resp = await fetch(host+'/octetStream',{\n\t\tmethod: 'POST',\n\t\tbody: file\n\t});\n\treturn await resp.text();\n}\n\nasync function testRedirect() {\n    const url = host + '/redirect?num=3'\n    return await new Promise((resolve, reject) => {\n        fetch(url, {\n            method: 'HEAD',\n            redirect: 'error',\n        }).then(()=>reject())\n\n        fetch(url, {\n            method: 'HEAD',\n            redirect: 'follow',\n        }).then((res) =>res.headers.has('location') && reject()).catch(() => reject())\n\n        fetch(url, {\n            method: 'HEAD',\n            redirect: 'manual',\n        }).then((res) => {\n\t\t\tconst location = res.headers.get('location');\n\t\t\tlocation ? resolve(location) : reject()\n        }).catch(() => reject())\n    })\n}\n\nasync function testResponseUrl() {\n    return new Promise((resolve, reject) => {\n\t\tconst xhr = new XMLHttpRequest();\n\t\txhr.open('GET', host+'/redirect?num=3');\n\t\txhr.onload = function(){\n            if (xhr.responseURL.includes('/redirect?num=0')){\n                resolve();\n            }else{\n                reject();\n            }\n\t\t};\n\t\txhr.send();\n\t});\n}\n\nasync function testFormData(file){\n\tconst formData = new FormData();\n\tformData.append('name', 'test');\n\tformData.append('f', file);\n\tconst resp = await fetch(host+'/formData',{\n\t\tmethod: 'POST',\n\t\tbody: formData\n\t});\n\treturn await resp.json();\n}\n\nfunction testHeader(){\n\treturn new Promise((resolve, reject) => {\n\t\tconst xhr = new XMLHttpRequest();\n\t\txhr.open('GET', host+'/header');\n\t\txhr.setRequestHeader('X-Gopeed-Test', 'test1');\n\t\txhr.setRequestHeader('x-gopeed-test', 'test2');\n\t\txhr.setRequestHeader('x-Gopeed-test', 'test3');\n\t\txhr.onload = function(){\n\t\t\tconst testHeader1 = xhr.getResponseHeader(\"X-Gopeed-Test\");\n\t\t    const testHeader2 = xhr.getResponseHeader(\"x-gopeed-test\");\n\t\t    const testHeader3 = xhr.getResponseHeader(\"x-Gopeed-test\");\n\t\t\tconst expect = 'test1, test2, test3';\n\t\t\tconst all = xhr.getAllResponseHeaders();\n\t\t\tif(testHeader1 === expect && testHeader2 === expect && testHeader3 === expect \n\t\t\t\t&& all.includes('X-Gopeed-Test: '+expect)){\n\t\t\t\tresolve();\n\t\t\t}else{\n\t\t\t\treject();\n\t\t\t}\n\t\t};\n\t\txhr.send();\n\t});\n}\n\nfunction testProgress(){\n\treturn new Promise((resolve, reject) => {\n\t\tconst xhr = new XMLHttpRequest();\n\t\txhr.open('GET', host+'/get');\n\t\tconst xhrUploadPromise = new Promise((resolve, reject) => {\n\t\t\txhr.upload.onprogress = function(e){\n\t\t\t\tif(e.loaded === e.total){\n\t\t\t\t\tresolve();\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\t\tconst xhrPromise = new Promise((resolve, reject) => {\n\t\t\txhr.onprogress = function(e){\n\t\t\t\tif(e.loaded === e.total){\n\t\t\t\t\tresolve();\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\t\tPromise.all([xhrUploadPromise, xhrPromise]).then(() => {\n\t\t\tresolve();\n\t\t});\n\t\txhr.send();\n\t\tsetTimeout(() => {\n\t\t\treject('timeout');\n\t\t}, 1000);\n\t});\n}\n\nfunction testAbort(){\n\treturn new Promise((resolve, reject) => {\n\t\tconst xhr = new XMLHttpRequest();\n\t\txhr.open('GET', host+'/timeout?duration=500');\n\t\txhr.onabort = function() {\n\t\t\tresolve();\n\t\t};\n\t\txhr.send();\n\t\tsetTimeout(() => {\n\t\t\txhr.abort();\n\t\t}, 200);\n\t\tsetTimeout(() => {\n\t\t\treject('timeout');\n\t\t}, 1000);\n\t});\n}\n\nfunction testTimeout(){\n\treturn new Promise((resolve, reject) => {\n\t\tconst xhr = new XMLHttpRequest();\n\t\tconst t = 500;\n\t\txhr.open('GET', host+'/timeout?duration='+t);\n\t\txhr.timeout = t - 200;\n\t\txhr.onload = function() {\n\t\t\tresolve();\n\t\t};\n\t\txhr.ontimeout = function() {\n\t\t\treject('timeout');\n\t\t};\n\t\txhr.send();\n\t});\n}\n\nasync function testFingerprint(fingerprint,ua){\n\t__gopeed_setFingerprint(fingerprint);\n\tconst resp = await fetch(host+'/ua');\n\tconst data = await resp.json();\n\tif(!data.user_agent.includes(ua)){\n\t\tthrow new Error('fingerprint test failed, user agent: ' + data.user_agent);\n\t}\n}\n\nasync function testFingerprintDefault(){\n\tawait testFingerprint('none', 'Go')\n}\n\nasync function testFingerprintChrome(){\n\tawait testFingerprint('chrome', 'Chrome')\n}\n\nasync function testFingerprintFirefox(){\n\tawait testFingerprint('firefox', 'Firefox')\n}\n\nasync function testFingerprintSafari(){\n\tawait testFingerprint('safari', 'Safari')\n}\n`)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tresult, err := callTestFun(engine, \"testGet\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif result != int64(200) {\n\t\tt.Fatalf(\"testGet failed, want %d, got %d\", 200, result)\n\t}\n\n\tresult, err = callTestFun(engine, \"testText\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif result != \"test\" {\n\t\tt.Fatalf(\"testText failed, want %s, got %s\", \"test\", result)\n\t}\n\n\tfunc() {\n\t\tjsFile, _, md5 := buildFile(t, engine.Runtime)\n\t\tresult, err = callTestFun(engine, \"testOctetStream\", jsFile)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif result != md5 {\n\t\t\tt.Fatalf(\"testOctetStream failed, want %s, got %s\", md5, result)\n\t\t}\n\t}()\n\n\tt.Run(\"testRedirect\", func(t *testing.T) {\n\t\t_, err := callTestFun(engine, \"testRedirect\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t})\n\n\tt.Run(\"testResponseUrl\", func(t *testing.T) {\n\t\t_, err = callTestFun(engine, \"testResponseUrl\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t})\n\n\tfunc() {\n\t\tjsFile, goFile, md5 := buildFile(t, engine.Runtime)\n\t\tresult, err = callTestFun(engine, \"testFormData\", jsFile)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\twant := map[string]any{\n\t\t\t\"name\": \"test\",\n\t\t\t\"f\": map[string]string{\n\t\t\t\t\"filename\": goFile.Name,\n\t\t\t\t\"md5\":      md5,\n\t\t\t},\n\t\t}\n\t\tif !test.JsonEqual(result, want) {\n\t\t\tt.Fatalf(\"testFormData failed, want %v, got %v\", want, result)\n\t\t}\n\t}()\n\n\t_, err = callTestFun(engine, \"testHeader\")\n\tif err != nil {\n\t\tt.Fatal(\"header test failed\", err)\n\t}\n\n\t_, err = callTestFun(engine, \"testProgress\")\n\tif err != nil {\n\t\tt.Fatal(\"progress test failed\", err)\n\t}\n\n\t_, err = callTestFun(engine, \"testAbort\")\n\tif err != nil {\n\t\tt.Fatal(\"abort test failed\", err)\n\t}\n\n\t_, err = callTestFun(engine, \"testTimeout\")\n\tif err == nil || err.Error() != \"timeout\" {\n\t\tt.Fatalf(\"timeout test failed, want %s, got %s\", \"timeout\", err)\n\t}\n\n\t_, err = callTestFun(engine, \"testFingerprintChrome\")\n\tif err != nil {\n\t\tt.Fatal(\"testFingerprintChrome test failed\", err)\n\t}\n\t_, err = callTestFun(engine, \"testFingerprintFirefox\")\n\tif err != nil {\n\t\tt.Fatal(\"testFingerprintFirefox test failed\", err)\n\t}\n\t_, err = callTestFun(engine, \"testFingerprintSafari\")\n\tif err != nil {\n\t\tt.Fatal(\"testFingerprintSafari test failed\", err)\n\t}\n}\n\nfunc TestFetchWithProxy(t *testing.T) {\n\tdoTestFetchWithProxy(t, \"\", \"\")\n\tdoTestFetchWithProxy(t, \"admin\", \"123\")\n}\n\nfunc doTestFetchWithProxy(t *testing.T, usr, pwd string) {\n\thttpListener := startServer()\n\tdefer httpListener.Close()\n\n\tproxyListener := test.StartSocks5Server(usr, pwd)\n\tdefer proxyListener.Close()\n\tengine := NewEngine(&Config{\n\t\tProxyConfig: &base.DownloaderProxyConfig{\n\t\t\tEnable: true,\n\t\t\tSystem: false,\n\t\t\tScheme: \"socks5\",\n\t\t\tHost:   proxyListener.Addr().String(),\n\t\t\tUsr:    usr,\n\t\t\tPwd:    pwd,\n\t\t},\n\t})\n\n\tif _, err := engine.RunString(fmt.Sprintf(\"var host = 'http://%s';\", httpListener.Addr().String())); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\trespCode, err := engine.RunString(`\n(async function(){\n\tconst resp = await fetch(host+'/get');\n\treturn resp.status;\n})()\n`)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif respCode != int64(200) {\n\t\tt.Fatalf(\"fetch with proxy failed, want %d, got %d\", 200, respCode)\n\t}\n}\n\nfunc TestVm(t *testing.T) {\n\tengine := NewEngine(nil)\n\n\tvalue, err := engine.RunString(`\nconst vm = __gopeed_create_vm()\nvm.set('a', 1)\nvm.set('b', 2)\nconst result = vm.runString('a=a+1;b=b+1;a+b;')\nconst out = {\n\t\"a\": vm.get('a'),\n\t\"b\": vm.get('b'),\n\t\"result\": result\n}\nout\n`)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\twant := map[string]any{\n\t\t\"a\":      2,\n\t\t\"b\":      3,\n\t\t\"result\": 5,\n\t}\n\tif !test.JsonEqual(value, want) {\n\t\tt.Fatalf(\"vm test failed, want %v, got %v\", want, value)\n\t}\n}\n\nfunc TestNonStopLoop(t *testing.T) {\n\tengine := NewEngine(nil)\n\n\t_, err := engine.RunString(`\nfunction leak(){\n\tsetInterval(() => {\n\t},500)\n}\n\nfunction test(){\n\tleak()\n\treturn new Promise((resolve, reject) => {\n\t\tsetTimeout(() => {\n\t\t\tresolve('done')\n\t\t}, 1000)\t\n\t})\n}\n`)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tval, err := callTestFun(engine, \"test\")\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\tif val != \"done\" {\n\t\tt.Fatalf(\"infinite loop test failed, want %s, got %s\", \"done\", val)\n\t}\n}\n\nfunc doTestPolyfill(t *testing.T, module string) {\n\tvalue, err := Run(fmt.Sprintf(`\n!!globalThis['%s']\n`, module))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !value.(bool) {\n\t\tt.Fatalf(\"module %s not polyfilled\", module)\n\t}\n}\n\nfunc startServer() net.Listener {\n\tlistener, err := net.Listen(\"tcp\", \"127.0.0.1:0\")\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\tserver := &http.Server{}\n\tmux := http.NewServeMux()\n\tmux.HandleFunc(\"/get\", func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(\"ok\"))\n\t})\n\tmux.HandleFunc(\"/header\", func(w http.ResponseWriter, r *http.Request) {\n\t\tfor k, v := range r.Header {\n\t\t\tif strings.HasPrefix(k, \"X-Gopeed\") {\n\t\t\t\tw.Header().Set(k, strings.Join(v, \", \"))\n\t\t\t}\n\t\t}\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(\"ok\"))\n\t})\n\tmux.HandleFunc(\"/text\", func(w http.ResponseWriter, r *http.Request) {\n\t\tbuf, _ := io.ReadAll(r.Body)\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write(buf)\n\t})\n\tmux.HandleFunc(\"/octetStream\", func(w http.ResponseWriter, r *http.Request) {\n\t\tmd5 := calcMd5(r.Body)\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(md5))\n\t})\n\tmux.HandleFunc(\"/formData\", func(w http.ResponseWriter, r *http.Request) {\n\t\terr := r.ParseMultipartForm(1024 * 1024 * 30)\n\t\tif err != nil {\n\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\tw.Write([]byte(err.Error()))\n\t\t\treturn\n\t\t}\n\t\tresult := make(map[string]any)\n\t\tfor k, v := range r.MultipartForm.Value {\n\t\t\tresult[k] = v[0]\n\t\t}\n\t\tfor k, v := range r.MultipartForm.File {\n\t\t\tf, _ := v[0].Open()\n\t\t\tresult[k] = map[string]string{\n\t\t\t\t\"filename\": v[0].Filename,\n\t\t\t\t\"md5\":      calcMd5(f),\n\t\t\t}\n\t\t}\n\t\tw.WriteHeader(http.StatusOK)\n\t\tbuf, _ := json.Marshal(result)\n\t\tw.Write(buf)\n\t})\n\tmux.HandleFunc(\"/timeout\", func(w http.ResponseWriter, r *http.Request) {\n\t\tduration := r.URL.Query().Get(\"duration\")\n\t\tt, _ := strconv.Atoi(duration)\n\t\ttime.Sleep(time.Duration(t) * time.Millisecond)\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write([]byte(\"ok\"))\n\t})\n\tmux.HandleFunc(\"/redirect\", func(w http.ResponseWriter, r *http.Request) {\n\t\tnum := r.URL.Query().Get(\"num\")\n\t\tn, _ := strconv.Atoi(num)\n\t\tif n > 0 {\n\t\t\thttp.Redirect(w, r, fmt.Sprintf(\"/redirect?num=%d\", n-1), http.StatusFound)\n\t\t} else {\n\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\tw.Write([]byte(\"ok\"))\n\t\t}\n\t})\n\tmux.HandleFunc(\"/ua\", func(w http.ResponseWriter, r *http.Request) {\n\t\tw.Header().Set(\"Content-Type\", \"application/json\")\n\t\tdata := map[string]any{\n\t\t\t\"user_agent\": r.UserAgent(),\n\t\t}\n\t\tbuf, _ := json.Marshal(data)\n\t\tw.WriteHeader(http.StatusOK)\n\t\tw.Write(buf)\n\t})\n\tserver.Handler = mux\n\tgo server.Serve(listener)\n\treturn listener\n}\n\nfunc buildFile(t *testing.T, runtime *goja.Runtime) (goja.Value, *file.File, string) {\n\tjsFile, err := file.NewJsFile(runtime)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tf := jsFile.Export().(*file.File)\n\tdata := \"test\"\n\tf.Reader = strings.NewReader(data)\n\tf.Name = \"test.txt\"\n\tf.Size = int64(len(data))\n\treturn jsFile, f, calcMd5(strings.NewReader(data))\n}\n\nfunc callTestFun(engine *Engine, fun string, args ...any) (any, error) {\n\ttest, ok := goja.AssertFunction(engine.Runtime.Get(fun))\n\tif !ok {\n\t\treturn nil, errors.New(\"function not found:\" + fun)\n\t}\n\treturn engine.CallFunction(test, args...)\n}\n\nfunc calcMd5(reader io.Reader) string {\n\t// Open a new hash interface to write to\n\thash := md5.New()\n\n\t// Copy the file in the hash interface and check for any error\n\tif _, err := io.Copy(hash, reader); err != nil {\n\t\treturn \"\"\n\t}\n\treturn hex.EncodeToString(hash.Sum(nil))\n}\n"
  },
  {
    "path": "pkg/download/engine/inject/error/module.go",
    "content": "package error\n\nimport (\n\t\"github.com/dop251/goja\"\n)\n\ntype MessageError struct {\n\tMessage string `json:\"message\"`\n}\n\nfunc (e *MessageError) Error() string {\n\treturn e.Message\n}\n\nfunc Enable(runtime *goja.Runtime) error {\n\tmessageError := runtime.ToValue(func(call goja.ConstructorCall) *goja.Object {\n\t\tvar message string\n\t\tif len(call.Arguments) > 0 {\n\t\t\tmessage = call.Arguments[0].String()\n\t\t}\n\t\tinstance := &MessageError{\n\t\t\tMessage: message,\n\t\t}\n\t\tinstanceValue := runtime.ToValue(instance).(*goja.Object)\n\t\tinstanceValue.SetPrototype(call.This.Prototype())\n\t\treturn instanceValue\n\t})\n\treturn runtime.Set(\"MessageError\", messageError)\n}\n"
  },
  {
    "path": "pkg/download/engine/inject/file/module.go",
    "content": "package file\n\nimport (\n\t\"errors\"\n\t\"github.com/dop251/goja\"\n\t\"io\"\n)\n\ntype File struct {\n\tio.Reader `json:\"\"`\n\tio.Closer `json:\"\"`\n\tName      string `json:\"name\"`\n\tSize      int64  `json:\"size\"`\n}\n\nfunc NewJsFile(runtime *goja.Runtime) (goja.Value, error) {\n\tfileCtor, ok := goja.AssertConstructor(runtime.Get(\"File\"))\n\tif !ok {\n\t\treturn nil, errors.New(\"file is not defined\")\n\t}\n\treturn fileCtor(nil)\n}\n\nfunc Enable(runtime *goja.Runtime) error {\n\tfile := runtime.ToValue(func(call goja.ConstructorCall) *goja.Object {\n\t\tinstance := &File{}\n\t\tinstanceValue := runtime.ToValue(instance).(*goja.Object)\n\t\tinstanceValue.SetPrototype(call.This.Prototype())\n\t\treturn instanceValue\n\t})\n\treturn runtime.Set(\"File\", file)\n}\n"
  },
  {
    "path": "pkg/download/engine/inject/formdata/module.go",
    "content": "package formdata\n\nimport \"github.com/dop251/goja\"\n\ntype FormData struct {\n\tdata map[string]any\n}\n\nfunc (fd *FormData) Append(name string, value any) {\n\tfd.data[name] = value\n}\n\nfunc (fd *FormData) Delete(name string) {\n\tdelete(fd.data, name)\n}\n\nfunc (fd *FormData) Entries() []any {\n\tvar entries []any\n\tfor k, v := range fd.data {\n\t\tentries = append(entries, []any{k, v})\n\t}\n\treturn entries\n}\n\nfunc (fd *FormData) Get(name string) any {\n\treturn fd.data[name]\n}\n\nfunc (fd *FormData) GetAll(name string) []any {\n\treturn []any{fd.data[name]}\n}\n\nfunc (fd *FormData) Has(name string) bool {\n\t_, ok := fd.data[name]\n\treturn ok\n}\n\nfunc (fd *FormData) Keys() []string {\n\tvar keys []string\n\tfor k := range fd.data {\n\t\tkeys = append(keys, k)\n\t}\n\treturn keys\n}\n\nfunc (fd *FormData) Set(name string, value any) {\n\tfd.data[name] = value\n}\n\nfunc (fd *FormData) Values() []any {\n\tvar values []any\n\tfor _, v := range fd.data {\n\t\tvalues = append(values, v)\n\t}\n\treturn values\n}\n\nfunc Enable(runtime *goja.Runtime) error {\n\tfile := runtime.ToValue(func(call goja.ConstructorCall) *goja.Object {\n\t\tinstance := &FormData{\n\t\t\tdata: make(map[string]any),\n\t\t}\n\t\tinstanceValue := runtime.ToValue(instance).(*goja.Object)\n\t\tinstanceValue.SetPrototype(call.This.Prototype())\n\t\treturn instanceValue\n\t})\n\treturn runtime.Set(\"FormData\", file)\n}\n"
  },
  {
    "path": "pkg/download/engine/inject/vm/module.go",
    "content": "package vm\n\nimport (\n\t\"github.com/dop251/goja\"\n\t\"github.com/dop251/goja_nodejs/eventloop\"\n)\n\ntype Vm struct {\n\tloop *eventloop.EventLoop\n}\n\nfunc (vm *Vm) Set(name string, value any) {\n\tvm.loop.Run(func(runtime *goja.Runtime) {\n\t\truntime.Set(name, value)\n\t})\n}\n\nfunc (vm *Vm) Get(name string) (value any) {\n\tvm.loop.Run(func(runtime *goja.Runtime) {\n\t\tvalue = runtime.Get(name)\n\t})\n\treturn\n}\n\nfunc (vm *Vm) RunString(script string) (value any, err error) {\n\tdefer func() {\n\t\tif r := recover(); r != nil {\n\t\t\terr = r.(error)\n\t\t}\n\t}()\n\n\tvm.loop.Run(func(runtime *goja.Runtime) {\n\t\tvalue, err = runtime.RunString(script)\n\t})\n\treturn\n}\n\nfunc Enable(runtime *goja.Runtime) error {\n\treturn runtime.Set(\"__gopeed_create_vm\", func(call goja.FunctionCall) goja.Value {\n\t\treturn runtime.ToValue(&Vm{\n\t\t\tloop: eventloop.NewEventLoop(),\n\t\t})\n\t})\n}\n"
  },
  {
    "path": "pkg/download/engine/inject/xhr/module.go",
    "content": "package xhr\n\nimport (\n\t\"bytes\"\n\t\"errors\"\n\t\"io\"\n\t\"mime/multipart\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/GopeedLab/gopeed/pkg/download/engine/inject/file\"\n\t\"github.com/GopeedLab/gopeed/pkg/download/engine/inject/formdata\"\n\t\"github.com/GopeedLab/gopeed/pkg/download/engine/util\"\n\t\"github.com/dop251/goja\"\n\t\"github.com/imroc/req/v3\"\n)\n\nconst (\n\teventLoad             = \"load\"\n\teventReadystatechange = \"readystatechange\"\n\teventProgress         = \"progress\"\n\teventAbort            = \"abort\"\n\teventError            = \"error\"\n\teventTimeout          = \"timeout\"\n)\n\nconst (\n\tredirectError  = \"error\"\n\tredirectFollow = \"follow\"\n\tredirectManual = \"manual\"\n)\n\ntype ProgressEvent struct {\n\tType             string `json:\"type\"`\n\tLengthComputable bool   `json:\"lengthComputable\"`\n\tLoaded           int64  `json:\"loaded\"`\n\tTotal            int64  `json:\"total\"`\n}\n\ntype EventProp struct {\n\teventListeners map[string]func(event *ProgressEvent)\n\tOnload         func(event *ProgressEvent) `json:\"onload\"`\n\tOnprogress     func(event *ProgressEvent) `json:\"onprogress\"`\n\tOnabort        func(event *ProgressEvent) `json:\"onabort\"`\n\tOnerror        func(event *ProgressEvent) `json:\"onerror\"`\n\tOntimeout      func(event *ProgressEvent) `json:\"ontimeout\"`\n}\n\nfunc (ep *EventProp) AddEventListener(event string, cb func(event *ProgressEvent)) {\n\tep.eventListeners[event] = cb\n}\n\nfunc (ep *EventProp) RemoveEventListener(event string) {\n\tdelete(ep.eventListeners, event)\n}\n\nfunc (ep *EventProp) callOnload() {\n\tevent := &ProgressEvent{\n\t\tType:             eventLoad,\n\t\tLengthComputable: false,\n\t}\n\tif ep.Onload != nil {\n\t\tep.Onload(event)\n\t}\n\tep.callEventListener(event)\n}\n\nfunc (ep *EventProp) callOnprogress(loaded, total int64) {\n\tevent := &ProgressEvent{\n\t\tType:             eventProgress,\n\t\tLengthComputable: true,\n\t\tLoaded:           loaded,\n\t\tTotal:            total,\n\t}\n\tif ep.Onprogress != nil {\n\t\tep.Onprogress(event)\n\t}\n\tep.callEventListener(event)\n}\n\nfunc (ep *EventProp) callOnabort() {\n\tevent := &ProgressEvent{\n\t\tType:             eventAbort,\n\t\tLengthComputable: false,\n\t}\n\tif ep.Onabort != nil {\n\t\tep.Onabort(event)\n\t}\n\tep.callEventListener(event)\n}\n\nfunc (ep *EventProp) callOnerror() {\n\tevent := &ProgressEvent{\n\t\tType:             eventError,\n\t\tLengthComputable: false,\n\t}\n\tif ep.Onerror != nil {\n\t\tep.Onerror(event)\n\t}\n\tep.callEventListener(event)\n}\n\nfunc (ep *EventProp) callOntimeout() {\n\tevent := &ProgressEvent{\n\t\tType:             eventTimeout,\n\t\tLengthComputable: false,\n\t}\n\tif ep.Ontimeout != nil {\n\t\tep.Ontimeout(event)\n\t}\n\tep.callEventListener(event)\n}\n\nfunc (ep *EventProp) callEventListener(event *ProgressEvent) {\n\tif cb, ok := ep.eventListeners[event.Type]; ok {\n\t\tcb(event)\n\t}\n}\n\ntype XMLHttpRequestUpload struct {\n\t*EventProp\n}\n\ntype XMLHttpRequest struct {\n\tmethod          string\n\turl             string\n\trequestHeaders  http.Header\n\tresponseHeaders http.Header\n\taborted         bool\n\tclient          *req.Client\n\tfingerprint     string\n\n\tWithCredentials bool                  `json:\"withCredentials\"`\n\tUpload          *XMLHttpRequestUpload `json:\"upload\"`\n\tTimeout         int                   `json:\"timeout\"`\n\tReadyState      int                   `json:\"readyState\"`\n\tStatus          int                   `json:\"status\"`\n\tStatusText      string                `json:\"statusText\"`\n\tResponse        string                `json:\"response\"`\n\tResponseText    string                `json:\"responseText\"`\n\t// https://developer.mozilla.org/zh-CN/docs/Web/API/XMLHttpRequest/responseURL\n\t// https://xhr.spec.whatwg.org/#the-responseurl-attribute\n\tResponseUrl string `json:\"responseURL\"`\n\t// extend fetch redirect\n\t// https://developer.mozilla.org/en-US/docs/Web/API/RequestInit#redirect\n\t// https://fetch.spec.whatwg.org/#concept-request-redirect-mode\n\tRedirect string `json:\"redirect\"`\n\t*EventProp\n\tOnreadystatechange func(event *ProgressEvent) `json:\"onreadystatechange\"`\n}\n\nfunc (xhr *XMLHttpRequest) Open(method, url string) {\n\txhr.method = method\n\txhr.url = url\n\txhr.requestHeaders = make(http.Header)\n\txhr.responseHeaders = make(http.Header)\n\txhr.doReadystatechange(1)\n}\n\nfunc (xhr *XMLHttpRequest) SetRequestHeader(key, value string) {\n\txhr.requestHeaders.Add(key, value)\n}\n\nfunc (xhr *XMLHttpRequest) Send(data goja.Value) {\n\tsetFingerprint(xhr.client, xhr.fingerprint)\n\n\td := xhr.parseData(data)\n\tvar (\n\t\tcontentType   string\n\t\tcontentLength int64\n\t\tisStringBody  bool\n\t)\n\n\t// Create request using req library\n\treqBuilder := xhr.client.R()\n\n\t// Set headers first\n\tif xhr.requestHeaders != nil {\n\t\tfor key, values := range xhr.requestHeaders {\n\t\t\tif len(values) > 0 {\n\t\t\t\t// Merge multiple values with comma separator (HTTP standard)\n\t\t\t\tmergedValue := strings.Join(values, \", \")\n\t\t\t\treqBuilder.SetHeader(key, mergedValue)\n\t\t\t}\n\t\t}\n\t}\n\n\t// Handle request body\n\tif d != nil && xhr.method != \"GET\" && xhr.method != \"HEAD\" {\n\t\tswitch v := d.(type) {\n\t\tcase string:\n\t\t\treqBuilder.SetBody(v)\n\t\t\tcontentType = \"text/plain;charset=UTF-8\"\n\t\t\tcontentLength = int64(len(v))\n\t\t\tisStringBody = true\n\t\tcase *file.File:\n\t\t\treqBuilder.SetBody(v.Reader)\n\t\t\tcontentType = \"application/octet-stream\"\n\t\t\tcontentLength = v.Size\n\t\tcase *formdata.FormData:\n\t\t\tpr, pw := io.Pipe()\n\t\t\tmw := NewMultipart(pw)\n\t\t\tfor _, e := range v.Entries() {\n\t\t\t\tarr := e.([]any)\n\t\t\t\tk := arr[0].(string)\n\t\t\t\tv := arr[1]\n\t\t\t\tswitch v := v.(type) {\n\t\t\t\tcase string:\n\t\t\t\t\tmw.WriteField(k, v)\n\t\t\t\tcase *file.File:\n\t\t\t\t\tmw.WriteFile(k, v)\n\t\t\t\t}\n\t\t\t}\n\t\t\tgo func() {\n\t\t\t\tdefer pw.Close()\n\t\t\t\tdefer mw.Close()\n\t\t\t\tmw.Send()\n\t\t\t}()\n\t\t\treqBuilder.SetBody(pr)\n\t\t\tcontentType = mw.FormDataContentType()\n\t\t\tcontentLength = mw.Size()\n\t\t}\n\t}\n\n\t// Only string body can specify Content-Type header by user\n\tif contentType != \"\" && (!isStringBody || xhr.requestHeaders.Get(\"Content-Type\") == \"\") {\n\t\treqBuilder.SetHeader(\"Content-Type\", contentType)\n\t}\n\n\t// Set timeout\n\tif xhr.Timeout > 0 {\n\t\txhr.client.SetTimeout(time.Duration(xhr.Timeout) * time.Millisecond)\n\t}\n\n\t// Configure redirect behavior\n\txhr.client.SetRedirectPolicy(func(req *http.Request, via []*http.Request) error {\n\t\tif xhr.Redirect == redirectManual {\n\t\t\treturn http.ErrUseLastResponse\n\t\t}\n\t\tif xhr.Redirect == redirectError {\n\t\t\treturn errors.New(\"redirect failed\")\n\t\t}\n\t\tif len(via) > 20 {\n\t\t\treturn errors.New(\"too many redirects\")\n\t\t}\n\t\treturn nil\n\t})\n\n\t// Execute request\n\tresp, err := reqBuilder.Send(xhr.method, xhr.url)\n\tif err != nil {\n\t\t// handle timeout error\n\t\tvar ne net.Error\n\t\tif errors.As(err, &ne) && ne.Timeout() {\n\t\t\tif xhr.Timeout > 0 {\n\t\t\t\txhr.Upload.callOntimeout()\n\t\t\t\txhr.callOntimeout()\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t\txhr.Upload.callOnerror()\n\t\txhr.callOnerror()\n\t\treturn\n\t}\n\n\txhr.Upload.callOnprogress(contentLength, contentLength)\n\tif !xhr.aborted {\n\t\txhr.Upload.callOnload()\n\t}\n\n\t// Set response URL (final URL after redirects)\n\tif resp.Response != nil && resp.Response.Request != nil && resp.Response.Request.URL != nil {\n\t\tresponseUrl := resp.Response.Request.URL\n\t\tresponseUrl.Fragment = \"\"\n\t\txhr.ResponseUrl = responseUrl.String()\n\t} else {\n\t\txhr.ResponseUrl = xhr.url\n\t}\n\n\t// Set response headers\n\txhr.responseHeaders = resp.Header\n\txhr.Status = resp.StatusCode\n\txhr.StatusText = resp.Status\n\txhr.doReadystatechange(2)\n\n\tbodyBytes := resp.Bytes()\n\txhr.doReadystatechange(3)\n\txhr.Response = string(bodyBytes)\n\txhr.ResponseText = xhr.Response\n\txhr.doReadystatechange(4)\n\trespBodyLen := int64(len(bodyBytes))\n\txhr.callOnprogress(respBodyLen, respBodyLen)\n\tif !xhr.aborted {\n\t\txhr.callOnload()\n\t}\n}\n\nfunc (xhr *XMLHttpRequest) Abort() {\n\txhr.doReadystatechange(0)\n\txhr.aborted = true\n\txhr.Upload.callOnabort()\n\txhr.callOnabort()\n}\n\nfunc (xhr *XMLHttpRequest) GetResponseHeader(key string) string {\n\tif xhr.responseHeaders == nil {\n\t\treturn \"\"\n\t}\n\treturn strings.Join(xhr.responseHeaders.Values(key), \", \")\n}\n\nfunc (xhr *XMLHttpRequest) GetAllResponseHeaders() string {\n\tvar buf bytes.Buffer\n\tfor k, v := range xhr.responseHeaders {\n\t\tbuf.WriteString(k)\n\t\tbuf.WriteString(\": \")\n\t\tbuf.WriteString(strings.Join(v, \", \"))\n\t\tbuf.WriteString(\"\\r\\n\")\n\t}\n\treturn buf.String()\n}\n\nfunc (xhr *XMLHttpRequest) callOnreadystatechange() {\n\tevent := &ProgressEvent{\n\t\tType:             eventReadystatechange,\n\t\tLengthComputable: false,\n\t}\n\tif xhr.Onreadystatechange != nil {\n\t\txhr.Onreadystatechange(event)\n\t}\n\txhr.callEventListener(event)\n}\n\nfunc (xhr *XMLHttpRequest) doReadystatechange(state int) {\n\tif xhr.aborted {\n\t\treturn\n\t}\n\txhr.ReadyState = state\n\txhr.callOnreadystatechange()\n}\n\n// parse js data to go struct\nfunc (xhr *XMLHttpRequest) parseData(data goja.Value) any {\n\t// check if data is null or undefined\n\tif data == nil || goja.IsNull(data) || goja.IsUndefined(data) || goja.IsNaN(data) {\n\t\treturn nil\n\t}\n\t// check if data is File\n\tf, ok := data.Export().(*file.File)\n\tif ok {\n\t\treturn f\n\t}\n\t// check if data is FormData\n\tfd, ok := data.Export().(*formdata.FormData)\n\tif ok {\n\t\treturn fd\n\t}\n\t// otherwise, return data as string\n\treturn data.String()\n}\n\nfunc Enable(runtime *goja.Runtime, proxyHandler func(r *http.Request) (*url.URL, error)) error {\n\tprogressEvent := runtime.ToValue(func(call goja.ConstructorCall) *goja.Object {\n\t\tif len(call.Arguments) < 1 {\n\t\t\tutil.ThrowTypeError(runtime, \"Failed to construct 'ProgressEvent': 1 argument required, but only 0 present.\")\n\t\t}\n\t\tinstance := &ProgressEvent{\n\t\t\tType: call.Argument(0).String(),\n\t\t}\n\t\tinstanceValue := runtime.ToValue(instance).(*goja.Object)\n\t\tinstanceValue.SetPrototype(call.This.Prototype())\n\t\treturn instanceValue\n\t})\n\txhr := runtime.ToValue(func(call goja.ConstructorCall) *goja.Object {\n\t\t// Create req client with proxy support\n\t\tclient := req.C()\n\t\tif proxyHandler != nil {\n\t\t\tclient.SetProxy(proxyHandler)\n\t\t}\n\n\t\tinstance := &XMLHttpRequest{\n\t\t\tclient:      client,\n\t\t\tfingerprint: util.SafeGet[string](runtime, FingerprintMagicKey),\n\t\t\tUpload: &XMLHttpRequestUpload{\n\t\t\t\tEventProp: &EventProp{\n\t\t\t\t\teventListeners: make(map[string]func(event *ProgressEvent)),\n\t\t\t\t},\n\t\t\t},\n\t\t\tEventProp: &EventProp{\n\t\t\t\teventListeners: make(map[string]func(event *ProgressEvent)),\n\t\t\t},\n\t\t}\n\t\tinstanceValue := runtime.ToValue(instance).(*goja.Object)\n\t\tinstanceValue.SetPrototype(call.This.Prototype())\n\t\treturn instanceValue\n\t})\n\truntime.Set(\"__gopeed_setFingerprint\", func(fingerprint string) {\n\t\truntime.Set(FingerprintMagicKey, fingerprint)\n\t})\n\tif err := runtime.Set(\"ProgressEvent\", progressEvent); err != nil {\n\t\treturn err\n\t}\n\tif err := runtime.Set(\"XMLHttpRequest\", xhr); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// Wrap multipart.Writer and stat content length\ntype multipartWrapper struct {\n\tstatBuffer *bytes.Buffer\n\tstatWriter *multipart.Writer\n\twriter     *multipart.Writer\n\tfields     map[string]any\n}\n\nfunc NewMultipart(w io.Writer) *multipartWrapper {\n\tvar buf bytes.Buffer\n\treturn &multipartWrapper{\n\t\tstatBuffer: &buf,\n\t\tstatWriter: multipart.NewWriter(&buf),\n\t\twriter:     multipart.NewWriter(w),\n\t\tfields:     make(map[string]any),\n\t}\n}\n\nfunc (w *multipartWrapper) WriteField(fieldname string, value string) error {\n\tw.fields[fieldname] = value\n\treturn w.statWriter.WriteField(fieldname, value)\n}\n\nfunc (w *multipartWrapper) WriteFile(fieldname string, file *file.File) error {\n\tw.fields[fieldname] = file\n\t_, err := w.statWriter.CreateFormFile(fieldname, file.Name)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc (w *multipartWrapper) Size() int64 {\n\tw.statWriter.Close()\n\tsize := int64(w.statBuffer.Len())\n\tfor _, v := range w.fields {\n\t\tswitch v := v.(type) {\n\t\tcase *file.File:\n\t\t\tsize += v.Size\n\t\t}\n\t}\n\treturn size\n}\n\nfunc (w *multipartWrapper) Send() error {\n\tfor k, v := range w.fields {\n\t\tswitch v := v.(type) {\n\t\tcase string:\n\t\t\tif err := w.writer.WriteField(k, v); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\tcase *file.File:\n\t\t\tfw, err := w.writer.CreateFormFile(k, v.Name)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif _, err = io.Copy(fw, v); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (w *multipartWrapper) FormDataContentType() string {\n\treturn w.writer.FormDataContentType()\n}\n\nfunc (w *multipartWrapper) Close() error {\n\treturn w.writer.Close()\n}\n"
  },
  {
    "path": "pkg/download/engine/inject/xhr/tls_fingerprint.go",
    "content": "package xhr\n\nimport \"github.com/imroc/req/v3\"\n\ntype Fingerprint string\n\nconst (\n\tFingerprintMagicKey = \"__gopeed_xhr_fingerprint\"\n\n\tfingerprintChrome  = \"chrome\"\n\tfingerprintFirefox = \"firefox\"\n\tfingerprintSafari  = \"safari\"\n)\n\nfunc setFingerprint(client *req.Client, fingerprint string) {\n\tswitch fingerprint {\n\tcase fingerprintChrome:\n\t\tclient.ImpersonateChrome()\n\tcase fingerprintFirefox:\n\t\tclient.ImpersonateFirefox()\n\tcase fingerprintSafari:\n\t\tclient.ImpersonateSafari()\n\t}\n}\n"
  },
  {
    "path": "pkg/download/engine/polyfill/out/index.js",
    "content": "(()=>{var e={725:function(e,t,r){var o,n,i;i=\"undefined\"!=typeof self&&self||\"undefined\"!=typeof window&&window||void 0!==r.g&&r.g||this,o=function(e){\"use strict\";var t=i.BlobBuilder||i.WebKitBlobBuilder||i.MSBlobBuilder||i.MozBlobBuilder,r=i.URL||i.webkitURL||function(e,t){return(t=document.createElement(\"a\")).href=e,t},o=i.Blob,n=r.createObjectURL,a=r.revokeObjectURL,s=i.Symbol&&i.Symbol.toStringTag,u=!1,f=!1,l=t&&t.prototype.append&&t.prototype.getBlob;try{u=2===new Blob([\"ä\"]).size,f=2===new Blob([new Uint8Array([1,2])]).size}catch(e){}function c(e){return e.map((function(e){if(e.buffer instanceof ArrayBuffer){var t=e.buffer;if(e.byteLength!==t.byteLength){var r=new Uint8Array(e.byteLength);r.set(new Uint8Array(t,e.byteOffset,e.byteLength)),t=r.buffer}return t}return e}))}function h(e,r){r=r||{};var o=new t;return c(e).forEach((function(e){o.append(e)})),r.type?o.getBlob(r.type):o.getBlob()}function d(e,t){return new o(c(e),t||{})}i.Blob&&(h.prototype=Blob.prototype,d.prototype=Blob.prototype);var p=\"function\"==typeof TextEncoder?TextEncoder.prototype.encode.bind(new TextEncoder):function(e){for(var t=0,r=e.length,o=i.Uint8Array||Array,n=0,a=Math.max(32,r+(r>>1)+7),s=new o(a>>3<<3);t<r;){var u=e.charCodeAt(t++);if(u>=55296&&u<=56319){if(t<r){var f=e.charCodeAt(t);56320==(64512&f)&&(++t,u=((1023&u)<<10)+(1023&f)+65536)}if(u>=55296&&u<=56319)continue}if(n+4>s.length){a+=8,a=(a*=1+t/e.length*2)>>3<<3;var l=new Uint8Array(a);l.set(s),s=l}if(0!=(4294967168&u)){if(0==(4294965248&u))s[n++]=u>>6&31|192;else if(0==(4294901760&u))s[n++]=u>>12&15|224,s[n++]=u>>6&63|128;else{if(0!=(4292870144&u))continue;s[n++]=u>>18&7|240,s[n++]=u>>12&63|128,s[n++]=u>>6&63|128}s[n++]=63&u|128}else s[n++]=u}return s.slice(0,n)},y=\"function\"==typeof TextDecoder?TextDecoder.prototype.decode.bind(new TextDecoder):function(e){for(var t=e.length,r=[],o=0;o<t;){var n,i,a,s,u=e[o],f=null,l=u>239?4:u>223?3:u>191?2:1;if(o+l<=t)switch(l){case 1:u<128&&(f=u);break;case 2:128==(192&(n=e[o+1]))&&(s=(31&u)<<6|63&n)>127&&(f=s);break;case 3:n=e[o+1],i=e[o+2],128==(192&n)&&128==(192&i)&&(s=(15&u)<<12|(63&n)<<6|63&i)>2047&&(s<55296||s>57343)&&(f=s);break;case 4:n=e[o+1],i=e[o+2],a=e[o+3],128==(192&n)&&128==(192&i)&&128==(192&a)&&(s=(15&u)<<18|(63&n)<<12|(63&i)<<6|63&a)>65535&&s<1114112&&(f=s)}null===f?(f=65533,l=1):f>65535&&(f-=65536,r.push(f>>>10&1023|55296),f=56320|1023&f),r.push(f),o+=l}for(var c=r.length,h=\"\",d=0;d<c;)h+=String.fromCharCode.apply(String,r.slice(d,d+=4096));return h};function b(){var t=!!i.ActiveXObject||\"-ms-scroll-limit\"in document.documentElement.style&&\"-ms-ime-align\"in document.documentElement.style,r=i.XMLHttpRequest&&i.XMLHttpRequest.prototype.send;t&&r&&(XMLHttpRequest.prototype.send=function(e){e instanceof Blob?(this.setRequestHeader(\"Content-Type\",e.type),r.call(this,e)):r.call(this,e)});try{new File([],\"\"),e.File=i.File,e.FileReader=i.FileReader}catch(t){try{e.File=new Function('class File extends Blob {constructor(chunks, name, opts) {opts = opts || {};super(chunks, opts || {});this.name = name.replace(/\\\\//g, \":\");this.lastModifiedDate = opts.lastModified ? new Date(opts.lastModified) : new Date();this.lastModified = +this.lastModifiedDate;}};return new File([], \"\"), File')()}catch(t){e.File=function(e,t,r){var o=new Blob(e,r),n=r&&void 0!==r.lastModified?new Date(r.lastModified):new Date;return o.name=t.replace(/\\//g,\":\"),o.lastModifiedDate=n,o.lastModified=+n,o.toString=function(){return\"[object File]\"},s&&(o[s]=\"File\"),o}}}}u?(b(),e.Blob=f?i.Blob:d):l?(b(),e.Blob=h):function(){function t(e){for(var t=new Array(e.byteLength),r=new Uint8Array(e),o=t.length;o--;)t[o]=r[o];return t}function o(e){for(var t=\"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=\",r=[],o=0;o<e.length;o+=3){var n=e[o],i=o+1<e.length,a=i?e[o+1]:0,s=o+2<e.length,u=s?e[o+2]:0,f=n>>2,l=(3&n)<<4|a>>4,c=(15&a)<<2|u>>6,h=63&u;s||(h=64,i||(c=64)),r.push(t[f],t[l],t[c],t[h])}return r.join(\"\")}var s=Object.create||function(e){function t(){}return t.prototype=e,new t};function u(e){return Object.prototype.toString.call(e).slice(8,-1)}function f(e,t){return\"object\"==typeof e&&Object.prototype.isPrototypeOf.call(e.prototype,t)}var l=[\"Int8Array\",\"Uint8Array\",\"Uint8ClampedArray\",\"Int16Array\",\"Uint16Array\",\"Int32Array\",\"Uint32Array\",\"Float32Array\",\"Float64Array\",\"ArrayBuffer\"];function c(e){return t=l,r=u(e),-1!==t.indexOf(r)||f(i.ArrayBuffer,e);var t,r}function h(e,r){r=null==r?{}:r;for(var o=0,n=(e=e?e.slice():[]).length;o<n;o++){var a=e[o];a instanceof h?e[o]=a._buffer:\"string\"==typeof a?e[o]=p(a):\"DataView\"===u(s=a)||f(i.DataView,s)?e[o]=t(a.buffer):c(a)?e[o]=t(a):e[o]=p(String(a))}var s;this._buffer=i.Uint8Array?function(e){for(var t=0,r=e.length;r--;)t+=e[r].length;for(var o=new Uint8Array(t),n=0,i=0;i<e.length;i++){var a=e[i];o.set(a,n),n+=a.byteLength||a.length}return o}(e):[].concat.apply([],e),this.size=this._buffer.length,this.type=r.type||\"\",/[^\\u0020-\\u007E]/.test(this.type)?this.type=\"\":this.type=this.type.toLowerCase()}function d(e,t,r){r=r||{};var o=h.call(this,e,r)||this;return o.name=t.replace(/\\//g,\":\"),o.lastModifiedDate=r.lastModified?new Date(r.lastModified):new Date,o.lastModified=+o.lastModifiedDate,o}if(h.prototype.arrayBuffer=function(){return Promise.resolve(this._buffer.buffer||this._buffer)},h.prototype.text=function(){return Promise.resolve(y(this._buffer))},h.prototype.slice=function(e,t,r){return new h([this._buffer.slice(e||0,t||this._buffer.length)],{type:r})},h.prototype.toString=function(){return\"[object Blob]\"},d.prototype=s(h.prototype),d.prototype.constructor=d,Object.setPrototypeOf)Object.setPrototypeOf(d,h);else try{d.__proto__=h}catch(e){}function b(){if(!(this instanceof b))throw new TypeError(\"Failed to construct 'FileReader': Please use the 'new' operator, this DOM object constructor cannot be called as a function.\");var e=document.createDocumentFragment();this.addEventListener=e.addEventListener,this.dispatchEvent=function(t){var r=this[\"on\"+t.type];\"function\"==typeof r&&r(t),e.dispatchEvent(t)},this.removeEventListener=e.removeEventListener}function w(e,t,r){if(!(t instanceof h))throw new TypeError(\"Failed to execute '\"+r+\"' on 'FileReader': parameter 1 is not of type 'Blob'.\");e.result=\"\",setTimeout((function(){this.readyState=b.LOADING,e.dispatchEvent(new Event(\"load\")),e.dispatchEvent(new Event(\"loadend\"))}))}d.prototype.toString=function(){return\"[object File]\"},b.EMPTY=0,b.LOADING=1,b.DONE=2,b.prototype.error=null,b.prototype.onabort=null,b.prototype.onerror=null,b.prototype.onload=null,b.prototype.onloadend=null,b.prototype.onloadstart=null,b.prototype.onprogress=null,b.prototype.readAsDataURL=function(e){w(this,e,\"readAsDataURL\"),this.result=\"data:\"+e.type+\";base64,\"+o(e._buffer)},b.prototype.readAsText=function(e){w(this,e,\"readAsText\"),this.result=y(e._buffer)},b.prototype.readAsArrayBuffer=function(e){w(this,e,\"readAsText\"),this.result=(e._buffer.buffer||e._buffer).slice()},b.prototype.abort=function(){},r.createObjectURL=function(e){return e instanceof h?\"data:\"+e.type+\";base64,\"+o(e._buffer):n.call(r,e)},r.revokeObjectURL=function(e){a&&a.call(r,e)};var m=i.XMLHttpRequest&&i.XMLHttpRequest.prototype.send;m&&(XMLHttpRequest.prototype.send=function(e){e instanceof h?(this.setRequestHeader(\"Content-Type\",e.type),m.call(this,y(e._buffer))):m.call(this,e)}),e.Blob=h,e.File=d,e.FileReader=b,e.URL=r}(),s&&(e.File.prototype[s]||(e.File.prototype[s]=\"File\"),e.Blob.prototype[s]||(e.Blob.prototype[s]=\"Blob\"),e.FileReader.prototype[s]||(e.FileReader.prototype[s]=\"FileReader\"));var w,m=e.Blob.prototype;try{new ReadableStream({type:\"bytes\"}),w=function(){var e=0,t=this;return new ReadableStream({type:\"bytes\",autoAllocateChunkSize:524288,pull:function(r){var o=r.byobRequest.view;return t.slice(e,e+o.byteLength).arrayBuffer().then((function(n){var i=new Uint8Array(n),a=i.byteLength;e+=a,o.set(i),r.byobRequest.respond(a),e>=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<b;){for(r=y<=w?32:b-y|0;g<r;y=y+1|0,g=g+1|0){switch((f=255&e[y])>>4){case 15:if(2!=(h=255&e[y=y+1|0])>>6||247<f){y=y-1|0;break}m=(7&f)<<6|63&h,v=5,f=256;case 14:m<<=6,m|=(15&f)<<6|63&(h=255&e[y=y+1|0]),v=2==h>>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<b&&2==h>>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<b){if(d[0]=A,g=~A>>>31,A=-1,p.length<t.length)continue}else-1!==A&&(p+=o(A));t+=p,p=\"\"}return t},f.encode=function(e){var t,r=0|(e=void 0===e?\"\":\"\"+e).length,o=new u(8+(r<<1)|0),n=0,i=!s;for(t=0;t<r;t=t+1|0,n=n+1|0){var a=0|e.charCodeAt(t);if(127>=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<n&&t<<1<(n-7|0)&&(i=!0,(f=new u(3*r)).set(o),o=f)}o[n]=224|a>>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<r;t++)e[t]=Math.floor(256*Math.random());return e},randomUUID(){return\"10000000-1000-4000-8000-100000000000\".replace(/[018]/g,(e=>(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<t.length;o++)r[o]=String.fromCharCode(t[o]);return r.join(\"\")}(this._bodyArrayBuffer));if(this._bodyFormData)throw new Error(\"could not read FormData body as text\");return Promise.resolve(this._bodyText)},o.formData&&(this.formData=function(){return this.text().then(w)}),this.json=function(){return this.text().then(JSON.parse)},this}f.prototype.append=function(e,t){e=a(e),t=s(t);var r=this.map[e];this.map[e]=r?r+\", \"+t:t},f.prototype.delete=function(e){delete this.map[a(e)]},f.prototype.get=function(e){return e=a(e),this.has(e)?this.map[e]:null},f.prototype.has=function(e){return this.map.hasOwnProperty(a(e))},f.prototype.set=function(e,t){this.map[a(e)]=s(t)},f.prototype.forEach=function(e,t){for(var r in this.map)this.map.hasOwnProperty(r)&&e.call(t,this.map[r],r,this)},f.prototype.keys=function(){var e=[];return this.forEach((function(t,r){e.push(r)})),u(e)},f.prototype.values=function(){var e=[];return this.forEach((function(t){e.push(t)})),u(e)},f.prototype.entries=function(){var e=[];return this.forEach((function(t,r){e.push([r,t])})),u(e)},o.iterable&&(f.prototype[Symbol.iterator]=f.prototype.entries);var y=[\"CONNECT\",\"DELETE\",\"GET\",\"HEAD\",\"OPTIONS\",\"PATCH\",\"POST\",\"PUT\",\"TRACE\"];function b(e,r){if(!(this instanceof b))throw new TypeError('Please use the \"new\" operator, this DOM object constructor cannot be called as a function.');var o,n,i=(r=r||{}).body;if(e instanceof b){if(e.bodyUsed)throw new TypeError(\"Already read\");this.url=e.url,this.credentials=e.credentials,r.headers||(this.headers=new f(e.headers)),this.method=e.method,this.mode=e.mode,this.signal=e.signal,i||null==e._bodyInit||(i=e._bodyInit,e.bodyUsed=!0)}else this.url=String(e);if(this.credentials=r.credentials||this.credentials||\"same-origin\",!r.headers&&this.headers||(this.headers=new f(r.headers)),this.method=(n=(o=r.method||this.method||\"GET\").toUpperCase(),y.indexOf(n)>-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)})()})();"
  },
  {
    "path": "pkg/download/engine/polyfill/package.json",
    "content": "{\n  \"name\": \"polyfill\",\n  \"version\": \"1.0.0\",\n  \"description\": \"\",\n  \"main\": \"index.js\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"test\": \"echo \\\"Error: no test specified\\\" && exit 1\",\n    \"dev\": \"webpack --mode production --watch\",\n    \"build\": \"webpack --mode production\",\n    \"postinstall\": \"patch-package\"\n  },\n  \"author\": \"\",\n  \"license\": \"ISC\",\n  \"devDependencies\": {\n    \"patch-package\": \"^8.0.0\",\n    \"webpack\": \"^5.75.0\",\n    \"webpack-cli\": \"^5.0.1\"\n  },\n  \"dependencies\": {\n    \"blob-polyfill\": \"^7.0.20220408\",\n    \"fastestsmallesttextencoderdecoder\": \"^1.0.22\",\n    \"whatwg-fetch\": \"^3.6.20\"\n  }\n}\n"
  },
  {
    "path": "pkg/download/engine/polyfill/patches/whatwg-fetch+3.6.20.patch",
    "content": "diff --git a/node_modules/whatwg-fetch/dist/fetch.umd.js b/node_modules/whatwg-fetch/dist/fetch.umd.js\nindex 7a0d852..604691e 100644\n--- a/node_modules/whatwg-fetch/dist/fetch.umd.js\n+++ b/node_modules/whatwg-fetch/dist/fetch.umd.js\n@@ -394,6 +394,7 @@\n       }\n     }());\n     this.referrer = null;\n+    this.redirect = options.redirect || 'follow'\n \n     if ((this.method === 'GET' || this.method === 'HEAD') && body) {\n       throw new TypeError('Body not allowed for GET or HEAD requests')\n@@ -606,6 +607,10 @@\n         }\n       }\n \n+      if ('redirect' in xhr) {\n+        xhr.redirect = request.redirect\n+      }\n+\n       if (init && typeof init.headers === 'object' && !(init.headers instanceof Headers || (g.Headers && init.headers instanceof g.Headers))) {\n         var names = [];\n         Object.getOwnPropertyNames(init.headers).forEach(function(name) {\ndiff --git a/node_modules/whatwg-fetch/fetch.js b/node_modules/whatwg-fetch/fetch.js\nindex f39a983..d1fc903 100644\n--- a/node_modules/whatwg-fetch/fetch.js\n+++ b/node_modules/whatwg-fetch/fetch.js\n@@ -388,6 +388,7 @@ export function Request(input, options) {\n     }\n   }());\n   this.referrer = null\n+  this.redirect = options.redirect || 'follow'\n \n   if ((this.method === 'GET' || this.method === 'HEAD') && body) {\n     throw new TypeError('Body not allowed for GET or HEAD requests')\n@@ -600,6 +601,10 @@ export function fetch(input, init) {\n       }\n     }\n \n+    if ('redirect' in xhr) {\n+      xhr.redirect = request.redirect\n+    }\n+\n     if (init && typeof init.headers === 'object' && !(init.headers instanceof Headers || (g.Headers && init.headers instanceof g.Headers))) {\n       var names = [];\n       Object.getOwnPropertyNames(init.headers).forEach(function(name) {\n"
  },
  {
    "path": "pkg/download/engine/polyfill/src/blob/index.js",
    "content": "import { Blob } from \"blob-polyfill\";\n\nglobalThis.Blob = Blob;"
  },
  {
    "path": "pkg/download/engine/polyfill/src/crypto/index.js",
    "content": "globalThis.crypto = {\n    getRandomValues(arr) {\n        for (let i = 0, len = arr.length; i < len; i++) {\n            arr[i] = Math.floor(Math.random() * 256);\n        }\n        return arr;\n    },\n    randomUUID() {\n        return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c) => {\n            return (c ^ (this.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16)\n        })\n    }\n}"
  },
  {
    "path": "pkg/download/engine/polyfill/src/fetch/index.js",
    "content": "import 'whatwg-fetch'"
  },
  {
    "path": "pkg/download/engine/polyfill/src/index.js",
    "content": "import \"./blob/index.js\"\nimport \"./crypto/index.js\"\n// polyfill TextEncoder\nimport 'fastestsmallesttextencoderdecoder';\nimport \"./fetch/index.js\""
  },
  {
    "path": "pkg/download/engine/polyfill/webpack.config.js",
    "content": "import path from \"path\";\nimport { fileURLToPath } from \"url\";\n\nconst __dirname = fileURLToPath(import.meta.url);\n\nexport default {\n  entry: \"./src/index.js\",\n  output: {\n    filename: \"index.js\",\n    path: path.resolve(__dirname, \"../out\"),\n  },\n};\n"
  },
  {
    "path": "pkg/download/engine/util/util.go",
    "content": "package util\n\nimport (\n\t\"github.com/dop251/goja\"\n)\n\nfunc ThrowTypeError(vm *goja.Runtime, msg string) {\n\tpanic(vm.NewTypeError(msg))\n}\n\nfunc AssertError[T error](err error) (t T, r bool) {\n\tif err == nil {\n\t\treturn\n\t}\n\tif e, ok := err.(T); ok {\n\t\treturn e, true\n\t}\n\tif e, ok := err.(*goja.Exception); ok {\n\t\tif ee, okk := e.Value().Export().(T); okk {\n\t\t\treturn ee, true\n\t\t}\n\t}\n\treturn\n}\n\nfunc SafeGet[T any](vm *goja.Runtime, name string) T {\n\tv := vm.Get(name)\n\tif v == nil {\n\t\tvar init T\n\t\treturn init\n\t}\n\tif e, ok := v.Export().(T); ok {\n\t\treturn e\n\t}\n\tvar init T\n\treturn init\n}\n"
  },
  {
    "path": "pkg/download/event.go",
    "content": "package download\n\ntype EventKey string\n\nconst (\n\tEventKeyStart    = \"start\"\n\tEventKeyPause    = \"pause\"\n\tEventKeyProgress = \"progress\"\n\tEventKeyError    = \"error\"\n\tEventKeyDelete   = \"delete\"\n\tEventKeyDone     = \"done\"\n\tEventKeyFinally  = \"finally\"\n)\n\ntype Event struct {\n\tKey  EventKey\n\tTask *Task\n\tErr  error\n}\n"
  },
  {
    "path": "pkg/download/extension.go",
    "content": "package download\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"path\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/GopeedLab/gopeed/internal/logger\"\n\t\"github.com/GopeedLab/gopeed/pkg/base\"\n\t\"github.com/GopeedLab/gopeed/pkg/download/engine\"\n\tgojaerror \"github.com/GopeedLab/gopeed/pkg/download/engine/inject/error\"\n\tgojautil \"github.com/GopeedLab/gopeed/pkg/download/engine/util\"\n\t\"github.com/GopeedLab/gopeed/pkg/util\"\n\t\"github.com/dop251/goja\"\n\t\"github.com/go-git/go-git/v5\"\n\t\"github.com/go-git/go-git/v5/plumbing/transport\"\n)\n\nvar (\n\tgitSuffix = \".git\"\n\n\ttempExtensionsDir   = \".extensions\"\n\textensionsDir       = \"extensions\"\n\textensionIgnoreDirs = []string{gitSuffix, \"node_modules\"}\n\n\tErrExtensionNoManifest = fmt.Errorf(\"manifest.json not found\")\n\tErrExtensionNotFound   = fmt.Errorf(\"extension not found\")\n)\n\ntype ActivationEvent string\n\nconst (\n\tEventOnResolve ActivationEvent = \"onResolve\"\n\tEventOnStart   ActivationEvent = \"onStart\"\n\tEventOnError   ActivationEvent = \"onError\"\n\tEventOnDone    ActivationEvent = \"onDone\"\n)\n\nfunc (d *Downloader) InstallExtensionByGit(url string) (*Extension, error) {\n\treturn d.fetchExtensionByGit(url, d.InstallExtensionByFolder)\n}\n\nfunc (d *Downloader) InstallExtensionByFolder(path string, devMode bool) (*Extension, error) {\n\text, err := d.parseExtensionByPath(path)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// if dev mode, don't copy to the extensions' directory\n\tif devMode {\n\t\text.DevMode = true\n\t\text.DevPath, _ = filepath.Abs(path)\n\t} else {\n\t\tif err = util.CopyDir(path, d.ExtensionPath(ext), extensionIgnoreDirs...); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\t// if extension is not installed, add it to the list, otherwise update it\n\tinstalledExt := d.getExtension(ext.Identity)\n\tif installedExt == nil {\n\t\text.CreatedAt = time.Now()\n\t\text.UpdatedAt = ext.CreatedAt\n\t\tinstalledExt = ext\n\t\td.extensions = append(d.extensions, installedExt)\n\t} else {\n\t\tinstalledExt.update(ext)\n\t}\n\tif err = d.storage.Put(bucketExtension, installedExt.Identity, installedExt); err != nil {\n\t\treturn nil, err\n\t}\n\treturn installedExt, nil\n}\n\n// UpgradeCheckExtension Check if there is a new version for the extension.\nfunc (d *Downloader) UpgradeCheckExtension(identity string) (newVersion string, err error) {\n\text, err := d.GetExtension(identity)\n\tif err != nil {\n\t\treturn\n\t}\n\tinstallUrl := ext.buildInstallUrl()\n\tif installUrl == \"\" {\n\t\treturn\n\t}\n\t_, err = d.fetchExtensionByGit(installUrl, func(tempExtPath string, devMode bool) (*Extension, error) {\n\t\ttempExt, err := d.parseExtensionByPath(tempExtPath)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif tempExt.Version != ext.Version {\n\t\t\tnewVersion = tempExt.Version\n\t\t}\n\t\treturn tempExt, nil\n\t})\n\treturn\n}\n\nfunc (d *Downloader) UpgradeExtension(identity string) error {\n\text, err := d.GetExtension(identity)\n\tif err != nil {\n\t\treturn err\n\t}\n\tinstallUrl := ext.buildInstallUrl()\n\tif installUrl == \"\" {\n\t\treturn nil\n\t}\n\tif _, err := d.InstallExtensionByGit(installUrl); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc (d *Downloader) UpdateExtensionSettings(identity string, settings map[string]any) error {\n\text, err := d.GetExtension(identity)\n\tif err != nil {\n\t\treturn err\n\t}\n\tfor _, setting := range ext.Settings {\n\t\tif value, ok := settings[setting.Name]; ok {\n\t\t\tsetting.Value = tryParse(value, setting.Type)\n\t\t}\n\t}\n\treturn d.storage.Put(bucketExtension, ext.Identity, ext)\n}\n\nfunc (d *Downloader) SwitchExtension(identity string, status bool) error {\n\text, err := d.GetExtension(identity)\n\tif err != nil {\n\t\treturn err\n\t}\n\text.Disabled = !status\n\treturn d.storage.Put(bucketExtension, ext.Identity, ext)\n}\n\nfunc (d *Downloader) DeleteExtension(identity string) error {\n\text, err := d.GetExtension(identity)\n\tif err != nil {\n\t\treturn err\n\t}\n\t// remove from disk\n\tif !ext.DevMode {\n\t\tif err := os.RemoveAll(d.ExtensionPath(ext)); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\t// remove from extensions\n\tfor i, e := range d.extensions {\n\t\tif e.Identity == identity {\n\t\t\td.extensions = append(d.extensions[:i], d.extensions[i+1:]...)\n\t\t\tbreak\n\t\t}\n\t}\n\tif err = d.storage.Delete(bucketExtension, identity); err != nil {\n\t\treturn err\n\t}\n\tif err = d.storage.Delete(bucketExtensionStorage, identity); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc (d *Downloader) GetExtensions() []*Extension {\n\treturn d.extensions\n}\n\nfunc (d *Downloader) GetExtension(identity string) (*Extension, error) {\n\textension := d.getExtension(identity)\n\tif extension == nil {\n\t\treturn nil, ErrExtensionNotFound\n\t}\n\treturn extension, nil\n}\n\nfunc (d *Downloader) getExtension(identity string) *Extension {\n\tfor _, ext := range d.extensions {\n\t\tif ext.Identity == identity {\n\t\t\treturn ext\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (d *Downloader) fetchExtensionByGit(url string, handler func(tempExtPath string, devMode bool) (*Extension, error)) (*Extension, error) {\n\tif !strings.HasPrefix(url, \"https://\") && !strings.HasPrefix(url, \"http://\") {\n\t\turl = \"https://\" + url\n\t}\n\n\t// resolve project path\n\tparentPath, projectPath := path.Split(url)\n\t// resolve project name and sub path\n\tpathArr := strings.SplitN(projectPath, \"#\", 2)\n\tprojectPath = strings.TrimSuffix(pathArr[0], gitSuffix)\n\tsubPath := \"\"\n\tif len(pathArr) > 1 {\n\t\tsubPath = pathArr[1]\n\t}\n\n\t// Use unique suffix to avoid concurrent conflicts when multiple git operations\n\t// target the same extension (e.g., install and update check happening simultaneously)\n\ttempExtDir := filepath.Join(d.cfg.StorageDir, tempExtensionsDir, fmt.Sprintf(\"%s_%d\", projectPath, time.Now().UnixNano()))\n\tif err := os.MkdirAll(tempExtDir, 0755); err != nil {\n\t\treturn nil, err\n\t}\n\tdefer os.RemoveAll(tempExtDir)\n\n\tproxyOptions := transport.ProxyOptions{}\n\tproxyUrl := d.cfg.DownloaderStoreConfig.Proxy.ToUrl()\n\tif proxyUrl != nil {\n\t\tproxyOptions.URL = proxyUrl.Scheme + \"://\" + proxyUrl.Host\n\t\tproxyOptions.Username = proxyUrl.User.Username()\n\t\tproxyOptions.Password, _ = proxyUrl.User.Password()\n\t}\n\t// clone project to extension temp dir\n\tgitUrl := parentPath + projectPath + gitSuffix\n\tif _, err := git.PlainClone(tempExtDir, false, &git.CloneOptions{\n\t\tURL:          gitUrl,\n\t\tDepth:        1,\n\t\tProxyOptions: proxyOptions,\n\t}); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn handler(filepath.Join(tempExtDir, subPath), false)\n}\n\nfunc (d *Downloader) parseExtensionByPath(path string) (*Extension, error) {\n\t// resolve extension manifest\n\tmanifestTempPath := filepath.Join(path, \"manifest.json\")\n\tif _, err := os.Stat(manifestTempPath); os.IsNotExist(err) {\n\t\treturn nil, ErrExtensionNoManifest\n\t}\n\tfile, err := os.ReadFile(manifestTempPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar ext Extension\n\tif err = json.Unmarshal(file, &ext); err != nil {\n\t\treturn nil, err\n\t}\n\tif err = ext.validate(); err != nil {\n\t\treturn nil, err\n\t}\n\text.Identity = ext.buildIdentity()\n\treturn &ext, nil\n}\n\nfunc (d *Downloader) triggerOnResolve(req *base.Request) (res *base.Resource, err error) {\n\terr = doTrigger(d,\n\t\tEventOnResolve,\n\t\treq,\n\t\t&OnResolveContext{\n\t\t\tReq: req,\n\t\t},\n\t\tfunc(ext *Extension, gopeed *Instance, ctx *OnResolveContext) {\n\t\t\t// Validate resource structure\n\t\t\tif ctx.Res != nil && len(ctx.Res.Files) > 0 {\n\t\t\t\tif err := ctx.Res.Validate(); err != nil {\n\t\t\t\t\tgopeed.Logger.logger.Warn().Err(err).Msgf(\"[%s] resource invalid\", ext.buildIdentity())\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tctx.Res.Name = util.SafeFilename(ctx.Res.Name)\n\t\t\t\tfor _, file := range ctx.Res.Files {\n\t\t\t\t\tfile.Name = util.SafeFilename(file.Name)\n\t\t\t\t}\n\t\t\t\tctx.Res.CalcSize(nil)\n\t\t\t}\n\t\t\tres = ctx.Res\n\t\t},\n\t)\n\treturn\n}\n\nfunc (d *Downloader) triggerOnStart(task *Task) {\n\tdoTrigger(d,\n\t\tEventOnStart,\n\t\ttask.Meta.Req,\n\t\t&OnStartContext{\n\t\t\tTask: NewExtensionTask(d, task),\n\t\t},\n\t\tfunc(ext *Extension, gopeed *Instance, ctx *OnStartContext) {\n\t\t\t// Validate request structure\n\t\t\tif ctx.Task.Meta.Req != nil {\n\t\t\t\tif err := ctx.Task.Meta.Req.Validate(); err != nil {\n\t\t\t\t\tgopeed.Logger.logger.Warn().Err(err).Msgf(\"[%s] request invalid\", ext.buildIdentity())\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t)\n\treturn\n}\n\nfunc (d *Downloader) triggerOnError(task *Task, err error) {\n\tdoTrigger(d,\n\t\tEventOnError,\n\t\ttask.Meta.Req,\n\t\t&OnErrorContext{\n\t\t\tTask:  NewExtensionTask(d, task),\n\t\t\tError: err,\n\t\t},\n\t\tnil,\n\t)\n}\n\nfunc (d *Downloader) triggerOnDone(task *Task) {\n\tdoTrigger(d,\n\t\tEventOnDone,\n\t\ttask.Meta.Req,\n\t\t&OnErrorContext{\n\t\t\tTask: NewExtensionTask(d, task),\n\t\t},\n\t\tnil,\n\t)\n}\n\nfunc doTrigger[T any](d *Downloader, event ActivationEvent, req *base.Request, ctx T, handler func(ext *Extension, gopeed *Instance, ctx T)) error {\n\t// init extension global object\n\tgopeed := &Instance{\n\t\tEvents: make(InstanceEvents),\n\t}\n\tvar err error\n\tfor _, ext := range d.extensions {\n\t\tif ext.Disabled {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, script := range ext.Scripts {\n\t\t\tif script.match(event, req) {\n\t\t\t\tgopeed.Info = NewExtensionInfo(ext)\n\t\t\t\tgopeed.Logger = newInstanceLogger(ext, d.ExtensionLogger)\n\t\t\t\tgopeed.Settings = parseSettings(ext.Settings)\n\t\t\t\tgopeed.Storage = &ContextStorage{\n\t\t\t\t\tstorage:  d.storage,\n\t\t\t\t\tidentity: ext.buildIdentity(),\n\t\t\t\t}\n\t\t\t\tscriptFilePath := filepath.Join(d.ExtensionPath(ext), script.Entry)\n\t\t\t\tif _, err = os.Stat(scriptFilePath); os.IsNotExist(err) {\n\t\t\t\t\tgopeed.Logger.logger.Error().Err(err).Msgf(\"[%s] script file not exist\", ext.buildIdentity())\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tfunc() {\n\t\t\t\t\tvar scriptFile *os.File\n\t\t\t\t\tscriptFile, err = os.Open(scriptFilePath)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tgopeed.Logger.logger.Error().Err(err).Msgf(\"[%s] open script file failed\", ext.buildIdentity())\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tdefer scriptFile.Close()\n\t\t\t\t\tvar scriptBuf []byte\n\t\t\t\t\tscriptBuf, err = io.ReadAll(scriptFile)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tgopeed.Logger.logger.Error().Err(err).Msgf(\"[%s] read script file failed\", ext.buildIdentity())\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\t// Init request labels\n\t\t\t\t\tif req.Labels == nil {\n\t\t\t\t\t\treq.Labels = make(map[string]string)\n\t\t\t\t\t}\n\t\t\t\t\tengine := engine.NewEngine(&engine.Config{\n\t\t\t\t\t\tProxyConfig: d.cfg.Proxy,\n\t\t\t\t\t})\n\t\t\t\t\tdefer engine.Close()\n\t\t\t\t\terr = engine.Runtime.Set(\"gopeed\", gopeed)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tgopeed.Logger.logger.Error().Err(err).Msgf(\"[%s] engine inject failed\", ext.buildIdentity())\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\t_, err = engine.RunString(string(scriptBuf))\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tgopeed.Logger.logger.Error().Err(err).Msgf(\"[%s] run script failed\", ext.buildIdentity())\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tif fn, ok := gopeed.Events[event]; ok {\n\t\t\t\t\t\t_, err = engine.CallFunction(fn, ctx)\n\t\t\t\t\t\tif err != nil {\n\t\t\t\t\t\t\tgopeed.Logger.logger.Error().Err(err).Msgf(\"[%s] call function failed: %s\", ext.buildIdentity(), event)\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif handler != nil {\n\t\t\t\t\t\t\thandler(ext, gopeed, ctx)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}()\n\t\t\t}\n\t\t}\n\t}\n\n\t// Only return MessageError\n\tif me, ok := gojautil.AssertError[*gojaerror.MessageError](err); ok {\n\t\treturn me\n\t}\n\treturn nil\n}\n\nfunc (d *Downloader) ExtensionPath(ext *Extension) string {\n\tif ext.DevMode {\n\t\treturn ext.DevPath\n\t}\n\treturn filepath.Join(d.cfg.StorageDir, extensionsDir, ext.Identity)\n}\n\ntype Extension struct {\n\t// Identity is global unique for an extension, it's a combination of author and name\n\tIdentity    string `json:\"identity\"`\n\tName        string `json:\"name\"`\n\tAuthor      string `json:\"author\"`\n\tTitle       string `json:\"title\"`\n\tDescription string `json:\"description\"`\n\tIcon        string `json:\"icon\"`\n\t// Version semantic version string, like: 1.0.0\n\tVersion string `json:\"version\"`\n\t// Homepage homepage url\n\tHomepage string `json:\"homepage\"`\n\t// Repository git repository info\n\tRepository *Repository `json:\"repository\"`\n\tScripts    []*Script   `json:\"scripts\"`\n\tSettings   []*Setting  `json:\"settings\"`\n\t// Disabled if true, this extension will be ignored\n\tDisabled bool `json:\"disabled\"`\n\n\tDevMode bool `json:\"devMode\"`\n\t// DevPath is the local path of extension source code\n\tDevPath string `json:\"devPath\"`\n\n\tCreatedAt time.Time `json:\"createdAt\"`\n\tUpdatedAt time.Time `json:\"updatedAt\"`\n}\n\nfunc (e *Extension) validate() error {\n\tif e.Name == \"\" {\n\t\treturn fmt.Errorf(\"extension name is required\")\n\t}\n\tif e.Title == \"\" {\n\t\treturn fmt.Errorf(\"extension title is required\")\n\t}\n\tif e.Version == \"\" {\n\t\treturn fmt.Errorf(\"extension version is required\")\n\t}\n\treturn nil\n}\n\nfunc (e *Extension) buildIdentity() string {\n\tif e.Author == \"\" {\n\t\treturn e.Name\n\t}\n\treturn e.Author + \"@\" + e.Name\n}\n\nfunc (e *Extension) buildInstallUrl() string {\n\tif e.Repository == nil || e.Repository.Url == \"\" {\n\t\treturn \"\"\n\t}\n\trepoUrl := e.Repository.Url\n\tif e.Repository.Directory != \"\" {\n\t\tif strings.HasSuffix(repoUrl, \"/\") {\n\t\t\trepoUrl = repoUrl[:len(repoUrl)-1]\n\t\t}\n\t\tdir := e.Repository.Directory\n\t\tif strings.HasPrefix(dir, \"/\") {\n\t\t\tdir = dir[1:]\n\t\t}\n\t\trepoUrl = repoUrl + \"#\" + dir\n\t}\n\treturn repoUrl\n}\n\nfunc (e *Extension) update(newExt *Extension) error {\n\te.Title = newExt.Title\n\te.Description = newExt.Description\n\te.Icon = newExt.Icon\n\te.Version = newExt.Version\n\te.Homepage = newExt.Homepage\n\te.Repository = newExt.Repository\n\te.Scripts = newExt.Scripts\n\t// merge settings\n\t// if new setting not exist in old settings, append it\n\tfor _, newSetting := range newExt.Settings {\n\t\tvar exist bool\n\t\tfor _, setting := range e.Settings {\n\t\t\tif setting.Name == newSetting.Name {\n\t\t\t\texist = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif !exist {\n\t\t\te.Settings = append(e.Settings, newSetting)\n\t\t}\n\t}\n\t// if old setting not exist in new settings, remove it\n\tfor i := 0; i < len(e.Settings); i++ {\n\t\tvar exist bool\n\t\tfor _, setting := range newExt.Settings {\n\t\t\tif setting.Name == e.Settings[i].Name {\n\t\t\t\texist = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif !exist {\n\t\t\te.Settings = append(e.Settings[:i], e.Settings[i+1:]...)\n\t\t\ti--\n\t\t}\n\t}\n\t// if new setting exist in old settings, update it\n\tfor _, newSetting := range newExt.Settings {\n\t\tfor _, setting := range e.Settings {\n\t\t\tif setting.Name == newSetting.Name {\n\t\t\t\tsetting.Title = newSetting.Title\n\t\t\t\tsetting.Description = newSetting.Description\n\t\t\t\tsetting.Options = newSetting.Options\n\t\t\t\t// if type changed, reset value\n\t\t\t\tif setting.Type != newSetting.Type {\n\t\t\t\t\tsetting.Type = newSetting.Type\n\t\t\t\t\tsetting.Value = newSetting.Value\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\te.UpdatedAt = time.Now()\n\treturn nil\n}\n\ntype Repository struct {\n\tUrl       string `json:\"url\"`\n\tDirectory string `json:\"directory\"`\n}\n\ntype Script struct {\n\t// Event active event name\n\tEvent string `json:\"event\"`\n\t// Match rules\n\tMatch *Match `json:\"match\"`\n\t// Entry js script file path\n\tEntry string `json:\"entry\"`\n}\n\nfunc (s *Script) match(event ActivationEvent, req *base.Request) bool {\n\tif s.Event == \"\" {\n\t\treturn false\n\t}\n\tif s.Event != string(event) {\n\t\treturn false\n\t}\n\tif s.Match == nil || (len(s.Match.Urls) == 0 && len(s.Match.Labels) == 0) {\n\t\treturn false\n\t}\n\n\t// match url\n\tfor _, url := range s.Match.Urls {\n\t\tif util.Match(url, req.URL) {\n\t\t\treturn true\n\t\t}\n\t}\n\n\t// match label\n\tfor _, label := range s.Match.Labels {\n\t\tif _, ok := req.Labels[label]; ok {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\ntype Match struct {\n\t// Urls match expression, refer to https://developer.chrome.com/docs/extensions/mv3/match_patterns/\n\tUrls []string `json:\"urls\"`\n\t// Labels match request labels\n\tLabels []string `json:\"labels\"`\n}\n\ntype SettingType string\n\nconst (\n\tSettingTypeString  SettingType = \"string\"\n\tSettingTypeNumber  SettingType = \"number\"\n\tSettingTypeBoolean SettingType = \"boolean\"\n)\n\ntype Setting struct {\n\tName        string `json:\"name\"`\n\tTitle       string `json:\"title\"`\n\tDescription string `json:\"description\"`\n\tRequired    bool   `json:\"required\"`\n\t// setting type\n\tType SettingType `json:\"type\"`\n\t// setting value\n\tValue any `json:\"value\"`\n\t//Multiple bool      `json:\"multiple\"`\n\tOptions []*Option `json:\"options\"`\n}\n\ntype Option struct {\n\tLabel string `json:\"label\"`\n\tValue any    `json:\"value\"`\n}\n\n// Instance inject to js context when extension script is activated\ntype Instance struct {\n\tEvents   InstanceEvents  `json:\"events\"`\n\tInfo     *ExtensionInfo  `json:\"info\"`\n\tLogger   *InstanceLogger `json:\"logger\"`\n\tSettings map[string]any  `json:\"settings\"`\n\tStorage  *ContextStorage `json:\"storage\"`\n}\n\ntype InstanceEvents map[ActivationEvent]goja.Callable\n\nfunc (h InstanceEvents) register(name ActivationEvent, fn goja.Callable) {\n\th[name] = fn\n}\n\nfunc (h InstanceEvents) OnResolve(fn goja.Callable) {\n\th.register(EventOnResolve, fn)\n}\n\nfunc (h InstanceEvents) OnStart(fn goja.Callable) {\n\th.register(EventOnStart, fn)\n}\n\nfunc (h InstanceEvents) OnError(fn goja.Callable) {\n\th.register(EventOnError, fn)\n}\n\nfunc (h InstanceEvents) OnDone(fn goja.Callable) {\n\th.register(EventOnDone, fn)\n}\n\ntype ExtensionInfo struct {\n\tIdentity string `json:\"identity\"`\n\tName     string `json:\"name\"`\n\tAuthor   string `json:\"author\"`\n\tTitle    string `json:\"title\"`\n\tVersion  string `json:\"version\"`\n}\n\nfunc NewExtensionInfo(ext *Extension) *ExtensionInfo {\n\treturn &ExtensionInfo{\n\t\tIdentity: ext.buildIdentity(),\n\t\tName:     ext.Name,\n\t\tAuthor:   ext.Author,\n\t\tTitle:    ext.Title,\n\t\tVersion:  ext.Version,\n\t}\n}\n\ntype InstanceLogger struct {\n\tidentity string\n\tdevMode  bool\n\tlogger   *logger.Logger\n}\n\nfunc (l *InstanceLogger) Debug(msg ...goja.Value) {\n\tif l.devMode {\n\t\tl.logger.Debug().Msg(l.append(msg...))\n\t}\n}\n\nfunc (l *InstanceLogger) Info(msg ...goja.Value) {\n\tl.logger.Info().Msg(l.append(msg...))\n}\n\nfunc (l *InstanceLogger) Warn(msg ...goja.Value) {\n\tl.logger.Warn().Msg(l.append(msg...))\n}\n\nfunc (l *InstanceLogger) Error(msg ...goja.Value) {\n\tl.logger.Error().Msg(l.append(msg...))\n}\n\nfunc (l *InstanceLogger) append(msg ...goja.Value) string {\n\tstrMsg := make([]string, len(msg))\n\tfor i, m := range msg {\n\t\tstrMsg[i] = m.String()\n\t}\n\treturn fmt.Sprintf(\"[%s] %s\", l.identity, strings.Join(strMsg, \" \"))\n}\n\nfunc newInstanceLogger(extension *Extension, logger *logger.Logger) *InstanceLogger {\n\treturn &InstanceLogger{\n\t\tidentity: extension.buildIdentity(),\n\t\tdevMode:  extension.DevMode,\n\t\tlogger:   logger,\n\t}\n}\n\ntype OnResolveContext struct {\n\tReq *base.Request  `json:\"req\"`\n\tRes *base.Resource `json:\"res\"`\n}\n\ntype OnStartContext struct {\n\tTask *ExtensionTask `json:\"task\"`\n}\n\ntype OnErrorContext struct {\n\tTask  *ExtensionTask `json:\"task\"`\n\tError error          `json:\"error\"`\n}\n\ntype OnDoneContext struct {\n\tTask *Task `json:\"task\"`\n}\n\n// ExtensionTask is a wrapper of Task, it's used to interact with extension scripts.\n// Avoid extension scripts modifying task directly, use ExtensionTask to encapsulate task,\n// only some fields can be modified, such as request info.\ntype ExtensionTask struct {\n\tdownload *Downloader\n\n\t*Task\n}\n\nfunc NewExtensionTask(download *Downloader, task *Task) *ExtensionTask {\n\t// restricts extension scripts to only modify request info\n\tnewTask := task.clone()\n\tnewTask.Meta.Req = task.Meta.Req\n\treturn &ExtensionTask{\n\t\tdownload: download,\n\t\tTask:     newTask,\n\t}\n}\n\nfunc (t *ExtensionTask) Continue() error {\n\treturn t.download.Continue(&TaskFilter{\n\t\tIDs: []string{t.ID},\n\t})\n}\n\nfunc (t *ExtensionTask) Pause() error {\n\treturn t.download.Pause(&TaskFilter{\n\t\tIDs: []string{t.ID},\n\t})\n}\n\nfunc parseSettings(settings []*Setting) map[string]any {\n\tm := make(map[string]any)\n\tfor _, s := range settings {\n\t\tvar val any\n\t\tif s.Value != nil {\n\t\t\tval = s.Value\n\t\t}\n\t\tm[s.Name] = tryParse(val, s.Type)\n\t}\n\treturn m\n}\n\nfunc tryParse(val any, settingType SettingType) any {\n\tif val == nil {\n\t\treturn nil\n\t}\n\tswitch settingType {\n\tcase SettingTypeString:\n\t\treturn fmt.Sprint(val)\n\tcase SettingTypeNumber:\n\t\tvv, err := strconv.ParseFloat(fmt.Sprint(val), 64)\n\t\tif err != nil {\n\t\t\treturn nil\n\t\t}\n\t\treturn vv\n\tcase SettingTypeBoolean:\n\t\tvv, err := strconv.ParseBool(fmt.Sprint(val))\n\t\tif err != nil {\n\t\t\treturn nil\n\t\t}\n\t\treturn vv\n\tdefault:\n\t\treturn nil\n\t}\n}\n\ntype ContextStorage struct {\n\tstorage  Storage\n\tidentity string\n}\n\nfunc (s *ContextStorage) Get(key string) any {\n\traw := s.getRawData()\n\tif v, ok := raw[key]; ok {\n\t\treturn v\n\t}\n\treturn nil\n}\n\nfunc (s *ContextStorage) Set(key string, value string) {\n\traw := s.getRawData()\n\traw[key] = value\n\ts.storage.Put(bucketExtensionStorage, s.identity, raw)\n}\n\nfunc (s *ContextStorage) Remove(key string) {\n\traw := s.getRawData()\n\tdelete(raw, key)\n\ts.storage.Put(bucketExtensionStorage, s.identity, raw)\n}\n\nfunc (s *ContextStorage) Keys() []string {\n\traw := s.getRawData()\n\tkeys := make([]string, 0)\n\tfor k := range raw {\n\t\tkeys = append(keys, k)\n\t}\n\treturn keys\n}\n\nfunc (s *ContextStorage) Clear() {\n\ts.storage.Delete(bucketExtensionStorage, s.identity)\n}\n\nfunc (s *ContextStorage) getRawData() map[string]string {\n\tvar data map[string]string\n\ts.storage.Get(bucketExtensionStorage, s.identity, &data)\n\tif data == nil {\n\t\tdata = make(map[string]string)\n\t}\n\treturn data\n}\n"
  },
  {
    "path": "pkg/download/extension_test.go",
    "content": "package download\n\nimport (\n\t\"errors\"\n\t\"os\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/GopeedLab/gopeed/internal/logger\"\n\t\"github.com/GopeedLab/gopeed/pkg/base\"\n\tgojaerror \"github.com/GopeedLab/gopeed/pkg/download/engine/inject/error\"\n\t\"github.com/dop251/goja\"\n)\n\nfunc TestDownloader_InstallExtensionByFolder(t *testing.T) {\n\tsetupDownloader(func(downloader *Downloader) {\n\t\tif _, err := downloader.InstallExtensionByFolder(\"./testdata/extensions/basic\", false); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\trr, err := downloader.Resolve(&base.Request{\n\t\t\tURL: \"https://github.com/test\",\n\t\t}, nil)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif len(rr.Res.Files) == 1 {\n\t\t\tt.Fatal(\"resolve error\")\n\t\t}\n\t})\n}\n\nfunc TestDownloader_InstallExtensionByFolderDevMode(t *testing.T) {\n\tsetupDownloader(func(downloader *Downloader) {\n\t\tif _, err := downloader.InstallExtensionByFolder(\"./testdata/extensions/basic\", true); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\trr, err := downloader.Resolve(&base.Request{\n\t\t\tURL: \"https://github.com/test\",\n\t\t}, nil)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif len(rr.Res.Files) == 1 {\n\t\t\tt.Fatal(\"resolve error\")\n\t\t}\n\t})\n}\n\nfunc TestDownloader_InstallExtensionByGit(t *testing.T) {\n\tsetupDownloader(func(downloader *Downloader) {\n\t\tif _, err := downloader.InstallExtensionByGit(\"https://github.com/GopeedLab/gopeed-extension-samples#github-release-sample\"); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\trr, err := downloader.Resolve(&base.Request{\n\t\t\tURL: \"https://github.com/GopeedLab/gopeed/releases\",\n\t\t}, nil)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif len(rr.Res.Files) == 1 {\n\t\t\tt.Fatal(\"resolve error\")\n\t\t}\n\t})\n}\n\nfunc TestDownloader_InstallExtensionByGitSimple(t *testing.T) {\n\tsetupDownloader(func(downloader *Downloader) {\n\t\tif _, err := downloader.InstallExtensionByGit(\"github.com/GopeedLab/gopeed-extension-samples#github-release-sample\"); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\trr, err := downloader.Resolve(&base.Request{\n\t\t\tURL: \"https://github.com/GopeedLab/gopeed/releases\",\n\t\t}, nil)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif len(rr.Res.Files) == 1 {\n\t\t\tt.Fatal(\"resolve error\")\n\t\t}\n\t})\n}\n\nfunc TestDownloader_InstallExtensionByGitFull(t *testing.T) {\n\tsetupDownloader(func(downloader *Downloader) {\n\t\tif _, err := downloader.InstallExtensionByGit(\"https://github.com/GopeedLab/gopeed-extension-samples.git#github-release-sample\"); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\trr, err := downloader.Resolve(&base.Request{\n\t\t\tURL: \"https://github.com/GopeedLab/gopeed/releases\",\n\t\t}, nil)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif len(rr.Res.Files) == 1 {\n\t\t\tt.Fatal(\"resolve error\")\n\t\t}\n\t})\n}\n\nfunc TestDownloader_UpgradeExtension(t *testing.T) {\n\tgetSetting := func(settings []*Setting, name string) *Setting {\n\t\tfor _, setting := range settings {\n\t\t\tif setting.Name == name {\n\t\t\t\treturn setting\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t}\n\n\tsetupDownloader(func(downloader *Downloader) {\n\t\tinstalledExt, err := downloader.InstallExtensionByFolder(\"./testdata/extensions/update\", false)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\textensions := downloader.GetExtensions()\n\t\tif len(extensions) == 0 {\n\t\t\tt.Fatal(\"extension not installed\")\n\t\t}\n\t\toldVersion := installedExt.Version\n\t\t// fetch new version from git\n\t\tnewVersion, err := downloader.UpgradeCheckExtension(installedExt.Identity)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif newVersion == \"\" {\n\t\t\tt.Fatal(\"new version not found\")\n\t\t}\n\t\t// update extension\n\t\tif err = downloader.UpgradeExtension(installedExt.Identity); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tupgradeExt := downloader.getExtension(installedExt.Identity)\n\t\tif upgradeExt.Version == oldVersion {\n\t\t\tt.Fatal(\"extension update fail\")\n\t\t}\n\n\t\t// check setting update\n\t\ts1 := getSetting(upgradeExt.Settings, \"s1\")\n\t\tif s1.Title == \"S1 old\" {\n\t\t\tt.Fatal(\"setting update fail\")\n\t\t}\n\t\t// check setting type update\n\t\ts2 := getSetting(upgradeExt.Settings, \"s2\")\n\t\tif s2.Type == \"number\" {\n\t\t\tt.Fatal(\"setting type update fail\")\n\t\t}\n\t\t// check setting remove\n\t\td1 := getSetting(upgradeExt.Settings, \"d1\")\n\t\tif d1 != nil {\n\t\t\tt.Fatal(\"setting remove fail\")\n\t\t}\n\t\t// check setting add\n\t\ts3 := getSetting(upgradeExt.Settings, \"s3\")\n\t\tif s3 == nil {\n\t\t\tt.Fatal(\"setting add fail\")\n\t\t}\n\n\t\trr, err := downloader.Resolve(&base.Request{\n\t\t\tURL: \"https://test.com\",\n\t\t}, nil)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif rr.Res.Name != \"test\" {\n\t\t\tt.Fatal(\"script update fail\")\n\t\t}\n\t})\n}\n\nfunc TestDownloader_Extension_OnStart(t *testing.T) {\n\tdownloadAndCheck := func(req *base.Request) {\n\t\tsetupDownloader(func(downloader *Downloader) {\n\t\t\tif _, err := downloader.InstallExtensionByFolder(\"./testdata/extensions/on_start\", false); err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\t\t\terrCh := make(chan error, 1)\n\t\t\tdownloader.Listener(func(event *Event) {\n\t\t\t\tif event.Key == EventKeyFinally {\n\t\t\t\t\terrCh <- event.Err\n\t\t\t\t}\n\t\t\t})\n\t\t\tid, err := downloader.CreateDirect(req, nil)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\t\t\tselect {\n\t\t\tcase err = <-errCh:\n\t\t\t\tbreak\n\t\t\tcase <-time.After(time.Second * 30): // Increased timeout for real network requests\n\t\t\t\terr = errors.New(\"timeout\")\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\tpanic(\"extension on start download error: \" + err.Error())\n\t\t\t}\n\t\t\ttask := downloader.GetTask(id)\n\t\t\tif task.Meta.Req.URL != \"https://github.com\" {\n\t\t\t\tt.Fatalf(\"except url: https://github.com, actual: %s\", task.Meta.Req.URL)\n\t\t\t}\n\t\t\tif task.Meta.Req.Labels[\"modified\"] != \"true\" {\n\t\t\t\tt.Fatalf(\"except label: modified=true, actual: %s\", task.Meta.Req.Labels[\"modified\"])\n\t\t\t}\n\t\t})\n\t}\n\n\t// url match\n\tdownloadAndCheck(&base.Request{\n\t\tURL: \"https://github.com/gopeed/test/404\",\n\t})\n\n\t// label match\n\tdownloadAndCheck(&base.Request{\n\t\tURL: \"https://test.com\",\n\t\tLabels: map[string]string{\n\t\t\t\"test\": \"true\",\n\t\t},\n\t})\n}\n\nfunc TestDownloader_Extension_OnError(t *testing.T) {\n\tsetupDownloader(func(downloader *Downloader) {\n\t\tif _, err := downloader.InstallExtensionByFolder(\"./testdata/extensions/on_error\", false); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\terrCh := make(chan error, 1)\n\t\tdownloader.Listener(func(event *Event) {\n\t\t\tif event.Key == EventKeyFinally {\n\t\t\t\terrCh <- event.Err\n\t\t\t}\n\t\t})\n\t\tid, err := downloader.CreateDirect(&base.Request{\n\t\t\tURL: \"https://github.com/gopeed/test/404\",\n\t\t\tLabels: map[string]string{\n\t\t\t\t\"test\": \"true\",\n\t\t\t},\n\t\t}, nil)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tselect {\n\t\tcase err = <-errCh:\n\t\t\tbreak\n\t\tcase <-time.After(time.Second * 30): // Increased timeout for real network requests\n\t\t\terr = errors.New(\"timeout\")\n\t\t}\n\n\t\tif err != nil {\n\t\t\tpanic(\"extension on error download error: \" + err.Error())\n\t\t}\n\t\t// extension on error modify url and continue download\n\t\ttask := downloader.GetTask(id)\n\t\tif task.Status != base.DownloadStatusDone {\n\t\t\tt.Fatalf(\"except status is done, actual: %s\", task.Status)\n\t\t}\n\t})\n}\n\nfunc TestDownloader_Extension_OnDone(t *testing.T) {\n\tsetupDownloader(func(downloader *Downloader) {\n\t\tif _, err := downloader.InstallExtensionByFolder(\"./testdata/extensions/on_done\", false); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\terrCh := make(chan error, 1)\n\t\tdownloader.Listener(func(event *Event) {\n\t\t\tif event.Key == EventKeyFinally {\n\t\t\t\terrCh <- event.Err\n\t\t\t}\n\t\t})\n\t\tid, err := downloader.CreateDirect(&base.Request{\n\t\t\tURL: \"https://github.com\",\n\t\t}, nil)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tselect {\n\t\tcase err = <-errCh:\n\t\t\tbreak\n\t\tcase <-time.After(time.Second * 30): // Increased timeout for real network requests\n\t\t\terr = errors.New(\"timeout\")\n\t\t}\n\t\t// wait for script execution\n\t\ttime.Sleep(time.Millisecond * 3000)\n\n\t\tif err != nil {\n\t\t\tpanic(\"extension on done download error: \" + err.Error())\n\t\t}\n\t\t// extension on error modify url and continue download\n\t\ttask := downloader.GetTask(id)\n\t\tif task.Meta.Req.Labels[\"modified\"] != \"true\" {\n\t\t\tt.Fatalf(\"except label: modified=true, actual: %s\", task.Meta.Req.Labels[\"modified\"])\n\t\t}\n\t\tif task.Status != base.DownloadStatusDone {\n\t\t\tt.Fatalf(\"except status is done, actual: %s\", task.Status)\n\t\t}\n\t})\n}\n\nfunc TestDownloader_Extension_Errors(t *testing.T) {\n\tsetupDownloader(func(downloader *Downloader) {\n\t\tif _, err := downloader.InstallExtensionByFolder(\"./testdata/extensions/script_error\", false); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\trr, err := downloader.Resolve(&base.Request{\n\t\t\tURL: \"https://github.com/test\",\n\t\t}, nil)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif len(rr.Res.Files) == 2 {\n\t\t\tt.Fatal(\"script error catch failed\")\n\t\t}\n\t})\n\n\tsetupDownloader(func(downloader *Downloader) {\n\t\tif _, err := downloader.InstallExtensionByFolder(\"./testdata/extensions/function_error\", false); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\trr, err := downloader.Resolve(&base.Request{\n\t\t\tURL: \"https://github.com/test\",\n\t\t}, nil)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif len(rr.Res.Files) == 2 {\n\t\t\tt.Fatal(\"function error catch failed\")\n\t\t}\n\t})\n\n\tsetupDownloader(func(downloader *Downloader) {\n\t\tif _, err := downloader.InstallExtensionByFolder(\"./testdata/extensions/message_error\", false); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\t_, err := downloader.Resolve(&base.Request{\n\t\t\tURL: \"https://github.com/test\",\n\t\t}, nil)\n\t\tif err == nil {\n\t\t\tt.Fatalf(\"except error, but got nil\")\n\t\t}\n\t\tme, ok := err.(*gojaerror.MessageError)\n\t\tif !ok {\n\t\t\tt.Fatalf(\"except MessageError type, but got %s\", err)\n\t\t}\n\t\twant := \"test\"\n\t\tif me.Error() != want {\n\t\t\tt.Fatalf(\"except MessageError message %s, but got %s\", want, me.Message)\n\t\t}\n\t})\n}\n\nfunc TestDownloader_Extension_Settings(t *testing.T) {\n\tsetupDownloader(func(downloader *Downloader) {\n\t\tif _, err := downloader.InstallExtensionByFolder(\"./testdata/extensions/settings_empty\", false); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\trr, err := downloader.Resolve(&base.Request{\n\t\t\tURL: \"https://github.com/test\",\n\t\t}, nil)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif len(rr.Res.Files) == 1 {\n\t\t\tt.Fatal(\"settings parse error\")\n\t\t}\n\t})\n\n\tsetupDownloader(func(downloader *Downloader) {\n\t\tinstalledExt, err := downloader.InstallExtensionByFolder(\"./testdata/extensions/settings_all\", false)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tdownloader.UpdateExtensionSettings(installedExt.Identity, map[string]any{\n\t\t\t\"stringValued\":  \"valued\",\n\t\t\t\"numberValued\":  1.1,\n\t\t\t\"booleanValued\": true,\n\t\t})\n\t\trr, err := downloader.Resolve(&base.Request{\n\t\t\tURL: \"https://github.com/test\",\n\t\t}, nil)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif len(rr.Res.Files) == 1 {\n\t\t\tt.Fatal(\"settings parse error\")\n\t\t}\n\t})\n}\n\nfunc TestDownloader_ExtensionStorage(t *testing.T) {\n\tsetupDownloader(func(downloader *Downloader) {\n\t\tif _, err := downloader.InstallExtensionByFolder(\"./testdata/extensions/storage\", false); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\trr, err := downloader.Resolve(&base.Request{\n\t\t\tURL: \"https://github.com/test\",\n\t\t}, nil)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif len(rr.Res.Files) == 1 {\n\t\t\tt.Fatal(\"resolve error\")\n\t\t}\n\t})\n}\n\nfunc TestDownloader_SwitchExtension(t *testing.T) {\n\tsetupDownloader(func(downloader *Downloader) {\n\t\tinstalledExt, err := downloader.InstallExtensionByFolder(\"./testdata/extensions/basic\", false)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif installedExt.Disabled == true {\n\t\t\tt.Fatal(\"extension disabled\")\n\t\t}\n\t\tif err = downloader.SwitchExtension(installedExt.Identity, false); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tif installedExt.Disabled == false {\n\t\t\tt.Fatal(\"extension enabled\")\n\t\t}\n\t})\n}\n\nfunc TestDownloader_DeleteExtension(t *testing.T) {\n\tsetupDownloader(func(downloader *Downloader) {\n\t\tinstalledExt, err := downloader.InstallExtensionByFolder(\"./testdata/extensions/settings_all\", false)\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\textensions := downloader.GetExtensions()\n\t\tif err := downloader.DeleteExtension(installedExt.Identity); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\textensions = downloader.GetExtensions()\n\t\tif len(extensions) != 0 {\n\t\t\tt.Fatal(\"extension delete fail\")\n\t\t}\n\t})\n}\n\nfunc TestDownloader_Extension_Logger(t *testing.T) {\n\tlogger := logger.NewLogger(false, \"\")\n\til := newInstanceLogger(&Extension{\n\t\tName: \"test\",\n\t}, logger)\n\til.Debug(goja.NaN(), goja.Undefined())\n\til.Info(goja.NaN(), goja.Undefined())\n\til.Warn(goja.NaN(), goja.Undefined())\n\til.Error(goja.NaN(), goja.Undefined())\n}\n\nfunc setupDownloader(fn func(downloader *Downloader)) {\n\tdefaultDownloader.Setup()\n\tdefaultDownloader.cfg.StorageDir = \".test_storage\"\n\tdefaultDownloader.cfg.DownloadDir = \".test_download\"\n\tdefer func() {\n\t\tdefaultDownloader.Clear()\n\t\tos.RemoveAll(defaultDownloader.cfg.StorageDir)\n\t\tos.RemoveAll(defaultDownloader.cfg.DownloadDir)\n\t}()\n\tfn(defaultDownloader)\n}\n"
  },
  {
    "path": "pkg/download/extract.go",
    "content": "package download\n\nimport (\n\t\"context\"\n\t\"io\"\n\t\"math\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"slices\"\n\t\"strings\"\n\t\"sync/atomic\"\n\n\t\"github.com/mholt/archives\"\n\t\"golang.org/x/text/encoding/simplifiedchinese\"\n)\n\n// supportedArchiveExtensions contains file extensions supported by mholt/archives library\nvar supportedArchiveExtensions = []string{\n\t// Archive formats\n\t\".zip\",\n\t\".tar\",\n\t\".tar.gz\", \".tgz\",\n\t\".tar.bz2\", \".tbz2\",\n\t\".tar.xz\", \".txz\",\n\t\".tar.lz4\", \".tlz4\",\n\t\".tar.sz\", \".tsz\",\n\t\".tar.zst\", \".tzst\",\n\t\".rar\",\n\t\".7z\",\n\t// Compression formats\n\t\".gz\",\n\t\".bz2\",\n\t\".xz\",\n\t\".lz4\",\n\t\".sz\",\n\t\".zst\",\n\t\".br\",\n\t\".lz\",\n}\n\n// multiPartArchivePatterns contains regex patterns for multi-part archive detection\n// Each pattern should have a capture group for the base name and part number\nvar multiPartArchivePatterns = []*regexp.Regexp{\n\t// 7z multi-part: file.7z.001, file.7z.002, etc.\n\tregexp.MustCompile(`(?i)^(.+\\.7z)\\.(\\d{3})$`),\n\t// RAR multi-part (new style): file.part01.rar, file.part02.rar, etc.\n\tregexp.MustCompile(`(?i)^(.+)\\.part(\\d+)\\.rar$`),\n\t// RAR multi-part (old style): file.rar, file.r00, file.r01, etc. (first file is .rar)\n\tregexp.MustCompile(`(?i)^(.+)\\.r(\\d{2})$`),\n\t// ZIP multi-part: file.zip.001, file.zip.002, etc.\n\tregexp.MustCompile(`(?i)^(.+\\.zip)\\.(\\d{3})$`),\n\t// ZIP split: file.z01, file.z02, ... file.zip (last file is .zip)\n\tregexp.MustCompile(`(?i)^(.+)\\.z(\\d{2})$`),\n}\n\n// ArchivePartInfo contains information about a multi-part archive\ntype ArchivePartInfo struct {\n\t// IsMultiPart indicates if this file is part of a multi-part archive\n\tIsMultiPart bool\n\t// BaseName is the common base name for all parts (without part number extension)\n\tBaseName string\n\t// PartNumber is the part number (1-indexed)\n\tPartNumber int\n\t// FirstPartPath is the path to the first part of the archive\n\tFirstPartPath string\n\t// Pattern indicates which pattern matched (for determining extraction method)\n\tPattern string\n}\n\n// ExtractProgressCallback is called to report extraction progress\ntype ExtractProgressCallback func(extractedFiles int, totalFiles int, progress int)\n\n// newZipFormat creates a Zip format with proper character encoding support.\n// It uses GB18030 encoding to handle Chinese characters in filenames that may\n// be encoded with legacy GBK/GB18030 instead of UTF-8.\nfunc newZipFormat() archives.Zip {\n\treturn archives.Zip{\n\t\t// GB18030 is a superset of GBK and handles Chinese characters correctly\n\t\tTextEncoding: simplifiedchinese.GB18030,\n\t}\n}\n\n// isArchiveFile checks if a file is a supported archive format\nfunc isArchiveFile(filename string) bool {\n\tlowerName := strings.ToLower(filename)\n\t// Check for multi-part archive first\n\tif isMultiPartArchive(filename) {\n\t\treturn true\n\t}\n\treturn slices.ContainsFunc(supportedArchiveExtensions, func(ext string) bool {\n\t\treturn strings.HasSuffix(lowerName, ext)\n\t})\n}\n\n// archiveInfo holds information about an opened archive\ntype archiveInfo struct {\n\tfile   *os.File\n\tstat   os.FileInfo\n\tformat archives.Format\n\tinput  io.Reader\n}\n\n// openArchive opens an archive file and identifies its format\nfunc openArchive(archivePath string, password string) (*archiveInfo, error) {\n\tfile, err := os.Open(archivePath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tstat, err := file.Stat()\n\tif err != nil {\n\t\tfile.Close()\n\t\treturn nil, err\n\t}\n\n\tformat, input, err := archives.Identify(context.Background(), archivePath, file)\n\tif err != nil {\n\t\tfile.Close()\n\t\treturn nil, err\n\t}\n\n\t// Configure format-specific settings\n\t// Handle password-protected archives and character encoding\n\tif password != \"\" {\n\t\tif rar, ok := format.(archives.Rar); ok {\n\t\t\trar.Password = password\n\t\t\tformat = rar\n\t\t}\n\t\tif sz, ok := format.(archives.SevenZip); ok {\n\t\t\tsz.Password = password\n\t\t\tformat = sz\n\t\t}\n\t}\n\n\t// For ZIP files, configure character encoding to handle non-UTF8 filenames\n\t// This is essential for Chinese characters encoded in GBK/GB18030\n\tif _, ok := format.(archives.Zip); ok {\n\t\tformat = newZipFormat()\n\t}\n\n\treturn &archiveInfo{\n\t\tfile:   file,\n\t\tstat:   stat,\n\t\tformat: format,\n\t\tinput:  input,\n\t}, nil\n}\n\n// createExtractionHandler creates a handler function for extracting files with progress tracking\nfunc createExtractionHandler(destDir string, totalFiles int, progressCallback ExtractProgressCallback) func(ctx context.Context, fileInfo archives.FileInfo) error {\n\tvar extractedFiles atomic.Int32\n\treturn func(ctx context.Context, fileInfo archives.FileInfo) error {\n\t\terr := extractFile(ctx, fileInfo, destDir)\n\t\tif err == nil && !fileInfo.IsDir() {\n\t\t\textracted := int(extractedFiles.Add(1))\n\t\t\tif progressCallback != nil && totalFiles > 0 {\n\t\t\t\tprogress := int(math.Min(float64((extracted*100)/totalFiles), 100))\n\t\t\t\tprogressCallback(extracted, totalFiles, progress)\n\t\t\t}\n\t\t}\n\t\treturn err\n\t}\n}\n\n// extractArchive extracts an archive file to a destination directory\nfunc extractArchive(archivePath string, destDir string, password string, progressCallback ExtractProgressCallback) error {\n\t// Open the archive file\n\tinfo, err := openArchive(archivePath, password)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer info.file.Close()\n\n\t// Create destination directory if it doesn't exist\n\tif err := os.MkdirAll(destDir, 0755); err != nil {\n\t\treturn err\n\t}\n\n\t// Handle extraction based on format type\n\tswitch f := info.format.(type) {\n\tcase archives.Extractor:\n\t\t// For archive formats (zip, rar, 7z, tar, etc.)\n\t\t// First, count total files for progress tracking\n\t\ttotalFiles, err := countArchiveFiles(archivePath, password)\n\t\tif err != nil {\n\t\t\t// If counting fails, proceed without progress reporting\n\t\t\ttotalFiles = 0\n\t\t}\n\t\treturn f.Extract(context.Background(), info.input, createExtractionHandler(destDir, totalFiles, progressCallback))\n\tcase archives.Decompressor:\n\t\t// For single-file compression formats (gz, bz2, xz, etc.)\n\t\t// Decompress to a file without the compression extension\n\t\tbaseName := filepath.Base(archivePath)\n\t\tlowerBaseName := strings.ToLower(baseName)\n\t\tfor _, ext := range supportedArchiveExtensions {\n\t\t\tif strings.HasSuffix(lowerBaseName, ext) {\n\t\t\t\t// Get the actual suffix from the original filename (preserving case)\n\t\t\t\tactualSuffix := baseName[len(baseName)-len(ext):]\n\t\t\t\tbaseName = strings.TrimSuffix(baseName, actualSuffix)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tdestPath := filepath.Join(destDir, baseName)\n\n\t\treader, err := f.OpenReader(info.input)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer reader.Close()\n\n\t\tdestFile, err := os.Create(destPath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer destFile.Close()\n\n\t\t// Report progress at start and end for decompression\n\t\tif progressCallback != nil {\n\t\t\tprogressCallback(0, 1, 0)\n\t\t}\n\t\t_, err = io.Copy(destFile, reader)\n\t\tif err == nil && progressCallback != nil {\n\t\t\tprogressCallback(1, 1, 100)\n\t\t}\n\t\treturn err\n\tcase archives.Archiver:\n\t\t// This format is an archiver, try to extract using the extractor interface\n\t\tif ext, ok := info.format.(archives.Extractor); ok {\n\t\t\t// Reset file position\n\t\t\tif seeker, ok := info.input.(io.Seeker); ok {\n\t\t\t\tseeker.Seek(0, io.SeekStart)\n\t\t\t}\n\t\t\t// Count total files for progress tracking\n\t\t\ttotalFiles, err := countArchiveFiles(archivePath, password)\n\t\t\tif err != nil {\n\t\t\t\ttotalFiles = 0\n\t\t\t}\n\t\t\treturn ext.Extract(context.Background(), io.NewSectionReader(info.file, 0, info.stat.Size()), createExtractionHandler(destDir, totalFiles, progressCallback))\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// createCountingHandler creates a handler function for counting files in an archive\nfunc createCountingHandler(count *int) func(ctx context.Context, fileInfo archives.FileInfo) error {\n\treturn func(ctx context.Context, fileInfo archives.FileInfo) error {\n\t\tif !fileInfo.IsDir() {\n\t\t\t(*count)++\n\t\t}\n\t\treturn nil\n\t}\n}\n\n// countArchiveFiles counts the number of files in an archive for progress calculation\nfunc countArchiveFiles(archivePath string, password string) (int, error) {\n\tinfo, err := openArchive(archivePath, password)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tdefer info.file.Close()\n\n\tcount := 0\n\tswitch f := info.format.(type) {\n\tcase archives.Extractor:\n\t\terr = f.Extract(context.Background(), info.input, createCountingHandler(&count))\n\tcase archives.Archiver:\n\t\tif ext, ok := info.format.(archives.Extractor); ok {\n\t\t\tif seeker, ok := info.input.(io.Seeker); ok {\n\t\t\t\tseeker.Seek(0, io.SeekStart)\n\t\t\t}\n\t\t\terr = ext.Extract(context.Background(), io.NewSectionReader(info.file, 0, info.stat.Size()), createCountingHandler(&count))\n\t\t}\n\tcase archives.Decompressor:\n\t\t// Single file compression, count as 1\n\t\treturn 1, nil\n\t}\n\n\treturn count, err\n}\n\n// extractFile handles extracting a single file from an archive\nfunc extractFile(ctx context.Context, fileInfo archives.FileInfo, destDir string) error {\n\t// Skip directories, they will be created when extracting files\n\tif fileInfo.IsDir() {\n\t\tdestPath := filepath.Join(destDir, fileInfo.NameInArchive)\n\t\treturn os.MkdirAll(destPath, fileInfo.Mode())\n\t}\n\n\t// Sanitize the path to prevent path traversal attacks\n\tcleanPath := filepath.Clean(fileInfo.NameInArchive)\n\tif strings.HasPrefix(cleanPath, \"..\") || filepath.IsAbs(cleanPath) {\n\t\t// Skip files with suspicious paths\n\t\treturn nil\n\t}\n\n\tdestPath := filepath.Join(destDir, cleanPath)\n\n\t// Create parent directories\n\tif err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {\n\t\treturn err\n\t}\n\n\t// Open the file from the archive\n\treader, err := fileInfo.Open()\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer reader.Close()\n\n\t// Create the destination file\n\tdestFile, err := os.Create(destPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer destFile.Close()\n\n\t// Copy the contents\n\t_, err = io.Copy(destFile, reader)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Set file permissions\n\treturn os.Chmod(destPath, fileInfo.Mode())\n}\n\n// isMultiPartArchive checks if a file is part of a multi-part archive\nfunc isMultiPartArchive(filename string) bool {\n\tinfo := getArchivePartInfo(filename)\n\treturn info.IsMultiPart\n}\n\n// getArchivePartInfo returns detailed information about a multi-part archive file\nfunc getArchivePartInfo(filename string) ArchivePartInfo {\n\tbaseName := filepath.Base(filename)\n\n\tfor _, pattern := range multiPartArchivePatterns {\n\t\tmatches := pattern.FindStringSubmatch(baseName)\n\t\tif len(matches) >= 3 {\n\t\t\tpartNum := 0\n\t\t\t_, err := parsePartNumber(matches[2], &partNum)\n\t\t\tif err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tinfo := ArchivePartInfo{\n\t\t\t\tIsMultiPart: true,\n\t\t\t\tBaseName:    matches[1],\n\t\t\t\tPartNumber:  partNum,\n\t\t\t\tPattern:     pattern.String(),\n\t\t\t}\n\n\t\t\t// Determine the first part path based on pattern\n\t\t\tdir := filepath.Dir(filename)\n\t\t\tinfo.FirstPartPath = determineFirstPartPath(dir, info.BaseName, pattern.String())\n\n\t\t\treturn info\n\t\t}\n\t}\n\n\t// Check for .rar files that might be the first part of old-style multi-part RAR\n\tif strings.HasSuffix(strings.ToLower(baseName), \".rar\") && !strings.Contains(strings.ToLower(baseName), \".part\") {\n\t\t// Check if there are .r00, .r01 files in the same directory\n\t\tdir := filepath.Dir(filename)\n\t\tnameWithoutExt := strings.TrimSuffix(baseName, filepath.Ext(baseName))\n\t\tr00Path := filepath.Join(dir, nameWithoutExt+\".r00\")\n\t\tif _, err := os.Stat(r00Path); err == nil {\n\t\t\treturn ArchivePartInfo{\n\t\t\t\tIsMultiPart:   true,\n\t\t\t\tBaseName:      nameWithoutExt,\n\t\t\t\tPartNumber:    1, // .rar is the first part in old-style\n\t\t\t\tFirstPartPath: filename,\n\t\t\t\tPattern:       \"rar-old-style\",\n\t\t\t}\n\t\t}\n\t}\n\n\treturn ArchivePartInfo{IsMultiPart: false}\n}\n\n// parsePartNumber parses a part number string and stores it in the provided pointer\n// Returns the parsed number and nil error on success\nfunc parsePartNumber(s string, partNum *int) (int, error) {\n\tn := 0\n\tfor _, c := range s {\n\t\tif c >= '0' && c <= '9' {\n\t\t\tn = n*10 + int(c-'0')\n\t\t}\n\t}\n\t// For .001, .002 style, part number is the value itself\n\t// For .part01 style, part number is the value itself\n\t// For .r00, .r01 style: r00=2 (since .rar is part 1), r01=3, etc.\n\t// However, for consistency, we return the raw number and handle the offset elsewhere\n\t*partNum = n\n\t// Treat 00 as part 0, let callers handle the semantics\n\tif n == 0 {\n\t\t*partNum = 1 // For .001 format, 001 should be 1\n\t}\n\treturn *partNum, nil\n}\n\n// determineFirstPartPath determines the path to the first part of a multi-part archive\nfunc determineFirstPartPath(dir, baseName, pattern string) string {\n\tswitch {\n\tcase strings.Contains(pattern, `.7z)`):\n\t\t// 7z multi-part: first part is .7z.001\n\t\treturn filepath.Join(dir, baseName+\".001\")\n\tcase strings.Contains(pattern, `.part`):\n\t\t// RAR new style: first part is .part01.rar or .part1.rar\n\t\t// Try both single and double digit formats\n\t\tif _, err := os.Stat(filepath.Join(dir, baseName+\".part1.rar\")); err == nil {\n\t\t\treturn filepath.Join(dir, baseName+\".part1.rar\")\n\t\t}\n\t\treturn filepath.Join(dir, baseName+\".part01.rar\")\n\tcase strings.Contains(pattern, `.r(`):\n\t\t// RAR old style: first part is .rar (not .r00)\n\t\treturn filepath.Join(dir, baseName+\".rar\")\n\tcase strings.Contains(pattern, `.zip)`):\n\t\t// ZIP multi-part: first part is .zip.001\n\t\treturn filepath.Join(dir, baseName+\".001\")\n\tcase strings.Contains(pattern, `.z(`):\n\t\t// ZIP split: last part is .zip, but extraction should start from .z01\n\t\treturn filepath.Join(dir, baseName+\".z01\")\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// isFirstPart checks if the given file is the first part of a multi-part archive\nfunc isFirstPart(filename string) bool {\n\tinfo := getArchivePartInfo(filename)\n\tif !info.IsMultiPart {\n\t\treturn false\n\t}\n\n\t// For most formats, part 1 is the first part\n\t// For old-style RAR, check if this is the .rar file\n\tif info.Pattern == \"rar-old-style\" {\n\t\treturn strings.HasSuffix(strings.ToLower(filename), \".rar\")\n\t}\n\n\treturn info.PartNumber == 1\n}\n\n// GetMultiPartArchiveBaseName returns the base name for a multi-part archive\n// This is used to group related parts together\nfunc GetMultiPartArchiveBaseName(filename string) string {\n\tinfo := getArchivePartInfo(filename)\n\tif !info.IsMultiPart {\n\t\treturn \"\"\n\t}\n\treturn filepath.Join(filepath.Dir(filename), info.BaseName)\n}\n\n// extractMultiPartArchive extracts a multi-part archive starting from the first part\nfunc extractMultiPartArchive(firstPartPath string, destDir string, password string, progressCallback ExtractProgressCallback) error {\n\tinfo := getArchivePartInfo(firstPartPath)\n\n\t// For 7z multi-part archives, the bodgit/sevenzip library handles multi-volume automatically\n\t// when using OpenReader with a .001 file\n\tif strings.Contains(info.Pattern, `\\.7z\\)`) || strings.HasSuffix(strings.ToLower(firstPartPath), \".7z.001\") {\n\t\treturn extractSevenZipMultiPart(firstPartPath, destDir, password, progressCallback)\n\t}\n\n\t// For RAR multi-part archives, use the archives library with Name and FS fields\n\tif strings.Contains(info.Pattern, \"rar\") || info.Pattern == \"rar-old-style\" {\n\t\treturn extractRarMultiPart(firstPartPath, destDir, password, progressCallback)\n\t}\n\n\t// For ZIP multi-part archives (.zip.001, .zip.002, etc.), concatenate parts and extract\n\tif strings.Contains(info.Pattern, `\\.zip\\)`) || strings.HasSuffix(strings.ToLower(firstPartPath), \".zip.001\") {\n\t\treturn extractZipMultiPart(firstPartPath, destDir, password, progressCallback)\n\t}\n\n\t// For other formats, try standard extraction (may not work for all multi-part formats)\n\treturn extractArchive(firstPartPath, destDir, password, progressCallback)\n}\n"
  },
  {
    "path": "pkg/download/extract_7z.go",
    "content": "package download\n\nimport (\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"github.com/bodgit/sevenzip\"\n)\n\n// extractSevenZipMultiPart extracts a multi-part 7z archive using bodgit/sevenzip directly\n// The mholt/archives wrapper doesn't properly handle multi-part 7z files because it uses\n// io.SectionReader which can only see the first part. The bodgit/sevenzip library's\n// OpenReaderWithPassword function handles multi-part files automatically when given the .001 file path.\nfunc extractSevenZipMultiPart(firstPartPath string, destDir string, password string, progressCallback ExtractProgressCallback) error {\n\t// Use bodgit/sevenzip directly - it automatically handles .001, .002, etc. files\n\tvar reader *sevenzip.ReadCloser\n\tvar err error\n\n\tif password != \"\" {\n\t\treader, err = sevenzip.OpenReaderWithPassword(firstPartPath, password)\n\t} else {\n\t\treader, err = sevenzip.OpenReader(firstPartPath)\n\t}\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer reader.Close()\n\n\t// Create destination directory\n\tif err := os.MkdirAll(destDir, 0755); err != nil {\n\t\treturn err\n\t}\n\n\t// Count total files for progress\n\ttotalFiles := 0\n\tfor _, f := range reader.File {\n\t\tif !f.FileInfo().IsDir() {\n\t\t\ttotalFiles++\n\t\t}\n\t}\n\n\t// Extract files with progress tracking\n\textractedFiles := 0\n\tfor _, f := range reader.File {\n\t\tdestPath := filepath.Join(destDir, f.Name)\n\n\t\tif f.FileInfo().IsDir() {\n\t\t\tif err := os.MkdirAll(destPath, f.Mode()); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\t// Ensure parent directory exists\n\t\tif err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Extract file - open, copy, close immediately (as recommended by bodgit/sevenzip)\n\t\tif err := extractSevenZipFile(f, destPath); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\textractedFiles++\n\t\tif progressCallback != nil && totalFiles > 0 {\n\t\t\tprogress := int(float64(extractedFiles) / float64(totalFiles) * 100)\n\t\t\tprogressCallback(extractedFiles, totalFiles, progress)\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// extractSevenZipFile extracts a single file from a 7z archive\n// This follows the bodgit/sevenzip recommended pattern of closing rc before processing the next file\nfunc extractSevenZipFile(f *sevenzip.File, destPath string) error {\n\trc, err := f.Open()\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer rc.Close()\n\n\toutFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer outFile.Close()\n\n\t_, err = io.Copy(outFile, rc)\n\treturn err\n}\n"
  },
  {
    "path": "pkg/download/extract_queue.go",
    "content": "package download\n\nimport (\n\t\"sync\"\n)\n\n// ExtractionJob represents a single extraction job in the queue\ntype ExtractionJob struct {\n\t// ID is a unique identifier for this job (usually task ID or multi-part base name)\n\tID string\n\t// Execute is the function that performs the actual extraction\n\tExecute func()\n\t// done channel is signaled when the job has been executed\n\tdone chan struct{}\n}\n\n// NewExtractionJob creates a new extraction job\nfunc NewExtractionJob(id string, execute func()) *ExtractionJob {\n\treturn &ExtractionJob{\n\t\tID:      id,\n\t\tExecute: execute,\n\t\tdone:    make(chan struct{}),\n\t}\n}\n\n// Wait blocks until the job has been executed\nfunc (j *ExtractionJob) Wait() {\n\t<-j.done\n}\n\n// ExtractionQueue manages a queue of extraction jobs to prevent resource exhaustion\n// by ensuring only one extraction (or one multi-part archive extraction) runs at a time\ntype ExtractionQueue struct {\n\tmu       sync.Mutex\n\tcond     *sync.Cond\n\tjobs     []*ExtractionJob\n\trunning  bool\n\tshutdown bool\n\twg       sync.WaitGroup\n}\n\n// NewExtractionQueue creates a new extraction queue\nfunc NewExtractionQueue() *ExtractionQueue {\n\tq := &ExtractionQueue{\n\t\tjobs: make([]*ExtractionJob, 0),\n\t}\n\tq.cond = sync.NewCond(&q.mu)\n\treturn q\n}\n\n// Start starts the queue worker that processes jobs sequentially\nfunc (q *ExtractionQueue) Start() {\n\tq.mu.Lock()\n\tif q.running {\n\t\tq.mu.Unlock()\n\t\treturn\n\t}\n\tq.running = true\n\tq.shutdown = false\n\tq.mu.Unlock()\n\n\tq.wg.Add(1)\n\tgo q.worker()\n}\n\n// Stop stops the queue worker gracefully\n// It waits for the current job to complete but discards pending jobs\nfunc (q *ExtractionQueue) Stop() {\n\tq.mu.Lock()\n\tif !q.running {\n\t\tq.mu.Unlock()\n\t\treturn\n\t}\n\tq.shutdown = true\n\tq.cond.Signal()\n\tq.mu.Unlock()\n\n\tq.wg.Wait()\n}\n\n// Enqueue adds a new extraction job to the queue\n// The job will be executed when its turn comes (FIFO order)\n// Returns the job so the caller can wait for completion if needed\nfunc (q *ExtractionQueue) Enqueue(job *ExtractionJob) *ExtractionJob {\n\tq.mu.Lock()\n\tdefer q.mu.Unlock()\n\n\tif q.shutdown {\n\t\t// Queue is shutting down, signal done immediately without executing\n\t\tclose(job.done)\n\t\treturn job\n\t}\n\n\tq.jobs = append(q.jobs, job)\n\tq.cond.Signal()\n\treturn job\n}\n\n// EnqueueAndWait adds a new extraction job to the queue and waits for it to complete\nfunc (q *ExtractionQueue) EnqueueAndWait(job *ExtractionJob) {\n\tq.Enqueue(job)\n\tjob.Wait()\n}\n\n// QueueLength returns the current number of pending jobs in the queue\nfunc (q *ExtractionQueue) QueueLength() int {\n\tq.mu.Lock()\n\tdefer q.mu.Unlock()\n\treturn len(q.jobs)\n}\n\n// IsRunning returns true if the queue worker is running\nfunc (q *ExtractionQueue) IsRunning() bool {\n\tq.mu.Lock()\n\tdefer q.mu.Unlock()\n\treturn q.running\n}\n\n// HasPendingJob checks if there's a pending job with the given ID\nfunc (q *ExtractionQueue) HasPendingJob(id string) bool {\n\tq.mu.Lock()\n\tdefer q.mu.Unlock()\n\n\tfor _, job := range q.jobs {\n\t\tif job.ID == id {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// RemovePendingJob removes a pending job with the given ID from the queue\n// Returns true if a job was removed, false if not found\n// Note: This cannot remove a job that is currently being executed\nfunc (q *ExtractionQueue) RemovePendingJob(id string) bool {\n\tq.mu.Lock()\n\tdefer q.mu.Unlock()\n\n\tfor i, job := range q.jobs {\n\t\tif job.ID == id {\n\t\t\t// Close the done channel to unblock any waiters\n\t\t\tclose(job.done)\n\t\t\t// Remove from queue\n\t\t\tq.jobs = append(q.jobs[:i], q.jobs[i+1:]...)\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// worker is the main loop that processes jobs sequentially\nfunc (q *ExtractionQueue) worker() {\n\tdefer q.wg.Done()\n\n\tfor {\n\t\tq.mu.Lock()\n\t\t// Wait for a job or shutdown signal\n\t\tfor len(q.jobs) == 0 && !q.shutdown {\n\t\t\tq.cond.Wait()\n\t\t}\n\n\t\tif q.shutdown {\n\t\t\t// Close done channels for all remaining jobs\n\t\t\tfor _, job := range q.jobs {\n\t\t\t\tclose(job.done)\n\t\t\t}\n\t\t\tq.jobs = nil\n\t\t\tq.running = false\n\t\t\tq.mu.Unlock()\n\t\t\treturn\n\t\t}\n\n\t\t// Dequeue the first job\n\t\tjob := q.jobs[0]\n\t\tq.jobs = q.jobs[1:]\n\t\tq.mu.Unlock()\n\n\t\t// Execute the job outside the lock\n\t\tif job.Execute != nil {\n\t\t\tjob.Execute()\n\t\t}\n\n\t\t// Signal that the job is done\n\t\tclose(job.done)\n\t}\n}\n\n// Global extraction queue instance\nvar globalExtractionQueue *ExtractionQueue\nvar extractionQueueOnce sync.Once\n\n// GetExtractionQueue returns the global extraction queue instance\nfunc GetExtractionQueue() *ExtractionQueue {\n\textractionQueueOnce.Do(func() {\n\t\tglobalExtractionQueue = NewExtractionQueue()\n\t\tglobalExtractionQueue.Start()\n\t})\n\treturn globalExtractionQueue\n}\n"
  },
  {
    "path": "pkg/download/extract_queue_test.go",
    "content": "package download\n\nimport (\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestNewExtractionQueue(t *testing.T) {\n\tq := NewExtractionQueue()\n\tif q == nil {\n\t\tt.Fatal(\"NewExtractionQueue returned nil\")\n\t}\n\tif q.jobs == nil {\n\t\tt.Fatal(\"jobs slice should be initialized\")\n\t}\n\tif len(q.jobs) != 0 {\n\t\tt.Fatalf(\"expected empty jobs slice, got %d\", len(q.jobs))\n\t}\n\tif q.running {\n\t\tt.Fatal(\"queue should not be running initially\")\n\t}\n\tif q.cond == nil {\n\t\tt.Fatal(\"condition variable should be initialized\")\n\t}\n}\n\nfunc TestExtractionQueue_StartStop(t *testing.T) {\n\tq := NewExtractionQueue()\n\n\t// Test Start\n\tq.Start()\n\tif !q.IsRunning() {\n\t\tt.Fatal(\"queue should be running after Start\")\n\t}\n\n\t// Test double Start (should be idempotent)\n\tq.Start()\n\tif !q.IsRunning() {\n\t\tt.Fatal(\"queue should still be running after second Start\")\n\t}\n\n\t// Test Stop\n\tq.Stop()\n\tif q.IsRunning() {\n\t\tt.Fatal(\"queue should not be running after Stop\")\n\t}\n\n\t// Test double Stop (should be idempotent)\n\tq.Stop()\n\tif q.IsRunning() {\n\t\tt.Fatal(\"queue should still not be running after second Stop\")\n\t}\n}\n\nfunc TestExtractionQueue_EnqueueSingleJob(t *testing.T) {\n\tq := NewExtractionQueue()\n\tq.Start()\n\tdefer q.Stop()\n\n\texecuted := false\n\tjob := NewExtractionJob(\"test-1\", func() {\n\t\texecuted = true\n\t})\n\n\tq.Enqueue(job)\n\tjob.Wait()\n\n\tif !executed {\n\t\tt.Fatal(\"job should have been executed\")\n\t}\n}\n\nfunc TestExtractionQueue_EnqueueAndWait(t *testing.T) {\n\tq := NewExtractionQueue()\n\tq.Start()\n\tdefer q.Stop()\n\n\texecuted := false\n\tjob := NewExtractionJob(\"test-1\", func() {\n\t\texecuted = true\n\t})\n\n\tq.EnqueueAndWait(job)\n\n\tif !executed {\n\t\tt.Fatal(\"job should have been executed after EnqueueAndWait returns\")\n\t}\n}\n\nfunc TestExtractionQueue_FIFOOrder(t *testing.T) {\n\tq := NewExtractionQueue()\n\t// Don't start yet - we want to queue multiple jobs first\n\n\tvar executionOrder []string\n\tvar mu sync.Mutex\n\n\taddToOrder := func(id string) {\n\t\tmu.Lock()\n\t\texecutionOrder = append(executionOrder, id)\n\t\tmu.Unlock()\n\t}\n\n\tjob1 := NewExtractionJob(\"job-1\", func() {\n\t\taddToOrder(\"job-1\")\n\t})\n\tjob2 := NewExtractionJob(\"job-2\", func() {\n\t\taddToOrder(\"job-2\")\n\t})\n\tjob3 := NewExtractionJob(\"job-3\", func() {\n\t\taddToOrder(\"job-3\")\n\t})\n\n\t// Enqueue jobs before starting (they'll wait in queue)\n\tq.mu.Lock()\n\tq.jobs = append(q.jobs, job1, job2, job3)\n\tq.mu.Unlock()\n\n\t// Now start processing\n\tq.Start()\n\tdefer q.Stop()\n\n\t// Wait for all jobs\n\tjob1.Wait()\n\tjob2.Wait()\n\tjob3.Wait()\n\n\t// Verify FIFO order\n\tif len(executionOrder) != 3 {\n\t\tt.Fatalf(\"expected 3 executions, got %d\", len(executionOrder))\n\t}\n\tif executionOrder[0] != \"job-1\" || executionOrder[1] != \"job-2\" || executionOrder[2] != \"job-3\" {\n\t\tt.Fatalf(\"expected FIFO order [job-1, job-2, job-3], got %v\", executionOrder)\n\t}\n}\n\nfunc TestExtractionQueue_SequentialExecution(t *testing.T) {\n\tq := NewExtractionQueue()\n\tq.Start()\n\tdefer q.Stop()\n\n\tvar activeJobs int32\n\tvar maxConcurrent int32\n\tvar mu sync.Mutex\n\n\t// Create jobs that take some time to execute\n\tcreateJob := func(id string) *ExtractionJob {\n\t\treturn NewExtractionJob(id, func() {\n\t\t\tcurrent := atomic.AddInt32(&activeJobs, 1)\n\t\t\tmu.Lock()\n\t\t\tif current > maxConcurrent {\n\t\t\t\tmaxConcurrent = current\n\t\t\t}\n\t\t\tmu.Unlock()\n\t\t\ttime.Sleep(10 * time.Millisecond)\n\t\t\tatomic.AddInt32(&activeJobs, -1)\n\t\t})\n\t}\n\n\tjobs := make([]*ExtractionJob, 5)\n\tfor i := range jobs {\n\t\tjobs[i] = q.Enqueue(createJob(string(rune('A' + i))))\n\t}\n\n\t// Wait for all jobs\n\tfor _, job := range jobs {\n\t\tjob.Wait()\n\t}\n\n\t// Verify only one job ran at a time\n\tif maxConcurrent > 1 {\n\t\tt.Fatalf(\"expected max 1 concurrent job, got %d\", maxConcurrent)\n\t}\n}\n\nfunc TestExtractionQueue_QueueLength(t *testing.T) {\n\tq := NewExtractionQueue()\n\n\tif q.QueueLength() != 0 {\n\t\tt.Fatalf(\"expected queue length 0, got %d\", q.QueueLength())\n\t}\n\n\t// Add jobs without starting (they'll stay in queue)\n\tblockChan := make(chan struct{})\n\tblockingJob := NewExtractionJob(\"blocking\", func() {\n\t\t<-blockChan // Block until signaled\n\t})\n\n\tq.Start()\n\tdefer q.Stop()\n\n\tq.Enqueue(blockingJob)\n\n\t// Give worker time to pick up the blocking job\n\ttime.Sleep(20 * time.Millisecond)\n\n\t// Queue more jobs while blocking job is running\n\tjob2 := q.Enqueue(NewExtractionJob(\"job-2\", func() {}))\n\tjob3 := q.Enqueue(NewExtractionJob(\"job-3\", func() {}))\n\n\t// Should have 2 jobs waiting\n\tif q.QueueLength() != 2 {\n\t\tt.Fatalf(\"expected queue length 2, got %d\", q.QueueLength())\n\t}\n\n\t// Unblock and wait\n\tclose(blockChan)\n\tblockingJob.Wait()\n\tjob2.Wait()\n\tjob3.Wait()\n\n\tif q.QueueLength() != 0 {\n\t\tt.Fatalf(\"expected queue length 0 after completion, got %d\", q.QueueLength())\n\t}\n}\n\nfunc TestExtractionQueue_HasPendingJob(t *testing.T) {\n\tq := NewExtractionQueue()\n\n\tblockChan := make(chan struct{})\n\tblockingJob := NewExtractionJob(\"blocking\", func() {\n\t\t<-blockChan\n\t})\n\n\tq.Start()\n\tdefer q.Stop()\n\n\tq.Enqueue(blockingJob)\n\ttime.Sleep(20 * time.Millisecond) // Let blocking job start\n\n\t// Queue more jobs\n\tjob2 := q.Enqueue(NewExtractionJob(\"job-2\", func() {}))\n\n\tif !q.HasPendingJob(\"job-2\") {\n\t\tt.Fatal(\"expected HasPendingJob to return true for job-2\")\n\t}\n\n\tif q.HasPendingJob(\"job-99\") {\n\t\tt.Fatal(\"expected HasPendingJob to return false for non-existent job\")\n\t}\n\n\t// Unblock and wait\n\tclose(blockChan)\n\tblockingJob.Wait()\n\tjob2.Wait()\n\n\tif q.HasPendingJob(\"job-2\") {\n\t\tt.Fatal(\"expected HasPendingJob to return false after job completion\")\n\t}\n}\n\nfunc TestExtractionQueue_RemovePendingJob(t *testing.T) {\n\tq := NewExtractionQueue()\n\n\tblockChan := make(chan struct{})\n\tblockingJob := NewExtractionJob(\"blocking\", func() {\n\t\t<-blockChan\n\t})\n\n\texecuted := false\n\tjob2 := NewExtractionJob(\"job-2\", func() {\n\t\texecuted = true\n\t})\n\n\tq.Start()\n\tdefer q.Stop()\n\n\tq.Enqueue(blockingJob)\n\ttime.Sleep(20 * time.Millisecond) // Let blocking job start\n\tq.Enqueue(job2)\n\n\t// Remove job-2 before it executes\n\tremoved := q.RemovePendingJob(\"job-2\")\n\tif !removed {\n\t\tt.Fatal(\"expected RemovePendingJob to return true\")\n\t}\n\n\t// Try to remove again (should return false)\n\tremoved = q.RemovePendingJob(\"job-2\")\n\tif removed {\n\t\tt.Fatal(\"expected second RemovePendingJob to return false\")\n\t}\n\n\t// Unblock\n\tclose(blockChan)\n\tblockingJob.Wait()\n\n\t// Wait a bit for any potential execution\n\ttime.Sleep(50 * time.Millisecond)\n\n\tif executed {\n\t\tt.Fatal(\"removed job should not have been executed\")\n\t}\n\n\t// The removed job's done channel should be closed\n\tselect {\n\tcase <-job2.done:\n\t\t// Expected\n\tdefault:\n\t\tt.Fatal(\"removed job's done channel should be closed\")\n\t}\n}\n\nfunc TestExtractionQueue_StopDiscardsPendingJobs(t *testing.T) {\n\tq := NewExtractionQueue()\n\n\tblockChan := make(chan struct{})\n\texecuted1 := false\n\texecuted2 := false\n\n\tblockingJob := NewExtractionJob(\"blocking\", func() {\n\t\t<-blockChan\n\t\texecuted1 = true\n\t})\n\tpendingJob := NewExtractionJob(\"pending\", func() {\n\t\texecuted2 = true\n\t})\n\n\tq.Start()\n\n\tq.Enqueue(blockingJob)\n\ttime.Sleep(20 * time.Millisecond) // Let blocking job start\n\tq.Enqueue(pendingJob)\n\n\t// Stop without unblocking\n\tgo func() {\n\t\ttime.Sleep(10 * time.Millisecond)\n\t\tclose(blockChan)\n\t}()\n\tq.Stop()\n\n\t// Blocking job should have completed, but pending job was discarded\n\tif !executed1 {\n\t\tt.Log(\"Note: blocking job may not complete if Stop() won races\")\n\t}\n\tif executed2 {\n\t\tt.Fatal(\"pending job should not have been executed after Stop\")\n\t}\n\n\t// Pending job's done channel should be closed\n\tselect {\n\tcase <-pendingJob.done:\n\t\t// Expected\n\tdefault:\n\t\tt.Fatal(\"pending job's done channel should be closed after Stop\")\n\t}\n}\n\nfunc TestExtractionQueue_EnqueueAfterShutdown(t *testing.T) {\n\tq := NewExtractionQueue()\n\tq.Start()\n\tq.Stop()\n\n\texecuted := false\n\tjob := NewExtractionJob(\"after-shutdown\", func() {\n\t\texecuted = true\n\t})\n\n\tq.Enqueue(job)\n\n\t// Job should be immediately done (without execution)\n\tselect {\n\tcase <-job.done:\n\t\t// Expected\n\tcase <-time.After(100 * time.Millisecond):\n\t\tt.Fatal(\"job should be immediately done after shutdown\")\n\t}\n\n\tif executed {\n\t\tt.Fatal(\"job should not be executed after shutdown\")\n\t}\n}\n\nfunc TestNewExtractionJob(t *testing.T) {\n\tjob := NewExtractionJob(\"test-id\", func() {})\n\n\tif job.ID != \"test-id\" {\n\t\tt.Fatalf(\"expected ID 'test-id', got '%s'\", job.ID)\n\t}\n\tif job.Execute == nil {\n\t\tt.Fatal(\"Execute should not be nil\")\n\t}\n\tif job.done == nil {\n\t\tt.Fatal(\"done channel should be initialized\")\n\t}\n}\n\nfunc TestExtractionJob_Wait(t *testing.T) {\n\tjob := NewExtractionJob(\"test\", func() {})\n\n\tdone := make(chan struct{})\n\tgo func() {\n\t\tjob.Wait()\n\t\tclose(done)\n\t}()\n\n\t// Wait should block\n\tselect {\n\tcase <-done:\n\t\tt.Fatal(\"Wait should block until job is done\")\n\tcase <-time.After(50 * time.Millisecond):\n\t\t// Expected\n\t}\n\n\t// Close done channel\n\tclose(job.done)\n\n\t// Now Wait should return\n\tselect {\n\tcase <-done:\n\t\t// Expected\n\tcase <-time.After(100 * time.Millisecond):\n\t\tt.Fatal(\"Wait should return after job is done\")\n\t}\n}\n\nfunc TestExtractionQueue_NilExecuteFunction(t *testing.T) {\n\tq := NewExtractionQueue()\n\tq.Start()\n\tdefer q.Stop()\n\n\t// Job with nil Execute should not panic\n\tjob := &ExtractionJob{\n\t\tID:      \"nil-execute\",\n\t\tExecute: nil,\n\t\tdone:    make(chan struct{}),\n\t}\n\n\tq.Enqueue(job)\n\tjob.Wait()\n\t// No panic means success\n}\n\nfunc TestExtractionQueue_ConcurrentEnqueue(t *testing.T) {\n\tq := NewExtractionQueue()\n\tq.Start()\n\tdefer q.Stop()\n\n\tvar counter int32\n\tvar wg sync.WaitGroup\n\tnumGoroutines := 10\n\tjobsPerGoroutine := 10\n\n\twg.Add(numGoroutines)\n\tfor i := 0; i < numGoroutines; i++ {\n\t\tgo func(goroutineID int) {\n\t\t\tdefer wg.Done()\n\t\t\tfor j := 0; j < jobsPerGoroutine; j++ {\n\t\t\t\tjob := NewExtractionJob(\"\", func() {\n\t\t\t\t\tatomic.AddInt32(&counter, 1)\n\t\t\t\t})\n\t\t\t\tq.EnqueueAndWait(job)\n\t\t\t}\n\t\t}(i)\n\t}\n\n\twg.Wait()\n\n\texpected := int32(numGoroutines * jobsPerGoroutine)\n\tif counter != expected {\n\t\tt.Fatalf(\"expected counter %d, got %d\", expected, counter)\n\t}\n}\n\nfunc TestExtractionQueue_Restart(t *testing.T) {\n\tq := NewExtractionQueue()\n\n\t// First run\n\tq.Start()\n\texecuted1 := false\n\tjob1 := NewExtractionJob(\"job-1\", func() {\n\t\texecuted1 = true\n\t})\n\tq.EnqueueAndWait(job1)\n\tq.Stop()\n\n\tif !executed1 {\n\t\tt.Fatal(\"first job should have been executed\")\n\t}\n\n\t// Restart\n\tq.Start()\n\texecuted2 := false\n\tjob2 := NewExtractionJob(\"job-2\", func() {\n\t\texecuted2 = true\n\t})\n\tq.EnqueueAndWait(job2)\n\tq.Stop()\n\n\tif !executed2 {\n\t\tt.Fatal(\"second job should have been executed after restart\")\n\t}\n}\n\nfunc TestGetExtractionQueue(t *testing.T) {\n\t// Note: This test modifies global state, so it should ideally be run in isolation\n\t// However, the implementation ensures the queue is created only once\n\n\tqueue := GetExtractionQueue()\n\tif queue == nil {\n\t\tt.Fatal(\"GetExtractionQueue should not return nil\")\n\t}\n\tif !queue.IsRunning() {\n\t\tt.Fatal(\"global queue should be running\")\n\t}\n\n\t// Calling again should return the same instance\n\tqueue2 := GetExtractionQueue()\n\tif queue != queue2 {\n\t\tt.Fatal(\"GetExtractionQueue should return the same instance\")\n\t}\n}\n\nfunc TestExtractionQueue_LongRunningJob(t *testing.T) {\n\tq := NewExtractionQueue()\n\tq.Start()\n\tdefer q.Stop()\n\n\tstartTime := time.Now()\n\tlongJobDuration := 100 * time.Millisecond\n\n\tlongJob := NewExtractionJob(\"long-job\", func() {\n\t\ttime.Sleep(longJobDuration)\n\t})\n\n\tshortJobExecuted := false\n\tshortJob := NewExtractionJob(\"short-job\", func() {\n\t\tshortJobExecuted = true\n\t})\n\n\tq.Enqueue(longJob)\n\tq.Enqueue(shortJob)\n\n\tshortJob.Wait()\n\n\telapsed := time.Since(startTime)\n\tif elapsed < longJobDuration {\n\t\tt.Fatalf(\"short job should wait for long job, elapsed: %v\", elapsed)\n\t}\n\n\tif !shortJobExecuted {\n\t\tt.Fatal(\"short job should have been executed\")\n\t}\n}\n"
  },
  {
    "path": "pkg/download/extract_rar.go",
    "content": "package download\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"github.com/mholt/archives\"\n)\n\n// extractRarMultiPart extracts a multi-part RAR archive\nfunc extractRarMultiPart(firstPartPath string, destDir string, password string, progressCallback ExtractProgressCallback) error {\n\t// For RAR archives, we need to use the Name field in archives.Rar\n\t// to let it automatically find subsequent volumes\n\n\tdir := filepath.Dir(firstPartPath)\n\tfileName := filepath.Base(firstPartPath)\n\n\trar := archives.Rar{\n\t\tPassword: password,\n\t\tName:     fileName,\n\t\tFS:       os.DirFS(dir),\n\t}\n\n\t// Create destination directory\n\tif err := os.MkdirAll(destDir, 0755); err != nil {\n\t\treturn err\n\t}\n\n\t// Count files first for progress\n\ttotalFiles := 0\n\terr := rar.Extract(context.Background(), nil, func(ctx context.Context, fileInfo archives.FileInfo) error {\n\t\tif !fileInfo.IsDir() {\n\t\t\ttotalFiles++\n\t\t}\n\t\treturn nil\n\t})\n\n\tif err != nil {\n\t\t// If counting fails, proceed without progress\n\t\ttotalFiles = 0\n\t}\n\n\t// Reset and extract with progress tracking\n\treturn rar.Extract(context.Background(), nil, createExtractionHandler(destDir, totalFiles, progressCallback))\n}\n"
  },
  {
    "path": "pkg/download/extract_test.go",
    "content": "package download\n\nimport (\n\t\"archive/tar\"\n\t\"archive/zip\"\n\t\"compress/gzip\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"golang.org/x/text/encoding/simplifiedchinese\"\n)\n\nfunc TestIsArchiveFile(t *testing.T) {\n\ttests := []struct {\n\t\tfilename string\n\t\texpected bool\n\t}{\n\t\t// Archive formats\n\t\t{\"file.zip\", true},\n\t\t{\"file.ZIP\", true},\n\t\t{\"file.tar\", true},\n\t\t{\"file.tar.gz\", true},\n\t\t{\"file.tgz\", true},\n\t\t{\"file.tar.bz2\", true},\n\t\t{\"file.tbz2\", true},\n\t\t{\"file.tar.xz\", true},\n\t\t{\"file.txz\", true},\n\t\t{\"file.tar.zst\", true},\n\t\t{\"file.tzst\", true},\n\t\t{\"file.rar\", true},\n\t\t{\"file.RAR\", true},\n\t\t{\"file.7z\", true},\n\t\t// Compression formats\n\t\t{\"file.gz\", true},\n\t\t{\"file.bz2\", true},\n\t\t{\"file.xz\", true},\n\t\t{\"file.lz4\", true},\n\t\t{\"file.sz\", true},\n\t\t{\"file.zst\", true},\n\t\t{\"file.br\", true},\n\t\t{\"file.lz\", true},\n\t\t// Non-archive files\n\t\t{\"file.txt\", false},\n\t\t{\"file.pdf\", false},\n\t\t{\"file.exe\", false},\n\t\t{\"archive\", false},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.filename, func(t *testing.T) {\n\t\t\tresult := isArchiveFile(tt.filename)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"isArchiveFile(%q) = %v, expected %v\", tt.filename, result, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestExtractArchive_Zip(t *testing.T) {\n\t// Create a temporary directory\n\ttempDir, err := os.MkdirTemp(\"\", \"extract_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create a test zip file\n\tzipPath := filepath.Join(tempDir, \"test.zip\")\n\tdestDir := filepath.Join(tempDir, \"extracted\")\n\n\tif err := createTestZip(zipPath); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Extract the archive\n\terr = extractArchive(zipPath, destDir, \"\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"extractArchive failed: %v\", err)\n\t}\n\n\t// Verify the extracted files\n\texpectedFiles := []string{\n\t\tfilepath.Join(destDir, \"test.txt\"),\n\t\tfilepath.Join(destDir, \"subdir\", \"nested.txt\"),\n\t}\n\n\tfor _, path := range expectedFiles {\n\t\tif _, err := os.Stat(path); os.IsNotExist(err) {\n\t\t\tt.Errorf(\"expected file %q not found after extraction\", path)\n\t\t}\n\t}\n\n\t// Verify content\n\tcontent, err := os.ReadFile(filepath.Join(destDir, \"test.txt\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif string(content) != \"Hello, World!\" {\n\t\tt.Errorf(\"unexpected content: %q\", string(content))\n\t}\n}\n\nfunc TestExtractArchive_NonArchive(t *testing.T) {\n\t// Create a temporary directory\n\ttempDir, err := os.MkdirTemp(\"\", \"extract_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create a non-archive file\n\ttxtPath := filepath.Join(tempDir, \"test.txt\")\n\tif err := os.WriteFile(txtPath, []byte(\"not an archive\"), 0644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tdestDir := filepath.Join(tempDir, \"extracted\")\n\n\t// Trying to extract a non-archive should return an error\n\terr = extractArchive(txtPath, destDir, \"\", nil)\n\tif err == nil {\n\t\tt.Error(\"expected error when extracting non-archive file\")\n\t}\n}\n\n// createTestZip creates a test zip file with sample content\nfunc createTestZip(path string) error {\n\tzipFile, err := os.Create(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer zipFile.Close()\n\n\tw := zip.NewWriter(zipFile)\n\n\t// Add a file to the root\n\tf, err := w.Create(\"test.txt\")\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = f.Write([]byte(\"Hello, World!\"))\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Add a file in a subdirectory\n\tf, err = w.Create(\"subdir/nested.txt\")\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = f.Write([]byte(\"Nested content\"))\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn w.Close()\n}\n\nfunc TestExtractArchive_Progress(t *testing.T) {\n\t// Create a temporary directory\n\ttempDir, err := os.MkdirTemp(\"\", \"extract_progress_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create a test zip file with multiple files\n\tzipPath := filepath.Join(tempDir, \"test.zip\")\n\tdestDir := filepath.Join(tempDir, \"extracted\")\n\n\t// Create zip with 4 files\n\tif err := createTestZipWithMultipleFiles(zipPath, 4); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Track progress callbacks\n\tprogressCalls := make([]struct {\n\t\textracted int\n\t\ttotal     int\n\t\tprogress  int\n\t}, 0)\n\n\t// Extract the archive with progress tracking\n\terr = extractArchive(zipPath, destDir, \"\", func(extracted int, total int, progress int) {\n\t\tprogressCalls = append(progressCalls, struct {\n\t\t\textracted int\n\t\t\ttotal     int\n\t\t\tprogress  int\n\t\t}{extracted, total, progress})\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"extractArchive failed: %v\", err)\n\t}\n\n\t// Verify that progress callbacks were made\n\tif len(progressCalls) != 4 {\n\t\tt.Errorf(\"expected 4 progress callbacks, got %d\", len(progressCalls))\n\t}\n\n\t// Verify the first progress call\n\tif len(progressCalls) > 0 {\n\t\tfirst := progressCalls[0]\n\t\tif first.extracted != 1 || first.total != 4 {\n\t\t\tt.Errorf(\"first progress call: expected extracted=1, total=4, got extracted=%d, total=%d\", first.extracted, first.total)\n\t\t}\n\t}\n\n\t// Verify the last progress call\n\tif len(progressCalls) > 0 {\n\t\tlast := progressCalls[len(progressCalls)-1]\n\t\tif last.extracted != 4 || last.total != 4 || last.progress != 100 {\n\t\t\tt.Errorf(\"last progress call: expected extracted=4, total=4, progress=100, got extracted=%d, total=%d, progress=%d\", last.extracted, last.total, last.progress)\n\t\t}\n\t}\n}\n\n// createTestZipWithMultipleFiles creates a test zip file with the specified number of files\nfunc createTestZipWithMultipleFiles(path string, numFiles int) error {\n\tzipFile, err := os.Create(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer zipFile.Close()\n\n\tw := zip.NewWriter(zipFile)\n\n\tfor i := 0; i < numFiles; i++ {\n\t\t// Use Windows-safe names. The previous rune-based approach could generate\n\t\t// invalid Windows filename characters (e.g. '\\\\', ':', '|') when numFiles is large.\n\t\tnameInZip := fmt.Sprintf(\"dir/file%03d.txt\", i+1)\n\t\tf, err := w.Create(nameInZip)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\t_, err = f.Write([]byte(fmt.Sprintf(\"Content of file %03d\", i+1)))\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn w.Close()\n}\n\n// createTestZipWithChineseFilenames creates a test ZIP file with Chinese filenames encoded in GBK\nfunc createTestZipWithChineseFilenames(path string) error {\n\tzipFile, err := os.Create(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer zipFile.Close()\n\n\tw := zip.NewWriter(zipFile)\n\n\t// Encode Chinese filenames in GBK (as some legacy Windows applications do)\n\tencoder := simplifiedchinese.GBK.NewEncoder()\n\n\t// Add a file with Chinese filename\n\tchineseFilename := \"测试文件.txt\"\n\tgbkFilename, err := encoder.String(chineseFilename)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Create a FileHeader and manually set the Name with GBK encoding\n\t// We need to mark it as non-UTF8 by not setting the UTF-8 flag\n\theader := &zip.FileHeader{\n\t\tName:   gbkFilename,\n\t\tMethod: zip.Deflate,\n\t}\n\t// Clear the UTF-8 bit (bit 11) to indicate non-UTF8 encoding\n\theader.Flags = 0\n\n\tf, err := w.CreateHeader(header)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = f.Write([]byte(\"这是测试内容\"))\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Add a file in a subdirectory with Chinese name\n\tchineseDirAndFile := \"文件夹/中文内容.txt\"\n\tgbkDirAndFile, err := encoder.String(chineseDirAndFile)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\theader2 := &zip.FileHeader{\n\t\tName:   gbkDirAndFile,\n\t\tMethod: zip.Deflate,\n\t}\n\theader2.Flags = 0\n\n\tf2, err := w.CreateHeader(header2)\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = f2.Write([]byte(\"中文子文件内容\"))\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn w.Close()\n}\n\nfunc TestOpenArchive_NonExistentFile(t *testing.T) {\n\t_, err := openArchive(\"/nonexistent/path/file.zip\", \"\")\n\tif err == nil {\n\t\tt.Error(\"expected error when opening non-existent file\")\n\t}\n}\n\nfunc TestOpenArchive_InvalidFormat(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"open_archive_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create a file that's not a valid archive format\n\t// Use a txt extension so it's not detected as an archive\n\tinvalidPath := filepath.Join(tempDir, \"invalid.txt\")\n\tif err := os.WriteFile(invalidPath, []byte(\"not an archive file\"), 0644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t_, err = openArchive(invalidPath, \"\")\n\tif err == nil {\n\t\t// archives.Identify may return a format even for non-archive files\n\t\t// This is expected behavior - it identifies based on content/extension\n\t\tt.Log(\"openArchive accepted the file - this is acceptable behavior\")\n\t}\n}\n\nfunc TestOpenArchive_WithPassword(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"open_archive_pwd_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create a valid zip file to test password parameter handling\n\tzipPath := filepath.Join(tempDir, \"test.zip\")\n\tif err := createTestZip(zipPath); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Test that password is accepted (even if zip doesn't use it)\n\tinfo, err := openArchive(zipPath, \"testpassword\")\n\tif err != nil {\n\t\tt.Fatalf(\"openArchive with password failed: %v\", err)\n\t}\n\tdefer info.file.Close()\n\n\tif info.format == nil {\n\t\tt.Error(\"expected format to be set\")\n\t}\n}\n\nfunc TestExtractArchive_NonExistentFile(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"extract_nonexistent_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\tdestDir := filepath.Join(tempDir, \"extracted\")\n\terr = extractArchive(\"/nonexistent/file.zip\", destDir, \"\", nil)\n\tif err == nil {\n\t\tt.Error(\"expected error when extracting non-existent file\")\n\t}\n}\n\nfunc TestExtractArchive_WithPassword(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"extract_pwd_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create a test zip file\n\tzipPath := filepath.Join(tempDir, \"test.zip\")\n\tdestDir := filepath.Join(tempDir, \"extracted\")\n\n\tif err := createTestZip(zipPath); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Extract with password (zip doesn't require it, but tests the code path)\n\terr = extractArchive(zipPath, destDir, \"password123\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"extractArchive with password failed: %v\", err)\n\t}\n\n\t// Verify extraction succeeded\n\tif _, err := os.Stat(filepath.Join(destDir, \"test.txt\")); os.IsNotExist(err) {\n\t\tt.Error(\"expected file not found after extraction\")\n\t}\n}\n\nfunc TestExtractArchive_Gzip(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"extract_gzip_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create a gzip compressed file\n\tgzPath := filepath.Join(tempDir, \"test.txt.gz\")\n\tdestDir := filepath.Join(tempDir, \"extracted\")\n\n\tif err := createTestGzip(gzPath, \"Hello from gzip!\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Track progress callback values\n\tvar progressCalls []struct {\n\t\textracted int\n\t\ttotal     int\n\t\tprogress  int\n\t}\n\terr = extractArchive(gzPath, destDir, \"\", func(extracted int, total int, progress int) {\n\t\tprogressCalls = append(progressCalls, struct {\n\t\t\textracted int\n\t\t\ttotal     int\n\t\t\tprogress  int\n\t\t}{extracted, total, progress})\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"extractArchive failed for gzip: %v\", err)\n\t}\n\n\t// Verify the decompressed file exists\n\tdestPath := filepath.Join(destDir, \"test.txt\")\n\tif _, err := os.Stat(destPath); os.IsNotExist(err) {\n\t\tt.Error(\"expected decompressed file not found\")\n\t}\n\n\t// Verify content\n\tcontent, err := os.ReadFile(destPath)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif string(content) != \"Hello from gzip!\" {\n\t\tt.Errorf(\"unexpected content: %q\", string(content))\n\t}\n\n\t// Verify progress callbacks - should have exactly 2 calls for gzip: start (0,1,0) and end (1,1,100)\n\tif len(progressCalls) != 2 {\n\t\tt.Errorf(\"expected 2 progress callbacks for gzip, got %d\", len(progressCalls))\n\t}\n\tif len(progressCalls) >= 2 {\n\t\t// Verify start callback\n\t\tif progressCalls[0].extracted != 0 || progressCalls[0].total != 1 || progressCalls[0].progress != 0 {\n\t\t\tt.Errorf(\"expected start callback (0,1,0), got (%d,%d,%d)\", progressCalls[0].extracted, progressCalls[0].total, progressCalls[0].progress)\n\t\t}\n\t\t// Verify end callback\n\t\tif progressCalls[1].extracted != 1 || progressCalls[1].total != 1 || progressCalls[1].progress != 100 {\n\t\t\tt.Errorf(\"expected end callback (1,1,100), got (%d,%d,%d)\", progressCalls[1].extracted, progressCalls[1].total, progressCalls[1].progress)\n\t\t}\n\t}\n}\n\nfunc createTestGzip(path string, content string) error {\n\tfile, err := os.Create(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer file.Close()\n\n\tgz := gzip.NewWriter(file)\n\t_, err = gz.Write([]byte(content))\n\tif err != nil {\n\t\tgz.Close()\n\t\treturn err\n\t}\n\treturn gz.Close()\n}\n\nfunc TestCountArchiveFiles(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"count_archive_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create a test zip with known number of files\n\tzipPath := filepath.Join(tempDir, \"test.zip\")\n\tif err := createTestZipWithMultipleFiles(zipPath, 5); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tcount, err := countArchiveFiles(zipPath, \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"countArchiveFiles failed: %v\", err)\n\t}\n\n\tif count != 5 {\n\t\tt.Errorf(\"expected 5 files, got %d\", count)\n\t}\n}\n\nfunc TestCountArchiveFiles_NonExistent(t *testing.T) {\n\t_, err := countArchiveFiles(\"/nonexistent/file.zip\", \"\")\n\tif err == nil {\n\t\tt.Error(\"expected error for non-existent file\")\n\t}\n}\n\nfunc TestCountArchiveFiles_Gzip(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"count_gzip_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create a gzip file\n\tgzPath := filepath.Join(tempDir, \"test.txt.gz\")\n\tif err := createTestGzip(gzPath, \"test content\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tcount, err := countArchiveFiles(gzPath, \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"countArchiveFiles failed: %v\", err)\n\t}\n\n\t// Gzip is single-file compression, should return 1\n\tif count != 1 {\n\t\tt.Errorf(\"expected 1 file for gzip, got %d\", count)\n\t}\n}\n\nfunc TestExtractArchive_ProgressWithZeroFiles(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"extract_zero_progress_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create an empty zip file\n\tzipPath := filepath.Join(tempDir, \"empty.zip\")\n\tdestDir := filepath.Join(tempDir, \"extracted\")\n\n\tif err := createEmptyZip(zipPath); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tprogressCalled := false\n\terr = extractArchive(zipPath, destDir, \"\", func(extracted int, total int, progress int) {\n\t\tprogressCalled = true\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"extractArchive failed: %v\", err)\n\t}\n\n\t// Progress should not be called for empty archive\n\tif progressCalled {\n\t\tt.Error(\"progress callback should not be called for empty archive\")\n\t}\n}\n\nfunc createEmptyZip(path string) error {\n\tzipFile, err := os.Create(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer zipFile.Close()\n\n\tw := zip.NewWriter(zipFile)\n\treturn w.Close()\n}\n\nfunc TestExtractArchive_WithDirectories(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"extract_dirs_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\tzipPath := filepath.Join(tempDir, \"test.zip\")\n\tdestDir := filepath.Join(tempDir, \"extracted\")\n\n\tif err := createTestZipWithDirectories(zipPath); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\terr = extractArchive(zipPath, destDir, \"\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"extractArchive failed: %v\", err)\n\t}\n\n\t// Verify files were created (directories are created implicitly)\n\texpectedPaths := []string{\n\t\tfilepath.Join(destDir, \"dir1\", \"file1.txt\"),\n\t\tfilepath.Join(destDir, \"dir2\", \"subdir\", \"file2.txt\"),\n\t}\n\n\tfor _, path := range expectedPaths {\n\t\tif _, err := os.Stat(path); os.IsNotExist(err) {\n\t\t\tt.Errorf(\"expected path %q not found\", path)\n\t\t}\n\t}\n}\n\nfunc createTestZipWithDirectories(path string) error {\n\tzipFile, err := os.Create(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer zipFile.Close()\n\n\tw := zip.NewWriter(zipFile)\n\n\t// Create file in directory (directory will be created automatically)\n\tf, err := w.Create(\"dir1/file1.txt\")\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = f.Write([]byte(\"content1\"))\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Create nested directory structure\n\tf, err = w.Create(\"dir2/subdir/file2.txt\")\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = f.Write([]byte(\"content2\"))\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn w.Close()\n}\n\nfunc TestExtractArchive_NilProgressCallback(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"extract_nil_progress_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\tzipPath := filepath.Join(tempDir, \"test.zip\")\n\tdestDir := filepath.Join(tempDir, \"extracted\")\n\n\tif err := createTestZipWithMultipleFiles(zipPath, 3); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Should not panic with nil callback\n\terr = extractArchive(zipPath, destDir, \"\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"extractArchive failed: %v\", err)\n\t}\n}\n\nfunc TestExtractArchive_GzipUppercase(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"extract_gzip_upper_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create a gzip compressed file with uppercase extension\n\tgzPath := filepath.Join(tempDir, \"test.txt.GZ\")\n\tdestDir := filepath.Join(tempDir, \"extracted\")\n\n\tif err := createTestGzip(gzPath, \"Hello from gzip!\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\terr = extractArchive(gzPath, destDir, \"\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"extractArchive failed for gzip with uppercase: %v\", err)\n\t}\n\n\t// The base name should have the extension stripped\n\tdestPath := filepath.Join(destDir, \"test.txt\")\n\tif _, err := os.Stat(destPath); os.IsNotExist(err) {\n\t\tt.Error(\"expected decompressed file not found\")\n\t}\n}\n\nfunc TestExtractArchive_PathTraversalPrevention(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"extract_traversal_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create a zip with a path traversal attempt\n\tzipPath := filepath.Join(tempDir, \"malicious.zip\")\n\tdestDir := filepath.Join(tempDir, \"extracted\")\n\n\tif err := createMaliciousZip(zipPath); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\terr = extractArchive(zipPath, destDir, \"\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"extractArchive failed: %v\", err)\n\t}\n\n\t// Verify that the file was not created outside destDir\n\t// The malicious path should be sanitized\n\tdangerousPath := filepath.Join(tempDir, \"evil.txt\")\n\tif _, err := os.Stat(dangerousPath); err == nil {\n\t\tt.Error(\"path traversal attack succeeded - file created outside destDir\")\n\t}\n}\n\nfunc createMaliciousZip(path string) error {\n\tzipFile, err := os.Create(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer zipFile.Close()\n\n\tw := zip.NewWriter(zipFile)\n\n\t// Create a file with path traversal attempt\n\t// Note: Go's zip library may sanitize this, but we test it anyway\n\tf, err := w.Create(\"../evil.txt\")\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = f.Write([]byte(\"malicious content\"))\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Also add a normal file\n\tf, err = w.Create(\"safe.txt\")\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = f.Write([]byte(\"safe content\"))\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn w.Close()\n}\n\nfunc TestIsArchiveFile_AdditionalFormats(t *testing.T) {\n\t// Test additional archive formats\n\ttests := []struct {\n\t\tfilename string\n\t\texpected bool\n\t}{\n\t\t// More compression formats\n\t\t{\"file.tar.lz4\", true},\n\t\t{\"file.tlz4\", true},\n\t\t{\"file.tar.sz\", true},\n\t\t{\"file.tsz\", true},\n\t\t// Edge cases\n\t\t{\"file.tar.gz.backup\", false},\n\t\t{\".gz\", true},\n\t\t{\"archive.ZIP.bak\", false},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.filename, func(t *testing.T) {\n\t\t\tresult := isArchiveFile(tt.filename)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"isArchiveFile(%q) = %v, expected %v\", tt.filename, result, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestExtractArchive_DestDirCreation(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"extract_destdir_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\tzipPath := filepath.Join(tempDir, \"test.zip\")\n\t// Use a nested path that doesn't exist\n\tdestDir := filepath.Join(tempDir, \"level1\", \"level2\", \"extracted\")\n\n\tif err := createTestZip(zipPath); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\terr = extractArchive(zipPath, destDir, \"\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"extractArchive failed: %v\", err)\n\t}\n\n\t// Verify destDir was created\n\tif _, err := os.Stat(destDir); os.IsNotExist(err) {\n\t\tt.Error(\"destDir was not created\")\n\t}\n\n\t// Verify files were extracted\n\tif _, err := os.Stat(filepath.Join(destDir, \"test.txt\")); os.IsNotExist(err) {\n\t\tt.Error(\"file not extracted\")\n\t}\n}\n\nfunc TestExtractArchive_ProgressCallbackValues(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"extract_progress_values_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\tzipPath := filepath.Join(tempDir, \"test.zip\")\n\tdestDir := filepath.Join(tempDir, \"extracted\")\n\n\t// Create zip with 10 files to test progress calculation\n\tif err := createTestZipWithMultipleFiles(zipPath, 10); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tvar progressValues []int\n\terr = extractArchive(zipPath, destDir, \"\", func(extracted int, total int, progress int) {\n\t\tprogressValues = append(progressValues, progress)\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"extractArchive failed: %v\", err)\n\t}\n\n\t// Verify progress increases monotonically\n\tfor i := 1; i < len(progressValues); i++ {\n\t\tif progressValues[i] < progressValues[i-1] {\n\t\t\tt.Errorf(\"progress decreased from %d to %d\", progressValues[i-1], progressValues[i])\n\t\t}\n\t}\n\n\t// Verify last progress is 100\n\tif len(progressValues) > 0 && progressValues[len(progressValues)-1] != 100 {\n\t\tt.Errorf(\"final progress should be 100, got %d\", progressValues[len(progressValues)-1])\n\t}\n}\n\nfunc TestCountArchiveFiles_WithDirectories(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"count_dirs_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\tzipPath := filepath.Join(tempDir, \"test.zip\")\n\tif err := createTestZipWithDirectories(zipPath); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tcount, err := countArchiveFiles(zipPath, \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"countArchiveFiles failed: %v\", err)\n\t}\n\n\t// Should only count files, not directories\n\t// createTestZipWithDirectories creates 2 files\n\tif count != 2 {\n\t\tt.Errorf(\"expected 2 files, got %d\", count)\n\t}\n}\n\nfunc TestOpenArchive_FileStatError(t *testing.T) {\n\t// Test error path when file can't be stat'd\n\t// This is hard to test without mocking, so we just ensure\n\t// the function handles basic error cases\n\n\ttempDir, err := os.MkdirTemp(\"\", \"open_archive_stat_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create a valid zip first\n\tzipPath := filepath.Join(tempDir, \"test.zip\")\n\tif err := createTestZip(zipPath); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Test normal opening\n\tinfo, err := openArchive(zipPath, \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"openArchive failed: %v\", err)\n\t}\n\tdefer info.file.Close()\n\n\tif info.stat == nil {\n\t\tt.Error(\"stat should not be nil\")\n\t}\n\tif info.format == nil {\n\t\tt.Error(\"format should not be nil\")\n\t}\n\tif info.input == nil {\n\t\tt.Error(\"input should not be nil\")\n\t}\n}\n\nfunc TestExtractArchive_FilePermissions(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"extract_perms_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\tzipPath := filepath.Join(tempDir, \"test.zip\")\n\tdestDir := filepath.Join(tempDir, \"extracted\")\n\n\tif err := createTestZip(zipPath); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\terr = extractArchive(zipPath, destDir, \"\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"extractArchive failed: %v\", err)\n\t}\n\n\t// Check that files are readable\n\tcontent, err := os.ReadFile(filepath.Join(destDir, \"test.txt\"))\n\tif err != nil {\n\t\tt.Fatalf(\"couldn't read extracted file: %v\", err)\n\t}\n\tif string(content) != \"Hello, World!\" {\n\t\tt.Errorf(\"unexpected content: %q\", string(content))\n\t}\n}\n\nfunc TestExtractArchive_TarGz(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"extract_targz_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\ttarGzPath := filepath.Join(tempDir, \"test.tar.gz\")\n\tdestDir := filepath.Join(tempDir, \"extracted\")\n\n\tif err := createTestTarGz(tarGzPath); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tvar progressCalls int\n\terr = extractArchive(tarGzPath, destDir, \"\", func(extracted int, total int, progress int) {\n\t\tprogressCalls++\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"extractArchive failed for tar.gz: %v\", err)\n\t}\n\n\t// Verify the extracted file exists\n\tdestPath := filepath.Join(destDir, \"test.txt\")\n\tcontent, err := os.ReadFile(destPath)\n\tif err != nil {\n\t\tt.Fatalf(\"couldn't read extracted file: %v\", err)\n\t}\n\tif string(content) != \"Hello from tar.gz!\" {\n\t\tt.Errorf(\"unexpected content: %q\", string(content))\n\t}\n\n\t// Verify nested file exists\n\tnestedPath := filepath.Join(destDir, \"subdir\", \"nested.txt\")\n\tcontent, err = os.ReadFile(nestedPath)\n\tif err != nil {\n\t\tt.Fatalf(\"couldn't read nested file: %v\", err)\n\t}\n\tif string(content) != \"Nested content\" {\n\t\tt.Errorf(\"unexpected nested content: %q\", string(content))\n\t}\n}\n\nfunc createTestTarGz(path string) error {\n\tfile, err := os.Create(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer file.Close()\n\n\tgzWriter := gzip.NewWriter(file)\n\tdefer gzWriter.Close()\n\n\ttarWriter := tar.NewWriter(gzWriter)\n\tdefer tarWriter.Close()\n\n\t// Add a file\n\tcontent := []byte(\"Hello from tar.gz!\")\n\theader := &tar.Header{\n\t\tName: \"test.txt\",\n\t\tMode: 0644,\n\t\tSize: int64(len(content)),\n\t}\n\tif err := tarWriter.WriteHeader(header); err != nil {\n\t\treturn err\n\t}\n\tif _, err := tarWriter.Write(content); err != nil {\n\t\treturn err\n\t}\n\n\t// Add a nested file\n\tnestedContent := []byte(\"Nested content\")\n\tnestedHeader := &tar.Header{\n\t\tName: \"subdir/nested.txt\",\n\t\tMode: 0644,\n\t\tSize: int64(len(nestedContent)),\n\t}\n\tif err := tarWriter.WriteHeader(nestedHeader); err != nil {\n\t\treturn err\n\t}\n\tif _, err := tarWriter.Write(nestedContent); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc TestCountArchiveFiles_TarGz(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"count_targz_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\ttarGzPath := filepath.Join(tempDir, \"test.tar.gz\")\n\tif err := createTestTarGz(tarGzPath); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tcount, err := countArchiveFiles(tarGzPath, \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"countArchiveFiles failed: %v\", err)\n\t}\n\n\t// Should have 2 files\n\tif count != 2 {\n\t\tt.Errorf(\"expected 2 files, got %d\", count)\n\t}\n}\n\nfunc TestExtractArchive_LargeFileCount(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"extract_large_count_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\tzipPath := filepath.Join(tempDir, \"test.zip\")\n\tdestDir := filepath.Join(tempDir, \"extracted\")\n\n\t// Create zip with many files to test progress capping at 100\n\tnumFiles := 100\n\tif err := createTestZipWithMultipleFiles(zipPath, numFiles); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tvar maxProgress int\n\terr = extractArchive(zipPath, destDir, \"\", func(extracted int, total int, progress int) {\n\t\tif progress > maxProgress {\n\t\t\tmaxProgress = progress\n\t\t}\n\t\t// Progress should never exceed 100\n\t\tif progress > 100 {\n\t\t\tt.Errorf(\"progress exceeded 100: %d\", progress)\n\t\t}\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"extractArchive failed: %v\", err)\n\t}\n\n\t// Max progress should be 100\n\tif maxProgress != 100 {\n\t\tt.Errorf(\"expected max progress 100, got %d\", maxProgress)\n\t}\n}\n\nfunc TestExtractArchive_GzipNoExtension(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"extract_gzip_noext_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create a gzip with just .gz (no original extension)\n\tgzPath := filepath.Join(tempDir, \"compressed.gz\")\n\tdestDir := filepath.Join(tempDir, \"extracted\")\n\n\tif err := createTestGzip(gzPath, \"Compressed content\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\terr = extractArchive(gzPath, destDir, \"\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"extractArchive failed: %v\", err)\n\t}\n\n\t// Should create file named \"compressed\" (without .gz)\n\tdestPath := filepath.Join(destDir, \"compressed\")\n\tif _, err := os.Stat(destPath); os.IsNotExist(err) {\n\t\tt.Error(\"expected decompressed file not found\")\n\t}\n}\n\nfunc TestExtractArchive_ReadOnlyDestDir(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"extract_readonly_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\tzipPath := filepath.Join(tempDir, \"test.zip\")\n\tdestDir := filepath.Join(tempDir, \"readonly\")\n\n\tif err := createTestZip(zipPath); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Create a read-only directory\n\tif err := os.MkdirAll(destDir, 0555); err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// Make it writable for cleanup\n\tdefer func() { _ = os.Chmod(destDir, 0755) }()\n\n\t// Windows ACL semantics mean chmod often does not prevent writes.\n\t// If the filesystem doesn't enforce the permission change, skip.\n\tprobe := filepath.Join(destDir, \"__write_probe.tmp\")\n\tif err := os.WriteFile(probe, []byte(\"x\"), 0644); err == nil {\n\t\t_ = os.Remove(probe)\n\t\tt.Skip(\"filesystem does not enforce read-only directory permissions\")\n\t}\n\n\t// Extraction should fail due to read-only destination\n\terr = extractArchive(zipPath, destDir, \"\", nil)\n\tif err == nil {\n\t\tt.Error(\"expected error when extracting to read-only directory\")\n\t}\n}\n\nfunc TestSupportedArchiveExtensions(t *testing.T) {\n\t// Test that all listed extensions are recognized\n\tfor _, ext := range supportedArchiveExtensions {\n\t\tfilename := \"test\" + ext\n\t\tif !isArchiveFile(filename) {\n\t\t\tt.Errorf(\"extension %q should be recognized as archive\", ext)\n\t\t}\n\t}\n}\n\nfunc TestExtractArchive_Tar(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"extract_tar_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\ttarPath := filepath.Join(tempDir, \"test.tar\")\n\tdestDir := filepath.Join(tempDir, \"extracted\")\n\n\tif err := createTestTar(tarPath); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\terr = extractArchive(tarPath, destDir, \"\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"extractArchive failed for tar: %v\", err)\n\t}\n\n\t// Verify files were extracted\n\tdestPath := filepath.Join(destDir, \"test.txt\")\n\tif _, err := os.Stat(destPath); os.IsNotExist(err) {\n\t\tt.Error(\"expected file not found\")\n\t}\n}\n\nfunc createTestTar(path string) error {\n\tfile, err := os.Create(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer file.Close()\n\n\ttarWriter := tar.NewWriter(file)\n\tdefer tarWriter.Close()\n\n\tcontent := []byte(\"Hello from tar!\")\n\theader := &tar.Header{\n\t\tName: \"test.txt\",\n\t\tMode: 0644,\n\t\tSize: int64(len(content)),\n\t}\n\tif err := tarWriter.WriteHeader(header); err != nil {\n\t\treturn err\n\t}\n\tif _, err := tarWriter.Write(content); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc TestCountArchiveFiles_Tar(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"count_tar_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\ttarPath := filepath.Join(tempDir, \"test.tar\")\n\tif err := createTestTar(tarPath); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tcount, err := countArchiveFiles(tarPath, \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"countArchiveFiles failed: %v\", err)\n\t}\n\n\tif count != 1 {\n\t\tt.Errorf(\"expected 1 file, got %d\", count)\n\t}\n}\n\nfunc TestOpenArchive_ValidArchive(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"open_valid_archive_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\tzipPath := filepath.Join(tempDir, \"test.zip\")\n\tif err := createTestZip(zipPath); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tinfo, err := openArchive(zipPath, \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"openArchive failed: %v\", err)\n\t}\n\tdefer info.file.Close()\n\n\t// Verify all fields are set\n\tif info.file == nil {\n\t\tt.Error(\"file should not be nil\")\n\t}\n\tif info.stat == nil {\n\t\tt.Error(\"stat should not be nil\")\n\t}\n\tif info.format == nil {\n\t\tt.Error(\"format should not be nil\")\n\t}\n\tif info.input == nil {\n\t\tt.Error(\"input should not be nil\")\n\t}\n\tif info.stat.Size() == 0 {\n\t\tt.Error(\"file size should be > 0\")\n\t}\n}\n\nfunc TestExtractArchive_NestedDirectoryStructure(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"extract_nested_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\tzipPath := filepath.Join(tempDir, \"nested.zip\")\n\tdestDir := filepath.Join(tempDir, \"extracted\")\n\n\tif err := createDeeplyNestedZip(zipPath); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\terr = extractArchive(zipPath, destDir, \"\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"extractArchive failed: %v\", err)\n\t}\n\n\t// Verify deeply nested file\n\tdeepPath := filepath.Join(destDir, \"a\", \"b\", \"c\", \"d\", \"deep.txt\")\n\tif _, err := os.Stat(deepPath); os.IsNotExist(err) {\n\t\tt.Error(\"deeply nested file not found\")\n\t}\n}\n\nfunc createDeeplyNestedZip(path string) error {\n\tzipFile, err := os.Create(path)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer zipFile.Close()\n\n\tw := zip.NewWriter(zipFile)\n\n\tf, err := w.Create(\"a/b/c/d/deep.txt\")\n\tif err != nil {\n\t\treturn err\n\t}\n\t_, err = f.Write([]byte(\"Deep content\"))\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn w.Close()\n}\n\nfunc TestExtractArchive_EmptyFileName(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"extract_empty_name_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\tzipPath := filepath.Join(tempDir, \"test.zip\")\n\tdestDir := filepath.Join(tempDir, \"extracted\")\n\n\t// Create a normal zip - the archive library handles empty names gracefully\n\tif err := createTestZip(zipPath); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\terr = extractArchive(zipPath, destDir, \"\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"extractArchive failed: %v\", err)\n\t}\n}\n\nfunc TestExtractArchive_ProgressTracking(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"extract_progress_track_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\tzipPath := filepath.Join(tempDir, \"test.zip\")\n\tdestDir := filepath.Join(tempDir, \"extracted\")\n\n\tnumFiles := 5\n\tif err := createTestZipWithMultipleFiles(zipPath, numFiles); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tvar extractedValues []int\n\tvar totalValues []int\n\n\terr = extractArchive(zipPath, destDir, \"\", func(extracted int, total int, progress int) {\n\t\textractedValues = append(extractedValues, extracted)\n\t\ttotalValues = append(totalValues, total)\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"extractArchive failed: %v\", err)\n\t}\n\n\t// Verify total is always the same\n\tfor _, total := range totalValues {\n\t\tif total != numFiles {\n\t\t\tt.Errorf(\"total should be %d, got %d\", numFiles, total)\n\t\t}\n\t}\n\n\t// Verify extracted increases sequentially\n\tfor i, extracted := range extractedValues {\n\t\tif extracted != i+1 {\n\t\t\tt.Errorf(\"extracted should be %d, got %d\", i+1, extracted)\n\t\t}\n\t}\n}\n\nfunc TestArchiveInfo_Fields(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"archive_info_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\tzipPath := filepath.Join(tempDir, \"test.zip\")\n\tif err := createTestZip(zipPath); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tinfo, err := openArchive(zipPath, \"\")\n\tif err != nil {\n\t\tt.Fatalf(\"openArchive failed: %v\", err)\n\t}\n\tdefer info.file.Close()\n\n\t// Verify file name\n\tif info.file.Name() != zipPath {\n\t\tt.Errorf(\"expected file name %q, got %q\", zipPath, info.file.Name())\n\t}\n\n\t// Verify stat mode\n\tif info.stat.Mode().IsDir() {\n\t\tt.Error(\"expected file, not directory\")\n\t}\n}\n\n// Tests for multi-part archive detection\nfunc TestIsMultiPartArchive(t *testing.T) {\n\ttests := []struct {\n\t\tfilename string\n\t\texpected bool\n\t}{\n\t\t// 7z multi-part\n\t\t{\"archive.7z.001\", true},\n\t\t{\"archive.7z.002\", true},\n\t\t{\"archive.7z.100\", true},\n\t\t{\"ARCHIVE.7Z.001\", true},\n\n\t\t// RAR new style\n\t\t{\"archive.part01.rar\", true},\n\t\t{\"archive.part1.rar\", true},\n\t\t{\"archive.part99.rar\", true},\n\t\t{\"ARCHIVE.PART01.RAR\", true},\n\n\t\t// RAR old style (extension parts)\n\t\t{\"archive.r00\", true},\n\t\t{\"archive.r01\", true},\n\t\t{\"archive.r99\", true},\n\n\t\t// ZIP multi-part\n\t\t{\"archive.zip.001\", true},\n\t\t{\"archive.zip.002\", true},\n\n\t\t// ZIP split\n\t\t{\"archive.z01\", true},\n\t\t{\"archive.z02\", true},\n\n\t\t// Regular (non-multi-part) archives\n\t\t{\"archive.zip\", false},\n\t\t{\"archive.rar\", false},\n\t\t{\"archive.7z\", false},\n\t\t{\"archive.tar.gz\", false},\n\n\t\t// Non-archive files\n\t\t{\"file.txt\", false},\n\t\t{\"file.001\", false}, // No .7z or .zip prefix\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.filename, func(t *testing.T) {\n\t\t\tresult := isMultiPartArchive(tt.filename)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"isMultiPartArchive(%q) = %v, expected %v\", tt.filename, result, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetArchivePartInfo_7z(t *testing.T) {\n\ttests := []struct {\n\t\tfilename    string\n\t\tbaseName    string\n\t\tpartNumber  int\n\t\tisMultiPart bool\n\t}{\n\t\t{\"archive.7z.001\", \"archive.7z\", 1, true},\n\t\t{\"archive.7z.002\", \"archive.7z\", 2, true},\n\t\t{\"archive.7z.010\", \"archive.7z\", 10, true},\n\t\t{\"my.file.7z.005\", \"my.file.7z\", 5, true},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.filename, func(t *testing.T) {\n\t\t\tinfo := getArchivePartInfo(tt.filename)\n\t\t\tif info.IsMultiPart != tt.isMultiPart {\n\t\t\t\tt.Errorf(\"IsMultiPart: expected %v, got %v\", tt.isMultiPart, info.IsMultiPart)\n\t\t\t}\n\t\t\tif info.BaseName != tt.baseName {\n\t\t\t\tt.Errorf(\"BaseName: expected %q, got %q\", tt.baseName, info.BaseName)\n\t\t\t}\n\t\t\tif info.PartNumber != tt.partNumber {\n\t\t\t\tt.Errorf(\"PartNumber: expected %d, got %d\", tt.partNumber, info.PartNumber)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetArchivePartInfo_RarNewStyle(t *testing.T) {\n\ttests := []struct {\n\t\tfilename    string\n\t\tbaseName    string\n\t\tpartNumber  int\n\t\tisMultiPart bool\n\t}{\n\t\t{\"archive.part01.rar\", \"archive\", 1, true},\n\t\t{\"archive.part02.rar\", \"archive\", 2, true},\n\t\t{\"archive.part1.rar\", \"archive\", 1, true},\n\t\t{\"my.archive.part10.rar\", \"my.archive\", 10, true},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.filename, func(t *testing.T) {\n\t\t\tinfo := getArchivePartInfo(tt.filename)\n\t\t\tif info.IsMultiPart != tt.isMultiPart {\n\t\t\t\tt.Errorf(\"IsMultiPart: expected %v, got %v\", tt.isMultiPart, info.IsMultiPart)\n\t\t\t}\n\t\t\tif info.BaseName != tt.baseName {\n\t\t\t\tt.Errorf(\"BaseName: expected %q, got %q\", tt.baseName, info.BaseName)\n\t\t\t}\n\t\t\tif info.PartNumber != tt.partNumber {\n\t\t\t\tt.Errorf(\"PartNumber: expected %d, got %d\", tt.partNumber, info.PartNumber)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetArchivePartInfo_RarOldStyle(t *testing.T) {\n\ttests := []struct {\n\t\tfilename    string\n\t\tbaseName    string\n\t\tpartNumber  int\n\t\tisMultiPart bool\n\t}{\n\t\t{\"archive.r00\", \"archive\", 1, true}, // r00 is treated as first extension part (after .rar)\n\t\t{\"archive.r01\", \"archive\", 1, true},\n\t\t{\"archive.r99\", \"archive\", 99, true},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.filename, func(t *testing.T) {\n\t\t\tinfo := getArchivePartInfo(tt.filename)\n\t\t\tif info.IsMultiPart != tt.isMultiPart {\n\t\t\t\tt.Errorf(\"IsMultiPart: expected %v, got %v\", tt.isMultiPart, info.IsMultiPart)\n\t\t\t}\n\t\t\tif info.BaseName != tt.baseName {\n\t\t\t\tt.Errorf(\"BaseName: expected %q, got %q\", tt.baseName, info.BaseName)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetArchivePartInfo_ZipMultiPart(t *testing.T) {\n\ttests := []struct {\n\t\tfilename    string\n\t\tbaseName    string\n\t\tpartNumber  int\n\t\tisMultiPart bool\n\t}{\n\t\t{\"archive.zip.001\", \"archive.zip\", 1, true},\n\t\t{\"archive.zip.002\", \"archive.zip\", 2, true},\n\t\t{\"my.file.zip.010\", \"my.file.zip\", 10, true},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.filename, func(t *testing.T) {\n\t\t\tinfo := getArchivePartInfo(tt.filename)\n\t\t\tif info.IsMultiPart != tt.isMultiPart {\n\t\t\t\tt.Errorf(\"IsMultiPart: expected %v, got %v\", tt.isMultiPart, info.IsMultiPart)\n\t\t\t}\n\t\t\tif info.BaseName != tt.baseName {\n\t\t\t\tt.Errorf(\"BaseName: expected %q, got %q\", tt.baseName, info.BaseName)\n\t\t\t}\n\t\t\tif info.PartNumber != tt.partNumber {\n\t\t\t\tt.Errorf(\"PartNumber: expected %d, got %d\", tt.partNumber, info.PartNumber)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetArchivePartInfo_ZipSplit(t *testing.T) {\n\ttests := []struct {\n\t\tfilename    string\n\t\tbaseName    string\n\t\tpartNumber  int\n\t\tisMultiPart bool\n\t}{\n\t\t{\"archive.z01\", \"archive\", 1, true},\n\t\t{\"archive.z02\", \"archive\", 2, true},\n\t\t{\"archive.z99\", \"archive\", 99, true},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.filename, func(t *testing.T) {\n\t\t\tinfo := getArchivePartInfo(tt.filename)\n\t\t\tif info.IsMultiPart != tt.isMultiPart {\n\t\t\t\tt.Errorf(\"IsMultiPart: expected %v, got %v\", tt.isMultiPart, info.IsMultiPart)\n\t\t\t}\n\t\t\tif info.BaseName != tt.baseName {\n\t\t\t\tt.Errorf(\"BaseName: expected %q, got %q\", tt.baseName, info.BaseName)\n\t\t\t}\n\t\t\tif info.PartNumber != tt.partNumber {\n\t\t\t\tt.Errorf(\"PartNumber: expected %d, got %d\", tt.partNumber, info.PartNumber)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetArchivePartInfo_NonMultiPart(t *testing.T) {\n\ttests := []string{\n\t\t\"archive.zip\",\n\t\t\"archive.rar\",\n\t\t\"archive.7z\",\n\t\t\"file.txt\",\n\t\t\"file.001\",\n\t}\n\n\tfor _, filename := range tests {\n\t\tt.Run(filename, func(t *testing.T) {\n\t\t\tinfo := getArchivePartInfo(filename)\n\t\t\tif info.IsMultiPart {\n\t\t\t\tt.Errorf(\"Expected non-multi-part for %q, but got IsMultiPart=true\", filename)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestIsFirstPart(t *testing.T) {\n\ttests := []struct {\n\t\tfilename string\n\t\texpected bool\n\t}{\n\t\t// First parts\n\t\t{\"archive.7z.001\", true},\n\t\t{\"archive.part01.rar\", true},\n\t\t{\"archive.part1.rar\", true},\n\t\t{\"archive.zip.001\", true},\n\t\t{\"archive.z01\", true},\n\n\t\t// Non-first parts\n\t\t{\"archive.7z.002\", false},\n\t\t{\"archive.part02.rar\", false},\n\t\t{\"archive.zip.002\", false},\n\t\t{\"archive.z02\", false},\n\n\t\t// For RAR old style (.r00, .r01), these are NOT the first part\n\t\t// The first part is the .rar file, but these extension files\n\t\t// have partNumber=1 due to parsePartNumber treating 00 as 1\n\t\t// So isFirstPart returns true for these (which is technically correct\n\t\t// in terms of part numbering, even though .rar is the \"real\" first file)\n\t\t{\"archive.r00\", true},\n\t\t{\"archive.r01\", true},\n\n\t\t// Non-multi-part (should return false)\n\t\t{\"archive.zip\", false},\n\t\t{\"archive.rar\", false},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.filename, func(t *testing.T) {\n\t\t\tresult := isFirstPart(tt.filename)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"isFirstPart(%q) = %v, expected %v\", tt.filename, result, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestGetMultiPartArchiveBaseName(t *testing.T) {\n\ttests := []struct {\n\t\tfilename string\n\t\texpected string\n\t}{\n\t\t{\"/path/to/archive.7z.001\", \"/path/to/archive.7z\"},\n\t\t{\"/path/to/archive.part01.rar\", \"/path/to/archive\"},\n\t\t{\"/path/to/archive.zip.001\", \"/path/to/archive.zip\"},\n\t\t{\"/path/to/archive.z01\", \"/path/to/archive\"},\n\t\t// Non-multi-part should return empty\n\t\t{\"/path/to/archive.zip\", \"\"},\n\t\t{\"/path/to/file.txt\", \"\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.filename, func(t *testing.T) {\n\t\t\tfilename := filepath.FromSlash(tt.filename)\n\t\t\texpected := tt.expected\n\t\t\tif expected != \"\" {\n\t\t\t\texpected = filepath.FromSlash(expected)\n\t\t\t}\n\t\t\tresult := GetMultiPartArchiveBaseName(filename)\n\t\t\tif result != expected {\n\t\t\t\tt.Errorf(\"GetMultiPartArchiveBaseName(%q) = %q, expected %q\", filename, result, expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestIsArchiveFile_IncludesMultiPart(t *testing.T) {\n\t// Test that isArchiveFile returns true for multi-part archives\n\ttests := []struct {\n\t\tfilename string\n\t\texpected bool\n\t}{\n\t\t{\"archive.7z.001\", true},\n\t\t{\"archive.7z.002\", true},\n\t\t{\"archive.part01.rar\", true},\n\t\t{\"archive.zip.001\", true},\n\t\t{\"archive.z01\", true},\n\t\t{\"archive.r00\", true},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.filename, func(t *testing.T) {\n\t\t\tresult := isArchiveFile(tt.filename)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"isArchiveFile(%q) = %v, expected %v\", tt.filename, result, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestArchivePartInfo_PatternField(t *testing.T) {\n\t// Verify that the Pattern field is set correctly for different formats\n\ttests := []struct {\n\t\tfilename        string\n\t\tpatternContains string\n\t}{\n\t\t{\"archive.7z.001\", \".7z)\"},\n\t\t{\"archive.part01.rar\", \".part\"},\n\t\t{\"archive.r00\", \".r(\"},\n\t\t{\"archive.zip.001\", \".zip)\"},\n\t\t{\"archive.z01\", \".z(\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.filename, func(t *testing.T) {\n\t\t\tinfo := getArchivePartInfo(tt.filename)\n\t\t\tif !info.IsMultiPart {\n\t\t\t\tt.Fatalf(\"Expected multi-part archive for %q\", tt.filename)\n\t\t\t}\n\t\t\tif info.Pattern == \"\" {\n\t\t\t\tt.Errorf(\"Pattern should not be empty for %q\", tt.filename)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestArchivePartInfo_FirstPartPath(t *testing.T) {\n\t// Verify that FirstPartPath is set correctly for different formats\n\ttests := []struct {\n\t\tfilename       string\n\t\texpectedSuffix string // Expected suffix of the FirstPartPath\n\t}{\n\t\t{\"archive.7z.001\", \"archive.7z.001\"},\n\t\t{\"archive.7z.002\", \"archive.7z.001\"},\n\t\t{\"archive.7z.005\", \"archive.7z.001\"},\n\t\t{\"archive.part01.rar\", \"archive.part01.rar\"},\n\t\t{\"archive.part02.rar\", \"archive.part01.rar\"},\n\t\t{\"archive.zip.001\", \"archive.zip.001\"},\n\t\t{\"archive.zip.003\", \"archive.zip.001\"},\n\t\t{\"archive.z01\", \"archive.z01\"},\n\t\t{\"archive.z05\", \"archive.z01\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.filename, func(t *testing.T) {\n\t\t\tinfo := getArchivePartInfo(tt.filename)\n\t\t\tif !info.IsMultiPart {\n\t\t\t\tt.Fatalf(\"Expected multi-part archive for %q\", tt.filename)\n\t\t\t}\n\t\t\tif info.FirstPartPath == \"\" {\n\t\t\t\tt.Errorf(\"FirstPartPath should not be empty for %q\", tt.filename)\n\t\t\t}\n\t\t\tif !strings.HasSuffix(info.FirstPartPath, tt.expectedSuffix) {\n\t\t\t\tt.Errorf(\"FirstPartPath for %q = %q, expected suffix %q\", tt.filename, info.FirstPartPath, tt.expectedSuffix)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// Tests for multiPartFileReader\nfunc TestMultiPartFileReader_Basic(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"multipart_reader_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create test parts with known content\n\tpart1Content := []byte(\"Hello\")\n\tpart2Content := []byte(\"World\")\n\tpart3Content := []byte(\"!\")\n\n\tpart1Path := filepath.Join(tempDir, \"test.001\")\n\tpart2Path := filepath.Join(tempDir, \"test.002\")\n\tpart3Path := filepath.Join(tempDir, \"test.003\")\n\n\tif err := os.WriteFile(part1Path, part1Content, 0644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(part2Path, part2Content, 0644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(part3Path, part3Content, 0644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\treader := newMultiPartFileReader([]string{part1Path, part2Path, part3Path})\n\tdefer reader.Close()\n\n\t// Test Size()\n\texpectedSize := int64(len(part1Content) + len(part2Content) + len(part3Content))\n\tif reader.Size() != expectedSize {\n\t\tt.Errorf(\"Size() = %d, expected %d\", reader.Size(), expectedSize)\n\t}\n}\n\nfunc TestMultiPartFileReader_ReadAt(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"multipart_readat_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create test parts with known content\n\tpart1Path := filepath.Join(tempDir, \"test.001\")\n\tpart2Path := filepath.Join(tempDir, \"test.002\")\n\n\tif err := os.WriteFile(part1Path, []byte(\"AAAA\"), 0644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(part2Path, []byte(\"BBBB\"), 0644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\treader := newMultiPartFileReader([]string{part1Path, part2Path})\n\tdefer reader.Close()\n\n\t// Test reading from first part\n\tbuf := make([]byte, 2)\n\tn, err := reader.ReadAt(buf, 0)\n\tif err != nil {\n\t\tt.Errorf(\"ReadAt(0) error: %v\", err)\n\t}\n\tif n != 2 || string(buf) != \"AA\" {\n\t\tt.Errorf(\"ReadAt(0) = %q, expected %q\", string(buf[:n]), \"AA\")\n\t}\n\n\t// Test reading across parts\n\tbuf = make([]byte, 4)\n\tn, err = reader.ReadAt(buf, 2)\n\tif err != nil {\n\t\tt.Errorf(\"ReadAt(2) error: %v\", err)\n\t}\n\tif n != 4 || string(buf) != \"AABB\" {\n\t\tt.Errorf(\"ReadAt(2) = %q, expected %q\", string(buf[:n]), \"AABB\")\n\t}\n\n\t// Test reading from second part only\n\tbuf = make([]byte, 2)\n\tn, err = reader.ReadAt(buf, 6)\n\tif err != nil {\n\t\tt.Errorf(\"ReadAt(6) error: %v\", err)\n\t}\n\tif n != 2 || string(buf) != \"BB\" {\n\t\tt.Errorf(\"ReadAt(6) = %q, expected %q\", string(buf[:n]), \"BB\")\n\t}\n}\n\nfunc TestMultiPartFileReader_ReadAtEOF(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"multipart_eof_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\tpart1Path := filepath.Join(tempDir, \"test.001\")\n\tif err := os.WriteFile(part1Path, []byte(\"ABC\"), 0644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\treader := newMultiPartFileReader([]string{part1Path})\n\tdefer reader.Close()\n\n\t// Reading beyond EOF should return io.EOF\n\tbuf := make([]byte, 10)\n\tn, err := reader.ReadAt(buf, 100)\n\tif err != io.EOF {\n\t\tt.Errorf(\"ReadAt beyond EOF: expected io.EOF, got %v\", err)\n\t}\n\tif n != 0 {\n\t\tt.Errorf(\"ReadAt beyond EOF: expected 0 bytes, got %d\", n)\n\t}\n}\n\nfunc TestMultiPartFileReader_Close(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"multipart_close_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\tpart1Path := filepath.Join(tempDir, \"test.001\")\n\tif err := os.WriteFile(part1Path, []byte(\"test\"), 0644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\treader := newMultiPartFileReader([]string{part1Path})\n\n\t// Initialize by calling Size\n\t_ = reader.Size()\n\n\t// Close should work\n\terr = reader.Close()\n\tif err != nil {\n\t\tt.Errorf(\"Close() error: %v\", err)\n\t}\n\n\t// After close, files should be nil\n\tif reader.files != nil {\n\t\tt.Error(\"files should be nil after Close()\")\n\t}\n}\n\nfunc TestMultiPartFileReader_InitError(t *testing.T) {\n\t// Test with non-existent file\n\treader := newMultiPartFileReader([]string{\"/nonexistent/path/file.001\"})\n\tdefer reader.Close()\n\n\t// Size should return 0 on error\n\tsize := reader.Size()\n\tif size != 0 {\n\t\tt.Errorf(\"Size() with non-existent file should return 0, got %d\", size)\n\t}\n\n\t// ReadAt should return error\n\tbuf := make([]byte, 10)\n\t_, err := reader.ReadAt(buf, 0)\n\tif err == nil {\n\t\tt.Error(\"ReadAt with non-existent file should return error\")\n\t}\n}\n\n// Tests for findZipMultiParts\nfunc TestFindZipMultiParts(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"find_zip_parts_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create test parts\n\tfor i := 1; i <= 5; i++ {\n\t\tpartPath := filepath.Join(tempDir, fmt.Sprintf(\"archive.zip.%03d\", i))\n\t\tif err := os.WriteFile(partPath, []byte(\"test\"), 0644); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\n\tfirstPartPath := filepath.Join(tempDir, \"archive.zip.001\")\n\tparts, err := findZipMultiParts(firstPartPath)\n\tif err != nil {\n\t\tt.Fatalf(\"findZipMultiParts error: %v\", err)\n\t}\n\n\tif len(parts) != 5 {\n\t\tt.Errorf(\"Expected 5 parts, got %d\", len(parts))\n\t}\n\n\t// Verify order\n\tfor i, part := range parts {\n\t\texpected := filepath.Join(tempDir, fmt.Sprintf(\"archive.zip.%03d\", i+1))\n\t\tif part != expected {\n\t\t\tt.Errorf(\"Part %d: expected %q, got %q\", i, expected, part)\n\t\t}\n\t}\n}\n\nfunc TestFindZipMultiParts_NoParts(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"find_zip_no_parts_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Don't create any parts\n\tfirstPartPath := filepath.Join(tempDir, \"archive.zip.001\")\n\t_, err = findZipMultiParts(firstPartPath)\n\tif err == nil {\n\t\tt.Error(\"Expected error when no parts found\")\n\t}\n}\n\nfunc TestFindZipMultiParts_SinglePart(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"find_zip_single_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create only the first part\n\tfirstPartPath := filepath.Join(tempDir, \"archive.zip.001\")\n\tif err := os.WriteFile(firstPartPath, []byte(\"test\"), 0644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tparts, err := findZipMultiParts(firstPartPath)\n\tif err != nil {\n\t\tt.Fatalf(\"findZipMultiParts error: %v\", err)\n\t}\n\n\tif len(parts) != 1 {\n\t\tt.Errorf(\"Expected 1 part, got %d\", len(parts))\n\t}\n}\n\n// Tests for extractMultiPartArchive error paths\nfunc TestExtractMultiPartArchive_NonExistentFile(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"extract_multipart_err_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\tdestDir := filepath.Join(tempDir, \"extracted\")\n\n\t// Test with non-existent 7z multi-part\n\terr = extractMultiPartArchive(\"/nonexistent/archive.7z.001\", destDir, \"\", nil)\n\tif err == nil {\n\t\tt.Error(\"Expected error for non-existent 7z file\")\n\t}\n\n\t// Test with non-existent zip multi-part\n\terr = extractMultiPartArchive(\"/nonexistent/archive.zip.001\", destDir, \"\", nil)\n\tif err == nil {\n\t\tt.Error(\"Expected error for non-existent zip file\")\n\t}\n}\n\n// Test extractZipMultiPart with invalid archive\nfunc TestExtractZipMultiPart_InvalidArchive(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"extract_zip_invalid_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create invalid \"zip\" parts (just text files)\n\tpart1Path := filepath.Join(tempDir, \"invalid.zip.001\")\n\tif err := os.WriteFile(part1Path, []byte(\"not a zip file\"), 0644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tdestDir := filepath.Join(tempDir, \"extracted\")\n\terr = extractZipMultiPart(part1Path, destDir, \"\", nil)\n\t// Should return an error because the file is not a valid zip\n\tif err == nil {\n\t\tt.Error(\"Expected error for invalid zip archive\")\n\t}\n}\n\n// Test extractRarMultiPart with non-existent file\nfunc TestExtractRarMultiPart_NonExistent(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"extract_rar_err_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\tdestDir := filepath.Join(tempDir, \"extracted\")\n\terr = extractRarMultiPart(\"/nonexistent/archive.part01.rar\", destDir, \"\", nil)\n\tif err == nil {\n\t\tt.Error(\"Expected error for non-existent RAR file\")\n\t}\n}\n\n// Test extractSevenZipMultiPart with non-existent file\nfunc TestExtractSevenZipMultiPart_NonExistent(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"extract_7z_err_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\tdestDir := filepath.Join(tempDir, \"extracted\")\n\terr = extractSevenZipMultiPart(\"/nonexistent/archive.7z.001\", destDir, \"\", nil)\n\tif err == nil {\n\t\tt.Error(\"Expected error for non-existent 7z file\")\n\t}\n}\n\n// Test extractSevenZipMultiPart with invalid archive\nfunc TestExtractSevenZipMultiPart_Invalid(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"extract_7z_invalid_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create an invalid 7z file\n\tpart1Path := filepath.Join(tempDir, \"invalid.7z.001\")\n\tif err := os.WriteFile(part1Path, []byte(\"not a 7z file\"), 0644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tdestDir := filepath.Join(tempDir, \"extracted\")\n\terr = extractSevenZipMultiPart(part1Path, destDir, \"\", nil)\n\tif err == nil {\n\t\tt.Error(\"Expected error for invalid 7z archive\")\n\t}\n}\n\n// Test determineFirstPartPath with all patterns\nfunc TestDetermineFirstPartPath(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tdir      string\n\t\tbaseName string\n\t\tpattern  string\n\t\texpected string\n\t}{\n\t\t{\n\t\t\tname:     \"7z pattern\",\n\t\t\tdir:      \"/path/to\",\n\t\t\tbaseName: \"archive.7z\",\n\t\t\tpattern:  `(?i)^(.+\\.7z)\\.(\\d{3})$`,\n\t\t\texpected: \"/path/to/archive.7z.001\",\n\t\t},\n\t\t{\n\t\t\tname:     \"RAR new style pattern\",\n\t\t\tdir:      \"/path/to\",\n\t\t\tbaseName: \"archive\",\n\t\t\tpattern:  `(?i)^(.+)\\.part(\\d+)\\.rar$`,\n\t\t\texpected: \"/path/to/archive.part01.rar\",\n\t\t},\n\t\t{\n\t\t\tname:     \"RAR old style pattern\",\n\t\t\tdir:      \"/path/to\",\n\t\t\tbaseName: \"archive\",\n\t\t\tpattern:  `(?i)^(.+)\\.r(\\d{2})$`,\n\t\t\texpected: \"/path/to/archive.rar\",\n\t\t},\n\t\t{\n\t\t\tname:     \"ZIP multi-part pattern\",\n\t\t\tdir:      \"/path/to\",\n\t\t\tbaseName: \"archive.zip\",\n\t\t\tpattern:  `(?i)^(.+\\.zip)\\.(\\d{3})$`,\n\t\t\texpected: \"/path/to/archive.zip.001\",\n\t\t},\n\t\t{\n\t\t\tname:     \"ZIP split pattern\",\n\t\t\tdir:      \"/path/to\",\n\t\t\tbaseName: \"archive\",\n\t\t\tpattern:  `(?i)^(.+)\\.z(\\d{2})$`,\n\t\t\texpected: \"/path/to/archive.z01\",\n\t\t},\n\t\t{\n\t\t\tname:     \"Unknown pattern\",\n\t\t\tdir:      \"/path/to\",\n\t\t\tbaseName: \"archive\",\n\t\t\tpattern:  \"unknown\",\n\t\t\texpected: \"\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tdir := filepath.FromSlash(tt.dir)\n\t\t\texpected := tt.expected\n\t\t\tif expected != \"\" {\n\t\t\t\texpected = filepath.FromSlash(expected)\n\t\t\t}\n\n\t\t\tresult := determineFirstPartPath(dir, tt.baseName, tt.pattern)\n\t\t\tif result != expected {\n\t\t\t\tt.Errorf(\"determineFirstPartPath(%q, %q, %q) = %q, expected %q\",\n\t\t\t\t\tdir, tt.baseName, tt.pattern, result, expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// Test parsePartNumber\nfunc TestParsePartNumber(t *testing.T) {\n\ttests := []struct {\n\t\tinput    string\n\t\texpected int\n\t}{\n\t\t{\"001\", 1},\n\t\t{\"01\", 1},\n\t\t{\"1\", 1},\n\t\t{\"00\", 1}, // 00 is treated as 1\n\t\t{\"10\", 10},\n\t\t{\"99\", 99},\n\t\t{\"100\", 100},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.input, func(t *testing.T) {\n\t\t\tvar result int\n\t\t\t_, err := parsePartNumber(tt.input, &result)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"parsePartNumber(%q) error: %v\", tt.input, err)\n\t\t\t}\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"parsePartNumber(%q) = %d, expected %d\", tt.input, result, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// Test multiPartArchivePatterns directly\nfunc TestMultiPartArchivePatterns(t *testing.T) {\n\t// Verify patterns are valid and match expected formats\n\ttestCases := []struct {\n\t\tfilename string\n\t\tmatches  bool\n\t}{\n\t\t// 7z\n\t\t{\"archive.7z.001\", true},\n\t\t{\"archive.7z.999\", true},\n\t\t{\"Archive.7Z.001\", true},\n\t\t{\"archive.7z.01\", false},   // Only 3 digits\n\t\t{\"archive.7z.0001\", false}, // 4 digits not matched\n\n\t\t// RAR new style\n\t\t{\"archive.part01.rar\", true},\n\t\t{\"archive.part1.rar\", true},\n\t\t{\"archive.part999.rar\", true},\n\t\t{\"archive.PART01.RAR\", true},\n\n\t\t// RAR old style\n\t\t{\"archive.r00\", true},\n\t\t{\"archive.r01\", true},\n\t\t{\"archive.r99\", true},\n\t\t{\"archive.R00\", true},\n\n\t\t// ZIP multi-part\n\t\t{\"archive.zip.001\", true},\n\t\t{\"archive.zip.999\", true},\n\t\t{\"Archive.ZIP.001\", true},\n\n\t\t// ZIP split\n\t\t{\"archive.z01\", true},\n\t\t{\"archive.z99\", true},\n\t\t{\"Archive.Z01\", true},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.filename, func(t *testing.T) {\n\t\t\tmatched := false\n\t\t\tfor _, pattern := range multiPartArchivePatterns {\n\t\t\t\tif pattern.MatchString(tc.filename) {\n\t\t\t\t\tmatched = true\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif matched != tc.matches {\n\t\t\t\tt.Errorf(\"Pattern match for %q: got %v, expected %v\", tc.filename, matched, tc.matches)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// Test extractRarMultiPart - test that destDir creation works\nfunc TestExtractRarMultiPart_DestDirCreation(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"extract_rar_destdir_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create a simple RAR-like file (will fail extraction but destDir should be created)\n\trarPath := filepath.Join(tempDir, \"test.part01.rar\")\n\tif err := os.WriteFile(rarPath, []byte(\"Rar!\\x1a\\x07\\x00\"), 0644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tdestDir := filepath.Join(tempDir, \"level1\", \"level2\", \"extracted\")\n\t// We expect an error since the file is not a complete valid RAR\n\t_ = extractRarMultiPart(rarPath, destDir, \"\", nil)\n\n\t// The destDir should have been created before the extraction error\n\tif _, err := os.Stat(destDir); os.IsNotExist(err) {\n\t\tt.Log(\"Note: destDir was not created, extraction failed early\")\n\t}\n}\n\n// Test the old-style RAR detection with .rar + .r00 files\nfunc TestGetArchivePartInfo_RarOldStyleWithRar(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"rar_old_style_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create a .rar file and .r00 file to simulate old-style multi-part\n\trarPath := filepath.Join(tempDir, \"archive.rar\")\n\tr00Path := filepath.Join(tempDir, \"archive.r00\")\n\n\tif err := os.WriteFile(rarPath, []byte(\"fake rar\"), 0644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := os.WriteFile(r00Path, []byte(\"fake r00\"), 0644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Test that .rar file is detected as first part of old-style multi-part\n\tinfo := getArchivePartInfo(rarPath)\n\tif !info.IsMultiPart {\n\t\tt.Error(\"Expected .rar with .r00 to be detected as multi-part\")\n\t}\n\tif info.Pattern != \"rar-old-style\" {\n\t\tt.Errorf(\"Expected pattern 'rar-old-style', got %q\", info.Pattern)\n\t}\n\tif info.FirstPartPath != rarPath {\n\t\tt.Errorf(\"Expected FirstPartPath to be %q, got %q\", rarPath, info.FirstPartPath)\n\t}\n}\n\n// Test extractSevenZipFile function indirectly through mock\nfunc TestExtractSevenZipMultiPart_DestDirCreation(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"7z_destdir_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create an invalid 7z file - the test is for error handling, not extraction\n\tpart1Path := filepath.Join(tempDir, \"test.7z.001\")\n\tif err := os.WriteFile(part1Path, []byte(\"invalid\"), 0644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Use a nested dest directory that doesn't exist\n\tdestDir := filepath.Join(tempDir, \"level1\", \"level2\", \"extracted\")\n\terr = extractSevenZipMultiPart(part1Path, destDir, \"\", nil)\n\t// Error is expected because the file is invalid, but the dest directory should be created\n\t// Actually, error happens before directory creation in this case\n\tif err == nil {\n\t\tt.Error(\"Expected error for invalid 7z\")\n\t}\n}\n\n// Test multiPartFileReader with multiple files spanning reads\nfunc TestMultiPartFileReader_SpanningRead(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"multipart_spanning_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create 3 parts with known sizes\n\tparts := []string{\n\t\tfilepath.Join(tempDir, \"test.001\"),\n\t\tfilepath.Join(tempDir, \"test.002\"),\n\t\tfilepath.Join(tempDir, \"test.003\"),\n\t}\n\n\tcontents := []string{\"12345\", \"67890\", \"ABCDE\"}\n\tfor i, content := range contents {\n\t\tif err := os.WriteFile(parts[i], []byte(content), 0644); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\n\treader := newMultiPartFileReader(parts)\n\tdefer reader.Close()\n\n\t// Read all content at once\n\tbuf := make([]byte, 15)\n\tn, err := reader.ReadAt(buf, 0)\n\tif err != nil {\n\t\tt.Errorf(\"ReadAt error: %v\", err)\n\t}\n\tif n != 15 {\n\t\tt.Errorf(\"Expected to read 15 bytes, got %d\", n)\n\t}\n\tif string(buf) != \"1234567890ABCDE\" {\n\t\tt.Errorf(\"Expected '1234567890ABCDE', got %q\", string(buf))\n\t}\n}\n\n// Test extractZipMultiPart with destDir creation\nfunc TestExtractZipMultiPart_DestDirCreation(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"zip_multipart_destdir_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create a valid single-part \"multi-part\" zip (just one .001 file with valid zip content)\n\t// First create a valid zip\n\tzipPath := filepath.Join(tempDir, \"temp.zip\")\n\tif err := createTestZip(zipPath); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Read the zip content and write as .001\n\tzipContent, err := os.ReadFile(zipPath)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tpart1Path := filepath.Join(tempDir, \"archive.zip.001\")\n\tif err := os.WriteFile(part1Path, zipContent, 0644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Extract to nested directory\n\tdestDir := filepath.Join(tempDir, \"level1\", \"level2\", \"extracted\")\n\terr = extractZipMultiPart(part1Path, destDir, \"\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"extractZipMultiPart error: %v\", err)\n\t}\n\n\t// Verify destDir was created and files extracted\n\tif _, err := os.Stat(destDir); os.IsNotExist(err) {\n\t\tt.Error(\"destDir was not created\")\n\t}\n\n\t// Verify extracted file\n\textractedFile := filepath.Join(destDir, \"test.txt\")\n\tif _, err := os.Stat(extractedFile); os.IsNotExist(err) {\n\t\tt.Error(\"Expected file not found after extraction\")\n\t}\n}\n\n// Test extractZipMultiPart with progress callback\nfunc TestExtractZipMultiPart_Progress(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"zip_multipart_progress_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create a valid zip with multiple files\n\tzipPath := filepath.Join(tempDir, \"temp.zip\")\n\tif err := createTestZipWithMultipleFiles(zipPath, 4); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tzipContent, err := os.ReadFile(zipPath)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tpart1Path := filepath.Join(tempDir, \"archive.zip.001\")\n\tif err := os.WriteFile(part1Path, zipContent, 0644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tdestDir := filepath.Join(tempDir, \"extracted\")\n\tvar progressCalls int\n\terr = extractZipMultiPart(part1Path, destDir, \"\", func(extracted int, total int, progress int) {\n\t\tprogressCalls++\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"extractZipMultiPart error: %v\", err)\n\t}\n\n\t// Should have progress calls\n\tif progressCalls == 0 {\n\t\tt.Error(\"Expected progress callbacks\")\n\t}\n}\n\n// Test extracting ZIP files with Chinese filenames encoded in GBK/GB18030\nfunc TestExtractArchive_ChineseFilenames(t *testing.T) {\n\ttempDir, err := os.MkdirTemp(\"\", \"extract_chinese_test\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tdefer os.RemoveAll(tempDir)\n\n\t// Create a test ZIP file with Chinese filenames encoded in GBK\n\tzipPath := filepath.Join(tempDir, \"chinese.zip\")\n\tdestDir := filepath.Join(tempDir, \"extracted\")\n\n\tif err := createTestZipWithChineseFilenames(zipPath); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\t// Extract the archive\n\terr = extractArchive(zipPath, destDir, \"\", nil)\n\tif err != nil {\n\t\tt.Fatalf(\"extractArchive failed: %v\", err)\n\t}\n\n\t// Verify the extracted files with proper Chinese filenames\n\texpectedFiles := []string{\n\t\tfilepath.Join(destDir, \"测试文件.txt\"),\n\t\tfilepath.Join(destDir, \"文件夹\", \"中文内容.txt\"),\n\t}\n\n\tfor _, path := range expectedFiles {\n\t\tif _, err := os.Stat(path); os.IsNotExist(err) {\n\t\t\tt.Errorf(\"expected file %q not found after extraction\", path)\n\t\t}\n\t}\n\n\t// Verify content of the Chinese file\n\tcontent, err := os.ReadFile(filepath.Join(destDir, \"测试文件.txt\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif string(content) != \"这是测试内容\" {\n\t\tt.Errorf(\"unexpected content: %q\", string(content))\n\t}\n}\n"
  },
  {
    "path": "pkg/download/extract_zip.go",
    "content": "package download\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/mholt/archives\"\n)\n\n// extractZipMultiPart extracts a multi-part ZIP archive (.zip.001, .zip.002, etc.)\n// These are created by simply splitting a ZIP file into chunks, so we concatenate them\nfunc extractZipMultiPart(firstPartPath string, destDir string, password string, progressCallback ExtractProgressCallback) error {\n\t// Find all parts\n\tparts, err := findZipMultiParts(firstPartPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Create a multi-part reader that reads across all parts\n\tmultiReader := newMultiPartFileReader(parts)\n\tdefer multiReader.Close()\n\n\t// Get total size\n\ttotalSize := multiReader.Size()\n\n\t// Create destination directory\n\tif err := os.MkdirAll(destDir, 0755); err != nil {\n\t\treturn err\n\t}\n\n\t// First pass: count files for progress\n\ttotalFiles := 0\n\tzip := newZipFormat()\n\terr = zip.Extract(context.Background(), io.NewSectionReader(multiReader, 0, totalSize), func(ctx context.Context, fileInfo archives.FileInfo) error {\n\t\tif !fileInfo.IsDir() {\n\t\t\ttotalFiles++\n\t\t}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\t// If counting fails, proceed without progress\n\t\ttotalFiles = 0\n\t}\n\n\t// Reset reader for actual extraction\n\tmultiReader.Close()\n\tmultiReader = newMultiPartFileReader(parts)\n\tdefer multiReader.Close()\n\n\t// Second pass: extract with progress tracking\n\treturn zip.Extract(context.Background(), io.NewSectionReader(multiReader, 0, totalSize), createExtractionHandler(destDir, totalFiles, progressCallback))\n}\n\n// findZipMultiParts finds all parts of a multi-part ZIP archive in order\nfunc findZipMultiParts(firstPartPath string) ([]string, error) {\n\t// Extract base name (e.g., \"Archive.zip\" from \"Archive.zip.001\")\n\tdir := filepath.Dir(firstPartPath)\n\tbaseName := filepath.Base(firstPartPath)\n\n\t// Remove the .001 suffix to get the base\n\tif idx := strings.LastIndex(baseName, \".\"); idx > 0 {\n\t\tbaseName = baseName[:idx] // \"Archive.zip\"\n\t}\n\n\tvar parts []string\n\tpartNum := 1\n\n\tfor {\n\t\tpartPath := filepath.Join(dir, baseName+fmt.Sprintf(\".%03d\", partNum))\n\t\tif _, err := os.Stat(partPath); os.IsNotExist(err) {\n\t\t\tbreak\n\t\t}\n\t\tparts = append(parts, partPath)\n\t\tpartNum++\n\t}\n\n\tif len(parts) == 0 {\n\t\treturn nil, fmt.Errorf(\"no parts found for %s\", firstPartPath)\n\t}\n\n\treturn parts, nil\n}\n\n// multiPartFileReader provides io.ReaderAt over multiple files concatenated\ntype multiPartFileReader struct {\n\tparts     []string\n\tfiles     []*os.File\n\tsizes     []int64\n\toffsets   []int64 // cumulative offsets for each file\n\ttotalSize int64\n}\n\nfunc newMultiPartFileReader(parts []string) *multiPartFileReader {\n\treturn &multiPartFileReader{parts: parts}\n}\n\nfunc (m *multiPartFileReader) init() error {\n\tif m.files != nil {\n\t\treturn nil\n\t}\n\n\tm.files = make([]*os.File, len(m.parts))\n\tm.sizes = make([]int64, len(m.parts))\n\tm.offsets = make([]int64, len(m.parts))\n\n\tvar offset int64\n\tfor i, part := range m.parts {\n\t\tf, err := os.Open(part)\n\t\tif err != nil {\n\t\t\tm.Close()\n\t\t\treturn err\n\t\t}\n\t\tstat, err := f.Stat()\n\t\tif err != nil {\n\t\t\tf.Close()\n\t\t\tm.Close()\n\t\t\treturn err\n\t\t}\n\t\tm.files[i] = f\n\t\tm.sizes[i] = stat.Size()\n\t\tm.offsets[i] = offset\n\t\toffset += stat.Size()\n\t}\n\tm.totalSize = offset\n\n\treturn nil\n}\n\nfunc (m *multiPartFileReader) Size() int64 {\n\tif err := m.init(); err != nil {\n\t\treturn 0\n\t}\n\treturn m.totalSize\n}\n\nfunc (m *multiPartFileReader) ReadAt(p []byte, off int64) (n int, err error) {\n\tif err := m.init(); err != nil {\n\t\treturn 0, err\n\t}\n\n\tif off >= m.totalSize {\n\t\treturn 0, io.EOF\n\t}\n\n\t// Find which file(s) to read from\n\tfor i, fileOffset := range m.offsets {\n\t\tfileEnd := fileOffset + m.sizes[i]\n\n\t\tif off >= fileEnd {\n\t\t\tcontinue\n\t\t}\n\n\t\t// Read from this file\n\t\tlocalOffset := off - fileOffset\n\t\ttoRead := len(p) - n\n\t\tif int64(toRead) > fileEnd-off {\n\t\t\ttoRead = int(fileEnd - off)\n\t\t}\n\n\t\tread, err := m.files[i].ReadAt(p[n:n+toRead], localOffset)\n\t\tn += read\n\t\toff += int64(read)\n\n\t\tif err != nil && err != io.EOF {\n\t\t\treturn n, err\n\t\t}\n\n\t\tif n >= len(p) {\n\t\t\treturn n, nil\n\t\t}\n\t}\n\n\tif n == 0 {\n\t\treturn 0, io.EOF\n\t}\n\treturn n, nil\n}\n\nfunc (m *multiPartFileReader) Close() error {\n\tfor _, f := range m.files {\n\t\tif f != nil {\n\t\t\tf.Close()\n\t\t}\n\t}\n\tm.files = nil\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/download/model.go",
    "content": "package download\n\nimport (\n\t\"encoding/json\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/GopeedLab/gopeed/internal/controller\"\n\t\"github.com/GopeedLab/gopeed/internal/fetcher\"\n\t\"github.com/GopeedLab/gopeed/internal/protocol/bt\"\n\t\"github.com/GopeedLab/gopeed/internal/protocol/ed2k\"\n\t\"github.com/GopeedLab/gopeed/internal/protocol/http\"\n\t\"github.com/GopeedLab/gopeed/pkg/base\"\n\t\"github.com/GopeedLab/gopeed/pkg/util\"\n\tgonanoid \"github.com/matoous/go-nanoid/v2\"\n)\n\ntype ResolveResult struct {\n\tID  string         `json:\"id\"`\n\tRes *base.Resource `json:\"res\"`\n}\n\ntype Task struct {\n\tID        string               `json:\"id\"`\n\tProtocol  string               `json:\"protocol\"`\n\tMeta      *fetcher.FetcherMeta `json:\"meta\"`\n\tStatus    base.Status          `json:\"status\"`\n\tUploading bool                 `json:\"uploading\"`\n\tProgress  *Progress            `json:\"progress\"`\n\tIsCreated bool                 `json:\"isCreated\"`\n\tCreatedAt time.Time            `json:\"createdAt\"`\n\tUpdatedAt time.Time            `json:\"updatedAt\"`\n\n\tfetcherManager fetcher.FetcherManager\n\tfetcher        fetcher.Fetcher\n\ttimer          *util.Timer\n\tstatusLock     *sync.Mutex\n\tlock           *sync.Mutex\n\tspeedArr       []int64\n\tuploadSpeedArr []int64\n}\n\nfunc NewTask() *Task {\n\tid, err := gonanoid.New()\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\treturn &Task{\n\t\tID:        id,\n\t\tStatus:    base.DownloadStatusReady,\n\t\tCreatedAt: time.Now(),\n\t\tUpdatedAt: time.Now(),\n\t\tIsCreated: false,\n\t}\n}\n\n// Name returns the display name of the task.\nfunc (t *Task) Name() string {\n\t// Custom name first\n\tif t.Meta.Opts.Name != \"\" {\n\t\treturn t.Meta.Opts.Name\n\t}\n\n\t// Task is not resolved, parse the name from the URL\n\tif t.Meta.Res == nil {\n\t\tfallbackName := \"unknown\"\n\t\tif t.fetcherManager == nil {\n\t\t\treturn fallbackName\n\t\t}\n\t\tparseName := t.fetcherManager.ParseName(t.Meta.Req.URL)\n\t\tif parseName == \"\" {\n\t\t\treturn fallbackName\n\t\t}\n\t\treturn parseName\n\t}\n\n\t// Task is a folder\n\tif t.Meta.Res.Name != \"\" {\n\t\treturn t.Meta.Res.Name\n\t}\n\n\t// Get the name of the first file\n\treturn t.Meta.Res.Files[0].Name\n}\n\nfunc (t *Task) MarshalJSON() ([]byte, error) {\n\ttype rawTaskType Task\n\tjsonTask := struct {\n\t\trawTaskType\n\t\tName string `json:\"name\"`\n\t}{\n\t\trawTaskType(*t),\n\t\tt.Name(),\n\t}\n\treturn json.Marshal(jsonTask)\n}\n\nfunc (t *Task) updateStatus(status base.Status) {\n\tt.UpdatedAt = time.Now()\n\tt.Status = status\n}\n\nfunc (t *Task) clone() *Task {\n\treturn util.DeepClone(t)\n}\n\nfunc (t *Task) updateSpeed(downloaded int64, usedTime float64) int64 {\n\treturn calcSpeed(&t.speedArr, downloaded, usedTime)\n}\n\nfunc (t *Task) updateUploadSpeed(downloaded int64, usedTime float64) int64 {\n\treturn calcSpeed(&t.uploadSpeedArr, downloaded, usedTime)\n}\n\nfunc calcSpeed(speedArr *[]int64, downloaded int64, usedTime float64) int64 {\n\tif usedTime <= 0 {\n\t\treturn 0\n\t}\n\tif downloaded < 0 {\n\t\t*speedArr = (*speedArr)[:0]\n\t\treturn 0\n\t}\n\n\t*speedArr = append(*speedArr, downloaded)\n\t// Record last 5 seconds of download speed to calculate the average speed\n\tif len(*speedArr) > int(5.0/usedTime) {\n\t\t*speedArr = (*speedArr)[1:]\n\t}\n\n\tvar total int64\n\tfor _, v := range *speedArr {\n\t\ttotal += v\n\t}\n\n\treturn int64(float64(total) / float64(len(*speedArr)) / usedTime)\n}\n\ntype TaskFilter struct {\n\tIDs         []string\n\tStatuses    []base.Status\n\tNotStatuses []base.Status\n}\n\nfunc (f *TaskFilter) IsEmpty() bool {\n\treturn len(f.IDs) == 0 && len(f.Statuses) == 0 && len(f.NotStatuses) == 0\n}\n\ntype DownloaderConfig struct {\n\tController    *controller.Controller\n\tFetchManagers []fetcher.FetcherManager\n\n\tRefreshInterval   int // RefreshInterval time duration to refresh task progress(ms)\n\tStorage           Storage\n\tStorageDir        string\n\tWhiteDownloadDirs []string\n\n\tProductionMode bool\n\n\t*base.DownloaderStoreConfig\n}\n\nfunc (cfg *DownloaderConfig) Init() *DownloaderConfig {\n\tif cfg.Controller == nil {\n\t\tcfg.Controller = controller.NewController()\n\t}\n\tif len(cfg.FetchManagers) == 0 {\n\t\tcfg.FetchManagers = []fetcher.FetcherManager{\n\t\t\tnew(http.FetcherManager),\n\t\t\tnew(bt.FetcherManager),\n\t\t\tnew(ed2k.FetcherManager),\n\t\t}\n\t}\n\tif cfg.RefreshInterval == 0 {\n\t\tcfg.RefreshInterval = 350\n\t}\n\tif cfg.Storage == nil {\n\t\tcfg.Storage = NewMemStorage()\n\t}\n\treturn cfg\n}\n"
  },
  {
    "path": "pkg/download/model_test.go",
    "content": "package download\n\nimport \"testing\"\n\nfunc TestCalcSpeedResetOnRollback(t *testing.T) {\n\tspeedArr := []int64{1024, 2048, 4096}\n\n\tif got := calcSpeed(&speedArr, -512, 1); got != 0 {\n\t\tt.Fatalf(\"calcSpeed() = %d, want 0 after rollback\", got)\n\t}\n\tif len(speedArr) != 0 {\n\t\tt.Fatalf(\"speed window len = %d, want 0 after rollback\", len(speedArr))\n\t}\n\n\tif got := calcSpeed(&speedArr, 1024, 1); got != 1024 {\n\t\tt.Fatalf(\"calcSpeed() = %d, want 1024 after reset\", got)\n\t}\n}\n"
  },
  {
    "path": "pkg/download/script.go",
    "content": "package download\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"time\"\n)\n\n// ScriptEvent represents the type of script event\ntype ScriptEvent string\n\nconst (\n\tScriptEventDownloadDone  ScriptEvent = \"DOWNLOAD_DONE\"\n\tScriptEventDownloadError ScriptEvent = \"DOWNLOAD_ERROR\"\n)\n\n// ScriptData is the internal data structure for passing script information\ntype ScriptData struct {\n\tEvent   ScriptEvent\n\tTime    int64 // Unix timestamp in milliseconds\n\tPayload *ScriptPayload\n}\n\n// ScriptPayload contains the task data\ntype ScriptPayload struct {\n\tTask *Task\n}\n\n// getScriptPaths extracts script paths from config\nfunc (d *Downloader) getScriptPaths() []string {\n\tcfg := d.cfg.DownloaderStoreConfig\n\tif cfg == nil {\n\t\treturn nil\n\t}\n\n\t// Check new script config\n\tif cfg.Script != nil && cfg.Script.Enable && len(cfg.Script.Paths) > 0 {\n\t\tpaths := make([]string, 0, len(cfg.Script.Paths))\n\t\tfor _, path := range cfg.Script.Paths {\n\t\t\tif path != \"\" {\n\t\t\t\tpaths = append(paths, path)\n\t\t\t}\n\t\t}\n\t\tif len(paths) > 0 {\n\t\t\treturn paths\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// executeScriptAtPath executes a single script with the given data\n// Returns any error that occurred during execution\nfunc (d *Downloader) executeScriptAtPath(scriptPath string, data *ScriptData) error {\n\tif scriptPath == \"\" {\n\t\treturn fmt.Errorf(\"script path is empty\")\n\t}\n\n\t// Check if script file exists\n\tif _, err := os.Stat(scriptPath); os.IsNotExist(err) {\n\t\treturn fmt.Errorf(\"script file does not exist: %s\", scriptPath)\n\t}\n\n\t// Determine the script interpreter based on file extension\n\tvar cmd *exec.Cmd\n\text := filepath.Ext(scriptPath)\n\t\n\tswitch ext {\n\tcase \".sh\", \".bash\":\n\t\tcmd = exec.Command(\"bash\", scriptPath)\n\tcase \".py\":\n\t\tcmd = exec.Command(\"python3\", scriptPath)\n\tcase \".js\":\n\t\tcmd = exec.Command(\"node\", scriptPath)\n\tcase \".bat\", \".cmd\":\n\t\t// Windows batch files\n\t\tif runtime.GOOS == \"windows\" {\n\t\t\tcmd = exec.Command(\"cmd\", \"/c\", scriptPath)\n\t\t} else {\n\t\t\t// Batch files are Windows-specific\n\t\t\treturn fmt.Errorf(\"batch files (.bat/.cmd) are only supported on Windows\")\n\t\t}\n\tcase \".ps1\":\n\t\t// PowerShell scripts\n\t\tif runtime.GOOS == \"windows\" {\n\t\t\tcmd = exec.Command(\"powershell\", \"-ExecutionPolicy\", \"Bypass\", \"-File\", scriptPath)\n\t\t} else {\n\t\t\t// Try pwsh (PowerShell Core) on non-Windows systems\n\t\t\tcmd = exec.Command(\"pwsh\", \"-File\", scriptPath)\n\t\t}\n\tcase \"\":\n\t\t// No extension, try to execute directly (assumes shebang or executable)\n\t\tcmd = exec.Command(scriptPath)\n\tdefault:\n\t\t// Unknown extension, try to execute directly\n\t\tcmd = exec.Command(scriptPath)\n\t}\n\n\t// Set environment variables with task information\n\tcmd.Env = append(os.Environ(),\n\t\tfmt.Sprintf(\"GOPEED_EVENT=%s\", data.Event),\n\t\tfmt.Sprintf(\"GOPEED_TASK_ID=%s\", data.Payload.Task.ID),\n\t\tfmt.Sprintf(\"GOPEED_TASK_NAME=%s\", data.Payload.Task.Name()),\n\t\tfmt.Sprintf(\"GOPEED_TASK_STATUS=%s\", data.Payload.Task.Status),\n\t)\n\n\t// Add task path using the same logic as task deletion\n\tif data.Payload.Task.Meta != nil && data.Payload.Task.Meta.Res != nil {\n\t\tvar taskPath string\n\t\tif data.Payload.Task.Meta.Res.Name != \"\" {\n\t\t\t// Multi-file task (folder)\n\t\t\ttaskPath = data.Payload.Task.Meta.FolderPath()\n\t\t} else {\n\t\t\t// Single file task\n\t\t\ttaskPath = data.Payload.Task.Meta.SingleFilepath()\n\t\t}\n\t\tcmd.Env = append(cmd.Env,\n\t\t\tfmt.Sprintf(\"GOPEED_TASK_PATH=%s\", taskPath),\n\t\t)\n\t}\n\n\t// Start and wait for the command to complete (no timeout)\n\treturn cmd.Run()\n}\n\n// triggerScripts executes all configured scripts\nfunc (d *Downloader) triggerScripts(event ScriptEvent, task *Task, err error) {\n\tpaths := d.getScriptPaths()\n\tif len(paths) == 0 {\n\t\treturn\n\t}\n\n\tdata := &ScriptData{\n\t\tEvent: event,\n\t\tTime:  time.Now().UnixMilli(),\n\t\tPayload: &ScriptPayload{\n\t\t\tTask: task.clone(),\n\t\t},\n\t}\n\n\tgo d.executeScripts(paths, data)\n}\n\nfunc (d *Downloader) executeScripts(paths []string, data *ScriptData) {\n\tfor _, path := range paths {\n\t\tif path == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tgo func(scriptPath string) {\n\t\t\terr := d.executeScriptAtPath(scriptPath, data)\n\t\t\tif err != nil {\n\t\t\t\td.Logger.Warn().Err(err).Str(\"path\", scriptPath).Msg(\"script: failed to execute\")\n\t\t\t\treturn\n\t\t\t}\n\t\t\td.Logger.Debug().Str(\"path\", scriptPath).Msg(\"script: executed successfully\")\n\t\t}(path)\n\t}\n}\n"
  },
  {
    "path": "pkg/download/script_test.go",
    "content": "package download\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/GopeedLab/gopeed/internal/fetcher\"\n\t\"github.com/GopeedLab/gopeed/pkg/base\"\n)\n\nfunc TestScript_NoScriptConfigured(t *testing.T) {\n\tsetupScriptTest(t, func(downloader *Downloader) {\n\t\t// Create a mock task\n\t\ttask := NewTask()\n\t\ttask.Protocol = \"http\"\n\t\ttask.Meta = &mockFetcherMeta\n\n\t\t// Trigger script (should not panic with no scripts configured)\n\t\tdownloader.triggerScripts(ScriptEventDownloadDone, task, nil)\n\t})\n}\n\nfunc TestScript_GetScriptPaths_EmptyConfig(t *testing.T) {\n\tsetupScriptTest(t, func(downloader *Downloader) {\n\t\tpaths := downloader.getScriptPaths()\n\t\tif paths != nil {\n\t\t\tt.Errorf(\"Expected nil, got %v\", paths)\n\t\t}\n\t})\n}\n\nfunc TestScript_GetScriptPaths_NoScriptConfig(t *testing.T) {\n\tsetupScriptTest(t, func(downloader *Downloader) {\n\t\tcfg, _ := downloader.GetConfig()\n\t\tcfg.Script = nil\n\t\tdownloader.PutConfig(cfg)\n\n\t\tpaths := downloader.getScriptPaths()\n\t\tif paths != nil {\n\t\t\tt.Errorf(\"Expected nil, got %v\", paths)\n\t\t}\n\t})\n}\n\nfunc TestScript_GetScriptPaths_DisabledScript(t *testing.T) {\n\tsetupScriptTest(t, func(downloader *Downloader) {\n\t\tcfg, _ := downloader.GetConfig()\n\t\tcfg.Script = &base.ScriptConfig{\n\t\t\tEnable: false,\n\t\t\tPaths:  []string{\"/path/to/script.sh\"},\n\t\t}\n\t\tdownloader.PutConfig(cfg)\n\n\t\tpaths := downloader.getScriptPaths()\n\t\tif paths != nil {\n\t\t\tt.Errorf(\"Expected nil for disabled script, got %v\", paths)\n\t\t}\n\t})\n}\n\nfunc TestScript_GetScriptPaths_EmptyPaths(t *testing.T) {\n\tsetupScriptTest(t, func(downloader *Downloader) {\n\t\tcfg, _ := downloader.GetConfig()\n\t\tcfg.Script = &base.ScriptConfig{\n\t\t\tEnable: true,\n\t\t\tPaths:  []string{},\n\t\t}\n\t\tdownloader.PutConfig(cfg)\n\n\t\tpaths := downloader.getScriptPaths()\n\t\tif paths != nil {\n\t\t\tt.Errorf(\"Expected nil for empty paths, got %v\", paths)\n\t\t}\n\t})\n}\n\nfunc TestScript_GetScriptPaths_WithEmptyStrings(t *testing.T) {\n\tsetupScriptTest(t, func(downloader *Downloader) {\n\t\tcfg, _ := downloader.GetConfig()\n\t\tcfg.Script = &base.ScriptConfig{\n\t\t\tEnable: true,\n\t\t\tPaths:  []string{\"/path/to/script1.sh\", \"\", \"/path/to/script2.sh\", \"\"},\n\t\t}\n\t\tdownloader.PutConfig(cfg)\n\n\t\tpaths := downloader.getScriptPaths()\n\t\tif len(paths) != 2 {\n\t\t\tt.Errorf(\"Expected 2 valid paths (ignoring empty strings), got %d: %v\", len(paths), paths)\n\t\t}\n\t\tif paths[0] != \"/path/to/script1.sh\" || paths[1] != \"/path/to/script2.sh\" {\n\t\t\tt.Errorf(\"Paths don't match expected values: %v\", paths)\n\t\t}\n\t})\n}\n\nfunc TestScript_ExecuteScriptAtPath_EmptyPath(t *testing.T) {\n\tsetupScriptTest(t, func(downloader *Downloader) {\n\t\tdata := &ScriptData{\n\t\t\tEvent: ScriptEventDownloadDone,\n\t\t\tTime:  time.Now().UnixMilli(),\n\t\t}\n\n\t\terr := downloader.executeScriptAtPath(\"\", data)\n\t\tif err == nil {\n\t\t\tt.Error(\"Expected error for empty path\")\n\t\t}\n\t\tif err.Error() != \"script path is empty\" {\n\t\t\tt.Errorf(\"Expected 'script path is empty' error, got: %v\", err)\n\t\t}\n\t})\n}\n\nfunc TestScript_ExecuteScriptAtPath_NonExistentFile(t *testing.T) {\n\tsetupScriptTest(t, func(downloader *Downloader) {\n\t\tdata := &ScriptData{\n\t\t\tEvent: ScriptEventDownloadDone,\n\t\t\tTime:  time.Now().UnixMilli(),\n\t\t}\n\n\t\terr := downloader.executeScriptAtPath(\"/non/existent/script.sh\", data)\n\t\tif err == nil {\n\t\t\tt.Error(\"Expected error for non-existent script\")\n\t\t}\n\t})\n}\n\nfunc createDownloadDoneTask(t *testing.T, downloadDir, fileName string) (*Task, string) {\n\tt.Helper()\n\tcontent := []byte(\"downloaded file\")\n\ttask := NewTask()\n\ttask.Protocol = \"http\"\n\ttask.Status = base.DownloadStatusDone\n\ttask.Meta = &fetcher.FetcherMeta{\n\t\tReq: &base.Request{\n\t\t\tURL: \"https://example.com/\" + fileName,\n\t\t},\n\t\tOpts: &base.Options{\n\t\t\tName: fileName,\n\t\t\tPath: filepath.ToSlash(downloadDir),\n\t\t},\n\t\tRes: &base.Resource{\n\t\t\tSize: int64(len(content)),\n\t\t\tFiles: []*base.FileInfo{\n\t\t\t\t{Name: fileName, Size: int64(len(content))},\n\t\t\t},\n\t\t},\n\t}\n\n\tfilePath := task.Meta.SingleFilepath()\n\tfilePathOS := filepath.FromSlash(filePath)\n\tif err := os.MkdirAll(filepath.Dir(filePathOS), 0755); err != nil {\n\t\tt.Fatalf(\"Failed to create download dir: %v\", err)\n\t}\n\tif err := os.WriteFile(filePathOS, content, 0644); err != nil {\n\t\tt.Fatalf(\"Failed to create download file: %v\", err)\n\t}\n\treturn task, filePath\n}\n\nfunc waitForFile(t *testing.T, path string, timeout time.Duration) {\n\tt.Helper()\n\tdeadline := time.Now().Add(timeout)\n\tfor time.Now().Before(deadline) {\n\t\tif _, err := os.Stat(path); err == nil {\n\t\t\treturn\n\t\t}\n\t\ttime.Sleep(50 * time.Millisecond)\n\t}\n\tt.Fatalf(\"Timeout waiting for file: %s\", path)\n}\n\nfunc getTestScriptPath(t *testing.T, name string) string {\n\tt.Helper()\n\tpath := filepath.Join(\"testdata\", \"scripts\", name)\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"Missing test script %s: %v\", path, err)\n\t}\n\treturn path\n}\n\nfunc ensureScriptExecutable(t *testing.T, scriptPath string) {\n\tt.Helper()\n\tif filepath.Ext(scriptPath) != \".sh\" {\n\t\treturn\n\t}\n\tif err := os.Chmod(scriptPath, 0755); err != nil {\n\t\tt.Fatalf(\"Failed to chmod script: %v\", err)\n\t}\n}\n\nfunc setupScriptTest(t *testing.T, fn func(downloader *Downloader)) {\n\tdefaultDownloader.Setup()\n\tdefaultDownloader.cfg.StorageDir = \".test_storage\"\n\tdefaultDownloader.cfg.DownloadDir = \".test_download\"\n\tdefer func() {\n\t\tdefaultDownloader.Clear()\n\t\tos.RemoveAll(defaultDownloader.cfg.StorageDir)\n\t\tos.RemoveAll(defaultDownloader.cfg.DownloadDir)\n\t}()\n\tfn(defaultDownloader)\n}\n"
  },
  {
    "path": "pkg/download/script_unix_test.go",
    "content": "//go:build !windows\n// +build !windows\n\npackage download\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/GopeedLab/gopeed/pkg/base\"\n)\n\nfunc TestScript_TriggerOnDone_MoveFile(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tdownloadDir := filepath.Join(tmpDir, \"downloads\")\n\tdestDir := filepath.Join(tmpDir, \"moved\")\n\ttask, taskPath := createDownloadDoneTask(t, downloadDir, \"test.txt\")\n\tsrcPath := filepath.FromSlash(taskPath)\n\tdestFile := filepath.Join(destDir, \"test.txt\")\n\n\tscriptPath := getTestScriptPath(t, \"move.sh\")\n\tensureScriptExecutable(t, scriptPath)\n\n\tsetupScriptTest(t, func(downloader *Downloader) {\n\t\tcfg, _ := downloader.GetConfig()\n\t\tcfg.Script = &base.ScriptConfig{\n\t\t\tEnable: true,\n\t\t\tPaths:  []string{scriptPath},\n\t\t}\n\t\tdownloader.PutConfig(cfg)\n\n\t\tt.Setenv(\"GOPEED_TEST_DEST_DIR\", destDir)\n\t\tdownloader.triggerScripts(ScriptEventDownloadDone, task, nil)\n\n\t\twaitForFile(t, destFile, 3*time.Second)\n\t\tif _, err := os.Stat(srcPath); !os.IsNotExist(err) {\n\t\t\tt.Errorf(\"Expected source file to be moved, but it still exists: %s\", srcPath)\n\t\t}\n\t})\n}\n\nfunc TestScript_MultipleScripts(t *testing.T) {\n\ttmpDir := t.TempDir()\n\toutputFile1 := filepath.Join(tmpDir, \"output1.txt\")\n\toutputFile2 := filepath.Join(tmpDir, \"output2.txt\")\n\tscriptPath1 := getTestScriptPath(t, \"write_output1.sh\")\n\tensureScriptExecutable(t, scriptPath1)\n\tscriptPath2 := getTestScriptPath(t, \"write_output2.sh\")\n\tensureScriptExecutable(t, scriptPath2)\n\n\tsetupScriptTest(t, func(downloader *Downloader) {\n\t\tcfg, _ := downloader.GetConfig()\n\t\tcfg.Script = &base.ScriptConfig{\n\t\t\tEnable: true,\n\t\t\tPaths:  []string{scriptPath1, scriptPath2},\n\t\t}\n\t\tdownloader.PutConfig(cfg)\n\n\t\tdownloadDir := filepath.Join(tmpDir, \"downloads\")\n\t\ttask, _ := createDownloadDoneTask(t, downloadDir, \"multi.txt\")\n\n\t\tt.Setenv(\"GOPEED_TEST_OUTPUT_FILE_1\", outputFile1)\n\t\tt.Setenv(\"GOPEED_TEST_OUTPUT_FILE_2\", outputFile2)\n\t\tdownloader.triggerScripts(ScriptEventDownloadDone, task, nil)\n\n\t\twaitForFile(t, outputFile1, 3*time.Second)\n\t\twaitForFile(t, outputFile2, 3*time.Second)\n\t})\n}\n\nfunc TestScript_EnvironmentVariables(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tscriptPath := getTestScriptPath(t, \"env_dump.sh\")\n\tensureScriptExecutable(t, scriptPath)\n\toutputFile := filepath.Join(tmpDir, \"env_output.txt\")\n\n\tsetupScriptTest(t, func(downloader *Downloader) {\n\t\tcfg, _ := downloader.GetConfig()\n\t\tcfg.Script = &base.ScriptConfig{\n\t\t\tEnable: true,\n\t\t\tPaths:  []string{scriptPath},\n\t\t}\n\t\tdownloader.PutConfig(cfg)\n\n\t\tdownloadDir := filepath.Join(tmpDir, \"downloads\")\n\t\ttask, taskPath := createDownloadDoneTask(t, downloadDir, \"env.txt\")\n\n\t\tt.Setenv(\"GOPEED_TEST_OUTPUT_FILE\", outputFile)\n\t\tdownloader.triggerScripts(ScriptEventDownloadDone, task, nil)\n\n\t\twaitForFile(t, outputFile, 3*time.Second)\n\t\tcontent, err := os.ReadFile(outputFile)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Failed to read output file: %v\", err)\n\t\t}\n\n\t\toutput := string(content)\n\t\tif !strings.Contains(output, \"GOPEED_EVENT=DOWNLOAD_DONE\") {\n\t\t\tt.Errorf(\"Expected GOPEED_EVENT in output, got: %s\", output)\n\t\t}\n\t\tif !strings.Contains(output, \"GOPEED_TASK_ID=\"+task.ID) {\n\t\t\tt.Errorf(\"Expected GOPEED_TASK_ID in output, got: %s\", output)\n\t\t}\n\t\tif !strings.Contains(output, \"GOPEED_TASK_NAME=\"+task.Name()) {\n\t\t\tt.Errorf(\"Expected GOPEED_TASK_NAME in output, got: %s\", output)\n\t\t}\n\t\tif !strings.Contains(output, \"GOPEED_TASK_STATUS=\"+string(task.Status)) {\n\t\t\tt.Errorf(\"Expected GOPEED_TASK_STATUS in output, got: %s\", output)\n\t\t}\n\t\tif !strings.Contains(output, \"GOPEED_TASK_PATH=\"+taskPath) {\n\t\t\tt.Errorf(\"Expected GOPEED_TASK_PATH in output, got: %s\", output)\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "pkg/download/script_windows_test.go",
    "content": "//go:build windows\n// +build windows\n\npackage download\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/GopeedLab/gopeed/pkg/base\"\n)\n\nfunc TestScript_TriggerOnDone_MoveFile(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tdownloadDir := filepath.Join(tmpDir, \"downloads\")\n\tdestDir := filepath.Join(tmpDir, \"moved\")\n\ttask, taskPath := createDownloadDoneTask(t, downloadDir, \"test.txt\")\n\tsrcPath := filepath.FromSlash(taskPath)\n\tdestFile := filepath.Join(destDir, \"test.txt\")\n\n\tscriptPath := getTestScriptPath(t, \"move.bat\")\n\n\tsetupScriptTest(t, func(downloader *Downloader) {\n\t\tcfg, _ := downloader.GetConfig()\n\t\tcfg.Script = &base.ScriptConfig{\n\t\t\tEnable: true,\n\t\t\tPaths:  []string{scriptPath},\n\t\t}\n\t\tdownloader.PutConfig(cfg)\n\n\t\tt.Setenv(\"GOPEED_TEST_DEST_DIR\", destDir)\n\t\tdownloader.triggerScripts(ScriptEventDownloadDone, task, nil)\n\n\t\twaitForFile(t, destFile, 5*time.Second)\n\t\tif _, err := os.Stat(srcPath); !os.IsNotExist(err) {\n\t\t\tt.Errorf(\"Expected source file to be moved, but it still exists: %s\", srcPath)\n\t\t}\n\t})\n}\n\nfunc TestScript_MultipleScripts(t *testing.T) {\n\ttmpDir := t.TempDir()\n\toutputFile1 := filepath.Join(tmpDir, \"output1.txt\")\n\toutputFile2 := filepath.Join(tmpDir, \"output2.txt\")\n\tscriptPath1 := getTestScriptPath(t, \"write_output1.bat\")\n\tscriptPath2 := getTestScriptPath(t, \"write_output2.bat\")\n\n\tsetupScriptTest(t, func(downloader *Downloader) {\n\t\tcfg, _ := downloader.GetConfig()\n\t\tcfg.Script = &base.ScriptConfig{\n\t\t\tEnable: true,\n\t\t\tPaths:  []string{scriptPath1, scriptPath2},\n\t\t}\n\t\tdownloader.PutConfig(cfg)\n\n\t\tdownloadDir := filepath.Join(tmpDir, \"downloads\")\n\t\ttask, _ := createDownloadDoneTask(t, downloadDir, \"multi.txt\")\n\n\t\tt.Setenv(\"GOPEED_TEST_OUTPUT_FILE_1\", outputFile1)\n\t\tt.Setenv(\"GOPEED_TEST_OUTPUT_FILE_2\", outputFile2)\n\t\tdownloader.triggerScripts(ScriptEventDownloadDone, task, nil)\n\n\t\twaitForFile(t, outputFile1, 5*time.Second)\n\t\twaitForFile(t, outputFile2, 5*time.Second)\n\t})\n}\n\nfunc TestScript_EnvironmentVariables(t *testing.T) {\n\ttmpDir := t.TempDir()\n\tscriptPath := getTestScriptPath(t, \"env_dump.bat\")\n\toutputFile := filepath.Join(tmpDir, \"env_output.txt\")\n\n\tsetupScriptTest(t, func(downloader *Downloader) {\n\t\tcfg, _ := downloader.GetConfig()\n\t\tcfg.Script = &base.ScriptConfig{\n\t\t\tEnable: true,\n\t\t\tPaths:  []string{scriptPath},\n\t\t}\n\t\tdownloader.PutConfig(cfg)\n\n\t\tdownloadDir := filepath.Join(tmpDir, \"downloads\")\n\t\ttask, taskPath := createDownloadDoneTask(t, downloadDir, \"env.txt\")\n\n\t\tt.Setenv(\"GOPEED_TEST_OUTPUT_FILE\", outputFile)\n\t\tdownloader.triggerScripts(ScriptEventDownloadDone, task, nil)\n\n\t\twaitForFile(t, outputFile, 5*time.Second)\n\t\tcontent, err := os.ReadFile(outputFile)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"Failed to read output file: %v\", err)\n\t\t}\n\n\t\toutput := string(content)\n\t\tif !strings.Contains(output, \"GOPEED_EVENT=DOWNLOAD_DONE\") {\n\t\t\tt.Errorf(\"Expected GOPEED_EVENT in output, got: %s\", output)\n\t\t}\n\t\tif !strings.Contains(output, \"GOPEED_TASK_ID=\"+task.ID) {\n\t\t\tt.Errorf(\"Expected GOPEED_TASK_ID in output, got: %s\", output)\n\t\t}\n\t\tif !strings.Contains(output, \"GOPEED_TASK_NAME=\"+task.Name()) {\n\t\t\tt.Errorf(\"Expected GOPEED_TASK_NAME in output, got: %s\", output)\n\t\t}\n\t\tif !strings.Contains(output, \"GOPEED_TASK_STATUS=\"+string(task.Status)) {\n\t\t\tt.Errorf(\"Expected GOPEED_TASK_STATUS in output, got: %s\", output)\n\t\t}\n\t\tif !strings.Contains(output, \"GOPEED_TASK_PATH=\"+taskPath) {\n\t\t\tt.Errorf(\"Expected GOPEED_TASK_PATH in output, got: %s\", output)\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "pkg/download/storage.go",
    "content": "package download\n\nimport (\n\t\"encoding/json\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"sync\"\n\n\t\"go.etcd.io/bbolt\"\n)\n\ntype Storage interface {\n\tSetup(buckets []string) error\n\tPut(bucket string, key string, v any) error\n\tGet(bucket string, key string, v any) (bool, error)\n\tList(bucket string, v any) error\n\tPop(bucket string, key string, v any) error\n\tDelete(bucket string, key string) error\n\n\tClose() error\n\tClear() error\n}\n\nfunc changeValue(p any, v any) {\n\tif v == nil {\n\t\treturn\n\t}\n\trp := reflect.ValueOf(p)\n\trv := reflect.ValueOf(v)\n\tif rv.Kind() == reflect.Slice {\n\t\tif rv.Len() == 0 {\n\t\t\treturn\n\t\t}\n\t\t// get underlying type\n\t\ttp := reflect.TypeOf(p).Elem().Elem()\n\t\tfor i := 0; i < rv.Len(); i++ {\n\t\t\t// convert to underlying type\n\t\t\tvv := rv.Index(i).Elem().Convert(tp)\n\t\t\trp.Elem().Set(reflect.Append(rp.Elem(), vv))\n\t\t}\n\t} else if rv.Kind() == reflect.Ptr {\n\t\trp.Elem().Set(rv.Elem())\n\t} else {\n\t\trp.Elem().Set(rv)\n\t}\n}\n\ntype MemStorage struct {\n\tlock *sync.RWMutex\n\tdata map[string]map[string]any\n}\n\nfunc NewMemStorage() *MemStorage {\n\treturn &MemStorage{\n\t\tlock: &sync.RWMutex{},\n\t\tdata: make(map[string]map[string]any),\n\t}\n}\n\nfunc (n *MemStorage) Setup(buckets []string) error {\n\tn.lock.Lock()\n\tdefer n.lock.Unlock()\n\tfor _, bucket := range buckets {\n\t\tif _, ok := n.data[bucket]; !ok {\n\t\t\tn.data[bucket] = make(map[string]any)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (n *MemStorage) Put(bucket string, key string, v any) error {\n\tn.lock.Lock()\n\tdefer n.lock.Unlock()\n\tif bucketData, ok := n.data[bucket]; ok {\n\t\tbucketData[key] = v\n\t}\n\treturn nil\n}\n\nfunc (n *MemStorage) Get(bucket string, key string, v any) (bool, error) {\n\tn.lock.RLock()\n\tdefer n.lock.RUnlock()\n\tif dv, ok := n.data[bucket][key]; ok {\n\t\tchangeValue(v, dv)\n\t\treturn true, nil\n\t}\n\treturn false, nil\n}\n\nfunc (n *MemStorage) List(bucket string, v any) error {\n\tn.lock.RLock()\n\tdefer n.lock.RUnlock()\n\tdata := n.data[bucket]\n\tlist := make([]any, 0)\n\tfor _, v := range data {\n\t\tlist = append(list, v)\n\t}\n\tchangeValue(v, list)\n\treturn nil\n}\n\nfunc (n *MemStorage) Pop(bucket string, key string, v any) error {\n\tn.lock.Lock()\n\tdefer n.lock.Unlock()\n\tdata := n.data[bucket]\n\tchangeValue(v, data[key])\n\tdelete(data, key)\n\treturn nil\n}\n\nfunc (n *MemStorage) Delete(bucket string, key string) error {\n\tn.lock.Lock()\n\tdefer n.lock.Unlock()\n\tdelete(n.data[bucket], key)\n\treturn nil\n}\n\nfunc (n *MemStorage) Close() error {\n\treturn nil\n}\n\nfunc (n *MemStorage) Clear() error {\n\tn.lock.Lock()\n\tdefer n.lock.Unlock()\n\tn.data = make(map[string]map[string]any)\n\treturn nil\n}\n\nconst (\n\tdbFile = \"gopeed.db\"\n)\n\ntype BoltStorage struct {\n\tdb   *bbolt.DB\n\tpath string\n}\n\nfunc NewBoltStorage(dir string) *BoltStorage {\n\tif err := os.MkdirAll(dir, 0755); err != nil {\n\t\tpanic(err)\n\t}\n\tpath := filepath.Join(dir, dbFile)\n\tdb, err := bbolt.Open(path, 0600, nil)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\treturn &BoltStorage{\n\t\tdb:   db,\n\t\tpath: path,\n\t}\n}\n\nfunc (b *BoltStorage) Setup(buckets []string) error {\n\treturn b.db.Update(func(tx *bbolt.Tx) error {\n\t\tfor _, bucket := range buckets {\n\t\t\t_, err := tx.CreateBucketIfNotExists([]byte(bucket))\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\nfunc (b *BoltStorage) Put(bucket string, key string, v any) error {\n\tbuf, err := json.Marshal(v)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn b.db.Update(func(tx *bbolt.Tx) error {\n\t\tb := tx.Bucket([]byte(bucket))\n\t\treturn b.Put([]byte(key), buf)\n\t})\n}\n\nfunc (b *BoltStorage) Get(bucket string, key string, v any) (bool, error) {\n\tvar data []byte\n\terr := b.db.View(func(tx *bbolt.Tx) error {\n\t\tb := tx.Bucket([]byte(bucket))\n\t\tdata = b.Get([]byte(key))\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tif data == nil {\n\t\treturn false, nil\n\t}\n\tif err := json.Unmarshal(data, v); err != nil {\n\t\treturn false, err\n\t}\n\treturn true, nil\n}\n\nfunc (b *BoltStorage) List(bucket string, v any) error {\n\tlist := make([]any, 0)\n\ttv := reflect.TypeOf(v).Elem().Elem()\n\tif err := b.db.View(func(tx *bbolt.Tx) error {\n\t\tb := tx.Bucket([]byte(bucket))\n\t\tif err := b.ForEach(func(k, v []byte) error {\n\t\t\tdata := reflect.New(tv.Elem()).Interface()\n\t\t\tif err := json.Unmarshal(v, &data); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tlist = append(list, data)\n\t\t\treturn nil\n\t\t}); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t}); err != nil {\n\t\treturn err\n\t}\n\tchangeValue(v, list)\n\treturn nil\n}\n\nfunc (b *BoltStorage) Pop(bucket string, key string, v any) error {\n\tvar data []byte\n\terr := b.db.Update(func(tx *bbolt.Tx) error {\n\t\tb := tx.Bucket([]byte(bucket))\n\t\tkb := []byte(key)\n\t\tdata = b.Get(kb)\n\t\treturn b.Delete(kb)\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\tif len(data) == 0 {\n\t\treturn nil\n\t}\n\treturn json.Unmarshal(data, v)\n}\n\nfunc (b *BoltStorage) Delete(bucket string, key string) error {\n\treturn b.db.Update(func(tx *bbolt.Tx) error {\n\t\tb := tx.Bucket([]byte(bucket))\n\t\treturn b.Delete([]byte(key))\n\t})\n}\n\nfunc (b *BoltStorage) Close() error {\n\treturn b.db.Close()\n}\n\nfunc (b *BoltStorage) Clear() error {\n\tif err := b.Close(); err != nil {\n\t\treturn err\n\t}\n\tif err := os.Remove(b.path); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/download/testdata/extensions/basic/index.js",
    "content": "gopeed.events.onResolve(async function (ctx) {\n    ctx.res = {\n        name: \"test\",\n        files: Array(2).fill(true).map((_, i) => ({\n                name: `test-${i}.txt`,\n                size: 1024,\n                req: {\n                    url: ctx.req.url + \"/\" + i,\n                    labels:{\n                        \"from\": gopeed.info.name,\n                    }\n                }\n            }),\n        ),\n    };\n});\n"
  },
  {
    "path": "pkg/download/testdata/extensions/basic/manifest.json",
    "content": "{\n  \"name\": \"basic\",\n  \"title\": \"gopeed extension basic test\",\n  \"version\": \"0.0.1\",\n  \"scripts\": [\n    {\n      \"event\": \"onResolve\",\n      \"match\": {\n        \"urls\": [\n          \"*://github.com/*\"\n        ]\n      },\n      \"entry\": \"index.js\"\n    }\n  ],\n  \"settings\": [\n    {\n      \"name\": \"ua\",\n      \"title\": \"User-Agent\",\n      \"type\": \"string\"\n    }\n  ]\n}"
  },
  {
    "path": "pkg/download/testdata/extensions/extra/index.js",
    "content": "gopeed.events.onResolve(async function (ctx) {\n    ctx.res = {\n        name: \"test\",\n        files: Array(2).fill(true).map((_, i) => ({\n                name: `test-${i}.txt`,\n                size: 1024,\n                req: {\n                    url: ctx.req.url + \"/\" + i,\n                    extra: {\n                        headers: {\n                            'User-Agent': ctx.settings.ua,\n                        },\n                    }\n                }\n            }),\n        ),\n    };\n});\n"
  },
  {
    "path": "pkg/download/testdata/extensions/extra/manifest.json",
    "content": "{\n  \"name\": \"extra\",\n  \"title\": \"gopeed extension extra test\",\n  \"version\": \"0.0.1\",\n  \"scripts\": [\n    {\n      \"event\": \"onResolve\",\n      \"matches\": [\n        \"*://github.com/*\"\n      ],\n      \"entry\": \"index.js\"\n    }\n  ],\n  \"settings\": [\n    {\n      \"name\": \"ua\",\n      \"title\": \"User-Agent\",\n      \"type\": \"string\",\n      \"value\": \"gopeed\"\n    }\n  ]\n}"
  },
  {
    "path": "pkg/download/testdata/extensions/function_error/index.js",
    "content": "gopeed.events.onResolve(async function (ctx) {\n    const aaa = {};\n    // access undefined property\n    gopeed.logger.info(aaa.bbb.ccc);\n\n    ctx.res = {\n        name: \"test\",\n        files: Array(2).fill(true).map((_, i) => ({\n                name: `test-${i}.txt`,\n                size: 1024,\n                req: {\n                    url: ctx.req.url + \"/\" + i,\n                }\n            }),\n        ),\n    };\n});\n"
  },
  {
    "path": "pkg/download/testdata/extensions/function_error/manifest.json",
    "content": "{\n  \"name\": \"function-error\",\n  \"title\": \"gopeed extension function error test\",\n  \"version\": \"0.0.1\",\n  \"scripts\": [\n    {\n      \"event\": \"onResolve\",\n      \"match\": {\n        \"urls\": [\n          \"*://github.com/*\"\n        ]\n      },\n      \"entry\": \"index.js\"\n    }\n  ],\n  \"settings\": [\n    {\n      \"name\": \"ua\",\n      \"title\": \"User-Agent\",\n      \"type\": \"string\"\n    }\n  ]\n}"
  },
  {
    "path": "pkg/download/testdata/extensions/message_error/index.js",
    "content": "gopeed.events.onResolve(async function (ctx) {\n    throw new MessageError(\"test\");\n});\n"
  },
  {
    "path": "pkg/download/testdata/extensions/message_error/manifest.json",
    "content": "{\n  \"name\": \"message-error\",\n  \"title\": \"gopeed extension message error test\",\n  \"version\": \"0.0.1\",\n  \"scripts\": [\n    {\n      \"event\": \"onResolve\",\n      \"match\": {\n        \"urls\": [\n          \"*://github.com/*\"\n        ]\n      },\n      \"entry\": \"index.js\"\n    }\n  ]\n}"
  },
  {
    "path": "pkg/download/testdata/extensions/on_done/index.js",
    "content": "gopeed.events.onDone(async function (ctx) {\n    gopeed.logger.info(\"url\", ctx.task.meta.req.url);\n    ctx.task.meta.req.labels['modified'] = 'true';\n});\n\n"
  },
  {
    "path": "pkg/download/testdata/extensions/on_done/manifest.json",
    "content": "{\n  \"name\": \"on-done\",\n  \"title\": \"gopeed extension on done event test\",\n  \"version\": \"0.0.1\",\n  \"scripts\": [\n    {\n      \"event\": \"onDone\",\n      \"match\": {\n        \"urls\": [\n          \"*://github.com/*\"\n        ]\n      },\n      \"entry\": \"index.js\"\n    }\n  ]\n}"
  },
  {
    "path": "pkg/download/testdata/extensions/on_error/index.js",
    "content": "gopeed.events.onError(async function (ctx) {\n    gopeed.logger.info(\"url\", ctx.task.meta.req.url);\n    gopeed.logger.info(\"error\", ctx.error);\n    ctx.task.meta.req.url = \"https://github.com\";\n    ctx.task.continue();\n});\n\n"
  },
  {
    "path": "pkg/download/testdata/extensions/on_error/manifest.json",
    "content": "{\n  \"name\": \"on-error\",\n  \"title\": \"gopeed extension on error event test\",\n  \"version\": \"0.0.1\",\n  \"scripts\": [\n    {\n      \"event\": \"onError\",\n      \"match\": {\n        \"labels\": [\n          \"test\"\n        ]\n      },\n      \"entry\": \"index.js\"\n    }\n  ]\n}"
  },
  {
    "path": "pkg/download/testdata/extensions/on_start/index.js",
    "content": "gopeed.events.onStart(async function (ctx) {\n    gopeed.logger.info(\"url\", ctx.task.meta.req.url);\n    ctx.task.meta.req.url = \"https://github.com\";\n    ctx.task.meta.req.labels['modified'] = 'true';\n});\n\n"
  },
  {
    "path": "pkg/download/testdata/extensions/on_start/manifest.json",
    "content": "{\n  \"name\": \"on-start\",\n  \"title\": \"gopeed extension on start event test\",\n  \"version\": \"0.0.1\",\n  \"scripts\": [\n    {\n      \"event\": \"onStart\",\n      \"match\": {\n        \"urls\": [\n          \"*://github.com/*\"\n        ],\n        \"labels\": [\n          \"test\"\n        ]\n      },\n      \"entry\": \"index.js\"\n    }\n  ]\n}"
  },
  {
    "path": "pkg/download/testdata/extensions/script_error/index.js",
    "content": "const aaa = {};\ngopeed.logger.info(aaa.bbb.ccc);\n\ngopeed.events.onResolve(async function (ctx) {\n    ctx.res = {\n        name: \"test\",\n        files: Array(2).fill(true).map((_, i) => ({\n                name: `test-${i}.txt`,\n                size: 1024,\n                req: {\n                    url: ctx.req.url + \"/\" + i,\n                }\n            }),\n        ),\n    };\n});\n"
  },
  {
    "path": "pkg/download/testdata/extensions/script_error/manifest.json",
    "content": "{\n  \"name\": \"script-error\",\n  \"title\": \"gopeed extension script error test\",\n  \"version\": \"0.0.1\",\n  \"scripts\": [\n    {\n      \"event\": \"onResolve\",\n      \"match\": {\n        \"urls\": [\n          \"*://github.com/*\"\n        ]\n      },\n      \"entry\": \"index.js\"\n    }\n  ],\n  \"settings\": [\n    {\n      \"name\": \"ua\",\n      \"title\": \"User-Agent\",\n      \"type\": \"string\"\n    }\n  ]\n}"
  },
  {
    "path": "pkg/download/testdata/extensions/settings_all/index.js",
    "content": "gopeed.events.onResolve(async function (ctx) {\n    if (gopeed.settings.string != null) {\n        throw new Error(\"string is not null\");\n    }\n    if (gopeed.settings.number != null) {\n        throw new Error(\"number is not null\");\n    }\n    if (gopeed.settings.boolean != null) {\n        throw new Error(\"boolean is not null\");\n    }\n\n    if (gopeed.settings.stringDefault !== \"default\") {\n        throw new Error(\"string default value is incorrect\");\n    }\n    if (gopeed.settings.numberDefault !== 1) {\n        throw new Error(\"number default value is incorrect\");\n    }\n    if (gopeed.settings.booleanDefault !== true) {\n        throw new Error(\"boolean default value is incorrect\");\n    }\n\n    if (gopeed.settings.stringValued !== \"valued\") {\n        throw new Error(\"string value is incorrect\");\n    }\n    if (gopeed.settings.numberValued !== 1.1) {\n        throw new Error(\"number value is incorrect\");\n    }\n    if (gopeed.settings.booleanValued !== true) {\n        throw new Error(\"boolean value is incorrect\");\n    }\n\n    ctx.res = {\n        name: \"test\",\n        files: Array(2).fill(true).map((_, i) => ({\n                name: `test-${i}.txt`,\n                size: 1024,\n                req: {\n                    url: ctx.req.url + \"/\" + i,\n                }\n            }),\n        ),\n    };\n});\n"
  },
  {
    "path": "pkg/download/testdata/extensions/settings_all/manifest.json",
    "content": "{\n  \"name\": \"settings-all\",\n  \"title\": \"gopeed extension settings all type test\",\n  \"version\": \"0.0.1\",\n  \"scripts\": [\n    {\n      \"event\": \"onResolve\",\n      \"match\": {\n        \"urls\": [\n          \"*://*/*\"\n        ]\n      },\n      \"entry\": \"index.js\"\n    }\n  ],\n  \"settings\": [\n    {\n      \"name\": \"string\",\n      \"title\": \"string null test\",\n      \"type\": \"string\"\n    },\n    {\n      \"name\": \"number\",\n      \"title\": \"number null test\",\n      \"type\": \"number\"\n    },\n    {\n      \"name\": \"boolean\",\n      \"title\": \"boolean null test\",\n      \"type\": \"boolean\"\n    },\n    {\n      \"name\": \"stringDefault\",\n      \"title\": \"string default test\",\n      \"type\": \"string\",\n      \"value\": \"default\"\n    },\n    {\n      \"name\": \"numberDefault\",\n      \"title\": \"number default test\",\n      \"type\": \"number\",\n      \"value\": 1\n    },\n    {\n      \"name\": \"booleanDefault\",\n      \"title\": \"boolean default test\",\n      \"type\": \"boolean\",\n      \"value\": true\n    },\n    {\n      \"name\": \"stringValued\",\n      \"title\": \"string valued test\",\n      \"type\": \"string\"\n    },\n    {\n      \"name\": \"numberValued\",\n      \"title\": \"number valued test\",\n      \"type\": \"number\"\n    },\n    {\n      \"name\": \"booleanValued\",\n      \"title\": \"boolean valued test\",\n      \"type\": \"boolean\"\n    }\n  ]\n}"
  },
  {
    "path": "pkg/download/testdata/extensions/settings_empty/index.js",
    "content": "gopeed.events.onResolve(async function (ctx) {\n    if (Object.keys(gopeed.settings).length > 0){\n        throw new Error(\"settings is not empty\");\n    }\n\n    ctx.res = {\n        name: \"test\",\n        files: Array(2).fill(true).map((_, i) => ({\n                name: `test-${i}.txt`,\n                size: 1024,\n                req: {\n                    url: ctx.req.url + \"/\" + i,\n                }\n            }),\n        ),\n    };\n});\n"
  },
  {
    "path": "pkg/download/testdata/extensions/settings_empty/manifest.json",
    "content": "{\n  \"name\": \"settings-empty\",\n  \"title\": \"gopeed extension settings empty test\",\n  \"version\": \"0.0.1\",\n  \"scripts\": [\n    {\n      \"event\": \"onResolve\",\n      \"match\": {\n        \"urls\": [\n          \"*://*/*\"\n        ]\n      },\n      \"entry\": \"index.js\"\n    }\n  ]\n}"
  },
  {
    "path": "pkg/download/testdata/extensions/storage/index.js",
    "content": "gopeed.events.onResolve(async function (ctx) {\n    const key = \"key\"\n    const value1 = \"value1\", value2 = JSON.stringify({a: 1, b: \"2\"})\n\n    if (gopeed.storage.get(key) !== null) {\n        throw new Error(\"storage get null error\")\n    }\n    gopeed.storage.remove(key)\n    if(gopeed.storage.keys().length !== 0) {\n        throw new Error(\"storage keys null error\")\n    }\n\n    gopeed.storage.set(key, value1)\n    if (gopeed.storage.get(key) !== value1) {\n        throw new Error(\"storage put1 error\")\n    }\n\n    gopeed.storage.set(key, value2)\n    if (gopeed.storage.get(key) !== value2) {\n        throw new Error(\"storage put2 error\")\n    }\n\n    if(gopeed.storage.keys().length !== 1) {\n        throw new Error(\"storage keys error\")\n    }\n\n    gopeed.storage.remove(key)\n    if (gopeed.storage.get(key) !== null) {\n        throw new Error(\"storage delete error\")\n    }\n\n    gopeed.storage.set(key, value1)\n    gopeed.storage.clear()\n    if (gopeed.storage.get(key) !== null) {\n        throw new Error(\"storage clear error\")\n    }\n\n    ctx.res = {\n        name: \"test\",\n        files: Array(2).fill(true).map((_, i) => ({\n                name: `test-${i}.txt`,\n                size: 1024,\n                req: {\n                    url: ctx.req.url + \"/\" + i,\n                }\n            }),\n        ),\n    };\n});\n"
  },
  {
    "path": "pkg/download/testdata/extensions/storage/manifest.json",
    "content": "{\n  \"name\": \"storage\",\n  \"title\": \"gopeed extension storage test\",\n  \"version\": \"0.0.1\",\n  \"scripts\": [\n    {\n      \"event\": \"onResolve\",\n      \"match\": {\n        \"urls\": [\n          \"*://*/*\"\n        ]\n      },\n      \"entry\": \"index.js\"\n    }\n  ],\n  \"settings\": [\n    {\n      \"name\": \"ua\",\n      \"title\": \"User-Agent\",\n      \"type\": \"string\"\n    }\n  ]\n}"
  },
  {
    "path": "pkg/download/testdata/extensions/update/index.js",
    "content": "gopeed.events.onResolve(async function (ctx) {\n    // do nothing for test\n});\n"
  },
  {
    "path": "pkg/download/testdata/extensions/update/manifest.json",
    "content": "{\n  \"name\": \"extension-test\",\n  \"author\": \"gopeed\",\n  \"title\": \"Gopeed Extension Test\",\n  \"description\": \"Test extension settings and upgrade\",\n  \"version\": \"0.0.1\",\n  \"homepage\": \"https://gopeed.com\",\n  \"repository\": {\n    \"url\": \"https://github.com/GopeedLab/gopeed-extension-samples\",\n    \"directory\": \"extension-test\"\n  },\n  \"scripts\": [\n    {\n      \"event\": \"onResolve\",\n      \"match\": {\n        \"urls\": [\n          \"*://*/*\"\n        ]\n      },\n      \"entry\": \"index.js\"\n    }\n  ],\n  \"settings\": [\n    {\n      \"name\": \"s1\",\n      \"title\": \"S1 old\",\n      \"description\": \"Test setting update\",\n      \"type\": \"string\",\n      \"required\": true\n    },\n    {\n      \"name\": \"s2\",\n      \"title\": \"s2 number old\",\n      \"description\": \"Test setting type update\",\n      \"type\": \"number\",\n      \"required\": true,\n      \"value\": 1\n    },\n    {\n      \"name\": \"d1\",\n      \"title\": \"Delete test\",\n      \"description\": \"Test setting delete\",\n      \"type\": \"string\",\n      \"required\": true\n    }\n  ]\n}"
  },
  {
    "path": "pkg/download/testdata/scripts/env_dump.bat",
    "content": "@echo off\nsetlocal\n\nif \"%GOPEED_TEST_OUTPUT_FILE%\"==\"\" exit /b 2\necho GOPEED_EVENT=%GOPEED_EVENT% > \"%GOPEED_TEST_OUTPUT_FILE%\"\necho GOPEED_TASK_ID=%GOPEED_TASK_ID% >> \"%GOPEED_TEST_OUTPUT_FILE%\"\necho GOPEED_TASK_NAME=%GOPEED_TASK_NAME% >> \"%GOPEED_TEST_OUTPUT_FILE%\"\necho GOPEED_TASK_STATUS=%GOPEED_TASK_STATUS% >> \"%GOPEED_TEST_OUTPUT_FILE%\"\necho GOPEED_TASK_PATH=%GOPEED_TASK_PATH% >> \"%GOPEED_TEST_OUTPUT_FILE%\"\n"
  },
  {
    "path": "pkg/download/testdata/scripts/env_dump.sh",
    "content": "#!/bin/bash\nset -e\n\nif [ -z \"$GOPEED_TEST_OUTPUT_FILE\" ]; then\n  echo \"GOPEED_TEST_OUTPUT_FILE is empty\" >&2\n  exit 2\nfi\n\necho \"GOPEED_EVENT=$GOPEED_EVENT\" > \"$GOPEED_TEST_OUTPUT_FILE\"\necho \"GOPEED_TASK_ID=$GOPEED_TASK_ID\" >> \"$GOPEED_TEST_OUTPUT_FILE\"\necho \"GOPEED_TASK_NAME=$GOPEED_TASK_NAME\" >> \"$GOPEED_TEST_OUTPUT_FILE\"\necho \"GOPEED_TASK_STATUS=$GOPEED_TASK_STATUS\" >> \"$GOPEED_TEST_OUTPUT_FILE\"\necho \"GOPEED_TASK_PATH=$GOPEED_TASK_PATH\" >> \"$GOPEED_TEST_OUTPUT_FILE\"\n"
  },
  {
    "path": "pkg/download/testdata/scripts/move.bat",
    "content": "@echo off\nsetlocal\n\nif \"%GOPEED_TEST_DEST_DIR%\"==\"\" exit /b 2\nset \"SRC=%GOPEED_TASK_PATH:/=\\%\"\nset \"DEST=%GOPEED_TEST_DEST_DIR%\"\nif not exist \"%DEST%\" mkdir \"%DEST%\"\nmove /Y \"%SRC%\" \"%DEST%\\\" >nul\n"
  },
  {
    "path": "pkg/download/testdata/scripts/move.sh",
    "content": "#!/bin/bash\nset -e\n\nif [ -z \"$GOPEED_TEST_DEST_DIR\" ]; then\n  echo \"GOPEED_TEST_DEST_DIR is empty\" >&2\n  exit 2\nfi\n\nmkdir -p \"$GOPEED_TEST_DEST_DIR\"\nmv \"$GOPEED_TASK_PATH\" \"$GOPEED_TEST_DEST_DIR/\"\n"
  },
  {
    "path": "pkg/download/testdata/scripts/write_output1.bat",
    "content": "@echo off\nsetlocal\n\nif \"%GOPEED_TEST_OUTPUT_FILE_1%\"==\"\" exit /b 2\necho Script 1 > \"%GOPEED_TEST_OUTPUT_FILE_1%\"\n"
  },
  {
    "path": "pkg/download/testdata/scripts/write_output1.sh",
    "content": "#!/bin/bash\nset -e\n\nif [ -z \"$GOPEED_TEST_OUTPUT_FILE_1\" ]; then\n  echo \"GOPEED_TEST_OUTPUT_FILE_1 is empty\" >&2\n  exit 2\nfi\n\necho \"Script 1\" > \"$GOPEED_TEST_OUTPUT_FILE_1\"\n"
  },
  {
    "path": "pkg/download/testdata/scripts/write_output2.bat",
    "content": "@echo off\nsetlocal\n\nif \"%GOPEED_TEST_OUTPUT_FILE_2%\"==\"\" exit /b 2\necho Script 2 > \"%GOPEED_TEST_OUTPUT_FILE_2%\"\n"
  },
  {
    "path": "pkg/download/testdata/scripts/write_output2.sh",
    "content": "#!/bin/bash\nset -e\n\nif [ -z \"$GOPEED_TEST_OUTPUT_FILE_2\" ]; then\n  echo \"GOPEED_TEST_OUTPUT_FILE_2 is empty\" >&2\n  exit 2\nfi\n\necho \"Script 2\" > \"$GOPEED_TEST_OUTPUT_FILE_2\"\n"
  },
  {
    "path": "pkg/download/webhook.go",
    "content": "package download\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/GopeedLab/gopeed/internal/fetcher\"\n\t\"github.com/GopeedLab/gopeed/pkg/base\"\n)\n\nconst (\n\twebhookTimeout = 10 * time.Second\n)\n\n// WebhookEvent represents the type of webhook event\ntype WebhookEvent string\n\nconst (\n\tWebhookEventDownloadDone  WebhookEvent = \"DOWNLOAD_DONE\"\n\tWebhookEventDownloadError WebhookEvent = \"DOWNLOAD_ERROR\"\n)\n\n// WebhookData is the data sent to webhook URLs\ntype WebhookData struct {\n\tEvent   WebhookEvent    `json:\"event\"`\n\tTime    int64           `json:\"time\"` // Unix timestamp in milliseconds\n\tPayload *WebhookPayload `json:\"payload\"`\n}\n\n// WebhookPayload contains the task data\ntype WebhookPayload struct {\n\tTask *Task `json:\"task\"`\n}\n\n// getWebhookUrls extracts webhook URLs from config\n// Supports both new webhook config format and legacy extra field for backward compatibility\nfunc (d *Downloader) getWebhookUrls() []string {\n\tcfg := d.cfg.DownloaderStoreConfig\n\tif cfg == nil {\n\t\treturn nil\n\t}\n\n\t// Try new webhook config first\n\tif cfg.Webhook != nil && cfg.Webhook.Enable && len(cfg.Webhook.URLs) > 0 {\n\t\turls := make([]string, 0, len(cfg.Webhook.URLs))\n\t\tfor _, url := range cfg.Webhook.URLs {\n\t\t\tif url != \"\" {\n\t\t\t\turls = append(urls, url)\n\t\t\t}\n\t\t}\n\t\tif len(urls) > 0 {\n\t\t\treturn urls\n\t\t}\n\t}\n\n\t// Fall back to legacy extra field for backward compatibility\n\tif cfg.Extra == nil {\n\t\treturn nil\n\t}\n\n\twebhookUrls, ok := cfg.Extra[\"webhookUrls\"]\n\tif !ok {\n\t\treturn nil\n\t}\n\n\t// Try direct string slice first\n\tif urlsStr, ok := webhookUrls.([]string); ok {\n\t\tif len(urlsStr) == 0 {\n\t\t\treturn nil\n\t\t}\n\t\treturn urlsStr\n\t}\n\n\t// Convert []interface{} to []string\n\tif urlsInterface, ok := webhookUrls.([]any); ok {\n\t\tif len(urlsInterface) == 0 {\n\t\t\treturn nil\n\t\t}\n\t\turls := make([]string, 0, len(urlsInterface))\n\t\tfor _, urlInterface := range urlsInterface {\n\t\t\tif url, ok := urlInterface.(string); ok && url != \"\" {\n\t\t\t\turls = append(urls, url)\n\t\t\t}\n\t\t}\n\t\tif len(urls) == 0 {\n\t\t\treturn nil\n\t\t}\n\t\treturn urls\n\t}\n\n\treturn nil\n}\n\n// sendWebhookToUrl sends webhook data to a single URL\n// Returns the HTTP status code and any error that occurred\nfunc (d *Downloader) sendWebhookToUrl(url string, data *WebhookData) (int, error) {\n\tif url == \"\" {\n\t\treturn 0, fmt.Errorf(\"webhook URL is empty\")\n\t}\n\n\tjsonData, err := json.Marshal(data)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\tclient := &http.Client{\n\t\tTimeout: webhookTimeout,\n\t}\n\n\treq, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonData))\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"User-Agent\", \"Gopeed-Webhook/1.0\")\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\tdefer resp.Body.Close()\n\n\treturn resp.StatusCode, nil\n}\n\n// triggerWebhooks sends webhook notifications to all configured URLs\nfunc (d *Downloader) triggerWebhooks(event WebhookEvent, task *Task, err error) {\n\turls := d.getWebhookUrls()\n\tif len(urls) == 0 {\n\t\treturn\n\t}\n\n\tdata := &WebhookData{\n\t\tEvent: event,\n\t\tTime:  time.Now().UnixMilli(),\n\t\tPayload: &WebhookPayload{\n\t\t\tTask: task.clone(),\n\t\t},\n\t}\n\n\tgo d.sendWebhooks(urls, data)\n}\n\nfunc (d *Downloader) sendWebhooks(urls []string, data *WebhookData) {\n\tfor _, url := range urls {\n\t\tif url == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tgo func(webhookUrl string) {\n\t\t\tstatusCode, err := d.sendWebhookToUrl(webhookUrl, data)\n\t\t\tif err != nil {\n\t\t\t\td.Logger.Warn().Err(err).Str(\"url\", webhookUrl).Msg(\"webhook: failed to send request\")\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif statusCode >= 200 && statusCode < 300 {\n\t\t\t\td.Logger.Debug().Str(\"url\", webhookUrl).Int(\"status\", statusCode).Msg(\"webhook: sent successfully\")\n\t\t\t} else {\n\t\t\t\td.Logger.Warn().Str(\"url\", webhookUrl).Int(\"status\", statusCode).Msg(\"webhook: received non-success status\")\n\t\t\t}\n\t\t}(url)\n\t}\n}\n\n// SendTestWebhook sends a test webhook with a simulated payload\n// Returns error if any webhook URL does not respond with HTTP 200\nfunc (d *Downloader) SendTestWebhook() error {\n\turls := d.getWebhookUrls()\n\tif len(urls) == 0 {\n\t\treturn nil\n\t}\n\n\tfor _, url := range urls {\n\t\tif url == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tif err := d.TestWebhookUrl(url); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// TestWebhookUrl tests a single webhook URL with a simulated payload\n// Returns error if the URL does not respond with HTTP 200\nfunc (d *Downloader) TestWebhookUrl(url string) error {\n\t// Create a simulated test task with minimal required fields\n\ttestTask := NewTask()\n\ttestTask.Protocol = \"http\"\n\ttestTask.Status = base.DownloadStatusDone\n\ttestTask.Meta = &fetcher.FetcherMeta{\n\t\tReq: &base.Request{\n\t\t\tURL: \"https://example.com/test-file.zip\",\n\t\t},\n\t\tOpts: &base.Options{\n\t\t\tName: \"test-file.zip\",\n\t\t\tPath: \"/downloads\",\n\t\t},\n\t\tRes: &base.Resource{\n\t\t\tSize: 1024 * 1024 * 100, // 100MB\n\t\t\tFiles: []*base.FileInfo{\n\t\t\t\t{Name: \"test-file.zip\", Size: 1024 * 1024 * 100},\n\t\t\t},\n\t\t},\n\t}\n\n\t// Create test data\n\ttestData := &WebhookData{\n\t\tEvent: WebhookEventDownloadDone,\n\t\tTime:  time.Now().UnixMilli(),\n\t\tPayload: &WebhookPayload{\n\t\t\tTask: testTask,\n\t\t},\n\t}\n\n\tstatusCode, err := d.sendWebhookToUrl(url, testData)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif statusCode != http.StatusOK {\n\t\treturn fmt.Errorf(\"webhook test failed: %s returned status %d\", url, statusCode)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/download/webhook_test.go",
    "content": "package download\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/GopeedLab/gopeed/internal/fetcher\"\n\t\"github.com/GopeedLab/gopeed/pkg/base\"\n)\n\nvar mockFetcherMeta = fetcher.FetcherMeta{\n\tReq: &base.Request{\n\t\tURL: \"https://example.com/test.zip\",\n\t},\n\tOpts: &base.Options{\n\t\tPath: \"/downloads\",\n\t},\n\tRes: &base.Resource{\n\t\tSize: 1024 * 1024,\n\t\tFiles: []*base.FileInfo{\n\t\t\t{Name: \"test.zip\", Size: 1024 * 1024},\n\t\t},\n\t},\n}\n\nfunc TestWebhook_TriggerOnDone(t *testing.T) {\n\treceivedData := make(chan *WebhookData, 1)\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif r.Method != http.MethodPost {\n\t\t\tt.Errorf(\"Expected POST request, got %s\", r.Method)\n\t\t}\n\t\tif r.Header.Get(\"Content-Type\") != \"application/json\" {\n\t\t\tt.Errorf(\"Expected Content-Type application/json, got %s\", r.Header.Get(\"Content-Type\"))\n\t\t}\n\t\tvar data WebhookData\n\t\tif err := json.NewDecoder(r.Body).Decode(&data); err != nil {\n\t\t\tt.Errorf(\"Failed to decode data: %v\", err)\n\t\t\treturn\n\t\t}\n\t\treceivedData <- &data\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tdefer server.Close()\n\n\tsetupWebhookTest(t, func(downloader *Downloader) {\n\t\t// Configure webhook URLs\n\t\tcfg, _ := downloader.GetConfig()\n\t\tcfg.Webhook = &base.WebhookConfig{\n\t\t\tEnable: true,\n\t\t\tURLs:   []string{server.URL},\n\t\t}\n\t\tdownloader.PutConfig(cfg)\n\n\t\t// Create a mock task\n\t\ttask := NewTask()\n\t\ttask.Protocol = \"http\"\n\t\ttask.Meta = &mockFetcherMeta\n\n\t\t// Trigger webhook\n\t\tdownloader.triggerWebhooks(WebhookEventDownloadDone, task, nil)\n\n\t\tselect {\n\t\tcase data := <-receivedData:\n\t\t\tif data.Event != WebhookEventDownloadDone {\n\t\t\t\tt.Errorf(\"Expected event 'DOWNLOAD_DONE', got '%s'\", data.Event)\n\t\t\t}\n\t\t\tif data.Payload == nil || data.Payload.Task == nil {\n\t\t\t\tt.Error(\"Expected payload.task to be present\")\n\t\t\t} else if data.Payload.Task.ID != task.ID {\n\t\t\t\tt.Errorf(\"Expected task ID '%s', got '%s'\", task.ID, data.Payload.Task.ID)\n\t\t\t}\n\t\t\tif data.Time == 0 {\n\t\t\t\tt.Error(\"Expected time to be set\")\n\t\t\t}\n\t\tcase <-time.After(2 * time.Second):\n\t\t\tt.Error(\"Timeout waiting for webhook\")\n\t\t}\n\t})\n}\n\nfunc TestWebhook_TriggerOnError(t *testing.T) {\n\treceivedData := make(chan *WebhookData, 1)\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar data WebhookData\n\t\tif err := json.NewDecoder(r.Body).Decode(&data); err != nil {\n\t\t\tt.Errorf(\"Failed to decode data: %v\", err)\n\t\t\treturn\n\t\t}\n\t\treceivedData <- &data\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tdefer server.Close()\n\n\tsetupWebhookTest(t, func(downloader *Downloader) {\n\t\t// Configure webhook URLs\n\t\tcfg, _ := downloader.GetConfig()\n\t\tcfg.Webhook = &base.WebhookConfig{\n\t\t\tEnable: true,\n\t\t\tURLs:   []string{server.URL},\n\t\t}\n\t\tdownloader.PutConfig(cfg)\n\n\t\t// Create a mock task\n\t\ttask := NewTask()\n\t\ttask.Protocol = \"http\"\n\t\ttask.Meta = &mockFetcherMeta\n\n\t\t// Trigger webhook with error\n\t\ttestError := http.ErrServerClosed\n\t\tdownloader.triggerWebhooks(WebhookEventDownloadError, task, testError)\n\n\t\tselect {\n\t\tcase data := <-receivedData:\n\t\t\tif data.Event != WebhookEventDownloadError {\n\t\t\t\tt.Errorf(\"Expected event 'DOWNLOAD_ERROR', got '%s'\", data.Event)\n\t\t\t}\n\t\t\tif data.Payload == nil || data.Payload.Task == nil {\n\t\t\t\tt.Error(\"Expected payload.task to be present\")\n\t\t\t}\n\t\tcase <-time.After(2 * time.Second):\n\t\t\tt.Error(\"Timeout waiting for webhook\")\n\t\t}\n\t})\n}\n\nfunc TestWebhook_SendTestWebhook(t *testing.T) {\n\treceivedData := make(chan *WebhookData, 1)\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar data WebhookData\n\t\tif err := json.NewDecoder(r.Body).Decode(&data); err != nil {\n\t\t\tt.Errorf(\"Failed to decode data: %v\", err)\n\t\t\treturn\n\t\t}\n\t\treceivedData <- &data\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tdefer server.Close()\n\n\tsetupWebhookTest(t, func(downloader *Downloader) {\n\t\t// Configure webhook URLs\n\t\tcfg, _ := downloader.GetConfig()\n\t\tcfg.Webhook = &base.WebhookConfig{\n\t\t\tEnable: true,\n\t\t\tURLs:   []string{server.URL},\n\t\t}\n\t\tdownloader.PutConfig(cfg)\n\n\t\t// Send test webhook\n\t\terr := downloader.SendTestWebhook()\n\t\tif err != nil {\n\t\t\tt.Errorf(\"SendTestWebhook failed: %v\", err)\n\t\t}\n\n\t\tselect {\n\t\tcase data := <-receivedData:\n\t\t\tif data.Event != WebhookEventDownloadDone {\n\t\t\t\tt.Errorf(\"Expected event 'DOWNLOAD_DONE', got '%s'\", data.Event)\n\t\t\t}\n\t\t\tif data.Payload == nil || data.Payload.Task == nil {\n\t\t\t\tt.Error(\"Expected payload.task to be present\")\n\t\t\t}\n\t\t\tif data.Time == 0 {\n\t\t\t\tt.Error(\"Expected time to be set\")\n\t\t\t}\n\t\tcase <-time.After(2 * time.Second):\n\t\t\tt.Error(\"Timeout waiting for webhook\")\n\t\t}\n\t})\n}\n\nfunc TestWebhook_NoWebhookConfigured(t *testing.T) {\n\tsetupWebhookTest(t, func(downloader *Downloader) {\n\t\t// Create a mock task\n\t\ttask := NewTask()\n\t\ttask.Protocol = \"http\"\n\t\ttask.Meta = &mockFetcherMeta\n\n\t\t// Trigger webhook (should not panic with no webhooks configured)\n\t\tdownloader.triggerWebhooks(WebhookEventDownloadDone, task, nil)\n\n\t\t// Send test webhook (should not panic)\n\t\terr := downloader.SendTestWebhook()\n\t\tif err != nil {\n\t\t\tt.Errorf(\"SendTestWebhook failed: %v\", err)\n\t\t}\n\t})\n}\n\nfunc TestWebhook_MultipleUrls(t *testing.T) {\n\tcount := 0\n\tserver1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tcount++\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tdefer server1.Close()\n\tserver2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tcount++\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tdefer server2.Close()\n\n\tsetupWebhookTest(t, func(downloader *Downloader) {\n\t\t// Configure multiple webhook URLs\n\t\tcfg, _ := downloader.GetConfig()\n\t\tcfg.Webhook = &base.WebhookConfig{\n\t\t\tEnable: true,\n\t\t\tURLs:   []string{server1.URL, server2.URL},\n\t\t}\n\t\tdownloader.PutConfig(cfg)\n\n\t\t// Create a mock task\n\t\ttask := NewTask()\n\t\ttask.Protocol = \"http\"\n\t\ttask.Meta = &mockFetcherMeta\n\n\t\t// Trigger webhook\n\t\tdownloader.triggerWebhooks(WebhookEventDownloadDone, task, nil)\n\n\t\t// Wait for webhooks\n\t\ttime.Sleep(500 * time.Millisecond)\n\n\t\tif count != 2 {\n\t\t\tt.Errorf(\"Expected 2 webhook calls, got %d\", count)\n\t\t}\n\t})\n}\n\nfunc TestWebhook_TestWebhookFailsOnNon200(t *testing.T) {\n\t// Test that SendTestWebhook returns error for non-200 status codes\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusInternalServerError) // 500\n\t}))\n\tdefer server.Close()\n\n\tsetupWebhookTest(t, func(downloader *Downloader) {\n\t\t// Configure webhook URLs\n\t\tcfg, _ := downloader.GetConfig()\n\t\tcfg.Webhook = &base.WebhookConfig{\n\t\t\tEnable: true,\n\t\t\tURLs:   []string{server.URL},\n\t\t}\n\t\tdownloader.PutConfig(cfg)\n\n\t\t// Send test webhook - should fail with non-200 status\n\t\terr := downloader.SendTestWebhook()\n\t\tif err == nil {\n\t\t\tt.Error(\"Expected SendTestWebhook to return error for non-200 status\")\n\t\t}\n\t})\n}\n\nfunc TestWebhook_TestWebhookFailsOn201(t *testing.T) {\n\t// Test that SendTestWebhook returns error for 201 (only 200 is success)\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusCreated) // 201\n\t}))\n\tdefer server.Close()\n\n\tsetupWebhookTest(t, func(downloader *Downloader) {\n\t\t// Configure webhook URLs\n\t\tcfg, _ := downloader.GetConfig()\n\t\tcfg.Webhook = &base.WebhookConfig{\n\t\t\tEnable: true,\n\t\t\tURLs:   []string{server.URL},\n\t\t}\n\t\tdownloader.PutConfig(cfg)\n\n\t\t// Send test webhook - should fail with 201 status (only 200 is success)\n\t\terr := downloader.SendTestWebhook()\n\t\tif err == nil {\n\t\t\tt.Error(\"Expected SendTestWebhook to return error for 201 status\")\n\t\t}\n\t})\n}\n\nfunc TestWebhook_TestWebhookUrl(t *testing.T) {\n\treceivedData := make(chan *WebhookData, 1)\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar data WebhookData\n\t\tif err := json.NewDecoder(r.Body).Decode(&data); err != nil {\n\t\t\tt.Errorf(\"Failed to decode data: %v\", err)\n\t\t\treturn\n\t\t}\n\t\treceivedData <- &data\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tdefer server.Close()\n\n\tsetupWebhookTest(t, func(downloader *Downloader) {\n\t\t// Test single URL\n\t\terr := downloader.TestWebhookUrl(server.URL)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"TestWebhookUrl failed: %v\", err)\n\t\t}\n\n\t\tselect {\n\t\tcase data := <-receivedData:\n\t\t\tif data.Event != WebhookEventDownloadDone {\n\t\t\t\tt.Errorf(\"Expected event 'DOWNLOAD_DONE', got '%s'\", data.Event)\n\t\t\t}\n\t\t\tif data.Payload == nil || data.Payload.Task == nil {\n\t\t\t\tt.Error(\"Expected payload.task to be present\")\n\t\t\t}\n\t\tcase <-time.After(2 * time.Second):\n\t\t\tt.Error(\"Timeout waiting for webhook\")\n\t\t}\n\t})\n}\n\nfunc TestWebhook_TestWebhookUrlEmpty(t *testing.T) {\n\tsetupWebhookTest(t, func(downloader *Downloader) {\n\t\t// Test with empty URL - should return error\n\t\terr := downloader.TestWebhookUrl(\"\")\n\t\tif err == nil {\n\t\t\tt.Error(\"Expected TestWebhookUrl to return error for empty URL\")\n\t\t}\n\t})\n}\n\nfunc TestWebhook_GetWebhookUrls_EmptyConfig(t *testing.T) {\n\tsetupWebhookTest(t, func(downloader *Downloader) {\n\t\turls := downloader.getWebhookUrls()\n\t\tif urls != nil {\n\t\t\tt.Errorf(\"Expected nil, got %v\", urls)\n\t\t}\n\t})\n}\n\nfunc TestWebhook_GetWebhookUrls_NoExtraField(t *testing.T) {\n\tsetupWebhookTest(t, func(downloader *Downloader) {\n\t\tcfg, _ := downloader.GetConfig()\n\t\tcfg.Webhook = nil\n\t\tdownloader.PutConfig(cfg)\n\n\t\turls := downloader.getWebhookUrls()\n\t\tif urls != nil {\n\t\t\tt.Errorf(\"Expected nil, got %v\", urls)\n\t\t}\n\t})\n}\n\nfunc TestWebhook_GetWebhookUrls_NoWebhookUrlsKey(t *testing.T) {\n\tsetupWebhookTest(t, func(downloader *Downloader) {\n\t\tcfg, _ := downloader.GetConfig()\n\t\tcfg.Webhook = &base.WebhookConfig{Enable: true} // No URLs set\n\t\tdownloader.PutConfig(cfg)\n\n\t\turls := downloader.getWebhookUrls()\n\t\tif urls != nil {\n\t\t\tt.Errorf(\"Expected nil, got %v\", urls)\n\t\t}\n\t})\n}\n\nfunc TestWebhook_GetWebhookUrls_StringSlice(t *testing.T) {\n\tsetupWebhookTest(t, func(downloader *Downloader) {\n\t\tcfg, _ := downloader.GetConfig()\n\t\tcfg.Webhook = &base.WebhookConfig{\n\t\t\tEnable: true,\n\t\t\tURLs:   []string{\"http://example.com\", \"http://example2.com\"},\n\t\t}\n\t\tdownloader.PutConfig(cfg)\n\n\t\turls := downloader.getWebhookUrls()\n\t\tif len(urls) != 2 {\n\t\t\tt.Errorf(\"Expected 2 URLs, got %d\", len(urls))\n\t\t}\n\t\tif urls[0] != \"http://example.com\" || urls[1] != \"http://example2.com\" {\n\t\t\tt.Errorf(\"URLs don't match expected values: %v\", urls)\n\t\t}\n\t})\n}\n\nfunc TestWebhook_GetWebhookUrls_InterfaceSlice(t *testing.T) {\n\tsetupWebhookTest(t, func(downloader *Downloader) {\n\t\tcfg, _ := downloader.GetConfig()\n\t\tcfg.Webhook = &base.WebhookConfig{\n\t\t\tEnable: true,\n\t\t\tURLs:   []string{\"http://example.com\", \"http://example2.com\"},\n\t\t}\n\t\tdownloader.PutConfig(cfg)\n\n\t\turls := downloader.getWebhookUrls()\n\t\tif len(urls) != 2 {\n\t\t\tt.Errorf(\"Expected 2 URLs, got %d\", len(urls))\n\t\t}\n\t\tif urls[0] != \"http://example.com\" || urls[1] != \"http://example2.com\" {\n\t\t\tt.Errorf(\"URLs don't match expected values: %v\", urls)\n\t\t}\n\t})\n}\n\nfunc TestWebhook_GetWebhookUrls_EmptyStringSlice(t *testing.T) {\n\tsetupWebhookTest(t, func(downloader *Downloader) {\n\t\tcfg, _ := downloader.GetConfig()\n\t\tcfg.Extra = make(map[string]any)\n\t\tcfg.Webhook.URLs = []string{}\n\t\tdownloader.PutConfig(cfg)\n\n\t\turls := downloader.getWebhookUrls()\n\t\tif urls != nil {\n\t\t\tt.Errorf(\"Expected nil, got %v\", urls)\n\t\t}\n\t})\n}\n\nfunc TestWebhook_GetWebhookUrls_InterfaceSliceWithEmptyStrings(t *testing.T) {\n\tsetupWebhookTest(t, func(downloader *Downloader) {\n\t\tcfg, _ := downloader.GetConfig()\n\t\tcfg.Webhook = &base.WebhookConfig{\n\t\t\tEnable: true,\n\t\t\tURLs:   []string{\"\", \"\", \"\"},\n\t\t}\n\t\tdownloader.PutConfig(cfg)\n\n\t\turls := downloader.getWebhookUrls()\n\t\tif urls != nil {\n\t\t\tt.Errorf(\"Expected nil for all empty strings, got %v\", urls)\n\t\t}\n\t})\n}\n\nfunc TestWebhook_GetWebhookUrls_InterfaceSliceMixedTypes(t *testing.T) {\n\tsetupWebhookTest(t, func(downloader *Downloader) {\n\t\tcfg, _ := downloader.GetConfig()\n\t\tcfg.Webhook = &base.WebhookConfig{\n\t\t\tEnable: true,\n\t\t\tURLs:   []string{\"http://example.com\", \"\", \"http://example2.com\", \"\"},\n\t\t}\n\t\tdownloader.PutConfig(cfg)\n\n\t\turls := downloader.getWebhookUrls()\n\t\tif len(urls) != 2 {\n\t\t\tt.Errorf(\"Expected 2 valid URLs (ignoring empty strings), got %d: %v\", len(urls), urls)\n\t\t}\n\t\tif urls[0] != \"http://example.com\" || urls[1] != \"http://example2.com\" {\n\t\t\tt.Errorf(\"URLs don't match expected values: %v\", urls)\n\t\t}\n\t})\n}\n\nfunc TestWebhook_GetWebhookUrls_InvalidType(t *testing.T) {\n\tsetupWebhookTest(t, func(downloader *Downloader) {\n\t\tcfg, _ := downloader.GetConfig()\n\t\tcfg.Webhook = &base.WebhookConfig{Enable: false} // Disabled webhook\n\t\tdownloader.PutConfig(cfg)\n\n\t\turls := downloader.getWebhookUrls()\n\t\tif urls != nil {\n\t\t\tt.Errorf(\"Expected nil for disabled webhook, got %v\", urls)\n\t\t}\n\t})\n}\n\nfunc TestWebhook_GetWebhookUrls_DisabledWebhook(t *testing.T) {\n\tsetupWebhookTest(t, func(downloader *Downloader) {\n\t\tcfg, _ := downloader.GetConfig()\n\t\tcfg.Webhook = &base.WebhookConfig{\n\t\t\tEnable: false,\n\t\t\tURLs:   []string{\"http://example.com\"},\n\t\t}\n\t\tdownloader.PutConfig(cfg)\n\n\t\turls := downloader.getWebhookUrls()\n\t\tif urls != nil {\n\t\t\tt.Errorf(\"Expected nil for disabled webhook even with URLs, got %v\", urls)\n\t\t}\n\t})\n}\n\nfunc TestWebhook_SendWebhookToUrl_Success(t *testing.T) {\n\treceivedData := make(chan *WebhookData, 1)\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t// Verify headers\n\t\tif r.Header.Get(\"Content-Type\") != \"application/json\" {\n\t\t\tt.Errorf(\"Expected Content-Type application/json, got %s\", r.Header.Get(\"Content-Type\"))\n\t\t}\n\t\tif r.Header.Get(\"User-Agent\") != \"Gopeed-Webhook/1.0\" {\n\t\t\tt.Errorf(\"Expected User-Agent Gopeed-Webhook/1.0, got %s\", r.Header.Get(\"User-Agent\"))\n\t\t}\n\n\t\tvar data WebhookData\n\t\tif err := json.NewDecoder(r.Body).Decode(&data); err != nil {\n\t\t\tt.Errorf(\"Failed to decode data: %v\", err)\n\t\t\treturn\n\t\t}\n\t\treceivedData <- &data\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tdefer server.Close()\n\n\tsetupWebhookTest(t, func(downloader *Downloader) {\n\t\ttask := NewTask()\n\t\ttask.Protocol = \"http\"\n\t\ttask.Meta = &mockFetcherMeta\n\n\t\tdata := &WebhookData{\n\t\t\tEvent: WebhookEventDownloadDone,\n\t\t\tTime:  time.Now().UnixMilli(),\n\t\t\tPayload: &WebhookPayload{\n\t\t\t\tTask: task,\n\t\t\t},\n\t\t}\n\n\t\tstatusCode, err := downloader.sendWebhookToUrl(server.URL, data)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"sendWebhookToUrl failed: %v\", err)\n\t\t}\n\t\tif statusCode != http.StatusOK {\n\t\t\tt.Errorf(\"Expected status 200, got %d\", statusCode)\n\t\t}\n\n\t\tselect {\n\t\tcase received := <-receivedData:\n\t\t\tif received.Event != WebhookEventDownloadDone {\n\t\t\t\tt.Errorf(\"Expected event DOWNLOAD_DONE, got %s\", received.Event)\n\t\t\t}\n\t\tcase <-time.After(1 * time.Second):\n\t\t\tt.Error(\"Timeout waiting for webhook\")\n\t\t}\n\t})\n}\n\nfunc TestWebhook_SendWebhookToUrl_EmptyUrl(t *testing.T) {\n\tsetupWebhookTest(t, func(downloader *Downloader) {\n\t\tdata := &WebhookData{\n\t\t\tEvent: WebhookEventDownloadDone,\n\t\t\tTime:  time.Now().UnixMilli(),\n\t\t}\n\n\t\t_, err := downloader.sendWebhookToUrl(\"\", data)\n\t\tif err == nil {\n\t\t\tt.Error(\"Expected error for empty URL\")\n\t\t}\n\t\tif err.Error() != \"webhook URL is empty\" {\n\t\t\tt.Errorf(\"Expected 'webhook URL is empty' error, got: %v\", err)\n\t\t}\n\t})\n}\n\nfunc TestWebhook_SendWebhookToUrl_InvalidUrl(t *testing.T) {\n\tsetupWebhookTest(t, func(downloader *Downloader) {\n\t\tdata := &WebhookData{\n\t\t\tEvent: WebhookEventDownloadDone,\n\t\t\tTime:  time.Now().UnixMilli(),\n\t\t}\n\n\t\t_, err := downloader.sendWebhookToUrl(\"://invalid-url\", data)\n\t\tif err == nil {\n\t\t\tt.Error(\"Expected error for invalid URL\")\n\t\t}\n\t})\n}\n\nfunc TestWebhook_SendWebhookToUrl_NonExistentHost(t *testing.T) {\n\tsetupWebhookTest(t, func(downloader *Downloader) {\n\t\tdata := &WebhookData{\n\t\t\tEvent: WebhookEventDownloadDone,\n\t\t\tTime:  time.Now().UnixMilli(),\n\t\t}\n\n\t\t_, err := downloader.sendWebhookToUrl(\"http://non-existent-host-12345.example\", data)\n\t\tif err == nil {\n\t\t\tt.Error(\"Expected error for non-existent host\")\n\t\t}\n\t})\n}\n\nfunc TestWebhook_SendWebhookToUrl_Timeout(t *testing.T) {\n\t// Create a server that delays response beyond webhook timeout\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\ttime.Sleep(15 * time.Second) // Longer than webhookTimeout (10s)\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tdefer server.Close()\n\n\tsetupWebhookTest(t, func(downloader *Downloader) {\n\t\tdata := &WebhookData{\n\t\t\tEvent: WebhookEventDownloadDone,\n\t\t\tTime:  time.Now().UnixMilli(),\n\t\t}\n\n\t\t_, err := downloader.sendWebhookToUrl(server.URL, data)\n\t\tif err == nil {\n\t\t\tt.Error(\"Expected timeout error\")\n\t\t}\n\t})\n}\n\nfunc TestWebhook_SendWebhookToUrl_VariousStatusCodes(t *testing.T) {\n\ttestCases := []struct {\n\t\tname       string\n\t\tstatusCode int\n\t}{\n\t\t{\"200 OK\", http.StatusOK},\n\t\t{\"201 Created\", http.StatusCreated},\n\t\t{\"204 No Content\", http.StatusNoContent},\n\t\t{\"400 Bad Request\", http.StatusBadRequest},\n\t\t{\"404 Not Found\", http.StatusNotFound},\n\t\t{\"500 Internal Server Error\", http.StatusInternalServerError},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tw.WriteHeader(tc.statusCode)\n\t\t\t}))\n\t\t\tdefer server.Close()\n\n\t\t\tsetupWebhookTest(t, func(downloader *Downloader) {\n\t\t\t\tdata := &WebhookData{\n\t\t\t\t\tEvent: WebhookEventDownloadDone,\n\t\t\t\t\tTime:  time.Now().UnixMilli(),\n\t\t\t\t}\n\n\t\t\t\tstatusCode, err := downloader.sendWebhookToUrl(server.URL, data)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Errorf(\"sendWebhookToUrl failed: %v\", err)\n\t\t\t\t}\n\t\t\t\tif statusCode != tc.statusCode {\n\t\t\t\t\tt.Errorf(\"Expected status %d, got %d\", tc.statusCode, statusCode)\n\t\t\t\t}\n\t\t\t})\n\t\t})\n\t}\n}\n\nfunc TestWebhook_TriggerWebhooks_EmptyUrlSkipped(t *testing.T) {\n\trequestCount := 0\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\trequestCount++\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tdefer server.Close()\n\n\tsetupWebhookTest(t, func(downloader *Downloader) {\n\t\tcfg, _ := downloader.GetConfig()\n\t\tcfg.Webhook = &base.WebhookConfig{\n\t\t\tEnable: true,\n\t\t\tURLs:   []string{server.URL, \"\", server.URL},\n\t\t}\n\t\tdownloader.PutConfig(cfg)\n\n\t\ttask := NewTask()\n\t\ttask.Protocol = \"http\"\n\t\ttask.Meta = &mockFetcherMeta\n\n\t\tdownloader.triggerWebhooks(WebhookEventDownloadDone, task, nil)\n\n\t\ttime.Sleep(500 * time.Millisecond)\n\n\t\tif requestCount != 2 {\n\t\t\tt.Errorf(\"Expected 2 requests (empty URL should be skipped), got %d\", requestCount)\n\t\t}\n\t})\n}\n\nfunc TestWebhook_WebhookDataStructure(t *testing.T) {\n\treceivedData := make(chan *WebhookData, 1)\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar data WebhookData\n\t\tif err := json.NewDecoder(r.Body).Decode(&data); err != nil {\n\t\t\tt.Errorf(\"Failed to decode data: %v\", err)\n\t\t\treturn\n\t\t}\n\t\treceivedData <- &data\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tdefer server.Close()\n\n\tsetupWebhookTest(t, func(downloader *Downloader) {\n\t\tcfg, _ := downloader.GetConfig()\n\t\tcfg.Webhook = &base.WebhookConfig{\n\t\t\tEnable: true,\n\t\t\tURLs:   []string{server.URL},\n\t\t}\n\t\tdownloader.PutConfig(cfg)\n\n\t\ttask := NewTask()\n\t\ttask.Protocol = \"http\"\n\t\ttask.Status = base.DownloadStatusDone\n\t\ttask.Meta = &mockFetcherMeta\n\n\t\tdownloader.triggerWebhooks(WebhookEventDownloadDone, task, nil)\n\n\t\tselect {\n\t\tcase data := <-receivedData:\n\t\t\t// Verify event\n\t\t\tif data.Event != WebhookEventDownloadDone {\n\t\t\t\tt.Errorf(\"Expected event DOWNLOAD_DONE, got %s\", data.Event)\n\t\t\t}\n\t\t\t// Verify time is set\n\t\t\tif data.Time == 0 {\n\t\t\t\tt.Error(\"Expected time to be set\")\n\t\t\t}\n\t\t\t// Verify payload\n\t\t\tif data.Payload == nil {\n\t\t\t\tt.Error(\"Expected payload to be present\")\n\t\t\t}\n\t\t\tif data.Payload.Task == nil {\n\t\t\t\tt.Error(\"Expected task in payload to be present\")\n\t\t\t}\n\t\t\t// Verify task is cloned (has same ID but different pointer)\n\t\t\tif data.Payload.Task.ID != task.ID {\n\t\t\t\tt.Errorf(\"Expected task ID %s, got %s\", task.ID, data.Payload.Task.ID)\n\t\t\t}\n\t\tcase <-time.After(2 * time.Second):\n\t\t\tt.Error(\"Timeout waiting for webhook\")\n\t\t}\n\t})\n}\n\nfunc TestWebhook_SendTestWebhook_EmptyUrls(t *testing.T) {\n\tsetupWebhookTest(t, func(downloader *Downloader) {\n\t\tcfg, _ := downloader.GetConfig()\n\t\tcfg.Extra = make(map[string]any)\n\t\tcfg.Webhook.URLs = []string{}\n\t\tdownloader.PutConfig(cfg)\n\n\t\terr := downloader.SendTestWebhook()\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Expected no error for empty URLs, got: %v\", err)\n\t\t}\n\t})\n}\n\nfunc TestWebhook_SendTestWebhook_MixedResults(t *testing.T) {\n\t// First server returns 200\n\tserver1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tdefer server1.Close()\n\n\t// Second server returns 500\n\tserver2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tw.WriteHeader(http.StatusInternalServerError)\n\t}))\n\tdefer server2.Close()\n\n\tsetupWebhookTest(t, func(downloader *Downloader) {\n\t\tcfg, _ := downloader.GetConfig()\n\t\tcfg.Webhook = &base.WebhookConfig{\n\t\t\tEnable: true,\n\t\t\tURLs:   []string{server1.URL, server2.URL},\n\t\t}\n\t\tdownloader.PutConfig(cfg)\n\n\t\t// Should fail because server2 returns 500\n\t\terr := downloader.SendTestWebhook()\n\t\tif err == nil {\n\t\t\tt.Error(\"Expected error when one server returns non-200 status\")\n\t\t}\n\t})\n}\n\nfunc TestWebhook_TestWebhookUrl_InvalidUrl(t *testing.T) {\n\tsetupWebhookTest(t, func(downloader *Downloader) {\n\t\terr := downloader.TestWebhookUrl(\"://invalid\")\n\t\tif err == nil {\n\t\t\tt.Error(\"Expected error for invalid URL\")\n\t\t}\n\t})\n}\n\nfunc TestWebhook_TestWebhookUrl_VerifyTestPayload(t *testing.T) {\n\treceivedData := make(chan *WebhookData, 1)\n\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tvar data WebhookData\n\t\tif err := json.NewDecoder(r.Body).Decode(&data); err != nil {\n\t\t\tt.Errorf(\"Failed to decode data: %v\", err)\n\t\t\treturn\n\t\t}\n\t\treceivedData <- &data\n\t\tw.WriteHeader(http.StatusOK)\n\t}))\n\tdefer server.Close()\n\n\tsetupWebhookTest(t, func(downloader *Downloader) {\n\t\terr := downloader.TestWebhookUrl(server.URL)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"TestWebhookUrl failed: %v\", err)\n\t\t}\n\n\t\tselect {\n\t\tcase data := <-receivedData:\n\t\t\t// Verify it's a test webhook\n\t\t\tif data.Event != WebhookEventDownloadDone {\n\t\t\t\tt.Errorf(\"Expected event DOWNLOAD_DONE, got %s\", data.Event)\n\t\t\t}\n\t\t\tif data.Payload == nil || data.Payload.Task == nil {\n\t\t\t\tt.Error(\"Expected payload with task\")\n\t\t\t}\n\t\t\t// Verify test task properties\n\t\t\ttask := data.Payload.Task\n\t\t\tif task.Protocol != \"http\" {\n\t\t\t\tt.Errorf(\"Expected protocol 'http', got '%s'\", task.Protocol)\n\t\t\t}\n\t\t\tif task.Status != base.DownloadStatusDone {\n\t\t\t\tt.Errorf(\"Expected status Done, got %s\", task.Status)\n\t\t\t}\n\t\t\tif task.Meta == nil || task.Meta.Req == nil {\n\t\t\t\tt.Error(\"Expected meta and request in test task\")\n\t\t\t}\n\t\t\tif task.Meta.Req.URL != \"https://example.com/test-file.zip\" {\n\t\t\t\tt.Errorf(\"Expected test URL, got %s\", task.Meta.Req.URL)\n\t\t\t}\n\t\tcase <-time.After(2 * time.Second):\n\t\t\tt.Error(\"Timeout waiting for webhook\")\n\t\t}\n\t})\n}\n\nfunc setupWebhookTest(t *testing.T, fn func(downloader *Downloader)) {\n\tdefaultDownloader.Setup()\n\tdefaultDownloader.cfg.StorageDir = \".test_storage\"\n\tdefaultDownloader.cfg.DownloadDir = \".test_download\"\n\tdefer func() {\n\t\tdefaultDownloader.Clear()\n\t\tos.RemoveAll(defaultDownloader.cfg.StorageDir)\n\t\tos.RemoveAll(defaultDownloader.cfg.DownloadDir)\n\t}()\n\tfn(defaultDownloader)\n}\n"
  },
  {
    "path": "pkg/protocol/bt/model.go",
    "content": "package bt\n\ntype ReqExtra struct {\n\tTrackers []string `json:\"trackers\"`\n}\n\n// Stats for torrent\ntype Stats struct {\n\t// health indicators of torrents, from large to small, ConnectedSeeders are also the key to the health of seed resources\n\tTotalPeers       int `json:\"totalPeers\"`\n\tActivePeers      int `json:\"activePeers\"`\n\tConnectedSeeders int `json:\"connectedSeeders\"`\n\t// Total seed bytes\n\tSeedBytes int64 `json:\"seedBytes\"`\n\t// Seed ratio\n\tSeedRatio float64 `json:\"seedRatio\"`\n\t// Total seed time\n\tSeedTime int64 `json:\"seedTime\"`\n}\n"
  },
  {
    "path": "pkg/protocol/ed2k/model.go",
    "content": "package ed2k\n\ntype Stats struct {\n\tState         string `json:\"state\"`\n\tPaused        bool   `json:\"paused\"`\n\tActivePeers   int    `json:\"activePeers\"`\n\tTotalPeers    int    `json:\"totalPeers\"`\n\tDownloadRate  int    `json:\"downloadRate\"`\n\tUpload        int64  `json:\"upload\"`\n\tUploadRate    int    `json:\"uploadRate\"`\n\tTotalDone     int64  `json:\"totalDone\"`\n\tTotalReceived int64  `json:\"totalReceived\"`\n\tTotalWanted   int64  `json:\"totalWanted\"`\n}\n"
  },
  {
    "path": "pkg/protocol/http/model.go",
    "content": "package http\n\ntype ReqExtra struct {\n\tMethod string            `json:\"method\"`\n\tHeader map[string]string `json:\"header\"`\n\tBody   string            `json:\"body\"`\n}\n\ntype OptsExtra struct {\n\tConnections int `json:\"connections\"`\n\t// AutoTorrent when task download complete, and it is a .torrent file, it will be auto create a new task for the torrent file\n\t// nil means use global config, true/false means explicit setting\n\tAutoTorrent *bool `json:\"autoTorrent\"`\n\t// DeleteTorrentAfterDownload when true, deletes the .torrent file after creating BT task\n\t// nil means use global config, true/false means explicit setting\n\tDeleteTorrentAfterDownload *bool `json:\"deleteTorrentAfterDownload\"`\n\t// AutoExtract when task download complete, and it is an archive file, it will be auto extracted\n\t// nil means use global config, true/false means explicit setting\n\tAutoExtract *bool `json:\"autoExtract\"`\n\t// ArchivePassword is the password for extracting password-protected archives\n\tArchivePassword string `json:\"archivePassword\"`\n\t// DeleteAfterExtract when true, deletes the archive file after successful extraction\n\tDeleteAfterExtract bool `json:\"deleteAfterExtract\"`\n}\n\n// Stats for download\ntype Stats struct {\n\tConnections []*StatsConnection `json:\"connections\"`\n}\n\ntype StatsConnection struct {\n\tDownloaded int64 `json:\"downloaded\"`\n\tCompleted  bool  `json:\"completed\"`\n\tFailed     bool  `json:\"failed\"`\n\tRetryTimes int   `json:\"retryTimes\"`\n}\n"
  },
  {
    "path": "pkg/rest/api.go",
    "content": "package rest\n\nimport (\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"runtime\"\n\n\t\"github.com/GopeedLab/gopeed/pkg/base\"\n\t\"github.com/GopeedLab/gopeed/pkg/download\"\n\t\"github.com/GopeedLab/gopeed/pkg/rest/model\"\n\t\"github.com/gorilla/mux\"\n)\n\nfunc Info(w http.ResponseWriter, r *http.Request) {\n\tinfo := map[string]any{\n\t\t\"version\":  base.Version,\n\t\t\"runtime\":  runtime.Version(),\n\t\t\"os\":       runtime.GOOS,\n\t\t\"arch\":     runtime.GOARCH,\n\t\t\"inDocker\": base.InDocker == \"true\",\n\t}\n\tWriteJson(w, model.NewOkResult(info))\n}\n\nfunc Resolve(w http.ResponseWriter, r *http.Request) {\n\tvar req model.ResolveTask\n\tif ReadJson(r, w, &req) {\n\t\trr, err := Downloader.Resolve(req.Req, req.Opts)\n\t\tif err != nil {\n\t\t\tWriteJson(w, model.NewErrorResult(err.Error()))\n\t\t\treturn\n\t\t}\n\t\tWriteJson(w, model.NewOkResult(rr))\n\t}\n}\n\nfunc CreateTask(w http.ResponseWriter, r *http.Request) {\n\tvar req model.CreateTask\n\tif ReadJson(r, w, &req) {\n\t\tvar (\n\t\t\ttaskId string\n\t\t\terr    error\n\t\t)\n\t\tif req.Rid != \"\" {\n\t\t\ttaskId, err = Downloader.Create(req.Rid)\n\t\t} else if req.Req != nil {\n\t\t\ttaskId, err = Downloader.CreateDirect(req.Req, req.Opts)\n\t\t} else {\n\t\t\tWriteJson(w, model.NewErrorResult(\"param invalid: rid or req\", model.CodeInvalidParam))\n\t\t\treturn\n\t\t}\n\t\tif err != nil {\n\t\t\tWriteJson(w, model.NewErrorResult(err.Error()))\n\t\t\treturn\n\t\t}\n\t\tWriteJson(w, model.NewOkResult(taskId))\n\t}\n}\n\nfunc CreateTaskBatch(w http.ResponseWriter, r *http.Request) {\n\tvar req base.CreateTaskBatch\n\tif ReadJson(r, w, &req) {\n\t\tif len(req.Reqs) == 0 {\n\t\t\tWriteJson(w, model.NewErrorResult(\"param invalid: reqs\", model.CodeInvalidParam))\n\t\t\treturn\n\t\t}\n\t\ttaskIds, err := Downloader.CreateDirectBatch(&req)\n\t\tif err != nil {\n\t\t\tWriteJson(w, model.NewErrorResult(err.Error()))\n\t\t\treturn\n\t\t}\n\t\tWriteJson(w, model.NewOkResult(taskIds))\n\t}\n}\n\nfunc PatchTask(w http.ResponseWriter, r *http.Request) {\n\tvars := mux.Vars(r)\n\ttaskId := vars[\"id\"]\n\tif taskId == \"\" {\n\t\tWriteJson(w, model.NewErrorResult(\"param invalid: id\", model.CodeInvalidParam))\n\t\treturn\n\t}\n\n\tvar req model.ResolveTask\n\tif ReadJson(r, w, &req) {\n\t\tif err := Downloader.Patch(taskId, req.Req, req.Opts); err != nil {\n\t\t\tif err == download.ErrTaskNotFound {\n\t\t\t\tWriteJson(w, model.NewErrorResult(\"task not found\", model.CodeTaskNotFound))\n\t\t\t\treturn\n\t\t\t}\n\t\t\tWriteJson(w, model.NewErrorResult(err.Error()))\n\t\t\treturn\n\t\t}\n\t\tWriteJson(w, model.NewNilResult())\n\t}\n}\n\nfunc PauseTask(w http.ResponseWriter, r *http.Request) {\n\tfilter, errResult := parseIdFilter(r)\n\tif errResult != nil {\n\t\tWriteJson(w, errResult)\n\t\treturn\n\t}\n\n\tif err := Downloader.Pause(filter); err != nil {\n\t\tWriteJson(w, model.NewErrorResult(err.Error()))\n\t\treturn\n\t}\n\tWriteJson(w, model.NewNilResult())\n}\n\nfunc PauseTasks(w http.ResponseWriter, r *http.Request) {\n\tfilter, errResult := parseFilter(r)\n\tif errResult != nil {\n\t\tWriteJson(w, errResult)\n\t\treturn\n\t}\n\n\tif err := Downloader.Pause(filter); err != nil {\n\t\tWriteJson(w, model.NewErrorResult(err.Error()))\n\t\treturn\n\t}\n\tWriteJson(w, model.NewNilResult())\n}\n\nfunc ContinueTask(w http.ResponseWriter, r *http.Request) {\n\tfilter, errResult := parseIdFilter(r)\n\tif errResult != nil {\n\t\tWriteJson(w, errResult)\n\t\treturn\n\t}\n\n\tif err := Downloader.Continue(filter); err != nil {\n\t\tWriteJson(w, model.NewErrorResult(err.Error()))\n\t\treturn\n\t}\n\tWriteJson(w, model.NewNilResult())\n}\n\nfunc ContinueTasks(w http.ResponseWriter, r *http.Request) {\n\tfilter, errResult := parseFilter(r)\n\tif errResult != nil {\n\t\tWriteJson(w, errResult)\n\t\treturn\n\t}\n\n\tif err := Downloader.Continue(filter); err != nil {\n\t\tWriteJson(w, model.NewErrorResult(err.Error()))\n\t\treturn\n\t}\n\tWriteJson(w, model.NewNilResult())\n}\n\nfunc DeleteTask(w http.ResponseWriter, r *http.Request) {\n\tfilter, errResult := parseIdFilter(r)\n\tif errResult != nil {\n\t\tWriteJson(w, errResult)\n\t\treturn\n\t}\n\tforce := r.FormValue(\"force\")\n\n\tif err := Downloader.Delete(filter, force == \"true\"); err != nil {\n\t\tWriteJson(w, model.NewErrorResult(err.Error()))\n\t\treturn\n\t}\n\tWriteJson(w, model.NewNilResult())\n}\n\nfunc DeleteTasks(w http.ResponseWriter, r *http.Request) {\n\tfilter, errResult := parseFilter(r)\n\tif errResult != nil {\n\t\tWriteJson(w, errResult)\n\t\treturn\n\t}\n\tforce := r.FormValue(\"force\")\n\n\tif err := Downloader.Delete(filter, force == \"true\"); err != nil {\n\t\tWriteJson(w, model.NewErrorResult(err.Error()))\n\t\treturn\n\t}\n\tWriteJson(w, model.NewNilResult())\n}\n\nfunc GetTask(w http.ResponseWriter, r *http.Request) {\n\tvars := mux.Vars(r)\n\ttaskId := vars[\"id\"]\n\tif taskId == \"\" {\n\t\tWriteJson(w, model.NewErrorResult(\"param invalid: id\", model.CodeInvalidParam))\n\t\treturn\n\t}\n\ttask := Downloader.GetTask(taskId)\n\tif task == nil {\n\t\tWriteJson(w, model.NewErrorResult(\"task not found\", model.CodeTaskNotFound))\n\t\treturn\n\t}\n\tWriteJson(w, model.NewOkResult(task))\n}\n\nfunc GetTasks(w http.ResponseWriter, r *http.Request) {\n\tfilter, errResult := parseFilter(r)\n\tif errResult != nil {\n\t\tWriteJson(w, errResult)\n\t\treturn\n\t}\n\n\ttasks := Downloader.GetTasksByFilter(filter)\n\tWriteJson(w, model.NewOkResult(tasks))\n}\n\nfunc GetConfig(w http.ResponseWriter, r *http.Request) {\n\tWriteJson(w, model.NewOkResult(getServerConfig()))\n}\n\nfunc PutConfig(w http.ResponseWriter, r *http.Request) {\n\tvar cfg base.DownloaderStoreConfig\n\tif ReadJson(r, w, &cfg) {\n\t\tif err := Downloader.PutConfig(&cfg); err != nil {\n\t\t\tWriteJson(w, model.NewErrorResult(err.Error()))\n\t\t\treturn\n\t\t}\n\t}\n\tWriteJson(w, model.NewNilResult())\n}\n\nfunc InstallExtension(w http.ResponseWriter, r *http.Request) {\n\tvar req model.InstallExtension\n\tif ReadJson(r, w, &req) {\n\t\tvar (\n\t\t\tinstalledExt *download.Extension\n\t\t\terr          error\n\t\t)\n\t\tif req.DevMode {\n\t\t\tinstalledExt, err = Downloader.InstallExtensionByFolder(req.URL, true)\n\t\t} else {\n\t\t\tinstalledExt, err = Downloader.InstallExtensionByGit(req.URL)\n\t\t}\n\t\tif err != nil {\n\t\t\tWriteJson(w, model.NewErrorResult(err.Error()))\n\t\t\treturn\n\t\t}\n\t\tWriteJson(w, model.NewOkResult(installedExt.Identity))\n\t}\n}\n\nfunc GetExtensions(w http.ResponseWriter, r *http.Request) {\n\tlist := Downloader.GetExtensions()\n\tWriteJson(w, model.NewOkResult(list))\n}\n\nfunc GetExtension(w http.ResponseWriter, r *http.Request) {\n\tvars := mux.Vars(r)\n\tidentity := vars[\"identity\"]\n\text, err := Downloader.GetExtension(identity)\n\tif err != nil {\n\t\tWriteJson(w, model.NewErrorResult(err.Error()))\n\t\treturn\n\t}\n\tWriteJson(w, model.NewOkResult(ext))\n}\n\nfunc UpdateExtensionSettings(w http.ResponseWriter, r *http.Request) {\n\tvars := mux.Vars(r)\n\tidentity := vars[\"identity\"]\n\tvar req model.UpdateExtensionSettings\n\tif ReadJson(r, w, &req) {\n\t\tif err := Downloader.UpdateExtensionSettings(identity, req.Settings); err != nil {\n\t\t\tWriteJson(w, model.NewErrorResult(err.Error()))\n\t\t\treturn\n\t\t}\n\t}\n\tWriteJson(w, model.NewNilResult())\n}\n\nfunc SwitchExtension(w http.ResponseWriter, r *http.Request) {\n\tvars := mux.Vars(r)\n\tidentity := vars[\"identity\"]\n\tvar switchExtension model.SwitchExtension\n\tif ReadJson(r, w, &switchExtension) {\n\t\tif err := Downloader.SwitchExtension(identity, switchExtension.Status); err != nil {\n\t\t\tWriteJson(w, model.NewErrorResult(err.Error()))\n\t\t\treturn\n\t\t}\n\t}\n\tWriteJson(w, model.NewNilResult())\n}\n\nfunc DeleteExtension(w http.ResponseWriter, r *http.Request) {\n\tvars := mux.Vars(r)\n\tidentity := vars[\"identity\"]\n\tif err := Downloader.DeleteExtension(identity); err != nil {\n\t\tWriteJson(w, model.NewErrorResult(err.Error()))\n\t\treturn\n\t}\n\tWriteJson(w, model.NewNilResult())\n}\n\nfunc UpdateCheckExtension(w http.ResponseWriter, r *http.Request) {\n\tvars := mux.Vars(r)\n\tidentity := vars[\"identity\"]\n\tnewVersion, err := Downloader.UpgradeCheckExtension(identity)\n\tif err != nil {\n\t\tWriteJson(w, model.NewErrorResult(err.Error()))\n\t\treturn\n\t}\n\tWriteJson(w, model.NewOkResult(&model.UpdateCheckExtensionResp{\n\t\tNewVersion: newVersion,\n\t}))\n}\n\nfunc UpdateExtension(w http.ResponseWriter, r *http.Request) {\n\tvars := mux.Vars(r)\n\tidentity := vars[\"identity\"]\n\tif err := Downloader.UpgradeExtension(identity); err != nil {\n\t\tWriteJson(w, model.NewErrorResult(err.Error()))\n\t\treturn\n\t}\n\tWriteJson(w, model.NewNilResult())\n}\n\nfunc DoProxy(w http.ResponseWriter, r *http.Request) {\n\ttarget := r.Header.Get(\"X-Target-Uri\")\n\tif target == \"\" {\n\t\twriteError(w, \"param invalid: X-Target-Uri\")\n\t\treturn\n\t}\n\ttargetUrl, err := url.Parse(target)\n\tif err != nil {\n\t\twriteError(w, err.Error())\n\t\treturn\n\t}\n\tr.RequestURI = \"\"\n\tr.URL = targetUrl\n\tr.Host = targetUrl.Host\n\tr.Header.Del(\"Authorization\")\n\tr.Header.Del(\"X-Target-Uri\")\n\tresp, err := http.DefaultClient.Do(r)\n\tif err != nil {\n\t\twriteError(w, err.Error())\n\t\treturn\n\t}\n\tdefer resp.Body.Close()\n\tfor k, vv := range resp.Header {\n\t\tfor _, v := range vv {\n\t\t\tw.Header().Set(k, v)\n\t\t}\n\t}\n\tw.WriteHeader(resp.StatusCode)\n\tbuf, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\twriteError(w, err.Error())\n\t\treturn\n\t}\n\tw.Write(buf)\n}\n\nfunc GetStats(w http.ResponseWriter, r *http.Request) {\n\tvars := mux.Vars(r)\n\ttaskId := vars[\"id\"]\n\tif taskId == \"\" {\n\t\tWriteJson(w, model.NewErrorResult(\"param invalid: id\", model.CodeInvalidParam))\n\t\treturn\n\t}\n\tstatsResult, err := Downloader.Stats(taskId)\n\tif err != nil {\n\t\twriteError(w, err.Error())\n\t\treturn\n\t}\n\tWriteJson(w, model.NewOkResult(statsResult))\n}\n\nfunc parseIdFilter(r *http.Request) (*download.TaskFilter, any) {\n\tvars := mux.Vars(r)\n\ttaskId := vars[\"id\"]\n\tif taskId == \"\" {\n\t\treturn nil, model.NewErrorResult(\"param invalid: id\", model.CodeInvalidParam)\n\t}\n\n\tfilter := &download.TaskFilter{\n\t\tIDs: []string{taskId},\n\t}\n\treturn filter, nil\n}\n\nfunc parseFilter(r *http.Request) (*download.TaskFilter, any) {\n\tif err := r.ParseForm(); err != nil {\n\t\treturn nil, model.NewErrorResult(err.Error())\n\t}\n\n\tfilter := &download.TaskFilter{\n\t\tIDs:         r.Form[\"id\"],\n\t\tStatuses:    convertStatues(r.Form[\"status\"]),\n\t\tNotStatuses: convertStatues(r.Form[\"notStatus\"]),\n\t}\n\treturn filter, nil\n}\n\nfunc convertStatues(statues []string) []base.Status {\n\tresult := make([]base.Status, 0)\n\tfor _, status := range statues {\n\t\tresult = append(result, base.Status(status))\n\t}\n\treturn result\n}\n\nfunc writeError(w http.ResponseWriter, msg string) {\n\tw.WriteHeader(http.StatusInternalServerError)\n\tw.Write([]byte(msg))\n}\n\nfunc getServerConfig() *base.DownloaderStoreConfig {\n\tcfg, _ := Downloader.GetConfig()\n\treturn cfg\n}\n\nfunc TestWebhook(w http.ResponseWriter, r *http.Request) {\n\tvar req model.TestWebhookReq\n\tif ReadJson(r, w, &req) {\n\t\tif err := Downloader.TestWebhookUrl(req.URL); err != nil {\n\t\t\tWriteJson(w, model.NewErrorResult(err.Error()))\n\t\t\treturn\n\t\t}\n\t\tWriteJson(w, model.NewNilResult())\n\t}\n}\n"
  },
  {
    "path": "pkg/rest/config.go",
    "content": "package rest\n\ntype Config struct {\n\tHost string `json:\"host\"`\n\tPort int    `json:\"port\"`\n}\n"
  },
  {
    "path": "pkg/rest/gizp_middleware.go",
    "content": "package rest\n\nimport (\n\t\"compress/gzip\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n)\n\ntype gzipResponseWriter struct {\n\tio.Writer\n\thttp.ResponseWriter\n}\n\nfunc (g gzipResponseWriter) Write(b []byte) (int, error) {\n\treturn g.Writer.Write(b)\n}\n\nfunc gzipMiddleware(next http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif !strings.Contains(r.Header.Get(\"Accept-Encoding\"), \"gzip\") {\n\t\t\tnext.ServeHTTP(w, r)\n\t\t\treturn\n\t\t}\n\n\t\tw.Header().Set(\"Content-Encoding\", \"gzip\")\n\t\tw.Header().Add(\"Vary\", \"Accept-Encoding\")\n\n\t\tgz := gzip.NewWriter(w)\n\t\tdefer gz.Close()\n\n\t\tnext.ServeHTTP(gzipResponseWriter{Writer: gz, ResponseWriter: w}, r)\n\t})\n}\n"
  },
  {
    "path": "pkg/rest/model/extension.go",
    "content": "package model\n\ntype InstallExtension struct {\n\tDevMode bool   `json:\"devMode\"`\n\tURL     string `json:\"url\"`\n}\n\ntype UpdateExtensionSettings struct {\n\tSettings map[string]any `json:\"settings\"`\n}\n\ntype SwitchExtension struct {\n\tStatus bool `json:\"status\"`\n}\n\ntype UpdateCheckExtensionResp struct {\n\tNewVersion string `json:\"newVersion\"`\n}\n"
  },
  {
    "path": "pkg/rest/model/result.go",
    "content": "package model\n\ntype RespCode int\n\nconst (\n\tCodeOk RespCode = 0\n\t// CodeError is the common error code\n\tCodeError RespCode = 1000\n\t// CodeUnauthorized is the error code for unauthorized\n\tCodeUnauthorized RespCode = 1001\n\t// CodeInvalidParam is the error code for invalid parameter\n\tCodeInvalidParam RespCode = 1002\n\t// CodeTaskNotFound is the error code for task not found\n\tCodeTaskNotFound RespCode = 2001\n)\n\ntype Result[T any] struct {\n\tCode RespCode `json:\"code\"`\n\tMsg  string   `json:\"msg\"`\n\tData T        `json:\"data\"`\n}\n\nfunc NewOkResult[T any](data T) *Result[T] {\n\treturn &Result[T]{\n\t\tCode: CodeOk,\n\t\tData: data,\n\t}\n}\n\nfunc NewNilResult() *Result[any] {\n\treturn &Result[any]{\n\t\tCode: CodeOk,\n\t}\n}\n\nfunc NewErrorResult(msg string, code ...RespCode) *Result[any] {\n\t// if code is not provided, the default code is CodeError\n\tc := CodeError\n\tif len(code) > 0 {\n\t\tc = code[0]\n\t}\n\n\treturn &Result[any]{\n\t\tCode: c,\n\t\tMsg:  msg,\n\t}\n}\n"
  },
  {
    "path": "pkg/rest/model/server.go",
    "content": "package model\n\nimport (\n\t\"github.com/GopeedLab/gopeed/pkg/base\"\n\t\"io/fs\"\n)\n\ntype Storage string\n\nconst (\n\tStorageMem  Storage = \"mem\"\n\tStorageBolt Storage = \"bolt\"\n)\n\ntype StartConfig struct {\n\tNetwork           string                      `json:\"network\"`\n\tAddress           string                      `json:\"address\"`\n\tRefreshInterval   int                         `json:\"refreshInterval\"`\n\tStorage           Storage                     `json:\"storage\"`\n\tStorageDir        string                      `json:\"storageDir\"`\n\tWhiteDownloadDirs []string                    `json:\"whiteDownloadDirs\"`\n\tApiToken          string                      `json:\"apiToken\"`\n\tDownloadConfig    *base.DownloaderStoreConfig `json:\"downloadConfig\"`\n\n\tProductionMode bool\n\n\tWebEnable bool\n\tWebFS     fs.FS\n\tWebAuth   *WebAuth\n}\n\nfunc (cfg *StartConfig) Init() *StartConfig {\n\tif cfg.Network == \"\" {\n\t\tcfg.Network = \"tcp\"\n\t}\n\tif cfg.Address == \"\" {\n\t\tcfg.Address = \"127.0.0.1:0\"\n\t}\n\tif cfg.RefreshInterval == 0 {\n\t\tcfg.RefreshInterval = 350\n\t}\n\tif cfg.Storage == \"\" {\n\t\tcfg.Storage = StorageBolt\n\t}\n\tif cfg.StorageDir == \"\" {\n\t\tcfg.StorageDir = \"./\"\n\t}\n\treturn cfg\n}\n\ntype WebAuth struct {\n\tUsername string\n\tPassword string\n}\n"
  },
  {
    "path": "pkg/rest/model/task.go",
    "content": "package model\n\nimport \"github.com/GopeedLab/gopeed/pkg/base\"\n\ntype ResolveTask struct {\n\tReq  *base.Request `json:\"req\"`\n\tOpts *base.Options `json:\"opts\"`\n}\n\ntype CreateTask struct {\n\tRid string `json:\"rid\"`\n\n\tReq  *base.Request `json:\"req\"`\n\tOpts *base.Options `json:\"opts\"`\n}\n"
  },
  {
    "path": "pkg/rest/model/webhook.go",
    "content": "package model\n\n// TestWebhookReq is the request body for testing a single webhook URL\ntype TestWebhookReq struct {\n\tURL string `json:\"url\"`\n}\n"
  },
  {
    "path": "pkg/rest/server.go",
    "content": "package rest\n\nimport (\n\t\"context\"\n\t\"crypto/aes\"\n\t\"crypto/cipher\"\n\t\"crypto/rand\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/fs\"\n\t\"net\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/GopeedLab/gopeed/pkg/download\"\n\t\"github.com/GopeedLab/gopeed/pkg/rest/model\"\n\t\"github.com/GopeedLab/gopeed/pkg/util\"\n\t\"github.com/gorilla/handlers\"\n\t\"github.com/gorilla/mux\"\n\t\"github.com/pkg/errors\"\n)\n\nvar (\n\tsrv         *http.Server\n\trunningPort int\n\taesKey      []byte\n\n\tDownloader *download.Downloader\n)\n\nfunc Start(startCfg *model.StartConfig) (port int, err error) {\n\t// avoid repeat start\n\tif srv != nil {\n\t\treturn runningPort, nil\n\t}\n\n\tvar listener net.Listener\n\tsrv, listener, err = BuildServer(startCfg)\n\tif err != nil {\n\t\treturn\n\t}\n\n\tgo func() {\n\t\tif err := srv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {\n\t\t\tpanic(err)\n\t\t}\n\t}()\n\n\tif addr, ok := listener.Addr().(*net.TCPAddr); ok {\n\t\tport = addr.Port\n\t\trunningPort = port\n\t}\n\treturn\n}\n\nfunc Stop() {\n\tdefer func() {\n\t\tsrv = nil\n\t}()\n\n\tif srv != nil {\n\t\tshutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)\n\t\tdefer cancel()\n\t\tif err := srv.Shutdown(shutdownCtx); err != nil {\n\t\t\tDownloader.Logger.Warn().Err(err).Msg(\"shutdown server failed\")\n\t\t}\n\t}\n\tif Downloader != nil {\n\t\tif err := Downloader.Close(); err != nil {\n\t\t\tDownloader.Logger.Warn().Err(err).Msg(\"close downloader failed\")\n\t\t}\n\t}\n}\n\nfunc BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error) {\n\tif startCfg == nil {\n\t\tstartCfg = &model.StartConfig{}\n\t}\n\tstartCfg.Init()\n\n\tdownloadCfg := &download.DownloaderConfig{\n\t\tProductionMode:    startCfg.ProductionMode,\n\t\tRefreshInterval:   startCfg.RefreshInterval,\n\t\tWhiteDownloadDirs: startCfg.WhiteDownloadDirs,\n\t}\n\tif startCfg.Storage == model.StorageBolt {\n\t\tdownloadCfg.Storage = download.NewBoltStorage(startCfg.StorageDir)\n\t} else {\n\t\tdownloadCfg.Storage = download.NewMemStorage()\n\t}\n\tdownloadCfg.StorageDir = startCfg.StorageDir\n\tdownloadCfg.Init()\n\tDownloader = download.NewDownloader(downloadCfg)\n\tif err := Downloader.Setup(); err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\tif startCfg.Network == \"unix\" {\n\t\tutil.SafeRemove(startCfg.Address)\n\t}\n\n\tif startCfg.WebEnable {\n\t\taesKey = make([]byte, 32)\n\t\tif _, err := rand.Read(aesKey); err != nil {\n\t\t\treturn nil, nil, errors.Wrap(err, \"generate aes key failed\")\n\t\t}\n\t}\n\n\tlistener, err := net.Listen(startCfg.Network, startCfg.Address)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\tvar r = mux.NewRouter()\n\tr.Methods(http.MethodGet).Path(\"/api/v1/info\").HandlerFunc(Info)\n\tr.Methods(http.MethodPost).Path(\"/api/v1/resolve\").HandlerFunc(Resolve)\n\tr.Methods(http.MethodPost).Path(\"/api/v1/tasks\").HandlerFunc(CreateTask)\n\tr.Methods(http.MethodPost).Path(\"/api/v1/tasks/batch\").HandlerFunc(CreateTaskBatch)\n\tr.Methods(http.MethodPatch).Path(\"/api/v1/tasks/{id}\").HandlerFunc(PatchTask)\n\tr.Methods(http.MethodPut).Path(\"/api/v1/tasks/{id}/pause\").HandlerFunc(PauseTask)\n\tr.Methods(http.MethodPut).Path(\"/api/v1/tasks/pause\").HandlerFunc(PauseTasks)\n\tr.Methods(http.MethodPut).Path(\"/api/v1/tasks/{id}/continue\").HandlerFunc(ContinueTask)\n\tr.Methods(http.MethodPut).Path(\"/api/v1/tasks/continue\").HandlerFunc(ContinueTasks)\n\tr.Methods(http.MethodDelete).Path(\"/api/v1/tasks/{id}\").HandlerFunc(DeleteTask)\n\tr.Methods(http.MethodDelete).Path(\"/api/v1/tasks\").HandlerFunc(DeleteTasks)\n\tr.Methods(http.MethodGet).Path(\"/api/v1/tasks/{id}\").HandlerFunc(GetTask)\n\tr.Methods(http.MethodGet).Path(\"/api/v1/tasks\").HandlerFunc(GetTasks)\n\tr.Methods(http.MethodGet).Path(\"/api/v1/tasks/{id}/stats\").HandlerFunc(GetStats)\n\tr.Methods(http.MethodGet).Path(\"/api/v1/config\").HandlerFunc(GetConfig)\n\tr.Methods(http.MethodPut).Path(\"/api/v1/config\").HandlerFunc(PutConfig)\n\tr.Methods(http.MethodPost).Path(\"/api/v1/extensions\").HandlerFunc(InstallExtension)\n\tr.Methods(http.MethodGet).Path(\"/api/v1/extensions\").HandlerFunc(GetExtensions)\n\tr.Methods(http.MethodGet).Path(\"/api/v1/extensions/{identity}\").HandlerFunc(GetExtension)\n\tr.Methods(http.MethodPut).Path(\"/api/v1/extensions/{identity}/settings\").HandlerFunc(UpdateExtensionSettings)\n\tr.Methods(http.MethodPut).Path(\"/api/v1/extensions/{identity}/switch\").HandlerFunc(SwitchExtension)\n\tr.Methods(http.MethodDelete).Path(\"/api/v1/extensions/{identity}\").HandlerFunc(DeleteExtension)\n\tr.Methods(http.MethodGet).Path(\"/api/v1/extensions/{identity}/update\").HandlerFunc(UpdateCheckExtension)\n\tr.Methods(http.MethodPost).Path(\"/api/v1/extensions/{identity}/update\").HandlerFunc(UpdateExtension)\n\tr.Methods(http.MethodPost).Path(\"/api/v1/webhook/test\").HandlerFunc(TestWebhook)\n\tr.Path(\"/api/v1/proxy\").HandlerFunc(DoProxy)\n\n\tenableApiToken := startCfg.ApiToken != \"\"\n\tenableWebAuth := startCfg.WebEnable && startCfg.WebAuth != nil\n\tif startCfg.WebEnable {\n\t\tif enableWebAuth {\n\t\t\tr.Methods(http.MethodPost).Path(\"/api/web/login\").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tvar loginReq model.WebAuth\n\t\t\t\tif ReadJson(r, w, &loginReq) {\n\t\t\t\t\tif loginReq.Username == startCfg.WebAuth.Username && loginReq.Password == startCfg.WebAuth.Password {\n\t\t\t\t\t\t// Generate a login token, Username:Password:Timestamp\n\t\t\t\t\t\ttimestamp := time.Now().Unix()\n\t\t\t\t\t\ttokenData := fmt.Sprintf(\"%s:%s:%d\", loginReq.Username, loginReq.Password, timestamp)\n\t\t\t\t\t\ttoken, err := aesEncrypt(aesKey, []byte(tokenData))\n\t\t\t\t\t\tif err != nil {\n\t\t\t\t\t\t\tWriteJson(w, model.NewErrorResult(err.Error()))\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tWriteJson(w, model.NewOkResult(token))\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tWriteStatusJson(w, http.StatusUnauthorized, model.NewErrorResult(\"unauthorized\", model.CodeUnauthorized))\n\t\t\t})\n\t\t}\n\t\tr.PathPrefix(\"/fs/tasks\").Handler(http.FileServer(new(taskFileSystem)))\n\t\tr.PathPrefix(\"/fs/extensions\").Handler(http.FileServer(new(extensionFileSystem)))\n\t\tr.PathPrefix(\"/\").Handler(gzipMiddleware(http.FileServer(newEmbedCacheFileSystem(http.FS(startCfg.WebFS)))))\n\t}\n\tif enableApiToken || enableWebAuth {\n\t\twriteUnauthorized := func(w http.ResponseWriter, r *http.Request) {\n\t\t\tWriteStatusJson(w, http.StatusUnauthorized, model.NewErrorResult(\"unauthorized\", model.CodeUnauthorized))\n\t\t}\n\n\t\tr.Use(func(h http.Handler) http.Handler {\n\t\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif enableApiToken {\n\t\t\t\t\tapiTokenHeader := r.Header[\"X-Api-Token\"]\n\t\t\t\t\t// If api token header is set, only check api token ignore basic auth\n\t\t\t\t\tif len(apiTokenHeader) > 0 {\n\t\t\t\t\t\tif apiTokenHeader[0] == startCfg.ApiToken {\n\t\t\t\t\t\t\th.ServeHTTP(w, r)\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\twriteUnauthorized(w, r)\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif enableWebAuth {\n\t\t\t\t\tif !strings.HasPrefix(r.URL.Path, \"/api/\") || r.URL.Path == \"/api/web/login\" {\n\t\t\t\t\t\th.ServeHTTP(w, r)\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\n\t\t\t\t\ttoken := r.Header.Get(\"Authorization\")\n\t\t\t\t\tif token == \"\" {\n\t\t\t\t\t\twriteUnauthorized(w, r)\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\n\t\t\t\t\ttoken = strings.TrimPrefix(token, \"Bearer \")\n\t\t\t\t\ttokenData, err := aesDecrypt(aesKey, token)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\twriteUnauthorized(w, r)\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tparts := strings.SplitN(string(tokenData), \":\", 3)\n\t\t\t\t\tusername := parts[0]\n\t\t\t\t\tpassword := parts[1]\n\t\t\t\t\ttimestamp, _ := strconv.Atoi(parts[2])\n\n\t\t\t\t\tif username != startCfg.WebAuth.Username || password != startCfg.WebAuth.Password {\n\t\t\t\t\t\twriteUnauthorized(w, r)\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\n\t\t\t\t\t// Check if the token is expired (7 days)\n\t\t\t\t\tif time.Now().Unix()-int64(timestamp) > 7*24*3600 {\n\t\t\t\t\t\twriteUnauthorized(w, r)\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\n\t\t\t\t\th.ServeHTTP(w, r)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\twriteUnauthorized(w, r)\n\t\t\t})\n\t\t})\n\t}\n\n\t// recover panic\n\tr.Use(func(h http.Handler) http.Handler {\n\t\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\tdefer func() {\n\t\t\t\tif v := recover(); v != nil {\n\t\t\t\t\terr := errors.WithStack(fmt.Errorf(\"%v\", v))\n\t\t\t\t\tDownloader.Logger.Error().Stack().Err(err).Msgf(\"http server panic: %s %s\", r.Method, r.RequestURI)\n\t\t\t\t\tWriteJson(w, model.NewErrorResult(err.Error(), model.CodeError))\n\t\t\t\t}\n\t\t\t}()\n\t\t\th.ServeHTTP(w, r)\n\t\t})\n\t})\n\n\tsrv = &http.Server{Handler: handlers.CORS(\n\t\thandlers.AllowedHeaders([]string{\"Content-Type\", \"Authorization\", \"X-Api-Token\", \"X-Target-Uri\"}),\n\t\thandlers.AllowedMethods([]string{\"GET\", \"POST\", \"PUT\", \"PATCH\", \"DELETE\", \"OPTIONS\"}),\n\t\thandlers.AllowedOrigins([]string{\"*\"}),\n\t\thandlers.AllowCredentials(),\n\t)(r)}\n\treturn srv, listener, nil\n}\n\nfunc resolvePath(urlPath string, prefix string) (identity string, path string, err error) {\n\t// remove prefix\n\tclearPath := strings.TrimPrefix(urlPath, prefix)\n\t// match extension identity, eg: /fs/extensions/identity/xxx\n\treg := regexp.MustCompile(`^/([^/]+)/(.*)$`)\n\tif !reg.MatchString(clearPath) {\n\t\terr = os.ErrNotExist\n\t\treturn\n\t}\n\tmatched := reg.FindStringSubmatch(clearPath)\n\tif len(matched) != 3 {\n\t\terr = os.ErrNotExist\n\t\treturn\n\t}\n\treturn matched[1], matched[2], nil\n}\n\n// handle task file resource\ntype taskFileSystem struct {\n}\n\nfunc (e *taskFileSystem) Open(name string) (http.File, error) {\n\t// get extension identity\n\tidentity, path, err := resolvePath(name, \"/fs/tasks\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\ttask := Downloader.GetTask(identity)\n\tif task == nil {\n\t\treturn nil, os.ErrNotExist\n\t}\n\treturn os.Open(filepath.Join(task.Meta.RootDirPath(), path))\n}\n\n// handle extension file resource\ntype extensionFileSystem struct {\n}\n\nfunc (e *extensionFileSystem) Open(name string) (http.File, error) {\n\t// get extension identity\n\tidentity, path, err := resolvePath(name, \"/fs/extensions\")\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\textension, err := Downloader.GetExtension(identity)\n\tif err != nil {\n\t\treturn nil, os.ErrNotExist\n\t}\n\textensionPath := Downloader.ExtensionPath(extension)\n\treturn os.Open(filepath.Join(extensionPath, path))\n}\n\ntype embedCacheFileSystem struct {\n\tfs          http.FileSystem\n\tlastModTime time.Time\n}\n\nfunc newEmbedCacheFileSystem(fs http.FileSystem) *embedCacheFileSystem {\n\tefs := &embedCacheFileSystem{\n\t\tfs:          fs,\n\t\tlastModTime: time.Now(),\n\t}\n\n\texe, err := os.Executable()\n\tif err != nil {\n\t\treturn efs\n\t}\n\n\tfi, err := os.Stat(exe)\n\tif err != nil {\n\t\treturn efs\n\t}\n\n\tefs.lastModTime = fi.ModTime()\n\treturn efs\n}\n\nfunc (e *embedCacheFileSystem) Open(name string) (http.File, error) {\n\tfile, err := e.fs.Open(name)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &embedFile{\n\t\tFile:        file,\n\t\tlastModTime: e.lastModTime,\n\t}, nil\n}\n\ntype embedFile struct {\n\thttp.File\n\tlastModTime time.Time\n}\n\ntype embedFileInfo struct {\n\tfs.FileInfo\n\tlastModTime time.Time\n}\n\nfunc (e *embedFileInfo) ModTime() time.Time {\n\treturn e.lastModTime\n}\n\nfunc (e *embedFile) Stat() (fs.FileInfo, error) {\n\tfi, err := e.File.Stat()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &embedFileInfo{\n\t\tFileInfo:    fi,\n\t\tlastModTime: e.lastModTime,\n\t}, nil\n}\n\nfunc ReadJson(r *http.Request, w http.ResponseWriter, v any) bool {\n\tif err := json.NewDecoder(r.Body).Decode(v); err != nil {\n\t\tWriteJson(w, model.NewErrorResult(err.Error()))\n\t\treturn false\n\t}\n\treturn true\n}\n\nfunc WriteJson(w http.ResponseWriter, v any) {\n\tWriteStatusJson(w, http.StatusOK, v)\n}\n\nfunc WriteStatusJson(w http.ResponseWriter, statusCode int, v any) {\n\tw.Header().Set(\"Content-Type\", \"application/json; charset=utf-8\")\n\tw.WriteHeader(statusCode)\n\tjson.NewEncoder(w).Encode(v)\n}\n\nfunc aesEncrypt(key, data []byte) (string, error) {\n\tblock, err := aes.NewCipher(key)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tgcm, err := cipher.NewGCM(block)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tnonce := make([]byte, gcm.NonceSize())\n\tif _, err := io.ReadFull(rand.Reader, nonce); err != nil {\n\t\treturn \"\", err\n\t}\n\n\tcipherText := gcm.Seal(nonce, nonce, data, nil)\n\treturn base64.StdEncoding.EncodeToString(cipherText), nil\n}\n\nfunc aesDecrypt(key []byte, encryptedData string) ([]byte, error) {\n\tcipherText, err := base64.StdEncoding.DecodeString(encryptedData)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tblock, err := aes.NewCipher(key)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tgcm, err := cipher.NewGCM(block)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif len(cipherText) < gcm.NonceSize() {\n\t\treturn nil, errors.New(\"ciphertext too short\")\n\t}\n\n\tnonce, cipherText := cipherText[:gcm.NonceSize()], cipherText[gcm.NonceSize():]\n\treturn gcm.Open(nil, nonce, cipherText, nil)\n}\n"
  },
  {
    "path": "pkg/rest/server_test.go",
    "content": "package rest\n\nimport (\n\t\"bytes\"\n\t\"crypto/md5\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"strings\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/GopeedLab/gopeed/internal/test\"\n\t\"github.com/GopeedLab/gopeed/pkg/base\"\n\t\"github.com/GopeedLab/gopeed/pkg/download\"\n\t\"github.com/GopeedLab/gopeed/pkg/rest/model\"\n)\n\nvar (\n\trestPort int\n\n\ttaskReq = &base.Request{\n\t\tExtra: map[string]any{\n\t\t\t\"method\": \"\",\n\t\t\t\"header\": map[string]string{\n\t\t\t\t\"Usr-Agent\": \"gopeed\",\n\t\t\t},\n\t\t\t\"body\": \"\",\n\t\t},\n\t}\n\ttaskRes = &base.Resource{\n\t\tSize:  test.BuildSize,\n\t\tRange: true,\n\t\tFiles: []*base.FileInfo{\n\t\t\t{\n\t\t\t\tName: test.BuildName,\n\t\t\t\tPath: \"\",\n\t\t\t\tSize: test.BuildSize,\n\t\t\t},\n\t\t},\n\t}\n\tcreateOpts = &base.Options{\n\t\tPath: test.Dir,\n\t\tName: test.DownloadName,\n\t\tExtra: map[string]any{\n\t\t\t\"connections\": 2,\n\t\t},\n\t}\n\tresolveReq = &model.ResolveTask{\n\t\tReq:  taskReq,\n\t\tOpts: createOpts,\n\t}\n\tcreateReq = &model.CreateTask{\n\t\tReq:  taskReq,\n\t\tOpts: createOpts,\n\t}\n\tinstallExtensionReq = &model.InstallExtension{\n\t\tURL: \"https://github.com/GopeedLab/gopeed-extension-samples#github-contributor-avatars-sample\",\n\t}\n)\n\nfunc TestInfo(t *testing.T) {\n\tmatchKeys := []string{\"version\", \"runtime\", \"os\", \"arch\", \"inDocker\"}\n\tdoTest(func() {\n\t\tresp := httpRequestCheckOk[map[string]any](http.MethodGet, \"/api/v1/info\", nil)\n\t\tfor _, key := range matchKeys {\n\t\t\tif _, ok := resp[key]; !ok {\n\t\t\t\tt.Errorf(\"Info() missing key = %v\", key)\n\t\t\t}\n\t\t}\n\t})\n}\n\nfunc TestResolve(t *testing.T) {\n\tdoTest(func() {\n\t\tresp := httpRequestCheckOk[*download.ResolveResult](http.MethodPost, \"/api/v1/resolve\", resolveReq)\n\t\tif !test.AssertResourceEqual(taskRes, resp.Res) {\n\t\t\tt.Errorf(\"Resolve() got = %v, want %v\", test.ToJson(resp.Res), test.ToJson(taskRes))\n\t\t}\n\t})\n}\n\nfunc TestCreateTask(t *testing.T) {\n\tdoTest(func() {\n\t\tresp := httpRequestCheckOk[*download.ResolveResult](http.MethodPost, \"/api/v1/resolve\", resolveReq)\n\n\t\tvar wg sync.WaitGroup\n\t\twg.Add(1)\n\t\tDownloader.Listener(func(event *download.Event) {\n\t\t\tif event.Key == download.EventKeyFinally {\n\t\t\t\twg.Done()\n\t\t\t}\n\t\t})\n\n\t\ttaskId := httpRequestCheckOk[string](http.MethodPost, \"/api/v1/tasks\", &model.CreateTask{\n\t\t\tRid: resp.ID,\n\t\t})\n\t\tif taskId == \"\" {\n\t\t\tt.Fatal(\"create task failed\")\n\t\t}\n\n\t\twg.Wait()\n\t\twant := test.FileMd5(test.BuildFile)\n\t\tgot := test.FileMd5(test.DownloadFile)\n\t\tif want != got {\n\t\t\tt.Errorf(\"CreateTask() got = %v, want %v\", got, want)\n\t\t}\n\t})\n}\n\nfunc TestCreateDirectTask(t *testing.T) {\n\tdoTest(func() {\n\t\tvar wg sync.WaitGroup\n\t\twg.Add(1)\n\t\tDownloader.Listener(func(event *download.Event) {\n\t\t\tif event.Key == download.EventKeyFinally {\n\t\t\t\twg.Done()\n\t\t\t}\n\t\t})\n\n\t\ttaskId := httpRequestCheckOk[string](http.MethodPost, \"/api/v1/tasks\", createReq)\n\t\tif taskId == \"\" {\n\t\t\tt.Fatal(\"create task failed\")\n\t\t}\n\n\t\twg.Wait()\n\t\twant := test.FileMd5(test.BuildFile)\n\t\tgot := test.FileMd5(test.DownloadFile)\n\t\tif want != got {\n\t\t\tt.Errorf(\"CreateDirectTask() got = %v, want %v\", got, want)\n\t\t}\n\t})\n}\n\nfunc TestCreateDirectTaskBatch(t *testing.T) {\n\tdoTest(func() {\n\t\treqs := make([]*base.CreateTaskBatchItem, 0)\n\t\tfor i := 0; i < 5; i++ {\n\t\t\treqs = append(reqs, &base.CreateTaskBatchItem{\n\t\t\t\tReq: createReq.Req,\n\t\t\t})\n\t\t}\n\t\ttaskIds := httpRequestCheckOk[[]string](http.MethodPost, \"/api/v1/tasks/batch\", &base.CreateTaskBatch{\n\t\t\tReqs: reqs,\n\t\t})\n\t\tif len(taskIds) != len(reqs) {\n\t\t\tt.Errorf(\"CreateDirectTaskBatch() got = %v, want %v\", len(taskIds), len(reqs))\n\t\t}\n\t})\n}\n\nfunc TestCreateDirectTaskBatchWithOpt(t *testing.T) {\n\tdoTest(func() {\n\t\treqs := make([]*base.CreateTaskBatchItem, 0)\n\t\tfor i := 0; i < 5; i++ {\n\t\t\titem := &base.CreateTaskBatchItem{\n\t\t\t\tReq: createReq.Req,\n\t\t\t}\n\t\t\tif i == 0 {\n\t\t\t\titem.Opts = &base.Options{\n\t\t\t\t\tName: \"spe_opt.data\",\n\t\t\t\t}\n\t\t\t}\n\t\t\treqs = append(reqs, item)\n\t\t}\n\t\ttaskIds := httpRequestCheckOk[[]string](http.MethodPost, \"/api/v1/tasks/batch\", &base.CreateTaskBatch{\n\t\t\tReqs: reqs,\n\t\t\tOpts: &base.Options{\n\t\t\t\tName: \"default_opt.data\",\n\t\t\t},\n\t\t})\n\t\tif len(taskIds) != len(reqs) {\n\t\t\tt.Errorf(\"CreateDirectTaskBatch() got = %v, want %v\", len(taskIds), len(reqs))\n\t\t}\n\n\t\tfor i, taskId := range taskIds {\n\t\t\ttask := httpRequestCheckOk[*download.Task](http.MethodGet, \"/api/v1/tasks/\"+taskId, nil)\n\t\t\tif i == 0 {\n\t\t\t\tif !strings.Contains(task.Name(), \"spe_opt\") {\n\t\t\t\t\tt.Errorf(\"CreateDirectTaskBatch() got = %v, want %v\", task.Name(), \"spe_opt.data\")\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif !strings.Contains(task.Name(), \"default_opt\") {\n\t\t\t\t\tt.Errorf(\"CreateDirectTaskBatch() got = %v, want %v\", task.Name(), \"default_opt.data\")\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t})\n}\n\nfunc TestPauseAndContinueTask(t *testing.T) {\n\tdoTest(func() {\n\t\tvar wg sync.WaitGroup\n\t\twg.Add(1)\n\t\tDownloader.Listener(func(event *download.Event) {\n\t\t\tswitch event.Key {\n\t\t\tcase download.EventKeyFinally:\n\t\t\t\twg.Done()\n\t\t\t}\n\t\t})\n\n\t\ttaskId := httpRequestCheckOk[string](http.MethodPost, \"/api/v1/tasks\", createReq)\n\t\tt1 := httpRequestCheckOk[*download.Task](http.MethodGet, \"/api/v1/tasks/\"+taskId, nil)\n\t\tif t1.Status != base.DownloadStatusRunning {\n\t\t\tt.Errorf(\"CreateTask() got = %v, want %v\", t1.Status, base.DownloadStatusRunning)\n\t\t}\n\t\thttpRequestCheckOk[any](http.MethodPut, \"/api/v1/tasks/\"+taskId+\"/pause\", nil)\n\t\tt2 := httpRequestCheckOk[*download.Task](http.MethodGet, \"/api/v1/tasks/\"+taskId, nil)\n\t\tif t2.Status != base.DownloadStatusPause {\n\t\t\tt.Errorf(\"PauseTask() got = %v, want %v\", t2.Status, base.DownloadStatusPause)\n\t\t}\n\t\ttime.Sleep(time.Millisecond * 100)\n\t\thttpRequestCheckOk[any](http.MethodPut, \"/api/v1/tasks/\"+taskId+\"/continue\", nil)\n\t\tt3 := httpRequestCheckOk[*download.Task](http.MethodGet, \"/api/v1/tasks/\"+taskId, nil)\n\t\tif t3.Status != base.DownloadStatusRunning {\n\t\t\tt.Errorf(\"ContinueTask() got = %v, want %v\", t3.Status, base.DownloadStatusRunning)\n\t\t}\n\n\t\twg.Wait()\n\t\twant := test.FileMd5(test.BuildFile)\n\t\tgot := test.FileMd5(test.DownloadFile)\n\t\tif !reflect.DeepEqual(got, want) {\n\t\t\tt.Errorf(\"PauseAndContinueTask() got = %v, want %v\", got, want)\n\t\t}\n\t})\n}\n\nfunc TestPatchTask(t *testing.T) {\n\tdoTest(func() {\n\t\t// Create a task\n\t\ttaskId := httpRequestCheckOk[string](http.MethodPost, \"/api/v1/tasks\", createReq)\n\t\tif taskId == \"\" {\n\t\t\tt.Fatal(\"create task failed\")\n\t\t}\n\n\t\t// Pause the task\n\t\thttpRequestCheckOk[any](http.MethodPut, \"/api/v1/tasks/\"+taskId+\"/pause\", nil)\n\t\ttime.Sleep(time.Millisecond * 100)\n\n\t\t// Patch the task with new labels\n\t\tpatchReq := &model.ResolveTask{\n\t\t\tReq: &base.Request{\n\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\"patched\": \"true\",\n\t\t\t\t\t\"version\": \"2\",\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\thttpRequestCheckOk[any](http.MethodPatch, \"/api/v1/tasks/\"+taskId, patchReq)\n\n\t\t// Verify the patch was applied\n\t\ttask := httpRequestCheckOk[*download.Task](http.MethodGet, \"/api/v1/tasks/\"+taskId, nil)\n\t\tif task.Meta.Req.Labels[\"patched\"] != \"true\" {\n\t\t\tt.Errorf(\"PatchTask() label 'patched' = %v, want %v\", task.Meta.Req.Labels[\"patched\"], \"true\")\n\t\t}\n\t\tif task.Meta.Req.Labels[\"version\"] != \"2\" {\n\t\t\tt.Errorf(\"PatchTask() label 'version' = %v, want %v\", task.Meta.Req.Labels[\"version\"], \"2\")\n\t\t}\n\n\t\t// Clean up\n\t\thttpRequestCheckOk[any](http.MethodDelete, \"/api/v1/tasks/\"+taskId+\"?force=true\", nil)\n\t})\n}\n\nfunc TestPatchTaskNotFound(t *testing.T) {\n\tdoTest(func() {\n\t\t// Try to patch a non-existent task\n\t\tpatchReq := &model.ResolveTask{\n\t\t\tReq: &base.Request{\n\t\t\t\tLabels: map[string]string{\n\t\t\t\t\t\"test\": \"value\",\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\tcode, _ := httpRequest[any](http.MethodPatch, \"/api/v1/tasks/non-existent-id\", patchReq)\n\t\tif code != int(model.CodeTaskNotFound) {\n\t\t\tt.Errorf(\"PatchTaskNotFound() result code = %v, want %v\", code, model.CodeTaskNotFound)\n\t\t}\n\t})\n}\n\nfunc TestPauseAllAndContinueALLTasks(t *testing.T) {\n\tdoTest(func() {\n\t\tcfg, err := Downloader.GetConfig()\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\n\t\tcreateAndPause := func() {\n\t\t\ttaskId := httpRequestCheckOk[string](http.MethodPost, \"/api/v1/tasks\", createReq)\n\t\t\ttime.Sleep(time.Millisecond * 5)\n\t\t\thttpRequestCheckOk[*download.Task](http.MethodPut, \"/api/v1/tasks/\"+taskId+\"/pause\", nil)\n\t\t}\n\n\t\ttotal := cfg.MaxRunning + 2\n\t\tfor i := 0; i < total; i++ {\n\t\t\tcreateAndPause()\n\t\t}\n\t\ttime.Sleep(time.Millisecond * 50)\n\n\t\t// continue all\n\t\thttpRequestCheckOk[any](http.MethodPut, \"/api/v1/tasks/continue\", nil)\n\t\ttime.Sleep(time.Millisecond * 100)\n\t\ttasks := httpRequestCheckOk[[]*download.Task](http.MethodGet, fmt.Sprintf(\"/api/v1/tasks?status=%s\", base.DownloadStatusRunning), nil)\n\t\tif len(tasks) != cfg.MaxRunning {\n\t\t\tt.Errorf(\"ContinueAllTasks() got = %v, want %v\", len(tasks), cfg.MaxRunning)\n\t\t}\n\t\t// pause all\n\t\thttpRequestCheckOk[any](http.MethodPut, \"/api/v1/tasks/pause\", nil)\n\t\ttime.Sleep(time.Millisecond * 100)\n\t\ttasks = httpRequestCheckOk[[]*download.Task](http.MethodGet, fmt.Sprintf(\"/api/v1/tasks?status=%s\", base.DownloadStatusPause), nil)\n\t\tif len(tasks) != total {\n\t\t\tt.Errorf(\"PauseAllTasks() got = %v, want %v\", len(tasks), total)\n\t\t}\n\t})\n}\n\nfunc TestDeleteTask(t *testing.T) {\n\tdoTest(func() {\n\t\ttaskId := httpRequestCheckOk[string](http.MethodPost, \"/api/v1/tasks\", createReq)\n\t\ttime.Sleep(time.Millisecond * 200)\n\t\thttpRequestCheckOk[any](http.MethodDelete, \"/api/v1/tasks/\"+taskId, nil)\n\t\tcode, _ := httpRequest[*download.Task](http.MethodGet, \"/api/v1/tasks/\"+taskId, nil)\n\t\tcheckCode(code, model.CodeTaskNotFound)\n\t})\n}\n\nfunc TestDeleteTaskForce(t *testing.T) {\n\tdoTest(func() {\n\t\ttaskId := httpRequestCheckOk[string](http.MethodPost, \"/api/v1/tasks\", createReq)\n\t\ttime.Sleep(time.Millisecond * 200)\n\t\thttpRequestCheckOk[any](http.MethodDelete, \"/api/v1/tasks/\"+taskId+\"?force=true\", nil)\n\t\tcode, _ := httpRequest[*download.Task](http.MethodGet, \"/api/v1/tasks/\"+taskId, nil)\n\t\tcheckCode(code, model.CodeTaskNotFound)\n\t\tif _, err := os.Stat(test.DownloadFile); !errors.Is(err, os.ErrNotExist) {\n\t\t\tt.Errorf(\"DeleteTaskForce() got = %v, want %v\", err, os.ErrNotExist)\n\t\t}\n\t})\n}\n\nfunc TestDeleteAllTasks(t *testing.T) {\n\tdoTest(func() {\n\t\ttaskCount := 3\n\n\t\tvar wg sync.WaitGroup\n\t\twg.Add(taskCount)\n\t\tDownloader.Listener(func(event *download.Event) {\n\t\t\tif event.Key == download.EventKeyFinally {\n\t\t\t\twg.Done()\n\t\t\t}\n\t\t})\n\n\t\tfor i := 0; i < taskCount; i++ {\n\t\t\thttpRequestCheckOk[string](http.MethodPost, \"/api/v1/tasks\", createReq)\n\t\t}\n\n\t\twg.Wait()\n\n\t\thttpRequestCheckOk[any](http.MethodDelete, \"/api/v1/tasks?force=true\", nil)\n\t\ttasks := httpRequestCheckOk[[]*download.Task](http.MethodGet, \"/api/v1/tasks\", nil)\n\t\tif len(tasks) != 0 {\n\t\t\tt.Errorf(\"DeleteTasks() got = %v, want %v\", len(tasks), 0)\n\t\t}\n\t})\n}\n\nfunc TestDeleteTasksByStatues(t *testing.T) {\n\tdoTest(func() {\n\t\ttaskCount := 3\n\n\t\tvar wg sync.WaitGroup\n\t\twg.Add(taskCount)\n\t\tDownloader.Listener(func(event *download.Event) {\n\t\t\tif event.Key == download.EventKeyFinally {\n\t\t\t\twg.Done()\n\t\t\t}\n\t\t})\n\n\t\tfor i := 0; i < taskCount; i++ {\n\t\t\thttpRequestCheckOk[string](http.MethodPost, \"/api/v1/tasks\", createReq)\n\t\t}\n\n\t\twg.Wait()\n\n\t\thttpRequestCheckOk[any](http.MethodDelete, fmt.Sprintf(\"/api/v1/tasks?status=%s&force=true\", base.DownloadStatusDone), nil)\n\t\ttasks := httpRequestCheckOk[[]*download.Task](http.MethodGet, \"/api/v1/tasks\", nil)\n\t\tif len(tasks) != 0 {\n\t\t\tt.Errorf(\"DeleteTasks() got = %v, want %v\", len(tasks), 0)\n\t\t}\n\t})\n}\n\nfunc TestGetTasks(t *testing.T) {\n\tdoTest(func() {\n\t\tvar wg sync.WaitGroup\n\t\twg.Add(1)\n\t\tDownloader.Listener(func(event *download.Event) {\n\t\t\tif event.Key == download.EventKeyFinally {\n\t\t\t\twg.Done()\n\t\t\t}\n\t\t})\n\n\t\thttpRequestCheckOk[string](http.MethodPost, fmt.Sprintf(\"/api/v1/tasks?status=%s&status=%s\",\n\t\t\tbase.DownloadStatusReady, base.DownloadStatusRunning), createReq)\n\t\thttpRequestCheckOk[[]*download.Task](http.MethodGet, \"/api/v1/tasks\", nil)\n\n\t\twg.Wait()\n\t\tr := httpRequestCheckOk[[]*download.Task](http.MethodGet, fmt.Sprintf(\"/api/v1/tasks?status=%s\",\n\t\t\tbase.DownloadStatusDone), nil)\n\t\tif r[0].Status != base.DownloadStatusDone {\n\t\t\tt.Errorf(\"GetTasks() got = %v, want %v\", r[0].Status, base.DownloadStatusDone)\n\t\t}\n\t\tr = httpRequestCheckOk[[]*download.Task](http.MethodGet, fmt.Sprintf(\"/api/v1/tasks?status=%s,%s\",\n\t\t\tbase.DownloadStatusReady, base.DownloadStatusRunning), nil)\n\t\tif len(r) > 0 {\n\t\t\tt.Errorf(\"GetTasks() got = %v, want %v\", len(r), 0)\n\t\t}\n\t})\n}\n\nfunc TestGetAndPutConfig(t *testing.T) {\n\tdoTest(func() {\n\t\tcfg := httpRequestCheckOk[*base.DownloaderStoreConfig](http.MethodGet, \"/api/v1/config\", nil)\n\t\tcfg.DownloadDir = \"./download\"\n\t\tcfg.Extra = map[string]any{\n\t\t\t\"serverConfig\": &Config{\n\t\t\t\tHost: \"127.0.0.1\",\n\t\t\t\tPort: 8080,\n\t\t\t},\n\t\t\t\"theme\": \"dark\",\n\t\t}\n\t\thttpRequestCheckOk[any](http.MethodPut, \"/api/v1/config\", cfg)\n\n\t\tnewCfg := httpRequestCheckOk[*base.DownloaderStoreConfig](http.MethodGet, \"/api/v1/config\", nil)\n\t\tif !test.JsonEqual(cfg, newCfg) {\n\t\t\tt.Errorf(\"GetAndPutConfig() got = %v, want %v\", test.ToJson(newCfg), test.ToJson(cfg))\n\t\t}\n\t})\n}\n\nfunc TestInstallExtension(t *testing.T) {\n\tdoTest(func() {\n\t\tidentity := httpRequestCheckOk[string](http.MethodPost, \"/api/v1/extensions\", installExtensionReq)\n\t\tif identity == \"\" {\n\t\t\tt.Errorf(\"InstallExtension() got = %v, want %v\", identity, \"not empty\")\n\t\t}\n\n\t\t// not a valid extension repository\n\t\tcode, _ := httpRequest[string](http.MethodPost, \"/api/v1/extensions\", &model.InstallExtension{\n\t\t\tURL: \"https://github.com/GopeedLab/gopeed\",\n\t\t})\n\t\tcheckCode(code, model.CodeError)\n\n\t\t// not a git repository\n\t\tcode, _ = httpRequest[string](http.MethodPost, \"/api/v1/extensions\", &model.InstallExtension{\n\t\t\tURL: \"https://github.com\",\n\t\t})\n\t\tcheckCode(code, model.CodeError)\n\t})\n}\n\nfunc TestGetExtensions(t *testing.T) {\n\tdoTest(func() {\n\t\thttpRequestCheckOk[string](http.MethodPost, \"/api/v1/extensions\", installExtensionReq)\n\t\textensions := httpRequestCheckOk[[]*download.Extension](http.MethodGet, \"/api/v1/extensions\", nil)\n\t\tif len(extensions) == 0 {\n\t\t\tt.Errorf(\"GetExtensions() got = %v, want %v\", len(extensions), \"not empty\")\n\t\t}\n\t})\n}\n\nfunc TestUpdateExtensionSettings(t *testing.T) {\n\tdoTest(func() {\n\t\tidentity := httpRequestCheckOk[string](http.MethodPost, \"/api/v1/extensions\", installExtensionReq)\n\n\t\thttpRequestCheckOk[any](http.MethodPut, \"/api/v1/extensions/\"+identity+\"/settings\", &model.UpdateExtensionSettings{\n\t\t\tSettings: map[string]any{\n\t\t\t\t\"undefined\": \"test\",\n\t\t\t\t\"ua\":        \"test\",\n\t\t\t},\n\t\t})\n\n\t\tsettings := httpRequestCheckOk[*download.Extension](http.MethodGet, \"/api/v1/extensions/\"+identity, nil).Settings\n\t\tif len(settings) != 1 {\n\t\t\tt.Errorf(\"UpdateExtensionSettings() got = %v, want %v\", len(settings), 1)\n\t\t}\n\n\t\tif settings[0].Name != \"ua\" || settings[0].Value != \"test\" {\n\t\t\tt.Errorf(\"UpdateExtensionSettings() got = %v, want %v\", settings[0].Value, \"test\")\n\t\t}\n\t})\n}\n\nfunc TestSwitchExtension(t *testing.T) {\n\tdoTest(func() {\n\t\tidentity := httpRequestCheckOk[string](http.MethodPost, \"/api/v1/extensions\", installExtensionReq)\n\t\thttpRequestCheckOk[any](http.MethodPut, \"/api/v1/extensions/\"+identity+\"/switch\", &model.SwitchExtension{\n\t\t\tStatus: false,\n\t\t})\n\t\textensions := httpRequestCheckOk[[]*download.Extension](http.MethodGet, \"/api/v1/extensions\", nil)\n\t\tif !extensions[0].Disabled {\n\t\t\tt.Errorf(\"TestSwitchExtension() got = %v, want %v\", extensions[0].Disabled, true)\n\t\t}\n\t})\n}\n\nfunc TestDeleteExtension(t *testing.T) {\n\tdoTest(func() {\n\t\tidentity := httpRequestCheckOk[string](http.MethodPost, \"/api/v1/extensions\", installExtensionReq)\n\t\thttpRequestCheckOk[any](http.MethodDelete, \"/api/v1/extensions/\"+identity, nil)\n\t\textensions := httpRequestCheckOk[[]*download.Extension](http.MethodGet, \"/api/v1/extensions\", nil)\n\t\tif len(extensions) != 0 {\n\t\t\tt.Errorf(\"TestDeleteExtension() got = %v, want %v\", len(extensions), 0)\n\t\t}\n\t})\n}\n\nfunc TestUpdateCheckExtension(t *testing.T) {\n\tdoTest(func() {\n\t\tidentity := httpRequestCheckOk[string](http.MethodPost, \"/api/v1/extensions\", installExtensionReq)\n\t\tresp := httpRequestCheckOk[*model.UpdateCheckExtensionResp](http.MethodGet, \"/api/v1/extensions/\"+identity+\"/update\", nil)\n\t\t// no new version\n\t\tif resp.NewVersion != \"\" {\n\t\t\tt.Errorf(\"UpdateCheckExtension() got = %v, want %v\", resp.NewVersion, \"\")\n\t\t}\n\t\t// force update\n\t\thttpRequestCheckOk[any](http.MethodPost, \"/api/v1/extensions/\"+identity+\"/update\", nil)\n\t})\n}\n\nfunc TestFsExtension(t *testing.T) {\n\tdoTest(func() {\n\t\tidentity := httpRequestCheckOk[string](http.MethodPost, \"/api/v1/extensions\", installExtensionReq)\n\t\tstatusCode, _ := doHttpRequest0(http.MethodGet, \"/fs/extensions/\"+identity+\"/icon.png\", nil, nil)\n\t\tif statusCode != http.StatusOK {\n\t\t\tt.Errorf(\"FsExtension() got = %v, want %v\", statusCode, http.StatusOK)\n\t\t}\n\t})\n}\n\nfunc TestFsExtensionFail(t *testing.T) {\n\tdoTest(func() {\n\t\tstatusCode, _ := doHttpRequest0(http.MethodGet, \"/fs/extensions/not_exist/icon.png\", nil, nil)\n\t\tif statusCode != http.StatusNotFound {\n\t\t\tt.Errorf(\"TestFsExtensionFail() got = %v, want %v\", statusCode, http.StatusNotFound)\n\t\t}\n\t})\n}\n\nfunc TestWebFsEnhance(t *testing.T) {\n\tindexHtml := `\n<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n\t<meta charset=\"UTF-8\">\n\t<title>index</title>\n</head>\n<body>\n\t<h1>index</h1>\n</body>\n</html>\n`\n\twebDistPath := \"dist\"\n\tos.MkdirAll(\"dist\", os.ModePerm)\n\tif err := os.WriteFile(filepath.Join(webDistPath, \"index.html\"), []byte(indexHtml), os.ModePerm); err != nil {\n\t\tpanic(err)\n\t}\n\tdefer os.RemoveAll(webDistPath)\n\n\tdoTest0(func(cfg *model.StartConfig) {\n\t\tcfg.WebFS = os.DirFS(webDistPath)\n\t}, func() {\n\t\t// First request no cache\n\t\tcode, header, _ := doHttpRequest1(http.MethodGet, \"/index.html\", map[string]string{\n\t\t\t\"Accept-Encoding\": \"gzip\",\n\t\t}, nil)\n\t\tif code != http.StatusOK {\n\t\t\tt.Errorf(\"TestWebFsEnhance() got = %v, want %v\", code, http.StatusOK)\n\t\t}\n\t\t// Check header last-modified\n\t\tif _, ok := header[\"Last-Modified\"]; !ok {\n\t\t\tt.Errorf(\"TestWebFsEnhance() missing key = %v\", \"Last-Modified\")\n\t\t}\n\t\t// Check gzip compress\n\t\tif _, ok := header[\"Content-Encoding\"]; !ok || header[\"Content-Encoding\"] != \"gzip\" {\n\t\t\tt.Errorf(\"TestWebFsEnhance() no gzip compress\")\n\t\t}\n\n\t\t// Request with If-Modified-Since\n\t\tifModifiedSince := header[\"Last-Modified\"]\n\t\tcode, _, _ = doHttpRequest1(http.MethodGet, \"/index.html\", map[string]string{\n\t\t\t\"If-Modified-Since\": ifModifiedSince,\n\t\t}, nil)\n\t\tif code != http.StatusNotModified {\n\t\t\tt.Errorf(\"TestWebFsEnhance() got = %v, want %v\", code, http.StatusNotModified)\n\t\t}\n\n\t\t// Request with un gzip\n\t\tcode, header, _ = doHttpRequest1(http.MethodGet, \"/index.html?t=123\", nil, nil)\n\t\tif code != http.StatusOK {\n\t\t\tt.Errorf(\"TestWebFsEnhance() got = %v, want %v\", code, http.StatusOK)\n\t\t}\n\t\t// Check no gzip compress\n\t\tif _, ok := header[\"Content-Encoding\"]; ok && header[\"Content-Encoding\"] == \"gzip\" {\n\t\t\tt.Errorf(\"TestWebFsEnhance() has gzip compress\")\n\t\t}\n\t})\n}\n\nfunc TestDoProxy(t *testing.T) {\n\tdoTest(func() {\n\t\tcode, respBody := doHttpRequest0(http.MethodGet, \"/api/v1/proxy\", map[string]string{\n\t\t\t\"X-Target-Uri\": \"https://github.com/GopeedLab/gopeed/raw/695da7ea87d2b455552b709d3cb4d7879484d4d1/README.md\",\n\t\t}, nil)\n\t\tif code != http.StatusOK {\n\t\t\tt.Errorf(\"DoProxy() got = %v, want %v\", code, http.StatusOK)\n\t\t}\n\t\twant := \"4ee193b676f1ebb2ad810e016350d52a\"\n\t\tgot := fmt.Sprintf(\"%x\", md5.Sum(respBody))\n\t\tif got != want {\n\t\t\tt.Errorf(\"DoProxy() got = %v, want %v\", got, want)\n\t\t}\n\t})\n\n\tdoTest(func() {\n\t\tcode, _ := doHttpRequest0(http.MethodGet, \"/api/v1/proxy\", map[string]string{\n\t\t\t\"X-Target-Uri\": \"https://github.com/GopeedLab/gopeed/raw/695da7ea87d2b455552b709d3cb4d7879484d4d1/NOT_FOUND\",\n\t\t}, nil)\n\t\tif code != http.StatusNotFound {\n\t\t\tt.Errorf(\"DoProxy() got = %v, want %v\", code, http.StatusNotFound)\n\t\t}\n\t})\n}\n\nfunc TestTestWebhook(t *testing.T) {\n\tdoTest(func() {\n\t\t// Set up a mock webhook server\n\t\twebhookReceived := false\n\t\tvar receivedData map[string]interface{}\n\t\tvar wg sync.WaitGroup\n\t\twg.Add(1)\n\n\t\twebhookServer := http.Server{\n\t\t\tHandler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tif r.Method != http.MethodPost {\n\t\t\t\t\tw.WriteHeader(http.StatusMethodNotAllowed)\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tbody, err := io.ReadAll(r.Body)\n\t\t\t\tif err != nil {\n\t\t\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tif err := json.Unmarshal(body, &receivedData); err != nil {\n\t\t\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\t// Check Content-Type\n\t\t\t\tif r.Header.Get(\"Content-Type\") != \"application/json\" {\n\t\t\t\t\tt.Errorf(\"TestWebhook() Content-Type got = %v, want %v\", r.Header.Get(\"Content-Type\"), \"application/json\")\n\t\t\t\t}\n\n\t\t\t\twebhookReceived = true\n\t\t\t\tw.WriteHeader(http.StatusOK)\n\t\t\t\twg.Done()\n\t\t\t}),\n\t\t}\n\n\t\t// Start webhook server\n\t\tlistener, err := net.Listen(\"tcp\", \"127.0.0.1:0\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\twebhookPort := listener.Addr().(*net.TCPAddr).Port\n\t\twebhookURL := fmt.Sprintf(\"http://127.0.0.1:%d/webhook\", webhookPort)\n\n\t\tgo webhookServer.Serve(listener)\n\t\tdefer webhookServer.Close()\n\n\t\t// Test with valid webhook URL\n\t\thttpRequestCheckOk[any](http.MethodPost, \"/api/v1/webhook/test\", &model.TestWebhookReq{\n\t\t\tURL: webhookURL,\n\t\t})\n\n\t\t// Wait for webhook to be received\n\t\twg.Wait()\n\n\t\tif !webhookReceived {\n\t\t\tt.Error(\"TestWebhook() webhook was not received\")\n\t\t}\n\n\t\t// Verify webhook data structure\n\t\tif receivedData[\"event\"] == nil {\n\t\t\tt.Error(\"TestWebhook() missing 'event' field\")\n\t\t}\n\t\tif receivedData[\"time\"] == nil {\n\t\t\tt.Error(\"TestWebhook() missing 'time' field\")\n\t\t}\n\t\tif receivedData[\"payload\"] == nil {\n\t\t\tt.Error(\"TestWebhook() missing 'payload' field\")\n\t\t}\n\n\t\t// Test with invalid webhook URL\n\t\tcode, _ := httpRequest[any](http.MethodPost, \"/api/v1/webhook/test\", &model.TestWebhookReq{\n\t\t\tURL: \"http://invalid-webhook-url-that-does-not-exist.local:99999/webhook\",\n\t\t})\n\t\tcheckCode(code, model.CodeError)\n\n\t\t// Test with empty URL\n\t\tcode, _ = httpRequest[any](http.MethodPost, \"/api/v1/webhook/test\", &model.TestWebhookReq{\n\t\t\tURL: \"\",\n\t\t})\n\t\tcheckCode(code, model.CodeError)\n\n\t\t// Test with webhook server returning non-200 status\n\t\twg.Add(1)\n\t\tbadWebhookServer := http.Server{\n\t\t\tHandler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\tw.WriteHeader(http.StatusBadRequest)\n\t\t\t\twg.Done()\n\t\t\t}),\n\t\t}\n\t\tbadListener, err := net.Listen(\"tcp\", \"127.0.0.1:0\")\n\t\tif err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t\tbadWebhookPort := badListener.Addr().(*net.TCPAddr).Port\n\t\tbadWebhookURL := fmt.Sprintf(\"http://127.0.0.1:%d/webhook\", badWebhookPort)\n\n\t\tgo badWebhookServer.Serve(badListener)\n\t\tdefer badWebhookServer.Close()\n\n\t\tcode, _ = httpRequest[any](http.MethodPost, \"/api/v1/webhook/test\", &model.TestWebhookReq{\n\t\t\tURL: badWebhookURL,\n\t\t})\n\t\tcheckCode(code, model.CodeError)\n\n\t\twg.Wait()\n\t})\n}\n\nfunc TestApiToken(t *testing.T) {\n\tvar cfg = &model.StartConfig{}\n\tcfg.Init()\n\tcfg.ApiToken = \"123456\"\n\tfileListener := doStart(cfg)\n\tdefer func() {\n\t\tif err := fileListener.Close(); err != nil {\n\t\t\tpanic(err)\n\t\t}\n\t\tStop()\n\t}()\n\n\tstatus, _ := doHttpRequest0(http.MethodGet, \"/api/v1/config\", nil, nil)\n\tif status != http.StatusUnauthorized {\n\t\tt.Errorf(\"TestApiToken() got = %v, want %v\", status, http.StatusUnauthorized)\n\t}\n\n\tstatus, _ = doHttpRequest0(http.MethodGet, \"/api/v1/config\", map[string]string{\n\t\t\"X-Api-Token\": cfg.ApiToken,\n\t}, nil)\n\tif status != http.StatusOK {\n\t\tt.Errorf(\"TestApiToken() got = %v, want %v\", status, http.StatusOK)\n\t}\n\n}\n\nfunc TestAuthorization(t *testing.T) {\n\tvar cfg = &model.StartConfig{}\n\tcfg.Init()\n\tcfg.ApiToken = \"123456\"\n\tcfg.WebEnable = true\n\tcfg.WebAuth = &model.WebAuth{\n\t\tUsername: \"admin\",\n\t\tPassword: \"123456\",\n\t}\n\tfileListener := doStart(cfg)\n\tdefer func() {\n\t\tif err := fileListener.Close(); err != nil {\n\t\t\tpanic(err)\n\t\t}\n\t\tStop()\n\t}()\n\n\tstatus, _ := doHttpRequest0(http.MethodPost, \"/api/web/login\", nil, &model.WebAuth{\n\t\tUsername: \"xxx\",\n\t\tPassword: \"xxx\",\n\t})\n\tif status != http.StatusUnauthorized {\n\t\tt.Errorf(\"TestAuthorization() got = %v, want %v\", status, http.StatusUnauthorized)\n\t}\n\n\ttoken := httpRequestCheckOk[string](http.MethodPost, \"/api/web/login\", cfg.WebAuth)\n\tauthToken := fmt.Sprintf(\"Bearer %s\", token)\n\tauthHeaders := map[string]string{\n\t\t\"Authorization\": authToken,\n\t}\n\n\tstatus, _ = doHttpRequest0(http.MethodGet, \"/api/v1/config\", nil, nil)\n\tif status != http.StatusUnauthorized {\n\t\tt.Errorf(\"TestAuthorization() got = %v, want %v\", status, http.StatusUnauthorized)\n\t}\n\n\tstatus, _ = doHttpRequest0(http.MethodGet, \"/api/v1/config\", map[string]string{\n\t\t\"Authorization\": \"xxx\",\n\t}, nil)\n\tif status != http.StatusUnauthorized {\n\t\tt.Errorf(\"TestAuthorization() got = %v, want %v\", status, http.StatusUnauthorized)\n\t}\n\n\tstatus, _ = doHttpRequest0(http.MethodGet, \"/api/v1/config\", map[string]string{\n\t\t\"Authorization\": \"xxx\",\n\t}, nil)\n\tif status != http.StatusUnauthorized {\n\t\tt.Errorf(\"TestAuthorization() got = %v, want %v\", status, http.StatusUnauthorized)\n\t}\n\n\tbuildToken := func(username, password string, ts int64) string {\n\t\ttoken, _ := aesEncrypt(aesKey, []byte(fmt.Sprintf(\"%s:%s:%d\", username, password, ts)))\n\t\treturn token\n\t}\n\n\tfakeToken := buildToken(\"fake\", \"fake\", time.Now().Unix())\n\tstatus, _ = doHttpRequest0(http.MethodGet, \"/api/v1/config\", map[string]string{\n\t\t\"Authorization\": fmt.Sprintf(\"Bearer %s\", fakeToken),\n\t}, nil)\n\tif status != http.StatusUnauthorized {\n\t\tt.Errorf(\"TestAuthorization() got = %v, want %v\", status, http.StatusUnauthorized)\n\t}\n\n\texpireToken := buildToken(cfg.WebAuth.Username, cfg.WebAuth.Password, time.Now().Add(-time.Hour*8*24).Unix())\n\tstatus, _ = doHttpRequest0(http.MethodGet, \"/api/v1/config\", map[string]string{\n\t\t\"Authorization\": fmt.Sprintf(\"Bearer %s\", expireToken),\n\t}, nil)\n\tif status != http.StatusUnauthorized {\n\t\tt.Errorf(\"TestAuthorization() got = %v, want %v\", status, http.StatusUnauthorized)\n\t}\n\n\tstatus, _ = doHttpRequest0(http.MethodGet, \"/api/v1/config\", authHeaders, nil)\n\tif status != http.StatusOK {\n\t\tt.Errorf(\"TestAuthorization() got = %v, want %v\", status, http.StatusOK)\n\t}\n\n\tstatus, _ = doHttpRequest0(http.MethodGet, \"/api/v1/config\", map[string]string{\n\t\t\"X-Api-Token\": cfg.ApiToken,\n\t}, nil)\n\tif status != http.StatusOK {\n\t\tt.Errorf(\"TestAuthorization() got = %v, want %v\", status, http.StatusOK)\n\t}\n\n\tstatus, _ = doHttpRequest0(http.MethodGet, \"/api/v1/config\", map[string]string{\n\t\t\"Authorization\": authToken,\n\t\t\"X-Api-Token\":   cfg.ApiToken,\n\t}, nil)\n\tif status != http.StatusOK {\n\t\tt.Errorf(\"TestAuthorization() got = %v, want %v\", status, http.StatusOK)\n\t}\n\n\tstatus, _ = doHttpRequest0(http.MethodGet, \"/api/v1/config\", map[string]string{\n\t\t\"Authorization\": authToken,\n\t\t\"X-Api-Token\":   \"\",\n\t}, nil)\n\tif status != http.StatusUnauthorized {\n\t\tt.Errorf(\"TestAuthorization() got = %v, want %v\", status, http.StatusUnauthorized)\n\t}\n}\n\nfunc doTest(handler func()) {\n\tdoTest0(nil, handler)\n}\n\nfunc doTest0(onStart func(cfg *model.StartConfig), handler func()) {\n\ttestFunc := func(storage model.Storage) {\n\t\tvar cfg = &model.StartConfig{}\n\t\tcfg.Init()\n\t\tcfg.Storage = storage\n\t\tcfg.StorageDir = \".test_storage\"\n\t\tcfg.WebEnable = true\n\t\tif onStart != nil {\n\t\t\tonStart(cfg)\n\t\t}\n\t\tfileListener := doStart(cfg)\n\t\tdefer func() {\n\t\t\tif err := fileListener.Close(); err != nil {\n\t\t\t\tpanic(err)\n\t\t\t}\n\t\t\tStop()\n\t\t\tDownloader.Clear()\n\t\t}()\n\t\tdefer func() {\n\t\t\ttime.Sleep(500 * time.Millisecond)\n\t\t\tDownloader.Pause(nil)\n\t\t\tDownloader.Delete(nil, true)\n\t\t\tos.RemoveAll(cfg.StorageDir)\n\t\t}()\n\t\ttaskReq.URL = \"http://\" + fileListener.Addr().String() + \"/\" + test.BuildName\n\t\thandler()\n\t}\n\ttestFunc(model.StorageMem)\n\ttestFunc(model.StorageBolt)\n}\n\nfunc doStart(cfg *model.StartConfig) net.Listener {\n\tport, err := Start(cfg)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\trestPort = port\n\treturn test.StartTestFileServer()\n}\n\nfunc doHttpRequest0(method string, path string, headers map[string]string, body any) (int, []byte) {\n\tr1, _, r3 := doHttpRequest1(method, path, headers, body)\n\treturn r1, r3\n}\n\nfunc doHttpRequest1(method string, path string, headers map[string]string, body any) (int, map[string]string, []byte) {\n\tvar reader io.Reader\n\tif body != nil {\n\t\tbuf, _ := json.Marshal(body)\n\t\treader = bytes.NewBuffer(buf)\n\t}\n\n\trequest, err := http.NewRequest(method, fmt.Sprintf(\"http://127.0.0.1:%d%s\", restPort, path), reader)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\tif headers != nil {\n\t\tfor k, v := range headers {\n\t\t\trequest.Header.Set(k, v)\n\t\t}\n\t}\n\tresponse, err := http.DefaultClient.Do(request)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\tdefer response.Body.Close()\n\n\trespHeader := make(map[string]string)\n\tfor k, vv := range response.Header {\n\t\trespHeader[k] = vv[0]\n\t}\n\n\trespBody, err := io.ReadAll(response.Body)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\treturn response.StatusCode, respHeader, respBody\n}\n\nfunc doHttpRequest[T any](method string, path string, headers map[string]string, body any) (int, *model.Result[T]) {\n\tstatusCode, respBody := doHttpRequest0(method, path, headers, body)\n\tif statusCode != http.StatusOK {\n\t\tpanic(fmt.Sprintf(\"http request failed, status code: %d\", statusCode))\n\t}\n\n\tvar r model.Result[T]\n\tif err := json.Unmarshal(respBody, &r); err != nil {\n\t\tpanic(err)\n\t}\n\treturn int(r.Code), &r\n}\n\nfunc httpRequest[T any](method string, path string, body any) (int, *model.Result[T]) {\n\treturn doHttpRequest[T](method, path, nil, body)\n}\n\nfunc httpRequestCheckOk[T any](method string, path string, body any) T {\n\tcode, result := httpRequest[T](method, path, body)\n\tcheckOk(code)\n\treturn result.Data\n}\n\nfunc checkOk(code int) {\n\tcheckCode(code, model.CodeOk)\n}\n\nfunc checkCode(code int, exceptCode model.RespCode) {\n\tif code != int(exceptCode) {\n\t\tpanic(fmt.Sprintf(\"code got = %d, want %d\", code, exceptCode))\n\t}\n}\n"
  },
  {
    "path": "pkg/util/bytefmt.go",
    "content": "package util\n\nimport (\n\t\"fmt\"\n\t\"math\"\n)\n\nconst unknownSize = \"unknown\"\n\nvar unitArr = []string{\"B\", \"KB\", \"MB\", \"GB\", \"TB\", \"PB\", \"EB\"}\n\nfunc ByteFmt(size int64) string {\n\tif size == 0 {\n\t\treturn unknownSize\n\t}\n\t// Handle negative values\n\tif size < 0 {\n\t\treturn unknownSize\n\t}\n\tfs := float64(size)\n\tp := int(math.Log(fs) / math.Log(1024))\n\t// Ensure index is within bounds\n\tif p < 0 {\n\t\tp = 0\n\t}\n\tif p >= len(unitArr) {\n\t\tp = len(unitArr) - 1\n\t}\n\tval := fs / math.Pow(1024, float64(p))\n\t_, frac := math.Modf(val)\n\tif frac > 0 {\n\t\treturn fmt.Sprintf(\"%.1f%s\", math.Floor(val*10)/10, unitArr[p])\n\t} else {\n\t\treturn fmt.Sprintf(\"%d%s\", int(val), unitArr[p])\n\t}\n}\n"
  },
  {
    "path": "pkg/util/bytefmt_test.go",
    "content": "package util\n\nimport \"testing\"\n\nfunc TestByteFmt(t *testing.T) {\n\ttype args struct {\n\t\tsize int64\n\t}\n\ttests := []struct {\n\t\tname string\n\t\targs args\n\t\twant string\n\t}{\n\t\t{\n\t\t\tname: \"unknown\",\n\t\t\targs: args{size: int64(0)},\n\t\t\twant: unknownSize,\n\t\t},\n\t\t{\n\t\t\tname: \"negative value\",\n\t\t\targs: args{size: int64(-1)},\n\t\t\twant: unknownSize,\n\t\t},\n\t\t{\n\t\t\tname: \"negative min int64\",\n\t\t\targs: args{size: int64(-9223372036854775808)},\n\t\t\twant: unknownSize,\n\t\t},\n\t\t{\n\t\t\tname: \"100B\",\n\t\t\targs: args{size: int64(100)},\n\t\t\twant: \"100B\",\n\t\t},\n\t\t{\n\t\t\tname: \"1KB\",\n\t\t\targs: args{size: int64(1024)},\n\t\t\twant: \"1KB\",\n\t\t},\n\t\t{\n\t\t\tname: \"1.9KB\",\n\t\t\targs: args{size: int64(1024*2 - 1)},\n\t\t\twant: \"1.9KB\",\n\t\t},\n\t\t{\n\t\t\tname: \"2KB\",\n\t\t\targs: args{size: int64(1024 * 2)},\n\t\t\twant: \"2KB\",\n\t\t},\n\t\t{\n\t\t\tname: \"1MB\",\n\t\t\targs: args{size: int64(1024 * 1024)},\n\t\t\twant: \"1MB\",\n\t\t},\n\t\t{\n\t\t\tname: \"1.9MB\",\n\t\t\targs: args{size: int64(1024*1024*2 - 1)},\n\t\t\twant: \"1.9MB\",\n\t\t},\n\t\t{\n\t\t\tname: \"2MB\",\n\t\t\targs: args{size: int64(1024 * 1024 * 2)},\n\t\t\twant: \"2MB\",\n\t\t},\n\t\t{\n\t\t\tname: \"large value\",\n\t\t\targs: args{size: int64(9223372036854775807)}, // max int64\n\t\t\twant: \"8EB\",\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := ByteFmt(tt.args.size); got != tt.want {\n\t\t\t\tt.Errorf(\"ByteFmt() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/util/json.go",
    "content": "package util\n\nimport \"encoding/json\"\n\nfunc MapToStruct(s any, v any) error {\n\tif s == nil {\n\t\treturn nil\n\t}\n\tb, err := json.Marshal(s)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn json.Unmarshal(b, v)\n}\n\nfunc DeepClone[T any](v *T) *T {\n\tif v == nil {\n\t\treturn nil\n\t}\n\n\tvar t T\n\tb, err := json.Marshal(v)\n\tif err != nil {\n\t\treturn &t\n\t}\n\tjson.Unmarshal(b, &t)\n\treturn &t\n}\n\n// Ptr returns a pointer to the given value.\nfunc Ptr[T any](v T) *T {\n\treturn &v\n}\n\n// BoolPtr returns a pointer to a bool value.\nfunc BoolPtr(v bool) *bool {\n\treturn &v\n}\n"
  },
  {
    "path": "pkg/util/json_test.go",
    "content": "package util\n\nimport (\n\t\"reflect\"\n\t\"testing\"\n)\n\nfunc TestDeepClone(t *testing.T) {\n\ttype user struct {\n\t\tName string `json:\"name\"`\n\t\tAge  int    `json:\"age\"`\n\n\t\tv int\n\t}\n\n\ttype args[T any] struct {\n\t\tv *T\n\t}\n\ttype testCase[T any] struct {\n\t\tname string\n\t\targs args[T]\n\t\twant *T\n\t}\n\ttests := []testCase[user]{\n\t\t{\n\t\t\tname: \"case 1\",\n\t\t\targs: args[user]{\n\t\t\t\tv: &user{\n\t\t\t\t\tName: \"test\",\n\t\t\t\t\tAge:  10,\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: &user{\n\t\t\t\tName: \"test\",\n\t\t\t\tAge:  10,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"case 2\",\n\t\t\targs: args[user]{\n\t\t\t\tv: &user{\n\t\t\t\t\tName: \"test\",\n\t\t\t\t\tAge:  10,\n\t\t\t\t\tv:    1,\n\t\t\t\t},\n\t\t\t},\n\t\t\twant: &user{\n\t\t\t\tName: \"test\",\n\t\t\t\tAge:  10,\n\t\t\t},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := DeepClone(tt.args.v); !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Errorf(\"DeepClone() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/util/matcher.go",
    "content": "package util\n\nimport (\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n)\n\n// Match url with pattern by chrome extension match pattern style\n// https://developer.chrome.com/docs/extensions/mv3/match_patterns/\nfunc Match(pattern string, u string) bool {\n\tscheme, host, path := parsePattern(pattern)\n\turl, err := url.Parse(u)\n\tif err != nil {\n\t\treturn false\n\t}\n\tif scheme != \"*\" && scheme != url.Scheme {\n\t\treturn false\n\t}\n\tif !matchHost(host, url.Hostname()) {\n\t\treturn false\n\t}\n\tif !matchPath(path, url.Path) {\n\t\treturn false\n\t}\n\treturn true\n}\n\nfunc parsePattern(pattern string) (scheme string, host string, path string) {\n\tparts := strings.Split(pattern, \"://\")\n\tif len(parts) == 2 {\n\t\tscheme = parts[0]\n\t\tpattern = parts[1]\n\t} else {\n\t\tscheme = \"\"\n\t}\n\tparts = strings.SplitN(pattern, \"/\", 2)\n\tif len(parts) == 2 {\n\t\thost = parts[0]\n\t\tpath = \"/\" + parts[1]\n\t} else {\n\t\thost = pattern\n\t\tpath = \"/\"\n\t}\n\treturn\n}\n\nfunc matchHost(pattern string, host string) bool {\n\tif pattern == \"*\" {\n\t\treturn true\n\t}\n\tif strings.HasPrefix(pattern, \"*.\") {\n\t\treturn strings.HasSuffix(host, pattern[1:])\n\t}\n\treturn pattern == host\n}\n\nfunc matchPath(pattern string, path string) bool {\n\tif pattern == \"*\" {\n\t\treturn true\n\t}\n\tif !strings.HasSuffix(pattern, \"*\") && !strings.HasSuffix(pattern, \"/\") {\n\t\tpattern += \"/\"\n\t}\n\tif !strings.HasSuffix(path, \"/\") {\n\t\tpath += \"/\"\n\t}\n\n\tif strings.Contains(pattern, \"*\") {\n\t\tpattern = strings.Replace(pattern, \"*\", \".*\", -1)\n\t\tmatched, _ := regexp.MatchString(\"^\"+pattern+\"$\", path)\n\t\treturn matched\n\t}\n\treturn pattern == path\n}\n"
  },
  {
    "path": "pkg/util/matcher_test.go",
    "content": "package util\n\nimport \"testing\"\n\nfunc TestMatch(t *testing.T) {\n\ttests := []struct {\n\t\tpattern string\n\t\turls    []string\n\t\twant    bool\n\t}{\n\t\t{\"*://*/*\", []string{\"https://www.google.com/\", \"http://example.org/foo/bar.html\"}, true},\n\t\t{\"https://*/*\", []string{\"https://www.google.com\", \"https://example.org/foo/bar.html\"}, true},\n\t\t{\"*://www.google.com\", []string{\"https://www.google.com/\", \"https://www.google.com\"}, true},\n\t\t{\"*://*.google.com/\", []string{\"https://a.www.google.com/\", \"https://c.www.google.com/\", \"https://www.google.com/\"}, true},\n\t\t{\"https://*/foo*\", []string{\"https://www.google.com/foo\", \"https://example.com/foo/bar.html\"}, true},\n\t\t{\"https://www.google.com/*/b/*\", []string{\"https://www.google.com/a/b\", \"https://www.google.com/a/b/c\"}, true},\n\t\t{\"https://*.google.com/foo*bar\", []string{\"https://www.google.com/foo/baz/bar\", \"https://docs.google.com/foobar\"}, true},\n\t\t{\"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},\n\t\t{\"https://example.org/foo/bar.html\", []string{\"https://example.org/foo/bar.html\"}, true},\n\t\t{\"http://127.0.0.1/*\", []string{\"http://127.0.0.1/\", \"http://127.0.0.1/foo/bar.html\"}, true},\n\t\t{\"*://mail.google.com/*\", []string{\"http://mail.google.com/foo/baz/bar\", \"https://mail.google.com/foobar\"}, true},\n\t\t{\"https://www.google.com/\", []string{\"http://www.google.com/\"}, false},\n\t\t{\"www.google.com/\", []string{\"http://www.google.com/\", \"https://www.google.com/\"}, false},\n\t\t{\"www.google.com/*c\", []string{\"https://www.google.com/a\", \"https://www.google.com/b\"}, false},\n\t\t{\"https://*.example.org/*\", []string{\"https://www.google.com\", \"https://docs.google.com\"}, false},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.pattern, func(t *testing.T) {\n\t\t\tfor _, url := range tt.urls {\n\t\t\t\tif got := Match(tt.pattern, url); got != tt.want {\n\t\t\t\t\tt.Errorf(\"Match() = %v, want %v\", got, tt.want)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/util/path.go",
    "content": "package util\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\tsyspath \"path\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\t\"unicode/utf8\"\n)\n\nfunc Dir(path string) string {\n\tdir := syspath.Dir(path)\n\tif dir == \".\" {\n\t\treturn \"\"\n\t}\n\treturn dir\n}\n\nfunc Filepath(path string, originName string, customName string) string {\n\tif customName == \"\" {\n\t\tcustomName = originName\n\t}\n\treturn syspath.Join(path, customName)\n}\n\n// SafeRemove remove file safely, ignoring errors if the path does not exist.\nfunc SafeRemove(name string) error {\n\tif err := os.Remove(name); err != nil && !errors.Is(err, os.ErrNotExist) {\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// CheckDuplicateAndRename rename duplicate file, add suffix (1) (2) ...\n// if file name is a.txt, rename to a (1).txt\n// if directory name is a, rename to a (1)\n// return new name\nfunc CheckDuplicateAndRename(path string) (string, error) {\n\tdir := syspath.Dir(path)\n\tname := syspath.Base(path)\n\n\t// if file not exists, return directly\n\t_, err := os.Stat(path)\n\tif err != nil {\n\t\tif errors.Is(err, os.ErrNotExist) {\n\t\t\treturn name, nil\n\t\t}\n\t\treturn \"\", err\n\t}\n\n\text := syspath.Ext(name)\n\tvar nameTpl string\n\t// Special case: if the extension is the entire filename (like .gitignore),\n\t// or if index of last dot is 0 (starts with dot), treat it as no extension\n\tif ext == \"\" || ext == name || (len(ext) > 0 && strings.LastIndex(name, \".\") == 0) {\n\t\t// No extension or hidden file without extension\n\t\tnameTpl = name + \" (%d)\"\n\t} else {\n\t\t// Has extension\n\t\tnameWithoutExt := name[:len(name)-len(ext)]\n\t\tnameTpl = nameWithoutExt + \" (%d)\" + ext\n\t}\n\tfor i := 1; ; i++ {\n\t\tnewName := fmt.Sprintf(nameTpl, i)\n\t\tnewPath := syspath.Join(dir, newName)\n\t\tif _, err := os.Stat(newPath); os.IsNotExist(err) {\n\t\t\treturn newName, nil\n\t\t}\n\t}\n}\n\n// CopyDir Copy all files to the target directory, if the file already exists, it will be overwritten.\n// Remove target file if the source file is not exist.\nfunc CopyDir(source string, target string, excludeDir ...string) error {\n\tif err := os.MkdirAll(target, 0755); err != nil {\n\t\treturn err\n\t}\n\tif err := filepath.Walk(source, func(path string, info os.FileInfo, err error) error {\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif info.IsDir() {\n\t\t\tif len(excludeDir) > 0 {\n\t\t\t\tfor _, dir := range excludeDir {\n\t\t\t\t\tif info.IsDir() && info.Name() == dir {\n\t\t\t\t\t\treturn filepath.SkipDir\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn nil\n\t\t}\n\t\trelPath, err := filepath.Rel(source, path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ttargetPath := filepath.Join(target, relPath)\n\t\tif err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := copyForce(path, targetPath); err != nil {\n\t\t\treturn err\n\t\t}\n\t\treturn nil\n\t}); err != nil {\n\t\treturn err\n\t}\n\n\tif err := filepath.Walk(target, func(path string, info os.FileInfo, err error) error {\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif info.IsDir() {\n\t\t\tif len(excludeDir) > 0 {\n\t\t\t\tfor _, dir := range excludeDir {\n\t\t\t\t\tif info.IsDir() && info.Name() == dir {\n\t\t\t\t\t\treturn filepath.SkipDir\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn nil\n\t\t}\n\t\trelPath, err := filepath.Rel(target, path)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\ttargetPath := filepath.Join(target, relPath)\n\t\tsourcePath := filepath.Join(source, relPath)\n\t\t// if source file is not exist, remove target file\n\t\tif _, err := os.Stat(sourcePath); os.IsNotExist(err) {\n\t\t\tif err := SafeRemove(targetPath); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t}); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// copy file, if the target file already exists, it will be overwritten.\nfunc copyForce(source string, target string) error {\n\tsourceFile, err := os.Open(source)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer sourceFile.Close()\n\n\ttargetFile, err := os.Create(target)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer targetFile.Close()\n\n\t_, err = io.Copy(targetFile, sourceFile)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc CreateDirIfNotExist(dir string) error {\n\tif _, err := os.Stat(dir); os.IsNotExist(err) {\n\t\treturn os.MkdirAll(dir, 0o777)\n\t}\n\treturn nil\n}\n\n// IsExistsFile check file exists and is a file\nfunc IsExistsFile(path string) bool {\n\tinfo, err := os.Stat(path)\n\t// if file exists and is a file\n\tif err == nil && !info.IsDir() {\n\t\treturn true\n\t}\n\treturn false\n}\n\nconst (\n\t// MaxFilenameLength is the maximum length in bytes for a filename\n\tMaxFilenameLength = 100\n\t// maxExtensionLength is the maximum length in bytes for a file extension\n\t// to be treated as a valid extension. Extensions longer than this are\n\t// treated as part of the filename to avoid edge cases.\n\tmaxExtensionLength = 20\n)\n\n// SafeFilename sanitizes a filename by replacing invalid characters and truncating to a safe length.\n// It performs two operations:\n// 1. Replaces invalid path characters (platform-specific) with underscores\n// 2. Truncates filename to MaxFilenameLength bytes while preserving the file extension\n// The function handles UTF-8 multi-byte characters correctly by truncating at valid boundaries.\nfunc SafeFilename(filename string) string {\n\tif filename == \"\" {\n\t\treturn \"\"\n\t}\n\t\n\t// Step 1: Replace invalid characters\n\tfor _, char := range invalidPathChars {\n\t\tfilename = strings.ReplaceAll(filename, char, \"_\")\n\t}\n\t\n\t// Step 2: Truncate if needed\n\tif len(filename) <= MaxFilenameLength {\n\t\treturn filename\n\t}\n\n\t// Find the extension (last dot in filename)\n\text := \"\"\n\tlastDot := strings.LastIndex(filename, \".\")\n\t\n\t// Only treat as extension if:\n\t// 1. There is a dot\n\t// 2. The dot is not at the start (not a hidden file like .gitignore)\n\t// 3. The extension is reasonable length (< maxExtensionLength bytes) to avoid edge cases\n\tif lastDot > 0 && lastDot < len(filename)-1 && len(filename)-lastDot < maxExtensionLength {\n\t\text = filename[lastDot:]\n\t\tfilename = filename[:lastDot]\n\t}\n\n\t// Calculate how much space we have for the base name\n\tavailableLength := MaxFilenameLength - len(ext)\n\t\n\t// Ensure we have at least some space for the base name\n\tif availableLength < 1 {\n\t\t// Extension itself is too long or no room, just truncate everything at byte boundary\n\t\treturn truncateAtValidUTF8Boundary(filename+ext, MaxFilenameLength)\n\t}\n\n\t// Truncate the base name at a valid UTF-8 boundary\n\ttruncatedBase := truncateAtValidUTF8Boundary(filename, availableLength)\n\t\n\treturn truncatedBase + ext\n}\n\n// ReplaceInvalidFilename replace invalid path characters\n// Deprecated: Use SafeFilename instead which also handles length truncation\nfunc ReplaceInvalidFilename(path string) string {\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\tfor _, char := range invalidPathChars {\n\t\tpath = strings.ReplaceAll(path, char, \"_\")\n\t}\n\treturn path\n}\n\n// TruncateFilename truncates a filename to a maximum byte length while preserving the extension.\n// Deprecated: Use SafeFilename instead which also handles invalid character replacement\nfunc TruncateFilename(filename string, maxLength int) string {\n\t// If already short enough, return as-is\n\tif len(filename) <= maxLength {\n\t\treturn filename\n\t}\n\n\t// Find the extension (last dot in filename)\n\text := \"\"\n\tlastDot := strings.LastIndex(filename, \".\")\n\t\n\t// Only treat as extension if:\n\t// 1. There is a dot\n\t// 2. The dot is not at the start (not a hidden file like .gitignore)\n\t// 3. The extension is reasonable length (< maxExtensionLength bytes) to avoid edge cases\n\tif lastDot > 0 && lastDot < len(filename)-1 && len(filename)-lastDot < maxExtensionLength {\n\t\text = filename[lastDot:]\n\t\tfilename = filename[:lastDot]\n\t}\n\n\t// Calculate how much space we have for the base name\n\tavailableLength := maxLength - len(ext)\n\t\n\t// Ensure we have at least some space for the base name\n\tif availableLength < 1 {\n\t\t// Extension itself is too long or no room, just truncate everything at byte boundary\n\t\treturn truncateAtValidUTF8Boundary(filename+ext, maxLength)\n\t}\n\n\t// Truncate the base name at a valid UTF-8 boundary\n\ttruncatedBase := truncateAtValidUTF8Boundary(filename, availableLength)\n\t\n\treturn truncatedBase + ext\n}\n\n// truncateAtValidUTF8Boundary truncates a string to at most maxBytes,\n// ensuring we don't cut in the middle of a UTF-8 character\nfunc truncateAtValidUTF8Boundary(s string, maxBytes int) string {\n\tif len(s) <= maxBytes {\n\t\treturn s\n\t}\n\t\n\t// Truncate at byte position\n\ttruncated := s[:maxBytes]\n\t\n\t// Find the last valid UTF-8 character boundary\n\t// Walk backwards to find where the last complete character ends\n\tfor len(truncated) > 0 {\n\t\t// Check if this is a valid UTF-8 string\n\t\tif utf8.ValidString(truncated) {\n\t\t\treturn truncated\n\t\t}\n\t\t// Remove one byte and try again\n\t\ttruncated = truncated[:len(truncated)-1]\n\t}\n\t\n\treturn truncated\n}\n\n// ReplacePathPlaceholders replaces date placeholders in a path with actual values\n// Supported placeholders:\n//   - %year%  - Current year (e.g., 2025)\n//   - %month% - Current month (01-12)\n//   - %day%   - Current day (01-31)\n//   - %date%  - Full date format (2025-01-01)\nfunc ReplacePathPlaceholders(path string) string {\n\tif path == \"\" {\n\t\treturn \"\"\n\t}\n\n\tnow := time.Now()\n\tyear := fmt.Sprintf(\"%d\", now.Year())\n\tmonth := fmt.Sprintf(\"%02d\", now.Month())\n\tday := fmt.Sprintf(\"%02d\", now.Day())\n\tdate := fmt.Sprintf(\"%s-%s-%s\", year, month, day)\n\n\tpath = strings.ReplaceAll(path, \"%year%\", year)\n\tpath = strings.ReplaceAll(path, \"%month%\", month)\n\tpath = strings.ReplaceAll(path, \"%day%\", day)\n\tpath = strings.ReplaceAll(path, \"%date%\", date)\n\n\treturn path\n}\n"
  },
  {
    "path": "pkg/util/path_other.go",
    "content": "//go:build !windows\n// +build !windows\n\npackage util\n\nvar invalidPathChars = []string{`/`, `:`}\n"
  },
  {
    "path": "pkg/util/path_test.go",
    "content": "package util\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n)\n\nfunc TestDir(t *testing.T) {\n\ttype args struct {\n\t\tpath string\n\t}\n\ttests := []struct {\n\t\tname string\n\t\targs args\n\t\twant string\n\t}{\n\t\t{\n\t\t\tname: \"empty path\",\n\t\t\targs: args{\n\t\t\t\tpath: \".\",\n\t\t\t},\n\t\t\twant: \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"normal path case 1\",\n\t\t\targs: args{\n\t\t\t\tpath: \"./a/b/c/1.txt\",\n\t\t\t},\n\t\t\twant: \"a/b/c\",\n\t\t},\n\t\t{\n\t\t\tname: \"normal path case 2\",\n\t\t\targs: args{\n\t\t\t\tpath: \"a/b/c/1.txt\",\n\t\t\t},\n\t\t\twant: \"a/b/c\",\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := Dir(tt.args.path); got != tt.want {\n\t\t\t\tt.Errorf(\"Dir() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestFilepath(t *testing.T) {\n\ttype args struct {\n\t\tpath       string\n\t\toriginName string\n\t\tcustomName string\n\t}\n\ttests := []struct {\n\t\tname string\n\t\targs args\n\t\twant string\n\t}{\n\t\t{\n\t\t\tname: \"origin name\",\n\t\t\targs: args{\n\t\t\t\tpath:       \"/Downloads\",\n\t\t\t\toriginName: \"1.txt\",\n\t\t\t\tcustomName: \"\",\n\t\t\t},\n\t\t\twant: \"/Downloads/1.txt\",\n\t\t},\n\t\t{\n\t\t\tname: \"origin name\",\n\t\t\targs: args{\n\t\t\t\tpath:       \"/Downloads\",\n\t\t\t\toriginName: \"1.txt\",\n\t\t\t\tcustomName: \"2.txt\",\n\t\t\t},\n\t\t\twant: \"/Downloads/2.txt\",\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := Filepath(tt.args.path, tt.args.originName, tt.args.customName); got != tt.want {\n\t\t\t\tt.Errorf(\"SingleFilepath() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestSafeRemove(t *testing.T) {\n\tname := \"test_safe_remove.data\"\n\tfile, err := os.Create(name)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := file.Close(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif err := SafeRemove(name); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif _, err := os.Stat(name); err == nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif err := SafeRemove(\"test_safe_remove_not_exist.data\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\nfunc TestCheckDuplicateAndRename(t *testing.T) {\n\t// Test with extension\n\tdoCheckDuplicateAndRename(t, []string{}, \"a.txt\", \"a.txt\")\n\tdoCheckDuplicateAndRename(t, []string{\"a.txt\"}, \"a.txt\", \"a (1).txt\")\n\tdoCheckDuplicateAndRename(t, []string{\"a.txt\", \"a (1).txt\"}, \"a.txt\", \"a (2).txt\")\n\n\t// Test without extension\n\tdoCheckDuplicateAndRename(t, []string{}, \"a\", \"a\")\n\tdoCheckDuplicateAndRename(t, []string{\"a\"}, \"a\", \"a (1)\")\n\tdoCheckDuplicateAndRename(t, []string{\"a\", \"a (1)\"}, \"a\", \"a (2)\")\n\n\t// Test hidden files (starting with dot)\n\tdoCheckDuplicateAndRename(t, []string{}, \".gitignore\", \".gitignore\")\n\tdoCheckDuplicateAndRename(t, []string{\".gitignore\"}, \".gitignore\", \".gitignore (1)\")\n\tdoCheckDuplicateAndRename(t, []string{\".gitignore\", \".gitignore (1)\"}, \".gitignore\", \".gitignore (2)\")\n\n\t// Test hidden files with extension\n\tdoCheckDuplicateAndRename(t, []string{}, \".config.json\", \".config.json\")\n\tdoCheckDuplicateAndRename(t, []string{\".config.json\"}, \".config.json\", \".config (1).json\")\n\n\t// Test multiple dots\n\tdoCheckDuplicateAndRename(t, []string{}, \"test.tar.gz\", \"test.tar.gz\")\n\tdoCheckDuplicateAndRename(t, []string{\"test.tar.gz\"}, \"test.tar.gz\", \"test.tar (1).gz\")\n}\n\nfunc doCheckDuplicateAndRename(t *testing.T, exitsPaths []string, path string, except string) {\n\tfor _, path := range exitsPaths {\n\t\tif err := os.MkdirAll(path, 0755); err != nil {\n\t\t\tt.Fatal(err)\n\t\t}\n\t}\n\n\tdefer func() {\n\t\tfor _, path := range exitsPaths {\n\t\t\tif err := os.RemoveAll(path); err != nil {\n\t\t\t\tt.Fatal(err)\n\t\t\t}\n\t\t}\n\t}()\n\n\tgot, err := CheckDuplicateAndRename(path)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif got != except {\n\t\tt.Errorf(\"CheckDuplicateAndRename() = %v, want %v\", got, except)\n\t}\n}\n\nfunc TestIsExistsFile(t *testing.T) {\n\ttype args struct {\n\t\tpath string\n\t}\n\ttests := []struct {\n\t\tname string\n\t\targs args\n\t\twant bool\n\t}{\n\t\t{\n\t\t\tname: \"exist\",\n\t\t\targs: args{\n\t\t\t\tpath: \"./path.go\",\n\t\t\t},\n\t\t\twant: true,\n\t\t},\n\t\t{\n\t\t\tname: \"not exist\",\n\t\t\targs: args{\n\t\t\t\tpath: \"./path_not_exist.go\",\n\t\t\t},\n\t\t\twant: false,\n\t\t},\n\t\t{\n\t\t\tname: \"is dir\",\n\t\t\targs: args{\n\t\t\t\tpath: \"../util\",\n\t\t\t},\n\t\t\twant: false,\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := IsExistsFile(tt.args.path); got != tt.want {\n\t\t\t\tt.Errorf(\"IsExistsFile() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestReplaceInvalidFilename(t *testing.T) {\n\ttype args struct {\n\t\tpath string\n\t}\n\ttests := []struct {\n\t\tname string\n\t\targs args\n\t\twant string\n\t}{\n\t\t{\n\t\t\tname: \"blank\",\n\t\t\targs: args{\n\t\t\t\tpath: \"\",\n\t\t\t},\n\t\t\twant: \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"normal\",\n\t\t\targs: args{\n\t\t\t\tpath: \"test.txt\",\n\t\t\t},\n\t\t\twant: \"test.txt\",\n\t\t},\n\t\t{\n\t\t\tname: \"case1\",\n\t\t\targs: args{\n\t\t\t\tpath: \"te/st.txt\",\n\t\t\t},\n\t\t\twant: \"te_st.txt\",\n\t\t},\n\t\t{\n\t\t\tname: \"case2\",\n\t\t\targs: args{\n\t\t\t\tpath: \"te/st:.txt\",\n\t\t\t},\n\t\t\twant: \"te_st_.txt\",\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := ReplaceInvalidFilename(tt.args.path); got != tt.want {\n\t\t\t\tt.Errorf(\"ReplaceInvalidFilename() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestSafeFilename tests the combined filename sanitization functionality\nfunc TestSafeFilename(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tfilename string\n\t\twant     string\n\t}{\n\t\t{\n\t\t\tname:     \"short filename - no changes needed\",\n\t\t\tfilename: \"test.txt\",\n\t\t\twant:     \"test.txt\",\n\t\t},\n\t\t{\n\t\t\tname:     \"empty filename\",\n\t\t\tfilename: \"\",\n\t\t\twant:     \"\",\n\t\t},\n\t\t{\n\t\t\tname:     \"invalid chars only\",\n\t\t\tfilename: \"te/st:file.txt\",\n\t\t\twant:     \"te_st_file.txt\",\n\t\t},\n\t\t{\n\t\t\tname:     \"long filename only\",\n\t\t\tfilename: \"this_is_a_very_long_filename_that_exceeds_the_maximum_allowed_length_and_should_be_truncated_properly.txt\",\n\t\t\twant:     \"this_is_a_very_long_filename_that_exceeds_the_maximum_allowed_length_and_should_be_truncated_pro.txt\",\n\t\t},\n\t\t{\n\t\t\tname:     \"both invalid chars and too long\",\n\t\t\tfilename: \"path/to/very:long*filename?that<exceeds>filesystem|limits_and_has_invalid_characters_everywhere.pdf\",\n\t\t\twant:     \"path_to_very_long*filename?that<exceeds>filesystem|limits_and_has_invalid_characters_everywhere.pdf\",\n\t\t},\n\t\t{\n\t\t\tname:     \"unicode with invalid chars and truncation\",\n\t\t\tfilename: \"测试/文件名:非常长的中文文件名_需要被截断_这是一个测试用的超长文件名.pdf\",\n\t\t\twant:     \"测试_文件名_非常长的中文文件名_需要被截断_这是一个测试用的超长文.pdf\",\n\t\t},\n\t\t{\n\t\t\tname:     \"hidden file with truncation\",\n\t\t\tfilename: \".gitignore_with_very_long_name_that_needs_truncation_and_more_characters_to_exceed_the_maximum_length\",\n\t\t\twant:     \".gitignore_with_very_long_name_that_needs_truncation_and_more_characters_to_exceed_the_maximum_lengt\",\n\t\t},\n\t\t{\n\t\t\tname:     \"multiple dots and invalid chars\",\n\t\t\tfilename: \"archive/tar.gz.backup:old.txt\",\n\t\t\twant:     \"archive_tar.gz.backup_old.txt\",\n\t\t},\n\t\t{\n\t\t\tname:     \"extension longer than reasonable\",\n\t\t\tfilename: \"test.verylongextensionthatshouldnotbetreatedasextension_with_more_characters_to_exceed_maximum_length\",\n\t\t\twant:     \"test.verylongextensionthatshouldnotbetreatedasextension_with_more_characters_to_exceed_maximum_lengt\",\n\t\t},\n\t\t{\n\t\t\tname:     \"filename with spaces and invalid chars\",\n\t\t\tfilename: \"my document/with:spaces and a very long name that needs to be truncated because it exceeds the maximum length.docx\",\n\t\t\twant:     \"my document_with_spaces and a very long name that needs to be truncated because it exceeds the .docx\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := SafeFilename(tt.filename)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"SafeFilename() = %q (len=%d), want %q (len=%d)\",\n\t\t\t\t\tgot, len(got), tt.want, len(tt.want))\n\t\t\t}\n\t\t\t// Verify result doesn't exceed MaxFilenameLength\n\t\t\tif len(got) > MaxFilenameLength {\n\t\t\t\tt.Errorf(\"SafeFilename() result length %d exceeds MaxFilenameLength %d\",\n\t\t\t\t\tlen(got), MaxFilenameLength)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestReplacePathPlaceholders(t *testing.T) {\n\tnow := time.Now()\n\tyear := fmt.Sprintf(\"%d\", now.Year())\n\tmonth := fmt.Sprintf(\"%02d\", now.Month())\n\tday := fmt.Sprintf(\"%02d\", now.Day())\n\tdate := fmt.Sprintf(\"%s-%s-%s\", year, month, day)\n\n\ttests := []struct {\n\t\tname string\n\t\tpath string\n\t\twant string\n\t}{\n\t\t{\n\t\t\tname: \"empty path\",\n\t\t\tpath: \"\",\n\t\t\twant: \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"no placeholders\",\n\t\t\tpath: \"/home/user/Downloads\",\n\t\t\twant: \"/home/user/Downloads\",\n\t\t},\n\t\t{\n\t\t\tname: \"year placeholder\",\n\t\t\tpath: \"/Downloads/%year%\",\n\t\t\twant: \"/Downloads/\" + year,\n\t\t},\n\t\t{\n\t\t\tname: \"month placeholder\",\n\t\t\tpath: \"/Downloads/%month%\",\n\t\t\twant: \"/Downloads/\" + month,\n\t\t},\n\t\t{\n\t\t\tname: \"day placeholder\",\n\t\t\tpath: \"/Downloads/%day%\",\n\t\t\twant: \"/Downloads/\" + day,\n\t\t},\n\t\t{\n\t\t\tname: \"date placeholder\",\n\t\t\tpath: \"/Downloads/%date%\",\n\t\t\twant: \"/Downloads/\" + date,\n\t\t},\n\t\t{\n\t\t\tname: \"multiple placeholders\",\n\t\t\tpath: \"/Downloads/%year%-%month%\",\n\t\t\twant: \"/Downloads/\" + year + \"-\" + month,\n\t\t},\n\t\t{\n\t\t\tname: \"mixed path with placeholders\",\n\t\t\tpath: \"/home/user/Downloads/%year%/%month%/%day%\",\n\t\t\twant: \"/home/user/Downloads/\" + year + \"/\" + month + \"/\" + day,\n\t\t},\n\t\t{\n\t\t\tname: \"windows style path\",\n\t\t\tpath: \"D:\\\\Downloads\\\\%year%-%month%\",\n\t\t\twant: \"D:\\\\Downloads\\\\\" + year + \"-\" + month,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := ReplacePathPlaceholders(tt.path)\n\t\t\tif !strings.Contains(got, year) && tt.path != \"\" && strings.Contains(tt.path, \"%year%\") {\n\t\t\t\tt.Errorf(\"ReplacePathPlaceholders() = %v, want containing year %v\", got, year)\n\t\t\t}\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"ReplacePathPlaceholders() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestTruncateFilename tests the filename truncation functionality\nfunc TestTruncateFilename(t *testing.T) {\n\ttests := []struct {\n\t\tname      string\n\t\tfilename  string\n\t\tmaxLength int\n\t\twant      string\n\t}{\n\t\t{\n\t\t\tname:      \"short filename - no truncation\",\n\t\t\tfilename:  \"test.txt\",\n\t\t\tmaxLength: 100,\n\t\t\twant:      \"test.txt\",\n\t\t},\n\t\t{\n\t\t\tname:      \"filename at exact limit\",\n\t\t\tfilename:  \"abcdefghij.txt\", // 14 chars\n\t\t\tmaxLength: 14,\n\t\t\twant:      \"abcdefghij.txt\",\n\t\t},\n\t\t{\n\t\t\tname:      \"long filename with extension - truncate base\",\n\t\t\tfilename:  \"this_is_a_very_long_filename_that_exceeds_the_maximum_allowed_length_and_should_be_truncated_properly.txt\",\n\t\t\tmaxLength: 100,\n\t\t\twant:      \"this_is_a_very_long_filename_that_exceeds_the_maximum_allowed_length_and_should_be_truncated_pro.txt\",\n\t\t},\n\t\t{\n\t\t\tname:      \"long filename without extension\",\n\t\t\tfilename:  \"this_is_a_very_long_filename_without_extension_that_exceeds_maximum_length_and_needs_truncation\",\n\t\t\tmaxLength: 100,\n\t\t\twant:      \"this_is_a_very_long_filename_without_extension_that_exceeds_maximum_length_and_needs_truncation\",\n\t\t},\n\t\t{\n\t\t\tname:      \"filename with multiple dots\",\n\t\t\tfilename:  \"archive.tar.gz.backup.old.file.with.many.dots.txt\",\n\t\t\tmaxLength: 30,\n\t\t\twant:      \"archive.tar.gz.backup.old..txt\", // Preserves last extension\n\t\t},\n\t\t{\n\t\t\tname:      \"hidden file (starts with dot)\",\n\t\t\tfilename:  \".gitignore_with_very_long_name_that_needs_truncation\",\n\t\t\tmaxLength: 30,\n\t\t\twant:      \".gitignore_with_very_long_name\",\n\t\t},\n\t\t{\n\t\t\tname:      \"only extension (no base name)\",\n\t\t\tfilename:  \".txt\",\n\t\t\tmaxLength: 100,\n\t\t\twant:      \".txt\",\n\t\t},\n\t\t{\n\t\t\tname:      \"unicode characters in filename\",\n\t\t\tfilename:  \"测试文件名_非常长的中文文件名_需要被截断_这是一个测试用的超长文件名.pdf\",\n\t\t\tmaxLength: 50,\n\t\t\twant:      \"测试文件名_非常长的中文文件名_.pdf\", // Truncated at byte boundary, preserving UTF-8\n\t\t},\n\t\t{\n\t\t\tname:      \"extension longer than reasonable (>20 chars)\",\n\t\t\tfilename:  \"test.verylongextensionthatshouldnotbetreatedasextension\",\n\t\t\tmaxLength: 30,\n\t\t\twant:      \"test.verylongextensionthatshou\",\n\t\t},\n\t\t{\n\t\t\tname:      \"very short max length with extension\",\n\t\t\tfilename:  \"document.pdf\",\n\t\t\tmaxLength: 10,\n\t\t\twant:      \"docume.pdf\",\n\t\t},\n\t\t{\n\t\t\tname:      \"maxLength smaller than extension\",\n\t\t\tfilename:  \"test.pdf\",\n\t\t\tmaxLength: 3,\n\t\t\twant:      \"tes\",\n\t\t},\n\t\t{\n\t\t\tname:      \"empty filename\",\n\t\t\tfilename:  \"\",\n\t\t\tmaxLength: 100,\n\t\t\twant:      \"\",\n\t\t},\n\t\t{\n\t\t\tname:      \"filename with spaces\",\n\t\t\tfilename:  \"my document with spaces and a very long name that needs to be truncated.docx\",\n\t\t\tmaxLength: 50,\n\t\t\twant:      \"my document with spaces and a very long name .docx\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := TruncateFilename(tt.filename, tt.maxLength)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"TruncateFilename() = %q (len=%d), want %q (len=%d)\",\n\t\t\t\t\tgot, len(got), tt.want, len(tt.want))\n\t\t\t}\n\t\t\t// Verify result doesn't exceed maxLength\n\t\t\tif len(got) > tt.maxLength {\n\t\t\t\tt.Errorf(\"TruncateFilename() result length %d exceeds maxLength %d\",\n\t\t\t\t\tlen(got), tt.maxLength)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/util/path_windows.go",
    "content": "package util\n\nvar invalidPathChars = []string{`\\`, `/`, `:`, `*`, `?`, `\"`, `<`, `>`, `|`}\n"
  },
  {
    "path": "pkg/util/timer.go",
    "content": "package util\n\nimport \"time\"\n\ntype Timer struct {\n\tt    int64\n\tused int64\n}\n\nfunc NewTimer(used int64) *Timer {\n\treturn &Timer{\n\t\tused: used,\n\t}\n}\n\nfunc (t *Timer) Start() {\n\tt.t = time.Now().UnixNano()\n}\n\nfunc (t *Timer) Pause() {\n\tt.used += time.Now().UnixNano() - t.t\n}\n\nfunc (t *Timer) Used() int64 {\n\treturn (time.Now().UnixNano() - t.t) + t.used\n}\n"
  },
  {
    "path": "pkg/util/url.go",
    "content": "package util\n\nimport (\n\t\"encoding/base64\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n)\n\nfunc ParseSchema(url string) string {\n\tindex := strings.Index(url, \":\")\n\tif index == -1 || index == 1 {\n\t\treturn \"\"\n\t}\n\tschema := url[:index]\n\treturn strings.ToUpper(schema)\n}\n\n// ParseDataUri parses a data URI and returns the MIME type and decode data.\nfunc ParseDataUri(uri string) (string, []byte) {\n\tre := regexp.MustCompile(`^data:(.*);base64,(.*)$`)\n\tmatches := re.FindStringSubmatch(uri)\n\tif len(matches) != 3 {\n\t\treturn \"\", nil\n\t}\n\tmime := matches[1]\n\tbase64Data := matches[2]\n\tdata, err := base64.StdEncoding.DecodeString(base64Data)\n\tif err != nil {\n\t\treturn \"\", nil\n\t}\n\treturn mime, data\n}\n\n// BuildProxyUrl builds a proxy url with given host, username and password.\nfunc BuildProxyUrl(scheme, host, usr, pwd string) *url.URL {\n\tvar user *url.Userinfo\n\tif usr != \"\" && pwd != \"\" {\n\t\tuser = url.UserPassword(usr, pwd)\n\t}\n\treturn &url.URL{\n\t\tScheme: scheme,\n\t\tUser:   user,\n\t\tHost:   host,\n\t}\n}\n\n// ProxyUrlToHandler gets the proxy handler from the proxy url.\nfunc ProxyUrlToHandler(proxyUrl *url.URL) func(*http.Request) (*url.URL, error) {\n\tif proxyUrl == nil {\n\t\treturn nil\n\t}\n\tif proxyUrl.Scheme == \"system\" {\n\t\treturn http.ProxyFromEnvironment\n\t}\n\treturn http.ProxyURL(proxyUrl)\n}\n\n// TryUrlQueryUnescape tries to unescape a URL-encoded string.\n//\n// If unescaping fails, it returns the original string.\nfunc TryUrlQueryUnescape(s string) string {\n\tif decoded, err := url.QueryUnescape(s); err == nil {\n\t\treturn decoded\n\t}\n\treturn s\n}\n\n// TryUrlPathUnescape tries to unescape a URL path-encoded string.\n// Unlike QueryUnescape, PathUnescape does not treat '+' as a space.\n// This is the correct function to use for decoding URL paths and filenames\n// where %2B should decode to '+', not to a space.\n//\n// If unescaping fails, it returns the original string.\nfunc TryUrlPathUnescape(s string) string {\n\tif decoded, err := url.PathUnescape(s); err == nil {\n\t\treturn decoded\n\t}\n\treturn s\n}\n"
  },
  {
    "path": "pkg/util/url_test.go",
    "content": "package util\n\nimport (\n\t\"encoding/base64\"\n\t\"reflect\"\n\t\"testing\"\n)\n\nfunc TestParseSchema(t *testing.T) {\n\ttype args struct {\n\t\turl string\n\t}\n\ttests := []struct {\n\t\tname string\n\t\targs args\n\t\twant string\n\t}{\n\t\t{\n\t\t\tname: \"http\",\n\t\t\targs: args{\n\t\t\t\turl: \"http://www.google.com\",\n\t\t\t},\n\t\t\twant: \"HTTP\",\n\t\t},\n\t\t{\n\t\t\tname: \"https\",\n\t\t\targs: args{\n\t\t\t\turl: \"https://www.google.com\",\n\t\t\t},\n\t\t\twant: \"HTTPS\",\n\t\t},\n\t\t{\n\t\t\tname: \"file\",\n\t\t\targs: args{\n\t\t\t\turl: \"file:///home/bt.torrent\",\n\t\t\t},\n\t\t\twant: \"FILE\",\n\t\t},\n\t\t{\n\t\t\tname: \"file-no-scheme\",\n\t\t\targs: args{\n\t\t\t\turl: \"./url.go\",\n\t\t\t},\n\t\t\twant: \"\",\n\t\t},\n\t\t{\n\t\t\tname: \"data-uri\",\n\t\t\targs: args{\n\t\t\t\turl: \"data:application/x-bittorrent;base64,test\",\n\t\t\t},\n\t\t\twant: \"DATA\",\n\t\t},\n\t\t{\n\t\t\tname: \"windows-path\",\n\t\t\targs: args{\n\t\t\t\turl: \"D:\\\\bt.torrent\",\n\t\t\t},\n\t\t\twant: \"\",\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := ParseSchema(tt.args.url); got != tt.want {\n\t\t\t\tt.Errorf(\"ParseSchema() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestParseDataUri(t *testing.T) {\n\ttype args struct {\n\t\turi string\n\t}\n\ttype result struct {\n\t\tmime string\n\t\tdata []byte\n\t}\n\n\ttestData := []byte(\"test\")\n\ttestData64 := base64.StdEncoding.EncodeToString(testData)\n\n\ttests := []struct {\n\t\tname string\n\t\targs args\n\t\twant result\n\t}{\n\t\t{\n\t\t\tname: \"success\",\n\t\t\targs: args{\n\t\t\t\turi: \"data:application/x-bittorrent;base64,\" + testData64,\n\t\t\t},\n\t\t\twant: result{\n\t\t\t\tmime: \"application/x-bittorrent\",\n\t\t\t\tdata: testData,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"fail-dirty-data\",\n\t\t\targs: args{\n\t\t\t\turi: \"data::application/x-bittorrent;base64,!@$\",\n\t\t\t},\n\t\t\twant: result{\n\t\t\t\tmime: \"\",\n\t\t\t\tdata: nil,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"fail-miss-data\",\n\t\t\targs: args{\n\t\t\t\turi: \":application/x-bittorrent;base64,\" + testData64,\n\t\t\t},\n\t\t\twant: result{\n\t\t\t\tmime: \"\",\n\t\t\t\tdata: nil,\n\t\t\t},\n\t\t},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tmime, data := ParseDataUri(tt.args.uri)\n\t\t\tgot := result{\n\t\t\t\tmime: mime,\n\t\t\t\tdata: data,\n\t\t\t}\n\t\t\tif !reflect.DeepEqual(got, tt.want) {\n\t\t\t\tt.Errorf(\"ParseDataUri() = %v, want %v\", got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestTryURLDecode(t *testing.T) {\n\ttests := []struct {\n\t\tinput    string\n\t\texpected string\n\t}{\n\t\t{\"normal.txt\", \"normal.txt\"},\n\t\t{\"%E7%8F%80%E5%B0%94%E8%AF%BA.zip\", \"珀尔诺.zip\"},\n\t\t{\"hello%20world.txt\", \"hello world.txt\"},\n\t\t{\"bad%2-text\", \"bad%2-text\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.input, func(t *testing.T) {\n\t\t\tgot := TryUrlQueryUnescape(tt.input)\n\t\t\tif got != tt.expected {\n\t\t\t\tt.Errorf(\"TryUrlQueryUnescape(%q) = %q, want %q\", tt.input, got, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestTryUrlPathUnescape(t *testing.T) {\n\ttests := []struct {\n\t\tinput    string\n\t\texpected string\n\t}{\n\t\t{\"normal.txt\", \"normal.txt\"},\n\t\t{\"%E7%8F%80%E5%B0%94%E8%AF%BA.zip\", \"珀尔诺.zip\"},\n\t\t{\"hello%20world.txt\", \"hello world.txt\"},\n\t\t{\"bad%2-text\", \"bad%2-text\"},\n\t\t// The key difference: %2B should decode to + (not space)\n\t\t{\"C%2B%2B%20Primer.txt\", \"C++ Primer.txt\"},\n\t\t{\"test%2Bfile.txt\", \"test+file.txt\"},\n\t\t// Plus sign in path should remain as-is (not decoded to space)\n\t\t{\"test+plus.txt\", \"test+plus.txt\"},\n\t\t// Mixed encoding\n\t\t{\"C%2B%2B%20%20Primer%20%20Plus.mobi\", \"C++  Primer  Plus.mobi\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.input, func(t *testing.T) {\n\t\t\tgot := TryUrlPathUnescape(tt.input)\n\t\t\tif got != tt.expected {\n\t\t\t\tt.Errorf(\"TryUrlPathUnescape(%q) = %q, want %q\", tt.input, got, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "ui/flutter/.gitignore",
    "content": "# Miscellaneous\n*.class\n*.log\n*.pyc\n*.swp\n.DS_Store\n.atom/\n.buildlog/\n.history\n.svn/\n\n# IntelliJ related\n*.iml\n*.ipr\n*.iws\n.idea/\n\n# The .vscode folder contains launch configuration and tasks you configure in\n# VS Code which you may wish to be included in version control, so this line\n# is commented out by default.\n#.vscode/\n\n# Flutter/Dart/Pub related\n**/doc/api/\n**/ios/Flutter/.last_build_id\n.dart_tool/\n.flutter-plugins\n.flutter-plugins-dependencies\n.packages\n.pub-cache/\n.pub/\nbuild/\n.fvm/\n\n# Web related\n\n# Symbolication related\napp.*.symbols\n\n# Obfuscation related\napp.*.map.json\n\n# Exceptions to above rules.\n!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages\n\n# Libgopeed\nwindows/libgopeed.h\nwindows/libgopeed.dll\nmacos/Frameworks/libgopeed.h\nmacos/Frameworks/libgopeed.dylib\nlinux/bundle/lib/libgopeed.h\nlinux/bundle/lib/libgopeed.dylib\nandroid/app/libs/libgopeed.aar\nandroid/app/libs/libgopeed-sources.jar\n\ndebian/packages\n\nlinux/flutter/generated_plugin_registrant.cc\nlinux/flutter/generated_plugin_registrant.h\nlinux/flutter/generated_plugins.cmake\nmacos/Flutter/GeneratedPluginRegistrant.swift\nwindows/flutter/generated_plugin_registrant.cc\nwindows/flutter/generated_plugin_registrant.h\nwindows/flutter/generated_plugins.cmake\n\n/extensions\n\n# Hive database files\ndatabase.hive\ndatabase.lock\n\nassets/exec/host\nassets/exec/host.exe\nassets/exec/updater\nassets/exec/updater.exe\n"
  },
  {
    "path": "ui/flutter/.metadata",
    "content": "# This file tracks properties of this Flutter project.\n# Used by Flutter tool to assess capabilities and perform upgrades etc.\n#\n# This file should be version controlled.\n\nversion:\n  revision: 7048ed95a5ad3e43d697e0c397464193991fc230\n  channel: stable\n\nproject_type: app\n\n# Tracks metadata for the flutter migrate command\nmigration:\n  platforms:\n    - platform: root\n      create_revision: 7048ed95a5ad3e43d697e0c397464193991fc230\n      base_revision: 7048ed95a5ad3e43d697e0c397464193991fc230\n    - platform: windows\n      create_revision: 7048ed95a5ad3e43d697e0c397464193991fc230\n      base_revision: 7048ed95a5ad3e43d697e0c397464193991fc230\n\n  # User provided section\n\n  # List of Local paths (relative to this file) that should be\n  # ignored by the migrate tool.\n  #\n  # Files that are not part of the templates will be ignored by default.\n  unmanaged_files:\n    - 'lib/main.dart'\n    - 'ios/Runner.xcodeproj/project.pbxproj'\n"
  },
  {
    "path": "ui/flutter/analysis_options.yaml",
    "content": "# This file configures the analyzer, which statically analyzes Dart code to\n# check for errors, warnings, and lints.\n#\n# The issues identified by the analyzer are surfaced in the UI of Dart-enabled\n# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be\n# invoked from the command line by running `flutter analyze`.\n\n# The following line activates a set of recommended lints for Flutter apps,\n# packages, and plugins designed to encourage good coding practices.\ninclude: package:flutter_lints/flutter.yaml\n\nlinter:\n  # The lint rules applied to this project can be customized in the\n  # section below to disable rules from the `package:flutter_lints/flutter.yaml`\n  # included above or to enable additional rules. A list of all available lints\n  # and their documentation is published at\n  # https://dart-lang.github.io/linter/lints/index.html.\n  #\n  # Instead of disabling a lint rule for the entire project in the\n  # section below, it can also be suppressed for a single line of code\n  # or a specific dart file by using the `// ignore: name_of_lint` and\n  # `// ignore_for_file: name_of_lint` syntax on the line or in the file\n  # producing the lint.\n  rules:\n    # avoid_print: false  # Uncomment to disable the `avoid_print` rule\n    # prefer_single_quotes: true  # Uncomment to enable the `prefer_single_quotes` rule\n\n# Additional information about this file can be found at\n# https://dart.dev/guides/language/analysis-options\n"
  },
  {
    "path": "ui/flutter/android/.gitignore",
    "content": "gradle-wrapper.jar\n/.gradle\n/captures/\n/gradlew\n/gradlew.bat\n/local.properties\nGeneratedPluginRegistrant.java\n\n# Remember to never publicly share your keystore.\n# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app\nkey.properties\n**/*.keystore\n**/*.jks\n"
  },
  {
    "path": "ui/flutter/android/app/build.gradle",
    "content": "plugins {\n    id 'com.android.application'\n    id 'kotlin-android'\n    id 'dev.flutter.flutter-gradle-plugin'\n}\n\ndef localProperties = new Properties()\ndef localPropertiesFile = rootProject.file('local.properties')\nif (localPropertiesFile.exists()) {\n    localPropertiesFile.withReader('UTF-8') { reader ->\n        localProperties.load(reader)\n    }\n}\n\ndef flutterVersionCode = localProperties.getProperty('flutter.versionCode')\nif (flutterVersionCode == null) {\n    flutterVersionCode = '1'\n}\n\ndef flutterVersionName = localProperties.getProperty('flutter.versionName')\nif (flutterVersionName == null) {\n    flutterVersionName = '1.0'\n}\n\nandroid {\n    namespace 'com.gopeed.gopeed'\n    compileSdk 35\n    ndkVersion flutter.ndkVersion\n\n    compileOptions {\n        coreLibraryDesugaringEnabled true\n        sourceCompatibility JavaVersion.VERSION_17\n        targetCompatibility JavaVersion.VERSION_17\n    }\n\n    kotlinOptions {\n        jvmTarget = '17'\n    }\n\n    sourceSets {\n        main.java.srcDirs += 'src/main/kotlin'\n    }\n\n    defaultConfig {\n        // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).\n        applicationId 'com.gopeed.gopeed'\n        // You can update the following values to match your application needs.\n        // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration.\n        minSdk flutter.minSdkVersion\n        targetSdk 35\n        versionCode flutterVersionCode.toInteger()\n        versionName flutterVersionName\n    }\n\n    signingConfigs {\n        release {\n            keyAlias 'upload'\n            keyPassword System.getenv('APK_KEY_PASSWORD')\n            storeFile file('upload-keystore.jks')\n            storePassword System.getenv('APK_STORE_PASSWORD')\n        }\n    }\n\n    buildTypes {\n        release {\n            if (signingConfigs.release.keyPassword != null) {\n                signingConfig signingConfigs.release\n            } else {\n                signingConfig signingConfigs.debug\n            }\n        }\n    }\n\n    repositories {\n        flatDir {\n            dirs 'libs'\n        }\n    }\n}\n\nflutter {\n    source '../..'\n}\n\ndependencies {\n    implementation(name: 'libgopeed', ext: 'aar')\n    coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'\n}\n"
  },
  {
    "path": "ui/flutter/android/app/libs/.gitkeep",
    "content": ""
  },
  {
    "path": "ui/flutter/android/app/src/debug/AndroidManifest.xml",
    "content": "<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n    <!-- The INTERNET permission is required for development. Specifically,\n         the Flutter tool needs it to communicate with the running application\n         to allow setting breakpoints, to provide hot reload, etc.\n    -->\n    <uses-permission android:name=\"android.permission.INTERNET\" />\n</manifest>"
  },
  {
    "path": "ui/flutter/android/app/src/main/AndroidManifest.xml",
    "content": "<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n\n    <uses-permission android:name=\"android.permission.INTERNET\" />\n    <uses-permission android:name=\"android.permission.READ_MEDIA_IMAGES\" />\n    <uses-permission android:name=\"android.permission.READ_MEDIA_AUDIO\" />\n    <uses-permission android:name=\"android.permission.READ_MEDIA_VIDEO\" />\n    <uses-permission android:name=\"android.permission.READ_EXTERNAL_STORAGE\"\n        android:maxSdkVersion=\"32\" />\n    <uses-permission android:name=\"android.permission.WRITE_EXTERNAL_STORAGE\"\n        android:maxSdkVersion=\"32\" />\n    <uses-permission android:name=\"android.permission.FOREGROUND_SERVICE\" />\n    <uses-permission android:name=\"android.permission.FOREGROUND_SERVICE_DATA_SYNC\" />\n    <uses-permission android:name=\"android.permission.REQUEST_INSTALL_PACKAGES\" />\n\n    <application\n        android:requestLegacyExternalStorage=\"true\"\n        android:name=\"${applicationName}\"\n        android:icon=\"@mipmap/ic_launcher\"\n        android:label=\"Gopeed\"\n        android:extractNativeLibs=\"true\">\n        <activity\n            android:name=\".MainActivity\"\n            android:configChanges=\"orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode\"\n            android:exported=\"true\"\n            android:hardwareAccelerated=\"true\"\n            android:launchMode=\"singleInstance\"\n            android:theme=\"@style/LaunchTheme\"\n            android:windowSoftInputMode=\"adjustResize\">\n\n            <intent-filter>\n                <action android:name=\"android.intent.action.VIEW\" />\n                <category android:name=\"android.intent.category.DEFAULT\" />\n                <category android:name=\"android.intent.category.BROWSABLE\" />\n                <data\n                    android:scheme=\"file\"\n                    android:host=\"*\"\n                    android:pathPattern=\".*torrent\" />\n            </intent-filter>\n            <intent-filter>\n                <action android:name=\"android.intent.action.VIEW\" />\n                <category android:name=\"android.intent.category.DEFAULT\" />\n                <category android:name=\"android.intent.category.BROWSABLE\" />\n                <data\n                    android:scheme=\"file\"\n                    android:host=\"*\"\n                    android:mimeType=\"application/x-bittorrent\" />\n            </intent-filter>\n            <intent-filter>\n                <action android:name=\"android.intent.action.VIEW\" />\n                <category android:name=\"android.intent.category.DEFAULT\" />\n                <category android:name=\"android.intent.category.BROWSABLE\" />\n                <data\n                    android:scheme=\"content\"\n                    android:host=\"*\"\n                    android:pathPattern=\".*torrent\" />\n            </intent-filter>\n            <intent-filter>\n                <action android:name=\"android.intent.action.VIEW\" />\n                <category android:name=\"android.intent.category.DEFAULT\" />\n                <category android:name=\"android.intent.category.BROWSABLE\" />\n                <data\n                    android:scheme=\"content\"\n                    android:host=\"*\"\n                    android:mimeType=\"application/x-bittorrent\" />\n            </intent-filter>\n            <intent-filter>\n                <action android:name=\"android.intent.action.VIEW\" />\n                <category android:name=\"android.intent.category.DEFAULT\" />\n                <category android:name=\"android.intent.category.BROWSABLE\" />\n                <data\n                    android:scheme=\"magnet\" />\n            </intent-filter>\n            <!-- Handle http/https URLs with common downloadable mimeTypes -->\n            <intent-filter android:label=\"Gopeed\">\n                <action android:name=\"android.intent.action.VIEW\" />\n                <category android:name=\"android.intent.category.DEFAULT\" />\n                <category android:name=\"android.intent.category.BROWSABLE\" />\n                <data android:scheme=\"http\" />\n                <data android:scheme=\"https\" />\n                <data android:mimeType=\"*/*\" />\n            </intent-filter>\n            <intent-filter>\n                <action android:name=\"android.intent.action.VIEW_DOWNLOADS\" />\n                <category android:name=\"android.intent.category.DEFAULT\" />\n            </intent-filter>\n\n            <!-- Specifies an Android theme to apply to this Activity as soon as\n                 the Android process has started. This theme is visible to the user\n                 while the Flutter UI initializes. After that, this theme continues\n                 to determine the Window background behind the Flutter UI. -->\n            <meta-data\n                android:name=\"io.flutter.embedding.android.NormalTheme\"\n                android:resource=\"@style/NormalTheme\" />\n            <intent-filter>\n                <action android:name=\"android.intent.action.MAIN\" />\n                <category android:name=\"android.intent.category.LAUNCHER\" />\n            </intent-filter>\n            <intent-filter>\n                <action android:name=\"android.intent.action.SEND\" />\n                <action android:name=\"android.intent.action.SEND_MULTIPLE\" />\n\n                <category android:name=\"android.intent.category.DEFAULT\" />\n                <data android:mimeType=\"*/*\" />\n            </intent-filter>\n        </activity>\n\n        <!-- Add android:stopWithTask option only when necessary. -->\n        <service\n            android:name=\"com.pravera.flutter_foreground_task.service.ForegroundService\"\n            android:foregroundServiceType=\"dataSync\"\n            android:exported=\"false\" />\n\n        <!-- Don't delete the meta-data below.\n             This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->\n        <meta-data\n            android:name=\"flutterEmbedding\"\n            android:value=\"2\" />\n    </application>\n</manifest>"
  },
  {
    "path": "ui/flutter/android/app/src/main/kotlin/com/gopeed/gopeed/MainActivity.kt",
    "content": "package com.gopeed.gopeed\n\nimport androidx.annotation.NonNull\nimport com.gopeed.libgopeed.Libgopeed\nimport io.flutter.embedding.android.FlutterActivity\nimport io.flutter.embedding.engine.FlutterEngine\nimport io.flutter.plugin.common.MethodChannel\nimport io.flutter.plugin.common.StandardMethodCodec\n\nclass MainActivity : FlutterActivity() {\n    private val CHANNEL = \"gopeed.com/libgopeed\"\n\n    override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {\n        super.configureFlutterEngine(flutterEngine)\n        val taskQueue =\n            flutterEngine.dartExecutor.binaryMessenger.makeBackgroundTaskQueue()\n        MethodChannel(\n            flutterEngine.dartExecutor.binaryMessenger,\n            CHANNEL,\n            StandardMethodCodec.INSTANCE,\n            taskQueue\n        ).setMethodCallHandler { call, result ->\n            when (call.method) {\n                \"start\" -> {\n                    val cfg = call.argument<String>(\"cfg\")\n                    try {\n                        val port = Libgopeed.start(cfg)\n                        result.success(port)\n                    } catch (e: Exception) {\n                        result.error(\"ERROR\", e.message, null)\n                    }\n                }\n                \"stop\" -> {\n                    Libgopeed.stop()\n                    result.success(null)\n                }\n                else -> {\n                    result.notImplemented()\n                }\n            }\n        }\n    }\n\n}\n"
  },
  {
    "path": "ui/flutter/android/app/src/main/res/drawable/launch_background.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!-- Modify this file to customize your launch splash screen -->\n<layer-list xmlns:android=\"http://schemas.android.com/apk/res/android\">\n    <item android:drawable=\"@android:color/white\" />\n\n    <!-- You can insert your own image assets here -->\n    <!-- <item>\n        <bitmap\n            android:gravity=\"center\"\n            android:src=\"@mipmap/launch_image\" />\n    </item> -->\n</layer-list>\n"
  },
  {
    "path": "ui/flutter/android/app/src/main/res/drawable-v21/launch_background.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!-- Modify this file to customize your launch splash screen -->\n<layer-list xmlns:android=\"http://schemas.android.com/apk/res/android\">\n    <item android:drawable=\"?android:colorBackground\" />\n\n    <!-- You can insert your own image assets here -->\n    <!-- <item>\n        <bitmap\n            android:gravity=\"center\"\n            android:src=\"@mipmap/launch_image\" />\n    </item> -->\n</layer-list>\n"
  },
  {
    "path": "ui/flutter/android/app/src/main/res/values/styles.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n    <!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->\n    <style name=\"LaunchTheme\" parent=\"@android:style/Theme.Light.NoTitleBar\">\n        <!-- Show a splash screen on the activity. Automatically removed when\n             the Flutter engine draws its first frame -->\n        <item name=\"android:windowBackground\">@drawable/launch_background</item>\n    </style>\n    <!-- Theme applied to the Android Window as soon as the process has started.\n         This theme determines the color of the Android Window while your\n         Flutter UI initializes, as well as behind your Flutter UI while its\n         running.\n\n         This Theme is only used starting with V2 of Flutter's Android embedding. -->\n    <style name=\"NormalTheme\" parent=\"@android:style/Theme.Light.NoTitleBar\">\n        <item name=\"android:windowBackground\">?android:colorBackground</item>\n    </style>\n</resources>\n"
  },
  {
    "path": "ui/flutter/android/app/src/main/res/values-night/styles.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n    <!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->\n    <style name=\"LaunchTheme\" parent=\"@android:style/Theme.Black.NoTitleBar\">\n        <!-- Show a splash screen on the activity. Automatically removed when\n             the Flutter engine draws its first frame -->\n        <item name=\"android:windowBackground\">@drawable/launch_background</item>\n    </style>\n    <!-- Theme applied to the Android Window as soon as the process has started.\n         This theme determines the color of the Android Window while your\n         Flutter UI initializes, as well as behind your Flutter UI while its\n         running.\n\n         This Theme is only used starting with V2 of Flutter's Android embedding. -->\n    <style name=\"NormalTheme\" parent=\"@android:style/Theme.Black.NoTitleBar\">\n        <item name=\"android:windowBackground\">?android:colorBackground</item>\n    </style>\n</resources>\n"
  },
  {
    "path": "ui/flutter/android/app/src/profile/AndroidManifest.xml",
    "content": "<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n    <!-- The INTERNET permission is required for development. Specifically,\n         the Flutter tool needs it to communicate with the running application\n         to allow setting breakpoints, to provide hot reload, etc.\n    -->\n    <uses-permission android:name=\"android.permission.INTERNET\" />\n</manifest>"
  },
  {
    "path": "ui/flutter/android/build.gradle",
    "content": "allprojects {\n    repositories {\n        google()\n        mavenCentral()\n    }\n}\n\nrootProject.buildDir = '../build'\nsubprojects {\n    project.buildDir = \"${rootProject.buildDir}/${project.name}\"\n}\nsubprojects {\n    project.evaluationDependsOn(':app')\n}\n\nsubprojects { subproject ->\n    subproject.plugins.withId('com.android.library') {\n        subproject.android {\n            if (namespace == null || namespace.isEmpty()) {\n                def manifest = file(\"${subproject.projectDir}/src/main/AndroidManifest.xml\")\n                if (manifest.exists()) {\n                    def manifestContent = manifest.getText()\n                    def packagePattern = /package\\s*=\\s*[\"']([^\"']+)[\"']/\n                    def matcher = (manifestContent =~ packagePattern)\n                    if (matcher.find()) {\n                        namespace = matcher.group(1)\n                    }\n                }\n            }\n        }\n    }\n}\n\ntasks.register('clean', Delete) {\n    delete rootProject.buildDir\n}\n"
  },
  {
    "path": "ui/flutter/android/gradle/wrapper/gradle-wrapper.properties",
    "content": "#Fri Jun 23 08:50:38 CEST 2017\ndistributionBase=GRADLE_USER_HOME\ndistributionPath=wrapper/dists\nzipStoreBase=GRADLE_USER_HOME\nzipStorePath=wrapper/dists\ndistributionUrl=https\\://services.gradle.org/distributions/gradle-8.11.1-all.zip\n"
  },
  {
    "path": "ui/flutter/android/gradle.properties",
    "content": "org.gradle.jvmargs=-Xmx4G -XX:+HeapDumpOnOutOfMemoryError\nandroid.useAndroidX=true\nandroid.enableJetifier=true\nandroid.nonTransitiveRClass=true\nandroid.nonFinalResIds=true\nandroid.defaults.buildfeatures.buildconfig=true\n"
  },
  {
    "path": "ui/flutter/android/settings.gradle",
    "content": "pluginManagement {\n    def flutterSdkPath = {\n        def properties = new Properties()\n        file('local.properties').withInputStream { properties.load(it) }\n        def flutterSdkPath = properties.getProperty('flutter.sdk')\n        assert flutterSdkPath != null, 'flutter.sdk not set in local.properties'\n        return flutterSdkPath\n    }()\n\n    includeBuild(\"$flutterSdkPath/packages/flutter_tools/gradle\")\n\n    repositories {\n        google()\n        mavenCentral()\n        gradlePluginPortal()\n    }\n}\n\nplugins {\n    id 'dev.flutter.flutter-plugin-loader' version '1.0.0'\n    id 'com.android.application' version '8.7.3' apply false\n    id 'org.jetbrains.kotlin.android' version '2.1.0' apply false\n}\n\ninclude ':app'\n"
  },
  {
    "path": "ui/flutter/assets/exec/.gitkeep",
    "content": ""
  },
  {
    "path": "ui/flutter/build.yaml",
    "content": "targets:\n  $default:\n    builders:\n      json_serializable:\n        options:\n          # Options configure how source code is generated for every\n          # `@JsonSerializable`-annotated class in the package.\n          #\n          # The default value for each is listed.\n          any_map: false\n          checked: false\n          create_factory: true\n          create_to_json: true\n          disallow_unrecognized_keys: false\n          explicit_to_json: false\n          field_rename: none\n          generic_argument_factories: false\n          ignore_unannotated: false\n          include_if_null: false"
  },
  {
    "path": "ui/flutter/distribute_options.yaml",
    "content": "output: dist/"
  },
  {
    "path": "ui/flutter/include/libgopeed.h",
    "content": "/* Code generated by cmd/cgo; DO NOT EDIT. */\n\n/* package github.com/GopeedLab/gopeed/bind/desktop */\n\n\n#line 1 \"cgo-builtin-export-prolog\"\n\n#include <stddef.h>\n\n#ifndef GO_CGO_EXPORT_PROLOGUE_H\n#define GO_CGO_EXPORT_PROLOGUE_H\n\n#ifndef GO_CGO_GOSTRING_TYPEDEF\ntypedef struct { const char *p; ptrdiff_t n; } _GoString_;\n#endif\n\n#endif\n\n/* Start of preamble from import \"C\" comments.  */\n\n\n\n\n/* End of preamble from import \"C\" comments.  */\n\n\n/* Start of boilerplate cgo prologue.  */\n#line 1 \"cgo-gcc-export-header-prolog\"\n\n#ifndef GO_CGO_PROLOGUE_H\n#define GO_CGO_PROLOGUE_H\n\ntypedef signed char GoInt8;\ntypedef unsigned char GoUint8;\ntypedef short GoInt16;\ntypedef unsigned short GoUint16;\ntypedef int GoInt32;\ntypedef unsigned int GoUint32;\ntypedef long long GoInt64;\ntypedef unsigned long long GoUint64;\ntypedef GoInt64 GoInt;\ntypedef GoUint64 GoUint;\ntypedef size_t GoUintptr;\ntypedef float GoFloat32;\ntypedef double GoFloat64;\n#ifdef _MSC_VER\n#include <complex.h>\ntypedef _Fcomplex GoComplex64;\ntypedef _Dcomplex GoComplex128;\n#else\ntypedef float _Complex GoComplex64;\ntypedef double _Complex GoComplex128;\n#endif\n\n/*\n  static assertion to make sure the file is being used on architecture\n  at least with matching size of GoInt.\n*/\ntypedef char _check_for_64_bit_pointer_matching_GoInt[sizeof(void*)==64/8 ? 1:-1];\n\n#ifndef GO_CGO_GOSTRING_TYPEDEF\ntypedef _GoString_ GoString;\n#endif\ntypedef void *GoMap;\ntypedef void *GoChan;\ntypedef struct { void *t; void *v; } GoInterface;\ntypedef struct { void *data; GoInt len; GoInt cap; } GoSlice;\n\n#endif\n\n/* End of boilerplate cgo prologue.  */\n\n#ifdef __cplusplus\nextern \"C\" {\n#endif\n\n\n/* Return type for Start */\nstruct Start_return {\n\tGoInt r0;\n\tchar* r1;\n};\nextern struct Start_return Start(char* cfg);\nextern void Stop();\n\n#ifdef __cplusplus\n}\n#endif\n"
  },
  {
    "path": "ui/flutter/ios/.gitignore",
    "content": "**/dgph\n*.mode1v3\n*.mode2v3\n*.moved-aside\n*.pbxuser\n*.perspectivev3\n**/*sync/\n.sconsign.dblite\n.tags*\n**/.vagrant/\n**/DerivedData/\nIcon?\n**/Pods/\n**/.symlinks/\nprofile\nxcuserdata\n**/.generated/\nFlutter/App.framework\nFlutter/Flutter.framework\nFlutter/Flutter.podspec\nFlutter/Generated.xcconfig\nFlutter/ephemeral/\nFlutter/app.flx\nFlutter/app.zip\nFlutter/flutter_assets/\nFlutter/flutter_export_environment.sh\nServiceDefinitions.json\nRunner/GeneratedPluginRegistrant.*\n\n# Exceptions to above rules.\n!default.mode1v3\n!default.mode2v3\n!default.pbxuser\n!default.perspectivev3\n"
  },
  {
    "path": "ui/flutter/ios/Flutter/AppFrameworkInfo.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n  <key>CFBundleDevelopmentRegion</key>\n  <string>en</string>\n  <key>CFBundleExecutable</key>\n  <string>App</string>\n  <key>CFBundleIdentifier</key>\n  <string>io.flutter.flutter.app</string>\n  <key>CFBundleInfoDictionaryVersion</key>\n  <string>6.0</string>\n  <key>CFBundleName</key>\n  <string>App</string>\n  <key>CFBundlePackageType</key>\n  <string>FMWK</string>\n  <key>CFBundleShortVersionString</key>\n  <string>1.0</string>\n  <key>CFBundleSignature</key>\n  <string>????</string>\n  <key>CFBundleVersion</key>\n  <string>1.0</string>\n  <key>MinimumOSVersion</key>\n  <string>11.0</string>\n</dict>\n</plist>\n"
  },
  {
    "path": "ui/flutter/ios/Flutter/Debug.xcconfig",
    "content": "#include? \"Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig\"\n#include \"Generated.xcconfig\"\n"
  },
  {
    "path": "ui/flutter/ios/Flutter/Release.xcconfig",
    "content": "#include? \"Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig\"\n#include \"Generated.xcconfig\"\n"
  },
  {
    "path": "ui/flutter/ios/Podfile",
    "content": "# Uncomment this line to define a global platform for your project\n# platform :ios, '11.0'\n\n# CocoaPods analytics sends network stats synchronously affecting flutter build latency.\nENV['COCOAPODS_DISABLE_STATS'] = 'true'\n\nproject 'Runner', {\n  'Debug' => :debug,\n  'Profile' => :release,\n  'Release' => :release,\n}\n\ndef flutter_root\n  generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)\n  unless File.exist?(generated_xcode_build_settings_path)\n    raise \"#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first\"\n  end\n\n  File.foreach(generated_xcode_build_settings_path) do |line|\n    matches = line.match(/FLUTTER_ROOT\\=(.*)/)\n    return matches[1].strip if matches\n  end\n  raise \"FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get\"\nend\n\nrequire File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)\n\nflutter_ios_podfile_setup\n\ntarget 'Runner' do\n  use_frameworks!\n  use_modular_headers!\n\n  flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))\n\n  # share_handler addition start\n  target 'ShareExtension' do\n    inherit! :search_paths\n    pod \"share_handler_ios_models\", :path => \".symlinks/plugins/share_handler_ios/ios/Models\"\n  end\n  # share_handler addition end\nend\n\npost_install do |installer|\n  installer.pods_project.targets.each do |target|\n    flutter_additional_ios_build_settings(target)\n  end\nend\n"
  },
  {
    "path": "ui/flutter/ios/Runner/AppDelegate.swift",
    "content": "import UIKit\nimport Flutter\nimport Libgopeed\n\n@UIApplicationMain\n@objc class AppDelegate: FlutterAppDelegate {\n    override func application(\n        _ application: UIApplication,\n        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?\n    ) -> Bool {\n        let controller : FlutterViewController = window?.rootViewController as! FlutterViewController\n        let batteryChannel = FlutterMethodChannel(name: \"gopeed.com/libgopeed\",\n                                                  binaryMessenger: controller.binaryMessenger)\n        batteryChannel.setMethodCallHandler({\n            (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in\n            switch call.method {\n            case \"start\":\n                let args = call.arguments as? Dictionary<String, Any>\n                let cfg = args?[\"cfg\"] as? String\n                let portPrt = UnsafeMutablePointer<Int>.allocate(capacity: MemoryLayout<Int>.stride)\n                var error: NSError?\n                if LibgopeedStart(cfg, portPrt, &error){\n                    result(portPrt.pointee)\n                }else{\n                    result(FlutterError(code: \"ERROR\", message: error.debugDescription, details: nil))\n                }\n            case \"stop\":\n                LibgopeedStop()\n                result(nil)\n            default:\n                result(FlutterMethodNotImplemented)\n            }\n        })\n        \n        GeneratedPluginRegistrant.register(with: self)\n\n        SwiftFlutterForegroundTaskPlugin.setPluginRegistrantCallback(registerPlugins)\n        if #available(iOS 10.0, *) {\n            UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate\n        }\n        return super.application(application, didFinishLaunchingWithOptions: launchOptions)\n    }\n}\n\nfunc registerPlugins(registry: FlutterPluginRegistry) {\n  GeneratedPluginRegistrant.register(with: registry)\n}\n"
  },
  {
    "path": "ui/flutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json",
    "content": "{\n  \"images\" : [\n    {\n      \"size\" : \"20x20\",\n      \"idiom\" : \"iphone\",\n      \"filename\" : \"Icon-App-20x20@2x.png\",\n      \"scale\" : \"2x\"\n    },\n    {\n      \"size\" : \"20x20\",\n      \"idiom\" : \"iphone\",\n      \"filename\" : \"Icon-App-20x20@3x.png\",\n      \"scale\" : \"3x\"\n    },\n    {\n      \"size\" : \"29x29\",\n      \"idiom\" : \"iphone\",\n      \"filename\" : \"Icon-App-29x29@1x.png\",\n      \"scale\" : \"1x\"\n    },\n    {\n      \"size\" : \"29x29\",\n      \"idiom\" : \"iphone\",\n      \"filename\" : \"Icon-App-29x29@2x.png\",\n      \"scale\" : \"2x\"\n    },\n    {\n      \"size\" : \"29x29\",\n      \"idiom\" : \"iphone\",\n      \"filename\" : \"Icon-App-29x29@3x.png\",\n      \"scale\" : \"3x\"\n    },\n    {\n      \"size\" : \"40x40\",\n      \"idiom\" : \"iphone\",\n      \"filename\" : \"Icon-App-40x40@2x.png\",\n      \"scale\" : \"2x\"\n    },\n    {\n      \"size\" : \"40x40\",\n      \"idiom\" : \"iphone\",\n      \"filename\" : \"Icon-App-40x40@3x.png\",\n      \"scale\" : \"3x\"\n    },\n    {\n      \"size\" : \"60x60\",\n      \"idiom\" : \"iphone\",\n      \"filename\" : \"Icon-App-60x60@2x.png\",\n      \"scale\" : \"2x\"\n    },\n    {\n      \"size\" : \"60x60\",\n      \"idiom\" : \"iphone\",\n      \"filename\" : \"Icon-App-60x60@3x.png\",\n      \"scale\" : \"3x\"\n    },\n    {\n      \"size\" : \"20x20\",\n      \"idiom\" : \"ipad\",\n      \"filename\" : \"Icon-App-20x20@1x.png\",\n      \"scale\" : \"1x\"\n    },\n    {\n      \"size\" : \"20x20\",\n      \"idiom\" : \"ipad\",\n      \"filename\" : \"Icon-App-20x20@2x.png\",\n      \"scale\" : \"2x\"\n    },\n    {\n      \"size\" : \"29x29\",\n      \"idiom\" : \"ipad\",\n      \"filename\" : \"Icon-App-29x29@1x.png\",\n      \"scale\" : \"1x\"\n    },\n    {\n      \"size\" : \"29x29\",\n      \"idiom\" : \"ipad\",\n      \"filename\" : \"Icon-App-29x29@2x.png\",\n      \"scale\" : \"2x\"\n    },\n    {\n      \"size\" : \"40x40\",\n      \"idiom\" : \"ipad\",\n      \"filename\" : \"Icon-App-40x40@1x.png\",\n      \"scale\" : \"1x\"\n    },\n    {\n      \"size\" : \"40x40\",\n      \"idiom\" : \"ipad\",\n      \"filename\" : \"Icon-App-40x40@2x.png\",\n      \"scale\" : \"2x\"\n    },\n    {\n      \"size\" : \"76x76\",\n      \"idiom\" : \"ipad\",\n      \"filename\" : \"Icon-App-76x76@1x.png\",\n      \"scale\" : \"1x\"\n    },\n    {\n      \"size\" : \"76x76\",\n      \"idiom\" : \"ipad\",\n      \"filename\" : \"Icon-App-76x76@2x.png\",\n      \"scale\" : \"2x\"\n    },\n    {\n      \"size\" : \"83.5x83.5\",\n      \"idiom\" : \"ipad\",\n      \"filename\" : \"Icon-App-83.5x83.5@2x.png\",\n      \"scale\" : \"2x\"\n    },\n    {\n      \"size\" : \"1024x1024\",\n      \"idiom\" : \"ios-marketing\",\n      \"filename\" : \"Icon-App-1024x1024@1x.png\",\n      \"scale\" : \"1x\"\n    }\n  ],\n  \"info\" : {\n    \"version\" : 1,\n    \"author\" : \"xcode\"\n  }\n}\n"
  },
  {
    "path": "ui/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json",
    "content": "{\n  \"images\" : [\n    {\n      \"idiom\" : \"universal\",\n      \"filename\" : \"LaunchImage.png\",\n      \"scale\" : \"1x\"\n    },\n    {\n      \"idiom\" : \"universal\",\n      \"filename\" : \"LaunchImage@2x.png\",\n      \"scale\" : \"2x\"\n    },\n    {\n      \"idiom\" : \"universal\",\n      \"filename\" : \"LaunchImage@3x.png\",\n      \"scale\" : \"3x\"\n    }\n  ],\n  \"info\" : {\n    \"version\" : 1,\n    \"author\" : \"xcode\"\n  }\n}\n"
  },
  {
    "path": "ui/flutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md",
    "content": "# Launch Screen Assets\n\nYou can customize the launch screen with your own desired assets by replacing the image files in this directory.\n\nYou 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."
  },
  {
    "path": "ui/flutter/ios/Runner/Base.lproj/LaunchScreen.storyboard",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n<document type=\"com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB\" version=\"3.0\" toolsVersion=\"12121\" systemVersion=\"16G29\" targetRuntime=\"iOS.CocoaTouch\" propertyAccessControl=\"none\" useAutolayout=\"YES\" launchScreen=\"YES\" colorMatched=\"YES\" initialViewController=\"01J-lp-oVM\">\n    <dependencies>\n        <deployment identifier=\"iOS\"/>\n        <plugIn identifier=\"com.apple.InterfaceBuilder.IBCocoaTouchPlugin\" version=\"12089\"/>\n    </dependencies>\n    <scenes>\n        <!--View Controller-->\n        <scene sceneID=\"EHf-IW-A2E\">\n            <objects>\n                <viewController id=\"01J-lp-oVM\" sceneMemberID=\"viewController\">\n                    <layoutGuides>\n                        <viewControllerLayoutGuide type=\"top\" id=\"Ydg-fD-yQy\"/>\n                        <viewControllerLayoutGuide type=\"bottom\" id=\"xbc-2k-c8Z\"/>\n                    </layoutGuides>\n                    <view key=\"view\" contentMode=\"scaleToFill\" id=\"Ze5-6b-2t3\">\n                        <autoresizingMask key=\"autoresizingMask\" widthSizable=\"YES\" heightSizable=\"YES\"/>\n                        <subviews>\n                            <imageView opaque=\"NO\" clipsSubviews=\"YES\" multipleTouchEnabled=\"YES\" contentMode=\"center\" image=\"LaunchImage\" translatesAutoresizingMaskIntoConstraints=\"NO\" id=\"YRO-k0-Ey4\">\n                            </imageView>\n                        </subviews>\n                        <color key=\"backgroundColor\" red=\"1\" green=\"1\" blue=\"1\" alpha=\"1\" colorSpace=\"custom\" customColorSpace=\"sRGB\"/>\n                        <constraints>\n                            <constraint firstItem=\"YRO-k0-Ey4\" firstAttribute=\"centerX\" secondItem=\"Ze5-6b-2t3\" secondAttribute=\"centerX\" id=\"1a2-6s-vTC\"/>\n                            <constraint firstItem=\"YRO-k0-Ey4\" firstAttribute=\"centerY\" secondItem=\"Ze5-6b-2t3\" secondAttribute=\"centerY\" id=\"4X2-HB-R7a\"/>\n                        </constraints>\n                    </view>\n                </viewController>\n                <placeholder placeholderIdentifier=\"IBFirstResponder\" id=\"iYj-Kq-Ea1\" userLabel=\"First Responder\" sceneMemberID=\"firstResponder\"/>\n            </objects>\n            <point key=\"canvasLocation\" x=\"53\" y=\"375\"/>\n        </scene>\n    </scenes>\n    <resources>\n        <image name=\"LaunchImage\" width=\"168\" height=\"185\"/>\n    </resources>\n</document>\n"
  },
  {
    "path": "ui/flutter/ios/Runner/Base.lproj/Main.storyboard",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n<document type=\"com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB\" version=\"3.0\" toolsVersion=\"10117\" systemVersion=\"15F34\" targetRuntime=\"iOS.CocoaTouch\" propertyAccessControl=\"none\" useAutolayout=\"YES\" useTraitCollections=\"YES\" initialViewController=\"BYZ-38-t0r\">\n    <dependencies>\n        <deployment identifier=\"iOS\"/>\n        <plugIn identifier=\"com.apple.InterfaceBuilder.IBCocoaTouchPlugin\" version=\"10085\"/>\n    </dependencies>\n    <scenes>\n        <!--Flutter View Controller-->\n        <scene sceneID=\"tne-QT-ifu\">\n            <objects>\n                <viewController id=\"BYZ-38-t0r\" customClass=\"FlutterViewController\" sceneMemberID=\"viewController\">\n                    <layoutGuides>\n                        <viewControllerLayoutGuide type=\"top\" id=\"y3c-jy-aDJ\"/>\n                        <viewControllerLayoutGuide type=\"bottom\" id=\"wfy-db-euE\"/>\n                    </layoutGuides>\n                    <view key=\"view\" contentMode=\"scaleToFill\" id=\"8bC-Xf-vdC\">\n                        <rect key=\"frame\" x=\"0.0\" y=\"0.0\" width=\"600\" height=\"600\"/>\n                        <autoresizingMask key=\"autoresizingMask\" widthSizable=\"YES\" heightSizable=\"YES\"/>\n                        <color key=\"backgroundColor\" white=\"1\" alpha=\"1\" colorSpace=\"custom\" customColorSpace=\"calibratedWhite\"/>\n                    </view>\n                </viewController>\n                <placeholder placeholderIdentifier=\"IBFirstResponder\" id=\"dkx-z0-nzr\" sceneMemberID=\"firstResponder\"/>\n            </objects>\n        </scene>\n    </scenes>\n</document>\n"
  },
  {
    "path": "ui/flutter/ios/Runner/Info.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n\t<dict>\n\t\t<key>CADisableMinimumFrameDurationOnPhone</key>\n\t\t<true />\n\t\t<key>CFBundleDevelopmentRegion</key>\n\t\t<string>$(DEVELOPMENT_LANGUAGE)</string>\n\t\t<key>CFBundleDisplayName</key>\n\t\t<string>Gopeed</string>\n\t\t<key>CFBundleExecutable</key>\n\t\t<string>$(EXECUTABLE_NAME)</string>\n\t\t<key>CFBundleIdentifier</key>\n\t\t<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>\n\t\t<key>CFBundleInfoDictionaryVersion</key>\n\t\t<string>6.0</string>\n\t\t<key>CFBundlePackageType</key>\n\t\t<string>APPL</string>\n\t\t<key>CFBundleShortVersionString</key>\n\t\t<string>$(FLUTTER_BUILD_NAME)</string>\n\t\t<key>CFBundleSignature</key>\n\t\t<string>????</string>\n\t\t<key>CFBundleVersion</key>\n\t\t<string>$(FLUTTER_BUILD_NUMBER)</string>\n\t\t<key>LSRequiresIPhoneOS</key>\n\t\t<true />\n\t\t<key>NSAppTransportSecurity</key>\n\t\t<dict>\n\t\t\t<key>NSAllowsArbitraryLoads</key>\n\t\t\t<true />\n\t\t</dict>\n\t\t<key>UILaunchStoryboardName</key>\n\t\t<string>LaunchScreen</string>\n\t\t<key>UIMainStoryboardFile</key>\n\t\t<string>Main</string>\n\t\t<key>UISupportedInterfaceOrientations</key>\n\t\t<array>\n\t\t\t<string>UIInterfaceOrientationPortrait</string>\n\t\t\t<string>UIInterfaceOrientationLandscapeLeft</string>\n\t\t\t<string>UIInterfaceOrientationLandscapeRight</string>\n\t\t</array>\n\t\t<key>UISupportedInterfaceOrientations~ipad</key>\n\t\t<array>\n\t\t\t<string>UIInterfaceOrientationPortrait</string>\n\t\t\t<string>UIInterfaceOrientationPortraitUpsideDown</string>\n\t\t\t<string>UIInterfaceOrientationLandscapeLeft</string>\n\t\t\t<string>UIInterfaceOrientationLandscapeRight</string>\n\t\t</array>\n\t\t<key>UIViewControllerBasedStatusBarAppearance</key>\n\t\t<false />\n\t\t<key>UIApplicationSupportsIndirectInputEvents</key>\n\t\t<true />\n\n\t\t<!-- Add for share_handler start -->\n\t\t<!-- The 'NSUserActivityTypes' key is only needed if you plan to use the recordSentMessage API\n\t\tallowing for conversations to show up as direct share suggestions -->\n\t\t<key>NSUserActivityTypes</key>\n\t\t<array>\n\t\t\t<string>INSendMessageIntent</string>\n\t\t</array>\n\n\t\t<!-- Uncomment below lines if you want to use a custom group id rather than the default. Set it\n\t\tin Build Settings -> User-Defined -->\n\t\t<!-- <key>AppGroupId</key>\n<string>$(CUSTOM_GROUP_ID)</string> -->\n\n\t\t<key>CFBundleURLTypes</key>\n\t\t<array>\n\t\t\t<dict>\n\t\t\t\t<key>CFBundleTypeRole</key>\n\t\t\t\t<string>Editor</string>\n\t\t\t\t<key>CFBundleURLSchemes</key>\n\t\t\t\t<array>\n\t\t\t\t\t<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>\n\t\t\t\t</array>\n\t\t\t</dict>\n\t\t</array>\n\n\t\t<key>NSPhotoLibraryUsageDescription</key>\n\t\t<string>Photos can be shared to and used in this app</string>\n\n\t\t<!-- Optional: Add/Customize for AirDrop support -->\n\t\t<key>LSSupportsOpeningDocumentsInPlace</key>\n\t\t<string>No</string>\n\t\t<key>CFBundleDocumentTypes</key>\n\t\t<array>\n\t\t\t<dict>\n\t\t\t\t<key>CFBundleTypeName</key>\n\t\t\t\t<string>ShareHandler</string>\n\t\t\t\t<key>LSHandlerRank</key>\n\t\t\t\t<string>Alternate</string>\n\t\t\t\t<key>LSItemContentTypes</key>\n\t\t\t\t<array>\n\t\t\t\t\t<string>public.file-url</string>\n\t\t\t\t\t<string>public.image</string>\n\t\t\t\t\t<string>public.text</string>\n\t\t\t\t\t<string>public.movie</string>\n\t\t\t\t\t<string>public.url</string>\n\t\t\t\t\t<string>public.data</string>\n\t\t\t\t</array>\n\t\t\t</dict>\n\t\t</array>\n\n\t\t<!-- Add for share_handler end -->\n\t</dict>\n</plist>"
  },
  {
    "path": "ui/flutter/ios/Runner/Runner-Bridging-Header.h",
    "content": "#import \"GeneratedPluginRegistrant.h\"\n#import <flutter_foreground_task/FlutterForegroundTaskPlugin.h>\n"
  },
  {
    "path": "ui/flutter/ios/Runner/Runner.entitlements",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>com.apple.security.application-groups</key>\n\t<array>\n\t\t<string>group.com.gopeed.gopeed</string>\n\t</array>\n</dict>\n</plist>\n"
  },
  {
    "path": "ui/flutter/ios/Runner.xcodeproj/project.pbxproj",
    "content": "// !$*UTF8*$!\n{\n\tarchiveVersion = 1;\n\tclasses = {\n\t};\n\tobjectVersion = 54;\n\tobjects = {\n\n/* Begin PBXBuildFile section */\n\t\t0C585CBA2D41E28900FF2EC0 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C585CB92D41E28900FF2EC0 /* ShareViewController.swift */; };\n\t\t0C585CBD2D41E28900FF2EC0 /* Base in Resources */ = {isa = PBXBuildFile; fileRef = 0C585CBC2D41E28900FF2EC0 /* Base */; };\n\t\t0C585CC12D41E28900FF2EC0 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0C585CB72D41E28900FF2EC0 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };\n\t\t1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };\n\t\t3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };\n\t\t74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };\n\t\t97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };\n\t\t97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };\n\t\t97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };\n\t\tB44F54F47E581A39546303C9 /* libresolv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 029EF293EAA92EB3D18BBF19 /* libresolv.tbd */; };\n\t\tD081C25C294826C0006EB10B /* libc++.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = D081C25B294826C0006EB10B /* libc++.tbd */; };\n\t\tD0E623A929482D160001185E /* Libgopeed.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0E623A729482D030001185E /* Libgopeed.xcframework */; };\n\t\tD0E623AA29482D160001185E /* Libgopeed.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D0E623A729482D030001185E /* Libgopeed.xcframework */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };\n\t\tDCA5B0FA95A8007ADEDACA1B /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 70BC8D37FAC641C7F20125E8 /* Pods_Runner.framework */; };\n/* End PBXBuildFile section */\n\n/* Begin PBXContainerItemProxy section */\n\t\t0C585CBF2D41E28900FF2EC0 /* PBXContainerItemProxy */ = {\n\t\t\tisa = PBXContainerItemProxy;\n\t\t\tcontainerPortal = 97C146E61CF9000F007C117D /* Project object */;\n\t\t\tproxyType = 1;\n\t\t\tremoteGlobalIDString = 0C585CB62D41E28900FF2EC0;\n\t\t\tremoteInfo = ShareExtension;\n\t\t};\n/* End PBXContainerItemProxy section */\n\n/* Begin PBXCopyFilesBuildPhase section */\n\t\t0C585CC22D41E28900FF2EC0 /* Embed Foundation Extensions */ = {\n\t\t\tisa = PBXCopyFilesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tdstPath = \"\";\n\t\t\tdstSubfolderSpec = 13;\n\t\t\tfiles = (\n\t\t\t\t0C585CC12D41E28900FF2EC0 /* ShareExtension.appex in Embed Foundation Extensions */,\n\t\t\t);\n\t\t\tname = \"Embed Foundation Extensions\";\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n\t\t9705A1C41CF9048500538489 /* Embed Frameworks */ = {\n\t\t\tisa = PBXCopyFilesBuildPhase;\n\t\t\tbuildActionMask = 8;\n\t\t\tdstPath = \"\";\n\t\t\tdstSubfolderSpec = 10;\n\t\t\tfiles = (\n\t\t\t\tD0E623AA29482D160001185E /* Libgopeed.xcframework in Embed Frameworks */,\n\t\t\t);\n\t\t\tname = \"Embed Frameworks\";\n\t\t\trunOnlyForDeploymentPostprocessing = 1;\n\t\t};\n/* End PBXCopyFilesBuildPhase section */\n\n/* Begin PBXFileReference section */\n\t\t029EF293EAA92EB3D18BBF19 /* libresolv.tbd */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = \"sourcecode.text-based-dylib-definition\"; name = libresolv.tbd; path = usr/lib/libresolv.tbd; sourceTree = \"<group>\"; };\n\t\t0C585CB72D41E28900FF2EC0 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = \"wrapper.app-extension\"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };\n\t\t0C585CB92D41E28900FF2EC0 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = \"<group>\"; };\n\t\t0C585CBC2D41E28900FF2EC0 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = \"<group>\"; };\n\t\t0C585CBE2D41E28900FF2EC0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = \"<group>\"; };\n\t\t0C585CC72D41E36800FF2EC0 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = \"<group>\"; };\n\t\t0C585CC82D41E38D00FF2EC0 /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = \"<group>\"; };\n\t\t1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = \"<group>\"; };\n\t\t1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = \"<group>\"; };\n\t\t25B61E78A388C87913468FD6 /* 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 = \"<group>\"; };\n\t\t3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = \"<group>\"; };\n\t\t70BC8D37FAC641C7F20125E8 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };\n\t\t74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = \"Runner-Bridging-Header.h\"; sourceTree = \"<group>\"; };\n\t\t74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = \"<group>\"; };\n\t\t7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = \"<group>\"; };\n\t\t97232923D357E56CAA39281D /* 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 = \"<group>\"; };\n\t\t9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = \"<group>\"; };\n\t\t9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = \"<group>\"; };\n\t\t97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };\n\t\t97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = \"<group>\"; };\n\t\t97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = \"<group>\"; };\n\t\t97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = \"<group>\"; };\n\t\t97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = \"<group>\"; };\n\t\tBC90D28B9068C663815415CC /* 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 = \"<group>\"; };\n\t\tD081C25B294826C0006EB10B /* libc++.tbd */ = {isa = PBXFileReference; lastKnownFileType = \"sourcecode.text-based-dylib-definition\"; name = \"libc++.tbd\"; path = \"usr/lib/libc++.tbd\"; sourceTree = SDKROOT; };\n\t\tD0E623A729482D030001185E /* Libgopeed.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = Libgopeed.xcframework; path = Frameworks/Libgopeed.xcframework; sourceTree = \"<group>\"; };\n/* End PBXFileReference section */\n\n/* Begin PBXFrameworksBuildPhase section */\n\t\t0C585CB42D41E28900FF2EC0 /* Frameworks */ = {\n\t\t\tisa = PBXFrameworksBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n\t\t97C146EB1CF9000F007C117D /* Frameworks */ = {\n\t\t\tisa = PBXFrameworksBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\tD0E623A929482D160001185E /* Libgopeed.xcframework in Frameworks */,\n\t\t\t\tD081C25C294826C0006EB10B /* libc++.tbd in Frameworks */,\n\t\t\t\tDCA5B0FA95A8007ADEDACA1B /* Pods_Runner.framework in Frameworks */,\n\t\t\t\tB44F54F47E581A39546303C9 /* libresolv.tbd in Frameworks */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n/* End PBXFrameworksBuildPhase section */\n\n/* Begin PBXGroup section */\n\t\t0C585CB82D41E28900FF2EC0 /* ShareExtension */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t0C585CC82D41E38D00FF2EC0 /* ShareExtension.entitlements */,\n\t\t\t\t0C585CB92D41E28900FF2EC0 /* ShareViewController.swift */,\n\t\t\t\t0C585CBB2D41E28900FF2EC0 /* MainInterface.storyboard */,\n\t\t\t\t0C585CBE2D41E28900FF2EC0 /* Info.plist */,\n\t\t\t);\n\t\t\tpath = ShareExtension;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t9740EEB11CF90186004384FC /* Flutter */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,\n\t\t\t\t9740EEB21CF90195004384FC /* Debug.xcconfig */,\n\t\t\t\t7AFA3C8E1D35360C0083082E /* Release.xcconfig */,\n\t\t\t\t9740EEB31CF90195004384FC /* Generated.xcconfig */,\n\t\t\t);\n\t\t\tname = Flutter;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t97C146E51CF9000F007C117D = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t9740EEB11CF90186004384FC /* Flutter */,\n\t\t\t\t97C146F01CF9000F007C117D /* Runner */,\n\t\t\t\t0C585CB82D41E28900FF2EC0 /* ShareExtension */,\n\t\t\t\t97C146EF1CF9000F007C117D /* Products */,\n\t\t\t\tADF7F433B4BC23D6C18AB78B /* Pods */,\n\t\t\t\tB4A1CBAC5AA50208793CFF34 /* Frameworks */,\n\t\t\t);\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t97C146EF1CF9000F007C117D /* Products */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t97C146EE1CF9000F007C117D /* Runner.app */,\n\t\t\t\t0C585CB72D41E28900FF2EC0 /* ShareExtension.appex */,\n\t\t\t);\n\t\t\tname = Products;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t97C146F01CF9000F007C117D /* Runner */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t0C585CC72D41E36800FF2EC0 /* Runner.entitlements */,\n\t\t\t\t97C146FA1CF9000F007C117D /* Main.storyboard */,\n\t\t\t\t97C146FD1CF9000F007C117D /* Assets.xcassets */,\n\t\t\t\t97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,\n\t\t\t\t97C147021CF9000F007C117D /* Info.plist */,\n\t\t\t\t1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,\n\t\t\t\t1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,\n\t\t\t\t74858FAE1ED2DC5600515810 /* AppDelegate.swift */,\n\t\t\t\t74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,\n\t\t\t);\n\t\t\tpath = Runner;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\tADF7F433B4BC23D6C18AB78B /* Pods */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t97232923D357E56CAA39281D /* Pods-Runner.debug.xcconfig */,\n\t\t\t\tBC90D28B9068C663815415CC /* Pods-Runner.release.xcconfig */,\n\t\t\t\t25B61E78A388C87913468FD6 /* Pods-Runner.profile.xcconfig */,\n\t\t\t);\n\t\t\tpath = Pods;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\tB4A1CBAC5AA50208793CFF34 /* Frameworks */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\tD0E623A729482D030001185E /* Libgopeed.xcframework */,\n\t\t\t\tD081C25B294826C0006EB10B /* libc++.tbd */,\n\t\t\t\t70BC8D37FAC641C7F20125E8 /* Pods_Runner.framework */,\n\t\t\t\t029EF293EAA92EB3D18BBF19 /* libresolv.tbd */,\n\t\t\t);\n\t\t\tname = Frameworks;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n/* End PBXGroup section */\n\n/* Begin PBXNativeTarget section */\n\t\t0C585CB62D41E28900FF2EC0 /* ShareExtension */ = {\n\t\t\tisa = PBXNativeTarget;\n\t\t\tbuildConfigurationList = 0C585CC62D41E28900FF2EC0 /* Build configuration list for PBXNativeTarget \"ShareExtension\" */;\n\t\t\tbuildPhases = (\n\t\t\t\t0C585CB32D41E28900FF2EC0 /* Sources */,\n\t\t\t\t0C585CB42D41E28900FF2EC0 /* Frameworks */,\n\t\t\t\t0C585CB52D41E28900FF2EC0 /* Resources */,\n\t\t\t);\n\t\t\tbuildRules = (\n\t\t\t);\n\t\t\tdependencies = (\n\t\t\t);\n\t\t\tname = ShareExtension;\n\t\t\tproductName = ShareExtension;\n\t\t\tproductReference = 0C585CB72D41E28900FF2EC0 /* ShareExtension.appex */;\n\t\t\tproductType = \"com.apple.product-type.app-extension\";\n\t\t};\n\t\t97C146ED1CF9000F007C117D /* Runner */ = {\n\t\t\tisa = PBXNativeTarget;\n\t\t\tbuildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget \"Runner\" */;\n\t\t\tbuildPhases = (\n\t\t\t\tF3093A8F123671F8F587626F /* [CP] Check Pods Manifest.lock */,\n\t\t\t\t9740EEB61CF901F6004384FC /* Run Script */,\n\t\t\t\t97C146EA1CF9000F007C117D /* Sources */,\n\t\t\t\t97C146EB1CF9000F007C117D /* Frameworks */,\n\t\t\t\t97C146EC1CF9000F007C117D /* Resources */,\n\t\t\t\t0C585CC22D41E28900FF2EC0 /* Embed Foundation Extensions */,\n\t\t\t\t9705A1C41CF9048500538489 /* Embed Frameworks */,\n\t\t\t\t3B06AD1E1E4923F5004D2608 /* Thin Binary */,\n\t\t\t\tDDA801E9D83E2E88398514D3 /* [CP] Embed Pods Frameworks */,\n\t\t\t);\n\t\t\tbuildRules = (\n\t\t\t);\n\t\t\tdependencies = (\n\t\t\t\t0C585CC02D41E28900FF2EC0 /* PBXTargetDependency */,\n\t\t\t);\n\t\t\tname = Runner;\n\t\t\tproductName = Runner;\n\t\t\tproductReference = 97C146EE1CF9000F007C117D /* Runner.app */;\n\t\t\tproductType = \"com.apple.product-type.application\";\n\t\t};\n/* End PBXNativeTarget section */\n\n/* Begin PBXProject section */\n\t\t97C146E61CF9000F007C117D /* Project object */ = {\n\t\t\tisa = PBXProject;\n\t\t\tattributes = {\n\t\t\t\tLastSwiftUpdateCheck = 1540;\n\t\t\t\tLastUpgradeCheck = 1430;\n\t\t\t\tORGANIZATIONNAME = \"\";\n\t\t\t\tTargetAttributes = {\n\t\t\t\t\t0C585CB62D41E28900FF2EC0 = {\n\t\t\t\t\t\tCreatedOnToolsVersion = 15.4;\n\t\t\t\t\t};\n\t\t\t\t\t97C146ED1CF9000F007C117D = {\n\t\t\t\t\t\tCreatedOnToolsVersion = 7.3.1;\n\t\t\t\t\t\tLastSwiftMigration = 1100;\n\t\t\t\t\t};\n\t\t\t\t};\n\t\t\t};\n\t\t\tbuildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject \"Runner\" */;\n\t\t\tcompatibilityVersion = \"Xcode 9.3\";\n\t\t\tdevelopmentRegion = en;\n\t\t\thasScannedForEncodings = 0;\n\t\t\tknownRegions = (\n\t\t\t\ten,\n\t\t\t\tBase,\n\t\t\t);\n\t\t\tmainGroup = 97C146E51CF9000F007C117D;\n\t\t\tproductRefGroup = 97C146EF1CF9000F007C117D /* Products */;\n\t\t\tprojectDirPath = \"\";\n\t\t\tprojectRoot = \"\";\n\t\t\ttargets = (\n\t\t\t\t97C146ED1CF9000F007C117D /* Runner */,\n\t\t\t\t0C585CB62D41E28900FF2EC0 /* ShareExtension */,\n\t\t\t);\n\t\t};\n/* End PBXProject section */\n\n/* Begin PBXResourcesBuildPhase section */\n\t\t0C585CB52D41E28900FF2EC0 /* Resources */ = {\n\t\t\tisa = PBXResourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\t0C585CBD2D41E28900FF2EC0 /* Base in Resources */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n\t\t97C146EC1CF9000F007C117D /* Resources */ = {\n\t\t\tisa = PBXResourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\t97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,\n\t\t\t\t3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,\n\t\t\t\t97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,\n\t\t\t\t97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n/* End PBXResourcesBuildPhase section */\n\n/* Begin PBXShellScriptBuildPhase section */\n\t\t3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {\n\t\t\tisa = PBXShellScriptBuildPhase;\n\t\t\talwaysOutOfDate = 1;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\tinputPaths = (\n\t\t\t\t\"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}\",\n\t\t\t);\n\t\t\tname = \"Thin Binary\";\n\t\t\toutputPaths = (\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t\tshellPath = /bin/sh;\n\t\t\tshellScript = \"/bin/sh \\\"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\\\" embed_and_thin\";\n\t\t};\n\t\t9740EEB61CF901F6004384FC /* Run Script */ = {\n\t\t\tisa = PBXShellScriptBuildPhase;\n\t\t\talwaysOutOfDate = 1;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\tinputPaths = (\n\t\t\t);\n\t\t\tname = \"Run Script\";\n\t\t\toutputPaths = (\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t\tshellPath = /bin/sh;\n\t\t\tshellScript = \"/bin/sh \\\"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\\\" build\";\n\t\t};\n\t\tDDA801E9D83E2E88398514D3 /* [CP] Embed Pods Frameworks */ = {\n\t\t\tisa = PBXShellScriptBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\tinputFileListPaths = (\n\t\t\t\t\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist\",\n\t\t\t);\n\t\t\tname = \"[CP] Embed Pods Frameworks\";\n\t\t\toutputFileListPaths = (\n\t\t\t\t\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist\",\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t\tshellPath = /bin/sh;\n\t\t\tshellScript = \"\\\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\\\"\\n\";\n\t\t\tshowEnvVarsInLog = 0;\n\t\t};\n\t\tF3093A8F123671F8F587626F /* [CP] Check Pods Manifest.lock */ = {\n\t\t\tisa = PBXShellScriptBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\tinputFileListPaths = (\n\t\t\t);\n\t\t\tinputPaths = (\n\t\t\t\t\"${PODS_PODFILE_DIR_PATH}/Podfile.lock\",\n\t\t\t\t\"${PODS_ROOT}/Manifest.lock\",\n\t\t\t);\n\t\t\tname = \"[CP] Check Pods Manifest.lock\";\n\t\t\toutputFileListPaths = (\n\t\t\t);\n\t\t\toutputPaths = (\n\t\t\t\t\"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt\",\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t\tshellPath = /bin/sh;\n\t\t\tshellScript = \"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\";\n\t\t\tshowEnvVarsInLog = 0;\n\t\t};\n/* End PBXShellScriptBuildPhase section */\n\n/* Begin PBXSourcesBuildPhase section */\n\t\t0C585CB32D41E28900FF2EC0 /* Sources */ = {\n\t\t\tisa = PBXSourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\t0C585CBA2D41E28900FF2EC0 /* ShareViewController.swift in Sources */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n\t\t97C146EA1CF9000F007C117D /* Sources */ = {\n\t\t\tisa = PBXSourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\t74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,\n\t\t\t\t1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n/* End PBXSourcesBuildPhase section */\n\n/* Begin PBXTargetDependency section */\n\t\t0C585CC02D41E28900FF2EC0 /* PBXTargetDependency */ = {\n\t\t\tisa = PBXTargetDependency;\n\t\t\ttarget = 0C585CB62D41E28900FF2EC0 /* ShareExtension */;\n\t\t\ttargetProxy = 0C585CBF2D41E28900FF2EC0 /* PBXContainerItemProxy */;\n\t\t};\n/* End PBXTargetDependency section */\n\n/* Begin PBXVariantGroup section */\n\t\t0C585CBB2D41E28900FF2EC0 /* MainInterface.storyboard */ = {\n\t\t\tisa = PBXVariantGroup;\n\t\t\tchildren = (\n\t\t\t\t0C585CBC2D41E28900FF2EC0 /* Base */,\n\t\t\t);\n\t\t\tname = MainInterface.storyboard;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t97C146FA1CF9000F007C117D /* Main.storyboard */ = {\n\t\t\tisa = PBXVariantGroup;\n\t\t\tchildren = (\n\t\t\t\t97C146FB1CF9000F007C117D /* Base */,\n\t\t\t);\n\t\t\tname = Main.storyboard;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {\n\t\t\tisa = PBXVariantGroup;\n\t\t\tchildren = (\n\t\t\t\t97C147001CF9000F007C117D /* Base */,\n\t\t\t);\n\t\t\tname = LaunchScreen.storyboard;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n/* End PBXVariantGroup section */\n\n/* Begin XCBuildConfiguration section */\n\t\t0C585CC32D41E28900FF2EC0 /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;\n\t\t\t\tCLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;\n\t\t\t\tCLANG_CXX_LANGUAGE_STANDARD = \"gnu++20\";\n\t\t\t\tCLANG_ENABLE_OBJC_WEAK = YES;\n\t\t\t\tCLANG_WARN_DOCUMENTATION_COMMENTS = YES;\n\t\t\t\tCLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;\n\t\t\t\tCLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;\n\t\t\t\tCODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;\n\t\t\t\tCODE_SIGN_STYLE = Automatic;\n\t\t\t\tCURRENT_PROJECT_VERSION = 1;\n\t\t\t\tDEVELOPMENT_TEAM = JH48DS925K;\n\t\t\t\tENABLE_USER_SCRIPT_SANDBOXING = YES;\n\t\t\t\tGCC_C_LANGUAGE_STANDARD = gnu17;\n\t\t\t\tGENERATE_INFOPLIST_FILE = YES;\n\t\t\t\tINFOPLIST_FILE = ShareExtension/Info.plist;\n\t\t\t\tINFOPLIST_KEY_CFBundleDisplayName = ShareExtension;\n\t\t\t\tINFOPLIST_KEY_NSHumanReadableCopyright = \"\";\n\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 17.5;\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/Frameworks\",\n\t\t\t\t\t\"@executable_path/../../Frameworks\",\n\t\t\t\t);\n\t\t\t\tLOCALIZATION_PREFERS_STRING_CATALOGS = YES;\n\t\t\t\tMARKETING_VERSION = 1.0;\n\t\t\t\tMTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;\n\t\t\t\tMTL_FAST_MATH = YES;\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.gopeed.gopeed.ShareExtension;\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t\tSKIP_INSTALL = YES;\n\t\t\t\tSWIFT_ACTIVE_COMPILATION_CONDITIONS = \"DEBUG $(inherited)\";\n\t\t\t\tSWIFT_EMIT_LOC_STRINGS = YES;\n\t\t\t\tSWIFT_OPTIMIZATION_LEVEL = \"-Onone\";\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t\tTARGETED_DEVICE_FAMILY = \"1,2\";\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\t0C585CC42D41E28900FF2EC0 /* Release */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;\n\t\t\t\tCLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;\n\t\t\t\tCLANG_CXX_LANGUAGE_STANDARD = \"gnu++20\";\n\t\t\t\tCLANG_ENABLE_OBJC_WEAK = YES;\n\t\t\t\tCLANG_WARN_DOCUMENTATION_COMMENTS = YES;\n\t\t\t\tCLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;\n\t\t\t\tCLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;\n\t\t\t\tCODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;\n\t\t\t\tCODE_SIGN_STYLE = Automatic;\n\t\t\t\tCURRENT_PROJECT_VERSION = 1;\n\t\t\t\tDEVELOPMENT_TEAM = JH48DS925K;\n\t\t\t\tENABLE_USER_SCRIPT_SANDBOXING = YES;\n\t\t\t\tGCC_C_LANGUAGE_STANDARD = gnu17;\n\t\t\t\tGENERATE_INFOPLIST_FILE = YES;\n\t\t\t\tINFOPLIST_FILE = ShareExtension/Info.plist;\n\t\t\t\tINFOPLIST_KEY_CFBundleDisplayName = ShareExtension;\n\t\t\t\tINFOPLIST_KEY_NSHumanReadableCopyright = \"\";\n\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 17.5;\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/Frameworks\",\n\t\t\t\t\t\"@executable_path/../../Frameworks\",\n\t\t\t\t);\n\t\t\t\tLOCALIZATION_PREFERS_STRING_CATALOGS = YES;\n\t\t\t\tMARKETING_VERSION = 1.0;\n\t\t\t\tMTL_FAST_MATH = YES;\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.gopeed.gopeed.ShareExtension;\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t\tSKIP_INSTALL = YES;\n\t\t\t\tSWIFT_EMIT_LOC_STRINGS = YES;\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t\tTARGETED_DEVICE_FAMILY = \"1,2\";\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n\t\t0C585CC52D41E28900FF2EC0 /* Profile */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;\n\t\t\t\tCLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;\n\t\t\t\tCLANG_CXX_LANGUAGE_STANDARD = \"gnu++20\";\n\t\t\t\tCLANG_ENABLE_OBJC_WEAK = YES;\n\t\t\t\tCLANG_WARN_DOCUMENTATION_COMMENTS = YES;\n\t\t\t\tCLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;\n\t\t\t\tCLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;\n\t\t\t\tCODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;\n\t\t\t\tCODE_SIGN_STYLE = Automatic;\n\t\t\t\tCURRENT_PROJECT_VERSION = 1;\n\t\t\t\tDEVELOPMENT_TEAM = JH48DS925K;\n\t\t\t\tENABLE_USER_SCRIPT_SANDBOXING = YES;\n\t\t\t\tGCC_C_LANGUAGE_STANDARD = gnu17;\n\t\t\t\tGENERATE_INFOPLIST_FILE = YES;\n\t\t\t\tINFOPLIST_FILE = ShareExtension/Info.plist;\n\t\t\t\tINFOPLIST_KEY_CFBundleDisplayName = ShareExtension;\n\t\t\t\tINFOPLIST_KEY_NSHumanReadableCopyright = \"\";\n\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 17.5;\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/Frameworks\",\n\t\t\t\t\t\"@executable_path/../../Frameworks\",\n\t\t\t\t);\n\t\t\t\tLOCALIZATION_PREFERS_STRING_CATALOGS = YES;\n\t\t\t\tMARKETING_VERSION = 1.0;\n\t\t\t\tMTL_FAST_MATH = YES;\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.gopeed.gopeed.ShareExtension;\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t\tSKIP_INSTALL = YES;\n\t\t\t\tSWIFT_EMIT_LOC_STRINGS = YES;\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t\tTARGETED_DEVICE_FAMILY = \"1,2\";\n\t\t\t};\n\t\t\tname = Profile;\n\t\t};\n\t\t249021D3217E4FDB00AE95B9 /* Profile */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tALWAYS_SEARCH_USER_PATHS = NO;\n\t\t\t\tCLANG_ANALYZER_NONNULL = YES;\n\t\t\t\tCLANG_CXX_LANGUAGE_STANDARD = \"gnu++0x\";\n\t\t\t\tCLANG_CXX_LIBRARY = \"libc++\";\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCLANG_ENABLE_OBJC_ARC = YES;\n\t\t\t\tCLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;\n\t\t\t\tCLANG_WARN_BOOL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_COMMA = YES;\n\t\t\t\tCLANG_WARN_CONSTANT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;\n\t\t\t\tCLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;\n\t\t\t\tCLANG_WARN_EMPTY_BODY = YES;\n\t\t\t\tCLANG_WARN_ENUM_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_INFINITE_RECURSION = YES;\n\t\t\t\tCLANG_WARN_INT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;\n\t\t\t\tCLANG_WARN_OBJC_LITERAL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;\n\t\t\t\tCLANG_WARN_RANGE_LOOP_ANALYSIS = YES;\n\t\t\t\tCLANG_WARN_STRICT_PROTOTYPES = YES;\n\t\t\t\tCLANG_WARN_SUSPICIOUS_MOVE = YES;\n\t\t\t\tCLANG_WARN_UNREACHABLE_CODE = YES;\n\t\t\t\tCLANG_WARN__DUPLICATE_METHOD_MATCH = YES;\n\t\t\t\t\"CODE_SIGN_IDENTITY[sdk=iphoneos*]\" = \"iPhone Developer\";\n\t\t\t\tCOPY_PHASE_STRIP = NO;\n\t\t\t\tDEBUG_INFORMATION_FORMAT = \"dwarf-with-dsym\";\n\t\t\t\tENABLE_NS_ASSERTIONS = NO;\n\t\t\t\tENABLE_STRICT_OBJC_MSGSEND = YES;\n\t\t\t\tGCC_C_LANGUAGE_STANDARD = gnu99;\n\t\t\t\tGCC_NO_COMMON_BLOCKS = YES;\n\t\t\t\tGCC_WARN_64_TO_32_BIT_CONVERSION = YES;\n\t\t\t\tGCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;\n\t\t\t\tGCC_WARN_UNDECLARED_SELECTOR = YES;\n\t\t\t\tGCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;\n\t\t\t\tGCC_WARN_UNUSED_FUNCTION = YES;\n\t\t\t\tGCC_WARN_UNUSED_VARIABLE = YES;\n\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 11.0;\n\t\t\t\tMTL_ENABLE_DEBUG_INFO = NO;\n\t\t\t\tSDKROOT = iphoneos;\n\t\t\t\tSUPPORTED_PLATFORMS = iphoneos;\n\t\t\t\tTARGETED_DEVICE_FAMILY = \"1,2\";\n\t\t\t\tVALIDATE_PRODUCT = YES;\n\t\t\t};\n\t\t\tname = Profile;\n\t\t};\n\t\t249021D4217E4FDB00AE95B9 /* Profile */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;\n\t\t\tbuildSettings = {\n\t\t\t\tALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;\n\t\t\t\tASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;\n\t\t\t\tCURRENT_PROJECT_VERSION = \"$(FLUTTER_BUILD_NUMBER)\";\n\t\t\t\tDEVELOPMENT_TEAM = JH48DS925K;\n\t\t\t\tENABLE_BITCODE = NO;\n\t\t\t\tINFOPLIST_FILE = Runner/Info.plist;\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/Frameworks\",\n\t\t\t\t);\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.gopeed.gopeed;\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t\tSWIFT_OBJC_BRIDGING_HEADER = \"Runner/Runner-Bridging-Header.h\";\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t\tVERSIONING_SYSTEM = \"apple-generic\";\n\t\t\t};\n\t\t\tname = Profile;\n\t\t};\n\t\t97C147031CF9000F007C117D /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tALWAYS_SEARCH_USER_PATHS = NO;\n\t\t\t\tCLANG_ANALYZER_NONNULL = YES;\n\t\t\t\tCLANG_CXX_LANGUAGE_STANDARD = \"gnu++0x\";\n\t\t\t\tCLANG_CXX_LIBRARY = \"libc++\";\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCLANG_ENABLE_OBJC_ARC = YES;\n\t\t\t\tCLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;\n\t\t\t\tCLANG_WARN_BOOL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_COMMA = YES;\n\t\t\t\tCLANG_WARN_CONSTANT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;\n\t\t\t\tCLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;\n\t\t\t\tCLANG_WARN_EMPTY_BODY = YES;\n\t\t\t\tCLANG_WARN_ENUM_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_INFINITE_RECURSION = YES;\n\t\t\t\tCLANG_WARN_INT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;\n\t\t\t\tCLANG_WARN_OBJC_LITERAL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;\n\t\t\t\tCLANG_WARN_RANGE_LOOP_ANALYSIS = YES;\n\t\t\t\tCLANG_WARN_STRICT_PROTOTYPES = YES;\n\t\t\t\tCLANG_WARN_SUSPICIOUS_MOVE = YES;\n\t\t\t\tCLANG_WARN_UNREACHABLE_CODE = YES;\n\t\t\t\tCLANG_WARN__DUPLICATE_METHOD_MATCH = YES;\n\t\t\t\t\"CODE_SIGN_IDENTITY[sdk=iphoneos*]\" = \"iPhone Developer\";\n\t\t\t\tCOPY_PHASE_STRIP = NO;\n\t\t\t\tDEBUG_INFORMATION_FORMAT = dwarf;\n\t\t\t\tENABLE_STRICT_OBJC_MSGSEND = YES;\n\t\t\t\tENABLE_TESTABILITY = YES;\n\t\t\t\tGCC_C_LANGUAGE_STANDARD = gnu99;\n\t\t\t\tGCC_DYNAMIC_NO_PIC = NO;\n\t\t\t\tGCC_NO_COMMON_BLOCKS = YES;\n\t\t\t\tGCC_OPTIMIZATION_LEVEL = 0;\n\t\t\t\tGCC_PREPROCESSOR_DEFINITIONS = (\n\t\t\t\t\t\"DEBUG=1\",\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t);\n\t\t\t\tGCC_WARN_64_TO_32_BIT_CONVERSION = YES;\n\t\t\t\tGCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;\n\t\t\t\tGCC_WARN_UNDECLARED_SELECTOR = YES;\n\t\t\t\tGCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;\n\t\t\t\tGCC_WARN_UNUSED_FUNCTION = YES;\n\t\t\t\tGCC_WARN_UNUSED_VARIABLE = YES;\n\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 11.0;\n\t\t\t\tMTL_ENABLE_DEBUG_INFO = YES;\n\t\t\t\tONLY_ACTIVE_ARCH = YES;\n\t\t\t\tSDKROOT = iphoneos;\n\t\t\t\tTARGETED_DEVICE_FAMILY = \"1,2\";\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\t97C147041CF9000F007C117D /* Release */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tALWAYS_SEARCH_USER_PATHS = NO;\n\t\t\t\tCLANG_ANALYZER_NONNULL = YES;\n\t\t\t\tCLANG_CXX_LANGUAGE_STANDARD = \"gnu++0x\";\n\t\t\t\tCLANG_CXX_LIBRARY = \"libc++\";\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCLANG_ENABLE_OBJC_ARC = YES;\n\t\t\t\tCLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;\n\t\t\t\tCLANG_WARN_BOOL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_COMMA = YES;\n\t\t\t\tCLANG_WARN_CONSTANT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;\n\t\t\t\tCLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;\n\t\t\t\tCLANG_WARN_EMPTY_BODY = YES;\n\t\t\t\tCLANG_WARN_ENUM_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_INFINITE_RECURSION = YES;\n\t\t\t\tCLANG_WARN_INT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;\n\t\t\t\tCLANG_WARN_OBJC_LITERAL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;\n\t\t\t\tCLANG_WARN_RANGE_LOOP_ANALYSIS = YES;\n\t\t\t\tCLANG_WARN_STRICT_PROTOTYPES = YES;\n\t\t\t\tCLANG_WARN_SUSPICIOUS_MOVE = YES;\n\t\t\t\tCLANG_WARN_UNREACHABLE_CODE = YES;\n\t\t\t\tCLANG_WARN__DUPLICATE_METHOD_MATCH = YES;\n\t\t\t\t\"CODE_SIGN_IDENTITY[sdk=iphoneos*]\" = \"iPhone Developer\";\n\t\t\t\tCOPY_PHASE_STRIP = NO;\n\t\t\t\tDEBUG_INFORMATION_FORMAT = \"dwarf-with-dsym\";\n\t\t\t\tENABLE_NS_ASSERTIONS = NO;\n\t\t\t\tENABLE_STRICT_OBJC_MSGSEND = YES;\n\t\t\t\tGCC_C_LANGUAGE_STANDARD = gnu99;\n\t\t\t\tGCC_NO_COMMON_BLOCKS = YES;\n\t\t\t\tGCC_WARN_64_TO_32_BIT_CONVERSION = YES;\n\t\t\t\tGCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;\n\t\t\t\tGCC_WARN_UNDECLARED_SELECTOR = YES;\n\t\t\t\tGCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;\n\t\t\t\tGCC_WARN_UNUSED_FUNCTION = YES;\n\t\t\t\tGCC_WARN_UNUSED_VARIABLE = YES;\n\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 11.0;\n\t\t\t\tMTL_ENABLE_DEBUG_INFO = NO;\n\t\t\t\tSDKROOT = iphoneos;\n\t\t\t\tSUPPORTED_PLATFORMS = iphoneos;\n\t\t\t\tSWIFT_COMPILATION_MODE = wholemodule;\n\t\t\t\tSWIFT_OPTIMIZATION_LEVEL = \"-O\";\n\t\t\t\tTARGETED_DEVICE_FAMILY = \"1,2\";\n\t\t\t\tVALIDATE_PRODUCT = YES;\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n\t\t97C147061CF9000F007C117D /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;\n\t\t\tbuildSettings = {\n\t\t\t\tALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;\n\t\t\t\tASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;\n\t\t\t\tCURRENT_PROJECT_VERSION = \"$(FLUTTER_BUILD_NUMBER)\";\n\t\t\t\tDEVELOPMENT_TEAM = JH48DS925K;\n\t\t\t\tENABLE_BITCODE = NO;\n\t\t\t\tINFOPLIST_FILE = Runner/Info.plist;\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/Frameworks\",\n\t\t\t\t);\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.gopeed.gopeed;\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t\tSWIFT_OBJC_BRIDGING_HEADER = \"Runner/Runner-Bridging-Header.h\";\n\t\t\t\tSWIFT_OPTIMIZATION_LEVEL = \"-Onone\";\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t\tVERSIONING_SYSTEM = \"apple-generic\";\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\t97C147071CF9000F007C117D /* Release */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;\n\t\t\tbuildSettings = {\n\t\t\t\tALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;\n\t\t\t\tASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;\n\t\t\t\tCURRENT_PROJECT_VERSION = \"$(FLUTTER_BUILD_NUMBER)\";\n\t\t\t\tDEVELOPMENT_TEAM = JH48DS925K;\n\t\t\t\tENABLE_BITCODE = NO;\n\t\t\t\tINFOPLIST_FILE = Runner/Info.plist;\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/Frameworks\",\n\t\t\t\t);\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.gopeed.gopeed;\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t\tSWIFT_OBJC_BRIDGING_HEADER = \"Runner/Runner-Bridging-Header.h\";\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t\tVERSIONING_SYSTEM = \"apple-generic\";\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n/* End XCBuildConfiguration section */\n\n/* Begin XCConfigurationList section */\n\t\t0C585CC62D41E28900FF2EC0 /* Build configuration list for PBXNativeTarget \"ShareExtension\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\t0C585CC32D41E28900FF2EC0 /* Debug */,\n\t\t\t\t0C585CC42D41E28900FF2EC0 /* Release */,\n\t\t\t\t0C585CC52D41E28900FF2EC0 /* Profile */,\n\t\t\t);\n\t\t\tdefaultConfigurationIsVisible = 0;\n\t\t\tdefaultConfigurationName = Release;\n\t\t};\n\t\t97C146E91CF9000F007C117D /* Build configuration list for PBXProject \"Runner\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\t97C147031CF9000F007C117D /* Debug */,\n\t\t\t\t97C147041CF9000F007C117D /* Release */,\n\t\t\t\t249021D3217E4FDB00AE95B9 /* Profile */,\n\t\t\t);\n\t\t\tdefaultConfigurationIsVisible = 0;\n\t\t\tdefaultConfigurationName = Release;\n\t\t};\n\t\t97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget \"Runner\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\t97C147061CF9000F007C117D /* Debug */,\n\t\t\t\t97C147071CF9000F007C117D /* Release */,\n\t\t\t\t249021D4217E4FDB00AE95B9 /* Profile */,\n\t\t\t);\n\t\t\tdefaultConfigurationIsVisible = 0;\n\t\t\tdefaultConfigurationName = Release;\n\t\t};\n/* End XCConfigurationList section */\n\t};\n\trootObject = 97C146E61CF9000F007C117D /* Project object */;\n}\n"
  },
  {
    "path": "ui/flutter/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Workspace\n   version = \"1.0\">\n   <FileRef\n      location = \"self:\">\n   </FileRef>\n</Workspace>\n"
  },
  {
    "path": "ui/flutter/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>IDEDidComputeMac32BitWarning</key>\n\t<true/>\n</dict>\n</plist>\n"
  },
  {
    "path": "ui/flutter/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>PreviewsEnabled</key>\n\t<false/>\n</dict>\n</plist>\n"
  },
  {
    "path": "ui/flutter/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Scheme\n   LastUpgradeVersion = \"1430\"\n   version = \"1.3\">\n   <BuildAction\n      parallelizeBuildables = \"YES\"\n      buildImplicitDependencies = \"YES\">\n      <BuildActionEntries>\n         <BuildActionEntry\n            buildForTesting = \"YES\"\n            buildForRunning = \"YES\"\n            buildForProfiling = \"YES\"\n            buildForArchiving = \"YES\"\n            buildForAnalyzing = \"YES\">\n            <BuildableReference\n               BuildableIdentifier = \"primary\"\n               BlueprintIdentifier = \"97C146ED1CF9000F007C117D\"\n               BuildableName = \"Runner.app\"\n               BlueprintName = \"Runner\"\n               ReferencedContainer = \"container:Runner.xcodeproj\">\n            </BuildableReference>\n         </BuildActionEntry>\n      </BuildActionEntries>\n   </BuildAction>\n   <TestAction\n      buildConfiguration = \"Debug\"\n      selectedDebuggerIdentifier = \"Xcode.DebuggerFoundation.Debugger.LLDB\"\n      selectedLauncherIdentifier = \"Xcode.DebuggerFoundation.Launcher.LLDB\"\n      shouldUseLaunchSchemeArgsEnv = \"YES\">\n      <MacroExpansion>\n         <BuildableReference\n            BuildableIdentifier = \"primary\"\n            BlueprintIdentifier = \"97C146ED1CF9000F007C117D\"\n            BuildableName = \"Runner.app\"\n            BlueprintName = \"Runner\"\n            ReferencedContainer = \"container:Runner.xcodeproj\">\n         </BuildableReference>\n      </MacroExpansion>\n      <Testables>\n      </Testables>\n   </TestAction>\n   <LaunchAction\n      buildConfiguration = \"Debug\"\n      selectedDebuggerIdentifier = \"Xcode.DebuggerFoundation.Debugger.LLDB\"\n      selectedLauncherIdentifier = \"Xcode.DebuggerFoundation.Launcher.LLDB\"\n      launchStyle = \"0\"\n      useCustomWorkingDirectory = \"NO\"\n      ignoresPersistentStateOnLaunch = \"NO\"\n      debugDocumentVersioning = \"YES\"\n      debugServiceExtension = \"internal\"\n      allowLocationSimulation = \"YES\">\n      <BuildableProductRunnable\n         runnableDebuggingMode = \"0\">\n         <BuildableReference\n            BuildableIdentifier = \"primary\"\n            BlueprintIdentifier = \"97C146ED1CF9000F007C117D\"\n            BuildableName = \"Runner.app\"\n            BlueprintName = \"Runner\"\n            ReferencedContainer = \"container:Runner.xcodeproj\">\n         </BuildableReference>\n      </BuildableProductRunnable>\n   </LaunchAction>\n   <ProfileAction\n      buildConfiguration = \"Profile\"\n      shouldUseLaunchSchemeArgsEnv = \"YES\"\n      savedToolIdentifier = \"\"\n      useCustomWorkingDirectory = \"NO\"\n      debugDocumentVersioning = \"YES\">\n      <BuildableProductRunnable\n         runnableDebuggingMode = \"0\">\n         <BuildableReference\n            BuildableIdentifier = \"primary\"\n            BlueprintIdentifier = \"97C146ED1CF9000F007C117D\"\n            BuildableName = \"Runner.app\"\n            BlueprintName = \"Runner\"\n            ReferencedContainer = \"container:Runner.xcodeproj\">\n         </BuildableReference>\n      </BuildableProductRunnable>\n   </ProfileAction>\n   <AnalyzeAction\n      buildConfiguration = \"Debug\">\n   </AnalyzeAction>\n   <ArchiveAction\n      buildConfiguration = \"Release\"\n      revealArchiveInOrganizer = \"YES\">\n   </ArchiveAction>\n</Scheme>\n"
  },
  {
    "path": "ui/flutter/ios/Runner.xcworkspace/contents.xcworkspacedata",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Workspace\n   version = \"1.0\">\n   <FileRef\n      location = \"group:Runner.xcodeproj\">\n   </FileRef>\n   <FileRef\n      location = \"group:Pods/Pods.xcodeproj\">\n   </FileRef>\n</Workspace>\n"
  },
  {
    "path": "ui/flutter/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>IDEDidComputeMac32BitWarning</key>\n\t<true/>\n</dict>\n</plist>\n"
  },
  {
    "path": "ui/flutter/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>PreviewsEnabled</key>\n\t<false/>\n</dict>\n</plist>\n"
  },
  {
    "path": "ui/flutter/ios/ShareExtension/Base.lproj/MainInterface.storyboard",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<document type=\"com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB\" version=\"3.0\" toolsVersion=\"13122.16\" targetRuntime=\"iOS.CocoaTouch\" propertyAccessControl=\"none\" useAutolayout=\"YES\" useTraitCollections=\"YES\" useSafeAreas=\"YES\" colorMatched=\"YES\" initialViewController=\"j1y-V4-xli\">\n    <dependencies>\n        <plugIn identifier=\"com.apple.InterfaceBuilder.IBCocoaTouchPlugin\" version=\"13104.12\"/>\n        <capability name=\"Safe area layout guides\" minToolsVersion=\"9.0\"/>\n        <capability name=\"documents saved in the Xcode 8 format\" minToolsVersion=\"8.0\"/>\n    </dependencies>\n    <scenes>\n        <!--Share View Controller-->\n        <scene sceneID=\"ceB-am-kn3\">\n            <objects>\n                <viewController id=\"j1y-V4-xli\" customClass=\"ShareViewController\" customModuleProvider=\"target\" sceneMemberID=\"viewController\">\n                    <view key=\"view\" opaque=\"NO\" contentMode=\"scaleToFill\" id=\"wbc-yd-nQP\">\n                        <rect key=\"frame\" x=\"0.0\" y=\"0.0\" width=\"375\" height=\"667\"/>\n                        <autoresizingMask key=\"autoresizingMask\" widthSizable=\"YES\" heightSizable=\"YES\"/>\n                        <color key=\"backgroundColor\" red=\"0.0\" green=\"0.0\" blue=\"0.0\" alpha=\"0.0\" colorSpace=\"custom\" customColorSpace=\"sRGB\"/>\n                        <viewLayoutGuide key=\"safeArea\" id=\"1Xd-am-t49\"/>\n                    </view>\n                </viewController>\n                <placeholder placeholderIdentifier=\"IBFirstResponder\" id=\"CEy-Cv-SGf\" userLabel=\"First Responder\" sceneMemberID=\"firstResponder\"/>\n            </objects>\n        </scene>\n    </scenes>\n</document>\n"
  },
  {
    "path": "ui/flutter/ios/ShareExtension/Info.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n\t<dict>\n\t\t<!-- Uncomment below lines if you want to use a custom group id rather than the default. Set it\n\t\tin Build Settings -> User-Defined -->\n\t\t<!-- <key>AppGroupId</key>\n    <string>$(CUSTOM_GROUP_ID)</string> -->\n\n\t\t<key>CFBundleVersion</key>\n\t\t<string>$(FLUTTER_BUILD_NUMBER)</string>\n\t\t<key>NSExtension</key>\n\t\t<dict>\n\t\t\t<key>NSExtensionAttributes</key>\n\t\t\t<dict>\n\t\t\t\t<!-- Add supported message intent if you support sharing to a specific conversation - start -->\n\t\t\t\t<key>IntentsSupported</key>\n\t\t\t\t<array>\n\t\t\t\t\t<string>INSendMessageIntent</string>\n\t\t\t\t</array>\n\t\t\t\t<!-- Add supported message intent if you support sharing to a specific conversation\n\t\t\t\t(registered via the recordSentMessage api call) - end -->\n\t\t\t\t<key>NSExtensionActivationRule</key>\n\t\t\t\t<!-- Comment or delete the TRUEPREDICATE NSExtensionActivationRule that only works in\n\t\t\t\tdevelopment mode -->\n\t\t\t\t<!-- <string>TRUEPREDICATE</string> -->\n\t\t\t\t<!-- Add a new NSExtensionActivationRule. The rule below will allow sharing one or more file\n\t\t\t\tof any type, url, or text content, You can modify these rules to your liking for which types\n\t\t\t\tof share content, as well as how many your app can handle -->\n\t\t\t\t<string>SUBQUERY ( extensionItems, $extensionItem, SUBQUERY ( $extensionItem.attachments,\n\t\t\t\t\t$attachment, ( ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO \"public.file-url\"\n\t\t\t\t\t|| ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO \"public.image\" || ANY\n\t\t\t\t\t$attachment.registeredTypeIdentifiers UTI-CONFORMS-TO \"public.text\" || ANY\n\t\t\t\t\t$attachment.registeredTypeIdentifiers UTI-CONFORMS-TO \"public.movie\" || ANY\n\t\t\t\t\t$attachment.registeredTypeIdentifiers UTI-CONFORMS-TO \"public.url\" ) ).@count > 0 ).@count\n\t\t\t\t\t> 0 </string>\n\t\t\t\t<key>PHSupportedMediaTypes</key>\n\t\t\t\t<array>\n\t\t\t\t\t<string>Video</string>\n\t\t\t\t\t<string>Image</string>\n\t\t\t\t</array>\n\t\t\t</dict>\n\t\t\t<key>NSExtensionMainStoryboard</key>\n\t\t\t<string>MainInterface</string>\n\t\t\t<key>NSExtensionPointIdentifier</key>\n\t\t\t<string>com.apple.share-services</string>\n\t\t</dict>\n\t</dict>\n</plist>"
  },
  {
    "path": "ui/flutter/ios/ShareExtension/ShareExtension.entitlements",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>com.apple.security.application-groups</key>\n\t<array>\n\t\t<string>group.com.gopeed.gopeed</string>\n\t</array>\n</dict>\n</plist>\n"
  },
  {
    "path": "ui/flutter/ios/ShareExtension/ShareViewController.swift",
    "content": "import share_handler_ios_models\n    \nclass ShareViewController: ShareHandlerIosViewController {}"
  },
  {
    "path": "ui/flutter/lib/api/api.dart",
    "content": "import 'dart:io';\n\nimport 'package:dio/dio.dart';\nimport 'package:dio/io.dart';\nimport 'package:flutter/foundation.dart';\nimport 'package:get/get.dart' as getx;\n\nimport '../app/routes/app_pages.dart';\nimport '../database/database.dart';\nimport '../util/util.dart';\nimport 'model/create_task.dart';\nimport 'model/create_task_batch.dart';\nimport 'model/downloader_config.dart';\nimport 'model/extension.dart';\nimport 'model/install_extension.dart';\nimport 'model/login.dart';\nimport 'model/resolve_result.dart';\nimport 'model/resolve_task.dart';\nimport 'model/result.dart';\nimport 'model/switch_extension.dart';\nimport 'model/task.dart';\nimport 'model/update_check_extension_resp.dart';\nimport 'model/update_extension_settings.dart';\n\nclass _Client {\n  static _Client? _instance;\n\n  late Dio dio;\n\n  _Client._internal();\n\n  factory _Client(String network, String address, String apiToken) {\n    if (_instance == null) {\n      _instance = _Client._internal();\n      var dio = Dio();\n      final isUnixSocket = network == 'unix';\n      var baseUrl = 'http://127.0.0.1/';\n      if (!isUnixSocket) {\n        if (Util.isWeb()) {\n          baseUrl = kDebugMode ? 'http://127.0.0.1:9999/' : '';\n        } else {\n          baseUrl = 'http://$address/';\n        }\n      }\n      dio.options.baseUrl = baseUrl;\n      dio.options.contentType = Headers.jsonContentType;\n      dio.options.sendTimeout = const Duration(seconds: 5);\n      dio.options.connectTimeout = const Duration(seconds: 5);\n      dio.options.receiveTimeout = const Duration(seconds: 60);\n      dio.interceptors.add(InterceptorsWrapper(\n        onRequest: (options, handler) {\n          if (apiToken.isNotEmpty) {\n            options.headers['X-Api-Token'] = apiToken;\n          }\n          if (Util.isWeb()) {\n            final token = Database.instance.getWebToken();\n            if (token != null) {\n              options.headers['Authorization'] = 'Bearer $token';\n            }\n          }\n          handler.next(options);\n        },\n        onError: (error, handler) {\n          // Only web version has a login page\n          if (Util.isWeb() && error.response?.statusCode == 401) {\n            getx.Get.rootDelegate.offAndToNamed(Routes.LOGIN);\n          }\n          handler.next(error);\n        },\n      ));\n\n      _instance!.dio = dio;\n      if (isUnixSocket) {\n        (_instance!.dio.httpClientAdapter as IOHttpClientAdapter)\n            .createHttpClient = () {\n          final client = HttpClient();\n          client.connectionFactory =\n              (Uri uri, String? proxyHost, int? proxyPort) {\n            return Socket.startConnect(\n                InternetAddress(address, type: InternetAddressType.unix), 0);\n          };\n          return client;\n        };\n      }\n    }\n    return _instance!;\n  }\n}\n\nclass TimeoutException implements Exception {\n  final String message;\n\n  TimeoutException(this.message);\n}\n\nlate _Client _client;\n\nvoid init(String network, String address, String apiToken) {\n  _client = _Client(network, address, apiToken);\n}\n\nFuture<T> _parse<T>(\n  Future<Response> Function() fetch,\n  T Function(dynamic json)? fromJsonT,\n) async {\n  try {\n    var resp = await fetch();\n    fromJsonT ??= (json) => null as T;\n    final result = Result<T>.fromJson(resp.data, fromJsonT);\n    if (result.code == 0) {\n      return result.data as T;\n    } else {\n      throw Exception(result);\n    }\n  } on DioException catch (e) {\n    if (e.type == DioExceptionType.sendTimeout ||\n        e.type == DioExceptionType.receiveTimeout ||\n        e.type == DioExceptionType.connectionTimeout ||\n        e.type == DioExceptionType.connectionError) {\n      throw TimeoutException(\"request timeout\");\n    }\n    throw Exception(Result(code: 1000, msg: e.message));\n  }\n}\n\nFuture<ResolveResult> resolve(ResolveTask resolveTask) async {\n  return _parse<ResolveResult>(\n      () => _client.dio.post(\"api/v1/resolve\", data: resolveTask),\n      (data) => ResolveResult.fromJson(data));\n}\n\nFuture<String> createTask(CreateTask createTask) async {\n  return _parse<String>(\n      () => _client.dio.post(\"api/v1/tasks\", data: createTask),\n      (data) => data as String);\n}\n\nFuture<List<String>> createTaskBatch(CreateTaskBatch createTaskBatch) async {\n  return _parse<List<String>>(\n      () => _client.dio.post(\"api/v1/tasks/batch\", data: createTaskBatch),\n      (data) => (data as List).map((e) => e as String).toList());\n}\n\nFuture<void> patchTask(String id, ResolveTask patchTask) async {\n  return _parse(\n      () => _client.dio.patch(\"api/v1/tasks/$id\", data: patchTask), null);\n}\n\nFuture<List<Task>> getTasks(List<Status> statuses) async {\n  return _parse<List<Task>>(\n      () => _client.dio.get(\n          \"/api/v1/tasks?${statuses.map((e) => \"status=${e.name}\").join(\"&\")}\"),\n      (data) => (data as List).map((e) => Task.fromJson(e)).toList());\n}\n\nFuture<void> pauseTask(String id) async {\n  return _parse(() => _client.dio.put(\"api/v1/tasks/$id/pause\"), null);\n}\n\nFuture<void> continueTask(String id) async {\n  return _parse(() => _client.dio.put(\"api/v1/tasks/$id/continue\"), null);\n}\n\nFuture<void> pauseAllTasks(List<String>? ids) async {\n  return _parse(\n      () => _client.dio.put(\"api/v1/tasks/pause\", queryParameters: {\n            \"id\": ids,\n          }),\n      null);\n}\n\nFuture<void> continueAllTasks(List<String>? ids) async {\n  return _parse(\n      () => _client.dio.put(\"api/v1/tasks/continue\", queryParameters: {\n            \"id\": ids,\n          }),\n      null);\n}\n\nFuture<void> deleteTask(String id, bool force) async {\n  return _parse(\n      () => _client.dio.delete(\"api/v1/tasks/$id?force=$force\"), null);\n}\n\nFuture<void> deleteTasks(List<String>? ids, bool force) async {\n  return _parse(\n      () => _client.dio.delete(\"api/v1/tasks\", queryParameters: {\n            \"id\": ids,\n            \"force\": force,\n          }),\n      null);\n}\n\nFuture<DownloaderConfig> getConfig() async {\n  return _parse(() => _client.dio.get(\"api/v1/config\"),\n      (data) => DownloaderConfig.fromJson(data));\n}\n\nFuture<void> putConfig(DownloaderConfig config) async {\n  return _parse(() => _client.dio.put(\"api/v1/config\", data: config), null);\n}\n\nFuture<String> installExtension(InstallExtension installExtension) async {\n  return _parse<String>(\n      () => _client.dio.post(\"api/v1/extensions\", data: installExtension),\n      (data) => data as String);\n}\n\nFuture<List<Extension>> getExtensions() async {\n  return _parse<List<Extension>>(() => _client.dio.get(\"api/v1/extensions\"),\n      (data) => (data as List).map((e) => Extension.fromJson(e)).toList());\n}\n\nFuture<void> updateExtensionSettings(\n    String identity, UpdateExtensionSettings updateExtensionSettings) async {\n  return _parse(\n      () => _client.dio.put(\"api/v1/extensions/$identity/settings\",\n          data: updateExtensionSettings),\n      null);\n}\n\nFuture<void> switchExtension(\n    String identity, SwitchExtension switchExtension) async {\n  return _parse(\n      () => _client.dio\n          .put(\"api/v1/extensions/$identity/switch\", data: switchExtension),\n      null);\n}\n\nFuture<void> deleteExtension(String identity) async {\n  return _parse(() => _client.dio.delete(\"api/v1/extensions/$identity\"), null);\n}\n\nFuture<UpdateCheckExtensionResp> upgradeCheckExtension(String identity) async {\n  return _parse(() => _client.dio.get(\"api/v1/extensions/$identity/update\"),\n      (data) => UpdateCheckExtensionResp.fromJson(data));\n}\n\nFuture<void> updateExtension(String identity) async {\n  return _parse(\n      () => _client.dio.post(\"api/v1/extensions/$identity/update\"), null);\n}\n\nFuture<void> testWebhook(String url) async {\n  return _parse(\n      () => _client.dio.post(\"api/v1/webhook/test\", data: {\"url\": url}), null);\n}\n\nFuture<String> login(LoginReq loginReq) async {\n  return _parse(() => _client.dio.post(\"api/web/login\", data: loginReq),\n      (data) => data as String);\n}\n\nFuture<Response<String>> proxyRequest<T>(String uri,\n    {data, Options? options}) async {\n  options ??= Options();\n  options.headers ??= {};\n  options.headers![\"X-Target-Uri\"] = uri;\n\n  // add timestamp to avoid cache\n  return _client.dio.request(\n      \"/api/v1/proxy?t=${DateTime.now().millisecondsSinceEpoch}\",\n      data: data,\n      options: options);\n}\n\nString join(String path) {\n  final baseUrl = _client.dio.options.baseUrl;\n  final cleanBaseUrl = baseUrl.endsWith('/')\n      ? baseUrl.substring(0, baseUrl.length - 1)\n      : baseUrl;\n  return \"$cleanBaseUrl/${Util.cleanPath(path)}\";\n}\n\n/// Generic request method for API proxy\n/// Directly forwards requests to gopeed REST API\nFuture<Response> forward(\n  String path, {\n  String method = 'GET',\n  dynamic data,\n  Map<String, dynamic>? queryParameters,\n}) async {\n  return _client.dio.request(\n    path,\n    data: data,\n    queryParameters: queryParameters,\n    options: Options(method: method),\n  );\n}\n"
  },
  {
    "path": "ui/flutter/lib/api/gopeed_site_api.dart",
    "content": "import 'dart:convert';\n\nimport 'package:dio/dio.dart';\n\nimport 'api.dart';\nimport 'model/store_extension.dart';\n\nclass GopeedSiteApi {\n  GopeedSiteApi._();\n\n  static final instance = GopeedSiteApi._();\n\n  static const _host = 'gopeed.com';\n\n  Future<Map<String, dynamic>> getRelease() async {\n    final json = await _getJson('/api/release');\n    return json as Map<String, dynamic>;\n  }\n\n  Future<StoreExtensionPage> getExtensions({\n    int page = 1,\n    int limit = 20,\n    StoreExtensionSort sort = StoreExtensionSort.stars,\n    StoreSortOrder order = StoreSortOrder.desc,\n    String? query,\n  }) async {\n    final json = await _getJson('/api/extensions', queryParameters: {\n      'page': page.toString(),\n      'limit': limit.clamp(1, 100).toString(),\n      'sort': sort.name,\n      'order': order.name,\n      if (query != null && query.trim().isNotEmpty) 'q': query.trim(),\n    });\n    return StoreExtensionPage.fromJson(json as Map<String, dynamic>);\n  }\n\n  Future<void> reportExtensionInstall(String id) async {\n    final uri = Uri.https(_host, '/api/extensions/install');\n    await proxyRequest(\n      uri.toString(),\n      data: {'id': id},\n      options: Options(method: 'POST', contentType: Headers.jsonContentType),\n    );\n  }\n\n  Future<dynamic> _getJson(String path,\n      {Map<String, String>? queryParameters}) async {\n    final uri = Uri.https(_host, path, queryParameters);\n    final Response<String> response = await proxyRequest(uri.toString());\n    if (response.data == null || response.data!.isEmpty) {\n      throw Exception('Empty response from $uri');\n    }\n    return jsonDecode(response.data!);\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/api/model/create_task.dart",
    "content": "import 'package:json_annotation/json_annotation.dart';\n\nimport 'options.dart';\nimport 'request.dart';\n\npart 'create_task.g.dart';\n\n@JsonSerializable(explicitToJson: true)\nclass CreateTask {\n  String? rid;\n  Request? req;\n  Options? opts;\n\n  CreateTask({\n    this.rid,\n    this.req,\n    this.opts,\n  });\n\n  factory CreateTask.fromJson(\n    Map<String, dynamic> json,\n  ) =>\n      _$CreateTaskFromJson(json);\n\n  Map<String, dynamic> toJson() => _$CreateTaskToJson(this);\n}\n"
  },
  {
    "path": "ui/flutter/lib/api/model/create_task.g.dart",
    "content": "// GENERATED CODE - DO NOT MODIFY BY HAND\n\npart of 'create_task.dart';\n\n// **************************************************************************\n// JsonSerializableGenerator\n// **************************************************************************\n\nCreateTask _$CreateTaskFromJson(Map<String, dynamic> json) => CreateTask(\n      rid: json['rid'] as String?,\n      req: json['req'] == null\n          ? null\n          : Request.fromJson(json['req'] as Map<String, dynamic>),\n      opts: json['opts'] == null\n          ? null\n          : Options.fromJson(json['opts'] as Map<String, dynamic>),\n    );\n\nMap<String, dynamic> _$CreateTaskToJson(CreateTask instance) {\n  final val = <String, dynamic>{};\n\n  void writeNotNull(String key, dynamic value) {\n    if (value != null) {\n      val[key] = value;\n    }\n  }\n\n  writeNotNull('rid', instance.rid);\n  writeNotNull('req', instance.req?.toJson());\n  writeNotNull('opts', instance.opts?.toJson());\n  return val;\n}\n"
  },
  {
    "path": "ui/flutter/lib/api/model/create_task_batch.dart",
    "content": "import 'package:json_annotation/json_annotation.dart';\n\nimport 'options.dart';\nimport 'request.dart';\n\npart 'create_task_batch.g.dart';\n\n@JsonSerializable(explicitToJson: true)\nclass CreateTaskBatch {\n  List<CreateTaskBatchItem>? reqs;\n  Options? opts;\n\n  CreateTaskBatch({\n    this.reqs,\n    this.opts,\n  });\n\n  factory CreateTaskBatch.fromJson(\n    Map<String, dynamic> json,\n  ) =>\n      _$CreateTaskBatchFromJson(json);\n\n  Map<String, dynamic> toJson() => _$CreateTaskBatchToJson(this);\n}\n\n@JsonSerializable()\nclass CreateTaskBatchItem {\n  Request? req;\n  Options? opts;\n\n  CreateTaskBatchItem({\n    this.req,\n    this.opts,\n  });\n\n  factory CreateTaskBatchItem.fromJson(Map<String, dynamic> json) =>\n      _$CreateTaskBatchItemFromJson(json);\n  Map<String, dynamic> toJson() => _$CreateTaskBatchItemToJson(this);\n}\n"
  },
  {
    "path": "ui/flutter/lib/api/model/create_task_batch.g.dart",
    "content": "// GENERATED CODE - DO NOT MODIFY BY HAND\n\npart of 'create_task_batch.dart';\n\n// **************************************************************************\n// JsonSerializableGenerator\n// **************************************************************************\n\nCreateTaskBatch _$CreateTaskBatchFromJson(Map<String, dynamic> json) =>\n    CreateTaskBatch(\n      reqs: (json['reqs'] as List<dynamic>?)\n          ?.map((e) => CreateTaskBatchItem.fromJson(e as Map<String, dynamic>))\n          .toList(),\n      opts: json['opts'] == null\n          ? null\n          : Options.fromJson(json['opts'] as Map<String, dynamic>),\n    );\n\nMap<String, dynamic> _$CreateTaskBatchToJson(CreateTaskBatch instance) {\n  final val = <String, dynamic>{};\n\n  void writeNotNull(String key, dynamic value) {\n    if (value != null) {\n      val[key] = value;\n    }\n  }\n\n  writeNotNull('reqs', instance.reqs?.map((e) => e.toJson()).toList());\n  writeNotNull('opts', instance.opts?.toJson());\n  return val;\n}\n\nCreateTaskBatchItem _$CreateTaskBatchItemFromJson(Map<String, dynamic> json) =>\n    CreateTaskBatchItem(\n      req: json['req'] == null\n          ? null\n          : Request.fromJson(json['req'] as Map<String, dynamic>),\n      opts: json['opts'] == null\n          ? null\n          : Options.fromJson(json['opts'] as Map<String, dynamic>),\n    );\n\nMap<String, dynamic> _$CreateTaskBatchItemToJson(CreateTaskBatchItem instance) {\n  final val = <String, dynamic>{};\n\n  void writeNotNull(String key, dynamic value) {\n    if (value != null) {\n      val[key] = value;\n    }\n  }\n\n  writeNotNull('req', instance.req);\n  writeNotNull('opts', instance.opts);\n  return val;\n}\n"
  },
  {
    "path": "ui/flutter/lib/api/model/downloader_config.dart",
    "content": "import 'package:json_annotation/json_annotation.dart';\n\npart 'downloader_config.g.dart';\n\n@JsonSerializable(explicitToJson: true)\nclass DownloaderConfig {\n  String downloadDir;\n  int maxRunning;\n  ProtocolConfig protocolConfig = ProtocolConfig();\n  ExtraConfig extra = ExtraConfig();\n  ProxyConfig proxy = ProxyConfig();\n  WebhookConfig webhook = WebhookConfig();\n  ScriptConfig script = ScriptConfig();\n  AutoTorrentConfig autoTorrent = AutoTorrentConfig();\n  ArchiveConfig archive = ArchiveConfig();\n  bool autoDeleteMissingFileTasks;\n\n  DownloaderConfig({\n    this.downloadDir = '',\n    this.maxRunning = 0,\n    this.autoDeleteMissingFileTasks = false,\n  });\n\n  factory DownloaderConfig.fromJson(Map<String, dynamic> json) =>\n      _$DownloaderConfigFromJson(json);\n\n  Map<String, dynamic> toJson() => _$DownloaderConfigToJson(this);\n}\n\n@JsonSerializable(explicitToJson: true)\nclass ProtocolConfig {\n  HttpConfig http = HttpConfig();\n  BtConfig bt = BtConfig();\n  Ed2kConfig ed2k = Ed2kConfig();\n\n  ProtocolConfig();\n\n  factory ProtocolConfig.fromJson(Map<String, dynamic>? json) =>\n      json == null ? ProtocolConfig() : _$ProtocolConfigFromJson(json);\n\n  Map<String, dynamic> toJson() => _$ProtocolConfigToJson(this);\n}\n\n@JsonSerializable()\nclass HttpConfig {\n  String userAgent;\n  int connections;\n  bool useServerCtime;\n\n  HttpConfig({\n    this.userAgent = '',\n    this.connections = 0,\n    this.useServerCtime = false,\n  });\n\n  factory HttpConfig.fromJson(Map<String, dynamic> json) =>\n      _$HttpConfigFromJson(json);\n\n  Map<String, dynamic> toJson() => _$HttpConfigToJson(this);\n}\n\n@JsonSerializable()\nclass BtConfig {\n  int listenPort;\n  List<String> trackers;\n  bool seedKeep;\n  double seedRatio;\n  int seedTime;\n\n  BtConfig({\n    this.listenPort = 0,\n    this.trackers = const [],\n    this.seedKeep = false,\n    this.seedRatio = 0,\n    this.seedTime = 0,\n  });\n\n  factory BtConfig.fromJson(Map<String, dynamic> json) =>\n      _$BtConfigFromJson(json);\n\n  Map<String, dynamic> toJson() => _$BtConfigToJson(this);\n}\n\n@JsonSerializable()\nclass Ed2kConfig {\n  int listenPort;\n  int udpPort;\n  String serverAddr;\n  String serverMet;\n  String nodesDat;\n\n  Ed2kConfig({\n    this.listenPort = 0,\n    this.udpPort = 0,\n    this.serverAddr = '',\n    this.serverMet = '',\n    this.nodesDat = '',\n  });\n\n  factory Ed2kConfig.fromJson(Map<String, dynamic> json) =>\n      _$Ed2kConfigFromJson(json);\n\n  Map<String, dynamic> toJson() => _$Ed2kConfigToJson(this);\n}\n\n@JsonSerializable(explicitToJson: true)\nclass ExtraConfig {\n  String themeMode;\n  String locale;\n  bool lastDeleteTaskKeep;\n  bool defaultDirectDownload;\n  bool defaultBtClient;\n  bool notifyWhenNewVersion;\n  bool autoStartTasks;\n  bool desktopNotification;\n  List<DownloadCategory> downloadCategories;\n\n  ExtraConfigBt bt = ExtraConfigBt();\n  ExtraConfigGithubMirror githubMirror = ExtraConfigGithubMirror();\n\n  ExtraConfig({\n    this.themeMode = '',\n    this.locale = '',\n    this.lastDeleteTaskKeep = false,\n    this.defaultDirectDownload = false,\n    this.defaultBtClient = true,\n    this.notifyWhenNewVersion = true,\n    this.autoStartTasks = false,\n    this.desktopNotification = true,\n    this.downloadCategories = const [],\n  });\n\n  factory ExtraConfig.fromJson(Map<String, dynamic>? json) =>\n      json == null ? ExtraConfig() : _$ExtraConfigFromJson(json);\n\n  Map<String, dynamic> toJson() => _$ExtraConfigToJson(this);\n}\n\n@JsonSerializable()\nclass DownloadCategory {\n  String name;\n  String path;\n  bool isBuiltIn;\n  String? nameKey; // i18n key for built-in categories (e.g., 'categoryMusic')\n  bool\n      isDeleted; // Mark built-in categories as deleted instead of removing them\n\n  DownloadCategory({\n    required this.name,\n    required this.path,\n    this.isBuiltIn = false,\n    this.nameKey,\n    this.isDeleted = false,\n  });\n\n  factory DownloadCategory.fromJson(Map<String, dynamic> json) =>\n      _$DownloadCategoryFromJson(json);\n\n  Map<String, dynamic> toJson() => _$DownloadCategoryToJson(this);\n}\n\n@JsonSerializable()\nclass WebhookConfig {\n  bool enable;\n  List<String> urls;\n\n  WebhookConfig({\n    this.enable = false,\n    this.urls = const [],\n  });\n\n  factory WebhookConfig.fromJson(Map<String, dynamic>? json) =>\n      json == null ? WebhookConfig() : _$WebhookConfigFromJson(json);\n\n  Map<String, dynamic> toJson() => _$WebhookConfigToJson(this);\n}\n\n@JsonSerializable()\nclass ScriptConfig {\n  bool enable;\n  List<String> paths;\n\n  ScriptConfig({\n    this.enable = false,\n    this.paths = const [],\n  });\n\n  factory ScriptConfig.fromJson(Map<String, dynamic>? json) =>\n      json == null ? ScriptConfig() : _$ScriptConfigFromJson(json);\n\n  Map<String, dynamic> toJson() => _$ScriptConfigToJson(this);\n}\n\n@JsonSerializable()\nclass ProxyConfig {\n  bool enable;\n  bool system;\n  String scheme;\n  String host;\n  String usr;\n  String pwd;\n\n  ProxyConfig({\n    this.enable = false,\n    this.system = false,\n    this.scheme = '',\n    this.host = '',\n    this.usr = '',\n    this.pwd = '',\n  });\n\n  factory ProxyConfig.fromJson(Map<String, dynamic> json) =>\n      _$ProxyConfigFromJson(json);\n\n  Map<String, dynamic> toJson() => _$ProxyConfigToJson(this);\n}\n\n@JsonSerializable()\nclass ExtraConfigBt {\n  List<String> trackerSubscribeUrls = [];\n  List<String> subscribeTrackers = [];\n  bool autoUpdateTrackers = true;\n  DateTime? lastTrackerUpdateTime;\n\n  List<String> customTrackers = [];\n\n  ExtraConfigBt();\n\n  factory ExtraConfigBt.fromJson(Map<String, dynamic> json) =>\n      _$ExtraConfigBtFromJson(json);\n\n  Map<String, dynamic> toJson() => _$ExtraConfigBtToJson(this);\n}\n\nenum GithubMirrorType {\n  jsdelivr,\n  ghProxy,\n}\n\n@JsonSerializable()\nclass GithubMirror {\n  GithubMirrorType type;\n  String url;\n  bool isBuiltIn;\n  bool isDeleted;\n\n  GithubMirror({\n    required this.type,\n    required this.url,\n    this.isBuiltIn = false,\n    this.isDeleted = false,\n  });\n\n  factory GithubMirror.fromJson(Map<String, dynamic> json) =>\n      _$GithubMirrorFromJson(json);\n\n  Map<String, dynamic> toJson() => _$GithubMirrorToJson(this);\n}\n\n@JsonSerializable(explicitToJson: true)\nclass ExtraConfigGithubMirror {\n  bool enabled;\n  List<GithubMirror> mirrors;\n\n  ExtraConfigGithubMirror({\n    this.enabled = true,\n    this.mirrors = const [],\n  });\n\n  factory ExtraConfigGithubMirror.fromJson(Map<String, dynamic>? json) =>\n      json == null\n          ? ExtraConfigGithubMirror()\n          : _$ExtraConfigGithubMirrorFromJson(json);\n\n  Map<String, dynamic> toJson() => _$ExtraConfigGithubMirrorToJson(this);\n}\n\n@JsonSerializable()\nclass AutoTorrentConfig {\n  bool enable;\n  bool deleteAfterDownload;\n\n  AutoTorrentConfig({\n    this.enable = false,\n    this.deleteAfterDownload = false,\n  });\n\n  factory AutoTorrentConfig.fromJson(Map<String, dynamic>? json) =>\n      json == null ? AutoTorrentConfig() : _$AutoTorrentConfigFromJson(json);\n\n  Map<String, dynamic> toJson() => _$AutoTorrentConfigToJson(this);\n}\n\n@JsonSerializable()\nclass ArchiveConfig {\n  bool autoExtract;\n  bool deleteAfterExtract;\n\n  ArchiveConfig({\n    this.autoExtract = true,\n    this.deleteAfterExtract = true,\n  });\n\n  factory ArchiveConfig.fromJson(Map<String, dynamic>? json) =>\n      json == null ? ArchiveConfig() : _$ArchiveConfigFromJson(json);\n\n  Map<String, dynamic> toJson() => _$ArchiveConfigToJson(this);\n}\n"
  },
  {
    "path": "ui/flutter/lib/api/model/downloader_config.g.dart",
    "content": "// GENERATED CODE - DO NOT MODIFY BY HAND\n\npart of 'downloader_config.dart';\n\n// **************************************************************************\n// JsonSerializableGenerator\n// **************************************************************************\n\nDownloaderConfig _$DownloaderConfigFromJson(Map<String, dynamic> json) =>\n    DownloaderConfig(\n      downloadDir: json['downloadDir'] as String? ?? '',\n      maxRunning: (json['maxRunning'] as num?)?.toInt() ?? 0,\n      autoDeleteMissingFileTasks:\n          json['autoDeleteMissingFileTasks'] as bool? ?? false,\n    )\n      ..protocolConfig = ProtocolConfig.fromJson(\n          json['protocolConfig'] as Map<String, dynamic>?)\n      ..extra = ExtraConfig.fromJson(json['extra'] as Map<String, dynamic>?)\n      ..proxy = ProxyConfig.fromJson(json['proxy'] as Map<String, dynamic>)\n      ..webhook =\n          WebhookConfig.fromJson(json['webhook'] as Map<String, dynamic>?)\n      ..script = ScriptConfig.fromJson(json['script'] as Map<String, dynamic>?)\n      ..autoTorrent = AutoTorrentConfig.fromJson(\n          json['autoTorrent'] as Map<String, dynamic>?)\n      ..archive =\n          ArchiveConfig.fromJson(json['archive'] as Map<String, dynamic>?);\n\nMap<String, dynamic> _$DownloaderConfigToJson(DownloaderConfig instance) =>\n    <String, dynamic>{\n      'downloadDir': instance.downloadDir,\n      'maxRunning': instance.maxRunning,\n      'protocolConfig': instance.protocolConfig.toJson(),\n      'extra': instance.extra.toJson(),\n      'proxy': instance.proxy.toJson(),\n      'webhook': instance.webhook.toJson(),\n      'script': instance.script.toJson(),\n      'autoTorrent': instance.autoTorrent.toJson(),\n      'archive': instance.archive.toJson(),\n      'autoDeleteMissingFileTasks': instance.autoDeleteMissingFileTasks,\n    };\n\nProtocolConfig _$ProtocolConfigFromJson(Map<String, dynamic> json) =>\n    ProtocolConfig()\n      ..http = HttpConfig.fromJson(json['http'] as Map<String, dynamic>)\n      ..bt = BtConfig.fromJson(json['bt'] as Map<String, dynamic>)\n      ..ed2k = Ed2kConfig.fromJson(\n          json['ed2k'] as Map<String, dynamic>? ?? <String, dynamic>{});\n\nMap<String, dynamic> _$ProtocolConfigToJson(ProtocolConfig instance) =>\n    <String, dynamic>{\n      'http': instance.http.toJson(),\n      'bt': instance.bt.toJson(),\n      'ed2k': instance.ed2k.toJson(),\n    };\n\nHttpConfig _$HttpConfigFromJson(Map<String, dynamic> json) => HttpConfig(\n      userAgent: json['userAgent'] as String? ?? '',\n      connections: (json['connections'] as num?)?.toInt() ?? 0,\n      useServerCtime: json['useServerCtime'] as bool? ?? false,\n    );\n\nMap<String, dynamic> _$HttpConfigToJson(HttpConfig instance) =>\n    <String, dynamic>{\n      'userAgent': instance.userAgent,\n      'connections': instance.connections,\n      'useServerCtime': instance.useServerCtime,\n    };\n\nBtConfig _$BtConfigFromJson(Map<String, dynamic> json) => BtConfig(\n      listenPort: (json['listenPort'] as num?)?.toInt() ?? 0,\n      trackers: (json['trackers'] as List<dynamic>?)\n              ?.map((e) => e as String)\n              .toList() ??\n          const [],\n      seedKeep: json['seedKeep'] as bool? ?? false,\n      seedRatio: (json['seedRatio'] as num?)?.toDouble() ?? 0,\n      seedTime: (json['seedTime'] as num?)?.toInt() ?? 0,\n    );\n\nMap<String, dynamic> _$BtConfigToJson(BtConfig instance) => <String, dynamic>{\n      'listenPort': instance.listenPort,\n      'trackers': instance.trackers,\n      'seedKeep': instance.seedKeep,\n      'seedRatio': instance.seedRatio,\n      'seedTime': instance.seedTime,\n    };\n\nEd2kConfig _$Ed2kConfigFromJson(Map<String, dynamic> json) => Ed2kConfig(\n      listenPort: (json['listenPort'] as num?)?.toInt() ?? 0,\n      udpPort: (json['udpPort'] as num?)?.toInt() ?? 0,\n      serverAddr: json['serverAddr'] as String? ?? '',\n      serverMet: json['serverMet'] as String? ?? '',\n      nodesDat: json['nodesDat'] as String? ?? '',\n    );\n\nMap<String, dynamic> _$Ed2kConfigToJson(Ed2kConfig instance) =>\n    <String, dynamic>{\n      'listenPort': instance.listenPort,\n      'udpPort': instance.udpPort,\n      'serverAddr': instance.serverAddr,\n      'serverMet': instance.serverMet,\n      'nodesDat': instance.nodesDat,\n    };\n\nExtraConfig _$ExtraConfigFromJson(Map<String, dynamic> json) => ExtraConfig(\n      themeMode: json['themeMode'] as String? ?? '',\n      locale: json['locale'] as String? ?? '',\n      lastDeleteTaskKeep: json['lastDeleteTaskKeep'] as bool? ?? false,\n      defaultDirectDownload: json['defaultDirectDownload'] as bool? ?? false,\n      defaultBtClient: json['defaultBtClient'] as bool? ?? true,\n      notifyWhenNewVersion: json['notifyWhenNewVersion'] as bool? ?? true,\n      autoStartTasks: json['autoStartTasks'] as bool? ?? false,\n      desktopNotification: json['desktopNotification'] as bool? ?? true,\n      downloadCategories: (json['downloadCategories'] as List<dynamic>?)\n              ?.map((e) => DownloadCategory.fromJson(e as Map<String, dynamic>))\n              .toList() ??\n          const [],\n    )\n      ..bt = ExtraConfigBt.fromJson(json['bt'] as Map<String, dynamic>)\n      ..githubMirror = ExtraConfigGithubMirror.fromJson(\n          json['githubMirror'] as Map<String, dynamic>?);\n\nMap<String, dynamic> _$ExtraConfigToJson(ExtraConfig instance) =>\n    <String, dynamic>{\n      'themeMode': instance.themeMode,\n      'locale': instance.locale,\n      'lastDeleteTaskKeep': instance.lastDeleteTaskKeep,\n      'defaultDirectDownload': instance.defaultDirectDownload,\n      'defaultBtClient': instance.defaultBtClient,\n      'notifyWhenNewVersion': instance.notifyWhenNewVersion,\n      'autoStartTasks': instance.autoStartTasks,\n      'desktopNotification': instance.desktopNotification,\n      'downloadCategories':\n          instance.downloadCategories.map((e) => e.toJson()).toList(),\n      'bt': instance.bt.toJson(),\n      'githubMirror': instance.githubMirror.toJson(),\n    };\n\nDownloadCategory _$DownloadCategoryFromJson(Map<String, dynamic> json) =>\n    DownloadCategory(\n      name: json['name'] as String,\n      path: json['path'] as String,\n      isBuiltIn: json['isBuiltIn'] as bool? ?? false,\n      nameKey: json['nameKey'] as String?,\n      isDeleted: json['isDeleted'] as bool? ?? false,\n    );\n\nMap<String, dynamic> _$DownloadCategoryToJson(DownloadCategory instance) {\n  final val = <String, dynamic>{\n    'name': instance.name,\n    'path': instance.path,\n    'isBuiltIn': instance.isBuiltIn,\n  };\n\n  void writeNotNull(String key, dynamic value) {\n    if (value != null) {\n      val[key] = value;\n    }\n  }\n\n  writeNotNull('nameKey', instance.nameKey);\n  val['isDeleted'] = instance.isDeleted;\n  return val;\n}\n\nWebhookConfig _$WebhookConfigFromJson(Map<String, dynamic> json) =>\n    WebhookConfig(\n      enable: json['enable'] as bool? ?? false,\n      urls:\n          (json['urls'] as List<dynamic>?)?.map((e) => e as String).toList() ??\n              const [],\n    );\n\nMap<String, dynamic> _$WebhookConfigToJson(WebhookConfig instance) =>\n    <String, dynamic>{\n      'enable': instance.enable,\n      'urls': instance.urls,\n    };\n\nScriptConfig _$ScriptConfigFromJson(Map<String, dynamic> json) => ScriptConfig(\n      enable: json['enable'] as bool? ?? false,\n      paths:\n          (json['paths'] as List<dynamic>?)?.map((e) => e as String).toList() ??\n              const [],\n    );\n\nMap<String, dynamic> _$ScriptConfigToJson(ScriptConfig instance) =>\n    <String, dynamic>{\n      'enable': instance.enable,\n      'paths': instance.paths,\n    };\n\nProxyConfig _$ProxyConfigFromJson(Map<String, dynamic> json) => ProxyConfig(\n      enable: json['enable'] as bool? ?? false,\n      system: json['system'] as bool? ?? false,\n      scheme: json['scheme'] as String? ?? '',\n      host: json['host'] as String? ?? '',\n      usr: json['usr'] as String? ?? '',\n      pwd: json['pwd'] as String? ?? '',\n    );\n\nMap<String, dynamic> _$ProxyConfigToJson(ProxyConfig instance) =>\n    <String, dynamic>{\n      'enable': instance.enable,\n      'system': instance.system,\n      'scheme': instance.scheme,\n      'host': instance.host,\n      'usr': instance.usr,\n      'pwd': instance.pwd,\n    };\n\nExtraConfigBt _$ExtraConfigBtFromJson(Map<String, dynamic> json) =>\n    ExtraConfigBt()\n      ..trackerSubscribeUrls = (json['trackerSubscribeUrls'] as List<dynamic>)\n          .map((e) => e as String)\n          .toList()\n      ..subscribeTrackers = (json['subscribeTrackers'] as List<dynamic>)\n          .map((e) => e as String)\n          .toList()\n      ..autoUpdateTrackers = json['autoUpdateTrackers'] as bool\n      ..lastTrackerUpdateTime = json['lastTrackerUpdateTime'] == null\n          ? null\n          : DateTime.parse(json['lastTrackerUpdateTime'] as String)\n      ..customTrackers = (json['customTrackers'] as List<dynamic>)\n          .map((e) => e as String)\n          .toList();\n\nMap<String, dynamic> _$ExtraConfigBtToJson(ExtraConfigBt instance) {\n  final val = <String, dynamic>{\n    'trackerSubscribeUrls': instance.trackerSubscribeUrls,\n    'subscribeTrackers': instance.subscribeTrackers,\n    'autoUpdateTrackers': instance.autoUpdateTrackers,\n  };\n\n  void writeNotNull(String key, dynamic value) {\n    if (value != null) {\n      val[key] = value;\n    }\n  }\n\n  writeNotNull('lastTrackerUpdateTime',\n      instance.lastTrackerUpdateTime?.toIso8601String());\n  val['customTrackers'] = instance.customTrackers;\n  return val;\n}\n\nGithubMirror _$GithubMirrorFromJson(Map<String, dynamic> json) => GithubMirror(\n      type: $enumDecode(_$GithubMirrorTypeEnumMap, json['type']),\n      url: json['url'] as String,\n      isBuiltIn: json['isBuiltIn'] as bool? ?? false,\n      isDeleted: json['isDeleted'] as bool? ?? false,\n    );\n\nMap<String, dynamic> _$GithubMirrorToJson(GithubMirror instance) =>\n    <String, dynamic>{\n      'type': _$GithubMirrorTypeEnumMap[instance.type]!,\n      'url': instance.url,\n      'isBuiltIn': instance.isBuiltIn,\n      'isDeleted': instance.isDeleted,\n    };\n\nconst _$GithubMirrorTypeEnumMap = {\n  GithubMirrorType.jsdelivr: 'jsdelivr',\n  GithubMirrorType.ghProxy: 'ghProxy',\n};\n\nExtraConfigGithubMirror _$ExtraConfigGithubMirrorFromJson(\n        Map<String, dynamic> json) =>\n    ExtraConfigGithubMirror(\n      enabled: json['enabled'] as bool? ?? true,\n      mirrors: (json['mirrors'] as List<dynamic>?)\n              ?.map((e) => GithubMirror.fromJson(e as Map<String, dynamic>))\n              .toList() ??\n          const [],\n    );\n\nMap<String, dynamic> _$ExtraConfigGithubMirrorToJson(\n        ExtraConfigGithubMirror instance) =>\n    <String, dynamic>{\n      'enabled': instance.enabled,\n      'mirrors': instance.mirrors.map((e) => e.toJson()).toList(),\n    };\n\nAutoTorrentConfig _$AutoTorrentConfigFromJson(Map<String, dynamic> json) =>\n    AutoTorrentConfig(\n      enable: json['enable'] as bool? ?? false,\n      deleteAfterDownload: json['deleteAfterDownload'] as bool? ?? false,\n    );\n\nMap<String, dynamic> _$AutoTorrentConfigToJson(AutoTorrentConfig instance) =>\n    <String, dynamic>{\n      'enable': instance.enable,\n      'deleteAfterDownload': instance.deleteAfterDownload,\n    };\n\nArchiveConfig _$ArchiveConfigFromJson(Map<String, dynamic> json) =>\n    ArchiveConfig(\n      autoExtract: json['autoExtract'] as bool? ?? true,\n      deleteAfterExtract: json['deleteAfterExtract'] as bool? ?? true,\n    );\n\nMap<String, dynamic> _$ArchiveConfigToJson(ArchiveConfig instance) =>\n    <String, dynamic>{\n      'autoExtract': instance.autoExtract,\n      'deleteAfterExtract': instance.deleteAfterExtract,\n    };\n"
  },
  {
    "path": "ui/flutter/lib/api/model/extension.dart",
    "content": "import 'package:json_annotation/json_annotation.dart';\n\npart 'extension.g.dart';\n\n@JsonSerializable(explicitToJson: true)\nclass Extension {\n  String identity;\n  String name;\n  String author;\n  String title;\n  String description;\n  String icon;\n  String version;\n  String homepage;\n  Repository? repository;\n  List<Setting>? settings;\n  bool disabled;\n  bool devMode;\n  String devPath;\n\n  Extension({\n    required this.identity,\n    required this.name,\n    required this.author,\n    required this.title,\n    required this.description,\n    required this.icon,\n    required this.version,\n    required this.homepage,\n    required this.repository,\n    required this.disabled,\n    required this.devMode,\n    required this.devPath,\n  });\n\n  factory Extension.fromJson(Map<String, dynamic> json) =>\n      _$ExtensionFromJson(json);\n  Map<String, dynamic> toJson() => _$ExtensionToJson(this);\n}\n\n@JsonSerializable()\nclass Repository {\n  String url;\n  String directory;\n\n  Repository({\n    required this.url,\n    required this.directory,\n  });\n\n  factory Repository.fromJson(Map<String, dynamic> json) =>\n      _$RepositoryFromJson(json);\n  Map<String, dynamic> toJson() => _$RepositoryToJson(this);\n}\n\n@JsonSerializable()\nclass Setting {\n  String name;\n  String title;\n  String description;\n  bool required;\n  SettingType type;\n  Object? value;\n  List<Option>? options;\n\n  Setting({\n    required this.name,\n    required this.title,\n    required this.description,\n    required this.required,\n    required this.type,\n  });\n\n  factory Setting.fromJson(Map<String, dynamic> json) =>\n      _$SettingFromJson(json);\n  Map<String, dynamic> toJson() => _$SettingToJson(this);\n}\n\n@JsonSerializable()\nclass Option {\n  String label;\n  Object value;\n\n  Option({\n    required this.label,\n    required this.value,\n  });\n\n  factory Option.fromJson(Map<String, dynamic> json) => _$OptionFromJson(json);\n  Map<String, dynamic> toJson() => _$OptionToJson(this);\n}\n\nenum SettingType {\n  string,\n  number,\n  boolean,\n}\n"
  },
  {
    "path": "ui/flutter/lib/api/model/extension.g.dart",
    "content": "// GENERATED CODE - DO NOT MODIFY BY HAND\n\npart of 'extension.dart';\n\n// **************************************************************************\n// JsonSerializableGenerator\n// **************************************************************************\n\nExtension _$ExtensionFromJson(Map<String, dynamic> json) => Extension(\n      identity: json['identity'] as String,\n      name: json['name'] as String,\n      author: json['author'] as String,\n      title: json['title'] as String,\n      description: json['description'] as String,\n      icon: json['icon'] as String,\n      version: json['version'] as String,\n      homepage: json['homepage'] as String,\n      repository: json['repository'] == null\n          ? null\n          : Repository.fromJson(json['repository'] as Map<String, dynamic>),\n      disabled: json['disabled'] as bool,\n      devMode: json['devMode'] as bool,\n      devPath: json['devPath'] as String,\n    )..settings = (json['settings'] as List<dynamic>?)\n        ?.map((e) => Setting.fromJson(e as Map<String, dynamic>))\n        .toList();\n\nMap<String, dynamic> _$ExtensionToJson(Extension instance) {\n  final val = <String, dynamic>{\n    'identity': instance.identity,\n    'name': instance.name,\n    'author': instance.author,\n    'title': instance.title,\n    'description': instance.description,\n    'icon': instance.icon,\n    'version': instance.version,\n    'homepage': instance.homepage,\n  };\n\n  void writeNotNull(String key, dynamic value) {\n    if (value != null) {\n      val[key] = value;\n    }\n  }\n\n  writeNotNull('repository', instance.repository?.toJson());\n  writeNotNull('settings', instance.settings?.map((e) => e.toJson()).toList());\n  val['disabled'] = instance.disabled;\n  val['devMode'] = instance.devMode;\n  val['devPath'] = instance.devPath;\n  return val;\n}\n\nRepository _$RepositoryFromJson(Map<String, dynamic> json) => Repository(\n      url: json['url'] as String,\n      directory: json['directory'] as String,\n    );\n\nMap<String, dynamic> _$RepositoryToJson(Repository instance) =>\n    <String, dynamic>{\n      'url': instance.url,\n      'directory': instance.directory,\n    };\n\nSetting _$SettingFromJson(Map<String, dynamic> json) => Setting(\n      name: json['name'] as String,\n      title: json['title'] as String,\n      description: json['description'] as String,\n      required: json['required'] as bool,\n      type: $enumDecode(_$SettingTypeEnumMap, json['type']),\n    )\n      ..value = json['value']\n      ..options = (json['options'] as List<dynamic>?)\n          ?.map((e) => Option.fromJson(e as Map<String, dynamic>))\n          .toList();\n\nMap<String, dynamic> _$SettingToJson(Setting instance) {\n  final val = <String, dynamic>{\n    'name': instance.name,\n    'title': instance.title,\n    'description': instance.description,\n    'required': instance.required,\n    'type': _$SettingTypeEnumMap[instance.type]!,\n  };\n\n  void writeNotNull(String key, dynamic value) {\n    if (value != null) {\n      val[key] = value;\n    }\n  }\n\n  writeNotNull('value', instance.value);\n  writeNotNull('options', instance.options);\n  return val;\n}\n\nconst _$SettingTypeEnumMap = {\n  SettingType.string: 'string',\n  SettingType.number: 'number',\n  SettingType.boolean: 'boolean',\n};\n\nOption _$OptionFromJson(Map<String, dynamic> json) => Option(\n      label: json['label'] as String,\n      value: json['value'] as Object,\n    );\n\nMap<String, dynamic> _$OptionToJson(Option instance) => <String, dynamic>{\n      'label': instance.label,\n      'value': instance.value,\n    };\n"
  },
  {
    "path": "ui/flutter/lib/api/model/install_extension.dart",
    "content": "import 'package:json_annotation/json_annotation.dart';\n\npart 'install_extension.g.dart';\n\n@JsonSerializable()\nclass InstallExtension {\n  bool devMode;\n  String url;\n\n  InstallExtension({\n    this.devMode = false,\n    required this.url,\n  });\n\n  factory InstallExtension.fromJson(Map<String, dynamic> json) =>\n      _$InstallExtensionFromJson(json);\n  Map<String, dynamic> toJson() => _$InstallExtensionToJson(this);\n}\n"
  },
  {
    "path": "ui/flutter/lib/api/model/install_extension.g.dart",
    "content": "// GENERATED CODE - DO NOT MODIFY BY HAND\n\npart of 'install_extension.dart';\n\n// **************************************************************************\n// JsonSerializableGenerator\n// **************************************************************************\n\nInstallExtension _$InstallExtensionFromJson(Map<String, dynamic> json) =>\n    InstallExtension(\n      devMode: json['devMode'] as bool? ?? false,\n      url: json['url'] as String,\n    );\n\nMap<String, dynamic> _$InstallExtensionToJson(InstallExtension instance) =>\n    <String, dynamic>{\n      'devMode': instance.devMode,\n      'url': instance.url,\n    };\n"
  },
  {
    "path": "ui/flutter/lib/api/model/login.dart",
    "content": "import 'package:json_annotation/json_annotation.dart';\n\npart 'login.g.dart';\n\n@JsonSerializable()\nclass LoginReq {\n  String username;\n  String password;\n\n  LoginReq({\n    required this.username,\n    required this.password,\n  });\n\n  factory LoginReq.fromJson(Map<String, dynamic> json) =>\n      _$LoginReqFromJson(json);\n  Map<String, dynamic> toJson() => _$LoginReqToJson(this);\n}\n"
  },
  {
    "path": "ui/flutter/lib/api/model/login.g.dart",
    "content": "// GENERATED CODE - DO NOT MODIFY BY HAND\n\npart of 'login.dart';\n\n// **************************************************************************\n// JsonSerializableGenerator\n// **************************************************************************\n\nLoginReq _$LoginReqFromJson(Map<String, dynamic> json) => LoginReq(\n      username: json['username'] as String,\n      password: json['password'] as String,\n    );\n\nMap<String, dynamic> _$LoginReqToJson(LoginReq instance) => <String, dynamic>{\n      'username': instance.username,\n      'password': instance.password,\n    };\n"
  },
  {
    "path": "ui/flutter/lib/api/model/meta.dart",
    "content": "import 'options.dart';\nimport 'request.dart';\nimport 'resource.dart';\nimport 'package:json_annotation/json_annotation.dart';\n\npart 'meta.g.dart';\n\n@JsonSerializable(explicitToJson: true)\nclass Meta {\n  Request req;\n  Resource? res;\n  Options opts;\n\n  Meta({\n    required this.req,\n    required this.opts,\n  });\n\n  factory Meta.fromJson(Map<String, dynamic> json) => _$MetaFromJson(json);\n  Map<String, dynamic> toJson() => _$MetaToJson(this);\n}\n"
  },
  {
    "path": "ui/flutter/lib/api/model/meta.g.dart",
    "content": "// GENERATED CODE - DO NOT MODIFY BY HAND\n\npart of 'meta.dart';\n\n// **************************************************************************\n// JsonSerializableGenerator\n// **************************************************************************\n\nMeta _$MetaFromJson(Map<String, dynamic> json) => Meta(\n      req: Request.fromJson(json['req'] as Map<String, dynamic>),\n      opts: Options.fromJson(json['opts'] as Map<String, dynamic>),\n    )..res = json['res'] == null\n        ? null\n        : Resource.fromJson(json['res'] as Map<String, dynamic>);\n\nMap<String, dynamic> _$MetaToJson(Meta instance) {\n  final val = <String, dynamic>{\n    'req': instance.req.toJson(),\n  };\n\n  void writeNotNull(String key, dynamic value) {\n    if (value != null) {\n      val[key] = value;\n    }\n  }\n\n  writeNotNull('res', instance.res?.toJson());\n  val['opts'] = instance.opts.toJson();\n  return val;\n}\n"
  },
  {
    "path": "ui/flutter/lib/api/model/options.dart",
    "content": "import 'package:json_annotation/json_annotation.dart';\n\npart 'options.g.dart';\n\n@JsonSerializable(explicitToJson: true)\nclass Options {\n  String name;\n  String path;\n  List<int> selectFiles;\n  Object? extra;\n\n  Options({\n    this.name = '',\n    this.path = '',\n    this.selectFiles = const [],\n    this.extra,\n  });\n\n  factory Options.fromJson(Map<String, dynamic> json) =>\n      _$OptionsFromJson(json);\n\n  Map<String, dynamic> toJson() => _$OptionsToJson(this);\n}\n\n@JsonSerializable()\nclass OptsExtraHttp {\n  int connections;\n  bool? autoTorrent;\n  bool? deleteTorrentAfterDownload;\n  bool? autoExtract;\n  String archivePassword;\n  bool deleteAfterExtract;\n\n  OptsExtraHttp({\n    this.connections = 0,\n    this.autoTorrent,\n    this.deleteTorrentAfterDownload,\n    this.autoExtract,\n    this.archivePassword = '',\n    this.deleteAfterExtract = false,\n  });\n\n  factory OptsExtraHttp.fromJson(Map<String, dynamic> json) =>\n      _$OptsExtraHttpFromJson(json);\n\n  Map<String, dynamic> toJson() => _$OptsExtraHttpToJson(this);\n}\n"
  },
  {
    "path": "ui/flutter/lib/api/model/options.g.dart",
    "content": "// GENERATED CODE - DO NOT MODIFY BY HAND\n\npart of 'options.dart';\n\n// **************************************************************************\n// JsonSerializableGenerator\n// **************************************************************************\n\nOptions _$OptionsFromJson(Map<String, dynamic> json) => Options(\n      name: json['name'] as String? ?? '',\n      path: json['path'] as String? ?? '',\n      selectFiles: (json['selectFiles'] as List<dynamic>?)\n              ?.map((e) => (e as num).toInt())\n              .toList() ??\n          const [],\n      extra: json['extra'],\n    );\n\nMap<String, dynamic> _$OptionsToJson(Options instance) {\n  final val = <String, dynamic>{\n    'name': instance.name,\n    'path': instance.path,\n    'selectFiles': instance.selectFiles,\n  };\n\n  void writeNotNull(String key, dynamic value) {\n    if (value != null) {\n      val[key] = value;\n    }\n  }\n\n  writeNotNull('extra', instance.extra);\n  return val;\n}\n\nOptsExtraHttp _$OptsExtraHttpFromJson(Map<String, dynamic> json) =>\n    OptsExtraHttp(\n      connections: (json['connections'] as num?)?.toInt() ?? 0,\n      autoTorrent: json['autoTorrent'] as bool?,\n      deleteTorrentAfterDownload: json['deleteTorrentAfterDownload'] as bool?,\n      autoExtract: json['autoExtract'] as bool?,\n      archivePassword: json['archivePassword'] as String? ?? '',\n      deleteAfterExtract: json['deleteAfterExtract'] as bool? ?? false,\n    );\n\nMap<String, dynamic> _$OptsExtraHttpToJson(OptsExtraHttp instance) {\n  final val = <String, dynamic>{\n    'connections': instance.connections,\n  };\n\n  void writeNotNull(String key, dynamic value) {\n    if (value != null) {\n      val[key] = value;\n    }\n  }\n\n  writeNotNull('autoTorrent', instance.autoTorrent);\n  writeNotNull(\n      'deleteTorrentAfterDownload', instance.deleteTorrentAfterDownload);\n  writeNotNull('autoExtract', instance.autoExtract);\n  val['archivePassword'] = instance.archivePassword;\n  val['deleteAfterExtract'] = instance.deleteAfterExtract;\n  return val;\n}\n"
  },
  {
    "path": "ui/flutter/lib/api/model/request.dart",
    "content": "import 'package:json_annotation/json_annotation.dart';\n\npart 'request.g.dart';\n\n@JsonSerializable(explicitToJson: true)\nclass Request {\n  String url;\n  Object? extra;\n  Map<String, String>? labels;\n  RequestProxy? proxy;\n  bool skipVerifyCert;\n\n  Request({\n    required this.url,\n    this.extra,\n    this.labels,\n    this.proxy,\n    this.skipVerifyCert = false,\n  });\n\n  factory Request.fromJson(Map<String, dynamic> json) =>\n      _$RequestFromJson(json);\n\n  Map<String, dynamic> toJson() => _$RequestToJson(this);\n}\n\n@JsonSerializable()\nclass ReqExtraHttp {\n  String method;\n  Map<String, String> header;\n  String body;\n\n  ReqExtraHttp({\n    this.method = 'GET',\n    this.header = const {},\n    this.body = '',\n  });\n\n  factory ReqExtraHttp.fromJson(Map<String, dynamic> json) =>\n      _$ReqExtraHttpFromJson(json);\n\n  Map<String, dynamic> toJson() => _$ReqExtraHttpToJson(this);\n}\n\n@JsonSerializable()\nclass ReqExtraBt {\n  List<String> trackers;\n\n  ReqExtraBt({\n    this.trackers = const [],\n  });\n\n  factory ReqExtraBt.fromJson(Map<String, dynamic> json) =>\n      _$ReqExtraBtFromJson(json);\n\n  Map<String, dynamic> toJson() => _$ReqExtraBtToJson(this);\n}\n\nenum RequestProxyMode {\n  follow,\n  none,\n  custom,\n}\n\n@JsonSerializable()\nclass RequestProxy {\n  RequestProxyMode mode;\n  String scheme;\n  String host;\n  String usr;\n  String pwd;\n\n  RequestProxy({\n    this.mode = RequestProxyMode.follow,\n    this.scheme = 'http',\n    this.host = '',\n    this.usr = '',\n    this.pwd = '',\n  });\n\n  factory RequestProxy.fromJson(Map<String, dynamic> json) =>\n      _$RequestProxyFromJson(json);\n\n  Map<String, dynamic> toJson() => _$RequestProxyToJson(this);\n}\n"
  },
  {
    "path": "ui/flutter/lib/api/model/request.g.dart",
    "content": "// GENERATED CODE - DO NOT MODIFY BY HAND\n\npart of 'request.dart';\n\n// **************************************************************************\n// JsonSerializableGenerator\n// **************************************************************************\n\nRequest _$RequestFromJson(Map<String, dynamic> json) => Request(\n      url: json['url'] as String,\n      extra: json['extra'],\n      labels: (json['labels'] as Map<String, dynamic>?)?.map(\n        (k, e) => MapEntry(k, e as String),\n      ),\n      proxy: json['proxy'] == null\n          ? null\n          : RequestProxy.fromJson(json['proxy'] as Map<String, dynamic>),\n      skipVerifyCert: json['skipVerifyCert'] as bool? ?? false,\n    );\n\nMap<String, dynamic> _$RequestToJson(Request instance) {\n  final val = <String, dynamic>{\n    'url': instance.url,\n  };\n\n  void writeNotNull(String key, dynamic value) {\n    if (value != null) {\n      val[key] = value;\n    }\n  }\n\n  writeNotNull('extra', instance.extra);\n  writeNotNull('labels', instance.labels);\n  writeNotNull('proxy', instance.proxy?.toJson());\n  val['skipVerifyCert'] = instance.skipVerifyCert;\n  return val;\n}\n\nReqExtraHttp _$ReqExtraHttpFromJson(Map<String, dynamic> json) => ReqExtraHttp(\n      method: json['method'] as String? ?? 'GET',\n      header: (json['header'] as Map<String, dynamic>?)?.map(\n            (k, e) => MapEntry(k, e as String),\n          ) ??\n          const {},\n      body: json['body'] as String? ?? '',\n    );\n\nMap<String, dynamic> _$ReqExtraHttpToJson(ReqExtraHttp instance) =>\n    <String, dynamic>{\n      'method': instance.method,\n      'header': instance.header,\n      'body': instance.body,\n    };\n\nReqExtraBt _$ReqExtraBtFromJson(Map<String, dynamic> json) => ReqExtraBt(\n      trackers: (json['trackers'] as List<dynamic>?)\n              ?.map((e) => e as String)\n              .toList() ??\n          const [],\n    );\n\nMap<String, dynamic> _$ReqExtraBtToJson(ReqExtraBt instance) =>\n    <String, dynamic>{\n      'trackers': instance.trackers,\n    };\n\nRequestProxy _$RequestProxyFromJson(Map<String, dynamic> json) => RequestProxy(\n      mode: $enumDecodeNullable(_$RequestProxyModeEnumMap, json['mode']) ??\n          RequestProxyMode.follow,\n      scheme: json['scheme'] as String? ?? 'http',\n      host: json['host'] as String? ?? '',\n      usr: json['usr'] as String? ?? '',\n      pwd: json['pwd'] as String? ?? '',\n    );\n\nMap<String, dynamic> _$RequestProxyToJson(RequestProxy instance) =>\n    <String, dynamic>{\n      'mode': _$RequestProxyModeEnumMap[instance.mode]!,\n      'scheme': instance.scheme,\n      'host': instance.host,\n      'usr': instance.usr,\n      'pwd': instance.pwd,\n    };\n\nconst _$RequestProxyModeEnumMap = {\n  RequestProxyMode.follow: 'follow',\n  RequestProxyMode.none: 'none',\n  RequestProxyMode.custom: 'custom',\n};\n"
  },
  {
    "path": "ui/flutter/lib/api/model/resolve_result.dart",
    "content": "import 'package:json_annotation/json_annotation.dart';\n\nimport 'resource.dart';\n\npart 'resolve_result.g.dart';\n\n@JsonSerializable(explicitToJson: true)\nclass ResolveResult {\n  String id;\n  Resource res;\n\n  ResolveResult({\n    this.id = \"\",\n    required this.res,\n  });\n\n  factory ResolveResult.fromJson(Map<String, dynamic> json) =>\n      _$ResolveResultFromJson(json);\n  Map<String, dynamic> toJson() => _$ResolveResultToJson(this);\n}\n"
  },
  {
    "path": "ui/flutter/lib/api/model/resolve_result.g.dart",
    "content": "// GENERATED CODE - DO NOT MODIFY BY HAND\n\npart of 'resolve_result.dart';\n\n// **************************************************************************\n// JsonSerializableGenerator\n// **************************************************************************\n\nResolveResult _$ResolveResultFromJson(Map<String, dynamic> json) =>\n    ResolveResult(\n      id: json['id'] as String? ?? \"\",\n      res: Resource.fromJson(json['res'] as Map<String, dynamic>),\n    );\n\nMap<String, dynamic> _$ResolveResultToJson(ResolveResult instance) =>\n    <String, dynamic>{\n      'id': instance.id,\n      'res': instance.res.toJson(),\n    };\n"
  },
  {
    "path": "ui/flutter/lib/api/model/resolve_task.dart",
    "content": "import 'package:json_annotation/json_annotation.dart';\n\nimport 'options.dart';\nimport 'request.dart';\n\npart 'resolve_task.g.dart';\n\n@JsonSerializable()\nclass ResolveTask {\n  Request? req;\n  Options? opts;\n\n  ResolveTask({\n    this.req,\n    this.opts,\n  });\n\n  factory ResolveTask.fromJson(Map<String, dynamic> json) =>\n      _$ResolveTaskFromJson(json);\n  Map<String, dynamic> toJson() => _$ResolveTaskToJson(this);\n}\n"
  },
  {
    "path": "ui/flutter/lib/api/model/resolve_task.g.dart",
    "content": "// GENERATED CODE - DO NOT MODIFY BY HAND\n\npart of 'resolve_task.dart';\n\n// **************************************************************************\n// JsonSerializableGenerator\n// **************************************************************************\n\nResolveTask _$ResolveTaskFromJson(Map<String, dynamic> json) => ResolveTask(\n      req: json['req'] == null\n          ? null\n          : Request.fromJson(json['req'] as Map<String, dynamic>),\n      opts: json['opts'] == null\n          ? null\n          : Options.fromJson(json['opts'] as Map<String, dynamic>),\n    );\n\nMap<String, dynamic> _$ResolveTaskToJson(ResolveTask instance) {\n  final val = <String, dynamic>{};\n\n  void writeNotNull(String key, dynamic value) {\n    if (value != null) {\n      val[key] = value;\n    }\n  }\n\n  writeNotNull('req', instance.req);\n  writeNotNull('opts', instance.opts);\n  return val;\n}\n"
  },
  {
    "path": "ui/flutter/lib/api/model/resource.dart",
    "content": "import 'package:gopeed/api/model/request.dart';\nimport 'package:json_annotation/json_annotation.dart';\n\npart 'resource.g.dart';\n\n@JsonSerializable(explicitToJson: true)\nclass Resource {\n  String name;\n  int size;\n  bool range;\n  List<FileInfo> files;\n  String hash;\n\n  Resource(\n      {this.name = \"\",\n      this.size = 0,\n      this.range = false,\n      required this.files,\n      this.hash = \"\"});\n\n  factory Resource.fromJson(Map<String, dynamic> json) =>\n      _$ResourceFromJson(json);\n\n  Map<String, dynamic> toJson() => _$ResourceToJson(this);\n}\n\n@JsonSerializable(explicitToJson: true)\nclass FileInfo {\n  String path;\n  String name;\n  int size;\n  Request? req;\n\n  FileInfo({\n    this.path = \"\",\n    required this.name,\n    this.size = 0,\n    this.req,\n  });\n\n  factory FileInfo.fromJson(Map<String, dynamic> json) =>\n      _$FileInfoFromJson(json);\n\n  Map<String, dynamic> toJson() => _$FileInfoToJson(this);\n}\n"
  },
  {
    "path": "ui/flutter/lib/api/model/resource.g.dart",
    "content": "// GENERATED CODE - DO NOT MODIFY BY HAND\n\npart of 'resource.dart';\n\n// **************************************************************************\n// JsonSerializableGenerator\n// **************************************************************************\n\nResource _$ResourceFromJson(Map<String, dynamic> json) => Resource(\n      name: json['name'] as String? ?? \"\",\n      size: (json['size'] as num?)?.toInt() ?? 0,\n      range: json['range'] as bool? ?? false,\n      files: (json['files'] as List<dynamic>)\n          .map((e) => FileInfo.fromJson(e as Map<String, dynamic>))\n          .toList(),\n      hash: json['hash'] as String? ?? \"\",\n    );\n\nMap<String, dynamic> _$ResourceToJson(Resource instance) => <String, dynamic>{\n      'name': instance.name,\n      'size': instance.size,\n      'range': instance.range,\n      'files': instance.files.map((e) => e.toJson()).toList(),\n      'hash': instance.hash,\n    };\n\nFileInfo _$FileInfoFromJson(Map<String, dynamic> json) => FileInfo(\n      path: json['path'] as String? ?? \"\",\n      name: json['name'] as String,\n      size: (json['size'] as num?)?.toInt() ?? 0,\n      req: json['req'] == null\n          ? null\n          : Request.fromJson(json['req'] as Map<String, dynamic>),\n    );\n\nMap<String, dynamic> _$FileInfoToJson(FileInfo instance) {\n  final val = <String, dynamic>{\n    'path': instance.path,\n    'name': instance.name,\n    'size': instance.size,\n  };\n\n  void writeNotNull(String key, dynamic value) {\n    if (value != null) {\n      val[key] = value;\n    }\n  }\n\n  writeNotNull('req', instance.req?.toJson());\n  return val;\n}\n"
  },
  {
    "path": "ui/flutter/lib/api/model/result.dart",
    "content": "import 'package:json_annotation/json_annotation.dart';\n\npart 'result.g.dart';\n\n@JsonSerializable(genericArgumentFactories: true)\nclass Result<T> {\n  int code;\n  String? msg;\n  T? data;\n\n  Result({\n    required this.code,\n    this.msg,\n    this.data,\n  });\n\n  factory Result.fromJson(\n    Map<String, dynamic> json,\n    T Function(dynamic json) fromJsonT,\n  ) =>\n      _$ResultFromJson(json, fromJsonT);\n  Map<String, dynamic> toJson() => {\n        'code': code,\n        'msg': msg,\n        'data': data is List\n            ? (data as dynamic)?.map((e) => e.toJson()).toList()\n            : (data as dynamic)?.toJson(),\n      };\n}\n"
  },
  {
    "path": "ui/flutter/lib/api/model/result.g.dart",
    "content": "// GENERATED CODE - DO NOT MODIFY BY HAND\n\npart of 'result.dart';\n\n// **************************************************************************\n// JsonSerializableGenerator\n// **************************************************************************\n\nResult<T> _$ResultFromJson<T>(\n  Map<String, dynamic> json,\n  T Function(Object? json) fromJsonT,\n) =>\n    Result<T>(\n      code: (json['code'] as num).toInt(),\n      msg: json['msg'] as String?,\n      data: _$nullableGenericFromJson(json['data'], fromJsonT),\n    );\n\nMap<String, dynamic> _$ResultToJson<T>(\n  Result<T> instance,\n  Object? Function(T value) toJsonT,\n) {\n  final val = <String, dynamic>{\n    'code': instance.code,\n  };\n\n  void writeNotNull(String key, dynamic value) {\n    if (value != null) {\n      val[key] = value;\n    }\n  }\n\n  writeNotNull('msg', instance.msg);\n  writeNotNull('data', _$nullableGenericToJson(instance.data, toJsonT));\n  return val;\n}\n\nT? _$nullableGenericFromJson<T>(\n  Object? input,\n  T Function(Object? json) fromJson,\n) =>\n    input == null ? null : fromJson(input);\n\nObject? _$nullableGenericToJson<T>(\n  T? input,\n  Object? Function(T value) toJson,\n) =>\n    input == null ? null : toJson(input);\n"
  },
  {
    "path": "ui/flutter/lib/api/model/store_extension.dart",
    "content": "import 'dart:convert';\n\nclass StoreExtensionPage {\n  final List<StoreExtension> data;\n  final StorePagination pagination;\n\n  StoreExtensionPage({required this.data, required this.pagination});\n\n  factory StoreExtensionPage.fromJson(Map<String, dynamic> json) {\n    return StoreExtensionPage(\n      data: (json['data'] as List<dynamic>? ?? [])\n          .map((e) => StoreExtension.fromJson(e as Map<String, dynamic>))\n          .toList(),\n      pagination: StorePagination.fromJson(\n          json['pagination'] as Map<String, dynamic>? ?? {}),\n    );\n  }\n}\n\nclass StorePagination {\n  final int page;\n  final int limit;\n  final int total;\n  final int totalPages;\n  final bool hasNext;\n  final bool hasPrev;\n\n  StorePagination({\n    required this.page,\n    required this.limit,\n    required this.total,\n    required this.totalPages,\n    required this.hasNext,\n    required this.hasPrev,\n  });\n\n  factory StorePagination.fromJson(Map<String, dynamic> json) {\n    return StorePagination(\n      page: (json['page'] as num?)?.toInt() ?? 1,\n      limit: (json['limit'] as num?)?.toInt() ?? 20,\n      total: (json['total'] as num?)?.toInt() ?? 0,\n      totalPages: (json['totalPages'] as num?)?.toInt() ?? 1,\n      hasNext: json['hasNext'] as bool? ?? false,\n      hasPrev: json['hasPrev'] as bool? ?? false,\n    );\n  }\n}\n\nclass StoreExtension {\n  final String id;\n  final String repoFullName;\n  final String repoUrl;\n  final String? directory;\n  final String? commitSha;\n  final String name;\n  final String author;\n  final String title;\n  final String description;\n  final String? icon;\n  final String version;\n  final String? homepage;\n  final String? readme;\n  final int installCount;\n  final int stars;\n  final List<String> topics;\n  final DateTime? createdAt;\n  final DateTime? updatedAt;\n\n  StoreExtension({\n    required this.id,\n    required this.repoFullName,\n    required this.repoUrl,\n    this.directory,\n    this.commitSha,\n    required this.name,\n    required this.author,\n    required this.title,\n    required this.description,\n    this.icon,\n    required this.version,\n    this.homepage,\n    this.readme,\n    required this.installCount,\n    required this.stars,\n    required this.topics,\n    this.createdAt,\n    this.updatedAt,\n  });\n\n  factory StoreExtension.fromJson(Map<String, dynamic> json) {\n    return StoreExtension(\n      id: json['id'] as String? ?? '',\n      repoFullName: json['repoFullName'] as String? ?? '',\n      repoUrl: json['repoUrl'] as String? ?? '',\n      directory: json['directory'] as String?,\n      commitSha: json['commitSha'] as String?,\n      name: json['name'] as String? ?? '',\n      author: json['author'] as String? ?? '',\n      title: json['title'] as String? ?? '',\n      description: json['description'] as String? ?? '',\n      icon: json['icon'] as String?,\n      version: json['version'] as String? ?? '0.0.0',\n      homepage: json['homepage'] as String?,\n      readme: json['readme'] as String?,\n      installCount: (json['installCount'] as num?)?.toInt() ?? 0,\n      stars: (json['stars'] as num?)?.toInt() ?? 0,\n      topics: _parseTopics(json['topics']),\n      createdAt: _parseDate(json['createdAt']),\n      updatedAt: _parseDate(json['updatedAt']),\n    );\n  }\n\n  static List<String> _parseTopics(dynamic value) {\n    if (value is List) {\n      return value.map((e) => e.toString()).toList();\n    }\n    if (value is String && value.isNotEmpty) {\n      try {\n        final parsed = jsonDecode(value);\n        if (parsed is List) {\n          return parsed.map((e) => e.toString()).toList();\n        }\n      } catch (_) {}\n    }\n    return const [];\n  }\n\n  static DateTime? _parseDate(dynamic value) {\n    if (value == null) return null;\n    if (value is int) {\n      return DateTime.fromMillisecondsSinceEpoch(value);\n    }\n    if (value is String && value.isNotEmpty) {\n      return DateTime.tryParse(value);\n    }\n    return null;\n  }\n}\n\nenum StoreExtensionSort {\n  stars,\n  installs,\n  updated,\n}\n\nenum StoreSortOrder {\n  asc,\n  desc,\n}\n"
  },
  {
    "path": "ui/flutter/lib/api/model/switch_extension.dart",
    "content": "import 'package:json_annotation/json_annotation.dart';\n\npart 'switch_extension.g.dart';\n\n@JsonSerializable()\nclass SwitchExtension {\n  bool status;\n\n  SwitchExtension({\n    required this.status,\n  });\n\n  factory SwitchExtension.fromJson(Map<String, dynamic> json) =>\n      _$SwitchExtensionFromJson(json);\n  Map<String, dynamic> toJson() => _$SwitchExtensionToJson(this);\n}\n"
  },
  {
    "path": "ui/flutter/lib/api/model/switch_extension.g.dart",
    "content": "// GENERATED CODE - DO NOT MODIFY BY HAND\n\npart of 'switch_extension.dart';\n\n// **************************************************************************\n// JsonSerializableGenerator\n// **************************************************************************\n\nSwitchExtension _$SwitchExtensionFromJson(Map<String, dynamic> json) =>\n    SwitchExtension(\n      status: json['status'] as bool,\n    );\n\nMap<String, dynamic> _$SwitchExtensionToJson(SwitchExtension instance) =>\n    <String, dynamic>{\n      'status': instance.status,\n    };\n"
  },
  {
    "path": "ui/flutter/lib/api/model/task.dart",
    "content": "import 'package:json_annotation/json_annotation.dart';\n\nimport 'meta.dart';\n\npart 'task.g.dart';\n\nenum Status { ready, running, pause, wait, error, done }\n\nenum Protocol { http, bt, ed2k }\n\n// ExtractStatus enum matching Go backend\nenum ExtractStatus {\n  @JsonValue('')\n  none,\n  @JsonValue('extracting')\n  extracting,\n  @JsonValue('done')\n  done,\n  @JsonValue('error')\n  error,\n  @JsonValue('waitingParts')\n  waitingParts\n}\n\n@JsonSerializable(explicitToJson: true)\nclass Task {\n  String id;\n  String name;\n  Protocol? protocol;\n  Meta meta;\n  Status status;\n  bool uploading;\n  Progress progress;\n  DateTime createdAt;\n  DateTime updatedAt;\n\n  Task({\n    required this.id,\n    required this.name,\n    required this.meta,\n    required this.status,\n    required this.uploading,\n    required this.progress,\n    required this.createdAt,\n    required this.updatedAt,\n  });\n\n  factory Task.fromJson(Map<String, dynamic> json) => _$TaskFromJson(json);\n\n  Map<String, dynamic> toJson() => _$TaskToJson(this);\n}\n\n@JsonSerializable()\nclass Progress {\n  int used;\n  int speed;\n  int downloaded;\n  int uploadSpeed;\n  int uploaded;\n  ExtractStatus extractStatus;\n  int extractProgress;\n\n  Progress({\n    required this.used,\n    required this.speed,\n    required this.downloaded,\n    required this.uploadSpeed,\n    required this.uploaded,\n    this.extractStatus = ExtractStatus.none,\n    this.extractProgress = 0,\n  });\n\n  factory Progress.fromJson(Map<String, dynamic> json) =>\n      _$ProgressFromJson(json);\n\n  Map<String, dynamic> toJson() => _$ProgressToJson(this);\n}\n"
  },
  {
    "path": "ui/flutter/lib/api/model/task.g.dart",
    "content": "// GENERATED CODE - DO NOT MODIFY BY HAND\n\npart of 'task.dart';\n\n// **************************************************************************\n// JsonSerializableGenerator\n// **************************************************************************\n\nTask _$TaskFromJson(Map<String, dynamic> json) => Task(\n      id: json['id'] as String,\n      name: json['name'] as String,\n      meta: Meta.fromJson(json['meta'] as Map<String, dynamic>),\n      status: $enumDecode(_$StatusEnumMap, json['status']),\n      uploading: json['uploading'] as bool,\n      progress: Progress.fromJson(json['progress'] as Map<String, dynamic>),\n      createdAt: DateTime.parse(json['createdAt'] as String),\n      updatedAt: DateTime.parse(json['updatedAt'] as String),\n    )..protocol = $enumDecodeNullable(_$ProtocolEnumMap, json['protocol']);\n\nMap<String, dynamic> _$TaskToJson(Task instance) {\n  final val = <String, dynamic>{\n    'id': instance.id,\n    'name': instance.name,\n  };\n\n  void writeNotNull(String key, dynamic value) {\n    if (value != null) {\n      val[key] = value;\n    }\n  }\n\n  writeNotNull('protocol', _$ProtocolEnumMap[instance.protocol]);\n  val['meta'] = instance.meta.toJson();\n  val['status'] = _$StatusEnumMap[instance.status]!;\n  val['uploading'] = instance.uploading;\n  val['progress'] = instance.progress.toJson();\n  val['createdAt'] = instance.createdAt.toIso8601String();\n  val['updatedAt'] = instance.updatedAt.toIso8601String();\n  return val;\n}\n\nconst _$StatusEnumMap = {\n  Status.ready: 'ready',\n  Status.running: 'running',\n  Status.pause: 'pause',\n  Status.wait: 'wait',\n  Status.error: 'error',\n  Status.done: 'done',\n};\n\nconst _$ProtocolEnumMap = {\n  Protocol.http: 'http',\n  Protocol.bt: 'bt',\n  Protocol.ed2k: 'ed2k',\n};\n\nProgress _$ProgressFromJson(Map<String, dynamic> json) => Progress(\n      used: (json['used'] as num).toInt(),\n      speed: (json['speed'] as num).toInt(),\n      downloaded: (json['downloaded'] as num).toInt(),\n      uploadSpeed: (json['uploadSpeed'] as num).toInt(),\n      uploaded: (json['uploaded'] as num).toInt(),\n      extractStatus:\n          $enumDecodeNullable(_$ExtractStatusEnumMap, json['extractStatus']) ??\n              ExtractStatus.none,\n      extractProgress: (json['extractProgress'] as num?)?.toInt() ?? 0,\n    );\n\nMap<String, dynamic> _$ProgressToJson(Progress instance) => <String, dynamic>{\n      'used': instance.used,\n      'speed': instance.speed,\n      'downloaded': instance.downloaded,\n      'uploadSpeed': instance.uploadSpeed,\n      'uploaded': instance.uploaded,\n      'extractStatus': _$ExtractStatusEnumMap[instance.extractStatus]!,\n      'extractProgress': instance.extractProgress,\n    };\n\nconst _$ExtractStatusEnumMap = {\n  ExtractStatus.none: '',\n  ExtractStatus.extracting: 'extracting',\n  ExtractStatus.done: 'done',\n  ExtractStatus.error: 'error',\n  ExtractStatus.waitingParts: 'waitingParts',\n};\n"
  },
  {
    "path": "ui/flutter/lib/api/model/update_check_extension_resp.dart",
    "content": "import 'package:json_annotation/json_annotation.dart';\n\npart 'update_check_extension_resp.g.dart';\n\n@JsonSerializable()\nclass UpdateCheckExtensionResp {\n  String newVersion;\n\n  UpdateCheckExtensionResp({\n    required this.newVersion,\n  });\n\n  factory UpdateCheckExtensionResp.fromJson(Map<String, dynamic> json) =>\n      _$UpdateCheckExtensionRespFromJson(json);\n  Map<String, dynamic> toJson() => _$UpdateCheckExtensionRespToJson(this);\n}\n"
  },
  {
    "path": "ui/flutter/lib/api/model/update_check_extension_resp.g.dart",
    "content": "// GENERATED CODE - DO NOT MODIFY BY HAND\n\npart of 'update_check_extension_resp.dart';\n\n// **************************************************************************\n// JsonSerializableGenerator\n// **************************************************************************\n\nUpdateCheckExtensionResp _$UpdateCheckExtensionRespFromJson(\n        Map<String, dynamic> json) =>\n    UpdateCheckExtensionResp(\n      newVersion: json['newVersion'] as String,\n    );\n\nMap<String, dynamic> _$UpdateCheckExtensionRespToJson(\n        UpdateCheckExtensionResp instance) =>\n    <String, dynamic>{\n      'newVersion': instance.newVersion,\n    };\n"
  },
  {
    "path": "ui/flutter/lib/api/model/update_extension_settings.dart",
    "content": "import 'package:json_annotation/json_annotation.dart';\n\npart 'update_extension_settings.g.dart';\n\n@JsonSerializable()\nclass UpdateExtensionSettings {\n  Map<String, dynamic> settings;\n\n  UpdateExtensionSettings({\n    required this.settings,\n  });\n\n  factory UpdateExtensionSettings.fromJson(Map<String, dynamic> json) =>\n      _$UpdateExtensionSettingsFromJson(json);\n  Map<String, dynamic> toJson() => _$UpdateExtensionSettingsToJson(this);\n}\n"
  },
  {
    "path": "ui/flutter/lib/api/model/update_extension_settings.g.dart",
    "content": "// GENERATED CODE - DO NOT MODIFY BY HAND\n\npart of 'update_extension_settings.dart';\n\n// **************************************************************************\n// JsonSerializableGenerator\n// **************************************************************************\n\nUpdateExtensionSettings _$UpdateExtensionSettingsFromJson(\n        Map<String, dynamic> json) =>\n    UpdateExtensionSettings(\n      settings: json['settings'] as Map<String, dynamic>,\n    );\n\nMap<String, dynamic> _$UpdateExtensionSettingsToJson(\n        UpdateExtensionSettings instance) =>\n    <String, dynamic>{\n      'settings': instance.settings,\n    };\n"
  },
  {
    "path": "ui/flutter/lib/app/modules/app/bindings/app_binding.dart",
    "content": "import 'package:get/get.dart';\n\nimport '../controllers/app_controller.dart';\n\nclass AppBinding extends Bindings {\n  @override\n  void dependencies() {\n    Get.lazyPut<AppController>(\n      () => AppController(),\n    );\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/modules/app/controllers/app_controller.dart",
    "content": "import 'dart:async';\nimport 'dart:convert';\nimport 'dart:io';\nimport 'dart:ui';\n\nimport 'package:app_links/app_links.dart';\nimport 'package:dio/dio.dart';\nimport 'package:flutter/material.dart';\nimport 'package:flutter_foreground_task/flutter_foreground_task.dart';\nimport 'package:get/get.dart';\nimport 'package:launch_at_startup/launch_at_startup.dart';\nimport 'package:path/path.dart' as path;\nimport 'package:path_provider/path_provider.dart';\nimport 'package:share_handler/share_handler.dart';\nimport 'package:tray_manager/tray_manager.dart';\nimport 'package:uri_to_file/uri_to_file.dart';\nimport 'package:url_launcher/url_launcher.dart';\nimport 'package:window_manager/window_manager.dart';\n\n\nimport '../../../../api/api.dart';\nimport '../../../../api/model/create_task.dart';\nimport '../../../../api/model/downloader_config.dart';\nimport '../../../../api/model/install_extension.dart';\nimport '../../../../api/model/request.dart';\nimport '../../../../api/model/result.dart';\nimport '../../../../core/common/start_config.dart';\nimport '../../../../core/libgopeed_boot.dart';\nimport '../../../../database/database.dart';\nimport '../../../../database/entity.dart';\nimport '../../../../i18n/message.dart';\nimport '../../../../main.dart';\nimport '../../../../util/github_mirror.dart';\nimport '../../../../util/locale_manager.dart';\nimport '../../../../util/log_util.dart';\nimport '../../../../util/package_info.dart';\nimport '../../../../util/updater.dart';\nimport '../../../../util/util.dart';\nimport '../../../routes/app_pages.dart';\nimport '../../../rpc/rpc.dart';\nimport '../../redirect/views/redirect_view.dart';\nimport '../../../services/notification_service.dart';\n\nconst unixSocketPath = 'gopeed.sock';\n\nconst allTrackerSubscribeUrls = [\n  'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all.txt',\n  'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_http.txt',\n  'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_https.txt',\n  'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_ip.txt',\n  'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_udp.txt',\n  'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_ws.txt',\n  'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_best.txt',\n  'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_best_ip.txt',\n  'https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/all.txt',\n  'https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/best.txt',\n  'https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/http.txt',\n];\nfinal allTrackerSubscribeUrlCdns = {\n  for (var v in allTrackerSubscribeUrls)\n    v: githubMirrorUrls(v, MirrorType.githubSource)\n};\n\n/// Represents a task that is pending URL update via listen mode.\nclass PendingUpdateTask {\n  final String id;\n  final String name;\n\n  PendingUpdateTask({required this.id, required this.name});\n}\n\nclass AppController extends GetxController with WindowListener, TrayListener {\n  static StartConfig? _defaultStartConfig;\n\n  /// Command line --hidden flag passed from main.dart\n  final bool hiddenFromArgs;\n\n  AppController({this.hiddenFromArgs = false});\n\n  final autoStartup = false.obs;\n  final startConfig = StartConfig().obs;\n  final runningPort = 0.obs;\n  final downloaderConfig = DownloaderConfig().obs;\n\n  /// The task that is pending URL update via listen mode.\n  /// Stored here in AppController to persist across page navigations.\n  final pendingUpdateTask = Rxn<PendingUpdateTask>();\n\n  late AppLinks _appLinks;\n  StreamSubscription<Uri>? _linkSubscription;\n\n  @override\n  void onReady() {\n    super.onReady();\n\n    _initDeepLinks().onError((error, stackTrace) =>\n        logger.w(\"initDeepLinks error\", error, stackTrace));\n\n    _initWindows().onError((error, stackTrace) =>\n        logger.w(\"initWindows error\", error, stackTrace));\n        \n    if (Util.isDesktop()) {\n      Get.put(NotificationService());\n    }\n\n    _initTray().onError(\n        (error, stackTrace) => logger.w(\"initTray error\", error, stackTrace));\n\n    _initRpcServer().onError((error, stackTrace) =>\n        logger.w(\"initRpcServer error\", error, stackTrace));\n\n    _initForegroundTask().onError((error, stackTrace) =>\n        logger.w(\"initForegroundTask error\", error, stackTrace));\n\n    _initTrackerUpdate().onError((error, stackTrace) =>\n        logger.w(\"initTrackerUpdate error\", error, stackTrace));\n\n    _initLaunchAtStartup().onError((error, stackTrace) =>\n        logger.w(\"initLaunchAtStartup error\", error, stackTrace));\n\n    _initCheckUpdate().onError((error, stackTrace) =>\n        logger.w(\"initCheckUpdate error\", error, stackTrace));\n  }\n\n  @override\n  void onClose() {\n    _linkSubscription?.cancel();\n    trayManager.removeListener(this);\n    LibgopeedBoot.instance.stop();\n  }\n\n  @override\n  void onWindowClose() async {\n    final isPreventClose = await windowManager.isPreventClose();\n    if (isPreventClose) {\n      windowManager.hide();\n    }\n  }\n\n  // According to the system_manager document, make sure to call setState once on the onWindowFocus event.\n  @override\n  void onWindowFocus() {\n    refresh();\n    if (Util.isMacos() && Database.instance.getRunAsMenubarApp()) {\n      windowManager.setSkipTaskbar(true);\n    }\n  }\n\n  @override\n  void onTrayIconMouseDown() {\n    windowManager.show();\n  }\n\n  @override\n  void onTrayIconRightMouseDown() {\n    trayManager.popUpContextMenu(bringAppToFront: true);\n  }\n\n  @override\n  void onWindowMaximize() {\n    Database.instance.saveWindowState(WindowStateEntity(isMaximized: true));\n  }\n\n  @override\n  void onWindowUnmaximize() {\n    Database.instance.saveWindowState(WindowStateEntity(isMaximized: false));\n  }\n\n  final _windowsResizeSave = Util.debounce(() async {\n    final size = await windowManager.getSize();\n    Database.instance.saveWindowState(\n        WindowStateEntity(width: size.width, height: size.height));\n  }, 500);\n\n  @override\n  void onWindowResize() {\n    _windowsResizeSave();\n  }\n\n  Future<void> _initDeepLinks() async {\n    if (Util.isWeb()) {\n      // For web, just show window\n      return;\n    }\n\n    // Handle deep link\n    _appLinks = AppLinks();\n\n    // Handle link when app is in warm state (front or background)\n    _linkSubscription = _appLinks.uriLinkStream.listen((uri) async {\n      await _handleDeepLink(uri);\n    });\n\n    // Get initial link for cold start - this works after runApp()\n    Uri? initialLink;\n    try {\n      initialLink = await _appLinks.getInitialLink();\n    } catch (e) {\n      // ignore errors\n    }\n\n    // Determine if window should be hidden\n    // Priority 1: gopeed: URL scheme hidden parameter\n    // Priority 2: Command line --hidden flag\n    bool shouldHide = hiddenFromArgs;\n    if (initialLink?.scheme == \"gopeed\") {\n      shouldHide = initialLink!.queryParameters[\"hidden\"] == \"true\";\n    }\n\n    // Show window if not hidden (desktop only)\n    if (Util.isDesktop() && !shouldHide) {\n      await windowManager.show();\n      await windowManager.focus();\n    }\n\n    // Handle initial link for deep link navigation\n    if (initialLink != null) {\n      _handleDeepLink(initialLink);\n    }\n\n    // Handle shared media, e.g. shared link from browser\n    if (Util.isMobile()) {\n      () async {\n        final handler = ShareHandlerPlatform.instance;\n\n        handler.sharedMediaStream.listen((SharedMedia media) {\n          if (media.content?.isNotEmpty == true) {\n            final uri = Uri.parse(media.content!);\n            // content uri will be handled by the app_links plugin\n            if (uri.scheme != \"content\") {\n              _handleDeepLink(uri);\n            }\n          }\n        });\n\n        final media = await handler.getInitialSharedMedia();\n        if (media?.content?.isNotEmpty == true) {\n          _handleDeepLink(Uri.parse(media!.content!));\n        }\n      }();\n    }\n  }\n\n  Future<void> _initWindows() async {\n    if (!Util.isDesktop()) {\n      return;\n    }\n    windowManager.addListener(this);\n  }\n\n  Future<void> _initTray() async {\n    if (!Util.isDesktop()) {\n      return;\n    }\n    if (Util.isWindows()) {\n      await trayManager.setIcon('assets/tray_icon/icon.ico');\n    } else if (Util.isMacos()) {\n      await trayManager.setIcon('assets/tray_icon/icon_mac.png',\n          isTemplate: true);\n    } else if (Platform.environment.containsKey('FLATPAK_ID') ||\n        Platform.environment.containsKey('SNAP')) {\n      await trayManager.setIcon('com.gopeed.Gopeed');\n    } else {\n      await trayManager.setIcon('assets/tray_icon/icon.png');\n    }\n    final menu = Menu(items: [\n      MenuItem(\n        label: \"show\".tr,\n        onClick: (menuItem) async => {\n          await windowManager.show(),\n        },\n      ),\n      MenuItem.separator(),\n      MenuItem(\n        label: \"create\".tr,\n        onClick: (menuItem) async => {\n          await windowManager.show(),\n          await Get.rootDelegate.offAndToNamed(Routes.CREATE),\n        },\n      ),\n      MenuItem(\n        label: \"startAll\".tr,\n        onClick: (menuItem) async => {continueAllTasks(null)},\n      ),\n      MenuItem(\n        label: \"pauseAll\".tr,\n        onClick: (menuItem) async => {pauseAllTasks(null)},\n      ),\n      MenuItem(\n        label: 'setting'.tr,\n        onClick: (menuItem) async => {\n          await windowManager.show(),\n          await Get.rootDelegate.offAndToNamed(Routes.SETTING),\n        },\n      ),\n      MenuItem.separator(),\n      MenuItem(\n        label: 'donate'.tr,\n        onClick: (menuItem) => {\n          launchUrl(Uri.parse(\"https://gopeed.com/docs/donate\"),\n              mode: LaunchMode.externalApplication)\n        },\n      ),\n      MenuItem(\n        label: '${\"version\".tr}（${packageInfo.version}）',\n      ),\n      MenuItem.separator(),\n      MenuItem(\n        label: 'exit'.tr,\n        onClick: (menuItem) async {\n          try {\n            await LibgopeedBoot.instance.stop();\n          } catch (e) {\n            logger.w(\"libgopeed stop fail\", e);\n          }\n          windowManager.destroy();\n        },\n      ),\n    ]);\n    if (!Util.isLinux()) {\n      // Linux seems not support setToolTip, refer to: https://github.com/GopeedLab/gopeed/issues/241\n      await trayManager.setToolTip('Gopeed');\n    }\n    await trayManager.setContextMenu(menu);\n    trayManager.addListener(this);\n  }\n\n  Future<void> _initRpcServer() async {\n    if (!Util.isDesktop()) {\n      return;\n    }\n    try {\n      await startRpcServer({\n        \"/create\": (ctx) async {\n          final meta =\n              ctx.request.headers[\"X-Gopeed-Host-Meta\"]?.firstOrNull ?? \"{}\";\n          final jsonMeta = jsonDecode(meta);\n          final silent = jsonMeta['silent'] as bool? ?? false;\n          final params = await ctx.readText();\n          final createTaskParams = CreateTask.fromJson(_decodeParams(params));\n          if (!silent || pendingUpdateTask.value != null) {\n            await windowManager.show();\n            _handleToCreate0(createTaskParams);\n          } else {\n            try {\n              await createTask(createTaskParams);\n            } catch (e) {\n              logger.w(\n                  \"create task from extension fail\", e, StackTrace.current);\n            }\n          }\n        },\n        \"/forward\": (ctx) async {\n          try {\n            final body = await ctx.readJSON();\n            final method = (body['method'] as String?)?.toUpperCase() ?? 'GET';\n            final path = (body['path'] as String?) ?? \"/\";\n            final data = body['data'];\n            final query = body['query'] as Map<String, dynamic>?;\n\n            // Forward request to gopeed REST API\n            final response = await forward(\n              path,\n              method: method,\n              data: data,\n              queryParameters: query,\n            );\n\n            // Return raw response\n            await ctx.writeJSON(response.data);\n          } catch (e) {\n            if (e is DioException && e.response != null) {\n              // Return API error response\n              await ctx.writeJSON(e.response!.data);\n            } else {\n              await ctx.writeJSON(Result(code: 1, msg: e.toString()).toJson());\n            }\n          }\n        },\n      });\n    } catch (e) {\n      logger.w(\"start rpc server fail\", e, StackTrace.current);\n    }\n  }\n\n  Future<void> _initForegroundTask() async {\n    if (!Util.isMobile()) {\n      return;\n    }\n\n    FlutterForegroundTask.init(\n      androidNotificationOptions: AndroidNotificationOptions(\n        channelId: 'gopeed_service',\n        channelName: 'Gopeed Background Service',\n        channelImportance: NotificationChannelImportance.LOW,\n        showWhen: true,\n        priority: NotificationPriority.LOW,\n      ),\n      iosNotificationOptions: const IOSNotificationOptions(\n        showNotification: true,\n        playSound: false,\n      ),\n      foregroundTaskOptions: const ForegroundTaskOptions(\n        interval: 5000,\n        isOnceEvent: false,\n        autoRunOnBoot: true,\n        allowWakeLock: true,\n        allowWifiLock: true,\n      ),\n    );\n\n    if (await FlutterForegroundTask.isRunningService) {\n      FlutterForegroundTask.restartService();\n    } else {\n      FlutterForegroundTask.startService(\n        notificationTitle: \"serviceTitle\".tr,\n        notificationText: \"serviceText\".tr,\n        notificationIcon: const NotificationIconData(\n          resType: ResourceType.mipmap,\n          resPrefix: ResourcePrefix.ic,\n          name: 'launcher',\n        ),\n      );\n    }\n  }\n\n  Future<void> _handleDeepLink(Uri uri) async {\n    if (uri.scheme == \"gopeed\") {\n      // gopeed:///create?params=eyJyZXEiOnsidXJsIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9maWxlLnR4dCJ9fQ==\n      if (uri.path == \"/create\") {\n        final params = uri.queryParameters[\"params\"];\n        if (params?.isNotEmpty == true) {\n          _handleToCreate(params!);\n          return;\n        }\n        Get.rootDelegate.offAndToNamed(Routes.CREATE);\n        return;\n      }\n      // gopeed:///extension?params=eyJ1cmwiOiJodHRwczovL2dpdGh1Yi5jb20vbW9ua2V5V2llL2dvcGVlZC1leHRlbnNpb24tYmlsaWJpbGkiLCJkZXZNb2RlIjpmYWxzZX0=\n      if (uri.path == \"/extension\") {\n        final params = uri.queryParameters[\"params\"];\n        if (params?.isNotEmpty == true) {\n          _handleToExtension(params!);\n          return;\n        }\n        Get.rootDelegate.offAndToNamed(Routes.EXTENSION);\n        return;\n      }\n      Get.rootDelegate.offAndToNamed(Routes.HOME);\n      return;\n    }\n\n    String path;\n    if (uri.scheme == \"magnet\" ||\n        uri.scheme == \"http\" ||\n        uri.scheme == \"https\") {\n      path = uri.toString();\n    } else if (uri.scheme == \"file\") {\n      path =\n          Util.isWindows() ? Uri.decodeFull(uri.path.substring(1)) : uri.path;\n    } else {\n      path = (await toFile(uri.toString())).path;\n    }\n    Get.rootDelegate.offAndToNamed(Routes.REDIRECT,\n        arguments: RedirectArgs(Routes.CREATE,\n            arguments: CreateTask(req: Request(url: path))));\n  }\n\n  String runningAddress() {\n    if (startConfig.value.network == 'unix') {\n      return startConfig.value.address;\n    }\n    return '${startConfig.value.address.split(':').first}:$runningPort';\n  }\n\n  Future<StartConfig> _initDefaultStartConfig() async {\n    if (_defaultStartConfig != null) {\n      return _defaultStartConfig!;\n    }\n    _defaultStartConfig = StartConfig();\n    if (!Util.supportUnixSocket()) {\n      // not support unix socket, use tcp\n      _defaultStartConfig!.network = \"tcp\";\n      _defaultStartConfig!.address = \"127.0.0.1:0\";\n    } else {\n      _defaultStartConfig!.network = \"unix\";\n      _defaultStartConfig!.address =\n          \"${(await getTemporaryDirectory()).path}/$unixSocketPath\";\n    }\n    _defaultStartConfig!.apiToken = '';\n    return _defaultStartConfig!;\n  }\n\n  Future<StartConfig> loadStartConfig() async {\n    final defaultCfg = await _initDefaultStartConfig();\n    final saveCfg = Database.instance.getStartConfig();\n    startConfig.value.network = saveCfg?.network ?? defaultCfg.network;\n    startConfig.value.address = saveCfg?.address ?? defaultCfg.address;\n    startConfig.value.apiToken = saveCfg?.apiToken ?? defaultCfg.apiToken;\n    return startConfig.value;\n  }\n\n  Future<DownloaderConfig> loadDownloaderConfig() async {\n    try {\n      downloaderConfig.value = await getConfig();\n    } catch (e) {\n      logger.w(\"load downloader config fail\", e, StackTrace.current);\n      downloaderConfig.value = DownloaderConfig();\n    }\n    await _initDownloaderConfig();\n    return downloaderConfig.value;\n  }\n\n  Future<void> trackerUpdate() async {\n    final btExtConfig = downloaderConfig.value.extra.bt;\n    final result = <String>[];\n    for (var u in btExtConfig.trackerSubscribeUrls) {\n      final cdns = allTrackerSubscribeUrlCdns[u];\n      if (cdns == null) {\n        continue;\n      }\n      try {\n        final trackers =\n            await Util.anyOk(cdns.map((cdn) => _fetchTrackers(cdn)));\n        result.addAll(trackers);\n      } catch (e) {\n        logger.w(\"subscribe trackers fail, url: $u\", e);\n        return;\n      }\n    }\n    btExtConfig.subscribeTrackers.clear();\n    btExtConfig.subscribeTrackers.addAll(result);\n    downloaderConfig.update((val) {\n      val!.extra.bt.lastTrackerUpdateTime = DateTime.now();\n    });\n    refreshTrackers();\n\n    await saveConfig();\n  }\n\n  refreshTrackers() {\n    final btConfig = downloaderConfig.value.protocolConfig.bt;\n    final btExtConfig = downloaderConfig.value.extra.bt;\n    btConfig.trackers.clear();\n    btConfig.trackers.addAll(btExtConfig.subscribeTrackers);\n    btConfig.trackers.addAll(btExtConfig.customTrackers);\n    // remove duplicate\n    btConfig.trackers.toSet().toList();\n  }\n\n  Future<void> _initTrackerUpdate() async {\n    final btExtConfig = downloaderConfig.value.extra.bt;\n    final lastUpdateTime = btExtConfig.lastTrackerUpdateTime;\n    // if last update time is null or more than 1 day, update trackers\n    if (btExtConfig.autoUpdateTrackers &&\n        (lastUpdateTime == null ||\n            lastUpdateTime.difference(DateTime.now()).inDays < 0)) {\n      try {\n        await trackerUpdate();\n      } catch (e) {\n        logger.w(\"tracker update fail\", e);\n      }\n    }\n  }\n\n  Future<List<String>> _fetchTrackers(String subscribeUrl) async {\n    final resp = await proxyRequest(subscribeUrl);\n    if (resp.statusCode != 200) {\n      throw Exception(\n          'Failed to get trackers, status code: ${resp.statusCode}');\n    }\n    if (resp.data == null || resp.data!.isEmpty) {\n      throw Exception('Failed to get trackers, data is null');\n    }\n    const ls = LineSplitter();\n    return ls.convert(resp.data!).where((e) => e.isNotEmpty).toList();\n  }\n\n  _initDownloaderConfig() async {\n    final config = downloaderConfig.value;\n    final extra = config.extra;\n    if (extra.themeMode.isEmpty) {\n      extra.themeMode = ThemeMode.dark.name;\n    }\n    if (extra.locale.isEmpty) {\n      final systemLocale = getLocaleKey(PlatformDispatcher.instance.locale);\n      extra.locale = messages.keys.containsKey(systemLocale)\n          ? systemLocale\n          : getLocaleKey(fallbackLocale);\n    }\n    if (extra.bt.trackerSubscribeUrls.isEmpty) {\n      // default select all tracker subscribe urls\n      extra.bt.trackerSubscribeUrls.addAll(allTrackerSubscribeUrls);\n    }\n\n    final proxy = config.proxy;\n    if (proxy.scheme.isEmpty) {\n      proxy.scheme = 'http';\n    }\n\n    if (config.downloadDir.isEmpty) {\n      if (Util.isDesktop()) {\n        config.downloadDir = (await getDownloadsDirectory())?.path ?? \"./\";\n      } else if (Util.isAndroid()) {\n        config.downloadDir = (await getExternalStorageDirectory())?.path ??\n            (await getApplicationDocumentsDirectory()).path;\n      } else if (Util.isIOS()) {\n        config.downloadDir = (await getApplicationDocumentsDirectory()).path;\n      } else {\n        config.downloadDir = './';\n      }\n    }\n\n    // Initialize default download categories if empty\n    if (extra.downloadCategories.isEmpty) {\n      _initDefaultDownloadCategories();\n    }\n\n    // Initialize default GitHub mirrors if empty\n    if (extra.githubMirror.mirrors.isEmpty) {\n      _initDefaultGithubMirrors();\n    }\n  }\n\n  void _initDefaultDownloadCategories() {\n    final extra = downloaderConfig.value.extra;\n    final downloadDir = downloaderConfig.value.downloadDir;\n\n    // Add default built-in categories with i18n keys\n    // No need to set initial name value, it will be retrieved via nameKey\n    extra.downloadCategories = [\n      DownloadCategory(\n        name: '',\n        path: path.join(downloadDir, 'Music'),\n        isBuiltIn: true,\n        nameKey: 'categoryMusic',\n      ),\n      DownloadCategory(\n        name: '',\n        path: path.join(downloadDir, 'Video'),\n        isBuiltIn: true,\n        nameKey: 'categoryVideo',\n      ),\n      DownloadCategory(\n        name: '',\n        path: path.join(downloadDir, 'Document'),\n        isBuiltIn: true,\n        nameKey: 'categoryDocument',\n      ),\n      DownloadCategory(\n        name: '',\n        path: path.join(downloadDir, 'Program'),\n        isBuiltIn: true,\n        nameKey: 'categoryProgram',\n      ),\n    ];\n  }\n\n  void _initDefaultGithubMirrors() {\n    final extra = downloaderConfig.value.extra;\n\n    // Add default built-in GitHub mirrors\n    extra.githubMirror.mirrors = [\n      GithubMirror(\n        type: GithubMirrorType.jsdelivr,\n        url: 'https://fastly.jsdelivr.net/gh',\n        isBuiltIn: true,\n      ),\n      GithubMirror(\n        type: GithubMirrorType.ghProxy,\n        url: 'https://fastgit.cc',\n        isBuiltIn: true,\n      ),\n    ];\n  }\n\n  Future<void> _initLaunchAtStartup() async {\n    if (!Util.isWindows() && !Util.isLinux()) {\n      return;\n    }\n    launchAtStartup.setup(\n        appName: packageInfo.appName,\n        appPath: Platform.resolvedExecutable,\n        args: ['--${StartupArgs.flagHidden}']);\n    autoStartup.value = await launchAtStartup.isEnabled();\n  }\n\n  Future<void> _initCheckUpdate() async {\n    // Check if auto check update is enabled\n    if (!downloaderConfig.value.extra.notifyWhenNewVersion) {\n      return;\n    }\n\n    final versionInfo = await checkUpdate();\n    if (versionInfo != null) {\n      await showUpdateDialog(Get.context!, versionInfo);\n    }\n  }\n\n  Future<void> saveConfig() async {\n    Database.instance.saveStartConfig(StartConfigEntity(\n        network: startConfig.value.network,\n        address: startConfig.value.address,\n        apiToken: startConfig.value.apiToken));\n    await putConfig(downloaderConfig.value);\n  }\n\n  Map<String, dynamic> _decodeParams(String params) {\n    final safeParams = params.replaceAll('\"', \"\").replaceAll(\" \", \"+\");\n    final paramsJson =\n        String.fromCharCodes(base64Decode(base64.normalize(safeParams)));\n    return jsonDecode(paramsJson);\n  }\n\n  _handleToCreate(String params) {\n    final createTaskParams = CreateTask.fromJson(_decodeParams(params));\n    _handleToCreate0(createTaskParams);\n  }\n\n  _handleToCreate0(CreateTask createTaskParams) {\n    Get.rootDelegate.offAndToNamed(Routes.REDIRECT,\n        arguments: RedirectArgs(Routes.CREATE, arguments: createTaskParams));\n  }\n\n  _handleToExtension(String params) {\n    final installExtension = InstallExtension.fromJson(_decodeParams(params));\n    Get.rootDelegate.offAndToNamed(Routes.REDIRECT,\n        arguments: RedirectArgs(Routes.EXTENSION, arguments: installExtension));\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/modules/app/views/app_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_foreground_task/flutter_foreground_task.dart';\nimport 'package:flutter_localizations/flutter_localizations.dart';\nimport 'package:get/get.dart';\nimport 'package:window_manager/window_manager.dart'; // Import the required packages\n\nimport '../../../../i18n/message.dart';\nimport '../../../../theme/theme.dart';\nimport '../../../../util/locale_manager.dart';\nimport '../../../../util/util.dart'; // Import the required packages\nimport '../../../routes/app_pages.dart';\nimport '../controllers/app_controller.dart';\n\nclass AppView extends GetView<AppController> {\n  const AppView({Key? key}) : super(key: key);\n\n  @override\n  Widget build(BuildContext context) {\n    final config = controller.downloaderConfig.value;\n    return WithForegroundTask(\n      child: GetMaterialApp.router(\n        useInheritedMediaQuery: true,\n        debugShowCheckedModeBanner: false,\n        theme: GopeedTheme.light,\n        darkTheme: GopeedTheme.dark,\n        themeMode: ThemeMode.values.byName(config.extra.themeMode),\n        translations: messages,\n        locale: toLocale(config.extra.locale),\n        fallbackLocale: fallbackLocale,\n        localizationsDelegates: const [\n          GlobalMaterialLocalizations.delegate,\n          GlobalWidgetsLocalizations.delegate,\n          GlobalCupertinoLocalizations.delegate,\n        ],\n        supportedLocales: messages.keys.keys.map((e) => toLocale(e)).toList(),\n        getPages: AppPages.routes,\n\n        // Add listening to theme changes, set the title bar color according to the current system theme.\n        builder: (context, child) {\n          // if platform is desktop\n          if (Util.isDesktop()) {\n            // actual brightness of the UI\n            Brightness brightness = Theme.of(context).brightness;\n            // Set the title bar to use the actual brightness of the UI\n            windowManager.setBrightness(brightness);\n          }\n          // Fix for GetX Overlay issue with Flutter 3.38.1+\n          // Reference: https://github.com/jonataslaw/getx/issues/3425\n          return Overlay(\n            initialEntries: [OverlayEntry(builder: (_) => child!)],\n          );\n        },\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/modules/create/bindings/create_binding.dart",
    "content": "import 'package:get/get.dart';\n\nimport '../controllers/create_controller.dart';\n\nclass CreateBinding extends Bindings {\n  @override\n  void dependencies() {\n    Get.lazyPut<CreateController>(\n      () => CreateController(),\n    );\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/modules/create/controllers/create_controller.dart",
    "content": "import 'dart:convert';\nimport 'dart:typed_data';\n\nimport 'package:flutter/material.dart';\nimport 'package:get/get.dart';\nimport 'package:gopeed/api/model/request.dart';\n\nimport '../../app/controllers/app_controller.dart';\n\nclass CreateController extends GetxController\n    with GetSingleTickerProviderStateMixin {\n  final RxList fileInfos = [].obs;\n  final RxList openedFolders = [].obs;\n  final selectedIndexes = <int>[].obs;\n  final isConfirming = false.obs;\n  final showAdvanced = false.obs;\n  final directDownload = false.obs;\n  final proxyConfig = Rx<RequestProxy?>(null);\n  late TabController advancedTabController;\n  final oldUrl = \"\".obs;\n  final fileDataUri = \"\".obs;\n\n  /// Flag to prevent handling pending create task from deep link multiple times\n  bool pendingCreateHandled = false;\n\n  @override\n  void onInit() {\n    super.onInit();\n    advancedTabController = TabController(length: 2, vsync: this);\n    directDownload.value = Get.find<AppController>()\n        .downloaderConfig\n        .value\n        .extra\n        .defaultDirectDownload;\n  }\n\n  @override\n  void onClose() {\n    advancedTabController.dispose();\n    super.onClose();\n  }\n\n  void setFileDataUri(Uint8List bytes) {\n    fileDataUri.value =\n        \"data:application/x-bittorrent;base64,${base64.encode(bytes)}\";\n  }\n\n  void clearFileDataUri() {\n    fileDataUri.value = \"\";\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/modules/create/views/create_view.dart",
    "content": "import 'dart:convert';\n\nimport 'package:contentsize_tabbarview/contentsize_tabbarview.dart';\nimport 'package:desktop_drop/desktop_drop.dart';\nimport 'package:file_picker/file_picker.dart';\nimport 'package:flutter/material.dart';\nimport 'package:flutter/services.dart';\nimport 'package:get/get.dart';\nimport 'package:path/path.dart' as path;\nimport 'package:rounded_loading_button_plus/rounded_loading_button.dart';\n\nimport '../../../../api/api.dart';\nimport '../../../../api/model/create_task.dart';\nimport '../../../../api/model/create_task_batch.dart';\nimport '../../../../api/model/downloader_config.dart';\nimport '../../../../api/model/options.dart';\nimport '../../../../api/model/request.dart';\nimport '../../../../api/model/resolve_result.dart';\nimport '../../../../api/model/resolve_task.dart';\nimport '../../../../api/model/task.dart';\nimport '../../../../database/database.dart';\nimport '../../../../util/input_formatter.dart';\nimport '../../../../util/message.dart';\nimport '../../../../util/util.dart';\nimport '../../../routes/app_pages.dart';\nimport '../../../views/compact_checkbox.dart';\nimport '../../../views/directory_selector.dart';\nimport '../../../views/file_tree_view.dart';\nimport '../../app/controllers/app_controller.dart';\nimport '../../history/views/history_view.dart';\nimport '../controllers/create_controller.dart';\n\nclass CreateView extends GetView<CreateController> {\n  final _confirmFormKey = GlobalKey<FormState>();\n\n  final _urlController = TextEditingController();\n  final _renameController = TextEditingController();\n  final _connectionsController = TextEditingController();\n  final _pathController = TextEditingController();\n  final _confirmController = RoundedLoadingButtonController();\n  final _proxyIpController = TextEditingController();\n  final _proxyPortController = TextEditingController();\n  final _proxyUsrController = TextEditingController();\n  final _proxyPwdController = TextEditingController();\n  final _httpHeaderControllers = [\n    (\n      name: TextEditingController(text: \"User-Agent\"),\n      value: TextEditingController()\n    ),\n    (\n      name: TextEditingController(text: \"Cookie\"),\n      value: TextEditingController()\n    ),\n    (\n      name: TextEditingController(text: \"Referer\"),\n      value: TextEditingController()\n    ),\n  ];\n  final _btTrackerController = TextEditingController();\n  final _archivePasswordController = TextEditingController();\n\n  final _availableSchemes = [\"http:\", \"https:\", \"magnet:\", \"ed2k:\"];\n\n  final _skipVerifyCertController = false.obs;\n  final _autoTorrentController = Rxn<bool>();\n  final _deleteTorrentAfterDownloadController = Rxn<bool>();\n  final _autoExtractController = Rxn<bool>();\n  final _deleteAfterExtractController = Rxn<bool>();\n\n  CreateView({Key? key}) : super(key: key);\n\n  @override\n  Widget build(BuildContext context) {\n    final appController = Get.find<AppController>();\n\n    if (_connectionsController.text.isEmpty) {\n      _connectionsController.text = appController\n          .downloaderConfig.value.protocolConfig.http.connections\n          .toString();\n    }\n    if (_pathController.text.isEmpty) {\n      // Render placeholders when initializing the path\n      final downloadDir = appController.downloaderConfig.value.downloadDir;\n      _pathController.text = renderPathPlaceholders(downloadDir);\n    }\n\n    // Handle pending create task from deep link\n    final CreateTask? routerParams = Get.rootDelegate.arguments();\n    if ((routerParams?.req?.url.isNotEmpty ?? false) &&\n        !controller.pendingCreateHandled) {\n      controller.pendingCreateHandled = true;\n      // get url from route arguments\n      final url = routerParams!.req!.url;\n      _urlController.text = url;\n      _urlController.selection = TextSelection.fromPosition(\n          TextPosition(offset: _urlController.text.length));\n      final protocol = parseProtocol(url);\n      if (protocol != null) {\n        final extraHandlers = {\n          Protocol.http: () {\n            final reqExtra = ReqExtraHttp.fromJson(\n                jsonDecode(jsonEncode(routerParams.req!.extra)));\n            _httpHeaderControllers.clear();\n            reqExtra.header.forEach((key, value) {\n              _httpHeaderControllers.add(\n                (\n                  name: TextEditingController(text: key),\n                  value: TextEditingController(text: value),\n                ),\n              );\n            });\n            _skipVerifyCertController.value = routerParams.req!.skipVerifyCert;\n          },\n          Protocol.bt: () {\n            final reqExtra = ReqExtraBt.fromJson(\n                jsonDecode(jsonEncode(routerParams.req!.extra)));\n            _btTrackerController.text = reqExtra.trackers.join(\"\\n\");\n          },\n          Protocol.ed2k: null,\n        };\n        if (routerParams.req?.extra != null) {\n          extraHandlers[protocol]?.call();\n        }\n\n        // handle options\n        if (routerParams.opts != null) {\n          _renameController.text = routerParams.opts!.name;\n          _pathController.text = routerParams.opts!.path;\n\n          final optionsHandlers = {\n            Protocol.http: () {\n              final opt = routerParams.opts!;\n              _renameController.text = opt.name;\n              _pathController.text = opt.path;\n              if (opt.extra != null) {\n                final optsExtraHttp =\n                    OptsExtraHttp.fromJson(jsonDecode(jsonEncode(opt.extra)));\n                _connectionsController.text =\n                    optsExtraHttp.connections.toString();\n              }\n            },\n            Protocol.bt: null,\n            Protocol.ed2k: null,\n          };\n          if (routerParams.opts?.extra != null) {\n            optionsHandlers[protocol]?.call();\n          }\n        }\n      }\n    } else if (_urlController.text.isEmpty) {\n      // read clipboard\n      Clipboard.getData('text/plain').then((value) {\n        if (value?.text?.isNotEmpty ?? false) {\n          if (_availableSchemes\n              .where((e) =>\n                  value!.text!.startsWith(e) ||\n                  value.text!.startsWith(e.toUpperCase()))\n              .isNotEmpty) {\n            _urlController.text = value!.text!;\n            _urlController.selection = TextSelection.fromPosition(\n                TextPosition(offset: _urlController.text.length));\n            return;\n          }\n\n          recognizeMagnetUri(value!.text!);\n        }\n      });\n    }\n\n    return Scaffold(\n      appBar: AppBar(\n        leading: IconButton(\n            icon: const Icon(Icons.arrow_back),\n            onPressed: () => Get.rootDelegate.offNamed(Routes.TASK)),\n        // actions: [],\n        title: Text('create'.tr),\n      ),\n      body: DropTarget(\n        onDragDone: (details) async {\n          if (!Util.isWeb()) {\n            _urlController.text = details.files[0].path;\n            return;\n          }\n          _urlController.text = details.files[0].name;\n          final bytes = await details.files[0].readAsBytes();\n          controller.setFileDataUri(bytes);\n        },\n        child: GestureDetector(\n          behavior: HitTestBehavior.opaque,\n          onTap: () {\n            FocusScope.of(context).requestFocus(FocusNode());\n          },\n          child: SingleChildScrollView(\n            child: Padding(\n              padding:\n                  const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0),\n              child: Form(\n                key: _confirmFormKey,\n                autovalidateMode: AutovalidateMode.onUserInteraction,\n                child: Column(\n                  children: [\n                    Row(children: [\n                      Expanded(\n                        child: TextFormField(\n                          autofocus: !Util.isMobile(),\n                          controller: _urlController,\n                          minLines: 1,\n                          maxLines: 5,\n                          decoration: InputDecoration(\n                            hintText: _hitText(),\n                            hintStyle: const TextStyle(fontSize: 12),\n                            labelText: 'downloadLink'.tr,\n                            icon: const Icon(Icons.link),\n                            suffixIcon: IconButton(\n                              onPressed: () {\n                                _urlController.clear();\n                                controller.clearFileDataUri();\n                              },\n                              icon: const Icon(Icons.clear),\n                            ),\n                          ),\n                          validator: (v) {\n                            return v!.trim().isNotEmpty\n                                ? null\n                                : 'downloadLinkValid'.tr;\n                          },\n                          onChanged: (v) async {\n                            controller.clearFileDataUri();\n                            if (controller.oldUrl.value.isEmpty) {\n                              recognizeMagnetUri(v);\n                            }\n                            controller.oldUrl.value = v;\n                          },\n                        ),\n                      ),\n                      IconButton(\n                        icon: const Icon(Icons.folder_open),\n                        onPressed: () async {\n                          var pr = await FilePicker.platform.pickFiles(\n                              type: FileType.custom,\n                              allowedExtensions: [\"torrent\"]);\n                          if (pr != null) {\n                            if (!Util.isWeb()) {\n                              _urlController.text = pr.files[0].path ?? \"\";\n                              return;\n                            }\n                            _urlController.text = pr.files[0].name;\n                            controller.setFileDataUri(pr.files[0].bytes!);\n                          }\n                        },\n                      ),\n                      IconButton(\n                        icon: const Icon(Icons.history_rounded),\n                        onPressed: () async {\n                          List<String> resultOfHistories =\n                              Database.instance.getCreateHistory() ?? [];\n                          // show dialog box to list history\n                          if (context.mounted) {\n                            showGeneralDialog(\n                              barrierColor: Colors.black.withOpacity(0.5),\n                              transitionBuilder: (context, a1, a2, widget) {\n                                return Transform.scale(\n                                  scale: a1.value,\n                                  child: Opacity(\n                                    opacity: a1.value,\n                                    child: HistoryView(\n                                      isHistoryListEmpty:\n                                          resultOfHistories.isEmpty,\n                                      historyList: ListView.builder(\n                                        itemCount: resultOfHistories.length,\n                                        itemBuilder: (context, index) {\n                                          return GestureDetector(\n                                            onTap: () {\n                                              _urlController.text =\n                                                  resultOfHistories[index];\n                                              Navigator.pop(context);\n                                            },\n                                            child: MouseRegion(\n                                              cursor: SystemMouseCursors.click,\n                                              child: Container(\n                                                padding:\n                                                    const EdgeInsets.symmetric(\n                                                  horizontal: 8.0,\n                                                  vertical: 8.0,\n                                                ),\n                                                margin:\n                                                    const EdgeInsets.symmetric(\n                                                  horizontal: 10.0,\n                                                  vertical: 8.0,\n                                                ),\n                                                decoration: BoxDecoration(\n                                                  color: Theme.of(context)\n                                                      .colorScheme\n                                                      .surface,\n                                                  borderRadius:\n                                                      BorderRadius.circular(\n                                                          10.0),\n                                                ),\n                                                child: Text(\n                                                  resultOfHistories[index],\n                                                ),\n                                              ),\n                                            ),\n                                          );\n                                        },\n                                      ),\n                                    ),\n                                  ),\n                                );\n                              },\n                              transitionDuration:\n                                  const Duration(milliseconds: 250),\n                              barrierDismissible: true,\n                              barrierLabel: '',\n                              context: context,\n                              pageBuilder: (context, animation1, animation2) {\n                                return const Text('PAGE BUILDER');\n                              },\n                            );\n                          }\n                        },\n                      ),\n                    ]),\n                    Padding(\n                      padding: const EdgeInsets.only(left: 40),\n                      child: Column(children: [\n                        TextField(\n                          controller: _renameController,\n                          decoration: InputDecoration(labelText: 'rename'.tr),\n                        ),\n                        TextField(\n                          controller: _connectionsController,\n                          decoration: InputDecoration(\n                            labelText: 'connections'.tr,\n                          ),\n                          keyboardType: TextInputType.number,\n                          inputFormatters: [\n                            FilteringTextInputFormatter.digitsOnly,\n                            NumericalRangeFormatter(min: 1, max: 256),\n                          ],\n                        ),\n                        DirectorySelector(\n                          controller: _pathController,\n                        ),\n                        // Category selector\n                        _buildCategorySelector(appController),\n                        Obx(\n                          () => Visibility(\n                            visible: controller.showAdvanced.value,\n                            child: Column(\n                              mainAxisSize: MainAxisSize.min,\n                              crossAxisAlignment: CrossAxisAlignment.start,\n                              children: [\n                                Column(\n                                  mainAxisSize: MainAxisSize.min,\n                                  crossAxisAlignment: CrossAxisAlignment.start,\n                                  children: [\n                                    Transform.translate(\n                                      offset: const Offset(-40, 0),\n                                      child: Row(\n                                        children: [\n                                          const Icon(\n                                            Icons.wifi_2_bar,\n                                            color: Colors.grey,\n                                          ),\n                                          const SizedBox(\n                                            width: 15,\n                                          ),\n                                          SizedBox(\n                                              width: 150,\n                                              child: DropdownButton<\n                                                  RequestProxyMode>(\n                                                hint: Text('proxy'.tr),\n                                                isExpanded: true,\n                                                value: controller\n                                                    .proxyConfig.value?.mode,\n                                                onChanged: (value) async {\n                                                  if (value != null) {\n                                                    controller.proxyConfig\n                                                        .value = RequestProxy()\n                                                      ..mode = value;\n                                                  }\n                                                },\n                                                items: [\n                                                  DropdownMenuItem<\n                                                      RequestProxyMode>(\n                                                    value:\n                                                        RequestProxyMode.follow,\n                                                    child: Text(\n                                                        'followSettings'.tr),\n                                                  ),\n                                                  DropdownMenuItem<\n                                                      RequestProxyMode>(\n                                                    value:\n                                                        RequestProxyMode.none,\n                                                    child: Text('noProxy'.tr),\n                                                  ),\n                                                  DropdownMenuItem<\n                                                      RequestProxyMode>(\n                                                    value:\n                                                        RequestProxyMode.custom,\n                                                    child:\n                                                        Text('customProxy'.tr),\n                                                  ),\n                                                ],\n                                              ))\n                                        ],\n                                      ),\n                                    ),\n                                    ...(controller.proxyConfig.value?.mode ==\n                                            RequestProxyMode.custom\n                                        ? [\n                                            SizedBox(\n                                              width: 150,\n                                              child: DropdownButtonFormField<\n                                                  String>(\n                                                value: controller\n                                                    .proxyConfig.value?.scheme,\n                                                onChanged: (value) async {\n                                                  if (value != null) {}\n                                                },\n                                                items: const [\n                                                  DropdownMenuItem<String>(\n                                                    value: 'http',\n                                                    child: Text('HTTP'),\n                                                  ),\n                                                  DropdownMenuItem<String>(\n                                                    value: 'https',\n                                                    child: Text('HTTPS'),\n                                                  ),\n                                                  DropdownMenuItem<String>(\n                                                    value: 'socks5',\n                                                    child: Text('SOCKS5'),\n                                                  ),\n                                                ],\n                                              ),\n                                            ),\n                                            Row(children: [\n                                              Flexible(\n                                                child: TextFormField(\n                                                  controller:\n                                                      _proxyIpController,\n                                                  decoration: InputDecoration(\n                                                    labelText: 'server'.tr,\n                                                    contentPadding:\n                                                        const EdgeInsets.all(\n                                                            0.0),\n                                                  ),\n                                                ),\n                                              ),\n                                              const Padding(\n                                                  padding: EdgeInsets.only(\n                                                      left: 10)),\n                                              Flexible(\n                                                child: TextFormField(\n                                                  controller:\n                                                      _proxyPortController,\n                                                  decoration: InputDecoration(\n                                                    labelText: 'port'.tr,\n                                                    contentPadding:\n                                                        const EdgeInsets.all(\n                                                            0.0),\n                                                  ),\n                                                  keyboardType:\n                                                      TextInputType.number,\n                                                  inputFormatters: [\n                                                    FilteringTextInputFormatter\n                                                        .digitsOnly,\n                                                    NumericalRangeFormatter(\n                                                        min: 0, max: 65535),\n                                                  ],\n                                                ),\n                                              ),\n                                            ]),\n                                            Row(children: [\n                                              Flexible(\n                                                child: TextFormField(\n                                                  controller:\n                                                      _proxyUsrController,\n                                                  decoration: InputDecoration(\n                                                    labelText: 'username'.tr,\n                                                    contentPadding:\n                                                        const EdgeInsets.all(\n                                                            0.0),\n                                                  ),\n                                                ),\n                                              ),\n                                              const Padding(\n                                                  padding: EdgeInsets.only(\n                                                      left: 10)),\n                                              Flexible(\n                                                child: TextFormField(\n                                                  controller:\n                                                      _proxyPwdController,\n                                                  decoration: InputDecoration(\n                                                    labelText: 'password'.tr,\n                                                    contentPadding:\n                                                        const EdgeInsets.all(\n                                                            0.0),\n                                                  ),\n                                                ),\n                                              ),\n                                            ])\n                                          ]\n                                        : const []),\n                                  ],\n                                ),\n                                const Divider(),\n                                TabBar(\n                                  controller: controller.advancedTabController,\n                                  tabs: const [\n                                    Tab(\n                                      text: 'HTTP',\n                                    ),\n                                    Tab(\n                                      text: 'BitTorrent',\n                                    ),\n                                  ],\n                                ),\n                                DefaultTabController(\n                                  length: 2,\n                                  child: ContentSizeTabBarView(\n                                    controller:\n                                        controller.advancedTabController,\n                                    children: [\n                                      Column(\n                                        children: [\n                                          ..._httpHeaderControllers.map((e) {\n                                            return Row(\n                                              children: [\n                                                Flexible(\n                                                  child: TextFormField(\n                                                    controller: e.name,\n                                                    decoration: InputDecoration(\n                                                      hintText:\n                                                          'httpHeaderName'.tr,\n                                                    ),\n                                                  ),\n                                                ),\n                                                const Padding(\n                                                    padding: EdgeInsets.only(\n                                                        left: 10)),\n                                                Flexible(\n                                                  child: TextFormField(\n                                                    controller: e.value,\n                                                    decoration: InputDecoration(\n                                                      hintText:\n                                                          'httpHeaderValue'.tr,\n                                                    ),\n                                                  ),\n                                                ),\n                                                const Padding(\n                                                    padding: EdgeInsets.only(\n                                                        left: 10)),\n                                                IconButton(\n                                                  icon: const Icon(Icons.add),\n                                                  onPressed: () {\n                                                    _httpHeaderControllers.add(\n                                                      (\n                                                        name:\n                                                            TextEditingController(),\n                                                        value:\n                                                            TextEditingController(),\n                                                      ),\n                                                    );\n                                                    controller.showAdvanced\n                                                        .update((val) => val);\n                                                  },\n                                                ),\n                                                IconButton(\n                                                  icon:\n                                                      const Icon(Icons.remove),\n                                                  onPressed: () {\n                                                    if (_httpHeaderControllers\n                                                            .length <=\n                                                        1) {\n                                                      return;\n                                                    }\n                                                    _httpHeaderControllers\n                                                        .remove(e);\n                                                    controller.showAdvanced\n                                                        .update((val) => val);\n                                                  },\n                                                ),\n                                              ],\n                                            );\n                                          }),\n                                          Padding(\n                                            padding:\n                                                const EdgeInsets.only(top: 10),\n                                            child: CompactCheckbox(\n                                              label: 'skipVerifyCert'.tr,\n                                              value: _skipVerifyCertController\n                                                  .value,\n                                              onChanged: (bool? value) {\n                                                _skipVerifyCertController\n                                                    .value = value ?? false;\n                                              },\n                                              textStyle: const TextStyle(\n                                                color: Colors.grey,\n                                              ),\n                                            ),\n                                          ),\n                                          // AutoTorrent options\n                                          Padding(\n                                            padding:\n                                                const EdgeInsets.only(top: 10),\n                                            child: CompactCheckbox(\n                                              label: 'autoTorrentEnable'.tr,\n                                              value: _autoTorrentController\n                                                      .value ??\n                                                  false,\n                                              onChanged: (bool? value) {\n                                                _autoTorrentController.value =\n                                                    value ?? false;\n                                              },\n                                              textStyle: const TextStyle(\n                                                color: Colors.grey,\n                                              ),\n                                            ),\n                                          ),\n                                          Obx(\n                                            () => Visibility(\n                                              visible: _autoTorrentController\n                                                      .value ??\n                                                  false,\n                                              child: Padding(\n                                                padding: const EdgeInsets.only(\n                                                    top: 10, left: 20),\n                                                child: CompactCheckbox(\n                                                  label:\n                                                      'autoTorrentDeleteAfterDownload'\n                                                          .tr,\n                                                  value:\n                                                      _deleteTorrentAfterDownloadController\n                                                              .value ??\n                                                          false,\n                                                  onChanged: (bool? value) {\n                                                    _deleteTorrentAfterDownloadController\n                                                        .value = value ?? false;\n                                                  },\n                                                  textStyle: const TextStyle(\n                                                    color: Colors.grey,\n                                                  ),\n                                                ),\n                                              ),\n                                            ),\n                                          ),\n                                          // AutoExtract options\n                                          Padding(\n                                            padding:\n                                                const EdgeInsets.only(top: 10),\n                                            child: CompactCheckbox(\n                                              label: 'autoExtract'.tr,\n                                              value: _autoExtractController\n                                                      .value ??\n                                                  false,\n                                              onChanged: (bool? value) {\n                                                _autoExtractController.value =\n                                                    value ?? false;\n                                              },\n                                              textStyle: const TextStyle(\n                                                color: Colors.grey,\n                                              ),\n                                            ),\n                                          ),\n                                          Obx(\n                                            () => Visibility(\n                                              visible: _autoExtractController\n                                                      .value ??\n                                                  false,\n                                              child: Column(\n                                                children: [\n                                                  Padding(\n                                                    padding:\n                                                        const EdgeInsets.only(\n                                                            top: 10, left: 20),\n                                                    child: TextFormField(\n                                                      controller:\n                                                          _archivePasswordController,\n                                                      obscureText: true,\n                                                      decoration:\n                                                          InputDecoration(\n                                                        labelText:\n                                                            'archivePassword'\n                                                                .tr,\n                                                        hintText:\n                                                            'archivePasswordHint'\n                                                                .tr,\n                                                      ),\n                                                    ),\n                                                  ),\n                                                  Padding(\n                                                    padding:\n                                                        const EdgeInsets.only(\n                                                            top: 10, left: 20),\n                                                    child: CompactCheckbox(\n                                                      label:\n                                                          'deleteAfterExtract'\n                                                              .tr,\n                                                      value:\n                                                          _deleteAfterExtractController\n                                                                  .value ??\n                                                              false,\n                                                      onChanged: (bool? value) {\n                                                        _deleteAfterExtractController\n                                                                .value =\n                                                            value ?? false;\n                                                      },\n                                                      textStyle:\n                                                          const TextStyle(\n                                                        color: Colors.grey,\n                                                      ),\n                                                    ),\n                                                  ),\n                                                ],\n                                              ),\n                                            ),\n                                          ),\n                                        ],\n                                      ),\n                                      Column(\n                                        children: [\n                                          TextFormField(\n                                              controller: _btTrackerController,\n                                              maxLines: 5,\n                                              decoration: InputDecoration(\n                                                labelText: 'Trackers',\n                                                hintText: 'addTrackerHit'.tr,\n                                              )),\n                                        ],\n                                      ),\n                                    ],\n                                  ),\n                                )\n                              ],\n                            ).paddingOnly(top: 16),\n                          ),\n                        ),\n                      ]),\n                    ),\n                    Center(\n                      child: Padding(\n                        padding: const EdgeInsets.only(top: 15),\n                        child: Column(\n                          children: [\n                            Row(\n                              mainAxisSize: MainAxisSize.min,\n                              children: [\n                                CompactCheckbox(\n                                    label: 'directDownload'.tr,\n                                    value: controller.directDownload.value,\n                                    onChanged: (bool? value) {\n                                      controller.directDownload.value =\n                                          value ?? false;\n                                    }),\n                                TextButton(\n                                  onPressed: () {\n                                    controller.showAdvanced.value =\n                                        !controller.showAdvanced.value;\n                                  },\n                                  child: Row(children: [\n                                    Obx(() => Checkbox(\n                                          value: controller.showAdvanced.value,\n                                          onChanged: (bool? value) {\n                                            controller.showAdvanced.value =\n                                                value ?? false;\n                                          },\n                                        )),\n                                    Text('advancedOptions'.tr),\n                                  ]),\n                                ),\n                              ],\n                            ),\n                            SizedBox(\n                              width: 150,\n                              child: RoundedLoadingButton(\n                                color: Get.theme.colorScheme.secondary,\n                                onPressed: _doConfirm,\n                                controller: _confirmController,\n                                child: Text('confirm'.tr),\n                              ),\n                            ),\n                          ],\n                        ),\n                      ),\n                    ),\n                  ],\n                ),\n              ),\n            ),\n          ),\n        ),\n      ),\n    );\n  }\n\n  // parse protocol from url\n  parseProtocol(String url) {\n    final uppercaseUrl = url.toUpperCase();\n    Protocol? protocol;\n    if (uppercaseUrl.startsWith(\"HTTP:\") || uppercaseUrl.startsWith(\"HTTPS:\")) {\n      protocol = Protocol.http;\n    }\n    if (uppercaseUrl.startsWith(\"MAGNET:\") ||\n        uppercaseUrl.endsWith(\".TORRENT\")) {\n      protocol = Protocol.bt;\n    }\n    if (uppercaseUrl.startsWith(\"ED2K:\")) {\n      protocol = Protocol.ed2k;\n    }\n    return protocol;\n  }\n\n  // recognize magnet uri, if length == 40, auto add magnet prefix\n  recognizeMagnetUri(String text) {\n    if (text.length != 40) {\n      return;\n    }\n    final exp = RegExp(r\"[0-9a-fA-F]+\");\n    if (exp.hasMatch(text)) {\n      final uri = \"magnet:?xt=urn:btih:$text\";\n      _urlController.text = uri;\n      _urlController.selection = TextSelection.fromPosition(\n          TextPosition(offset: _urlController.text.length));\n    }\n  }\n\n  Future<void> _doConfirm() async {\n    if (controller.isConfirming.value) {\n      return;\n    }\n    controller.isConfirming.value = true;\n    try {\n      _confirmController.start();\n      if (_confirmFormKey.currentState!.validate()) {\n        final isWebFileChosen =\n            Util.isWeb() && controller.fileDataUri.isNotEmpty;\n        final submitUrl = isWebFileChosen\n            ? controller.fileDataUri.value\n            : _urlController.text.trim();\n\n        final urls = Util.textToLines(submitUrl);\n\n        // Check if there is a pending update task (only for single URL)\n        if (urls.length == 1) {\n          final appController = Get.find<AppController>();\n          final pendingTask = appController.pendingUpdateTask.value;\n          if (pendingTask != null) {\n            final shouldUpdate = await _showPendingUpdateDialog(\n                pendingTask.id, pendingTask.name);\n            if (shouldUpdate == true) {\n              // Update the pending task instead of creating a new one\n              await _updatePendingTask(pendingTask.id, urls.first);\n              return;\n            } else if (shouldUpdate == null) {\n              // User cancelled, don't create task either\n              return;\n            }\n            // shouldUpdate == false, continue to create new task\n          }\n        }\n\n        // Add url to the history\n        if (!isWebFileChosen) {\n          for (final url in urls) {\n            Database.instance.saveCreateHistory(url);\n          }\n        }\n\n        /*\n        Check if is direct download, there has two ways to direct download\n        1. Direct download option is checked\n        2. Muli line urls\n        */\n        final isMultiLine = urls.length > 1;\n        final isDirect = controller.directDownload.value || isMultiLine;\n        final opt = Options(\n          name: isMultiLine ? \"\" : _renameController.text,\n          path: _pathController.text,\n          selectFiles: [],\n          extra: parseReqOptsExtra(),\n        );\n        if (isDirect) {\n          await Future.wait(urls.map((url) {\n            return createTask(CreateTask(\n              req: Request(\n                url: url,\n                extra: parseReqExtra(url),\n                proxy: parseProxy(),\n                skipVerifyCert: _skipVerifyCertController.value,\n              ),\n              opts: opt,\n            ));\n          }));\n          Get.rootDelegate.offNamed(Routes.TASK);\n        } else {\n          final rr = await resolve(ResolveTask(\n            req: Request(\n              url: submitUrl,\n              extra: parseReqExtra(_urlController.text),\n              proxy: parseProxy(),\n              skipVerifyCert: _skipVerifyCertController.value,\n            ),\n            opts: opt,\n          ));\n          await _showResolveDialog(rr);\n        }\n      }\n    } catch (e) {\n      showErrorMessage(e);\n    } finally {\n      _confirmController.reset();\n      controller.isConfirming.value = false;\n    }\n  }\n\n  /// Shows a dialog to ask if user wants to update pending task or create new\n  /// Returns true to update, false to create new, null if cancelled\n  Future<bool?> _showPendingUpdateDialog(String taskId, String taskName) async {\n    return showDialog<bool>(\n      context: Get.context!,\n      barrierDismissible: false,\n      builder: (context) => AlertDialog(\n        title: Text('pendingUpdateFound'.tr),\n        content: Text('pendingUpdateConfirm'.trParams({'name': taskName})),\n        actions: [\n          TextButton(\n            onPressed: () => Navigator.of(context).pop(null),\n            child: Text('cancel'.tr),\n          ),\n          TextButton(\n            onPressed: () => Navigator.of(context).pop(false),\n            child: Text('pendingUpdateNo'.tr),\n          ),\n          TextButton(\n            onPressed: () => Navigator.of(context).pop(true),\n            child: Text('pendingUpdateYes'.tr),\n          ),\n        ],\n      ),\n    );\n  }\n\n  /// Updates the pending task with new URL and headers\n  Future<void> _updatePendingTask(String taskId, String newUrl) async {\n    try {\n      // Build headers from current form\n      final headers = <String, String>{};\n      for (final c in _httpHeaderControllers) {\n        final key = c.name.text.trim();\n        final value = c.value.text.trim();\n        if (key.isNotEmpty) {\n          headers[key] = value;\n        }\n      }\n\n      // Build ReqExtraHttp\n      final reqExtra = ReqExtraHttp(header: headers);\n\n      // Create patch request\n      final patchData = ResolveTask(\n        req: Request(\n          url: newUrl,\n          extra: reqExtra.toJson(),\n          proxy: parseProxy(),\n          skipVerifyCert: _skipVerifyCertController.value,\n        ),\n      );\n\n      await patchTask(taskId, patchData);\n      await continueTask(taskId);\n\n      // Clear pending update state\n      final appController = Get.find<AppController>();\n      appController.pendingUpdateTask.value = null;\n\n      Get.rootDelegate.offNamed(Routes.TASK);\n    } catch (e) {\n      showErrorMessage(e);\n    }\n  }\n\n  RequestProxy? parseProxy() {\n    if (controller.proxyConfig.value?.mode == RequestProxyMode.custom) {\n      return RequestProxy()\n        ..mode = RequestProxyMode.custom\n        ..scheme = _proxyIpController.text\n        ..host = \"${_proxyIpController.text}:${_proxyPortController.text}\"\n        ..usr = _proxyUsrController.text\n        ..pwd = _proxyPwdController.text;\n    }\n    return controller.proxyConfig.value;\n  }\n\n  Object? parseReqExtra(String url) {\n    Object? reqExtra;\n    final protocol = parseProtocol(url);\n    switch (protocol) {\n      case Protocol.http:\n        final header = Map<String, String>.fromEntries(_httpHeaderControllers\n            .map((e) => MapEntry(e.name.text, e.value.text)));\n        header.removeWhere(\n            (key, value) => key.trim().isEmpty || value.trim().isEmpty);\n        if (header.isNotEmpty) {\n          reqExtra = ReqExtraHttp()..header = header;\n        }\n        break;\n      case Protocol.bt:\n        if (_btTrackerController.text.trim().isNotEmpty) {\n          reqExtra = ReqExtraBt()\n            ..trackers = Util.textToLines(_btTrackerController.text);\n        }\n        break;\n      case Protocol.ed2k:\n      case null:\n        break;\n    }\n    return reqExtra;\n  }\n\n  Object? parseReqOptsExtra() {\n    return OptsExtraHttp()\n      ..connections = int.tryParse(_connectionsController.text) ?? 0\n      ..autoTorrent = _autoTorrentController.value\n      ..deleteTorrentAfterDownload = _deleteTorrentAfterDownloadController.value\n      ..autoExtract = _autoExtractController.value\n      ..archivePassword = _archivePasswordController.text\n      ..deleteAfterExtract = _deleteAfterExtractController.value ?? false;\n  }\n\n  String _hitText() {\n    return 'downloadLinkHit'.trParams({\n      'append':\n          Util.isDesktop() || Util.isWeb() ? 'downloadLinkHitDesktop'.tr : '',\n    });\n  }\n\n  Future<void> _showResolveDialog(ResolveResult rr) async {\n    final createFormKey = GlobalKey<FormState>();\n    final downloadController = RoundedLoadingButtonController();\n    return showDialog<void>(\n        context: Get.context!,\n        barrierDismissible: false,\n        builder: (_) => AlertDialog(\n              title: rr.res.name.isEmpty ? null : Text(rr.res.name),\n              content: Builder(\n                builder: (context) {\n                  // Get available height and width of the build area of this widget. Make a choice depending on the size.\n                  var height = MediaQuery.of(context).size.height;\n                  var width = MediaQuery.of(context).size.width;\n\n                  return SizedBox(\n                    height: height * 0.75,\n                    width: width,\n                    child: Form(\n                        key: createFormKey,\n                        autovalidateMode: AutovalidateMode.always,\n                        child: FileTreeView(\n                          files: rr.res.files,\n                          initialValues: rr.res.files.asMap().keys.toList(),\n                          onSelectionChanged: (List<int> values) {\n                            controller.selectedIndexes.value = values;\n                          },\n                        )),\n                  );\n                },\n              ),\n              actions: [\n                ConstrainedBox(\n                  constraints: BoxConstraints.tightFor(\n                    width: Get.theme.buttonTheme.minWidth,\n                    height: Get.theme.buttonTheme.height,\n                  ),\n                  child: ElevatedButton(\n                    style:\n                        ElevatedButton.styleFrom(shape: const StadiumBorder())\n                            .copyWith(\n                                backgroundColor: MaterialStateProperty.all(\n                                    Get.theme.colorScheme.background)),\n                    onPressed: () {\n                      Get.back();\n                    },\n                    child: Text('cancel'.tr),\n                  ),\n                ),\n                ConstrainedBox(\n                  constraints: BoxConstraints.tightFor(\n                    width: Get.theme.buttonTheme.minWidth,\n                    height: Get.theme.buttonTheme.height,\n                  ),\n                  child: RoundedLoadingButton(\n                      color: Get.theme.colorScheme.secondary,\n                      onPressed: () async {\n                        try {\n                          downloadController.start();\n                          if (controller.selectedIndexes.isEmpty) {\n                            showMessage('tip'.tr, 'noFileSelected'.tr);\n                            return;\n                          }\n                          final optExtra = parseReqOptsExtra();\n                          if (createFormKey.currentState!.validate()) {\n                            if (rr.id.isEmpty) {\n                              // from extension resolve result\n                              final reqs =\n                                  controller.selectedIndexes.map((index) {\n                                final file = rr.res.files[index];\n                                return CreateTaskBatchItem(\n                                    req: file.req!..proxy = parseProxy(),\n                                    opts: Options(\n                                        name: file.name,\n                                        path: path.join(_pathController.text,\n                                            rr.res.name, file.path),\n                                        selectFiles: [],\n                                        extra: optExtra));\n                              }).toList();\n                              await createTaskBatch(\n                                  CreateTaskBatch(reqs: reqs));\n                            } else {\n                              await createTask(CreateTask(\n                                  rid: rr.id,\n                                  opts: Options(\n                                      name: _renameController.text,\n                                      path: _pathController.text,\n                                      selectFiles: controller.selectedIndexes,\n                                      extra: optExtra)));\n                            }\n                            Get.back();\n                            Get.rootDelegate.offNamed(Routes.TASK);\n                          }\n                        } catch (e) {\n                          showErrorMessage(e);\n                        } finally {\n                          downloadController.reset();\n                        }\n                      },\n                      controller: downloadController,\n                      child: Text(\n                        'download'.tr,\n                        // style: controller.selectedIndexes.isEmpty\n                        //     ? Get.textTheme.disabled\n                        //     : Get.textTheme.titleSmall\n                      )),\n                ),\n              ],\n            ));\n  }\n\n  Widget _buildCategorySelector(AppController appController) {\n    final categories =\n        appController.downloaderConfig.value.extra.downloadCategories\n            .where((c) => !c.isDeleted) // Filter out deleted categories\n            .toList();\n    if (categories.isEmpty) {\n      return const SizedBox.shrink();\n    }\n\n    // Helper to get display name\n    String getCategoryDisplayName(DownloadCategory category) {\n      if (category.nameKey != null && category.nameKey!.isNotEmpty) {\n        return category.nameKey!.tr;\n      }\n      return category.name;\n    }\n\n    return Padding(\n      padding: const EdgeInsets.only(top: 8),\n      child: Row(\n        children: [\n          Text(\n            'selectCategory'.tr,\n            style: TextStyle(\n              color: Get.theme.hintColor,\n              fontSize: 12,\n            ),\n          ),\n          const SizedBox(width: 8),\n          Expanded(\n            child: SingleChildScrollView(\n              scrollDirection: Axis.horizontal,\n              child: Row(\n                children: categories.map((category) {\n                  return Padding(\n                    padding: const EdgeInsets.only(right: 8),\n                    child: OutlinedButton(\n                      onPressed: () {\n                        _pathController.text =\n                            renderPathPlaceholders(category.path);\n                      },\n                      style: OutlinedButton.styleFrom(\n                        padding: const EdgeInsets.symmetric(\n                          horizontal: 12,\n                          vertical: 4,\n                        ),\n                        minimumSize: Size.zero,\n                      ),\n                      child: Text(\n                        getCategoryDisplayName(category),\n                        style: const TextStyle(fontSize: 12),\n                      ),\n                    ),\n                  );\n                }).toList(),\n              ),\n            ),\n          ),\n        ],\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/modules/extension/bindings/extension_binding.dart",
    "content": "import 'package:get/get.dart';\n\nimport '../controllers/extension_controller.dart';\n\nclass ExtensionBinding extends Bindings {\n  @override\n  void dependencies() {\n    Get.lazyPut<ExtensionController>(\n      () => ExtensionController(),\n    );\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/modules/extension/controllers/extension_controller.dart",
    "content": "import 'dart:async';\nimport 'dart:collection';\n\nimport 'package:get/get.dart';\n\nimport '../../../../api/api.dart';\nimport '../../../../api/gopeed_site_api.dart';\nimport '../../../../api/model/extension.dart';\nimport '../../../../api/model/install_extension.dart';\nimport '../../../../api/model/store_extension.dart';\nimport '../../../../api/model/switch_extension.dart';\n\nenum ExtensionListFilter {\n  market,\n  installed,\n}\n\nclass ExtensionListItem {\n  final Extension? installed;\n  final StoreExtension? store;\n\n  const ExtensionListItem({this.installed, this.store});\n\n  bool get isInstalled => installed != null;\n\n  String get id => installed?.identity ?? store!.id;\n\n  String get title => installed?.title ?? store?.title ?? '';\n\n  String get author => installed?.author ?? store?.author ?? '';\n\n  String get description => installed?.description ?? store?.description ?? '';\n\n  String get version => installed?.version ?? store?.version ?? '0.0.0';\n\n  String? get homepage {\n    final installedHomepage = installed?.homepage;\n    if (installedHomepage != null && installedHomepage.isNotEmpty) {\n      return installedHomepage;\n    }\n    return store?.homepage;\n  }\n\n  String? get repoUrl {\n    final installedRepo = installed?.repository?.url;\n    if (installedRepo != null && installedRepo.isNotEmpty) {\n      return installedRepo;\n    }\n    return store?.repoUrl;\n  }\n\n  int get stars => store?.stars ?? 0;\n\n  int get installCount => store?.installCount ?? 0;\n\n  String? get icon => store?.icon;\n}\n\nclass ExtensionController extends GetxController {\n  static const manualInstallBusyKey = '__manual_install__';\n\n  final installedExtensions = <Extension>[].obs;\n  final updateFlags = <String, String>{}.obs;\n\n  final storeExtensions = <StoreExtension>[].obs;\n  final storePagination = Rxn<StorePagination>();\n  final storeQuery = ''.obs;\n  final storeSort = StoreExtensionSort.stars.obs;\n\n  final listFilter = ExtensionListFilter.market.obs;\n\n  final loadingInstalled = false.obs;\n  final loadingStore = false.obs;\n  final loadingMoreStore = false.obs;\n  final busyExtensionIds = <String>{}.obs;\n  final showInstallTools = false.obs;\n\n  final devMode = false.obs;\n  var _devModeCount = 0;\n\n  bool pendingInstallHandled = false;\n\n  UnmodifiableMapView<String, Extension> get installedMap =>\n      UnmodifiableMapView(\n          {for (final ext in installedExtensions) ext.identity: ext});\n\n  UnmodifiableMapView<String, StoreExtension> get storeMap =>\n      UnmodifiableMapView({for (final ext in storeExtensions) ext.id: ext});\n\n  List<ExtensionListItem> get displayItems {\n    if (listFilter.value == ExtensionListFilter.installed) {\n      return installedExtensions\n          .map((ext) =>\n              ExtensionListItem(installed: ext, store: storeMap[ext.identity]))\n          .toList();\n    }\n\n    return storeExtensions\n        .map((ext) =>\n            ExtensionListItem(installed: installedMap[ext.id], store: ext))\n        .toList();\n  }\n\n  @override\n  Future<void> onInit() async {\n    super.onInit();\n    await loadInitialData();\n  }\n\n  Future<void> loadInitialData() async {\n    await Future.wait([loadInstalled(refreshUpdates: true), refreshStore()]);\n  }\n\n  Future<void> loadInstalled({bool refreshUpdates = false}) async {\n    loadingInstalled.value = true;\n    try {\n      installedExtensions.value = await getExtensions();\n      if (refreshUpdates) {\n        await checkUpdate();\n      }\n    } finally {\n      loadingInstalled.value = false;\n    }\n  }\n\n  Future<void> checkUpdate() async {\n    final nextFlags = <String, String>{};\n    for (final ext in installedExtensions) {\n      try {\n        final resp = await upgradeCheckExtension(ext.identity);\n        if (resp.newVersion.isNotEmpty) {\n          nextFlags[ext.identity] = resp.newVersion;\n        }\n      } catch (_) {\n        // Ignore single extension check failures to avoid breaking the whole list.\n      }\n    }\n    updateFlags.assignAll(nextFlags);\n  }\n\n  Future<void> refreshStore() async {\n    loadingStore.value = true;\n    try {\n      final page = await GopeedSiteApi.instance.getExtensions(\n        page: 1,\n        limit: 20,\n        sort: storeSort.value,\n        query: storeQuery.value,\n      );\n      storeExtensions.assignAll(page.data);\n      storePagination.value = page.pagination;\n    } finally {\n      loadingStore.value = false;\n    }\n  }\n\n  Future<void> loadMoreStore() async {\n    final pagination = storePagination.value;\n    if (pagination == null || !pagination.hasNext || loadingMoreStore.value) {\n      return;\n    }\n    loadingMoreStore.value = true;\n    try {\n      final page = await GopeedSiteApi.instance.getExtensions(\n        page: pagination.page + 1,\n        limit: pagination.limit,\n        sort: storeSort.value,\n        query: storeQuery.value,\n      );\n      storeExtensions.addAll(page.data);\n      storePagination.value = page.pagination;\n    } finally {\n      loadingMoreStore.value = false;\n    }\n  }\n\n  Future<void> searchStore(String query) async {\n    storeQuery.value = query.trim();\n    await refreshStore();\n  }\n\n  Future<void> changeSort(StoreExtensionSort sort) async {\n    if (storeSort.value == sort) {\n      await refreshStore();\n      return;\n    }\n    storeSort.value = sort;\n    await refreshStore();\n  }\n\n  void changeFilter(ExtensionListFilter filter) {\n    listFilter.value = filter;\n  }\n\n  void toggleInstallTools() {\n    showInstallTools.value = !showInstallTools.value;\n  }\n\n  Extension? findInstalled(StoreExtension extension) {\n    return installedMap[extension.id];\n  }\n\n  bool canUpdateFromStore(StoreExtension extension) {\n    final installed = findInstalled(extension);\n    if (installed == null) return false;\n    return _compareVersion(extension.version, installed.version) > 0;\n  }\n\n  bool canUpdateItem(ExtensionListItem item) {\n    if (item.installed == null) return false;\n    if (listFilter.value == ExtensionListFilter.market && item.store != null) {\n      return canUpdateFromStore(item.store!);\n    }\n    return updateFlags.containsKey(item.installed!.identity);\n  }\n\n  Future<void> installFromStore(StoreExtension extension) async {\n    await _runBusy(extension.id, () async {\n      final installUrl = (extension.directory ?? '').trim().isEmpty\n          ? extension.repoUrl\n          : '${extension.repoUrl}#${extension.directory!.trim()}';\n\n      final installedId =\n          await installExtension(InstallExtension(url: installUrl));\n      final statsId = installedId.isNotEmpty ? installedId : extension.id;\n      await loadInstalled(refreshUpdates: false);\n      _backgroundCheckUpdate();\n      _bumpStoreInstallCount(statsId);\n      _reportInstallSafe(statsId);\n    });\n  }\n\n  Future<void> installFromUrl(String url,\n      {bool devInstall = false, String? statsId}) async {\n    await _runBusy(manualInstallBusyKey, () async {\n      final installedId = await installExtension(\n          InstallExtension(devMode: devInstall, url: url));\n      await loadInstalled(refreshUpdates: false);\n      _backgroundCheckUpdate();\n      if (installedId.isNotEmpty) {\n        _bumpStoreInstallCount(installedId);\n        _reportInstallSafe(installedId);\n      } else if (statsId != null && statsId.isNotEmpty) {\n        _bumpStoreInstallCount(statsId);\n        _reportInstallSafe(statsId);\n      }\n    });\n  }\n\n  Future<void> toggleExtension(Extension extension, bool enabled) async {\n    await _runBusy(extension.identity, () async {\n      await switchExtension(\n          extension.identity, SwitchExtension(status: enabled));\n      await loadInstalled(refreshUpdates: false);\n    });\n  }\n\n  Future<void> removeExtension(Extension extension) async {\n    await _runBusy(extension.identity, () async {\n      await deleteExtension(extension.identity);\n      await loadInstalled(refreshUpdates: false);\n      updateFlags.remove(extension.identity);\n    });\n  }\n\n  Future<void> upgradeExtension(Extension extension) async {\n    await _runBusy(extension.identity, () async {\n      await updateExtension(extension.identity);\n      await loadInstalled(refreshUpdates: false);\n      _backgroundCheckUpdate();\n      _bumpStoreInstallCount(extension.identity);\n      _reportInstallSafe(extension.identity);\n    });\n  }\n\n  void tryOpenDevMode() {\n    if (_devModeCount == 0) {\n      Future.delayed(const Duration(seconds: 2), () {\n        if (devMode.value) return;\n        devMode.value = false;\n        _devModeCount = 0;\n      });\n    }\n    _devModeCount++;\n    if (_devModeCount >= 5) {\n      devMode.value = true;\n    }\n  }\n\n  static int _compareVersion(String a, String b) {\n    final aNums = _toVersionNumbers(a);\n    final bNums = _toVersionNumbers(b);\n    final maxLen = aNums.length > bNums.length ? aNums.length : bNums.length;\n\n    for (var i = 0; i < maxLen; i++) {\n      final left = i < aNums.length ? aNums[i] : 0;\n      final right = i < bNums.length ? bNums[i] : 0;\n      if (left > right) return 1;\n      if (left < right) return -1;\n    }\n    return 0;\n  }\n\n  static List<int> _toVersionNumbers(String version) {\n    return version\n        .split(RegExp(r'[^0-9]+'))\n        .where((part) => part.isNotEmpty)\n        .map((part) => int.tryParse(part) ?? 0)\n        .toList();\n  }\n\n  Future<void> _runBusy(String id, Future<void> Function() action) async {\n    if (busyExtensionIds.contains(id)) return;\n    busyExtensionIds.add(id);\n    try {\n      await action();\n    } finally {\n      busyExtensionIds.remove(id);\n    }\n  }\n\n  void _backgroundCheckUpdate() {\n    unawaited(checkUpdate());\n  }\n\n  void _reportInstallSafe(String id) {\n    unawaited(() async {\n      try {\n        await GopeedSiteApi.instance.reportExtensionInstall(id);\n      } catch (_) {\n        // Ignore stats reporting failures.\n      }\n    }());\n  }\n\n  void _bumpStoreInstallCount(String id) {\n    final index = storeExtensions.indexWhere((e) => e.id == id);\n    if (index < 0) return;\n    final ext = storeExtensions[index];\n    storeExtensions[index] = StoreExtension(\n      id: ext.id,\n      repoFullName: ext.repoFullName,\n      repoUrl: ext.repoUrl,\n      directory: ext.directory,\n      commitSha: ext.commitSha,\n      name: ext.name,\n      author: ext.author,\n      title: ext.title,\n      description: ext.description,\n      icon: ext.icon,\n      version: ext.version,\n      homepage: ext.homepage,\n      readme: ext.readme,\n      installCount: ext.installCount + 1,\n      stars: ext.stars,\n      topics: ext.topics,\n      createdAt: ext.createdAt,\n      updatedAt: ext.updatedAt,\n    );\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/modules/extension/views/extension_card.dart",
    "content": "import 'dart:io';\n\nimport 'package:flutter/material.dart';\nimport 'package:get/get.dart';\nimport 'package:path/path.dart' as path;\nimport 'package:url_launcher/url_launcher.dart';\n\nimport '../../../../api/api.dart';\nimport '../../../../database/database.dart';\nimport '../../../../util/util.dart';\nimport '../controllers/extension_controller.dart';\n\nclass ExtensionCard extends StatelessWidget {\n  const ExtensionCard({\n    super.key,\n    required this.item,\n    required this.busy,\n    required this.canUpdate,\n    this.onTap,\n    this.onToggle,\n    this.onOpenSetting,\n    this.onUpdate,\n    this.onDelete,\n    this.onInstall,\n  });\n\n  final ExtensionListItem item;\n  final bool busy;\n  final bool canUpdate;\n  final VoidCallback? onTap;\n  final Future<void> Function(bool value)? onToggle;\n  final VoidCallback? onOpenSetting;\n  final VoidCallback? onUpdate;\n  final VoidCallback? onDelete;\n  final VoidCallback? onInstall;\n\n  @override\n  Widget build(BuildContext context) {\n    final installed = item.installed;\n    final store = item.store;\n    final installedFlag = item.isInstalled;\n\n    final content = Stack(\n      children: [\n        Container(\n          padding: const EdgeInsets.all(12),\n          decoration: BoxDecoration(\n            color: Theme.of(context).colorScheme.surface,\n            borderRadius: BorderRadius.circular(12),\n            border: Border.all(\n              color: Theme.of(context).dividerColor.withValues(alpha: 0.2),\n            ),\n          ),\n          child: Column(\n            crossAxisAlignment: CrossAxisAlignment.start,\n            children: [\n              Row(\n                crossAxisAlignment: CrossAxisAlignment.start,\n                children: [\n                  _buildCardIcon(item),\n                  const SizedBox(width: 8),\n                  Expanded(\n                    child: Column(\n                      crossAxisAlignment: CrossAxisAlignment.start,\n                      children: [\n                        Text(\n                          item.title,\n                          maxLines: 1,\n                          overflow: TextOverflow.ellipsis,\n                          style: Theme.of(context).textTheme.titleSmall,\n                        ),\n                        const SizedBox(height: 2),\n                        Text(\n                          '${item.author} · v${item.version}',\n                          maxLines: 1,\n                          overflow: TextOverflow.ellipsis,\n                          style: Theme.of(context).textTheme.bodySmall,\n                        ),\n                      ],\n                    ),\n                  ),\n                  if (installed != null) ...[\n                    Transform.scale(\n                      scale: 0.82,\n                      child: Switch(\n                        value: !installed.disabled,\n                        onChanged: busy || onToggle == null\n                            ? null\n                            : (value) async {\n                                await onToggle!(value);\n                              },\n                      ),\n                    ),\n                  ],\n                ],\n              ),\n              const SizedBox(height: 8),\n              Text(\n                item.description,\n                maxLines: 2,\n                overflow: TextOverflow.ellipsis,\n              ),\n              const Spacer(),\n              Row(\n                crossAxisAlignment: CrossAxisAlignment.center,\n                children: [\n                  Expanded(\n                    child: Row(\n                      children: [\n                        if (store != null) ...[\n                          _metricItem(\n                              context, Icons.star_rounded, item.stars.toString()),\n                          const SizedBox(width: 10),\n                          _metricItem(context, Icons.download_outlined,\n                              item.installCount.toString()),\n                        ],\n                      ],\n                    ),\n                  ),\n                  Row(\n                    mainAxisSize: MainAxisSize.min,\n                    children: [\n                      if (installed != null && canUpdate)\n                        IconButton.filledTonal(\n                          tooltip: 'newVersionUpdate'.tr,\n                          onPressed: busy ? null : onUpdate,\n                          icon: busy\n                              ? const SizedBox(\n                                  width: 14,\n                                  height: 14,\n                                  child: CircularProgressIndicator(strokeWidth: 2),\n                                )\n                              : const Icon(Icons.refresh_rounded),\n                        ),\n                      if (!installedFlag && store != null)\n                        IconButton.filledTonal(\n                          tooltip: 'extensionInstall'.tr,\n                          onPressed: busy ? null : onInstall,\n                          icon: busy\n                              ? const SizedBox(\n                                  width: 14,\n                                  height: 14,\n                                  child: CircularProgressIndicator(strokeWidth: 2),\n                                )\n                              : const Icon(Icons.download),\n                        ),\n                      if ((item.homepage ?? '').isNotEmpty)\n                        IconButton(\n                          tooltip: 'homepage'.tr,\n                          onPressed: () => launchUrl(Uri.parse(item.homepage!)),\n                          icon: const Icon(Icons.home_outlined),\n                        ),\n                      if ((item.repoUrl ?? '').isNotEmpty)\n                        IconButton(\n                          tooltip: 'GitHub',\n                          onPressed: () => launchUrl(Uri.parse(item.repoUrl!)),\n                          icon: const Icon(Icons.code),\n                        ),\n                      if (installed != null &&\n                          installed.settings?.isNotEmpty == true)\n                        IconButton(\n                          tooltip: 'setting'.tr,\n                          onPressed: busy ? null : onOpenSetting,\n                          icon: const Icon(Icons.settings),\n                        ),\n                      if (installed != null)\n                        IconButton(\n                          tooltip: 'delete'.tr,\n                          onPressed: busy ? null : onDelete,\n                          icon: const Icon(Icons.delete_outline),\n                        ),\n                    ],\n                  ),\n                ],\n              ),\n            ],\n          ),\n        ),\n        if (installedFlag && canUpdate)\n          Positioned(\n            top: 10,\n            right: 10,\n            child: _updateDot(),\n          ),\n      ],\n    );\n\n    if (onTap == null) return content;\n    return InkWell(\n      borderRadius: BorderRadius.circular(12),\n      onTap: onTap,\n      child: content,\n    );\n  }\n\n  Widget _buildCardIcon(ExtensionListItem item) {\n    final storeIcon = item.icon;\n    if (storeIcon != null && storeIcon.isNotEmpty) {\n      return ClipRRect(\n        borderRadius: BorderRadius.circular(8),\n        child: Image.network(\n          storeIcon,\n          width: 42,\n          height: 42,\n          fit: BoxFit.cover,\n          errorBuilder: (_, __, ___) => Image.asset(\n              'assets/extension/default_icon.png',\n              width: 42,\n              height: 42),\n        ),\n      );\n    }\n\n    final extension = item.installed;\n    if (extension == null) {\n      return Image.asset('assets/extension/default_icon.png',\n          width: 42, height: 42);\n    }\n\n    final image = extension.icon.isEmpty\n        ? Image.asset('assets/extension/default_icon.png',\n            width: 42, height: 42)\n        : Util.isWeb()\n            ? Image.network(\n                join('/fs/extensions/${extension.identity}/${extension.icon}'),\n                width: 42,\n                height: 42,\n                headers: {\n                  'Authorization': 'Bearer ${Database.instance.getWebToken()}'\n                },\n                errorBuilder: (_, __, ___) => Image.asset(\n                    'assets/extension/default_icon.png',\n                    width: 42,\n                    height: 42),\n              )\n            : Image.file(\n                extension.devMode\n                    ? File(path.join(extension.devPath, extension.icon))\n                    : File(path.join(Util.getStorageDir(), 'extensions',\n                        extension.identity, extension.icon)),\n                width: 42,\n                height: 42,\n                errorBuilder: (_, __, ___) => Image.asset(\n                    'assets/extension/default_icon.png',\n                    width: 42,\n                    height: 42),\n              );\n\n    return ClipRRect(borderRadius: BorderRadius.circular(8), child: image);\n  }\n\n  Widget _metricItem(BuildContext context, IconData icon, String text) {\n    return Row(\n      mainAxisSize: MainAxisSize.min,\n      children: [\n        Icon(icon, size: 18, color: Theme.of(context).hintColor),\n        const SizedBox(width: 4),\n        Text(text, style: Theme.of(context).textTheme.bodyMedium),\n      ],\n    );\n  }\n\n  Widget _updateDot() {\n    return Container(\n      width: 9,\n      height: 9,\n      decoration: const BoxDecoration(\n        color: Colors.redAccent,\n        shape: BoxShape.circle,\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/modules/extension/views/extension_detail_view.dart",
    "content": "import 'dart:io';\n\nimport 'package:flutter/material.dart';\nimport 'package:flutter_markdown_plus/flutter_markdown_plus.dart';\nimport 'package:get/get.dart';\nimport 'package:path/path.dart' as path;\nimport 'package:url_launcher/url_launcher.dart';\n\nimport '../../../../api/model/extension.dart';\nimport '../../../../api/model/store_extension.dart';\nimport '../../../../util/message.dart';\nimport '../../../../util/util.dart';\nimport '../controllers/extension_controller.dart';\n\nclass ExtensionDetailDrawer extends GetView<ExtensionController> {\n  const ExtensionDetailDrawer({\n    super.key,\n    required this.extension,\n    required this.onClose,\n    this.installed,\n  });\n\n  final StoreExtension extension;\n  final Extension? installed;\n  final VoidCallback onClose;\n\n  @override\n  Widget build(BuildContext context) {\n    return Material(\n      color: Theme.of(context).colorScheme.surface,\n      child: SafeArea(\n        child: Column(\n          children: [\n            Padding(\n              padding: const EdgeInsets.fromLTRB(16, 14, 12, 10),\n              child: Row(\n                children: [\n                  Expanded(\n                    child: Text(\n                      extension.title,\n                      style: Theme.of(context).textTheme.titleLarge,\n                      maxLines: 1,\n                      overflow: TextOverflow.ellipsis,\n                    ),\n                  ),\n                  IconButton(\n                    onPressed: onClose,\n                    icon: const Icon(Icons.close),\n                  )\n                ],\n              ),\n            ),\n            const Divider(height: 1),\n            Expanded(\n              child: Obx(() {\n                final localInstalled =\n                    installed ?? controller.findInstalled(extension);\n                final canUpdate = controller.canUpdateFromStore(extension);\n                final busy =\n                    controller.busyExtensionIds.contains(extension.id) ||\n                        (localInstalled != null &&\n                            controller.busyExtensionIds\n                                .contains(localInstalled.identity));\n\n                return ListView(\n                  padding: const EdgeInsets.all(16),\n                  children: [\n                    Row(\n                      crossAxisAlignment: CrossAxisAlignment.start,\n                      children: [\n                        _buildIcon(),\n                        const SizedBox(width: 12),\n                        Expanded(\n                          child: Column(\n                            crossAxisAlignment: CrossAxisAlignment.start,\n                            children: [\n                              Text(\n                                  '${extension.author} • v${extension.version}'),\n                              const SizedBox(height: 6),\n                              Text(\n                                extension.description,\n                                style: Theme.of(context).textTheme.bodyMedium,\n                              ),\n                            ],\n                          ),\n                        ),\n                      ],\n                    ),\n                    const SizedBox(height: 14),\n                    Wrap(\n                      spacing: 8,\n                      runSpacing: 8,\n                      children: [\n                        if (localInstalled == null)\n                          ElevatedButton.icon(\n                            onPressed: busy\n                                ? null\n                                : () async {\n                                    try {\n                                      await controller\n                                          .installFromStore(extension);\n                                      showMessage('tip'.tr,\n                                          'extensionInstallSuccess'.tr);\n                                    } catch (e) {\n                                      showErrorMessage(e);\n                                    }\n                                  },\n                            icon: busy\n                                ? const SizedBox(\n                                    width: 14,\n                                    height: 14,\n                                    child: CircularProgressIndicator(\n                                        strokeWidth: 2),\n                                  )\n                                : const Icon(Icons.download),\n                            label: Text('extensionInstall'.tr),\n                          ),\n                        if (localInstalled != null && canUpdate)\n                          ElevatedButton.icon(\n                            onPressed: busy\n                                ? null\n                                : () async {\n                                    try {\n                                      await controller\n                                          .upgradeExtension(localInstalled);\n                                      showMessage('tip'.tr,\n                                          'extensionUpdateSuccess'.tr);\n                                    } catch (e) {\n                                      showErrorMessage(e);\n                                    }\n                                  },\n                            icon: busy\n                                ? const SizedBox(\n                                    width: 14,\n                                    height: 14,\n                                    child: CircularProgressIndicator(\n                                        strokeWidth: 2),\n                                  )\n                                : const Icon(Icons.refresh_rounded),\n                            label: Text('newVersionUpdate'.tr),\n                          ),\n                        if ((extension.homepage ?? '').isNotEmpty)\n                          OutlinedButton.icon(\n                            onPressed: () =>\n                                launchUrl(Uri.parse(extension.homepage!)),\n                            icon: const Icon(Icons.home_outlined),\n                            label: Text('homepage'.tr),\n                          ),\n                        OutlinedButton.icon(\n                          onPressed: () =>\n                              launchUrl(Uri.parse(extension.repoUrl)),\n                          icon: const Icon(Icons.code),\n                          label: const Text('GitHub'),\n                        ),\n                      ],\n                    ),\n                    const SizedBox(height: 14),\n                    const Divider(height: 1),\n                    const SizedBox(height: 12),\n                    _buildReadme(context, localInstalled),\n                  ],\n                );\n              }),\n            ),\n          ],\n        ),\n      ),\n    );\n  }\n\n  Widget _buildReadme(BuildContext context, Extension? installed) {\n    return FutureBuilder<_ReadmeInfo>(\n      future: _loadReadme(installed),\n      builder: (context, snapshot) {\n        final info = snapshot.data;\n        final markdown = info?.content ?? extension.readme ?? '';\n        if (markdown.trim().isEmpty) {\n          return Text('No README',\n              style: Theme.of(context).textTheme.bodyMedium);\n        }\n\n        return MarkdownBody(\n          data: markdown,\n          selectable: true,\n          onTapLink: (text, href, title) {\n            if (href == null || href.isEmpty) return;\n            final resolved = _resolvePath(href, info, forImage: false);\n            if (resolved == null) return;\n            launchUrl(Uri.parse(resolved),\n                mode: LaunchMode.externalApplication);\n          },\n          imageBuilder: (uri, title, alt) {\n            final resolved = _resolvePath(uri.toString(), info, forImage: true);\n            if (resolved == null) return const SizedBox.shrink();\n            if (resolved.startsWith('file://')) {\n              return Padding(\n                padding: const EdgeInsets.symmetric(vertical: 8),\n                child: Image.file(\n                  File(Uri.parse(resolved).toFilePath()),\n                  fit: BoxFit.contain,\n                  errorBuilder: (_, __, ___) => const SizedBox.shrink(),\n                ),\n              );\n            }\n            return Padding(\n              padding: const EdgeInsets.symmetric(vertical: 8),\n              child: Image.network(\n                resolved,\n                fit: BoxFit.contain,\n                errorBuilder: (_, __, ___) => const SizedBox.shrink(),\n              ),\n            );\n          },\n        );\n      },\n    );\n  }\n\n  Future<_ReadmeInfo> _loadReadme(Extension? installed) async {\n    if (installed == null) {\n      return _ReadmeInfo(\n        content: extension.readme ?? '',\n        mode: _ReadmeMode.remote,\n        localReadmePath: null,\n      );\n    }\n\n    final rootDir = installed.devMode\n        ? installed.devPath\n        : path.join(Util.getStorageDir(), 'extensions', installed.identity);\n    final candidates = [\n      path.join(rootDir, 'README.md'),\n      path.join(rootDir, 'readme.md'),\n      path.join(rootDir, 'README.MD'),\n    ];\n    for (final filePath in candidates) {\n      final file = File(filePath);\n      if (await file.exists()) {\n        return _ReadmeInfo(\n          content: await file.readAsString(),\n          mode: _ReadmeMode.local,\n          localReadmePath: filePath,\n        );\n      }\n    }\n\n    return _ReadmeInfo(\n      content: extension.readme ?? '',\n      mode: _ReadmeMode.remote,\n      localReadmePath: null,\n    );\n  }\n\n  String? _resolvePath(String raw, _ReadmeInfo? info,\n      {required bool forImage}) {\n    final value = raw.trim();\n    if (value.isEmpty) return null;\n    final uri = Uri.tryParse(value);\n    if (uri != null && uri.hasScheme) {\n      return uri.toString();\n    }\n\n    if (info?.mode == _ReadmeMode.local &&\n        info?.localReadmePath != null &&\n        forImage) {\n      final readmeDir = path.dirname(info!.localReadmePath!);\n      final clean = value.split('#').first;\n      final absolute = path.normalize(path.join(readmeDir, clean));\n      return Uri.file(absolute).toString();\n    }\n\n    final ref =\n        extension.commitSha?.isNotEmpty == true ? extension.commitSha! : 'HEAD';\n    final dir = (extension.directory ?? '').trim();\n    final baseSegments = [\n      if (dir.isNotEmpty) ...dir.split('/').where((e) => e.isNotEmpty),\n      '',\n    ];\n\n    final base = forImage\n        ? Uri.https('raw.githubusercontent.com',\n            '/${extension.repoFullName}/$ref/${baseSegments.join('/')}')\n        : Uri.https('github.com',\n            '/${extension.repoFullName}/blob/$ref/${baseSegments.join('/')}');\n    return base.resolve(value).toString();\n  }\n\n  Widget _buildIcon() {\n    if ((extension.icon ?? '').isEmpty) {\n      return Image.asset('assets/extension/default_icon.png',\n          width: 56, height: 56);\n    }\n    return ClipRRect(\n      borderRadius: BorderRadius.circular(12),\n      child: Image.network(\n        extension.icon!,\n        width: 56,\n        height: 56,\n        fit: BoxFit.cover,\n        errorBuilder: (_, __, ___) {\n          return Image.asset('assets/extension/default_icon.png',\n              width: 56, height: 56);\n        },\n      ),\n    );\n  }\n}\n\nenum _ReadmeMode {\n  remote,\n  local,\n}\n\nclass _ReadmeInfo {\n  final String content;\n  final _ReadmeMode mode;\n  final String? localReadmePath;\n\n  _ReadmeInfo({\n    required this.content,\n    required this.mode,\n    required this.localReadmePath,\n  });\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/modules/extension/views/extension_view.dart",
    "content": "import 'package:file_picker/file_picker.dart';\nimport 'package:flutter/material.dart';\nimport 'package:flutter/services.dart';\nimport 'package:flutter_form_builder/flutter_form_builder.dart';\nimport 'package:form_builder_validators/form_builder_validators.dart';\nimport 'package:get/get.dart';\nimport 'package:rounded_loading_button_plus/rounded_loading_button.dart';\nimport 'package:url_launcher/url_launcher.dart';\n\nimport '../../../../api/api.dart';\nimport '../../../../api/model/extension.dart';\nimport '../../../../api/model/install_extension.dart';\nimport '../../../../api/model/store_extension.dart';\nimport '../../../../api/model/update_extension_settings.dart';\nimport '../../../../util/message.dart';\nimport '../../../../util/util.dart';\nimport '../../../views/icon_button_loading.dart';\nimport '../../../views/responsive_builder.dart';\nimport '../controllers/extension_controller.dart';\nimport 'extension_card.dart';\nimport 'extension_detail_view.dart';\n\nclass ExtensionView extends GetView<ExtensionController> {\n  ExtensionView({Key? key}) : super(key: key);\n\n  final _installUrlController = TextEditingController();\n  final _searchController = TextEditingController();\n  final _installBtnController = IconButtonLoadingController();\n\n  Future<void> _doInstall() async {\n    final url = _installUrlController.text.trim();\n    if (url.isEmpty) {\n      controller.tryOpenDevMode();\n      return;\n    }\n    if (controller.busyExtensionIds\n        .contains(ExtensionController.manualInstallBusyKey)) {\n      return;\n    }\n    _installBtnController.start();\n    try {\n      await controller.installFromUrl(url);\n      showMessage('tip'.tr, 'extensionInstallSuccess'.tr);\n    } catch (e) {\n      showErrorMessage(e);\n    } finally {\n      _installBtnController.stop();\n    }\n  }\n\n  Future<void> _installFromFolder() async {\n    if (controller.busyExtensionIds\n        .contains(ExtensionController.manualInstallBusyKey)) {\n      return;\n    }\n    final dir = await FilePicker.platform.getDirectoryPath();\n    if (dir == null) return;\n    try {\n      await controller.installFromUrl(dir, devInstall: true);\n      showMessage('tip'.tr, 'extensionInstallSuccess'.tr);\n    } catch (e) {\n      showErrorMessage(e);\n    }\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    final args = Get.rootDelegate.arguments();\n    if (args is InstallExtension && !controller.pendingInstallHandled) {\n      controller.pendingInstallHandled = true;\n      _installUrlController.text = args.url;\n      WidgetsBinding.instance.addPostFrameCallback((_) => _doInstall());\n    }\n\n    return Scaffold(\n      body: SafeArea(\n        child: Obx(\n          () => RefreshIndicator(\n            onRefresh: controller.loadInitialData,\n            child: ListView(\n              padding: EdgeInsets.symmetric(\n                horizontal: ResponsiveBuilder.isNarrow(context) ? 16 : 24,\n                vertical: 20,\n              ),\n              children: [\n                _buildMarketToolbar(context),\n                const SizedBox(height: 12),\n                _buildFilterBar(context),\n                if (controller.showInstallTools.value) ...[\n                  const SizedBox(height: 10),\n                  _buildInstallPanel(context),\n                ],\n                const SizedBox(height: 12),\n                _buildUnifiedGrid(context),\n                if (controller.listFilter.value == ExtensionListFilter.market &&\n                    controller.storePagination.value?.hasNext == true) ...[\n                  const SizedBox(height: 12),\n                  Align(\n                    alignment: Alignment.center,\n                    child: OutlinedButton.icon(\n                      onPressed: controller.loadingMoreStore.value\n                          ? null\n                          : controller.loadMoreStore,\n                      icon: controller.loadingMoreStore.value\n                          ? const SizedBox(\n                              width: 14,\n                              height: 14,\n                              child: CircularProgressIndicator(strokeWidth: 2),\n                            )\n                          : const Icon(Icons.expand_more),\n                      label: Text('extensionLoadMore'.tr),\n                    ),\n                  ),\n                ],\n                if (controller.listFilter.value == ExtensionListFilter.market &&\n                    controller.storePagination.value != null &&\n                    controller.storeExtensions.isNotEmpty &&\n                    controller.storePagination.value!.hasNext == false) ...[\n                  const SizedBox(height: 12),\n                  Center(\n                    child: Text(\n                      'extensionNoMore'.tr,\n                      style: Get.textTheme.bodySmall\n                          ?.copyWith(color: Get.theme.hintColor),\n                    ),\n                  ),\n                ],\n              ],\n            ),\n          ),\n        ),\n      ),\n    );\n  }\n\n  Widget _buildInstallPanel(BuildContext context) {\n    final isNarrow = ResponsiveBuilder.isNarrow(context);\n\n    return isNarrow\n        ? Column(\n            children: [\n              TextField(\n                controller: _installUrlController,\n                decoration: InputDecoration(\n                  isDense: true,\n                  labelText: 'extensionInstallUrl'.tr,\n                  hintText: 'https://github.com/author/repo',\n                  border: const OutlineInputBorder(),\n                ),\n              ),\n              const SizedBox(height: 8),\n              Row(\n                mainAxisAlignment: MainAxisAlignment.end,\n                children: [\n                  IconButtonLoading(\n                    controller: _installBtnController,\n                    onPressed: _doInstall,\n                    icon: const Icon(Icons.download),\n                  ),\n                  if (controller.devMode.value && Util.isDesktop()) ...[\n                    const SizedBox(width: 8),\n                    IconButton(\n                      tooltip: 'extensionLoadLocal'.tr,\n                      onPressed: _installFromFolder,\n                      icon: const Icon(Icons.folder_open),\n                    ),\n                  ]\n                ],\n              ),\n            ],\n          )\n        : Row(\n            children: [\n              Expanded(\n                child: TextField(\n                  controller: _installUrlController,\n                  decoration: InputDecoration(\n                    isDense: true,\n                    labelText: 'extensionInstallUrl'.tr,\n                    hintText: 'https://github.com/author/repo',\n                    border: const OutlineInputBorder(),\n                  ),\n                ),\n              ),\n              const SizedBox(width: 10),\n              IconButtonLoading(\n                controller: _installBtnController,\n                onPressed: _doInstall,\n                icon: const Icon(Icons.download),\n              ),\n              if (controller.devMode.value && Util.isDesktop()) ...[\n                const SizedBox(width: 8),\n                IconButton(\n                  tooltip: 'extensionLoadLocal'.tr,\n                  onPressed: _installFromFolder,\n                  icon: const Icon(Icons.folder_open),\n                ),\n              ],\n            ],\n          );\n  }\n\n  Widget _buildMarketToolbar(BuildContext context) {\n    final narrow = ResponsiveBuilder.isNarrow(context);\n    InputDecoration decoration() {\n      return const InputDecoration(\n        hintText: '搜索扩展...',\n        hintStyle: TextStyle(fontSize: 13),\n      );\n    }\n\n    return narrow\n        ? Column(\n            children: [\n              TextField(\n                controller: _searchController,\n                textInputAction: TextInputAction.search,\n                onSubmitted: controller.searchStore,\n                decoration: decoration().copyWith(\n                  suffixIcon: IconButton(\n                    onPressed: () =>\n                        controller.searchStore(_searchController.text),\n                    icon: const Icon(Icons.search_rounded),\n                  ),\n                ),\n              ),\n              const SizedBox(height: 8),\n              Row(\n                mainAxisAlignment: MainAxisAlignment.end,\n                children: [\n                  Flexible(child: _buildSortTabs(context)),\n                  const SizedBox(width: 6),\n                  IconButton(\n                    tooltip: 'update'.tr,\n                    onPressed: controller.refreshStore,\n                    icon: const Icon(Icons.refresh),\n                  ),\n                ],\n              ),\n            ],\n          )\n        : Column(\n            children: [\n              Row(\n                children: [\n                  Expanded(\n                    child: TextField(\n                      controller: _searchController,\n                      textInputAction: TextInputAction.search,\n                      onSubmitted: controller.searchStore,\n                      decoration: decoration().copyWith(\n                        suffixIcon: IconButton(\n                          onPressed: () =>\n                              controller.searchStore(_searchController.text),\n                          icon: const Icon(Icons.search_rounded),\n                        ),\n                      ),\n                    ),\n                  ),\n                  const SizedBox(width: 8),\n                  _buildSortTabs(context),\n                  IconButton(\n                    tooltip: 'update'.tr,\n                    onPressed: controller.refreshStore,\n                    icon: const Icon(Icons.refresh),\n                  ),\n                ],\n              ),\n            ],\n          );\n  }\n\n  Widget _buildSortTabs(BuildContext context) {\n    Widget tab(StoreExtensionSort sort, String label) {\n      final selected = controller.storeSort.value == sort;\n      return InkWell(\n        borderRadius: BorderRadius.circular(7),\n        onTap: () => controller.changeSort(sort),\n        child: AnimatedContainer(\n          duration: const Duration(milliseconds: 150),\n          padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 6),\n          decoration: BoxDecoration(\n            borderRadius: BorderRadius.circular(7),\n            color: selected\n                ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.12)\n                : Colors.transparent,\n          ),\n          child: Text(\n            label,\n            style: TextStyle(\n              fontWeight: selected ? FontWeight.w600 : FontWeight.w500,\n              fontSize: 12,\n              height: 1.0,\n              color: selected\n                  ? Theme.of(context).colorScheme.primary\n                  : Theme.of(context)\n                      .textTheme\n                      .bodyMedium\n                      ?.color\n                      ?.withValues(alpha: 0.82),\n            ),\n          ),\n        ),\n      );\n    }\n\n    return Container(\n      constraints: const BoxConstraints(maxWidth: 310),\n      padding: const EdgeInsets.all(2),\n      decoration: BoxDecoration(\n        color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.35),\n        border: Border.all(\n          color: Theme.of(context).dividerColor.withValues(alpha: 0.45),\n        ),\n        borderRadius: BorderRadius.circular(8),\n      ),\n      child: Row(\n        mainAxisSize: MainAxisSize.min,\n        children: [\n          tab(StoreExtensionSort.stars, 'extensionSortStars'.tr),\n          tab(StoreExtensionSort.installs, 'extensionSortInstalls'.tr),\n          tab(StoreExtensionSort.updated, 'extensionSortUpdated'.tr),\n        ],\n      ),\n    );\n  }\n\n  Widget _buildFilterBar(BuildContext context) {\n    final narrow = ResponsiveBuilder.isNarrow(context);\n    Widget option(ExtensionListFilter filter, String text) {\n      final selected = controller.listFilter.value == filter;\n      return InkWell(\n        onTap: () => controller.changeFilter(filter),\n        borderRadius: BorderRadius.circular(8),\n        child: Container(\n          padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),\n          decoration: BoxDecoration(\n            borderRadius: BorderRadius.circular(8),\n            color: selected\n                ? Get.theme.colorScheme.primary.withValues(alpha: 0.14)\n                : Colors.transparent,\n          ),\n          child: Text(\n            text,\n            style: TextStyle(\n              color: selected ? Get.theme.colorScheme.primary : null,\n              fontWeight: selected ? FontWeight.w600 : FontWeight.w500,\n            ),\n          ),\n        ),\n      );\n    }\n\n    final left = Row(\n      mainAxisSize: MainAxisSize.min,\n      children: [\n        option(ExtensionListFilter.market, 'extensionFilterMarket'.tr),\n        const SizedBox(width: 8),\n        option(ExtensionListFilter.installed, 'extensionFilterInstalled'.tr),\n      ],\n    );\n\n    final right = Row(\n      mainAxisSize: MainAxisSize.min,\n      children: [\n        TextButton.icon(\n          onPressed: controller.toggleInstallTools,\n          icon: const Icon(Icons.link),\n          label: Text('extensionManualInstall'.tr),\n        ),\n        const SizedBox(width: 8),\n        TextButton.icon(\n          onPressed: () =>\n              launchUrl(Uri.parse('https://gopeed.com/docs/dev-extension')),\n          icon: const Icon(Icons.menu_book_outlined),\n          label: Text('extensionDevelop'.tr),\n        ),\n      ],\n    );\n\n    if (narrow) {\n      return Wrap(\n        spacing: 8,\n        runSpacing: 6,\n        alignment: WrapAlignment.spaceBetween,\n        children: [left, right],\n      );\n    }\n\n    return Row(\n      children: [\n        left,\n        const Spacer(),\n        right,\n      ],\n    );\n  }\n\n  Widget _buildUnifiedGrid(BuildContext context) {\n    final items = controller.displayItems;\n    final loading =\n        controller.loadingInstalled.value || controller.loadingStore.value;\n    if (loading && items.isEmpty) {\n      return const Center(child: CircularProgressIndicator());\n    }\n    if (items.isEmpty) {\n      return Padding(\n        padding: const EdgeInsets.symmetric(vertical: 24),\n        child: Text('extensionStoreEmpty'.tr),\n      );\n    }\n\n    return LayoutBuilder(\n      builder: (context, constraints) {\n        final width = constraints.maxWidth;\n        final crossAxisCount = width >= 1320\n            ? 4\n            : width >= 980\n                ? 3\n                : width >= 700\n                    ? 2\n                    : 1;\n        const cardHeight = 180.0;\n        final childAspectRatio = (width / crossAxisCount) / cardHeight;\n\n        return GridView.builder(\n          itemCount: items.length,\n          shrinkWrap: true,\n          physics: const NeverScrollableScrollPhysics(),\n          gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(\n            crossAxisCount: crossAxisCount,\n            childAspectRatio: childAspectRatio,\n            crossAxisSpacing: 10,\n            mainAxisSpacing: 10,\n          ),\n          itemBuilder: (context, index) => _buildUnifiedCard(items[index]),\n        );\n      },\n    );\n  }\n\n  Widget _buildUnifiedCard(ExtensionListItem item) {\n    final installed = item.installed;\n    final store = item.store;\n    final canUpdate = controller.canUpdateItem(item);\n    final busy = controller.busyExtensionIds.contains(item.id);\n\n    return ExtensionCard(\n      item: item,\n      busy: busy,\n      canUpdate: canUpdate,\n      onTap: store != null ? () => _showExtensionDrawer(item) : null,\n      onToggle: installed == null\n          ? null\n          : (value) async {\n              try {\n                await controller.toggleExtension(installed, value);\n              } catch (e) {\n                showErrorMessage(e);\n              }\n            },\n      onOpenSetting: installed != null && installed.settings?.isNotEmpty == true\n          ? () => _showSettingDialog(installed)\n          : null,\n      onUpdate: installed != null && canUpdate\n          ? () async {\n              try {\n                await controller.upgradeExtension(installed);\n                showMessage('tip'.tr, 'extensionUpdateSuccess'.tr);\n              } catch (e) {\n                showErrorMessage(e);\n              }\n            }\n          : null,\n      onDelete: installed != null ? () => _showDeleteDialog(installed) : null,\n      onInstall: !item.isInstalled && store != null\n          ? () async {\n              try {\n                await controller.installFromStore(store);\n                showMessage('tip'.tr, 'extensionInstallSuccess'.tr);\n              } catch (e) {\n                showErrorMessage(e);\n              }\n            }\n          : null,\n    );\n  }\n\n  Future<void> _showExtensionDrawer(ExtensionListItem item) async {\n    final store = item.store;\n    if (store == null) return;\n    await showGeneralDialog(\n      context: Get.context!,\n      barrierDismissible: true,\n      barrierLabel: 'close',\n      barrierColor: Colors.black54,\n      transitionDuration: const Duration(milliseconds: 180),\n      pageBuilder: (context, animation, secondaryAnimation) {\n        return Align(\n          alignment: Alignment.centerRight,\n          child: Material(\n            child: SizedBox(\n              width: (MediaQuery.of(context).size.width * 0.5)\n                  .clamp(320.0, 1200.0),\n              child: ExtensionDetailDrawer(\n                extension: store,\n                installed: item.installed,\n                onClose: () => Navigator.of(context).pop(),\n              ),\n            ),\n          ),\n        );\n      },\n      transitionBuilder: (context, animation, secondaryAnimation, child) {\n        final offset = Tween<Offset>(\n          begin: const Offset(1, 0),\n          end: Offset.zero,\n        ).animate(animation);\n        return SlideTransition(position: offset, child: child);\n      },\n    );\n  }\n\n  Future<void> _showSettingDialog(Extension extension) async {\n    final formKey = GlobalKey<FormBuilderState>();\n    final confrimController = RoundedLoadingButtonController();\n\n    return showDialog<void>(\n      context: Get.context!,\n      barrierDismissible: false,\n      builder: (dialogContext) => AlertDialog(\n        content: Builder(builder: (context) {\n          final height = MediaQuery.of(context).size.height;\n          final width = MediaQuery.of(context).size.width;\n\n          return SizedBox(\n            height: height * 0.75,\n            width: width,\n            child: FormBuilder(\n              key: formKey,\n              child: Column(children: [\n                Text('setting'.tr),\n                Expanded(\n                  child: SingleChildScrollView(\n                    child: Column(\n                      children: extension.settings!.map((e) {\n                        final settingItem = _buildSettingItem(e);\n\n                        return Row(\n                          crossAxisAlignment: CrossAxisAlignment.end,\n                          children: [\n                            SizedBox(\n                              width: 20,\n                              child: e.description.isEmpty\n                                  ? null\n                                  : Tooltip(\n                                      message: e.description,\n                                      child: const CircleAvatar(\n                                        radius: 10,\n                                        backgroundColor: Colors.grey,\n                                        child:\n                                            Icon(Icons.question_mark, size: 10),\n                                      ),\n                                    ),\n                            ).paddingOnly(right: 10),\n                            Expanded(child: settingItem),\n                          ],\n                        );\n                      }).toList(),\n                    ),\n                  ),\n                ),\n              ]),\n            ),\n          );\n        }),\n        actions: [\n          ConstrainedBox(\n            constraints: BoxConstraints.tightFor(\n              width: Get.theme.buttonTheme.minWidth,\n              height: Get.theme.buttonTheme.height,\n            ),\n            child: ElevatedButton(\n              style: ElevatedButton.styleFrom(shape: const StadiumBorder())\n                  .copyWith(\n                backgroundColor:\n                    WidgetStateProperty.all(Get.theme.colorScheme.surface),\n              ),\n              onPressed: () => Navigator.of(dialogContext).pop(),\n              child: Text('cancel'.tr),\n            ),\n          ),\n          ConstrainedBox(\n            constraints: BoxConstraints.tightFor(\n              width: Get.theme.buttonTheme.minWidth,\n              height: Get.theme.buttonTheme.height,\n            ),\n            child: RoundedLoadingButton(\n              color: Get.theme.colorScheme.secondary,\n              onPressed: () async {\n                try {\n                  confrimController.start();\n                  if (formKey.currentState?.saveAndValidate() == true) {\n                    await updateExtensionSettings(\n                      extension.identity,\n                      UpdateExtensionSettings(\n                          settings: formKey.currentState!.value),\n                    );\n                    await controller.loadInstalled(refreshUpdates: false);\n                    if (dialogContext.mounted) {\n                      Navigator.of(dialogContext).pop();\n                    }\n                  }\n                } catch (e) {\n                  showErrorMessage(e);\n                } finally {\n                  confrimController.reset();\n                }\n              },\n              controller: confrimController,\n              child: Text('confirm'.tr),\n            ),\n          ),\n        ],\n      ),\n    );\n  }\n\n  Widget _buildSettingItem(Setting setting) {\n    final requiredValidator =\n        setting.required ? FormBuilderValidators.required() : null;\n\n    Widget buildTextField(TextInputFormatter? inputFormatter,\n        FormFieldValidator<String>? validator, TextInputType? keyBoardType) {\n      return FormBuilderTextField(\n        name: setting.name,\n        decoration: InputDecoration(labelText: setting.title),\n        initialValue: setting.value?.toString(),\n        inputFormatters: inputFormatter != null ? [inputFormatter] : null,\n        keyboardType: keyBoardType,\n        validator: FormBuilderValidators.compose([\n          requiredValidator,\n          validator,\n        ].where((e) => e != null).map((e) => e!).toList()),\n      );\n    }\n\n    Widget buildDropdown() {\n      return FormBuilderDropdown<String>(\n        name: setting.name,\n        decoration: InputDecoration(labelText: setting.title),\n        initialValue: setting.value?.toString(),\n        validator: FormBuilderValidators.compose([\n          requiredValidator,\n        ].where((e) => e != null).map((e) => e!).toList()),\n        items: setting.options!\n            .map((e) => DropdownMenuItem(\n                  value: e.value.toString(),\n                  child: Text(e.label),\n                ))\n            .toList(),\n      );\n    }\n\n    switch (setting.type) {\n      case SettingType.string:\n        return setting.options?.isNotEmpty == true\n            ? buildDropdown()\n            : buildTextField(null, null, null);\n      case SettingType.number:\n        return setting.options?.isNotEmpty == true\n            ? buildDropdown()\n            : buildTextField(\n                FilteringTextInputFormatter.allow(RegExp(r'^\\d+\\.?\\d*')),\n                FormBuilderValidators.numeric(),\n                const TextInputType.numberWithOptions(decimal: true),\n              );\n      case SettingType.boolean:\n        return FormBuilderSwitch(\n          name: setting.name,\n          initialValue: (setting.value as bool?) ?? false,\n          title: Text(setting.title),\n          validator: requiredValidator,\n        );\n    }\n  }\n\n  void _showDeleteDialog(Extension extension) {\n    showDialog(\n      context: Get.context!,\n      barrierDismissible: false,\n      builder: (dialogContext) => AlertDialog(\n        title: Text('extensionDelete'.tr),\n        actions: [\n          TextButton(\n            child: Text('cancel'.tr),\n            onPressed: () => Navigator.of(dialogContext).pop(),\n          ),\n          TextButton(\n            child: Text(\n              'confirm'.tr,\n              style: const TextStyle(color: Colors.redAccent),\n            ),\n            onPressed: () async {\n              try {\n                await controller.removeExtension(extension);\n                if (dialogContext.mounted) {\n                  Navigator.of(dialogContext).pop();\n                }\n              } catch (e) {\n                showErrorMessage(e);\n              }\n            },\n          ),\n        ],\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/modules/history/views/history_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:get/get.dart';\nimport 'package:gopeed/database/database.dart';\n\nclass HistoryView extends StatefulWidget {\n  const HistoryView({\n    super.key,\n    required this.isHistoryListEmpty,\n    required this.historyList,\n  });\n\n  final bool isHistoryListEmpty;\n  final Widget historyList;\n\n  @override\n  State<HistoryView> createState() => _HistoryViewState();\n}\n\nclass _HistoryViewState extends State<HistoryView> {\n  @override\n  Widget build(BuildContext context) {\n    final Size size = MediaQuery.sizeOf(context);\n    return Scaffold(\n      backgroundColor: Colors.transparent,\n      body: Dialog(\n        shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),\n        elevation: 0,\n        backgroundColor: Colors.transparent,\n        child: Container(\n          width: size.width * 0.8,\n          height: size.height * 0.8,\n          decoration: BoxDecoration(\n            color: Theme.of(context).colorScheme.surface,\n            borderRadius: BorderRadius.circular(10.0),\n          ),\n          child: Column(\n            children: <Widget>[\n              Padding(\n                padding: const EdgeInsets.all(8.0),\n                child: Row(\n                  mainAxisAlignment: MainAxisAlignment.spaceBetween,\n                  crossAxisAlignment: CrossAxisAlignment.center,\n                  children: <Widget>[\n                    Row(\n                      mainAxisAlignment: MainAxisAlignment.start,\n                      children: <Widget>[\n                        Icon(\n                          Icons.history_rounded,\n                          size: Theme.of(context)\n                              .textTheme\n                              .headlineSmall\n                              ?.fontSize,\n                        ),\n                        const SizedBox(width: 8.0),\n                        Text(\n                          'history'.tr,\n                          style: Theme.of(context).textTheme.headlineSmall,\n                        ),\n                      ],\n                    ),\n                    Row(\n                      children: <Widget>[\n                        IconButton(\n                          onPressed: () {\n                            Database.instance.clearCreateHistory();\n                            Navigator.pop(context);\n                          },\n                          tooltip: \"clearHistory\".tr,\n                          icon: const Icon(\n                            Icons.history_toggle_off_rounded,\n                          ),\n                        ),\n                        IconButton(\n                          onPressed: () => Navigator.pop(context),\n                          icon: const Icon(\n                            Icons.close_rounded,\n                          ),\n                        ),\n                      ],\n                    ),\n                  ],\n                ),\n              ),\n              Expanded(\n                child: Center(\n                  child: Column(\n                    mainAxisAlignment: MainAxisAlignment.center,\n                    children: widget.isHistoryListEmpty\n                        ? <Widget>[\n                            const Icon(\n                              Icons.manage_history_rounded,\n                            ),\n                            const SizedBox(height: 10.0),\n                            Text(\n                              'noHistoryFound'.tr,\n                            ),\n                          ]\n                        : <Widget>[\n                            Expanded(child: widget.historyList),\n                          ],\n                  ),\n                ),\n              ),\n            ],\n          ),\n        ),\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/modules/home/bindings/home_binding.dart",
    "content": "import 'package:get/get.dart';\n\nimport '../controllers/home_controller.dart';\n\nclass HomeBinding extends Bindings {\n  @override\n  void dependencies() {\n    Get.lazyPut<HomeController>(\n      () => HomeController(),\n      fenix: true,\n    );\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/modules/home/controllers/home_controller.dart",
    "content": "import 'package:get/get.dart';\n\nclass HomeController extends GetxController {\n  var currentIndex = 0.obs;\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/modules/home/views/home_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:get/get.dart';\n\nimport '../../../routes/app_pages.dart';\nimport '../../../views/responsive_builder.dart';\nimport '../controllers/home_controller.dart';\n\nclass HomeView extends GetView<HomeController> {\n  const HomeView({Key? key}) : super(key: key);\n\n  @override\n  Widget build(BuildContext context) {\n    return GetRouterOutlet.builder(builder: (context, delegate, currentRoute) {\n      switch (currentRoute?.uri.path) {\n        case Routes.EXTENSION:\n          controller.currentIndex.value = 1;\n          break;\n        case Routes.SETTING:\n          controller.currentIndex.value = 2;\n          break;\n        default:\n          controller.currentIndex.value = 0;\n          break;\n      }\n\n      return Scaffold(\n        // extendBody: true,\n        body: Row(\n            // crossAxisAlignment: CrossAxisAlignment.start,\n            children: [\n              !ResponsiveBuilder.isNarrow(context)\n                  ? NavigationRail(\n                      extended: true,\n                      labelType: NavigationRailLabelType.none,\n                      minExtendedWidth: 170,\n                      groupAlignment: 0,\n                      // useIndicator: false,\n                      onDestinationSelected: (int index) {\n                        controller.currentIndex.value = index;\n                        switch (index) {\n                          case 0:\n                            delegate.offAndToNamed(Routes.TASK);\n                            break;\n                          case 1:\n                            delegate.offAndToNamed(Routes.EXTENSION);\n                            break;\n                          case 2:\n                            delegate.offAndToNamed(Routes.SETTING);\n                            break;\n                        }\n                      },\n                      destinations: [\n                        NavigationRailDestination(\n                          icon: const Icon(Icons.task),\n                          selectedIcon: const Icon(Icons.task),\n                          label: Text('task'.tr),\n                        ),\n                        NavigationRailDestination(\n                          icon: const Icon(Icons.extension),\n                          selectedIcon: const Icon(Icons.extension),\n                          label: Text('extensions'.tr),\n                        ),\n                        NavigationRailDestination(\n                          icon: const Icon(Icons.settings),\n                          selectedIcon: const Icon(Icons.settings),\n                          label: Text('setting'.tr),\n                        ),\n                      ],\n                      selectedIndex: controller.currentIndex.value,\n                      leading: const Icon(Icons.menu),\n                      // trailing: const Icon(Icons.info_outline),\n                    )\n                  : const SizedBox.shrink(),\n              Expanded(\n                  child: GetRouterOutlet(\n                initialRoute: Routes.TASK,\n                // anchorRoute: '/',\n                // filterPages: (afterAnchor) {\n                //   logger.w(afterAnchor);\n                //   logger.w(afterAnchor.take(1));\n                //   return afterAnchor.take(1);\n                // },\n              ))\n            ]),\n        bottomNavigationBar: ResponsiveBuilder.isNarrow(context)\n            ? BottomNavigationBar(\n                items: <BottomNavigationBarItem>[\n                  BottomNavigationBarItem(\n                    icon: const Icon(Icons.task),\n                    label: 'task'.tr,\n                  ),\n                  BottomNavigationBarItem(\n                    icon: const Icon(Icons.extension),\n                    label: 'extensions'.tr,\n                  ),\n                  BottomNavigationBarItem(\n                    icon: const Icon(Icons.settings),\n                    label: 'setting'.tr,\n                  ),\n                ],\n                currentIndex: controller.currentIndex.value,\n                // selectedItemColor: Get.theme.highlightColor,\n                onTap: (index) {\n                  controller.currentIndex.value = index;\n                  switch (index) {\n                    case 0:\n                      delegate.offAndToNamed(Routes.TASK);\n                      break;\n                    case 1:\n                      delegate.offAndToNamed(Routes.EXTENSION);\n                      break;\n                    case 2:\n                      delegate.offAndToNamed(Routes.SETTING);\n                      break;\n                  }\n                },\n              )\n            // StylishBottomBar(\n            //         option: AnimatedBarOptions(\n            //           iconSize: 32,\n            //           barAnimation: BarAnimation.blink,\n            //           iconStyle: IconStyle.Default,\n            //           opacity: 0.3,\n            //         ),\n            //         items: [\n            //           BottomBarItem(\n            //               icon: const Icon(Icons.file_download),\n            //               selectedColor: Get.theme.primaryColor,\n            //               title: Text('downloading'.tr)),\n            //           BottomBarItem(\n            //               icon: const Icon(Icons.done),\n            //               selectedColor: Get.theme.primaryColor,\n            //               title: Text('downloaded'.tr)),\n            //           BottomBarItem(\n            //               icon: const Icon(Icons.settings),\n            //               selectedColor: Get.theme.primaryColor,\n            //               title: Text('setting'.tr)),\n            //         ],\n            //         // hasNotch: true,\n            //         currentIndex: controller.currentIndex.value,\n            //         onTap: (index) {\n            //           switch (index) {\n            //             case 0:\n            //               delegate.toNamed(Routes.DOWNLOADING);\n            //               controller.currentIndex.value = 0;\n            //               break;\n            //             case 1:\n            //               delegate.toNamed(Routes.DOWNLOADED);\n            //               controller.currentIndex.value = 1;\n            //               break;\n            //             case 2:\n            //               delegate.toNamed(Routes.SETTING);\n            //               controller.currentIndex.value = 2;\n            //               break;\n            //           }\n            //         },\n            //       )\n            : const SizedBox.shrink(),\n      );\n    });\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/modules/login/bindings/login_binding.dart",
    "content": "import 'package:get/get.dart';\n\nimport '../controllers/login_controller.dart';\n\nclass LoginBinding extends Bindings {\n  @override\n  void dependencies() {\n    Get.lazyPut<LoginController>(\n      () => LoginController(),\n    );\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/modules/login/controllers/login_controller.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:get/get.dart';\n\nimport '../../../../api/api.dart' as api;\nimport '../../../../api/api.dart';\nimport '../../../../api/model/login.dart';\nimport '../../../../database/database.dart';\nimport '../../../../util/message.dart';\nimport '../../../routes/app_pages.dart';\nimport '../../app/controllers/app_controller.dart';\n\nclass LoginController extends GetxController {\n  final formKey = GlobalKey<FormState>();\n  final usernameController = TextEditingController();\n  final passwordController = TextEditingController();\n\n  final isLoading = false.obs;\n  final passwordVisible = false.obs;\n\n  @override\n  void onClose() {\n    usernameController.dispose();\n    passwordController.dispose();\n    super.onClose();\n  }\n\n  void togglePasswordVisibility() {\n    passwordVisible.value = !passwordVisible.value;\n  }\n\n  Future<void> login() async {\n    if (!formKey.currentState!.validate()) {\n      return;\n    }\n\n    isLoading.value = true;\n    try {\n      final loginReq = LoginReq(\n        username: usernameController.text.trim(),\n        password: passwordController.text,\n      );\n\n      final token = await api.login(loginReq);\n      // Login successful, save the token\n      Database.instance.saveWebToken(token);\n      // Reload config\n      final controller = Get.put(AppController());\n      await controller.loadDownloaderConfig();\n      // Navigate to home page\n      Get.rootDelegate.offAndToNamed(Routes.HOME);\n    } catch (e) {\n      if (e is TimeoutException) {\n        showMessage('error'.tr, 'login_failed_network'.tr);\n      } else {\n        showMessage('error'.tr, 'login_failed'.tr);\n      }\n    } finally {\n      isLoading.value = false;\n    }\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/modules/login/views/login_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_svg/flutter_svg.dart';\nimport 'package:get/get.dart';\n\nimport '../../../views/responsive_builder.dart';\nimport '../controllers/login_controller.dart';\n\nclass LoginView extends GetView<LoginController> {\n  const LoginView({Key? key}) : super(key: key);\n\n  @override\n  Widget build(BuildContext context) {\n    final isNarrow = ResponsiveBuilder.isNarrow(context);\n\n    return Scaffold(\n      body: Container(\n        decoration: BoxDecoration(\n          gradient: LinearGradient(\n            begin: Alignment.topLeft,\n            end: Alignment.bottomRight,\n            colors: [\n              Get.theme.colorScheme.surface,\n              Get.theme.colorScheme.surface.withOpacity(0.8),\n              Get.theme.colorScheme.surface.withOpacity(0.9),\n            ],\n          ),\n        ),\n        child: Center(\n          child: SingleChildScrollView(\n            padding: EdgeInsets.symmetric(\n              horizontal: isNarrow ? 16.0 : 32.0,\n              vertical: isNarrow ? 24.0 : 32.0,\n            ),\n            child: Card(\n              elevation: 12,\n              shadowColor: Get.theme.colorScheme.shadow.withOpacity(0.3),\n              shape: RoundedRectangleBorder(\n                borderRadius: BorderRadius.circular(isNarrow ? 20 : 24),\n              ),\n              color: Get.theme.colorScheme.surface,\n              child: Container(\n                constraints: BoxConstraints(\n                  maxWidth: isNarrow ? double.infinity : 420,\n                ),\n                padding: EdgeInsets.all(isNarrow ? 24.0 : 40.0),\n                decoration: BoxDecoration(\n                  borderRadius: BorderRadius.circular(isNarrow ? 20 : 24),\n                  border: Border.all(\n                    color: Get.theme.colorScheme.outline.withOpacity(0.1),\n                    width: 1,\n                  ),\n                ),\n                child: FocusTraversalGroup(\n                  policy: OrderedTraversalPolicy(),\n                  child: Form(\n                    key: controller.formKey,\n                    child: Column(\n                      mainAxisSize: MainAxisSize.min,\n                      crossAxisAlignment: CrossAxisAlignment.stretch,\n                      children: [\n                        // Logo/Title Section\n                        Container(\n                          alignment: Alignment.center,\n                          margin: EdgeInsets.only(bottom: isNarrow ? 32 : 48),\n                          child: Column(\n                            children: [\n                              Container(\n                                padding: EdgeInsets.all(isNarrow ? 8 : 12),\n                                decoration: BoxDecoration(\n                                  borderRadius:\n                                      BorderRadius.circular(isNarrow ? 20 : 24),\n                                  boxShadow: [\n                                    BoxShadow(\n                                      color: Get.theme.colorScheme.primary\n                                          .withOpacity(0.2),\n                                      blurRadius: isNarrow ? 16 : 24,\n                                      offset: Offset(0, isNarrow ? 8 : 12),\n                                      spreadRadius: isNarrow ? 1 : 2,\n                                    ),\n                                  ],\n                                ),\n                                child: ClipRRect(\n                                  borderRadius:\n                                      BorderRadius.circular(isNarrow ? 16 : 20),\n                                  child: SvgPicture.asset(\n                                    'assets/icon/icon.svg',\n                                    width: isNarrow ? 56 : 72,\n                                    height: isNarrow ? 56 : 72,\n                                    fit: BoxFit.cover,\n                                  ),\n                                ),\n                              ),\n                              const SizedBox(height: 8),\n                              Text(\n                                'Gopeed',\n                                style: Get.textTheme.headlineLarge?.copyWith(\n                                  fontWeight: FontWeight.w900,\n                                  color: Get.theme.colorScheme.onSurface,\n                                  letterSpacing: 2.0,\n                                  fontSize: isNarrow ? 28 : 36,\n                                ),\n                              ),\n                            ],\n                          ),\n                        ),\n\n                        // Username Field\n                        FocusTraversalOrder(\n                          order: const NumericFocusOrder(1.0),\n                          child: TextFormField(\n                            controller: controller.usernameController,\n                            autofillHints: const [\n                              AutofillHints.username,\n                            ],\n                            autofocus: true,\n                            textInputAction: TextInputAction.done,\n                            onFieldSubmitted: (_) => controller.login(),\n                            decoration: InputDecoration(\n                              labelText: 'username'.tr,\n                              labelStyle: TextStyle(\n                                color: Get.theme.colorScheme.onSurface\n                                    .withOpacity(0.7),\n                                fontWeight: FontWeight.w500,\n                                fontSize: 16,\n                              ),\n                              prefixIcon: Container(\n                                margin: const EdgeInsets.all(12),\n                                padding: const EdgeInsets.all(8),\n                                decoration: BoxDecoration(\n                                  color: Get.theme.colorScheme.primary\n                                      .withOpacity(0.1),\n                                  borderRadius: BorderRadius.circular(12),\n                                ),\n                                child: Icon(\n                                  Icons.person_outline_rounded,\n                                  color: Get.theme.colorScheme.primary,\n                                  size: 20,\n                                ),\n                              ),\n                              border: OutlineInputBorder(\n                                borderRadius: BorderRadius.circular(18),\n                                borderSide: BorderSide(\n                                  color: Get.theme.colorScheme.outline\n                                      .withOpacity(0.2),\n                                  width: 1,\n                                ),\n                              ),\n                              enabledBorder: OutlineInputBorder(\n                                borderRadius: BorderRadius.circular(18),\n                                borderSide: BorderSide(\n                                  color: Get.theme.colorScheme.outline\n                                      .withOpacity(0.3),\n                                  width: 1.5,\n                                ),\n                              ),\n                              focusedBorder: OutlineInputBorder(\n                                borderRadius: BorderRadius.circular(18),\n                                borderSide: BorderSide(\n                                  color: Get.theme.colorScheme.primary,\n                                  width: 2.5,\n                                ),\n                              ),\n                              errorBorder: OutlineInputBorder(\n                                borderRadius: BorderRadius.circular(18),\n                                borderSide: BorderSide(\n                                  color: Get.theme.colorScheme.error,\n                                  width: 2,\n                                ),\n                              ),\n                              focusedErrorBorder: OutlineInputBorder(\n                                borderRadius: BorderRadius.circular(18),\n                                borderSide: BorderSide(\n                                  color: Get.theme.colorScheme.error,\n                                  width: 2.5,\n                                ),\n                              ),\n                              filled: true,\n                              fillColor: Get\n                                  .theme.colorScheme.surfaceContainerHighest\n                                  .withOpacity(0.8),\n                              contentPadding: const EdgeInsets.symmetric(\n                                horizontal: 24,\n                                vertical: 20,\n                              ),\n                            ),\n                            validator: (value) {\n                              if (value == null || value.trim().isEmpty) {\n                                return 'username_required'.tr;\n                              }\n                              return null;\n                            },\n                          ),\n                        ),\n\n                        SizedBox(height: isNarrow ? 16 : 24),\n\n                        // Password Field\n                        FocusTraversalOrder(\n                          order: const NumericFocusOrder(2.0),\n                          child: Obx(() => TextFormField(\n                                controller: controller.passwordController,\n                                autofillHints: const [\n                                  AutofillHints.password,\n                                ],\n                                obscureText: !controller.passwordVisible.value,\n                                decoration: InputDecoration(\n                                  labelText: 'password'.tr,\n                                  labelStyle: TextStyle(\n                                    color: Get.theme.colorScheme.onSurface\n                                        .withOpacity(0.7),\n                                    fontWeight: FontWeight.w500,\n                                    fontSize: 16,\n                                  ),\n                                  prefixIcon: Container(\n                                    margin: const EdgeInsets.all(12),\n                                    padding: const EdgeInsets.all(8),\n                                    decoration: BoxDecoration(\n                                      color: Get.theme.colorScheme.primary\n                                          .withOpacity(0.1),\n                                      borderRadius: BorderRadius.circular(12),\n                                    ),\n                                    child: Icon(\n                                      Icons.lock_outline_rounded,\n                                      color: Get.theme.colorScheme.primary,\n                                      size: 20,\n                                    ),\n                                  ),\n                                  suffixIcon: IconButton(\n                                    icon: Icon(\n                                      controller.passwordVisible.value\n                                          ? Icons.visibility_off_rounded\n                                          : Icons.visibility_rounded,\n                                      color: Get.theme.colorScheme.onSurface\n                                          .withOpacity(0.6),\n                                    ),\n                                    onPressed:\n                                        controller.togglePasswordVisibility,\n                                  ),\n                                  border: OutlineInputBorder(\n                                    borderRadius: BorderRadius.circular(18),\n                                    borderSide: BorderSide(\n                                      color: Get.theme.colorScheme.outline\n                                          .withOpacity(0.2),\n                                      width: 1,\n                                    ),\n                                  ),\n                                  enabledBorder: OutlineInputBorder(\n                                    borderRadius: BorderRadius.circular(18),\n                                    borderSide: BorderSide(\n                                      color: Get.theme.colorScheme.outline\n                                          .withOpacity(0.3),\n                                      width: 1.5,\n                                    ),\n                                  ),\n                                  focusedBorder: OutlineInputBorder(\n                                    borderRadius: BorderRadius.circular(18),\n                                    borderSide: BorderSide(\n                                      color: Get.theme.colorScheme.primary,\n                                      width: 2.5,\n                                    ),\n                                  ),\n                                  errorBorder: OutlineInputBorder(\n                                    borderRadius: BorderRadius.circular(18),\n                                    borderSide: BorderSide(\n                                      color: Get.theme.colorScheme.error,\n                                      width: 2,\n                                    ),\n                                  ),\n                                  focusedErrorBorder: OutlineInputBorder(\n                                    borderRadius: BorderRadius.circular(18),\n                                    borderSide: BorderSide(\n                                      color: Get.theme.colorScheme.error,\n                                      width: 2.5,\n                                    ),\n                                  ),\n                                  filled: true,\n                                  fillColor: Get\n                                      .theme.colorScheme.surfaceContainerHighest\n                                      .withOpacity(0.8),\n                                  contentPadding: const EdgeInsets.symmetric(\n                                    horizontal: 24,\n                                    vertical: 20,\n                                  ),\n                                ),\n                                validator: (value) {\n                                  if (value == null || value.isEmpty) {\n                                    return 'password_required'.tr;\n                                  }\n                                  return null;\n                                },\n                                textInputAction: TextInputAction.done,\n                                onFieldSubmitted: (_) => controller.login(),\n                              )),\n                        ),\n\n                        SizedBox(height: isNarrow ? 24 : 32),\n\n                        // Login Button\n                        Obx(() => ElevatedButton(\n                              onPressed: controller.isLoading.value\n                                  ? null\n                                  : controller.login,\n                              style: ElevatedButton.styleFrom(\n                                padding: EdgeInsets.symmetric(\n                                  vertical: isNarrow ? 16 : 18,\n                                ),\n                                shape: RoundedRectangleBorder(\n                                  borderRadius: BorderRadius.circular(16),\n                                ),\n                                backgroundColor: controller.isLoading.value\n                                    ? Get.theme.disabledColor\n                                    : Get.theme.colorScheme.primary,\n                                foregroundColor:\n                                    Get.theme.colorScheme.onPrimary,\n                                elevation: controller.isLoading.value ? 0 : 4,\n                                shadowColor: Get.theme.colorScheme.primary\n                                    .withOpacity(0.4),\n                              ),\n                              child: controller.isLoading.value\n                                  ? SizedBox(\n                                      height: 24,\n                                      width: 24,\n                                      child: CircularProgressIndicator(\n                                        strokeWidth: 2.5,\n                                        valueColor:\n                                            AlwaysStoppedAnimation<Color>(Get\n                                                .theme.colorScheme.onPrimary),\n                                      ),\n                                    )\n                                  : Text(\n                                      'login'.tr,\n                                      style: const TextStyle(\n                                        fontSize: 18,\n                                        fontWeight: FontWeight.w600,\n                                        letterSpacing: 0.5,\n                                      ),\n                                    ),\n                            )),\n                      ],\n                    ),\n                  ),\n                ),\n              ),\n            ),\n          ),\n        ),\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/modules/redirect/bindings/redirect_binding.dart",
    "content": "import 'package:get/get.dart';\n\nimport '../controllers/redirect_controller.dart';\n\nclass RedirectBinding extends Bindings {\n  @override\n  void dependencies() {\n    Get.lazyPut<RedirectController>(\n      () => RedirectController(),\n    );\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/modules/redirect/controllers/redirect_controller.dart",
    "content": "import 'package:get/get.dart';\n\nclass RedirectController extends GetxController {}\n"
  },
  {
    "path": "ui/flutter/lib/app/modules/redirect/views/redirect_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:get/get.dart';\n\nimport '../controllers/redirect_controller.dart';\n\nclass RedirectArgs {\n  final String page;\n  final dynamic arguments;\n\n  RedirectArgs(this.page, {this.arguments});\n}\n\nclass RedirectView extends GetView<RedirectController> {\n  const RedirectView({Key? key}) : super(key: key);\n\n  @override\n  Widget build(BuildContext context) {\n    final redirectArgs = Get.rootDelegate.arguments() as RedirectArgs;\n    // Waiting for previous page controller to delete, avoid deleting controller that route page after redirect\n    WidgetsBinding.instance.addPostFrameCallback((_) async {\n      await Future.delayed(const Duration(milliseconds: 350));\n      Get.rootDelegate\n          .offAndToNamed(redirectArgs.page, arguments: redirectArgs.arguments);\n    });\n    return const SizedBox();\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/modules/root/bindings/root_binding.dart",
    "content": "import 'package:get/get.dart';\n\nimport '../controllers/root_controller.dart';\n\nclass RootBinding extends Bindings {\n  @override\n  void dependencies() {\n    Get.lazyPut<RootController>(() => RootController(), fenix: true);\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/modules/root/controllers/root_controller.dart",
    "content": "import 'package:get/get.dart';\n\nclass RootController extends GetxController {}\n"
  },
  {
    "path": "ui/flutter/lib/app/modules/root/views/root_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:get/get.dart';\n\nimport '../../../routes/app_pages.dart';\nimport '../controllers/root_controller.dart';\n\nclass RootView extends GetView<RootController> {\n  const RootView({Key? key}) : super(key: key);\n\n  @override\n  Widget build(BuildContext context) {\n    return GetRouterOutlet.builder(\n      builder: (context, delegate, current) {\n        return GetRouterOutlet(\n          initialRoute: Routes.HOME,\n          // anchorRoute: '/',\n          // filterPages: (afterAnchor) {\n          //   return afterAnchor.take(1);\n          // },\n        );\n      },\n    );\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/modules/setting/bindings/setting_binding.dart",
    "content": "import 'package:get/get.dart';\n\nimport '../controllers/setting_controller.dart';\n\nclass SettingBinding extends Bindings {\n  @override\n  void dependencies() {\n    Get.lazyPut<SettingController>(\n      () => SettingController(),\n    );\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/modules/setting/controllers/setting_controller.dart",
    "content": "import 'package:get/get.dart';\n\nimport '../../../../util/updater.dart';\n\nclass SettingController extends GetxController {\n  final tapStatues = <String, bool>{}.obs;\n  final latestVersion = Rxn<VersionInfo>();\n\n  @override\n  void onInit() {\n    super.onInit();\n    fetchLatestVersion();\n  }\n\n  // set all tap status to false\n  void clearTap() {\n    tapStatues.updateAll((key, value) => false);\n  }\n\n  // set one tap status to true\n  void onTap(String key) {\n    clearTap();\n    tapStatues[key] = true;\n  }\n\n  // fetch latest version\n  void fetchLatestVersion() async {\n    latestVersion.value = await checkUpdate();\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/modules/setting/views/setting_view.dart",
    "content": "import 'dart:async';\nimport 'dart:io';\n\nimport 'package:badges/badges.dart' as badges;\nimport 'package:file_picker/file_picker.dart';\nimport 'package:flutter/material.dart';\nimport 'package:flutter/services.dart';\nimport 'package:get/get.dart';\nimport 'package:gopeed/app/views/copy_button.dart';\nimport 'package:intl/intl.dart';\nimport 'package:launch_at_startup/launch_at_startup.dart';\nimport 'package:url_launcher/url_launcher.dart';\nimport 'package:window_manager/window_manager.dart';\n\nimport '../../../../api/api.dart' as api;\nimport '../../../../api/model/downloader_config.dart';\nimport '../../../../database/database.dart';\nimport '../../../../util/analytics.dart';\nimport '../../../../i18n/message.dart';\nimport '../../../../util/input_formatter.dart';\nimport '../../../../util/locale_manager.dart';\nimport '../../../../util/log_util.dart';\nimport '../../../../util/message.dart';\nimport '../../../../util/package_info.dart';\nimport '../../../../util/scheme_register/scheme_register.dart';\nimport '../../../../util/updater.dart';\nimport '../../../../util/util.dart';\nimport '../../../views/check_list_view.dart';\nimport '../../../views/directory_selector.dart';\nimport '../../../views/open_in_new.dart';\nimport '../../../views/outlined_button_loading.dart';\nimport '../../../views/text_button_loading.dart';\nimport '../../app/controllers/app_controller.dart';\nimport '../controllers/setting_controller.dart';\n\nconst _padding = SizedBox(height: 10);\nfinal _divider = const Divider().paddingOnly(left: 10, right: 10);\n\nclass SettingView extends GetView<SettingController> {\n  const SettingView({Key? key}) : super(key: key);\n\n  // Helper function to get display name for a category\n  static String _getCategoryDisplayName(DownloadCategory category) {\n    if (category.nameKey != null && category.nameKey!.isNotEmpty) {\n      return category.nameKey!.tr;\n    }\n    return category.name;\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    final appController = Get.find<AppController>();\n    final downloaderCfg = appController.downloaderConfig;\n    final startCfg = appController.startConfig;\n\n    Timer? timer;\n    Future<bool> debounceSave(\n        {Future<String> Function()? check, bool needRestart = false}) {\n      var completer = Completer<bool>();\n      timer?.cancel();\n      timer = Timer(const Duration(milliseconds: 1000), () async {\n        if (check != null) {\n          final checkResult = await check();\n          if (checkResult.isNotEmpty) {\n            showErrorMessage(checkResult);\n            completer.complete(false);\n            return;\n          }\n        }\n        appController\n            .saveConfig()\n            .then((_) => completer.complete(true))\n            .onError(completer.completeError);\n        if (needRestart) {\n          showMessage('tip'.tr, 'effectAfterRestart'.tr);\n        }\n      });\n      return completer.future;\n    }\n\n    // download basic config items start\n    final buildDownloadDir = _buildConfigItem(\n        'downloadDir', () => downloaderCfg.value.downloadDir, (Key key) {\n      final downloadDirController =\n          TextEditingController(text: downloaderCfg.value.downloadDir);\n\n      // Update config only when editing is done (on focus lost or submit)\n      void onEditComplete() {\n        if (downloadDirController.text != downloaderCfg.value.downloadDir) {\n          downloaderCfg.value.downloadDir = downloadDirController.text;\n          if (Util.isDesktop()) {\n            controller.clearTap();\n          }\n          debounceSave();\n        }\n      }\n\n      return DirectorySelector(\n        controller: downloadDirController,\n        showLabel: false,\n        showAndoirdToggle: true,\n        allowEdit: true,\n        showPlaceholderButton: true,\n        onEditComplete: onEditComplete,\n      );\n    });\n    final buildMaxRunning = _buildConfigItem(\n        'maxRunning', () => downloaderCfg.value.maxRunning.toString(),\n        (Key key) {\n      final maxRunningController = TextEditingController(\n          text: downloaderCfg.value.maxRunning.toString());\n      maxRunningController.addListener(() async {\n        if (maxRunningController.text.isNotEmpty &&\n            maxRunningController.text !=\n                downloaderCfg.value.maxRunning.toString()) {\n          downloaderCfg.value.maxRunning = int.parse(maxRunningController.text);\n\n          await debounceSave();\n        }\n      });\n\n      return TextField(\n        key: key,\n        focusNode: FocusNode(),\n        controller: maxRunningController,\n        keyboardType: TextInputType.number,\n        inputFormatters: [\n          FilteringTextInputFormatter.digitsOnly,\n          NumericalRangeFormatter(min: 1, max: 256),\n        ],\n      );\n    });\n\n    final buildDefaultDirectDownload =\n        _buildConfigItem('defaultDirectDownload', () {\n      return appController.downloaderConfig.value.extra.defaultDirectDownload\n          ? 'on'.tr\n          : 'off'.tr;\n    }, (Key key) {\n      return Container(\n        alignment: Alignment.centerLeft,\n        child: Switch(\n          value:\n              appController.downloaderConfig.value.extra.defaultDirectDownload,\n          onChanged: (bool value) async {\n            appController.downloaderConfig.update((val) {\n              val!.extra.defaultDirectDownload = value;\n            });\n            await debounceSave();\n          },\n        ),\n      );\n    });\n\n    final buildAutoStartTasks = _buildConfigItem('autoStartTasks', () {\n      return appController.downloaderConfig.value.extra.autoStartTasks\n          ? 'on'.tr\n          : 'off'.tr;\n    }, (Key key) {\n      return Container(\n        alignment: Alignment.centerLeft,\n        child: Switch(\n          value: appController.downloaderConfig.value.extra.autoStartTasks,\n          onChanged: (bool value) async {\n            appController.downloaderConfig.update((val) {\n              val!.extra.autoStartTasks = value;\n            });\n            await debounceSave();\n          },\n        ),\n      );\n    });\n\n    // AutoTorrent: Enable auto create BT tasks from .torrent files\n    final buildAutoTorrentEnable = _buildConfigItem('autoTorrentEnable', () {\n      return appController.downloaderConfig.value.autoTorrent.enable\n          ? 'on'.tr\n          : 'off'.tr;\n    }, (Key key) {\n      return Container(\n        alignment: Alignment.centerLeft,\n        child: Switch(\n          value: appController.downloaderConfig.value.autoTorrent.enable,\n          onChanged: (bool value) async {\n            appController.downloaderConfig.update((val) {\n              val!.autoTorrent.enable = value;\n            });\n            await debounceSave();\n          },\n        ),\n      );\n    });\n\n    // AutoTorrent: Delete .torrent file after BT task creation\n    final buildAutoTorrentDeleteAfterDownload =\n        _buildConfigItem('autoTorrentDeleteAfterDownload', () {\n      return appController\n              .downloaderConfig.value.autoTorrent.deleteAfterDownload\n          ? 'on'.tr\n          : 'off'.tr;\n    }, (Key key) {\n      return Container(\n        alignment: Alignment.centerLeft,\n        child: Switch(\n          value: appController\n              .downloaderConfig.value.autoTorrent.deleteAfterDownload,\n          onChanged: (bool value) async {\n            appController.downloaderConfig.update((val) {\n              val!.autoTorrent.deleteAfterDownload = value;\n            });\n            await debounceSave();\n          },\n        ),\n      );\n    });\n\n    // New: Auto Delete Missing File Tasks\n    final buildAutoDeleteMissingFileTasks =\n        _buildConfigItem('autoDeleteMissingFileTasks', () {\n      return appController.downloaderConfig.value.autoDeleteMissingFileTasks\n          ? 'on'.tr\n          : 'off'.tr;\n    }, (Key key) {\n      return Container(\n        alignment: Alignment.centerLeft,\n        child: Switch(\n          value:\n              appController.downloaderConfig.value.autoDeleteMissingFileTasks,\n          onChanged: (bool value) async {\n            appController.downloaderConfig.update((val) {\n              val!.autoDeleteMissingFileTasks = value;\n            });\n            await debounceSave();\n          },\n        ),\n      );\n    });\n\n    // Archive auto extract configuration\n    final buildAutoExtract = _buildConfigItem('autoExtract', () {\n      return appController.downloaderConfig.value.archive.autoExtract\n          ? 'on'.tr\n          : 'off'.tr;\n    }, (Key key) {\n      return Container(\n        alignment: Alignment.centerLeft,\n        child: Switch(\n          value: appController.downloaderConfig.value.archive.autoExtract,\n          onChanged: (bool value) async {\n            appController.downloaderConfig.update((val) {\n              val!.archive.autoExtract = value;\n            });\n            await debounceSave();\n          },\n        ),\n      );\n    });\n\n    // Archive delete after extract configuration\n    final buildDeleteAfterExtract = _buildConfigItem('deleteAfterExtract', () {\n      return appController.downloaderConfig.value.archive.deleteAfterExtract\n          ? 'on'.tr\n          : 'off'.tr;\n    }, (Key key) {\n      return Container(\n        alignment: Alignment.centerLeft,\n        child: Switch(\n          value:\n              appController.downloaderConfig.value.archive.deleteAfterExtract,\n          onChanged: (bool value) async {\n            appController.downloaderConfig.update((val) {\n              val!.archive.deleteAfterExtract = value;\n            });\n            await debounceSave();\n          },\n        ),\n      );\n    });\n\n    // Download categories configuration\n    buildDownloadCategories() {\n      final categories = downloaderCfg.value.extra.downloadCategories\n          .where((c) => !c.isDeleted) // Filter out deleted categories\n          .toList();\n      return ListTile(\n        title: Text('downloadCategories'.tr),\n        subtitle: Column(\n          crossAxisAlignment: CrossAxisAlignment.start,\n          children: [\n            if (categories.isEmpty)\n              Padding(\n                padding: const EdgeInsets.only(top: 8),\n                child: Text(\n                  'add'.tr,\n                  style: TextStyle(color: Theme.of(context).hintColor),\n                ),\n              ),\n            ...categories.map((category) {\n              return Padding(\n                padding: const EdgeInsets.only(top: 8),\n                child: Row(\n                  children: [\n                    Expanded(\n                      child: Column(\n                        crossAxisAlignment: CrossAxisAlignment.start,\n                        children: [\n                          Text(\n                            _getCategoryDisplayName(category),\n                            style: const TextStyle(fontWeight: FontWeight.bold),\n                          ),\n                          Text(\n                            category.path,\n                            style: TextStyle(\n                              fontSize: 12,\n                              color: Theme.of(context).hintColor,\n                            ),\n                            overflow: TextOverflow.ellipsis,\n                          ),\n                        ],\n                      ),\n                    ),\n                    IconButton(\n                      icon: Icon(\n                        Icons.edit,\n                        size: 20,\n                        color: Theme.of(context).hintColor,\n                      ),\n                      onPressed: () {\n                        _showCategoryDialog(\n                          context,\n                          debounceSave,\n                          downloaderCfg,\n                          category: category,\n                        );\n                      },\n                    ),\n                    IconButton(\n                      icon: Icon(\n                        Icons.delete,\n                        size: 20,\n                        color: Theme.of(context).hintColor,\n                      ),\n                      onPressed: () async {\n                        // Show confirmation dialog\n                        final confirmed = await showDialog<bool>(\n                          context: context,\n                          builder: (context) => AlertDialog(\n                            title: Text('tip'.tr),\n                            content: Text('confirmDelete'.tr),\n                            actions: [\n                              TextButton(\n                                onPressed: () =>\n                                    Navigator.of(context).pop(false),\n                                child: Text('cancel'.tr),\n                              ),\n                              TextButton(\n                                onPressed: () =>\n                                    Navigator.of(context).pop(true),\n                                child: Text('confirm'.tr),\n                              ),\n                            ],\n                          ),\n                        );\n\n                        if (confirmed == true) {\n                          if (category.isBuiltIn) {\n                            // Mark built-in category as deleted instead of removing it\n                            downloaderCfg.update((val) {\n                              category.isDeleted = true;\n                            });\n                          } else {\n                            // Remove custom categories completely\n                            downloaderCfg.update((val) {\n                              val!.extra.downloadCategories = val\n                                  .extra.downloadCategories\n                                  .where((c) => c != category)\n                                  .toList();\n                            });\n                          }\n                          debounceSave();\n                        }\n                      },\n                    ),\n                  ],\n                ),\n              );\n            }),\n            Padding(\n              padding: const EdgeInsets.only(top: 8),\n              child: OutlinedButton.icon(\n                icon: const Icon(Icons.add, size: 18),\n                label: Text('add'.tr),\n                onPressed: () {\n                  _showCategoryDialog(\n                    context,\n                    debounceSave,\n                    downloaderCfg,\n                  );\n                },\n              ),\n            ),\n          ],\n        ),\n      );\n    }\n\n    buildBrowserExtension() {\n      return ListTile(\n          title: Text('browserExtension'.tr),\n          subtitle: const Row(\n            crossAxisAlignment: CrossAxisAlignment.start,\n            children: [\n              OpenInNew(\n                text: \"Chrome\",\n                url:\n                    \"https://chromewebstore.google.com/detail/gopeed/mijpgljlfcapndmchhjffkpckknofcnd\",\n              ),\n              SizedBox(width: 10),\n              OpenInNew(\n                text: \"Edge\",\n                url:\n                    \"https://microsoftedge.microsoft.com/addons/detail/dkajnckekendchdleoaenoophcobooce\",\n              ),\n              SizedBox(width: 10),\n              OpenInNew(\n                text: \"Firefox\",\n                url:\n                    \"https://addons.mozilla.org/zh-CN/firefox/addon/gopeed-extension\",\n              ),\n            ],\n          ).paddingOnly(top: 5));\n    }\n\n    // Currently auto startup only support Windows and Linux\n    final buildAutoStartup = !Util.isWindows() && !Util.isLinux()\n        ? () => null\n        : _buildConfigItem('launchAtStartup', () {\n            return appController.autoStartup.value ? 'on'.tr : 'off'.tr;\n          }, (Key key) {\n            return Container(\n              alignment: Alignment.centerLeft,\n              child: Switch(\n                value: appController.autoStartup.value,\n                onChanged: (bool value) async {\n                  try {\n                    if (value) {\n                      await launchAtStartup.enable();\n                    } else {\n                      await launchAtStartup.disable();\n                    }\n                    appController.autoStartup.value = value;\n                  } catch (e) {\n                    showErrorMessage(e);\n                    logger.e('launchAtStartup fail', e);\n                  }\n                },\n              ),\n            );\n          });\n\n    // Menubar mode only for macOS\n    final buildMenubarMode = !Util.isMacos()\n        ? () => null\n        : _buildConfigItem('runAsMenubarApp', () {\n            return Database.instance.getRunAsMenubarApp() ? 'on'.tr : 'off'.tr;\n          }, (Key key) {\n            return Container(\n              alignment: Alignment.centerLeft,\n              child: Column(\n                crossAxisAlignment: CrossAxisAlignment.start,\n                children: [\n                  Switch(\n                    value: Database.instance.getRunAsMenubarApp(),\n                    onChanged: (bool value) async {\n                      // Save to database (single source of truth)\n                      Database.instance.saveRunAsMenubarApp(value);\n                      // Apply dock icon visibility\n                      await windowManager.setSkipTaskbar(value);\n                      // Small delay to let macOS process the activation policy change\n                      await Future.delayed(const Duration(milliseconds: 100));\n                      // Ensure window stays visible after toggle\n                      await windowManager.show();\n                      await windowManager.focus();\n                      // Force UI refresh\n                      controller.clearTap();\n                      await debounceSave();\n                    },\n                  ),\n                  Text(\n                    'runAsMenubarAppDesc'.tr,\n                    style: TextStyle(\n                      fontSize: 12,\n                      color: Theme.of(context).hintColor,\n                    ),\n                  ),\n                ],\n              ),\n            );\n          });\n\n    final buildDesktopNotification = !Util.isDesktop()\n        ? () => null\n        : _buildConfigItem('desktopNotification', () {\n            return appController\n                    .downloaderConfig.value.extra.desktopNotification\n                ? 'on'.tr\n                : 'off'.tr;\n          }, (Key key) {\n            return Container(\n              alignment: Alignment.centerLeft,\n              child: Switch(\n                value: appController\n                    .downloaderConfig.value.extra.desktopNotification,\n                onChanged: (bool value) async {\n                  appController.downloaderConfig.update((val) {\n                    val!.extra.desktopNotification = value;\n                  });\n                  await debounceSave();\n                },\n              ),\n            );\n          });\n\n    // http config items start\n    final httpConfig = downloaderCfg.value.protocolConfig.http;\n    final buildHttpUa =\n        _buildConfigItem('User-Agent', () => httpConfig.userAgent, (Key key) {\n      final uaController = TextEditingController(text: httpConfig.userAgent);\n      uaController.addListener(() async {\n        if (uaController.text.isNotEmpty &&\n            uaController.text != httpConfig.userAgent) {\n          httpConfig.userAgent = uaController.text;\n\n          await debounceSave();\n        }\n      });\n\n      return TextField(\n        key: key,\n        focusNode: FocusNode(),\n        controller: uaController,\n      );\n    });\n    final buildHttpConnections = _buildConfigItem(\n        'connections', () => httpConfig.connections.toString(), (Key key) {\n      final connectionsController =\n          TextEditingController(text: httpConfig.connections.toString());\n      connectionsController.addListener(() async {\n        if (connectionsController.text.isNotEmpty &&\n            connectionsController.text != httpConfig.connections.toString()) {\n          httpConfig.connections = int.parse(connectionsController.text);\n\n          await debounceSave();\n        }\n      });\n\n      return TextField(\n        key: key,\n        focusNode: FocusNode(),\n        controller: connectionsController,\n        keyboardType: TextInputType.number,\n        inputFormatters: [\n          FilteringTextInputFormatter.digitsOnly,\n          NumericalRangeFormatter(min: 1, max: 256),\n        ],\n      );\n    });\n    final buildHttpUseServerCtime = _buildConfigItem(\n        'useServerCtime', () => httpConfig.useServerCtime ? 'on'.tr : 'off'.tr,\n        (Key key) {\n      return Container(\n        alignment: Alignment.centerLeft,\n        child: Switch(\n          value: httpConfig.useServerCtime,\n          onChanged: (bool value) {\n            downloaderCfg.update((val) {\n              val!.protocolConfig.http.useServerCtime = value;\n            });\n            debounceSave();\n          },\n        ),\n      );\n    });\n\n    // bt config items start\n    final btConfig = downloaderCfg.value.protocolConfig.bt;\n    final btExtConfig = downloaderCfg.value.extra.bt;\n    final buildBtListenPort = _buildConfigItem(\n        'port', () => btConfig.listenPort.toString(), (Key key) {\n      final listenPortController =\n          TextEditingController(text: btConfig.listenPort.toString());\n      listenPortController.addListener(() async {\n        if (listenPortController.text.isNotEmpty &&\n            listenPortController.text != btConfig.listenPort.toString()) {\n          btConfig.listenPort = int.parse(listenPortController.text);\n\n          await debounceSave();\n        }\n      });\n\n      return TextField(\n        key: key,\n        focusNode: FocusNode(),\n        controller: listenPortController,\n        keyboardType: TextInputType.number,\n        inputFormatters: [\n          FilteringTextInputFormatter.digitsOnly,\n          NumericalRangeFormatter(min: 0, max: 65535),\n        ],\n      );\n    });\n    final buildBtTrackerSubscribeUrls = _buildConfigItem(\n        'subscribeTracker',\n        () => 'items'.trParams(\n            {'count': btExtConfig.trackerSubscribeUrls.length.toString()}),\n        (Key key) {\n      final trackerUpdateController = OutlinedButtonLoadingController();\n      return Column(\n        crossAxisAlignment: CrossAxisAlignment.start,\n        children: [\n          SizedBox(\n            height: 200,\n            child: CheckListView(\n              items: allTrackerSubscribeUrls,\n              checked: btExtConfig.trackerSubscribeUrls,\n              onChanged: (value) {\n                btExtConfig.trackerSubscribeUrls = value;\n\n                debounceSave();\n              },\n            ),\n          ),\n          _padding,\n          Row(\n            children: [\n              OutlinedButtonLoading(\n                onPressed: () async {\n                  trackerUpdateController.start();\n                  try {\n                    await appController.trackerUpdate();\n                  } catch (e) {\n                    showErrorMessage('subscribeFail'.tr);\n                  } finally {\n                    trackerUpdateController.stop();\n                  }\n                },\n                controller: trackerUpdateController,\n                child: Text('update'.tr),\n              ),\n              Expanded(\n                child: SwitchListTile(\n                    controlAffinity: ListTileControlAffinity.leading,\n                    value: btExtConfig.autoUpdateTrackers,\n                    onChanged: (bool value) {\n                      downloaderCfg.update((val) {\n                        val!.extra.bt.autoUpdateTrackers = value;\n                      });\n                      debounceSave();\n                    },\n                    title: Text('updateDaily'.tr)),\n              ),\n            ],\n          ),\n          Text('lastUpdate'.trParams({\n            'time': btExtConfig.lastTrackerUpdateTime != null\n                ? DateFormat('yyyy-MM-dd HH:mm:ss')\n                    .format(btExtConfig.lastTrackerUpdateTime!)\n                : ''\n          })),\n        ],\n      );\n    });\n    final buildBtTrackers = _buildConfigItem(\n        'addTracker',\n        () => 'items'\n            .trParams({'count': btExtConfig.customTrackers.length.toString()}),\n        (Key key) {\n      final trackersController = TextEditingController(\n          text: btExtConfig.customTrackers.join('\\r\\n').toString());\n      return TextField(\n        key: key,\n        focusNode: FocusNode(),\n        controller: trackersController,\n        keyboardType: TextInputType.multiline,\n        maxLines: 5,\n        decoration: InputDecoration(\n          hintText: 'addTrackerHit'.tr,\n        ),\n        onChanged: (value) async {\n          btExtConfig.customTrackers = Util.textToLines(value);\n          appController.refreshTrackers();\n\n          await debounceSave();\n        },\n      );\n    });\n    final buildBtSeedConfig = _buildConfigItem('seedConfig',\n        () => '${'seedKeep'.tr}(${btConfig.seedKeep ? 'on'.tr : 'off'.tr})',\n        (Key key) {\n      final seedRatioController =\n          TextEditingController(text: btConfig.seedRatio.toString());\n      seedRatioController.addListener(() {\n        if (seedRatioController.text.isNotEmpty) {\n          btConfig.seedRatio = double.parse(seedRatioController.text);\n          debounceSave();\n        }\n      });\n      final seedTimeController =\n          TextEditingController(text: (btConfig.seedTime ~/ 60).toString());\n      seedTimeController.addListener(() {\n        if (seedTimeController.text.isNotEmpty) {\n          btConfig.seedTime = int.parse(seedTimeController.text) * 60;\n          debounceSave();\n        }\n      });\n      return Column(\n        crossAxisAlignment: CrossAxisAlignment.start,\n        children: [\n          SwitchListTile(\n              controlAffinity: ListTileControlAffinity.leading,\n              contentPadding: EdgeInsets.zero,\n              value: btConfig.seedKeep,\n              onChanged: (bool value) {\n                downloaderCfg.update((val) {\n                  val!.protocolConfig.bt.seedKeep = value;\n                });\n                debounceSave();\n              },\n              title: Text('seedKeep'.tr)),\n          btConfig.seedKeep\n              ? null\n              : TextField(\n                  controller: seedRatioController,\n                  decoration: InputDecoration(\n                    labelText: 'seedRatio'.tr,\n                  ),\n                  keyboardType:\n                      const TextInputType.numberWithOptions(decimal: true),\n                  inputFormatters: [\n                    FilteringTextInputFormatter.allow(\n                        RegExp(r'^\\d+\\.?\\d{0,2}')),\n                  ],\n                ),\n          btConfig.seedKeep\n              ? null\n              : TextField(\n                  controller: seedTimeController,\n                  decoration: InputDecoration(\n                    labelText: 'seedTime'.tr,\n                  ),\n                  keyboardType: TextInputType.number,\n                  inputFormatters: [\n                    FilteringTextInputFormatter.digitsOnly,\n                    NumericalRangeFormatter(min: 0, max: 100000000),\n                  ],\n                ),\n        ].where((e) => e != null).map((e) => e!).toList(),\n      );\n    });\n    final buildBtDefaultClientConfig = !Util.isWindows()\n        ? () => null\n        : _buildConfigItem('setAsDefaultBtClient', () {\n            return appController.downloaderConfig.value.extra.defaultBtClient\n                ? 'on'.tr\n                : 'off'.tr;\n          }, (Key key) {\n            return Container(\n              alignment: Alignment.centerLeft,\n              child: Switch(\n                value:\n                    appController.downloaderConfig.value.extra.defaultBtClient,\n                onChanged: (bool value) async {\n                  try {\n                    if (value) {\n                      registerDefaultTorrentClient();\n                    } else {\n                      unregisterDefaultTorrentClient();\n                    }\n                    appController.downloaderConfig.update((val) {\n                      val!.extra.defaultBtClient = value;\n                    });\n                    await debounceSave();\n                  } catch (e) {\n                    showErrorMessage(e);\n                    logger.e('register default torrent client fail', e);\n                  }\n                },\n              ),\n            );\n          });\n\n    // ed2k config items start\n    final ed2kConfig = downloaderCfg.value.protocolConfig.ed2k;\n    List<String> parseEd2kEntries(String value) {\n      return value\n          .split(',')\n          .map((e) => e.trim())\n          .where((e) => e.isNotEmpty)\n          .toList();\n    }\n\n    String summarizeEd2kEntries(String value) {\n      final entries = parseEd2kEntries(value);\n      if (entries.isEmpty) {\n        return 'notSet'.tr;\n      }\n      return 'items'.trParams({'count': entries.length.toString()});\n    }\n\n    String formatEd2kMultilineValue(String value) {\n      return parseEd2kEntries(value).join('\\r\\n');\n    }\n\n    final buildEd2kListenPort = _buildConfigItem(\n        'ed2kTcpPort', () => ed2kConfig.listenPort.toString(), (Key key) {\n      final controller =\n          TextEditingController(text: ed2kConfig.listenPort.toString());\n      controller.addListener(() async {\n        if (controller.text.isNotEmpty &&\n            controller.text != ed2kConfig.listenPort.toString()) {\n          ed2kConfig.listenPort = int.parse(controller.text);\n          await debounceSave();\n        }\n      });\n\n      return TextField(\n        key: key,\n        focusNode: FocusNode(),\n        controller: controller,\n        keyboardType: TextInputType.number,\n        inputFormatters: [\n          FilteringTextInputFormatter.digitsOnly,\n          NumericalRangeFormatter(min: 0, max: 65535),\n        ],\n      );\n    });\n    final buildEd2kUdpPort = _buildConfigItem(\n        'ed2kUdpPort', () => ed2kConfig.udpPort.toString(), (Key key) {\n      final controller =\n          TextEditingController(text: ed2kConfig.udpPort.toString());\n      controller.addListener(() async {\n        if (controller.text.isNotEmpty &&\n            controller.text != ed2kConfig.udpPort.toString()) {\n          ed2kConfig.udpPort = int.parse(controller.text);\n          await debounceSave();\n        }\n      });\n\n      return TextField(\n        key: key,\n        focusNode: FocusNode(),\n        controller: controller,\n        keyboardType: TextInputType.number,\n        inputFormatters: [\n          FilteringTextInputFormatter.digitsOnly,\n          NumericalRangeFormatter(min: 0, max: 65535),\n        ],\n      );\n    });\n    final buildEd2kServerAddr = _buildConfigItem(\n        'ed2kServerList', () => summarizeEd2kEntries(ed2kConfig.serverAddr),\n        (Key key) {\n      final controller = TextEditingController(\n          text: formatEd2kMultilineValue(ed2kConfig.serverAddr));\n      return TextField(\n        key: key,\n        focusNode: FocusNode(),\n        controller: controller,\n        keyboardType: TextInputType.multiline,\n        maxLines: 5,\n        decoration: InputDecoration(\n          hintText: 'ed2kServersHint'.tr,\n          helperText: 'ed2kOnePerLine'.tr,\n        ),\n        onChanged: (value) async {\n          ed2kConfig.serverAddr = Util.textToLines(value).join(',');\n          await debounceSave();\n        },\n      );\n    });\n    final buildEd2kServerMet = _buildConfigItem(\n        'ed2kServerMet', () => summarizeEd2kEntries(ed2kConfig.serverMet),\n        (Key key) {\n      final controller = TextEditingController(\n          text: formatEd2kMultilineValue(ed2kConfig.serverMet));\n      return TextField(\n        key: key,\n        focusNode: FocusNode(),\n        controller: controller,\n        keyboardType: TextInputType.multiline,\n        maxLines: 4,\n        decoration: InputDecoration(\n          hintText: 'ed2kServerMetHint'.tr,\n          helperText: 'ed2kOnePerLine'.tr,\n        ),\n        onChanged: (value) async {\n          ed2kConfig.serverMet = Util.textToLines(value).join(',');\n          await debounceSave();\n        },\n      );\n    });\n    final buildEd2kNodesDat = _buildConfigItem(\n        'ed2kNodesDat', () => summarizeEd2kEntries(ed2kConfig.nodesDat),\n        (Key key) {\n      final controller = TextEditingController(\n          text: formatEd2kMultilineValue(ed2kConfig.nodesDat));\n      return TextField(\n        key: key,\n        focusNode: FocusNode(),\n        controller: controller,\n        keyboardType: TextInputType.multiline,\n        maxLines: 4,\n        decoration: InputDecoration(\n          hintText: 'ed2kNodesDatHint'.tr,\n          helperText: 'ed2kOnePerLine'.tr,\n        ),\n        onChanged: (value) async {\n          ed2kConfig.nodesDat = Util.textToLines(value).join(',');\n          await debounceSave();\n        },\n      );\n    });\n\n    // ui config items start\n    final buildTheme = _buildConfigItem(\n        'theme',\n        () => _getThemeName(downloaderCfg.value.extra.themeMode),\n        (Key key) => DropdownButton<String>(\n              key: key,\n              value: downloaderCfg.value.extra.themeMode,\n              onChanged: (value) async {\n                downloaderCfg.update((val) {\n                  val?.extra.themeMode = value!;\n                });\n                Get.changeThemeMode(ThemeMode.values.byName(value!));\n                controller.clearTap();\n\n                await debounceSave();\n              },\n              items: ThemeMode.values\n                  .map((e) => DropdownMenuItem<String>(\n                        value: e.name,\n                        child: Text(_getThemeName(e.name)),\n                      ))\n                  .toList(),\n            ));\n    final buildLocale = _buildConfigItem(\n        'locale',\n        () => messages.keys[downloaderCfg.value.extra.locale]!['label']!,\n        (Key key) => DropdownButton<String>(\n              key: key,\n              value: downloaderCfg.value.extra.locale,\n              isDense: true,\n              onChanged: (value) async {\n                downloaderCfg.update((val) {\n                  val!.extra.locale = value!;\n                });\n                Get.updateLocale(toLocale(value!));\n                controller.clearTap();\n\n                await debounceSave();\n              },\n              items: messages.keys.keys\n                  .map((e) => DropdownMenuItem<String>(\n                        value: e,\n                        child: Text(messages.keys[e]!['label']!),\n                      ))\n                  .toList(),\n            ));\n\n    // about config items start\n    buildHomepage() {\n      const homePage = 'https://gopeed.com';\n      return ListTile(\n        title: Text('homepage'.tr),\n        subtitle: const Text(homePage),\n        onTap: () {\n          launchUrl(Uri.parse(homePage), mode: LaunchMode.externalApplication);\n        },\n      );\n    }\n\n    buildVersion() {\n      var hasNewVersion = controller.latestVersion.value != null;\n      return ListTile(\n        title: hasNewVersion\n            ? badges.Badge(\n                position: badges.BadgePosition.topStart(start: 36),\n                child: Text('version'.tr))\n            : Text('version'.tr),\n        subtitle: Text(packageInfo.version),\n        onTap: () {\n          if (hasNewVersion) {\n            showUpdateDialog(context, controller.latestVersion.value!);\n          }\n        },\n      );\n    }\n\n    final buildAutoCheckUpdate = _buildConfigItem(\n        'notifyWhenNewVersion',\n        () =>\n            downloaderCfg.value.extra.notifyWhenNewVersion ? 'on'.tr : 'off'.tr,\n        (Key key) {\n      return Container(\n        alignment: Alignment.centerLeft,\n        child: Switch(\n          value: downloaderCfg.value.extra.notifyWhenNewVersion,\n          onChanged: (bool value) async {\n            downloaderCfg.update((val) {\n              val!.extra.notifyWhenNewVersion = value;\n            });\n            await debounceSave();\n          },\n        ),\n      );\n    });\n\n    final analyticsEnabled = Database.instance.getAnalyticsEnabled().obs;\n    buildAnalyticsEnabled() {\n      return ListTile(\n        title: Text('analyticsEnabled'.tr),\n        subtitle: Text('analyticsEnabledDesc'.tr),\n        trailing: Obx(() => Switch(\n              value: analyticsEnabled.value,\n              onChanged: (bool value) {\n                analyticsEnabled.value = value;\n                Database.instance.saveAnalyticsEnabled(value);\n              },\n            )),\n      );\n    }\n\n    buildThanks() {\n      const thankPage =\n          'https://github.com/GopeedLab/gopeed/graphs/contributors';\n      return ListTile(\n        title: Text('thanks'.tr),\n        subtitle: Text('thanksDesc'.tr),\n        onTap: () {\n          launchUrl(Uri.parse(thankPage), mode: LaunchMode.externalApplication);\n        },\n      );\n    }\n\n    // advanced config proxy items start\n    final proxy = downloaderCfg.value.proxy;\n    final buildProxy = _buildConfigItem(\n      'proxy',\n      () {\n        switch (proxy.proxyMode) {\n          case ProxyModeEnum.noProxy:\n            return 'noProxy'.tr;\n          case ProxyModeEnum.systemProxy:\n            return 'systemProxy'.tr;\n          case ProxyModeEnum.customProxy:\n            return '${downloaderCfg.value.proxy.scheme}://${downloaderCfg.value.proxy.host}';\n        }\n      },\n      (Key key) {\n        final mode = SizedBox(\n          width: 150,\n          child: DropdownButtonFormField<ProxyModeEnum>(\n            value: proxy.proxyMode,\n            onChanged: (value) async {\n              if (value != null && value != proxy.proxyMode) {\n                proxy.proxyMode = value;\n                downloaderCfg.update((val) {\n                  val!.proxy = proxy;\n                });\n\n                await debounceSave();\n              }\n            },\n            items: [\n              DropdownMenuItem<ProxyModeEnum>(\n                value: ProxyModeEnum.noProxy,\n                child: Text('noProxy'.tr),\n              ),\n              DropdownMenuItem<ProxyModeEnum>(\n                value: ProxyModeEnum.systemProxy,\n                child: Text('systemProxy'.tr),\n              ),\n              DropdownMenuItem<ProxyModeEnum>(\n                value: ProxyModeEnum.customProxy,\n                child: Text('customProxy'.tr),\n              ),\n            ],\n          ),\n        );\n\n        final scheme = SizedBox(\n          width: 150,\n          child: DropdownButtonFormField<String>(\n            value: proxy.scheme,\n            onChanged: (value) async {\n              if (value != null && value != proxy.scheme) {\n                proxy.scheme = value;\n\n                await debounceSave();\n              }\n            },\n            items: const [\n              DropdownMenuItem<String>(\n                value: 'http',\n                child: Text('HTTP'),\n              ),\n              DropdownMenuItem<String>(\n                value: 'https',\n                child: Text('HTTPS'),\n              ),\n              DropdownMenuItem<String>(\n                value: 'socks5',\n                child: Text('SOCKS5'),\n              ),\n            ],\n          ),\n        );\n\n        final arr = proxy.host.split(':');\n        var host = '';\n        var port = '';\n        if (arr.length > 1) {\n          host = arr[0];\n          port = arr[1];\n        }\n\n        final ipController = TextEditingController(text: host);\n        final portController = TextEditingController(text: port);\n        updateAddress() async {\n          final newAddress = '${ipController.text}:${portController.text}';\n          if (newAddress != startCfg.value.address) {\n            proxy.host = newAddress;\n\n            await debounceSave();\n          }\n        }\n\n        ipController.addListener(updateAddress);\n        portController.addListener(updateAddress);\n        final server = Row(children: [\n          Flexible(\n            child: TextFormField(\n              controller: ipController,\n              decoration: InputDecoration(\n                labelText: 'server'.tr,\n                contentPadding: const EdgeInsets.all(0.0),\n              ),\n            ),\n          ),\n          const Padding(padding: EdgeInsets.only(left: 10)),\n          Flexible(\n            child: TextFormField(\n              controller: portController,\n              decoration: InputDecoration(\n                labelText: 'port'.tr,\n                contentPadding: const EdgeInsets.all(0.0),\n              ),\n              keyboardType: TextInputType.number,\n              inputFormatters: [\n                FilteringTextInputFormatter.digitsOnly,\n                NumericalRangeFormatter(min: 0, max: 65535),\n              ],\n            ),\n          ),\n        ]);\n\n        final usrController = TextEditingController(text: proxy.usr);\n        final pwdController = TextEditingController(text: proxy.pwd);\n\n        updateAuth() async {\n          if (usrController.text != proxy.usr ||\n              pwdController.text != proxy.pwd) {\n            proxy.usr = usrController.text;\n            proxy.pwd = pwdController.text;\n\n            await debounceSave();\n          }\n        }\n\n        usrController.addListener(updateAuth);\n        pwdController.addListener(updateAuth);\n\n        final auth = Row(children: [\n          Flexible(\n            child: TextFormField(\n              controller: usrController,\n              decoration: InputDecoration(\n                labelText: 'username'.tr,\n                contentPadding: const EdgeInsets.all(0.0),\n              ),\n            ),\n          ),\n          const Padding(padding: EdgeInsets.only(left: 10)),\n          Flexible(\n            child: TextFormField(\n              controller: pwdController,\n              decoration: InputDecoration(\n                labelText: 'password'.tr,\n                contentPadding: const EdgeInsets.all(0.0),\n              ),\n              obscureText: true,\n            ),\n          ),\n        ]);\n\n        List<Widget> customView() {\n          if (proxy.proxyMode != ProxyModeEnum.customProxy) {\n            return [];\n          }\n          return [scheme, server, auth];\n        }\n\n        return Form(\n          child: Column(\n            crossAxisAlignment: CrossAxisAlignment.start,\n            children: _addPadding([\n              mode,\n              ...customView(),\n            ]),\n          ),\n        );\n      },\n    );\n\n    // advanced config GitHub mirror items start\n    final buildGithubMirror = _buildConfigItem(\n      'githubMirror',\n      () => downloaderCfg.value.extra.githubMirror.enabled ? 'on'.tr : 'off'.tr,\n      (Key key) {\n        return Column(\n          crossAxisAlignment: CrossAxisAlignment.start,\n          children: [\n            Row(\n              children: [\n                Text(\n                  'githubMirrorEnable'.tr,\n                  style: Theme.of(Get.context!).textTheme.bodyMedium,\n                ),\n                const Spacer(),\n                Switch(\n                  value: downloaderCfg.value.extra.githubMirror.enabled,\n                  onChanged: (value) {\n                    downloaderCfg.update((val) {\n                      val!.extra.githubMirror.enabled = value;\n                    });\n                    debounceSave();\n                  },\n                ),\n              ],\n            ),\n            _padding,\n            Text(\n              'githubMirrorDesc'.tr,\n              style: Theme.of(Get.context!).textTheme.bodySmall,\n            ),\n            _padding,\n            // List of existing GitHub mirrors\n            ...downloaderCfg.value.extra.githubMirror.mirrors\n                .where((m) => !m.isDeleted)\n                .toList()\n                .asMap()\n                .entries\n                .map((entry) {\n              // Get the original index in the full list\n              final mirror = entry.value;\n              final index = downloaderCfg.value.extra.githubMirror.mirrors\n                  .indexOf(mirror);\n              return Padding(\n                padding: const EdgeInsets.only(bottom: 8.0),\n                child: Row(\n                  children: [\n                    Expanded(\n                      child: Row(\n                        children: [\n                          Expanded(\n                            child: Text(\n                              mirror.url,\n                              style:\n                                  Theme.of(Get.context!).textTheme.bodyMedium,\n                              overflow: TextOverflow.ellipsis,\n                            ),\n                          ),\n                        ],\n                      ),\n                    ),\n                    IconButton(\n                      icon: const Icon(Icons.edit, size: 20),\n                      tooltip: 'edit'.tr,\n                      onPressed: () {\n                        _showGithubMirrorDialog(\n                            index: index, initialMirror: mirror);\n                      },\n                    ),\n                    IconButton(\n                      icon: const Icon(Icons.delete, size: 20),\n                      tooltip: 'delete'.tr,\n                      onPressed: () async {\n                        // Show confirmation dialog\n                        final confirmed = await showDialog<bool>(\n                          context: context,\n                          builder: (context) => AlertDialog(\n                            title: Text('tip'.tr),\n                            content: Text('confirmDelete'.tr),\n                            actions: [\n                              TextButton(\n                                onPressed: () =>\n                                    Navigator.of(context).pop(false),\n                                child: Text('cancel'.tr),\n                              ),\n                              TextButton(\n                                onPressed: () =>\n                                    Navigator.of(context).pop(true),\n                                child: Text('confirm'.tr),\n                              ),\n                            ],\n                          ),\n                        );\n                        if (confirmed != true) return;\n\n                        if (mirror.isBuiltIn) {\n                          // Mark built-in mirror as deleted (logical delete)\n                          downloaderCfg.update((val) {\n                            mirror.isDeleted = true;\n                          });\n                        } else {\n                          // Remove custom mirrors completely (physical delete)\n                          final mirrors = List<GithubMirror>.from(\n                              downloaderCfg.value.extra.githubMirror.mirrors);\n                          mirrors.removeAt(index);\n                          downloaderCfg.update((val) {\n                            val!.extra.githubMirror.mirrors = mirrors;\n                          });\n                        }\n                        await debounceSave();\n                      },\n                    ),\n                  ],\n                ),\n              );\n            }),\n            _padding,\n            // Add button\n            OutlinedButton.icon(\n              onPressed: () {\n                _showGithubMirrorDialog();\n              },\n              icon: const Icon(Icons.add),\n              label: Text('add'.tr),\n            ),\n          ],\n        );\n      },\n    );\n\n    // advanced config API items start\n    final buildApiProtocol = _buildConfigItem(\n      'protocol',\n      () => startCfg.value.network == 'tcp'\n          ? 'TCP ${startCfg.value.address}'\n          : 'Unix',\n      (Key key) {\n        final items = <Widget>[\n          SizedBox(\n            width: 80,\n            child: DropdownButtonFormField<String>(\n              value: startCfg.value.network,\n              onChanged: Util.isDesktop() || Util.isAndroid()\n                  ? (value) async {\n                      startCfg.update((val) {\n                        val!.network = value!;\n                      });\n\n                      await debounceSave(needRestart: true);\n                    }\n                  : null,\n              items: [\n                const DropdownMenuItem<String>(\n                  value: 'tcp',\n                  child: Text('TCP'),\n                ),\n                Util.supportUnixSocket()\n                    ? const DropdownMenuItem<String>(\n                        value: 'unix',\n                        child: Text('Unix'),\n                      )\n                    : null,\n              ].where((e) => e != null).map((e) => e!).toList(),\n            ),\n          )\n        ];\n        if ((Util.isDesktop() || Util.isAndroid()) &&\n            startCfg.value.network == 'tcp') {\n          final arr = startCfg.value.address.split(':');\n          var ip = '127.0.0.1';\n          var port = '0';\n          if (arr.length > 1) {\n            ip = arr[0];\n            port = arr[1];\n          }\n\n          final ipController = TextEditingController(text: ip);\n          final portController = TextEditingController(text: port);\n          updateAddress() async {\n            if (ipController.text.isEmpty || portController.text.isEmpty) {\n              return;\n            }\n            final newAddress = '${ipController.text}:${portController.text}';\n            if (newAddress != startCfg.value.address) {\n              startCfg.value.address = newAddress;\n\n              final saved = await debounceSave(\n                  check: () async {\n                    // Check if address already in use\n                    final configIp = ipController.text;\n                    final configPort = int.parse(portController.text);\n                    if (configPort == 0) {\n                      return '';\n                    }\n                    try {\n                      final socket = await Socket.connect(configIp, configPort,\n                          timeout: const Duration(seconds: 3));\n                      socket.close();\n                      return 'portInUse'\n                          .trParams({'port': configPort.toString()});\n                    } catch (e) {\n                      return '';\n                    }\n                  },\n                  needRestart: true);\n\n              // If save failed, restore the old address\n              if (!saved) {\n                final oldAddress =\n                    (await appController.loadStartConfig()).address;\n                startCfg.update((val) async {\n                  val!.address = oldAddress;\n                });\n              }\n            }\n          }\n\n          ipController.addListener(updateAddress);\n          portController.addListener(updateAddress);\n          items.addAll([\n            const Padding(padding: EdgeInsets.only(left: 20)),\n            Flexible(\n              child: TextFormField(\n                controller: ipController,\n                decoration: const InputDecoration(\n                  labelText: 'IP',\n                  contentPadding: EdgeInsets.all(0.0),\n                ),\n                keyboardType: TextInputType.number,\n                inputFormatters: [\n                  FilteringTextInputFormatter.allow(RegExp('[0-9.]')),\n                ],\n              ),\n            ),\n            const Padding(padding: EdgeInsets.only(left: 10)),\n            Flexible(\n              child: TextFormField(\n                controller: portController,\n                decoration: InputDecoration(\n                  labelText: 'port'.tr,\n                  contentPadding: const EdgeInsets.all(0.0),\n                ),\n                keyboardType: TextInputType.number,\n                inputFormatters: [\n                  FilteringTextInputFormatter.digitsOnly,\n                  NumericalRangeFormatter(min: 0, max: 65535),\n                ],\n              ),\n            ),\n          ]);\n        }\n\n        return Form(\n          child: Row(\n            children: items,\n          ),\n        );\n      },\n    );\n    final buildApiToken = _buildConfigItem('apiToken',\n        () => startCfg.value.apiToken.isEmpty ? 'notSet'.tr : 'set'.tr,\n        (Key key) {\n      final apiTokenController =\n          TextEditingController(text: startCfg.value.apiToken);\n      apiTokenController.addListener(() async {\n        if (apiTokenController.text != startCfg.value.apiToken) {\n          startCfg.value.apiToken = apiTokenController.text;\n\n          await debounceSave(needRestart: true);\n        }\n      });\n      return TextField(\n        key: key,\n        obscureText: true,\n        controller: apiTokenController,\n        focusNode: FocusNode(),\n      );\n    });\n\n    // advanced config webhook items\n    final buildWebhook = _buildConfigItem(\n      'webhook',\n      () => downloaderCfg.value.webhook.enable ? 'on'.tr : 'off'.tr,\n      (Key key) {\n        return Column(\n          crossAxisAlignment: CrossAxisAlignment.start,\n          children: [\n            Row(\n              children: [\n                Text(\n                  'webhookEnable'.tr,\n                  style: Theme.of(Get.context!).textTheme.bodyMedium,\n                ),\n                const Spacer(),\n                Switch(\n                  value: downloaderCfg.value.webhook.enable,\n                  onChanged: (value) {\n                    downloaderCfg.update((val) {\n                      val!.webhook.enable = value;\n                    });\n                    debounceSave();\n                  },\n                ),\n              ],\n            ),\n            _padding,\n            Text(\n              'webhookDesc'.tr,\n              style: Theme.of(Get.context!).textTheme.bodySmall,\n            ),\n            _padding,\n            // List of existing webhook URLs\n            ...downloaderCfg.value.webhook.urls.asMap().entries.map((entry) {\n              final index = entry.key;\n              final url = entry.value;\n              return Padding(\n                padding: const EdgeInsets.only(bottom: 8.0),\n                child: Row(\n                  children: [\n                    Expanded(\n                      child: Text(\n                        url,\n                        style: Theme.of(Get.context!).textTheme.bodyMedium,\n                        overflow: TextOverflow.ellipsis,\n                      ),\n                    ),\n                    IconButton(\n                      icon: const Icon(Icons.edit, size: 20),\n                      tooltip: 'edit'.tr,\n                      onPressed: () {\n                        _showWebhookDialog(index: index, initialUrl: url);\n                      },\n                    ),\n                    IconButton(\n                      icon: const Icon(Icons.delete, size: 20),\n                      tooltip: 'delete'.tr,\n                      onPressed: () async {\n                        // Create new list to avoid unmodifiable list error\n                        final urls =\n                            List<String>.from(downloaderCfg.value.webhook.urls);\n                        urls.removeAt(index);\n                        downloaderCfg.update((val) {\n                          val!.webhook.urls = urls;\n                        });\n                        await debounceSave();\n                      },\n                    ),\n                  ],\n                ),\n              );\n            }),\n            _padding,\n            // Add button\n            OutlinedButton.icon(\n              onPressed: () {\n                _showWebhookDialog();\n              },\n              icon: const Icon(Icons.add),\n              label: Text('add'.tr),\n            ),\n          ],\n        );\n      },\n    );\n\n    // advanced config script items\n    final buildScript = _buildConfigItem(\n      'script',\n      () => downloaderCfg.value.script.enable ? 'on'.tr : 'off'.tr,\n      (Key key) {\n        return Column(\n          crossAxisAlignment: CrossAxisAlignment.start,\n          children: [\n            Row(\n              children: [\n                Text(\n                  'scriptEnable'.tr,\n                  style: Theme.of(Get.context!).textTheme.bodyMedium,\n                ),\n                const Spacer(),\n                Switch(\n                  value: downloaderCfg.value.script.enable,\n                  onChanged: (value) {\n                    downloaderCfg.update((val) {\n                      val!.script.enable = value;\n                    });\n                    debounceSave();\n                  },\n                ),\n              ],\n            ),\n            _padding,\n            Text(\n              'scriptDesc'.tr,\n              style: Theme.of(Get.context!).textTheme.bodySmall,\n            ),\n            _padding,\n            // List of existing script paths\n            ...downloaderCfg.value.script.paths.asMap().entries.map((entry) {\n              final index = entry.key;\n              final path = entry.value;\n              return Padding(\n                padding: const EdgeInsets.only(bottom: 8.0),\n                child: Row(\n                  children: [\n                    Expanded(\n                      child: Text(\n                        path,\n                        style: Theme.of(Get.context!).textTheme.bodyMedium,\n                        overflow: TextOverflow.ellipsis,\n                      ),\n                    ),\n                    IconButton(\n                      icon: const Icon(Icons.edit, size: 20),\n                      tooltip: 'edit'.tr,\n                      onPressed: () {\n                        _showScriptDialog(index: index, initialPath: path);\n                      },\n                    ),\n                    IconButton(\n                      icon: const Icon(Icons.delete, size: 20),\n                      tooltip: 'delete'.tr,\n                      onPressed: () async {\n                        // Create new list to avoid unmodifiable list error\n                        final paths =\n                            List<String>.from(downloaderCfg.value.script.paths);\n                        paths.removeAt(index);\n                        downloaderCfg.update((val) {\n                          val!.script.paths = paths;\n                        });\n                        await debounceSave();\n                      },\n                    ),\n                  ],\n                ),\n              );\n            }),\n            _padding,\n            // Add button\n            OutlinedButton.icon(\n              onPressed: () {\n                _showScriptDialog();\n              },\n              icon: const Icon(Icons.add),\n              label: Text('add'.tr),\n            ),\n          ],\n        );\n      },\n    );\n\n    // advanced config log items start\n    buildLogsDir() {\n      return ListTile(\n          title: Text(\"logDirectory\".tr),\n          subtitle: Row(\n            crossAxisAlignment: CrossAxisAlignment.start,\n            children: [\n              Expanded(\n                child: TextField(\n                  controller: TextEditingController(text: logsDir()),\n                  enabled: false,\n                  readOnly: true,\n                ),\n              ),\n              Util.isDesktop()\n                  ? IconButton(\n                      icon: const Icon(Icons.folder_open),\n                      onPressed: () {\n                        launchUrl(Uri.file(logsDir()));\n                      },\n                    )\n                  : CopyButton(logsDir()),\n            ],\n          ));\n    }\n\n    return Obx(() {\n      return GestureDetector(\n        onTap: () {\n          controller.clearTap();\n        },\n        child: DefaultTabController(\n          length: 2,\n          child: Scaffold(\n              appBar: PreferredSize(\n                  preferredSize: const Size.fromHeight(56),\n                  child: AppBar(\n                    bottom: TabBar(\n                      tabs: [\n                        Tab(\n                          text: 'basic'.tr,\n                        ),\n                        Tab(\n                          text: 'advanced'.tr,\n                        ),\n                      ],\n                    ),\n                  )),\n              body: TabBarView(\n                children: [\n                  SingleChildScrollView(\n                    child: Column(\n                      crossAxisAlignment: CrossAxisAlignment.start,\n                      children: _addPadding([\n                        Text('general'.tr),\n                        Card(\n                            child: Column(\n                          children: _addDivider([\n                            buildDownloadDir(),\n                            buildDownloadCategories(),\n                            buildMaxRunning(),\n                            buildDefaultDirectDownload(),\n                            buildAutoStartTasks(),\n                            buildAutoTorrentEnable(),\n                            Obx(() => Visibility(\n                                  visible: appController.downloaderConfig.value\n                                      .autoTorrent.enable,\n                                  child: buildAutoTorrentDeleteAfterDownload(),\n                                )),\n                            buildAutoDeleteMissingFileTasks(),\n                            buildBrowserExtension(),\n                            buildAutoStartup(),\n                            buildMenubarMode(),\n                            buildDesktopNotification(),\n                          ]),\n                        )),\n                        Text('archives'.tr),\n                        Card(\n                            child: Column(\n                          children: _addDivider([\n                            buildAutoExtract(),\n                            Obx(() => Visibility(\n                                  visible: appController.downloaderConfig.value\n                                      .archive.autoExtract,\n                                  child: buildDeleteAfterExtract(),\n                                )),\n                          ]),\n                        )),\n                        const Text('HTTP'),\n                        Card(\n                            child: Column(\n                          children: _addDivider([\n                            buildHttpUa(),\n                            buildHttpConnections(),\n                            buildHttpUseServerCtime(),\n                          ]),\n                        )),\n                        const Text('BitTorrent'),\n                        Card(\n                            child: Column(\n                          children: _addDivider([\n                            buildBtListenPort(),\n                            buildBtTrackerSubscribeUrls(),\n                            buildBtTrackers(),\n                            buildBtSeedConfig(),\n                            buildBtDefaultClientConfig(),\n                          ]),\n                        )),\n                        Text('ed2k'.tr),\n                        Card(\n                            child: Column(\n                          children: _addDivider([\n                            buildEd2kListenPort(),\n                            buildEd2kUdpPort(),\n                            buildEd2kServerAddr(),\n                            buildEd2kServerMet(),\n                            buildEd2kNodesDat(),\n                          ]),\n                        )),\n                        Text('ui'.tr),\n                        Card(\n                            child: Column(\n                          children: _addDivider([\n                            buildTheme(),\n                            buildLocale(),\n                          ]),\n                        )),\n                        Text('about'.tr),\n                        Card(\n                            child: Column(\n                          children: _addDivider([\n                            buildHomepage(),\n                            buildVersion(),\n                            buildAutoCheckUpdate(),\n                            if (Config.isConfigured) buildAnalyticsEnabled(),\n                            buildThanks(),\n                          ]),\n                        )),\n                      ]),\n                    ),\n                  ),\n                  // Column(\n                  //   children: [\n                  //     Card(\n                  //         child: Column(\n                  //       children: [\n                  //         ..._addDivider([\n                  //           buildApiProtocol(),\n                  //           Util.isDesktop() && startCfg.value.network == 'tcp'\n                  //               ? buildApiToken()\n                  //               : null,\n                  //         ]),\n                  //       ],\n                  //     )),\n                  //   ],\n                  // ),\n                  SingleChildScrollView(\n                      child: Column(\n                    crossAxisAlignment: CrossAxisAlignment.start,\n                    children: _addPadding([\n                      Text('network'.tr),\n                      Card(\n                          child: Column(\n                        children: _addDivider([\n                          buildProxy(),\n                          buildGithubMirror(),\n                        ]),\n                      )),\n                      const Text('API'),\n                      Card(\n                          child: Column(\n                        children: _addDivider([\n                          buildApiProtocol(),\n                          Util.isDesktop() && startCfg.value.network == 'tcp'\n                              ? buildApiToken()\n                              : null,\n                        ]),\n                      )),\n                      Text('developer'.tr),\n                      Card(\n                          child: Column(\n                        children: _addDivider([\n                          buildWebhook(),\n                          if (Util.isDesktop()) buildScript(),\n                          buildLogsDir(),\n                        ]),\n                      )),\n                    ]),\n                  ))\n                ],\n              ).paddingOnly(left: 16, right: 16, top: 16, bottom: 16)),\n        ),\n      );\n    });\n  }\n\n  void _showWebhookDialog({int? index, String? initialUrl}) {\n    final urlController = TextEditingController(text: initialUrl ?? '');\n    final testController = OutlinedButtonLoadingController();\n    final saveController = TextButtonLoadingController();\n    final appController = Get.find<AppController>();\n    final downloaderCfg = appController.downloaderConfig;\n    final isEdit = index != null;\n    final formKey = GlobalKey<FormState>();\n\n    showDialog(\n      context: Get.context!,\n      builder: (dialogContext) => AlertDialog(\n        title: Text(isEdit ? 'edit'.tr : 'add'.tr),\n        content: Form(\n          key: formKey,\n          child: Column(\n            mainAxisSize: MainAxisSize.min,\n            children: [\n              TextFormField(\n                controller: urlController,\n                decoration: InputDecoration(\n                  hintText: 'webhookUrlHint'.tr,\n                ),\n                keyboardType: TextInputType.url,\n                validator: (value) {\n                  if (value == null || value.trim().isEmpty) {\n                    return 'required'.tr;\n                  }\n                  final url = value.trim().toLowerCase();\n                  if (!url.startsWith('http://') &&\n                      !url.startsWith('https://')) {\n                    return 'urlInvalid'.tr;\n                  }\n                  try {\n                    Uri.parse(value.trim());\n                  } catch (e) {\n                    return 'urlInvalid'.tr;\n                  }\n                  return null;\n                },\n              ),\n            ],\n          ),\n        ),\n        actionsAlignment: MainAxisAlignment.spaceBetween,\n        actions: [\n          // Test button on the left\n          Padding(\n            padding: const EdgeInsets.only(left: 16.0),\n            child: OutlinedButtonLoading(\n              controller: testController,\n              onPressed: () async {\n                if (!formKey.currentState!.validate()) {\n                  return;\n                }\n                final url = urlController.text.trim();\n                if (url.isEmpty) return;\n                testController.start();\n                try {\n                  await api.testWebhook(url);\n                  showMessage('tip'.tr, 'webhookTestSuccess'.tr);\n                } catch (e) {\n                  showErrorMessage('webhookTestFail'.tr);\n                } finally {\n                  testController.stop();\n                }\n              },\n              child: Text('webhookTest'.tr),\n            ),\n          ),\n          // Cancel and Confirm on the right\n          Row(\n            mainAxisSize: MainAxisSize.min,\n            children: [\n              TextButton(\n                onPressed: () => Navigator.of(dialogContext).pop(),\n                child: Text('cancel'.tr),\n              ),\n              TextButtonLoading(\n                controller: saveController,\n                onPressed: () async {\n                  if (!formKey.currentState!.validate()) {\n                    return;\n                  }\n                  final url = urlController.text.trim();\n                  if (url.isEmpty) return;\n\n                  saveController.start();\n                  try {\n                    // Create new list to avoid unmodifiable list error\n                    final urls =\n                        List<String>.from(downloaderCfg.value.webhook.urls);\n                    if (isEdit) {\n                      urls[index] = url;\n                    } else {\n                      urls.add(url);\n                    }\n                    downloaderCfg.update((val) {\n                      val!.webhook.urls = urls;\n                    });\n                    await appController.saveConfig();\n                    if (dialogContext.mounted) {\n                      Navigator.of(dialogContext).pop();\n                    }\n                  } catch (e) {\n                    showErrorMessage(e);\n                  } finally {\n                    saveController.stop();\n                  }\n                },\n                child: Text('confirm'.tr),\n              ),\n            ],\n          ),\n        ],\n      ),\n    );\n  }\n\n  void _showScriptDialog({int? index, String? initialPath}) {\n    final pathController = TextEditingController(text: initialPath ?? '');\n    final saveController = TextButtonLoadingController();\n    final appController = Get.find<AppController>();\n    final downloaderCfg = appController.downloaderConfig;\n    final isEdit = index != null;\n    final formKey = GlobalKey<FormState>();\n\n    showDialog(\n      context: Get.context!,\n      builder: (dialogContext) => AlertDialog(\n        title: Text(isEdit ? 'edit'.tr : 'add'.tr),\n        content: Form(\n          key: formKey,\n          child: Column(\n            mainAxisSize: MainAxisSize.min,\n            children: [\n              Row(\n                children: [\n                  Expanded(\n                    child: TextFormField(\n                      controller: pathController,\n                      decoration: InputDecoration(\n                        hintText: 'scriptPathHint'.tr,\n                      ),\n                      validator: (value) {\n                        if (value == null || value.trim().isEmpty) {\n                          return 'required'.tr;\n                        }\n                        return null;\n                      },\n                    ),\n                  ),\n                  if (Util.isDesktop())\n                    Padding(\n                      padding: const EdgeInsets.only(left: 8.0),\n                      child: IconButton(\n                        icon: const Icon(Icons.folder_open),\n                        onPressed: () async {\n                          final result = await FilePicker.platform.pickFiles();\n                          if (result != null && result.files.isNotEmpty) {\n                            final filePath = result.files.first.path;\n                            if (filePath != null) {\n                              pathController.text = filePath;\n                            }\n                          }\n                        },\n                      ),\n                    ),\n                ],\n              ),\n            ],\n          ),\n        ),\n        actions: [\n          TextButton(\n            onPressed: () => Navigator.of(dialogContext).pop(),\n            child: Text('cancel'.tr),\n          ),\n          TextButtonLoading(\n            controller: saveController,\n            onPressed: () async {\n              if (!formKey.currentState!.validate()) {\n                return;\n              }\n              final path = pathController.text.trim();\n              if (path.isEmpty) return;\n\n              saveController.start();\n              try {\n                // Create new list to avoid unmodifiable list error\n                final paths =\n                    List<String>.from(downloaderCfg.value.script.paths);\n                if (isEdit) {\n                  paths[index] = path;\n                } else {\n                  paths.add(path);\n                }\n                downloaderCfg.update((val) {\n                  val!.script.paths = paths;\n                });\n                await appController.saveConfig();\n                if (dialogContext.mounted) {\n                  Navigator.of(dialogContext).pop();\n                }\n              } catch (e) {\n                showErrorMessage(e);\n              } finally {\n                saveController.stop();\n              }\n            },\n            child: Text('confirm'.tr),\n          ),\n        ],\n      ),\n    );\n  }\n\n  void _showGithubMirrorDialog({int? index, GithubMirror? initialMirror}) {\n    final isEdit = index != null;\n    GithubMirrorType selectedType =\n        initialMirror?.type ?? GithubMirrorType.jsdelivr;\n    final urlController = TextEditingController(text: initialMirror?.url ?? '');\n    final saveController = TextButtonLoadingController();\n    final appController = Get.find<AppController>();\n    final downloaderCfg = appController.downloaderConfig;\n    final formKey = GlobalKey<FormState>();\n\n    showDialog(\n      context: Get.context!,\n      builder: (dialogContext) => StatefulBuilder(\n        builder: (context, setState) => AlertDialog(\n          title: Text(isEdit ? 'edit'.tr : 'add'.tr),\n          content: Form(\n            key: formKey,\n            child: Column(\n              mainAxisSize: MainAxisSize.min,\n              children: [\n                DropdownButtonFormField<GithubMirrorType>(\n                  value: selectedType,\n                  decoration: InputDecoration(\n                    labelText: 'githubMirrorType'.tr,\n                  ),\n                  onChanged: (value) {\n                    if (value != null) {\n                      setState(() {\n                        selectedType = value;\n                      });\n                    }\n                  },\n                  items: GithubMirrorType.values\n                      .map((type) => DropdownMenuItem(\n                            value: type,\n                            child: Text(type.name),\n                          ))\n                      .toList(),\n                ),\n                const SizedBox(height: 16),\n                TextFormField(\n                  controller: urlController,\n                  decoration: InputDecoration(\n                    labelText: 'githubMirrorUrl'.tr,\n                    hintText: 'githubMirrorUrlHint'.tr,\n                  ),\n                  keyboardType: TextInputType.url,\n                  validator: (value) {\n                    if (value == null || value.trim().isEmpty) {\n                      return 'required'.tr;\n                    }\n                    final url = value.trim().toLowerCase();\n                    if (!url.startsWith('http://') &&\n                        !url.startsWith('https://')) {\n                      return 'urlInvalid'.tr;\n                    }\n                    try {\n                      Uri.parse(value.trim());\n                    } catch (e) {\n                      return 'urlInvalid'.tr;\n                    }\n                    return null;\n                  },\n                ),\n              ],\n            ),\n          ),\n          actions: [\n            TextButton(\n              onPressed: () => Navigator.of(dialogContext).pop(),\n              child: Text('cancel'.tr),\n            ),\n            TextButtonLoading(\n              controller: saveController,\n              onPressed: () async {\n                if (!formKey.currentState!.validate()) {\n                  return;\n                }\n                final url = urlController.text.trim();\n                if (url.isEmpty) return;\n\n                saveController.start();\n                try {\n                  // Create new list to avoid unmodifiable list error\n                  final mirrors = List<GithubMirror>.from(\n                      downloaderCfg.value.extra.githubMirror.mirrors);\n\n                  final newMirror = GithubMirror(\n                    type: selectedType,\n                    url: url,\n                  );\n\n                  if (isEdit) {\n                    mirrors[index] = newMirror;\n                  } else {\n                    mirrors.add(newMirror);\n                  }\n\n                  downloaderCfg.update((val) {\n                    val!.extra.githubMirror.mirrors = mirrors;\n                  });\n                  await appController.saveConfig();\n                  if (dialogContext.mounted) {\n                    Navigator.of(dialogContext).pop();\n                  }\n                } catch (e) {\n                  showErrorMessage(e);\n                } finally {\n                  saveController.stop();\n                }\n              },\n              child: Text('confirm'.tr),\n            ),\n          ],\n        ),\n      ),\n    );\n  }\n\n  void _tapInputWidget(GlobalKey key) {\n    if (key.currentContext == null) {\n      return;\n    }\n\n    if (key.currentContext?.widget is TextField) {\n      final textField = key.currentContext?.widget as TextField;\n      textField.focusNode?.requestFocus();\n      return;\n    }\n\n    /* GestureDetector? detector;\n    void searchForGestureDetector(BuildContext? element) {\n      element?.visitChildElements((element) {\n        if (element.widget is GestureDetector) {\n          detector = element.widget as GestureDetector?;\n        } else {\n          searchForGestureDetector(element);\n        }\n      });\n    }\n\n    searchForGestureDetector(key.currentContext);\n    detector?.onTap?.call(); */\n  }\n\n  Widget Function() _buildConfigItem(\n      String label, String Function() text, Widget Function(Key key) input) {\n    final tapStatues = controller.tapStatues;\n    final inputKey = GlobalKey();\n    return () => ListTile(\n        title: Text(label.tr),\n        subtitle: tapStatues[label] ?? false ? input(inputKey) : Text(text()),\n        onTap: () {\n          controller.onTap(label);\n          WidgetsBinding.instance.addPostFrameCallback((timeStamp) {\n            _tapInputWidget(inputKey);\n          });\n        });\n  }\n\n  List<Widget> _addPadding(List<Widget> widgets) {\n    final result = <Widget>[];\n    for (var i = 0; i < widgets.length; i++) {\n      result.add(widgets[i]);\n      result.add(_padding);\n    }\n    return result;\n  }\n\n  List<Widget> _addDivider(List<Widget?> widgets) {\n    final result = <Widget>[];\n    final newArr = widgets.where((e) => e != null).map((e) => e!).toList();\n    for (var i = 0; i < newArr.length; i++) {\n      result.add(newArr[i]);\n      if (i != newArr.length - 1) {\n        result.add(_divider);\n      }\n    }\n    return result;\n  }\n\n  String _getThemeName(String? themeMode) {\n    switch (ThemeMode.values.byName(themeMode ?? ThemeMode.system.name)) {\n      case ThemeMode.light:\n        return 'themeLight'.tr;\n      case ThemeMode.dark:\n        return 'themeDark'.tr;\n      default:\n        return 'themeSystem'.tr;\n    }\n  }\n\n  void _showCategoryDialog(\n    BuildContext context,\n    Future<bool> Function() debounceSave,\n    Rx<DownloaderConfig> downloaderCfg, {\n    DownloadCategory? category,\n  }) {\n    final isEdit = category != null;\n    final nameController = TextEditingController(\n      text: isEdit ? _getCategoryDisplayName(category) : '',\n    );\n    final pathController = TextEditingController(text: category?.path ?? '');\n\n    showDialog(\n      context: context,\n      builder: (context) => AlertDialog(\n        title: Text(isEdit ? 'edit'.tr : 'add'.tr),\n        content: Column(\n          mainAxisSize: MainAxisSize.min,\n          children: [\n            TextField(\n              controller: nameController,\n              decoration: InputDecoration(\n                labelText: 'categoryName'.tr,\n              ),\n            ),\n            const SizedBox(height: 16),\n            DirectorySelector(\n              controller: pathController,\n              showLabel: true,\n              allowEdit: true,\n              showPlaceholderButton: true,\n            ),\n          ],\n        ),\n        actions: [\n          TextButton(\n            onPressed: () => Navigator.of(context).pop(),\n            child: Text('cancel'.tr),\n          ),\n          TextButton(\n            onPressed: () {\n              if (nameController.text.isEmpty || pathController.text.isEmpty) {\n                return;\n              }\n\n              if (isEdit) {\n                // Trigger UI update by wrapping changes in update()\n                downloaderCfg.update((val) {\n                  // If name changed, clear nameKey so it won't be re-translated\n                  final nameChanged =\n                      nameController.text != _getCategoryDisplayName(category);\n                  category.name = nameController.text;\n                  category.path = pathController.text;\n                  if (nameChanged) {\n                    category.nameKey = null;\n                  }\n                  // If editing a deleted built-in category, unmark it as deleted\n                  if (category.isBuiltIn && category.isDeleted) {\n                    category.isDeleted = false;\n                  }\n                });\n              } else {\n                downloaderCfg.update((val) {\n                  val!.extra.downloadCategories = [\n                    ...val.extra.downloadCategories,\n                    DownloadCategory(\n                      name: nameController.text,\n                      path: pathController.text,\n                    ),\n                  ];\n                });\n              }\n              debounceSave();\n              Navigator.of(context).pop();\n            },\n            child: Text('confirm'.tr),\n          ),\n        ],\n      ),\n    );\n  }\n}\n\nenum ProxyModeEnum {\n  noProxy,\n  systemProxy,\n  customProxy,\n}\n\nextension ProxyMode on ProxyConfig {\n  ProxyModeEnum get proxyMode {\n    if (!enable) {\n      return ProxyModeEnum.noProxy;\n    }\n    if (system) {\n      return ProxyModeEnum.systemProxy;\n    }\n    return ProxyModeEnum.customProxy;\n  }\n\n  set proxyMode(ProxyModeEnum value) {\n    switch (value) {\n      case ProxyModeEnum.noProxy:\n        enable = false;\n        break;\n      case ProxyModeEnum.systemProxy:\n        enable = true;\n        system = true;\n        break;\n      case ProxyModeEnum.customProxy:\n        enable = true;\n        system = false;\n        break;\n    }\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/modules/task/bindings/task_binding.dart",
    "content": "import 'package:get/get.dart';\n\nimport '../controllers/task_controller.dart';\nimport '../controllers/task_downloaded_controller.dart';\nimport '../controllers/task_downloading_controller.dart';\n\nclass TaskBinding extends Bindings {\n  @override\n  void dependencies() {\n    Get.lazyPut<TaskController>(\n      () => TaskController(),\n    );\n    Get.lazyPut<TaskDownloadingController>(\n      () => TaskDownloadingController(),\n    );\n    Get.lazyPut<TaskDownloadedController>(\n      () => TaskDownloadedController(),\n    );\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/modules/task/bindings/task_files_binding.dart",
    "content": "import 'package:get/get.dart';\n\nimport '../controllers/task_files_controller.dart';\n\nclass TaskFilesBinding extends Bindings {\n  @override\n  void dependencies() {\n    Get.lazyPut<TaskFilesController>(\n      () => TaskFilesController(),\n    );\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/modules/task/controllers/task_controller.dart",
    "content": "import 'package:flutter/foundation.dart';\nimport 'package:flutter/material.dart';\nimport 'package:flutter/services.dart';\nimport 'package:get/get.dart';\nimport 'package:gopeed/api/model/task.dart';\n\nclass TaskController extends GetxController {\n  final tabIndex = 0.obs;\n  final scaffoldKey = GlobalKey<ScaffoldState>();\n  final selectTask = Rx<Task?>(null);\n\n  @override\n  void onInit() {\n    super.onInit();\n    if (kIsWeb) {\n      BrowserContextMenu.disableContextMenu();\n    }\n  }\n\n  @override\n  void onClose() {\n    super.onClose();\n    if (kIsWeb) {\n      BrowserContextMenu.enableContextMenu();\n    }\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/modules/task/controllers/task_downloaded_controller.dart",
    "content": "import 'package:gopeed/app/modules/task/controllers/task_list_controller.dart';\n\nimport '../../../../api/model/task.dart';\n\nclass TaskDownloadedController extends TaskListController {\n  TaskDownloadedController()\n      : super([Status.done], (a, b) => b.updatedAt.compareTo(a.updatedAt));\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/modules/task/controllers/task_downloading_controller.dart",
    "content": "import '../../../../api/model/task.dart';\nimport 'task_list_controller.dart';\n\nclass TaskDownloadingController extends TaskListController {\n  TaskDownloadingController()\n      : super([\n          Status.ready,\n          Status.running,\n          Status.pause,\n          Status.wait,\n          Status.error\n        ], (a, b) {\n          if (a.status == Status.running && b.status != Status.running) {\n            return -1;\n          } else if (a.status != Status.running && b.status == Status.running) {\n            return 1;\n          } else {\n            return b.updatedAt.compareTo(a.updatedAt);\n          }\n        });\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/modules/task/controllers/task_files_controller.dart",
    "content": "import 'package:get/get.dart';\nimport '../../../../api/api.dart';\nimport '../../../../api/model/resource.dart';\nimport '../../../../api/model/task.dart';\n\nclass FileItem {\n  final bool isDirectory;\n  final String path;\n  final String name;\n  final int size;\n\n  FileItem(this.isDirectory, this.path, this.name, this.size);\n\n  String get fullPath => \"${path == \"/\" ? path : \"$path/\"}$name\";\n\n  String filePath(String optName) {\n    return optName.isEmpty\n        ? fullPath\n        : \"${path == \"/\" ? path : \"$path/\"}$optName\";\n  }\n}\n\nclass TaskFilesController extends GetxController {\n  final Map<String, List<FileItem>> _dirMap = {};\n\n  final task = Rx<Task?>(null);\n  final fileList = <FileItem>[].obs;\n\n  @override\n  void onInit() async {\n    super.onInit();\n\n    final taskId = Get.rootDelegate.parameters['id'];\n    final tasks = await getTasks([]);\n    task.value = tasks.firstWhere((element) => element.id == taskId);\n    parseDirMap(task.value!.meta.res!.files);\n    toDir(\"/\");\n  }\n\n  void parseDirMap(List<FileInfo> fileList) {\n    for (final file in fileList) {\n      String dir = file.path;\n      if (!dir.startsWith(\"/\")) {\n        dir = \"/$dir\";\n      }\n      if (!_dirMap.containsKey(dir)) {\n        _dirMap[dir] = [];\n      }\n\n      _dirMap[dir]!.add(FileItem(false, dir, file.name, file.size));\n\n      void findParent(String dir) {\n        final parentDirIndex = dir.lastIndexOf(\"/\");\n        String parentDir =\n            parentDirIndex == 0 ? \"/\" : dir.substring(0, parentDirIndex);\n        if (!_dirMap.containsKey(parentDir)) {\n          _dirMap[parentDir] = [];\n        }\n        String dirName = dir.substring(dir.lastIndexOf(\"/\") + 1);\n        if (!_dirMap[parentDir]!.any((element) => element.name == dirName)) {\n          _dirMap[parentDir]!.add(FileItem(true, parentDir, dirName, 0));\n        }\n      }\n\n      while (dir != \"/\" && dir != \"\") {\n        findParent(dir);\n        dir = dir.substring(0, dir.lastIndexOf(\"/\"));\n      }\n    }\n\n    // sort children, directories first then files, alphabetically\n    _dirMap.forEach((key, value) {\n      value.sort((a, b) {\n        if (a.isDirectory && !b.isDirectory) {\n          return -1;\n        } else if (!a.isDirectory && b.isDirectory) {\n          return 1;\n        } else {\n          return a.name.compareTo(b.name);\n        }\n      });\n    });\n  }\n\n  void toDir(String dir) {\n    fileList.value = _dirMap[dir]?.toList() ?? [];\n  }\n\n  int dirItemCount(String dir) {\n    return _dirMap[dir]?.length ?? 0;\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/modules/task/controllers/task_list_controller.dart",
    "content": "import 'dart:async';\n\nimport 'package:get/get.dart';\n\nimport '../../../../api/api.dart';\nimport '../../../../api/model/task.dart';\n\nabstract class TaskListController extends GetxController {\n  List<Status> statuses;\n  int Function(Task a, Task b) compare;\n\n  TaskListController(this.statuses, this.compare);\n\n  final tasks = <Task>[].obs;\n  final selectedTaskIds = <String>[].obs;\n  final isRunning = false.obs;\n\n  late final Timer _timer;\n\n  @override\n  void onInit() async {\n    super.onInit();\n\n    start();\n    _timer = Timer.periodic(const Duration(milliseconds: 1000), (timer) async {\n      if (isRunning.value) {\n        await getTasksState();\n      }\n    });\n  }\n\n  @override\n  void onClose() {\n    super.onClose();\n    _timer.cancel();\n  }\n\n  void start() async {\n    await getTasksState();\n    isRunning.value = true;\n  }\n\n  void stop() {\n    isRunning.value = false;\n  }\n\n  getTasksState() async {\n    final tasks = await getTasks(statuses);\n    // sort tasks by create time\n    tasks.sort(compare);\n    this.tasks.value = tasks;\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/modules/task/views/task_downloaded_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:get/get.dart';\n\nimport '../../../views/buid_task_list_view.dart';\nimport '../controllers/task_downloaded_controller.dart';\n\nclass TaskDownloadedView extends GetView<TaskDownloadedController> {\n  const TaskDownloadedView({Key? key}) : super(key: key);\n\n  @override\n  Widget build(BuildContext context) {\n    return BuildTaskListView(\n        tasks: controller.tasks, selectedTaskIds: controller.selectedTaskIds);\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/modules/task/views/task_downloading_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:get/get.dart';\n\nimport '../../../views/buid_task_list_view.dart';\nimport '../controllers/task_downloading_controller.dart';\n\nclass TaskDownloadingView extends GetView<TaskDownloadingController> {\n  const TaskDownloadingView({Key? key}) : super(key: key);\n\n  @override\n  Widget build(BuildContext context) {\n    return BuildTaskListView(\n        tasks: controller.tasks, selectedTaskIds: controller.selectedTaskIds);\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/modules/task/views/task_files_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:get/get.dart';\nimport 'package:open_filex/open_filex.dart';\nimport 'package:path/path.dart';\nimport 'package:share_plus/share_plus.dart';\nimport 'package:url_launcher/url_launcher.dart';\n\nimport '../../../../api/api.dart' as api;\nimport '../../../../util/browser_download/browser_download.dart';\nimport '../../../../util/util.dart';\nimport '../../../views/breadcrumb_view.dart';\nimport '../../../views/file_icon.dart';\nimport '../controllers/task_files_controller.dart';\n\nclass TaskFilesView extends GetView<TaskFilesController> {\n  const TaskFilesView({Key? key}) : super(key: key);\n\n  @override\n  Widget build(BuildContext context) {\n    return Scaffold(\n        appBar: AppBar(\n          leading: IconButton(\n              icon: const Icon(Icons.arrow_back),\n              onPressed: () => Get.rootDelegate.popRoute()),\n          // actions: [],\n          title: Obx(() => Text(controller.task.value?.meta.res?.name ?? \"\")),\n        ),\n        body: Obx(() {\n          final fileList = controller.fileList;\n          final breadcrumbItems = [\"/\"];\n          if (fileList.isNotEmpty) {\n            final file = fileList.first;\n            final path = file.path.substring(1);\n            if (path.isNotEmpty) {\n              final pathArr = path.split(\"/\");\n              for (int i = 0; i < pathArr.length; i++) {\n                breadcrumbItems.add(pathArr[i]);\n              }\n            }\n          }\n          return Column(\n            children: [\n              Breadcrumb(\n                  items: breadcrumbItems,\n                  onItemTap: (index) {\n                    final targetDirArr = <String>[];\n                    for (int i = 0; i <= index; i++) {\n                      targetDirArr.add(breadcrumbItems[i]);\n                    }\n                    controller\n                        .toDir(targetDirArr.join(\"/\").replaceFirst('//', '/'));\n                  }).paddingOnly(left: 16, top: 16, bottom: 8),\n              Expanded(\n                child: ListView.builder(\n                  itemBuilder: (context, index) {\n                    final meta = controller.task.value!.meta;\n                    final file = fileList[index];\n                    // if resource is single file, use opts.name as file name\n                    final realFileName = meta.res!.name.isEmpty\n                        ? (meta.opts.name.isEmpty ? file.name : meta.opts.name)\n                        : \"\";\n                    final fileRelativePath = file.filePath(realFileName);\n                    final filePath = Util.safePathJoin(\n                        [meta.opts.path, meta.res!.name, fileRelativePath]);\n                    final fileName = basename(filePath);\n                    return ListTile(\n                      leading:\n                          Icon(fileIcon(fileName, isFolder: file.isDirectory)),\n                      title: Text(fileName),\n                      subtitle: file.isDirectory\n                          ? Text('items'.trParams({\n                              'count': controller\n                                  .dirItemCount(file.fullPath)\n                                  .toString()\n                            }))\n                          : Text(Util.fmtByte(file.size)),\n                      trailing: file.isDirectory\n                          ? null\n                          : SizedBox(\n                              width: 100,\n                              child: Row(\n                                mainAxisAlignment: MainAxisAlignment.end,\n                                children: Util.isWeb()\n                                    ? () {\n                                        final accessUrl = api.join(\n                                            \"/fs/tasks/${controller.task.value!.id}$fileRelativePath\");\n                                        return [\n                                          IconButton(\n                                              icon:\n                                                  const Icon(Icons.open_in_new),\n                                              onPressed: () {\n                                                launchUrl(Uri.parse(accessUrl),\n                                                    webOnlyWindowName:\n                                                        \"_blank\");\n                                              }),\n                                          IconButton(\n                                              icon: const Icon(Icons.download),\n                                              onPressed: () {\n                                                download(accessUrl, fileName);\n                                              })\n                                        ];\n                                      }()\n                                    : [\n                                        IconButton(\n                                            icon: const Icon(Icons.open_in_new),\n                                            onPressed: () async {\n                                              await OpenFilex.open(filePath);\n                                            }),\n                                        IconButton(\n                                            icon: const Icon(Icons.share),\n                                            onPressed: () {\n                                              final xfile = XFile(filePath);\n                                              Share.shareXFiles([xfile]);\n                                            })\n                                      ],\n                              ),\n                            ),\n                      onTap: () {\n                        if (file.isDirectory) {\n                          controller.toDir(file.fullPath);\n                        }\n                      },\n                    );\n                  },\n                  itemCount: controller.fileList.length,\n                ),\n              )\n            ],\n          );\n        }));\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/modules/task/views/task_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:get/get.dart';\nimport 'package:open_filex/open_filex.dart';\nimport 'package:path/path.dart' as path;\n\nimport '../../../../api/model/task.dart';\nimport '../../../../util/file_explorer.dart';\nimport '../../../../util/util.dart';\nimport '../../../routes/app_pages.dart';\nimport '../../../views/copy_button.dart';\nimport '../controllers/task_controller.dart';\nimport '../controllers/task_downloaded_controller.dart';\nimport '../controllers/task_downloading_controller.dart';\nimport 'task_downloaded_view.dart';\nimport 'task_downloading_view.dart';\n\nclass TaskView extends GetView<TaskController> {\n  const TaskView({Key? key}) : super(key: key);\n\n  @override\n  Widget build(BuildContext context) {\n    final selectTask = controller.selectTask;\n\n    return DefaultTabController(\n      length: 2,\n      child: Scaffold(\n        key: controller.scaffoldKey,\n        appBar: PreferredSize(\n            preferredSize: const Size.fromHeight(56),\n            child: AppBar(\n              bottom: TabBar(\n                tabs: const [\n                  Tab(\n                    icon: Icon(Icons.file_download),\n                  ),\n                  Tab(\n                    icon: Icon(Icons.done),\n                  ),\n                ],\n                onTap: (index) {\n                  if (controller.tabIndex.value != index) {\n                    controller.tabIndex.value = index;\n                    final downloadingController =\n                        Get.find<TaskDownloadingController>();\n                    final downloadedController =\n                        Get.find<TaskDownloadedController>();\n                    switch (index) {\n                      case 0:\n                        downloadingController.start();\n                        downloadedController.stop();\n                        break;\n                      case 1:\n                        downloadingController.stop();\n                        downloadedController.start();\n                        break;\n                    }\n                  }\n                },\n              ),\n            )),\n        body: const TabBarView(\n          children: [\n            TaskDownloadingView(),\n            TaskDownloadedView(),\n          ],\n        ),\n        endDrawer: Drawer(\n          // Add a ListView to the drawer. This ensures the user can scroll\n          // through the options in the drawer if there isn't enough vertical\n          // space to fit everything.\n          child: Obx(() => ListView(\n                // Important: Remove any padding from the ListView.\n                padding: EdgeInsets.zero,\n                children: [\n                  SizedBox(\n                    height: MediaQuery.of(context).padding.top + 65,\n                    child: DrawerHeader(\n                        child: Text(\n                      'taskDetail'.tr,\n                      style: Theme.of(context).textTheme.titleLarge,\n                    )),\n                  ),\n                  ListTile(\n                      title: Text('taskName'.tr),\n                      subtitle: buildTooltipSubtitle(selectTask.value?.name)),\n                  ListTile(\n                    title: Text('taskUrl'.tr),\n                    subtitle:\n                        buildTooltipSubtitle(selectTask.value?.meta.req.url),\n                    trailing: CopyButton(selectTask.value?.meta.req.url),\n                  ),\n                  ListTile(\n                    title: Text('downloadPath'.tr),\n                    subtitle:\n                        buildTooltipSubtitle(selectTask.value?.explorerUrl),\n                    trailing: IconButton(\n                      icon: const Icon(Icons.folder_open),\n                      onPressed: () {\n                        selectTask.value?.explorer();\n                      },\n                    ),\n                  ),\n                ],\n              )),\n        ),\n      ),\n    );\n  }\n\n  Widget buildTooltipSubtitle(String? text) {\n    final showText = text ?? \"\";\n    return Tooltip(\n      message: showText,\n      child: Text(\n        showText,\n        overflow: TextOverflow.ellipsis,\n      ),\n    );\n  }\n}\n\nextension TaskEnhance on Task {\n  bool get isFolder {\n    return meta.res?.name.isNotEmpty ?? false;\n  }\n\n  String get explorerUrl {\n    return path.join(Util.safeDir(meta.opts.path), Util.safeDir(name));\n  }\n\n  Future<void> explorer() async {\n    if (Util.isDesktop()) {\n      await FileExplorer.openAndSelectFile(explorerUrl);\n    } else {\n      Get.rootDelegate.toNamed(Routes.TASK_FILES, parameters: {'id': id});\n    }\n  }\n\n  Future<void> open() async {\n    if (status != Status.done) {\n      return;\n    }\n\n    if (isFolder) {\n      await explorer();\n    } else {\n      await OpenFilex.open(explorerUrl);\n    }\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/routes/app_pages.dart",
    "content": "import 'package:get/get.dart';\n\nimport '../modules/create/bindings/create_binding.dart';\nimport '../modules/create/views/create_view.dart';\nimport '../modules/extension/bindings/extension_binding.dart';\nimport '../modules/extension/views/extension_view.dart';\nimport '../modules/home/bindings/home_binding.dart';\nimport '../modules/home/views/home_view.dart';\nimport '../modules/login/bindings/login_binding.dart';\nimport '../modules/login/views/login_view.dart';\nimport '../modules/redirect/bindings/redirect_binding.dart';\nimport '../modules/redirect/views/redirect_view.dart';\nimport '../modules/root/bindings/root_binding.dart';\nimport '../modules/root/views/root_view.dart';\nimport '../modules/setting/bindings/setting_binding.dart';\nimport '../modules/setting/views/setting_view.dart';\nimport '../modules/task/bindings/task_binding.dart';\nimport '../modules/task/bindings/task_files_binding.dart';\nimport '../modules/task/views/task_files_view.dart';\nimport '../modules/task/views/task_view.dart';\n\npart 'app_routes.dart';\n\nclass AppPages {\n  AppPages._();\n\n  static final routes = [\n    GetPage(\n        name: _Paths.ROOT,\n        participatesInRootNavigator: true,\n        transition: Transition.topLevel,\n        // preventDuplicates: true,\n        page: () => const RootView(),\n        binding: RootBinding(),\n        children: [\n          GetPage(\n              name: _Paths.HOME,\n              // participatesInRootNavigator: true,\n              // transition: Transition.topLevel,\n              // preventDuplicates: true,\n              page: () => const HomeView(),\n              binding: HomeBinding(),\n              children: [\n                GetPage(\n                    name: _Paths.TASK,\n                    page: () => const TaskView(),\n                    transition: Transition.noTransition,\n                    binding: TaskBinding(),\n                    children: [\n                      GetPage(\n                          name: _Paths.TASK_FILES,\n                          page: () => const TaskFilesView(),\n                          transition: Transition.noTransition,\n                          binding: TaskFilesBinding()),\n                    ]),\n                GetPage(\n                    name: _Paths.EXTENSION,\n                    page: () => ExtensionView(),\n                    transition: Transition.noTransition,\n                    binding: ExtensionBinding()),\n                GetPage(\n                  name: _Paths.SETTING,\n                  page: () => const SettingView(),\n                  transition: Transition.noTransition,\n                  binding: SettingBinding(),\n                ),\n              ]),\n          GetPage(\n            name: _Paths.LOGIN,\n            page: () => const LoginView(),\n            binding: LoginBinding(),\n            transition: Transition.fadeIn,\n          ),\n          GetPage(\n            name: _Paths.CREATE,\n            transition: Transition.downToUp,\n            // preventDuplicates: true,\n            page: () => CreateView(),\n            binding: CreateBinding(),\n          ),\n          GetPage(\n            name: _Paths.REDIRECT,\n            page: () => const RedirectView(),\n            binding: RedirectBinding(),\n          ),\n        ]),\n  ];\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/routes/app_routes.dart",
    "content": "part of 'app_pages.dart';\n// DO NOT EDIT. This is code generated via package:get_cli/get_cli.dart\n\nabstract class Routes {\n  Routes._();\n\n  static const ROOT = _Paths.ROOT;\n  static const HOME = _Paths.HOME;\n  static const CREATE = _Paths.CREATE;\n  static const LOGIN = _Paths.LOGIN;\n  static const TASK = _Paths.HOME + _Paths.TASK;\n  static const TASK_FILES = TASK + _Paths.TASK_FILES;\n  static const EXTENSION = _Paths.HOME + _Paths.EXTENSION;\n  static const SETTING = _Paths.HOME + _Paths.SETTING;\n  static const REDIRECT = _Paths.REDIRECT;\n}\n\nabstract class _Paths {\n  _Paths._();\n\n  static const ROOT = '/';\n  static const HOME = '/home';\n  static const CREATE = '/create';\n  static const LOGIN = '/login';\n  static const TASK = '/task';\n  static const TASK_FILES = '/files';\n  static const EXTENSION = '/extension';\n  static const SETTING = '/setting';\n  static const REDIRECT = '/redirect';\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/rpc/rpc.dart",
    "content": "import 'dart:convert';\nimport 'dart:io';\n\nimport 'package:dart_ipc/dart_ipc.dart';\n\nimport '../../util/util.dart';\n\n/// RPC context class that contains request information and response methods\nclass RpcContext {\n  final HttpRequest request;\n  final HttpResponse response;\n  String? _bodyCache;\n\n  RpcContext(this.request, this.response);\n\n  /// Read and parse JSON request body\n  Future<Map<String, dynamic>> readJSON() async {\n    if (_bodyCache == null) {\n      final content = <int>[];\n      await for (var data in request) {\n        content.addAll(data);\n      }\n      _bodyCache = String.fromCharCodes(content);\n    }\n\n    if (_bodyCache!.isEmpty) {\n      return {};\n    }\n\n    return jsonDecode(_bodyCache!) as Map<String, dynamic>;\n  }\n\n  Future<String> readText() async {\n    if (_bodyCache == null) {\n      final content = <int>[];\n      await for (var data in request) {\n        content.addAll(data);\n      }\n      _bodyCache = String.fromCharCodes(content);\n    }\n\n    return _bodyCache ?? '';\n  }\n\n  /// Write JSON response\n  Future<void> writeJSON(Map<String, dynamic> data) async {\n    response.headers.contentType = ContentType.json;\n    response.write(jsonEncode(data));\n    await response.close();\n  }\n\n  /// Write error response\n  Future<void> writeError(String message, [int statusCode = 500]) async {\n    response.statusCode = statusCode;\n    await writeJSON({'error': message});\n  }\n}\n\n/// Route handler type definition\ntypedef RouteHandler = Future<void> Function(RpcContext ctx);\n\n/// Route registry\nclass RouteRegistry {\n  final Map<String, RouteHandler> _routes = {};\n\n  /// Register a route\n  void register(String path, RouteHandler handler) {\n    _routes[path] = handler;\n  }\n\n  /// Get route handler\n  RouteHandler? getHandler(String path) {\n    return _routes[path];\n  }\n\n  /// Get all registered routes\n  List<String> get routes => _routes.keys.toList();\n}\n\n/// Start RPC server\nFuture<void> startRpcServer([Map<String, RouteHandler>? routes]) async {\n  String path;\n  if (Util.isWindows()) {\n    path = r'\\\\.\\pipe\\gopeed_host';\n  } else {\n    path = await Util.homePathJoin(\"gopeed_host.sock\");\n    // try to delete existing socket file\n    final socketFile = File(path);\n    if (await socketFile.exists()) {\n      try {\n        await socketFile.delete();\n      } catch (e) {\n        // ignore\n      }\n    }\n  }\n\n  // Create route registry\n  final registry = RouteRegistry();\n\n  // Register provided routes\n  if (routes != null) {\n    routes.forEach((path, handler) {\n      registry.register(path, handler);\n    });\n  }\n\n  final serverSocket = await bind(path);\n  final httpServer = HttpServer.listenOn(serverSocket);\n\n  httpServer.forEach((HttpRequest request) async {\n    final ctx = RpcContext(request, request.response);\n\n    try {\n      // Get request path\n      final requestPath = request.uri.path;\n      // Find route handler\n      final handler = registry.getHandler(requestPath);\n      if (handler != null) {\n        // Execute route handler\n        await handler(ctx);\n      } else {\n        // 404 Route not found\n        await ctx.writeError('Route not found: $requestPath', 404);\n      }\n    } catch (e) {\n      await ctx.writeError('Internal server error: $e', 500);\n    } finally {\n      await request.response.close();\n    }\n  });\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/services/notification_service.dart",
    "content": "import 'dart:async';\nimport 'dart:io';\n\nimport 'package:flutter/services.dart';\nimport 'package:flutter_local_notifications/flutter_local_notifications.dart';\nimport 'package:get/get.dart';\nimport 'package:path_provider/path_provider.dart';\n\nimport '../../api/api.dart';\nimport '../../api/model/task.dart';\nimport '../../util/util.dart';\nimport '../modules/app/controllers/app_controller.dart';\n\nclass NotificationService extends GetxService {\n  Timer? _timer;\n  final Map<String, Status> _previousStatus = {};\n\n  final AppController appController = Get.find<AppController>();\n  final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =\n      FlutterLocalNotificationsPlugin();\n\n  @override\n  void onInit() {\n    super.onInit();\n    _initNotifications();\n    _startPolling();\n  }\n\n  Future<void> _initNotifications() async {\n    const DarwinInitializationSettings initializationSettingsDarwin =\n        DarwinInitializationSettings(\n            requestAlertPermission: false,\n            requestBadgePermission: false,\n            requestSoundPermission: false);\n\n    final LinuxInitializationSettings initializationSettingsLinux =\n        LinuxInitializationSettings(\n      defaultActionName: 'Open notification',\n      defaultIcon: AssetsLinuxIcon('assets/icon/icon.png'),\n    );\n\n    String? windowsIconPath;\n    try {\n      if (Util.isWindows()) {\n        final byteData = await rootBundle.load('assets/icon/icon.ico');\n        final tempDir = await getTemporaryDirectory();\n        final file = File('${tempDir.path}/notification_icon.ico');\n        await file.writeAsBytes(byteData.buffer\n            .asUint8List(byteData.offsetInBytes, byteData.lengthInBytes));\n        windowsIconPath = file.path;\n      }\n    } catch (e) {\n      // Ignore\n    }\n\n    final WindowsInitializationSettings initializationSettingsWindows =\n        WindowsInitializationSettings(\n      appName: 'Gopeed',\n      appUserModelId: 'com.gopeed.gopeed',\n      guid: '3c1bf3f4-3d91-4eaa-a33f-8705e71cf1ce', // unique guid\n      iconPath: windowsIconPath,\n    );\n\n    final InitializationSettings initializationSettings =\n        InitializationSettings(\n      macOS: initializationSettingsDarwin,\n      linux: initializationSettingsLinux,\n      windows: initializationSettingsWindows,\n    );\n\n    await flutterLocalNotificationsPlugin.initialize(\n      settings: initializationSettings,\n    );\n  }\n\n  @override\n  void onClose() {\n    _timer?.cancel();\n    super.onClose();\n  }\n\n  void _startPolling() {\n    _timer = Timer.periodic(const Duration(seconds: 2), (timer) async {\n      try {\n        final config = appController.downloaderConfig.value;\n        if (!config.extra.desktopNotification) {\n          return;\n        }\n\n        final tasks = await getTasks([\n          Status.ready,\n          Status.running,\n          Status.pause,\n          Status.wait,\n          Status.error,\n          Status.done,\n        ]);\n\n        for (var task in tasks) {\n          final prevStatus = _previousStatus[task.id];\n          final currentStatus = task.status;\n\n          if (prevStatus != null && prevStatus != currentStatus) {\n            if (currentStatus == Status.done) {\n              _showNotification(\n                title: 'notificationTaskDone'.tr,\n                body: task.name,\n              );\n            } else if (currentStatus == Status.error) {\n              _showNotification(\n                title: 'notificationTaskError'.tr,\n                body: task.name,\n              );\n            }\n          }\n          _previousStatus[task.id] = currentStatus;\n        }\n\n        // Clean up deleted tasks from map\n        final currentTaskIds = tasks.map((t) => t.id).toSet();\n        _previousStatus\n            .removeWhere((id, status) => !currentTaskIds.contains(id));\n      } catch (e) {\n        // Ignored\n      }\n    });\n  }\n\n  int _notificationId = 0;\n\n  Future<void> _showNotification(\n      {required String title, required String body}) async {\n    const NotificationDetails notificationDetails = NotificationDetails(\n      macOS: DarwinNotificationDetails(),\n      linux: LinuxNotificationDetails(),\n      windows: WindowsNotificationDetails(),\n    );\n\n    await flutterLocalNotificationsPlugin.show(\n      id: _notificationId++,\n      title: title,\n      body: body,\n      notificationDetails: notificationDetails,\n    );\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/views/breadcrumb_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:get/get.dart';\n\nclass Breadcrumb extends StatelessWidget {\n  final List<String> items;\n  final Function(int)? onItemTap;\n  final TextStyle textStyle;\n  final TextStyle activeTextStyle;\n\n  const Breadcrumb({\n    super.key,\n    required this.items,\n    this.onItemTap,\n    this.textStyle = const TextStyle(fontSize: 16),\n    this.activeTextStyle =\n        const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),\n  });\n\n  @override\n  Widget build(BuildContext context) {\n    List<Widget> children = [];\n    for (int i = 0; i < items.length; i++) {\n      children.add(\n        GestureDetector(\n          onTap: () {\n            if (onItemTap != null) {\n              onItemTap!(i);\n            }\n          },\n          child: Text(\n            items[i],\n            style: i == items.length - 1 ? activeTextStyle : textStyle,\n          ),\n        ),\n      );\n      if (i != items.length - 1) {\n        children.add(const Text(\" > \"));\n      }\n    }\n    return Row(\n      children: [\n        ...(children.length == 1\n            ? children.sublist(0, 1)\n            : children.sublist(0, 2)),\n        children.length > 2\n            ? Expanded(\n                child: SingleChildScrollView(\n                  reverse: true,\n                  scrollDirection: Axis.horizontal,\n                  child: Row(\n                    children: children.sublist(2),\n                  ),\n                ),\n              )\n            : null,\n      ].where((e) => e != null).map((e) => e!).toList(),\n    ).paddingOnly(right: 12);\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/views/buid_task_list_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter_context_menu/flutter_context_menu.dart';\nimport 'package:get/get.dart';\nimport 'package:styled_widget/styled_widget.dart';\n\nimport '../../api/api.dart';\nimport '../../api/model/request.dart';\nimport '../../api/model/resolve_task.dart';\nimport '../../api/model/task.dart';\nimport '../../util/message.dart';\nimport '../../util/util.dart';\nimport '../modules/app/controllers/app_controller.dart';\nimport '../modules/task/controllers/task_controller.dart';\nimport '../modules/task/controllers/task_downloaded_controller.dart';\nimport '../modules/task/controllers/task_downloading_controller.dart';\nimport '../modules/task/views/task_view.dart';\nimport '../routes/app_pages.dart';\nimport 'file_icon.dart';\n\nclass BuildTaskListView extends GetView {\n  final List<Task> tasks;\n  final List<String> selectedTaskIds;\n\n  const BuildTaskListView({\n    Key? key,\n    required this.tasks,\n    required this.selectedTaskIds,\n  }) : super(key: key);\n\n  @override\n  Widget build(BuildContext context) {\n    return Scaffold(\n        floatingActionButton: FloatingActionButton(\n          onPressed: () {\n            Get.rootDelegate.toNamed(Routes.CREATE);\n          },\n          tooltip: 'create'.tr,\n          child: const Icon(Icons.add),\n        ),\n        body: Obx(() {\n          return buildTaskList(context, tasks);\n        }));\n  }\n\n  Widget buildTaskList(BuildContext context, tasks) {\n    return ListView.builder(\n      itemCount: tasks.length + 1,\n      itemBuilder: (context, index) {\n        if (index == tasks.length) {\n          return const SizedBox(height: 75);\n        }\n        return item(context, tasks[index]);\n      },\n    );\n  }\n\n  Widget item(BuildContext context, Task task) {\n    bool isDone() {\n      return task.status == Status.done;\n    }\n\n    bool isRunning() {\n      return task.status == Status.running;\n    }\n\n    bool isSelect() {\n      return selectedTaskIds.contains(task.id);\n    }\n\n    bool isFolderTask() {\n      return task.isFolder;\n    }\n\n    Future<void> showDeleteDialog(List<String> ids) {\n      final appController = Get.find<AppController>();\n\n      final context = Get.context!;\n\n      return showDialog<void>(\n          context: context,\n          barrierDismissible: false,\n          builder: (_) => AlertDialog(\n                title: Text(\n                    'deleteTask'.trParams({'count': ids.length.toString()})),\n                content: Obx(() => CheckboxListTile(\n                    value: appController\n                        .downloaderConfig.value.extra.lastDeleteTaskKeep,\n                    title: Text('deleteTaskTip'.tr,\n                        style: context.textTheme.bodyLarge),\n                    onChanged: (v) {\n                      appController.downloaderConfig.update((val) {\n                        val!.extra.lastDeleteTaskKeep = v!;\n                      });\n                    })),\n                actions: [\n                  TextButton(\n                    child: Text('cancel'.tr),\n                    onPressed: () => Get.back(),\n                  ),\n                  TextButton(\n                    child: Text(\n                      'confirm'.tr,\n                      style: const TextStyle(color: Colors.redAccent),\n                    ),\n                    onPressed: () async {\n                      try {\n                        final force = !appController\n                            .downloaderConfig.value.extra.lastDeleteTaskKeep;\n                        await appController.saveConfig();\n                        await deleteTasks(ids, force);\n                        Get.back();\n                      } catch (e) {\n                        showErrorMessage(e);\n                      }\n                    },\n                  ),\n                ],\n              ));\n    }\n\n    Future<void> showUpdateUrlDialog(BuildContext context, Task task) async {\n      final urlController = TextEditingController(text: task.meta.req.url);\n      final headerControllers =\n          <MapEntry<TextEditingController, TextEditingController>>[];\n\n      // Initialize with existing headers if available\n      if (task.meta.req.extra != null && task.meta.req.extra is Map) {\n        final extra = task.meta.req.extra as Map<String, dynamic>;\n        if (extra.containsKey('header') && extra['header'] is Map) {\n          final headers = extra['header'] as Map<String, dynamic>;\n          for (final entry in headers.entries) {\n            headerControllers.add(MapEntry(\n              TextEditingController(text: entry.key),\n              TextEditingController(text: entry.value.toString()),\n            ));\n          }\n        }\n      }\n\n      // Add one empty header row by default if none exists\n      if (headerControllers.isEmpty) {\n        headerControllers.add(MapEntry(\n          TextEditingController(),\n          TextEditingController(),\n        ));\n      }\n\n      return showDialog<void>(\n        context: context,\n        barrierDismissible: false,\n        builder: (_) => StatefulBuilder(\n          builder: (context, setState) {\n            return AlertDialog(\n              title: Text('updateUrl'.tr),\n              content: SizedBox(\n                width: 400,\n                child: SingleChildScrollView(\n                  child: Column(\n                    mainAxisSize: MainAxisSize.min,\n                    crossAxisAlignment: CrossAxisAlignment.start,\n                    children: [\n                      TextField(\n                        controller: urlController,\n                        decoration: InputDecoration(\n                          labelText: 'downloadLink'.tr,\n                          hintText: 'updateUrlDialogHint'.tr,\n                          icon: const Icon(Icons.link),\n                        ),\n                      ),\n                      const SizedBox(height: 16),\n                      Text('httpHeader'.tr,\n                          style: Theme.of(context).textTheme.titleMedium),\n                      const SizedBox(height: 8),\n                      ...headerControllers.asMap().entries.map((entry) {\n                        final index = entry.key;\n                        final controllers = entry.value;\n                        return Padding(\n                          padding: const EdgeInsets.only(bottom: 8),\n                          child: Row(\n                            children: [\n                              Expanded(\n                                child: TextField(\n                                  controller: controllers.key,\n                                  decoration: InputDecoration(\n                                    hintText: 'httpHeaderName'.tr,\n                                    isDense: true,\n                                  ),\n                                ),\n                              ),\n                              const SizedBox(width: 8),\n                              Expanded(\n                                child: TextField(\n                                  controller: controllers.value,\n                                  decoration: InputDecoration(\n                                    hintText: 'httpHeaderValue'.tr,\n                                    isDense: true,\n                                  ),\n                                ),\n                              ),\n                              IconButton(\n                                icon: const Icon(Icons.add),\n                                onPressed: () {\n                                  setState(() {\n                                    headerControllers.add(MapEntry(\n                                      TextEditingController(),\n                                      TextEditingController(),\n                                    ));\n                                  });\n                                },\n                                iconSize: 20,\n                              ),\n                              IconButton(\n                                icon: const Icon(Icons.remove),\n                                onPressed: () {\n                                  if (headerControllers.length <= 1) {\n                                    return;\n                                  }\n                                  setState(() {\n                                    headerControllers.removeAt(index);\n                                  });\n                                },\n                                iconSize: 20,\n                              ),\n                            ],\n                          ),\n                        );\n                      }),\n                    ],\n                  ),\n                ),\n              ),\n              actions: [\n                TextButton(\n                  child: Text('cancel'.tr),\n                  onPressed: () => Get.back(),\n                ),\n                TextButton(\n                  child: Text('confirm'.tr),\n                  onPressed: () async {\n                    try {\n                      // Build headers map\n                      final headers = <String, String>{};\n                      for (final entry in headerControllers) {\n                        final key = entry.key.text.trim();\n                        final value = entry.value.text.trim();\n                        if (key.isNotEmpty) {\n                          headers[key] = value;\n                        }\n                      }\n\n                      // Build ReqExtraHttp\n                      final reqExtra = ReqExtraHttp(header: headers);\n\n                      // Create patch request\n                      final patchData = ResolveTask(\n                        req: Request(\n                          url: urlController.text.trim(),\n                          extra: reqExtra.toJson(),\n                        ),\n                      );\n\n                      await patchTask(task.id, patchData);\n                      await continueTask(task.id);\n                      Get.back();\n                    } catch (e) {\n                      showErrorMessage(e);\n                    }\n                  },\n                ),\n              ],\n            );\n          },\n        ),\n      );\n    }\n\n    List<Widget> buildActions() {\n      final list = <Widget>[];\n      if (isDone()) {\n        list.add(IconButton(\n          icon: const Icon(Icons.folder_open),\n          onPressed: () {\n            task.explorer();\n          },\n        ));\n      } else {\n        if (isRunning()) {\n          list.add(IconButton(\n            icon: const Icon(Icons.pause),\n            onPressed: () async {\n              try {\n                await pauseTask(task.id);\n              } catch (e) {\n                showErrorMessage(e);\n              }\n            },\n          ));\n        } else {\n          list.add(IconButton(\n            icon: const Icon(Icons.play_arrow),\n            onPressed: () async {\n              try {\n                await continueTask(task.id);\n              } catch (e) {\n                showErrorMessage(e);\n              }\n            },\n          ));\n        }\n      }\n      list.add(IconButton(\n        icon: const Icon(Icons.delete),\n        onPressed: () {\n          showDeleteDialog([task.id]);\n        },\n      ));\n      return list;\n    }\n\n    double getProgress() {\n      final totalSize = task.meta.res?.size ?? 0;\n      return totalSize <= 0 ? 0 : task.progress.downloaded / totalSize;\n    }\n\n    String getExtractionStatusText() {\n      switch (task.progress.extractStatus) {\n        case ExtractStatus.extracting:\n          return '${'extracting'.tr} ${task.progress.extractProgress}%';\n        case ExtractStatus.done:\n          return 'extractDone'.tr;\n        case ExtractStatus.error:\n          return 'extractError'.tr;\n        case ExtractStatus.waitingParts:\n          return 'waitingParts'.tr;\n        default:\n          return '';\n      }\n    }\n\n    String getProgressText() {\n      if (isDone()) {\n        return Util.fmtByte(task.meta.res!.size);\n      }\n      if (task.meta.res == null) {\n        return \"\";\n      }\n      final total = task.meta.res!.size;\n      return Util.fmtByte(task.progress.downloaded) +\n          (total > 0 ? \" / ${Util.fmtByte(total)}\" : \"\");\n    }\n\n    // Get percentage text, e.g. \" (50.5%)\"\n    String getPercentText() {\n      final total = task.meta.res?.size ?? 0;\n      if (total <= 0 || isDone()) return \"\";\n      double p = getProgress();\n      return \"(${(p * 100).toStringAsFixed(1)}%)\";\n    }\n\n    // Get ETA text, e.g. \"00:05:30\"\n    String getEtaText() {\n      if (isDone()) return \"\";\n      if (!isRunning()) return \"\";\n\n      final total = task.meta.res?.size ?? 0;\n      final downloaded = task.progress.downloaded;\n      final speed = task.progress.speed;\n\n      // If speed is 0 or total unknown, don't show time\n      if (total <= 0 || speed <= 0) {\n        return \"\";\n      }\n\n      final remainingBytes = total - downloaded;\n      // If remaining bytes <= 0, download is essentially complete\n      if (remainingBytes <= 0) {\n        return \"\";\n      }\n\n      // Use ceiling division to avoid showing 0 seconds when there's still data remaining\n      final remainingSeconds = (remainingBytes + speed - 1) ~/ speed;\n\n      // If time is too long (e.g. > 1 day), return > 1d\n      if (remainingSeconds > 86400) return \"> 1d\";\n\n      Duration duration = Duration(seconds: remainingSeconds);\n      String twoDigits(int n) => n.toString().padLeft(2, \"0\");\n\n      if (duration.inHours > 0) {\n        return \"${twoDigits(duration.inHours)}:${twoDigits(duration.inMinutes.remainder(60))}:${twoDigits(duration.inSeconds.remainder(60))}\";\n      } else {\n        return \"${twoDigits(duration.inMinutes.remainder(60))}:${twoDigits(duration.inSeconds.remainder(60))}\";\n      }\n    }\n\n    final appController = Get.find<AppController>();\n    final taskController = Get.find<TaskController>();\n    final taskListController = taskController.tabIndex.value == 0\n        ? Get.find<TaskDownloadingController>()\n        : Get.find<TaskDownloadedController>();\n\n    // Filter selected task ids that are still in the task list\n    filterSelectedTaskIds(Iterable<String> selectedTaskIds) => selectedTaskIds\n        .where((id) => tasks.any((task) => task.id == id))\n        .toList();\n\n    // Build context menu entries\n    final contextMenuEntries = <ContextMenuEntry>[\n      MenuItem(\n        icon: const Icon(Icons.checklist),\n        label: Text('selectAll'.tr),\n        onSelected: (_) {\n          if (tasks.isEmpty) return;\n          if (selectedTaskIds.isNotEmpty) {\n            taskListController.selectedTaskIds([]);\n          } else {\n            taskListController.selectedTaskIds(tasks.map((e) => e.id).toList());\n          }\n        },\n      ),\n      MenuItem(\n        icon: const Icon(Icons.check),\n        label: Text('select'.tr),\n        onSelected: (_) {\n          if (isSelect()) {\n            taskListController.selectedTaskIds(taskListController\n                .selectedTaskIds\n                .where((element) => element != task.id)\n                .toList());\n          } else {\n            taskListController.selectedTaskIds(\n                [...taskListController.selectedTaskIds, task.id]);\n          }\n        },\n      ),\n      const MenuDivider(),\n      MenuItem(\n        icon: const Icon(Icons.play_arrow),\n        label: Text('continue'.tr),\n        enabled: !isDone() && !isRunning(),\n        onSelected: (_) async {\n          try {\n            await continueAllTasks(filterSelectedTaskIds(\n                {...taskListController.selectedTaskIds, task.id}));\n          } finally {\n            taskListController.selectedTaskIds([]);\n          }\n        },\n      ),\n      MenuItem(\n        icon: const Icon(Icons.pause),\n        label: Text('pause'.tr),\n        enabled: !isDone() && isRunning(),\n        onSelected: (_) async {\n          try {\n            await pauseAllTasks(filterSelectedTaskIds(\n                {...taskListController.selectedTaskIds, task.id}));\n          } finally {\n            taskListController.selectedTaskIds([]);\n          }\n        },\n      ),\n      MenuItem(\n        icon: const Icon(Icons.delete),\n        label: Text('delete'.tr),\n        onSelected: (_) async {\n          try {\n            await showDeleteDialog(filterSelectedTaskIds(\n                {...taskListController.selectedTaskIds, task.id}));\n          } finally {\n            taskListController.selectedTaskIds([]);\n          }\n        },\n      ),\n      // Update URL submenu - only enabled for HTTP tasks in pause or error status\n      const MenuDivider(),\n      MenuItem.submenu(\n        icon: const Icon(Icons.link),\n        label: Text('updateUrl'.tr),\n        enabled: task.protocol == Protocol.http &&\n            (task.status == Status.pause || task.status == Status.error),\n        items: [\n          MenuItem(\n            icon: const Icon(Icons.edit_note),\n            label: Text('updateUrlManual'.tr),\n            onSelected: (_) async {\n              await showUpdateUrlDialog(context, task);\n            },\n          ),\n          MenuItem(\n            icon: Icon(appController.pendingUpdateTask.value?.id == task.id\n                ? Icons.cancel\n                : Icons.sensors),\n            label: Text(appController.pendingUpdateTask.value?.id == task.id\n                ? 'updateUrlCancelListen'.tr\n                : 'updateUrlListen'.tr),\n            onSelected: (_) {\n              if (appController.pendingUpdateTask.value?.id == task.id) {\n                appController.pendingUpdateTask.value = null;\n              } else {\n                appController.pendingUpdateTask.value =\n                    PendingUpdateTask(id: task.id, name: task.name);\n              }\n            },\n          ),\n        ],\n      ),\n    ];\n\n    final contextMenu = ContextMenu(\n      entries: contextMenuEntries,\n      padding: const EdgeInsets.all(8.0),\n    );\n\n    return ContextMenuRegion(\n      contextMenu: contextMenu,\n      child: Obx(\n        () => Card(\n            elevation: 4.0,\n            shape: isSelect()\n                ? RoundedRectangleBorder(\n                    borderRadius: BorderRadius.circular(8.0),\n                    side: BorderSide(\n                      color: Theme.of(context).colorScheme.primary,\n                      width: 2.0,\n                    ),\n                  )\n                : null,\n            child: InkWell(\n              onTap: () {\n                taskController.scaffoldKey.currentState?.openEndDrawer();\n                taskController.selectTask.value = task;\n              },\n              onDoubleTap: () {\n                task.open();\n              },\n              child: Column(\n                mainAxisSize: MainAxisSize.min,\n                children: [\n                  ListTile(\n                      title: Row(\n                        children: [\n                          Expanded(child: Text(task.name)),\n                          // Show pending update indicator\n                          if (appController.pendingUpdateTask.value?.id ==\n                              task.id)\n                            Tooltip(\n                              message: 'updateUrlListeningTip'.tr,\n                              child: Padding(\n                                padding: const EdgeInsets.only(left: 8),\n                                child: Icon(Icons.hearing,\n                                    size: 16,\n                                    color:\n                                        Theme.of(context).colorScheme.primary),\n                              ),\n                            ),\n                        ],\n                      ),\n                      leading: Icon(\n                        fileIcon(task.name,\n                            isFolder: isFolderTask(),\n                            isBitTorrent: task.protocol == Protocol.bt),\n                      )),\n                  Row(\n                    children: [\n                      // Left side: Progress text + Percentage\n                      Expanded(\n                          child: Row(\n                        children: [\n                          Flexible(\n                            child: Text(\n                              getProgressText(),\n                              style: Get.textTheme.bodyLarge\n                                  ?.copyWith(color: Get.theme.disabledColor),\n                              overflow: TextOverflow.ellipsis,\n                            ),\n                          ),\n                          // Hide percentage on mobile\n                          if (!Util.isMobile() &&\n                              getPercentText().isNotEmpty) ...[\n                            const SizedBox(width: 4),\n                            Text(\n                              getPercentText(),\n                              style: Get.textTheme.bodyLarge\n                                  ?.copyWith(color: Get.theme.disabledColor),\n                            ),\n                          ],\n                        ],\n                      ).padding(left: 18)),\n                      // Right side: ETA + Speed + Actions\n                      Row(\n                        mainAxisSize: MainAxisSize.min,\n                        children: [\n                          // Only show ETA on wider screens\n                          if (!Util.isMobile() && getEtaText().isNotEmpty) ...[\n                            Text(\n                              getEtaText(),\n                              style: Get.textTheme.titleSmall,\n                            ),\n                            Text(\n                              \" | \",\n                              style: Get.textTheme.titleSmall?.copyWith(\n                                color: Get.theme.disabledColor,\n                                fontWeight: FontWeight.w300,\n                              ),\n                            ).padding(horizontal: 4),\n                          ],\n                          Text(\"${Util.fmtByte(task.progress.speed)}/s\",\n                              style: Get.textTheme.titleSmall),\n                          ...buildActions()\n                        ],\n                      ),\n                    ],\n                  ),\n                  isDone()\n                      ? Container()\n                      : LinearProgressIndicator(\n                          value: getProgress(),\n                        ),\n                  // Extraction status row\n                  if (task.progress.extractStatus != ExtractStatus.none)\n                    Builder(builder: (context) {\n                      final isExtracting = task.progress.extractStatus ==\n                          ExtractStatus.extracting;\n                      final isExtractDone =\n                          task.progress.extractStatus == ExtractStatus.done;\n                      final isWaitingParts = task.progress.extractStatus ==\n                          ExtractStatus.waitingParts;\n                      final statusColor = isExtracting\n                          ? Get.theme.colorScheme.primary\n                          : (isExtractDone\n                              ? Colors.green\n                              : isWaitingParts\n                                  ? Colors.orange\n                                  : Colors.red);\n                      return Column(\n                        children: [\n                          Row(\n                            children: [\n                              Expanded(\n                                child: Row(\n                                  children: [\n                                    Icon(\n                                      isExtracting\n                                          ? Icons.unarchive\n                                          : (isExtractDone\n                                              ? Icons.check_circle\n                                              : Icons.error),\n                                      size: 16,\n                                      color: statusColor,\n                                    ),\n                                    const SizedBox(width: 4),\n                                    Text(\n                                      getExtractionStatusText(),\n                                      style: Get.textTheme.bodySmall?.copyWith(\n                                        color: statusColor,\n                                      ),\n                                    ),\n                                  ],\n                                ).padding(left: 18),\n                              ),\n                            ],\n                          ).padding(top: 4, bottom: 8),\n                          // Extraction progress bar\n                          if (isExtracting)\n                            LinearProgressIndicator(\n                              value: task.progress.extractProgress / 100.0,\n                              backgroundColor:\n                                  Get.theme.colorScheme.surfaceContainerHighest,\n                              valueColor: AlwaysStoppedAnimation<Color>(\n                                  Get.theme.colorScheme.secondary),\n                            ),\n                        ],\n                      );\n                    }),\n                ],\n              ),\n            )).padding(horizontal: 14, top: 8),\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/views/check_list_view.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:get/get.dart';\n\nclass CheckListView extends StatefulWidget {\n  final List<String> items;\n  final List<String> checked;\n  final void Function(List<String> value) onChanged;\n\n  const CheckListView({\n    Key? key,\n    required this.items,\n    required this.checked,\n    required this.onChanged,\n  }) : super(key: key);\n\n  @override\n  State<CheckListView> createState() => _CheckListView();\n}\n\nclass _CheckListView extends State<CheckListView> {\n  bool get _allChecked => _checked.length == _items.length;\n\n  List<String> get _checked => widget.checked;\n\n  List<String> get _items => widget.items;\n\n  @override\n  Widget build(BuildContext context) {\n    return Container(\n        margin: const EdgeInsets.only(top: 10),\n        decoration: BoxDecoration(\n            border: Border.all(color: Colors.grey, width: 1),\n            borderRadius: BorderRadius.circular(5)),\n        child: Column(\n          children: [\n            CheckboxListTile(\n              value: _allChecked,\n              onChanged: (value) {\n                setState(() {\n                  _checked.clear();\n                  if (value!) {\n                    _checked.addAll(_items);\n                  }\n                  widget.onChanged(_checked);\n                });\n              },\n              title: Text('selectAll'.tr),\n            ),\n            Expanded(\n              child: ListView.builder(\n                  itemCount: _items.length,\n                  itemBuilder: (context, index) {\n                    var item = _items[index];\n                    return CheckboxListTile(\n                      value: _checked.contains(item),\n                      onChanged: (value) {\n                        setState(() {\n                          _checked.contains(item)\n                              ? _checked.remove(item)\n                              : _checked.add(item);\n                          widget.onChanged(_checked);\n                        });\n                      },\n                      title: Text(item),\n                    );\n                  }),\n            ),\n          ],\n        ));\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/views/compact_checkbox.dart",
    "content": "import 'package:flutter/material.dart';\n\nclass CompactCheckbox extends StatefulWidget {\n  final String label;\n  final bool value;\n  final ValueChanged<bool?>? onChanged;\n  final double? scale;\n  final TextStyle? textStyle;\n\n  const CompactCheckbox({\n    super.key,\n    required this.label,\n    required this.value,\n    this.onChanged,\n    this.scale,\n    this.textStyle,\n  });\n\n  @override\n  State<CompactCheckbox> createState() => _CompactCheckboxState();\n}\n\nclass _CompactCheckboxState extends State<CompactCheckbox> {\n  late bool _value;\n\n  @override\n  void initState() {\n    super.initState();\n    _value = widget.value;\n  }\n\n  valueChanged(bool? value) {\n    setState(() {\n      _value = value!;\n    });\n    widget.onChanged?.call(_value);\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    final checkbox = Checkbox(\n      value: _value,\n      onChanged: valueChanged,\n    );\n\n    return TextButton(\n      onPressed: () {\n        valueChanged(!_value);\n      },\n      child: Row(\n        children: [\n          widget.scale == null\n              ? checkbox\n              : Transform.scale(\n                  scale: widget.scale,\n                  child: checkbox,\n                ),\n          Text(\n            widget.label,\n            style: widget.textStyle,\n          ),\n        ],\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/views/copy_button.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:flutter/services.dart';\n\nimport '../../util/message.dart';\n\nclass CopyButton extends StatefulWidget {\n  final String? url;\n\n  const CopyButton(this.url, {Key? key}) : super(key: key);\n\n  @override\n  State<CopyButton> createState() => _CopyButtonState();\n}\n\nclass _CopyButtonState extends State<CopyButton> {\n  bool success = false;\n\n  copy() {\n    final url = widget.url;\n    if (url != null) {\n      try {\n        Clipboard.setData(ClipboardData(text: url));\n        setState(() {\n          success = true;\n        });\n        Future.delayed(const Duration(milliseconds: 300), () {\n          setState(() {\n            success = false;\n          });\n        });\n      } catch (e) {\n        showErrorMessage(e);\n      }\n    }\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    return IconButton(\n      icon: success ? const Icon(Icons.check_circle) : const Icon(Icons.copy),\n      onPressed: copy,\n    );\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/views/directory_selector.dart",
    "content": "import 'dart:io';\n\nimport 'package:device_info_plus/device_info_plus.dart';\nimport 'package:file_picker/file_picker.dart';\nimport 'package:flutter/material.dart';\nimport 'package:get/get.dart';\nimport 'package:lecle_downloads_path_provider/lecle_downloads_path_provider.dart';\nimport 'package:path_provider/path_provider.dart';\nimport 'package:permission_handler/permission_handler.dart';\nimport 'package:toggle_switch/toggle_switch.dart';\n\nimport '../../util/message.dart';\nimport '../../util/util.dart';\n\nfinal deviceInfo = DeviceInfoPlugin();\n\n// Placeholder information for download directory\nclass PathPlaceholder {\n  final String placeholder;\n  final String description;\n  final String example;\n\n  const PathPlaceholder({\n    required this.placeholder,\n    required this.description,\n    required this.example,\n  });\n}\n\n// Available placeholders for download directory\nList<PathPlaceholder> getPathPlaceholders() {\n  final now = DateTime.now();\n  final year = now.year.toString();\n  final month = now.month.toString().padLeft(2, '0');\n  final day = now.day.toString().padLeft(2, '0');\n\n  return [\n    PathPlaceholder(\n      placeholder: '%year%',\n      description: 'placeholderYear'.tr,\n      example: year,\n    ),\n    PathPlaceholder(\n      placeholder: '%month%',\n      description: 'placeholderMonth'.tr,\n      example: month,\n    ),\n    PathPlaceholder(\n      placeholder: '%day%',\n      description: 'placeholderDay'.tr,\n      example: day,\n    ),\n    PathPlaceholder(\n      placeholder: '%date%',\n      description: 'placeholderDate'.tr,\n      example: '$year-$month-$day',\n    ),\n  ];\n}\n\n// Render placeholders in a path with actual values\nString renderPathPlaceholders(String path) {\n  if (path.isEmpty) return path;\n\n  final now = DateTime.now();\n  final year = now.year.toString();\n  final month = now.month.toString().padLeft(2, '0');\n  final day = now.day.toString().padLeft(2, '0');\n  final date = '$year-$month-$day';\n\n  return path\n      .replaceAll('%year%', year)\n      .replaceAll('%month%', month)\n      .replaceAll('%day%', day)\n      .replaceAll('%date%', date);\n}\n\nclass DirectorySelector extends StatefulWidget {\n  final TextEditingController controller;\n  final bool showLabel;\n  final bool showAndoirdToggle;\n  final bool allowEdit;\n  final bool showPlaceholderButton;\n  final VoidCallback? onEditComplete;\n  final bool showRenderedPlaceholders;\n\n  const DirectorySelector({\n    Key? key,\n    required this.controller,\n    this.showLabel = true,\n    this.showAndoirdToggle = false,\n    this.allowEdit = false,\n    this.showPlaceholderButton = false,\n    this.onEditComplete,\n    this.showRenderedPlaceholders = false,\n  }) : super(key: key);\n\n  @override\n  State<DirectorySelector> createState() => _DirectorySelectorState();\n}\n\nclass _DirectorySelectorState extends State<DirectorySelector> {\n  @override\n  Widget build(BuildContext context) {\n    Widget? buildSelectWidget() {\n      if (Util.isDesktop()) {\n        return IconButton(\n            icon: const Icon(Icons.folder_open),\n            onPressed: () async {\n              var dir = await FilePicker.platform.getDirectoryPath();\n              if (dir != null) {\n                widget.controller.text = dir;\n              }\n            });\n      }\n      // After Android 11, access to external storage is increasingly restricted, so it no longer supports selecting the download directory. However, if you do not download in external storage, all downloaded files will be deleted after the application is uninstalled.\n      // Fortunately, so far, most Android devices can still access the system download directory.\n      // For the sake of user experience, it is decided to only support selecting the application's internal directory and the system download directory. Also, a test for file write permission is performed when selecting the system download directory. If it cannot be written, selection is not allowed.\n      if (Util.isAndroid() && widget.showAndoirdToggle) {\n        final isSwitchToDownloadDir =\n            widget.controller.text.endsWith('/Gopeed');\n\n        return ToggleSwitch(\n          initialLabelIndex: isSwitchToDownloadDir ? 1 : 0,\n          totalSwitches: 2,\n          icons: const [Icons.home, Icons.download],\n          customWidths: const [50, 50],\n          onToggle: (index) async {\n            if (index == 0) {\n              widget.controller.text =\n                  (await getExternalStorageDirectory())?.path ??\n                      (await getApplicationDocumentsDirectory()).path;\n            } else {\n              widget.controller.text =\n                  '${(await DownloadsPath.downloadsDirectory())!.path}/Gopeed';\n            }\n          },\n          cancelToggle: (index) async {\n            if (index == 0) {\n              return false;\n            }\n\n            final downloadDir =\n                (await DownloadsPath.downloadsDirectory())?.path;\n            if (downloadDir == null) {\n              return true;\n            }\n\n            // Check and request external storage permission when sdk version < 30 (android 11)\n            if ((await deviceInfo.androidInfo).version.sdkInt < 30) {\n              var status = await Permission.storage.status;\n              if (!status.isGranted) {\n                status = await Permission.storage.request();\n                if (!status.isGranted) {\n                  showErrorMessage('noStoragePermission'.tr);\n                  return true;\n                }\n              }\n            }\n\n            // Check write permission\n            final fileRandomeName =\n                \"test_${DateTime.now().millisecondsSinceEpoch}.tmp\";\n            final testFile = File('$downloadDir/Gopeed/$fileRandomeName');\n            try {\n              await testFile.create(recursive: true);\n              await testFile.writeAsString('test');\n              await testFile.delete();\n              return false;\n            } catch (e) {\n              showErrorMessage(e);\n              return true;\n            }\n          },\n        ).marginOnly(left: 10);\n      }\n      return null;\n    }\n\n    Widget? buildPlaceholderButton() {\n      if (!widget.showPlaceholderButton) return null;\n\n      return PopupMenuButton<String>(\n        icon: const Icon(Icons.data_object),\n        tooltip: 'insertPlaceholder'.tr,\n        onSelected: (String placeholder) {\n          final currentText = widget.controller.text;\n          final selection = widget.controller.selection;\n          final cursorPosition = selection.baseOffset >= 0\n              ? selection.baseOffset\n              : currentText.length;\n\n          final newText = currentText.substring(0, cursorPosition) +\n              placeholder +\n              currentText.substring(cursorPosition);\n          widget.controller.text = newText;\n          widget.controller.selection = TextSelection.fromPosition(\n            TextPosition(offset: cursorPosition + placeholder.length),\n          );\n        },\n        itemBuilder: (BuildContext context) {\n          final placeholders = getPathPlaceholders();\n          return placeholders.map((p) {\n            return PopupMenuItem<String>(\n              value: p.placeholder,\n              child: Column(\n                crossAxisAlignment: CrossAxisAlignment.start,\n                children: [\n                  Text(\n                    '${p.placeholder} - ${p.description}',\n                    style: const TextStyle(fontWeight: FontWeight.bold),\n                  ),\n                  Text(\n                    'example'.trParams({'value': p.example}),\n                    style: TextStyle(\n                      fontSize: 12,\n                      color: Theme.of(context).hintColor,\n                    ),\n                  ),\n                ],\n              ),\n            );\n          }).toList();\n        },\n      );\n    }\n\n    return Row(\n      children: [\n        Expanded(\n            child: ValueListenableBuilder<TextEditingValue>(\n          valueListenable: widget.controller,\n          builder: (context, value, child) {\n            Widget? suffix;\n            if (widget.showRenderedPlaceholders && value.text.contains('%')) {\n              final renderedPath = renderPathPlaceholders(value.text);\n              // Show rendered path as a chip/badge in the input field\n              suffix = Container(\n                padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),\n                margin: const EdgeInsets.only(right: 8),\n                decoration: BoxDecoration(\n                  color: Colors.blue[50],\n                  borderRadius: BorderRadius.circular(4),\n                  border: Border.all(color: Colors.blue[200]!),\n                ),\n                child: Row(\n                  mainAxisSize: MainAxisSize.min,\n                  children: [\n                    Icon(Icons.arrow_forward,\n                        size: 14, color: Colors.blue[700]),\n                    const SizedBox(width: 4),\n                    Flexible(\n                      child: Text(\n                        renderedPath,\n                        style: TextStyle(\n                          color: Colors.blue[700],\n                          fontSize: 12,\n                          fontWeight: FontWeight.w500,\n                        ),\n                        overflow: TextOverflow.ellipsis,\n                      ),\n                    ),\n                  ],\n                ),\n              );\n            }\n\n            return TextFormField(\n              readOnly:\n                  widget.allowEdit ? false : (Util.isWeb() ? false : true),\n              controller: widget.controller,\n              decoration: widget.showLabel\n                  ? InputDecoration(\n                      labelText: 'downloadDir'.tr,\n                      suffix: suffix,\n                    )\n                  : InputDecoration(\n                      suffix: suffix,\n                    ),\n              validator: (v) {\n                return v!.trim().isNotEmpty ? null : 'downloadDirValid'.tr;\n              },\n              onEditingComplete: widget.onEditComplete,\n              onTapOutside: (event) {\n                // Call onEditComplete when user taps outside the field\n                widget.onEditComplete?.call();\n              },\n            );\n          },\n        )),\n        buildSelectWidget(),\n        buildPlaceholderButton(),\n      ].where((e) => e != null).map((e) => e!).toList(),\n    );\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/views/file_icon.dart",
    "content": "import 'package:flutter/material.dart';\n\nimport '../../icon/gopeed_icons.dart';\n\nfinal Map<IconData, List<String>> iconConfigMap = {\n  Gopeed.install: ['exe', 'msi', 'dmg', 'deb', 'rpm'],\n  Gopeed.android: ['apk'],\n  Gopeed.app_store_ios: ['ipa'],\n  Gopeed.file_bt: ['torrent'],\n  Gopeed.cd: ['iso'],\n  Gopeed.html5: ['html', 'htm'],\n  Gopeed.file_alt: ['txt', 'md', 'log', 'csv', 'tsv', 'json', 'yaml', 'yml'],\n  Gopeed.file_pdf: ['pdf'],\n  Gopeed.file_word: ['doc', 'docx'],\n  Gopeed.file_excel: ['xls', 'xlsx'],\n  Gopeed.file_powerpoint: ['ppt', 'pptx'],\n  Gopeed.file_archive: ['zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz'],\n  Gopeed.file_image: [\n    'jpg',\n    'jpeg',\n    'png',\n    'gif',\n    'bmp',\n    'tiff',\n    'svg',\n    'webp'\n  ],\n  Gopeed.file_audio: ['mp3', 'wav', 'flac', 'aac', 'ogg', 'wma', 'm4a'],\n  Gopeed.file_video: ['mp4', 'avi', 'mkv', 'mov', 'wmv', 'flv', 'webm'],\n  Gopeed.file_code: [\n    'js',\n    'css',\n    'json',\n    'xml',\n    'java',\n    'cpp',\n    'dart',\n    'py',\n    'rb',\n    'php',\n    'ts',\n    'swift',\n    'go',\n    'rs'\n  ],\n  Gopeed.file: [''],\n};\n\nfinal Map<String, IconData> _iconCache = Map.fromEntries(\n  iconConfigMap.entries.expand(\n    (entry) => entry.value.map((ext) => MapEntry(ext, entry.key)),\n  ),\n);\n\nString fileExt(String? name) {\n  if (name == null) {\n    return '';\n  }\n\n  final ext = name.split('.').last;\n  if (ext.length > 8) {\n    return '';\n  }\n\n  return ext.toLowerCase();\n}\n\nIconData fileIcon(String? name,\n    {bool isFolder = false, bool isBitTorrent = false}) {\n  if (isFolder) {\n    return isBitTorrent ? Gopeed.folder_bt : Gopeed.folder;\n  }\n\n  final ext = fileExt(name);\n  return _iconCache[ext] ?? Gopeed.file;\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/views/file_tree_view.dart",
    "content": "import 'package:checkable_treeview/checkable_treeview.dart';\nimport 'package:flutter/material.dart';\nimport 'package:get/get.dart';\nimport 'package:toggle_switch/toggle_switch.dart';\n\nimport '../../api/model/resource.dart';\nimport '../../icon/gopeed_icons.dart';\nimport '../../util/util.dart';\nimport 'file_icon.dart';\nimport 'responsive_builder.dart';\nimport 'sort_icon_button.dart';\n\nconst _toggleSwitchIcons = [\n  Gopeed.file_video,\n  Gopeed.file_audio,\n  Gopeed.file_image,\n];\nconst _sizeGapWidth = 72.0;\n\nclass FileTreeView extends StatefulWidget {\n  final List<FileInfo> files;\n  final List<int> initialValues;\n  final Function(List<int>) onSelectionChanged;\n\n  const FileTreeView(\n      {Key? key,\n      required this.files,\n      required this.initialValues,\n      required this.onSelectionChanged})\n      : super(key: key);\n\n  @override\n  State<FileTreeView> createState() => _FileTreeViewState();\n}\n\nclass _FileTreeViewState extends State<FileTreeView> {\n  late GlobalKey<TreeViewState<int>> key;\n  late int totalSize;\n  int? toggleSwitchIndex;\n\n  @override\n  void initState() {\n    super.initState();\n    key = GlobalKey<TreeViewState<int>>();\n    totalSize = widget.files\n        .fold(0, (previousValue, element) => previousValue + element.size);\n    widget.onSelectionChanged(widget.initialValues);\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    final selectedFileCount =\n        key.currentState?.getSelectedValues().where((e) => e != null).length ??\n            widget.files.length;\n    final selectedFileSize = calcSelectedSize(null);\n\n    final filterRow = InkWell(\n      onTap: () {},\n      child: ToggleSwitch(\n        minHeight: 32,\n        cornerRadius: 8,\n        doubleTapDisable: true,\n        inactiveBgColor: Theme.of(context).dividerColor,\n        activeBgColor: [Theme.of(context).colorScheme.primary],\n        initialLabelIndex: toggleSwitchIndex,\n        icons: _toggleSwitchIcons,\n        onToggle: (index) {\n          toggleSwitchIndex = index;\n          if (index == null) {\n            key.currentState?.setSelectedValues(List.empty());\n            return;\n          }\n\n          final iconFileExtArr = iconConfigMap[_toggleSwitchIcons[index]] ?? [];\n          final selectedFileIndexes = widget.files\n              .asMap()\n              .entries\n              .where((e) => iconFileExtArr.contains(fileExt(e.value.name)))\n              .map((e) => e.key)\n              .toList();\n          key.currentState?.setSelectedValues(selectedFileIndexes);\n        },\n      ),\n    );\n    final countRow = Row(\n      children: [\n        Text('fileSelectedCount'.tr),\n        Text(\n          selectedFileCount.toString(),\n          style: Theme.of(context).textTheme.bodySmall,\n        ),\n        const SizedBox(width: 12),\n        Text('fileSelectedSize'.tr),\n        Text(\n          selectedFileCount > 0 && selectedFileSize == 0\n              ? 'unknown'.tr\n              : Util.fmtByte(selectedFileSize),\n          style: Theme.of(context).textTheme.bodySmall,\n        ),\n      ],\n    );\n\n    return Column(\n      crossAxisAlignment: CrossAxisAlignment.start,\n      children: [\n        Expanded(\n          child: Container(\n            decoration: BoxDecoration(\n              border: Border.all(color: Theme.of(context).dividerColor),\n              borderRadius: BorderRadius.circular(4),\n            ),\n            child: TreeView(\n              key: key,\n              nodes: buildTreeNodes(),\n              showExpandCollapseButton: true,\n              showSelectAll: true,\n              onSelectionChanged: (selectedValues) {\n                setState(() {});\n                widget.onSelectionChanged(selectedValues\n                    .where((e) => e != null)\n                    .map((e) => e!)\n                    .toList());\n              },\n              selectAllTrailing: (context) {\n                return Row(\n                  mainAxisAlignment: MainAxisAlignment.spaceBetween,\n                  children: [\n                    Row(\n                      mainAxisAlignment: MainAxisAlignment.end,\n                      children: [\n                        Text('name'.tr),\n                        SortIconButton(\n                          onStateChanged: (state) {\n                            switch (state) {\n                              case SortState.asc:\n                                key.currentState?.sort((p0, p1) {\n                                  return (p0.label as Text)\n                                      .data!\n                                      .compareTo((p1.label as Text).data!);\n                                });\n                                break;\n                              case SortState.desc:\n                                key.currentState?.sort((p0, p1) {\n                                  return (p1.label as Text)\n                                      .data!\n                                      .compareTo((p0.label as Text).data!);\n                                });\n                                break;\n                              default:\n                                key.currentState?.sort(null);\n                                break;\n                            }\n                          },\n                        ),\n                      ],\n                    ),\n                    Row(\n                      mainAxisAlignment: MainAxisAlignment.end,\n                      children: [\n                        Text('size'.tr),\n                        SortIconButton(\n                          onStateChanged: (state) {\n                            switch (state) {\n                              case SortState.asc:\n                                key.currentState?.sort((p0, p1) {\n                                  return calcSelectedSize(p0)\n                                      .compareTo(calcSelectedSize(p1));\n                                });\n                                break;\n                              case SortState.desc:\n                                key.currentState?.sort((p0, p1) {\n                                  return calcSelectedSize(p1)\n                                      .compareTo(calcSelectedSize(p0));\n                                });\n                                break;\n                              default:\n                                key.currentState?.sort(null);\n                                break;\n                            }\n                          },\n                        ),\n                      ],\n                    )\n                  ],\n                );\n              },\n            ),\n          ),\n        ),\n        const SizedBox(height: 12),\n        !ResponsiveBuilder.isNarrow(context)\n            ? Row(\n                mainAxisAlignment: MainAxisAlignment.spaceBetween,\n                crossAxisAlignment: CrossAxisAlignment.center,\n                children: [\n                  filterRow,\n                  countRow,\n                ],\n              )\n            : Column(\n                crossAxisAlignment: CrossAxisAlignment.start,\n                children: [\n                  filterRow,\n                  const SizedBox(height: 8),\n                  countRow,\n                ],\n              ),\n      ],\n    );\n  }\n\n  int calcSelectedSize(TreeNode<int>? node) {\n    if (key.currentState == null) {\n      return widget.files\n          .fold(0, (previousValue, element) => previousValue + element.size);\n    }\n\n    final selectedFileIndexes = node == null\n        ? key.currentState?.getSelectedValues()\n        : (node.value != null\n            ? [node.value]\n            : key.currentState?.getChildSelectedValues(node));\n\n    if (selectedFileIndexes == null) return 0;\n    return selectedFileIndexes.where((e) => e != null).map((e) => e!).fold(0,\n        (previousValue, element) => previousValue + widget.files[element].size);\n  }\n\n  List<TreeNode<int>> buildTreeNodes() {\n    final List<TreeNode<int>> rootNodes = [];\n    final Map<String, TreeNode<int>> dirNodes = {};\n\n    for (var i = 0; i < widget.files.length; i++) {\n      final file = widget.files[i];\n      final parts = file.path.split('/');\n      String currentPath = '';\n      TreeNode<int>? parentNode;\n\n      // Create or get directory nodes\n      for (final part in parts) {\n        if (part.isEmpty) continue;\n\n        currentPath += '/$part';\n        if (!dirNodes.containsKey(currentPath)) {\n          final node = TreeNode<int>(\n            label: Text(part),\n            icon: Icon(\n              fileIcon(part, isFolder: true),\n              size: 18,\n            ),\n            trailing: (context, node) {\n              final size = calcSelectedSize(node);\n              return size > 0\n                  ? Text(Util.fmtByte(calcSelectedSize(node)),\n                      style: Theme.of(context).textTheme.bodySmall)\n                  : const SizedBox(width: _sizeGapWidth);\n            },\n            children: [],\n          );\n          dirNodes[currentPath] = node;\n\n          if (parentNode == null) {\n            rootNodes.add(node);\n          } else {\n            parentNode.children.add(node);\n          }\n        }\n        parentNode = dirNodes[currentPath];\n      }\n\n      // Create file node using file.name\n      final fileNode = TreeNode<int>(\n        label: Text(file.name),\n        value: i,\n        icon: Icon(fileIcon(file.name, isFolder: false), size: 18),\n        trailing: (context, node) {\n          return file.size > 0\n              ? Text(Util.fmtByte(file.size),\n                  style: Theme.of(context).textTheme.bodySmall)\n              : const SizedBox(width: _sizeGapWidth);\n        },\n        isSelected: widget.initialValues.contains(i),\n        children: [],\n      );\n\n      // Add file node to parent or root\n      if (parentNode != null) {\n        parentNode.children.add(fileNode);\n      } else {\n        rootNodes.add(fileNode);\n      }\n    }\n\n    return rootNodes;\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/views/icon_button_loading.dart",
    "content": "import 'package:flutter/material.dart';\n\nclass IconButtonLoading extends StatefulWidget {\n  final Widget icon;\n  final VoidCallback? onPressed;\n  final IconButtonLoadingController controller;\n\n  const IconButtonLoading(\n      {Key? key,\n      required this.icon,\n      required this.onPressed,\n      required this.controller})\n      : super(key: key);\n\n  @override\n  State<IconButtonLoading> createState() => _IconButtonLoadingState();\n}\n\nclass _IconButtonLoadingState extends State<IconButtonLoading> {\n  @override\n  Widget build(BuildContext context) {\n    return ValueListenableBuilder<bool>(\n      valueListenable: widget.controller,\n      builder: (context, value, child) {\n        return IconButton(\n          key: widget.key,\n          onPressed: value ? null : widget.onPressed,\n          icon: value\n              ? const SizedBox(\n                  height: 20,\n                  width: 20,\n                  child: CircularProgressIndicator(\n                    strokeWidth: 2,\n                  ),\n                )\n              : widget.icon,\n        );\n      },\n    );\n  }\n}\n\nclass IconButtonLoadingController extends ValueNotifier<bool> {\n  IconButtonLoadingController() : super(false);\n\n  void start() {\n    value = true;\n  }\n\n  void stop() {\n    value = false;\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/views/open_in_new.dart",
    "content": "import 'package:flutter/material.dart';\nimport 'package:get/get.dart';\nimport 'package:url_launcher/url_launcher.dart';\n\nclass OpenInNew extends StatelessWidget {\n  final String text;\n  final String url;\n\n  const OpenInNew({super.key, required this.text, required this.url});\n\n  @override\n  Widget build(BuildContext context) {\n    return Stack(\n      children: [\n        ElevatedButton(\n          onPressed: () {\n            launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);\n          },\n          style: ElevatedButton.styleFrom(\n            backgroundColor: Get.theme.colorScheme.background,\n          ),\n          child: Row(\n            mainAxisSize: MainAxisSize\n                .min, // Set the row's size to be as small as possible\n            children: <Widget>[\n              Text(text),\n              const SizedBox(\n                  width: 4), // Add some space between the text and the icon\n              const Icon(\n                Icons.open_in_new,\n                size: 14,\n              ), // The icon is after the text\n            ],\n          ),\n        )\n      ],\n    );\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/views/outlined_button_loading.dart",
    "content": "import 'package:flutter/material.dart';\n\nclass OutlinedButtonLoading extends StatefulWidget {\n  final Widget child;\n  final VoidCallback onPressed;\n  final OutlinedButtonLoadingController controller;\n\n  const OutlinedButtonLoading(\n      {super.key,\n      required this.child,\n      required this.onPressed,\n      required this.controller});\n\n  @override\n  State<OutlinedButtonLoading> createState() => _OutlinedButtonLoadingState();\n}\n\nclass _OutlinedButtonLoadingState extends State<OutlinedButtonLoading> {\n  @override\n  Widget build(BuildContext context) {\n    return ValueListenableBuilder<bool>(\n      valueListenable: widget.controller,\n      builder: (context, value, child) {\n        return OutlinedButton(\n          key: widget.key,\n          onPressed: value ? null : widget.onPressed,\n          child: value\n              ? const SizedBox(\n                  height: 20,\n                  width: 20,\n                  child: CircularProgressIndicator(\n                    strokeWidth: 2,\n                  ),\n                )\n              : widget.child,\n        );\n      },\n    );\n  }\n}\n\nclass OutlinedButtonLoadingController extends ValueNotifier<bool> {\n  OutlinedButtonLoadingController() : super(false);\n\n  void start() {\n    value = true;\n  }\n\n  void stop() {\n    value = false;\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/views/responsive_builder.dart",
    "content": "import 'package:flutter/material.dart';\n\nclass ResponsiveBuilder extends StatelessWidget {\n  const ResponsiveBuilder({\n    required this.narrowBuilder,\n    required this.mediumBuilder,\n    required this.wideBuilder,\n    Key? key,\n  }) : super(key: key);\n\n  final Widget Function(\n    BuildContext context,\n    BoxConstraints constraints,\n  ) narrowBuilder;\n\n  final Widget Function(\n    BuildContext context,\n    BoxConstraints constraints,\n  ) mediumBuilder;\n\n  final Widget Function(\n    BuildContext context,\n    BoxConstraints constraints,\n  ) wideBuilder;\n\n  static bool isNarrow(BuildContext context) =>\n      MediaQuery.of(context).size.width < 768;\n\n  static bool isMedium(BuildContext context) =>\n      MediaQuery.of(context).size.width < 992 &&\n      MediaQuery.of(context).size.width >= 768;\n\n  static bool isWide(BuildContext context) =>\n      MediaQuery.of(context).size.width >= 992;\n\n  @override\n  Widget build(BuildContext context) {\n    return LayoutBuilder(\n      builder: (context, constraints) {\n        if (constraints.maxWidth >= 992) {\n          return wideBuilder(context, constraints);\n        } else if (constraints.maxWidth >= 768) {\n          return mediumBuilder(context, constraints);\n        } else {\n          return narrowBuilder(context, constraints);\n        }\n      },\n    );\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/views/sort_icon_button.dart",
    "content": "import 'package:flutter/material.dart';\n\nimport '../../icon/gopeed_icons.dart';\n\nenum SortState { none, asc, desc }\n\nclass SortIconButton extends StatefulWidget {\n  final double size;\n  final Color? color;\n  final Color? activeColor;\n  final SortState initialState;\n  final Function(SortState) onStateChanged;\n\n  const SortIconButton({\n    Key? key,\n    this.size = 18.0,\n    this.color,\n    this.activeColor,\n    this.initialState = SortState.none,\n    required this.onStateChanged,\n  }) : super(key: key);\n\n  @override\n  State<SortIconButton> createState() => _SortIconState();\n}\n\nclass _SortIconState extends State<SortIconButton> {\n  late SortState _currentState;\n\n  @override\n  void initState() {\n    super.initState();\n    _currentState = widget.initialState;\n  }\n\n  void _toggleState() {\n    setState(() {\n      switch (_currentState) {\n        case SortState.none:\n          _currentState = SortState.asc;\n          break;\n        case SortState.asc:\n          _currentState = SortState.desc;\n          break;\n        case SortState.desc:\n          _currentState = SortState.none;\n          break;\n      }\n    });\n    widget.onStateChanged(_currentState);\n  }\n\n  @override\n  Widget build(BuildContext context) {\n    return InkWell(\n      customBorder: const CircleBorder(),\n      onTap: _toggleState,\n      child: SizedBox(\n        width: widget.size,\n        height: widget.size,\n        child: _currentState == SortState.none\n            ? Icon(\n                Gopeed.sort,\n                size: widget.size,\n                color: widget.color,\n              )\n            : Column(\n                children: [\n                  ClipRect(\n                    child: Align(\n                      alignment: Alignment.topCenter,\n                      heightFactor: 0.5,\n                      child: Icon(\n                        Gopeed.sort,\n                        size: widget.size,\n                        color: _currentState == SortState.asc\n                            ? (widget.activeColor ??\n                                Theme.of(context).colorScheme.primary)\n                            : widget.color,\n                      ),\n                    ),\n                  ),\n                  ClipRect(\n                    child: Align(\n                      alignment: Alignment.bottomCenter,\n                      heightFactor: 0.5,\n                      child: Icon(\n                        Gopeed.sort,\n                        size: widget.size,\n                        color: _currentState == SortState.desc\n                            ? (widget.activeColor ??\n                                Theme.of(context).colorScheme.primary)\n                            : widget.color,\n                      ),\n                    ),\n                  ),\n                ],\n              ),\n      ),\n    );\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/app/views/text_button_loading.dart",
    "content": "import 'package:flutter/material.dart';\n\nclass TextButtonLoading extends StatefulWidget {\n  final Widget child;\n  final VoidCallback? onPressed;\n  final TextButtonLoadingController controller;\n\n  const TextButtonLoading(\n      {Key? key,\n      required this.child,\n      required this.onPressed,\n      required this.controller})\n      : super(key: key);\n\n  @override\n  State<TextButtonLoading> createState() => _TextButtonLoadingState();\n}\n\nclass _TextButtonLoadingState extends State<TextButtonLoading> {\n  @override\n  Widget build(BuildContext context) {\n    return ValueListenableBuilder<bool>(\n      valueListenable: widget.controller,\n      builder: (context, value, child) {\n        return TextButton(\n          key: widget.key,\n          onPressed: value ? null : widget.onPressed,\n          child: value\n              ? const SizedBox(\n                  height: 20,\n                  width: 20,\n                  child: CircularProgressIndicator(\n                    strokeWidth: 2,\n                  ),\n                )\n              : widget.child,\n        );\n      },\n    );\n  }\n}\n\nclass TextButtonLoadingController extends ValueNotifier<bool> {\n  TextButtonLoadingController() : super(false);\n\n  void start() {\n    value = true;\n  }\n\n  void stop() {\n    value = false;\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/core/common/libgopeed_channel.dart",
    "content": "import 'dart:convert';\n\nimport 'package:flutter/services.dart';\n\nimport 'libgopeed_interface.dart';\nimport 'start_config.dart';\n\nclass LibgopeedChannel implements LibgopeedInterface {\n  static const _channel = MethodChannel('gopeed.com/libgopeed');\n\n  @override\n  Future<int> start(StartConfig cfg) async {\n    final port = await _channel.invokeMethod('start', {\n      'cfg': jsonEncode(cfg),\n    });\n    return port as int;\n  }\n\n  @override\n  Future<void> stop() async {\n    return await _channel.invokeMethod('stop');\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/core/common/libgopeed_ffi.dart",
    "content": "import 'dart:async';\nimport 'dart:convert';\nimport 'dart:ffi';\n\nimport 'package:ffi/ffi.dart';\n\nimport '../ffi/libgopeed_bind.dart';\nimport 'libgopeed_interface.dart';\nimport 'start_config.dart';\n\nclass LibgopeedFFi implements LibgopeedInterface {\n  late LibgopeedBind _libgopeed;\n\n  LibgopeedFFi(LibgopeedBind libgopeed) {\n    _libgopeed = libgopeed;\n  }\n\n  @override\n  Future<int> start(StartConfig cfg) {\n    var completer = Completer<int>();\n    var result = _libgopeed.Start(jsonEncode(cfg).toNativeUtf8().cast());\n    if (result.r1 != nullptr) {\n      completer.completeError(Exception(result.r1.cast<Utf8>().toDartString()));\n    } else {\n      completer.complete(result.r0);\n    }\n    return completer.future;\n  }\n\n  @override\n  Future<void> stop() {\n    var completer = Completer<void>();\n    _libgopeed.Stop();\n    completer.complete();\n    return completer.future;\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/core/common/libgopeed_interface.dart",
    "content": "import 'start_config.dart';\n\nabstract class LibgopeedInterface {\n  Future<int> start(StartConfig cfg);\n\n  Future<void> stop();\n}\n"
  },
  {
    "path": "ui/flutter/lib/core/common/start_config.dart",
    "content": "import 'package:json_annotation/json_annotation.dart';\n\npart 'start_config.g.dart';\n\n@JsonSerializable()\nclass StartConfig {\n  late String network;\n  late String address;\n  late String storage;\n  late String storageDir;\n  late int refreshInterval;\n  late String apiToken;\n\n  StartConfig();\n\n  factory StartConfig.fromJson(Map<String, dynamic> json) =>\n      _$StartConfigFromJson(json);\n\n  Map<String, dynamic> toJson() => _$StartConfigToJson(this);\n}\n"
  },
  {
    "path": "ui/flutter/lib/core/common/start_config.g.dart",
    "content": "// GENERATED CODE - DO NOT MODIFY BY HAND\n\npart of 'start_config.dart';\n\n// **************************************************************************\n// JsonSerializableGenerator\n// **************************************************************************\n\nStartConfig _$StartConfigFromJson(Map<String, dynamic> json) => StartConfig()\n  ..network = json['network'] as String\n  ..address = json['address'] as String\n  ..storage = json['storage'] as String\n  ..storageDir = json['storageDir'] as String\n  ..refreshInterval = (json['refreshInterval'] as num).toInt()\n  ..apiToken = json['apiToken'] as String;\n\nMap<String, dynamic> _$StartConfigToJson(StartConfig instance) =>\n    <String, dynamic>{\n      'network': instance.network,\n      'address': instance.address,\n      'storage': instance.storage,\n      'storageDir': instance.storageDir,\n      'refreshInterval': instance.refreshInterval,\n      'apiToken': instance.apiToken,\n    };\n"
  },
  {
    "path": "ui/flutter/lib/core/entry/libgopeed_boot_browser.dart",
    "content": "import 'dart:async';\n\nimport '../common/start_config.dart';\n\nimport '../libgopeed_boot.dart';\n\nLibgopeedBoot create() => LibgopeedBootBrowser();\n\nclass LibgopeedBootBrowser implements LibgopeedBoot {\n  // do nothing\n  @override\n  Future<int> start(StartConfig cfg) async {\n    return 0;\n  }\n\n  @override\n  Future<void> stop() async {}\n}\n"
  },
  {
    "path": "ui/flutter/lib/core/entry/libgopeed_boot_native.dart",
    "content": "import 'dart:async';\nimport 'dart:ffi';\nimport 'dart:io';\n\nimport '../../util/util.dart';\nimport '../common/libgopeed_channel.dart';\nimport '../common/libgopeed_ffi.dart';\nimport '../common/libgopeed_interface.dart';\nimport '../common/start_config.dart';\nimport '../ffi/libgopeed_bind.dart';\nimport '../libgopeed_boot.dart';\n\nLibgopeedBoot create() => LibgopeedBootNative();\n\nclass LibgopeedBootNative implements LibgopeedBoot {\n  late LibgopeedInterface _libgopeed;\n\n  LibgopeedBootNative() {\n    if (Util.isDesktop()) {\n      var libName = \"libgopeed.\";\n      if (Platform.isWindows) {\n        libName += \"dll\";\n      }\n      if (Platform.isMacOS) {\n        libName += \"dylib\";\n      }\n      if (Platform.isLinux) {\n        libName += \"so\";\n      }\n      _libgopeed = LibgopeedFFi(LibgopeedBind(DynamicLibrary.open(libName)));\n    } else {\n      _libgopeed = LibgopeedChannel();\n    }\n  }\n\n  @override\n  Future<int> start(StartConfig cfg) async {\n    cfg.storage = 'bolt';\n    cfg.storageDir = Util.getStorageDir();\n    cfg.refreshInterval = 0;\n    var port = await _libgopeed.start(cfg);\n    return port;\n  }\n\n  @override\n  Future<void> stop() async {\n    await _libgopeed.stop();\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/core/ffi/libgopeed_bind.dart",
    "content": "// AUTO GENERATED FILE, DO NOT EDIT.\n//\n// Generated by `package:ffigen`.\n// ignore_for_file: type=lint\nimport 'dart:ffi' as ffi;\n\n/// Bindings to gopeed library.\nclass LibgopeedBind {\n  /// Holds the symbol lookup function.\n  final ffi.Pointer<T> Function<T extends ffi.NativeType>(String symbolName)\n      _lookup;\n\n  /// The symbols are looked up in [dynamicLibrary].\n  LibgopeedBind(ffi.DynamicLibrary dynamicLibrary)\n      : _lookup = dynamicLibrary.lookup;\n\n  /// The symbols are looked up with [lookup].\n  LibgopeedBind.fromLookup(\n      ffi.Pointer<T> Function<T extends ffi.NativeType>(String symbolName)\n          lookup)\n      : _lookup = lookup;\n\n  void __va_start(\n    ffi.Pointer<va_list> arg0,\n  ) {\n    return ___va_start(\n      arg0,\n    );\n  }\n\n  late final ___va_startPtr =\n      _lookup<ffi.NativeFunction<ffi.Void Function(ffi.Pointer<va_list>)>>(\n          '__va_start');\n  late final ___va_start =\n      ___va_startPtr.asFunction<void Function(ffi.Pointer<va_list>)>();\n\n  void __security_init_cookie() {\n    return ___security_init_cookie();\n  }\n\n  late final ___security_init_cookiePtr =\n      _lookup<ffi.NativeFunction<ffi.Void Function()>>(\n          '__security_init_cookie');\n  late final ___security_init_cookie =\n      ___security_init_cookiePtr.asFunction<void Function()>();\n\n  void __security_check_cookie(\n    int _StackCookie,\n  ) {\n    return ___security_check_cookie(\n      _StackCookie,\n    );\n  }\n\n  late final ___security_check_cookiePtr =\n      _lookup<ffi.NativeFunction<ffi.Void Function(ffi.UintPtr)>>(\n          '__security_check_cookie');\n  late final ___security_check_cookie =\n      ___security_check_cookiePtr.asFunction<void Function(int)>();\n\n  void __report_gsfailure(\n    int _StackCookie,\n  ) {\n    return ___report_gsfailure(\n      _StackCookie,\n    );\n  }\n\n  late final ___report_gsfailurePtr =\n      _lookup<ffi.NativeFunction<ffi.Void Function(ffi.UintPtr)>>(\n          '__report_gsfailure');\n  late final ___report_gsfailure =\n      ___report_gsfailurePtr.asFunction<void Function(int)>();\n\n  late final ffi.Pointer<ffi.UintPtr> ___security_cookie =\n      _lookup<ffi.UintPtr>('__security_cookie');\n\n  int get __security_cookie => ___security_cookie.value;\n\n  set __security_cookie(int value) => ___security_cookie.value = value;\n\n  void _invalid_parameter_noinfo() {\n    return __invalid_parameter_noinfo();\n  }\n\n  late final __invalid_parameter_noinfoPtr =\n      _lookup<ffi.NativeFunction<ffi.Void Function()>>(\n          '_invalid_parameter_noinfo');\n  late final __invalid_parameter_noinfo =\n      __invalid_parameter_noinfoPtr.asFunction<void Function()>();\n\n  void _invalid_parameter_noinfo_noreturn() {\n    return __invalid_parameter_noinfo_noreturn();\n  }\n\n  late final __invalid_parameter_noinfo_noreturnPtr =\n      _lookup<ffi.NativeFunction<ffi.Void Function()>>(\n          '_invalid_parameter_noinfo_noreturn');\n  late final __invalid_parameter_noinfo_noreturn =\n      __invalid_parameter_noinfo_noreturnPtr.asFunction<void Function()>();\n\n  void _invoke_watson(\n    ffi.Pointer<ffi.WChar> _Expression,\n    ffi.Pointer<ffi.WChar> _FunctionName,\n    ffi.Pointer<ffi.WChar> _FileName,\n    int _LineNo,\n    int _Reserved,\n  ) {\n    return __invoke_watson(\n      _Expression,\n      _FunctionName,\n      _FileName,\n      _LineNo,\n      _Reserved,\n    );\n  }\n\n  late final __invoke_watsonPtr = _lookup<\n      ffi.NativeFunction<\n          ffi.Void Function(\n              ffi.Pointer<ffi.WChar>,\n              ffi.Pointer<ffi.WChar>,\n              ffi.Pointer<ffi.WChar>,\n              ffi.UnsignedInt,\n              ffi.UintPtr)>>('_invoke_watson');\n  late final __invoke_watson = __invoke_watsonPtr.asFunction<\n      void Function(ffi.Pointer<ffi.WChar>, ffi.Pointer<ffi.WChar>,\n          ffi.Pointer<ffi.WChar>, int, int)>();\n\n  ffi.Pointer<ffi.Int> _errno() {\n    return __errno();\n  }\n\n  late final __errnoPtr =\n      _lookup<ffi.NativeFunction<ffi.Pointer<ffi.Int> Function()>>('_errno');\n  late final __errno = __errnoPtr.asFunction<ffi.Pointer<ffi.Int> Function()>();\n\n  int _set_errno(\n    int _Value,\n  ) {\n    return __set_errno(\n      _Value,\n    );\n  }\n\n  late final __set_errnoPtr =\n      _lookup<ffi.NativeFunction<errno_t Function(ffi.Int)>>('_set_errno');\n  late final __set_errno = __set_errnoPtr.asFunction<int Function(int)>();\n\n  int _get_errno(\n    ffi.Pointer<ffi.Int> _Value,\n  ) {\n    return __get_errno(\n      _Value,\n    );\n  }\n\n  late final __get_errnoPtr =\n      _lookup<ffi.NativeFunction<errno_t Function(ffi.Pointer<ffi.Int>)>>(\n          '_get_errno');\n  late final __get_errno =\n      __get_errnoPtr.asFunction<int Function(ffi.Pointer<ffi.Int>)>();\n\n  int __threadid() {\n    return ___threadid();\n  }\n\n  late final ___threadidPtr =\n      _lookup<ffi.NativeFunction<ffi.UnsignedLong Function()>>('__threadid');\n  late final ___threadid = ___threadidPtr.asFunction<int Function()>();\n\n  int __threadhandle() {\n    return ___threadhandle();\n  }\n\n  late final ___threadhandlePtr =\n      _lookup<ffi.NativeFunction<ffi.UintPtr Function()>>('__threadhandle');\n  late final ___threadhandle = ___threadhandlePtr.asFunction<int Function()>();\n\n  double cabs(\n    _Dcomplex _Z,\n  ) {\n    return _cabs(\n      _Z,\n    );\n  }\n\n  late final _cabsPtr =\n      _lookup<ffi.NativeFunction<ffi.Double Function(_Dcomplex)>>('cabs');\n  late final _cabs = _cabsPtr.asFunction<double Function(_Dcomplex)>();\n\n  _Dcomplex cacos(\n    _Dcomplex _Z,\n  ) {\n    return _cacos(\n      _Z,\n    );\n  }\n\n  late final _cacosPtr =\n      _lookup<ffi.NativeFunction<_Dcomplex Function(_Dcomplex)>>('cacos');\n  late final _cacos = _cacosPtr.asFunction<_Dcomplex Function(_Dcomplex)>();\n\n  _Dcomplex cacosh(\n    _Dcomplex _Z,\n  ) {\n    return _cacosh(\n      _Z,\n    );\n  }\n\n  late final _cacoshPtr =\n      _lookup<ffi.NativeFunction<_Dcomplex Function(_Dcomplex)>>('cacosh');\n  late final _cacosh = _cacoshPtr.asFunction<_Dcomplex Function(_Dcomplex)>();\n\n  double carg(\n    _Dcomplex _Z,\n  ) {\n    return _carg(\n      _Z,\n    );\n  }\n\n  late final _cargPtr =\n      _lookup<ffi.NativeFunction<ffi.Double Function(_Dcomplex)>>('carg');\n  late final _carg = _cargPtr.asFunction<double Function(_Dcomplex)>();\n\n  _Dcomplex casin(\n    _Dcomplex _Z,\n  ) {\n    return _casin(\n      _Z,\n    );\n  }\n\n  late final _casinPtr =\n      _lookup<ffi.NativeFunction<_Dcomplex Function(_Dcomplex)>>('casin');\n  late final _casin = _casinPtr.asFunction<_Dcomplex Function(_Dcomplex)>();\n\n  _Dcomplex casinh(\n    _Dcomplex _Z,\n  ) {\n    return _casinh(\n      _Z,\n    );\n  }\n\n  late final _casinhPtr =\n      _lookup<ffi.NativeFunction<_Dcomplex Function(_Dcomplex)>>('casinh');\n  late final _casinh = _casinhPtr.asFunction<_Dcomplex Function(_Dcomplex)>();\n\n  _Dcomplex catan(\n    _Dcomplex _Z,\n  ) {\n    return _catan(\n      _Z,\n    );\n  }\n\n  late final _catanPtr =\n      _lookup<ffi.NativeFunction<_Dcomplex Function(_Dcomplex)>>('catan');\n  late final _catan = _catanPtr.asFunction<_Dcomplex Function(_Dcomplex)>();\n\n  _Dcomplex catanh(\n    _Dcomplex _Z,\n  ) {\n    return _catanh(\n      _Z,\n    );\n  }\n\n  late final _catanhPtr =\n      _lookup<ffi.NativeFunction<_Dcomplex Function(_Dcomplex)>>('catanh');\n  late final _catanh = _catanhPtr.asFunction<_Dcomplex Function(_Dcomplex)>();\n\n  _Dcomplex ccos(\n    _Dcomplex _Z,\n  ) {\n    return _ccos(\n      _Z,\n    );\n  }\n\n  late final _ccosPtr =\n      _lookup<ffi.NativeFunction<_Dcomplex Function(_Dcomplex)>>('ccos');\n  late final _ccos = _ccosPtr.asFunction<_Dcomplex Function(_Dcomplex)>();\n\n  _Dcomplex ccosh(\n    _Dcomplex _Z,\n  ) {\n    return _ccosh(\n      _Z,\n    );\n  }\n\n  late final _ccoshPtr =\n      _lookup<ffi.NativeFunction<_Dcomplex Function(_Dcomplex)>>('ccosh');\n  late final _ccosh = _ccoshPtr.asFunction<_Dcomplex Function(_Dcomplex)>();\n\n  _Dcomplex cexp(\n    _Dcomplex _Z,\n  ) {\n    return _cexp(\n      _Z,\n    );\n  }\n\n  late final _cexpPtr =\n      _lookup<ffi.NativeFunction<_Dcomplex Function(_Dcomplex)>>('cexp');\n  late final _cexp = _cexpPtr.asFunction<_Dcomplex Function(_Dcomplex)>();\n\n  double cimag(\n    _Dcomplex _Z,\n  ) {\n    return _cimag(\n      _Z,\n    );\n  }\n\n  late final _cimagPtr =\n      _lookup<ffi.NativeFunction<ffi.Double Function(_Dcomplex)>>('cimag');\n  late final _cimag = _cimagPtr.asFunction<double Function(_Dcomplex)>();\n\n  _Dcomplex clog(\n    _Dcomplex _Z,\n  ) {\n    return _clog(\n      _Z,\n    );\n  }\n\n  late final _clogPtr =\n      _lookup<ffi.NativeFunction<_Dcomplex Function(_Dcomplex)>>('clog');\n  late final _clog = _clogPtr.asFunction<_Dcomplex Function(_Dcomplex)>();\n\n  _Dcomplex clog10(\n    _Dcomplex _Z,\n  ) {\n    return _clog10(\n      _Z,\n    );\n  }\n\n  late final _clog10Ptr =\n      _lookup<ffi.NativeFunction<_Dcomplex Function(_Dcomplex)>>('clog10');\n  late final _clog10 = _clog10Ptr.asFunction<_Dcomplex Function(_Dcomplex)>();\n\n  _Dcomplex conj(\n    _Dcomplex _Z,\n  ) {\n    return _conj(\n      _Z,\n    );\n  }\n\n  late final _conjPtr =\n      _lookup<ffi.NativeFunction<_Dcomplex Function(_Dcomplex)>>('conj');\n  late final _conj = _conjPtr.asFunction<_Dcomplex Function(_Dcomplex)>();\n\n  _Dcomplex cpow(\n    _Dcomplex _X,\n    _Dcomplex _Y,\n  ) {\n    return _cpow(\n      _X,\n      _Y,\n    );\n  }\n\n  late final _cpowPtr =\n      _lookup<ffi.NativeFunction<_Dcomplex Function(_Dcomplex, _Dcomplex)>>(\n          'cpow');\n  late final _cpow =\n      _cpowPtr.asFunction<_Dcomplex Function(_Dcomplex, _Dcomplex)>();\n\n  _Dcomplex cproj(\n    _Dcomplex _Z,\n  ) {\n    return _cproj(\n      _Z,\n    );\n  }\n\n  late final _cprojPtr =\n      _lookup<ffi.NativeFunction<_Dcomplex Function(_Dcomplex)>>('cproj');\n  late final _cproj = _cprojPtr.asFunction<_Dcomplex Function(_Dcomplex)>();\n\n  double creal(\n    _Dcomplex _Z,\n  ) {\n    return _creal(\n      _Z,\n    );\n  }\n\n  late final _crealPtr =\n      _lookup<ffi.NativeFunction<ffi.Double Function(_Dcomplex)>>('creal');\n  late final _creal = _crealPtr.asFunction<double Function(_Dcomplex)>();\n\n  _Dcomplex csin(\n    _Dcomplex _Z,\n  ) {\n    return _csin(\n      _Z,\n    );\n  }\n\n  late final _csinPtr =\n      _lookup<ffi.NativeFunction<_Dcomplex Function(_Dcomplex)>>('csin');\n  late final _csin = _csinPtr.asFunction<_Dcomplex Function(_Dcomplex)>();\n\n  _Dcomplex csinh(\n    _Dcomplex _Z,\n  ) {\n    return _csinh(\n      _Z,\n    );\n  }\n\n  late final _csinhPtr =\n      _lookup<ffi.NativeFunction<_Dcomplex Function(_Dcomplex)>>('csinh');\n  late final _csinh = _csinhPtr.asFunction<_Dcomplex Function(_Dcomplex)>();\n\n  _Dcomplex csqrt(\n    _Dcomplex _Z,\n  ) {\n    return _csqrt(\n      _Z,\n    );\n  }\n\n  late final _csqrtPtr =\n      _lookup<ffi.NativeFunction<_Dcomplex Function(_Dcomplex)>>('csqrt');\n  late final _csqrt = _csqrtPtr.asFunction<_Dcomplex Function(_Dcomplex)>();\n\n  _Dcomplex ctan(\n    _Dcomplex _Z,\n  ) {\n    return _ctan(\n      _Z,\n    );\n  }\n\n  late final _ctanPtr =\n      _lookup<ffi.NativeFunction<_Dcomplex Function(_Dcomplex)>>('ctan');\n  late final _ctan = _ctanPtr.asFunction<_Dcomplex Function(_Dcomplex)>();\n\n  _Dcomplex ctanh(\n    _Dcomplex _Z,\n  ) {\n    return _ctanh(\n      _Z,\n    );\n  }\n\n  late final _ctanhPtr =\n      _lookup<ffi.NativeFunction<_Dcomplex Function(_Dcomplex)>>('ctanh');\n  late final _ctanh = _ctanhPtr.asFunction<_Dcomplex Function(_Dcomplex)>();\n\n  double norm(\n    _Dcomplex _Z,\n  ) {\n    return _norm(\n      _Z,\n    );\n  }\n\n  late final _normPtr =\n      _lookup<ffi.NativeFunction<ffi.Double Function(_Dcomplex)>>('norm');\n  late final _norm = _normPtr.asFunction<double Function(_Dcomplex)>();\n\n  double cabsf(\n    _Fcomplex _Z,\n  ) {\n    return _cabsf(\n      _Z,\n    );\n  }\n\n  late final _cabsfPtr =\n      _lookup<ffi.NativeFunction<ffi.Float Function(_Fcomplex)>>('cabsf');\n  late final _cabsf = _cabsfPtr.asFunction<double Function(_Fcomplex)>();\n\n  _Fcomplex cacosf(\n    _Fcomplex _Z,\n  ) {\n    return _cacosf(\n      _Z,\n    );\n  }\n\n  late final _cacosfPtr =\n      _lookup<ffi.NativeFunction<_Fcomplex Function(_Fcomplex)>>('cacosf');\n  late final _cacosf = _cacosfPtr.asFunction<_Fcomplex Function(_Fcomplex)>();\n\n  _Fcomplex cacoshf(\n    _Fcomplex _Z,\n  ) {\n    return _cacoshf(\n      _Z,\n    );\n  }\n\n  late final _cacoshfPtr =\n      _lookup<ffi.NativeFunction<_Fcomplex Function(_Fcomplex)>>('cacoshf');\n  late final _cacoshf = _cacoshfPtr.asFunction<_Fcomplex Function(_Fcomplex)>();\n\n  double cargf(\n    _Fcomplex _Z,\n  ) {\n    return _cargf(\n      _Z,\n    );\n  }\n\n  late final _cargfPtr =\n      _lookup<ffi.NativeFunction<ffi.Float Function(_Fcomplex)>>('cargf');\n  late final _cargf = _cargfPtr.asFunction<double Function(_Fcomplex)>();\n\n  _Fcomplex casinf(\n    _Fcomplex _Z,\n  ) {\n    return _casinf(\n      _Z,\n    );\n  }\n\n  late final _casinfPtr =\n      _lookup<ffi.NativeFunction<_Fcomplex Function(_Fcomplex)>>('casinf');\n  late final _casinf = _casinfPtr.asFunction<_Fcomplex Function(_Fcomplex)>();\n\n  _Fcomplex casinhf(\n    _Fcomplex _Z,\n  ) {\n    return _casinhf(\n      _Z,\n    );\n  }\n\n  late final _casinhfPtr =\n      _lookup<ffi.NativeFunction<_Fcomplex Function(_Fcomplex)>>('casinhf');\n  late final _casinhf = _casinhfPtr.asFunction<_Fcomplex Function(_Fcomplex)>();\n\n  _Fcomplex catanf(\n    _Fcomplex _Z,\n  ) {\n    return _catanf(\n      _Z,\n    );\n  }\n\n  late final _catanfPtr =\n      _lookup<ffi.NativeFunction<_Fcomplex Function(_Fcomplex)>>('catanf');\n  late final _catanf = _catanfPtr.asFunction<_Fcomplex Function(_Fcomplex)>();\n\n  _Fcomplex catanhf(\n    _Fcomplex _Z,\n  ) {\n    return _catanhf(\n      _Z,\n    );\n  }\n\n  late final _catanhfPtr =\n      _lookup<ffi.NativeFunction<_Fcomplex Function(_Fcomplex)>>('catanhf');\n  late final _catanhf = _catanhfPtr.asFunction<_Fcomplex Function(_Fcomplex)>();\n\n  _Fcomplex ccosf(\n    _Fcomplex _Z,\n  ) {\n    return _ccosf(\n      _Z,\n    );\n  }\n\n  late final _ccosfPtr =\n      _lookup<ffi.NativeFunction<_Fcomplex Function(_Fcomplex)>>('ccosf');\n  late final _ccosf = _ccosfPtr.asFunction<_Fcomplex Function(_Fcomplex)>();\n\n  _Fcomplex ccoshf(\n    _Fcomplex _Z,\n  ) {\n    return _ccoshf(\n      _Z,\n    );\n  }\n\n  late final _ccoshfPtr =\n      _lookup<ffi.NativeFunction<_Fcomplex Function(_Fcomplex)>>('ccoshf');\n  late final _ccoshf = _ccoshfPtr.asFunction<_Fcomplex Function(_Fcomplex)>();\n\n  _Fcomplex cexpf(\n    _Fcomplex _Z,\n  ) {\n    return _cexpf(\n      _Z,\n    );\n  }\n\n  late final _cexpfPtr =\n      _lookup<ffi.NativeFunction<_Fcomplex Function(_Fcomplex)>>('cexpf');\n  late final _cexpf = _cexpfPtr.asFunction<_Fcomplex Function(_Fcomplex)>();\n\n  double cimagf(\n    _Fcomplex _Z,\n  ) {\n    return _cimagf(\n      _Z,\n    );\n  }\n\n  late final _cimagfPtr =\n      _lookup<ffi.NativeFunction<ffi.Float Function(_Fcomplex)>>('cimagf');\n  late final _cimagf = _cimagfPtr.asFunction<double Function(_Fcomplex)>();\n\n  _Fcomplex clogf(\n    _Fcomplex _Z,\n  ) {\n    return _clogf(\n      _Z,\n    );\n  }\n\n  late final _clogfPtr =\n      _lookup<ffi.NativeFunction<_Fcomplex Function(_Fcomplex)>>('clogf');\n  late final _clogf = _clogfPtr.asFunction<_Fcomplex Function(_Fcomplex)>();\n\n  _Fcomplex clog10f(\n    _Fcomplex _Z,\n  ) {\n    return _clog10f(\n      _Z,\n    );\n  }\n\n  late final _clog10fPtr =\n      _lookup<ffi.NativeFunction<_Fcomplex Function(_Fcomplex)>>('clog10f');\n  late final _clog10f = _clog10fPtr.asFunction<_Fcomplex Function(_Fcomplex)>();\n\n  _Fcomplex conjf(\n    _Fcomplex _Z,\n  ) {\n    return _conjf(\n      _Z,\n    );\n  }\n\n  late final _conjfPtr =\n      _lookup<ffi.NativeFunction<_Fcomplex Function(_Fcomplex)>>('conjf');\n  late final _conjf = _conjfPtr.asFunction<_Fcomplex Function(_Fcomplex)>();\n\n  _Fcomplex cpowf(\n    _Fcomplex _X,\n    _Fcomplex _Y,\n  ) {\n    return _cpowf(\n      _X,\n      _Y,\n    );\n  }\n\n  late final _cpowfPtr =\n      _lookup<ffi.NativeFunction<_Fcomplex Function(_Fcomplex, _Fcomplex)>>(\n          'cpowf');\n  late final _cpowf =\n      _cpowfPtr.asFunction<_Fcomplex Function(_Fcomplex, _Fcomplex)>();\n\n  _Fcomplex cprojf(\n    _Fcomplex _Z,\n  ) {\n    return _cprojf(\n      _Z,\n    );\n  }\n\n  late final _cprojfPtr =\n      _lookup<ffi.NativeFunction<_Fcomplex Function(_Fcomplex)>>('cprojf');\n  late final _cprojf = _cprojfPtr.asFunction<_Fcomplex Function(_Fcomplex)>();\n\n  double crealf(\n    _Fcomplex _Z,\n  ) {\n    return _crealf(\n      _Z,\n    );\n  }\n\n  late final _crealfPtr =\n      _lookup<ffi.NativeFunction<ffi.Float Function(_Fcomplex)>>('crealf');\n  late final _crealf = _crealfPtr.asFunction<double Function(_Fcomplex)>();\n\n  _Fcomplex csinf(\n    _Fcomplex _Z,\n  ) {\n    return _csinf(\n      _Z,\n    );\n  }\n\n  late final _csinfPtr =\n      _lookup<ffi.NativeFunction<_Fcomplex Function(_Fcomplex)>>('csinf');\n  late final _csinf = _csinfPtr.asFunction<_Fcomplex Function(_Fcomplex)>();\n\n  _Fcomplex csinhf(\n    _Fcomplex _Z,\n  ) {\n    return _csinhf(\n      _Z,\n    );\n  }\n\n  late final _csinhfPtr =\n      _lookup<ffi.NativeFunction<_Fcomplex Function(_Fcomplex)>>('csinhf');\n  late final _csinhf = _csinhfPtr.asFunction<_Fcomplex Function(_Fcomplex)>();\n\n  _Fcomplex csqrtf(\n    _Fcomplex _Z,\n  ) {\n    return _csqrtf(\n      _Z,\n    );\n  }\n\n  late final _csqrtfPtr =\n      _lookup<ffi.NativeFunction<_Fcomplex Function(_Fcomplex)>>('csqrtf');\n  late final _csqrtf = _csqrtfPtr.asFunction<_Fcomplex Function(_Fcomplex)>();\n\n  _Fcomplex ctanf(\n    _Fcomplex _Z,\n  ) {\n    return _ctanf(\n      _Z,\n    );\n  }\n\n  late final _ctanfPtr =\n      _lookup<ffi.NativeFunction<_Fcomplex Function(_Fcomplex)>>('ctanf');\n  late final _ctanf = _ctanfPtr.asFunction<_Fcomplex Function(_Fcomplex)>();\n\n  _Fcomplex ctanhf(\n    _Fcomplex _Z,\n  ) {\n    return _ctanhf(\n      _Z,\n    );\n  }\n\n  late final _ctanhfPtr =\n      _lookup<ffi.NativeFunction<_Fcomplex Function(_Fcomplex)>>('ctanhf');\n  late final _ctanhf = _ctanhfPtr.asFunction<_Fcomplex Function(_Fcomplex)>();\n\n  double normf(\n    _Fcomplex _Z,\n  ) {\n    return _normf(\n      _Z,\n    );\n  }\n\n  late final _normfPtr =\n      _lookup<ffi.NativeFunction<ffi.Float Function(_Fcomplex)>>('normf');\n  late final _normf = _normfPtr.asFunction<double Function(_Fcomplex)>();\n\n  _Dcomplex _Cbuild(\n    double _Re,\n    double _Im,\n  ) {\n    return __Cbuild(\n      _Re,\n      _Im,\n    );\n  }\n\n  late final __CbuildPtr =\n      _lookup<ffi.NativeFunction<_Dcomplex Function(ffi.Double, ffi.Double)>>(\n          '_Cbuild');\n  late final __Cbuild =\n      __CbuildPtr.asFunction<_Dcomplex Function(double, double)>();\n\n  _Dcomplex _Cmulcc(\n    _Dcomplex _X,\n    _Dcomplex _Y,\n  ) {\n    return __Cmulcc(\n      _X,\n      _Y,\n    );\n  }\n\n  late final __CmulccPtr =\n      _lookup<ffi.NativeFunction<_Dcomplex Function(_Dcomplex, _Dcomplex)>>(\n          '_Cmulcc');\n  late final __Cmulcc =\n      __CmulccPtr.asFunction<_Dcomplex Function(_Dcomplex, _Dcomplex)>();\n\n  _Dcomplex _Cmulcr(\n    _Dcomplex _X,\n    double _Y,\n  ) {\n    return __Cmulcr(\n      _X,\n      _Y,\n    );\n  }\n\n  late final __CmulcrPtr =\n      _lookup<ffi.NativeFunction<_Dcomplex Function(_Dcomplex, ffi.Double)>>(\n          '_Cmulcr');\n  late final __Cmulcr =\n      __CmulcrPtr.asFunction<_Dcomplex Function(_Dcomplex, double)>();\n\n  _Fcomplex _FCbuild(\n    double _Re,\n    double _Im,\n  ) {\n    return __FCbuild(\n      _Re,\n      _Im,\n    );\n  }\n\n  late final __FCbuildPtr =\n      _lookup<ffi.NativeFunction<_Fcomplex Function(ffi.Float, ffi.Float)>>(\n          '_FCbuild');\n  late final __FCbuild =\n      __FCbuildPtr.asFunction<_Fcomplex Function(double, double)>();\n\n  _Fcomplex _FCmulcc(\n    _Fcomplex _X,\n    _Fcomplex _Y,\n  ) {\n    return __FCmulcc(\n      _X,\n      _Y,\n    );\n  }\n\n  late final __FCmulccPtr =\n      _lookup<ffi.NativeFunction<_Fcomplex Function(_Fcomplex, _Fcomplex)>>(\n          '_FCmulcc');\n  late final __FCmulcc =\n      __FCmulccPtr.asFunction<_Fcomplex Function(_Fcomplex, _Fcomplex)>();\n\n  _Fcomplex _FCmulcr(\n    _Fcomplex _X,\n    double _Y,\n  ) {\n    return __FCmulcr(\n      _X,\n      _Y,\n    );\n  }\n\n  late final __FCmulcrPtr =\n      _lookup<ffi.NativeFunction<_Fcomplex Function(_Fcomplex, ffi.Float)>>(\n          '_FCmulcr');\n  late final __FCmulcr =\n      __FCmulcrPtr.asFunction<_Fcomplex Function(_Fcomplex, double)>();\n\n  Start_return Start(\n    ffi.Pointer<ffi.Char> cfg,\n  ) {\n    return _Start(\n      cfg,\n    );\n  }\n\n  late final _StartPtr =\n      _lookup<ffi.NativeFunction<Start_return Function(ffi.Pointer<ffi.Char>)>>(\n          'Start');\n  late final _Start =\n      _StartPtr.asFunction<Start_return Function(ffi.Pointer<ffi.Char>)>();\n\n  void Stop() {\n    return _Stop();\n  }\n\n  late final _StopPtr =\n      _lookup<ffi.NativeFunction<ffi.Void Function()>>('Stop');\n  late final _Stop = _StopPtr.asFunction<void Function()>();\n}\n\ntypedef va_list = ffi.Pointer<ffi.Char>;\n\nfinal class __crt_locale_data_public extends ffi.Struct {\n  external ffi.Pointer<ffi.UnsignedShort> _locale_pctype;\n\n  @ffi.Int()\n  external int _locale_mb_cur_max;\n\n  @ffi.UnsignedInt()\n  external int _locale_lc_codepage;\n}\n\nfinal class __crt_locale_pointers extends ffi.Struct {\n  external ffi.Pointer<__crt_locale_data> locinfo;\n\n  external ffi.Pointer<__crt_multibyte_data> mbcinfo;\n}\n\nfinal class __crt_locale_data extends ffi.Opaque {}\n\nfinal class __crt_multibyte_data extends ffi.Opaque {}\n\nfinal class _Mbstatet extends ffi.Struct {\n  @ffi.UnsignedLong()\n  external int _Wchar;\n\n  @ffi.UnsignedShort()\n  external int _Byte;\n\n  @ffi.UnsignedShort()\n  external int _State;\n}\n\ntypedef errno_t = ffi.Int;\n\nfinal class _GoString_ extends ffi.Struct {\n  external ffi.Pointer<ffi.Char> p;\n\n  @ptrdiff_t()\n  external int n;\n}\n\ntypedef ptrdiff_t = ffi.LongLong;\n\nfinal class _C_double_complex extends ffi.Struct {\n  @ffi.Array.multi([2])\n  external ffi.Array<ffi.Double> _Val;\n}\n\nfinal class _C_float_complex extends ffi.Struct {\n  @ffi.Array.multi([2])\n  external ffi.Array<ffi.Float> _Val;\n}\n\nfinal class _C_ldouble_complex extends ffi.Opaque {}\n\ntypedef _Dcomplex = _C_double_complex;\ntypedef _Fcomplex = _C_float_complex;\n\nfinal class GoInterface extends ffi.Struct {\n  external ffi.Pointer<ffi.Void> t;\n\n  external ffi.Pointer<ffi.Void> v;\n}\n\nfinal class GoSlice extends ffi.Struct {\n  external ffi.Pointer<ffi.Void> data;\n\n  @GoInt()\n  external int len;\n\n  @GoInt()\n  external int cap;\n}\n\ntypedef GoInt = GoInt64;\ntypedef GoInt64 = ffi.LongLong;\n\nfinal class Start_return extends ffi.Struct {\n  @GoInt()\n  external int r0;\n\n  external ffi.Pointer<ffi.Char> r1;\n}\n\nconst int _VCRT_COMPILER_PREPROCESSOR = 1;\n\nconst int _SAL_VERSION = 20;\n\nconst int __SAL_H_VERSION = 180000000;\n\nconst int _USE_DECLSPECS_FOR_SAL = 0;\n\nconst int _USE_ATTRIBUTES_FOR_SAL = 0;\n\nconst int _CRT_PACKING = 8;\n\nconst int _VCRUNTIME_DISABLED_WARNINGS = 4514;\n\nconst int _HAS_EXCEPTIONS = 1;\n\nconst int _WCHAR_T_DEFINED = 1;\n\nconst int NULL = 0;\n\nconst int _HAS_CXX17 = 0;\n\nconst int _HAS_CXX20 = 0;\n\nconst int _HAS_CXX23 = 0;\n\nconst int _HAS_NODISCARD = 1;\n\nconst int _UCRT_DISABLED_WARNINGS = 4324;\n\nconst int _ARGMAX = 100;\n\nconst int _TRUNCATE = -1;\n\nconst int _CRT_INT_MAX = 2147483647;\n\nconst int _CRT_SIZE_MAX = -1;\n\nconst String __FILEW__ = 'C';\n\nconst int _CRT_FUNCTIONS_REQUIRED = 1;\n\nconst int _CRT_HAS_CXX17 = 0;\n\nconst int _ARM_WINAPI_PARTITION_DESKTOP_SDK_AVAILABLE = 1;\n\nconst int _CRT_BUILD_DESKTOP_APP = 1;\n\nconst int _CRT_INTERNAL_NONSTDC_NAMES = 1;\n\nconst int __STDC_SECURE_LIB__ = 200411;\n\nconst int __GOT_SECURE_LIB__ = 200411;\n\nconst int __STDC_WANT_SECURE_LIB__ = 1;\n\nconst int _SECURECRT_FILL_BUFFER_PATTERN = 254;\n\nconst int _CRT_SECURE_CPP_OVERLOAD_STANDARD_NAMES = 0;\n\nconst int _CRT_SECURE_CPP_OVERLOAD_STANDARD_NAMES_COUNT = 0;\n\nconst int _CRT_SECURE_CPP_OVERLOAD_SECURE_NAMES = 1;\n\nconst int _CRT_SECURE_CPP_OVERLOAD_STANDARD_NAMES_MEMORY = 0;\n\nconst int _CRT_SECURE_CPP_OVERLOAD_SECURE_NAMES_MEMORY = 0;\n"
  },
  {
    "path": "ui/flutter/lib/core/libgopeed_boot.dart",
    "content": "import 'common/start_config.dart';\nimport \"libgopeed_boot_stub.dart\"\n    if (dart.library.html) 'entry/libgopeed_boot_browser.dart'\n    if (dart.library.io) 'entry/libgopeed_boot_native.dart';\n\nabstract class LibgopeedBoot {\n  static LibgopeedBoot? _instance;\n\n  static LibgopeedBoot get instance {\n    _instance ??= LibgopeedBoot();\n    return _instance!;\n  }\n\n  factory LibgopeedBoot() => create();\n\n  Future<int> start(StartConfig cfg);\n\n  Future<void> stop();\n}\n"
  },
  {
    "path": "ui/flutter/lib/core/libgopeed_boot_stub.dart",
    "content": "import 'libgopeed_boot.dart';\n\nLibgopeedBoot create() => throw UnimplementedError();\n"
  },
  {
    "path": "ui/flutter/lib/database/database.dart",
    "content": "import 'dart:convert';\n\nimport 'package:gopeed/util/util.dart';\nimport 'package:hive/hive.dart';\n\nimport 'entity.dart';\n\nconst String _startConfig = 'startConfig';\nconst String _windowState = 'windowState';\nconst String _bookmark = 'bookmark';\nconst String _createHistory = 'createHistory';\nconst String _webToken = 'webToken';\nconst String _runAsMenubarApp = 'runAsMenubarApp';\nconst String _analyticsEnabled = 'analyticsEnabled';\nconst String _analyticsClientId = 'analyticsClientId';\n\nclass Database {\n  static final Database _instance = Database._internal();\n\n  static Database get instance => _instance;\n\n  factory Database() {\n    return _instance;\n  }\n\n  late Box box;\n\n  Database._internal();\n\n  Future<void> init() async {\n    Hive.init(Util.getStorageDir());\n    box = await Hive.openBox('database');\n  }\n\n  void save<T>(String key, T entity) {\n    box.put(key, jsonEncode(entity));\n  }\n\n  T? get<T>(String key, T Function(dynamic json) fromJsonT) {\n    final json = box.get(key);\n    if (json == null) {\n      return null;\n    }\n    return fromJsonT(jsonDecode(json));\n  }\n\n  void clear(String key) {\n    box.delete(key);\n  }\n\n  void saveStartConfig(StartConfigEntity entity) {\n    save<StartConfigEntity>(_startConfig, entity);\n  }\n\n  StartConfigEntity? getStartConfig() {\n    return get<StartConfigEntity>(\n        _startConfig, (json) => StartConfigEntity.fromJson(json));\n  }\n\n  /// Patch non-null fields with the original value\n  void saveWindowState(WindowStateEntity entity) {\n    final state = getWindowState();\n    entity.isMaximized ??= state?.isMaximized;\n    entity.width ??= state?.width;\n    entity.height ??= state?.height;\n    save<WindowStateEntity>(_windowState, entity);\n  }\n\n  WindowStateEntity? getWindowState() {\n    return get<WindowStateEntity>(\n        _windowState, (json) => WindowStateEntity.fromJson(json));\n  }\n\n  /// Use map to ensure that the same directory only saves the latest bookmark\n  void saveBookmark(MapEntry<String, String> entry) {\n    final map = getBookmark() ?? {};\n    map[entry.key] = entry.value;\n    save<Map<String, String>>(_bookmark, map);\n  }\n\n  Map<String, String>? getBookmark() {\n    return get<Map<String, String>>(_bookmark, (json) {\n      return (json as Map<String, dynamic>)\n          .map((key, value) => MapEntry(key, value.toString()));\n    });\n  }\n\n  void saveWebToken(String token) {\n    save<String>(_webToken, token);\n  }\n\n  String? getWebToken() {\n    return get<String>(_webToken, (json) => json.toString());\n  }\n\n  void saveCreateHistory(String url) {\n    var list = getCreateHistory() ?? [];\n    list.remove(url);\n    list.insert(0, url);\n    if (list.length > 64) {\n      list.removeLast();\n    }\n    save<List<String>>(_createHistory, list);\n  }\n\n  List<String>? getCreateHistory() {\n    return get<List<String>>(_createHistory, (json) {\n      return (json as List<dynamic>).map((e) => e.toString()).toList();\n    });\n  }\n\n  void clearCreateHistory() {\n    clear(_createHistory);\n  }\n\n  void saveRunAsMenubarApp(bool value) {\n    box.put(_runAsMenubarApp, value);\n  }\n\n  bool getRunAsMenubarApp() {\n    return box.get(_runAsMenubarApp, defaultValue: false) as bool;\n  }\n\n  void saveAnalyticsEnabled(bool value) {\n    box.put(_analyticsEnabled, value);\n  }\n\n  bool getAnalyticsEnabled() {\n    return box.get(_analyticsEnabled, defaultValue: true) as bool;\n  }\n\n  void saveAnalyticsClientId(String clientId) {\n    box.put(_analyticsClientId, clientId);\n  }\n\n  String? getAnalyticsClientId() {\n    return box.get(_analyticsClientId) as String?;\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/database/entity.dart",
    "content": "import 'package:json_annotation/json_annotation.dart';\n\npart 'entity.g.dart';\n\n@JsonSerializable()\nclass StartConfigEntity {\n  String network;\n  String address;\n  String apiToken;\n\n  StartConfigEntity({\n    required this.network,\n    required this.address,\n    required this.apiToken,\n  });\n\n  factory StartConfigEntity.fromJson(Map<String, dynamic> json) =>\n      _$StartConfigEntityFromJson(json);\n  Map<String, dynamic> toJson() => _$StartConfigEntityToJson(this);\n}\n\n@JsonSerializable()\nclass WindowStateEntity {\n  bool? isMaximized;\n  double? width;\n  double? height;\n\n  WindowStateEntity({\n    this.isMaximized,\n    this.width,\n    this.height,\n  });\n\n  factory WindowStateEntity.fromJson(Map<String, dynamic> json) =>\n      _$WindowStateEntityFromJson(json);\n  Map<String, dynamic> toJson() => _$WindowStateEntityToJson(this);\n}\n"
  },
  {
    "path": "ui/flutter/lib/database/entity.g.dart",
    "content": "// GENERATED CODE - DO NOT MODIFY BY HAND\n\npart of 'entity.dart';\n\n// **************************************************************************\n// JsonSerializableGenerator\n// **************************************************************************\n\nStartConfigEntity _$StartConfigEntityFromJson(Map<String, dynamic> json) =>\n    StartConfigEntity(\n      network: json['network'] as String,\n      address: json['address'] as String,\n      apiToken: json['apiToken'] as String,\n    );\n\nMap<String, dynamic> _$StartConfigEntityToJson(StartConfigEntity instance) =>\n    <String, dynamic>{\n      'network': instance.network,\n      'address': instance.address,\n      'apiToken': instance.apiToken,\n    };\n\nWindowStateEntity _$WindowStateEntityFromJson(Map<String, dynamic> json) =>\n    WindowStateEntity(\n      isMaximized: json['isMaximized'] as bool?,\n      width: (json['width'] as num?)?.toDouble(),\n      height: (json['height'] as num?)?.toDouble(),\n    );\n\nMap<String, dynamic> _$WindowStateEntityToJson(WindowStateEntity instance) {\n  final val = <String, dynamic>{};\n\n  void writeNotNull(String key, dynamic value) {\n    if (value != null) {\n      val[key] = value;\n    }\n  }\n\n  writeNotNull('isMaximized', instance.isMaximized);\n  writeNotNull('width', instance.width);\n  writeNotNull('height', instance.height);\n  return val;\n}\n"
  },
  {
    "path": "ui/flutter/lib/i18n/langs/ca_es.dart",
    "content": "const caES = {\n  'ca_ES': {\n    'label': 'Català',\n    'error': 'Error',\n    'tip': 'Consell',\n    'confirm': 'Confirma',\n    'confirmDelete': 'Esteu segur que voleu suprimir-lo?',\n    'cancel': 'Cancel·la',\n    'on': 'Activat',\n    'off': 'Desactivat',\n    'selectAll': 'Selecciona-ho tot',\n    'select': 'Selecciona',\n    'task': 'Tasques',\n    'downloading': 's\\'està baixant',\n    'downloaded': 's\\'ha baixat',\n    'setting': 'Configuració',\n    'donate': 'Donació',\n    'exit': 'Surt',\n    'create': 'Crea una tasca',\n    'directDownload': 'Baixada directa',\n    'advancedOptions': 'Opcions avançades',\n    'followSettings': 'Segueix la configuració',\n    'downloadLink': 'Enllaç de baixada',\n    'downloadLinkValid': 'Introduïu l\\'enllaç de baixada',\n    'downloadLinkHit': 'Introduïu l\\'enllaç de baixada, un per línia@append',\n    'downloadLinkHitDesktop': 'o arrossegueu el fitxer torrent aquí',\n    'download': 'Baixa',\n    'noFileSelected': 'Seleccioneu almenys un fitxer per continuar.',\n    'noStoragePermission': 'Cal permís d\\'emmagatzematge',\n    'selectFile': 'Seleccioneu el fitxer',\n    'rename': 'Canvia el nom',\n    'basic': 'Bàsic',\n    'advanced': 'Avançat',\n    'general': 'General',\n    'downloadDir': 'Carpeta de baixades',\n    'downloadDirValid': 'Seleccioneu la carpeta de baixades',\n    'connections': 'Connexions',\n    'useServerCtime':\n        'Utilitza l\\'hora del servidor per a la creació de fitxers',\n    'maxRunning': 'Màxim de tasques en execució',\n    'defaultDirectDownload': 'Activa la baixada directa per defecte',\n    'autoStartTasks':\n        'Inicia automàticament les tasques incompletes a l\\'inici',\n    'autoTorrentEnable':\n        'Crea automàticament tasques BT a partir de fitxers .torrent',\n    'autoTorrentDeleteAfterDownload':\n        'Suprimeix el fitxer .torrent després de crear la tasca BT',\n    'autoDeleteMissingFileTasks':\n        'Suprimeix automàticament les tasques dels fitxers que falten',\n    'items': '@count elements',\n    'subscribeTracker': 'Subscripció al rastrejador',\n    'subscribeFail':\n        'La subscripció ha fallat. Comproveu la xarxa o torneu-ho a provar més tard',\n    'update': 'Actualitza',\n    'updateDaily': 'Actualització diària',\n    'lastUpdate': 'Última actualització: @time',\n    'addTracker': 'Afegeix un rastrejador',\n    'addTrackerHit':\n        'Introduïu la URL del servidor del rastrejador, un per línia',\n    'ui': 'UI',\n    'theme': 'Tema',\n    'themeSystem': 'Sistema',\n    'themeLight': 'Clar',\n    'themeDark': 'Fosc',\n    'locale': 'Idioma',\n    'notifyWhenNewVersion': 'Notificació d\\'actualitzacions',\n    'analyticsEnabled': 'Puja les estadístiques',\n    'analyticsEnabledDesc':\n        'Compartiu dades d\\'ús anònimes per ajudar-nos a millorar',\n    'about': 'Quant a',\n    'homepage': 'Pàgina d\\'inici',\n    'version': 'Versió',\n    'protocol': 'Protocol',\n    'port': 'Port',\n    'apiToken': 'Token de l\\'API',\n    'notSet': 'Sense congigurar',\n    'set': 'Configurat',\n    'portInUse': 'El port [@port] està en ús, canvieu-lo',\n    'effectAfterRestart': 'Els efectes s\\'aplicaran després de reiniciar',\n    'developer': 'Desenvolupador',\n    'logDirectory': 'Carpeta dels registres',\n    'webhook': 'Webhook',\n    'webhookEnable': 'Activa el Webhook',\n    'webhookDesc':\n        'Envia notificacions HTTP POST quan les tasques finalitzin o fallin',\n    'webhookUrlHint': 'Introduïu la URL del webhook',\n    'webhookTest': 'Prova',\n    'webhookTestSuccess': 'La prova del webhook ha estat correcta',\n    'webhookTestFail': 'La prova del webhook ha fallat',\n    'script': 'Seqüència d\\'ordres',\n    'scriptEnable': 'Activa la seqüència d\\'ordres',\n    'scriptDesc':\n        'Executa seqüències d\\'ordres  personalitzades quan les baixades finalitzin correctament',\n    'scriptPathHint':\n        'Introduïu la ruta del fitxer de seqüències d\\'ordres (p.ex., /ruta/al/fitxer.sh)',\n    'urlInvalid': 'Introduïu una URL HTTP o HTTPS vàlida',\n    'required': 'Aquest camp és obligatori',\n    'show': 'Mostra',\n    'continue': 'Contina',\n    'pause': 'Pausa',\n    'startAll': 'Inicia-ho tot',\n    'pauseAll': 'Pausa-ho tot',\n    'deleteTask': 'Suprimeix @count tasques',\n    'deleteTaskTip': 'Mantén els fitxers baixats',\n    'delete': 'Suprimeix',\n    'add': 'Afegeix',\n    'edit': 'Edita',\n    'newVersionTitle': 'Descobriu la nova versió @version',\n    'newVersionUpdate': 'Actualitza ara',\n    'newVersionLater': 'Més tard',\n    'extensions': 'Extensions',\n    'extensionInstallUrl': 'URL d\\'instal·lació',\n    'extensionInstallSuccess': 'S\\'ha instal·lat amb èxit',\n    'extensionUpdateSuccess': 'S\\'ha actualitzat amb èxit',\n    'extensionDelete': 'Suprimeix l\\'extensió',\n    'extensionAlreadyLatest': 'Ja està actualitzada',\n    'extensionFind': 'Cerca extensions',\n    'extensionDevelop': 'Desenvolupa extensions',\n    'extensionStoreTitle': 'Botiga d\\'extensions',\n    'extensionStoreSection': 'Extensions de la botiga',\n    'extensionInstalledSection': 'Extensions instal·lades',\n    'extensionInstalledEmpty': 'Encara no s\\'ha instal·lat cap extensió',\n    'extensionStoreEmpty': 'No s\\'han trobat extensions coincidents',\n    'extensionSearchHint': 'Cerca per nom, títol, descripció o autor',\n    'extensionSortStars': 'Més estrelles',\n    'extensionSortInstalls': 'Més instal·lacions',\n    'extensionSortUpdated': 'Actualitzada recentment',\n    'extensionSortToggle': 'Commuta la direcció d\\'ordenació',\n    'extensionLoadMore': 'Carrega\\'n més',\n    'extensionNoMore': 'No hi ha més extensions',\n    'extensionInstalled': 'Instal·lada',\n    'extensionCanUpdate': 'Actualització disponible',\n    'extensionInstall': 'Instal·la',\n    'extensionLoadLocal': 'Instal·la de d\\'una carpeta local',\n    'extensionFilterAll': 'Totes',\n    'extensionFilterMarket': 'Mercat',\n    'extensionFilterInstalled': 'Instal·lada',\n    'extensionManualInstall': 'Instal·lació manual',\n    'extensionInstallTools': 'Opcions d\\'instal·lació',\n    'history': 'Historial',\n    'clearHistory': 'Neteja l\\'historial',\n    'noHistoryFound': 'No s\\'ha trobat cap historial',\n    'serviceTitle': 'Servei de baixades',\n    'serviceText': 'S\\'està executant',\n    'network': 'Xarxa',\n    'proxy': 'Servidor intermediari',\n    'noProxy': 'Sense servidor intermediari',\n    'systemProxy': 'Servidor intermediari del sistema',\n    'customProxy': 'Servidor intermediari personalitzat',\n    'server': 'Servidor',\n    'username': 'Nom d\\'usuari',\n    'password': 'Contrasenya',\n    'thanks': 'Gràcies',\n    'thanksDesc':\n        'Gràcies a tots els col·laboradors que han ajudat a construir i desenvolupar la comunitat del Gopeed!',\n    'browserExtension': 'Extensió del navegador',\n    'launchAtStartup': 'Executa a l\\'inici',\n    'runAsMenubarApp': 'Executa com a aplicació de barra de menú',\n    'runAsMenubarAppDesc':\n        'Amaga la icona de l\\'acoblador i executa només a la barra de menú',\n    'seedConfig': 'Configuració de les llavors',\n    'seedKeep': 'Continua sembrant fins que s\\'aturi manualment',\n    'seedRatio': 'Proporció de les llavors',\n    'seedTime': 'Temps de la llavor (minuts)',\n    'setAsDefaultBtClient': 'Estableix com a client BT per defecte',\n    'taskDetail': 'Detall de la tasca',\n    'taskName': 'Nom de la tasca',\n    'taskUrl': 'URL de la tasca',\n    'downloadPath': 'Ruta de la baixada',\n    'skipVerifyCert': 'Omet la verificació del certificat',\n    'archives': 'Comprimits',\n    'autoExtract': 'Extreu els comprimits automàticament',\n    'archivePassword': 'Contrasenya del comprimit',\n    'archivePasswordHint':\n        'Deixeu-lo en blanc si no està protegit amb contrasenya',\n    'deleteAfterExtract': 'Suprimeix el comprimit després de l\\'extracció',\n    'extracting': 'S\\'està extraient',\n    'extractDone': 'Extracció completada',\n    'extractError': 'Extracció fallida',\n    'waitingParts': 'S\\'està esperant a les parts',\n    'name': 'Nom',\n    'size': 'Mida',\n    'unknown': 'Desconegut',\n    'fileSelectedCount': 'Fitxers: ',\n    'fileSelectedSize': 'Mida: ',\n    'httpHeader': 'Capçalera',\n    'httpHeaderName': 'Nomm de la capçalera',\n    'httpHeaderValue': 'Valor de la capçalera',\n    'login': 'Inici de sessió',\n    'username_required': 'Introduïu el vostre nom d\\'usuari',\n    'password_required': 'Introduïu la vostra contrasenya',\n    'login_success': 'Inici de sessió correcte',\n    'login_failed':\n        'No s\\'ha pogut iniciar la sessió, comproveu el vostre nom d\\'usuari i contrasenya',\n    'login_failed_network':\n        'No s\\'ha pogut iniciar la sessió, comproveu la vostra connexió de xarxa',\n    'insertPlaceholder': 'Inseriu un marcador de posició',\n    'placeholderYear': 'Any actual',\n    'placeholderMonth': 'Mes actual (01-12)',\n    'placeholderDay': 'Dia actual (01-31)',\n    'placeholderDate': 'Data completa (AAAA-MM-DD)',\n    'example': 'p.ex. @value',\n    'downloadCategories': 'Categories de baixades',\n    'categoryMusic': 'Música',\n    'categoryVideo': 'Vídeo',\n    'categoryDocument': 'Document',\n    'categoryProgram': 'Programa',\n    'categoryName': 'Nom de la categoria',\n    'categoryPath': 'Ruta de la categoria',\n    'builtInCategory': 'La categoria integrada no es pot suprimir',\n    'selectCategory': 'Seleccioneu una categoria',\n    'githubMirror': 'Rèplica del GitHub',\n    'githubMirrorEnable': 'Activa la rèplica del GitHub',\n    'githubMirrorDesc':\n        'Utilitzeu rèpliques per accelerar les baixades de contingut del GitHub',\n    'githubMirrorType': 'Tipus de rèplica',\n    'githubMirrorUrl': 'URL de la rèplica',\n    'githubMirrorUrlHint': 'Introduïu la URL de la rèplica',\n    'updateUrl': 'Actualitza la URL',\n    'updateUrlManual': 'Actualització manual',\n    'updateUrlListen': 'Escolta l\\'actualització',\n    'updateUrlListeningTip': 'S\\'està esperant l\\'actualització de l\\'URL',\n    'updateUrlCancelListen': 'Cancel·la l\\'escolta',\n    'updateUrlDialogHint': 'Introduïu una nova URL de baixada',\n    'pendingUpdateFound': 'S\\'ha trobat una tasca d\\'actualització pendent',\n    'pendingUpdateConfirm':\n        'La tasca \"@name\" està esperant l\\'actualització de la URL. Voleu actualitzar-la amb la URL nova?',\n    'pendingUpdateYes': 'Actualitza la tasca',\n    'pendingUpdateNo': 'Crea una tasca nova',\n    'ed2k': 'ED2K',\n    'ed2kTcpPort': 'TCP Listen Port',\n    'ed2kUdpPort': 'UDP Listen Port',\n    'ed2kServerList': 'Server Addresses',\n    'ed2kServerMet': 'Server.met Sources',\n    'ed2kNodesDat': 'nodes.dat Sources',\n    'ed2kAutoPort': 'Auto assign',\n    'ed2kOnePerLine': 'One entry per line',\n    'ed2kServersHint': 'host:port, one per line',\n    'ed2kServerMetHint': 'Server.met URL or ed2k serverlist link, one per line',\n    'ed2kNodesDatHint': 'nodes.dat path or URL, one per line',\n    'desktopNotification': 'Notificacions d\\'escriptori',\n    'notificationTaskDone': 'Tasca completada',\n    'notificationTaskError': 'Error de tasca',\n  },\n};\n"
  },
  {
    "path": "ui/flutter/lib/i18n/langs/de_de.dart",
    "content": "const deDE = {\n  'de_DE': {\n    'label': 'Deutsch',\n    'error': 'Fehler',\n    'tip': 'Tipp',\n    'confirm': 'Bestätigen',\n    'confirmDelete': 'Möchten Sie wirklich löschen?',\n    'cancel': 'Abbrechen',\n    'on': 'An',\n    'off': 'Aus',\n    'selectAll': 'Alle Auswählen',\n    'select': 'Auswählen',\n    'task': 'Aufgaben',\n    'downloading': 'Wird Heruntergeladen',\n    'downloaded': 'Heruntergeladen',\n    'setting': 'Einstellungen',\n    'donate': 'Spenden',\n    'exit': 'Beenden',\n    'create': 'Aufgabe Erstellen',\n    'directDownload': 'Direkter Download',\n    'advancedOptions': 'Erweiterte Einstellungen',\n    'followSettings': 'Einstellungen Folgen',\n    'downloadLink': 'Download Link',\n    'downloadLinkValid': 'Bitte geben Sie den Download-Link ein',\n    'downloadLinkHit':\n        'Bitte geben Sie den Download-Link ein, einen pro Zeile@append',\n    'downloadLinkHitDesktop':\n        ', oder ziehen Sie die Torrent-Datei direkt hierher',\n    'download': 'Download',\n    'noFileSelected':\n        'Bitte wählen Sie mindestens eine Datei aus, um fortzufahren.',\n    'noStoragePermission': 'Speicherberechtigung erforderlich',\n    'selectFile': 'Datei auswählen',\n    'rename': 'Umbenennen',\n    'basic': 'Basic',\n    'advanced': 'Fortschrittlich',\n    'general': 'Allgemein',\n    'downloadDir': 'Download-Verzeichnis',\n    'downloadDirValid': 'Bitte wählen Sie das Download-Verzeichnis',\n    'connections': 'Anschlüsse',\n    'useServerCtime': 'Serverzeit für die Dateierstellung verwenden',\n    'maxRunning': 'Maximale Anzahl ausgeführter Aufgaben',\n    'defaultDirectDownload': 'Direkten Download standardmäßig aktivieren',\n    'autoStartTasks': 'Unvollständige Aufgaben beim Start automatisch starten',\n    'autoTorrentEnable':\n        'BT-Aufgaben automatisch aus .torrent-Dateien erstellen',\n    'autoTorrentDeleteAfterDownload':\n        '.torrent-Datei nach BT-Aufgabenerstellung löschen',\n    'autoDeleteMissingFileTasks': 'Fehlende Dateiaufgaben automatisch löschen',\n    'items': '@count Artikel',\n    'subscribeTracker': 'Abonnement-Tracker',\n    'subscribeFail':\n        'Anmeldung fehlgeschlagen. Bitte überprüfen Sie das Netzwerk oder versuchen Sie es später erneut.',\n    'update': 'Aktualisieren',\n    'updateDaily': 'Tägliche Aktualisierung',\n    'lastUpdate': 'Letzte Aktualisierung: @time',\n    'addTracker': 'Tracker hinzufügen',\n    'addTrackerHit':\n        'Bitte geben Sie die URL des Tracker-Servers ein, eine pro Zeile',\n    'ui': 'Benutzeroberfläche',\n    'theme': 'Thema',\n    'themeSystem': 'System',\n    'themeLight': 'Lichtmodus',\n    'themeDark': 'Dunkler Modus',\n    'locale': 'Sprache',\n    'about': 'Über',\n    'homepage': 'Homepage',\n    'version': 'Version',\n    'protocol': 'Protokoll',\n    'port': 'Port',\n    'apiToken': 'API Token',\n    'notSet': 'NS',\n    'set': 'SET',\n    'portInUse': 'Port [@port] wird verwendet, bitte ändern Sie den Port',\n    'effectAfterRestart': 'Wirkung nach Neustart',\n    'developer': 'Entwickler-in',\n    'logDirectory': 'Protokollverzeichnis',\n    'show': 'Zeigen',\n    'continue': 'Weitermachen',\n    'pause': 'Pause',\n    'startAll': 'Alles starten',\n    'pauseAll': 'Alle pausieren',\n    'deleteTask': '@count Aufgaben löschen',\n    'deleteTaskTip': 'Heruntergeladene Dateien behalten',\n    'delete': 'Löschen',\n    'newVersionTitle': 'Entdecken Sie die neue Version @version',\n    'newVersionUpdate': 'Jetzt aktualisieren',\n    'newVersionLater': 'Später',\n    'extensions': 'Erweiterungen',\n    'extensionInstallUrl': 'Installations-URL',\n    'extensionInstallSuccess': 'Erfolgreich installiert',\n    'extensionUpdateSuccess': 'Erfolgreich aktualisiert',\n    'extensionDelete': 'Erweiterung löschen',\n    'extensionAlreadyLatest': 'Es ist bereits die neueste Version',\n    'extensionFind': 'Erweiterungen suchen',\n    'extensionDevelop': 'Erweiterungen entwickeln',\n    'history': 'Verlauf',\n    'clearHistory': 'Verlauf löschen',\n    'noHistoryFound': 'Kein Verlauf gefunden',\n    'serviceTitle': 'Download-Service',\n    'serviceText': 'Läuft',\n    'network': 'Netzwerk',\n    'proxy': 'Proxy',\n    'noProxy': 'Kein Proxy',\n    'systemProxy': 'System-Proxy',\n    'customProxy': 'Benutzerdefinierter Proxy',\n    'server': 'Server',\n    'username': 'Benutzername',\n    'password': 'Passwort',\n    'thanks': 'Danke',\n    'thanksDesc':\n        'Vielen Dank an alle Mitwirkenden, die beim Aufbau und der Entwicklung der Gopeed-Community geholfen haben!',\n    'browserExtension': 'Browsererweiterung',\n    'launchAtStartup': 'Beim Start starten',\n    'runAsMenubarApp': 'Als Menüleisten-App ausführen',\n    'runAsMenubarAppDesc':\n        'Dock-Symbol ausblenden und nur in der Menüleiste ausführen',\n    'seedConfig': 'Seed-Konfiguration',\n    'seedKeep': 'Weiter-seeden bis manuell gestoppt',\n    'seedRatio': 'Seed Verhältniss',\n    'seedTime': 'Seed-Zeit (Minuten)',\n    'setAsDefaultBtClient': 'Als Standard-BT-Client festlegen',\n    'taskDetail': 'Aufgabendetails',\n    'taskName': 'Aufgabenname',\n    'taskUrl': 'Aufgaben-URL',\n    'downloadPath': 'Download-Pfad',\n    'skipVerifyCert': 'Zertifikatsüberprüfung überspringen',\n    'archives': 'Archive',\n    'autoExtract': 'Archive automatisch entpacken',\n    'archivePassword': 'Archiv-Passwort',\n    'archivePasswordHint': 'Leer lassen, wenn nicht passwortgeschützt',\n    'deleteAfterExtract': 'Archiv nach Entpacken löschen',\n    'extracting': 'Wird entpackt',\n    'extractDone': 'Entpacken abgeschlossen',\n    'extractError': 'Entpacken fehlgeschlagen',\n    'waitingParts': 'Warte auf Teile',\n    'name': 'Name',\n    'size': 'Größe',\n    'unknown': 'Unbekannt',\n    'fileSelectedCount': 'Dateien: ',\n    'fileSelectedSize': 'Größe: ',\n    'httpHeaderName': 'Header-Name',\n    'httpHeaderValue': 'Header-Wert',\n    'login': 'Login',\n    'username_required': 'Bitte geben Sie Ihren Benutzernamen ein',\n    'password_required': 'Bitte geben Sie Ihr Passwort ein',\n    'login_success': 'Anmeldung erfolgreich',\n    'login_failed':\n        'Anmeldung fehlgeschlagen, bitte überprüfen Sie Ihren Benutzernamen und Ihr Passwort',\n    'login_failed_network':\n        'Anmeldung fehlgeschlagen, bitte überprüfen Sie Ihre Netzwerkverbindung',\n    'insertPlaceholder': 'Platzhalter einfügen',\n    'placeholderYear': 'Aktuelles Jahr',\n    'placeholderMonth': 'Aktueller Monat (01-12)',\n    'placeholderDay': 'Aktueller Tag (01-31)',\n    'placeholderDate': 'Vollständiges Datum (JJJJ-MM-TT)',\n    'example': 'z.B. @value',\n    'downloadCategories': 'Download-Kategorien',\n    'categoryMusic': 'Musik',\n    'categoryVideo': 'Video',\n    'categoryDocument': 'Dokument',\n    'categoryProgram': 'Programm',\n    'categoryName': 'Kategoriename',\n    'categoryPath': 'Kategoriepfad',\n    'builtInCategory': 'Integrierte Kategorie kann nicht gelöscht werden',\n    'selectCategory': 'Kategorie auswählen',\n    'ed2k': 'ED2K',\n    'ed2kTcpPort': 'TCP Listen Port',\n    'ed2kUdpPort': 'UDP Listen Port',\n    'ed2kServerList': 'Server Addresses',\n    'ed2kServerMet': 'Server.met Sources',\n    'ed2kNodesDat': 'nodes.dat Sources',\n    'ed2kAutoPort': 'Auto assign',\n    'ed2kOnePerLine': 'One entry per line',\n    'ed2kServersHint': 'host:port, one per line',\n    'ed2kServerMetHint': 'Server.met URL or ed2k serverlist link, one per line',\n    'ed2kNodesDatHint': 'nodes.dat path or URL, one per line',\n    'desktopNotification': 'Desktop-Benachrichtigungen',\n    'notificationTaskDone': 'Aufgabe abgeschlossen',\n    'notificationTaskError': 'Aufgabenfehler',\n  },\n};\n"
  },
  {
    "path": "ui/flutter/lib/i18n/langs/en_us.dart",
    "content": "const enUS = {\n  'en_US': {\n    'label': 'English',\n    'error': 'Error',\n    'tip': 'Tip',\n    'confirm': 'Confirm',\n    'confirmDelete': 'Are you sure you want to delete?',\n    'cancel': 'Cancel',\n    'on': 'On',\n    'off': 'Off',\n    'selectAll': 'Select All',\n    'select': 'Select',\n    'task': 'Tasks',\n    'downloading': 'downloading',\n    'downloaded': 'downloaded',\n    'setting': 'Settings',\n    'donate': 'Donate',\n    'exit': 'Exit',\n    'create': 'Create Task',\n    'directDownload': 'Direct Download',\n    'advancedOptions': 'Advanced Options',\n    'followSettings': 'Follow Settings',\n    'downloadLink': 'Download Link',\n    'downloadLinkValid': 'Please enter the download link',\n    'downloadLinkHit': 'Please enter the download link, one per line@append',\n    'downloadLinkHitDesktop': ', or drag the torrent file here directly',\n    'download': 'Download',\n    'noFileSelected': 'Please select at least one file to continue.',\n    'noStoragePermission': 'Storage permission required',\n    'selectFile': 'Select File',\n    'rename': 'Rename',\n    'basic': 'Basic',\n    'advanced': 'Advanced',\n    'general': 'General',\n    'downloadDir': 'Download Directory',\n    'downloadDirValid': 'Please select the download directory',\n    'connections': 'Connections',\n    'useServerCtime': 'Use server time for file creation',\n    'maxRunning': 'Max Running Tasks',\n    'defaultDirectDownload': 'Check direct download by default',\n    'autoStartTasks': 'Auto-start incomplete tasks on startup',\n    'autoTorrentEnable': 'Auto create BT tasks from .torrent files',\n    'autoTorrentDeleteAfterDownload':\n        'Delete .torrent file after BT task creation',\n    'autoDeleteMissingFileTasks': 'Auto delete missing file tasks',\n    'items': '@count items',\n    'subscribeTracker': 'Subscribe Tracker',\n    'subscribeFail':\n        'Subscribe failed, please check network or try again later',\n    'update': 'Update',\n    'updateDaily': 'Update daily',\n    'lastUpdate': 'Last update: @time',\n    'addTracker': 'Add Tracker',\n    'addTrackerHit': 'Please enter the tracker server url, one per line',\n    'ui': 'UI',\n    'theme': 'Theme',\n    'themeSystem': 'System',\n    'themeLight': 'Light',\n    'themeDark': 'Dark',\n    'locale': 'Language',\n    'notifyWhenNewVersion': 'Notify for updates',\n    'analyticsEnabled': 'Upload Statistics',\n    'analyticsEnabledDesc': 'Share anonymous usage data to help us improve',\n    'about': 'About',\n    'homepage': 'Homepage',\n    'version': 'Version',\n    'protocol': 'Protocol',\n    'port': 'Port',\n    'apiToken': 'API Token',\n    'notSet': 'NS',\n    'set': 'SET',\n    'portInUse': 'Port [@port] is in use, please change the port',\n    'effectAfterRestart': 'Effect after restart',\n    'developer': 'Developer',\n    'logDirectory': 'Log Directory',\n    'webhook': 'Webhook',\n    'webhookEnable': 'Enable Webhook',\n    'webhookDesc': 'Send HTTP POST notifications when tasks complete or fail',\n    'webhookUrlHint': 'Enter webhook URL',\n    'webhookTest': 'Test',\n    'webhookTestSuccess': 'Webhook test successful',\n    'webhookTestFail': 'Webhook test failed',\n    'script': 'Script',\n    'scriptEnable': 'Enable Script',\n    'scriptDesc': 'Execute custom scripts when downloads complete successfully',\n    'scriptPathHint': 'Enter script file path (e.g., /path/to/script.sh)',\n    'urlInvalid': 'Please enter a valid HTTP or HTTPS URL',\n    'required': 'This field is required',\n    'show': 'Show',\n    'continue': 'Continue',\n    'pause': 'Pause',\n    'startAll': 'Start All',\n    'pauseAll': 'Pause All',\n    'deleteTask': 'Delete @count tasks',\n    'deleteTaskTip': 'Keep downloaded files',\n    'delete': 'Delete',\n    'add': 'Add',\n    'edit': 'Edit',\n    'newVersionTitle': 'Discover new version @version',\n    'newVersionUpdate': 'Update Now',\n    'newVersionLater': 'Later',\n    'extensions': 'Extensions',\n    'extensionInstallUrl': 'Install URL',\n    'extensionInstallSuccess': 'Installed successfully',\n    'extensionUpdateSuccess': 'Updated successfully',\n    'extensionDelete': 'Delete Extension',\n    'extensionAlreadyLatest': 'It\\'s already the latest version',\n    'extensionFind': 'Find Extensions',\n    'extensionDevelop': 'Develop Extensions',\n    'extensionStoreTitle': 'Extension Store',\n    'extensionStoreSection': 'Store Extensions',\n    'extensionInstalledSection': 'Installed Extensions',\n    'extensionInstalledEmpty': 'No extensions installed yet',\n    'extensionStoreEmpty': 'No matching extensions found',\n    'extensionSearchHint': 'Search by name, title, description or author',\n    'extensionSortStars': 'Most Stars',\n    'extensionSortInstalls': 'Most Installs',\n    'extensionSortUpdated': 'Recently Updated',\n    'extensionSortToggle': 'Toggle sort direction',\n    'extensionLoadMore': 'Load More',\n    'extensionNoMore': 'No more extensions',\n    'extensionInstalled': 'Installed',\n    'extensionCanUpdate': 'Update Available',\n    'extensionInstall': 'Install',\n    'extensionLoadLocal': 'Install Local Folder',\n    'extensionFilterAll': 'All',\n    'extensionFilterMarket': 'Market',\n    'extensionFilterInstalled': 'Installed',\n    'extensionManualInstall': 'Manual Install',\n    'extensionInstallTools': 'Install Options',\n    'history': 'History',\n    'clearHistory': 'Clear History',\n    'noHistoryFound': 'No History Found',\n    'serviceTitle': 'Download Service',\n    'serviceText': 'Running',\n    'network': 'Network',\n    'proxy': 'Proxy',\n    'noProxy': 'No Proxy',\n    'systemProxy': 'System Proxy',\n    'customProxy': 'Custom Proxy',\n    'server': 'Server',\n    'username': 'Username',\n    'password': 'Password',\n    'thanks': 'Thanks',\n    'thanksDesc':\n        'Thanks to all the contributors who have helped build and develop the Gopeed community!',\n    'browserExtension': 'Browser Extension',\n    'launchAtStartup': 'Launch at Startup',\n    'runAsMenubarApp': 'Run as menubar app',\n    'runAsMenubarAppDesc': 'Hide dock icon and run in menubar only',\n    'seedConfig': 'Seed Config',\n    'seedKeep': 'Keep seeding until manually stopped',\n    'seedRatio': 'Seed ratio',\n    'seedTime': 'Seed time (minutes)',\n    'setAsDefaultBtClient': 'Set as the default BT client',\n    'taskDetail': 'Task Detail',\n    'taskName': 'Task Name',\n    'taskUrl': 'Task URL',\n    'downloadPath': 'Download Path',\n    'skipVerifyCert': 'Skip Certificate Verification',\n    'archives': 'Archives',\n    'autoExtract': 'Auto Extract Archives',\n    'archivePassword': 'Archive Password',\n    'archivePasswordHint': 'Leave empty if not password protected',\n    'deleteAfterExtract': 'Delete Archive After Extraction',\n    'extracting': 'Extracting',\n    'extractDone': 'Extraction completed',\n    'extractError': 'Extraction failed',\n    'waitingParts': 'Waiting for parts',\n    'name': 'Name',\n    'size': 'Size',\n    'unknown': 'Unknown',\n    'fileSelectedCount': 'Files: ',\n    'fileSelectedSize': 'Size: ',\n    'httpHeader': 'Header',\n    'httpHeaderName': 'Header Name',\n    'httpHeaderValue': 'Header Value',\n    'login': 'Login',\n    'username_required': 'Please enter your username',\n    'password_required': 'Please enter your password',\n    'login_success': 'Login successful',\n    'login_failed': 'Login failed, please check your username and password',\n    'login_failed_network':\n        'Login failed, please check your network connection',\n    'insertPlaceholder': 'Insert Placeholder',\n    'placeholderYear': 'Current year',\n    'placeholderMonth': 'Current month (01-12)',\n    'placeholderDay': 'Current day (01-31)',\n    'placeholderDate': 'Full date (YYYY-MM-DD)',\n    'example': 'e.g. @value',\n    'downloadCategories': 'Download Categories',\n    'categoryMusic': 'Music',\n    'categoryVideo': 'Video',\n    'categoryDocument': 'Document',\n    'categoryProgram': 'Program',\n    'categoryName': 'Category Name',\n    'categoryPath': 'Category Path',\n    'builtInCategory': 'Built-in category cannot be deleted',\n    'selectCategory': 'Select Category',\n    'githubMirror': 'GitHub Mirror',\n    'githubMirrorEnable': 'Enable GitHub Mirror',\n    'githubMirrorDesc': 'Use mirrors to accelerate GitHub content downloads',\n    'githubMirrorType': 'Mirror Type',\n    'githubMirrorUrl': 'Mirror URL',\n    'githubMirrorUrlHint': 'Enter mirror URL',\n    'updateUrl': 'Update URL',\n    'updateUrlManual': 'Manual Update',\n    'updateUrlListen': 'Listen Update',\n    'updateUrlListeningTip': 'Waiting for URL update',\n    'updateUrlCancelListen': 'Cancel Listen',\n    'updateUrlDialogHint': 'Enter new download URL',\n    'pendingUpdateFound': 'Pending Update Task Found',\n    'pendingUpdateConfirm':\n        'Task \"@name\" is waiting for URL update. Do you want to update it with the new URL?',\n    'pendingUpdateYes': 'Update Task',\n    'pendingUpdateNo': 'Create New Task',\n    'ed2k': 'ED2K',\n    'ed2kTcpPort': 'TCP Listen Port',\n    'ed2kUdpPort': 'UDP Listen Port',\n    'ed2kServerList': 'Server Addresses',\n    'ed2kServerMet': 'Server.met Sources',\n    'ed2kNodesDat': 'nodes.dat Sources',\n    'ed2kAutoPort': 'Auto assign',\n    'ed2kOnePerLine': 'One entry per line',\n    'ed2kServersHint': 'host:port, one per line',\n    'ed2kServerMetHint': 'Server.met URL or ed2k serverlist link, one per line',\n    'ed2kNodesDatHint': 'nodes.dat path or URL, one per line',\n    'desktopNotification': 'Desktop Notifications',\n    'notificationTaskDone': 'Task completed',\n    'notificationTaskError': 'Task error',\n  },\n};\n"
  },
  {
    "path": "ui/flutter/lib/i18n/langs/es_es.dart",
    "content": "const esES = {\n  'es_ES': {\n    'label': 'Español',\n    'error': 'Error',\n    'tip': 'Consejo',\n    'confirm': 'Confirmar',\n    'confirmDelete': '¿Está seguro de que desea eliminar?',\n    'cancel': 'Cancelar',\n    'on': 'Activado',\n    'off': 'Desactivado',\n    'selectAll': 'Seleccionar Todo',\n    'task': 'Tareas',\n    'downloading': 'descargando',\n    'downloaded': 'descargado',\n    'setting': 'Ajustes',\n    'donate': 'Donar',\n    'exit': 'Salir',\n    'create': 'Crear Tarea',\n    'directDownload': 'Descarga Directa',\n    'advancedOptions': 'Opciones Avanzadas',\n    'downloadLink': 'Enlace de Descarga',\n    'downloadLinkValid': 'Por favor, introduce el enlace de descarga',\n    'downloadLinkHit':\n        'Por favor, introduce el enlace de descarga, se admite HTTP/HTTPS/MAGNET@append',\n    'downloadLinkHitDesktop':\n        ', o arrastra el archivo torrent aquí directamente',\n    'download': 'Descargar',\n    'noFileSelected':\n        'Por favor, selecciona al menos un archivo para continuar.',\n    'noStoragePermission': 'Se requiere permiso de almacenamiento',\n    'selectFile': 'Seleccionar Archivo',\n    'rename': 'Renombrar',\n    'basic': 'Básico',\n    'advanced': 'Avanzado',\n    'general': 'General',\n    'downloadDir': 'Directorio de Descarga',\n    'downloadDirValid': 'Por favor, selecciona el directorio de descarga',\n    'connections': 'Conexiones',\n    'useServerCtime': 'Usar tiempo del servidor para la creación de archivos',\n    'maxRunning': 'Máximo de Tareas en Ejecución',\n    'autoStartTasks': 'Iniciar automáticamente tareas incompletas al arrancar',\n    'autoTorrentEnable':\n        'Crear automáticamente tareas BT desde archivos .torrent',\n    'autoTorrentDeleteAfterDownload':\n        'Eliminar archivo .torrent después de crear tarea BT',\n    'autoDeleteMissingFileTasks':\n        'Eliminar automáticamente tareas con archivos faltantes',\n    'items': '@count elementos',\n    'subscribeTracker': 'Suscribirse al Tracker',\n    'subscribeFail':\n        'Suscripción fallida, por favor verifica la red o intenta más tarde',\n    'update': 'Actualizar',\n    'updateDaily': 'Actualizar diariamente',\n    'lastUpdate': 'Última actualización: @time',\n    'addTracker': 'Añadir Tracker',\n    'addTrackerHit':\n        'Por favor, introduce la URL del servidor tracker, una por línea',\n    'ui': 'Interfaz',\n    'theme': 'Tema',\n    'themeSystem': 'Sistema',\n    'themeLight': 'Claro',\n    'themeDark': 'Oscuro',\n    'locale': 'Idioma',\n    'about': 'Acerca de',\n    'homepage': 'Página de inicio',\n    'version': 'Versión',\n    'protocol': 'Protocolo',\n    'port': 'Puerto',\n    'apiToken': 'Token API',\n    'notSet': 'No establecido',\n    'set': 'Establecido',\n    'portInUse': 'El puerto [@port] está en uso, por favor cambia el puerto',\n    'effectAfterRestart': 'Efectivo después de reiniciar',\n    'show': 'Mostrar',\n    'startAll': 'Iniciar Todo',\n    'pauseAll': 'Pausar Todo',\n    'deleteTask': 'Eliminar @count tareas',\n    'deleteTaskTip': 'Mantener archivos descargados',\n    'delete': 'Eliminar',\n    'newVersionTitle': 'Nueva versión @version disponible',\n    'newVersionUpdate': 'Actualizar Ahora',\n    'newVersionLater': 'Más tarde',\n    'extensions': 'Extensiones',\n    'extensionInstallUrl': 'URL de instalación',\n    'extensionInstallSuccess': 'Instalado con éxito',\n    'extensionUpdateSuccess': 'Actualizado con éxito',\n    'extensionDelete': 'Eliminar Extensión',\n    'extensionAlreadyLatest': 'Ya es la versión más reciente',\n    'extensionFind': 'Buscar Extensiones',\n    'extensionDevelop': 'Desarrollar Extensiones',\n    'history': 'Historial',\n    'clearHistory': 'Borrar Historial',\n    'noHistoryFound': 'No se encontró historial',\n    'serviceTitle': 'Servicio de Descarga',\n    'serviceText': 'En ejecución',\n    'network': 'Red',\n    'proxy': 'Proxy',\n    'noProxy': 'Sin Proxy',\n    'systemProxy': 'Proxy del Sistema',\n    'customProxy': 'Proxy Personalizado',\n    'server': 'Servidor',\n    'username': 'Nombre de usuario',\n    'password': 'Contraseña',\n    'thanks': 'Agradecimientos',\n    'thanksDesc':\n        '¡Gracias a todos los contribuyentes que han ayudado a construir y desarrollar la comunidad Gopeed!',\n    'browserExtension': 'Extensión del Navegador',\n    'launchAtStartup': 'Iniciar al Arranque',\n    'runAsMenubarApp': 'Ejecutar como aplicación de barra de menú',\n    'runAsMenubarAppDesc':\n        'Ocultar icono del Dock y ejecutar solo en la barra de menú',\n    'seedConfig': 'Configuración de Semilla',\n    'seedKeep': 'Mantener sembrado',\n    'seedRatio': 'Ratio de semilla',\n    'seedTime': 'Tiempo de semilla (minutos)',\n    'taskDetail': 'Detalles de la Tarea',\n    'taskName': 'Nombre de la Tarea',\n    'taskUrl': 'URL de la Tarea',\n    'downloadPath': 'Ruta de Descarga',\n    'skipVerifyCert': 'Omitir Verificación de Certificado',\n    'archives': 'Archivos',\n    'autoExtract': 'Extraer archivos automáticamente',\n    'archivePassword': 'Contraseña del archivo',\n    'archivePasswordHint': 'Dejar vacío si no tiene contraseña',\n    'deleteAfterExtract': 'Eliminar archivo después de extraer',\n    'extracting': 'Extrayendo',\n    'extractDone': 'Extracción completada',\n    'extractError': 'Error en la extracción',\n    'waitingParts': 'Esperando partes',\n    'insertPlaceholder': 'Insertar marcador de posición',\n    'placeholderYear': 'Año actual',\n    'placeholderMonth': 'Mes actual (01-12)',\n    'placeholderDay': 'Día actual (01-31)',\n    'placeholderDate': 'Fecha completa (AAAA-MM-DD)',\n    'example': 'ej. @value',\n    'downloadCategories': 'Categorías de descarga',\n    'categoryMusic': 'Música',\n    'categoryVideo': 'Video',\n    'categoryDocument': 'Documento',\n    'categoryProgram': 'Programa',\n    'categoryName': 'Nombre de categoría',\n    'categoryPath': 'Ruta de categoría',\n    'builtInCategory': 'La categoría incorporada no se puede eliminar',\n    'selectCategory': 'Seleccionar categoría',\n    'ed2k': 'ED2K',\n    'ed2kTcpPort': 'TCP Listen Port',\n    'ed2kUdpPort': 'UDP Listen Port',\n    'ed2kServerList': 'Server Addresses',\n    'ed2kServerMet': 'Server.met Sources',\n    'ed2kNodesDat': 'nodes.dat Sources',\n    'ed2kAutoPort': 'Auto assign',\n    'ed2kOnePerLine': 'One entry per line',\n    'ed2kServersHint': 'host:port, one per line',\n    'ed2kServerMetHint': 'Server.met URL or ed2k serverlist link, one per line',\n    'ed2kNodesDatHint': 'nodes.dat path or URL, one per line',\n    'desktopNotification': 'Notificaciones de escritorio',\n    'notificationTaskDone': 'Tarea completada',\n    'notificationTaskError': 'Error de tarea',\n  },\n};\n"
  },
  {
    "path": "ui/flutter/lib/i18n/langs/fa_ir.dart",
    "content": "const faIR = {\n  \"fa_IR\": {\n    'label': 'فارسی',\n    'error': 'اررور',\n    'tip': 'Tip',\n    'confirm': 'تایید',\n    'confirmDelete': 'آیا مطمئن هستید که می خواهید حذف کنید؟',\n    'cancel': 'انصراف',\n    'selectAll': 'انتخاب همه',\n    'task': 'کار',\n    'setting': 'تنظیمات',\n    'donate': 'کمک مالی',\n    'exit': 'خروج',\n    'create': 'ایجاد کار',\n    'downloading': 'دانلود کردن',\n    'downloaded': 'دانلود شدن',\n    'downloadLink': 'لینک دانلود',\n    'downloadLinkValid': 'لطفا لینک دانلود را وارد کنید',\n    'download': 'دانلود',\n    'noFileSelected': 'لطفا حداقل یک فایل را جهت دانلود انتخاب کنید.',\n    'noStoragePermission': 'مجوز ذخیره سازی مورد نیاز است',\n    'selectFile': 'انتخاب فایل',\n    'basic': 'پایه',\n    'advanced': 'پیشرفته',\n    'general': 'عمومی',\n    'downloadDir': 'دایرکتوری دانلود',\n    'downloadDirValid': 'لطفا دایرکتوری دانلود را انتخاب کنید',\n    'connections': 'اتصالات',\n    'autoTorrentEnable': 'ایجاد خودکار وظایف BT از فایل‌های .torrent',\n    'autoTorrentDeleteAfterDownload': 'حذف فایل .torrent پس از ایجاد وظیفه BT',\n    'autoDeleteMissingFileTasks': 'حذف خودکار وظایف با فایل‌های گم شده',\n    'items': '@count آیتم ها',\n    'subscribeTracker': 'Subscribe Tracker',\n    'subscribeFail':\n        'Subscribe failed, please check network or try again later',\n    'update': 'بروزرسانی',\n    'updateDaily': 'بروزرسانی روزانه',\n    'lastUpdate': 'Last update: @time',\n    'addTracker': 'Add Tracker',\n    'addTrackerHit': 'Please enter the tracker server url, one per line',\n    'ui': 'رابط کاربری',\n    'theme': 'پوسته',\n    'themeSystem': 'سیستم',\n    'themeLight': 'روشن',\n    'themeDark': 'تاریک',\n    'locale': 'Language',\n    'about': 'درباره ی',\n    'homepage': 'صفحه اصلی',\n    'version': 'نسخه',\n    'protocol': 'پروتکل',\n    'port': 'پورت',\n    'apiToken': 'API توکن',\n    'notSet': 'NS',\n    'set': 'SET',\n    'effectAfterRestart': 'Effect after restart',\n    'startAll': 'شروع همه',\n    'pauseAll': 'توقف همه',\n    'deleteTask': 'حذف @count کار',\n    'deleteTaskTip': 'فایل های دانلود شده را نگه دارد',\n    'delete': 'پاک کردن',\n    'newVersionTitle': '@version عنوان: کشف نسخه جدید',\n    'newVersionUpdate': 'بروزرسانی',\n    'newVersionLater': 'بعداً',\n    'thanks': 'تشکر',\n    'thanksDesc':\n        'از تمامی افرادی که در ساخت و توسعه‌ی جامعه‌ی Gopeed مشارکت کرده‌اند، سپاسگزاری می‌کنیم!',\n    'browserExtension': 'افزونه مرورگر',\n    'skipVerifyCert': 'رد کردن تأیید گواهینامه',\n    'archives': 'آرشیوها',\n    'autoExtract': 'استخراج خودکار بایگانی‌ها',\n    'archivePassword': 'رمز عبور بایگانی',\n    'archivePasswordHint': 'اگر رمز ندارد خالی بگذارید',\n    'deleteAfterExtract': 'حذف آرشیو پس از استخراج',\n    'extracting': 'در حال استخراج',\n    'extractDone': 'استخراج کامل شد',\n    'extractError': 'استخراج ناموفق بود',\n    'waitingParts': 'در انتظار قسمت‌ها',\n    'insertPlaceholder': 'درج نگهدارنده',\n    'placeholderYear': 'سال جاری',\n    'placeholderMonth': 'ماه جاری (01-12)',\n    'placeholderDay': 'روز جاری (01-31)',\n    'placeholderDate': 'تاریخ کامل (YYYY-MM-DD)',\n    'example': 'مثلا @value',\n    'downloadCategories': 'دسته‌بندی‌های دانلود',\n    'categoryMusic': 'موسیقی',\n    'categoryVideo': 'ویدیو',\n    'categoryDocument': 'سند',\n    'categoryProgram': 'برنامه',\n    'categoryName': 'نام دسته‌بندی',\n    'categoryPath': 'مسیر دسته‌بندی',\n    'builtInCategory': 'دسته‌بندی داخلی قابل حذف نیست',\n    'selectCategory': 'انتخاب دسته‌بندی',\n    'launchAtStartup': 'راه‌اندازی در استارت‌آپ',\n    'runAsMenubarApp': 'اجرا به عنوان برنامه نوار منو',\n    'runAsMenubarAppDesc': 'پنهان کردن آیکون Dock و اجرا فقط در نوار منو',\n    'ed2k': 'ED2K',\n    'ed2kTcpPort': 'TCP Listen Port',\n    'ed2kUdpPort': 'UDP Listen Port',\n    'ed2kServerList': 'Server Addresses',\n    'ed2kServerMet': 'Server.met Sources',\n    'ed2kNodesDat': 'nodes.dat Sources',\n    'ed2kAutoPort': 'Auto assign',\n    'ed2kOnePerLine': 'One entry per line',\n    'ed2kServersHint': 'host:port, one per line',\n    'ed2kServerMetHint': 'Server.met URL or ed2k serverlist link, one per line',\n    'ed2kNodesDatHint': 'nodes.dat path or URL, one per line',\n    'desktopNotification': 'اعلان‌های دسکتاپ',\n    'notificationTaskDone': 'وظیفه تکمیل شد',\n    'notificationTaskError': 'خطای وظیفه',\n  }\n};\n"
  },
  {
    "path": "ui/flutter/lib/i18n/langs/fr_fr.dart",
    "content": "const frFR = {\n  'fr_FR': {\n    'label': 'Français',\n    'error': 'Erreur',\n    'tip': 'Conseil',\n    'confirm': 'Confirmer',\n    'confirmDelete': 'Êtes-vous sûr de vouloir supprimer ?',\n    'cancel': 'Annuler',\n    'on': 'Activé',\n    'off': 'Désactivé',\n    'selectAll': 'Sélectionner tout',\n    'task': 'Tâches',\n    'downloading': 'Téléchargement',\n    'downloaded': 'Téléchargé',\n    'setting': 'Paramètres',\n    'donate': 'Faire un don',\n    'exit': 'Quitter',\n    'create': 'Créer une tâche',\n    'directDownload': 'Téléchargement direct',\n    'advancedOptions': 'Options avancées',\n    'downloadLink': 'Lien de téléchargement',\n    'downloadLinkValid': 'Veuillez entrer le lien de téléchargement',\n    'downloadLinkHit':\n        'Veuillez entrer le lien de téléchargement, HTTP/HTTPS/MAGNET pris en charge',\n    'downloadLinkHitDesktop': ', ou glissez directement le fichier torrent ici',\n    'download': 'Télécharger',\n    'noFileSelected':\n        'Veuillez sélectionner au moins un fichier pour continuer.',\n    'noStoragePermission': 'Permission de stockage requise',\n    'selectFile': 'Sélectionner un fichier',\n    'rename': 'Renommer',\n    'basic': 'De base',\n    'advanced': 'Avancé',\n    'general': 'Général',\n    'downloadDir': 'Répertoire de téléchargement',\n    'downloadDirValid': 'Veuillez sélectionner le répertoire de téléchargement',\n    'connections': 'Connexions',\n    'useServerCtime':\n        'Utiliser l\\'heure du serveur pour la création de fichier',\n    'maxRunning': 'Tâches en cours maximum',\n    'autoStartTasks':\n        'Démarrer automatiquement les tâches incomplètes au lancement',\n    'autoTorrentEnable':\n        'Créer automatiquement des tâches BT à partir de fichiers .torrent',\n    'autoTorrentDeleteAfterDownload':\n        'Supprimer le fichier .torrent après création de tâche BT',\n    'autoDeleteMissingFileTasks':\n        'Supprimer automatiquement les tâches avec fichiers manquants',\n    'items': '@count éléments',\n    'subscribeTracker': 'S\\'abonner au tracker',\n    'subscribeFail':\n        'Abonnement échoué, veuillez vérifier le réseau ou réessayer plus tard',\n    'update': 'Mettre à jour',\n    'updateDaily': 'Mettre à jour quotidiennement',\n    'lastUpdate': 'Dernière mise à jour : @time',\n    'addTracker': 'Ajouter un tracker',\n    'addTrackerHit':\n        'Veuillez entrer l\\'URL du serveur de tracker, une par ligne',\n    'ui': 'Interface utilisateur',\n    'theme': 'Thème',\n    'themeSystem': 'Système',\n    'themeLight': 'Clair',\n    'themeDark': 'Sombre',\n    'locale': 'Langue',\n    'about': 'À propos',\n    'homepage': 'Page d\\'accueil',\n    'version': 'Version',\n    'protocol': 'Protocole',\n    'port': 'Port',\n    'apiToken': 'Jeton API',\n    'notSet': 'Non défini',\n    'set': 'Défini',\n    'portInUse': 'Le port [@port] est utilisé, veuillez changer de port',\n    'effectAfterRestart': 'Effectif après redémarrage',\n    'show': 'Afficher',\n    'startAll': 'Tout démarrer',\n    'pauseAll': 'Tout suspendre',\n    'deleteTask': 'Supprimer @count tâches',\n    'deleteTaskTip': 'Conserver les fichiers téléchargés',\n    'delete': 'Supprimer',\n    'newVersionTitle': 'Nouvelle version @version disponible',\n    'newVersionUpdate': 'Mettre à jour maintenant',\n    'newVersionLater': 'Plus tard',\n    'extensions': 'Extensions',\n    'extensionInstallUrl': 'URL d\\'installation',\n    'extensionInstallSuccess': 'Installé avec succès',\n    'extensionUpdateSuccess': 'Mis à jour avec succès',\n    'extensionDelete': 'Supprimer l\\'extension',\n    'extensionAlreadyLatest': 'C\\'est déjà la dernière version',\n    'extensionFind': 'Trouver des extensions',\n    'extensionDevelop': 'Développer des extensions',\n    'history': 'Historique',\n    'clearHistory': 'Effacer l\\'historique',\n    'noHistoryFound': 'Aucun historique trouvé',\n    'serviceTitle': 'Service de téléchargement',\n    'serviceText': 'En cours d\\'exécution',\n    'network': 'Réseau',\n    'proxy': 'Proxy',\n    'noProxy': 'Pas de proxy',\n    'systemProxy': 'Proxy système',\n    'customProxy': 'Proxy personnalisé',\n    'server': 'Serveur',\n    'username': 'Nom d\\'utilisateur',\n    'password': 'Mot de passe',\n    'thanks': 'Merci',\n    'thanksDesc':\n        'Merci à tous les contributeurs qui ont aidé à construire et développer la communauté Gopeed !',\n    'browserExtension': 'Extension du navigateur',\n    'launchAtStartup': 'Lancer au démarrage',\n    'runAsMenubarApp': 'Exécuter en tant qu\\'application de barre de menus',\n    'runAsMenubarAppDesc':\n        'Masquer l\\'icône du Dock et exécuter uniquement dans la barre de menus',\n    'skipVerifyCert': 'Ignorer la vérification du certificat',\n    'archives': 'Archives',\n    'autoExtract': 'Extraire automatiquement les archives',\n    'archivePassword': 'Mot de passe de l\\'archive',\n    'archivePasswordHint': 'Laisser vide si non protégé par mot de passe',\n    'deleteAfterExtract': \"Supprimer l'archive après extraction\",\n    'extracting': 'Extraction en cours',\n    'extractDone': 'Extraction terminée',\n    'extractError': 'Échec de l\\'extraction',\n    'waitingParts': 'En attente des parties',\n    'insertPlaceholder': 'Insérer un espace réservé',\n    'placeholderYear': 'Année en cours',\n    'placeholderMonth': 'Mois en cours (01-12)',\n    'placeholderDay': 'Jour en cours (01-31)',\n    'placeholderDate': 'Date complète (AAAA-MM-JJ)',\n    'example': 'ex. @value',\n    'downloadCategories': 'Catégories de téléchargement',\n    'categoryMusic': 'Musique',\n    'categoryVideo': 'Vidéo',\n    'categoryDocument': 'Document',\n    'categoryProgram': 'Programme',\n    'categoryName': 'Nom de la catégorie',\n    'categoryPath': 'Chemin de la catégorie',\n    'builtInCategory': 'La catégorie intégrée ne peut pas être supprimée',\n    'selectCategory': 'Sélectionner une catégorie',\n    'ed2k': 'ED2K',\n    'ed2kTcpPort': 'TCP Listen Port',\n    'ed2kUdpPort': 'UDP Listen Port',\n    'ed2kServerList': 'Server Addresses',\n    'ed2kServerMet': 'Server.met Sources',\n    'ed2kNodesDat': 'nodes.dat Sources',\n    'ed2kAutoPort': 'Auto assign',\n    'ed2kOnePerLine': 'One entry per line',\n    'ed2kServersHint': 'host:port, one per line',\n    'ed2kServerMetHint': 'Server.met URL or ed2k serverlist link, one per line',\n    'ed2kNodesDatHint': 'nodes.dat path or URL, one per line',\n    'desktopNotification': 'Notifications de bureau',\n    'notificationTaskDone': 'Tâche terminée',\n    'notificationTaskError': 'Erreur de tâche',\n  },\n};\n"
  },
  {
    "path": "ui/flutter/lib/i18n/langs/hu_hu.dart",
    "content": "const huHU = {\n  'hu_HU': {\n    'label': 'Magyar',\n    'error': 'Hiba',\n    'tip': 'Tipp',\n    'confirm': 'Megerősít',\n    'confirmDelete': 'Biztosan törölni szeretné?',\n    'cancel': 'Mégsem',\n    'on': 'Bekapcsolva',\n    'off': 'Kikapcsolva',\n    'selectAll': 'Összes kiválasztása',\n    'select': 'Kiválaszt',\n    'task': 'Feladatok',\n    'downloading': 'letöltés alatt',\n    'downloaded': 'letöltve',\n    'setting': 'Beállítások',\n    'donate': 'Támogatás',\n    'exit': 'Kilépés',\n    'create': 'Feladat létrehozása',\n    'directDownload': 'Közvetlen letöltés',\n    'advancedOptions': 'Haladó beállítások',\n    'followSettings': 'Beállítások követése',\n    'downloadLink': 'Letöltési hivatkozás',\n    'downloadLinkValid': 'Kérjük, adja meg a letöltési hivatkozást',\n    'downloadLinkHit':\n        'Kérjük, adja meg a letöltési hivatkozást, soronként egyet@append',\n    'downloadLinkHitDesktop': ', vagy húzza ide a torrent fájlt közvetlenül',\n    'download': 'Letöltés',\n    'noFileSelected': 'Kérjük, válasszon ki legalább egy fájlt a folytatáshoz.',\n    'noStoragePermission': 'Tárolási engedély szükséges',\n    'selectFile': 'Fájl kiválasztása',\n    'rename': 'Átnevezés',\n    'basic': 'Alap',\n    'advanced': 'Haladó',\n    'general': 'Általános',\n    'downloadDir': 'Letöltési könyvtár',\n    'downloadDirValid': 'Kérjük, válassza ki a letöltési könyvtárat',\n    'connections': 'Kapcsolatok',\n    'useServerCtime': 'Használja a szerveridőt a fájl létrehozásához',\n    'maxRunning': 'Maximális futó feladatok',\n    'defaultDirectDownload':\n        'Alapértelmezettként ellenőrizze a közvetlen letöltést',\n    'autoTorrentEnable':\n        'BT feladatok automatikus létrehozása .torrent fájlokból',\n    'autoTorrentDeleteAfterDownload':\n        '.torrent fájl törlése a BT feladat létrehozása után',\n    'autoDeleteMissingFileTasks':\n        'Hiányzó fájlokkal rendelkező feladatok automatikus törlése',\n    'items': '@count elem',\n    'subscribeTracker': 'Nyomkövető előfizetése',\n    'subscribeFail':\n        'Az előfizetés nem sikerült, kérjük, ellenőrizze a hálózatot, vagy próbálja újra később',\n    'update': 'Frissítés',\n    'updateDaily': 'Napi frissítés',\n    'lastUpdate': 'Utolsó frissítés: @time',\n    'addTracker': 'Nyomkövető hozzáadása',\n    'addTrackerHit':\n        'Kérjük, adja meg a nyomkövető szerver URL-jét, soronként egyet',\n    'ui': 'Felhasználói felület',\n    'theme': 'Téma',\n    'themeSystem': 'Rendszer',\n    'themeLight': 'Világos',\n    'themeDark': 'Sötét',\n    'locale': 'Nyelv',\n    'about': 'Névjegy',\n    'homepage': 'Főoldal',\n    'version': 'Verzió',\n    'protocol': 'Protokoll',\n    'port': 'Port',\n    'apiToken': 'API token',\n    'notSet': 'NS',\n    'set': 'BEÁLLÍT',\n    'portInUse':\n        'A [@port] port használatban van, kérjük, változtassa meg a portot',\n    'effectAfterRestart': 'Kihatás indítás után',\n    'developer': 'Fejlesztő',\n    'logDirectory': 'Napló könyvtár',\n    'show': 'Megjelenítés',\n    'continue': 'Folyatás',\n    'pause': 'Szünet',\n    'startAll': 'Összes indítása',\n    'pauseAll': 'Összes szüneteltetése',\n    'deleteTask': '@count feladat törlése',\n    'deleteTaskTip': 'Letöltött fájlok megőrzése',\n    'delete': 'Törlés',\n    'newVersionTitle': 'Új verzió felfedezése @version',\n    'newVersionUpdate': 'Frissítés most',\n    'newVersionLater': 'Később',\n    'extensions': 'Bővítmények',\n    'extensionInstallUrl': 'Telepítési URL',\n    'extensionInstallSuccess': 'Sikeresen telepítve',\n    'extensionUpdateSuccess': 'Sikeresen frissítve',\n    'extensionDelete': 'Bővítmény törlése',\n    'extensionAlreadyLatest': 'Ez már a legfrissebb verzió',\n    'extensionFind': 'Bővítmények keresése',\n    'extensionDevelop': 'Bővítmények fejlesztése',\n    'history': 'Előzmények',\n    'clearHistory': 'Előzmények törlése',\n    'noHistoryFound': 'Nincs előzménye',\n    'serviceTitle': 'Letöltési szolgáltatás',\n    'serviceText': 'Futtatás',\n    'network': 'Hálózat',\n    'proxy': 'Proxy',\n    'noProxy': 'Nincs proxy',\n    'systemProxy': 'Rendszer proxy',\n    'customProxy': 'Egyéni proxy',\n    'server': 'Szerver',\n    'username': 'Felhasználónév',\n    'password': 'Jelszó',\n    'thanks': 'Köszönet',\n    'thanksDesc':\n        'Köszönet minden hozzájárulónak, aki segített a Gopeed közösség építésében és fejlesztésében!',\n    'browserExtension': 'Böngésző bővítmény',\n    'launchAtStartup': 'Indítás indításkor',\n    'runAsMenubarApp': 'Futtatás menüsáv alkalmazásként',\n    'runAsMenubarAppDesc':\n        'Dock ikon elrejtése és csak a menüsávban való futtatás',\n    'seedConfig': 'Seed beállítás',\n    'seedKeep': 'Tartsa a seedet amíg manuálisan le nem állítják',\n    'seedRatio': 'Seed arány',\n    'seedTime': 'Seed idő (perc)',\n    'setAsDefaultBtClient': 'Alapértelmezett BT klienseként beállít',\n    'taskDetail': 'Feladat részlete',\n    'taskName': 'Feladat neve',\n    'taskUrl': 'Feladat URL',\n    'downloadPath': 'Letöltési útvonal',\n    'skipVerifyCert': 'Tanúsítvány ellenőrzés átugrása',\n    'archives': 'Archívumok',\n    'autoExtract': 'Archívumok automatikus kicsomagolása',\n    'archivePassword': 'Archívum jelszó',\n    'archivePasswordHint': 'Hagyja üresen, ha nincs jelszóval védve',\n    'deleteAfterExtract': 'Archívum törlése kicsomagolás után',\n    'extracting': 'Kicsomagolás',\n    'extractDone': 'Kicsomagolás befejezve',\n    'extractError': 'Kicsomagolás sikertelen',\n    'waitingParts': 'Várakozás a részekre',\n    'name': 'Név',\n    'size': 'Méret',\n    'unknown': 'Ismeretlen',\n    'fileSelectedCount': 'Fájlok: ',\n    'fileSelectedSize': 'Méret: ',\n    'httpHeaderName': 'Fejléc neve',\n    'httpHeaderValue': 'Fejléc értéke',\n    'insertPlaceholder': 'Helyőrző beszúrása',\n    'placeholderYear': 'Aktuális év',\n    'placeholderMonth': 'Aktuális hónap (01-12)',\n    'placeholderDay': 'Aktuális nap (01-31)',\n    'placeholderDate': 'Teljes dátum (ÉÉÉÉ-HH-NN)',\n    'example': 'pl. @value',\n    'downloadCategories': 'Letöltési kategóriák',\n    'categoryMusic': 'Zene',\n    'categoryVideo': 'Videó',\n    'categoryDocument': 'Dokumentum',\n    'categoryProgram': 'Program',\n    'categoryName': 'Kategória neve',\n    'categoryPath': 'Kategória útvonala',\n    'builtInCategory': 'A beépített kategória nem törölhető',\n    'selectCategory': 'Kategória kiválasztása',\n    'ed2k': 'ED2K',\n    'ed2kTcpPort': 'TCP Listen Port',\n    'ed2kUdpPort': 'UDP Listen Port',\n    'ed2kServerList': 'Server Addresses',\n    'ed2kServerMet': 'Server.met Sources',\n    'ed2kNodesDat': 'nodes.dat Sources',\n    'ed2kAutoPort': 'Auto assign',\n    'ed2kOnePerLine': 'One entry per line',\n    'ed2kServersHint': 'host:port, one per line',\n    'ed2kServerMetHint': 'Server.met URL or ed2k serverlist link, one per line',\n    'ed2kNodesDatHint': 'nodes.dat path or URL, one per line',\n    'desktopNotification': 'Asztali értesítések',\n    'notificationTaskDone': 'Feladat befejezve',\n    'notificationTaskError': 'Feladat hiba',\n  },\n};\n"
  },
  {
    "path": "ui/flutter/lib/i18n/langs/id_id.dart",
    "content": "const idID = {\n  'id_ID': {\n    'label': 'Indonesia',\n    'error': 'Kesalahan',\n    'tip': 'Tips',\n    'confirm': 'Konfirmasi',\n    'confirmDelete': 'Apakah Anda yakin ingin menghapus?',\n    'cancel': 'Batal',\n    'on': 'Aktif',\n    'off': 'Nonaktif',\n    'selectAll': 'Pilih Semua',\n    'task': 'Tugas',\n    'downloading': 'Mengunduh',\n    'downloaded': 'Terunduh',\n    'setting': 'Pengaturan',\n    'donate': 'Donasi',\n    'exit': 'Keluar',\n    'create': 'Membuat Tugas',\n    'directDownload': 'Unduhan Langsung',\n    'advancedOptions': 'Pengaturan Lanjutan',\n    'followSettings': 'Mengikuti Pengaturan',\n    'downloadLink': 'Tautan Unduhan',\n    'downloadLinkValid': 'Silakan masukkan tautan unduhan',\n    'downloadLinkHit':\n        'Tolong masukkan tautan unduhan, HTTP/HTTPS/MAGNET didukung@append',\n    'downloadLinkHitDesktop':\n        ', atau seret file torrent ke sini secara langsung',\n    'download': 'Download',\n    'noFileSelected': 'Silakan pilih setidaknya satu file untuk melanjutkan.',\n    'noStoragePermission': 'Izin penyimpanan diperlukan',\n    'selectFile': 'Pilih File',\n    'rename': 'Mengganti Nama',\n    'basic': 'Dasar',\n    'advanced': 'Lanjutan',\n    'general': 'Umum',\n    'downloadDir': 'Direktori Unduhan',\n    'downloadDirValid': 'Silahkan pilih direktori unduhan',\n    'connections': 'Koneksi',\n    'useServerCtime': 'Gunakan waktu server untuk pembuatan file',\n    'maxRunning': 'Tugas Maksimum yang Berjalan',\n    'defaultDirectDownload': 'Pilih unduhan langsung sebagai opsi standar',\n    'autoTorrentEnable': 'Buat tugas BT secara otomatis dari file .torrent',\n    'autoTorrentDeleteAfterDownload':\n        'Hapus file .torrent setelah membuat tugas BT',\n    'autoDeleteMissingFileTasks':\n        'Otomatis hapus tugas dengan file yang hilang',\n    'items': '@count items',\n    'subscribeTracker': 'Berlangganan Tracker',\n    'subscribeFail':\n        'Berlangganan gagal, periksa jaringan atau coba lagi nanti',\n    'update': 'Perbarui',\n    'updateDaily': 'Perbarui setiap hari',\n    'lastUpdate': 'Pembaruan terakhir: @time',\n    'addTracker': 'Tambah Tracker',\n    'addTrackerHit': 'Silakan masukkan URL server tracker, satu per baris',\n    'ui': 'UI',\n    'theme': 'Tema',\n    'themeSystem': 'Sistem',\n    'themeLight': 'Terang',\n    'themeDark': 'Gelap',\n    'locale': 'Bahasa',\n    'about': 'Tentang',\n    'homepage': 'Beranda',\n    'version': 'Versi',\n    'protocol': 'Protokol',\n    'port': 'Port',\n    'apiToken': 'Token API',\n    'notSet': 'Tidak Diatur',\n    'set': 'ATUR',\n    'portInUse': 'Port [@port] sedang digunakan, silakan ubah port',\n    'effectAfterRestart': 'Berlaku setelah restart',\n    'developer': 'Pengembang',\n    'logDirectory': 'Direktori Log',\n    'show': 'Tampilkan',\n    'startAll': 'Mulai Semua',\n    'pauseAll': 'Jeda Semua',\n    'deleteTask': 'Hapus @count Tugas',\n    'deleteTaskTip': 'Simpan file yang terunduh',\n    'delete': 'Hapus',\n    'newVersionTitle': 'Temukan versi batu @version',\n    'newVersionUpdate': 'Perbarui Sekarang',\n    'newVersionLater': 'Nanti',\n    'extensions': 'Ekstensi',\n    'extensionInstallUrl': 'URL Instal',\n    'extensionInstallSuccess': 'Diinstal dengan sukses',\n    'extensionUpdateSuccess': 'Diperbaruhi dengan sukses',\n    'extensionDelete': 'Hapus Ekstensi',\n    'extensionAlreadyLatest': 'Ini sudah versi terbaru',\n    'extensionFind': 'Temukan Ekstensi',\n    'extensionDevelop': 'Kembangkan Ekstensi',\n    'history': 'Riwayat',\n    'clearHistory': 'Hapus Riwayat',\n    'noHistoryFound': 'Riwayat Tidak DItemukan',\n    'serviceTitle': 'Layanan Unduhan',\n    'serviceText': 'Sedang Berjalan',\n    'network': 'Jaringan',\n    'proxy': 'Proxy',\n    'noProxy': 'Tanpa Proxy',\n    'systemProxy': 'Proxy Sistem',\n    'customProxy': 'Proxy Kustom',\n    'server': 'Server',\n    'username': 'Nama Pengguna',\n    'password': 'Kata Sandi',\n    'thanks': 'Terima Kasih',\n    'thanksDesc':\n        'Terima kasih kepada semua kontributor yang telah membantu membangun dan mengembangkan komunitas Gopeed!',\n    'browserExtension': 'Ekstensi Perambanan',\n    'launchAtStartup': 'Luncurkan saat Startup',\n    'runAsMenubarApp': 'Jalankan sebagai aplikasi bilah menu',\n    'runAsMenubarAppDesc':\n        'Sembunyikan ikon Dock dan jalankan hanya di bilah menu',\n    'seedConfig': 'Konfigurasi Seed',\n    'seedKeep': 'Terus seeding hingga dihentikan secara manual',\n    'seedRatio': 'Rasio Seed',\n    'seedTime': 'Waktu Seed (menit)',\n    'taskDetail': 'Detail Tugas',\n    'taskName': 'Nama Tugas',\n    'taskUrl': 'URL Tugas',\n    'downloadPath': 'Direktori Unduhan',\n    'skipVerifyCert': 'Lupakan verifikasi sertifikat',\n    'archives': 'Arsip',\n    'autoExtract': 'Ekstrak Arsip Otomatis',\n    'archivePassword': 'Kata Sandi Arsip',\n    'archivePasswordHint': 'Kosongkan jika tidak dilindungi kata sandi',\n    'deleteAfterExtract': 'Hapus arsip setelah ekstraksi',\n    'extracting': 'Mengekstrak',\n    'extractDone': 'Ekstraksi selesai',\n    'extractError': 'Ekstraksi gagal',\n    'waitingParts': 'Menunggu bagian',\n    'insertPlaceholder': 'Sisipkan placeholder',\n    'placeholderYear': 'Tahun saat ini',\n    'placeholderMonth': 'Bulan saat ini (01-12)',\n    'placeholderDay': 'Hari saat ini (01-31)',\n    'placeholderDate': 'Tanggal lengkap (YYYY-MM-DD)',\n    'example': 'cth. @value',\n    'downloadCategories': 'Kategori Unduhan',\n    'categoryMusic': 'Musik',\n    'categoryVideo': 'Video',\n    'categoryDocument': 'Dokumen',\n    'categoryProgram': 'Program',\n    'categoryName': 'Nama Kategori',\n    'categoryPath': 'Path Kategori',\n    'builtInCategory': 'Kategori bawaan tidak dapat dihapus',\n    'selectCategory': 'Pilih Kategori',\n    'ed2k': 'ED2K',\n    'ed2kTcpPort': 'TCP Listen Port',\n    'ed2kUdpPort': 'UDP Listen Port',\n    'ed2kServerList': 'Server Addresses',\n    'ed2kServerMet': 'Server.met Sources',\n    'ed2kNodesDat': 'nodes.dat Sources',\n    'ed2kAutoPort': 'Auto assign',\n    'ed2kOnePerLine': 'One entry per line',\n    'ed2kServersHint': 'host:port, one per line',\n    'ed2kServerMetHint': 'Server.met URL or ed2k serverlist link, one per line',\n    'ed2kNodesDatHint': 'nodes.dat path or URL, one per line',\n    'desktopNotification': 'Notifikasi Desktop',\n    'notificationTaskDone': 'Tugas selesai',\n    'notificationTaskError': 'Tugas error',\n  },\n};\n"
  },
  {
    "path": "ui/flutter/lib/i18n/langs/it_it.dart",
    "content": "const itIT = {\n  'it_IT': {\n    'label': 'Italiano',\n    'error': 'Errore',\n    'tip': 'Suggerimento',\n    'confirm': 'Conferma',\n    'confirmDelete': 'Sei sicuro di voler eliminare?',\n    'cancel': 'Annullla',\n    'on': 'Acceso',\n    'off': 'Spento',\n    'selectAll': 'Seleziona tutto',\n    'task': 'Attività',\n    'downloading': 'scaricamento in corso',\n    'downloaded': 'scaricato',\n    'setting': 'Impostazioni',\n    'donate': 'Donazione',\n    'exit': 'Uscita',\n    'create': 'Crea attività',\n    'directDownload': 'Scaricamento diretto',\n    'advancedOptions': 'Opzioni avanzate',\n    'downloadLink': 'Link di scaricamento',\n    'downloadLinkValid': 'Inserisci il collegamento per lo scaricamento',\n    'downloadLinkHit':\n        'Inserisci il collegamento per il download, HTTP/HTTPS/MAGNET supported@append',\n    'downloadLinkHitDesktop':\n        ', oppure trascina qui direttamente il file torrent',\n    'download': 'Download',\n    'noFileSelected': 'Seleziona almeno un file per continuare.',\n    'noStoragePermission': \"È richiesta l'autorizzazione di archiviazione\",\n    'selectFile': 'Selezione file',\n    'rename': 'Rinomina',\n    'basic': 'Basico',\n    'advanced': 'Avanzato',\n    'general': 'Generale',\n    'downloadDir': 'Directory di scaricamento',\n    'downloadDirValid': 'Seleziona la directory di scaricamento',\n    'connections': 'Connessioni',\n    'useServerCtime': \"Utilizza l'ora del server per la creazione dei file\",\n    'maxRunning': 'Numero massimo di attività in esecuzione',\n    'autoTorrentEnable': 'Crea automaticamente attività BT da file .torrent',\n    'autoTorrentDeleteAfterDownload':\n        'Elimina file .torrent dopo creazione attività BT',\n    'autoDeleteMissingFileTasks':\n        'Elimina automaticamente le attività con file mancanti',\n    'items': '@count elementi',\n    'subscribeTracker': 'Iscriviti al tracker',\n    'subscribeFail':\n        'Iscrizione non riuscita, controlla la rete o riprova più tardir',\n    'update': 'Aggiornaento',\n    'updateDaily': 'Aggiornamento quotidiano',\n    'lastUpdate': 'Ultimo aggiornamento: @time',\n    'addTracker': 'Aggiungi Tracker',\n    'addTrackerHit': \"Inserisci l'URL del server del tracker, uno per riga\",\n    'ui': 'UI',\n    'theme': 'Tema',\n    'themeSystem': 'Sistema',\n    'themeLight': 'Chiaro',\n    'themeDark': 'Scuro',\n    'locale': 'Lingua',\n    'about': 'Informazioni',\n    'homepage': 'Homepage',\n    'version': 'Versione',\n    'protocol': 'Protocollo',\n    'port': 'Porta',\n    'apiToken': 'Token API',\n    'notSet': 'NS',\n    'set': 'SET',\n    'portInUse': 'La porta [@port] è in uso, per favore cambia la porta',\n    'effectAfterRestart': 'Effetto dopo il riavvio',\n    'show': 'Mostra',\n    'startAll': 'Avvia tutti',\n    'pauseAll': 'Mtti in pausa tutti',\n    'deleteTask': 'Elimina @count attività',\n    'deleteTaskTip': 'Conserva i file scaricati',\n    'delete': 'Elimina',\n    'newVersionTitle': 'Scopri la nuova versione @version',\n    'newVersionUpdate': 'Aggiorna ora',\n    'newVersionLater': 'Dopo',\n    'extensions': 'Estensioni',\n    'extensionInstallUrl': 'Installa URL',\n    'extensionInstallSuccess': 'Installato con successo',\n    'extensionUpdateSuccess': 'Aggiornato con successo',\n    'extensionDelete': 'Elimina estensione',\n    'extensionAlreadyLatest': \"È già l'ultima versione\",\n    'extensionFind': 'Trova estensioni',\n    'extensionDevelop': 'Sviluppare estensioni',\n    'history': 'Cronologia',\n    'clearHistory': 'Pulisci cronologia',\n    'noHistoryFound': 'Nessuna cronologia trovata',\n    'serviceTitle': 'Scarica il servizio',\n    'serviceText': 'In esecuzione',\n    'network': 'Rete',\n    'proxy': 'Proxy',\n    'noProxy': 'Nessun Proxy',\n    'systemProxy': 'Sistema Proxy',\n    'customProxy': 'Proxy personalizzato',\n    'server': 'Server',\n    'username': 'Nome utente',\n    'password': 'Password',\n    'thanks': 'Ringraziamenti',\n    'thanksDesc':\n        'Grazie a tutti i contributori che hanno contribuito a costruire e sviluppare la comunità Gopeed!',\n    'browserExtension': 'Estensione del browser',\n    'launchAtStartup': \"Lancia all'avvio\",\n    'runAsMenubarApp': 'Esegui come app della barra dei menu',\n    'runAsMenubarAppDesc':\n        'Nascondi icona Dock ed esegui solo nella barra dei menu',\n    'skipVerifyCert': 'Salta la verifica del certificato',\n    'archives': 'Archivi',\n    'autoExtract': 'Estrai automaticamente gli archivi',\n    'archivePassword': 'Password archivio',\n    'archivePasswordHint': 'Lasciare vuoto se non protetto da password',\n    'deleteAfterExtract': \"Elimina l'archivio dopo l'estrazione\",\n    'extracting': 'Estrazione in corso',\n    'extractDone': 'Estrazione completata',\n    'extractError': 'Estrazione fallita',\n    'waitingParts': 'In attesa delle parti',\n    'insertPlaceholder': 'Inserisci segnaposto',\n    'placeholderYear': 'Anno corrente',\n    'placeholderMonth': 'Mese corrente (01-12)',\n    'placeholderDay': 'Giorno corrente (01-31)',\n    'placeholderDate': 'Data completa (AAAA-MM-GG)',\n    'example': 'es. @value',\n    'downloadCategories': 'Categorie di download',\n    'categoryMusic': 'Musica',\n    'categoryVideo': 'Video',\n    'categoryDocument': 'Documento',\n    'categoryProgram': 'Programma',\n    'categoryName': 'Nome categoria',\n    'categoryPath': 'Percorso categoria',\n    'builtInCategory': 'La categoria predefinita non può essere eliminata',\n    'selectCategory': 'Seleziona categoria',\n    'ed2k': 'ED2K',\n    'ed2kTcpPort': 'TCP Listen Port',\n    'ed2kUdpPort': 'UDP Listen Port',\n    'ed2kServerList': 'Server Addresses',\n    'ed2kServerMet': 'Server.met Sources',\n    'ed2kNodesDat': 'nodes.dat Sources',\n    'ed2kAutoPort': 'Auto assign',\n    'ed2kOnePerLine': 'One entry per line',\n    'ed2kServersHint': 'host:port, one per line',\n    'ed2kServerMetHint': 'Server.met URL or ed2k serverlist link, one per line',\n    'ed2kNodesDatHint': 'nodes.dat path or URL, one per line',\n    'desktopNotification': 'Notifiche Desktop',\n    'notificationTaskDone': 'Attività completata',\n    'notificationTaskError': 'Errore attività',\n  },\n};\n"
  },
  {
    "path": "ui/flutter/lib/i18n/langs/ja_jp.dart",
    "content": "const jaJP = {\n  \"ja_JP\": {\n    'label': '日本語',\n    'error': 'エラー',\n    'tip': 'ヒント',\n    'confirm': '確認',\n    'confirmDelete': '削除してもよろしいですか？',\n    'cancel': 'キャンセル',\n    'selectAll': 'すべてを選択',\n    'task': 'タスク',\n    'downloading': 'ダウンロード中',\n    'downloaded': 'ダウンロード済',\n    'setting': 'セッティング',\n    'donate': '寄付する',\n    'exit': '終了',\n    'create': 'タスクを作成',\n    'downloadLink': 'ダウンロードリンク',\n    'downloadLinkValid': 'ダウンロードリンクを入力してください',\n    'downloadLinkHit': 'ダウンロード リンクを入力してください、HTTP/HTTPS/MAGNET support@append',\n    'downloadLinkHitDesktop': 'または、torrent ファイルを直接ここにドラッグします',\n    'download': 'ダウンロード',\n    'noFileSelected': '続行するには少なくとも 1 つのファイルを選択してください。',\n    'noStoragePermission': 'ストレージのパーミッションが必要',\n    'selectFile': 'ファイルを選択',\n    'basic': 'ベーシック',\n    'advanced': 'アドバンスド',\n    'general': 'ジェネラル',\n    'downloadDir': 'ディレクトリのダウンロード',\n    'downloadDirValid': 'ダウンロードディレクトリを選択してください',\n    'connections': '接続',\n    'maxRunning': '最大実行タスク',\n    'autoStartTasks': '起動時に未完了のタスクを自動的に開始',\n    'autoTorrentEnable': '.torrentファイルからBTタスクを自動作成',\n    'autoTorrentDeleteAfterDownload': 'BTタスク作成後に.torrentファイルを削除',\n    'autoDeleteMissingFileTasks': '欠落ファイルタスクを自動削除',\n    'items': '@count アイテム',\n    'subscribeTracker': 'トラッカーを購読',\n    'subscribeFail': '登録に失敗しました。ネットワークを確認するか、後でもう一度お試しください',\n    'update': 'アップデート',\n    'updateDaily': '毎日アップデート',\n    'lastUpdate': '最終アップデート: @time',\n    'addTracker': 'トラッカーを追加',\n    'addTrackerHit': 'トラッカーサーバーの URL を 1 行に 1 つずつ入力してください',\n    'ui': 'UI',\n    'theme': 'テーマ',\n    'themeSystem': 'システム',\n    'themeLight': 'ライト',\n    'themeDark': 'ダーク',\n    'locale': '言語',\n    'about': '概要',\n    'homepage': 'ホームページ',\n    'version': 'バージョン',\n    'protocol': 'プロトコル',\n    'port': 'ポート',\n    'apiToken': 'API トークン',\n    'notSet': '未設置',\n    'set': '設置',\n    'effectAfterRestart': '再起動後の効果',\n    'startAll': 'すべてを開始',\n    'pauseAll': 'すべてを一時停止',\n    'deleteTask': '@count タスクを削除',\n    'deleteTaskTip': 'ダウンロードしたファイルを保持',\n    'delete': '削除',\n    'newVersionTitle': '新しいバージョン @version を発見する',\n    'newVersionUpdate': 'アップデート',\n    'newVersionLater': '後で',\n    'thanks': '感謝',\n    'thanksDesc': 'Gopeedコミュニティの建設に協力してくださったすべての貢献者の方々に感謝します！',\n    'browserExtension': 'ブラウザ拡張機能',\n    'skipVerifyCert': '証明書の検証をスキップ',\n    'archives': 'アーカイブ',\n    'autoExtract': 'アーカイブを自動展開',\n    'archivePassword': 'アーカイブパスワード',\n    'archivePasswordHint': 'パスワードなしの場合は空のままにしてください',\n    'deleteAfterExtract': '展開後にアーカイブを削除',\n    'extracting': '展開中',\n    'extractDone': '展開完了',\n    'extractError': '展開失敗',\n    'waitingParts': '分割ファイルを待っています',\n    'insertPlaceholder': 'プレースホルダーを挿入',\n    'placeholderYear': '現在の年',\n    'placeholderMonth': '現在の月 (01-12)',\n    'placeholderDay': '現在の日 (01-31)',\n    'placeholderDate': '完全な日付 (年-月-日)',\n    'example': '例: @value',\n    'downloadCategories': 'ダウンロードカテゴリ',\n    'categoryMusic': '音楽',\n    'categoryVideo': '動画',\n    'categoryDocument': 'ドキュメント',\n    'categoryProgram': 'プログラム',\n    'categoryName': 'カテゴリ名',\n    'categoryPath': 'カテゴリパス',\n    'builtInCategory': '内蔵カテゴリは削除できません',\n    'selectCategory': 'カテゴリを選択',\n    'launchAtStartup': '起動時に起動',\n    'runAsMenubarApp': 'メニューバーアプリとして実行',\n    'runAsMenubarAppDesc': 'Dockアイコンを非表示にして、メニューバーのみで実行',\n    'ed2k': 'ED2K',\n    'ed2kTcpPort': 'TCP Listen Port',\n    'ed2kUdpPort': 'UDP Listen Port',\n    'ed2kServerList': 'Server Addresses',\n    'ed2kServerMet': 'Server.met Sources',\n    'ed2kNodesDat': 'nodes.dat Sources',\n    'ed2kAutoPort': 'Auto assign',\n    'ed2kOnePerLine': 'One entry per line',\n    'ed2kServersHint': 'host:port, one per line',\n    'ed2kServerMetHint': 'Server.met URL or ed2k serverlist link, one per line',\n    'ed2kNodesDatHint': 'nodes.dat path or URL, one per line',\n    'desktopNotification': 'デスクトップ通知',\n    'notificationTaskDone': 'タスク完了',\n    'notificationTaskError': 'タスクエラー',\n  }\n};\n"
  },
  {
    "path": "ui/flutter/lib/i18n/langs/pl_pl.dart",
    "content": "const plPL = {\n  'pl_PL': {\n    'label': 'Polski',\n    'error': 'Błąd',\n    'tip': 'Tip',\n    'confirm': 'Potwierdź',\n    'confirmDelete': 'Czy na pewno chcesz usunąć?',\n    'cancel': 'Anuluj',\n    'on': 'Włącz',\n    'off': 'Wyłącz',\n    'selectAll': 'Zaznacz wszystko',\n    'task': 'Zadania',\n    'downloading': 'Pobierane',\n    'downloaded': 'Pobrane',\n    'setting': 'Ustawienia',\n    'donate': 'Wesprzyj',\n    'exit': 'Wyjście',\n    'create': 'Utwórz zadanie',\n    'directDownload': 'Pobieranie bezpośrednie',\n    'advancedOptions': 'Zaawansowane',\n    'downloadLink': 'Pobierz z linku',\n    'downloadLinkValid': 'Otwórz link pobierania',\n    'downloadLinkHit':\n        'Otwórz link pobierania, HTTP/HTTPS/MAGNET wspierane@append',\n    'downloadLinkHitDesktop': ', lub przenieś bezpośrednio plik torrent',\n    'download': 'Pobierz',\n    'noFileSelected': 'Wybierz co najmniej jeden plik aby kontynuować.',\n    'noStoragePermission': 'Przyznaj uprawnienia do zapisu',\n    'selectFile': 'Wybierz plik',\n    'rename': 'Zmień nazwę',\n    'basic': 'Zwykły',\n    'advanced': 'Zaawansowane',\n    'general': 'Ogólne',\n    'downloadDir': 'Folder Pobierania',\n    'downloadDirValid': 'Wskaż folder pobierania',\n    'connections': 'Połączenia',\n    'useServerCtime': 'Użyj czasu serwera do utworzenia pliku',\n    'maxRunning': 'Maksymalna liczba zadań',\n    'autoTorrentEnable': 'Automatyczne tworzenie zadań BT z plików .torrent',\n    'autoTorrentDeleteAfterDownload':\n        'Usuń plik .torrent po utworzeniu zadania BT',\n    'autoDeleteMissingFileTasks':\n        'Automatycznie usuwaj zadania z brakującymi plikami',\n    'items': '@count items',\n    'subscribeTracker': 'Subskrybuj tracker',\n    'subscribeFail': 'Błąd subskrypcji, sprawdź łączność z internetem',\n    'update': 'Aktualizacja',\n    'updateDaily': 'Aktualizuj codziennie',\n    'lastUpdate': 'Ostatnia aktualizacja: @time',\n    'addTracker': 'Dodaj tracker',\n    'addTrackerHit': 'Wpisz adres trackera w jednej linii',\n    'ui': 'Wygląd',\n    'theme': 'Schemat kolorów',\n    'themeSystem': 'Systemowy',\n    'themeLight': 'Jasny',\n    'themeDark': 'Ciemny',\n    'locale': 'Język',\n    'about': 'Info',\n    'homepage': 'Strona WWW',\n    'version': 'Wersja',\n    'protocol': 'Protokół',\n    'port': 'Port',\n    'apiToken': 'API Token',\n    'notSet': 'NS',\n    'set': 'SET',\n    'portInUse': 'Port [@port] w użyciu, proszę zmień port',\n    'effectAfterRestart': 'Zmiany po restarcie',\n    'show': 'Pokaż',\n    'startAll': 'Zacznij wszystkie',\n    'pauseAll': 'Zatrzymaj wszystkie',\n    'deleteTask': 'Usuń @count zadania',\n    'deleteTaskTip': 'Zachowaj pobrane pliki',\n    'delete': 'Usuń',\n    'newVersionTitle': 'Sprawdź aktualizację @version',\n    'newVersionUpdate': 'Aktualizuj teraz',\n    'newVersionLater': 'Później',\n    'extensions': 'Wtyczki',\n    'extensionInstallUrl': 'Instaluj z url',\n    'extensionInstallSuccess': 'Instalacja zakończona',\n    'extensionUpdateSuccess': 'Aktualizacja zakończona',\n    'extensionDelete': 'Usuń wtyczkę',\n    'extensionAlreadyLatest': 'Wtyczka w aktualnej wersji',\n    'extensionFind': 'Wyszukaj wtyczkę',\n    'extensionDevelop': 'Stwórz wtyczkę',\n    'history': 'Historia',\n    'clearHistory': 'Wyczyść historię',\n    'noHistoryFound': 'Brak historii',\n    'serviceTitle': 'Usługa pobierania',\n    'serviceText': 'Trwa',\n    'network': 'Sieć',\n    'proxy': 'Proxy',\n    'noProxy': 'Bez Proxy',\n    'systemProxy': 'Systemowe Proxy',\n    'customProxy': 'Własne Proxy',\n    'server': 'Serwer',\n    'username': 'Użytkownik',\n    'password': 'Hasło',\n    'thanks': 'Dzięki',\n    'thanksDesc':\n        'Dziękuję wszystkim za pomoc przy powstawaniu aplikacji Gopeed!',\n    'browserExtension': 'Wtyczka dla przeglądarki',\n    'launchAtStartup': 'Uruchom na starcie',\n    'runAsMenubarApp': 'Uruchom jako aplikacja paska menu',\n    'runAsMenubarAppDesc': 'Ukryj ikonę Docka i uruchom tylko w pasku menu',\n    'skipVerifyCert': 'Pomiń weryfikację certyfikatu',\n    'archives': 'Archiwa',\n    'autoExtract': 'Automatycznie wyodrębnij archiwa',\n    'archivePassword': 'Hasło archiwum',\n    'archivePasswordHint': 'Pozostaw puste, jeśli nie ma hasła',\n    'deleteAfterExtract': 'Usuń archiwum po rozpakowaniu',\n    'extracting': 'Rozpakowywanie',\n    'extractDone': 'Rozpakowywanie zakończone',\n    'extractError': 'Rozpakowywanie nie powiodło się',\n    'waitingParts': 'Oczekiwanie na części',\n    'insertPlaceholder': 'Wstaw placeholder',\n    'placeholderYear': 'Bieżący rok',\n    'placeholderMonth': 'Bieżący miesiąc (01-12)',\n    'placeholderDay': 'Bieżący dzień (01-31)',\n    'placeholderDate': 'Pełna data (RRRR-MM-DD)',\n    'example': 'np. @value',\n    'downloadCategories': 'Kategorie pobierania',\n    'categoryMusic': 'Muzyka',\n    'categoryVideo': 'Wideo',\n    'categoryDocument': 'Dokument',\n    'categoryProgram': 'Program',\n    'categoryName': 'Nazwa kategorii',\n    'categoryPath': 'Ścieżka kategorii',\n    'builtInCategory': 'Wbudowanej kategorii nie można usunąć',\n    'selectCategory': 'Wybierz kategorię',\n    'ed2k': 'ED2K',\n    'ed2kTcpPort': 'TCP Listen Port',\n    'ed2kUdpPort': 'UDP Listen Port',\n    'ed2kServerList': 'Server Addresses',\n    'ed2kServerMet': 'Server.met Sources',\n    'ed2kNodesDat': 'nodes.dat Sources',\n    'ed2kAutoPort': 'Auto assign',\n    'ed2kOnePerLine': 'One entry per line',\n    'ed2kServersHint': 'host:port, one per line',\n    'ed2kServerMetHint': 'Server.met URL or ed2k serverlist link, one per line',\n    'ed2kNodesDatHint': 'nodes.dat path or URL, one per line',\n    'desktopNotification': 'Powiadomienia na pulpicie',\n    'notificationTaskDone': 'Zadanie zakończone',\n    'notificationTaskError': 'Błąd zadania',\n  },\n};\n"
  },
  {
    "path": "ui/flutter/lib/i18n/langs/pt_br.dart",
    "content": "const ptBR = {\n  'pt_BR': {\n    'label': 'Português(Brasil)',\n    'error': 'Erro',\n    'tip': 'Dica',\n    'confirm': 'Confirmar',\n    'confirmDelete': 'Tem certeza de que deseja excluir?',\n    'cancel': 'Cancelar',\n    'on': 'Ligado',\n    'off': 'Desligado',\n    'selectAll': 'Selecionar Tudo',\n    'select': 'Selecionar',\n    'task': 'Tarefas',\n    'downloading': 'baixando',\n    'downloaded': 'baixado',\n    'setting': 'Configurações',\n    'donate': 'Doação',\n    'exit': 'Sair',\n    'create': 'Criar Tarefa',\n    'directDownload': 'Download Direto',\n    'advancedOptions': 'Opções Avançadas',\n    'followSettings': 'Follow Settings',\n    'downloadLink': 'Link de Download',\n    'downloadLinkValid': 'Por favor, insira o link de download',\n    'downloadLinkHit':\n        'Por favor, insira o link de download, um por linha@append',\n    'downloadLinkHitDesktop': ', ou arraste o arquivo torrent aqui',\n    'download': 'Download',\n    'noFileSelected': 'Por favor, selecione ao menos um arquivo para continuar',\n    'noStoragePermission': 'Permissão de Armazenamento Necessária',\n    'selectFile': 'Selecionar Arquivo',\n    'rename': 'Renomear para',\n    'basic': 'Configurações Básica',\n    'advanced': 'Configurações Avançada',\n    'general': 'Principal',\n    'downloadDir': 'Onde você deseja salvar?',\n    'downloadDirValid': 'Por favor, selecione a pasta',\n    'connections': 'Conexões',\n    'useServerCtime': 'Use server time for file creation',\n    'maxRunning': 'Máximo de Tarefas Simultâneas',\n    'defaultDirectDownload': 'Marcar download direto por padrão',\n    'autoTorrentEnable':\n        'Criar automaticamente tarefas BT de arquivos .torrent',\n    'autoTorrentDeleteAfterDownload':\n        'Excluir arquivo .torrent após criação da tarefa BT',\n    'autoDeleteMissingFileTasks':\n        'Excluir automaticamente tarefas com arquivos ausentes',\n    'items': '@count itens',\n    'subscribeTracker': 'Assinar Rastreador',\n    'subscribeFail':\n        'Falha na Assinatura, verifique sua conexão ou tente novamente mais tarde',\n    'update': 'Atualizar',\n    'updateDaily': 'Atualizar Diariamente',\n    'lastUpdate': 'Última Atualização: @time',\n    'addTracker': 'Adicionar Rastreador',\n    'addTrackerHit': 'Por favor, insira a URL do rastreador, um por linha',\n    'ui': 'Interface',\n    'theme': 'Tema',\n    'themeSystem': 'Sistema',\n    'themeLight': 'Claro',\n    'themeDark': 'Escuro',\n    'locale': 'Idioma',\n    'about': 'Sobre',\n    'homepage': 'Site',\n    'version': 'Versão',\n    'protocol': 'Protocolo',\n    'port': 'Porta',\n    'apiToken': 'API Token',\n    'notSet': 'NS',\n    'set': 'SET',\n    'portInUse': 'Porta [@port] em uso, por favor utilize outra porta',\n    'effectAfterRestart': 'Effect after restart',\n    'developer': 'Desenvolvedor',\n    'logDirectory': 'Pasta de Logs',\n    'show': 'Mostrar',\n    'continue': 'Continuar',\n    'pause': 'Pausar',\n    'startAll': 'Iniciar Tudo',\n    'pauseAll': 'Pausar Tudo',\n    'deleteTask': 'Excluir @count tarefas',\n    'deleteTaskTip': 'Manter arquivos baixados',\n    'delete': 'Excluir',\n    'newVersionTitle': 'Nova Versão Disponível @version',\n    'newVersionUpdate': 'Atualizar Agora',\n    'newVersionLater': 'Mais Tarde',\n    'extensions': 'Extensões',\n    'extensionInstallUrl': 'Link de Instalação',\n    'extensionInstallSuccess': 'Instalado com Sucesso',\n    'extensionUpdateSuccess': 'Atualizado com Sucesso',\n    'extensionDelete': 'Excluir Extensão',\n    'extensionAlreadyLatest': 'It\\'s already the latest version',\n    'extensionFind': 'Encontrar Extensões',\n    'extensionDevelop': 'Desenvolvedor',\n    'history': 'Histórico',\n    'clearHistory': 'Limpar Histórico',\n    'noHistoryFound': 'Nenhum Histório Encontrado',\n    'serviceTitle': 'Download Service',\n    'serviceText': 'Executando',\n    'network': 'Rede',\n    'proxy': 'Proxy',\n    'noProxy': 'Sem Proxy',\n    'systemProxy': 'Proxy do Sistema',\n    'customProxy': 'Proxy Personalizado',\n    'server': 'Servidor',\n    'username': 'Usuário',\n    'password': 'Senha',\n    'thanks': 'Obrigado',\n    'thanksDesc':\n        'Obrigado a todos os colaboradores que ajudaram a construir e desenvolver a comunidade Gopeed!',\n    'browserExtension': 'Extensões para Navegadores',\n    'launchAtStartup': 'Launch at Startup',\n    'runAsMenubarApp': 'Executar como aplicativo da barra de menus',\n    'runAsMenubarAppDesc':\n        'Ocultar ícone do Dock e executar apenas na barra de menus',\n    'seedConfig': 'Seed Config',\n    'seedKeep': 'Keep seeding until manually stopped',\n    'seedRatio': 'Seed ratio',\n    'seedTime': 'Seed time (minutes)',\n    'setAsDefaultBtClient': 'Set as the default BT client',\n    'taskDetail': 'Detalhes da Tarefa',\n    'taskName': 'Nome da Tarefa',\n    'taskUrl': 'Tarefa URL',\n    'downloadPath': 'Download Path',\n    'skipVerifyCert': 'Pular Verificação de Certificado',\n    'archives': 'Arquivos',\n    'autoExtract': 'Extrair arquivos automaticamente',\n    'archivePassword': 'Senha do arquivo',\n    'archivePasswordHint': 'Deixar vazio se não estiver protegido por senha',\n    'deleteAfterExtract': 'Excluir arquivo após extração',\n    'extracting': 'Extraindo',\n    'extractDone': 'Extração concluída',\n    'extractError': 'Falha na extração',\n    'waitingParts': 'Aguardando partes',\n    'name': 'Nome',\n    'size': 'Tamanho',\n    'unknown': 'Desconhecido',\n    'fileSelectedCount': 'Arquivos: ',\n    'fileSelectedSize': 'Tamanho: ',\n    'httpHeaderName': 'Nome do Header',\n    'httpHeaderValue': 'Valor do Header',\n    'insertPlaceholder': 'Inserir marcador',\n    'placeholderYear': 'Ano atual',\n    'placeholderMonth': 'Mês atual (01-12)',\n    'placeholderDay': 'Dia atual (01-31)',\n    'placeholderDate': 'Data completa (AAAA-MM-DD)',\n    'example': 'ex. @value',\n    'downloadCategories': 'Categorias de Download',\n    'categoryMusic': 'Música',\n    'categoryVideo': 'Vídeo',\n    'categoryDocument': 'Documento',\n    'categoryProgram': 'Programa',\n    'categoryName': 'Nome da Categoria',\n    'categoryPath': 'Caminho da Categoria',\n    'builtInCategory': 'Categoria integrada não pode ser excluída',\n    'selectCategory': 'Selecionar Categoria',\n    'ed2k': 'ED2K',\n    'ed2kTcpPort': 'TCP Listen Port',\n    'ed2kUdpPort': 'UDP Listen Port',\n    'ed2kServerList': 'Server Addresses',\n    'ed2kServerMet': 'Server.met Sources',\n    'ed2kNodesDat': 'nodes.dat Sources',\n    'ed2kAutoPort': 'Auto assign',\n    'ed2kOnePerLine': 'One entry per line',\n    'ed2kServersHint': 'host:port, one per line',\n    'ed2kServerMetHint': 'Server.met URL or ed2k serverlist link, one per line',\n    'ed2kNodesDatHint': 'nodes.dat path or URL, one per line',\n    'desktopNotification': 'Notificações na área de trabalho',\n    'notificationTaskDone': 'Tarefa concluída',\n    'notificationTaskError': 'Erro na tarefa',\n  },\n};\n"
  },
  {
    "path": "ui/flutter/lib/i18n/langs/ru_ru.dart",
    "content": "const ruRU = {\n  'ru_RU': {\n    'label': 'Русский',\n    'error': 'Ошибка',\n    'tip': 'Подсказка',\n    'confirm': 'Подтвердить',\n    'confirmDelete': 'Вы уверены, что хотите удалить?',\n    'cancel': 'Отмена',\n    'on': 'Вкл.',\n    'off': 'Откл.',\n    'selectAll': 'Выбрать все',\n    'select': 'Выбрать',\n    'task': 'Задачи',\n    'downloading': 'Загрузка',\n    'downloaded': 'Загружено',\n    'setting': 'Настройки',\n    'donate': 'Пожертвовать',\n    'exit': 'Выход',\n    'create': 'Создать задачу',\n    'directDownload': 'Прямая загрузка',\n    'advancedOptions': 'Расширенные опции',\n    'followSettings': 'По умолчанию',\n    'downloadLink': 'Ссылка на скачивание',\n    'downloadLinkValid': 'Пожалуйста, введите ссылку для скачивания',\n    'downloadLinkHit':\n        'Пожалуйста, введите ссылку для скачивания, HTTP/HTTPS/MAGNET поддерживаются@append',\n    'downloadLinkHitDesktop': ', или перетащите сюда торрент-файл',\n    'download': 'Скачать',\n    'noFileSelected': 'Файл не выбран',\n    'noStoragePermission': 'Требуется доступ к хранилищу',\n    'selectFile': 'Выбрать файл',\n    'rename': 'Переименовать',\n    'basic': 'Основные параметры',\n    'advanced': 'Расширенные',\n    'general': 'Общие',\n    'downloadDir': 'Папка загрузки',\n    'downloadDirValid': 'Пожалуйста, выберите папку для загрузки',\n    'connections': 'Соединений',\n    'useServerCtime': 'Использовать время сервера для создания файла',\n    'maxRunning': 'Максимальное количество активных задач',\n    'defaultDirectDownload': 'Прямая загрузка по умолчанию',\n    'autoStartTasks': 'Автоматически запускать незавершённые задачи при старте',\n    'autoTorrentEnable': 'Автоматически создавать BT-задачи из .torrent файлов',\n    'autoTorrentDeleteAfterDownload':\n        'Удалить .torrent файл после создания BT-задачи',\n    'autoDeleteMissingFileTasks': 'Автоудаление задач с отсутствующими файлами',\n    'items': '@подсчет элементов',\n    'subscribeTracker': 'Подпись трекера',\n    'subscribeFail':\n        'Подписка не удалась, пожалуйста, проверьте сеть или повторите попытку позже',\n    'update': 'Обновить',\n    'updateDaily': 'Обновлять ежедневно',\n    'lastUpdate': 'Последнее обновление: @время',\n    'addTracker': 'Добавить трекер',\n    'addTrackerHit': 'Пожалуйста, введите url трекера, по одному в строке',\n    'ui': 'Интерфейс',\n    'theme': 'Тема',\n    'themeSystem': 'Системная',\n    'themeLight': 'Светлая',\n    'themeDark': 'Тёмная',\n    'locale': 'Язык',\n    'about': 'О приложении',\n    'homepage': 'Домашняя страница',\n    'version': 'Версия',\n    'protocol': 'Протокол',\n    'port': 'Порт',\n    'apiToken': 'API Токен',\n    'notSet': 'Не установлено',\n    'set': 'Установлено',\n    'portInUse': 'Порт [@PORT] уже используется, пожалуйста, измените порт',\n    'effectAfterRestart': 'Эффект после перезагрузки',\n    'developer': 'Разработчик',\n    'logDirectory': 'Каталог журналов',\n    'show': 'Показать',\n    'continue': 'Продолжить',\n    'pause': 'Пауза',\n    'startAll': 'Запустить все',\n    'pauseAll': 'Приостановить все',\n    'deleteTask': 'Удалить @count задач',\n    'deleteTaskTip': 'Сохранить загруженные файлы',\n    'delete': 'Удалить',\n    'newVersionTitle': 'Обнаружена новая версия @version',\n    'newVersionUpdate': 'Обновить',\n    'newVersionLater': 'позже',\n    'extensions': 'Расширения',\n    'extensionInstallUrl': 'URL для установки',\n    'extensionInstallSuccess': 'Установка завершена',\n    'extensionUpdateSuccess': 'Обновление завершено',\n    'extensionDelete': 'Удалить расширение',\n    'extensionAlreadyLatest': 'Это последняя версия',\n    'extensionFind': 'Найти расширения',\n    'extensionDevelop': 'Разработка расширений',\n    'history': 'История',\n    'clearHistory': 'Очистить историю',\n    'noHistoryFound': 'История не найдена',\n    'serviceTitle': 'Служба загрузки',\n    'serviceText': 'Работает',\n    'network': 'Сеть',\n    'proxy': 'Прокси',\n    'noProxy': 'Без прокси',\n    'systemProxy': 'Системный прокси',\n    'customProxy': 'Пользовательский прокси',\n    'server': 'Сервер',\n    'username': 'Имя пользователя',\n    'password': 'Пароль',\n    'thanks': 'Благодарности',\n    'thanksDesc':\n        'Благодарим всех участников за их вклад в строительство и развитие сообщества Gopeed!',\n    'browserExtension': 'Расширение браузера',\n    'launchAtStartup': 'Запускать при загрузке системы',\n    'runAsMenubarApp': 'Запускать как приложение в строке меню',\n    'runAsMenubarAppDesc':\n        'Скрыть значок в Dock и запускать только в строке меню',\n    'seedConfig': 'Настройка раздачи',\n    'seedKeep': 'Раздавать пока не остановлю вручную',\n    'seedRatio': 'Коэффициент раздачи',\n    'seedTime': 'Время раздачи (минуты)',\n    'setAsDefaultBtClient': 'Установить как клиент BT по умолчанию',\n    'taskDetail': 'Детали задания',\n    'taskName': 'Название задачи',\n    'taskUrl': 'URL задачи',\n    'downloadPath': 'Путь загрузки',\n    'skipVerifyCert': 'Пропустить проверку сертификата',\n    'archives': 'Архивы',\n    'autoExtract': 'Автоматически распаковать архивы',\n    'archivePassword': 'Пароль архива',\n    'archivePasswordHint': 'Оставить пустым, если архив не защищён паролем',\n    'deleteAfterExtract': 'Удалить архив после распаковки',\n    'extracting': 'Распаковка',\n    'extractDone': 'Распаковка завершена',\n    'extractError': 'Ошибка распаковки',\n    'waitingParts': 'Ожидание частей',\n    'name': 'Название',\n    'size': 'Размер',\n    'unknown': 'Неизвестно',\n    'fileSelectedCount': 'Файлов: ',\n    'fileSelectedSize': 'Размер: ',\n    'httpHeaderName': 'Название заголовка HTTP',\n    'httpHeaderValue': 'Значение заголовка HTTP',\n    'insertPlaceholder': 'Вставить заполнитель',\n    'placeholderYear': 'Текущий год',\n    'placeholderMonth': 'Текущий месяц (01-12)',\n    'placeholderDay': 'Текущий день (01-31)',\n    'placeholderDate': 'Полная дата (ГГГГ-ММ-ДД)',\n    'example': 'напр. @value',\n    'downloadCategories': 'Категории загрузки',\n    'categoryMusic': 'Музыка',\n    'categoryVideo': 'Видео',\n    'categoryDocument': 'Документ',\n    'categoryProgram': 'Программа',\n    'categoryName': 'Название категории',\n    'categoryPath': 'Путь категории',\n    'builtInCategory': 'Встроенную категорию нельзя удалить',\n    'selectCategory': 'Выбрать категорию',\n    'ed2k': 'ED2K',\n    'ed2kTcpPort': 'TCP Listen Port',\n    'ed2kUdpPort': 'UDP Listen Port',\n    'ed2kServerList': 'Server Addresses',\n    'ed2kServerMet': 'Server.met Sources',\n    'ed2kNodesDat': 'nodes.dat Sources',\n    'ed2kAutoPort': 'Auto assign',\n    'ed2kOnePerLine': 'One entry per line',\n    'ed2kServersHint': 'host:port, one per line',\n    'ed2kServerMetHint': 'Server.met URL or ed2k serverlist link, one per line',\n    'ed2kNodesDatHint': 'nodes.dat path or URL, one per line',\n    'desktopNotification': 'Уведомления',\n    'notificationTaskDone': 'Задача завершена',\n    'notificationTaskError': 'Ошибка задачи',\n  }\n};\n"
  },
  {
    "path": "ui/flutter/lib/i18n/langs/ta_ta.dart",
    "content": "const taTA = {\n  'ta_TA': {\n    'label': 'தமிழ்',\n    'error': 'பிழை',\n    'tip': 'குறிப்பு',\n    'confirm': 'சரி',\n    'confirmDelete': 'நிச்சயமாக நீக்க விரும்புகிறீர்களா?',\n    'cancel': 'ரத்துசெய்',\n    'on': 'On',\n    'off': 'Off',\n    'selectAll': 'அனைத்தையும் தேர்வுசெய்',\n    'task': 'பணிகள்',\n    'downloading': 'பதிவிறக்குகிறது',\n    'downloaded': 'பதிவிறக்கம் செய்யப்பட்டது',\n    'setting': 'அமைப்புகள் ',\n    'donate': 'நன்கொடை',\n    'exit': 'வெளியேறு',\n    'create': 'பணியை உருவாக்கு',\n    'directDownload': 'நேரடி பதிவிறக்கம்',\n    'advancedOptions': 'மேம்பட்ட விருப்பங்கள்',\n    'downloadLink': 'தரவிறக்க இணைப்பு',\n    'downloadLinkValid': 'பதிவிறக்க இணைப்பை உள்ளிடவும்',\n    'downloadLinkHit':\n        'பதிவிறக்க இணைப்பை உள்ளிடவும், HTTP/HTTPS/MAGNET ஆகியவற்றை ஏற்கும்@append',\n    'downloadLinkHitDesktop':\n        ', அல்லது torrent கோப்பை நேரடியாக இங்கே இழுக்கவும்',\n    'download': 'பதிவிறக்கு',\n    'noFileSelected': 'குறைந்தபட்சம் ஒரு கோப்பைத் தேர்ந்தெடுக்கவும்.',\n    'noStoragePermission': 'சேமிப்பக அனுமதி தேவை',\n    'selectFile': 'கோப்பைத் தேர்ந்தெடுக்கவும்',\n    'rename': 'பெயறைமாற்று',\n    'basic': 'அடிப்படை',\n    'advanced': 'மேம்பட்ட',\n    'general': 'பொதுவான',\n    'downloadDir': 'பதிவிரக்குமிடம்',\n    'downloadDirValid': 'பதிவிரக்குமிடத்தை தேர்வு செயுங்கள்',\n    'connections': 'தொடர்புகள்',\n    'useServerCtime': 'Use server time for file creation',\n    'maxRunning': 'அதிகபட்ச இயங்கும் பணிகள்',\n    'autoTorrentEnable': '.torrent கோப்புகளிலிருந்து BT பணிகளை தானாக உருவாக்கு',\n    'autoTorrentDeleteAfterDownload':\n        'BT பணி உருவாக்கத்திற்குப் பிறகு .torrent கோப்பை நீக்கு',\n    'autoDeleteMissingFileTasks':\n        'காணாமல் போன கோப்புகளுடன் பணிகளை தானாக நீக்கு',\n    'items': '@count items',\n    'subscribeTracker': 'Subscribe Tracker',\n    'subscribeFail':\n        'Subscribe failed, please check network or try again later',\n    'update': 'புதுப்பிக்கவும்',\n    'updateDaily': 'தினமும் புதுப்பிக்கவும்',\n    'lastUpdate': 'கடைசியாக புதுப்பிக்கப்பட்டது: @time',\n    'addTracker': 'Trackerஐ சேர்',\n    'addTrackerHit': 'Please enter the tracker server url, one per line',\n    'ui': 'UI',\n    'theme': 'தீம்',\n    'themeSystem': 'இயல்புநிலை',\n    'themeLight': 'வெண்மையானநிலை',\n    'themeDark': 'இருண்டநிலை',\n    'locale': 'மொழி',\n    'about': 'எங்களை பற்றி',\n    'homepage': 'முகப்புப்பக்கம்',\n    'version': 'பதிப்பு',\n    'protocol': 'நெறிமுறை',\n    'port': 'Port',\n    'apiToken': 'API Token',\n    'notSet': 'NS',\n    'set': 'SET',\n    'portInUse': 'Port [@port] is in use, please change the port',\n    'effectAfterRestart': 'Effect after restart',\n    'show': 'காட்டு',\n    'startAll': 'அனைத்தையும் தொடங்கு',\n    'pauseAll': 'அனைத்தையும் நிறுத்திவை',\n    'deleteTask': 'பணிகளை நீக்கு @count',\n    'deleteTaskTip': 'பதிவிறக்கம் செய்யப்பட்ட கோப்புகளை வைத்திரு',\n    'delete': 'நீக்கு',\n    'newVersionTitle': 'புதிய பதிப்பைக் கண்டறியவும் @version',\n    'newVersionUpdate': 'இப்பொழுது புதுப்பி',\n    'newVersionLater': 'பின்னர்',\n    'extensions': 'நீட்டிப்புகள்',\n    'extensionInstallUrl': 'நிறுவப்படும் URL',\n    'extensionInstallSuccess': 'வெற்றிகரமாக நிறுவப்பட்டது',\n    'extensionUpdateSuccess': 'வெற்றிகரமாக புதுப்பிக்கப்பட்டது',\n    'extensionDelete': 'நீட்டிப்பை நீக்கு',\n    'extensionAlreadyLatest': 'இது ஏற்கனவே சமீபத்திய பதிப்பாகும்',\n    'extensionFind': 'நீட்டிப்புகளைக் கண்டறியவும்',\n    'extensionDevelop': 'நீட்டிப்புகளை உருவாக்கவும்',\n    'history': 'வரலாறு',\n    'clearHistory': 'உள்ளீடுகளை நீக்கு',\n    'noHistoryFound': 'உள்ளீடுகள் இல்லை',\n    'serviceTitle': 'பதிவிறக்க சேவை',\n    'serviceText': 'இயக்கத்தில் உள்ளது',\n    'network': 'வலைப்பின்னல்',\n    'proxy': 'பதிலி',\n    'noProxy': 'பதிலி இல்லாமல் ',\n    'systemProxy': 'இயல்புநிலை பதிலி',\n    'customProxy': 'மாற்றியமைக்கப்பட்ட  பதிலி',\n    'server': 'சேவையகம்',\n    'username': 'பயனர் பெயர்',\n    'password': 'கடவுச்சொல்',\n    'thanks': 'நன்றி',\n    'thanksDesc':\n        'Gopeed சமூகத்தை உருவாக்கவும் மற்றும் மேம்படுத்த உதவிய அனைத்து பங்களிப்பாளர்களுக்கும் நன்றி!',\n    'browserExtension': 'உலாவி நீட்டிப்பு',\n    'launchAtStartup': 'தொடக்கத்தின் போது இயங்கு',\n    'runAsMenubarApp': 'மெனுபார் பயன்பாடாக இயக்கு',\n    'runAsMenubarAppDesc': 'டாக் ஐகானை மறைத்து மெனுபாரில் மட்டும் இயக்கு',\n    'skipVerifyCert': 'திருத்தி சான்று சரிபார்க்கவும்',\n    'archives': 'காப்பகங்கள்',\n    'autoExtract': 'காப்பகங்களை தானாக பிரித்தெடு',\n    'archivePassword': 'காப்பக கடவுச்சொல்',\n    'archivePasswordHint': 'கடவுச்சொல் இல்லையென்றால் காலியாக விடவும்',\n    'deleteAfterExtract': 'பிரித்தெடுத்த பிறகு காப்பகத்தை நீக்கு',\n    'extracting': 'பிரித்தெடுக்கிறது',\n    'extractDone': 'பிரித்தெடுத்தல் முடிந்தது',\n    'extractError': 'பிரித்தெடுத்தல் தோல்வியடைந்தது',\n    'waitingParts': 'பாகங்களுக்காக காத்திருக்கிறது',\n    'insertPlaceholder': 'இடங்காட்டி செருக',\n    'placeholderYear': 'தற்போதைய ஆண்டு',\n    'placeholderMonth': 'தற்போதைய மாதம் (01-12)',\n    'placeholderDay': 'தற்போதைய நாள் (01-31)',\n    'placeholderDate': 'முழு தேதி (YYYY-MM-DD)',\n    'example': 'எ.கா. @value',\n    'downloadCategories': 'பதிவிறக்க வகைகள்',\n    'categoryMusic': 'இசை',\n    'categoryVideo': 'காணொளி',\n    'categoryDocument': 'ஆவணம்',\n    'categoryProgram': 'நிரல்',\n    'categoryName': 'வகை பெயர்',\n    'categoryPath': 'வகை பாதை',\n    'builtInCategory': 'உள்ளமைக்கப்பட்ட வகையை நீக்க முடியாது',\n    'selectCategory': 'வகையைத் தேர்ந்தெடுக்கவும்',\n    'ed2k': 'ED2K',\n    'ed2kTcpPort': 'TCP Listen Port',\n    'ed2kUdpPort': 'UDP Listen Port',\n    'ed2kServerList': 'Server Addresses',\n    'ed2kServerMet': 'Server.met Sources',\n    'ed2kNodesDat': 'nodes.dat Sources',\n    'ed2kAutoPort': 'Auto assign',\n    'ed2kOnePerLine': 'One entry per line',\n    'ed2kServersHint': 'host:port, one per line',\n    'ed2kServerMetHint': 'Server.met URL or ed2k serverlist link, one per line',\n    'ed2kNodesDatHint': 'nodes.dat path or URL, one per line',\n    'desktopNotification': 'கணினி அறிவிப்புகள்',\n    'notificationTaskDone': 'பணி முடிந்தது',\n    'notificationTaskError': 'பணி பிழை',\n  },\n};\n"
  },
  {
    "path": "ui/flutter/lib/i18n/langs/tr_tr.dart",
    "content": "const trTR = {\n  'tr_TR': {\n    'label': 'Türkçe',\n    'error': 'Hata',\n    'tip': 'İpucu',\n    'confirm': 'Onayla',\n    'confirmDelete': 'Silmek istediğinize emin misiniz?',\n    'cancel': 'İptal',\n    'on': 'Açık',\n    'off': 'Kapalı',\n    'selectAll': 'Tümünü Seç',\n    'select': 'Seç',\n    'task': 'İşlemler',\n    'downloading': 'İndiriliyor',\n    'downloaded': 'İndirildi',\n    'setting': 'Ayarlar',\n    'donate': 'Bağış Yap',\n    'exit': 'Çıkış',\n    'create': 'İşlem Oluştur',\n    'directDownload': 'Direkt İndir',\n    'advancedOptions': 'Gelişmiş Seçenekler',\n    'followSettings': 'Ayarları Takip Et',\n    'downloadLink': 'İndirme Bağlantısı',\n    'downloadLinkValid': 'Lütfen indirme bağlantısını girin',\n    'downloadLinkHit':\n        'Lütfen her satıra bir tane indirme bağlantısını girin@append',\n    'downloadLinkHitDesktop':\n        ', veya torrent dosyasını olarak buraya sürükleyin',\n    'download': 'İndir',\n    'noFileSelected': 'Devam etmek için lütfen en az bir dosya seçin.',\n    'noStoragePermission': 'Depolama izni gerekli',\n    'selectFile': 'Dosya Seç',\n    'rename': 'Yeniden Adlandır',\n    'basic': 'Temel',\n    'advanced': 'Gelişmiş',\n    'general': 'Genel',\n    'downloadDir': 'İndirme Dizini',\n    'downloadDirValid': 'Lütfen indirme dizinini seçin',\n    'connections': 'Bağlantılar',\n    'useServerCtime': 'Dosya oluşturma tarihi için sunucu zamanını kullan',\n    'maxRunning': 'Maksimum Çalışan İşlem',\n    'defaultDirectDownload': 'Varsayılan olarak direkt indirmeyi işaretle',\n    'autoStartTasks': 'Başlangıçta tamamlanmamış işlemleri otomatik başlat',\n    'autoTorrentEnable':\n        '.torrent dosyalarından otomatik BitTorrent işlemleri oluştur',\n    'autoTorrentDeleteAfterDownload':\n        'BitTorrent işlemi oluşturulduktan sonra .torrent dosyasını sil',\n    'autoDeleteMissingFileTasks': 'Eksik dosya işlemlerini otomatik sil',\n    'items': '@count öğe',\n    'subscribeTracker': 'Tracker Aboneliği',\n    'subscribeFail':\n        'Abonelik başarısız oldu, lütfen ağı kontrol edin veya daha sonra tekrar deneyin',\n    'update': 'Güncelle',\n    'updateDaily': 'Günlük güncelle',\n    'lastUpdate': 'Son güncelleme: @time',\n    'addTracker': 'Tracker Ekle',\n    'addTrackerHit':\n        'Lütfen her satıra bir tane olacak şekilde tracker sunucu adresini girin',\n    'ui': 'Arayüz',\n    'theme': 'Tema',\n    'themeSystem': 'Sistem',\n    'themeLight': 'Açık',\n    'themeDark': 'Koyu',\n    'locale': 'Dil',\n    'notifyWhenNewVersion': 'Güncellemeleri bildir',\n    'analyticsEnabled': 'İstatistikleri Yükle',\n    'analyticsEnabledDesc':\n        'Uygulamayı geliştirmemize yardımcı olmak için anonim kullanım verilerini paylaşın',\n    'about': 'Hakkında',\n    'homepage': 'Ana Sayfa',\n    'version': 'Sürüm',\n    'protocol': 'Protokol',\n    'port': 'Port',\n    'apiToken': 'API Anahtarı',\n    'notSet': 'AYARLANMADI',\n    'set': 'AYARLA',\n    'portInUse': 'Port [@port] kullanımda, lütfen portu değiştirin',\n    'effectAfterRestart': 'Yeniden başlatmadan sonra etkili olacak',\n    'developer': 'Geliştirici',\n    'logDirectory': 'Günlük Dizini',\n    'webhook': 'Webhook',\n    'webhookEnable': 'Webhook\\'u Etkinleştir',\n    'webhookDesc':\n        'İşlemler tamamlandığında veya başarısız olduğunda HTTP POST bildirimleri gönder',\n    'webhookUrlHint': 'Webhook URL\\'sini girin',\n    'webhookTest': 'Test',\n    'webhookTestSuccess': 'Webhook testi başarılı',\n    'webhookTestFail': 'Webhook testi başarısız',\n    'script': 'Betik',\n    'scriptEnable': 'Betiği Etkinleştir',\n    'scriptDesc':\n        'İndirmeler başarıyla tamamlandığında özel betikleri çalıştır',\n    'scriptPathHint': 'Betik dosya yolunu girin (örn. /path/to/script.sh)',\n    'urlInvalid': 'Lütfen geçerli bir HTTP veya HTTPS URL\\'si girin',\n    'required': 'Bu alan zorunludur',\n    'show': 'Göster',\n    'continue': 'Devam Et',\n    'pause': 'Duraklat',\n    'startAll': 'Tümünü Başlat',\n    'pauseAll': 'Tümünü Duraklat',\n    'deleteTask': '@count işlemi sil',\n    'deleteTaskTip': 'İndirilen dosyaları sakla',\n    'delete': 'Sil',\n    'add': 'Ekle',\n    'edit': 'Düzenle',\n    'newVersionTitle': 'Yeni sürüm @version keşfedildi',\n    'newVersionUpdate': 'Şimdi Güncelle',\n    'newVersionLater': 'Daha Sonra',\n    'extensions': 'Eklentiler',\n    'extensionInstallUrl': 'Kurulum URL\\'si',\n    'extensionInstallSuccess': 'Başarıyla kuruldu',\n    'extensionUpdateSuccess': 'Başarıyla güncellendi',\n    'extensionDelete': 'Eklentiyi Sil',\n    'extensionAlreadyLatest': 'Zaten en son sürüm',\n    'extensionFind': 'Eklenti Bul',\n    'extensionDevelop': 'Eklenti Geliştir',\n    'extensionStoreTitle': 'Eklenti Mağazası',\n    'extensionStoreSection': 'Mağaza Eklentileri',\n    'extensionInstalledSection': 'Yüklü Eklentiler',\n    'extensionInstalledEmpty': 'Henüz yüklü eklenti yok',\n    'extensionStoreEmpty': 'Eşleşen eklenti bulunamadı',\n    'extensionSearchHint': 'Ada, başlığa, açıklamaya veya yazara göre ara',\n    'extensionSortStars': 'En Çok Yıldız Alanlar',\n    'extensionSortInstalls': 'En Çok Yüklenenler',\n    'extensionSortUpdated': 'Son Güncellenenler',\n    'extensionSortToggle': 'Sıralama yönünü değiştir',\n    'extensionLoadMore': 'Daha Fazla Yükle',\n    'extensionNoMore': 'Daha fazla eklenti yok',\n    'extensionInstalled': 'Yüklü',\n    'extensionCanUpdate': 'Güncelleme Mevcut',\n    'extensionInstall': 'Yükle',\n    'extensionLoadLocal': 'Yerel Klasörden Yükle',\n    'extensionFilterAll': 'Tümü',\n    'extensionFilterMarket': 'Market',\n    'extensionFilterInstalled': 'Yüklü',\n    'extensionManualInstall': 'Manuel Yükleme',\n    'extensionInstallTools': 'Yükleme Seçenekleri',\n    'history': 'Geçmiş',\n    'clearHistory': 'Geçmişi Temizle',\n    'noHistoryFound': 'Geçmiş Bulunamadı',\n    'serviceTitle': 'İndirme Servisi',\n    'serviceText': 'Çalışıyor',\n    'network': 'Ağ',\n    'proxy': 'Proxy',\n    'noProxy': 'Proxy Yok',\n    'systemProxy': 'Sistem Proxy\\'si',\n    'customProxy': 'Özel Proxy',\n    'server': 'Sunucu',\n    'username': 'Kullanıcı Adı',\n    'password': 'Şifre',\n    'thanks': 'Teşekkürler',\n    'thanksDesc':\n        'Gopeed topluluğunu oluşturmaya ve geliştirmeye yardımcı olan herkese teşekkürler!',\n    'browserExtension': 'Tarayıcı Eklentisi',\n    'launchAtStartup': 'Başlangıçta çalıştır',\n    'runAsMenubarApp': 'Menü çubuğu uygulaması olarak çalıştır',\n    'runAsMenubarAppDesc':\n        'Dock simgesini gizle ve sadece menü çubuğunda çalıştır',\n    'seedConfig': 'Seed Yapılandırması',\n    'seedKeep': 'Manuel olarak durdurulana kadar seed\\'e devam et',\n    'seedRatio': 'Seed oranı',\n    'seedTime': 'Seed süresi (dakika)',\n    'setAsDefaultBtClient': 'Varsayılan BitTorrent istemcisi olarak ayarla',\n    'taskDetail': 'İşlem Detayı',\n    'taskName': 'İşlem Adı',\n    'taskUrl': 'İşlem URL\\'si',\n    'downloadPath': 'İndirme Yolu',\n    'skipVerifyCert': 'Sertifika Doğrulamasını Atla',\n    'archives': 'Arşivler',\n    'autoExtract': 'Arşivleri Otomatik Çıkar',\n    'archivePassword': 'Arşiv Şifresi',\n    'archivePasswordHint': 'Şifre korumalı değilse boş bırakın',\n    'deleteAfterExtract': 'Çıkardıktan sonra arşivi sil',\n    'extracting': 'Çıkarılıyor',\n    'extractDone': 'Çıkarma tamamlandı',\n    'extractError': 'Çıkarma başarısız',\n    'waitingParts': 'Parçalar bekleniyor',\n    'name': 'Ad',\n    'size': 'Boyut',\n    'unknown': 'Bilinmeyen',\n    'fileSelectedCount': 'Dosyalar: ',\n    'fileSelectedSize': 'Boyut: ',\n    'httpHeader': 'Üstbilgi (Header)',\n    'httpHeaderName': 'Üstbilgi Adı',\n    'httpHeaderValue': 'Üstbilgi Değeri',\n    'login': 'Giriş Yap',\n    'username_required': 'Lütfen kullanıcı adınızı girin',\n    'password_required': 'Lütfen şifrenizi girin',\n    'login_success': 'Giriş başarılı',\n    'login_failed':\n        'Giriş başarısız, lütfen kullanıcı adınızı ve şifrenizi kontrol edin',\n    'login_failed_network':\n        'Giriş başarısız, lütfen ağ bağlantınızı kontrol edin',\n    'insertPlaceholder': 'Yer Tutucu Ekle',\n    'placeholderYear': 'Mevcut yıl',\n    'placeholderMonth': 'Mevcut ay (01-12)',\n    'placeholderDay': 'Mevcut gün (01-31)',\n    'placeholderDate': 'Tam tarih (YYYY-MM-DD)',\n    'example': 'örn. @value',\n    'downloadCategories': 'İndirme Kategorileri',\n    'categoryMusic': 'Müzik',\n    'categoryVideo': 'Video',\n    'categoryDocument': 'Belge',\n    'categoryProgram': 'Program',\n    'categoryName': 'Kategori Adı',\n    'categoryPath': 'Kategori Yolu',\n    'builtInCategory': 'Yerleşik kategori silinemez',\n    'selectCategory': 'Kategori Seç',\n    'githubMirror': 'GitHub Yansısı',\n    'githubMirrorEnable': 'GitHub Yansısını Etkinleştir',\n    'githubMirrorDesc':\n        'GitHub içerik indirmelerini hızlandırmak için yansıları kullanın',\n    'githubMirrorType': 'Yansı Türü',\n    'githubMirrorUrl': 'Yansı URL\\'si',\n    'githubMirrorUrlHint': 'Yansı URL\\'sini girin',\n    'updateUrl': 'Güncelleme URL\\'si',\n    'updateUrlManual': 'Manuel Güncelleme',\n    'updateUrlListen': 'Güncellemeyi İzle',\n    'updateUrlListeningTip': 'URL güncellemesi bekleniyor',\n    'updateUrlCancelListen': 'İzlemeyi İptal Et',\n    'updateUrlDialogHint': 'Yeni indirme URL\\'sini girin',\n    'pendingUpdateFound': 'Bekleyen Güncelleme İşlemi Bulundu',\n    'pendingUpdateConfirm':\n        '\"@name\" işlemi URL güncellemesi bekliyor. Yeni URL ile güncellemek istiyor musunuz?',\n    'pendingUpdateYes': 'İşlemi Güncelle',\n    'pendingUpdateNo': 'Yeni İşlem Oluştur',\n    'ed2k': 'ED2K',\n    'ed2kTcpPort': 'TCP Listen Port',\n    'ed2kUdpPort': 'UDP Listen Port',\n    'ed2kServerList': 'Server Addresses',\n    'ed2kServerMet': 'Server.met Sources',\n    'ed2kNodesDat': 'nodes.dat Sources',\n    'ed2kAutoPort': 'Auto assign',\n    'ed2kOnePerLine': 'One entry per line',\n    'ed2kServersHint': 'host:port, one per line',\n    'ed2kServerMetHint': 'Server.met URL or ed2k serverlist link, one per line',\n    'ed2kNodesDatHint': 'nodes.dat path or URL, one per line',\n    'desktopNotification': 'Masaüstü Bildirimleri',\n    'notificationTaskDone': 'Görev tamamlandı',\n    'notificationTaskError': 'Görev hatası',\n  },\n};\n"
  },
  {
    "path": "ui/flutter/lib/i18n/langs/uk_ua.dart",
    "content": "const ukUA = {\n  'uk_UA': {\n    'label': 'Мова',\n    'error': 'Помилка',\n    'tip': 'Підказка',\n    'confirm': 'Підтвердити',\n    'confirmDelete': 'Ви впевнені, що хочете видалити?',\n    'cancel': 'Скасувати',\n    'on': 'Увімк.',\n    'off': 'Вимк.',\n    'selectAll': 'Вибрати все',\n    'select': 'Вибрати',\n    'task': 'Завдання',\n    'downloading': 'завантажується',\n    'downloaded': 'завантажено',\n    'setting': 'Налаштування',\n    'donate': 'Підтримати',\n    'exit': 'Вихід',\n    'create': 'Створити завдання',\n    'directDownload': 'Пряме завантаження',\n    'advancedOptions': 'Додаткові параметри',\n    'followSettings': 'За замовчуванням',\n    'downloadLink': 'Посилання на завантаження',\n    'downloadLinkValid': 'Будь ласка, введіть посилання на завантаження',\n    'downloadLinkHit':\n        'Будь ласка, введіть посилання на завантаження, одне на рядок@Append',\n    'downloadLinkHitDesktop': ', або перетягніть сюди файл торенту',\n    'download': 'Завантажити',\n    'noFileSelected': 'Будь ласка, виберіть хоча б один файл для продовження.',\n    'noStoragePermission': 'Потрібен дозвіл на зберігання',\n    'selectFile': 'Вибрати файл',\n    'rename': 'Перейменувати',\n    'basic': 'Основні',\n    'advanced': 'Розширені',\n    'general': 'Загальні',\n    'downloadDir': 'Каталог завантажень',\n    'downloadDirValid': 'Будь ласка, виберіть каталог завантажень',\n    'connections': 'З’єднання',\n    'useServerCtime': 'Використовувати час сервера для створення файлу',\n    'maxRunning': 'Макс. завдань у черзі',\n    'defaultDirectDownload': 'Пряме завантаження за замовчуванням',\n    'autoTorrentEnable': 'Автоматично створювати BT-завдання з .torrent файлів',\n    'autoTorrentDeleteAfterDownload':\n        'Видалити .torrent файл після створення BT-завдання',\n    'autoDeleteMissingFileTasks':\n        'Автоматично видаляти завдання з відсутніми файлами',\n    'items': '@count елементів',\n    'subscribeTracker': 'Підписатися на трекер',\n    'subscribeFail':\n        'Не вдалося підписатися, перевірте мережу або спробуйте пізніше',\n    'update': 'Оновити',\n    'updateDaily': 'Оновлювати щодня',\n    'lastUpdate': 'Останнє оновлення: @time',\n    'addTracker': 'Додати трекер',\n    'addTrackerHit': 'Будь ласка, введіть URL-адресу трекера, одну на рядок',\n    'ui': 'Інтерфейс',\n    'theme': 'Тема',\n    'themeSystem': 'Системна',\n    'themeLight': 'Світла',\n    'themeDark': 'Темна',\n    'locale': 'Мова',\n    'about': 'Про програму',\n    'homepage': 'Домашня сторінка',\n    'version': 'Версія',\n    'protocol': 'Протокол',\n    'port': 'Порт',\n    'apiToken': 'API Токен',\n    'notSet': 'Не задано',\n    'set': 'Задати',\n    'portInUse': 'Порт [@PORT] вже використовується, будь ласка, змініть порт',\n    'effectAfterRestart': 'Ефект після перезапуску',\n    'developer': 'Розробник',\n    'logDirectory': 'Каталог журналів',\n    'show': 'Показати',\n    'continue': 'Продовжити',\n    'pause': 'Пауза',\n    'startAll': 'Запустити все',\n    'pauseAll': 'Зупинити все',\n    'deleteTask': 'Видалити @count завдань',\n    'deleteTaskTip': 'Зберегти завантажені файли',\n    'delete': 'Видалити',\n    'newVersionTitle': 'Доступна нова версія @Version',\n    'newVersionUpdate': 'Оновити зараз',\n    'newVersionLater': 'Пізніше',\n    'extensions': 'Розширення',\n    'extensionInstallUrl': 'URL для встановлення',\n    'extensionInstallSuccess': 'Встановлено успішно',\n    'extensionUpdateSuccess': 'Оновлено успішно',\n    'extensionDelete': 'Видалити розширення',\n    'extensionAlreadyLatest': 'Вже остання версія',\n    'extensionFind': 'Знайти розширення',\n    'extensionDevelop': 'Розробка розширень',\n    'history': 'Історія',\n    'clearHistory': 'Очистити історію',\n    'noHistoryFound': 'Історію не знайдено',\n    'serviceTitle': 'Служба завантаження',\n    'serviceText': 'Працює',\n    'network': 'Мережа',\n    'proxy': 'Проксі',\n    'noProxy': 'Без проксі',\n    'systemProxy': 'Системний проксі',\n    'customProxy': 'Користувацький проксі',\n    'server': 'Сервер',\n    'username': 'Ім’я користувача',\n    'password': 'Пароль',\n    'thanks': 'Подяки',\n    'thanksDesc':\n        'Дякуємо всім учасникам, які допомогли створити та розвивати спільноту Gopeed!',\n    'browserExtension': 'Розширення для браузера',\n    'launchAtStartup': 'Запускати під час завантаження системи',\n    'runAsMenubarApp': 'Запускати як додаток у рядку меню',\n    'runAsMenubarAppDesc':\n        'Приховати значок у Dock і запускати лише в рядку меню',\n    'seedConfig': 'Налаштування роздачі',\n    'seedKeep': 'Роздавати доки не зупиню вручну',\n    'seedRatio': 'Коефіцієнт роздачі',\n    'seedTime': 'Час роздачі (хвилини)',\n    'setAsDefaultBtClient': 'Встановити як клієнт BT за замовчуванням',\n    'taskDetail': 'Деталі завдання',\n    'taskName': 'Назва завдання',\n    'taskUrl': 'URL завдання',\n    'downloadPath': 'Шлях завантаження',\n    'skipVerifyCert': 'Пропустити перевірку сертифіката',\n    'archives': 'Архіви',\n    'autoExtract': 'Автоматично розпаковувати архіви',\n    'archivePassword': 'Пароль архіву',\n    'archivePasswordHint': 'Залишити порожнім, якщо архів не захищений паролем',\n    'deleteAfterExtract': 'Видалити архів після розпакування',\n    'extracting': 'Розпакування',\n    'extractDone': 'Розпакування завершено',\n    'extractError': 'Помилка розпакування',\n    'waitingParts': 'Очікування частин',\n    'name': 'Назва',\n    'size': 'Розмір',\n    'unknown': 'Невідомо',\n    'fileSelectedCount': 'Файлів: ',\n    'fileSelectedSize': 'Розмір: ',\n    'httpHeaderName': 'Назва заголовка HTTP',\n    'httpHeaderValue': 'Значення заголовка HTTP',\n    'insertPlaceholder': 'Вставити заповнювач',\n    'placeholderYear': 'Поточний рік',\n    'placeholderMonth': 'Поточний місяць (01-12)',\n    'placeholderDay': 'Поточний день (01-31)',\n    'placeholderDate': 'Повна дата (РРРР-ММ-ДД)',\n    'example': 'напр. @value',\n    'downloadCategories': 'Категорії завантажень',\n    'categoryMusic': 'Музика',\n    'categoryVideo': 'Відео',\n    'categoryDocument': 'Документ',\n    'categoryProgram': 'Програма',\n    'categoryName': 'Назва категорії',\n    'categoryPath': 'Шлях категорії',\n    'builtInCategory': 'Вбудовану категорію неможливо видалити',\n    'selectCategory': 'Вибрати категорію',\n    'ed2k': 'ED2K',\n    'ed2kTcpPort': 'TCP Listen Port',\n    'ed2kUdpPort': 'UDP Listen Port',\n    'ed2kServerList': 'Server Addresses',\n    'ed2kServerMet': 'Server.met Sources',\n    'ed2kNodesDat': 'nodes.dat Sources',\n    'ed2kAutoPort': 'Auto assign',\n    'ed2kOnePerLine': 'One entry per line',\n    'ed2kServersHint': 'host:port, one per line',\n    'ed2kServerMetHint': 'Server.met URL or ed2k serverlist link, one per line',\n    'ed2kNodesDatHint': 'nodes.dat path or URL, one per line',\n    'desktopNotification': 'Сповіщення',\n    'notificationTaskDone': 'Завдання завершено',\n    'notificationTaskError': 'Помилка завдання',\n  },\n};\n"
  },
  {
    "path": "ui/flutter/lib/i18n/langs/vi_vn.dart",
    "content": "const viVN = {\n  'vi_VN': {\n    'label': 'Tiếng Việt',\n    'error': 'Lỗi',\n    'tip': 'Mẹo',\n    'confirm': 'Xác nhận',\n    'confirmDelete': 'Bạn có chắc chắn muốn xóa?',\n    'cancel': 'Hủy',\n    'on': 'Bật',\n    'off': 'Tắt',\n    'selectAll': 'Chọn tất cả',\n    'task': 'Nhiệm vụ',\n    'downloading': 'đang tải',\n    'downloaded': 'đã tải',\n    'setting': 'Cài đặt',\n    'donate': 'Ủng hộ',\n    'exit': 'Thoát',\n    'create': 'Tạo nhiệm vụ',\n    'directDownload': 'Tải trực tiếp',\n    'advancedOptions': 'Tùy chọn nâng cao',\n    'downloadLink': 'Liên kết tải về',\n    'downloadLinkValid': 'Vui lòng nhập liên kết tải về',\n    'downloadLinkHit':\n        'Vui lòng nhập liên kết tải về, hỗ trợ HTTP/HTTPS/MAGNET@append',\n    'downloadLinkHitDesktop': ', hoặc kéo tệp torrent vào đây trực tiếp',\n    'download': 'Tải về',\n    'noFileSelected': 'Vui lòng chọn ít nhất một tệp để tiếp tục.',\n    'noStoragePermission': 'Yêu cầu quyền lưu trữ',\n    'selectFile': 'Chọn tệp',\n    'rename': 'Đổi tên',\n    'basic': 'Cơ bản',\n    'advanced': 'Nâng cao',\n    'general': 'Chung',\n    'downloadDir': 'Thư mục tải về',\n    'downloadDirValid': 'Vui lòng chọn thư mục tải về',\n    'connections': 'Kết nối',\n    'useServerCtime': 'Sử dụng thời gian máy chủ cho việc tạo tệp',\n    'maxRunning': 'Số nhiệm vụ tối đa',\n    'autoTorrentEnable': 'Tự động tạo nhiệm vụ BT từ file .torrent',\n    'autoTorrentDeleteAfterDownload':\n        'Xóa file .torrent sau khi tạo nhiệm vụ BT',\n    'autoDeleteMissingFileTasks': 'Tự động xóa tác vụ có tệp bị thiếu',\n    'items': '@count mục',\n    'subscribeTracker': 'Theo dõi Tracker',\n    'subscribeFail':\n        'Theo dõi thất bại, vui lòng kiểm tra kết nối mạng hoặc thử lại sau',\n    'update': 'Cập nhật',\n    'updateDaily': 'Cập nhật hàng ngày',\n    'lastUpdate': 'Cập nhật lần cuối: @time',\n    'addTracker': 'Thêm Tracker',\n    'addTrackerHit': 'Vui lòng nhập địa chỉ máy chủ tracker, mỗi dòng một',\n    'ui': 'Giao diện',\n    'theme': 'Chủ đề',\n    'themeSystem': 'Hệ thống',\n    'themeLight': 'Sáng',\n    'themeDark': 'Tối',\n    'locale': 'Ngôn ngữ',\n    'about': 'Về chúng tôi',\n    'homepage': 'Trang chủ',\n    'version': 'Phiên bản',\n    'protocol': 'Giao thức',\n    'port': 'Cổng',\n    'apiToken': 'Mã API',\n    'notSet': 'Chưa đặt',\n    'set': 'Đặt',\n    'effectAfterRestart': 'Hiệu lực sau khi khởi động lại',\n    'startAll': 'Bắt đầu tất cả',\n    'pauseAll': 'Tạm dừng tất cả',\n    'deleteTask': 'Xóa @count nhiệm vụ',\n    'deleteTaskTip': 'Giữ các tệp đã tải về',\n    'delete': 'Xóa',\n    'newVersionTitle': 'Khám phá phiên bản mới @version',\n    'newVersionUpdate': 'Cập nhật ngay',\n    'newVersionLater': 'Sau',\n    'extensions': 'Tiện ích mở rộng',\n    'extensionInstallUrl': 'Liên kết cài đặt',\n    'extensionInstallSuccess': 'Cài đặt thành công',\n    'extensionUpdateSuccess': 'Cập nhật thành công',\n    'extensionDelete': 'Xóa tiện ích mở rộng',\n    'extensionAlreadyLatest': 'Đây đã là phiên bản mới nhất',\n    'extensionFind': 'Tìm tiện ích mở rộng',\n    'extensionDevelop': 'Phát triển tiện ích mở rộng',\n    'history': 'Lịch sử',\n    'clearHistory': 'Xóa lịch sử',\n    'noHistoryFound': 'Không tìm thấy lịch sử',\n    'serviceTitle': 'Dịch vụ Tải xuống',\n    'serviceText': 'Đang chạy',\n    'network': 'Mạng',\n    'proxy': 'Proxy',\n    'server': 'Máy chủ',\n    'username': 'Tên đăng nhập',\n    'password': 'Mật khẩu',\n    'thanks': 'Cảm ơn',\n    'thanksDesc':\n        'Cảm ơn tất cả những người đóng góp đã giúp xây dựng và phát triển cộng đồng Gopeed!',\n    'browserExtension': 'Tiện ích mở rộng trình duyệt',\n    'skipVerifyCert': 'Bỏ qua xác thực chứng chỉ',\n    'archives': 'Tập tin nén',\n    'autoExtract': 'Tự động giải nén tập tin nén',\n    'archivePassword': 'Mật khẩu tập tin nén',\n    'archivePasswordHint': 'Để trống nếu không có mật khẩu',\n    'deleteAfterExtract': 'Xóa tập tin nén sau khi giải nén',\n    'extracting': 'Đang giải nén',\n    'extractDone': 'Giải nén hoàn tất',\n    'extractError': 'Giải nén thất bại',\n    'waitingParts': 'Đang chờ các phần',\n    'insertPlaceholder': 'Chèn placeholder',\n    'placeholderYear': 'Năm hiện tại',\n    'placeholderMonth': 'Tháng hiện tại (01-12)',\n    'placeholderDay': 'Ngày hiện tại (01-31)',\n    'placeholderDate': 'Ngày đầy đủ (YYYY-MM-DD)',\n    'example': 'vd. @value',\n    'downloadCategories': 'Danh mục tải xuống',\n    'categoryMusic': 'Nhạc',\n    'categoryVideo': 'Video',\n    'categoryDocument': 'Tài liệu',\n    'categoryProgram': 'Chương trình',\n    'categoryName': 'Tên danh mục',\n    'categoryPath': 'Đường dẫn danh mục',\n    'builtInCategory': 'Không thể xóa danh mục tích hợp',\n    'selectCategory': 'Chọn danh mục',\n    'launchAtStartup': 'Khởi động cùng hệ thống',\n    'runAsMenubarApp': 'Chạy như ứng dụng thanh menu',\n    'runAsMenubarAppDesc': 'Ẩn biểu tượng Dock và chỉ chạy trong thanh menu',\n    'ed2k': 'ED2K',\n    'ed2kTcpPort': 'TCP Listen Port',\n    'ed2kUdpPort': 'UDP Listen Port',\n    'ed2kServerList': 'Server Addresses',\n    'ed2kServerMet': 'Server.met Sources',\n    'ed2kNodesDat': 'nodes.dat Sources',\n    'ed2kAutoPort': 'Auto assign',\n    'ed2kOnePerLine': 'One entry per line',\n    'ed2kServersHint': 'host:port, one per line',\n    'ed2kServerMetHint': 'Server.met URL or ed2k serverlist link, one per line',\n    'ed2kNodesDatHint': 'nodes.dat path or URL, one per line',\n    'desktopNotification': 'Thông báo trên màn hình',\n    'notificationTaskDone': 'Nhiệm vụ hoàn thành',\n    'notificationTaskError': 'Lỗi nhiệm vụ',\n  },\n};\n"
  },
  {
    "path": "ui/flutter/lib/i18n/langs/zh_cn.dart",
    "content": "const zhCN = {\n  'zh_CN': {\n    'label': '中文（简体）',\n    'error': '错误',\n    'tip': '提示',\n    'confirm': '确认',\n    'confirmDelete': '确定要删除吗？',\n    'cancel': '取消',\n    'on': '开启',\n    'off': '关闭',\n    'selectAll': '全选',\n    'select': '选择',\n    'task': '任务',\n    'downloading': '下载中',\n    'downloaded': '已完成',\n    'setting': '设置',\n    'donate': '打赏',\n    'exit': '退出',\n    'create': '创建任务',\n    'directDownload': '直接下载',\n    'advancedOptions': '高级选项',\n    'followSettings': '跟随设置',\n    'downloadLink': '下载链接',\n    'downloadLinkValid': '请输入下载链接',\n    'downloadLinkHit': '请输入下载链接，支持批量下载，每行一个链接@append',\n    'downloadLinkHitDesktop': '，也可以直接拖拽种子文件到此处',\n    'download': '下载',\n    'noFileSelected': '请至少选择一个文件下载',\n    'noStoragePermission': '需要开启存储权限',\n    'selectFile': '选择文件',\n    'rename': '重命名',\n    'basic': '基础',\n    'advanced': '高级',\n    'general': '通用',\n    'downloadDir': '下载目录',\n    'downloadDirValid': '请选择下载目录',\n    'connections': '连接数',\n    'useServerCtime': '创建文件使用服务器时间',\n    'maxRunning': '最大下载数',\n    'defaultDirectDownload': '默认勾选直接下载',\n    'autoStartTasks': '启动时自动开始未完成的任务',\n    'autoTorrentEnable': '自动从 .torrent 文件创建 BT 任务',\n    'autoTorrentDeleteAfterDownload': '创建 BT 任务后删除 .torrent 文件',\n    'autoDeleteMissingFileTasks': '自动删除缺失文件任务',\n    'items': '@count 项',\n    'subscribeTracker': '订阅 Tracker',\n    'subscribeFail': '订阅失败，请检查网络或稍后重试',\n    'update': '更新',\n    'updateDaily': '每天自动更新',\n    'lastUpdate': '上次更新：@time',\n    'addTracker': '添加 Tracker',\n    'addTrackerHit': '请输入 tracker 服务器地址，每行一条',\n    'ui': '界面',\n    'theme': '主题',\n    'themeSystem': '跟随系统',\n    'themeLight': '明亮主题',\n    'themeDark': '暗黑主题',\n    'locale': '语言',\n    'notifyWhenNewVersion': '有新版本时提醒',\n    'analyticsEnabled': '上传统计数据',\n    'analyticsEnabledDesc': '分享匿名使用数据以帮助我们改进',\n    'about': '关于',\n    'homepage': '主页',\n    'version': '版本',\n    'protocol': '通讯协议',\n    'port': '端口',\n    'apiToken': '接口令牌',\n    'notSet': '未设置',\n    'set': '已设置',\n    'portInUse': '端口[@port]已被占用，请更换端口',\n    'effectAfterRestart': '此配置项将在重启应用后生效',\n    'developer': '开发者',\n    'logDirectory': '日志目录',\n    'webhook': 'Webhook 推送',\n    'webhookEnable': '启用 Webhook',\n    'webhookDesc': '在任务完成或失败时发送 HTTP POST 通知',\n    'webhookUrlHint': '请输入 Webhook URL',\n    'webhookTest': '测试',\n    'webhookTestSuccess': 'Webhook 测试成功',\n    'webhookTestFail': 'Webhook 测试失败',\n    'script': '脚本执行',\n    'scriptEnable': '启用脚本',\n    'scriptDesc': '在下载成功完成时执行自定义脚本',\n    'scriptPathHint': '请输入脚本文件路径（例如：/path/to/script.sh）',\n    'urlInvalid': '请输入有效的 HTTP 或 HTTPS 链接',\n    'required': '此项为必填项',\n    'show': '显示',\n    'continue': '继续',\n    'pause': '暂停',\n    'startAll': '全部开始',\n    'pauseAll': '全部暂停',\n    'deleteTask': '删除 @count 个任务',\n    'deleteTaskTip': '保留已下载的文件',\n    'delete': '删除',\n    'add': '添加',\n    'edit': '编辑',\n    'newVersionTitle': '发现新版本 @version',\n    'newVersionUpdate': '立即更新',\n    'newVersionLater': '稍后再说',\n    'extensions': '扩展',\n    'extensionInstallUrl': '安装链接',\n    'extensionInstallSuccess': '安装成功',\n    'extensionUpdateSuccess': '更新成功',\n    'extensionDelete': '删除扩展',\n    'extensionAlreadyLatest': '已经是最新版本',\n    'extensionFind': '获取扩展',\n    'extensionDevelop': '开发扩展',\n    'extensionStoreTitle': '扩展商店',\n    'extensionStoreSection': '在线扩展',\n    'extensionInstalledSection': '已安装扩展',\n    'extensionInstalledEmpty': '暂未安装扩展',\n    'extensionStoreEmpty': '没有匹配的扩展',\n    'extensionSearchHint': '按名称、标题、描述或作者搜索',\n    'extensionSortStars': '最多 Star',\n    'extensionSortInstalls': '最多安装',\n    'extensionSortUpdated': '最近更新',\n    'extensionSortToggle': '切换排序方向',\n    'extensionLoadMore': '加载更多',\n    'extensionNoMore': '已经到底了',\n    'extensionInstalled': '已安装',\n    'extensionCanUpdate': '可更新',\n    'extensionInstall': '安装',\n    'extensionLoadLocal': '安装本地目录',\n    'extensionFilterAll': '全部',\n    'extensionFilterMarket': '市场',\n    'extensionFilterInstalled': '已安装',\n    'extensionManualInstall': '手动安装',\n    'extensionInstallTools': '安装方式',\n    'history': '历史记录',\n    'clearHistory': '清空历史记录',\n    'noHistoryFound': '暂无历史记录',\n    'serviceTitle': '下载服务',\n    'serviceText': '运行中',\n    'network': '网络',\n    'proxy': '代理',\n    'noProxy': '不使用代理',\n    'systemProxy': '系统代理',\n    'customProxy': '自定义代理',\n    'server': '服务器',\n    'username': '用户名',\n    'password': '密码',\n    'thanks': '鸣谢',\n    'thanksDesc': '感谢所有为 Gopeed 社区建设添砖加瓦的贡献者们！',\n    'browserExtension': '浏览器扩展',\n    'launchAtStartup': '开机自动运行',\n    'runAsMenubarApp': '以菜单栏应用运行',\n    'runAsMenubarAppDesc': '隐藏程序坞图标，仅在菜单栏运行',\n    'seedConfig': '做种设置',\n    'seedKeep': '持续做种直到手动停止',\n    'seedRatio': '做种分享率',\n    'seedTime': '做种时间（分钟）',\n    'setAsDefaultBtClient': '设为系统默认 BT 客户端',\n    'taskDetail': '任务详情',\n    'taskName': '任务名称',\n    'taskUrl': '任务链接',\n    'downloadPath': '下载路径',\n    'skipVerifyCert': '跳过证书验证',\n    'archives': '压缩包',\n    'autoExtract': '自动解压压缩包',\n    'archivePassword': '压缩包密码',\n    'archivePasswordHint': '如无密码请留空',\n    'deleteAfterExtract': '解压后删除压缩包',\n    'extracting': '正在解压',\n    'extractDone': '解压完成',\n    'extractError': '解压失败',\n    'waitingParts': '等待分卷',\n    'name': '名称',\n    'size': '大小',\n    'unknown': '未知',\n    'fileSelectedCount': '文件数：',\n    'fileSelectedSize': '大小：',\n    'httpHeader': '请求头',\n    'httpHeaderName': '请求头名称',\n    'httpHeaderValue': '请求头值',\n    'login': '登录',\n    'username_required': '请输入用户名',\n    'password_required': '请输入密码',\n    'login_success': '登录成功',\n    'login_failed': '登录失败，请检查用户名和密码',\n    'login_failed_network': '登录失败，请检查网络连接',\n    'insertPlaceholder': '插入占位符',\n    'placeholderYear': '当前年份',\n    'placeholderMonth': '当前月份 (01-12)',\n    'placeholderDay': '当前日期 (01-31)',\n    'placeholderDate': '完整日期 (年-月-日)',\n    'example': '例如 @value',\n    'downloadCategories': '下载目录分类',\n    'categoryMusic': '音乐',\n    'categoryVideo': '视频',\n    'categoryDocument': '文档',\n    'categoryProgram': '程序',\n    'categoryName': '分类名称',\n    'categoryPath': '分类路径',\n    'builtInCategory': '内置分类不能删除',\n    'selectCategory': '选择分类',\n    'githubMirror': 'GitHub 镜像',\n    'githubMirrorEnable': '启用 GitHub 镜像',\n    'githubMirrorDesc': '使用镜像站点加速 GitHub 内容下载',\n    'githubMirrorType': '镜像类型',\n    'githubMirrorUrl': '镜像地址',\n    'githubMirrorUrlHint': '请输入镜像地址',\n    'updateUrl': '更新地址',\n    'updateUrlManual': '手动更新',\n    'updateUrlListen': '监听更新',\n    'updateUrlListeningTip': '正在等待更新地址',\n    'updateUrlCancelListen': '取消监听',\n    'updateUrlDialogHint': '请输入新的下载地址',\n    'pendingUpdateFound': '发现待更新任务',\n    'pendingUpdateConfirm': '任务 \"@name\" 正在等待更新地址，是否使用新地址更新该任务？',\n    'pendingUpdateYes': '更新任务',\n    'pendingUpdateNo': '创建新任务',\n    'ed2k': 'ED2K',\n    'ed2kTcpPort': 'TCP 监听端口',\n    'ed2kUdpPort': 'UDP 监听端口',\n    'ed2kServerList': '服务器地址',\n    'ed2kServerMet': 'Server.met 源',\n    'ed2kNodesDat': 'nodes.dat 源',\n    'ed2kAutoPort': '自动分配',\n    'ed2kOnePerLine': '每行填写一条',\n    'ed2kServersHint': 'host:port，每行一条',\n    'ed2kServerMetHint': '请输入 Server.met 地址或 ed2k 服务器列表链接，每行一条',\n    'ed2kNodesDatHint': '请输入 nodes.dat 本地路径或 URL，每行一条',\n    'desktopNotification': '桌面通知',\n    'notificationTaskDone': '任务完成',\n    'notificationTaskError': '任务失败',\n  }\n};\n"
  },
  {
    "path": "ui/flutter/lib/i18n/langs/zh_tw.dart",
    "content": "const zhTW = {\n  \"zh_TW\": {\n    'label': '中文 (正體)',\n    'error': '錯誤',\n    'tip': '提示',\n    'confirm': '確認',\n    'confirmDelete': '確定要刪除嗎？',\n    'cancel': '取消',\n    'on': '開啟',\n    'off': '關閉',\n    'selectAll': '全選',\n    'select': '選擇',\n    'task': '任務',\n    'downloading': '下載中',\n    'downloaded': '已下載',\n    'setting': '設定',\n    'donate': '捐款',\n    'exit': '退出',\n    'create': '建立任務',\n    'directDownload': '直接下載',\n    'advancedOptions': '進階選項',\n    'followSettings': '跟隨設定',\n    'downloadLink': '下載連結',\n    'downloadLinkValid': '請輸入下載連結',\n    'downloadLinkHit': '請輸入下載連結，支援批量下載，每行一個連結@append',\n    'downloadLinkHitDesktop': '，或直接拖曳種子檔到此處',\n    'download': '下載',\n    'noFileSelected': '請至少選擇一個檔案以繼續。',\n    'noStoragePermission': '需要儲存空間權限',\n    'selectFile': '選擇檔案',\n    'rename': '重新命名',\n    'basic': '基本',\n    'advanced': '進階',\n    'general': '一般',\n    'downloadDir': '下載目錄',\n    'downloadDirValid': '請選擇下載目錄',\n    'connections': '連接數',\n    'useServerCtime': '使用伺服器時間作為檔案建立時間',\n    'maxRunning': '最大執行任務數',\n    'defaultDirectDownload': '預設勾選直接下載',\n    'autoStartTasks': '啟動時自動開始未完成的任務',\n    'autoTorrentEnable': '自動從 .torrent 檔案建立 BT 任務',\n    'autoTorrentDeleteAfterDownload': '建立 BT 任務後刪除 .torrent 檔案',\n    'autoDeleteMissingFileTasks': '自動刪除缺失檔案任務',\n    'items': '@count 個項目',\n    'subscribeTracker': '訂閱 Tracker',\n    'subscribeFail': '訂閱失敗，請檢查網路或稍後再試',\n    'update': '更新',\n    'updateDaily': '每日更新',\n    'lastUpdate': '最後更新時間：@time',\n    'addTracker': '新增 Tracker',\n    'addTrackerHit': '請輸入 Tracker 伺服器 URL，每行一個',\n    'ui': '介面',\n    'theme': '主題',\n    'themeSystem': '系統',\n    'themeLight': '淺色',\n    'themeDark': '深色',\n    'locale': '語言',\n    'about': '關於',\n    'homepage': '首頁',\n    'version': '版本',\n    'protocol': '協議',\n    'port': '連接埠',\n    'apiToken': 'API Token',\n    'notSet': '未設定',\n    'set': '已設定',\n    'portInUse': '連接埠 [@port] 已被使用，請更改連接埠',\n    'effectAfterRestart': '重新啟動後生效',\n    'developer': '開發者',\n    'logDirectory': '日誌目錄',\n    'show': '顯示',\n    'continue': '繼續',\n    'pause': '暫停',\n    'startAll': '全部開始',\n    'pauseAll': '全部暫停',\n    'deleteTask': '刪除 @count 個任務',\n    'deleteTaskTip': '保留已下載的檔案',\n    'delete': '刪除',\n    'newVersionTitle': '發現新版本 @version',\n    'newVersionUpdate': '立即更新',\n    'newVersionLater': '稍後',\n    'extensions': '擴充功能',\n    'extensionInstallUrl': '安裝 URL',\n    'extensionInstallSuccess': '安裝成功',\n    'extensionUpdateSuccess': '更新成功',\n    'extensionDelete': '刪除擴充功能',\n    'extensionAlreadyLatest': '已是最新版本',\n    'extensionFind': '尋找擴充功能',\n    'extensionDevelop': '開發擴充功能',\n    'history': '歷史記錄',\n    'clearHistory': '清除歷史記錄',\n    'noHistoryFound': '未找到歷史記錄',\n    'serviceTitle': '下載服務',\n    'serviceText': '運行中',\n    'network': '網路',\n    'proxy': 'Proxy',\n    'noProxy': '沒有 Proxy',\n    'systemProxy': '系統 Proxy',\n    'customProxy': '自訂 Proxy',\n    'server': '伺服器',\n    'username': '使用者名稱',\n    'password': '密碼',\n    'thanks': '感謝',\n    'thanksDesc': '感謝所有幫助建立和發展 Gopeed 社群的貢獻者！',\n    'browserExtension': '瀏覽器擴充功能',\n    'launchAtStartup': '開機自動執行',\n    'runAsMenubarApp': '以選單列應用程式執行',\n    'runAsMenubarAppDesc': '隱藏 Dock 圖示，僅在選單列執行',\n    'seedConfig': '做種設定',\n    'seedKeep': '持續做種直到手動停止',\n    'seedRatio': '做種分享率',\n    'seedTime': '做種時間（分鐘）',\n    'setAsDefaultBtClient': '設為系統預設 BT 客戶端',\n    'taskDetail': '任務詳情',\n    'taskName': '任務名稱',\n    'taskUrl': '任務連結',\n    'downloadPath': '下載路徑',\n    'skipVerifyCert': '跳過憑證驗證',\n    'archives': '壓縮檔',\n    'autoExtract': '自動解壓縮壓縮包',\n    'archivePassword': '壓縮包密碼',\n    'archivePasswordHint': '如無密碼請留空',\n    'deleteAfterExtract': '解壓後刪除壓縮檔',\n    'extracting': '正在解壓',\n    'extractDone': '解壓完成',\n    'extractError': '解壓失敗',\n    'waitingParts': '等待分卷',\n    'name': '名稱',\n    'size': '大小',\n    'unknown': '未知',\n    'fileSelectedCount': '文件數：',\n    'fileSelectedSize': '大小：',\n    'httpHeaderName': '標頭名稱',\n    'httpHeaderValue': '標頭值',\n    'insertPlaceholder': '插入佔位符',\n    'placeholderYear': '當前年份',\n    'placeholderMonth': '當前月份 (01-12)',\n    'placeholderDay': '當前日期 (01-31)',\n    'placeholderDate': '完整日期 (年-月-日)',\n    'example': '例如 @value',\n    'downloadCategories': '下載目錄分類',\n    'categoryMusic': '音樂',\n    'categoryVideo': '影片',\n    'categoryDocument': '文件',\n    'categoryProgram': '程式',\n    'categoryName': '分類名稱',\n    'categoryPath': '分類路徑',\n    'builtInCategory': '內建分類無法刪除',\n    'selectCategory': '選擇分類',\n    'ed2k': 'ED2K',\n    'ed2kTcpPort': 'TCP 監聽連接埠',\n    'ed2kUdpPort': 'UDP 監聽連接埠',\n    'ed2kServerList': '伺服器位址',\n    'ed2kServerMet': 'Server.met 來源',\n    'ed2kNodesDat': 'nodes.dat 來源',\n    'ed2kAutoPort': '自動分配',\n    'ed2kOnePerLine': '每行填寫一條',\n    'ed2kServersHint': 'host:port，每行一條',\n    'ed2kServerMetHint': '請輸入 Server.met 位址或 ed2k 伺服器清單連結，每行一條',\n    'ed2kNodesDatHint': '請輸入 nodes.dat 本機路徑或 URL，每行一條',\n    'desktopNotification': '桌面通知',\n    'notificationTaskDone': '任務完成',\n    'notificationTaskError': '任務失敗',\n  }\n};\n"
  },
  {
    "path": "ui/flutter/lib/i18n/message.dart",
    "content": "import 'package:get/get.dart';\n\nimport 'langs/de_de.dart';\nimport 'langs/en_us.dart';\nimport 'langs/fa_ir.dart';\nimport 'langs/fr_fr.dart';\nimport 'langs/id_id.dart';\nimport 'langs/it_it.dart';\nimport 'langs/ja_jp.dart';\nimport 'langs/pl_pl.dart';\nimport 'langs/ru_ru.dart';\nimport 'langs/ta_ta.dart';\nimport 'langs/tr_tr.dart';\nimport 'langs/vi_vn.dart';\nimport 'langs/zh_cn.dart';\nimport 'langs/zh_tw.dart';\nimport 'langs/es_es.dart';\nimport 'langs/uk_ua.dart';\nimport 'langs/hu_hu.dart';\nimport 'langs/pt_br.dart';\nimport 'langs/ca_es.dart';\n\nfinal messages = _Messages();\n\nclass _Messages extends Translations {\n  // just include available locales here\n  @override\n  Map<String, Map<String, String>> get keys => {\n        ...zhCN,\n        ...enUS,\n        ...ruRU,\n        ...zhTW,\n        ...faIR,\n        ...jaJP,\n        ...viVN,\n        ...taTA,\n        ...trTR,\n        ...plPL,\n        ...itIT,\n        ...idID,\n        ...frFR,\n        ...esES,\n        ...ukUA,\n        ...huHU,\n        ...ptBR,\n        ...deDE,\n\t\t...caES\n      };\n}\n"
  },
  {
    "path": "ui/flutter/lib/icon/gopeed_icons.dart",
    "content": "/// Flutter icons Gopeed\n/// Copyright (C) 2024 by original authors @ fluttericon.com, fontello.com\n/// This font was generated by FlutterIcon.com, which is derived from Fontello.\n///\n/// To use this font, place it in your fonts/ directory and include the\n/// following in your pubspec.yaml\n///\n/// flutter:\n///   fonts:\n///    - family:  Gopeed\n///      fonts:\n///       - asset: fonts/Gopeed.ttf\n///\n///\n/// * Entypo, Copyright (C) 2012 by Daniel Bruce\n///         Author:    Daniel Bruce\n///         License:   SIL (http://scripts.sil.org/OFL)\n///         Homepage:  http://www.entypo.com\n/// * Zocial, Copyright (C) 2012 by Sam Collins\n///         Author:    Sam Collins\n///         License:   MIT (http://opensource.org/licenses/mit-license.php)\n///         Homepage:  http://zocial.smcllns.com/\n/// * Font Awesome 5, Copyright (C) 2016 by Dave Gandy\n///         Author:    Dave Gandy\n///         License:   SIL (https://github.com/FortAwesome/Font-Awesome/blob/master/LICENSE.txt)\n///         Homepage:  http://fortawesome.github.com/Font-Awesome/\n///\nimport 'package:flutter/widgets.dart';\n\nclass Gopeed {\n  Gopeed._();\n\n  static const _kFontFam = 'Gopeed';\n  static const String? _kFontPkg = null;\n\n  static const IconData install =\n      IconData(0xe800, fontFamily: _kFontFam, fontPackage: _kFontPkg);\n  static const IconData android =\n      IconData(0xe801, fontFamily: _kFontFam, fontPackage: _kFontPkg);\n  static const IconData cd =\n      IconData(0xe802, fontFamily: _kFontFam, fontPackage: _kFontPkg);\n  static const IconData folder_bt =\n      IconData(0xe803, fontFamily: _kFontFam, fontPackage: _kFontPkg);\n  static const IconData file_bt =\n      IconData(0xe804, fontFamily: _kFontFam, fontPackage: _kFontPkg);\n  static const IconData folder =\n      IconData(0xf07b, fontFamily: _kFontFam, fontPackage: _kFontPkg);\n  static const IconData sort =\n      IconData(0xf0dc, fontFamily: _kFontFam, fontPackage: _kFontPkg);\n  static const IconData html5 =\n      IconData(0xf13b, fontFamily: _kFontFam, fontPackage: _kFontPkg);\n  static const IconData file =\n      IconData(0xf15b, fontFamily: _kFontFam, fontPackage: _kFontPkg);\n  static const IconData file_alt =\n      IconData(0xf15c, fontFamily: _kFontFam, fontPackage: _kFontPkg);\n  static const IconData file_pdf =\n      IconData(0xf1c1, fontFamily: _kFontFam, fontPackage: _kFontPkg);\n  static const IconData file_word =\n      IconData(0xf1c2, fontFamily: _kFontFam, fontPackage: _kFontPkg);\n  static const IconData file_excel =\n      IconData(0xf1c3, fontFamily: _kFontFam, fontPackage: _kFontPkg);\n  static const IconData file_powerpoint =\n      IconData(0xf1c4, fontFamily: _kFontFam, fontPackage: _kFontPkg);\n  static const IconData file_image =\n      IconData(0xf1c5, fontFamily: _kFontFam, fontPackage: _kFontPkg);\n  static const IconData file_archive =\n      IconData(0xf1c6, fontFamily: _kFontFam, fontPackage: _kFontPkg);\n  static const IconData file_audio =\n      IconData(0xf1c7, fontFamily: _kFontFam, fontPackage: _kFontPkg);\n  static const IconData file_video =\n      IconData(0xf1c8, fontFamily: _kFontFam, fontPackage: _kFontPkg);\n  static const IconData file_code =\n      IconData(0xf1c9, fontFamily: _kFontFam, fontPackage: _kFontPkg);\n  static const IconData app_store_ios =\n      IconData(0xf370, fontFamily: _kFontFam, fontPackage: _kFontPkg);\n}\n"
  },
  {
    "path": "ui/flutter/lib/main.dart",
    "content": "import 'package:args/args.dart';\nimport 'package:flutter/foundation.dart';\nimport 'package:flutter/material.dart';\nimport 'package:flutter/services.dart';\nimport 'package:flutter_foreground_task/flutter_foreground_task.dart';\nimport 'package:get/get.dart';\nimport 'package:gopeed/util/analytics.dart';\nimport 'package:hotkey_manager/hotkey_manager.dart';\nimport 'package:window_manager/window_manager.dart';\n\nimport 'api/api.dart' as api;\nimport 'app/modules/app/controllers/app_controller.dart';\nimport 'app/modules/app/views/app_view.dart';\nimport 'core/libgopeed_boot.dart';\nimport 'database/database.dart';\nimport 'i18n/message.dart';\nimport 'util/browser_extension_host/browser_extension_host.dart';\nimport 'util/locale_manager.dart';\nimport 'util/log_util.dart';\nimport 'util/package_info.dart';\nimport 'util/scheme_register/scheme_register.dart';\nimport 'util/updater.dart';\nimport 'util/util.dart';\n\nclass StartupArgs {\n  static const flagHidden = \"hidden\";\n\n  /// Command line --hidden flag (for auto-start)\n  bool hiddenFromArgs = false;\n\n  StartupArgs._();\n\n  /// Parse from command line arguments only\n  static StartupArgs parse(List<String> arguments) {\n    final args = StartupArgs._();\n    try {\n      final parser = ArgParser()..addFlag(flagHidden);\n      final results = parser.parse(arguments);\n      args.hiddenFromArgs = results.flag(flagHidden);\n    } catch (e) {\n      // ignore parse errors\n    }\n    return args;\n  }\n}\n\nvoid main(List<String> arguments) async {\n  WidgetsFlutterBinding.ensureInitialized();\n\n  final args = StartupArgs.parse(arguments);\n\n  await init(args);\n  onStart();\n\n  runApp(const AppView());\n}\n\nFuture<void> init(StartupArgs args) async {\n  // Note: WidgetsFlutterBinding.ensureInitialized() is already called in main()\n  if (Util.isMobile()) {\n    FlutterForegroundTask.initCommunicationPort();\n  }\n  await Util.initStorageDir();\n  await Database.instance.init();\n  if (Util.isDesktop()) {\n    await windowManager.ensureInitialized();\n    final windowState = Database.instance.getWindowState();\n\n    // Check if menubar mode is enabled (only for macOS)\n    final runAsMenubarApp =\n        Util.isMacos() && Database.instance.getRunAsMenubarApp();\n\n    final windowOptions = WindowOptions(\n      size: Size(windowState?.width ?? 800, windowState?.height ?? 600),\n      center: true,\n      skipTaskbar: runAsMenubarApp,\n    );\n    await windowManager.waitUntilReadyToShow(windowOptions, () async {\n      await windowManager.setPreventClose(true);\n    });\n\n    // Register Cmd+W hotkey on macOS to close window\n    if (Util.isMacos()) {\n      await hotKeyManager.unregisterAll();\n      HotKey hotKey = HotKey(\n        key: PhysicalKeyboardKey.keyW,\n        modifiers: [HotKeyModifier.meta],\n        scope: HotKeyScope.inapp,\n      );\n      await hotKeyManager.register(\n        hotKey,\n        keyDownHandler: (hotKey) {\n          windowManager.hide();\n        },\n      );\n    }\n  }\n\n  initLogger();\n\n  try {\n    await initPackageInfo();\n  } catch (e) {\n    logger.e(\"init package info fail\", e);\n  }\n\n  final controller =\n      Get.put(AppController(hiddenFromArgs: args.hiddenFromArgs));\n  try {\n    await controller.loadStartConfig();\n    final startCfg = controller.startConfig.value;\n    controller.runningPort.value = await LibgopeedBoot.instance.start(startCfg);\n    api.init(startCfg.network, controller.runningAddress(), startCfg.apiToken);\n  } catch (e) {\n    logger.e(\"libgopeed init fail\", e);\n  }\n\n  try {\n    await controller.loadDownloaderConfig();\n  } catch (e) {\n    logger.e(\"load config fail\", e);\n  }\n\n  // Auto-start incomplete tasks if enabled\n  if (controller.downloaderConfig.value.extra.autoStartTasks) {\n    try {\n      await api.continueAllTasks(null);\n      logger.i(\"auto-start tasks completed\");\n    } catch (e) {\n      logger.w(\"auto-start tasks fail\", e);\n    }\n  }\n\n  () async {\n    if (Util.isDesktop()) {\n      try {\n        registerUrlScheme(\"gopeed\");\n        if (controller.downloaderConfig.value.extra.defaultBtClient) {\n          registerDefaultTorrentClient();\n        }\n      } catch (e) {\n        logger.e(\"register scheme fail\", e);\n      }\n\n      try {\n        await installHost();\n      } catch (e) {\n        logger.e(\"browser extension host binary install fail\", e);\n      }\n      for (final browser in Browser.values) {\n        try {\n          await installManifest(browser);\n        } catch (e) {\n          logger.e(\n              \"browser [${browser.name}] extension host integration fail\", e);\n        }\n      }\n\n      try {\n        await installUpdater();\n      } catch (e) {\n        logger.e(\"updater install fail\", e);\n      }\n    }\n  }();\n}\n\nFuture<void> onStart() async {\n  // if is debug mode, check language message is complete,change debug locale to your comfortable language if you want\n  if (kDebugMode) {\n    final debugLang = getLocaleKey(debugLocale);\n    final fullMessages = messages.keys[debugLang];\n    messages.keys.keys.where((e) => e != debugLang).forEach((lang) {\n      final langMessages = messages.keys[lang];\n      if (langMessages == null) {\n        logger.w(\"missing language: $lang\");\n        return;\n      }\n      final missingKeys =\n          fullMessages!.keys.where((key) => langMessages[key] == null);\n      if (missingKeys.isNotEmpty) {\n        logger.w(\"missing language: $lang, keys: $missingKeys\");\n      }\n    });\n  }\n\n  if (Config.isConfigured && Database.instance.getAnalyticsEnabled()) {\n    try {\n      await Analytics.instance.init();\n      await Analytics.instance.logAppOpen();\n    } catch (e) {\n      logger.w(\"GA4 init failed: $e\");\n    }\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/theme/theme.dart",
    "content": "import 'package:flutter/material.dart';\n\nclass GopeedTheme {\n  static const _gopeedreenPrimaryValue = 0xFF79C476;\n  static const _gopeedreen =\n      MaterialColor(_gopeedreenPrimaryValue, <int, Color>{\n    50: Color(0xFFEFF8EF),\n    100: Color(0xFFD7EDD6),\n    200: Color(0xFFBCE2BB),\n    300: Color(0xFFA1D69F),\n    400: Color(0xFF8DCD8B),\n    500: Color(_gopeedreenPrimaryValue),\n    600: Color(0xFF71BE6E),\n    700: Color(0xFF66B663),\n    800: Color(0xFF5CAF59),\n    900: Color(0xFF49A246),\n  });\n\n  static const _gopeedreenAccentValue = 0xFFC9FFC7;\n  static const _gopeedreenAccent =\n      MaterialColor(_gopeedreenAccentValue, <int, Color>{\n    100: Color(0xFFFAFFFA),\n    200: Color(_gopeedreenAccentValue),\n    400: Color(0xFF97FF94),\n    700: Color(0xFF7FFF7A),\n  });\n\n  static final _light = ThemeData(\n      useMaterial3: false,\n      brightness: Brightness.light,\n      primarySwatch: _gopeedreen);\n  static final light = _light.copyWith(\n      colorScheme: _light.colorScheme.copyWith(secondary: _gopeedreenAccent));\n\n  static final _dark = ThemeData(\n      useMaterial3: false,\n      brightness: Brightness.dark,\n      primarySwatch: _gopeedreen);\n  static final dark = _dark.copyWith(\n      colorScheme: _dark.colorScheme.copyWith(secondary: _gopeedreenAccent));\n}\n"
  },
  {
    "path": "ui/flutter/lib/util/analytics.dart",
    "content": "import 'dart:io';\n\nimport 'package:device_info_plus/device_info_plus.dart';\nimport 'package:dio/dio.dart';\nimport 'package:flutter/foundation.dart';\n\nimport 'package:gopeed/database/database.dart';\nimport 'package:gopeed/util/package_info.dart';\n\nimport 'log_util.dart';\n\n/// GA4 Measurement Protocol configuration from dart-define\nclass Config {\n  static const String measurementId =\n      String.fromEnvironment('GA4_MEASUREMENT_ID');\n  static const String apiSecret = String.fromEnvironment('GA4_API_SECRET');\n\n  static bool get isConfigured =>\n      measurementId.isNotEmpty && apiSecret.isNotEmpty;\n}\n\n/// GA4 Measurement Protocol Analytics\nclass Analytics {\n  static final Analytics _instance = Analytics._internal();\n  static Analytics get instance => _instance;\n\n  Analytics._internal();\n\n  late String _clientId;\n  late int _sessionId;\n  late Dio _dio;\n  bool _initialized = false;\n\n  Future<void> init() async {\n    if (_initialized) return;\n    if (!Config.isConfigured) return;\n\n    _clientId = await _getDeviceId();\n    _dio = Dio(BaseOptions(\n      connectTimeout: const Duration(seconds: 10),\n      receiveTimeout: const Duration(seconds: 10),\n    ));\n    _sessionId = DateTime.now().millisecondsSinceEpoch ~/ 1000;\n    _initialized = true;\n  }\n\n  Future<String> _getDeviceId() async {\n    final deviceInfo = DeviceInfoPlugin();\n    String? deviceId;\n    try {\n      if (kIsWeb) {\n        deviceId = null;\n      } else if (Platform.isAndroid) {\n        final androidInfo = await deviceInfo.androidInfo;\n        deviceId = androidInfo.id;\n      } else if (Platform.isIOS) {\n        final iosInfo = await deviceInfo.iosInfo;\n        deviceId = iosInfo.identifierForVendor;\n      } else if (Platform.isMacOS) {\n        final macInfo = await deviceInfo.macOsInfo;\n        deviceId = macInfo.systemGUID;\n      } else if (Platform.isWindows) {\n        final windowsInfo = await deviceInfo.windowsInfo;\n        deviceId = windowsInfo.deviceId;\n      } else if (Platform.isLinux) {\n        final linuxInfo = await deviceInfo.linuxInfo;\n        deviceId = linuxInfo.machineId;\n      }\n    } catch (e) {\n      debugPrint('GA4Analytics: Failed to get device id: $e');\n    }\n\n    // Fallback to persisted client id\n    if (deviceId == null || deviceId.isEmpty) {\n      deviceId = Database.instance.getAnalyticsClientId();\n      if (deviceId == null || deviceId.isEmpty) {\n        final random = DateTime.now().microsecondsSinceEpoch % 2147483647;\n        final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;\n        deviceId = '$random.$timestamp';\n        Database.instance.saveAnalyticsClientId(deviceId);\n      }\n    }\n    return deviceId;\n  }\n\n  String _getPlatform() {\n    if (kIsWeb) return 'web';\n    if (Platform.isAndroid) return 'android';\n    if (Platform.isIOS) return 'ios';\n    if (Platform.isMacOS) return 'macos';\n    if (Platform.isWindows) return 'windows';\n    if (Platform.isLinux) return 'linux';\n    return 'unknown';\n  }\n\n  Future<void> logAppOpen() async {\n    await logEvent('app_open');\n  }\n\n  Future<void> logEvent(String name, [Map<String, dynamic>? params]) async {\n    if (!_initialized) {\n      return;\n    }\n\n    final eventParams = <String, dynamic>{\n      'session_id': _sessionId.toString(),\n      'engagement_time_msec': 100,\n      'platform': _getPlatform(),\n      'app_version': packageInfo.version,\n      ...?params,\n    };\n\n    final payload = {\n      'client_id': _clientId,\n      'timestamp_micros': DateTime.now().microsecondsSinceEpoch,\n      'events': [\n        {\n          'name': name,\n          'params': eventParams,\n        }\n      ],\n    };\n\n    try {\n      await _dio.post(\n        'https://www.google-analytics.com/mp/collect',\n        queryParameters: {\n          'measurement_id': Config.measurementId,\n          'api_secret': Config.apiSecret,\n        },\n        data: payload,\n      );\n    } catch (e) {\n      logger.w('GA4Analytics: Failed to send event \"$name\": $e');\n    }\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/util/arch/arch.dart",
    "content": "import 'arch_stub.dart'\n    if (dart.library.io) 'entry/arch_native.dart'\n    if (dart.library.html) 'entry/arch_web.dart';\n\n// Copy from pkg/sky_engine/lib/ffi/abi.dart\nenum Architecture {\n  arm,\n  arm64,\n  ia32,\n  x64,\n  riscv32,\n  riscv64,\n}\n\nArchitecture getArch() => doGetArch();\n"
  },
  {
    "path": "ui/flutter/lib/util/arch/arch_stub.dart",
    "content": "import 'arch.dart';\n\nArchitecture doGetArch() => throw UnimplementedError();\n"
  },
  {
    "path": "ui/flutter/lib/util/arch/entry/arch_native.dart",
    "content": "// ignore: avoid_web_libraries_in_flutter\nimport 'dart:ffi';\n\nimport '../arch.dart';\n\nArchitecture doGetArch() {\n  final currentAbi = Abi.current().toString();\n  final archName = currentAbi.split(\"_\")[1];\n  final arch = Architecture.values.firstWhere(\n      (element) => element.name == archName,\n      orElse: () => Architecture.x64);\n  return arch;\n}\n"
  },
  {
    "path": "ui/flutter/lib/util/arch/entry/arch_web.dart",
    "content": "import '../arch.dart';\n\nArchitecture doGetArch() {\n  return Architecture.x64;\n}\n"
  },
  {
    "path": "ui/flutter/lib/util/browser_download/browser_download.dart",
    "content": "import 'browser_download_stub.dart'\n    if (dart.library.html) 'entry/browser_download_browser.dart';\n\nvoid download(String url, String name) => doDownload(url, name);\n"
  },
  {
    "path": "ui/flutter/lib/util/browser_download/browser_download_stub.dart",
    "content": "void doDownload(String url, String name) => throw UnimplementedError();\n"
  },
  {
    "path": "ui/flutter/lib/util/browser_download/entry/browser_download_browser.dart",
    "content": "// ignore: avoid_web_libraries_in_flutter\nimport 'dart:html' as html;\n\nvoid doDownload(String url, String name) {\n  final anchorElement = html.AnchorElement(href: url);\n  anchorElement.download = name;\n  anchorElement.target = '_blank';\n  anchorElement.click();\n}\n"
  },
  {
    "path": "ui/flutter/lib/util/browser_extension_host/browser_extension_host.dart",
    "content": "import 'browser_extension_host_stub.dart'\n    if (dart.library.io) 'entry/browser_extension_host_native.dart';\n\nenum Browser { chrome, edge, firefox }\n\n/// Install host binary for browser extension\nFuture<void> installHost() => doInstallHost();\n\n/// Check if specified browser is installed\nFuture<bool> checkBrowserInstalled(Browser browser) =>\n    doCheckBrowserInstalled(browser);\n\n/// Check if browser extension manifest is properly installed\nFuture<bool> checkManifestInstalled(Browser browser) =>\n    doCheckManifestInstalled(browser);\n\n/// Install browser extension manifest\nFuture<void> installManifest(Browser browser) => doInstallManifest(browser);\n"
  },
  {
    "path": "ui/flutter/lib/util/browser_extension_host/browser_extension_host_stub.dart",
    "content": "import 'browser_extension_host.dart';\n\nFuture<void> doInstallHost() => throw UnimplementedError();\nFuture<bool> doCheckBrowserInstalled(Browser browser) =>\n    throw UnimplementedError();\nFuture<bool> doCheckManifestInstalled(Browser browser) =>\n    throw UnimplementedError();\nFuture<void> doInstallManifest(Browser browser) => throw UnimplementedError();\n"
  },
  {
    "path": "ui/flutter/lib/util/browser_extension_host/entry/browser_extension_host_native.dart",
    "content": "import 'dart:convert';\nimport 'dart:io';\n\nimport 'package:path/path.dart' as path;\nimport 'package:win32_registry/win32_registry.dart';\n\nimport '../../util.dart';\nimport '../../win32.dart';\nimport '../browser_extension_host.dart';\n\nfinal _hostExecName = 'host${Platform.isWindows ? '.exe' : ''}';\n\nconst _hostName = 'com.gopeed.gopeed';\nconst _chromeExtensionId = 'mijpgljlfcapndmchhjffkpckknofcnd';\nconst _edgeExtensionId = 'dkajnckekendchdleoaenoophcobooce';\nconst _firefoxExtensionId = '{c5d69a8f-2ed0-46a7-afa4-b3a00dc58088}';\n\nList<String> get _debugExtensionIds {\n  final envValue = Platform.environment['GOPEED_DEBUG_EXTENSION_IDS'] ?? '';\n  if (envValue.isNotEmpty) {\n    return envValue.split(',').map((id) => id.trim()).toList();\n  }\n  return [];\n}\n\n// Windows NativeMessagingHosts registry constants\nconst _chromeNativeHostsKey = r'Software\\Google\\Chrome\\NativeMessagingHosts';\nconst _edgeNativeHostsKey = r'Software\\Microsoft\\Edge\\NativeMessagingHosts';\nconst _firefoxNativeHostsKey = r'Software\\Mozilla\\NativeMessagingHosts';\n\n/// Install host binary for browser extension\nFuture<void> doInstallHost() async {\n  final hostPath = await Util.homePathJoin(_hostExecName);\n  await Util.installAsset('assets/exec/$_hostExecName', hostPath,\n      executable: true);\n}\n\n/// Check if specified browser is installed\nFuture<bool> doCheckBrowserInstalled(Browser browser) async {\n  if (Platform.isWindows) {\n    switch (browser) {\n      case Browser.chrome:\n        return await _checkWindowsRegistry(\n                r'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe') ||\n            await _checkWindowsRegistry(\n                r'SOFTWARE\\WOW6432Node\\Google\\Chrome') ||\n            await _checkWindowsExecutable(browser);\n      case Browser.edge:\n        return await _checkWindowsRegistry(\n                r'SOFTWARE\\Microsoft\\Edge\\BLBeacon') ||\n            await _checkWindowsRegistry(\n                r'SOFTWARE\\WOW6432Node\\Microsoft\\Edge\\BLBeacon') ||\n            await _checkWindowsExecutable(browser);\n      case Browser.firefox:\n        return await _checkWindowsRegistry(r'SOFTWARE\\Mozilla\\Firefox') ||\n            await _checkWindowsRegistry(\n                r'SOFTWARE\\WOW6432Node\\Mozilla\\Firefox') ||\n            await _checkWindowsRegistry(r'SOFTWARE\\BrowserWorks\\Waterfox') ||\n            await _checkWindowsExecutable(browser);\n    }\n  } else {\n    return await _checkUnixExecutable(browser);\n  }\n}\n\n/// Check if browser extension manifest is properly installed\nFuture<bool> doCheckManifestInstalled(Browser browser) async {\n  if (await checkBrowserInstalled(browser) == false) return false;\n\n  final manifestPath = await _getManifestPath(browser);\n  if (manifestPath == null) return false;\n\n  if (Platform.isWindows) {\n    final regKey = _getWindowsRegistryKey(browser);\n    if (!checkRegistry('$regKey\\\\$_hostName', '', manifestPath)) {\n      return false;\n    }\n  }\n\n  if (!await File(manifestPath).exists()) {\n    return false;\n  }\n\n  final existingContent = await File(manifestPath).readAsString();\n  final expectedContent = await _getManifestContent(browser);\n  return existingContent == expectedContent;\n}\n\n/// Install browser extension manifest\nFuture<void> doInstallManifest(Browser browser) async {\n  if (await checkBrowserInstalled(browser) == false) return;\n  if (await checkManifestInstalled(browser)) return;\n\n  final manifestPath = (await _getManifestPath(browser))!;\n  final manifestContent = await _getManifestContent(browser);\n  final manifestDir = path.dirname(manifestPath);\n  await Directory(manifestDir).create(recursive: true);\n  await File(manifestPath).writeAsString(manifestContent);\n\n  if (Platform.isWindows) {\n    final regKey = _getWindowsRegistryKey(browser);\n    upsertRegistry('$regKey\\\\$_hostName', '', manifestPath);\n  }\n}\n\nFuture<bool> _checkWindowsExecutable(Browser browser) async {\n  final paths = _getWindowsExecutablePaths(browser);\n  for (var execPath in paths) {\n    if (await File(execPath).exists()) {\n      return true;\n    }\n  }\n  return false;\n}\n\nList<String> _getWindowsExecutablePaths(Browser browser) {\n  final programFiles = Platform.environment['PROGRAMFILES'];\n  final programFilesX86 = Platform.environment['PROGRAMFILES(X86)'];\n  final localAppData = Platform.environment['LOCALAPPDATA'];\n\n  switch (browser) {\n    case Browser.chrome:\n      return [\n        if (programFiles != null)\n          path.join(\n              programFiles, 'Google', 'Chrome', 'Application', 'chrome.exe'),\n        if (programFilesX86 != null)\n          path.join(\n              programFilesX86, 'Google', 'Chrome', 'Application', 'chrome.exe'),\n        if (localAppData != null)\n          path.join(\n              localAppData, 'Google', 'Chrome', 'Application', 'chrome.exe'),\n      ];\n    case Browser.edge:\n      return [\n        if (programFiles != null)\n          path.join(\n              programFiles, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),\n        if (programFilesX86 != null)\n          path.join(programFilesX86, 'Microsoft', 'Edge', 'Application',\n              'msedge.exe'),\n        if (localAppData != null)\n          path.join(\n              localAppData, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),\n      ];\n    case Browser.firefox:\n      return [\n        if (programFiles != null)\n          path.join(programFiles, 'Mozilla Firefox', 'firefox.exe'),\n        if (programFilesX86 != null)\n          path.join(programFilesX86, 'Mozilla Firefox', 'firefox.exe'),\n        if (programFiles != null)\n          path.join(programFiles, 'Waterfox', 'waterfox.exe'),\n        if (programFilesX86 != null)\n          path.join(programFilesX86, 'Waterfox', 'waterfox.exe'),\n      ];\n  }\n}\n\nFuture<bool> _checkUnixExecutable(Browser browser) async {\n  final paths = _getUnixExecutablePaths(browser);\n  for (var execPath in paths) {\n    execPath = execPath.replaceAll('~', Platform.environment['HOME'] ?? '');\n    if (await Directory(execPath).exists() || await File(execPath).exists()) {\n      return true;\n    }\n  }\n  return false;\n}\n\nList<String> _getUnixExecutablePaths(Browser browser) {\n  if (Platform.isMacOS) {\n    switch (browser) {\n      case Browser.chrome:\n        return [\n          '/Applications/Google Chrome.app',\n          '~/Applications/Google Chrome.app',\n          '/Users/${Platform.environment['USER']}/Applications/Google Chrome.app'\n        ];\n      case Browser.edge:\n        return [\n          '/Applications/Microsoft Edge.app',\n          '~/Applications/Microsoft Edge.app',\n          '/Users/${Platform.environment['USER']}/Applications/Microsoft Edge.app'\n        ];\n      case Browser.firefox:\n        return [\n          '/Applications/Firefox.app',\n          '~/Applications/Firefox.app',\n          '/Users/${Platform.environment['USER']}/Applications/Firefox.app',\n          '/Applications/Waterfox.app',\n          '~/Applications/Waterfox.app',\n          '/Users/${Platform.environment['USER']}/Applications/Waterfox.app'\n        ];\n    }\n  } else {\n    switch (browser) {\n      case Browser.chrome:\n        return [\n          '/usr/bin/google-chrome',\n          '/usr/bin/google-chrome-stable',\n          '/usr/bin/chrome',\n          '/snap/bin/google-chrome',\n          '/opt/google/chrome/google-chrome'\n        ];\n      case Browser.edge:\n        return [\n          '/usr/bin/microsoft-edge',\n          '/usr/bin/microsoft-edge-stable',\n          '/snap/bin/microsoft-edge',\n          '/opt/microsoft/msedge/msedge'\n        ];\n      case Browser.firefox:\n        return [\n          '/usr/bin/firefox',\n          '/snap/bin/firefox',\n          '/usr/lib/firefox/firefox',\n          '/opt/firefox/firefox',\n          '/usr/bin/waterfox',\n          '/snap/bin/waterfox',\n          '/usr/lib/waterfox/waterfox',\n          '/opt/waterfox/waterfox'\n        ];\n    }\n  }\n}\n\nFuture<String?> _getManifestPath(Browser browser) async {\n  final manifestName =\n      browser == Browser.firefox ? '$_hostName.moz.json' : '$_hostName.json';\n  if (Platform.isWindows) {\n    return await Util.homePathJoin(manifestName);\n  }\n\n  final home =\n      Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'];\n  if (home == null) return null;\n\n  if (Platform.isMacOS) {\n    switch (browser) {\n      case Browser.chrome:\n        return path.join(home, 'Library', 'Application Support', 'Google',\n            'Chrome', 'NativeMessagingHosts', manifestName);\n      case Browser.edge:\n        return path.join(home, 'Library', 'Application Support',\n            'Microsoft Edge', 'NativeMessagingHosts', manifestName);\n      case Browser.firefox:\n        return path.join(home, 'Library', 'Application Support', 'Mozilla',\n            'NativeMessagingHosts', manifestName);\n    }\n  } else if (Platform.isLinux) {\n    switch (browser) {\n      case Browser.chrome:\n        return path.join(home, '.config', 'google-chrome',\n            'NativeMessagingHosts', manifestName);\n      case Browser.edge:\n        return path.join(home, '.config', 'microsoft-edge',\n            'NativeMessagingHosts', manifestName);\n      case Browser.firefox:\n        return path.join(\n            home, '.mozilla', 'native-messaging-hosts', manifestName);\n    }\n  }\n  return null;\n}\n\nFuture<bool> _checkWindowsRegistry(String keyPath) async {\n  try {\n    final key = Registry.openPath(RegistryHive.localMachine, path: keyPath);\n    key.close();\n    return true;\n  } catch (e) {\n    return false;\n  }\n}\n\nFuture<String> _getManifestContent(Browser browser) async {\n  final hostPath = await Util.homePathJoin(_hostExecName);\n  final manifest = {\n    'name': _hostName,\n    'description': 'Gopeed browser extension host',\n    'path': hostPath,\n    'type': 'stdio',\n    if (browser != Browser.firefox)\n      'allowed_origins': [\n        'chrome-extension://$_chromeExtensionId/',\n        'chrome-extension://$_edgeExtensionId/',\n        ..._debugExtensionIds.map((id) => 'chrome-extension://$id/'),\n      ],\n    if (browser == Browser.firefox) 'allowed_extensions': [_firefoxExtensionId],\n  };\n  return const JsonEncoder.withIndent('  ').convert(manifest);\n}\n\nString _getWindowsRegistryKey(Browser browser) {\n  switch (browser) {\n    case Browser.chrome:\n      return _chromeNativeHostsKey;\n    case Browser.edge:\n      return _edgeNativeHostsKey;\n    case Browser.firefox:\n      return _firefoxNativeHostsKey;\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/util/extensions.dart",
    "content": "import 'dart:core';\nimport 'package:checkable_treeview/checkable_treeview.dart';\nimport 'package:flutter/material.dart';\nimport '../api/model/resource.dart';\nimport '../app/views/file_icon.dart';\nimport 'util.dart';\n\nextension ListFileInfoExtension on List<FileInfo> {\n  List<TreeNode<int>> toTreeNodes() {\n    final List<TreeNode<int>> rootNodes = [];\n    final Map<String, TreeNode<int>> dirNodes = {};\n    var nodeIndex = 0;\n\n    for (var i = 0; i < length; i++) {\n      final file = this[i];\n      final parts = file.path.split('/');\n      String currentPath = '';\n      TreeNode<int>? parentNode;\n\n      // Create or get directory nodes\n      for (final part in parts) {\n        if (part.isEmpty) continue;\n\n        currentPath += '/$part';\n        if (!dirNodes.containsKey(currentPath)) {\n          final node = TreeNode<int>(\n            value: nodeIndex++,\n            label: Row(\n              mainAxisAlignment: MainAxisAlignment.spaceBetween,\n              children: [\n                Text(file.name),\n                Text(Util.fmtByte(file.size)),\n              ],\n            ),\n            icon: Icon(\n              fileIcon(part, isFolder: true),\n              size: 18,\n            ),\n            children: [],\n          );\n          dirNodes[currentPath] = node;\n\n          if (parentNode == null) {\n            rootNodes.add(node);\n          } else {\n            parentNode.children.add(node);\n          }\n        }\n        parentNode = dirNodes[currentPath];\n      }\n\n      // Create file node using file.name\n      final fileNode = TreeNode<int>(\n        value: nodeIndex++,\n        label: Row(\n          mainAxisAlignment: MainAxisAlignment.spaceBetween,\n          children: [\n            Text(file.name),\n            Text(Util.fmtByte(file.size)),\n          ],\n        ),\n        icon: Icon(fileIcon(file.name, isFolder: false), size: 18),\n        children: [],\n      );\n\n      // Add file node to parent or root\n      if (parentNode != null) {\n        parentNode.children.add(fileNode);\n      } else {\n        rootNodes.add(fileNode);\n      }\n    }\n\n    return rootNodes;\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/util/file_explorer.dart",
    "content": "import 'dart:io';\nimport 'package:open_dir/open_dir.dart';\nimport 'package:path/path.dart' as path;\nimport 'package:url_launcher/url_launcher_string.dart';\n\nclass FileExplorer {\n  static Future<void> openAndSelectFile(String filePath) async {\n    if (await FileSystemEntity.isFile(filePath)) {\n      _openFile(filePath);\n    } else if (await FileSystemEntity.isDirectory(filePath)) {\n      await _openDirectory(filePath);\n    } else {\n      // If file does not exist, open its parent directory\n      final parentPath = path.dirname(filePath);\n      if (await FileSystemEntity.isDirectory(parentPath)) {\n        await _openDirectory(parentPath);\n      }\n    }\n  }\n\n  static Future<void> _openDirectory(String directoryPath) async {\n    if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) {\n      await OpenDir().openNativeDir(path: directoryPath);\n    } else {\n      await launchUrlString(\"file://$directoryPath\");\n    }\n  }\n\n  static Future<void> _openFile(String filePath) async {\n    if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) {\n      final fileName = path.basename(filePath);\n      final parentPath = path.dirname(filePath);\n      await OpenDir()\n          .openNativeDir(path: parentPath, highlightedFileName: fileName);\n    }\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/util/github_mirror.dart",
    "content": "import 'package:dio/dio.dart';\nimport 'package:get/get.dart';\n\nimport '../api/model/downloader_config.dart';\nimport '../app/modules/app/controllers/app_controller.dart';\n\nenum MirrorType {\n  githubSource,\n  githubRelease,\n}\n\n/// Get the configured mirrors\nList<GithubMirror> _getConfiguredMirrors() {\n  try {\n    final appController = Get.find<AppController>();\n    final config = appController.downloaderConfig.value.extra.githubMirror;\n\n    if (!config.enabled) {\n      return [];\n    }\n\n    // Return configured mirrors (filtering not deleted ones)\n    return config.mirrors.where((m) => !m.isDeleted).toList();\n  } catch (e) {\n    // Fallback to empty list if controller not found\n    return [];\n  }\n}\n\n/// Auto detect the best mirror for the given [rawUrl] and [type]\nFuture<String> githubAutoMirror(String rawUrl, MirrorType type) async {\n  final mirrorUrls = githubMirrorUrls(rawUrl, type);\n\n  // If no mirrors, return original URL\n  if (mirrorUrls.isEmpty) {\n    return rawUrl;\n  }\n\n  // Ping all mirrors and get the fastest one\n  final pingResult = await Future.wait(mirrorUrls.map((e) async {\n    final client = Dio()\n      ..options.sendTimeout = const Duration(seconds: 3)\n      ..options.connectTimeout = const Duration(seconds: 3);\n    var time = DateTime.now().millisecondsSinceEpoch;\n    try {\n      final response = await client.head(e);\n      if (response.statusCode == 200) {\n        time = DateTime.now().millisecondsSinceEpoch - time;\n        return (e, time);\n      }\n    } catch (e) {\n      // ignore\n    } finally {\n      client.close();\n    }\n    return (e, -1);\n  }));\n\n  var list = pingResult.where((e) => e.$2 != -1).toList()\n    ..sort((a, b) => a.$2.compareTo(b.$2));\n  if (list.isNotEmpty) {\n    return list.first.$1;\n  }\n\n  return rawUrl;\n}\n\nList<String> githubMirrorUrls(String rawUrl, MirrorType type) {\n  final mirrors = _getConfiguredMirrors();\n\n  final ret = <String>[];\n  for (final mirror in mirrors) {\n    String? mirrorUrl;\n\n    if (mirror.type == GithubMirrorType.jsdelivr) {\n      // jsdelivr only supports source files\n      if (type != MirrorType.githubSource) {\n        continue;\n      }\n\n      // Transform: https://raw.githubusercontent.com/user/repo/master/path\n      // To: https://fastly.jsdelivr.net/gh/user/repo/path\n      final jsDelivrPattern = RegExp(\n          r'.*raw\\.githubusercontent\\.com(/[^/]+)(/[^/]+)/(?:master|main)(/.*)');\n      final match = jsDelivrPattern.firstMatch(rawUrl);\n      if (match != null) {\n        final user = match.group(1);\n        final repo = match.group(2);\n        final path = match.group(3);\n        mirrorUrl = '${mirror.url}$user$repo$path';\n      }\n    } else if (mirror.type == GithubMirrorType.ghProxy) {\n      // gh-proxy supports both source and release\n      // Simply prepend the mirror URL to the original URL\n      mirrorUrl = '${mirror.url}/$rawUrl';\n    }\n\n    if (mirrorUrl != null) {\n      ret.add(mirrorUrl);\n    }\n  }\n\n  return ret;\n}\n"
  },
  {
    "path": "ui/flutter/lib/util/input_formatter.dart",
    "content": "import 'package:flutter/services.dart';\n\n/// A [TextInputFormatter] that restricts input to a numerical range between [min] and [max].\nclass NumericalRangeFormatter extends TextInputFormatter {\n  final int min;\n  final int max;\n\n  NumericalRangeFormatter({required this.min, required this.max});\n\n  @override\n  TextEditingValue formatEditUpdate(\n    TextEditingValue oldValue,\n    TextEditingValue newValue,\n  ) {\n    if (newValue.text.isEmpty) {\n      return newValue;\n    }\n    var intVal = int.tryParse(newValue.text);\n    if (intVal == null) {\n      return oldValue;\n    }\n    if (intVal < min) {\n      return newValue.copyWith(text: min.toString());\n    } else if (intVal > max) {\n      return oldValue.copyWith(text: max.toString());\n    } else {\n      return newValue;\n    }\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/util/locale_manager.dart",
    "content": "import 'package:flutter/material.dart';\n\nLocale toLocale(String key) {\n  final arr = key.split('_');\n  return Locale(arr[0], arr[1]);\n}\n\nString getLocaleKey(Locale locale) {\n  return '${locale.languageCode}_${locale.countryCode}';\n}\n\nconst debugLocale = Locale('zh', 'CN');\nconst fallbackLocale = Locale('en', 'US');\n"
  },
  {
    "path": "ui/flutter/lib/util/log_util.dart",
    "content": "import 'dart:io';\nimport 'package:flutter/foundation.dart';\nimport 'package:logger/logger.dart';\nimport 'package:path/path.dart' as path;\nimport 'util.dart';\n\nlate final Logger logger;\n\ninitLogger() {\n  // if is debug mode, don't log to file\n  logger = Logger(\n    filter: ProductionFilter(),\n    printer: SimplePrinter(printTime: true, colors: false),\n    output: _buildOutput(),\n  );\n}\n\nString logsDir() {\n  return path.join(Util.getStorageDir(), 'logs');\n}\n\n_buildOutput() {\n  // if is debug mode, don't log to file\n  if (!kDebugMode && Util.isDesktop()) {\n    final logDirPath = logsDir();\n    var logDir = Directory(logsDir());\n    if (!logDir.existsSync()) {\n      logDir.createSync();\n    }\n    return FileOutput(file: File('$logDirPath/client.log'));\n  }\n  return null;\n}\n"
  },
  {
    "path": "ui/flutter/lib/util/message.dart",
    "content": "import 'package:get/get.dart';\n\nimport '../api/model/result.dart';\n\nvoid showErrorMessage(msg) {\n  final title = 'error'.tr;\n  if (msg is Result) {\n    Get.snackbar(title, msg.msg!);\n    return;\n  }\n  if (msg is Exception) {\n    final message = (msg as dynamic).message;\n    if (message is Result) {\n      Get.snackbar(title, ((msg as dynamic).message as Result).msg!);\n      return;\n    }\n    if (message is String) {\n      Get.snackbar(title, message);\n      return;\n    }\n  }\n  Get.snackbar(title, msg.toString());\n}\n\nvar _showMessageFlag = true;\n\nvoid showMessage(title, msg) {\n  if (_showMessageFlag) {\n    _showMessageFlag = false;\n    Get.snackbar(title, msg);\n    Future.delayed(const Duration(seconds: 3), () {\n      _showMessageFlag = true;\n    });\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/util/package_info.dart",
    "content": "import 'package:package_info_plus/package_info_plus.dart';\n\nlate PackageInfo packageInfo;\n\nFuture<void> initPackageInfo() async {\n  packageInfo = await PackageInfo.fromPlatform();\n}\n"
  },
  {
    "path": "ui/flutter/lib/util/scheme_register/entry/scheme_register_native.dart",
    "content": "import 'dart:io';\n\nimport 'package:win32_registry/win32_registry.dart';\n\nimport '../../util.dart';\nimport '../../win32.dart';\n\ndoRegisterUrlScheme(String scheme) {\n  if (Util.isWindows()) {\n    final schemeKey = 'Software\\\\Classes\\\\$scheme';\n    final appPath = Platform.resolvedExecutable;\n\n    upsertRegistry(\n      schemeKey,\n      'URL Protocol',\n      '',\n    );\n    upsertRegistry(\n      '$schemeKey\\\\shell\\\\open\\\\command',\n      '',\n      '\"$appPath\" \"%1\"',\n    );\n  }\n}\n\ndoUnregisterUrlScheme(String scheme) {\n  if (Util.isWindows()) {\n    Registry.currentUser\n        .deleteKey('Software\\\\Classes\\\\$scheme', recursive: true);\n  }\n}\n\nconst _torrentRegKey = 'Software\\\\Classes\\\\.torrent';\nconst _torrentRegValue = 'Gopeed_torrent';\nconst _torrentAppRegKey = 'Software\\\\Classes\\\\$_torrentRegValue';\n\n/// Register as the system's default torrent client\n/// 1. Register the scheme \"magnet\"\n/// 2. Register the file type \".torrent\"\ndoRegisterDefaultTorrentClient() {\n  if (Util.isWindows()) {\n    doRegisterUrlScheme(\"magnet\");\n\n    final appPath = Platform.resolvedExecutable;\n    final iconPath =\n        '${File(appPath).parent.path}\\\\data\\\\flutter_assets\\\\assets\\\\tray_icon\\\\icon.ico';\n    upsertRegistry(\n      _torrentRegKey,\n      '',\n      _torrentRegValue,\n    );\n    upsertRegistry(\n      _torrentAppRegKey,\n      '',\n      'Torrent file',\n    );\n    upsertRegistry(\n      '$_torrentAppRegKey\\\\DefaultIcon',\n      '',\n      iconPath,\n    );\n    upsertRegistry(\n      '$_torrentAppRegKey\\\\shell\\\\open\\\\command',\n      '',\n      '\"$appPath\" \"file:///%1\"',\n    );\n  }\n}\n\ndoUnregisterDefaultTorrentClient() {\n  if (Util.isWindows()) {\n    doUnregisterUrlScheme(\"magnet\");\n\n    Registry.currentUser.deleteKey(_torrentRegKey, recursive: true);\n    Registry.currentUser.deleteKey(_torrentAppRegKey, recursive: true);\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/util/scheme_register/scheme_register.dart",
    "content": "import 'scheme_register_stub.dart'\n    if (dart.library.io) 'entry/scheme_register_native.dart';\n\nregisterUrlScheme(String scheme) => doRegisterUrlScheme(scheme);\n\nunregisterUrlScheme(String scheme) => doUnregisterUrlScheme(scheme);\n\nregisterDefaultTorrentClient() => doRegisterDefaultTorrentClient();\n\nunregisterDefaultTorrentClient() => doUnregisterDefaultTorrentClient();\n"
  },
  {
    "path": "ui/flutter/lib/util/scheme_register/scheme_register_stub.dart",
    "content": "doRegisterUrlScheme(String scheme) => throw UnimplementedError();\n\ndoUnregisterUrlScheme(String scheme) => throw UnimplementedError();\n\ndoRegisterDefaultTorrentClient() => throw UnimplementedError();\n\ndoUnregisterDefaultTorrentClient() => throw UnimplementedError();\n"
  },
  {
    "path": "ui/flutter/lib/util/updater.dart",
    "content": "import 'dart:async';\nimport 'dart:convert';\nimport 'dart:io';\n\nimport 'package:dio/dio.dart';\nimport 'package:flutter/material.dart';\nimport 'package:get/get.dart';\nimport 'package:install_plugin/install_plugin.dart';\nimport 'package:path/path.dart' as path;\nimport 'package:path_provider/path_provider.dart';\nimport 'package:url_launcher/url_launcher.dart';\n\nimport '../api/api.dart';\nimport '../api/gopeed_site_api.dart';\nimport '../app/views/outlined_button_loading.dart';\nimport 'arch/arch.dart';\nimport 'github_mirror.dart';\nimport 'log_util.dart';\nimport 'message.dart';\nimport 'package_info.dart';\nimport 'util.dart';\n\nenum Channel {\n  windowsInstaller,\n  windowsPortable,\n  macosDmg,\n  linuxFlathub,\n  linuxSnap,\n  linuxDeb,\n  linuxAppImage,\n  androidApk,\n  iosIpa,\n  docker,\n}\n\nconst _channelEnv = String.fromEnvironment(\"UPDATE_CHANNEL\");\nfinal _channel =\n    Channel.values.where((e) => e.name == _channelEnv).firstOrNull ??\n        () {\n          if (Util.isWindows()) {\n            return Channel.windowsPortable;\n          } else if (Util.isMacos()) {\n            return Channel.macosDmg;\n          } else if (Util.isAndroid()) {\n            return Channel.androidApk;\n          } else if (Util.isIOS()) {\n            return Channel.iosIpa;\n          } else {\n            return null;\n          }\n        }();\nfinal _updaterBin = \"updater${Util.isWindows() ? \".exe\" : \"\"}\";\n\nFuture<void> installUpdater() async {\n  await Util.installAsset(\n      'assets/exec/$_updaterBin', await Util.homePathJoin(_updaterBin),\n      executable: true);\n}\n\nclass VersionInfo {\n  final String version;\n  final String changeLog;\n\n  VersionInfo(this.version, this.changeLog);\n}\n\nFuture<VersionInfo?> checkUpdate() async {\n  String? releaseDataStr;\n  try {\n    releaseDataStr = (await proxyRequest(\n            \"https://api.github.com/repos/GopeedLab/gopeed/releases/latest\"))\n        .data;\n  } catch (e) {\n    releaseDataStr = jsonEncode(await GopeedSiteApi.instance.getRelease());\n  }\n  if (releaseDataStr == null) {\n    return null;\n  }\n  final releaseData = jsonDecode(releaseDataStr);\n  final tagName = releaseData[\"tag_name\"];\n  if (tagName == null) {\n    return null;\n  }\n  final latestVersion = releaseData[\"tag_name\"].substring(1);\n\n  // compare version x.y.z to x.y.z\n  final currentVersion = packageInfo.version;\n  var isNewVersion = false;\n  if (latestVersion != currentVersion) {\n    final currentVersionList = currentVersion.split(\".\");\n    final latestVersionList = latestVersion.split(\".\");\n    for (var i = 0; i < currentVersionList.length; i++) {\n      if (int.parse(latestVersionList[i]) > int.parse(currentVersionList[i])) {\n        isNewVersion = true;\n        break;\n      }\n    }\n  }\n\n  if (!isNewVersion) {\n    return null;\n  }\n\n  return VersionInfo(latestVersion, releaseData[\"body\"]);\n}\n\nFuture<void> showUpdateDialog(\n    BuildContext context, VersionInfo versionInfo) async {\n  final fullChangeLog = versionInfo.changeLog;\n  final isZh = Get.locale?.languageCode == \"zh\";\n  final changeLogRegex = isZh\n      ? RegExp(r\"(#\\s+更新日志.*)\", multiLine: true, dotAll: true)\n      : RegExp(r\"(# Release notes.*)#\\s+更新日志\", multiLine: true, dotAll: true);\n  final changeLog = changeLogRegex.firstMatch(fullChangeLog)?.group(1) ?? \"\";\n  await showDialog(\n    context: Get.context!,\n    barrierDismissible: false,\n    builder: (context) {\n      bool isDownloading = false;\n      double progress = 0;\n      int total = 0;\n      final buttonController = OutlinedButtonLoadingController();\n      return StatefulBuilder(\n        builder: (context, setState) {\n          final screenSize = MediaQuery.of(context).size;\n          final dialogWidth =\n              screenSize.width < 500 ? screenSize.width * 0.9 : 500.0;\n          final dialogHeight =\n              screenSize.height < 600 ? screenSize.height * 0.8 : 400.0;\n\n          return Dialog(\n            shape: RoundedRectangleBorder(\n              borderRadius: BorderRadius.circular(16),\n            ),\n            child: SizedBox(\n              width: dialogWidth,\n              child: Padding(\n                padding: const EdgeInsets.all(20),\n                child: Column(\n                  mainAxisSize: MainAxisSize.min,\n                  crossAxisAlignment: CrossAxisAlignment.start,\n                  children: [\n                    Text(\n                      'newVersionTitle'\n                          .trParams({'version': versionInfo.version}),\n                      style: const TextStyle(\n                        fontSize: 20,\n                        fontWeight: FontWeight.bold,\n                      ),\n                    ),\n                    const SizedBox(height: 16),\n                    Container(\n                      height: dialogHeight * 0.5,\n                      decoration: BoxDecoration(\n                        border:\n                            Border.all(color: Theme.of(context).dividerColor),\n                        borderRadius: BorderRadius.circular(8),\n                      ),\n                      child: ScrollConfiguration(\n                        behavior: ScrollConfiguration.of(context).copyWith(\n                          scrollbars: true,\n                        ),\n                        child: Builder(\n                          builder: (context) {\n                            final controller = ScrollController();\n                            return Scrollbar(\n                              controller: controller,\n                              thumbVisibility: true,\n                              child: SingleChildScrollView(\n                                controller: controller,\n                                child: Padding(\n                                  padding: const EdgeInsets.all(12),\n                                  child: Column(\n                                    crossAxisAlignment:\n                                        CrossAxisAlignment.start,\n                                    children:\n                                        _parseMarkdown(changeLog, context),\n                                  ),\n                                ),\n                              ),\n                            );\n                          },\n                        ),\n                      ),\n                    ),\n                    if (isDownloading) ...[\n                      const SizedBox(height: 16),\n                      LinearProgressIndicator(\n                        value: progress,\n                        valueColor: AlwaysStoppedAnimation<Color>(\n                          Theme.of(context).colorScheme.primary,\n                        ),\n                      ),\n                      const SizedBox(height: 4),\n                      Row(\n                        mainAxisAlignment: MainAxisAlignment.spaceBetween,\n                        children: [\n                          Text(\n                            '${(progress * 100).toStringAsFixed(1)}%',\n                            style: const TextStyle(fontSize: 12),\n                          ),\n                          Text(\n                            total == 0\n                                ? ''\n                                : '${Util.fmtByte((total * progress).toInt())} / ${Util.fmtByte(total)}',\n                            style: const TextStyle(fontSize: 12),\n                          ),\n                        ],\n                      )\n                    ],\n                    const SizedBox(height: 20),\n                    Row(\n                      mainAxisAlignment: MainAxisAlignment.end,\n                      children: [\n                        TextButton(\n                          onPressed: isDownloading ? null : () => Get.back(),\n                          child: Text(\n                            'newVersionLater'.tr,\n                            style: TextStyle(\n                                color: isDownloading\n                                    ? Theme.of(context).disabledColor\n                                    : Theme.of(context).colorScheme.error),\n                          ),\n                        ),\n                        const SizedBox(width: 8),\n                        OutlinedButtonLoading(\n                          controller: buttonController,\n                          onPressed: () async {\n                            setState(() {\n                              isDownloading = true;\n                            });\n                            buttonController.start();\n                            try {\n                              await _update(versionInfo.version,\n                                  (received, fileTotal) {\n                                setState(() {\n                                  total = fileTotal;\n                                  progress = received / fileTotal;\n                                });\n                              });\n                            } catch (e) {\n                              showErrorMessage(e);\n                            } finally {\n                              setState(() {\n                                isDownloading = false;\n                              });\n                              buttonController.stop();\n                            }\n                          },\n                          child: Text('newVersionUpdate'.tr),\n                        ),\n                      ],\n                    ),\n                  ],\n                ),\n              ),\n            ),\n          );\n        },\n      );\n    },\n  );\n}\n\nList<Widget> _parseMarkdown(String markdown, BuildContext context) {\n  final List<Widget> widgets = [];\n  final lines = markdown.split('\\n');\n\n  for (final line in lines) {\n    if (line.trim().isEmpty) continue;\n    if (line.startsWith('# ')) {\n      // H1 header\n      widgets.add(Text(\n        line.substring(2),\n        style: const TextStyle(\n          fontSize: 18,\n          fontWeight: FontWeight.bold,\n        ),\n      ));\n    } else if (line.startsWith('## ')) {\n      // H2 header\n      widgets.add(Text(\n        line.substring(3),\n        style: const TextStyle(\n          fontSize: 16,\n          fontWeight: FontWeight.bold,\n        ),\n      ));\n    } else if (line.trim().startsWith('- ')) {\n      // List item\n      widgets.add(Padding(\n        padding: const EdgeInsets.only(left: 8),\n        child: Row(\n          crossAxisAlignment: CrossAxisAlignment.start,\n          children: [\n            const Text('• ', style: TextStyle(fontSize: 14)),\n            Expanded(\n              child: Text(\n                line.substring(line.indexOf('-') + 1).trim().replaceFirst(\n                    RegExp(r'@[^\\s]*\\s\\(#\\d+\\)'),\n                    ''), // Remove contributor and pr number\n                style: const TextStyle(fontSize: 14),\n              ),\n            ),\n          ],\n        ),\n      ));\n    } else {\n      // Normal text\n      widgets.add(Text(\n        line,\n        style: const TextStyle(fontSize: 14),\n      ));\n    }\n\n    // Add spacing between elements\n    widgets.add(const SizedBox(height: 8));\n  }\n\n  return widgets;\n}\n\nFuture<void> _update(String version, Function(int, int) onProgress) async {\n  var newVersionAssetPath = \"\";\n  final newVersionAssetName = _getAssetName(version);\n\n  // Need to download the asset\n  if (newVersionAssetName.isNotEmpty) {\n    final downloadUrl =\n        'https://github.com/GopeedLab/gopeed/releases/download/v$version/$newVersionAssetName';\n    newVersionAssetPath = await _getAssetPath(version);\n\n    if (downloadUrl.isNotEmpty) {\n      final fastDownloadUrl =\n          await githubAutoMirror(downloadUrl, MirrorType.githubRelease);\n      final downloadClient = Dio();\n      await downloadClient.download(fastDownloadUrl, newVersionAssetPath,\n          onReceiveProgress: onProgress);\n    }\n  }\n\n  switch (_channel) {\n    case Channel.windowsInstaller:\n    case Channel.windowsPortable:\n    case Channel.macosDmg:\n    case Channel.linuxFlathub:\n    case Channel.linuxSnap:\n    case Channel.linuxDeb:\n      final updaterPath = await Util.homePathJoin(_updaterBin);\n      // Check the updater binary is exists\n      if (!await File(updaterPath).exists()) {\n        await launchUrl(\n            Uri.parse(\n                'https://github.com/GopeedLab/gopeed/releases/tag/v$version'),\n            mode: LaunchMode.externalApplication);\n        break;\n      }\n      /**\n       *Usage of updater command:\n          -pid int\n          PID of the process to update\n          -channel string\n          Update channel\n          -asset string\n          Path to the package asset\n          -exeDir string\n          Directory of the entry executable\n          -log string\n          Log file path\n       */\n      await Process.run(updaterPath, [\n        \"-pid\",\n        pid.toString(),\n        \"-channel\",\n        _channel!.name,\n        \"-asset\",\n        newVersionAssetPath,\n        \"-exeDir\",\n        path.dirname(Platform.resolvedExecutable),\n        \"-log\",\n        path.join(logsDir(), \"updater.log\")\n      ]);\n      break;\n    case Channel.androidApk:\n      await InstallPlugin.installApk(newVersionAssetPath);\n      break;\n    default:\n      await launchUrl(\n          Uri.parse(\n              'https://github.com/GopeedLab/gopeed/releases/tag/v$version'),\n          mode: LaunchMode.externalApplication);\n      break;\n  }\n}\n\nString _getAssetName(String version) {\n  final arch = getArch();\n\n  String commonArchName() {\n    return switch (arch) {\n      Architecture.ia32 => \"386\",\n      Architecture.x64 => \"amd64\",\n      _ => arch.name\n    };\n  }\n\n  switch (_channel) {\n    case Channel.windowsInstaller:\n      return 'Gopeed-v$version-windows-${commonArchName()}.zip';\n    case Channel.windowsPortable:\n      return 'Gopeed-v$version-windows-${commonArchName()}-portable.zip';\n    case Channel.macosDmg:\n      return 'Gopeed-v$version-macos-${commonArchName()}.dmg';\n    case Channel.linuxDeb:\n      return 'Gopeed-v$version-linux-${commonArchName()}.deb';\n    case Channel.androidApk:\n      final apkArchName = switch (arch) {\n        Architecture.arm => \"armeabi-v7a\",\n        Architecture.arm64 => \"arm64-v8a\",\n        Architecture.x64 => \"x86_64\",\n        _ => null\n      };\n      var apkNamePrefix = \"Gopeed-v$version-android\";\n      if (apkArchName != null) {\n        apkNamePrefix += \"-$apkArchName\";\n      }\n      return \"$apkNamePrefix.apk\";\n    default:\n      return \"\";\n  }\n}\n\nFuture<String> _getAssetPath(String version) async {\n  return path.join(\n      (await getTemporaryDirectory()).path, _getAssetName(version));\n}\n"
  },
  {
    "path": "ui/flutter/lib/util/util.dart",
    "content": "import 'dart:async';\nimport 'dart:convert';\nimport 'dart:io';\n\nimport 'package:crypto/crypto.dart';\nimport 'package:flutter/foundation.dart';\nimport 'package:flutter/services.dart';\nimport 'package:path_provider/path_provider.dart';\nimport 'package:path/path.dart' as path;\n\nclass Util {\n  static String? _storageDir;\n\n  static String cleanPath(String path) {\n    path = path.replaceAll(RegExp(r'\\\\'), \"/\");\n    if (path.startsWith(\".\")) {\n      path = path.substring(1);\n    }\n    if (path.startsWith(\"/\")) {\n      path = path.substring(1);\n    }\n    return path;\n  }\n\n  static String safeDir(String path) {\n    if (path == \".\" || path == \"./\" || path == \".\\\\\") {\n      return \"\";\n    }\n    return path;\n  }\n\n  static String safePathJoin(List<String> paths) {\n    return paths\n        .where((e) => e.isNotEmpty)\n        .map((e) => safeDir(e))\n        .join(\"/\")\n        .replaceAll(RegExp(r'//'), \"/\");\n  }\n\n  static String fmtByte(int byte) {\n    if (byte < 0) {\n      return \"0 B\";\n    } else if (byte < 1024) {\n      return \"$byte B\";\n    } else if (byte < 1024 * 1024) {\n      return \"${(byte / 1024).toStringAsFixed(2)} KB\";\n    } else if (byte < 1024 * 1024 * 1024) {\n      return \"${(byte / 1024 / 1024).toStringAsFixed(2)} MB\";\n    } else {\n      return \"${(byte / 1024 / 1024 / 1024).toStringAsFixed(2)} GB\";\n    }\n  }\n\n  static Future<void> initStorageDir() async {\n    var storageDir = \"\";\n    if (Util.isWindows()) {\n      storageDir =\n          path.join(File(Platform.resolvedExecutable).parent.path, \"storage\");\n    } else if (!Util.isWeb()) {\n      if (Util.isLinux()) {\n        storageDir = File(Platform.resolvedExecutable).parent.path;\n        // check has write permission, if not, fallback to application support dir\n        try {\n          final testFile = File(path.join(storageDir, \".test\"));\n          await testFile.writeAsString(\"test\");\n          await testFile.delete();\n        } catch (e) {\n          storageDir = (await getApplicationSupportDirectory()).path;\n        }\n      } else {\n        storageDir = (await getApplicationSupportDirectory()).path;\n      }\n    }\n    _storageDir = storageDir;\n  }\n\n  static String getStorageDir() {\n    return _storageDir!;\n  }\n\n  static isAndroid() {\n    return !kIsWeb && Platform.isAndroid;\n  }\n\n  static isIOS() {\n    return !kIsWeb && Platform.isIOS;\n  }\n\n  static isMobile() {\n    return !kIsWeb && (Platform.isAndroid || Platform.isIOS);\n  }\n\n  static isDesktop() {\n    if (kIsWeb) {\n      return false;\n    }\n    return Platform.isWindows || Platform.isLinux || Platform.isMacOS;\n  }\n\n  static isWindows() {\n    return !kIsWeb && Platform.isWindows;\n  }\n\n  static isMacos() {\n    return !kIsWeb && Platform.isMacOS;\n  }\n\n  static isLinux() {\n    return !kIsWeb && Platform.isLinux;\n  }\n\n  static isWeb() {\n    return kIsWeb;\n  }\n\n  static supportUnixSocket() {\n    if (kIsWeb) {\n      return false;\n    }\n    return Platform.isLinux || Platform.isMacOS || Platform.isAndroid;\n  }\n\n  static List<String> textToLines(String text) {\n    if (text.isEmpty) {\n      return [];\n    }\n    const ls = LineSplitter();\n    return ls.convert(text).where((line) => line.isNotEmpty).toList();\n  }\n\n  // if one future complete, return the result, only all future error, return the last error\n  static anyOk<T>(Iterable<Future<T>> futures) {\n    final completer = Completer<T>();\n    Object lastError;\n    var count = futures.length;\n    for (var future in futures) {\n      future.then((value) {\n        if (!completer.isCompleted) {\n          completer.complete(value);\n        }\n      }).catchError((e) {\n        lastError = e;\n        count--;\n        if (count == 0) {\n          completer.completeError(lastError);\n        }\n      });\n    }\n    return completer.future;\n  }\n\n  static void Function() debounce(Function() fn, int ms) {\n    Timer? timer;\n    return () {\n      timer?.cancel();\n      timer = Timer(Duration(milliseconds: ms), fn);\n    };\n  }\n\n  static Future<String> homePathJoin(String fileName) async {\n    if (Util.isWindows()) {\n      final execPath = Platform.resolvedExecutable;\n      final execDir = path.dirname(execPath);\n      return path.join(execDir, fileName);\n    }\n\n    final dir = await getApplicationSupportDirectory();\n    return path.join(dir.path, fileName);\n  }\n\n  static Future<void> installAsset(String assetPath, String targetPath,\n      {bool executable = false}) async {\n    Future<List<int>> getAssetData() async {\n      final asset = await rootBundle.load(assetPath);\n      return asset.buffer.asUint8List(asset.offsetInBytes, asset.lengthInBytes);\n    }\n\n    // Check if target file is not installed\n    if (!await File(targetPath).exists()) {\n      final assetData = await getAssetData();\n      final file = File(targetPath);\n      await file.writeAsBytes(assetData);\n      // Add execute permission when file first created\n      if (executable && !Platform.isWindows) {\n        await Process.run('chmod', ['+x', targetPath]);\n      }\n      return;\n    }\n\n    // Check if target file needs to be updated\n    final assetData = await getAssetData();\n    if (_md5(assetData) != await _md5File(File(targetPath))) {\n      await File(targetPath).writeAsBytes(assetData);\n      return;\n    }\n  }\n\n  static String _md5(List<int> data) {\n    return md5.convert(data).toString();\n  }\n\n  static Future<String> _md5File(File file) async {\n    return file.openRead().transform(md5).first.toString();\n  }\n}\n"
  },
  {
    "path": "ui/flutter/lib/util/win32.dart",
    "content": "import 'package:win32_registry/win32_registry.dart';\n\n/// Check registry key\n/// If the key does not exist or the value is different, return false\ncheckRegistry(String keyPath, String valueName, String value) {\n  RegistryKey regKey;\n  try {\n    regKey = Registry.openPath(RegistryHive.currentUser, path: keyPath);\n    return regKey.getValueAsString(valueName) == value;\n  } catch (e) {\n    return false;\n  }\n}\n\n/// Upsert registry key\n/// If the key does not exist, create it\n/// If the value does not exist or is different, update it\nupsertRegistry(String keyPath, String valueName, String value) {\n  RegistryKey regKey;\n  try {\n    regKey = Registry.openPath(RegistryHive.currentUser,\n        path: keyPath, desiredAccessRights: AccessRights.allAccess);\n  } catch (e) {\n    regKey = Registry.currentUser.createKey(keyPath);\n  }\n\n  if (regKey.getValueAsString(valueName) != value) {\n    regKey\n        .createValue(RegistryValue(valueName, RegistryValueType.string, value));\n  }\n  regKey.close();\n}\n"
  },
  {
    "path": "ui/flutter/linux/.gitignore",
    "content": "flutter/ephemeral\n"
  },
  {
    "path": "ui/flutter/linux/CMakeLists.txt",
    "content": "# Project-level configuration.\ncmake_minimum_required(VERSION 3.10)\nproject(runner LANGUAGES CXX)\n\n# The name of the executable created for the application. Change this to change\n# the on-disk name of your application.\nset(BINARY_NAME \"gopeed\")\n# The unique GTK application identifier for this application. See:\n# https://wiki.gnome.org/HowDoI/ChooseApplicationID\nset(APPLICATION_ID \"com.gopeed.gopeed\")\n\n# Explicitly opt in to modern CMake behaviors to avoid warnings with recent\n# versions of CMake.\ncmake_policy(SET CMP0063 NEW)\n\n# Load bundled libraries from the lib/ directory relative to the binary.\nset(CMAKE_INSTALL_RPATH \"$ORIGIN/lib\")\n\n# Root filesystem for cross-building.\nif(FLUTTER_TARGET_PLATFORM_SYSROOT)\n  set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT})\n  set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT})\n  set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)\n  set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)\n  set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)\n  set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)\nendif()\n\n# Define build configuration options.\nif(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)\n  set(CMAKE_BUILD_TYPE \"Debug\" CACHE\n    STRING \"Flutter build mode\" FORCE)\n  set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS\n    \"Debug\" \"Profile\" \"Release\")\nendif()\n\n# Compilation settings that should be applied to most targets.\n#\n# Be cautious about adding new options here, as plugins use this function by\n# default. In most cases, you should add new options to specific targets instead\n# of modifying this function.\nfunction(APPLY_STANDARD_SETTINGS TARGET)\n  target_compile_features(${TARGET} PUBLIC cxx_std_14)\n  target_compile_options(${TARGET} PRIVATE -Wall -Werror)\n  target_compile_options(${TARGET} PRIVATE -Wno-error=deprecated-declarations) # Allow deprecated warnings\n  target_compile_options(${TARGET} PRIVATE \"$<$<NOT:$<CONFIG:Debug>>:-O3>\")\n  target_compile_definitions(${TARGET} PRIVATE \"$<$<NOT:$<CONFIG:Debug>>:NDEBUG>\")\nendfunction()\n\n# Flutter library and tool build rules.\nset(FLUTTER_MANAGED_DIR \"${CMAKE_CURRENT_SOURCE_DIR}/flutter\")\nadd_subdirectory(${FLUTTER_MANAGED_DIR})\n\n# System-level dependencies.\nfind_package(PkgConfig REQUIRED)\npkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)\n\nadd_definitions(-DAPPLICATION_ID=\"${APPLICATION_ID}\")\n\n# Define the application target. To change its name, change BINARY_NAME above,\n# not the value here, or `flutter run` will no longer work.\n#\n# Any new source files that you add to the application should be added here.\nadd_executable(${BINARY_NAME}\n  \"main.cc\"\n  \"my_application.cc\"\n  \"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc\"\n)\n\n# Apply the standard set of build settings. This can be removed for applications\n# that need different build settings.\napply_standard_settings(${BINARY_NAME})\n\n# Add dependency libraries. Add any application-specific dependencies here.\ntarget_link_libraries(${BINARY_NAME} PRIVATE flutter)\ntarget_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)\n\n# Run the Flutter tool portions of the build. This must not be removed.\nadd_dependencies(${BINARY_NAME} flutter_assemble)\n\n# Only the install-generated bundle's copy of the executable will launch\n# correctly, since the resources must in the right relative locations. To avoid\n# people trying to run the unbundled copy, put it in a subdirectory instead of\n# the default top-level location.\nset_target_properties(${BINARY_NAME}\n  PROPERTIES\n  RUNTIME_OUTPUT_DIRECTORY \"${CMAKE_BINARY_DIR}/intermediates_do_not_run\"\n)\n\n# Generated plugin build rules, which manage building the plugins and adding\n# them to the application.\ninclude(flutter/generated_plugins.cmake)\n\n\n# === Installation ===\n# By default, \"installing\" just makes a relocatable bundle in the build\n# directory.\nset(BUILD_BUNDLE_DIR \"${PROJECT_BINARY_DIR}/bundle\")\nif(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)\n  set(CMAKE_INSTALL_PREFIX \"${BUILD_BUNDLE_DIR}\" CACHE PATH \"...\" FORCE)\nendif()\n\n# Start with a clean build bundle directory every time.\ninstall(CODE \"\n  file(REMOVE_RECURSE \\\"${BUILD_BUNDLE_DIR}/\\\")\n  \" COMPONENT Runtime)\n\nset(INSTALL_BUNDLE_DATA_DIR \"${CMAKE_INSTALL_PREFIX}/data\")\nset(INSTALL_BUNDLE_LIB_DIR \"${CMAKE_INSTALL_PREFIX}/lib\")\n\ninstall(TARGETS ${BINARY_NAME} RUNTIME DESTINATION \"${CMAKE_INSTALL_PREFIX}\"\n  COMPONENT Runtime)\n\ninstall(FILES \"${FLUTTER_ICU_DATA_FILE}\" DESTINATION \"${INSTALL_BUNDLE_DATA_DIR}\"\n  COMPONENT Runtime)\n\ninstall(FILES \"${FLUTTER_LIBRARY}\" DESTINATION \"${INSTALL_BUNDLE_LIB_DIR}\"\n  COMPONENT Runtime)\n\ninstall(FILES \"${CMAKE_CURRENT_SOURCE_DIR}/bundle/lib/libgopeed.so\" DESTINATION \"${INSTALL_BUNDLE_LIB_DIR}\"\n  COMPONENT Runtime)\n\nforeach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES})\n  install(FILES \"${bundled_library}\"\n    DESTINATION \"${INSTALL_BUNDLE_LIB_DIR}\"\n    COMPONENT Runtime)\nendforeach(bundled_library)\n\n# Fully re-copy the assets directory on each build to avoid having stale files\n# from a previous install.\nset(FLUTTER_ASSET_DIR_NAME \"flutter_assets\")\ninstall(CODE \"\n  file(REMOVE_RECURSE \\\"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\\\")\n  \" COMPONENT Runtime)\ninstall(DIRECTORY \"${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}\"\n  DESTINATION \"${INSTALL_BUNDLE_DATA_DIR}\" COMPONENT Runtime)\n\n# Install the AOT library on non-Debug builds only.\nif(NOT CMAKE_BUILD_TYPE MATCHES \"Debug\")\n  install(FILES \"${AOT_LIBRARY}\" DESTINATION \"${INSTALL_BUNDLE_LIB_DIR}\"\n    COMPONENT Runtime)\nendif()\n"
  },
  {
    "path": "ui/flutter/linux/assets/com.gopeed.Gopeed.desktop",
    "content": "[Desktop Entry]\nName=Gopeed\nGenericName=Download Manager\nGenericName[zh_CN]=下载器\nGenericName[zh_TW]=下載器\nComment=A modern download manager for all platforms\nComment[zh_CN]=支持全平台的高速下载器\nComment[zh_TW]=支持全平臺的高速下載器\nTerminal=false\nExec=gopeed %U\nIcon=com.gopeed.Gopeed\nType=Application\nCategories=Utility;Network;\nKeywords=Bittorrent;Downloader;\nMimeType=x-scheme-handler/gopeed;x-scheme-handler/magnet;application/x-bittorrent;\n"
  },
  {
    "path": "ui/flutter/linux/flutter/CMakeLists.txt",
    "content": "# This file controls Flutter-level build steps. It should not be edited.\ncmake_minimum_required(VERSION 3.10)\n\nset(EPHEMERAL_DIR \"${CMAKE_CURRENT_SOURCE_DIR}/ephemeral\")\n\n# Configuration provided via flutter tool.\ninclude(${EPHEMERAL_DIR}/generated_config.cmake)\n\n# TODO: Move the rest of this into files in ephemeral. See\n# https://github.com/flutter/flutter/issues/57146.\n\n# Serves the same purpose as list(TRANSFORM ... PREPEND ...),\n# which isn't available in 3.10.\nfunction(list_prepend LIST_NAME PREFIX)\n    set(NEW_LIST \"\")\n    foreach(element ${${LIST_NAME}})\n        list(APPEND NEW_LIST \"${PREFIX}${element}\")\n    endforeach(element)\n    set(${LIST_NAME} \"${NEW_LIST}\" PARENT_SCOPE)\nendfunction()\n\n# === Flutter Library ===\n# System-level dependencies.\nfind_package(PkgConfig REQUIRED)\npkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)\npkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0)\npkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0)\n\nset(FLUTTER_LIBRARY \"${EPHEMERAL_DIR}/libflutter_linux_gtk.so\")\n\n# Published to parent scope for install step.\nset(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)\nset(FLUTTER_ICU_DATA_FILE \"${EPHEMERAL_DIR}/icudtl.dat\" PARENT_SCOPE)\nset(PROJECT_BUILD_DIR \"${PROJECT_DIR}/build/\" PARENT_SCOPE)\nset(AOT_LIBRARY \"${PROJECT_DIR}/build/lib/libapp.so\" PARENT_SCOPE)\n\nlist(APPEND FLUTTER_LIBRARY_HEADERS\n  \"fl_basic_message_channel.h\"\n  \"fl_binary_codec.h\"\n  \"fl_binary_messenger.h\"\n  \"fl_dart_project.h\"\n  \"fl_engine.h\"\n  \"fl_json_message_codec.h\"\n  \"fl_json_method_codec.h\"\n  \"fl_message_codec.h\"\n  \"fl_method_call.h\"\n  \"fl_method_channel.h\"\n  \"fl_method_codec.h\"\n  \"fl_method_response.h\"\n  \"fl_plugin_registrar.h\"\n  \"fl_plugin_registry.h\"\n  \"fl_standard_message_codec.h\"\n  \"fl_standard_method_codec.h\"\n  \"fl_string_codec.h\"\n  \"fl_value.h\"\n  \"fl_view.h\"\n  \"flutter_linux.h\"\n)\nlist_prepend(FLUTTER_LIBRARY_HEADERS \"${EPHEMERAL_DIR}/flutter_linux/\")\nadd_library(flutter INTERFACE)\ntarget_include_directories(flutter INTERFACE\n  \"${EPHEMERAL_DIR}\"\n)\ntarget_link_libraries(flutter INTERFACE \"${FLUTTER_LIBRARY}\")\ntarget_link_libraries(flutter INTERFACE\n  PkgConfig::GTK\n  PkgConfig::GLIB\n  PkgConfig::GIO\n)\nadd_dependencies(flutter flutter_assemble)\n\n# === Flutter tool backend ===\n# _phony_ is a non-existent file to force this command to run every time,\n# since currently there's no way to get a full input/output list from the\n# flutter tool.\nadd_custom_command(\n  OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}\n    ${CMAKE_CURRENT_BINARY_DIR}/_phony_\n  COMMAND ${CMAKE_COMMAND} -E env\n    ${FLUTTER_TOOL_ENVIRONMENT}\n    \"${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh\"\n      ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE}\n  VERBATIM\n)\nadd_custom_target(flutter_assemble DEPENDS\n  \"${FLUTTER_LIBRARY}\"\n  ${FLUTTER_LIBRARY_HEADERS}\n)\n"
  },
  {
    "path": "ui/flutter/linux/main.cc",
    "content": "#include \"my_application.h\"\n\nint main(int argc, char** argv) {\n  g_autoptr(MyApplication) app = my_application_new();\n  return g_application_run(G_APPLICATION(app), argc, argv);\n}\n"
  },
  {
    "path": "ui/flutter/linux/my_application.cc",
    "content": "#include \"my_application.h\"\n\n#include <flutter_linux/flutter_linux.h>\n#ifdef GDK_WINDOWING_X11\n#include <gdk/gdkx.h>\n#endif\n\n#include \"flutter/generated_plugin_registrant.h\"\n\nstruct _MyApplication {\n  GtkApplication parent_instance;\n  char** dart_entrypoint_arguments;\n};\n\nG_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)\n\n// Implements GApplication::activate.\nstatic void my_application_activate(GApplication* application) {\n  MyApplication* self = MY_APPLICATION(application);\n\n  GList* windows = gtk_application_get_windows(GTK_APPLICATION(application));\n  if (windows) {\n    gtk_window_present(GTK_WINDOW(windows->data));\n    return;\n  }\n\n  GtkWindow* window =\n      GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application)));\n\n  // Use a header bar when running in GNOME as this is the common style used\n  // by applications and is the setup most users will be using (e.g. Ubuntu\n  // desktop).\n  // If running on X and not using GNOME then just use a traditional title bar\n  // in case the window manager does more exotic layout, e.g. tiling.\n  // If running on Wayland assume the header bar will work (may need changing\n  // if future cases occur).\n  gboolean use_header_bar = TRUE;\n#ifdef GDK_WINDOWING_X11\n  GdkScreen* screen = gtk_window_get_screen(window);\n  if (GDK_IS_X11_SCREEN(screen)) {\n    const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen);\n    if (g_strcmp0(wm_name, \"GNOME Shell\") != 0) {\n      use_header_bar = FALSE;\n    }\n  }\n#endif\n  if (use_header_bar) {\n    GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());\n    gtk_widget_show(GTK_WIDGET(header_bar));\n    gtk_header_bar_set_title(header_bar, \"gopeed\");\n    gtk_header_bar_set_show_close_button(header_bar, TRUE);\n    gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));\n  } else {\n    gtk_window_set_title(window, \"gopeed\");\n  }\n\n  gtk_window_set_default_size(window, 1280, 720);\n  gtk_widget_show(GTK_WIDGET(window));\n\n  g_autoptr(FlDartProject) project = fl_dart_project_new();\n  fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments);\n\n  FlView* view = fl_view_new(project);\n  gtk_widget_show(GTK_WIDGET(view));\n  gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view));\n\n  fl_register_plugins(FL_PLUGIN_REGISTRY(view));\n  gtk_widget_hide(GTK_WIDGET(window));\n\n  gtk_widget_grab_focus(GTK_WIDGET(view));\n}\n\n// Implements GApplication::local_command_line.\nstatic gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) {\n  MyApplication* self = MY_APPLICATION(application);\n  // Strip out the first argument as it is the binary name.\n  self->dart_entrypoint_arguments = g_strdupv(*arguments + 1);\n\n  g_autoptr(GError) error = nullptr;\n  if (!g_application_register(application, nullptr, &error)) {\n     g_warning(\"Failed to register: %s\", error->message);\n     *exit_status = 1;\n     return TRUE;\n  }\n\n  g_application_activate(application);\n  *exit_status = 0;\n\n  return FALSE;\n}\n\n// Implements GObject::dispose.\nstatic void my_application_dispose(GObject* object) {\n  MyApplication* self = MY_APPLICATION(object);\n  g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);\n  G_OBJECT_CLASS(my_application_parent_class)->dispose(object);\n}\n\nstatic void my_application_class_init(MyApplicationClass* klass) {\n  G_APPLICATION_CLASS(klass)->activate = my_application_activate;\n  G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line;\n  G_OBJECT_CLASS(klass)->dispose = my_application_dispose;\n}\n\nstatic void my_application_init(MyApplication* self) {}\n\nMyApplication* my_application_new() {\n  return MY_APPLICATION(g_object_new(my_application_get_type(),\n                                     \"application-id\", APPLICATION_ID,\n                                     \"flags\", G_APPLICATION_HANDLES_COMMAND_LINE | G_APPLICATION_HANDLES_OPEN,\n                                     nullptr));\n}\n"
  },
  {
    "path": "ui/flutter/linux/my_application.h",
    "content": "#ifndef FLUTTER_MY_APPLICATION_H_\n#define FLUTTER_MY_APPLICATION_H_\n\n#include <gtk/gtk.h>\n\nG_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION,\n                     GtkApplication)\n\n/**\n * my_application_new:\n *\n * Creates a new Flutter-based application.\n *\n * Returns: a new #MyApplication.\n */\nMyApplication* my_application_new();\n\n#endif  // FLUTTER_MY_APPLICATION_H_\n"
  },
  {
    "path": "ui/flutter/linux/packaging/appimage/make_config.yaml",
    "content": "display_name: Gopeed\n\nicon: assets/icon/icon.svg\n\nkeywords:\n  - Application\n  - DownloadManager\n  - Network\n  - Utility\n\ngeneric_name: Download Manager\n\ncategories:\n  - Network\n  - Utility\n\nsupported_mime_type:\n  - x-scheme-handler/gopeed\n  - x-scheme-handler/magnet\n  - application/x-bittorrent\n\nstartup_notify: true"
  },
  {
    "path": "ui/flutter/linux/packaging/deb/make_config.yaml",
    "content": "display_name: Gopeed\npackage_name: gopeed\nmaintainer:\n  name: monkeyWie\n  email: liwei-8466@qq.com\nco_authors:\n  - name: madoka773\n    email: valigarmanda55@gmail.com\npriority: optional\nsection: x11\ninstalled_size: 52360\nessential: false\nicon: assets/icon/icon.svg\n\ndependencies:\n  - libayatana-appindicator3-1\n  - gir1.2-ayatanaappindicator3-0.1\n  - libkeybinder-3.0-0\n\nkeywords:\n  - Application\n  - DownloadManager\n  - Network\n  - Utility\n\ngeneric_name: Download Manager\n\ncategories:\n  - Network\n  - Utility\n\nsupported_mime_type:\n  - x-scheme-handler/gopeed\n  - x-scheme-handler/magnet\n  - application/x-bittorrent\n\nstartup_notify: true\n"
  },
  {
    "path": "ui/flutter/macos/.gitignore",
    "content": "# Flutter-related\n**/Flutter/ephemeral/\n**/Pods/\n\n# Xcode-related\n**/dgph\n**/xcuserdata/\n"
  },
  {
    "path": "ui/flutter/macos/Flutter/Flutter-Debug.xcconfig",
    "content": "#include? \"Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig\"\n#include \"ephemeral/Flutter-Generated.xcconfig\"\n"
  },
  {
    "path": "ui/flutter/macos/Flutter/Flutter-Release.xcconfig",
    "content": "#include? \"Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig\"\n#include \"ephemeral/Flutter-Generated.xcconfig\"\n"
  },
  {
    "path": "ui/flutter/macos/Podfile",
    "content": "platform :osx, '10.15'\n\n# CocoaPods analytics sends network stats synchronously affecting flutter build latency.\nENV['COCOAPODS_DISABLE_STATS'] = 'true'\n\nproject 'Runner', {\n  'Debug' => :debug,\n  'Profile' => :release,\n  'Release' => :release,\n}\n\ndef flutter_root\n  generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__)\n  unless File.exist?(generated_xcode_build_settings_path)\n    raise \"#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \\\"flutter pub get\\\" is executed first\"\n  end\n\n  File.foreach(generated_xcode_build_settings_path) do |line|\n    matches = line.match(/FLUTTER_ROOT\\=(.*)/)\n    return matches[1].strip if matches\n  end\n  raise \"FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \\\"flutter pub get\\\"\"\nend\n\nrequire File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)\n\nflutter_macos_podfile_setup\n\ntarget 'Runner' do\n  use_frameworks!\n  use_modular_headers!\n\n  flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__))\nend\n\npost_install do |installer|\n  installer.pods_project.targets.each do |target|\n    flutter_additional_macos_build_settings(target)\n  end\nend\n"
  },
  {
    "path": "ui/flutter/macos/Runner/AppDelegate.swift",
    "content": "import Cocoa\nimport FlutterMacOS\nimport app_links\n\n@main\nclass AppDelegate: FlutterAppDelegate {\n  public override func application(_ application: NSApplication,\n                                  continue userActivity: NSUserActivity,\n                                  restorationHandler: @escaping ([any NSUserActivityRestoring]) -> Void) -> Bool {\n\n    guard let url = AppLinks.shared.getUniversalLink(userActivity) else {\n      return false\n    }\n    \n    AppLinks.shared.handleLink(link: url.absoluteString)\n    \n    return false // Returning true will stop the propagation to other packages\n  }\n\n  override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {\n    return false\n  }\n\n  override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {\n    return true\n  }\n\n  override func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {\n    if !flag {\n      for window in NSApp.windows {\n        if !window.isVisible {\n          window.setIsVisible(true)\n        }\n        window.makeKeyAndOrderFront(self)\n        NSApp.activate(ignoringOtherApps: true)\n      }\n    }\n    return true\n  }\n\n  override func application(_ sender: NSApplication, openFile filename: String) -> Bool {\n    let url = URL(fileURLWithPath: filename)\n    AppLinks.shared.handleLink(link: url.absoluteString)\n    return false;\n  }\n\n  override func application(_ application: NSApplication, open urls: [URL]) {\n    // Only handle the first file\n    if(urls.isEmpty) {\n      return\n    }\n    AppLinks.shared.handleLink(link: urls.first!.absoluteString)\n  }\n}\n"
  },
  {
    "path": "ui/flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json",
    "content": "{\n    \"info\": {\n        \"version\": 1,\n        \"author\": \"xcode\"\n    },\n    \"images\": [\n        {\n            \"size\": \"16x16\",\n            \"idiom\": \"mac\",\n            \"filename\": \"app_icon_16.png\",\n            \"scale\": \"1x\"\n        },\n        {\n            \"size\": \"16x16\",\n            \"idiom\": \"mac\",\n            \"filename\": \"app_icon_32.png\",\n            \"scale\": \"2x\"\n        },\n        {\n            \"size\": \"32x32\",\n            \"idiom\": \"mac\",\n            \"filename\": \"app_icon_32.png\",\n            \"scale\": \"1x\"\n        },\n        {\n            \"size\": \"32x32\",\n            \"idiom\": \"mac\",\n            \"filename\": \"app_icon_64.png\",\n            \"scale\": \"2x\"\n        },\n        {\n            \"size\": \"128x128\",\n            \"idiom\": \"mac\",\n            \"filename\": \"app_icon_128.png\",\n            \"scale\": \"1x\"\n        },\n        {\n            \"size\": \"128x128\",\n            \"idiom\": \"mac\",\n            \"filename\": \"app_icon_256.png\",\n            \"scale\": \"2x\"\n        },\n        {\n            \"size\": \"256x256\",\n            \"idiom\": \"mac\",\n            \"filename\": \"app_icon_256.png\",\n            \"scale\": \"1x\"\n        },\n        {\n            \"size\": \"256x256\",\n            \"idiom\": \"mac\",\n            \"filename\": \"app_icon_512.png\",\n            \"scale\": \"2x\"\n        },\n        {\n            \"size\": \"512x512\",\n            \"idiom\": \"mac\",\n            \"filename\": \"app_icon_512.png\",\n            \"scale\": \"1x\"\n        },\n        {\n            \"size\": \"512x512\",\n            \"idiom\": \"mac\",\n            \"filename\": \"app_icon_1024.png\",\n            \"scale\": \"2x\"\n        }\n    ]\n}"
  },
  {
    "path": "ui/flutter/macos/Runner/Base.lproj/MainMenu.xib",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<document type=\"com.apple.InterfaceBuilder3.Cocoa.XIB\" version=\"3.0\" toolsVersion=\"14490.70\" targetRuntime=\"MacOSX.Cocoa\" propertyAccessControl=\"none\" useAutolayout=\"YES\" customObjectInstantitationMethod=\"direct\">\n    <dependencies>\n        <deployment identifier=\"macosx\"/>\n        <plugIn identifier=\"com.apple.InterfaceBuilder.CocoaPlugin\" version=\"14490.70\"/>\n        <capability name=\"documents saved in the Xcode 8 format\" minToolsVersion=\"8.0\"/>\n    </dependencies>\n    <objects>\n        <customObject id=\"-2\" userLabel=\"File's Owner\" customClass=\"NSApplication\">\n            <connections>\n                <outlet property=\"delegate\" destination=\"Voe-Tx-rLC\" id=\"GzC-gU-4Uq\"/>\n            </connections>\n        </customObject>\n        <customObject id=\"-1\" userLabel=\"First Responder\" customClass=\"FirstResponder\"/>\n        <customObject id=\"-3\" userLabel=\"Application\" customClass=\"NSObject\"/>\n        <customObject id=\"Voe-Tx-rLC\" customClass=\"AppDelegate\" customModule=\"Runner\" customModuleProvider=\"target\">\n            <connections>\n                <outlet property=\"applicationMenu\" destination=\"uQy-DD-JDr\" id=\"XBo-yE-nKs\"/>\n                <outlet property=\"mainFlutterWindow\" destination=\"QvC-M9-y7g\" id=\"gIp-Ho-8D9\"/>\n            </connections>\n        </customObject>\n        <customObject id=\"YLy-65-1bz\" customClass=\"NSFontManager\"/>\n        <menu title=\"Main Menu\" systemMenu=\"main\" id=\"AYu-sK-qS6\">\n            <items>\n                <menuItem title=\"APP_NAME\" id=\"1Xt-HY-uBw\">\n                    <modifierMask key=\"keyEquivalentModifierMask\"/>\n                    <menu key=\"submenu\" title=\"APP_NAME\" systemMenu=\"apple\" id=\"uQy-DD-JDr\">\n                        <items>\n                            <menuItem title=\"About APP_NAME\" id=\"5kV-Vb-QxS\">\n                                <modifierMask key=\"keyEquivalentModifierMask\"/>\n                                <connections>\n                                    <action selector=\"orderFrontStandardAboutPanel:\" target=\"-1\" id=\"Exp-CZ-Vem\"/>\n                                </connections>\n                            </menuItem>\n                            <menuItem isSeparatorItem=\"YES\" id=\"VOq-y0-SEH\"/>\n                            <menuItem title=\"Preferences…\" keyEquivalent=\",\" id=\"BOF-NM-1cW\"/>\n                            <menuItem isSeparatorItem=\"YES\" id=\"wFC-TO-SCJ\"/>\n                            <menuItem title=\"Services\" id=\"NMo-om-nkz\">\n                                <modifierMask key=\"keyEquivalentModifierMask\"/>\n                                <menu key=\"submenu\" title=\"Services\" systemMenu=\"services\" id=\"hz9-B4-Xy5\"/>\n                            </menuItem>\n                            <menuItem isSeparatorItem=\"YES\" id=\"4je-JR-u6R\"/>\n                            <menuItem title=\"Hide APP_NAME\" keyEquivalent=\"h\" id=\"Olw-nP-bQN\">\n                                <connections>\n                                    <action selector=\"hide:\" target=\"-1\" id=\"PnN-Uc-m68\"/>\n                                </connections>\n                            </menuItem>\n                            <menuItem title=\"Hide Others\" keyEquivalent=\"h\" id=\"Vdr-fp-XzO\">\n                                <modifierMask key=\"keyEquivalentModifierMask\" option=\"YES\" command=\"YES\"/>\n                                <connections>\n                                    <action selector=\"hideOtherApplications:\" target=\"-1\" id=\"VT4-aY-XCT\"/>\n                                </connections>\n                            </menuItem>\n                            <menuItem title=\"Show All\" id=\"Kd2-mp-pUS\">\n                                <modifierMask key=\"keyEquivalentModifierMask\"/>\n                                <connections>\n                                    <action selector=\"unhideAllApplications:\" target=\"-1\" id=\"Dhg-Le-xox\"/>\n                                </connections>\n                            </menuItem>\n                            <menuItem isSeparatorItem=\"YES\" id=\"kCx-OE-vgT\"/>\n                            <menuItem title=\"Quit APP_NAME\" keyEquivalent=\"q\" id=\"4sb-4s-VLi\">\n                                <connections>\n                                    <action selector=\"terminate:\" target=\"-1\" id=\"Te7-pn-YzF\"/>\n                                </connections>\n                            </menuItem>\n                        </items>\n                    </menu>\n                </menuItem>\n                <menuItem title=\"Edit\" id=\"5QF-Oa-p0T\">\n                    <modifierMask key=\"keyEquivalentModifierMask\"/>\n                    <menu key=\"submenu\" title=\"Edit\" id=\"W48-6f-4Dl\">\n                        <items>\n                            <menuItem title=\"Undo\" keyEquivalent=\"z\" id=\"dRJ-4n-Yzg\">\n                                <connections>\n                                    <action selector=\"undo:\" target=\"-1\" id=\"M6e-cu-g7V\"/>\n                                </connections>\n                            </menuItem>\n                            <menuItem title=\"Redo\" keyEquivalent=\"Z\" id=\"6dh-zS-Vam\">\n                                <connections>\n                                    <action selector=\"redo:\" target=\"-1\" id=\"oIA-Rs-6OD\"/>\n                                </connections>\n                            </menuItem>\n                            <menuItem isSeparatorItem=\"YES\" id=\"WRV-NI-Exz\"/>\n                            <menuItem title=\"Cut\" keyEquivalent=\"x\" id=\"uRl-iY-unG\">\n                                <connections>\n                                    <action selector=\"cut:\" target=\"-1\" id=\"YJe-68-I9s\"/>\n                                </connections>\n                            </menuItem>\n                            <menuItem title=\"Copy\" keyEquivalent=\"c\" id=\"x3v-GG-iWU\">\n                                <connections>\n                                    <action selector=\"copy:\" target=\"-1\" id=\"G1f-GL-Joy\"/>\n                                </connections>\n                            </menuItem>\n                            <menuItem title=\"Paste\" keyEquivalent=\"v\" id=\"gVA-U4-sdL\">\n                                <connections>\n                                    <action selector=\"paste:\" target=\"-1\" id=\"UvS-8e-Qdg\"/>\n                                </connections>\n                            </menuItem>\n                            <menuItem title=\"Paste and Match Style\" keyEquivalent=\"V\" id=\"WeT-3V-zwk\">\n                                <modifierMask key=\"keyEquivalentModifierMask\" option=\"YES\" command=\"YES\"/>\n                                <connections>\n                                    <action selector=\"pasteAsPlainText:\" target=\"-1\" id=\"cEh-KX-wJQ\"/>\n                                </connections>\n                            </menuItem>\n                            <menuItem title=\"Delete\" id=\"pa3-QI-u2k\">\n                                <modifierMask key=\"keyEquivalentModifierMask\"/>\n                                <connections>\n                                    <action selector=\"delete:\" target=\"-1\" id=\"0Mk-Ml-PaM\"/>\n                                </connections>\n                            </menuItem>\n                            <menuItem title=\"Select All\" keyEquivalent=\"a\" id=\"Ruw-6m-B2m\">\n                                <connections>\n                                    <action selector=\"selectAll:\" target=\"-1\" id=\"VNm-Mi-diN\"/>\n                                </connections>\n                            </menuItem>\n                            <menuItem isSeparatorItem=\"YES\" id=\"uyl-h8-XO2\"/>\n                            <menuItem title=\"Find\" id=\"4EN-yA-p0u\">\n                                <modifierMask key=\"keyEquivalentModifierMask\"/>\n                                <menu key=\"submenu\" title=\"Find\" id=\"1b7-l0-nxx\">\n                                    <items>\n                                        <menuItem title=\"Find…\" tag=\"1\" keyEquivalent=\"f\" id=\"Xz5-n4-O0W\">\n                                            <connections>\n                                                <action selector=\"performFindPanelAction:\" target=\"-1\" id=\"cD7-Qs-BN4\"/>\n                                            </connections>\n                                        </menuItem>\n                                        <menuItem title=\"Find and Replace…\" tag=\"12\" keyEquivalent=\"f\" id=\"YEy-JH-Tfz\">\n                                            <modifierMask key=\"keyEquivalentModifierMask\" option=\"YES\" command=\"YES\"/>\n                                            <connections>\n                                                <action selector=\"performFindPanelAction:\" target=\"-1\" id=\"WD3-Gg-5AJ\"/>\n                                            </connections>\n                                        </menuItem>\n                                        <menuItem title=\"Find Next\" tag=\"2\" keyEquivalent=\"g\" id=\"q09-fT-Sye\">\n                                            <connections>\n                                                <action selector=\"performFindPanelAction:\" target=\"-1\" id=\"NDo-RZ-v9R\"/>\n                                            </connections>\n                                        </menuItem>\n                                        <menuItem title=\"Find Previous\" tag=\"3\" keyEquivalent=\"G\" id=\"OwM-mh-QMV\">\n                                            <connections>\n                                                <action selector=\"performFindPanelAction:\" target=\"-1\" id=\"HOh-sY-3ay\"/>\n                                            </connections>\n                                        </menuItem>\n                                        <menuItem title=\"Use Selection for Find\" tag=\"7\" keyEquivalent=\"e\" id=\"buJ-ug-pKt\">\n                                            <connections>\n                                                <action selector=\"performFindPanelAction:\" target=\"-1\" id=\"U76-nv-p5D\"/>\n                                            </connections>\n                                        </menuItem>\n                                        <menuItem title=\"Jump to Selection\" keyEquivalent=\"j\" id=\"S0p-oC-mLd\">\n                                            <connections>\n                                                <action selector=\"centerSelectionInVisibleArea:\" target=\"-1\" id=\"IOG-6D-g5B\"/>\n                                            </connections>\n                                        </menuItem>\n                                    </items>\n                                </menu>\n                            </menuItem>\n                            <menuItem title=\"Spelling and Grammar\" id=\"Dv1-io-Yv7\">\n                                <modifierMask key=\"keyEquivalentModifierMask\"/>\n                                <menu key=\"submenu\" title=\"Spelling\" id=\"3IN-sU-3Bg\">\n                                    <items>\n                                        <menuItem title=\"Show Spelling and Grammar\" keyEquivalent=\":\" id=\"HFo-cy-zxI\">\n                                            <connections>\n                                                <action selector=\"showGuessPanel:\" target=\"-1\" id=\"vFj-Ks-hy3\"/>\n                                            </connections>\n                                        </menuItem>\n                                        <menuItem title=\"Check Document Now\" keyEquivalent=\";\" id=\"hz2-CU-CR7\">\n                                            <connections>\n                                                <action selector=\"checkSpelling:\" target=\"-1\" id=\"fz7-VC-reM\"/>\n                                            </connections>\n                                        </menuItem>\n                                        <menuItem isSeparatorItem=\"YES\" id=\"bNw-od-mp5\"/>\n                                        <menuItem title=\"Check Spelling While Typing\" id=\"rbD-Rh-wIN\">\n                                            <modifierMask key=\"keyEquivalentModifierMask\"/>\n                                            <connections>\n                                                <action selector=\"toggleContinuousSpellChecking:\" target=\"-1\" id=\"7w6-Qz-0kB\"/>\n                                            </connections>\n                                        </menuItem>\n                                        <menuItem title=\"Check Grammar With Spelling\" id=\"mK6-2p-4JG\">\n                                            <modifierMask key=\"keyEquivalentModifierMask\"/>\n                                            <connections>\n                                                <action selector=\"toggleGrammarChecking:\" target=\"-1\" id=\"muD-Qn-j4w\"/>\n                                            </connections>\n                                        </menuItem>\n                                        <menuItem title=\"Correct Spelling Automatically\" id=\"78Y-hA-62v\">\n                                            <modifierMask key=\"keyEquivalentModifierMask\"/>\n                                            <connections>\n                                                <action selector=\"toggleAutomaticSpellingCorrection:\" target=\"-1\" id=\"2lM-Qi-WAP\"/>\n                                            </connections>\n                                        </menuItem>\n                                    </items>\n                                </menu>\n                            </menuItem>\n                            <menuItem title=\"Substitutions\" id=\"9ic-FL-obx\">\n                                <modifierMask key=\"keyEquivalentModifierMask\"/>\n                                <menu key=\"submenu\" title=\"Substitutions\" id=\"FeM-D8-WVr\">\n                                    <items>\n                                        <menuItem title=\"Show Substitutions\" id=\"z6F-FW-3nz\">\n                                            <modifierMask key=\"keyEquivalentModifierMask\"/>\n                                            <connections>\n                                                <action selector=\"orderFrontSubstitutionsPanel:\" target=\"-1\" id=\"oku-mr-iSq\"/>\n                                            </connections>\n                                        </menuItem>\n                                        <menuItem isSeparatorItem=\"YES\" id=\"gPx-C9-uUO\"/>\n                                        <menuItem title=\"Smart Copy/Paste\" id=\"9yt-4B-nSM\">\n                                            <modifierMask key=\"keyEquivalentModifierMask\"/>\n                                            <connections>\n                                                <action selector=\"toggleSmartInsertDelete:\" target=\"-1\" id=\"3IJ-Se-DZD\"/>\n                                            </connections>\n                                        </menuItem>\n                                        <menuItem title=\"Smart Quotes\" id=\"hQb-2v-fYv\">\n                                            <modifierMask key=\"keyEquivalentModifierMask\"/>\n                                            <connections>\n                                                <action selector=\"toggleAutomaticQuoteSubstitution:\" target=\"-1\" id=\"ptq-xd-QOA\"/>\n                                            </connections>\n                                        </menuItem>\n                                        <menuItem title=\"Smart Dashes\" id=\"rgM-f4-ycn\">\n                                            <modifierMask key=\"keyEquivalentModifierMask\"/>\n                                            <connections>\n                                                <action selector=\"toggleAutomaticDashSubstitution:\" target=\"-1\" id=\"oCt-pO-9gS\"/>\n                                            </connections>\n                                        </menuItem>\n                                        <menuItem title=\"Smart Links\" id=\"cwL-P1-jid\">\n                                            <modifierMask key=\"keyEquivalentModifierMask\"/>\n                                            <connections>\n                                                <action selector=\"toggleAutomaticLinkDetection:\" target=\"-1\" id=\"Gip-E3-Fov\"/>\n                                            </connections>\n                                        </menuItem>\n                                        <menuItem title=\"Data Detectors\" id=\"tRr-pd-1PS\">\n                                            <modifierMask key=\"keyEquivalentModifierMask\"/>\n                                            <connections>\n                                                <action selector=\"toggleAutomaticDataDetection:\" target=\"-1\" id=\"R1I-Nq-Kbl\"/>\n                                            </connections>\n                                        </menuItem>\n                                        <menuItem title=\"Text Replacement\" id=\"HFQ-gK-NFA\">\n                                            <modifierMask key=\"keyEquivalentModifierMask\"/>\n                                            <connections>\n                                                <action selector=\"toggleAutomaticTextReplacement:\" target=\"-1\" id=\"DvP-Fe-Py6\"/>\n                                            </connections>\n                                        </menuItem>\n                                    </items>\n                                </menu>\n                            </menuItem>\n                            <menuItem title=\"Transformations\" id=\"2oI-Rn-ZJC\">\n                                <modifierMask key=\"keyEquivalentModifierMask\"/>\n                                <menu key=\"submenu\" title=\"Transformations\" id=\"c8a-y6-VQd\">\n                                    <items>\n                                        <menuItem title=\"Make Upper Case\" id=\"vmV-6d-7jI\">\n                                            <modifierMask key=\"keyEquivalentModifierMask\"/>\n                                            <connections>\n                                                <action selector=\"uppercaseWord:\" target=\"-1\" id=\"sPh-Tk-edu\"/>\n                                            </connections>\n                                        </menuItem>\n                                        <menuItem title=\"Make Lower Case\" id=\"d9M-CD-aMd\">\n                                            <modifierMask key=\"keyEquivalentModifierMask\"/>\n                                            <connections>\n                                                <action selector=\"lowercaseWord:\" target=\"-1\" id=\"iUZ-b5-hil\"/>\n                                            </connections>\n                                        </menuItem>\n                                        <menuItem title=\"Capitalize\" id=\"UEZ-Bs-lqG\">\n                                            <modifierMask key=\"keyEquivalentModifierMask\"/>\n                                            <connections>\n                                                <action selector=\"capitalizeWord:\" target=\"-1\" id=\"26H-TL-nsh\"/>\n                                            </connections>\n                                        </menuItem>\n                                    </items>\n                                </menu>\n                            </menuItem>\n                            <menuItem title=\"Speech\" id=\"xrE-MZ-jX0\">\n                                <modifierMask key=\"keyEquivalentModifierMask\"/>\n                                <menu key=\"submenu\" title=\"Speech\" id=\"3rS-ZA-NoH\">\n                                    <items>\n                                        <menuItem title=\"Start Speaking\" id=\"Ynk-f8-cLZ\">\n                                            <modifierMask key=\"keyEquivalentModifierMask\"/>\n                                            <connections>\n                                                <action selector=\"startSpeaking:\" target=\"-1\" id=\"654-Ng-kyl\"/>\n                                            </connections>\n                                        </menuItem>\n                                        <menuItem title=\"Stop Speaking\" id=\"Oyz-dy-DGm\">\n                                            <modifierMask key=\"keyEquivalentModifierMask\"/>\n                                            <connections>\n                                                <action selector=\"stopSpeaking:\" target=\"-1\" id=\"dX8-6p-jy9\"/>\n                                            </connections>\n                                        </menuItem>\n                                    </items>\n                                </menu>\n                            </menuItem>\n                        </items>\n                    </menu>\n                </menuItem>\n                <menuItem title=\"View\" id=\"H8h-7b-M4v\">\n                    <modifierMask key=\"keyEquivalentModifierMask\"/>\n                    <menu key=\"submenu\" title=\"View\" id=\"HyV-fh-RgO\">\n                        <items>\n                            <menuItem title=\"Enter Full Screen\" keyEquivalent=\"f\" id=\"4J7-dP-txa\">\n                                <modifierMask key=\"keyEquivalentModifierMask\" control=\"YES\" command=\"YES\"/>\n                                <connections>\n                                    <action selector=\"toggleFullScreen:\" target=\"-1\" id=\"dU3-MA-1Rq\"/>\n                                </connections>\n                            </menuItem>\n                        </items>\n                    </menu>\n                </menuItem>\n                <menuItem title=\"Window\" id=\"aUF-d1-5bR\">\n                    <modifierMask key=\"keyEquivalentModifierMask\"/>\n                    <menu key=\"submenu\" title=\"Window\" systemMenu=\"window\" id=\"Td7-aD-5lo\">\n                        <items>\n                            <menuItem title=\"Minimize\" keyEquivalent=\"m\" id=\"OY7-WF-poV\">\n                                <connections>\n                                    <action selector=\"performMiniaturize:\" target=\"-1\" id=\"VwT-WD-YPe\"/>\n                                </connections>\n                            </menuItem>\n                            <menuItem title=\"Zoom\" id=\"R4o-n2-Eq4\">\n                                <modifierMask key=\"keyEquivalentModifierMask\"/>\n                                <connections>\n                                    <action selector=\"performZoom:\" target=\"-1\" id=\"DIl-cC-cCs\"/>\n                                </connections>\n                            </menuItem>\n                            <menuItem isSeparatorItem=\"YES\" id=\"eu3-7i-yIM\"/>\n                            <menuItem title=\"Bring All to Front\" id=\"LE2-aR-0XJ\">\n                                <modifierMask key=\"keyEquivalentModifierMask\"/>\n                                <connections>\n                                    <action selector=\"arrangeInFront:\" target=\"-1\" id=\"DRN-fu-gQh\"/>\n                                </connections>\n                            </menuItem>\n                        </items>\n                    </menu>\n                </menuItem>\n                <menuItem title=\"Help\" id=\"EPT-qC-fAb\">\n                    <modifierMask key=\"keyEquivalentModifierMask\"/>\n                    <menu key=\"submenu\" title=\"Help\" systemMenu=\"help\" id=\"rJ0-wn-3NY\"/>\n                </menuItem>\n            </items>\n            <point key=\"canvasLocation\" x=\"142\" y=\"-258\"/>\n        </menu>\n        <window title=\"APP_NAME\" allowsToolTipsWhenApplicationIsInactive=\"NO\" autorecalculatesKeyViewLoop=\"NO\" releasedWhenClosed=\"NO\" animationBehavior=\"default\" id=\"QvC-M9-y7g\" customClass=\"MainFlutterWindow\" customModule=\"Runner\" customModuleProvider=\"target\">\n            <windowStyleMask key=\"styleMask\" titled=\"YES\" closable=\"YES\" miniaturizable=\"YES\" resizable=\"YES\"/>\n            <rect key=\"contentRect\" x=\"335\" y=\"390\" width=\"800\" height=\"600\"/>\n            <rect key=\"screenRect\" x=\"0.0\" y=\"0.0\" width=\"2560\" height=\"1577\"/>\n            <view key=\"contentView\" wantsLayer=\"YES\" id=\"EiT-Mj-1SZ\">\n                <rect key=\"frame\" x=\"0.0\" y=\"0.0\" width=\"800\" height=\"600\"/>\n                <autoresizingMask key=\"autoresizingMask\"/>\n            </view>\n        </window>\n    </objects>\n</document>\n"
  },
  {
    "path": "ui/flutter/macos/Runner/Configs/AppInfo.xcconfig",
    "content": "// Application-level settings for the Runner target.\n//\n// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the\n// future. If not, the values below would default to using the project name when this becomes a\n// 'flutter create' template.\n\n// The application's name. By default this is also the title of the Flutter window.\nPRODUCT_NAME = Gopeed\n\n// The application's bundle identifier\nPRODUCT_BUNDLE_IDENTIFIER = com.gopeed.gopeed\n\n// The copyright displayed in application information\nPRODUCT_COPYRIGHT = Copyright © 2022 com.example. All rights reserved.\n"
  },
  {
    "path": "ui/flutter/macos/Runner/Configs/Debug.xcconfig",
    "content": "#include \"../../Flutter/Flutter-Debug.xcconfig\"\n#include \"Warnings.xcconfig\"\n"
  },
  {
    "path": "ui/flutter/macos/Runner/Configs/Release.xcconfig",
    "content": "#include \"../../Flutter/Flutter-Release.xcconfig\"\n#include \"Warnings.xcconfig\"\n"
  },
  {
    "path": "ui/flutter/macos/Runner/Configs/Warnings.xcconfig",
    "content": "WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings\nGCC_WARN_UNDECLARED_SELECTOR = YES\nCLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES\nCLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE\nCLANG_WARN__DUPLICATE_METHOD_MATCH = YES\nCLANG_WARN_PRAGMA_PACK = YES\nCLANG_WARN_STRICT_PROTOTYPES = YES\nCLANG_WARN_COMMA = YES\nGCC_WARN_STRICT_SELECTOR_MATCH = YES\nCLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES\nCLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES\nGCC_WARN_SHADOW = YES\nCLANG_WARN_UNREACHABLE_CODE = YES\n"
  },
  {
    "path": "ui/flutter/macos/Runner/DebugProfile.entitlements",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>com.apple.security.cs.allow-jit</key>\n\t<true/>\n\t<key>com.apple.security.files.bookmarks.app-scope</key>\n\t<true/>\n</dict>\n</plist>\n"
  },
  {
    "path": "ui/flutter/macos/Runner/Info.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n\t<dict>\n\t\t<key>CFBundleDevelopmentRegion</key>\n\t\t<string>$(DEVELOPMENT_LANGUAGE)</string>\n\t\t<key>CFBundleExecutable</key>\n\t\t<string>$(EXECUTABLE_NAME)</string>\n\t\t<key>CFBundleIconFile</key>\n\t\t<string></string>\n\t\t<key>CFBundleIdentifier</key>\n\t\t<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>\n\t\t<key>CFBundleInfoDictionaryVersion</key>\n\t\t<string>6.0</string>\n\t\t<key>CFBundleName</key>\n\t\t<string>$(PRODUCT_NAME)</string>\n\t\t<key>CFBundlePackageType</key>\n\t\t<string>APPL</string>\n\t\t<key>CFBundleShortVersionString</key>\n\t\t<string>$(FLUTTER_BUILD_NAME)</string>\n\t\t<key>CFBundleVersion</key>\n\t\t<string>$(FLUTTER_BUILD_NUMBER)</string>\n\t\t<key>LSMinimumSystemVersion</key>\n\t\t<string>$(MACOSX_DEPLOYMENT_TARGET)</string>\n\t\t<key>NSHumanReadableCopyright</key>\n\t\t<string>$(PRODUCT_COPYRIGHT)</string>\n\t\t<key>NSMainNibFile</key>\n\t\t<string>MainMenu</string>\n\t\t<key>NSPrincipalClass</key>\n\t\t<string>NSApplication</string>\n\t\t<key>CFBundleURLTypes</key>\n\t\t<array>\n\t\t\t<dict>\n\t\t\t\t<key>CFBundleURLName</key>\n\t\t\t\t<string>gopeed</string>\n\t\t\t\t<key>CFBundleURLSchemes</key>\n\t\t\t\t<array>\n\t\t\t\t\t<string>gopeed</string>\n\t\t\t\t</array>\n\t\t\t</dict>\n\t\t\t<dict>\n\t\t\t\t<key>CFBundleURLName</key>\n\t\t\t\t<string>magnet</string>\n\t\t\t\t<key>CFBundleURLSchemes</key>\n\t\t\t\t<array>\n\t\t\t\t\t<string>magnet</string>\n\t\t\t\t</array>\n\t\t\t</dict>\n\t\t</array>\n\t\t<key>CFBundleDocumentTypes</key>\n\t\t<array>\n\t\t\t<dict>\n\t\t\t\t<key>CFBundleTypeExtensions</key>\n\t\t\t\t<array>\n\t\t\t\t\t<string>torrent</string>\n\t\t\t\t</array>\n\t\t\t\t<key>CFBundleTypeIconFile</key>\n\t\t\t\t<string>icon</string>\n\t\t\t\t<key>CFBundleTypeName</key>\n\t\t\t\t<string>BitTorrent Document</string>\n\t\t\t\t<key>CFBundleTypeRole</key>\n\t\t\t\t<string>Viewer</string>\n\t\t\t\t<key>LSTypeIsPackage</key>\n\t\t\t\t<false />\n\t\t\t\t<key>LSHandlerRank</key>\n\t\t\t\t<string>Owner</string>\n\t\t\t\t<key>LSItemContentTypes</key>\n\t\t\t\t<array>\n\t\t\t\t\t<string>org.bittorrent.torrent</string>\n\t\t\t\t</array>\n\t\t\t</dict>\n\t\t</array>\n\t\t<key>UTExportedTypeDeclarations</key>\n\t\t<array>\n\t\t\t<dict>\n\t\t\t\t<key>UTTypeIdentifier</key>\n\t\t\t\t<string>org.bittorrent.torrent</string>\n\t\t\t\t<key>UTTypeDescription</key>\n\t\t\t\t<string>Torrent File</string>\n\t\t\t\t<key>UTTypeConformsTo</key>\n\t\t\t\t<array>\n\t\t\t\t\t<string>public.data</string>\n\t\t\t\t</array>\n\t\t\t\t<key>UTTypeTagSpecification</key>\n\t\t\t\t<dict>\n\t\t\t\t\t<key>public.filename-extension</key>\n\t\t\t\t\t<array>\n\t\t\t\t\t\t<string>torrent</string>\n\t\t\t\t\t</array>\n\t\t\t\t\t<key>public.mime-type</key>\n\t\t\t\t\t<string>application/x-bittorrent</string>\n\t\t\t\t</dict>\n\t\t\t</dict>\n\t\t</array>\n\t</dict>\n</plist>"
  },
  {
    "path": "ui/flutter/macos/Runner/MainFlutterWindow.swift",
    "content": "import Cocoa\nimport FlutterMacOS\nimport window_manager\n\nclass MainFlutterWindow: NSWindow {\n  override func awakeFromNib() {\n    let flutterViewController = FlutterViewController.init()\n    let windowFrame = self.frame\n    self.contentViewController = flutterViewController\n    self.setFrame(windowFrame, display: true)\n\n    RegisterGeneratedPlugins(registry: flutterViewController)\n\n    super.awakeFromNib()\n  }\n\n  override public func order(_ place: NSWindow.OrderingMode, relativeTo otherWin: Int) {\n    super.order(place, relativeTo: otherWin)\n    hiddenWindowAtLaunch()\n  }\n}\n"
  },
  {
    "path": "ui/flutter/macos/Runner/Release.entitlements",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>com.apple.security.files.bookmarks.app-scope</key>\n\t<true/>\n</dict>\n</plist>\n"
  },
  {
    "path": "ui/flutter/macos/Runner.xcodeproj/project.pbxproj",
    "content": "// !$*UTF8*$!\n{\n\tarchiveVersion = 1;\n\tclasses = {\n\t};\n\tobjectVersion = 54;\n\tobjects = {\n\n/* Begin PBXAggregateTarget section */\n\t\t33CC111A2044C6BA0003C045 /* Flutter Assemble */ = {\n\t\t\tisa = PBXAggregateTarget;\n\t\t\tbuildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget \"Flutter Assemble\" */;\n\t\t\tbuildPhases = (\n\t\t\t\t33CC111E2044C6BF0003C045 /* ShellScript */,\n\t\t\t);\n\t\t\tdependencies = (\n\t\t\t);\n\t\t\tname = \"Flutter Assemble\";\n\t\t\tproductName = FLX;\n\t\t};\n/* End PBXAggregateTarget section */\n\n/* Begin PBXBuildFile section */\n\t\t0F970437545A111A20927325 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8592AF029594F4D655755C12 /* Pods_Runner.framework */; };\n\t\t335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; };\n\t\t33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; };\n\t\t33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; };\n\t\t33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };\n\t\t33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };\n\t\tD024547D28D2E650009728A7 /* libgopeed.dylib in Embed Libraries */ = {isa = PBXBuildFile; fileRef = D024547C28D2E650009728A7 /* libgopeed.dylib */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };\n/* End PBXBuildFile section */\n\n/* Begin PBXContainerItemProxy section */\n\t\t33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = {\n\t\t\tisa = PBXContainerItemProxy;\n\t\t\tcontainerPortal = 33CC10E52044A3C60003C045 /* Project object */;\n\t\t\tproxyType = 1;\n\t\t\tremoteGlobalIDString = 33CC111A2044C6BA0003C045;\n\t\t\tremoteInfo = FLX;\n\t\t};\n/* End PBXContainerItemProxy section */\n\n/* Begin PBXCopyFilesBuildPhase section */\n\t\tD024547928D2E58B009728A7 /* Embed Libraries */ = {\n\t\t\tisa = PBXCopyFilesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tdstPath = \"\";\n\t\t\tdstSubfolderSpec = 10;\n\t\t\tfiles = (\n\t\t\t\tD024547D28D2E650009728A7 /* libgopeed.dylib in Embed Libraries */,\n\t\t\t);\n\t\t\tname = \"Embed Libraries\";\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n/* End PBXCopyFilesBuildPhase section */\n\n/* Begin PBXFileReference section */\n\t\t1D1C571344AEA87ADEA020F0 /* 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 = \"<group>\"; };\n\t\t1DFC930634B94349932896F5 /* 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 = \"<group>\"; };\n\t\t333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = \"<group>\"; };\n\t\t335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = \"<group>\"; };\n\t\t33CC10ED2044A3C60003C045 /* gopeed.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = gopeed.app; sourceTree = BUILT_PRODUCTS_DIR; };\n\t\t33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = \"<group>\"; };\n\t\t33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = \"<group>\"; };\n\t\t33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = \"<group>\"; };\n\t\t33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = \"<group>\"; };\n\t\t33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = \"<group>\"; };\n\t\t33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = \"Flutter-Debug.xcconfig\"; sourceTree = \"<group>\"; };\n\t\t33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = \"Flutter-Release.xcconfig\"; sourceTree = \"<group>\"; };\n\t\t33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = \"Flutter-Generated.xcconfig\"; path = \"ephemeral/Flutter-Generated.xcconfig\"; sourceTree = \"<group>\"; };\n\t\t33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = \"<group>\"; };\n\t\t33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = \"<group>\"; };\n\t\t33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = \"<group>\"; };\n\t\t418692B5160A46EC301B4678 /* 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 = \"<group>\"; };\n\t\t7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = \"<group>\"; };\n\t\t8592AF029594F4D655755C12 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };\n\t\t9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = \"<group>\"; };\n\t\tD024547C28D2E650009728A7 /* libgopeed.dylib */ = {isa = PBXFileReference; lastKnownFileType = \"compiled.mach-o.dylib\"; name = libgopeed.dylib; path = Frameworks/libgopeed.dylib; sourceTree = \"<group>\"; };\n/* End PBXFileReference section */\n\n/* Begin PBXFrameworksBuildPhase section */\n\t\t33CC10EA2044A3C60003C045 /* Frameworks */ = {\n\t\t\tisa = PBXFrameworksBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\t0F970437545A111A20927325 /* Pods_Runner.framework in Frameworks */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n/* End PBXFrameworksBuildPhase section */\n\n/* Begin PBXGroup section */\n\t\t33BA886A226E78AF003329D5 /* Configs */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t33E5194F232828860026EE4D /* AppInfo.xcconfig */,\n\t\t\t\t9740EEB21CF90195004384FC /* Debug.xcconfig */,\n\t\t\t\t7AFA3C8E1D35360C0083082E /* Release.xcconfig */,\n\t\t\t\t333000ED22D3DE5D00554162 /* Warnings.xcconfig */,\n\t\t\t);\n\t\t\tpath = Configs;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t33CC10E42044A3C60003C045 = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\tD024547C28D2E650009728A7 /* libgopeed.dylib */,\n\t\t\t\t33FAB671232836740065AC1E /* Runner */,\n\t\t\t\t33CEB47122A05771004F2AC0 /* Flutter */,\n\t\t\t\t33CC10EE2044A3C60003C045 /* Products */,\n\t\t\t\tD73912EC22F37F3D000D13A0 /* Frameworks */,\n\t\t\t\t3923364A102657E047C00360 /* Pods */,\n\t\t\t);\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t33CC10EE2044A3C60003C045 /* Products */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t33CC10ED2044A3C60003C045 /* gopeed.app */,\n\t\t\t);\n\t\t\tname = Products;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t33CC11242044D66E0003C045 /* Resources */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t33CC10F22044A3C60003C045 /* Assets.xcassets */,\n\t\t\t\t33CC10F42044A3C60003C045 /* MainMenu.xib */,\n\t\t\t\t33CC10F72044A3C60003C045 /* Info.plist */,\n\t\t\t);\n\t\t\tname = Resources;\n\t\t\tpath = ..;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t33CEB47122A05771004F2AC0 /* Flutter */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */,\n\t\t\t\t33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */,\n\t\t\t\t33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */,\n\t\t\t\t33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */,\n\t\t\t);\n\t\t\tpath = Flutter;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t33FAB671232836740065AC1E /* Runner */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t33CC10F02044A3C60003C045 /* AppDelegate.swift */,\n\t\t\t\t33CC11122044BFA00003C045 /* MainFlutterWindow.swift */,\n\t\t\t\t33E51913231747F40026EE4D /* DebugProfile.entitlements */,\n\t\t\t\t33E51914231749380026EE4D /* Release.entitlements */,\n\t\t\t\t33CC11242044D66E0003C045 /* Resources */,\n\t\t\t\t33BA886A226E78AF003329D5 /* Configs */,\n\t\t\t);\n\t\t\tpath = Runner;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t3923364A102657E047C00360 /* Pods */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t1DFC930634B94349932896F5 /* Pods-Runner.debug.xcconfig */,\n\t\t\t\t418692B5160A46EC301B4678 /* Pods-Runner.release.xcconfig */,\n\t\t\t\t1D1C571344AEA87ADEA020F0 /* Pods-Runner.profile.xcconfig */,\n\t\t\t);\n\t\t\tpath = Pods;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\tD73912EC22F37F3D000D13A0 /* Frameworks */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t8592AF029594F4D655755C12 /* Pods_Runner.framework */,\n\t\t\t);\n\t\t\tname = Frameworks;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n/* End PBXGroup section */\n\n/* Begin PBXNativeTarget section */\n\t\t33CC10EC2044A3C60003C045 /* Runner */ = {\n\t\t\tisa = PBXNativeTarget;\n\t\t\tbuildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget \"Runner\" */;\n\t\t\tbuildPhases = (\n\t\t\t\tE5E56BC62824FB70A8F2274B /* [CP] Check Pods Manifest.lock */,\n\t\t\t\t33CC10E92044A3C60003C045 /* Sources */,\n\t\t\t\t33CC10EA2044A3C60003C045 /* Frameworks */,\n\t\t\t\t33CC10EB2044A3C60003C045 /* Resources */,\n\t\t\t\t3399D490228B24CF009A79C7 /* ShellScript */,\n\t\t\t\t5EF3421E5AE3B237C5D61783 /* [CP] Embed Pods Frameworks */,\n\t\t\t\tD024547928D2E58B009728A7 /* Embed Libraries */,\n\t\t\t);\n\t\t\tbuildRules = (\n\t\t\t);\n\t\t\tdependencies = (\n\t\t\t\t33CC11202044C79F0003C045 /* PBXTargetDependency */,\n\t\t\t);\n\t\t\tname = Runner;\n\t\t\tproductName = Runner;\n\t\t\tproductReference = 33CC10ED2044A3C60003C045 /* gopeed.app */;\n\t\t\tproductType = \"com.apple.product-type.application\";\n\t\t};\n/* End PBXNativeTarget section */\n\n/* Begin PBXProject section */\n\t\t33CC10E52044A3C60003C045 /* Project object */ = {\n\t\t\tisa = PBXProject;\n\t\t\tattributes = {\n\t\t\t\tLastSwiftUpdateCheck = 0920;\n\t\t\t\tLastUpgradeCheck = 1510;\n\t\t\t\tORGANIZATIONNAME = \"\";\n\t\t\t\tTargetAttributes = {\n\t\t\t\t\t33CC10EC2044A3C60003C045 = {\n\t\t\t\t\t\tCreatedOnToolsVersion = 9.2;\n\t\t\t\t\t\tLastSwiftMigration = 1100;\n\t\t\t\t\t\tProvisioningStyle = Automatic;\n\t\t\t\t\t\tSystemCapabilities = {\n\t\t\t\t\t\t\tcom.apple.Sandbox = {\n\t\t\t\t\t\t\t\tenabled = 1;\n\t\t\t\t\t\t\t};\n\t\t\t\t\t\t};\n\t\t\t\t\t};\n\t\t\t\t\t33CC111A2044C6BA0003C045 = {\n\t\t\t\t\t\tCreatedOnToolsVersion = 9.2;\n\t\t\t\t\t\tProvisioningStyle = Manual;\n\t\t\t\t\t};\n\t\t\t\t};\n\t\t\t};\n\t\t\tbuildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject \"Runner\" */;\n\t\t\tcompatibilityVersion = \"Xcode 9.3\";\n\t\t\tdevelopmentRegion = en;\n\t\t\thasScannedForEncodings = 0;\n\t\t\tknownRegions = (\n\t\t\t\ten,\n\t\t\t\tBase,\n\t\t\t);\n\t\t\tmainGroup = 33CC10E42044A3C60003C045;\n\t\t\tproductRefGroup = 33CC10EE2044A3C60003C045 /* Products */;\n\t\t\tprojectDirPath = \"\";\n\t\t\tprojectRoot = \"\";\n\t\t\ttargets = (\n\t\t\t\t33CC10EC2044A3C60003C045 /* Runner */,\n\t\t\t\t33CC111A2044C6BA0003C045 /* Flutter Assemble */,\n\t\t\t);\n\t\t};\n/* End PBXProject section */\n\n/* Begin PBXResourcesBuildPhase section */\n\t\t33CC10EB2044A3C60003C045 /* Resources */ = {\n\t\t\tisa = PBXResourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\t33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */,\n\t\t\t\t33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n/* End PBXResourcesBuildPhase section */\n\n/* Begin PBXShellScriptBuildPhase section */\n\t\t3399D490228B24CF009A79C7 /* ShellScript */ = {\n\t\t\tisa = PBXShellScriptBuildPhase;\n\t\t\talwaysOutOfDate = 1;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\tinputFileListPaths = (\n\t\t\t);\n\t\t\tinputPaths = (\n\t\t\t);\n\t\t\toutputFileListPaths = (\n\t\t\t);\n\t\t\toutputPaths = (\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t\tshellPath = /bin/sh;\n\t\t\tshellScript = \"echo \\\"$PRODUCT_NAME.app\\\" > \\\"$PROJECT_DIR\\\"/Flutter/ephemeral/.app_filename && \\\"$FLUTTER_ROOT\\\"/packages/flutter_tools/bin/macos_assemble.sh embed\\n\";\n\t\t};\n\t\t33CC111E2044C6BF0003C045 /* ShellScript */ = {\n\t\t\tisa = PBXShellScriptBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\tinputFileListPaths = (\n\t\t\t\tFlutter/ephemeral/FlutterInputs.xcfilelist,\n\t\t\t);\n\t\t\tinputPaths = (\n\t\t\t\tFlutter/ephemeral/tripwire,\n\t\t\t);\n\t\t\toutputFileListPaths = (\n\t\t\t\tFlutter/ephemeral/FlutterOutputs.xcfilelist,\n\t\t\t);\n\t\t\toutputPaths = (\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t\tshellPath = /bin/sh;\n\t\t\tshellScript = \"\\\"$FLUTTER_ROOT\\\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire\";\n\t\t};\n\t\t5EF3421E5AE3B237C5D61783 /* [CP] Embed Pods Frameworks */ = {\n\t\t\tisa = PBXShellScriptBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\tinputFileListPaths = (\n\t\t\t\t\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist\",\n\t\t\t);\n\t\t\tname = \"[CP] Embed Pods Frameworks\";\n\t\t\toutputFileListPaths = (\n\t\t\t\t\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist\",\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t\tshellPath = /bin/sh;\n\t\t\tshellScript = \"\\\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\\\"\\n\";\n\t\t\tshowEnvVarsInLog = 0;\n\t\t};\n\t\tE5E56BC62824FB70A8F2274B /* [CP] Check Pods Manifest.lock */ = {\n\t\t\tisa = PBXShellScriptBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\tinputFileListPaths = (\n\t\t\t);\n\t\t\tinputPaths = (\n\t\t\t\t\"${PODS_PODFILE_DIR_PATH}/Podfile.lock\",\n\t\t\t\t\"${PODS_ROOT}/Manifest.lock\",\n\t\t\t);\n\t\t\tname = \"[CP] Check Pods Manifest.lock\";\n\t\t\toutputFileListPaths = (\n\t\t\t);\n\t\t\toutputPaths = (\n\t\t\t\t\"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt\",\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t\tshellPath = /bin/sh;\n\t\t\tshellScript = \"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\";\n\t\t\tshowEnvVarsInLog = 0;\n\t\t};\n/* End PBXShellScriptBuildPhase section */\n\n/* Begin PBXSourcesBuildPhase section */\n\t\t33CC10E92044A3C60003C045 /* Sources */ = {\n\t\t\tisa = PBXSourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\t33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */,\n\t\t\t\t33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */,\n\t\t\t\t335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n/* End PBXSourcesBuildPhase section */\n\n/* Begin PBXTargetDependency section */\n\t\t33CC11202044C79F0003C045 /* PBXTargetDependency */ = {\n\t\t\tisa = PBXTargetDependency;\n\t\t\ttarget = 33CC111A2044C6BA0003C045 /* Flutter Assemble */;\n\t\t\ttargetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */;\n\t\t};\n/* End PBXTargetDependency section */\n\n/* Begin PBXVariantGroup section */\n\t\t33CC10F42044A3C60003C045 /* MainMenu.xib */ = {\n\t\t\tisa = PBXVariantGroup;\n\t\t\tchildren = (\n\t\t\t\t33CC10F52044A3C60003C045 /* Base */,\n\t\t\t);\n\t\t\tname = MainMenu.xib;\n\t\t\tpath = Runner;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n/* End PBXVariantGroup section */\n\n/* Begin XCBuildConfiguration section */\n\t\t338D0CE9231458BD00FA5F75 /* Profile */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;\n\t\t\tbuildSettings = {\n\t\t\t\tALWAYS_SEARCH_USER_PATHS = NO;\n\t\t\t\tCLANG_ANALYZER_NONNULL = YES;\n\t\t\t\tCLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;\n\t\t\t\tCLANG_CXX_LANGUAGE_STANDARD = \"gnu++14\";\n\t\t\t\tCLANG_CXX_LIBRARY = \"libc++\";\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCLANG_ENABLE_OBJC_ARC = YES;\n\t\t\t\tCLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;\n\t\t\t\tCLANG_WARN_BOOL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_CONSTANT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;\n\t\t\t\tCLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;\n\t\t\t\tCLANG_WARN_DOCUMENTATION_COMMENTS = YES;\n\t\t\t\tCLANG_WARN_EMPTY_BODY = YES;\n\t\t\t\tCLANG_WARN_ENUM_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_INFINITE_RECURSION = YES;\n\t\t\t\tCLANG_WARN_INT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_LITERAL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;\n\t\t\t\tCLANG_WARN_RANGE_LOOP_ANALYSIS = YES;\n\t\t\t\tCLANG_WARN_SUSPICIOUS_MOVE = YES;\n\t\t\t\tCODE_SIGN_IDENTITY = \"-\";\n\t\t\t\tCOPY_PHASE_STRIP = NO;\n\t\t\t\tDEBUG_INFORMATION_FORMAT = \"dwarf-with-dsym\";\n\t\t\t\tENABLE_NS_ASSERTIONS = NO;\n\t\t\t\tENABLE_STRICT_OBJC_MSGSEND = YES;\n\t\t\t\tGCC_C_LANGUAGE_STANDARD = gnu11;\n\t\t\t\tGCC_NO_COMMON_BLOCKS = YES;\n\t\t\t\tGCC_WARN_64_TO_32_BIT_CONVERSION = YES;\n\t\t\t\tGCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;\n\t\t\t\tGCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;\n\t\t\t\tGCC_WARN_UNUSED_FUNCTION = YES;\n\t\t\t\tGCC_WARN_UNUSED_VARIABLE = YES;\n\t\t\t\tMACOSX_DEPLOYMENT_TARGET = 10.15;\n\t\t\t\tMTL_ENABLE_DEBUG_INFO = NO;\n\t\t\t\tSDKROOT = macosx;\n\t\t\t\tSWIFT_COMPILATION_MODE = wholemodule;\n\t\t\t\tSWIFT_OPTIMIZATION_LEVEL = \"-O\";\n\t\t\t};\n\t\t\tname = Profile;\n\t\t};\n\t\t338D0CEA231458BD00FA5F75 /* Profile */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;\n\t\t\tbuildSettings = {\n\t\t\t\tASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;\n\t\t\t\tCODE_SIGN_STYLE = Automatic;\n\t\t\t\tCOMBINE_HIDPI_IMAGES = YES;\n\t\t\t\tINFOPLIST_FILE = Runner/Info.plist;\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/../Frameworks\",\n\t\t\t\t);\n\t\t\t\tLIBRARY_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"$(PROJECT_DIR)/Frameworks\",\n\t\t\t\t);\n\t\t\t\tPROVISIONING_PROFILE_SPECIFIER = \"\";\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t};\n\t\t\tname = Profile;\n\t\t};\n\t\t338D0CEB231458BD00FA5F75 /* Profile */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tCODE_SIGN_STYLE = Manual;\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t};\n\t\t\tname = Profile;\n\t\t};\n\t\t33CC10F92044A3C60003C045 /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;\n\t\t\tbuildSettings = {\n\t\t\t\tALWAYS_SEARCH_USER_PATHS = NO;\n\t\t\t\tCLANG_ANALYZER_NONNULL = YES;\n\t\t\t\tCLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;\n\t\t\t\tCLANG_CXX_LANGUAGE_STANDARD = \"gnu++14\";\n\t\t\t\tCLANG_CXX_LIBRARY = \"libc++\";\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCLANG_ENABLE_OBJC_ARC = YES;\n\t\t\t\tCLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;\n\t\t\t\tCLANG_WARN_BOOL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_CONSTANT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;\n\t\t\t\tCLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;\n\t\t\t\tCLANG_WARN_DOCUMENTATION_COMMENTS = YES;\n\t\t\t\tCLANG_WARN_EMPTY_BODY = YES;\n\t\t\t\tCLANG_WARN_ENUM_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_INFINITE_RECURSION = YES;\n\t\t\t\tCLANG_WARN_INT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_LITERAL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;\n\t\t\t\tCLANG_WARN_RANGE_LOOP_ANALYSIS = YES;\n\t\t\t\tCLANG_WARN_SUSPICIOUS_MOVE = YES;\n\t\t\t\tCODE_SIGN_IDENTITY = \"-\";\n\t\t\t\tCOPY_PHASE_STRIP = NO;\n\t\t\t\tDEBUG_INFORMATION_FORMAT = dwarf;\n\t\t\t\tENABLE_STRICT_OBJC_MSGSEND = YES;\n\t\t\t\tENABLE_TESTABILITY = YES;\n\t\t\t\tGCC_C_LANGUAGE_STANDARD = gnu11;\n\t\t\t\tGCC_DYNAMIC_NO_PIC = NO;\n\t\t\t\tGCC_NO_COMMON_BLOCKS = YES;\n\t\t\t\tGCC_OPTIMIZATION_LEVEL = 0;\n\t\t\t\tGCC_PREPROCESSOR_DEFINITIONS = (\n\t\t\t\t\t\"DEBUG=1\",\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t);\n\t\t\t\tGCC_WARN_64_TO_32_BIT_CONVERSION = YES;\n\t\t\t\tGCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;\n\t\t\t\tGCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;\n\t\t\t\tGCC_WARN_UNUSED_FUNCTION = YES;\n\t\t\t\tGCC_WARN_UNUSED_VARIABLE = YES;\n\t\t\t\tMACOSX_DEPLOYMENT_TARGET = 10.15;\n\t\t\t\tMTL_ENABLE_DEBUG_INFO = YES;\n\t\t\t\tONLY_ACTIVE_ARCH = YES;\n\t\t\t\tSDKROOT = macosx;\n\t\t\t\tSWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;\n\t\t\t\tSWIFT_OPTIMIZATION_LEVEL = \"-Onone\";\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\t33CC10FA2044A3C60003C045 /* Release */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;\n\t\t\tbuildSettings = {\n\t\t\t\tALWAYS_SEARCH_USER_PATHS = NO;\n\t\t\t\tCLANG_ANALYZER_NONNULL = YES;\n\t\t\t\tCLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;\n\t\t\t\tCLANG_CXX_LANGUAGE_STANDARD = \"gnu++14\";\n\t\t\t\tCLANG_CXX_LIBRARY = \"libc++\";\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCLANG_ENABLE_OBJC_ARC = YES;\n\t\t\t\tCLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;\n\t\t\t\tCLANG_WARN_BOOL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_CONSTANT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;\n\t\t\t\tCLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;\n\t\t\t\tCLANG_WARN_DOCUMENTATION_COMMENTS = YES;\n\t\t\t\tCLANG_WARN_EMPTY_BODY = YES;\n\t\t\t\tCLANG_WARN_ENUM_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_INFINITE_RECURSION = YES;\n\t\t\t\tCLANG_WARN_INT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_LITERAL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;\n\t\t\t\tCLANG_WARN_RANGE_LOOP_ANALYSIS = YES;\n\t\t\t\tCLANG_WARN_SUSPICIOUS_MOVE = YES;\n\t\t\t\tCODE_SIGN_IDENTITY = \"-\";\n\t\t\t\tCOPY_PHASE_STRIP = NO;\n\t\t\t\tDEBUG_INFORMATION_FORMAT = \"dwarf-with-dsym\";\n\t\t\t\tENABLE_NS_ASSERTIONS = NO;\n\t\t\t\tENABLE_STRICT_OBJC_MSGSEND = YES;\n\t\t\t\tGCC_C_LANGUAGE_STANDARD = gnu11;\n\t\t\t\tGCC_NO_COMMON_BLOCKS = YES;\n\t\t\t\tGCC_WARN_64_TO_32_BIT_CONVERSION = YES;\n\t\t\t\tGCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;\n\t\t\t\tGCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;\n\t\t\t\tGCC_WARN_UNUSED_FUNCTION = YES;\n\t\t\t\tGCC_WARN_UNUSED_VARIABLE = YES;\n\t\t\t\tMACOSX_DEPLOYMENT_TARGET = 10.15;\n\t\t\t\tMTL_ENABLE_DEBUG_INFO = NO;\n\t\t\t\tSDKROOT = macosx;\n\t\t\t\tSWIFT_COMPILATION_MODE = wholemodule;\n\t\t\t\tSWIFT_OPTIMIZATION_LEVEL = \"-O\";\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n\t\t33CC10FC2044A3C60003C045 /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;\n\t\t\tbuildSettings = {\n\t\t\t\tASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;\n\t\t\t\tCODE_SIGN_STYLE = Automatic;\n\t\t\t\tCOMBINE_HIDPI_IMAGES = YES;\n\t\t\t\tINFOPLIST_FILE = Runner/Info.plist;\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/../Frameworks\",\n\t\t\t\t);\n\t\t\t\tLIBRARY_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"$(PROJECT_DIR)/Frameworks\",\n\t\t\t\t);\n\t\t\t\tPROVISIONING_PROFILE_SPECIFIER = \"\";\n\t\t\t\tSWIFT_OPTIMIZATION_LEVEL = \"-Onone\";\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\t33CC10FD2044A3C60003C045 /* Release */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;\n\t\t\tbuildSettings = {\n\t\t\t\tASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements;\n\t\t\t\tCODE_SIGN_STYLE = Automatic;\n\t\t\t\tCOMBINE_HIDPI_IMAGES = YES;\n\t\t\t\tINFOPLIST_FILE = Runner/Info.plist;\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/../Frameworks\",\n\t\t\t\t);\n\t\t\t\tLIBRARY_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"$(PROJECT_DIR)/Frameworks\",\n\t\t\t\t);\n\t\t\t\tPROVISIONING_PROFILE_SPECIFIER = \"\";\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n\t\t33CC111C2044C6BA0003C045 /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tCODE_SIGN_STYLE = Manual;\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\t33CC111D2044C6BA0003C045 /* Release */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tCODE_SIGN_STYLE = Automatic;\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n/* End XCBuildConfiguration section */\n\n/* Begin XCConfigurationList section */\n\t\t33CC10E82044A3C60003C045 /* Build configuration list for PBXProject \"Runner\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\t33CC10F92044A3C60003C045 /* Debug */,\n\t\t\t\t33CC10FA2044A3C60003C045 /* Release */,\n\t\t\t\t338D0CE9231458BD00FA5F75 /* Profile */,\n\t\t\t);\n\t\t\tdefaultConfigurationIsVisible = 0;\n\t\t\tdefaultConfigurationName = Release;\n\t\t};\n\t\t33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget \"Runner\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\t33CC10FC2044A3C60003C045 /* Debug */,\n\t\t\t\t33CC10FD2044A3C60003C045 /* Release */,\n\t\t\t\t338D0CEA231458BD00FA5F75 /* Profile */,\n\t\t\t);\n\t\t\tdefaultConfigurationIsVisible = 0;\n\t\t\tdefaultConfigurationName = Release;\n\t\t};\n\t\t33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget \"Flutter Assemble\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\t33CC111C2044C6BA0003C045 /* Debug */,\n\t\t\t\t33CC111D2044C6BA0003C045 /* Release */,\n\t\t\t\t338D0CEB231458BD00FA5F75 /* Profile */,\n\t\t\t);\n\t\t\tdefaultConfigurationIsVisible = 0;\n\t\t\tdefaultConfigurationName = Release;\n\t\t};\n/* End XCConfigurationList section */\n\t};\n\trootObject = 33CC10E52044A3C60003C045 /* Project object */;\n}\n"
  },
  {
    "path": "ui/flutter/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>IDEDidComputeMac32BitWarning</key>\n\t<true/>\n</dict>\n</plist>\n"
  },
  {
    "path": "ui/flutter/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Scheme\n   LastUpgradeVersion = \"1510\"\n   version = \"1.3\">\n   <BuildAction\n      parallelizeBuildables = \"YES\"\n      buildImplicitDependencies = \"YES\">\n      <BuildActionEntries>\n         <BuildActionEntry\n            buildForTesting = \"YES\"\n            buildForRunning = \"YES\"\n            buildForProfiling = \"YES\"\n            buildForArchiving = \"YES\"\n            buildForAnalyzing = \"YES\">\n            <BuildableReference\n               BuildableIdentifier = \"primary\"\n               BlueprintIdentifier = \"33CC10EC2044A3C60003C045\"\n               BuildableName = \"Gopeed.app\"\n               BlueprintName = \"Runner\"\n               ReferencedContainer = \"container:Runner.xcodeproj\">\n            </BuildableReference>\n         </BuildActionEntry>\n      </BuildActionEntries>\n   </BuildAction>\n   <TestAction\n      buildConfiguration = \"Debug\"\n      selectedDebuggerIdentifier = \"Xcode.DebuggerFoundation.Debugger.LLDB\"\n      selectedLauncherIdentifier = \"Xcode.DebuggerFoundation.Launcher.LLDB\"\n      shouldUseLaunchSchemeArgsEnv = \"YES\">\n      <MacroExpansion>\n         <BuildableReference\n            BuildableIdentifier = \"primary\"\n            BlueprintIdentifier = \"33CC10EC2044A3C60003C045\"\n            BuildableName = \"Gopeed.app\"\n            BlueprintName = \"Runner\"\n            ReferencedContainer = \"container:Runner.xcodeproj\">\n         </BuildableReference>\n      </MacroExpansion>\n      <Testables>\n      </Testables>\n   </TestAction>\n   <LaunchAction\n      buildConfiguration = \"Debug\"\n      selectedDebuggerIdentifier = \"Xcode.DebuggerFoundation.Debugger.LLDB\"\n      selectedLauncherIdentifier = \"Xcode.DebuggerFoundation.Launcher.LLDB\"\n      launchStyle = \"0\"\n      useCustomWorkingDirectory = \"NO\"\n      ignoresPersistentStateOnLaunch = \"NO\"\n      debugDocumentVersioning = \"YES\"\n      debugServiceExtension = \"internal\"\n      enableGPUValidationMode = \"1\"\n      allowLocationSimulation = \"YES\">\n      <BuildableProductRunnable\n         runnableDebuggingMode = \"0\">\n         <BuildableReference\n            BuildableIdentifier = \"primary\"\n            BlueprintIdentifier = \"33CC10EC2044A3C60003C045\"\n            BuildableName = \"Gopeed.app\"\n            BlueprintName = \"Runner\"\n            ReferencedContainer = \"container:Runner.xcodeproj\">\n         </BuildableReference>\n      </BuildableProductRunnable>\n   </LaunchAction>\n   <ProfileAction\n      buildConfiguration = \"Profile\"\n      shouldUseLaunchSchemeArgsEnv = \"YES\"\n      savedToolIdentifier = \"\"\n      useCustomWorkingDirectory = \"NO\"\n      debugDocumentVersioning = \"YES\">\n      <BuildableProductRunnable\n         runnableDebuggingMode = \"0\">\n         <BuildableReference\n            BuildableIdentifier = \"primary\"\n            BlueprintIdentifier = \"33CC10EC2044A3C60003C045\"\n            BuildableName = \"Gopeed.app\"\n            BlueprintName = \"Runner\"\n            ReferencedContainer = \"container:Runner.xcodeproj\">\n         </BuildableReference>\n      </BuildableProductRunnable>\n   </ProfileAction>\n   <AnalyzeAction\n      buildConfiguration = \"Debug\">\n   </AnalyzeAction>\n   <ArchiveAction\n      buildConfiguration = \"Release\"\n      revealArchiveInOrganizer = \"YES\">\n   </ArchiveAction>\n</Scheme>\n"
  },
  {
    "path": "ui/flutter/macos/Runner.xcworkspace/contents.xcworkspacedata",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Workspace\n   version = \"1.0\">\n   <FileRef\n      location = \"group:Runner.xcodeproj\">\n   </FileRef>\n   <FileRef\n      location = \"group:Pods/Pods.xcodeproj\">\n   </FileRef>\n</Workspace>\n"
  },
  {
    "path": "ui/flutter/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>IDEDidComputeMac32BitWarning</key>\n\t<true/>\n</dict>\n</plist>\n"
  },
  {
    "path": "ui/flutter/pubspec.yaml",
    "content": "name: gopeed\ndescription: A new Flutter project.\n\n# The following line prevents the package from being accidentally published to\n# pub.dev using `flutter pub publish`. This is preferred for private packages.\npublish_to: \"none\" # Remove this line if you wish to publish to pub.dev\n\n# The following defines the version and build number for your application.\n# A version number is three numbers separated by dots, like 1.2.43\n# followed by an optional build number separated by a +.\n# Both the version and the builder number may be overridden in flutter\n# build by specifying --build-name and --build-number, respectively.\n# In Android, build-name is used as versionName while build-number used as versionCode.\n# Read more about Android versioning at https://developer.android.com/studio/publish/versioning\n# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.\n# Read more about iOS versioning at\n# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html\nversion: 1.9.3+1\n\nenvironment:\n  sdk: \">=3.0.0 <4.0.0\"\n\n# Dependencies specify other packages that your package needs in order to work.\n# To automatically upgrade your package dependencies to the latest versions\n# consider running `flutter pub upgrade --major-versions`. Alternatively,\n# dependencies can be manually updated by changing the version numbers below to\n# the latest version available on pub.dev. To see which dependencies have newer\n# versions available, run `flutter pub outdated`.\ndependencies:\n  flutter:\n    sdk: flutter\n  flutter_localizations:\n    sdk: flutter\n\n  # The following adds the Cupertino Icons font to your application.\n  # Use with the CupertinoIcons class for iOS style icons.\n  cupertino_icons: ^1.0.2\n  get: ^4.6.6\n  styled_widget: ^0.4.0+3\n  flutter_context_menu: ^0.4.1\n  json_annotation: ^4.8.1\n  dio: ^5.2.1\n  path_provider: ^2.1.4\n  file_picker: ^8.1.2\n  rounded_loading_button_plus: ^3.0.1\n  url_launcher: ^6.3.1\n  logger: ^1.4.0\n  desktop_drop: ^0.4.4\n  package_info_plus: ^8.1.0\n  path: ^1.9.0\n  badges: ^3.0.3\n  app_links: ^6.3.3\n  uri_to_file: ^1.0.0\n  window_manager: ^0.4.2\n  hotkey_manager: ^0.2.3\n  share_plus: ^10.1.0\n  flutter_form_builder: ^10.2.0\n  form_builder_validators: ^11.0.0\n  flutter_foreground_task: ^8.2.0\n  open_filex: ^4.7.0\n  tray_manager:\n    git:\n      url: https://github.com/monkeyWie/tray_manager.git\n      ref: main\n      path: packages/tray_manager\n  lecle_downloads_path_provider: ^0.0.2+8\n  hive: ^2.2.3\n  launch_at_startup: ^0.3.1\n  args: ^2.5.0\n  toggle_switch: ^2.3.0\n  permission_handler: ^11.3.1\n  device_info_plus: ^11.1.0\n  checkable_treeview: ^1.3.1\n  contentsize_tabbarview: ^0.0.2\n  win32_registry: ^1.1.5\n  share_handler: ^0.0.22\n  crypto: ^3.0.6\n  open_dir: ^0.0.2+1\n  install_plugin:\n    git:\n      url: https://github.com/hui-z/flutter_install_plugin.git\n      ref: cf08af829f4a4145634f8a047108f505fdbe5eaa\n  flutter_svg: ^2.0.17\n  flutter_markdown_plus: ^1.0.3\n  dart_ipc: ^1.0.1\n  flutter_local_notifications: 20.1.0\ndependency_overrides:\n  permission_handler_windows:\n    git:\n      url: https://github.com/monkeyWie/flutter-permission-handler.git\n      ref: 35fc72c30262b9b49e1965b48a7524b44ba9daa7\n      path: permission_handler_windows\ndev_dependencies:\n  flutter_test:\n    sdk: flutter\n\n  # The \"flutter_lints\" package below contains a set of recommended lints to\n  # encourage good coding practices. The lint set provided by the package is\n  # activated in the `analysis_options.yaml` file located at the root of your\n  # package. See that file for information about deactivating specific lint\n  # rules and activating additional ones.\n  flutter_lints: ^2.0.0\n  ffigen: ^8.0.2\n  # build json model: flutter pub run build_runner build --delete-conflicting-outputs\n  build_runner: ^2.2.1\n  json_serializable: ^6.3.2\n  flutter_launcher_icons: ^0.13.1\n\n# For information on the generic Dart part of this file, see the\n# following page: https://dart.dev/tools/pub/pubspec\n\n# The following section is specific to Flutter packages.\nflutter:\n  # The following line ensures that the Material Icons font is\n  # included with your application, so that you can use the icons in\n  # the material Icons class.\n  uses-material-design: true\n\n  # To add assets to your application, add an assets section, like this:\n  assets:\n    - assets/tray_icon/\n    - assets/extension/\n    - assets/icon/\n    # Browser extension native host binary, see https://github.com/GopeedLab/gopeed/tree/main/cmd/host\n    - assets/exec/\n\n  # An image asset can refer to one or more resolution-specific \"variants\", see\n  # https://flutter.dev/assets-and-images/#resolution-aware\n\n  # For details regarding adding assets from package dependencies, see\n  # https://flutter.dev/assets-and-images/#from-packages\n\n  # To add custom fonts to your application, add a fonts section here,\n  # in this \"flutter\" section. Each entry in this list should have a\n  # \"family\" key with the font family name, and a \"fonts\" key with a\n  # list giving the asset and other descriptors for the font. For\n  # example:\n  fonts:\n    - family: Gopeed\n      fonts:\n        - asset: assets/fonts/Gopeed.ttf\n  #   - family: Trajan Pro\n  #     fonts:\n  #       - asset: fonts/TrajanPro.ttf\n  #       - asset: fonts/TrajanPro_Bold.ttf\n  #         weight: 700\n  #\n  # For details regarding fonts from package dependencies,\n  # see https://flutter.dev/custom-fonts/#from-packages\n\n# flutter pub run flutter_launcher_icons:main\nflutter_icons:\n  image_path: \"assets/icon/icon_1024.png\"\n  android: true # can specify file name here e.g. \"ic_launcher\"\n  ios: true\n  remove_alpha_ios: true\n  web:\n    generate: true\n    image_path: \"assets/icon/icon_1024.png\"\n  windows:\n    generate: true\n    image_path: \"assets/icon/icon_1024.png\"\n  macos:\n    generate: true\n    image_path: \"assets/icon/icon_macos_1024.png\"\n\n# flutter pub run ffigen\nffigen:\n  name: LibgopeedBind\n  description: Bindings to gopeed library.\n  output: \"lib/core/ffi/libgopeed_bind.dart\"\n  headers:\n    entry-points:\n      - \"include/libgopeed.h\"\n"
  },
  {
    "path": "ui/flutter/test/widget_test.dart",
    "content": "// This is a basic Flutter widget test.\n//\n// To perform an interaction with a widget in your test, use the WidgetTester\n// utility that Flutter provides. For example, you can send tap and scroll\n// gestures. You can also use WidgetTester to find child widgets in the widget\n// tree, read text, and verify that the values of widget properties are correct.\n\nimport 'package:flutter_test/flutter_test.dart';\n\nvoid main() {\n  testWidgets('Counter increments smoke test', (WidgetTester tester) async {});\n}\n"
  },
  {
    "path": "ui/flutter/web/index.html",
    "content": "<!DOCTYPE html>\n<html>\n<head>\n  <meta charset=\"UTF-8\">\n  <meta content=\"IE=Edge\" http-equiv=\"X-UA-Compatible\">\n  <meta name=\"description\" content=\"A modern download manager.\">\n\n  <!-- iOS meta tags & icons -->\n  <meta name=\"apple-mobile-web-app-capable\" content=\"yes\">\n  <meta name=\"apple-mobile-web-app-status-bar-style\" content=\"black\">\n  <meta name=\"apple-mobile-web-app-title\" content=\"Gopeed\">\n  <link rel=\"apple-touch-icon\" href=\"icons/Icon-192.png\">\n\n  <!-- Favicon -->\n  <link rel=\"icon\" type=\"image/png\" href=\"favicon.png\"/>\n\n  <title>Gopeed</title>\n  <link rel=\"manifest\" href=\"manifest.json\">\n</head>\n<body>\n<script src=\"flutter_bootstrap.js\" async></script>\n</body>\n</html>"
  },
  {
    "path": "ui/flutter/web/manifest.json",
    "content": "{\n    \"name\": \"gopeed\",\n    \"short_name\": \"gopeed\",\n    \"start_url\": \".\",\n    \"display\": \"standalone\",\n    \"background_color\": \"#hexcode\",\n    \"theme_color\": \"#hexcode\",\n    \"description\": \"A new Flutter project.\",\n    \"orientation\": \"portrait-primary\",\n    \"prefer_related_applications\": false,\n    \"icons\": [\n        {\n            \"src\": \"icons/Icon-192.png\",\n            \"sizes\": \"192x192\",\n            \"type\": \"image/png\"\n        },\n        {\n            \"src\": \"icons/Icon-512.png\",\n            \"sizes\": \"512x512\",\n            \"type\": \"image/png\"\n        },\n        {\n            \"src\": \"icons/Icon-maskable-192.png\",\n            \"sizes\": \"192x192\",\n            \"type\": \"image/png\",\n            \"purpose\": \"maskable\"\n        },\n        {\n            \"src\": \"icons/Icon-maskable-512.png\",\n            \"sizes\": \"512x512\",\n            \"type\": \"image/png\",\n            \"purpose\": \"maskable\"\n        }\n    ]\n}"
  },
  {
    "path": "ui/flutter/windows/.gitignore",
    "content": "flutter/ephemeral/\n\n# Visual Studio user-specific files.\n*.suo\n*.user\n*.userosscache\n*.sln.docstates\n\n# Visual Studio build-related files.\nx64/\nx86/\n\n# Visual Studio cache files\n# files ending in .cache can be ignored\n*.[Cc]ache\n# but keep track of directories ending in .cache\n!*.[Cc]ache/\n"
  },
  {
    "path": "ui/flutter/windows/CMakeLists.txt",
    "content": "# Project-level configuration.\ncmake_minimum_required(VERSION 3.14)\nproject(gopeed LANGUAGES CXX)\n\n# The name of the executable created for the application. Change this to change\n# the on-disk name of your application.\nset(BINARY_NAME \"gopeed\")\n\n# Explicitly opt in to modern CMake behaviors to avoid warnings with recent\n# versions of CMake.\ncmake_policy(SET CMP0063 NEW)\n\n# Define build configuration option.\nget_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG)\nif(IS_MULTICONFIG)\n  set(CMAKE_CONFIGURATION_TYPES \"Debug;Profile;Release\"\n    CACHE STRING \"\" FORCE)\nelse()\n  if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)\n    set(CMAKE_BUILD_TYPE \"Debug\" CACHE\n      STRING \"Flutter build mode\" FORCE)\n    set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS\n      \"Debug\" \"Profile\" \"Release\")\n  endif()\nendif()\n# Define settings for the Profile build mode.\nset(CMAKE_EXE_LINKER_FLAGS_PROFILE \"${CMAKE_EXE_LINKER_FLAGS_RELEASE}\")\nset(CMAKE_SHARED_LINKER_FLAGS_PROFILE \"${CMAKE_SHARED_LINKER_FLAGS_RELEASE}\")\nset(CMAKE_C_FLAGS_PROFILE \"${CMAKE_C_FLAGS_RELEASE}\")\nset(CMAKE_CXX_FLAGS_PROFILE \"${CMAKE_CXX_FLAGS_RELEASE}\")\n\n# Use Unicode for all projects.\nadd_definitions(-DUNICODE -D_UNICODE)\n\n# Compilation settings that should be applied to most targets.\n#\n# Be cautious about adding new options here, as plugins use this function by\n# default. In most cases, you should add new options to specific targets instead\n# of modifying this function.\nfunction(APPLY_STANDARD_SETTINGS TARGET)\n  target_compile_features(${TARGET} PUBLIC cxx_std_17)\n  target_compile_options(${TARGET} PRIVATE /W4 /WX /wd\"4100\")\n  target_compile_options(${TARGET} PRIVATE /EHsc)\n  target_compile_definitions(${TARGET} PRIVATE \"_HAS_EXCEPTIONS=0\")\n  target_compile_definitions(${TARGET} PRIVATE \"$<$<CONFIG:Debug>:_DEBUG>\")\nendfunction()\n\n# Flutter library and tool build rules.\nset(FLUTTER_MANAGED_DIR \"${CMAKE_CURRENT_SOURCE_DIR}/flutter\")\nadd_subdirectory(${FLUTTER_MANAGED_DIR})\n\n# Application build; see runner/CMakeLists.txt.\nadd_subdirectory(\"runner\")\n\n# Generated plugin build rules, which manage building the plugins and adding\n# them to the application.\ninclude(flutter/generated_plugins.cmake)\n\n\n# === Installation ===\n# Support files are copied into place next to the executable, so that it can\n# run in place. This is done instead of making a separate bundle (as on Linux)\n# so that building and running from within Visual Studio will work.\nset(BUILD_BUNDLE_DIR \"$<TARGET_FILE_DIR:${BINARY_NAME}>\")\n# Make the \"install\" step default, as it's required to run.\nset(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1)\nif(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)\n  set(CMAKE_INSTALL_PREFIX \"${BUILD_BUNDLE_DIR}\" CACHE PATH \"...\" FORCE)\nendif()\n\nset(INSTALL_BUNDLE_DATA_DIR \"${CMAKE_INSTALL_PREFIX}/data\")\nset(INSTALL_BUNDLE_LIB_DIR \"${CMAKE_INSTALL_PREFIX}\")\n\ninstall(TARGETS ${BINARY_NAME} RUNTIME DESTINATION \"${CMAKE_INSTALL_PREFIX}\"\n  COMPONENT Runtime)\n\ninstall(FILES \"${FLUTTER_ICU_DATA_FILE}\" DESTINATION \"${INSTALL_BUNDLE_DATA_DIR}\"\n  COMPONENT Runtime)\n\ninstall(FILES \"${FLUTTER_LIBRARY}\" DESTINATION \"${INSTALL_BUNDLE_LIB_DIR}\"\n  COMPONENT Runtime)\n\ninstall(FILES \"${CMAKE_CURRENT_SOURCE_DIR}/libgopeed.dll\" DESTINATION \"${INSTALL_BUNDLE_LIB_DIR}\"\n  COMPONENT Runtime)\n\nif(PLUGIN_BUNDLED_LIBRARIES)\n  install(FILES \"${PLUGIN_BUNDLED_LIBRARIES}\"\n    DESTINATION \"${INSTALL_BUNDLE_LIB_DIR}\"\n    COMPONENT Runtime)\nendif()\n\n# Fully re-copy the assets directory on each build to avoid having stale files\n# from a previous install.\nset(FLUTTER_ASSET_DIR_NAME \"flutter_assets\")\ninstall(CODE \"\n  file(REMOVE_RECURSE \\\"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\\\")\n  \" COMPONENT Runtime)\ninstall(DIRECTORY \"${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}\"\n  DESTINATION \"${INSTALL_BUNDLE_DATA_DIR}\" COMPONENT Runtime)\n\n# Install the AOT library on non-Debug builds only.\ninstall(FILES \"${AOT_LIBRARY}\" DESTINATION \"${INSTALL_BUNDLE_DATA_DIR}\"\n  CONFIGURATIONS Profile;Release\n  COMPONENT Runtime)\n"
  },
  {
    "path": "ui/flutter/windows/flutter/CMakeLists.txt",
    "content": "# This file controls Flutter-level build steps. It should not be edited.\ncmake_minimum_required(VERSION 3.14)\n\nset(EPHEMERAL_DIR \"${CMAKE_CURRENT_SOURCE_DIR}/ephemeral\")\n\n# Configuration provided via flutter tool.\ninclude(${EPHEMERAL_DIR}/generated_config.cmake)\n\n# TODO: Move the rest of this into files in ephemeral. See\n# https://github.com/flutter/flutter/issues/57146.\nset(WRAPPER_ROOT \"${EPHEMERAL_DIR}/cpp_client_wrapper\")\n\n# Set fallback configurations for older versions of the flutter tool.\nif (NOT DEFINED FLUTTER_TARGET_PLATFORM)\n  set(FLUTTER_TARGET_PLATFORM \"windows-x64\")\nendif()\n\n# === Flutter Library ===\nset(FLUTTER_LIBRARY \"${EPHEMERAL_DIR}/flutter_windows.dll\")\n\n# Published to parent scope for install step.\nset(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)\nset(FLUTTER_ICU_DATA_FILE \"${EPHEMERAL_DIR}/icudtl.dat\" PARENT_SCOPE)\nset(PROJECT_BUILD_DIR \"${PROJECT_DIR}/build/\" PARENT_SCOPE)\nset(AOT_LIBRARY \"${PROJECT_DIR}/build/windows/app.so\" PARENT_SCOPE)\n\nlist(APPEND FLUTTER_LIBRARY_HEADERS\n  \"flutter_export.h\"\n  \"flutter_windows.h\"\n  \"flutter_messenger.h\"\n  \"flutter_plugin_registrar.h\"\n  \"flutter_texture_registrar.h\"\n)\nlist(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND \"${EPHEMERAL_DIR}/\")\nadd_library(flutter INTERFACE)\ntarget_include_directories(flutter INTERFACE\n  \"${EPHEMERAL_DIR}\"\n)\ntarget_link_libraries(flutter INTERFACE \"${FLUTTER_LIBRARY}.lib\")\nadd_dependencies(flutter flutter_assemble)\n\n# === Wrapper ===\nlist(APPEND CPP_WRAPPER_SOURCES_CORE\n  \"core_implementations.cc\"\n  \"standard_codec.cc\"\n)\nlist(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND \"${WRAPPER_ROOT}/\")\nlist(APPEND CPP_WRAPPER_SOURCES_PLUGIN\n  \"plugin_registrar.cc\"\n)\nlist(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND \"${WRAPPER_ROOT}/\")\nlist(APPEND CPP_WRAPPER_SOURCES_APP\n  \"flutter_engine.cc\"\n  \"flutter_view_controller.cc\"\n)\nlist(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND \"${WRAPPER_ROOT}/\")\n\n# Wrapper sources needed for a plugin.\nadd_library(flutter_wrapper_plugin STATIC\n  ${CPP_WRAPPER_SOURCES_CORE}\n  ${CPP_WRAPPER_SOURCES_PLUGIN}\n)\napply_standard_settings(flutter_wrapper_plugin)\nset_target_properties(flutter_wrapper_plugin PROPERTIES\n  POSITION_INDEPENDENT_CODE ON)\nset_target_properties(flutter_wrapper_plugin PROPERTIES\n  CXX_VISIBILITY_PRESET hidden)\ntarget_link_libraries(flutter_wrapper_plugin PUBLIC flutter)\ntarget_include_directories(flutter_wrapper_plugin PUBLIC\n  \"${WRAPPER_ROOT}/include\"\n)\nadd_dependencies(flutter_wrapper_plugin flutter_assemble)\n\n# Wrapper sources needed for the runner.\nadd_library(flutter_wrapper_app STATIC\n  ${CPP_WRAPPER_SOURCES_CORE}\n  ${CPP_WRAPPER_SOURCES_APP}\n)\napply_standard_settings(flutter_wrapper_app)\ntarget_link_libraries(flutter_wrapper_app PUBLIC flutter)\ntarget_include_directories(flutter_wrapper_app PUBLIC\n  \"${WRAPPER_ROOT}/include\"\n)\nadd_dependencies(flutter_wrapper_app flutter_assemble)\n\n# === Flutter tool backend ===\n# _phony_ is a non-existent file to force this command to run every time,\n# since currently there's no way to get a full input/output list from the\n# flutter tool.\nset(PHONY_OUTPUT \"${CMAKE_CURRENT_BINARY_DIR}/_phony_\")\nset_source_files_properties(\"${PHONY_OUTPUT}\" PROPERTIES SYMBOLIC TRUE)\nadd_custom_command(\n  OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}\n    ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN}\n    ${CPP_WRAPPER_SOURCES_APP}\n    ${PHONY_OUTPUT}\n  COMMAND ${CMAKE_COMMAND} -E env\n    ${FLUTTER_TOOL_ENVIRONMENT}\n    \"${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat\"\n      ${FLUTTER_TARGET_PLATFORM} $<CONFIG>\n  VERBATIM\n)\nadd_custom_target(flutter_assemble DEPENDS\n  \"${FLUTTER_LIBRARY}\"\n  ${FLUTTER_LIBRARY_HEADERS}\n  ${CPP_WRAPPER_SOURCES_CORE}\n  ${CPP_WRAPPER_SOURCES_PLUGIN}\n  ${CPP_WRAPPER_SOURCES_APP}\n)\n"
  },
  {
    "path": "ui/flutter/windows/runner/CMakeLists.txt",
    "content": "cmake_minimum_required(VERSION 3.14)\nproject(runner LANGUAGES CXX)\n\n# Define the application target. To change its name, change BINARY_NAME in the\n# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer\n# work.\n#\n# Any new source files that you add to the application should be added here.\nadd_executable(${BINARY_NAME} WIN32\n  \"flutter_window.cpp\"\n  \"main.cpp\"\n  \"utils.cpp\"\n  \"win32_window.cpp\"\n  \"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc\"\n  \"Runner.rc\"\n  \"runner.exe.manifest\"\n)\n\n# Apply the standard set of build settings. This can be removed for applications\n# that need different build settings.\napply_standard_settings(${BINARY_NAME})\n\n# Add preprocessor definitions for the build version.\ntarget_compile_definitions(${BINARY_NAME} PRIVATE \"FLUTTER_VERSION=\\\"${FLUTTER_VERSION}\\\"\")\ntarget_compile_definitions(${BINARY_NAME} PRIVATE \"FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}\")\ntarget_compile_definitions(${BINARY_NAME} PRIVATE \"FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}\")\ntarget_compile_definitions(${BINARY_NAME} PRIVATE \"FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}\")\ntarget_compile_definitions(${BINARY_NAME} PRIVATE \"FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}\")\n\n# Disable Windows macros that collide with C++ standard library functions.\ntarget_compile_definitions(${BINARY_NAME} PRIVATE \"NOMINMAX\")\n\n# Add dependency libraries and include directories. Add any application-specific\n# dependencies here.\ntarget_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app)\ntarget_link_libraries(${BINARY_NAME} PRIVATE \"dwmapi.lib\")\ntarget_include_directories(${BINARY_NAME} PRIVATE \"${CMAKE_SOURCE_DIR}\")\n\n# Run the Flutter tool portions of the build. This must not be removed.\nadd_dependencies(${BINARY_NAME} flutter_assemble)\n"
  },
  {
    "path": "ui/flutter/windows/runner/Runner.rc",
    "content": "// Microsoft Visual C++ generated resource script.\n//\n#pragma code_page(65001)\n#include \"resource.h\"\n\n#define APSTUDIO_READONLY_SYMBOLS\n/////////////////////////////////////////////////////////////////////////////\n//\n// Generated from the TEXTINCLUDE 2 resource.\n//\n#include \"winres.h\"\n\n/////////////////////////////////////////////////////////////////////////////\n#undef APSTUDIO_READONLY_SYMBOLS\n\n/////////////////////////////////////////////////////////////////////////////\n// English (United States) resources\n\n#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU)\nLANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US\n\n#ifdef APSTUDIO_INVOKED\n/////////////////////////////////////////////////////////////////////////////\n//\n// TEXTINCLUDE\n//\n\n1 TEXTINCLUDE\nBEGIN\n    \"resource.h\\0\"\nEND\n\n2 TEXTINCLUDE\nBEGIN\n    \"#include \"\"winres.h\"\"\\r\\n\"\n    \"\\0\"\nEND\n\n3 TEXTINCLUDE\nBEGIN\n    \"\\r\\n\"\n    \"\\0\"\nEND\n\n#endif    // APSTUDIO_INVOKED\n\n\n/////////////////////////////////////////////////////////////////////////////\n//\n// Icon\n//\n\n// Icon with lowest ID value placed first to ensure application icon\n// remains consistent on all systems.\nIDI_APP_ICON            ICON                    \"resources\\\\app_icon.ico\"\n\n\n/////////////////////////////////////////////////////////////////////////////\n//\n// Version\n//\n\n#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD)\n#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD\n#else\n#define VERSION_AS_NUMBER 1,0,0,0\n#endif\n\n#if defined(FLUTTER_VERSION)\n#define VERSION_AS_STRING FLUTTER_VERSION\n#else\n#define VERSION_AS_STRING \"1.0.0\"\n#endif\n\nVS_VERSION_INFO VERSIONINFO\n FILEVERSION VERSION_AS_NUMBER\n PRODUCTVERSION VERSION_AS_NUMBER\n FILEFLAGSMASK VS_FFI_FILEFLAGSMASK\n#ifdef _DEBUG\n FILEFLAGS VS_FF_DEBUG\n#else\n FILEFLAGS 0x0L\n#endif\n FILEOS VOS__WINDOWS32\n FILETYPE VFT_APP\n FILESUBTYPE 0x0L\nBEGIN\n    BLOCK \"StringFileInfo\"\n    BEGIN\n        BLOCK \"040904e4\"\n        BEGIN\n            VALUE \"CompanyName\", \"com\" \"\\0\"\n            VALUE \"FileDescription\", \"gopeed\" \"\\0\"\n            VALUE \"FileVersion\", VERSION_AS_STRING \"\\0\"\n            VALUE \"InternalName\", \"gopeed\" \"\\0\"\n            VALUE \"LegalCopyright\", \"Copyright (C) 2023 com. All rights reserved.\" \"\\0\"\n            VALUE \"OriginalFilename\", \"gopeed.exe\" \"\\0\"\n            VALUE \"ProductName\", \"gopeed\" \"\\0\"\n            VALUE \"ProductVersion\", VERSION_AS_STRING \"\\0\"\n        END\n    END\n    BLOCK \"VarFileInfo\"\n    BEGIN\n        VALUE \"Translation\", 0x409, 1252\n    END\nEND\n\n#endif    // English (United States) resources\n/////////////////////////////////////////////////////////////////////////////\n\n\n\n#ifndef APSTUDIO_INVOKED\n/////////////////////////////////////////////////////////////////////////////\n//\n// Generated from the TEXTINCLUDE 3 resource.\n//\n\n\n/////////////////////////////////////////////////////////////////////////////\n#endif    // not APSTUDIO_INVOKED\n"
  },
  {
    "path": "ui/flutter/windows/runner/flutter_window.cpp",
    "content": "#include \"flutter_window.h\"\n\n#include <optional>\n\n#include \"flutter/generated_plugin_registrant.h\"\n\nFlutterWindow::FlutterWindow(const flutter::DartProject& project)\n    : project_(project) {}\n\nFlutterWindow::~FlutterWindow() {}\n\nbool FlutterWindow::OnCreate() {\n  if (!Win32Window::OnCreate()) {\n    return false;\n  }\n\n  RECT frame = GetClientArea();\n\n  // The size here must match the window dimensions to avoid unnecessary surface\n  // creation / destruction in the startup path.\n  flutter_controller_ = std::make_unique<flutter::FlutterViewController>(\n      frame.right - frame.left, frame.bottom - frame.top, project_);\n  // Ensure that basic setup of the controller was successful.\n  if (!flutter_controller_->engine() || !flutter_controller_->view()) {\n    return false;\n  }\n  RegisterPlugins(flutter_controller_->engine());\n  SetChildContent(flutter_controller_->view()->GetNativeWindow());\n\n  flutter_controller_->engine()->SetNextFrameCallback([&]() {\n  });\n\n  // Flutter can complete the first frame before the \"show window\" callback is\n  // registered. The following call ensures a frame is pending to ensure the\n  // window is shown. It is a no-op if the first frame hasn't completed yet.\n  flutter_controller_->ForceRedraw();\n\n  return true;\n}\n\nvoid FlutterWindow::OnDestroy() {\n  if (flutter_controller_) {\n    flutter_controller_ = nullptr;\n  }\n\n  Win32Window::OnDestroy();\n}\n\nLRESULT\nFlutterWindow::MessageHandler(HWND hwnd, UINT const message,\n                              WPARAM const wparam,\n                              LPARAM const lparam) noexcept {\n  // Give Flutter, including plugins, an opportunity to handle window messages.\n  if (flutter_controller_) {\n    std::optional<LRESULT> result =\n        flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam,\n                                                      lparam);\n    if (result) {\n      return *result;\n    }\n  }\n\n  switch (message) {\n    case WM_FONTCHANGE:\n      flutter_controller_->engine()->ReloadSystemFonts();\n      break;\n  }\n\n  return Win32Window::MessageHandler(hwnd, message, wparam, lparam);\n}\n"
  },
  {
    "path": "ui/flutter/windows/runner/flutter_window.h",
    "content": "#ifndef RUNNER_FLUTTER_WINDOW_H_\n#define RUNNER_FLUTTER_WINDOW_H_\n\n#include <flutter/dart_project.h>\n#include <flutter/flutter_view_controller.h>\n\n#include <memory>\n\n#include \"win32_window.h\"\n\n// A window that does nothing but host a Flutter view.\nclass FlutterWindow : public Win32Window {\n public:\n  // Creates a new FlutterWindow hosting a Flutter view running |project|.\n  explicit FlutterWindow(const flutter::DartProject& project);\n  virtual ~FlutterWindow();\n\n protected:\n  // Win32Window:\n  bool OnCreate() override;\n  void OnDestroy() override;\n  LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam,\n                         LPARAM const lparam) noexcept override;\n\n private:\n  // The project to run.\n  flutter::DartProject project_;\n\n  // The Flutter instance hosted by this window.\n  std::unique_ptr<flutter::FlutterViewController> flutter_controller_;\n};\n\n#endif  // RUNNER_FLUTTER_WINDOW_H_\n"
  },
  {
    "path": "ui/flutter/windows/runner/main.cpp",
    "content": "#include <flutter/dart_project.h>\n#include <flutter/flutter_view_controller.h>\n#include <windows.h>\n\n#include \"flutter_window.h\"\n#include \"utils.h\"\n#include \"app_links/app_links_plugin_c_api.h\"\n\nbool SendAppLinkToInstance(const std::wstring& title) {\n  // Find our exact window\n  HWND hwnd = ::FindWindow(L\"FLUTTER_RUNNER_WIN32_WINDOW\", title.c_str());\n\n  if (hwnd) {\n    // Dispatch new link to current window\n    SendAppLink(hwnd);\n\n    // (Optional) Restore our window to front in same state\n    WINDOWPLACEMENT place = { sizeof(WINDOWPLACEMENT) };\n    GetWindowPlacement(hwnd, &place);\n\n    switch(place.showCmd) {\n      case SW_SHOWMAXIMIZED:\n          ShowWindow(hwnd, SW_SHOWMAXIMIZED);\n          break;\n      case SW_SHOWMINIMIZED:\n          ShowWindow(hwnd, SW_RESTORE);\n          break;\n      default:\n          ShowWindow(hwnd, SW_NORMAL);\n          break;\n    }\n\n    SetWindowPos(0, HWND_TOP, 0, 0, 0, 0, SWP_SHOWWINDOW | SWP_NOSIZE | SWP_NOMOVE);\n    SetForegroundWindow(hwnd);\n    // END (Optional) Restore\n\n    // Window has been found, don't create another one.\n    return true;\n  }\n\n  return false;\n}\n\nint APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,\n                      _In_ wchar_t *command_line, _In_ int show_command) {\n  if (SendAppLinkToInstance(L\"gopeed\")) {\n    return EXIT_SUCCESS;\n  }\n\n  HWND hwnd = ::FindWindow(L\"FLUTTER_RUNNER_WIN32_WINDOW\", L\"gopeed\");\n  if (hwnd != NULL) {\n    ::ShowWindow(hwnd, SW_NORMAL);\n    ::SetForegroundWindow(hwnd);\n    return EXIT_FAILURE;\n  }\n  // Attach to console when present (e.g., 'flutter run') or create a\n  // new console when running with a debugger.\n  if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) {\n    CreateAndAttachConsole();\n  }\n\n  // Initialize COM, so that it is available for use in the library and/or\n  // plugins.\n  ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);\n\n  flutter::DartProject project(L\"data\");\n\n  // TODO: Remove this.\n  // This forces Flutter to use a separate thread for Dart.\n  // This mode will be removed in a future version of Flutter.\n  project.set_ui_thread_policy(flutter::UIThreadPolicy::RunOnSeparateThread);\n\n  std::vector<std::string> command_line_arguments =\n      GetCommandLineArguments();\n\n  project.set_dart_entrypoint_arguments(std::move(command_line_arguments));\n\n  FlutterWindow window(project);\n  Win32Window::Point origin(10, 10);\n  Win32Window::Size size(1280, 720);\n  if (!window.Create(L\"gopeed\", origin, size)) {\n    return EXIT_FAILURE;\n  }\n  window.SetQuitOnClose(true);\n\n  ::MSG msg;\n  while (::GetMessage(&msg, nullptr, 0, 0)) {\n    ::TranslateMessage(&msg);\n    ::DispatchMessage(&msg);\n  }\n\n  ::CoUninitialize();\n  return EXIT_SUCCESS;\n}\n"
  },
  {
    "path": "ui/flutter/windows/runner/resource.h",
    "content": "//{{NO_DEPENDENCIES}}\n// Microsoft Visual C++ generated include file.\n// Used by Runner.rc\n//\n#define IDI_APP_ICON                    101\n\n// Next default values for new objects\n//\n#ifdef APSTUDIO_INVOKED\n#ifndef APSTUDIO_READONLY_SYMBOLS\n#define _APS_NEXT_RESOURCE_VALUE        102\n#define _APS_NEXT_COMMAND_VALUE         40001\n#define _APS_NEXT_CONTROL_VALUE         1001\n#define _APS_NEXT_SYMED_VALUE           101\n#endif\n#endif\n"
  },
  {
    "path": "ui/flutter/windows/runner/runner.exe.manifest",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<assembly xmlns=\"urn:schemas-microsoft-com:asm.v1\" manifestVersion=\"1.0\">\n  <application xmlns=\"urn:schemas-microsoft-com:asm.v3\">\n    <windowsSettings>\n      <dpiAwareness xmlns=\"http://schemas.microsoft.com/SMI/2016/WindowsSettings\">PerMonitorV2</dpiAwareness>\n    </windowsSettings>\n  </application>\n  <compatibility xmlns=\"urn:schemas-microsoft-com:compatibility.v1\">\n    <application>\n      <!-- Windows 10 and Windows 11 -->\n      <supportedOS Id=\"{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}\"/>\n      <!-- Windows 8.1 -->\n      <supportedOS Id=\"{1f676c76-80e1-4239-95bb-83d0f6d0da78}\"/>\n      <!-- Windows 8 -->\n      <supportedOS Id=\"{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}\"/>\n      <!-- Windows 7 -->\n      <supportedOS Id=\"{35138b9a-5d96-4fbd-8e2d-a2440225f93a}\"/>\n    </application>\n  </compatibility>\n</assembly>\n"
  },
  {
    "path": "ui/flutter/windows/runner/utils.cpp",
    "content": "#include \"utils.h\"\n\n#include <flutter_windows.h>\n#include <io.h>\n#include <stdio.h>\n#include <windows.h>\n\n#include <iostream>\n\nvoid CreateAndAttachConsole() {\n  if (::AllocConsole()) {\n    FILE *unused;\n    if (freopen_s(&unused, \"CONOUT$\", \"w\", stdout)) {\n      _dup2(_fileno(stdout), 1);\n    }\n    if (freopen_s(&unused, \"CONOUT$\", \"w\", stderr)) {\n      _dup2(_fileno(stdout), 2);\n    }\n    std::ios::sync_with_stdio();\n    FlutterDesktopResyncOutputStreams();\n  }\n}\n\nstd::vector<std::string> GetCommandLineArguments() {\n  // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use.\n  int argc;\n  wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc);\n  if (argv == nullptr) {\n    return std::vector<std::string>();\n  }\n\n  std::vector<std::string> command_line_arguments;\n\n  // Skip the first argument as it's the binary name.\n  for (int i = 1; i < argc; i++) {\n    command_line_arguments.push_back(Utf8FromUtf16(argv[i]));\n  }\n\n  ::LocalFree(argv);\n\n  return command_line_arguments;\n}\n\nstd::string Utf8FromUtf16(const wchar_t* utf16_string) {\n  if (utf16_string == nullptr) {\n    return std::string();\n  }\n  int target_length = ::WideCharToMultiByte(\n      CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string,\n      -1, nullptr, 0, nullptr, nullptr);\n  std::string utf8_string;\n  if (target_length == 0 || target_length > utf8_string.max_size()) {\n    return utf8_string;\n  }\n  utf8_string.resize(target_length);\n  int converted_length = ::WideCharToMultiByte(\n      CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string,\n      -1, utf8_string.data(),\n      target_length, nullptr, nullptr);\n  if (converted_length == 0) {\n    return std::string();\n  }\n  return utf8_string;\n}\n"
  },
  {
    "path": "ui/flutter/windows/runner/utils.h",
    "content": "#ifndef RUNNER_UTILS_H_\n#define RUNNER_UTILS_H_\n\n#include <string>\n#include <vector>\n\n// Creates a console for the process, and redirects stdout and stderr to\n// it for both the runner and the Flutter library.\nvoid CreateAndAttachConsole();\n\n// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string\n// encoded in UTF-8. Returns an empty std::string on failure.\nstd::string Utf8FromUtf16(const wchar_t* utf16_string);\n\n// Gets the command line arguments passed in as a std::vector<std::string>,\n// encoded in UTF-8. Returns an empty std::vector<std::string> on failure.\nstd::vector<std::string> GetCommandLineArguments();\n\n#endif  // RUNNER_UTILS_H_\n"
  },
  {
    "path": "ui/flutter/windows/runner/win32_window.cpp",
    "content": "#include \"win32_window.h\"\n\n#include <dwmapi.h>\n#include <flutter_windows.h>\n\n#include \"resource.h\"\n\nnamespace {\n\n/// Window attribute that enables dark mode window decorations.\n///\n/// Redefined in case the developer's machine has a Windows SDK older than\n/// version 10.0.22000.0.\n/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute\n#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE\n#define DWMWA_USE_IMMERSIVE_DARK_MODE 20\n#endif\n\nconstexpr const wchar_t kWindowClassName[] = L\"FLUTTER_RUNNER_WIN32_WINDOW\";\n\n/// Registry key for app theme preference.\n///\n/// A value of 0 indicates apps should use dark mode. A non-zero or missing\n/// value indicates apps should use light mode.\nconstexpr const wchar_t kGetPreferredBrightnessRegKey[] =\n  L\"Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Themes\\\\Personalize\";\nconstexpr const wchar_t kGetPreferredBrightnessRegValue[] = L\"AppsUseLightTheme\";\n\n// The number of Win32Window objects that currently exist.\nstatic int g_active_window_count = 0;\n\nusing EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd);\n\n// Scale helper to convert logical scaler values to physical using passed in\n// scale factor\nint Scale(int source, double scale_factor) {\n  return static_cast<int>(source * scale_factor);\n}\n\n// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module.\n// This API is only needed for PerMonitor V1 awareness mode.\nvoid EnableFullDpiSupportIfAvailable(HWND hwnd) {\n  HMODULE user32_module = LoadLibraryA(\"User32.dll\");\n  if (!user32_module) {\n    return;\n  }\n  auto enable_non_client_dpi_scaling =\n      reinterpret_cast<EnableNonClientDpiScaling*>(\n          GetProcAddress(user32_module, \"EnableNonClientDpiScaling\"));\n  if (enable_non_client_dpi_scaling != nullptr) {\n    enable_non_client_dpi_scaling(hwnd);\n  }\n  FreeLibrary(user32_module);\n}\n\n}  // namespace\n\n// Manages the Win32Window's window class registration.\nclass WindowClassRegistrar {\n public:\n  ~WindowClassRegistrar() = default;\n\n  // Returns the singleton registar instance.\n  static WindowClassRegistrar* GetInstance() {\n    if (!instance_) {\n      instance_ = new WindowClassRegistrar();\n    }\n    return instance_;\n  }\n\n  // Returns the name of the window class, registering the class if it hasn't\n  // previously been registered.\n  const wchar_t* GetWindowClass();\n\n  // Unregisters the window class. Should only be called if there are no\n  // instances of the window.\n  void UnregisterWindowClass();\n\n private:\n  WindowClassRegistrar() = default;\n\n  static WindowClassRegistrar* instance_;\n\n  bool class_registered_ = false;\n};\n\nWindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr;\n\nconst wchar_t* WindowClassRegistrar::GetWindowClass() {\n  if (!class_registered_) {\n    WNDCLASS window_class{};\n    window_class.hCursor = LoadCursor(nullptr, IDC_ARROW);\n    window_class.lpszClassName = kWindowClassName;\n    window_class.style = CS_HREDRAW | CS_VREDRAW;\n    window_class.cbClsExtra = 0;\n    window_class.cbWndExtra = 0;\n    window_class.hInstance = GetModuleHandle(nullptr);\n    window_class.hIcon =\n        LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON));\n    window_class.hbrBackground = 0;\n    window_class.lpszMenuName = nullptr;\n    window_class.lpfnWndProc = Win32Window::WndProc;\n    RegisterClass(&window_class);\n    class_registered_ = true;\n  }\n  return kWindowClassName;\n}\n\nvoid WindowClassRegistrar::UnregisterWindowClass() {\n  UnregisterClass(kWindowClassName, nullptr);\n  class_registered_ = false;\n}\n\nWin32Window::Win32Window() {\n  ++g_active_window_count;\n}\n\nWin32Window::~Win32Window() {\n  --g_active_window_count;\n  Destroy();\n}\n\nbool Win32Window::Create(const std::wstring& title,\n                         const Point& origin,\n                         const Size& size) {\n  Destroy();\n\n  const wchar_t* window_class =\n      WindowClassRegistrar::GetInstance()->GetWindowClass();\n\n  const POINT target_point = {static_cast<LONG>(origin.x),\n                              static_cast<LONG>(origin.y)};\n  HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST);\n  UINT dpi = FlutterDesktopGetDpiForMonitor(monitor);\n  double scale_factor = dpi / 96.0;\n\n  HWND window = CreateWindow(\n      window_class, title.c_str(), WS_OVERLAPPEDWINDOW,\n      Scale(origin.x, scale_factor), Scale(origin.y, scale_factor),\n      Scale(size.width, scale_factor), Scale(size.height, scale_factor),\n      nullptr, nullptr, GetModuleHandle(nullptr), this);\n\n  if (!window) {\n    return false;\n  }\n\n  UpdateTheme(window);\n\n  return OnCreate();\n}\n\nbool Win32Window::Show() {\n  return ShowWindow(window_handle_, SW_SHOWNORMAL);\n}\n\n// static\nLRESULT CALLBACK Win32Window::WndProc(HWND const window,\n                                      UINT const message,\n                                      WPARAM const wparam,\n                                      LPARAM const lparam) noexcept {\n  if (message == WM_NCCREATE) {\n    auto window_struct = reinterpret_cast<CREATESTRUCT*>(lparam);\n    SetWindowLongPtr(window, GWLP_USERDATA,\n                     reinterpret_cast<LONG_PTR>(window_struct->lpCreateParams));\n\n    auto that = static_cast<Win32Window*>(window_struct->lpCreateParams);\n    EnableFullDpiSupportIfAvailable(window);\n    that->window_handle_ = window;\n  } else if (Win32Window* that = GetThisFromHandle(window)) {\n    return that->MessageHandler(window, message, wparam, lparam);\n  }\n\n  return DefWindowProc(window, message, wparam, lparam);\n}\n\nLRESULT\nWin32Window::MessageHandler(HWND hwnd,\n                            UINT const message,\n                            WPARAM const wparam,\n                            LPARAM const lparam) noexcept {\n  switch (message) {\n    case WM_DESTROY:\n      window_handle_ = nullptr;\n      Destroy();\n      if (quit_on_close_) {\n        PostQuitMessage(0);\n      }\n      return 0;\n\n    case WM_DPICHANGED: {\n      auto newRectSize = reinterpret_cast<RECT*>(lparam);\n      LONG newWidth = newRectSize->right - newRectSize->left;\n      LONG newHeight = newRectSize->bottom - newRectSize->top;\n\n      SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth,\n                   newHeight, SWP_NOZORDER | SWP_NOACTIVATE);\n\n      return 0;\n    }\n    case WM_SIZE: {\n      RECT rect = GetClientArea();\n      if (child_content_ != nullptr) {\n        // Size and position the child window.\n        MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left,\n                   rect.bottom - rect.top, TRUE);\n      }\n      return 0;\n    }\n\n    case WM_ACTIVATE:\n      if (child_content_ != nullptr) {\n        SetFocus(child_content_);\n      }\n      return 0;\n\n    case WM_DWMCOLORIZATIONCOLORCHANGED:\n      UpdateTheme(hwnd);\n      return 0;\n  }\n\n  return DefWindowProc(window_handle_, message, wparam, lparam);\n}\n\nvoid Win32Window::Destroy() {\n  OnDestroy();\n\n  if (window_handle_) {\n    DestroyWindow(window_handle_);\n    window_handle_ = nullptr;\n  }\n  if (g_active_window_count == 0) {\n    WindowClassRegistrar::GetInstance()->UnregisterWindowClass();\n  }\n}\n\nWin32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept {\n  return reinterpret_cast<Win32Window*>(\n      GetWindowLongPtr(window, GWLP_USERDATA));\n}\n\nvoid Win32Window::SetChildContent(HWND content) {\n  child_content_ = content;\n  SetParent(content, window_handle_);\n  RECT frame = GetClientArea();\n\n  MoveWindow(content, frame.left, frame.top, frame.right - frame.left,\n             frame.bottom - frame.top, true);\n\n  SetFocus(child_content_);\n}\n\nRECT Win32Window::GetClientArea() {\n  RECT frame;\n  GetClientRect(window_handle_, &frame);\n  return frame;\n}\n\nHWND Win32Window::GetHandle() {\n  return window_handle_;\n}\n\nvoid Win32Window::SetQuitOnClose(bool quit_on_close) {\n  quit_on_close_ = quit_on_close;\n}\n\nbool Win32Window::OnCreate() {\n  // No-op; provided for subclasses.\n  return true;\n}\n\nvoid Win32Window::OnDestroy() {\n  // No-op; provided for subclasses.\n}\n\nvoid Win32Window::UpdateTheme(HWND const window) {\n  DWORD light_mode;\n  DWORD light_mode_size = sizeof(light_mode);\n  LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey,\n                               kGetPreferredBrightnessRegValue,\n                               RRF_RT_REG_DWORD, nullptr, &light_mode,\n                               &light_mode_size);\n\n  if (result == ERROR_SUCCESS) {\n    BOOL enable_dark_mode = light_mode == 0;\n    DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE,\n                          &enable_dark_mode, sizeof(enable_dark_mode));\n  }\n}\n"
  },
  {
    "path": "ui/flutter/windows/runner/win32_window.h",
    "content": "#ifndef RUNNER_WIN32_WINDOW_H_\n#define RUNNER_WIN32_WINDOW_H_\n\n#include <windows.h>\n\n#include <functional>\n#include <memory>\n#include <string>\n\n// A class abstraction for a high DPI-aware Win32 Window. Intended to be\n// inherited from by classes that wish to specialize with custom\n// rendering and input handling\nclass Win32Window {\n public:\n  struct Point {\n    unsigned int x;\n    unsigned int y;\n    Point(unsigned int x, unsigned int y) : x(x), y(y) {}\n  };\n\n  struct Size {\n    unsigned int width;\n    unsigned int height;\n    Size(unsigned int width, unsigned int height)\n        : width(width), height(height) {}\n  };\n\n  Win32Window();\n  virtual ~Win32Window();\n\n  // Creates a win32 window with |title| that is positioned and sized using\n  // |origin| and |size|. New windows are created on the default monitor. Window\n  // sizes are specified to the OS in physical pixels, hence to ensure a\n  // consistent size this function will scale the inputted width and height as\n  // as appropriate for the default monitor. The window is invisible until\n  // |Show| is called. Returns true if the window was created successfully.\n  bool Create(const std::wstring& title, const Point& origin, const Size& size);\n\n  // Show the current window. Returns true if the window was successfully shown.\n  bool Show();\n\n  // Release OS resources associated with window.\n  void Destroy();\n\n  // Inserts |content| into the window tree.\n  void SetChildContent(HWND content);\n\n  // Returns the backing Window handle to enable clients to set icon and other\n  // window properties. Returns nullptr if the window has been destroyed.\n  HWND GetHandle();\n\n  // If true, closing this window will quit the application.\n  void SetQuitOnClose(bool quit_on_close);\n\n  // Return a RECT representing the bounds of the current client area.\n  RECT GetClientArea();\n\n protected:\n  // Processes and route salient window messages for mouse handling,\n  // size change and DPI. Delegates handling of these to member overloads that\n  // inheriting classes can handle.\n  virtual LRESULT MessageHandler(HWND window,\n                                 UINT const message,\n                                 WPARAM const wparam,\n                                 LPARAM const lparam) noexcept;\n\n  // Called when CreateAndShow is called, allowing subclass window-related\n  // setup. Subclasses should return false if setup fails.\n  virtual bool OnCreate();\n\n  // Called when Destroy is called.\n  virtual void OnDestroy();\n\n private:\n  friend class WindowClassRegistrar;\n\n  // OS callback called by message pump. Handles the WM_NCCREATE message which\n  // is passed when the non-client area is being created and enables automatic\n  // non-client DPI scaling so that the non-client area automatically\n  // responsponds to changes in DPI. All other messages are handled by\n  // MessageHandler.\n  static LRESULT CALLBACK WndProc(HWND const window,\n                                  UINT const message,\n                                  WPARAM const wparam,\n                                  LPARAM const lparam) noexcept;\n\n  // Retrieves a class instance pointer for |window|\n  static Win32Window* GetThisFromHandle(HWND const window) noexcept;\n\n  // Update the window frame's theme to match the system theme.\n  static void UpdateTheme(HWND const window);\n\n  bool quit_on_close_ = false;\n\n  // window handle for top level window.\n  HWND window_handle_ = nullptr;\n\n  // window handle for hosted content.\n  HWND child_content_ = nullptr;\n};\n\n#endif  // RUNNER_WIN32_WINDOW_H_\n"
  }
]