Compare commits

..
51 Commits
Author SHA1 Message Date
Li Wei 21ec1e4539 fix: follow android 11 storage permissions (#66) 2022-12-16 21:43:06 +08:00
levi dda44cc770 fix: docker tag 2022-12-16 15:24:41 +08:00
levi 78911b2aa9 ci: fix docker build 2022-12-16 15:10:34 +08:00
Li Wei 4935dcd0e6 ci: fix 2022-12-16 15:02:33 +08:00
Li Wei e8fe6ad550 ci: docker supports more platforms (#64) 2022-12-16 14:56:56 +08:00
Li Wei db4003ad4f fix: speed calculate (#63) 2022-12-16 14:53:07 +08:00
Li Wei 0a4be165a5 fix: http download fail (#61) 2022-12-14 12:12:39 +08:00
levi c62867e3ef chore: fix docker-compose command 2022-12-13 15:00:32 +08:00
levi 6df4959845 ci: fix 2022-12-13 14:49:53 +08:00
levi e0b18013be chore: release v1.2.0 2022-12-13 14:48:01 +08:00
levi d205e3b372 feat: enable ru_RU 2022-12-13 14:47:08 +08:00
azatzin30 719daf054b feat: Translation to Russian language (#60) 2022-12-13 14:42:08 +08:00
levi a5f72a22c3 fix: i18n 2022-12-13 14:39:51 +08:00
levi bf5c7655f2 test: remove fail code 2022-12-12 16:06:00 +08:00
Li Wei 691c2f496e feat: support custom tracker url (#59) 2022-12-12 15:58:11 +08:00
gesneriana 9ef3405e43 fix: missing single quotes (#57) 2022-11-16 13:34:15 +08:00
azatzin30 215faa0bf0 feat: Added Russian language (#55) 2022-11-11 22:17:38 +08:00
cgcel 695da7ea87 feat: support docker compose (#54) 2022-11-10 14:13:34 +08:00
liwei 7c6c361d30 fix: docker dist copy 2022-11-08 22:55:56 +08:00
liwei 872ba0ba87 doc: fix docker run command 2022-11-08 22:36:47 +08:00
monkeyWie 93c14dcee5 fix: ip bind (#50) 2022-11-08 22:29:33 +08:00
liwei 1af0b2175c ci: path include Dockerfile 2022-11-08 21:50:45 +08:00
monkeyWie ee81ebffef fix: docker executable (#49) 2022-11-08 21:49:02 +08:00
levi dc00fd2543 doc: fix 2022-11-08 15:02:58 +08:00
levi 11015c7be1 doc: mac repair tip 2022-11-08 14:35:51 +08:00
monkeyWie 57befd2c03 fix: web download dir setting (#46) 2022-11-08 14:31:49 +08:00
monkeyWie 35156ed968 ci: remove windows zip root folder 2022-11-07 20:29:10 +08:00
levi ee6c36174f ci: fix version 2022-11-07 17:25:23 +08:00
levi fdfd95555d ci: fix docker build 2022-11-07 17:20:31 +08:00
levi 7ec824edda ci: fix 2022-11-07 17:13:00 +08:00
levi 1f8b1be669 ci: fix action version 2022-11-07 16:49:58 +08:00
levi b0bcfeae34 ci: fix action version 2022-11-07 16:48:02 +08:00
levi 10b346a326 ci: fix 2022-11-07 16:45:44 +08:00
levi a85af39b35 ci: fix build 2022-11-07 16:43:55 +08:00
levi 576f0d2e24 ci: fix docker tag 2022-11-07 16:41:52 +08:00
monkeyWie 95eb3a427c feat: docker support (#43) 2022-11-07 16:32:52 +08:00
monkeyWie e9442edd94 feat: support drag torrent file (#42) 2022-11-07 14:12:12 +08:00
monkeyWie 77e59f7376 feat: i18n support (#40) 2022-11-05 17:04:22 +08:00
monkeyWie 135fba1b54 doc: fix md 2022-11-05 13:16:57 +08:00
liwei ff813e3bc9 ci: build on published 2022-11-05 11:27:56 +08:00
Trim21 babfe79023 Update flags.go 2022-11-05 10:43:07 +08:00
liwei 89ac52bebd ci: path ignore 2022-11-04 21:48:22 +08:00
liwei 15e1c2f2d5 chore: git ignore generated_plugin 2022-11-04 21:43:31 +08:00
levi 203f0ccc82 chore: fix go version 2022-11-04 17:13:32 +08:00
levi 2422bd2c66 chore: update md 2022-11-04 16:38:07 +08:00
levi 0306a1ed6b chore: en link 2022-11-04 16:26:19 +08:00
levi 8a94650370 chore: en readme 2022-11-04 16:25:57 +08:00
levi 837a193e96 chore: add discord link 2022-11-04 15:40:31 +08:00
levi fcf0746f9a ci: fix version 2022-11-04 15:12:38 +08:00
levi 9eb466ccd3 ci: path ignore 2022-11-04 15:11:06 +08:00
levi c54f22613e chore: issue tpl 2022-11-04 15:06:54 +08:00
98 changed files with 3148 additions and 1438 deletions

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
<!--
请根据以下模版提issue
-->
### 问题描述(必要)
### 版本号(必要)
### 操作系统(必要)
### 相关截图
### 相关日志
+2 -2
View File
@@ -1,5 +1,5 @@
name-template: 'v1.0.0 🌈' name-template: 'v$NEXT_PATCH_VERSION 🌈'
tag-template: 'v1.0.0' tag-template: 'v$NEXT_PATCH_VERSION'
categories: categories:
- title: '🆕 Features' - title: '🆕 Features'
labels: labels:
+349
View File
@@ -0,0 +1,349 @@
name: build
on:
workflow_dispatch:
release:
types: [published]
jobs:
get-release:
runs-on: ubuntu-latest
outputs:
tag_name: ${{ steps.get-release.outputs.tag_name }}
upload_url: ${{ steps.get-release.outputs.upload_url }}
steps:
- uses: actions/checkout@v3
- uses: thebritican/[email protected]
id: get-release
build-windows:
runs-on: windows-latest
needs: [get-release]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
go-version: "^1.19"
cache: true
- uses: subosito/flutter-action@v2
- name: Build
env:
VERSION: ${{ needs.get-release.outputs.tag_name }}
run: |
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/windows/libgopeed.dll github.com/monkeyWie/gopeed/bind/desktop
cd ui/flutter
flutter build windows
$mingw = "C:\ProgramData\Chocolatey\lib\mingw\tools\install\mingw64\bin"
$system = "C:\Windows\System32"
$release = "build\windows\runner\Release\"
cp $mingw\libstdc++-6.dll $release
cp $mingw\libgcc_s_seh-1.dll $release
cp $mingw\libwinpthread-1.dll $release
cp $system\msvcp140.dll $release
cp $system\vcruntime140.dll $release
cp $system\vcruntime140_1.dll $release
New-Item -Path build\windows\Output -ItemType Directory
Compress-Archive -Path "$release*" -DestinationPath build\windows\Output\Gopeed-$env:VERSION-windows-amd64.zip
cd build/windows
echo @"
; Script generated by the Inno Setup Script Wizard.
; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!
#define MyAppName "Gopeed"
#define MyAppVersion "$($env:VERSION.substring(1))"
#define MyAppPublisher "monkeyWie"
#define MyAppURL "https://gopeed.com"
#define MyAppExeName "gopeed.exe"
[Setup]
; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications.
; (To generate a new GUID, click Tools | Generate GUID inside the IDE.)
AppId={{5960F34D-1E42-402C-8C85-DE2FF24CBAE4}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
;AppVerName={#MyAppName} {#MyAppVersion}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
AppSupportURL={#MyAppURL}
AppUpdatesURL={#MyAppURL}
DefaultDirName={autopf}\gopeed
DisableProgramGroupPage=yes
LicenseFile=..\..\..\..\LICENSE
; Remove the following line to run in administrative install mode (install for all users.)
PrivilegesRequired=lowest
OutputBaseFilename=gopeed
SetupIconFile=..\..\assets\icon\icon.ico
Compression=lzma
SolidCompression=yes
WizardStyle=modern
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Files]
Source: ".\runner\Release\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs
; NOTE: Don't use "Flags: ignoreversion" on any shared system files
[Icons]
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
"@ > setup.iss
iscc.exe setup.iss
mv "Output\gopeed.exe" "Output\Gopeed-$env:VERSION-windows-amd64-install.exe"
- name: Upload
uses: shogo82148/actions-upload-release-asset@v1
with:
upload_url: ${{ needs.get-release.outputs.upload_url }}
asset_path: ui/flutter/build/windows/Output/*
overwrite: true
build-macos-arm64-lib:
runs-on: ubuntu-latest
needs: [get-release]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
go-version: "^1.19"
cache: true
- run: mkdir -p ~/image-cache
- id: image-cache
uses: actions/cache@v3
with:
path: ~/image-cache
key: image-cache-xgo-v1.19
- if: steps.image-cache.outputs.cache-hit != 'true'
run: |
docker pull techknowlogick/xgo:go-1.19.x
docker save -o ~/image-cache/xgo.tar techknowlogick/xgo:go-1.19.x
- if: steps.image-cache.outputs.cache-hit == 'true'
run: docker load -i ~/image-cache/xgo.tar
- name: Build
run: |
go install src.techknowlogick.com/xgo@latest
xgo --targets=darwin/arm64 -tags="nosqlite" -ldflags="-w -s" -buildmode=c-shared -pkg=bind/desktop -out=libgopeed .
mv libgopeed-*.dylib libgopeed.dylib
- uses: actions/upload-artifact@v3
with:
name: macos-arm64-lib
path: libgopeed.dylib
build-macos:
runs-on: macos-latest
needs: [get-release, build-macos-arm64-lib]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
go-version: "^1.19"
cache: true
- uses: actions/setup-node@v3
with:
node-version: 16
- uses: subosito/flutter-action@v2
- name: Install appdmg
run: npm install -g appdmg
- uses: actions/download-artifact@v3
with:
name: macos-arm64-lib
path: ui/flutter/macos/Frameworks
- name: Build
env:
VERSION: ${{ needs.get-release.outputs.tag_name }}
run: |
PROJECT_DIR=$(pwd)
cd ui/flutter
flutter build macos
cd build/macos/Build/Products/Release
cat>appdmg.json<<EOF
{
"title": "Gopeed",
"icon": "Gopeed.app/Contents/Resources/AppIcon.icns",
"contents": [
{ "x": 448, "y": 344, "type": "link", "path": "/Applications" },
{ "x": 192, "y": 344, "type": "file", "path": "Gopeed.app" }
]
}
EOF
mkdir dist
appdmg appdmg.json dist/Gopeed-$VERSION-macos-arm64.dmg
# amd64
cd $PROJECT_DIR
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o bin/libgopeed.dylib github.com/monkeyWie/gopeed/bind/desktop
cp bin/libgopeed.dylib ui/flutter/build/macos/Build/Products/Release/Gopeed.app/Contents/Frameworks/
cd ui/flutter/build/macos/Build/Products/Release
appdmg appdmg.json dist/Gopeed-$VERSION-macos-amd64.dmg
- name: Upload
uses: shogo82148/actions-upload-release-asset@v1
with:
upload_url: ${{ needs.get-release.outputs.upload_url }}
asset_path: ui/flutter/build/macos/Build/Products/Release/dist/*
overwrite: true
build-linux:
runs-on: ubuntu-latest
needs: [get-release]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
go-version: "^1.19"
cache: true
- uses: subosito/flutter-action@v2
- run: |
sudo apt-get update -y
sudo apt-get install -y ninja-build libgtk-3-dev
- name: Build
env:
VERSION: ${{ needs.get-release.outputs.tag_name }}
run: |
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/linux/bundle/lib/libgopeed.so github.com/monkeyWie/gopeed/bind/desktop
cd ui/flutter
flutter build linux
mkdir -p debian/gui
cp assets/icon/icon_1024.png debian/gui/gopeed.png
cat>debian/debian.yaml<<EOF
flutter_app:
command: gopeed
arch: x64
parent: /usr/local/lib
control:
Package: gopeed
Version: ${VERSION:1}
Architecture: amd64
Essential: no
Priority: optional
Depends:
Maintainer: monkeyWie
Description: High speed downloader that supports all platforms.
EOF
cat>debian/gui/gopeed.desktop<<EOF
[Desktop Entry]
Version=${VERSION:1}
Name=Gopeed
GenericName=Gopeed
Comment=High speed downloader that supports all platforms.
Terminal=false
Type=Application
Categories=Utility
Keywords=Flutter;Downloader;
EOF
dart pub global activate flutter_to_debian
flutter_to_debian
mv debian/packages/gopeed*.deb debian/packages/Gopeed-$VERSION-linux-amd64.deb
- name: Upload
uses: shogo82148/actions-upload-release-asset@v1
with:
upload_url: ${{ needs.get-release.outputs.upload_url }}
asset_path: ui/flutter/debian/packages/*
overwrite: true
build-android:
runs-on: ubuntu-latest
needs: [get-release]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
go-version: "^1.19"
cache: true
- uses: actions/setup-java@v3
with:
distribution: "zulu"
java-version: "11"
- uses: subosito/flutter-action@v2
- name: Build
env:
VERSION: ${{ needs.get-release.outputs.tag_name }}
run: |
mkdir ui/flutter/android/app/libs
go install golang.org/x/mobile/cmd/gomobile@latest
gomobile init
gomobile bind -tags nosqlite -ldflags="-w -s" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 19 -javapkg=com.gopeed github.com/monkeyWie/gopeed/bind/mobile
cd ui/flutter
echo $APK_KEYSTORE | base64 -d > android/app/upload-keystore.jks
flutter build apk
mkdir dist
cp build/app/outputs/flutter-apk/app-release.apk dist/Gopeed-$VERSION-android.apk
- name: Upload
uses: shogo82148/actions-upload-release-asset@v1
with:
upload_url: ${{ needs.get-release.outputs.upload_url }}
asset_path: ui/flutter/dist/*
overwrite: true
build-web:
runs-on: ubuntu-latest
needs: [get-release]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
go-version: "^1.19"
cache: true
- uses: subosito/flutter-action@v2
- name: Build
env:
VERSION: ${{ needs.get-release.outputs.tag_name }}
run: |
cd ui/flutter
flutter build web
cd ../../
cp -r ui/flutter/build/web cmd/web/dist
mkdir -p dist/zip
goos_arr=(windows darwin linux)
goarch_arr=(386 amd64 arm64)
export CGO_ENABLED=0
for goos in "${goos_arr[@]}"; do
for goarch in "${goarch_arr[@]}"; do
name=gopeed-web-$VERSION-$goos-$goarch
dir="dist/$name/"
(GOOS=$goos GOARCH=$goarch go build -tags nosqlite,web -ldflags="-s -w" -o $dir github.com/monkeyWie/gopeed/cmd/web \
&& cd $dir \
&& file=$(ls -AU | head -1) \
&& mkdir $name \
&& mv $file $name/$(echo $file | sed -e "s/web/gopeed/g") \
&& zip -r ../zip/$name.zip * \
&& cd ../..) \
|| true
done
done
- name: Upload
uses: shogo82148/actions-upload-release-asset@v1
with:
upload_url: ${{ needs.get-release.outputs.upload_url }}
asset_path: dist/zip/*
overwrite: true
build-docker:
runs-on: ubuntu-latest
needs: [get-release]
steps:
- uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: liwei2633
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v2
env:
VERSION: ${{ needs.get-release.outputs.tag_name }}
with:
push: true
platforms: |
linux/386
linux/amd64
linux/arm64
tags: |
liwei2633/gopeed:latest
liwei2633/gopeed:${{ env.VERSION }}
+12 -306
View File
@@ -4,10 +4,17 @@ on:
push: push:
branches: branches:
- main - main
paths-ignore: paths:
- '_docs/**' - "bind/**"
- '_examples/**' - "cmd/**"
- 'README*.md' - "internal/**"
- "pkg/**"
- "ui/**"
- ".github/workflows/release.yml"
- ".github/release-drafter.yml"
- "go.mod"
- "go.sum"
- "Dockerfile"
jobs: jobs:
release: release:
@@ -20,309 +27,8 @@ jobs:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: actions/setup-go@v3 - uses: actions/setup-go@v3
with: with:
go-version: '^1.19' go-version: "^1.19"
- uses: release-drafter/release-drafter@v5 - uses: release-drafter/release-drafter@v5
id: create_release id: create_release
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build-windows:
runs-on: windows-latest
needs: [ release ]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
go-version: '^1.19'
cache: true
- uses: subosito/flutter-action@v2
- name: Build
env:
VERSION: ${{ needs.release.outputs.tag_name }}
run: |
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/windows/libgopeed.dll github.com/monkeyWie/gopeed/bind/desktop
cd ui/flutter
flutter build windows
$mingw = "C:\ProgramData\Chocolatey\lib\mingw\tools\install\mingw64\bin"
$system = "C:\Windows\System32"
$release = "build\windows\runner\Release\"
cp $mingw\libstdc++-6.dll $release
cp $mingw\libgcc_s_seh-1.dll $release
cp $mingw\libwinpthread-1.dll $release
cp $system\msvcp140.dll $release
cp $system\vcruntime140.dll $release
cp $system\vcruntime140_1.dll $release
cd build/windows
echo @"
; Script generated by the Inno Setup Script Wizard.
; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!
#define MyAppName "Gopeed"
#define MyAppVersion "$($env:VERSION.substring(1))"
#define MyAppPublisher "monkeyWie"
#define MyAppURL "https://gopeed.com"
#define MyAppExeName "gopeed.exe"
[Setup]
; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications.
; (To generate a new GUID, click Tools | Generate GUID inside the IDE.)
AppId={{5960F34D-1E42-402C-8C85-DE2FF24CBAE4}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
;AppVerName={#MyAppName} {#MyAppVersion}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
AppSupportURL={#MyAppURL}
AppUpdatesURL={#MyAppURL}
DefaultDirName={autopf}\gopeed
DisableProgramGroupPage=yes
LicenseFile=..\..\..\..\LICENSE
; Remove the following line to run in administrative install mode (install for all users.)
PrivilegesRequired=lowest
OutputBaseFilename=gopeed
SetupIconFile=..\..\assets\icon\icon.ico
Compression=lzma
SolidCompression=yes
WizardStyle=modern
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Files]
Source: ".\runner\Release\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs
; NOTE: Don't use "Flags: ignoreversion" on any shared system files
[Icons]
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
"@ > setup.iss
iscc.exe setup.iss
mv "Output\gopeed.exe" "Output\Gopeed-$env:VERSION-windows-amd64.exe"
- name: Upload
uses: shogo82148/actions-upload-release-asset@v1
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: ui/flutter/build/windows/Output/*
overwrite: true
build-macos-arm64-lib:
runs-on: ubuntu-latest
needs: [ release ]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
go-version: '^1.19'
cache: true
- run: mkdir -p ~/image-cache
- id: image-cache
uses: actions/cache@v3
with:
path: ~/image-cache
key: image-cache-xgo-v1.19
- if: steps.image-cache.outputs.cache-hit != 'true'
run: |
docker pull techknowlogick/xgo:go-1.19.x
docker save -o ~/image-cache/xgo.tar techknowlogick/xgo:go-1.19.x
- if: steps.image-cache.outputs.cache-hit == 'true'
run: docker load -i ~/image-cache/xgo.tar
- name: Build
run: |
go install src.techknowlogick.com/xgo@latest
xgo --targets=darwin/arm64 -tags="nosqlite" -ldflags="-w -s" -buildmode=c-shared -pkg=bind/desktop -out=libgopeed .
mv libgopeed-*.dylib libgopeed.dylib
- uses: actions/upload-artifact@v3
with:
name: macos-arm64-lib
path: libgopeed.dylib
build-macos:
runs-on: macos-latest
needs: [ release, build-macos-arm64-lib ]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
go-version: '^1.19'
cache: true
- uses: actions/setup-node@v3
with:
node-version: 16
- uses: subosito/flutter-action@v2
- name: Install appdmg
run: npm install -g appdmg
- uses: actions/download-artifact@v3
with:
name: macos-arm64-lib
path: ui/flutter/macos/Frameworks
- name: Build
env:
VERSION: ${{ needs.release.outputs.tag_name }}
run: |
PROJECT_DIR=$(pwd)
cd ui/flutter
flutter build macos
cd build/macos/Build/Products/Release
cat>appdmg.json<<EOF
{
"title": "Gopeed",
"icon": "Gopeed.app/Contents/Resources/AppIcon.icns",
"contents": [
{ "x": 448, "y": 344, "type": "link", "path": "/Applications" },
{ "x": 192, "y": 344, "type": "file", "path": "Gopeed.app" }
]
}
EOF
mkdir dist
appdmg appdmg.json dist/Gopeed-$VERSION-macos-arm64.dmg
# amd64
cd $PROJECT_DIR
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o bin/libgopeed.dylib github.com/monkeyWie/gopeed/bind/desktop
cp bin/libgopeed.dylib ui/flutter/build/macos/Build/Products/Release/Gopeed.app/Contents/Frameworks/
cd ui/flutter/build/macos/Build/Products/Release
appdmg appdmg.json dist/Gopeed-$VERSION-macos-amd64.dmg
- name: Upload
uses: shogo82148/actions-upload-release-asset@v1
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: ui/flutter/build/macos/Build/Products/Release/dist/*
overwrite: true
build-linux:
runs-on: ubuntu-latest
needs: [ release ]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
go-version: '^1.19'
cache: true
- uses: subosito/flutter-action@v2
- run: |
sudo apt-get update -y
sudo apt-get install -y ninja-build libgtk-3-dev
- name: Build
env:
VERSION: ${{ needs.release.outputs.tag_name }}
run: |
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/linux/bundle/lib/libgopeed.so github.com/monkeyWie/gopeed/bind/desktop
cd ui/flutter
flutter build linux
mkdir -p debian/gui
cp assets/icon/icon_1024.png debian/gui/gopeed.png
cat>debian/debian.yaml<<EOF
flutter_app:
command: gopeed
arch: x64
parent: /usr/local/lib
control:
Package: gopeed
Version: ${VERSION:1}
Architecture: amd64
Essential: no
Priority: optional
Depends:
Maintainer: monkeyWie
Description: High speed downloader that supports all platforms.
EOF
cat>debian/gui/gopeed.desktop<<EOF
[Desktop Entry]
Version=${VERSION:1}
Name=Gopeed
GenericName=Gopeed
Comment=High speed downloader that supports all platforms.
Terminal=false
Type=Application
Categories=Utility
Keywords=Flutter;Downloader;
EOF
dart pub global activate flutter_to_debian
flutter_to_debian
mv debian/packages/gopeed*.deb debian/packages/Gopeed-$VERSION-linux-amd64.deb
- name: Upload
uses: shogo82148/actions-upload-release-asset@v1
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: ui/flutter/debian/packages/*
overwrite: true
build-android:
runs-on: ubuntu-latest
needs: [ release ]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
go-version: '^1.19'
cache: true
- uses: actions/setup-java@v3
with:
distribution: 'zulu'
java-version: '11'
- uses: subosito/flutter-action@v2
- name: Build
env:
VERSION: ${{ needs.release.outputs.tag_name }}
run: |
mkdir ui/flutter/android/app/libs
go install golang.org/x/mobile/cmd/gomobile@latest
gomobile init
gomobile bind -tags nosqlite -ldflags="-w -s" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 19 -javapkg=com.gopeed github.com/monkeyWie/gopeed/bind/mobile
cd ui/flutter
flutter build apk
mkdir dist
cp build/app/outputs/flutter-apk/app-release.apk dist/Gopeed-$VERSION-android.apk
- name: Upload
uses: shogo82148/actions-upload-release-asset@v1
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: ui/flutter/dist/*
overwrite: true
build-web:
runs-on: ubuntu-latest
needs: [ release ]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
go-version: '^1.19'
cache: true
- uses: subosito/flutter-action@v2
- name: Build
env:
VERSION: ${{ needs.release.outputs.tag_name }}
run: |
cd ui/flutter
flutter build web
cd ../../
cp -r ui/flutter/build/web cmd/web/dist
mkdir -p dist/zip
goos_arr=(windows darwin linux)
goarch_arr=(386 amd64 arm64)
export CGO_ENABLED=0
for goos in "${goos_arr[@]}"; do
for goarch in "${goarch_arr[@]}"; do
name=gopeed-web-$VERSION-$goos-$goarch
dir="dist/$name/"
(GOOS=$goos GOARCH=$goarch go build -tags nosqlite,web -ldflags="-s -w" -o $dir github.com/monkeyWie/gopeed/cmd/web \
&& cd $dir \
&& file=$(ls -AU | head -1) \
&& mkdir $name \
&& mv $file $name/$(echo $file | sed -e "s/web/gopeed/g") \
&& zip -r ../zip/$name.zip * \
&& cd ../..) \
|| true
done
done
- name: Upload
uses: shogo82148/actions-upload-release-asset@v1
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: dist/zip/*
overwrite: true
+35 -29
View File
@@ -4,36 +4,42 @@ on:
pull_request: pull_request:
branches: branches:
- main - main
paths-ignore: paths:
- '.github/workflows/release.yml' - "bind/**"
- '.github/release-drafter.yml' - "cmd/**"
- '_docs/**' - "internal/**"
- '_examples/**' - "pkg/**"
- 'README*.md' - "ui/**"
- ".github/workflows/test.yml"
- "go.mod"
- "go.sum"
push: push:
branches: branches:
- main - main
paths-ignore: paths:
- '.github/workflows/release.yml' - "bind/**"
- '.github/release-drafter.yml' - "cmd/**"
- '_docs/**' - "internal/**"
- '_examples/**' - "pkg/**"
- 'README*.md' - "ui/**"
- ".github/workflows/test.yml"
- "go.mod"
- "go.sum"
jobs: jobs:
# lint: # lint:
# name: Lint # name: Lint
# runs-on: ubuntu-latest # runs-on: ubuntu-latest
# steps: # steps:
# - uses: actions/setup-go@v2 # - uses: actions/setup-go@v2
# with: # with:
# go-version: '^1.19' # go-version: '^1.19'
# - uses: actions/checkout@v2 # - uses: actions/checkout@v2
# - name: Lint Go Code # - name: Lint Go Code
# run: | # run: |
# export PATH=$PATH:$(go env GOPATH)/bin # temporary fix. See https://github.com/actions/setup-go/issues/14 # export PATH=$PATH:$(go env GOPATH)/bin # temporary fix. See https://github.com/actions/setup-go/issues/14
# go get -u golang.org/x/lint/golint # go get -u golang.org/x/lint/golint
# golint -set_exit_status ./... # golint -set_exit_status ./...
test: test:
name: Test name: Test
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -41,7 +47,7 @@ jobs:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: actions/setup-go@v3 - uses: actions/setup-go@v3
with: with:
go-version: '^1.19' go-version: "^1.19"
cache: true cache: true
- name: Run Unit tests. - name: Run Unit tests.
run: go test $(go list ./... | grep -v /cmd/ | grep -v /bind/ ) -v -covermode=count -coverprofile=coverage.txt run: go test $(go list ./... | grep -v /cmd/ | grep -v /bind/ ) -v -covermode=count -coverprofile=coverage.txt
@@ -52,13 +58,13 @@ jobs:
build: build:
name: Build name: Build
runs-on: ubuntu-latest runs-on: ubuntu-latest
# needs: [lint, test] # needs: [lint, test]
needs: [test] needs: [test]
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: actions/setup-go@v3 - uses: actions/setup-go@v3
with: with:
go-version: '^1.19' go-version: "^1.19"
cache: true cache: true
- name: Build - name: Build
run: go build github.com/monkeyWie/gopeed/cmd/gopeed run: go build github.com/monkeyWie/gopeed/cmd/gopeed
+21
View File
@@ -0,0 +1,21 @@
FROM cirrusci/flutter:3.3.7 AS flutter
WORKDIR /app
COPY ./ui/flutter/pubspec.yaml ./ui/flutter/pubspec.lock ./
RUN flutter pub get
COPY ./ui/flutter ./
RUN flutter build web
FROM golang:1.19.3 AS go
WORKDIR /app
COPY ./go.mod ./go.sum ./
RUN go mod download
COPY . .
COPY --from=flutter /app/build/web ./cmd/web/dist
RUN CGO_ENABLED=0 go build -tags nosqlite,web -ldflags="-s -w" -o dist/gopeed github.com/monkeyWie/gopeed/cmd/web
FROM alpine:3.14.2
LABEL maintainer="monkeyWie"
WORKDIR /app
COPY --from=go /app/dist/gopeed ./
EXPOSE 9999
ENTRYPOINT ["./gopeed"]
+69 -27
View File
@@ -1,16 +1,19 @@
[![Test Status](https://github.com/monkeyWie/gopeed/workflows/test/badge.svg)](https://github.com/monkeyWie/gopeed/actions?query=workflow%3Atest) [![Test Status](https://github.com/monkeyWie/gopeed/workflows/test/badge.svg)](https://github.com/monkeyWie/gopeed/actions?query=workflow%3Atest)
[![Codecov](https://codecov.io/gh/monkeyWie/gopeed/branch/main/graph/badge.svg)](https://codecov.io/gh/monkeyWie/gopeed) [![Codecov](https://codecov.io/gh/monkeyWie/gopeed/branch/main/graph/badge.svg)](https://codecov.io/gh/monkeyWie/gopeed)
[![Release](https://img.shields.io/github/release/monkeyWie/gopeed.svg?style=flat-square)](https://github.com/monkeyWie/gopeed/releases) [![Release](https://img.shields.io/github/release/monkeyWie/gopeed.svg?style=flat-square)](https://github.com/monkeyWie/gopeed/releases)
[![Discord](https://img.shields.io/discord/1037992631881449472?label=Discord&logo=discord&style=social)](https://discord.gg/ZUJqJrwCGB)
![](_docs/img/banner.png) ![](_docs/img/banner.png)
## 介绍 [English](/README.md) | [中文](/README_zh-CN.md)
Gopeed 是一款由`Golang`+`Flutter`开发的高速下载器,支持(HTTP、BitTorrent、Magnet)协议下载,并且支持全平台使用。 ## Introduction
## 下载 Gopeed is a high-speed downloader developed by `Golang`+`Flutter`, which supports (HTTP, BitTorrent, Magnet) protocol downloads and supports all platforms.
**已支持平台** ## Installation
**Supported platforms**
- [x] windows - [x] windows
- [x] macos - [x] macos
@@ -18,45 +21,83 @@ Gopeed 是一款由`Golang`+`Flutter`开发的高速下载器,支持(HTTP、
- [x] android - [x] android
- [ ] ios - [ ] ios
- [x] web - [x] web
- [x] docker
[点击前往](https://github.com/monkeyWie/gopeed/releases/latest) [To Release](https://github.com/monkeyWie/gopeed/releases/latest)
### 命令行工具 > Tips: If the macos open failed, please execute the `xattr -d com.apple.quarantine /Applications/Gopeed.app` command in the terminal
使用`go install`安装: ### Command tool
use `go install`:
```bash ```bash
go install github.com/monkeyWie/gopeed/cmd/gopeed go install github.com/monkeyWie/gopeed/cmd/gopeed
``` ```
## 界面展示 ### Docker
#### Start directly
```bash
docker run -d -p 9999:9999 -v /path/to/download:/download liwei2633/gopeed
```
#### Using Docker Compose
```yaml
version: '3'
services:
gopeed:
container_name: gopeed
ports:
- 9999:9999
image: liwei2633/gopeed
volumes:
- ~/Downloads:/download
restart: unless-stopped
```
Use the command below to start container:
```bash
docker-compose up -d
```
#### Access Gopeed
When the docker container is running, you can access the web page through `http://localhost:9999`.
> Tip: Modify the download path to `/download` on the setting page to access the downloaded files on the host.
## Showcase
![](_docs/img/ui-demo.png) ![](_docs/img/ui-demo.png)
## 开发 ## Development
本项目分为前端和后端两个部分,前端使用`flutter`,后端使用`Golang`,两边通过`http`协议进行通讯,在 unix 系统下,使用的是`unix socket`,在 windows 系统下,使用的是`tcp`协议。 This project is divided into two parts, the front end uses `flutter`, the back end uses `Golang`, and the two sides communicate through the `http` protocol. On the unix system, `unix socket` is used, and on the windows system, `tcp` protocol is used.
> 前端代码位于`ui/flutter`目录下。 > The front code is located in the `ui/flutter` directory.
### 环境要求 ### Environment
1. Golang 1.9+ 1. Golang 1.19+
2. Flutter 3.0+ 2. Flutter 3.0+
### 克隆项目 ### Clone
```bash ```bash
git clone [email protected]:monkeyWie/gopeed.git git clone [email protected]:monkeyWie/gopeed.git
``` ```
### 编译 ### Build
#### 桌面端 #### Desktop
首先需要按照[flutter desktop 官网文档](https://docs.flutter.dev/development/platform-integration/desktop)进行环境配置,然后需要准备好`cgo`环境,具体可以自行搜索。 First, you need to configure the environment according to the [flutter desktop official website document](https://docs.flutter.dev/development/platform-integration/desktop), and then you need to prepare the `cgo` environment, which can be searched for yourself.
构建命令: command:
- windows - windows
@@ -82,16 +123,16 @@ cd ui/flutter
flutter build linux flutter build linux
``` ```
#### 移动端 #### Mobile
同样的也是需要准备好`cgo`环境,接着安装`gomobile`: Same as before, you also need to prepare the `cgo` environment, and then install `gomobile`:
```bash ```bash
go install golang.org/x/mobile/cmd/gomobile@latest go install golang.org/x/mobile/cmd/gomobile@latest
gomobile init gomobile init
``` ```
构建命令: command:
- android - android
@@ -101,20 +142,21 @@ cd ui/flutter
flutter build apk flutter build apk
``` ```
#### Web 端(推荐本地调试使用) #### Web
Web 端直接与后端 http 服务通讯,不需要额外准备环境。 Web platform communicates directly with the backend http server, no additional environment is required.
构建命令: command:
```bash ```bash
cd ui/flutter cd ui/flutter
flutter build web flutter build web
cd ../../ cd ../../
rm -rf cmd/web/dist
cp -r ui/flutter/build/web cmd/web/dist cp -r ui/flutter/build/web cmd/web/dist
go build -tags nosqlite,web -ldflags="-s -w" -o bin/ github.com/monkeyWie/gopeed/cmd/web go build -tags nosqlite,web -ldflags="-s -w" -o bin/ github.com/monkeyWie/gopeed/cmd/web
``` ````
## 开源许可 ## License
基于 [GPLv3](LICENSE) 协议开源。 [GPLv3](LICENSE)
+162
View File
@@ -0,0 +1,162 @@
[![Test Status](https://github.com/monkeyWie/gopeed/workflows/test/badge.svg)](https://github.com/monkeyWie/gopeed/actions?query=workflow%3Atest)
[![Codecov](https://codecov.io/gh/monkeyWie/gopeed/branch/main/graph/badge.svg)](https://codecov.io/gh/monkeyWie/gopeed)
[![Release](https://img.shields.io/github/release/monkeyWie/gopeed.svg?style=flat-square)](https://github.com/monkeyWie/gopeed/releases)
[![Discord](https://img.shields.io/discord/1037992631881449472?label=Discord&logo=discord&style=social)](https://discord.gg/ZUJqJrwCGB)
![](_docs/img/banner.png)
[English](/README.md) | [中文](/README_zh-CN.md)
## 介绍
Gopeed 是一款由`Golang`+`Flutter`开发的高速下载器,支持(HTTP、BitTorrent、Magnet)协议下载,并且支持全平台使用。
## 下载
**已支持平台**
- [x] windows
- [x] macos
- [x] linux
- [x] android
- [ ] ios
- [x] web
- [x] docker
[点击前往](https://github.com/monkeyWie/gopeed/releases/latest)
> 注:macos 版本运行如果提示损坏,请在终端执行 `xattr -d com.apple.quarantine /Applications/Gopeed.app` 命令
### 命令行工具
使用`go install`安装:
```bash
go install github.com/monkeyWie/gopeed/cmd/gopeed
```
### Docker
#### 直接运行
```bash
docker run -d -p 9999:9999 -v /path/to/download:/download liwei2633/gopeed
```
#### 使用 Docker Compose
```yaml
version: '3'
services:
gopeed:
container_name: gopeed
ports:
- 9999:9999
image: liwei2633/gopeed
volumes:
- ~/Downloads:/download
restart: unless-stopped
```
输入以下指令启动容器:
```bash
docker-compose up -d
```
#### 访问服务
当 docker 容器运行时,可以通过 `http://localhost:9999` 访问 web 页面。
> 提示:在设置页面把下载路径修改为 `/download` 以便在宿主机访问下载完的文件。
## 界面展示
![](_docs/img/ui-demo.png)
## 开发
本项目分为前端和后端两个部分,前端使用`flutter`,后端使用`Golang`,两边通过`http`协议进行通讯,在 unix 系统下,使用的是`unix socket`,在 windows 系统下,使用的是`tcp`协议。
> 前端代码位于`ui/flutter`目录下。
### 环境要求
1. Golang 1.19+
2. Flutter 3.0+
### 克隆项目
```bash
git clone [email protected]:monkeyWie/gopeed.git
```
### 编译
#### 桌面端
首先需要按照[flutter desktop 官网文档](https://docs.flutter.dev/development/platform-integration/desktop)进行环境配置,然后需要准备好`cgo`环境,具体可以自行搜索。
构建命令:
- windows
```bash
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/windows/libgopeed.dll github.com/monkeyWie/gopeed/bind/desktop
cd ui/flutter
flutter build windows
```
- macos
```bash
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/macos/Frameworks/libgopeed.dylib github.com/monkeyWie/gopeed/bind/desktop
cd ui/flutter
flutter build macos
```
- linux
```bash
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/linux/bundle/lib/libgopeed.so github.com/monkeyWie/gopeed/bind/desktop
cd ui/flutter
flutter build linux
```
#### 移动端
同样的也是需要准备好`cgo`环境,接着安装`gomobile`:
```bash
go install golang.org/x/mobile/cmd/gomobile@latest
gomobile init
```
构建命令:
- android
```bash
gomobile bind -tags nosqlite -ldflags="-w -s" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 19 -javapkg=com.gopeed github.com/monkeyWie/gopeed/bind/mobile
cd ui/flutter
flutter build apk
```
#### Web 端(推荐本地调试使用)
Web 端直接与后端 http 服务通讯,不需要额外准备环境。
构建命令:
```bash
cd ui/flutter
flutter build web
cd ../../
rm -rf cmd/web/dist
cp -r ui/flutter/build/web cmd/web/dist
go build -tags nosqlite,web -ldflags="-s -w" -o bin/ github.com/monkeyWie/gopeed/cmd/web
```
## 开源许可
基于 [GPLv3](LICENSE) 协议开源。
+7
View File
@@ -0,0 +1,7 @@
_______ ______ .______ _______ _______ _______
/ _____| / __ \ | _ \ | ____|| ____|| \
| | __ | | | | | |_) | | |__ | |__ | .--. |
| | |_ | | | | | | ___/ | __| | __| | | | |
| |__| | | `--' | | | | |____ | |____ | '--' |
\______| \______/ | _| |_______||_______||_______/
+29
View File
@@ -0,0 +1,29 @@
package main
import (
_ "embed"
"fmt"
"github.com/monkeyWie/gopeed/pkg/rest"
"github.com/monkeyWie/gopeed/pkg/rest/model"
"net/http"
)
//go:embed banner.txt
var banner string
func main() {
cfg := &model.StartConfig{
Network: "tcp",
Address: "127.0.0.1:9999",
Storage: model.StorageBolt,
}
fmt.Println(banner)
srv, listener, err := rest.BuildServer(cfg)
if err != nil {
panic(err)
}
fmt.Printf("Server start success on http://%s\n", listener.Addr().String())
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
panic(err)
}
}
+2 -3
View File
@@ -4,7 +4,6 @@ import (
"flag" "flag"
"fmt" "fmt"
"os" "os"
"path/filepath"
) )
type args struct { type args struct {
@@ -14,7 +13,7 @@ type args struct {
} }
func parse() *args { func parse() *args {
dir, err := filepath.Abs(filepath.Dir(os.Args[0])) dir, err := os.Getwd()
if err != nil { if err != nil {
panic(err) panic(err)
} }
@@ -26,7 +25,7 @@ func parse() *args {
if len(t) > 0 { if len(t) > 0 {
args.url = t[0] args.url = t[0]
} else { } else {
gPrintln("missing url parameter") gPrintln("missing url parameter, for example: gopeed https://www.google.com or gopeed bt.torrent or gopeed magnet:?xt=urn:btih:...")
gPrintln("try 'gopeed -h' for more information") gPrintln("try 'gopeed -h' for more information")
os.Exit(1) os.Exit(1)
} }
+3 -2
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"github.com/monkeyWie/gopeed/pkg/base" "github.com/monkeyWie/gopeed/pkg/base"
"github.com/monkeyWie/gopeed/pkg/download" "github.com/monkeyWie/gopeed/pkg/download"
"github.com/monkeyWie/gopeed/pkg/protocol/http"
"github.com/monkeyWie/gopeed/pkg/util" "github.com/monkeyWie/gopeed/pkg/util"
"strings" "strings"
"sync" "sync"
@@ -39,8 +40,8 @@ func main() {
} }
}). }).
Create(&base.Options{ Create(&base.Options{
Path: *args.dir, Path: *args.dir,
Connections: *args.connections, Extra: http.OptsExtra{Connections: *args.connections},
}) })
if err != nil { if err != nil {
panic(err) panic(err)
+2 -2
View File
@@ -25,8 +25,8 @@ func main() {
} }
cfg := &model.StartConfig{ cfg := &model.StartConfig{
Network: "tcp", Network: "tcp",
Address: "127.0.0.1:9999", Address: "0.0.0.0:9999",
Storage: model.StorageMem, Storage: model.StorageBolt,
WebEnable: true, WebEnable: true,
WebFS: sub, WebFS: sub,
} }
+11
View File
@@ -0,0 +1,11 @@
version: '3'
services:
gopeed:
container_name: gopeed
ports:
- 9999:9999 # HTTP port (host:container)
image: liwei2633/gopeed
volumes:
- ~/Downloads:/download # replace with your prefer path
restart: unless-stopped
+16 -6
View File
@@ -6,19 +6,29 @@ import (
"path/filepath" "path/filepath"
) )
type Controller interface { type Controller struct {
Touch(name string, size int64) (file *os.File, err error) GetConfig func(v any) (bool, error)
FileController
//ContextDialer() (proxy.Dialer, error) //ContextDialer() (proxy.Dialer, error)
} }
type DefaultController struct { type FileController interface {
Touch(name string, size int64) (file *os.File, err error)
} }
func NewController() *DefaultController { type DefaultFileController struct {
return &DefaultController{}
} }
func (c *DefaultController) Touch(name string, size int64) (file *os.File, err error) { func NewController() *Controller {
return &Controller{
FileController: &DefaultFileController{},
GetConfig: func(v any) (bool, error) {
return false, nil
},
}
}
func (c *DefaultFileController) Touch(name string, size int64) (file *os.File, err error) {
dir := filepath.Dir(name) dir := filepath.Dir(name)
if err = os.MkdirAll(dir, os.ModePerm); err != nil { if err = os.MkdirAll(dir, os.ModePerm); err != nil {
return return
+18 -12
View File
@@ -5,40 +5,46 @@ import (
"github.com/monkeyWie/gopeed/pkg/base" "github.com/monkeyWie/gopeed/pkg/base"
) )
// 对应协议的下载支持 // Fetcher defines the interface for a download protocol.
type Fetcher interface { type Fetcher interface {
Setup(ctl controller.Controller) error // Name return the name of the protocol.
// 解析请求 Name() string
Setup(ctl *controller.Controller) error
// Resolve resource info from request
Resolve(req *base.Request) (res *base.Resource, err error) Resolve(req *base.Request) (res *base.Resource, err error)
// 创建任务 // Create ready to download, but not started
Create(res *base.Resource, opts *base.Options) (err error) Create(res *base.Resource, opts *base.Options) (err error)
Start() (err error) Start() (err error)
Pause() (err error) Pause() (err error)
Continue() (err error) Continue() (err error)
Close() (err error) Close() (err error)
// 获取任务各个文件下载进度 // Progress returns the progress of the download.
Progress() Progress Progress() Progress
// 该方法会一直阻塞,直到任务下载结束 // Wait for the download to complete, this method will block until the download is done.
Wait() (err error) Wait() (err error)
} }
// FetcherBuilder defines the interface for a fetcher builder.
type FetcherBuilder interface { type FetcherBuilder interface {
// Schemes returns the schemes supported by the fetcher.
Schemes() []string Schemes() []string
// Build returns a new fetcher.
Build() Fetcher Build() Fetcher
// Store 存储任务 // Store fetcher
Store(fetcher Fetcher) (any, error) Store(fetcher Fetcher) (any, error)
// Restore 恢复任务 // Restore fetcher
Restore() (v any, f func(res *base.Resource, opts *base.Options, v any) Fetcher) Restore() (v any, f func(res *base.Resource, opts *base.Options, v any) Fetcher)
} }
type DefaultFetcher struct { type DefaultFetcher struct {
Ctl controller.Controller Ctl *controller.Controller
DoneCh chan error DoneCh chan error
} }
func (f *DefaultFetcher) Setup(ctl controller.Controller) (err error) { func (f *DefaultFetcher) Setup(ctl *controller.Controller) (err error) {
f.Ctl = ctl f.Ctl = ctl
f.DoneCh = make(chan error, 1) f.DoneCh = make(chan error, 1)
return return
@@ -48,10 +54,10 @@ func (f *DefaultFetcher) Wait() (err error) {
return <-f.DoneCh return <-f.DoneCh
} }
// 获取任务中各个文件的已下载字节数 // Progress is a map of the progress of each file in the torrent.
type Progress []int64 type Progress []int64
// TotalDownloaded 获取任务总下载字节数 // TotalDownloaded returns the total downloaded bytes.
func (p Progress) TotalDownloaded() int64 { func (p Progress) TotalDownloaded() int64 {
total := int64(0) total := int64(0)
for _, d := range p { for _, d := range p {
+5
View File
@@ -0,0 +1,5 @@
package bt
type config struct {
Trackers []string `json:"trackers"`
}
+21 -8
View File
@@ -17,7 +17,7 @@ import (
var client *torrent.Client var client *torrent.Client
type Fetcher struct { type Fetcher struct {
Ctl controller.Controller Ctl *controller.Controller
torrent *torrent.Torrent torrent *torrent.Torrent
res *base.Resource res *base.Resource
@@ -29,7 +29,11 @@ type Fetcher struct {
torrentPaths map[string]string torrentPaths map[string]string
} }
func (f *Fetcher) Setup(ctl controller.Controller) (err error) { func (f *Fetcher) Name() string {
return "bt"
}
func (f *Fetcher) Setup(ctl *controller.Controller) (err error) {
f.Ctl = ctl f.Ctl = ctl
var once sync.Once var once sync.Once
once.Do(func() { once.Do(func() {
@@ -105,9 +109,7 @@ func (f *Fetcher) Resolve(req *base.Request) (res *base.Resource, err error) {
Name: f.torrent.Name(), Name: f.torrent.Name(),
Range: true, Range: true,
Files: make([]*base.FileInfo, len(f.torrent.Files())), Files: make([]*base.FileInfo, len(f.torrent.Files())),
Extra: map[string]any{ Hash: f.torrent.InfoHash().String(),
"infoHash": f.torrent.InfoHash().String(),
},
} }
for i, file := range f.torrent.Files() { for i, file := range f.torrent.Files() {
res.Files[i] = &base.FileInfo{ res.Files[i] = &base.FileInfo{
@@ -130,10 +132,9 @@ func (f *Fetcher) Create(res *base.Resource, opts *base.Options) (err error) {
} }
} }
if f.opts.Path != "" { if f.opts.Path != "" {
if extra, ok := res.Extra.(map[string]any); ok { f.torrentPaths[res.Hash] = f.opts.Path
f.torrentPaths[extra["infoHash"].(string)] = f.opts.Path
}
} }
f.progress = make(fetcher.Progress, len(f.opts.SelectFiles)) f.progress = make(fetcher.Progress, len(f.opts.SelectFiles))
f.ready.Store(false) f.ready.Store(false)
return nil return nil
@@ -192,6 +193,18 @@ func (f *Fetcher) addTorrent(url string, resolve bool) (err error) {
if err != nil { if err != nil {
return return
} }
var cfg config
exist, err := f.Ctl.GetConfig(&cfg)
if err != nil {
return
}
if exist && len(cfg.Trackers) > 0 {
announceList := make([][]string, 0)
for _, tracker := range cfg.Trackers {
announceList = append(announceList, []string{tracker})
}
f.torrent.AddTrackers(announceList)
}
<-f.torrent.GotInfo() <-f.torrent.GotInfo()
if resolve { if resolve {
return return
+29 -4
View File
@@ -1,15 +1,24 @@
package bt package bt
import ( import (
"encoding/json"
"github.com/monkeyWie/gopeed/internal/controller" "github.com/monkeyWie/gopeed/internal/controller"
"github.com/monkeyWie/gopeed/internal/fetcher" "github.com/monkeyWie/gopeed/internal/fetcher"
"github.com/monkeyWie/gopeed/internal/test"
"github.com/monkeyWie/gopeed/pkg/base" "github.com/monkeyWie/gopeed/pkg/base"
"reflect" "reflect"
"testing" "testing"
) )
func TestFetcher_Resolve_Torrent(t *testing.T) { func TestFetcher_Resolve_Torrent(t *testing.T) {
fetcher := buildFetcher() doResolve(t, buildFetcher())
}
func TestFetcher_Config(t *testing.T) {
doResolve(t, buildConfigFetcher())
}
func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
res, err := fetcher.Resolve(&base.Request{ res, err := fetcher.Resolve(&base.Request{
URL: "./testdata/ubuntu-22.04-live-server-amd64.iso.torrent", URL: "./testdata/ubuntu-22.04-live-server-amd64.iso.torrent",
}) })
@@ -28,9 +37,7 @@ func TestFetcher_Resolve_Torrent(t *testing.T) {
Size: 1466714112, Size: 1466714112,
}, },
}, },
Extra: map[string]any{ Hash: "8a55cfbd5ca5d11507364765936c4f9e55b253ed",
"infoHash": "8a55cfbd5ca5d11507364765936c4f9e55b253ed",
},
} }
if !reflect.DeepEqual(want, res) { if !reflect.DeepEqual(want, res) {
t.Errorf("Resolve() got = %v, want %v", res, want) t.Errorf("Resolve() got = %v, want %v", res, want)
@@ -42,3 +49,21 @@ func buildFetcher() fetcher.Fetcher {
fetcher.Setup(controller.NewController()) fetcher.Setup(controller.NewController())
return fetcher return fetcher
} }
func buildConfigFetcher() fetcher.Fetcher {
fetcher := new(FetcherBuilder).Build()
newController := controller.NewController()
mockCfg := config{
Trackers: []string{
"udp://tracker.birkenwald.de:6969/announce",
"udp://tracker.bitsearch.to:1337/announce",
}}
newController.GetConfig = func(v any) (bool, error) {
if err := json.Unmarshal([]byte(test.ToJson(mockCfg)), v); err != nil {
return false, err
}
return true, nil
}
fetcher.Setup(newController)
return fetcher
}
+5
View File
@@ -0,0 +1,5 @@
package http
type config struct {
Connections int `json:"connections"`
}
+60 -11
View File
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"github.com/monkeyWie/gopeed/internal/fetcher" "github.com/monkeyWie/gopeed/internal/fetcher"
"github.com/monkeyWie/gopeed/pkg/base" "github.com/monkeyWie/gopeed/pkg/base"
fhttp "github.com/monkeyWie/gopeed/pkg/protocol/http"
"github.com/monkeyWie/gopeed/pkg/util" "github.com/monkeyWie/gopeed/pkg/util"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
"io" "io"
@@ -34,6 +35,21 @@ func (re *RequestError) Error() string {
return fmt.Sprintf("http request fail,code:%d", re.Code) return fmt.Sprintf("http request fail,code:%d", re.Code)
} }
type chunk struct {
Status base.Status
Begin int64
End int64
Downloaded int64
}
func newChunk(begin int64, end int64) *chunk {
return &chunk{
Status: base.DownloadStatusReady,
Begin: begin,
End: end,
}
}
type Fetcher struct { type Fetcher struct {
*fetcher.DefaultFetcher *fetcher.DefaultFetcher
@@ -48,7 +64,14 @@ type Fetcher struct {
pauseCh chan interface{} pauseCh chan interface{}
} }
func (f *Fetcher) Name() string {
return "http"
}
func (f *Fetcher) Resolve(req *base.Request) (*base.Resource, error) { func (f *Fetcher) Resolve(req *base.Request) (*base.Resource, error) {
if err := base.ParseReqExtra[fhttp.ReqExtra](req); err != nil {
return nil, err
}
httpReq, err := buildRequest(nil, req) httpReq, err := buildRequest(nil, req)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -120,6 +143,29 @@ func (f *Fetcher) Create(res *base.Resource, opts *base.Options) error {
f.res = res f.res = res
f.opts = opts f.opts = opts
f.status = base.DownloadStatusReady f.status = base.DownloadStatusReady
if err := base.ParseReqExtra[fhttp.ReqExtra](res.Req); err != nil {
return err
}
if err := base.ParseOptsExtra[fhttp.OptsExtra](opts); err != nil {
return err
}
if opts.Extra == nil {
opts.Extra = &fhttp.OptsExtra{}
}
extra := opts.Extra.(*fhttp.OptsExtra)
if extra.Connections == 0 {
var cfg config
exist, err := f.Ctl.GetConfig(&cfg)
if err != nil {
return err
}
if exist {
extra.Connections = cfg.Connections
} else {
extra.Connections = 1
}
}
return nil return nil
} }
@@ -131,7 +177,7 @@ func (f *Fetcher) Start() (err error) {
return err return err
} }
f.status = base.DownloadStatusRunning f.status = base.DownloadStatusRunning
f.chunks = splitChunk(f.res, f.opts) f.chunks = f.splitChunk()
f.fetch() f.fetch()
return return
} }
@@ -188,7 +234,7 @@ func (f *Fetcher) filepath() string {
func (f *Fetcher) fetch() { func (f *Fetcher) fetch() {
f.ctx, f.cancel = context.WithCancel(context.Background()) f.ctx, f.cancel = context.WithCancel(context.Background())
eg, _ := errgroup.WithContext(f.ctx) eg, _ := errgroup.WithContext(f.ctx)
for i := 0; i < f.opts.Connections; i++ { for i := 0; i < f.opts.Extra.(*fhttp.OptsExtra).Connections; i++ {
i := i i := i
eg.Go(func() error { eg.Go(func() error {
return f.fetchChunk(i) return f.fetchChunk(i)
@@ -301,19 +347,20 @@ func (f *Fetcher) fetchChunk(index int) (err error) {
return return
} }
func splitChunk(res *base.Resource, opts *base.Options) (chunks []*chunk) { func (f *Fetcher) splitChunk() (chunks []*chunk) {
if res.Range { if f.res.Range {
connections := f.opts.Extra.(*fhttp.OptsExtra).Connections
// 每个连接平均需要下载的分块大小 // 每个连接平均需要下载的分块大小
chunkSize := res.Size / int64(opts.Connections) chunkSize := f.res.Size / int64(connections)
chunks = make([]*chunk, opts.Connections) chunks = make([]*chunk, connections)
for i := 0; i < opts.Connections; i++ { for i := 0; i < connections; i++ {
var ( var (
begin = chunkSize * int64(i) begin = chunkSize * int64(i)
end int64 end int64
) )
if i == opts.Connections-1 { if i == connections-1 {
// 最后一个分块需要保证把文件下载完 // 最后一个分块需要保证把文件下载完
end = res.Size - 1 end = f.res.Size - 1
} else { } else {
end = begin + chunkSize - 1 end = begin + chunkSize - 1
} }
@@ -351,7 +398,7 @@ func buildRequest(ctx context.Context, req *base.Request) (httpReq *http.Request
if req.Extra == nil { if req.Extra == nil {
method = http.MethodGet method = http.MethodGet
} else { } else {
extra := req.Extra.(extra) extra := req.Extra.(*fhttp.ReqExtra)
if extra.Method != "" { if extra.Method != "" {
method = extra.Method method = extra.Method
} else { } else {
@@ -414,8 +461,10 @@ func (fb *FetcherBuilder) Restore() (v any, f func(res *base.Resource, opts *bas
fetcher.res = res fetcher.res = res
fetcher.opts = opts fetcher.opts = opts
fetcher.status = base.DownloadStatusPause fetcher.status = base.DownloadStatusPause
base.ParseReqExtra[fhttp.ReqExtra](res.Req)
base.ParseOptsExtra[fhttp.OptsExtra](opts)
if len(fd.Chunks) == 0 { if len(fd.Chunks) == 0 {
fetcher.chunks = splitChunk(res, opts) fetcher.chunks = fetcher.splitChunk()
} else { } else {
fetcher.chunks = fd.Chunks fetcher.chunks = fd.Chunks
} }
+50 -5
View File
@@ -1,11 +1,13 @@
package http package http
import ( import (
"encoding/json"
"fmt" "fmt"
"github.com/monkeyWie/gopeed/internal/controller" "github.com/monkeyWie/gopeed/internal/controller"
"github.com/monkeyWie/gopeed/internal/fetcher" "github.com/monkeyWie/gopeed/internal/fetcher"
"github.com/monkeyWie/gopeed/internal/test" "github.com/monkeyWie/gopeed/internal/test"
"github.com/monkeyWie/gopeed/pkg/base" "github.com/monkeyWie/gopeed/pkg/base"
"github.com/monkeyWie/gopeed/pkg/protocol/http"
"net" "net"
"reflect" "reflect"
"testing" "testing"
@@ -111,18 +113,45 @@ func TestFetcher_DownloadResume(t *testing.T) {
downloadResume(listener, 16, t) downloadResume(listener, 16, t)
} }
func TestFetcher_Config(t *testing.T) {
fetcher, _, _ := doDownloadReady(buildConfigFetcher(), test.StartTestFileServer(), 0, t)
err := fetcher.Start()
if err != nil {
t.Fatal(err)
}
err = fetcher.Wait()
if err != nil {
t.Fatal(err)
}
want := test.FileMd5(test.BuildFile)
got := test.FileMd5(test.DownloadFile)
if want != got {
t.Errorf("Download() got = %v, want %v", got, want)
}
}
func downloadReady(listener net.Listener, connections int, t *testing.T) (fetcher fetcher.Fetcher, res *base.Resource, opts *base.Options) { func downloadReady(listener net.Listener, connections int, t *testing.T) (fetcher fetcher.Fetcher, res *base.Resource, opts *base.Options) {
fetcher = buildFetcher() return doDownloadReady(buildFetcher(), listener, connections, t)
}
func doDownloadReady(f fetcher.Fetcher, listener net.Listener, connections int, t *testing.T) (fetcher fetcher.Fetcher, res *base.Resource, opts *base.Options) {
fetcher = f
res, err := fetcher.Resolve(&base.Request{ res, err := fetcher.Resolve(&base.Request{
URL: "http://" + listener.Addr().String() + "/" + test.BuildName, URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
}) })
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
var extra any = nil
if connections > 0 {
extra = http.OptsExtra{
Connections: connections,
}
}
opts = &base.Options{ opts = &base.Options{
Name: test.DownloadName, Name: test.DownloadName,
Path: test.Dir, Path: test.Dir,
Connections: connections, Extra: extra,
} }
err = fetcher.Create(res, opts) err = fetcher.Create(res, opts)
if err != nil { if err != nil {
@@ -150,7 +179,7 @@ func downloadNormal(listener net.Listener, connections int, t *testing.T) {
func downloadPost(listener net.Listener, connections int, t *testing.T) { func downloadPost(listener net.Listener, connections int, t *testing.T) {
fetcher, _, _ := downloadReady(listener, connections, t) fetcher, _, _ := downloadReady(listener, connections, t)
fetcher.(*Fetcher).res.Req.Extra = extra{ fetcher.(*Fetcher).res.Req.Extra = &http.ReqExtra{
Method: "POST", Method: "POST",
Header: map[string]string{ Header: map[string]string{
"Authorization": "Bearer 123456", "Authorization": "Bearer 123456",
@@ -249,3 +278,19 @@ func buildFetcher() *Fetcher {
fetcher.Setup(controller.NewController()) fetcher.Setup(controller.NewController())
return fetcher.(*Fetcher) return fetcher.(*Fetcher)
} }
func buildConfigFetcher() fetcher.Fetcher {
fetcher := new(FetcherBuilder).Build()
newController := controller.NewController()
mockCfg := config{
Connections: 16,
}
newController.GetConfig = func(v any) (bool, error) {
if err := json.Unmarshal([]byte(test.ToJson(mockCfg)), v); err != nil {
return false, err
}
return true, nil
}
fetcher.Setup(newController)
return fetcher
}
-24
View File
@@ -1,24 +0,0 @@
package http
import "github.com/monkeyWie/gopeed/pkg/base"
type chunk struct {
Status base.Status
Begin int64
End int64
Downloaded int64
}
func newChunk(begin int64, end int64) *chunk {
return &chunk{
Status: base.DownloadStatusReady,
Begin: begin,
End: end,
}
}
type extra struct {
Method string
Header map[string]string
Body string
}
+4
View File
@@ -49,3 +49,7 @@ func ToJson(v interface{}) string {
buf, _ := json.Marshal(v) buf, _ := json.Marshal(v)
return string(buf) return string(buf)
} }
func JsonEqual(v1 any, v2 any) bool {
return ToJson(v1) == ToJson(v2)
}
+8
View File
@@ -0,0 +1,8 @@
package base
import "errors"
var (
NotFound = errors.New("not found")
BadParams = errors.New("bad params")
)
+37 -5
View File
@@ -1,11 +1,13 @@
package base package base
import "github.com/monkeyWie/gopeed/pkg/util"
// 下载请求 // 下载请求
type Request struct { type Request struct {
// 下载链接 // 下载链接
URL string `json:"url"` URL string `json:"url"`
// 附加信息 // 附加信息
Extra interface{} `json:"extra"` Extra any `json:"extra"`
} }
// 资源信息 // 资源信息
@@ -19,8 +21,8 @@ type Resource struct {
Range bool `json:"range"` Range bool `json:"range"`
// 资源所包含的文件列表 // 资源所包含的文件列表
Files []*FileInfo `json:"files"` Files []*FileInfo `json:"files"`
// 附加信息 // 资源hash值
Extra interface{} `json:"extra"` Hash string `json:"hash"`
} }
type FileInfo struct { type FileInfo struct {
@@ -35,8 +37,38 @@ type Options struct {
Name string `json:"name"` Name string `json:"name"`
// 保存目录 // 保存目录
Path string `json:"path"` Path string `json:"path"`
// 并发连接数
Connections int `json:"connections"`
// 选择下载的文件下标列表 // 选择下载的文件下标列表
SelectFiles []int `json:"selectFiles"` SelectFiles []int `json:"selectFiles"`
// 附加信息
Extra any `json:"extra"`
}
func ParseReqExtra[E any](req *Request) error {
if req.Extra == nil {
return nil
}
if _, ok := req.Extra.(*E); ok {
return nil
}
var t E
if err := util.MapToStruct(req.Extra, &t); err != nil {
return err
}
req.Extra = &t
return nil
}
func ParseOptsExtra[E any](opts *Options) error {
if opts.Extra == nil {
return nil
}
if _, ok := opts.Extra.(*E); ok {
return nil
}
var t E
if err := util.MapToStruct(opts.Extra, &t); err != nil {
return err
}
opts.Extra = &t
return nil
} }
+48 -42
View File
@@ -4,8 +4,6 @@ import (
"errors" "errors"
"github.com/monkeyWie/gopeed/internal/controller" "github.com/monkeyWie/gopeed/internal/controller"
"github.com/monkeyWie/gopeed/internal/fetcher" "github.com/monkeyWie/gopeed/internal/fetcher"
"github.com/monkeyWie/gopeed/internal/protocol/bt"
"github.com/monkeyWie/gopeed/internal/protocol/http"
"github.com/monkeyWie/gopeed/pkg/base" "github.com/monkeyWie/gopeed/pkg/base"
"github.com/monkeyWie/gopeed/pkg/util" "github.com/monkeyWie/gopeed/pkg/util"
"strings" "strings"
@@ -32,7 +30,6 @@ type Progress struct {
} }
type Downloader struct { type Downloader struct {
controller *controller.DefaultController
fetchBuilders map[string]fetcher.FetcherBuilder fetchBuilders map[string]fetcher.FetcherBuilder
storage Storage storage Storage
tasks []*Task tasks []*Task
@@ -43,34 +40,6 @@ type Downloader struct {
closed atomic.Bool closed atomic.Bool
} }
type DownloaderConfig struct {
Controller *controller.DefaultController
FetchBuilders []fetcher.FetcherBuilder
Storage Storage
// RefreshInterval time duration to refresh task progress(ms)
RefreshInterval int
}
func (cfg *DownloaderConfig) Init() *DownloaderConfig {
if cfg.Controller == nil {
cfg.Controller = controller.NewController()
}
if len(cfg.FetchBuilders) == 0 {
cfg.FetchBuilders = []fetcher.FetcherBuilder{
new(http.FetcherBuilder),
new(bt.FetcherBuilder),
}
}
if cfg.Storage == nil {
cfg.Storage = NewMemStorage()
}
if cfg.RefreshInterval == 0 {
cfg.RefreshInterval = 1000
}
return cfg
}
func NewDownloader(cfg *DownloaderConfig) *Downloader { func NewDownloader(cfg *DownloaderConfig) *Downloader {
if cfg == nil { if cfg == nil {
cfg = &DownloaderConfig{} cfg = &DownloaderConfig{}
@@ -78,7 +47,6 @@ func NewDownloader(cfg *DownloaderConfig) *Downloader {
cfg.Init() cfg.Init()
d := &Downloader{ d := &Downloader{
controller: cfg.Controller,
fetchBuilders: make(map[string]fetcher.FetcherBuilder), fetchBuilders: make(map[string]fetcher.FetcherBuilder),
refreshInterval: cfg.RefreshInterval, refreshInterval: cfg.RefreshInterval,
storage: cfg.Storage, storage: cfg.Storage,
@@ -134,7 +102,9 @@ func (d *Downloader) Setup() error {
current := task.fetcher.Progress().TotalDownloaded() current := task.fetcher.Progress().TotalDownloaded()
task.Progress.Used = task.timer.Used() task.Progress.Used = task.timer.Used()
task.Progress.Speed = current - task.Progress.Downloaded currUsedTime := float64(d.refreshInterval) / 1000
currSpeed := int64(float64(current-task.Progress.Downloaded) / currUsedTime)
task.Progress.Speed = (task.Progress.Speed + currSpeed) / 2
task.Progress.Downloaded = current task.Progress.Downloaded = current
d.emit(EventKeyProgress, task) d.emit(EventKeyProgress, task)
@@ -166,12 +136,20 @@ func (d *Downloader) buildFetcher(url string) (fetcher.FetcherBuilder, fetcher.F
} }
if ok { if ok {
fetcher := fetchBuilder.Build() fetcher := fetchBuilder.Build()
fetcher.Setup(d.controller) d.setupFetcher(fetcher)
return fetchBuilder, fetcher, nil return fetchBuilder, fetcher, nil
} }
return nil, nil, errors.New("unsupported protocol") return nil, nil, errors.New("unsupported protocol")
} }
func (d *Downloader) setupFetcher(fetcher fetcher.Fetcher) {
ctl := controller.NewController()
ctl.GetConfig = func(v any) (bool, error) {
return d.getProtocolConfig(fetcher.Name(), v)
}
fetcher.Setup(ctl)
}
func (d *Downloader) Resolve(req *base.Request) (*base.Resource, error) { func (d *Downloader) Resolve(req *base.Request) (*base.Resource, error) {
_, fetcher, err := d.buildFetcher(req.URL) _, fetcher, err := d.buildFetcher(req.URL)
if err != nil { if err != nil {
@@ -189,15 +167,21 @@ func (d *Downloader) Create(res *base.Resource, opts *base.Options) (taskId stri
if opts == nil { if opts == nil {
opts = &base.Options{} opts = &base.Options{}
} }
if !res.Range || opts.Connections < 1 {
opts.Connections = 1
}
if len(opts.SelectFiles) == 0 { if len(opts.SelectFiles) == 0 {
opts.SelectFiles = make([]int, 0) opts.SelectFiles = make([]int, 0)
for i := range res.Files { for i := range res.Files {
opts.SelectFiles = append(opts.SelectFiles, i) opts.SelectFiles = append(opts.SelectFiles, i)
} }
} }
if opts.Path == "" {
exist, storeConfig, err := d.GetConfig()
if err != nil {
return "", err
}
if exist {
opts.Path = storeConfig.DownloadDir
}
}
fetcherBuilder, fetcher, err := d.buildFetcher(res.Req.URL) fetcherBuilder, fetcher, err := d.buildFetcher(res.Req.URL)
if err != nil { if err != nil {
@@ -380,14 +364,36 @@ func (d *Downloader) GetTasks() []*Task {
return d.tasks return d.tasks
} }
func (d *Downloader) GetConfig(v any) (bool, error) { func (d *Downloader) GetConfig() (bool, *DownloaderStoreConfig, error) {
return d.storage.Get(bucketConfig, "config", v) var cfg DownloaderStoreConfig
exist, err := d.storage.Get(bucketConfig, "config", &cfg)
if err != nil {
return false, nil, err
}
return exist, &cfg, nil
} }
func (d *Downloader) PutConfig(v any) error { func (d *Downloader) PutConfig(v *DownloaderStoreConfig) error {
return d.storage.Put(bucketConfig, "config", v) return d.storage.Put(bucketConfig, "config", v)
} }
func (d *Downloader) getProtocolConfig(name string, v any) (bool, error) {
exist, cfg, err := d.GetConfig()
if err != nil {
return false, err
}
if !exist {
return false, nil
}
if cfg.ProtocolConfig == nil || cfg.ProtocolConfig[name] == nil {
return false, nil
}
if err := util.MapToStruct(cfg.ProtocolConfig[name], v); err != nil {
return false, err
}
return true, nil
}
func (d *Downloader) watch(task *Task) { func (d *Downloader) watch(task *Task) {
err := task.fetcher.Wait() err := task.fetcher.Wait()
if err != nil { if err != nil {
@@ -435,9 +441,9 @@ func (d *Downloader) restoreFetcher(task *Task) error {
} }
if task.fetcher == nil { if task.fetcher == nil {
task.fetcher = fetcherBuilder.Build() task.fetcher = fetcherBuilder.Build()
task.fetcher.Create(task.Res, task.Opts)
} }
task.fetcher.Setup(d.controller) d.setupFetcher(task.fetcher)
task.fetcher.Create(task.Res, task.Opts)
go d.watch(task) go d.watch(task)
} }
return nil return nil
+66 -6
View File
@@ -3,6 +3,7 @@ package download
import ( import (
"github.com/monkeyWie/gopeed/internal/test" "github.com/monkeyWie/gopeed/internal/test"
"github.com/monkeyWie/gopeed/pkg/base" "github.com/monkeyWie/gopeed/pkg/base"
"github.com/monkeyWie/gopeed/pkg/protocol/http"
"reflect" "reflect"
"sync" "sync"
"testing" "testing"
@@ -68,9 +69,11 @@ func TestDownloader_Create(t *testing.T) {
} }
}) })
_, err = downloader.Create(res, &base.Options{ _, err = downloader.Create(res, &base.Options{
Path: test.Dir, Path: test.Dir,
Name: test.DownloadName, Name: test.DownloadName,
Connections: 4, Extra: http.OptsExtra{
Connections: 4,
},
}) })
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -103,9 +106,11 @@ func TestDownloader_StoreAndRestore(t *testing.T) {
} }
id, err := downloader.Create(res, &base.Options{ id, err := downloader.Create(res, &base.Options{
Path: test.Dir, Path: test.Dir,
Name: test.DownloadName, Name: test.DownloadName,
Connections: 4, Extra: http.OptsExtra{
Connections: 4,
},
}) })
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -148,3 +153,58 @@ func TestDownloader_StoreAndRestore(t *testing.T) {
downloader.Clear() downloader.Clear()
} }
func TestDownloader_Protocol_Config(t *testing.T) {
downloader := NewDownloader(nil)
if err := downloader.Setup(); err != nil {
t.Fatal(err)
}
defer downloader.Clear()
var httpCfg map[string]any
exits, err := downloader.getProtocolConfig("http", &httpCfg)
if err != nil {
t.Fatal(err)
}
if exits {
t.Errorf("getProtocolConfig() got = %v, want %v", exits, false)
}
storeCfg := &DownloaderStoreConfig{
RefreshInterval: 500,
DownloadDir: "./downloads",
ProtocolConfig: map[string]any{
"http": map[string]any{
"connections": 4,
},
"bt": map[string]any{
"trackerSubscribeUrls": []string{
"https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/best.txt",
},
"trackers": []string{
"udp://tracker.coppersurfer.tk:6969/announce",
"udp://tracker.leechers-paradise.org:6969/announce",
},
},
},
Extra: map[string]any{
"theme": "dark",
},
}
if err := downloader.PutConfig(storeCfg); err != nil {
t.Fatal(err)
}
exits, newStoreCfg, err := downloader.GetConfig()
if err != nil {
t.Fatal(err)
}
if !exits {
t.Errorf("GetConfig() got = %v, want %v", exits, true)
}
if !test.JsonEqual(storeCfg, newStoreCfg) {
t.Errorf("GetConfig() got = %v, want %v", test.ToJson(storeCfg), test.ToJson(newStoreCfg))
}
}
+48
View File
@@ -0,0 +1,48 @@
package download
import (
"github.com/monkeyWie/gopeed/internal/controller"
"github.com/monkeyWie/gopeed/internal/fetcher"
"github.com/monkeyWie/gopeed/internal/protocol/bt"
"github.com/monkeyWie/gopeed/internal/protocol/http"
)
type DownloaderConfig struct {
Controller *controller.Controller
FetchBuilders []fetcher.FetcherBuilder
Storage Storage
*DownloaderStoreConfig
}
func (cfg *DownloaderConfig) Init() *DownloaderConfig {
if cfg.Controller == nil {
cfg.Controller = controller.NewController()
}
if len(cfg.FetchBuilders) == 0 {
cfg.FetchBuilders = []fetcher.FetcherBuilder{
new(http.FetcherBuilder),
new(bt.FetcherBuilder),
}
}
if cfg.Storage == nil {
cfg.Storage = NewMemStorage()
}
if cfg.DownloaderStoreConfig == nil {
cfg.DownloaderStoreConfig = &DownloaderStoreConfig{}
}
if cfg.RefreshInterval == 0 {
cfg.RefreshInterval = 350
}
return cfg
}
// DownloaderStoreConfig is the config that can restore the downloader.
type DownloaderStoreConfig struct {
RefreshInterval int `json:"refreshInterval"` // RefreshInterval time duration to refresh task progress(ms)
DownloadDir string `json:"downloadDir"` // DownloadDir is the directory to save the downloaded files
ProtocolConfig map[string]any `json:"protocolConfig"` // ProtocolConfig is special config for each protocol
Extra map[string]any `json:"extra"` // Extra is the extra config
}
+11
View File
@@ -0,0 +1,11 @@
package http
type ReqExtra struct {
Method string `json:"method"`
Header map[string]string `json:"header"`
Body string `json:"body"`
}
type OptsExtra struct {
Connections int `json:"connections"`
}
+47 -11
View File
@@ -1,12 +1,15 @@
package rest package rest
import ( import (
"compress/gzip"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/monkeyWie/gopeed/pkg/base" "github.com/monkeyWie/gopeed/pkg/base"
"github.com/monkeyWie/gopeed/pkg/download" "github.com/monkeyWie/gopeed/pkg/download"
"github.com/monkeyWie/gopeed/pkg/rest/model" "github.com/monkeyWie/gopeed/pkg/rest/model"
"github.com/monkeyWie/gopeed/pkg/rest/util" "github.com/monkeyWie/gopeed/pkg/rest/util"
"io"
"net/http" "net/http"
"net/url"
"strings" "strings"
) )
@@ -25,12 +28,6 @@ func Resolve(w http.ResponseWriter, r *http.Request) {
func CreateTask(w http.ResponseWriter, r *http.Request) { func CreateTask(w http.ResponseWriter, r *http.Request) {
var req model.CreateTask var req model.CreateTask
if util.ReadJson(w, r, &req) { if util.ReadJson(w, r, &req) {
if req.Opts.Connections == 0 {
req.Opts.Connections = getServerConfig().Connections
}
if req.Opts.Path == "" {
req.Opts.Path = getServerConfig().DownloadDir
}
taskId, err := Downloader.Create(req.Res, req.Opts) taskId, err := Downloader.Create(req.Res, req.Opts)
if err != nil { if err != nil {
util.WriteJson(w, http.StatusInternalServerError, model.NewResultWithMsg(err.Error())) util.WriteJson(w, http.StatusInternalServerError, model.NewResultWithMsg(err.Error()))
@@ -106,7 +103,7 @@ func GetConfig(w http.ResponseWriter, r *http.Request) {
} }
func PutConfig(w http.ResponseWriter, r *http.Request) { func PutConfig(w http.ResponseWriter, r *http.Request) {
var cfg model.ServerConfig var cfg download.DownloaderStoreConfig
if util.ReadJson(w, r, &cfg) { if util.ReadJson(w, r, &cfg) {
if err := Downloader.PutConfig(&cfg); err != nil { if err := Downloader.PutConfig(&cfg); err != nil {
util.WriteJson(w, http.StatusInternalServerError, model.NewResultWithMsg(err.Error())) util.WriteJson(w, http.StatusInternalServerError, model.NewResultWithMsg(err.Error()))
@@ -116,8 +113,47 @@ func PutConfig(w http.ResponseWriter, r *http.Request) {
util.WriteJsonOk(w, nil) util.WriteJsonOk(w, nil)
} }
func getServerConfig() *model.ServerConfig { func DoProxy(w http.ResponseWriter, r *http.Request) {
var cfg model.ServerConfig target := r.Header.Get("X-Target-Uri")
Downloader.GetConfig(&cfg) if target == "" {
return &cfg util.WriteJson(w, http.StatusBadRequest, model.NewResultWithMsg("header is required: X-Target-Uri"))
return
}
targetUrl, err := url.Parse(target)
if err != nil {
util.WriteJson(w, http.StatusBadRequest, model.NewResultWithMsg(err.Error()))
return
}
r.RequestURI = ""
r.URL = targetUrl
r.Host = targetUrl.Host
resp, err := http.DefaultClient.Do(r)
if err != nil {
util.WriteJson(w, http.StatusBadRequest, model.NewResultWithMsg(err.Error()))
return
}
defer resp.Body.Close()
w.WriteHeader(resp.StatusCode)
for k, vv := range resp.Header {
for _, v := range vv {
w.Header().Add(k, v)
}
}
var reader io.ReadCloser
switch resp.Header.Get("Content-Encoding") {
case "gzip":
reader, err = gzip.NewReader(resp.Body)
defer reader.Close()
default:
reader = resp.Body
}
if _, err := io.Copy(w, reader); err != nil {
util.WriteJson(w, http.StatusBadRequest, model.NewResultWithMsg(err.Error()))
return
}
}
func getServerConfig() *download.DownloaderStoreConfig {
_, cfg, _ := Downloader.GetConfig()
return cfg
} }
+6
View File
@@ -0,0 +1,6 @@
package rest
type Config struct {
Host string `json:"host"`
Port int `json:"port"`
}
+7
View File
@@ -0,0 +1,7 @@
package model
type Command struct {
Protocol string `json:"protocol"`
Action string `json:"action"`
Params any `json:"params"`
}
+4 -24
View File
@@ -15,6 +15,7 @@ type StartConfig struct {
Storage Storage `json:"storage"` Storage Storage `json:"storage"`
StorageDir string `json:"storageDir"` StorageDir string `json:"storageDir"`
RefreshInterval int `json:"refreshInterval"` RefreshInterval int `json:"refreshInterval"`
ApiToken string `json:"apiToken"`
WebEnable bool WebEnable bool
WebFS fs.FS WebFS fs.FS
@@ -24,6 +25,9 @@ func (cfg *StartConfig) Init() *StartConfig {
if cfg.Network == "" { if cfg.Network == "" {
cfg.Network = "tcp" cfg.Network = "tcp"
} }
if cfg.Address == "" {
cfg.Address = "127.0.0.1:0"
}
if cfg.Storage == "" { if cfg.Storage == "" {
cfg.Storage = StorageBolt cfg.Storage = StorageBolt
} }
@@ -32,27 +36,3 @@ func (cfg *StartConfig) Init() *StartConfig {
} }
return cfg return cfg
} }
// ServerConfig is present in the database
type ServerConfig struct {
Host string `json:"host"`
Port int `json:"port"`
Connections int `json:"connections"`
DownloadDir string `json:"downloadDir"`
Extra map[string]any `json:"extra"`
}
func (cfg *ServerConfig) Init() *ServerConfig {
if cfg.Host == "" {
cfg.Host = "127.0.0.1"
}
if cfg.Port < 0 {
cfg.Port = 0
}
if cfg.Connections <= 0 {
cfg.Connections = 16
}
return cfg
}
+20 -30
View File
@@ -6,10 +6,10 @@ import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/monkeyWie/gopeed/pkg/download" "github.com/monkeyWie/gopeed/pkg/download"
"github.com/monkeyWie/gopeed/pkg/rest/model" "github.com/monkeyWie/gopeed/pkg/rest/model"
restUtil "github.com/monkeyWie/gopeed/pkg/rest/util"
"github.com/monkeyWie/gopeed/pkg/util" "github.com/monkeyWie/gopeed/pkg/util"
"net" "net"
"net/http" "net/http"
"strconv"
) )
var ( var (
@@ -63,22 +63,18 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
} else { } else {
downloadCfg.Storage = download.NewMemStorage() downloadCfg.Storage = download.NewMemStorage()
} }
downloadCfg.Init()
downloadCfg.RefreshInterval = startCfg.RefreshInterval downloadCfg.RefreshInterval = startCfg.RefreshInterval
Downloader = download.NewDownloader(downloadCfg.Init()) Downloader = download.NewDownloader(downloadCfg)
if err := Downloader.Setup(); err != nil { if err := Downloader.Setup(); err != nil {
return nil, nil, err return nil, nil, err
} }
serverConfig := getAndPutServerConfig(startCfg)
var address string
if startCfg.Network == "unix" { if startCfg.Network == "unix" {
address = startCfg.Address util.SafeRemove(startCfg.Address)
util.SafeRemove(address)
} else {
address = net.JoinHostPort(serverConfig.Host, strconv.Itoa(serverConfig.Port))
} }
listener, err := net.Listen(startCfg.Network, address) listener, err := net.Listen(startCfg.Network, startCfg.Address)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@@ -93,33 +89,27 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
r.Methods(http.MethodGet).Path("/api/v1/tasks").HandlerFunc(GetTasks) r.Methods(http.MethodGet).Path("/api/v1/tasks").HandlerFunc(GetTasks)
r.Methods(http.MethodGet).Path("/api/v1/config").HandlerFunc(GetConfig) r.Methods(http.MethodGet).Path("/api/v1/config").HandlerFunc(GetConfig)
r.Methods(http.MethodPut).Path("/api/v1/config").HandlerFunc(PutConfig) r.Methods(http.MethodPut).Path("/api/v1/config").HandlerFunc(PutConfig)
r.Path("/api/v1/proxy").HandlerFunc(DoProxy)
if startCfg.WebEnable { if startCfg.WebEnable {
r.PathPrefix("/").Handler(http.FileServer(http.FS(startCfg.WebFS))) r.PathPrefix("/").Handler(http.FileServer(http.FS(startCfg.WebFS)))
} }
if startCfg.ApiToken != "" {
r.Use(func(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Api-Token") != startCfg.ApiToken {
restUtil.WriteJson(w, http.StatusUnauthorized, model.NewResultWithMsg("invalid token"))
return
}
h.ServeHTTP(w, r)
})
})
}
srv = &http.Server{Handler: handlers.CORS( srv = &http.Server{Handler: handlers.CORS(
handlers.AllowedHeaders([]string{"X-Requested-With", "Content-Type", "Authorization"}), handlers.AllowedHeaders([]string{"Content-Type", "X-Api-Token", "X-Target-Uri"}),
handlers.AllowedMethods([]string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}), handlers.AllowedMethods([]string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}),
handlers.AllowedOrigins([]string{"*"}), handlers.AllowedOrigins([]string{"*"}),
)(r)} )(r)}
return srv, listener, nil return srv, listener, nil
} }
func getAndPutServerConfig(startCfg *model.StartConfig) *model.ServerConfig {
var serverConfig model.ServerConfig
exist, err := Downloader.GetConfig(&serverConfig)
if err != nil {
// TODO log
}
// first start
if !exist {
if startCfg.Network == "tcp" {
host, port, _ := net.SplitHostPort(startCfg.Address)
serverConfig.Host = host
serverConfig.Port, _ = strconv.Atoi(port)
}
serverConfig.Init()
Downloader.PutConfig(serverConfig)
}
return &serverConfig
}
+99 -30
View File
@@ -2,6 +2,7 @@ package rest
import ( import (
"bytes" "bytes"
"crypto/md5"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -22,7 +23,15 @@ import (
var ( var (
restPort int restPort int
taskReq = &base.Request{} taskReq = &base.Request{
Extra: map[string]any{
"method": "",
"header": map[string]string{
"User-Agent": "gopeed",
},
"body": "",
},
}
taskRes = &base.Resource{ taskRes = &base.Resource{
Name: test.BuildName, Name: test.BuildName,
Req: taskReq, Req: taskReq,
@@ -39,9 +48,11 @@ var (
createReq = &model.CreateTask{ createReq = &model.CreateTask{
Res: taskRes, Res: taskRes,
Opts: &base.Options{ Opts: &base.Options{
Path: test.Dir, Path: test.Dir,
Name: test.DownloadName, Name: test.DownloadName,
Connections: 2, Extra: map[string]any{
"connections": 2,
},
}, },
} }
) )
@@ -49,8 +60,8 @@ var (
func TestResolve(t *testing.T) { func TestResolve(t *testing.T) {
doTest(func() { doTest(func() {
resp := httpRequestCheckOk[*base.Resource](http.MethodPost, "/api/v1/resolve", taskReq) resp := httpRequestCheckOk[*base.Resource](http.MethodPost, "/api/v1/resolve", taskReq)
if !reflect.DeepEqual(taskRes, resp) { if !test.JsonEqual(taskRes, resp) {
t.Errorf("Resolve() got = %v, want %v", resp, taskRes) t.Errorf("Resolve() got = %v, want %v", test.ToJson(resp), test.ToJson(taskRes))
} }
}) })
} }
@@ -187,26 +198,68 @@ func TestGetTasks(t *testing.T) {
func TestGetAndPutConfig(t *testing.T) { func TestGetAndPutConfig(t *testing.T) {
doTest(func() { doTest(func() {
cfg := httpRequestCheckOk[*model.ServerConfig](http.MethodGet, "/api/v1/config", nil) cfg := httpRequestCheckOk[*download.DownloaderStoreConfig](http.MethodGet, "/api/v1/config", nil)
cfg.Port = 8888
cfg.Connections = 32
cfg.DownloadDir = "./download" cfg.DownloadDir = "./download"
cfg.Extra = map[string]any{ cfg.Extra = map[string]any{
"serverConfig": &Config{
Host: "127.0.0.1",
Port: 8080,
},
"theme": "dark", "theme": "dark",
} }
httpRequestCheckOk[any](http.MethodPut, "/api/v1/config", cfg) httpRequestCheckOk[any](http.MethodPut, "/api/v1/config", cfg)
newCfg := httpRequestCheckOk[*model.ServerConfig](http.MethodGet, "/api/v1/config", nil) newCfg := httpRequestCheckOk[*download.DownloaderStoreConfig](http.MethodGet, "/api/v1/config", nil)
if !reflect.DeepEqual(newCfg, cfg) { if !test.JsonEqual(cfg, newCfg) {
t.Errorf("GetAndPutConfig() got = %v, want %v", newCfg, cfg) t.Errorf("GetAndPutConfig() got = %v, want %v", test.ToJson(newCfg), test.ToJson(cfg))
} }
}) })
} }
func TestDoProxy(t *testing.T) {
doTest(func() {
code, respBody := doHttpRequest0(http.MethodGet, "/api/v1/proxy", map[string]string{
"X-Target-Uri": "https://github.com/monkeyWie/gopeed/raw/695da7ea87d2b455552b709d3cb4d7879484d4d1/README.md",
}, nil)
if code != http.StatusOK {
t.Errorf("DoProxy() got = %v, want %v", code, http.StatusOK)
}
want := "4ee193b676f1ebb2ad810e016350d52a"
got := fmt.Sprintf("%x", md5.Sum(respBody))
if got != want {
t.Errorf("DoProxy() got = %v, want %v", got, want)
}
})
}
func TestAuthorization(t *testing.T) {
var cfg = &model.StartConfig{}
cfg.Init()
cfg.ApiToken = "123456"
fileListener := doStart(cfg)
defer func() {
if err := fileListener.Close(); err != nil {
panic(err)
}
Stop()
}()
code, _ := doHttpRequest[any](http.MethodGet, "/api/v1/config", nil, nil)
checkCode(code, http.StatusUnauthorized)
code, _ = doHttpRequest[any](http.MethodGet, "/api/v1/config", map[string]string{
"X-Api-Token": cfg.ApiToken,
}, nil)
checkCode(code, http.StatusOK)
}
func doTest(handler func()) { func doTest(handler func()) {
testFunc := func(storage model.Storage) { testFunc := func(storage model.Storage) {
fileListener := doStart(storage) var cfg = &model.StartConfig{}
cfg.Init()
cfg.Storage = storage
fileListener := doStart(cfg)
defer func() { defer func() {
if err := fileListener.Close(); err != nil { if err := fileListener.Close(); err != nil {
panic(err) panic(err)
@@ -221,10 +274,8 @@ func doTest(handler func()) {
testFunc(model.StorageBolt) testFunc(model.StorageBolt)
} }
func doStart(storage model.Storage) net.Listener { func doStart(cfg *model.StartConfig) net.Listener {
port, err := Start((&model.StartConfig{ port, err := Start(cfg)
Storage: storage,
}).Init())
if err != nil { if err != nil {
panic(err) panic(err)
} }
@@ -232,32 +283,50 @@ func doStart(storage model.Storage) net.Listener {
return test.StartTestFileServer() return test.StartTestFileServer()
} }
func httpRequest[T any](method string, path string, req any) (int, *model.Result[T]) { func doHttpRequest0(method string, path string, headers map[string]string, body any) (int, []byte) {
var body io.Reader var reader io.Reader
if req != nil { if body != nil {
buf, _ := json.Marshal(req) buf, _ := json.Marshal(body)
body = bytes.NewBuffer(buf) reader = bytes.NewBuffer(buf)
} }
request, err := http.NewRequest(method, fmt.Sprintf("http://127.0.0.1:%d%s", restPort, path), body) request, err := http.NewRequest(method, fmt.Sprintf("http://127.0.0.1:%d%s", restPort, path), reader)
if err != nil { if err != nil {
panic(err) panic(err)
} }
if headers != nil {
for k, v := range headers {
request.Header.Set(k, v)
}
}
response, err := http.DefaultClient.Do(request) response, err := http.DefaultClient.Do(request)
if err != nil { if err != nil {
panic(err) panic(err)
} }
defer response.Body.Close() defer response.Body.Close()
respBody, err := io.ReadAll(response.Body)
var r model.Result[T] if err != nil {
if err := json.NewDecoder(response.Body).Decode(&r); err != nil {
panic(err) panic(err)
} }
return response.StatusCode, &r return response.StatusCode, respBody
} }
func httpRequestCheckOk[T any](method string, path string, req any) T { func doHttpRequest[T any](method string, path string, headers map[string]string, body any) (int, *model.Result[T]) {
code, result := httpRequest[T](method, path, req) code, respBody := doHttpRequest0(method, path, headers, body)
var r model.Result[T]
if err := json.Unmarshal(respBody, &r); err != nil {
panic(err)
}
return code, &r
}
func httpRequest[T any](method string, path string, body any) (int, *model.Result[T]) {
return doHttpRequest[T](method, path, nil, body)
}
func httpRequestCheckOk[T any](method string, path string, body any) T {
code, result := httpRequest[T](method, path, body)
checkOk(code) checkOk(code)
return result.Data return result.Data
} }
+3 -3
View File
@@ -8,18 +8,18 @@ import (
func ReadJson(w http.ResponseWriter, r *http.Request, v any) bool { func ReadJson(w http.ResponseWriter, r *http.Request, v any) bool {
if err := json.NewDecoder(r.Body).Decode(v); err != nil { if err := json.NewDecoder(r.Body).Decode(v); err != nil {
WriteJson(w, 500, model.NewResultWithMsg(err.Error())) WriteJson(w, http.StatusInternalServerError, model.NewResultWithMsg(err.Error()))
return false return false
} }
return true return true
} }
func WriteJsonOk(w http.ResponseWriter, v any) { func WriteJsonOk(w http.ResponseWriter, v any) {
WriteJson(w, 200, v) WriteJson(w, http.StatusOK, v)
} }
func WriteJson(w http.ResponseWriter, code int, v any) { func WriteJson(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code) w.WriteHeader(code)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v) json.NewEncoder(w).Encode(v)
} }
+14
View File
@@ -0,0 +1,14 @@
package util
import "encoding/json"
func MapToStruct(s any, v any) error {
if s == nil {
return nil
}
b, err := json.Marshal(s)
if err != nil {
return err
}
return json.Unmarshal(b, v)
}
+50
View File
@@ -0,0 +1,50 @@
package util
import "testing"
func TestParseSchema(t *testing.T) {
type args struct {
url string
}
tests := []struct {
name string
args args
want string
}{
{
name: "http",
args: args{
url: "http://www.google.com",
},
want: "HTTP",
},
{
name: "https",
args: args{
url: "https://www.google.com",
},
want: "HTTPS",
},
{
name: "file",
args: args{
url: "file:///home/bt.torrent",
},
want: "FILE",
},
{
name: "file-no-scheme",
args: args{
url: "/home/bt.torrent",
},
want: "FILE",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ParseSchema(tt.args.url); got != tt.want {
t.Errorf("ParseSchema() = %v, want %v", got, tt.want)
}
})
}
}
+7 -3
View File
@@ -54,6 +54,10 @@ android/app/libs/libgopeed-sources.jar
debian/packages debian/packages
generated_plugin_registrant.cc linux/flutter/generated_plugin_registrant.cc
generated_plugin_registrant.h linux/flutter/generated_plugin_registrant.h
generated_plugins.cmake linux/flutter/generated_plugins.cmake
macos/Flutter/GeneratedPluginRegistrant.swift
windows/flutter/generated_plugin_registrant.cc
windows/flutter/generated_plugin_registrant.h
windows/flutter/generated_plugins.cmake
+15 -4
View File
@@ -48,16 +48,27 @@ android {
// You can update the following values to match your application needs. // You can update the following values to match your application needs.
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration. // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration.
minSdkVersion 19 minSdkVersion 19
targetSdkVersion flutter.targetSdkVersion targetSdkVersion 29
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
versionName flutterVersionName versionName flutterVersionName
} }
signingConfigs {
release {
keyAlias "upload"
keyPassword System.getenv("APK_KEY_PASSWORD")
storeFile file("upload-keystore.jks")
storePassword System.getenv("APK_STORE_PASSWORD")
}
}
buildTypes { buildTypes {
release { release {
// TODO: Add your own signing config for the release build. if (signingConfigs.release.keyPassword != null) {
// Signing with the debug keys for now, so `flutter run --release` works. signingConfig signingConfigs.release
signingConfig signingConfigs.debug } else {
signingConfig signingConfigs.debug
}
} }
} }
@@ -4,9 +4,5 @@
the Flutter tool needs it to communicate with the running application the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc. to allow setting breakpoints, to provide hot reload, etc.
--> -->
<application android:requestLegacyExternalStorage="true"/>
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
</manifest> </manifest>
@@ -2,11 +2,8 @@
package="com.gopeed"> package="com.gopeed">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<application <application
android:requestLegacyExternalStorage="true"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="Gopeed"> android:label="Gopeed">
@@ -4,9 +4,5 @@
the Flutter tool needs it to communicate with the running application the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc. to allow setting breakpoints, to provide hot reload, etc.
--> -->
<application android:requestLegacyExternalStorage="true"/>
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
</manifest> </manifest>
+42 -19
View File
@@ -1,16 +1,16 @@
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:dio/adapter.dart'; import 'package:dio/adapter.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import '../util/util.dart';
import 'model/create_task.dart'; import 'model/create_task.dart';
import 'model/request.dart'; import 'model/request.dart';
import 'model/resource.dart'; import 'model/resource.dart';
import 'model/result.dart'; import 'model/result.dart';
import 'model/task.dart'; import 'model/task.dart';
import '../core/libgopeed_boot.dart'; import 'model/downloader_config.dart';
import 'model/server_config.dart';
class _Client { class _Client {
static _Client? _instance; static _Client? _instance;
@@ -19,23 +19,35 @@ class _Client {
_Client._internal(); _Client._internal();
factory _Client() { factory _Client(String network, String address, String apiToken) {
if (_instance == null) { if (_instance == null) {
_instance = _Client._internal(); _instance = _Client._internal();
var dio = Dio(); var dio = Dio();
final isUnixSocket = LibgopeedBoot.instance.config.network == 'unix'; final isUnixSocket = network == 'unix';
dio.options.baseUrl = isUnixSocket var baseUrl = 'http://127.0.0.1';
? 'http://127.0.0.1' if (!isUnixSocket) {
: 'http://${LibgopeedBoot.instance.config.address}'; if (Util.isWeb()) {
baseUrl = kDebugMode ? 'http://127.0.0.1:9999' : '';
} else {
baseUrl = 'http://$address';
}
}
dio.options.baseUrl = baseUrl;
dio.interceptors.add(InterceptorsWrapper(onRequest: (options, handler) {
if (apiToken.isNotEmpty) {
options.headers['X-Api-Token'] = apiToken;
}
handler.next(options);
}));
_instance!.dio = dio; _instance!.dio = dio;
if (isUnixSocket) { if (isUnixSocket) {
(_instance!.dio.httpClientAdapter as DefaultHttpClientAdapter) (_instance!.dio.httpClientAdapter as DefaultHttpClientAdapter)
.onHttpClientCreate = (client) { .onHttpClientCreate = (client) {
client.connectionFactory = client.connectionFactory =
(Uri uri, String? proxyHost, int? proxyPort) { (Uri uri, String? proxyHost, int? proxyPort) {
var address = InternetAddress(LibgopeedBoot.instance.config.address, return Socket.startConnect(
type: InternetAddressType.unix); InternetAddress(address, type: InternetAddressType.unix), 0);
return Socket.startConnect(address, 0);
}; };
return client; return client;
}; };
@@ -45,7 +57,11 @@ class _Client {
} }
} }
var _client = _Client(); late _Client _client;
void init(String network, String address, String apiToken) {
_client = _Client(network, address, apiToken);
}
Future<T> _parse<T>( Future<T> _parse<T>(
Future<Response> Function() fetch, Future<Response> Function() fetch,
@@ -54,7 +70,7 @@ Future<T> _parse<T>(
try { try {
var resp = await fetch(); var resp = await fetch();
if (fromJsonT != null) { if (fromJsonT != null) {
return Result<T>.fromJson(jsonDecode(resp.data), fromJsonT).data as T; return Result<T>.fromJson(resp.data, fromJsonT).data as T;
} else { } else {
return null as T; return null as T;
} }
@@ -62,8 +78,7 @@ Future<T> _parse<T>(
if (e.response == null) { if (e.response == null) {
throw Exception("Server error"); throw Exception("Server error");
} }
throw Exception( throw Exception(Result.fromJson(e.response?.data, (_) => null).msg);
Result.fromJson(jsonDecode(e.response?.data), (_) => null).msg);
} }
} }
@@ -74,7 +89,6 @@ Future<Resource> resolve(Request request) async {
} }
Future<String> createTask(CreateTask createTask) async { Future<String> createTask(CreateTask createTask) async {
print(jsonEncode(createTask));
return _parse<String>( return _parse<String>(
() => _client.dio.post("/api/v1/tasks", data: createTask), () => _client.dio.post("/api/v1/tasks", data: createTask),
(data) => data as String); (data) => data as String);
@@ -100,11 +114,20 @@ Future<void> deleteTask(String id, bool force) async {
() => _client.dio.delete("/api/v1/tasks/$id?force=$force"), null); () => _client.dio.delete("/api/v1/tasks/$id?force=$force"), null);
} }
Future<ServerConfig> getConfig() async { Future<DownloaderConfig> getConfig() async {
return _parse(() => _client.dio.get("/api/v1/config"), return _parse(() => _client.dio.get("/api/v1/config"),
(data) => ServerConfig.fromJson(data)); (data) => DownloaderConfig.fromJson(data));
} }
Future<void> putConfig(ServerConfig config) async { Future<void> putConfig(DownloaderConfig config) async {
return _parse(() => _client.dio.put("/api/v1/config", data: config), null); return _parse(() => _client.dio.put("/api/v1/config", data: config), null);
} }
Future<Response<T>> proxyRequest<T>(String uri,
{data, Options? options}) async {
options ??= Options();
options.headers ??= {};
options.headers!["X-Target-Uri"] = uri;
return _client.dio.request("/api/v1/proxy", data: data, options: options);
}
+3 -1
View File
@@ -15,7 +15,9 @@ class CreateTask {
this.opts, this.opts,
}); });
factory CreateTask.fromJson(Map<String, dynamic> json) => factory CreateTask.fromJson(
Map<String, dynamic> json,
) =>
_$CreateTaskFromJson(json); _$CreateTaskFromJson(json);
Map<String, dynamic> toJson() => _$CreateTaskToJson(this); Map<String, dynamic> toJson() => _$CreateTaskToJson(this);
@@ -0,0 +1,78 @@
import 'package:json_annotation/json_annotation.dart';
part 'downloader_config.g.dart';
@JsonSerializable(explicitToJson: true)
class DownloaderConfig {
String downloadDir = '';
ProtocolConfig protocolConfig = ProtocolConfig();
ExtraConfig extra = ExtraConfig();
DownloaderConfig();
factory DownloaderConfig.fromJson(Map<String, dynamic> json) =>
_$DownloaderConfigFromJson(json);
Map<String, dynamic> toJson() => _$DownloaderConfigToJson(this);
}
@JsonSerializable(explicitToJson: true)
class ProtocolConfig {
HttpConfig http = HttpConfig();
BtConfig bt = BtConfig();
ProtocolConfig();
factory ProtocolConfig.fromJson(Map<String, dynamic> json) =>
_$ProtocolConfigFromJson(json);
Map<String, dynamic> toJson() => _$ProtocolConfigToJson(this);
}
@JsonSerializable()
class HttpConfig {
int connections = 0;
HttpConfig();
factory HttpConfig.fromJson(Map<String, dynamic> json) =>
_$HttpConfigFromJson(json);
Map<String, dynamic> toJson() => _$HttpConfigToJson(this);
}
@JsonSerializable()
class BtConfig {
List<String> trackers = [];
BtConfig();
factory BtConfig.fromJson(Map<String, dynamic> json) =>
_$BtConfigFromJson(json);
Map<String, dynamic> toJson() => _$BtConfigToJson(this);
}
@JsonSerializable(explicitToJson: true)
class ExtraConfig {
String themeMode = '';
String locale = '';
ExtraConfigBt bt = ExtraConfigBt();
ExtraConfig();
factory ExtraConfig.fromJson(Map<String, dynamic> json) =>
_$ExtraConfigFromJson(json);
Map<String, dynamic> toJson() => _$ExtraConfigToJson(this);
}
@JsonSerializable()
class ExtraConfigBt {
List<String> trackerSubscribeUrls = [];
List<String> subscribeTrackers = [];
DateTime? lastTrackerUpdateTime;
List<String> customTrackers = [];
ExtraConfigBt();
factory ExtraConfigBt.fromJson(Map<String, dynamic> json) =>
_$ExtraConfigBtFromJson(json);
Map<String, dynamic> toJson() => _$ExtraConfigBtToJson(this);
}
@@ -0,0 +1,93 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'downloader_config.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
DownloaderConfig _$DownloaderConfigFromJson(Map<String, dynamic> json) =>
DownloaderConfig()
..downloadDir = json['downloadDir'] as String
..protocolConfig = ProtocolConfig.fromJson(
json['protocolConfig'] as Map<String, dynamic>)
..extra = ExtraConfig.fromJson(json['extra'] as Map<String, dynamic>);
Map<String, dynamic> _$DownloaderConfigToJson(DownloaderConfig instance) =>
<String, dynamic>{
'downloadDir': instance.downloadDir,
'protocolConfig': instance.protocolConfig.toJson(),
'extra': instance.extra.toJson(),
};
ProtocolConfig _$ProtocolConfigFromJson(Map<String, dynamic> json) =>
ProtocolConfig()
..http = HttpConfig.fromJson(json['http'] as Map<String, dynamic>)
..bt = BtConfig.fromJson(json['bt'] as Map<String, dynamic>);
Map<String, dynamic> _$ProtocolConfigToJson(ProtocolConfig instance) =>
<String, dynamic>{
'http': instance.http.toJson(),
'bt': instance.bt.toJson(),
};
HttpConfig _$HttpConfigFromJson(Map<String, dynamic> json) =>
HttpConfig()..connections = json['connections'] as int;
Map<String, dynamic> _$HttpConfigToJson(HttpConfig instance) =>
<String, dynamic>{
'connections': instance.connections,
};
BtConfig _$BtConfigFromJson(Map<String, dynamic> json) => BtConfig()
..trackers =
(json['trackers'] as List<dynamic>).map((e) => e as String).toList();
Map<String, dynamic> _$BtConfigToJson(BtConfig instance) => <String, dynamic>{
'trackers': instance.trackers,
};
ExtraConfig _$ExtraConfigFromJson(Map<String, dynamic> json) => ExtraConfig()
..themeMode = json['themeMode'] as String
..locale = json['locale'] as String
..bt = ExtraConfigBt.fromJson(json['bt'] as Map<String, dynamic>);
Map<String, dynamic> _$ExtraConfigToJson(ExtraConfig instance) =>
<String, dynamic>{
'themeMode': instance.themeMode,
'locale': instance.locale,
'bt': instance.bt.toJson(),
};
ExtraConfigBt _$ExtraConfigBtFromJson(Map<String, dynamic> json) =>
ExtraConfigBt()
..trackerSubscribeUrls = (json['trackerSubscribeUrls'] as List<dynamic>)
.map((e) => e as String)
.toList()
..subscribeTrackers = (json['subscribeTrackers'] as List<dynamic>)
.map((e) => e as String)
.toList()
..lastTrackerUpdateTime = json['lastTrackerUpdateTime'] == null
? null
: DateTime.parse(json['lastTrackerUpdateTime'] as String)
..customTrackers = (json['customTrackers'] as List<dynamic>)
.map((e) => e as String)
.toList();
Map<String, dynamic> _$ExtraConfigBtToJson(ExtraConfigBt instance) {
final val = <String, dynamic>{
'trackerSubscribeUrls': instance.trackerSubscribeUrls,
'subscribeTrackers': instance.subscribeTrackers,
};
void writeNotNull(String key, dynamic value) {
if (value != null) {
val[key] = value;
}
}
writeNotNull('lastTrackerUpdateTime',
instance.lastTrackerUpdateTime?.toIso8601String());
val['customTrackers'] = instance.customTrackers;
return val;
}
+13 -3
View File
@@ -2,17 +2,16 @@ import 'package:json_annotation/json_annotation.dart';
part 'options.g.dart'; part 'options.g.dart';
@JsonSerializable() @JsonSerializable(explicitToJson: true, genericArgumentFactories: true)
class Options { class Options {
String name; String name;
String path; String path;
int connections;
List<int> selectFiles; List<int> selectFiles;
Map<String, dynamic>? extra;
Options({ Options({
required this.name, required this.name,
required this.path, required this.path,
required this.connections,
required this.selectFiles, required this.selectFiles,
}); });
@@ -21,3 +20,14 @@ class Options {
Map<String, dynamic> toJson() => _$OptionsToJson(this); Map<String, dynamic> toJson() => _$OptionsToJson(this);
} }
@JsonSerializable()
class OptsExtraHttp {
int connections = 0;
OptsExtraHttp();
factory OptsExtraHttp.fromJson(Map<String, dynamic> json) =>
_$OptsExtraHttpFromJson(json);
Map<String, dynamic> toJson() => _$OptsExtraHttpToJson(this);
}
+23 -6
View File
@@ -9,14 +9,31 @@ part of 'options.dart';
Options _$OptionsFromJson(Map<String, dynamic> json) => Options( Options _$OptionsFromJson(Map<String, dynamic> json) => Options(
name: json['name'] as String, name: json['name'] as String,
path: json['path'] as String, path: json['path'] as String,
connections: json['connections'] as int,
selectFiles: selectFiles:
(json['selectFiles'] as List<dynamic>).map((e) => e as int).toList(), (json['selectFiles'] as List<dynamic>).map((e) => e as int).toList(),
); )..extra = json['extra'] as Map<String, dynamic>?;
Map<String, dynamic> _$OptionsToJson(Options instance) => <String, dynamic>{ Map<String, dynamic> _$OptionsToJson(Options instance) {
'name': instance.name, final val = <String, dynamic>{
'path': instance.path, 'name': instance.name,
'path': instance.path,
'selectFiles': instance.selectFiles,
};
void writeNotNull(String key, dynamic value) {
if (value != null) {
val[key] = value;
}
}
writeNotNull('extra', instance.extra);
return val;
}
OptsExtraHttp _$OptsExtraHttpFromJson(Map<String, dynamic> json) =>
OptsExtraHttp()..connections = json['connections'] as int;
Map<String, dynamic> _$OptsExtraHttpToJson(OptsExtraHttp instance) =>
<String, dynamic>{
'connections': instance.connections, 'connections': instance.connections,
'selectFiles': instance.selectFiles,
}; };
+16 -2
View File
@@ -2,17 +2,31 @@ import 'package:json_annotation/json_annotation.dart';
part 'request.g.dart'; part 'request.g.dart';
@JsonSerializable() @JsonSerializable(explicitToJson: true, genericArgumentFactories: true)
class Request { class Request {
String url; String url;
Map<String, dynamic>? extra; Map<String, dynamic>? extra;
Request({ Request({
required this.url, required this.url,
this.extra,
}); });
factory Request.fromJson(Map<String, dynamic> json) => factory Request.fromJson(Map<String, dynamic> json) =>
_$RequestFromJson(json); _$RequestFromJson(json);
Map<String, dynamic> toJson() => _$RequestToJson(this); Map<String, dynamic> toJson() => _$RequestToJson(this);
} }
@JsonSerializable()
class ReqExtraHttp {
String method = 'GET';
Map<String, String> headers = {};
String body = '';
ReqExtraHttp();
factory ReqExtraHttp.fromJson(Map<String, dynamic> json) =>
_$ReqExtraHttpFromJson(json);
Map<String, dynamic> toJson() => _$ReqExtraHttpToJson(this);
}
+13 -2
View File
@@ -8,8 +8,7 @@ part of 'request.dart';
Request _$RequestFromJson(Map<String, dynamic> json) => Request( Request _$RequestFromJson(Map<String, dynamic> json) => Request(
url: json['url'] as String, url: json['url'] as String,
extra: json['extra'] as Map<String, dynamic>?, )..extra = json['extra'] as Map<String, dynamic>?;
);
Map<String, dynamic> _$RequestToJson(Request instance) { Map<String, dynamic> _$RequestToJson(Request instance) {
final val = <String, dynamic>{ final val = <String, dynamic>{
@@ -25,3 +24,15 @@ Map<String, dynamic> _$RequestToJson(Request instance) {
writeNotNull('extra', instance.extra); writeNotNull('extra', instance.extra);
return val; return val;
} }
ReqExtraHttp _$ReqExtraHttpFromJson(Map<String, dynamic> json) => ReqExtraHttp()
..method = json['method'] as String
..headers = Map<String, String>.from(json['headers'] as Map)
..body = json['body'] as String;
Map<String, dynamic> _$ReqExtraHttpToJson(ReqExtraHttp instance) =>
<String, dynamic>{
'method': instance.method,
'headers': instance.headers,
'body': instance.body,
};
+9 -9
View File
@@ -11,19 +11,19 @@ class Resource {
int size; int size;
bool range; bool range;
List<FileInfo> files; List<FileInfo> files;
Map<String, dynamic>? extra; String hash;
Resource({ Resource(
required this.req, {required this.req,
required this.name, required this.name,
required this.size, required this.size,
required this.range, required this.range,
required this.files, required this.files,
this.extra, required this.hash});
});
factory Resource.fromJson(Map<String, dynamic> json) => factory Resource.fromJson(Map<String, dynamic> json) =>
_$ResourceFromJson(json); _$ResourceFromJson(json);
Map<String, dynamic> toJson() => _$ResourceToJson(this); Map<String, dynamic> toJson() => _$ResourceToJson(this);
} }
+9 -19
View File
@@ -14,27 +14,17 @@ Resource _$ResourceFromJson(Map<String, dynamic> json) => Resource(
files: (json['files'] as List<dynamic>) files: (json['files'] as List<dynamic>)
.map((e) => FileInfo.fromJson(e as Map<String, dynamic>)) .map((e) => FileInfo.fromJson(e as Map<String, dynamic>))
.toList(), .toList(),
extra: json['extra'] as Map<String, dynamic>?, hash: json['hash'] as String,
); );
Map<String, dynamic> _$ResourceToJson(Resource instance) { Map<String, dynamic> _$ResourceToJson(Resource instance) => <String, dynamic>{
final val = <String, dynamic>{ 'req': instance.req.toJson(),
'req': instance.req.toJson(), 'name': instance.name,
'name': instance.name, 'size': instance.size,
'size': instance.size, 'range': instance.range,
'range': instance.range, 'files': instance.files.map((e) => e.toJson()).toList(),
'files': instance.files.map((e) => e.toJson()).toList(), 'hash': instance.hash,
}; };
void writeNotNull(String key, dynamic value) {
if (value != null) {
val[key] = value;
}
}
writeNotNull('extra', instance.extra);
return val;
}
FileInfo _$FileInfoFromJson(Map<String, dynamic> json) => FileInfo( FileInfo _$FileInfoFromJson(Map<String, dynamic> json) => FileInfo(
name: json['name'] as String, name: json['name'] as String,
@@ -1,24 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
part 'server_config.g.dart';
@JsonSerializable()
class ServerConfig {
late String host;
late int port;
late int connections;
late String downloadDir;
Map<String, dynamic>? extra;
ServerConfig({
required this.host,
required this.port,
required this.connections,
required this.downloadDir,
this.extra,
});
factory ServerConfig.fromJson(Map<String, dynamic> json) =>
_$ServerConfigFromJson(json);
Map<String, dynamic> toJson() => _$ServerConfigToJson(this);
}
@@ -1,33 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'server_config.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
ServerConfig _$ServerConfigFromJson(Map<String, dynamic> json) => ServerConfig(
host: json['host'] as String,
port: json['port'] as int,
connections: json['connections'] as int,
downloadDir: json['downloadDir'] as String,
extra: json['extra'] as Map<String, dynamic>?,
);
Map<String, dynamic> _$ServerConfigToJson(ServerConfig instance) {
final val = <String, dynamic>{
'host': instance.host,
'port': instance.port,
'connections': instance.connections,
'downloadDir': instance.downloadDir,
};
void writeNotNull(String key, dynamic value) {
if (value != null) {
val[key] = value;
}
}
writeNotNull('extra', instance.extra);
return val;
}
+1 -1
View File
@@ -7,7 +7,7 @@ part 'task.g.dart';
enum Status { ready, running, pause, error, done } enum Status { ready, running, pause, error, done }
@JsonSerializable() @JsonSerializable(explicitToJson: true)
class Task { class Task {
String id; String id;
Resource res; Resource res;
+3 -3
View File
@@ -18,10 +18,10 @@ Task _$TaskFromJson(Map<String, dynamic> json) => Task(
Map<String, dynamic> _$TaskToJson(Task instance) => <String, dynamic>{ Map<String, dynamic> _$TaskToJson(Task instance) => <String, dynamic>{
'id': instance.id, 'id': instance.id,
'res': instance.res, 'res': instance.res.toJson(),
'opts': instance.opts, 'opts': instance.opts.toJson(),
'status': _$StatusEnumMap[instance.status]!, 'status': _$StatusEnumMap[instance.status]!,
'progress': instance.progress, 'progress': instance.progress.toJson(),
'size': instance.size, 'size': instance.size,
'createdAt': instance.createdAt.toIso8601String(), 'createdAt': instance.createdAt.toIso8601String(),
}; };
@@ -20,7 +20,7 @@ class LibgopeedFFi implements LibgopeedInterface {
var completer = Completer<int>(); var completer = Completer<int>();
var result = _libgopeed.Start(jsonEncode(cfg).toNativeUtf8().cast()); var result = _libgopeed.Start(jsonEncode(cfg).toNativeUtf8().cast());
if (result.r1 != nullptr) { if (result.r1 != nullptr) {
completer.completeError(result.r1.cast<Utf8>().toDartString()); completer.completeError(Exception(result.r1.cast<Utf8>().toDartString()));
} else { } else {
completer.complete(result.r0); completer.complete(result.r0);
} }
+7 -12
View File
@@ -4,19 +4,14 @@ part 'start_config.g.dart';
@JsonSerializable() @JsonSerializable()
class StartConfig { class StartConfig {
String network; late String network;
String address; late String address;
String storage; late String storage;
String storageDir; late String storageDir;
int refreshInterval; late int refreshInterval;
late String apiToken;
StartConfig({ StartConfig();
required this.network,
required this.address,
required this.storage,
required this.storageDir,
required this.refreshInterval,
});
factory StartConfig.fromJson(Map<String, dynamic> json) => factory StartConfig.fromJson(Map<String, dynamic> json) =>
_$StartConfigFromJson(json); _$StartConfigFromJson(json);
@@ -6,13 +6,13 @@ part of 'start_config.dart';
// JsonSerializableGenerator // JsonSerializableGenerator
// ************************************************************************** // **************************************************************************
StartConfig _$StartConfigFromJson(Map<String, dynamic> json) => StartConfig( StartConfig _$StartConfigFromJson(Map<String, dynamic> json) => StartConfig()
network: json['network'] as String, ..network = json['network'] as String
address: json['address'] as String, ..address = json['address'] as String
storage: json['storage'] as String, ..storage = json['storage'] as String
storageDir: json['storageDir'] as String, ..storageDir = json['storageDir'] as String
refreshInterval: json['refreshInterval'] as int, ..refreshInterval = json['refreshInterval'] as int
); ..apiToken = json['apiToken'] as String;
Map<String, dynamic> _$StartConfigToJson(StartConfig instance) => Map<String, dynamic> _$StartConfigToJson(StartConfig instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -21,4 +21,5 @@ Map<String, dynamic> _$StartConfigToJson(StartConfig instance) =>
'storage': instance.storage, 'storage': instance.storage,
'storageDir': instance.storageDir, 'storageDir': instance.storageDir,
'refreshInterval': instance.refreshInterval, 'refreshInterval': instance.refreshInterval,
'apiToken': instance.apiToken,
}; };
@@ -1,22 +1,18 @@
import 'dart:async'; import 'dart:async';
import 'package:gopeed/core/common/start_config.dart';
import '../libgopeed_boot.dart'; import '../libgopeed_boot.dart';
LibgopeedBoot create() => LibgopeedBootBrowser(); LibgopeedBoot create() => LibgopeedBootBrowser();
class LibgopeedBootBrowser implements LibgopeedBoot { class LibgopeedBootBrowser implements LibgopeedBoot {
late LibgopeedConfig _config; // do nothing
@override
LibgopeedBootBrowser() { Future<int> start(StartConfig cfg) async {
_config = LibgopeedConfig(); return 0;
} }
@override
LibgopeedConfig get config => _config;
@override
Future<void> start() async {}
@override @override
Future<void> stop() async {} Future<void> stop() async {}
} }
@@ -16,48 +16,30 @@ import '../libgopeed_boot.dart';
LibgopeedBoot create() => LibgopeedBootNative(); LibgopeedBoot create() => LibgopeedBootNative();
class LibgopeedBootNative implements LibgopeedBoot { class LibgopeedBootNative implements LibgopeedBoot {
late LibgopeedConfig _config;
late LibgopeedInterface _libgopeed; late LibgopeedInterface _libgopeed;
late SendPort _childSendPort; late SendPort _childSendPort;
LibgopeedBootNative() { LibgopeedBootNative() {
_config = LibgopeedConfig();
if (!Util.isDesktop()) { if (!Util.isDesktop()) {
_libgopeed = LibgopeedChannel(); _libgopeed = LibgopeedChannel();
} }
} }
@override @override
LibgopeedConfig get config => _config; Future<int> start(StartConfig cfg) async {
@override
Future<void> start() async {
var storageDir = "./"; var storageDir = "./";
if (!Util.isUnix()) { if (Platform.isAndroid) {
// not support unix socket, use tcp storageDir = (await getExternalStorageDirectory())?.path ?? storageDir;
_config.network = "tcp";
_config.address = "127.0.0.1:0";
} else {
_config.network = "unix";
if (Util.isDesktop()) {
_config.address = LibgopeedBoot.unixSocketPath;
} else if (Platform.isAndroid) {
_config.address =
"${(await getTemporaryDirectory()).path}/${LibgopeedBoot.unixSocketPath}";
storageDir = (await getExternalStorageDirectory())?.path ?? "";
}
} }
final cfg = StartConfig( if (Platform.isIOS) {
network: _config.network, storageDir = (await getLibraryDirectory()).path;
address: _config.address, }
storage: 'bolt', cfg.storage = 'bolt';
storageDir: storageDir, cfg.storageDir = storageDir;
refreshInterval: _config.refreshInterval); cfg.refreshInterval = 0;
var port = var port =
Util.isDesktop() ? await _ffiStart(cfg) : await _libgopeed.start(cfg); Util.isDesktop() ? await _ffiStart(cfg) : await _libgopeed.start(cfg);
if (_config.network == "tcp") { return port;
_config.address = "${_config.address.split(":")[0]}:$port";
}
} }
@override @override
+2 -15
View File
@@ -1,19 +1,8 @@
import 'common/start_config.dart';
import "libgopeed_boot_stub.dart" import "libgopeed_boot_stub.dart"
if (dart.library.html) 'entry/libgopeed_boot_browser.dart' if (dart.library.html) 'entry/libgopeed_boot_browser.dart'
if (dart.library.io) 'entry/libgopeed_boot_native.dart'; if (dart.library.io) 'entry/libgopeed_boot_native.dart';
class LibgopeedConfig {
late String network;
late String address;
late int refreshInterval;
LibgopeedConfig({
this.network = "tcp",
this.address = "127.0.0.1:9999",
this.refreshInterval = 500,
});
}
abstract class LibgopeedBoot { abstract class LibgopeedBoot {
static const unixSocketPath = 'gopeed.sock'; static const unixSocketPath = 'gopeed.sock';
@@ -26,9 +15,7 @@ abstract class LibgopeedBoot {
factory LibgopeedBoot() => create(); factory LibgopeedBoot() => create();
Future<void> start(); Future<int> start(StartConfig cfg);
Future<void> stop(); Future<void> stop();
LibgopeedConfig get config;
} }
+56
View File
@@ -0,0 +1,56 @@
const enUS = {
'en_US': {
'label': 'English',
'error': 'Error',
'tip': 'Tip',
'confirm': 'Confirm',
'cancel': 'Cancel',
'selectAll': 'Select All',
'home.task': 'Task',
'home.setting': 'Setting',
'create.title': 'Create Task',
'create.downloadLink': 'Download Link',
'create.downloadLinkValid': 'Please enter the download link',
'create.downloadLinkHit':
'Please enter the download link, HTTP/HTTPS/MAGNET supported@append',
'create.downloadLinkHitDesktop': ', or drag the torrent file here directly',
'create.download': 'Download',
'create.error.noStoragePermission': 'Storage permission required',
'create.selectFile': 'Select File',
'setting.title': 'Setting',
'setting.basic': 'Basic',
'setting.advanced': 'Advanced',
'setting.general': 'General',
'setting.downloadDir': 'Download Directory',
'setting.downloadDirValid': 'Please select the download directory',
'setting.connections': 'Connections',
'setting.items': '@count items',
'setting.subscribeTracker': 'Subscribe Tracker',
'setting.subscribeFail':
'Subscribe failed, please check network or try again later',
'setting.update': 'Update',
'setting.updateDaily': 'Update daily',
'setting.lastUpdate': 'Last update: @time',
'setting.addTracker': 'Add Tracker',
'setting.addTrackerHit':
'Please enter the tracker server url, one per line',
'setting.ui': 'UI',
'setting.theme': 'Theme',
'setting.themeSystem': 'System',
'setting.themeLight': 'Light',
'setting.themeDark': 'Dark',
'setting.locale': 'Language',
'setting.about': 'About',
'setting.homepage': 'Homepage',
'setting.version': 'Version',
'setting.protocol': 'Protocol',
'setting.port': 'Port',
'setting.apiToken': 'API Token',
'setting.notSet': 'NS',
'setting.set': 'SET',
'setting.effectAfterRestart': 'Effect after restart',
'task.deleteTask': 'Delete Task',
'task.deleteTaskTip': 'Keep downloaded files',
'task.delete': 'Delete',
},
};
+56
View File
@@ -0,0 +1,56 @@
const ruRU = {
'ru_RU': {
'label': 'Русский(Россия)',
'error': 'Ошибка',
'tip': 'Подсказка',
'confirm': 'Подтвердить',
'cancel': 'Отмена',
'selectAll': 'Выбрать все',
'home.task': 'Задачи',
'home.setting': 'Настройки',
'create.title': 'Создать задачу',
'create.downloadLink': 'Ссылка для скачивание',
'create.downloadLinkValid': 'Пожалуйста, введите ссылку для скачивания',
'create.downloadLinkHit':
'Пожалуйста, введите ссылку для скачивания, HTTP/HTTPS/MAGNET supported@append',
'create.downloadLinkHitDesktop': ', или перетащите сюда торрент-файл',
'create.download': 'Скачать',
'create.error.noStoragePermission': 'Требуется доступ к хранилищу',
'create.selectFile': 'Выберать файл',
'setting.title': 'Настройки',
'setting.basic': 'Основные параметры',
'setting.advanced': 'Расширенные',
'setting.general': 'Общие',
'setting.downloadDir': 'Папка загрузки',
'setting.downloadDirValid': 'Пожалуйста, выберите папку для загрузки',
'setting.connections': 'Соединений',
'setting.items': '@подсчет элементов',
'setting.subscribeTracker': 'Подпись трекера',
'setting.subscribeFail':
'Подписка не удалась, пожалуйста, проверьте сеть или повторите попытку позже',
'setting.update': 'Обновить',
'setting.updateDaily': 'Обновлять ежедневно',
'setting.lastUpdate': 'Последнее обновление: @время',
'setting.addTracker': 'Добавить трекер',
'setting.addTrackerHit':
'Пожалуйста, введите url трекера, по одному в строке',
'setting.ui': 'UI',
'setting.theme': 'Тема',
'setting.themeSystem': 'Системная',
'setting.themeLight': 'Светлая',
'setting.themeDark': 'Темная',
'setting.locale': 'Язык',
'setting.about': 'О приложении',
'setting.homepage': 'Домашняя страница',
'setting.version': 'Версия',
'setting.protocol': 'Протокол',
'setting.port': 'Порт',
'setting.apiToken': 'API Токен',
'setting.notSet': 'Не установлено',
'setting.set': 'Установлено',
'setting.effectAfterRestart': 'Эффект после перезагрузки',
'task.deleteTask': 'Удалить задачу',
'task.deleteTaskTip': 'Сохранить загруженные файлы',
'task.delete': 'Удалить',
},
};
+53
View File
@@ -0,0 +1,53 @@
const zhCN = {
'zh_CN': {
'label': '中文(简体)',
'error': '错误',
'tip': '提示',
'confirm': '确认',
'cancel': '取消',
'selectAll': '全选',
'home.task': '任务',
'home.setting': '设置',
'create.title': '创建任务',
'create.downloadLink': '下载链接',
'create.downloadLinkValid': '请输入下载链接',
'create.downloadLinkHit': '请输入下载链接,支持 HTTP/HTTPS/MAGNET@append',
'create.downloadLinkHitDesktop': ',也可以直接拖拽种子文件到此处',
'create.download': '下载',
'create.error.noStoragePermission': '需要开启存储权限',
'create.selectFile': '选择文件',
'setting.title': '设置',
'setting.basic': '基础',
'setting.advanced': '高级',
'setting.general': '通用',
'setting.downloadDir': '下载目录',
'setting.downloadDirValid': '请选择下载目录',
'setting.connections': '连接数',
'setting.items': '@count 条',
'setting.subscribeTracker': '订阅 Tracker',
'setting.subscribeFail': '订阅失败,请检查网络或稍后重试',
'setting.update': '更新',
'setting.updateDaily': '每天自动更新',
'setting.lastUpdate': '上次更新:@time',
'setting.addTracker': '添加 Tracker',
'setting.addTrackerHit': '请输入 tracker 服务器地址,每行一条',
'setting.ui': '界面',
'setting.theme': '主题',
'setting.themeSystem': '跟随系统',
'setting.themeLight': '明亮主题',
'setting.themeDark': '暗黑主题',
'setting.locale': '语言',
'setting.about': '关于',
'setting.homepage': '主页',
'setting.version': '版本',
'setting.protocol': '通讯协议',
'setting.port': '端口',
'setting.apiToken': '接口令牌',
'setting.notSet': '未设置',
'setting.set': '已设置',
'setting.effectAfterRestart': '此配置项将在重启应用后生效',
'task.deleteTask': '删除任务',
'task.deleteTaskTip': '保留已下载的文件',
'task.delete': '删除',
}
};
+25
View File
@@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:gopeed/i18n/langs/ru_RU.dart';
import 'langs/en_us.dart';
import 'langs/zh_cn.dart';
Locale toLocale(String key) {
final arr = key.split('_');
return Locale(arr[0], arr[1]);
}
String getLocaleKey(Locale locale) {
return '${locale.languageCode}_${locale.countryCode}';
}
final messages = _Messages();
const mainLocale = Locale('zh', 'CN');
const fallbackLocale = Locale('en', 'US');
class _Messages extends Translations {
// just include available locales here
@override
Map<String, Map<String, String>> get keys => {...zhCN, ...enUS, ...ruRU};
}
+54 -14
View File
@@ -1,29 +1,69 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:gopeed/api/api.dart' as api;
import 'package:gopeed/i18n/messages.dart';
import 'core/libgopeed_boot.dart'; import 'core/libgopeed_boot.dart';
import 'routes/router.dart'; import 'pages/app/app_controller.dart';
import 'setting/setting.dart'; import 'pages/app/app_view.dart';
import 'theme/theme.dart';
import 'util/log_util.dart'; import 'util/log_util.dart';
import 'util/mac_secure_util.dart'; import 'util/mac_secure_util.dart';
import 'util/package_info.dart';
void main() async { void main() async {
await init();
onStart();
runApp(const AppView());
}
Future<void> init() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
final controller = Get.put(AppController());
try { try {
await LibgopeedBoot.instance.start(); await controller.loadStartConfig();
await Setting.instance.load(); final startCfg = controller.startConfig.value;
controller.runningPort.value = await LibgopeedBoot.instance.start(startCfg);
api.init(startCfg.network, controller.runningAddress(), startCfg.apiToken);
} catch (e) { } catch (e) {
logger.e("init fail", e); logger.e("libgopeed init fail", e);
} }
MacSecureUtil.loadBookmark();
runApp(GetMaterialApp.router( try {
debugShowCheckedModeBanner: false, await controller.loadDownloaderConfig();
theme: GopeedTheme.light, MacSecureUtil.loadBookmark();
darkTheme: GopeedTheme.dark, } catch (e) {
themeMode: Setting.instance.themeMode, logger.e("load config fail", e);
getPages: Routes.routes, }
));
try {
await initPackageInfo();
} catch (e) {
logger.e("init package info fail", e);
}
}
Future<void> onStart() async {
final appController = Get.find<AppController>();
await appController.trackerUpdateOnStart();
// if is debug mode, check language message is complete
if (kDebugMode) {
final mainLang = getLocaleKey(mainLocale);
final fullMessages = messages.keys[mainLang];
messages.keys.keys.where((e) => e != mainLang).forEach((lang) {
final langMessages = messages.keys[lang];
if (langMessages == null) {
logger.w("missing language: $lang");
return;
}
final missingKeys =
fullMessages!.keys.where((key) => langMessages[key] == null);
if (missingKeys.isNotEmpty) {
logger.w("missing language: $lang, keys: $missingKeys");
}
});
}
} }
@@ -0,0 +1,178 @@
import 'dart:convert';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../api/api.dart';
import '../../api/model/downloader_config.dart';
import '../../core/common/start_config.dart';
import '../../i18n/messages.dart';
import '../../util/log_util.dart';
import '../../util/util.dart';
const _startConfigNetwork = "start.network";
const _startConfigAddress = "start.address";
const _startConfigApiToken = "start.apiToken";
const unixSocketPath = 'gopeed.sock';
const allTrackerSubscribeUrls = [
'https://github.com/ngosang/trackerslist/raw/master/trackers_all.txt',
'https://github.com/ngosang/trackerslist/raw/master/trackers_all_http.txt',
'https://github.com/ngosang/trackerslist/raw/master/trackers_all_https.txt',
'https://github.com/ngosang/trackerslist/raw/master/trackers_all_ip.txt',
'https://github.com/ngosang/trackerslist/raw/master/trackers_all_udp.txt',
'https://github.com/ngosang/trackerslist/raw/master/trackers_all_ws.txt',
'https://github.com/ngosang/trackerslist/raw/master/trackers_best.txt',
'https://github.com/ngosang/trackerslist/raw/master/trackers_best_ip.txt',
'https://github.com/XIU2/TrackersListCollection/raw/master/all.txt',
'https://github.com/XIU2/TrackersListCollection/raw/master/best.txt',
'https://github.com/XIU2/TrackersListCollection/raw/master/http.txt',
];
class AppController extends GetxController {
static StartConfig? _defaultStartConfig;
final startConfig = StartConfig().obs;
final runningPort = 0.obs;
final downloaderConfig = DownloaderConfig().obs;
String runningAddress() {
if (startConfig.value.network == 'unix') {
return startConfig.value.address;
}
return '${startConfig.value.address.split(':').first}:$runningPort';
}
Future<StartConfig> _initDefaultStartConfig() async {
if (_defaultStartConfig != null) {
return _defaultStartConfig!;
}
_defaultStartConfig = StartConfig();
if (!Util.isUnix()) {
// not support unix socket, use tcp
_defaultStartConfig!.network = "tcp";
_defaultStartConfig!.address = "127.0.0.1:0";
} else {
_defaultStartConfig!.network = "unix";
if (Util.isDesktop()) {
_defaultStartConfig!.address = unixSocketPath;
}
if (Util.isMobile()) {
_defaultStartConfig!.address =
"${(await getTemporaryDirectory()).path}/$unixSocketPath";
}
}
_defaultStartConfig!.apiToken = '';
return _defaultStartConfig!;
}
Future<void> loadStartConfig() async {
final defaultCfg = await _initDefaultStartConfig();
final prefs = await SharedPreferences.getInstance();
startConfig.value.network =
prefs.getString(_startConfigNetwork) ?? defaultCfg.network;
startConfig.value.address =
prefs.getString(_startConfigAddress) ?? defaultCfg.address;
startConfig.value.apiToken =
prefs.getString(_startConfigApiToken) ?? defaultCfg.apiToken;
}
Future<void> loadDownloaderConfig() async {
try {
downloaderConfig.value = await getConfig();
} catch (e) {
logger.w("load downloader config fail", e);
downloaderConfig.value = DownloaderConfig();
}
await _initDownloaderConfig();
}
Future<void> trackerUpdate() async {
final btExtConfig = downloaderConfig.value.extra.bt;
final result = <String>[];
for (var u in btExtConfig.trackerSubscribeUrls) {
result.addAll(await _fetchTrackers(u));
}
btExtConfig.subscribeTrackers.clear();
btExtConfig.subscribeTrackers.addAll(result);
downloaderConfig.update((val) {
val!.extra.bt.lastTrackerUpdateTime = DateTime.now();
});
refreshTrackers();
await saveConfig();
}
refreshTrackers() {
final btConfig = downloaderConfig.value.protocolConfig.bt;
final btExtConfig = downloaderConfig.value.extra.bt;
btConfig.trackers.clear();
btConfig.trackers.addAll(btExtConfig.subscribeTrackers);
btConfig.trackers.addAll(btExtConfig.customTrackers);
// remove duplicate
btConfig.trackers.toSet().toList();
}
Future<void> trackerUpdateOnStart() async {
final btExtConfig = downloaderConfig.value.extra.bt;
final lastUpdateTime = btExtConfig.lastTrackerUpdateTime;
// if last update time is null or more than 1 day, update trackers
if (lastUpdateTime == null ||
lastUpdateTime.difference(DateTime.now()).inDays < 0) {
await trackerUpdate();
}
}
Future<List<String>> _fetchTrackers(String subscribeUrl) async {
final resp = await proxyRequest(subscribeUrl);
if (resp.statusCode != 200) {
throw Exception('Failed to get trackers');
}
const ls = LineSplitter();
return ls.convert(resp.data).where((e) => e.isNotEmpty).toList();
}
_initDownloaderConfig() async {
final config = downloaderConfig.value;
if (config.protocolConfig.http.connections == 0) {
config.protocolConfig.http.connections = 16;
}
final extra = config.extra;
if (extra.themeMode.isEmpty) {
extra.themeMode = ThemeMode.system.name;
}
if (extra.locale.isEmpty) {
final systemLocale = getLocaleKey(ui.window.locale);
extra.locale = messages.keys.containsKey(systemLocale)
? systemLocale
: getLocaleKey(fallbackLocale);
}
if (extra.bt.trackerSubscribeUrls.isEmpty) {
// default select all tracker subscribe urls
extra.bt.trackerSubscribeUrls.addAll(allTrackerSubscribeUrls);
}
if (Util.isDesktop()) {
config.downloadDir = (await getDownloadsDirectory())?.path ?? "./";
} else if (Util.isAndroid()) {
config.downloadDir = (await getExternalStorageDirectory())?.path ??
(await getApplicationDocumentsDirectory()).path;
return;
} else if (Util.isIOS()) {
config.downloadDir = (await getApplicationDocumentsDirectory()).path;
} else {
config.downloadDir = './';
}
}
Future<void> saveConfig() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_startConfigNetwork, startConfig.value.network);
await prefs.setString(_startConfigAddress, startConfig.value.address);
await putConfig(downloaderConfig.value);
}
}
+33
View File
@@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:get/get.dart';
import '../../i18n/messages.dart';
import '../../routes/router.dart';
import '../../theme/theme.dart';
import 'app_controller.dart';
class AppView extends GetView<AppController> {
const AppView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final config = controller.downloaderConfig.value;
return GetMaterialApp.router(
debugShowCheckedModeBanner: false,
theme: GopeedTheme.light,
darkTheme: GopeedTheme.dark,
themeMode: ThemeMode.values.byName(config.extra.themeMode),
translations: messages,
locale: toLocale(config.extra.locale),
fallbackLocale: fallbackLocale,
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: messages.keys.keys.map((e) => toLocale(e)).toList(),
getPages: Routes.routes,
);
}
}
@@ -1,5 +1,3 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
class CreateController extends GetxController { class CreateController extends GetxController {}
var currentIndex = 0.obs;
}
+87 -71
View File
@@ -1,26 +1,26 @@
import 'package:desktop_drop/desktop_drop.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:rounded_loading_button/rounded_loading_button.dart';
import '../../api/api.dart'; import '../../api/api.dart';
import '../../api/model/create_task.dart'; import '../../api/model/create_task.dart';
import '../../api/model/options.dart'; import '../../api/model/options.dart';
import '../../api/model/request.dart'; import '../../api/model/request.dart';
import '../../api/model/resource.dart'; import '../../api/model/resource.dart';
import 'create_controller.dart';
import '../../setting/setting.dart';
import '../../widget/file_list_view.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:rounded_loading_button/rounded_loading_button.dart';
import '../../routes/router.dart'; import '../../routes/router.dart';
import '../../util/util.dart'; import '../../util/util.dart';
import '../../widget/directory_selector.dart'; import '../../widget/directory_selector.dart';
import '../../widget/file_list_view.dart';
import '../app/app_controller.dart';
import 'create_controller.dart';
class CreateView extends GetView<CreateController> { class CreateView extends GetView<CreateController> {
CreateView({Key? key}) : super(key: key);
final _resolveFormKey = GlobalKey<FormState>(); final _resolveFormKey = GlobalKey<FormState>();
final _urlController = TextEditingController(); final _urlController = TextEditingController();
final _confirmController = RoundedLoadingButtonController(); final _confirmController = RoundedLoadingButtonController();
CreateView({Key? key}) : super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -30,69 +30,83 @@ class CreateView extends GetView<CreateController> {
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
onPressed: () => Get.rootDelegate.offNamed(Routes.task)), onPressed: () => Get.rootDelegate.offNamed(Routes.task)),
actions: [Container()], actions: [Container()],
title: const Text("创建任务"), title: Text('create.title'.tr),
), ),
body: Padding( body: DropTarget(
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0), onDragDone: (details) {
child: Form( _urlController.text = details.files[0].path;
key: _resolveFormKey, },
autovalidateMode: AutovalidateMode.always, //开启自动校验 child: Padding(
child: Column( padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0),
children: [ child: Form(
TextFormField( key: _resolveFormKey,
autofocus: true, autovalidateMode: AutovalidateMode.always,
controller: _urlController, child: Column(
minLines: 1, children: [
maxLines: 15, TextFormField(
decoration: const InputDecoration( autofocus: true,
hintText: "请输入下载链接,支持 HTTP/HTTPS/MAGNET", controller: _urlController,
labelText: "下载链接", minLines: 1,
icon: Icon(Icons.link)), maxLines: 15,
validator: (v) { decoration: InputDecoration(
return v!.trim().isNotEmpty ? null : "请输入下载链接"; hintText: _hitText(),
}), labelText: 'create.downloadLink'.tr,
// 登录按钮 icon: const Icon(Icons.link)),
Center( validator: (v) {
child: Padding( return v!.trim().isNotEmpty
padding: const EdgeInsets.all(15.0), ? null
child: ConstrainedBox( : 'create.downloadLinkValid'.tr;
constraints: const BoxConstraints.tightFor( }),
width: 150, Center(
height: 40, child: Padding(
), padding: const EdgeInsets.all(15.0),
child: RoundedLoadingButton( child: ConstrainedBox(
color: Get.theme.colorScheme.secondary, constraints: const BoxConstraints.tightFor(
onPressed: () async { width: 150,
if (_resolveFormKey.currentState!.validate()) { height: 40,
_confirmController.start(); ),
try { child: RoundedLoadingButton(
final res = await resolve(Request( color: Get.theme.colorScheme.secondary,
url: _urlController.text, onPressed: () async {
)); if (_resolveFormKey.currentState!.validate()) {
await _showResolveDialog(res); _confirmController.start();
} catch (e) { try {
Get.snackbar("错误", e.toString()); final res = await resolve(Request(
} finally { url: _urlController.text,
_confirmController.reset(); ));
await _showResolveDialog(res);
} catch (e) {
Get.snackbar('error'.tr, e.toString());
} finally {
_confirmController.reset();
}
} }
} },
}, controller: _confirmController,
controller: _confirmController, child: Text('confirm'.tr),
child: const Text("确认"), ),
), )),
)), ),
), ],
], ),
), ),
), ),
), ),
); );
} }
String _hitText() {
return 'create.downloadLinkHit'.trParams({
'append': Util.isDesktop() ? 'create.downloadLinkHitDesktop'.tr : '',
});
}
Future<void> _showResolveDialog(Resource res) async { Future<void> _showResolveDialog(Resource res) async {
final appController = Get.find<AppController>();
final setting = appController.downloaderConfig.value;
final createFormKey = GlobalKey<FormState>(); final createFormKey = GlobalKey<FormState>();
final pathController = final pathController = TextEditingController(text: setting.downloadDir);
TextEditingController(text: Setting.instance.downloadDir);
final downloadController = RoundedLoadingButtonController(); final downloadController = RoundedLoadingButtonController();
var fileValues = List.filled(res.files.length, true); var fileValues = List.filled(res.files.length, true);
@@ -142,7 +156,7 @@ class CreateView extends GetView<CreateController> {
onPressed: () { onPressed: () {
Get.back(); Get.back();
}, },
child: const Text("取消"), child: Text('cancel'.tr),
), ),
), ),
ConstrainedBox( ConstrainedBox(
@@ -156,18 +170,20 @@ class CreateView extends GetView<CreateController> {
try { try {
if (createFormKey.currentState!.validate()) { if (createFormKey.currentState!.validate()) {
downloadController.start(); downloadController.start();
if (Util.isAndroid()) {
if (!await Permission.storage.request().isGranted) { // if (Util.isAndroid()) {
Get.snackbar("错误", "需要开启存储权限"); // if (!await Permission.storage.request().isGranted) {
return; // Get.snackbar('error'.tr,
} // 'create.error.noStoragePermission'.tr);
} // return;
// }
// }
await createTask(CreateTask( await createTask(CreateTask(
res: res, res: res,
opts: Options( opts: Options(
name: '', name: '',
path: pathController.text, path: pathController.text,
connections: Setting.instance.connections,
selectFiles: fileValues selectFiles: fileValues
.asMap() .asMap()
.entries .entries
@@ -179,14 +195,14 @@ class CreateView extends GetView<CreateController> {
Get.rootDelegate.offNamed(Routes.task); Get.rootDelegate.offNamed(Routes.task);
} }
} catch (e) { } catch (e) {
Get.snackbar("错误", e.toString()); Get.snackbar('error'.tr, e.toString());
rethrow; rethrow;
} finally { } finally {
downloadController.reset(); downloadController.reset();
} }
}, },
controller: downloadController, controller: downloadController,
child: const Text("下载"), child: Text('create.download'.tr),
), ),
), ),
], ],
+2 -2
View File
@@ -37,11 +37,11 @@ class HomeView extends GetView<HomeController> {
AnimatedBarItems( AnimatedBarItems(
icon: const Icon(Icons.list), icon: const Icon(Icons.list),
selectedColor: Get.theme.primaryColor, selectedColor: Get.theme.primaryColor,
title: const Text('任务')), title: Text('home.task'.tr)),
AnimatedBarItems( AnimatedBarItems(
icon: const Icon(Icons.settings), icon: const Icon(Icons.settings),
selectedColor: Get.theme.primaryColor, selectedColor: Get.theme.primaryColor,
title: const Text('设置')), title: Text('home.setting'.tr)),
], ],
iconSize: 32, iconSize: 32,
barAnimation: BarAnimation.fade, barAnimation: BarAnimation.fade,
+2 -18
View File
@@ -1,20 +1,4 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import '../../api/api.dart';
class RootController extends GetxController { class RootController extends GetxController {}
//TODO: Implement RootController
final count = 0.obs;
@override
void onInit() {
super.onInit();
}
@override
void onReady() {
super.onReady();
}
@override
void onClose() {}
void increment() => count.value++;
}
@@ -1,8 +1,16 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import '../../setting/setting.dart';
class SettingController extends GetxController { class SettingController extends GetxController {
final setting = Setting.instance.obs; final tapStatues = <String, bool>{}.obs;
final tapStatues = <int, bool>{}.obs;
// set all tap status to false
void clearTap() {
tapStatues.updateAll((key, value) => false);
}
// set one tap status to true
void onTap(String key) {
clearTap();
tapStatues[key] = true;
}
} }
+497 -108
View File
@@ -1,140 +1,529 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import '../../setting/setting.dart'; import 'package:intl/intl.dart';
import '../../widget/directory_selector.dart'; import 'package:url_launcher/url_launcher.dart';
import '../../i18n/messages.dart';
import '../../util/package_info.dart';
import '../../util/util.dart';
import '../../widget/check_list_view.dart';
import '../../widget/directory_selector.dart';
import '../../widget/outlined_button_loading.dart';
import '../app/app_controller.dart';
import 'setting_controller.dart'; import 'setting_controller.dart';
const _padding = SizedBox(height: 10);
final _divider = const Divider().paddingOnly(left: 10, right: 10);
class SettingView extends GetView<SettingController> { class SettingView extends GetView<SettingController> {
const SettingView({Key? key}) : super(key: key); const SettingView({Key? key}) : super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final appController = Get.find<AppController>();
final downloaderCfg = appController.downloaderConfig;
final startCfg = appController.startConfig;
Timer? timer; Timer? timer;
debounceSave() { debounceSave({bool needRestart = false}) {
var completer = Completer<void>();
timer?.cancel(); timer?.cancel();
timer = Timer(const Duration(milliseconds: 500), Setting.instance.save); timer = Timer(const Duration(milliseconds: 500), () {
appController
.saveConfig()
.then(completer.complete)
.onError(completer.completeError);
if (needRestart) {
Get.snackbar('tip'.tr, 'setting.effectAfterRestart'.tr);
}
});
return completer.future;
} }
return Scaffold( // download basic config items start
appBar: AppBar( final buildDownloadDir = _buildConfigItem(
title: const Text('设置'), 'setting.downloadDir', () => downloaderCfg.value.downloadDir,
centerTitle: true, (Key key) {
), final downloadDirController =
body: Column( TextEditingController(text: downloaderCfg.value.downloadDir);
// 左对齐 downloadDirController.addListener(() async {
if (downloadDirController.text != downloaderCfg.value.downloadDir) {
downloaderCfg.value.downloadDir = downloadDirController.text;
if (Util.isDesktop()) {
controller.clearTap();
}
await debounceSave();
}
});
return DirectorySelector(
controller: downloadDirController,
showLabel: false,
);
});
// http config items start
final httpConfig = downloaderCfg.value.protocolConfig.http;
final buildHttpConnections = _buildConfigItem(
'setting.connections'.tr, () => httpConfig.connections.toString(),
(Key key) {
final connectionsController =
TextEditingController(text: httpConfig.connections.toString());
connectionsController.addListener(() async {
if (connectionsController.text.isNotEmpty &&
connectionsController.text != httpConfig.connections.toString()) {
httpConfig.connections = int.parse(connectionsController.text);
await debounceSave();
}
});
return TextField(
key: key,
focusNode: FocusNode(),
controller: connectionsController,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
_NumericalRangeFormatter(min: 1, max: 256),
],
);
});
// bt config items start
final btExtConfig = downloaderCfg.value.extra.bt;
final buildBtTrackerSubscribeUrls = _buildConfigItem(
'setting.subscribeTracker'.tr,
() => 'setting.items'.trParams(
{'count': btExtConfig.trackerSubscribeUrls.length.toString()}),
(Key key) {
final trackerUpdateController = OutlinedButtonLoadingController();
return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text("基本设置", style: Get.textTheme.titleLarge), SizedBox(
const SizedBox(height: 10), height: 200,
Obx(() => Card( child: CheckListView(
child: Column( items: allTrackerSubscribeUrls,
children: _buildConfigItems([ checked: btExtConfig.trackerSubscribeUrls,
_ConfigItem( onChanged: (value) {
"主题", btExtConfig.trackerSubscribeUrls = value;
() => _getThemeName(controller.setting.value.themeMode),
() => DropdownButton<ThemeMode>(
value: controller.setting.value.themeMode,
isDense: true,
onChanged: (value) {
controller.setting.value.themeMode = value!;
Get.changeThemeMode(value);
debounceSave(); debounceSave();
}, },
items: ThemeMode.values ),
.map((e) => DropdownMenuItem<ThemeMode>( ),
value: e, _padding,
child: Text(_getThemeName(e)), Row(
)) children: [
.toList(), OutlinedButtonLoading(
)), onPressed: () async {
_ConfigItem( trackerUpdateController.start();
"下载目录", () => controller.setting.value.downloadDir, () { try {
final downloadDirController = TextEditingController( await appController.trackerUpdate();
text: controller.setting.value.downloadDir); } catch (e) {
downloadDirController.addListener(() { Get.snackbar('error'.tr, 'setting.subscribeFail'.tr);
if (downloadDirController.text != } finally {
controller.setting.value.downloadDir) { trackerUpdateController.stop();
controller.setting.value.downloadDir = }
downloadDirController.text; },
controller: trackerUpdateController,
debounceSave(); child: Text('setting.update'.tr),
} ),
}); Expanded(
return DirectorySelector( child: SwitchListTile(
controller: downloadDirController, controlAffinity: ListTileControlAffinity.leading,
showLabel: false, value: true,
); onChanged: (bool value) {},
}), title: Text('setting.updateDaily'.tr)),
_ConfigItem("连接数", ),
() => controller.setting.value.connections.toString(), ],
() { ),
final connectionsController = TextEditingController( Text('setting.lastUpdate'.trParams({
text: controller.setting.value.connections.toString()); 'time': btExtConfig.lastTrackerUpdateTime != null
connectionsController.addListener(() { ? DateFormat('yyyy-MM-dd HH:mm:ss')
if (connectionsController.text.isNotEmpty && .format(btExtConfig.lastTrackerUpdateTime!)
connectionsController.text != : ''
controller.setting.value.connections.toString()) { })),
controller.setting.value.connections =
int.parse(connectionsController.text);
debounceSave();
}
});
return TextField(
controller: connectionsController,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
NumericalRangeFormatter(min: 1, max: 256),
],
);
}),
]),
))),
], ],
).paddingAll(16), );
});
final buildBtTrackers = _buildConfigItem(
'setting.addTracker'.tr,
() => 'setting.items'
.trParams({'count': btExtConfig.customTrackers.length.toString()}),
(Key key) {
final trackersController = TextEditingController(
text: btExtConfig.customTrackers.join('\r\n').toString());
const ls = LineSplitter();
return TextField(
key: key,
focusNode: FocusNode(),
controller: trackersController,
keyboardType: TextInputType.multiline,
maxLines: 5,
decoration: InputDecoration(
hintText: 'setting.addTrackerHit'.tr,
),
onChanged: (value) async {
btExtConfig.customTrackers = ls.convert(value);
appController.refreshTrackers();
await debounceSave();
},
);
});
// ui config items start
final buildTheme = _buildConfigItem(
'setting.theme',
() => _getThemeName(downloaderCfg.value.extra.themeMode),
(Key key) => DropdownButton<String>(
key: key,
value: downloaderCfg.value.extra.themeMode,
onChanged: (value) async {
downloaderCfg.update((val) {
val?.extra.themeMode = value!;
});
Get.changeThemeMode(ThemeMode.values.byName(value!));
controller.clearTap();
await debounceSave();
},
items: ThemeMode.values
.map((e) => DropdownMenuItem<String>(
value: e.name,
child: Text(_getThemeName(e.name)),
))
.toList(),
));
final buildLocale = _buildConfigItem(
'setting.locale',
() => messages.keys[downloaderCfg.value.extra.locale]!['label']!,
(Key key) => DropdownButton<String>(
key: key,
value: downloaderCfg.value.extra.locale,
isDense: true,
onChanged: (value) async {
downloaderCfg.update((val) {
val!.extra.locale = value!;
});
Get.updateLocale(toLocale(value!));
controller.clearTap();
await debounceSave();
},
items: messages.keys.keys
.map((e) => DropdownMenuItem<String>(
value: e,
child: Text(messages.keys[e]!['label']!),
))
.toList(),
));
// about config items start
buildHomepage() => ListTile(
title: Text('setting.homepage'.tr),
subtitle: const Text('https://github.com/monkeyWie/gopeed'),
onTap: () {
launchUrl(Uri.parse('https://github.com/monkeyWie/gopeed'),
mode: LaunchMode.externalApplication);
},
);
buildVersion() => ListTile(
title: Text('setting.version'.tr),
subtitle: Text(packageInfo.version),
);
// advanced config items start
final buildApiProtocol = _buildConfigItem(
'setting.protocol'.tr,
() => startCfg.value.network == 'tcp'
? 'TCP ${startCfg.value.address}'
: 'Unix',
(Key key) {
final items = <Widget>[
SizedBox(
width: 150,
child: DropdownButtonFormField<String>(
value: startCfg.value.network,
onChanged: (value) async {
startCfg.update((val) {
val!.network = value!;
});
await debounceSave(needRestart: true);
},
items: [
!Util.isMobile()
? const DropdownMenuItem<String>(
value: 'tcp',
child: Text('TCP'),
)
: null,
Util.isUnix()
? const DropdownMenuItem<String>(
value: 'unix',
child: Text('Unix'),
)
: null,
].where((e) => e != null).map((e) => e!).toList(),
),
)
];
if (Util.isDesktop() && startCfg.value.network == 'tcp') {
final arr = startCfg.value.address.split(':');
var ip = '127.0.0.1';
var port = '0';
if (arr.length > 1) {
ip = arr[0];
port = arr[1];
}
final ipController = TextEditingController(text: ip);
final portController = TextEditingController(text: port);
updateAddress() async {
final newAddress = '${ipController.text}:${portController.text}';
if (newAddress != startCfg.value.address) {
startCfg.value.address = newAddress;
await debounceSave(needRestart: true);
}
}
ipController.addListener(updateAddress);
portController.addListener(updateAddress);
items.addAll([
const Padding(padding: EdgeInsets.only(left: 20)),
SizedBox(
width: 200,
child: TextFormField(
controller: ipController,
decoration: const InputDecoration(
labelText: 'IP',
contentPadding: EdgeInsets.all(0.0),
),
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp('[0-9.]')),
],
),
),
const Padding(padding: EdgeInsets.only(left: 10)),
SizedBox(
width: 200,
child: TextFormField(
controller: portController,
decoration: InputDecoration(
labelText: 'setting.port'.tr,
contentPadding: const EdgeInsets.all(0.0),
),
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
_NumericalRangeFormatter(min: 0, max: 65535),
],
),
),
]);
}
return Form(
child: Row(
children: items,
),
);
},
); );
final buildApiToken = _buildConfigItem(
'setting.apiToken'.tr,
() => startCfg.value.apiToken.isEmpty
? 'setting.notSet'.tr
: 'setting.seted'.tr, (Key key) {
final apiTokenController =
TextEditingController(text: startCfg.value.apiToken);
apiTokenController.addListener(() async {
if (apiTokenController.text != startCfg.value.apiToken) {
startCfg.value.apiToken = apiTokenController.text;
await debounceSave(needRestart: true);
}
});
apiTokenController.addListener(() async {});
return TextField(
key: key,
obscureText: true,
controller: apiTokenController,
focusNode: FocusNode(),
);
});
return Obx(() {
return GestureDetector(
onTap: () {
controller.clearTap();
},
child: DefaultTabController(
length: 2,
child: Scaffold(
appBar: PreferredSize(
preferredSize: Size.fromHeight(56),
child: AppBar(
bottom: TabBar(
tabs: [
Tab(
text: 'setting.basic'.tr,
),
Tab(
text: 'setting.advanced'.tr,
),
],
),
)),
body: TabBarView(
children: [
SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: _addPadding([
Text('setting.general'.tr),
Card(
child: Column(
children: _addDivider([
buildDownloadDir(),
]),
)),
const Text('HTTP'),
Card(
child: Column(
children: _addDivider([
buildHttpConnections(),
]),
)),
const Text('BitTorrent'),
Card(
child: Column(
children: _addDivider([
buildBtTrackerSubscribeUrls(),
buildBtTrackers(),
]),
)),
Text('setting.ui'.tr),
Card(
child: Column(
children: _addDivider([
buildTheme(),
buildLocale(),
]),
)),
Text('setting.about'.tr),
Card(
child: Column(
children: _addDivider([
buildHomepage(),
buildVersion(),
]),
)),
]),
),
),
Column(
children: [
Card(
child: Column(
children: [
..._addDivider([
buildApiProtocol(),
Util.isDesktop() && startCfg.value.network == 'tcp'
? buildApiToken()
: null,
]),
],
)),
],
),
],
).paddingOnly(left: 16, right: 16, top: 16, bottom: 64)),
),
);
});
} }
List<Widget> _buildConfigItems(List<_ConfigItem> buildItems) { void _tapInputWidget(GlobalKey key) {
if (key.currentContext == null) {
return;
}
if (key.currentContext?.widget is TextField) {
final textField = key.currentContext?.widget as TextField;
textField.focusNode?.requestFocus();
return;
}
/* GestureDetector? detector;
void searchForGestureDetector(BuildContext? element) {
element?.visitChildElements((element) {
if (element.widget is GestureDetector) {
detector = element.widget as GestureDetector?;
} else {
searchForGestureDetector(element);
}
});
}
searchForGestureDetector(key.currentContext);
detector?.onTap?.call(); */
}
Widget Function() _buildConfigItem(
String label, String Function() text, Widget Function(Key key) input) {
final tapStatues = controller.tapStatues;
final inputKey = GlobalKey();
return () => ListTile(
title: Text(label.tr),
subtitle: tapStatues[label] ?? false ? input(inputKey) : Text(text()),
onTap: () {
controller.onTap(label);
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
_tapInputWidget(inputKey);
});
});
}
List<Widget> _addPadding(List<Widget> widgets) {
final result = <Widget>[]; final result = <Widget>[];
for (var i = 0; i < buildItems.length; i++) { for (var i = 0; i < widgets.length; i++) {
final buildItem = buildItems[i]; result.add(widgets[i]);
result.add(ListTile( result.add(_padding);
title: Text(buildItem.label), }
subtitle: controller.tapStatues[i] ?? false return result;
? buildItem.inputItem() }
: Text(buildItem.text()),
onTap: () { List<Widget> _addDivider(List<Widget?> widgets) {
controller.tapStatues[i] = true; final result = <Widget>[];
// set other false final newArr = widgets.where((e) => e != null).map((e) => e!).toList();
for (var j = 0; j < controller.tapStatues.length; j++) { for (var i = 0; i < newArr.length; i++) {
if (j != i) { result.add(newArr[i]);
controller.tapStatues[j] = false; if (i != newArr.length - 1) {
} result.add(_divider);
}
}));
if (i != buildItems.length - 1) {
result.add(const Divider());
} }
} }
return result; return result;
} }
String _getThemeName(ThemeMode themeMode) { String _getThemeName(String? themeMode) {
switch (themeMode) { switch (ThemeMode.values.byName(themeMode ?? ThemeMode.system.name)) {
case ThemeMode.system:
return "跟随系统";
case ThemeMode.light: case ThemeMode.light:
return "明亮主题"; return 'setting.themeLight'.tr;
case ThemeMode.dark: case ThemeMode.dark:
return "暗黑主题"; return 'setting.themeDark'.tr;
default:
return 'setting.themeSystem'.tr;
} }
} }
} }
@@ -142,16 +531,16 @@ class SettingView extends GetView<SettingController> {
class _ConfigItem { class _ConfigItem {
late String label; late String label;
late String Function() text; late String Function() text;
late Widget Function() inputItem; late Widget Function(Key key) inputItem;
_ConfigItem(this.label, this.text, this.inputItem); _ConfigItem(this.label, this.text, this.inputItem);
} }
class NumericalRangeFormatter extends TextInputFormatter { class _NumericalRangeFormatter extends TextInputFormatter {
final int min; final int min;
final int max; final int max;
NumericalRangeFormatter({required this.min, required this.max}); _NumericalRangeFormatter({required this.min, required this.max});
@override @override
TextEditingValue formatEditUpdate( TextEditingValue formatEditUpdate(
+32 -35
View File
@@ -1,10 +1,9 @@
import 'dart:async'; import 'dart:async';
import 'package:get/get.dart'; import 'package:get/get.dart';
import '../../api/model/task.dart';
import '../../core/libgopeed_boot.dart';
import '../../api/api.dart'; import '../../api/api.dart';
import '../../api/model/task.dart';
class TaskController extends GetxController { class TaskController extends GetxController {
final tabIndex = 0.obs; final tabIndex = 0.obs;
@@ -16,27 +15,6 @@ class TaskController extends GetxController {
late final Timer _timer; late final Timer _timer;
_loadUnDone() async {
unDoneTasks.value = await getTasks(
[Status.ready, Status.running, Status.pause, Status.error]);
final lastLastSize = _lastUnDoneSize;
if (lastLastSize != unDoneTasks.length) {
_lastUnDoneSize = unDoneTasks.length;
}
// when unDoneTasks.length reduce, it means some task is done, refresh doneTasks
return lastLastSize > _lastUnDoneSize;
}
_loadDone() async {
doneTasks.value = await getTasks([Status.done]);
final lastLastSize = _lastDoneSize;
if (lastLastSize != doneTasks.length) {
_lastDoneSize = doneTasks.length;
}
// when doneTasks.length increase, it means some task is done, refresh unDoneTasks
return lastLastSize < _lastDoneSize;
}
loadTab() async { loadTab() async {
if (tabIndex.value == 0) { if (tabIndex.value == 0) {
if (await _loadUnDone()) { if (await _loadUnDone()) {
@@ -49,22 +27,41 @@ class TaskController extends GetxController {
} }
} }
@override
void onInit() {
super.onInit();
loadTab();
_timer = Timer.periodic(
Duration(milliseconds: LibgopeedBoot.instance.config.refreshInterval),
(timer) async {
loadTab();
});
}
@override @override
void onClose() { void onClose() {
super.onClose(); super.onClose();
_timer.cancel(); _timer.cancel();
} }
@override
void onInit() {
super.onInit();
loadTab();
_timer = Timer.periodic(const Duration(milliseconds: 1000), (timer) async {
loadTab();
});
}
_loadDone() async {
doneTasks.value = await getTasks([Status.done]);
final lastLastSize = _lastDoneSize;
if (lastLastSize != doneTasks.length) {
_lastDoneSize = doneTasks.length;
}
// when doneTasks.length increase, it means some task is done, refresh unDoneTasks
return lastLastSize < _lastDoneSize;
}
_loadUnDone() async {
unDoneTasks.value = await getTasks(
[Status.ready, Status.running, Status.pause, Status.error]);
final lastLastSize = _lastUnDoneSize;
if (lastLastSize != unDoneTasks.length) {
_lastUnDoneSize = unDoneTasks.length;
}
// when unDoneTasks.length reduce, it means some task is done, refresh doneTasks
return lastLastSize > _lastUnDoneSize;
}
} }
+81 -81
View File
@@ -2,62 +2,14 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import '../../api/api.dart';
import '../../api/model/task.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import '../../api/api.dart';
import '../../api/model/task.dart';
import '../../util/util.dart'; import '../../util/util.dart';
import 'task_controller.dart'; import 'task_controller.dart';
class TaskView extends GetView<TaskController> {
const TaskView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 2,
child: Scaffold(
appBar: PreferredSize(
preferredSize: const Size.fromHeight(56),
child: AppBar(
bottom: TabBar(
tabs: const [
Tab(
icon: Icon(Icons.file_download),
),
Tab(
icon: Icon(Icons.done),
),
],
onTap: (index) {
controller.tabIndex.value = index;
if (index == 1) {
controller.loadTab();
}
},
),
)),
body: TabBarView(
children: [
Obx(() => buildTaskList(controller.unDoneTasks)),
Obx(() => buildTaskList(controller.doneTasks)),
],
),
),
);
}
Widget buildTaskList(List<Task> tasks) {
return ListView.builder(
itemCount: tasks.length,
itemBuilder: (context, index) {
return Item(task: tasks[index]);
},
);
}
}
class Item extends StatelessWidget { class Item extends StatelessWidget {
final Task task; final Task task;
@@ -65,8 +17,6 @@ class Item extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var controller = Get.find<TaskController>();
return Card( return Card(
elevation: 4.0, elevation: 4.0,
child: InkWell( child: InkWell(
@@ -80,23 +30,22 @@ class Item extends StatelessWidget {
), ),
Row( Row(
children: [ children: [
Expanded( SizedBox(
flex: 1, width: 180,
child: Text( child: Text(
"${isDone() ? "" : "${Util.fmtByte(task.progress.downloaded)} / "}${Util.fmtByte(task.size)}", "${isDone() ? "" : "${Util.fmtByte(task.progress.downloaded)} / "}${Util.fmtByte(task.size)}",
style: Get.textTheme.bodyText1 style: context.textTheme.bodyText1
?.copyWith(color: Get.theme.disabledColor), ?.copyWith(color: Get.theme.disabledColor),
).padding(left: 18)), ).padding(left: 18)),
Expanded( Flexible(
flex: 1,
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
Text("${Util.fmtByte(task.progress.speed)} / s", Text("${Util.fmtByte(task.progress.speed)} / s",
style: Get.textTheme.subtitle2), style: context.textTheme.subtitle2),
...buildActions() ...buildActions()
], ],
)), )),
], ],
), ),
LinearProgressIndicator( LinearProgressIndicator(
@@ -146,29 +95,44 @@ class Item extends StatelessWidget {
return list; return list;
} }
double getProgress() {
return task.size <= 0 ? 1 : task.progress.downloaded / task.size;
}
bool isDone() {
return task.status == Status.done;
}
bool isRunning() {
return task.status == Status.running;
}
Future<void> _showDeleteDialog(String id) { Future<void> _showDeleteDialog(String id) {
final keep = true.obs; final keep = true.obs;
final context = Get.context!;
return showDialog<void>( return showDialog<void>(
context: Get.context!, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (_) => AlertDialog( builder: (_) => AlertDialog(
title: const Text("删除任务"), title: Text('task.deleteTask'.tr),
content: Obx(() => CheckboxListTile( content: Obx(() => CheckboxListTile(
value: keep.value, value: keep.value,
title: Text("保留已下载的文件", style: Get.textTheme.bodyText1), title: Text('task.deleteTaskTip'.tr,
style: context.textTheme.bodyText1),
onChanged: (v) { onChanged: (v) {
keep.value = v!; keep.value = v!;
})), })),
actions: [ actions: [
TextButton( TextButton(
child: const Text("取消"), child: Text('cancel'.tr),
onPressed: () => Get.back(), //关闭对话框 onPressed: () => Get.back(),
), ),
TextButton( TextButton(
child: const Text( child: Text(
"删除", 'task.delete'.tr,
style: TextStyle(color: Colors.redAccent), style: const TextStyle(color: Colors.redAccent),
), ),
onPressed: () async { onPressed: () async {
try { try {
@@ -176,23 +140,59 @@ class Item extends StatelessWidget {
Get.find<TaskController>().loadTab(); Get.find<TaskController>().loadTab();
Get.back(); Get.back();
} catch (e) { } catch (e) {
Get.snackbar("错误", e.toString()); Get.snackbar('error'.tr, e.toString());
} }
}, },
), ),
], ],
)); ));
} }
}
double getProgress() { class TaskView extends GetView<TaskController> {
return task.size <= 0 ? 1 : task.progress.downloaded / task.size; const TaskView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 2,
child: Scaffold(
appBar: PreferredSize(
preferredSize: const Size.fromHeight(56),
child: AppBar(
bottom: TabBar(
tabs: const [
Tab(
icon: Icon(Icons.file_download),
),
Tab(
icon: Icon(Icons.done),
),
],
onTap: (index) {
if (controller.tabIndex.value != index) {
controller.tabIndex.value = index;
controller.loadTab();
}
},
),
)),
body: TabBarView(
children: [
Obx(() => buildTaskList(controller.unDoneTasks)),
Obx(() => buildTaskList(controller.doneTasks)),
],
),
),
);
} }
bool isRunning() { Widget buildTaskList(List<Task> tasks) {
return task.status == Status.running; return ListView.builder(
} itemCount: tasks.length,
itemBuilder: (context, index) {
bool isDone() { return Item(task: tasks[index]);
return task.status == Status.done; },
);
} }
} }
+2 -2
View File
@@ -1,13 +1,13 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import '../pages/create/create_controller.dart';
import '../pages/setting/setting_view.dart';
import '../pages/create/create_controller.dart';
import '../pages/create/create_view.dart'; import '../pages/create/create_view.dart';
import '../pages/home/home_controller.dart'; import '../pages/home/home_controller.dart';
import '../pages/home/home_view.dart'; import '../pages/home/home_view.dart';
import '../pages/root/root_controller.dart'; import '../pages/root/root_controller.dart';
import '../pages/root/root_view.dart'; import '../pages/root/root_view.dart';
import '../pages/setting/setting_controller.dart'; import '../pages/setting/setting_controller.dart';
import '../pages/setting/setting_view.dart';
import '../pages/task/task_controller.dart'; import '../pages/task/task_controller.dart';
import '../pages/task/task_view.dart'; import '../pages/task/task_view.dart';
-76
View File
@@ -1,76 +0,0 @@
import 'package:external_path/external_path.dart';
import 'package:flutter/material.dart';
import '../api/api.dart';
import 'package:path_provider/path_provider.dart';
import '../api/model/server_config.dart';
import '../util/util.dart';
class Setting {
String host = "127.0.0.1";
int port = 0;
int connections = 0;
String downloadDir = "";
ThemeMode themeMode = ThemeMode.system;
Setting({
required this.host,
required this.port,
required this.connections,
required this.downloadDir,
required this.themeMode,
});
// singleton pattern
static Setting? _instance;
static Setting get instance {
_instance ??= Setting._internal();
return _instance!;
}
Setting._internal();
Future<void> load() async {
final config = await getConfig();
host = config.host;
port = config.port;
connections = config.connections;
downloadDir = config.downloadDir;
final themeMode = config.extra?['themeMode'];
if (themeMode == null) {
this.themeMode = ThemeMode.system;
} else {
this.themeMode = ThemeMode.values.byName(themeMode);
}
if (Util.isWeb()) {
downloadDir = "./";
return;
}
if (Util.isAndroid()) {
downloadDir =
'${await ExternalPath.getExternalStoragePublicDirectory(ExternalPath.DIRECTORY_DOWNLOADS)}/com.gopeed';
return;
}
if (downloadDir.isEmpty) {
if (Util.isDesktop()) {
downloadDir = (await getDownloadsDirectory())!.path;
}
}
}
Future<void> save() async {
final config = ServerConfig(
host: host,
port: port,
connections: connections,
downloadDir: downloadDir,
extra: {
'themeMode': themeMode.name,
},
);
await putConfig(config);
}
}
+10 -1
View File
@@ -1,5 +1,6 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:logger/logger.dart'; import 'package:logger/logger.dart';
import 'package:logger/src/outputs/file_output.dart'; import 'package:logger/src/outputs/file_output.dart';
@@ -7,5 +8,13 @@ import 'util.dart';
final logger = Logger( final logger = Logger(
printer: PrettyPrinter(), printer: PrettyPrinter(),
output: Util.isDesktop() ? FileOutput(file: File('log.txt')) : null, output: buildOutput(),
); );
buildOutput() {
// if is debug mode, output to console
if (!kDebugMode && Util.isDesktop()) {
return FileOutput(file: File('log.txt'));
}
return null;
}
+7
View File
@@ -0,0 +1,7 @@
import 'package:package_info_plus/package_info_plus.dart';
late PackageInfo packageInfo;
Future<void> initPackageInfo() async {
packageInfo = await PackageInfo.fromPlatform();
}
+22 -14
View File
@@ -3,6 +3,14 @@ import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
class Util { class Util {
static String buildAbsPath(String dir, String path, String name) {
return (dir == "" ? "" : "$dir/") + buildPath(path, name);
}
static String buildPath(String path, String name) {
return (path == "" ? "" : "$path/") + name;
}
static String fmtByte(int byte) { static String fmtByte(int byte) {
if (byte < 1024) { if (byte < 1024) {
return "$byte B"; return "$byte B";
@@ -15,24 +23,16 @@ class Util {
} }
} }
static String buildPath(String path, String name) {
return (path == "" ? "" : "$path/") + name;
}
static String buildAbsPath(String dir, String path, String name) {
return (dir == "" ? "" : "$dir/") + buildPath(path, name);
}
static isWeb() {
return kIsWeb;
}
static isAndroid() { static isAndroid() {
return !kIsWeb && Platform.isAndroid; return !kIsWeb && Platform.isAndroid;
} }
static isMacos() { static isIOS() {
return !kIsWeb && Platform.isMacOS; return !kIsWeb && Platform.isIOS;
}
static isMobile() {
return !kIsWeb && (Platform.isAndroid || Platform.isIOS);
} }
static isDesktop() { static isDesktop() {
@@ -42,10 +42,18 @@ class Util {
return Platform.isWindows || Platform.isLinux || Platform.isMacOS; return Platform.isWindows || Platform.isLinux || Platform.isMacOS;
} }
static isMacos() {
return !kIsWeb && Platform.isMacOS;
}
static isUnix() { static isUnix() {
if (kIsWeb) { if (kIsWeb) {
return false; return false;
} }
return !Platform.isWindows; return !Platform.isWindows;
} }
static isWeb() {
return kIsWeb;
}
} }
@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class CheckListView extends StatefulWidget {
final List<String> items;
final List<String> checked;
final void Function(List<String> value) onChanged;
const CheckListView({
Key? key,
required this.items,
required this.checked,
required this.onChanged,
}) : super(key: key);
@override
State<CheckListView> createState() => _CheckListView();
}
class _CheckListView extends State<CheckListView> {
bool get _allChecked => _checked.length == _items.length;
List<String> get _checked => widget.checked;
List<String> get _items => widget.items;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(top: 10),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey, width: 1),
borderRadius: BorderRadius.circular(5)),
child: Column(
children: [
CheckboxListTile(
value: _allChecked,
onChanged: (value) {
setState(() {
_checked.clear();
if (value!) {
_checked.addAll(_items);
}
widget.onChanged(_checked);
});
},
title: Text('selectAll'.tr),
),
Expanded(
child: ListView.builder(
itemCount: _items.length,
itemBuilder: (context, index) {
var item = _items[index];
return CheckboxListTile(
value: _checked.contains(item),
onChanged: (value) {
setState(() {
_checked.contains(item)
? _checked.remove(item)
: _checked.add(item);
widget.onChanged(_checked);
});
},
title: Text(item),
);
}),
),
],
));
}
}
+19 -16
View File
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import '../util/mac_secure_util.dart'; import '../util/mac_secure_util.dart';
import '../util/util.dart';
class DirectorySelector extends StatefulWidget { class DirectorySelector extends StatefulWidget {
final TextEditingController controller; final TextEditingController controller;
@@ -23,29 +24,31 @@ class _DirectorySelectorState extends State<DirectorySelector> {
children: [ children: [
Expanded( Expanded(
child: TextFormField( child: TextFormField(
readOnly: true, readOnly: Util.isWeb() ? false : true,
controller: widget.controller, controller: widget.controller,
decoration: widget.showLabel decoration: widget.showLabel
? const InputDecoration( ? InputDecoration(
labelText: "下载目录", labelText: 'setting.downloadDir'.tr,
) )
: null, : null,
validator: (v) { validator: (v) {
return v!.trim().isNotEmpty ? null : "请选择下载目录"; return v!.trim().isNotEmpty ? null : 'setting.downloadDirValid'.tr;
}, },
)), )),
IconButton( !Util.isDesktop()
icon: const Icon(Icons.folder_open), ? null
onPressed: () async { : IconButton(
if (GetPlatform.isDesktop) { icon: const Icon(Icons.folder_open),
var dir = await getDirectoryPath(); onPressed: () async {
if (dir != null) { if (GetPlatform.isDesktop) {
widget.controller.text = dir; var dir = await getDirectoryPath();
MacSecureUtil.saveBookmark(dir); if (dir != null) {
} widget.controller.text = dir;
} MacSecureUtil.saveBookmark(dir);
}) }
], }
})
].where((e) => e != null).map((e) => e!).toList(),
); );
} }
} }
+4 -2
View File
@@ -1,7 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../util/util.dart'; import 'package:get/get.dart';
import '../api/model/resource.dart'; import '../api/model/resource.dart';
import '../util/util.dart';
class FileListView extends StatefulWidget { class FileListView extends StatefulWidget {
final List<FileInfo> files; final List<FileInfo> files;
@@ -29,7 +30,8 @@ class _FileListViewState extends State<FileListView> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Padding(padding: EdgeInsets.only(top: 10)), const Padding(padding: EdgeInsets.only(top: 10)),
Text("选择文件", style: TextStyle(color: themeData.hintColor)), Text('create.selectFile'.tr,
style: TextStyle(color: themeData.hintColor)),
Expanded( Expanded(
child: Container( child: Container(
margin: const EdgeInsets.only(top: 10), margin: const EdgeInsets.only(top: 10),
@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
class OutlinedButtonLoading extends StatefulWidget {
final Widget child;
final VoidCallback onPressed;
final OutlinedButtonLoadingController controller;
const OutlinedButtonLoading(
{super.key,
required this.child,
required this.onPressed,
required this.controller});
@override
State<OutlinedButtonLoading> createState() => _OutlinedButtonLoadingState();
}
class _OutlinedButtonLoadingState extends State<OutlinedButtonLoading> {
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<bool>(
valueListenable: widget.controller,
builder: (context, value, child) {
return OutlinedButton(
key: widget.key,
onPressed: value ? null : widget.onPressed,
child: value
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: widget.child,
);
},
);
}
}
class OutlinedButtonLoadingController extends ValueNotifier<bool> {
OutlinedButtonLoadingController() : super(false);
void start() {
value = true;
}
void stop() {
value = false;
}
}
@@ -1,19 +0,0 @@
//
// Generated file. Do not edit.
//
// clang-format off
#include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
}
@@ -1,15 +0,0 @@
//
// Generated file. Do not edit.
//
// clang-format off
#ifndef GENERATED_PLUGIN_REGISTRANT_
#define GENERATED_PLUGIN_REGISTRANT_
#include <flutter_linux/flutter_linux.h>
// Registers Flutter plugins.
void fl_register_plugins(FlPluginRegistry* registry);
#endif // GENERATED_PLUGIN_REGISTRANT_
@@ -1,25 +0,0 @@
#
# Generated file, do not edit.
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
url_launcher_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
)
set(PLUGIN_BUNDLED_LIBRARIES)
foreach(plugin ${FLUTTER_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin})
target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
list(APPEND PLUGIN_BUNDLED_LIBRARIES $<TARGET_FILE:${plugin}_plugin>)
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
endforeach(plugin)
foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin})
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
endforeach(ffi_plugin)
@@ -1,20 +0,0 @@
//
// Generated file. Do not edit.
//
import FlutterMacOS
import Foundation
import file_selector_macos
import macos_secure_bookmarks
import path_provider_macos
import shared_preferences_macos
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
SecureBookmarksPlugin.register(with: registry.registrar(forPlugin: "SecureBookmarksPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
}
+12
View File
@@ -1,9 +1,13 @@
PODS: PODS:
- desktop_drop (0.0.1):
- FlutterMacOS
- file_selector_macos (0.0.1): - file_selector_macos (0.0.1):
- FlutterMacOS - FlutterMacOS
- FlutterMacOS (1.0.0) - FlutterMacOS (1.0.0)
- macos_secure_bookmarks (0.0.3): - macos_secure_bookmarks (0.0.3):
- FlutterMacOS - FlutterMacOS
- package_info_plus (0.0.1):
- FlutterMacOS
- path_provider_macos (0.0.1): - path_provider_macos (0.0.1):
- FlutterMacOS - FlutterMacOS
- shared_preferences_macos (0.0.1): - shared_preferences_macos (0.0.1):
@@ -12,20 +16,26 @@ PODS:
- FlutterMacOS - FlutterMacOS
DEPENDENCIES: DEPENDENCIES:
- desktop_drop (from `Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos`)
- file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`)
- FlutterMacOS (from `Flutter/ephemeral`) - FlutterMacOS (from `Flutter/ephemeral`)
- macos_secure_bookmarks (from `Flutter/ephemeral/.symlinks/plugins/macos_secure_bookmarks/macos`) - macos_secure_bookmarks (from `Flutter/ephemeral/.symlinks/plugins/macos_secure_bookmarks/macos`)
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
- path_provider_macos (from `Flutter/ephemeral/.symlinks/plugins/path_provider_macos/macos`) - path_provider_macos (from `Flutter/ephemeral/.symlinks/plugins/path_provider_macos/macos`)
- shared_preferences_macos (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_macos/macos`) - shared_preferences_macos (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_macos/macos`)
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
EXTERNAL SOURCES: EXTERNAL SOURCES:
desktop_drop:
:path: Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos
file_selector_macos: file_selector_macos:
:path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos :path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos
FlutterMacOS: FlutterMacOS:
:path: Flutter/ephemeral :path: Flutter/ephemeral
macos_secure_bookmarks: macos_secure_bookmarks:
:path: Flutter/ephemeral/.symlinks/plugins/macos_secure_bookmarks/macos :path: Flutter/ephemeral/.symlinks/plugins/macos_secure_bookmarks/macos
package_info_plus:
:path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos
path_provider_macos: path_provider_macos:
:path: Flutter/ephemeral/.symlinks/plugins/path_provider_macos/macos :path: Flutter/ephemeral/.symlinks/plugins/path_provider_macos/macos
shared_preferences_macos: shared_preferences_macos:
@@ -34,9 +44,11 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
SPEC CHECKSUMS: SPEC CHECKSUMS:
desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898
file_selector_macos: f1b08a781e66103e3ba279fd5d4024a2478b3af6 file_selector_macos: f1b08a781e66103e3ba279fd5d4024a2478b3af6
FlutterMacOS: ae6af50a8ea7d6103d888583d46bd8328a7e9811 FlutterMacOS: ae6af50a8ea7d6103d888583d46bd8328a7e9811
macos_secure_bookmarks: cff041c4b377fa00941bb901aaec4d27fac5edf2 macos_secure_bookmarks: cff041c4b377fa00941bb901aaec4d27fac5edf2
package_info_plus: 02d7a575e80f194102bef286361c6c326e4c29ce
path_provider_macos: 3c0c3b4b0d4a76d2bf989a913c2de869c5641a19 path_provider_macos: 3c0c3b4b0d4a76d2bf989a913c2de869c5641a19
shared_preferences_macos: a64dc611287ed6cbe28fd1297898db1336975727 shared_preferences_macos: a64dc611287ed6cbe28fd1297898db1336975727
url_launcher_macos: 597e05b8e514239626bcf4a850fcf9ef5c856ec3 url_launcher_macos: 597e05b8e514239626bcf4a850fcf9ef5c856ec3
+27 -43
View File
@@ -183,6 +183,13 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "2.2.4" version: "2.2.4"
desktop_drop:
dependency: "direct main"
description:
name: desktop_drop
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.3.3"
dio: dio:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -190,13 +197,6 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "4.0.6" version: "4.0.6"
external_path:
dependency: "direct main"
description:
name: external_path
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.3"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@@ -302,6 +302,11 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "2.0.1" version: "2.0.1"
flutter_localizations:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@@ -369,7 +374,7 @@ packages:
source: hosted source: hosted
version: "3.2.2" version: "3.2.2"
intl: intl:
dependency: transitive dependency: "direct main"
description: description:
name: intl name: intl
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
@@ -487,6 +492,20 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "2.1.0" version: "2.1.0"
package_info_plus:
dependency: "direct main"
description:
name: package_info_plus
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.2"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.1"
path: path:
dependency: transitive dependency: transitive
description: description:
@@ -543,41 +562,6 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "2.1.3" version: "2.1.3"
permission_handler:
dependency: "direct main"
description:
name: permission_handler
url: "https://pub.flutter-io.cn"
source: hosted
version: "10.1.0"
permission_handler_android:
dependency: transitive
description:
name: permission_handler_android
url: "https://pub.flutter-io.cn"
source: hosted
version: "10.1.0"
permission_handler_apple:
dependency: transitive
description:
name: permission_handler_apple
url: "https://pub.flutter-io.cn"
source: hosted
version: "9.0.6"
permission_handler_platform_interface:
dependency: transitive
description:
name: permission_handler_platform_interface
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.9.0"
permission_handler_windows:
dependency: transitive
description:
name: permission_handler_windows
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.1.1"
petitparser: petitparser:
dependency: transitive dependency: transitive
description: description:
+6 -3
View File
@@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at # Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 1.0.0+1 version: 1.2.3+1
environment: environment:
sdk: ">=2.17.6 <3.0.0" sdk: ">=2.17.6 <3.0.0"
@@ -29,6 +29,8 @@ environment:
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
flutter_localizations:
sdk: flutter
# The following adds the Cupertino Icons font to your application. # The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
@@ -46,9 +48,10 @@ dependencies:
rounded_loading_button: ^2.1.0 rounded_loading_button: ^2.1.0
url_launcher: ^6.1.6 url_launcher: ^6.1.6
shared_preferences: ^2.0.15 shared_preferences: ^2.0.15
permission_handler: ^10.1.0
external_path: ^1.0.3
logger: ^1.1.0 logger: ^1.1.0
desktop_drop: ^0.3.3
intl: ^0.17.0
package_info_plus: ^3.0.2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -1,20 +0,0 @@
//
// Generated file. Do not edit.
//
// clang-format off
#include "generated_plugin_registrant.h"
#include <file_selector_windows/file_selector_windows.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
PermissionHandlerWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}
@@ -1,15 +0,0 @@
//
// Generated file. Do not edit.
//
// clang-format off
#ifndef GENERATED_PLUGIN_REGISTRANT_
#define GENERATED_PLUGIN_REGISTRANT_
#include <flutter/plugin_registry.h>
// Registers Flutter plugins.
void RegisterPlugins(flutter::PluginRegistry* registry);
#endif // GENERATED_PLUGIN_REGISTRANT_
@@ -1,26 +0,0 @@
#
# Generated file, do not edit.
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
permission_handler_windows
url_launcher_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
)
set(PLUGIN_BUNDLED_LIBRARIES)
foreach(plugin ${FLUTTER_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin})
target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
list(APPEND PLUGIN_BUNDLED_LIBRARIES $<TARGET_FILE:${plugin}_plugin>)
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
endforeach(plugin)
foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin})
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
endforeach(ffi_plugin)